diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index b3055a5de0..c197e628ba 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -42,7 +42,8 @@ Sandbox runs use the operating-system user's home directory. Both legacy adapters and the native runner enter the same host-owned lifecycle before dispatch. Local execution keeps its existing workspace and home behavior. Legacy ACP proxies use a private host staging directory while agent sessions start -in the sandbox home. Native runner launches carry the validated scoped paths +in the sandbox home. For API-key Codex ACP runs, the adapter writes the explicit +key to an owner-only login file in the staging copy; host credentials stay unchanged. Native runner launches carry the validated scoped paths through runnerd to ACPX; CLI configuration remains in its private runtime directories. Warm sandbox task bindings persist independently of the experimental isolated workspace setting. Only the active host run can establish that binding; the diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index b9f79d9d4f..63d356fc89 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -890,6 +890,47 @@ describe("codex_local ACP lane", () => { expect(Object.keys(meta[0]?.env ?? {}).filter((key) => key.startsWith("XDG_"))).toEqual([]); }); + it.each([false, true])("stages an explicit API key without changing host credentials (existing auth: %s)", async (existingAuth) => { + const root = await makeTempRoot("paperclip-codex-acp-api-auth-"); + const localCwd = path.join(root, "worktree"); + const sandboxHome = path.join(root, "sandbox-home"); + const sourceHome = path.join(root, "codex-home"); + const sharedHome = path.join(root, "shared-home"); + await Promise.all([localCwd, path.join(sandboxHome, "repos", "primary"), sourceHome, sharedHome].map((dir) => fs.mkdir(dir, { recursive: true }))); + const original = subscriptionAuthJson("host-account", OLDER_REFRESH, "host"); + await fs.writeFile(path.join(sharedHome, "auth.json"), original, { mode: 0o600 }); + if (existingAuth) await fs.symlink(path.join(sharedHome, "auth.json"), path.join(sourceHome, "auth.json")); + process.env.CODEX_HOME = sharedHome; + const meta: AdapterInvocationMeta[] = []; + const execute = createCodexAcpExecutor({ + createRuntime: (options: FakeRuntimeOptions) => new FakeRuntime(options) as never, + }); + const result = await execute(buildContext(localCwd, { + config: { + engine: "acp", cwd: localCwd, agentCommand: "node ./fake-acp.js", + stateDir: path.join(root, "state"), + env: { CODEX_HOME: sourceHome, OPENAI_API_KEY: "explicit-test-key" }, + promptTemplate: "Do the assigned work.", + }, + context: { issueId: "issue-1", paperclipWorkspace: { cwd: localCwd, source: "project_workspace", workspaceId: "workspace-1" } }, + executionTarget: { kind: "remote", transport: "sandbox", providerKey: "fake-plugin", + remoteCwd: path.join(sandboxHome, "repos", "primary"), workFolderHome: sandboxHome, + runner: createLocalSandboxRunner() } as never, + authToken: "real-run-jwt", + onMeta: async (payload: AdapterInvocationMeta) => { meta.push(payload); }, + })); + expect(result.exitCode).toBe(0); + const remoteHome = String(meta[0]?.env?.CODEX_HOME ?? ""); + expect(remoteHome).toBe(path.join(sandboxHome, ".codex")); + expect(JSON.parse(await fs.readFile(path.join(remoteHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: "explicit-test-key" }); + expect((await fs.stat(path.join(remoteHome, "auth.json"))).mode & 0o777).toBe(0o600); + await expect(fs.readFile(path.join(sharedHome, "auth.json"), "utf8")).resolves.toBe(original); + if (existingAuth) { + expect((await fs.lstat(path.join(sourceHome, "auth.json"))).isSymbolicLink()).toBe(true); + await expect(fs.readFile(path.join(sourceHome, "auth.json"), "utf8")).resolves.toBe(original); + } else await expect(fs.access(path.join(sourceHome, "auth.json"))).rejects.toThrow(); + }); + it("copies a strictly-newer sandbox Codex auth back to the shared host on teardown", async () => { const root = await makeTempRoot("paperclip-codex-acp-copyback-newer-"); const localCwd = path.join(root, "worktree"); diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index 650fb04096..72a8c0ad98 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -44,6 +44,7 @@ import { evaluateCodexCredentialReadiness, resolveSharedCodexHomeDir, stageCodexHomeForSync, + writeApiKeyAuthJson, } from "./codex-home.js"; import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js"; @@ -199,6 +200,11 @@ async function prepareCodexRemoteManagedHome( const stagedCodexHomeDir = await stageCodexHomeForSync(effectiveCodexHome, { runId }); let stagedRuntime; try { + // Codex ACP reads API credentials from its login file. Materialize the + // explicit run key only in the private staging copy, never in a shared or + // user-supplied host home (which may contain a subscription symlink). + const apiKey = env.OPENAI_API_KEY?.trim(); + if (apiKey) await writeApiKeyAuthJson(stagedCodexHomeDir, apiKey); stagedRuntime = await input.stage([ { key: "home",