fix: align legacy Codex ACP sandbox network mode

Match the native runner's approved external-sandbox mode so Codex tools can reach the loopback API and Git credential bridge. Preserve local and restricted permission modes; verify the serialized launch environment.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-09 14:08:28 -05:00
parent daf1d64b20
commit 6fb569c0d0
4 changed files with 25 additions and 5 deletions

View File

@ -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.

View File

@ -555,6 +555,7 @@ describe("shared ACPX engine runtime behavior", () => {
const { configOptions, meta } = await runExecutor({ agent: "codex" });
expect((meta[0]?.env as Record<string, string>).CODEX_CONFIG).toBeUndefined();
expect((meta[0]?.env as Record<string, string>).INITIAL_AGENT_MODE).toBeUndefined();
expect(configOptions).toEqual([]);
});

View File

@ -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,

View File

@ -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<string, string> = {};
(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<string, string>).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<string, string>).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<string, string>).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"));