diff --git a/docs/security.md b/docs/security.md index 0652e16f2..6599a5640 100644 --- a/docs/security.md +++ b/docs/security.md @@ -136,7 +136,7 @@ It never exposes raw DevSpace workspace, edit, write, or shell tools, and `run_github_completion_loop()` caller through a JSON-lines host bridge. The Python caller cannot merge `main` directly: its CAS merge port is routed back into the existing `git_merge_pull_request` core. Integration generations are - same-repository, non-force PR-branch updates and are capped at 3 generations + same-repository, non-force PR-branch updates and are capped at 5 generations and 2700 seconds. Any affected semantic/authority/test/transport dimension fails closed for fresh Candidate acceptance. diff --git a/scripts/github_completion_bridge.py b/scripts/github_completion_bridge.py index 677ccfe7d..0ea3b7d84 100644 --- a/scripts/github_completion_bridge.py +++ b/scripts/github_completion_bridge.py @@ -199,7 +199,7 @@ def reconcile_post_merge( initial_evidence=evidence, request=request, port=HostPort(), - max_generations=int(start.get("max_generations", 3)), + max_generations=int(start.get("max_generations", 5)), max_elapsed_seconds=float(start.get("max_elapsed_seconds", 2700.0)), git_root=nexus_root, ) diff --git a/src/github-completion-host.test.ts b/src/github-completion-host.test.ts index c9eb1afa9..db0069e3b 100644 --- a/src/github-completion-host.test.ts +++ b/src/github-completion-host.test.ts @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { + MAX_COMPLETION_INTEGRATION_GENERATIONS, isTransientRequiredChecksDecision, + parseCompletionGeneration, + parseGitHubCompletionToolInput, } from "./github-completion-host.js"; const missingReason = "no check run or commit status matches the required context for the expected head SHA"; @@ -48,3 +51,39 @@ describe("isTransientRequiredChecksDecision", () => { ); }); }); + +describe("completion integration generation cap (#1384)", () => { + const baseInput = { + initialEvidence: {}, + standingGrantRequest: {}, + mergeMethod: "merge", + ownerConfirmation: true, + }; + + it("matches the Nexus-new MAX_INTEGRATION_GENERATIONS=5 loop budget", () => { + assert.equal(MAX_COMPLETION_INTEGRATION_GENERATIONS, 5); + }); + + it("defaults maxGenerations to 5 and accepts 4 and 5", () => { + assert.equal(parseGitHubCompletionToolInput({ ...baseInput }).maxGenerations, 5); + assert.equal(parseGitHubCompletionToolInput({ ...baseInput, maxGenerations: 4 }).maxGenerations, 4); + assert.equal(parseGitHubCompletionToolInput({ ...baseInput, maxGenerations: 5 }).maxGenerations, 5); + }); + + it("rejects maxGenerations above 5 or below 1", () => { + for (const value of [0, 6, 2.5, "5"]) { + assert.throws( + () => parseGitHubCompletionToolInput({ ...baseInput, maxGenerations: value }), + /maxGenerations_INVALID/, + ); + } + }); + + it("port generation guard admits I4 and I5 and rejects I6", () => { + assert.equal(parseCompletionGeneration(4), 4); + assert.equal(parseCompletionGeneration(5), 5); + for (const value of [0, 6, null, "4"]) { + assert.throws(() => parseCompletionGeneration(value), /generation_INVALID/); + } + }); +}); diff --git a/src/github-completion-host.ts b/src/github-completion-host.ts index a2c4252ab..9c693a7d2 100644 --- a/src/github-completion-host.ts +++ b/src/github-completion-host.ts @@ -20,6 +20,13 @@ const FULL_SHA = /^[0-9a-f]{40}$/; const SAFE_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\\)(?!.*[\u0000-\u001f\u007f]).+$/; const REPOSITORY = "James3014/Nexus-new"; const DEFAULT_BRANCH = "main"; +/** + * Mirrors Nexus-new `MAX_INTEGRATION_GENERATIONS` / + * `DEFAULT_MAX_INTEGRATION_GENERATIONS` in + * `nexus/orchestrator/github_completion_loop.py` (#1384). The host must not + * advertise or enforce a tighter budget than the loop it drives. + */ +export const MAX_COMPLETION_INTEGRATION_GENERATIONS = 5; const COMPLETION_TARGET = { remoteName: "origin", repository: REPOSITORY, @@ -75,6 +82,10 @@ function positiveInt(value: unknown, name: string, maximum: number): number { return value; } +export function parseCompletionGeneration(value: unknown): number { + return positiveInt(value, "generation", MAX_COMPLETION_INTEGRATION_GENERATIONS); +} + export function isTransientRequiredChecksDecision( decision: Awaited>, ): boolean { @@ -111,8 +122,8 @@ export function parseGitHubCompletionToolInput(raw: Record): Gi throw new Error("ownerConfirmation must be exactly true"); } const maxGenerations = raw.maxGenerations === undefined - ? 3 - : positiveInt(raw.maxGenerations, "maxGenerations", 3); + ? MAX_COMPLETION_INTEGRATION_GENERATIONS + : positiveInt(raw.maxGenerations, "maxGenerations", MAX_COMPLETION_INTEGRATION_GENERATIONS); const maxElapsedSeconds = raw.maxElapsedSeconds === undefined ? 2700 : positiveInt(raw.maxElapsedSeconds, "maxElapsedSeconds", 2700); @@ -272,7 +283,7 @@ class CompletionHostPort { const baseSha = normalizeSha(params.base_sha, "base_sha"); const baseTreeSha = normalizeSha(params.base_tree_sha, "base_tree_sha"); const expectedHead = normalizeSha(params.expected_pr_head_sha, "expected_pr_head_sha"); - const generation = positiveInt(params.generation, "generation", 3); + const generation = parseCompletionGeneration(params.generation); const target = await this.target(); const pr = await this.transport.getPullRequest(REPOSITORY, this.prNumber); if (pr.state !== "OPEN" || pr.isDraft) { @@ -368,7 +379,7 @@ class CompletionHostPort { private async readRequiredChecks(params: Record): Promise> { const headSha = normalizeSha(params.head_sha, "checks_head_sha"); - const generation = positiveInt(params.generation, "generation", 3); + const generation = parseCompletionGeneration(params.generation); const timeoutSeconds = typeof params.timeout_seconds === "number" ? Math.max(0, Math.min(params.timeout_seconds, 2700)) : 2700; diff --git a/src/nexus-gateway-proxy.test.ts b/src/nexus-gateway-proxy.test.ts index 4954feae2..72349c725 100644 --- a/src/nexus-gateway-proxy.test.ts +++ b/src/nexus-gateway-proxy.test.ts @@ -396,6 +396,12 @@ try { for (const forbiddenKey of ["workspaceId", "cwd", "repo", "repository", "remote", "branch", "execution_lane", "executionLane", "internal", "completion"]) { assert.ok(!properties.includes(forbiddenKey), `public schema must NOT contain ${forbiddenKey}`); } + // #1384: the completion action's generation budget matches the Nexus-new loop maximum (5). + const completionTool = schemaPayload.result.tools.find((tool: { name: string }) => tool.name === "github_complete_pull_request"); + assert.ok(completionTool, "github_complete_pull_request must be advertised"); + assert.equal(completionTool.inputSchema.properties?.maxGenerations?.maximum, 5); + assert.equal(completionTool.inputSchema.properties?.maxGenerations?.minimum, 1); + assert.match(completionTool.description, /at most 5 non-force PR integration generations/); await schemaBoundary.close(); } diff --git a/src/nexus-gateway-proxy.ts b/src/nexus-gateway-proxy.ts index 8673dc4f3..e264475f4 100644 --- a/src/nexus-gateway-proxy.ts +++ b/src/nexus-gateway-proxy.ts @@ -19,7 +19,7 @@ import { type MergeEffectGuard, type TrustedIntegrationTarget, } from "./git-pr-merge.js"; -import { runGitHubCompletionTool } from "./github-completion-host.js"; +import { MAX_COMPLETION_INTEGRATION_GENERATIONS, runGitHubCompletionTool } from "./github-completion-host.js"; type JsonSchema = { type?: string | string[]; @@ -620,13 +620,13 @@ export async function createNexusGatewayProxyServer( name, { description: - "Run Nexus run_github_completion_loop() against the server-bound James3014/Nexus-new checkout. Ordinary main drift may create at most 3 non-force PR integration generations and wait at most 2700 seconds total. Affected semantic/authority/test/transport dimensions fail closed for fresh acceptance. Protected main integration delegates to the existing git_merge_pull_request merge core; this action does not implement a second merge authority.", + `Run Nexus run_github_completion_loop() against the server-bound James3014/Nexus-new checkout. Ordinary main drift may create at most ${MAX_COMPLETION_INTEGRATION_GENERATIONS} non-force PR integration generations and wait at most 2700 seconds total. Affected semantic/authority/test/transport dimensions fail closed for fresh acceptance. Protected main integration delegates to the existing git_merge_pull_request merge core; this action does not implement a second merge authority.`, inputSchema: { initialEvidence: z.record(z.string(), z.unknown()).describe("Exact accepted nexus.github_orchestration_evidence payload."), standingGrantRequest: z.record(z.string(), z.unknown()).describe("Exact nexus.standing_grant_request.v1 payload bound to the durable standing grant."), mergeMethod: z.enum(["merge", "squash", "rebase"]).default("merge"), ownerConfirmation: z.boolean().describe("Must be exactly true for the host protected merge authority."), - maxGenerations: z.number().int().min(1).max(3).optional(), + maxGenerations: z.number().int().min(1).max(MAX_COMPLETION_INTEGRATION_GENERATIONS).optional(), maxElapsedSeconds: z.number().int().min(1).max(2700).optional(), }, annotations: {