diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 3c37296bfef..29777bd671a 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -56,6 +56,20 @@ stdin/stdout bridge admits the pinned Claude and Codex ACPX profiles. It validates the exact model, session identity, tool catalog, structured input, and terminal settlement at the process boundary. Pi remains unavailable. +ACPX Codex subscription logins use the selected run credential, staged in the +Runner's private `codex-home/auth.json`. The trusted launch environment carries +its derived path to the sidecar; arbitrary caller paths and ambient homes are +not inherited. The sidecar stages that file in its isolated Codex home and uses +file credential storage. It checkpoints the credential to +`codex-home/auth.json.returned` before publishing each turn's terminal result, +so the tenant can flush refreshes into that invocation's credential home while +the provider stays warm. It exports once more after the provider stops, before +removing its private copy. Failed export +retains the credential and lease for retry. The tenant merges the returned file +using its existing subscription identity and freshness checks. Both files are +excluded from backups and removed after a successful tenant merge. API-key +launches continue to use their explicit key environment. + Native Claude skill assignments travel in the runtime-context snapshot through runnerd to the ACPX sidecar. After acquiring the provider lifetime lease, the host materializes the assigned bundles under the isolated Claude home's diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 86602e4713d..db44fc468ca 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -97,7 +97,11 @@ impl AcpxSidecarTransport { ) -> Result { let credential_keys: &[&str] = match agent { "claude" => &["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"], - "codex" => &["OPENAI_API_KEY", "CODEX_API_KEY"], + "codex" => &[ + "OPENAI_API_KEY", + "CODEX_API_KEY", + "PAPERCLIP_ACPX_CODEX_AUTH_FILE", + ], _ => { return Err(LocalRunnerError::invalid( "ACPX sidecar credentials require a qualified claude or codex agent", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs index 2d84a6dbe70..c9676ce6f96 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs @@ -148,6 +148,7 @@ fn run() -> Result<(), Box> { "url": std::env::var("PAPERCLIP_NATIVE_MCP_URL").ok(), "hasToken": std::env::var("PAPERCLIP_NATIVE_MCP_TOKEN").is_ok(), "hasUnrelatedSecret": std::env::var("UNRELATED_EVAL_SECRET").is_ok(), + "codexAuthFile": std::env::var("PAPERCLIP_ACPX_CODEX_AUTH_FILE").ok(), } }), )?; diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs index eed42887838..a2a9a574ce9 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_sidecar_transport.rs @@ -233,6 +233,10 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets( "fixture-token-never-returned-in-test-output", ) .env("UNRELATED_EVAL_SECRET", "must-not-cross-boundary") + .env( + "PAPERCLIP_ACPX_CODEX_AUTH_FILE", + "/private/session/auth.json", + ) .status() .unwrap(); assert!(status.success(), "isolated gateway environment test failed"); @@ -260,6 +264,14 @@ fn assigned_gateway_binding_reaches_qualified_sidecar_without_unrelated_secrets( "assigned gateway credential was dropped" ); assert_eq!(response["hasUnrelatedSecret"], false); + assert_eq!( + response["codexAuthFile"], + if agent == "codex" { + json!("/private/session/auth.json") + } else { + serde_json::Value::Null + } + ); sidecar.shutdown().unwrap(); } } diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 2753ecb9bdc..72d77f18337 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -265,6 +265,10 @@ async function dispatch( systemInstructions: params.systemInstructions, runtimeContext: params.runtimeContext, environment: process.env, + managedCodexCredentialSourcePath: params.agent === "codex" + ? process.env.PAPERCLIP_ACPX_CODEX_AUTH_FILE : undefined, + managedCodexCredentialReturnPath: params.agent === "codex" && process.env.PAPERCLIP_ACPX_CODEX_AUTH_FILE + ? `${process.env.PAPERCLIP_ACPX_CODEX_AUTH_FILE}.returned` : undefined, expectedIdentity: params.expectedIdentity, semanticTools: { tools: params.tools, @@ -544,6 +548,7 @@ async function pumpTurn( ); } const result = await runtimeTurn.result; + await activeHost.checkpointCredential(); try { const usage = persistedAcpxTurnUsage( usageBefore, diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index 5729c4dded4..389de6e4d48 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -3283,6 +3283,7 @@ const runnerExplicitProviderEnvironmentKeys = [ "OPENAI_API_KEY", "CODEX_API_KEY", "PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET", + "PAPERCLIP_ACPX_CODEX_AUTH_FILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_WEB_IDENTITY_TOKEN_FILE", diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts index 8a18b200122..15c056314f3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts @@ -696,6 +696,30 @@ describe("managed Codex credentials", () => { await expect(readFile(source, "utf8")).resolves.toContain("managed-canary"); }); + it("returns refreshed credentials privately and retains them on export failure", async () => { + const fixture = await credentialFixture(); + const source = join(fixture.root, "auth.json"); + const returnPath = `${source}.returned`; + await writeFile(source, '{"tokens":{"access_token":"original"}}', { mode: 0o600 }); + const lease = await stageManagedCodexCredential({ agentHomeDirectory: fixture.home, sourcePath: source, returnPath }); + await writeFile(lease.path, '{"tokens":{"access_token":"refreshed"}}', { mode: 0o600 }); + await lease.checkpoint!(); + await expect(readFile(returnPath, "utf8")).resolves.toContain("refreshed"); + await expect(readFile(lease.path, "utf8")).resolves.toContain("refreshed"); + await rm(returnPath); + await mkdir(returnPath); + await expect(lease.close()).rejects.toThrow(); + await expect(readFile(lease.path, "utf8")).resolves.toContain("refreshed"); + await expect(stageManagedCodexCredential({ agentHomeDirectory: fixture.home, sourcePath: source })).rejects.toThrow("active lease"); + await rm(returnPath, { recursive: true }); + await lease.close(); + await lease.close(); + expect((await stat(returnPath)).mode & 0o777).toBe(0o600); + await expect(readFile(returnPath, "utf8")).resolves.toContain("refreshed"); + await expect(readFile(source, "utf8")).resolves.toContain("original"); + await expect(readFile(lease.path)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("replaces a stale regular auth destination in JSON modes", async () => { const fixture = await credentialFixture(); const destination = join(fixture.home, "auth.json"); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts index a7daf19b5a4..3ba33fa3010 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.ts @@ -10,7 +10,7 @@ import { type FileHandle, } from "node:fs/promises"; import { createServer, type Server } from "node:net"; -import { isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { verifiedRuntimeExecutableHandoff } from "./verified-runtime-executable.js"; @@ -121,6 +121,8 @@ export interface AcpxProviderLifetimeLease { readonly lifetimeFenceFds: readonly [number, number]; /** Validate the guardian while the provider-lifetime quorum is still held. */ activateLifetimeOwner(pid: number): Promise; + /** Publish a private credential snapshot before acknowledging a warm turn. */ + checkpoint?(): Promise; close(): Promise; } @@ -180,6 +182,7 @@ export async function stageManagedCodexCredential(input: { agentHomeDirectory: string; environment?: NodeJS.ProcessEnv; sourcePath?: string; + returnPath?: string; }): Promise { const home = await resolvePrivateAgentHome(input.agentHomeDirectory); // Join an older failed close before claiming the next generation. This @@ -229,6 +232,7 @@ async function stageClaimedManagedCodexCredential( input: { environment?: NodeJS.ProcessEnv; sourcePath?: string; + returnPath?: string; }, home: string, ownerGeneration: CredentialLeaseGeneration, @@ -279,6 +283,16 @@ async function stageClaimedManagedCodexCredential( ); } + if (input.returnPath !== undefined) { + if (!hasManagedFile || !isAbsolute(input.returnPath) || + resolve(input.returnPath) === destination || + resolve(input.returnPath) === resolve(input.sourcePath!) || + dirname(resolve(input.returnPath)) !== dirname(resolve(input.sourcePath!))) { + throw new Error("Managed Codex credential return must be a separate file beside its source"); + } + await resolvePrivateAgentHome(dirname(input.returnPath)); + } + if (hasApiKey) { // Codex will read the API key from the launch environment. Persist cleanup // intent before admitting the provider and retain it for the lease @@ -331,6 +345,7 @@ async function stageClaimedManagedCodexCredential( hasInlineJson ? "inline_json" : "managed_file", ownerGeneration, lock, + input.returnPath, ); } @@ -653,6 +668,7 @@ function credentialLease( mode: ManagedCodexCredentialMode, ownerGeneration: CredentialLeaseGeneration, lock: CredentialHomeLock, + returnPath?: string, ): ManagedCodexCredentialLease { // Do not admit a provider if kernel ownership was lost while its credential // was being staged. @@ -660,6 +676,22 @@ function credentialLease( let closed = false; let closeAttempt: Promise | null = null; let lifetimeOwnerAttempt: Promise | null = null; + let exportAttempt: Promise | null = null; + const exportCredential = (): Promise => { + if (returnPath === undefined) return Promise.resolve(); + exportAttempt ??= (async () => { + lock.assertHeld(); + const returnHome = await resolvePrivateAgentHome(dirname(returnPath)); + const bytes = await readManagedCredential(path); + try { + validateCredentialDocument(bytes); + await writeCredential(returnPath, `${returnPath}.staging`, returnHome, bytes); + } finally { + bytes.fill(0); + } + })().finally(() => { exportAttempt = null; }); + return exportAttempt; + }; return Object.freeze({ path, mode, @@ -678,6 +710,10 @@ function credentialLease( if (lifetimeOwnerAttempt === attempt) lifetimeOwnerAttempt = null; } }, + async checkpoint(): Promise { + if (closed || closeAttempt !== null) throw new Error("Managed Codex credential lease is closing"); + await exportCredential(); + }, async close(): Promise { if (closed) return; if (closeAttempt !== null) return await closeAttempt; @@ -696,6 +732,11 @@ function credentialLease( closed = true; return; } + lock.assertHeld(); + // Join a turn checkpoint, then capture the final stopped-provider state. + await exportAttempt?.catch(() => undefined); + // Failed export retains the only refreshed copy and its lease for retry. + await exportCredential(); try { lock.assertHeld(); await removeCredential(path, home); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts index efc09052a5b..c3b0bee8058 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.ts @@ -207,6 +207,8 @@ export interface OpenAcpxRuntimeHostOptions { runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; managedCodexCredentialSourcePath?: string; + /** Private per-session return slot; the caller owns the authoritative merge. */ + managedCodexCredentialReturnPath?: string; expectedIdentity?: AcpxExpectedSessionIdentity; /** Revalidate a pinned recovery workspace through provider admission. */ assertWorkspaceHeld?: () => void; @@ -319,7 +321,7 @@ export class AcpxRuntimeHost { options.assertWorkspaceHeld?.(); if ( options.agent !== "codex" && - options.managedCodexCredentialSourcePath !== undefined + (options.managedCodexCredentialSourcePath !== undefined || options.managedCodexCredentialReturnPath !== undefined) ) { throw new Error( "Managed Codex credentials require the Codex ACPX profile", @@ -398,6 +400,7 @@ export class AcpxRuntimeHost { agentHomeDirectory: sandbox.agentHomeDirectory, environment: options.environment, sourcePath: options.managedCodexCredentialSourcePath, + returnPath: options.managedCodexCredentialReturnPath, }), resource: "credential", releaseLate: (lateCredential) => lateCredential.close(), @@ -675,6 +678,10 @@ export class AcpxRuntimeHost { return turn; } + async checkpointCredential(): Promise { + await this.#credential?.checkpoint?.(); + } + async interruptActiveTurn(reason: string): Promise { const turn = this.#activeTurn; if (!turn) throw new Error("ACPX runtime host has no active turn"); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index cba482523a5..195a99d7354 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -117,7 +117,7 @@ describe("ACPX runtime sandbox", () => { join(sandbox.agentHomeDirectory, "config.toml"), "utf8", ); - expect(config).toBe("[features]\nshell_snapshot = false\n"); + expect(config).toBe('cli_auth_credentials_store = "file"\n[features]\nshell_snapshot = false\n'); expect(config).not.toContain("provider-secret"); } expect(await readFile(sandbox.workspaceRecordPath, "utf8")).toBe( diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 30d2f4cbb79..57bceceb6c2 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -398,6 +398,7 @@ export async function prepareAcpxRuntimeSandbox(input: { await writePrivateFile( join(agentHomeDirectory, "config.toml"), [ + 'cli_auth_credentials_store = "file"', // Codex shell snapshots serialize the provider process environment. // The ACPX sidecar receives a short-lived managed credential only so // it can authenticate the provider; that value must never become diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 2f5d38c249a..5a156c5e9aa 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -1646,6 +1646,25 @@ it("uses file-backed AWS workload identity without forwarding access keys or Pap expect(environment.PAPERCLIP_NATIVE_MCP_TOKEN).toBeUndefined(); }); +it("derives the ACPX credential path from the Runner home", () => { + const input = { + provider: "acpx" as const, + options: { acpxAgent: "codex" as const, environment: { PAPERCLIP_ACPX_CODEX_AUTH_FILE: "/attacker/auth.json" } }, + identity: { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: "run", normalizedSessionId: "session", turnId: "turn", itemId: "item" }, + codexHome: "/private/session/codex-home", runtimeContextPath: "/private/context", hasRuntimeContext: false, + acpxSidecarPath: "/verified/provider-pack/dist/cli/acpx-runtime-sidecar.cjs", + }; + expect(createCapabilityRunnerdProviderEnvironment(input).PAPERCLIP_ACPX_CODEX_AUTH_FILE).toBe("/private/session/codex-home/auth.json"); + expect(createCapabilityRunnerdProviderEnvironment({ ...input, options: { ...input.options, environment: { OPENAI_API_KEY: "explicit-key" } } }).PAPERCLIP_ACPX_CODEX_AUTH_FILE).toBeUndefined(); + expect(createCapabilityRunnerdProviderEnvironment({ ...input, options: { ...input.options, acpxAgent: "claude" } }).PAPERCLIP_ACPX_CODEX_AUTH_FILE).toBeUndefined(); + vi.stubEnv("OPENAI_API_KEY", "inherited-key"); + try { + expect(createCapabilityRunnerdProviderEnvironment({ ...input, options: { acpxAgent: "codex" } }).PAPERCLIP_ACPX_CODEX_AUTH_FILE).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } +}); + it.each([ { agent: "pi" as const, diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 1997257ef21..51c1308f4d9 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3098,6 +3098,10 @@ export function createCapabilityRunnerdProviderEnvironment(input: { input.options.acpxSidecarPath ?? resolve(packageRoot, "dist", "cli", "acpx-runtime-sidecar.cjs"); const providerPackageAuthority = acpxProviderPackageAuthority(sidecarPath); + const spawnEnvironment = createSanitizedAcpxSpawnInput( + input.options.environment, + input.options.acpxAgent ?? "codex", + ).env; // This is the trusted runner/sidecar boundary. The provider sandbox still // uses createSanitizedAcpxSpawnInput and does not inherit gateway tokens. const assignedGateway = input.options.acpxAgent === "pi" @@ -3106,11 +3110,13 @@ export function createCapabilityRunnerdProviderEnvironment(input: { ...(assignedGateway ? { PAPERCLIP_NATIVE_MCP_TOKEN: assignedGateway.token, } : {}), - ...createSanitizedAcpxSpawnInput( - input.options.environment, - input.options.acpxAgent ?? "codex", - ).env, + ...spawnEnvironment, ...commonIdentity, + ...((input.options.acpxAgent ?? "codex") === "codex" && + !spawnEnvironment.CODEX_API_KEY && + !spawnEnvironment.OPENAI_API_KEY + ? { PAPERCLIP_ACPX_CODEX_AUTH_FILE: resolve(input.codexHome, "auth.json") } + : {}), // The verified sidecar bundle cannot use import.meta.url while Node // executes it through /proc/self/fd. Anchor its closed provider package // lookups at the package that owns the already-authenticated bundle. @@ -4340,7 +4346,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (provider === "aws_agentcore") { mkdirSync(codexHome, { recursive: true, mode: 0o700 }); } - if (provider === "codex") { + if ( + provider === "codex" || + (provider === "acpx" && (this.options.acpxAgent ?? "codex") === "codex") + ) { await prepareIsolatedCodexHome({ context: sourceRuntimeContext, codexHome: localCodexHome, @@ -4350,7 +4359,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { apiKey: this.options.environment?.CODEX_API_KEY ?? this.options.environment?.OPENAI_API_KEY, - nativeMcp: nativeMcpLaunchBinding(this.options.environment), + nativeMcp: provider === "codex" + ? nativeMcpLaunchBinding(this.options.environment) : null, }); } const opencodeProxyPath = @@ -5234,7 +5244,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } if (provider === "aws_agentcore") mkdirSync(codexHome, { recursive: true, mode: 0o700 }); - if (provider === "codex") { + if ( + provider === "codex" || + (provider === "acpx" && (this.options.acpxAgent ?? "codex") === "codex") + ) { await releaseMaterializedNativeRuntimeSkills( resolve(localCodexHome, "skills"), ); @@ -5247,7 +5260,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { apiKey: this.options.environment?.CODEX_API_KEY ?? this.options.environment?.OPENAI_API_KEY, - nativeMcp: nativeMcpLaunchBinding(this.options.environment), + nativeMcp: provider === "codex" + ? nativeMcpLaunchBinding(this.options.environment) : null, }); } const adoptedRunner = this.options.adoptExistingRunner; diff --git a/server/src/services/native-runtime/managed-native-credentials.test.ts b/server/src/services/native-runtime/managed-native-credentials.test.ts index 8075ba9f8f4..cb81724e84e 100644 --- a/server/src/services/native-runtime/managed-native-credentials.test.ts +++ b/server/src/services/native-runtime/managed-native-credentials.test.ts @@ -101,11 +101,15 @@ describe("managed native credential turns", () => { await closing; }); - it("removes stopped-provider credentials even when refresh copy-back fails", async () => { + it("retains stopped-provider credentials until a failed copy-back succeeds", async () => { const session = { close: async () => {} } as unknown as NativeSession; const remove = vi.fn(async () => {}); - bindManagedNativeCredentialTurn(session, { copyBack: async () => { throw new Error("copy-back failed"); }, remove }); + const copyBack = vi.fn(async () => {}).mockRejectedValueOnce(new Error("copy-back failed")); + bindManagedNativeCredentialTurn(session, { copyBack, remove }); await expect(session.close({ reason: "completed" })).rejects.toThrow("copy-back failed"); + expect(remove).not.toHaveBeenCalled(); + await session.close({ reason: "retry" }); + expect(copyBack).toHaveBeenCalledTimes(2); expect(remove).toHaveBeenCalledOnce(); }); }); diff --git a/server/src/services/native-runtime/managed-native-credentials.ts b/server/src/services/native-runtime/managed-native-credentials.ts index 898211a845b..9d45e70db1c 100644 --- a/server/src/services/native-runtime/managed-native-credentials.ts +++ b/server/src/services/native-runtime/managed-native-credentials.ts @@ -49,15 +49,12 @@ export function bindManagedNativeCredentialTurn( if (owner) owner.closing = true; await close(input); if (!owner || owner.detached || credentialTurns.get(session) !== owner) return; - try { - await completeCredentialTurn(owner); - } finally { - // Idle expiry removes the provider credential, but never writes back to - // the already-deleted temporary home of a completed invocation. - if (!owner.detached && !owner.removed && credentialTurns.get(session) === owner) { - owner.removed = true; - await owner.remove(); - } + await completeCredentialTurn(owner); + // Failed copy-back retains credentials for retry. Idle expiry never writes + // back to the already-deleted temporary home of a completed invocation. + if (!owner.detached && !owner.removed && credentialTurns.get(session) === owner) { + await owner.remove(); + owner.removed = true; } }; return session; @@ -65,7 +62,10 @@ export function bindManagedNativeCredentialTurn( async function completeCredentialTurn(turn: CredentialTurn) { if (turn.detached || turn.completed) return; - turn.completion ??= turn.copyBack().finally(() => { turn.completed = true; }); + turn.completion ??= turn.copyBack().then( + () => { turn.completed = true; }, + (error: unknown) => { turn.completion = undefined; throw error; }, + ); await turn.completion; } 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 3274b2ed3f9..af0298b0756 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -1191,7 +1191,7 @@ describe("native harness persistence profiles", () => { "acpx codex", { kind: "acpx", agent: "codex" }, "acpx_runtime", - ["runner", "acpx"], + ["runner", "acpx", "codex-home"], ], ])( "declares the complete %s recovery state", @@ -1210,7 +1210,7 @@ describe("native harness persistence profiles", () => { expect( codex.directories.find((directory) => directory.name === "codex-home"), ).toMatchObject({ - excludeEntries: ["tmp", ".tmp", "auth.json", "config.toml"], + excludeEntries: ["tmp", ".tmp", "auth.json", "auth.json.returned", "auth.json.returned.staging", "config.toml"], }); }); @@ -1224,6 +1224,8 @@ describe("native harness persistence profiles", () => { `acpx/${sessionDirectory}/codex-home/tmp`, `acpx/${sessionDirectory}/codex-home/.tmp`, `acpx/${sessionDirectory}/codex-home/auth.json`, + `acpx/${sessionDirectory}/codex-home/auth.json.returned`, + `acpx/${sessionDirectory}/codex-home/auth.json.returned.staging`, `acpx/${sessionDirectory}/codex-home/config.toml`, ], }); @@ -1840,7 +1842,7 @@ describe("remote provider checkpoint snapshots", () => { sourcePath: "/remote/session/filesystem/codex-home", targetPath: "/tmp/paperclip-checkpoint-test-codex-home", mode: 0o700, - excludeEntries: ["tmp", ".tmp", "auth.json", "config.toml"], + excludeEntries: ["tmp", ".tmp", "auth.json", "auth.json.returned", "auth.json.returned.staging", "config.toml"], }); expect(execute).toHaveBeenNthCalledWith( @@ -2022,7 +2024,7 @@ describe("remote provider checkpoint restores", () => { sourcePath, targetPath: "/remote/codex-home", mode: 0o700, - excludeEntries: ["tmp", ".tmp", "auth.json", "config.toml"], + excludeEntries: ["tmp", ".tmp", "auth.json", "auth.json.returned", "auth.json.returned.staging", "config.toml"], }); expect(syncIn).toHaveBeenCalledOnce(); @@ -7688,6 +7690,34 @@ describe("runnerd provider runtime wiring", () => { await expect(access(authPath)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("merges ACPX returned auth and retains it when the tenant merge fails", async () => { + const acpxExecution = { ...execution, provider: { kind: "acpx", agent: "codex", model: "gpt-5.6-sol" }, session: { ...execution.session, driverKind: "acpx_runtime" } } as unknown as NativeExecutionInputV1; + const close = vi.fn(async () => undefined); + state.createBackend.mockReturnValueOnce({ kind: "test", openSession: async () => ({ close }) } as never); + const managedHome = join(isolatedStateDirectory, "managed-home"); + const backend = await createRunnerdBackend({ db: leaseDb(acpxExecution), execution: acpxExecution, runnerInstanceId: "runner-acpx-credential-close", managedAiCredentialHome: managedHome, runnerEnvironment: { OPENAI_API_KEY: "", CODEX_API_KEY: "" } }); + state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!(); + const root = state.createTransport.mock.calls.at(-1)![0].stateDirectory!; + const source = join(root, "codex-home", "auth.json"); + const returned = `${source}.returned`; + await mkdir(join(root, "codex-home"), { recursive: true }); + await writeFile(source, "original-fixture"); + await writeFile(returned, "refreshed-fixture"); + const session = await backend.openSession({} as never); + state.copyBackCodexAuth.mockRejectedValueOnce(new Error("merge unavailable")); + await expect(session.close({ reason: "completed" })).rejects.toThrow("merge unavailable"); + await expect(readFile(returned, "utf8")).resolves.toBe("refreshed-fixture"); + state.copyBackCodexAuth.mockImplementationOnce(async (...args: unknown[]) => { + const input = args[0] as { readSandboxAuth: () => Promise; hostAuthPath: string }; + expect(input.hostAuthPath).toBe(join(managedHome, "auth.json")); + expect((await input.readSandboxAuth()).toString()).toBe("refreshed-fixture"); + return "copied"; + }); + await session.close({ reason: "retry" }); + await expect(access(source)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(returned)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("stages from the authenticated run snapshot and cleans up after the provider turn", async () => { const cleanup = vi.fn(async () => undefined); state.stageNativeRunnerWakeAttachments.mockResolvedValueOnce({ diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 9c0adb000c5..b917e818faf 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -283,6 +283,8 @@ const CODEX_HOME_NON_PERSISTENT_ENTRIES = [ "tmp", ".tmp", "auth.json", + "auth.json.returned", + "auth.json.returned.staging", "config.toml", ] as const; const RUNNERD_CONTROL_PLANE_STATE_SCHEMA = @@ -5273,6 +5275,9 @@ export function resolveNativeHarnessPersistenceProfile( directories: [ { name: "runner", location: "runner", excludeEntries: [] }, ...(providerDirectory ? [providerDirectory] : []), + ...(execution.provider.kind === "acpx" && execution.provider.agent === "codex" + ? [{ name: "codex-home" as const, location: "filesystem" as const, excludeEntries: CODEX_HOME_NON_PERSISTENT_ENTRIES }] + : []), ], }; } @@ -12627,10 +12632,13 @@ async function createRunnerdBackendWithinSessionClaim( }); const boundManagedSessions = new WeakSet(); const wrapManagedSession = (session: NativeSession): NativeSession => { - if (!input.managedAiCredentialHome || input.execution.provider.kind !== "codex" || boundManagedSessions.has(session)) return session; + const acpxCodex = input.execution.provider.kind === "acpx" && input.execution.provider.agent === "codex"; + if (!input.managedAiCredentialHome || !(input.execution.provider.kind === "codex" || acpxCodex) || boundManagedSessions.has(session)) return session; boundManagedSessions.add(session); - const remoteAuth = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "codex-home", "auth.json") : null; - const localAuth = join(root, "codex-home", "auth.json"); + const authName = acpxCodex && !effectiveRunnerEnvironment.CODEX_API_KEY && !effectiveRunnerEnvironment.OPENAI_API_KEY + ? "auth.json.returned" : "auth.json"; + const remoteAuth = remoteRunnerFilesystemRoot ? posix.join(remoteRunnerFilesystemRoot, "codex-home", authName) : null; + const localAuth = join(root, "codex-home", authName); return bindManagedNativeCredentialTurn(session, { copyBack: async () => { await copyBackCodexAuth({ @@ -12645,8 +12653,10 @@ async function createRunnerdBackendWithinSessionClaim( }); }, remove: async () => { - rmSync(localAuth, { force: true }); - if (remoteAuth && remoteCommandRunner) await remoteCommandRunner.execute({ command: "rm", args: ["-f", "--", remoteAuth], bypassSession: true, timeoutMs: 10000 }); + for (const name of ["auth.json", "auth.json.returned"] as const) { + rmSync(join(root, "codex-home", name), { force: true }); + if (remoteRunnerFilesystemRoot && remoteCommandRunner) await remoteCommandRunner.execute({ command: "rm", args: ["-f", "--", posix.join(remoteRunnerFilesystemRoot, "codex-home", name)], bypassSession: true, timeoutMs: 10000 }); + } }, }); };