diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 75bcb225075..8b36cca46f7 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -1780,6 +1780,14 @@ cancellation remain enforced; the snapshot is removed when the provider exits. Native Codex is qualified only with `codexPermissionMode: "never"`. The create and edit surfaces do not offer `on-request` or `untrusted`, and a persisted unsupported value fails with remediation instead of being silently coerced. + +Remote native Codex prepares managed GitHub access and assigned MCP tools +concurrently after the backend claims its session scope and validates its launch +binding. Provider startup waits for both preparations. A failed preparation waits +for the other operation to settle before the existing run cleanup releases its +GitHub access. Discovery remains fresh for each run, and tool calls still use the +current gateway permissions. Local, custom-backend and other provider paths keep +their existing preparation order. OpenCode defaults to `allow`, with explicit `ask` and `deny` options; ACPX defaults to `approve-all`, with explicit `approve-paperclip`, `approve-reads`, and `deny-all` options. Codex conversion keeps a non-empty model and diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 359b6b0e64f..2b78bd9f858 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -105,6 +105,15 @@ serial. This changes no API or agent grant. The [real-shell controls](../../server/src/services/native-runtime/native-artifact-preparation.test.ts) do not qualify the whole validation capability or a live response-time target. +Scoped backend-preparation source evidence: remote native Codex can prepare its +managed GitHub transport during fresh assigned-MCP discovery. The session claim, +wake binding, review assignment and complete gateway launch binding checks still +precede that work. Provider startup waits for both results. Failures drain both +operations before the existing run cleanup releases any created GitHub access. +No grant, token, authority cache or provider dispatch bypass is added. The +[executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts) +remain separate from live latency and permitted/denied agent acceptance. + For a linked Codex subscription with server-resolved managed credential provenance, setup checks the resolved owner and stages a private probe copy before local or remote isolation. Without a selected API key, an unbound linked `CODEX_HOME` is diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 1e3503dfd5a..71b6649d16d 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -72,6 +72,14 @@ failed runner probe and confirm the harness work has settled before failure returns. Providers without concurrent sync and explicit harness installs remain serial. The [real-shell artifact controls](../../server/src/services/native-runtime/native-artifact-preparation.test.ts) cover these source contracts; they do not establish live reply latency. +For remote native Codex with managed GitHub access, inspect the controller +preparation spans for overlapping GitHub setup and assigned MCP discovery. +Provider startup must wait for both. Force either preparation to fail while the +other is pending; confirm that the pending operation settles and run-owned access +is released before failure returns. Repeat a warm follow-up and retain its actual +reply time. The [executor controls](../../server/src/services/native-runtime/native-session-executor.test.ts) +cover ordering, failure drain and credential projection; they do not qualify a +live response-time target. Missing dependencies, failed authentication, invalid models and incompatible custom settings must remain actionable errors without silent runner switching. Saved unchanged execution can still be tested without applying new diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index d10effb82bf..cc904fa00b0 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -10447,8 +10447,9 @@ describe("runnerd provider runtime wiring", () => { { PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/test" }, { PAPERCLIP_NATIVE_MCP_TOKEN: "private-run-token" }, ])("rejects partial remote assigned MCP bindings", async (runnerEnvironment) => { + const prepareRunnerEnvironment = vi.fn(async () => runnerEnvironment); await expect(createRunnerdBackend({ - db: leaseDb(execution), execution, runnerInstanceId: "runner-partial-mcp", runnerEnvironment, + db: leaseDb(execution), execution, runnerInstanceId: "runner-partial-mcp", runnerEnvironment, prepareRunnerEnvironment, runnerExecutionTarget: { kind: "remote", transport: "sandbox", providerKey: "daytona", leaseId: "lease-partial-mcp", remoteCwd: "/home/daytona/paperclip-workspace", @@ -10456,6 +10457,138 @@ describe("runnerd provider runtime wiring", () => { } as never, })).rejects.toThrow("assigned native MCP launch binding is incomplete"); expect(state.createAssignedMcpTools).not.toHaveBeenCalled(); + expect(prepareRunnerEnvironment).not.toHaveBeenCalled(); + }); + + it.each(["success", "environment", "assigned", "both"] as const)("drains overlapping backend preparation before admission: %s", async failure => { + let releaseEnvironment!: () => void; + let releaseAssigned!: () => void; + let assignedStarted!: () => void; + const environmentGate = new Promise(resolve => { releaseEnvironment = resolve; }); + const assignedGate = new Promise(resolve => { releaseAssigned = resolve; }); + const assignedReady = new Promise(resolve => { assignedStarted = resolve; }); + const runnerEnvironment = { + PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned", + PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/assigned-overlap", + PAPERCLIP_NATIVE_MCP_TOKEN: "private-overlap-token", + }; + const assignedMcpTools = { definitions: () => [], has: () => false, execute: vi.fn() }; + const prepareRunnerEnvironment = vi.fn(async () => { + await environmentGate; + if (failure === "environment" || failure === "both") throw new Error("environment failed"); + return { ...runnerEnvironment, PAPERCLIP_GITHUB_BROKER_TOKEN: "prepared-github-capability" }; + }); + state.createAssignedMcpTools.mockImplementationOnce(async () => { + assignedStarted(); + await assignedGate; + if (failure === "assigned" || failure === "both") throw new Error("assigned failed"); + return assignedMcpTools; + }); + state.createBackend.mockClear(); + state.createTransport.mockClear(); + let settled = false; + const preparation = createRunnerdBackend({ + db: leaseDb(execution), execution, runnerInstanceId: "runner-overlap", + runnerEnvironment, prepareRunnerEnvironment, + runnerExecutionTarget: { + kind: "remote", transport: "sandbox", providerKey: "daytona", + leaseId: "lease-overlap", remoteCwd: "/home/daytona/paperclip-workspace", + runner: { execute: vi.fn() }, + } as never, + }).then(value => ({ value }), error => ({ error })).finally(() => { settled = true; }); + try { + await assignedReady; + expect(prepareRunnerEnvironment).toHaveBeenCalledOnce(); + expect(settled).toBe(false); + expect(state.createBackend).not.toHaveBeenCalled(); + if (failure === "assigned") releaseAssigned(); + else releaseEnvironment(); + await new Promise(resolve => setImmediate(resolve)); + expect(settled).toBe(false); + expect(state.createBackend).not.toHaveBeenCalled(); + releaseEnvironment(); + releaseAssigned(); + const result = await preparation; + if (failure !== "success") { + expect(result).toMatchObject({ error: { message: failure === "assigned" ? "assigned failed" : "environment failed" } }); + expect(state.createBackend).not.toHaveBeenCalled(); + } else { + expect(result).not.toHaveProperty("error"); + expect(state.toolAuthorityDefinitions).toHaveBeenCalledWith(expect.objectContaining({ assignedMcpTools })); + state.createBackend.mock.calls[0]![1].codexTransportFactory!(); + const options = state.createTransport.mock.calls[0]![0] as { environment: NodeJS.ProcessEnv }; + expect(options.environment.PAPERCLIP_GITHUB_BROKER_TOKEN).toBe("prepared-github-capability"); + expect(options.environment.PAPERCLIP_NATIVE_MCP_TOKEN).toBeUndefined(); + } + } finally { + releaseEnvironment(); + releaseAssigned(); + await preparation; + } + }); + + it.each(["github", "assigned"] as const)("cleans run-owned access after overlapping executor failure: %s", async failure => { + const issueService = vi.spyOn(issueServiceModule, "issueService").mockReturnValue({ + update: vi.fn(async () => ({ status: "blocked", statusVersion: 1 })), + } as unknown as ReturnType); + let releaseGitHub!: () => void; + let releaseAssigned!: () => void; + const githubGate = new Promise(resolve => { releaseGitHub = resolve; }); + const assignedGate = new Promise(resolve => { releaseAssigned = resolve; }); + const deactivate = vi.fn(); + const stop = vi.fn(async () => undefined); + const activate = vi.fn(() => deactivate); + githubAccess.create.mockImplementationOnce(async () => { + await githubGate; + if (failure === "github") throw new Error("github setup failed"); + return { env: { PAPERCLIP_GITHUB_BROKER_TOKEN: "run-owned-capability" }, ready: true, + duplexRequested: false, activate, stop }; + }); + state.createAssignedMcpTools.mockImplementationOnce(async () => { + await assignedGate; + if (failure === "assigned") throw new Error("assigned setup failed"); + return { definitions: () => [], has: () => false, execute: vi.fn() }; + }); + state.execute.mockClear(); + state.createBackend.mockClear(); + let settled = false; + const running = executePaperclipNativeSession({ + db: leaseDb(execution), execution, runnerInstanceId: "runner-overlap-cleanup", + useRunnerd: true, managedGitHub: true, + runnerEnvironment: { + PAPERCLIP_NATIVE_MCP_NAME: "paperclip-assigned", + PAPERCLIP_NATIVE_MCP_URL: "http://127.0.0.1:3217/mcp/gateways/assigned-cleanup", + PAPERCLIP_NATIVE_MCP_TOKEN: "private-cleanup-token", + }, + runnerExecutionTarget: { + kind: "remote", transport: "sandbox", providerKey: "daytona", + leaseId: "lease-overlap-cleanup", remoteCwd: "/home/daytona/paperclip-workspace", + runner: { execute: vi.fn() }, + } as never, + }).then(value => ({ value }), error => ({ error })).finally(() => { settled = true; }); + try { + await vi.waitFor(() => expect(state.createAssignedMcpTools).toHaveBeenCalledOnce()); + expect(githubAccess.create).toHaveBeenCalled(); + if (failure === "github") releaseGitHub(); + else releaseAssigned(); + await new Promise(resolve => setImmediate(resolve)); + expect(settled).toBe(false); + releaseGitHub(); + releaseAssigned(); + expect(await running).toMatchObject({ error: { message: `${failure} setup failed` } }); + expect(state.execute).not.toHaveBeenCalled(); + expect(state.createBackend).not.toHaveBeenCalled(); + if (failure === "assigned") { + expect(activate).toHaveBeenCalledWith(execution.binding); + expect(deactivate).toHaveBeenCalledOnce(); + expect(stop).toHaveBeenCalledOnce(); + } + } finally { + releaseGitHub(); + releaseAssigned(); + await running; + issueService.mockRestore(); + } }); it("keeps assigned MCP credentials on the control plane for remote Codex", async () => { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 13aed940033..043d0640005 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -8680,19 +8680,27 @@ async function executePaperclipNativeSessionWithinScope( controller, }); try { - if (input.managedGitHub && input.execution.schema !== "paperclip.native-execution-input.v6") { - if (!input.execution.workspace.cwd) throw new Error("native_workspace_required"); - githubAccess ??= await timeController("github_access", () => createNativeGitHubAccess({ - scope: input.execution.binding, - target: input.runnerExecutionTarget, - cwd: input.execution.workspace.cwd!, - env: resolveNativeProviderEnvironment(input.execution.provider, input.runnerEnvironment), - resolveCredentials: (binding) => resolveGitHubOperationCredentials(input.db, binding), - onLog: input.onLog, - })); - releaseGitHubRun = githubAccess.activate(input.execution.binding); - input = { ...input, runnerEnvironment: { ...input.runnerEnvironment, ...githubAccess.env } }; - } + const prepareRunnerEnvironment = async () => { + if (input.managedGitHub && input.execution.schema !== "paperclip.native-execution-input.v6") { + if (!input.execution.workspace.cwd) throw new Error("native_workspace_required"); + githubAccess ??= await timeController("github_access", () => createNativeGitHubAccess({ + scope: input.execution.binding, + target: input.runnerExecutionTarget, + cwd: input.execution.workspace.cwd!, + env: resolveNativeProviderEnvironment(input.execution.provider, input.runnerEnvironment), + resolveCredentials: (binding) => resolveGitHubOperationCredentials(input.db, binding), + onLog: input.onLog, + })); + releaseGitHubRun = githubAccess.activate(input.execution.binding); + input = { ...input, runnerEnvironment: { ...input.runnerEnvironment, ...githubAccess.env } }; + } + return input.runnerEnvironment; + }; + const overlapGitHubPreparation = input.managedGitHub && input.useRunnerd && input.backend === undefined + && input.runnerExecutionTarget?.kind === "remote" && input.execution.provider.kind === "codex" + && input.execution.schema !== "paperclip.native-execution-input.v6" + && input.execution.schema !== "paperclip.native-execution-input.v8"; + if (!overlapGitHubPreparation) await prepareRunnerEnvironment(); const expectedCurrentWakeComments = await timeController("wake_binding", () => resolveCurrentWakeCommentsBinding( input.db, input.execution.binding, @@ -8702,6 +8710,7 @@ async function executePaperclipNativeSessionWithinScope( input.useRunnerd && input.backend === undefined ? await timeController("backend_setup", () => createRunnerdBackend({ ...input, + ...(overlapGitHubPreparation ? { prepareRunnerEnvironment } : {}), // Durable scope and prior-run verification use the controller's // canonical workspace identity. createRunnerdBackend separately // projects remoteCwd into the provider execution boundary. @@ -11258,6 +11267,7 @@ export async function createRunnerdBackend(input: { }) => Promise; dotWorkspaceRoot?: string; runnerEnvironment?: NodeJS.ProcessEnv; + prepareRunnerEnvironment?: () => Promise; /** Private grant materialization; never a user-configured host path. */ managedAiCredentialHome?: string; runnerExecutionTarget?: AdapterExecutionTarget | null; @@ -11485,14 +11495,21 @@ async function createRunnerdBackendWithinSessionClaim( if (relayAssignedMcp && hasAssignedMcp && !assignedGatewayPublicId) { throw new Error("assigned native MCP gateway path is invalid"); } - const assignedMcpTools = relayAssignedMcp && !nativeReview && assignedMcpUrl && assignedMcpToken - ? await timeController("assigned_mcp", () => createAssignedMcpTools({ + const [preparedEnvironment, preparedAssignedMcp] = await Promise.allSettled([ + Promise.resolve().then(() => input.prepareRunnerEnvironment?.() ?? input.runnerEnvironment), + Promise.resolve().then(() => relayAssignedMcp && !nativeReview && assignedMcpUrl && assignedMcpToken + ? timeController("assigned_mcp", () => createAssignedMcpTools({ gateway: getAssignedMcpGateway(input.db), gatewayPublicId: assignedGatewayPublicId!, bearerToken: assignedMcpToken, workMode: input.execution.task.workMode, })) - : undefined; + : undefined), + ]); + if (preparedEnvironment.status === "rejected") throw preparedEnvironment.reason; + if (preparedAssignedMcp.status === "rejected") throw preparedAssignedMcp.reason; + const runnerEnvironment = preparedEnvironment.value; + const assignedMcpTools = preparedAssignedMcp.value; const dotAttachmentActor = input.execution.schema === "paperclip.native-execution-input.v6" ? await input.db.select({ config: agents.adapterConfig }).from(agents).where(and( eq(agents.id, input.execution.binding.agentId), eq(agents.companyId, input.execution.binding.companyId), @@ -12726,8 +12743,8 @@ async function createRunnerdBackendWithinSessionClaim( const isGrok = input.execution.provider.kind === "acpx" && input.execution.provider.agent === "grok"; let effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = { ...(isGrok - ? input.runnerEnvironment ?? {} - : resolveNativeProviderEnvironment(input.execution.provider, input.runnerEnvironment)), + ? runnerEnvironment ?? {} + : resolveNativeProviderEnvironment(input.execution.provider, runnerEnvironment)), }; const grokCredential = isGrok ? await prepareGrokRunnerCredentials({ companyId: input.execution.binding.companyId, environment: effectiveRunnerEnvironmentBase, remote: Boolean(remoteTarget), @@ -13019,7 +13036,7 @@ async function createRunnerdBackendWithinSessionClaim( runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary ?? undefined, sourceCodexHome: remoteTarget - ? resolveSourceCodexHome(resolveNativeProviderEnvironment(input.execution.provider, input.runnerEnvironment)) + ? resolveSourceCodexHome(resolveNativeProviderEnvironment(input.execution.provider, runnerEnvironment)) : undefined, runnerReconnectGraceMs: remoteTarget ? 120_000 : undefined, environment: effectiveRunnerEnvironment,