diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 853bf0f632..37ca05711c 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -180,9 +180,10 @@ Sandbox Codex tool commands preserve the environment initialized by the adapter: login-shell execution and shell snapshots are disabled so image profiles cannot replace the managed Git PATH. This applies to CLI and ACP execution in both runner generations; local execution keeps its existing settings. -Native Codex ACP selects the provider's `agent-full-access` initial mode only +Legacy and native Codex ACP select the provider's `agent-full-access` initial mode only inside a validated external work-folder sandbox with an explicit `approve-all` -binding. This avoids starting an unsupported nested network namespace. Local +binding. This avoids an inner network namespace that prevents tools from reaching +the sandbox's loopback API and Git-credential callback bridge. Local execution and the `approve-reads` / `deny-all` modes retain their existing policy. CLI state is separate from the four shared collections. A change of task, agent, responsible user, or project cannot reuse a sandbox with another binding. diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 7912c98ae0..0049a45bfc 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -555,6 +555,7 @@ describe("shared ACPX engine runtime behavior", () => { const { configOptions, meta } = await runExecutor({ agent: "codex" }); expect((meta[0]?.env as Record).CODEX_CONFIG).toBeUndefined(); + expect((meta[0]?.env as Record).INITIAL_AGENT_MODE).toBeUndefined(); expect(configOptions).toEqual([]); }); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 8b9f4b5784..954850800d 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -1932,6 +1932,12 @@ async function buildRuntime(input: { env.ANTHROPIC_MODEL = requestedModel; } if (acpxAgent === "codex") { + // Match the native runner: the admitted outer sandbox supplies isolation. + // Codex ACP's default mode isolates loopback too, hiding our API bridge + // from tools. Restricted permission modes and local runs keep their mode. + if (workFolderHome && permissionMode === "approve-all") { + env.INITIAL_AGENT_MODE = "agent-full-access"; + } const codexStartupConfig = buildCodexStartupConfig({ existingConfig: env.CODEX_CONFIG, requestedModel, diff --git a/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts b/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts index 4fe1ed0870..0d7cc0d36e 100644 --- a/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts +++ b/packages/adapter-utils/src/acpx-engine/startup-characterization.test.ts @@ -533,10 +533,17 @@ describe("ACPX engine startup characterization", () => { expect(sessionInputs[0]?.cwd).toBe(remoteCwd); }); - it("preserves the Codex tool environment for work-folder sandboxes without changing other features", async () => { + it.each(["approve-all", "approve-reads", "deny-all"])("preserves the Codex sandbox tool environment with %s permissions", async (permissionMode) => { const { stateDir, executionTarget, remoteCwd } = await setupRemoteSandbox(); + let launchEnvironment: Record = {}; + (executionTarget as { runner: unknown }).runner = createLocalSandboxRunner((input) => { + const match = input.args?.[1]?.match(/PAPERCLIP_PROCESS_SESSION_COMMAND_B64='([^']+)'/); + if (match) { + launchEnvironment = JSON.parse(Buffer.from(match[1]!, "base64").toString("utf8")).env; + } + }); const { meta } = await runExecutor({ - agent: "codex", stateDir, cwd: remoteCwd, + agent: "codex", stateDir, cwd: remoteCwd, permissionMode, env: { CODEX_CONFIG: JSON.stringify({ features: { shell_snapshot: true, existing_feature: true } }) }, }, { authToken: "real-run-jwt", @@ -545,6 +552,10 @@ describe("ACPX engine startup characterization", () => { expect(JSON.parse(String((meta[0]?.env as Record).CODEX_CONFIG))).toEqual({ allow_login_shell: false, features: { shell_snapshot: false, existing_feature: true }, }); + const expectedMode = permissionMode === "approve-all" ? "agent-full-access" : undefined; + expect((meta[0]?.env as Record).INITIAL_AGENT_MODE).toBe(expectedMode); + expect(launchEnvironment.PAPERCLIP_API_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(launchEnvironment.INITIAL_AGENT_MODE).toBe(expectedMode); }); it("keeps sandbox work folders remote while spawning the ACP proxy on the host", async () => { @@ -554,7 +565,7 @@ describe("ACPX engine startup characterization", () => { await fs.mkdir(primary, { recursive: true }); await fs.writeFile(path.join(primary, "keep.txt"), "sandbox work"); const mkdir = vi.spyOn(fs, "mkdir"); - const { sessionInputs, runtimeOptions } = await runExecutor( + const { sessionInputs, runtimeOptions, meta } = await runExecutor( { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: primary }, { authToken: "real-run-jwt", @@ -565,6 +576,7 @@ describe("ACPX engine startup characterization", () => { expect(mkdir.mock.calls.some(([directory]) => directory === primary)).toBe(false); expect(sessionInputs[0]?.cwd).toBe(home); expect(runtimeOptions[0]?.spawnCwd).toBe(path.join(stateDir, "work-folder-proxy")); + expect((meta[0]?.env as Record).INITIAL_AGENT_MODE).toBeUndefined(); await expect(fs.readFile(path.join(primary, "keep.txt"), "utf8")).resolves.toBe("sandbox work"); expect(vi.mocked(prepareAdapterExecutionTargetRuntime).mock.calls[0]![0].workspaceLocalDir) .toBe(path.join(stateDir, "work-folder-proxy"));