diff --git a/packages/paperclip-runner/src/cli/eval-gateway-runtime.test.ts b/packages/paperclip-runner/src/cli/eval-gateway-runtime.test.ts index 0ef09abd57f..dfdad937057 100644 --- a/packages/paperclip-runner/src/cli/eval-gateway-runtime.test.ts +++ b/packages/paperclip-runner/src/cli/eval-gateway-runtime.test.ts @@ -73,6 +73,7 @@ it.each([["codex", undefined], ["acpx", "claude"]])("routes %s/%s with only its expect(config).toContain('env_key = "PAPERCLIP_AI_PROVIDER_KEY"'); expect(config).toContain("requires_openai_auth = false"); expect(config).toContain("shell_snapshot = false"); + expect(config).toContain("image_generation = false"); expect(config).toContain('web_search = "disabled"'); expect(config).not.toContain(token); expect((await stat(join(env.CODEX_HOME!, "config.toml"))).mode & 0o777).toBe(0o600); diff --git a/packages/paperclip-runner/src/cli/eval-gateway-runtime.ts b/packages/paperclip-runner/src/cli/eval-gateway-runtime.ts index a4e27f9c180..0dbf4d7cba6 100644 --- a/packages/paperclip-runner/src/cli/eval-gateway-runtime.ts +++ b/packages/paperclip-runner/src/cli/eval-gateway-runtime.ts @@ -76,7 +76,9 @@ export async function prepareEvalGatewayRuntime( 'name = "Paperclip eval controller"', `base_url = ${JSON.stringify(descriptor.baseUrl + "/v1")}`, 'wire_api = "responses"', 'requires_openai_auth = false', 'env_key = "PAPERCLIP_AI_PROVIDER_KEY"', - '[features]', 'shell_snapshot = false', '', + // The controller gateway admits no hosted tools, so qualified evals keep + // Codex's hosted image tool off even though product runs enable it. + '[features]', 'shell_snapshot = false', 'image_generation = false', '', ].join("\n"), { mode: 0o600, flag: "wx" }); } else { Object.assign(environment, { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts index 13e83500aba..f8f9d55e27d 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts @@ -129,7 +129,6 @@ describe("Codex app-server Codex driver", () => { "skills.include_instructions": false, include_apps_instructions: false, include_collaboration_mode_instructions: true, - "features.image_generation": false, }, permissions: "paperclip-runner-workspace-only", runtimeWorkspaceRoots: [WORKSPACE], @@ -160,8 +159,6 @@ describe("Codex app-server Codex driver", () => { expect.stringContaining( 'shell_environment_policy.set={PATH="/bin",LANG="C.UTF-8"}', ), - "--disable", - "image_generation", ]), ); expect( diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts index b1b9f42cb3a..c8e15ed4d5c 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts @@ -1,6 +1,8 @@ import { createRequire } from "node:module"; import { execFileSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import { resolvePinnedCodexCommand } from "./codex-command.js"; import { evalProviderTransportOptions } from "../../cli/eval-provider-runtime.js"; import { describe, expect, it } from "vitest"; @@ -358,10 +360,59 @@ describe("Codex security configuration", () => { "features.plugins": false, "features.multi_agent": false, "features.memories": false, - "features.image_generation": false, }); }); + it("N-12: lets an API-key Codex agent generate images with its existing provider key instead of asking for another", () => { + // Codex API path: the agent's OpenAI key reaches only the trusted + // app-server process. Codex's built-in image tool uses that provider auth; + // with it disabled the agent falls back to an OPENAI_API_KEY script that + // the sandboxed shell can never see, so it asks the user for a second key. + const source = { + HOME: "/host/home", CODEX_HOME: "/host/codex", PATH: "/safe/bin", + OPENAI_API_KEY: "agent-provider-key", + }; + const args = createIsolatedCodexAppServerArgs(source); + const serialized = args.join("\n"); + expect(args).not.toContain("image_generation"); + for (const mode of ["default", "plan"] as const) { + expect(createSecuredCodexThreadParams("/workspace", mode, true, false, source).config) + .not.toHaveProperty("features.image_generation"); + } + // The tool saves results under CODEX_HOME/generated_images; the agent must + // be able to read (and so copy) them into the workspace, nothing else there. + for (const profile of ["paperclip-runner-workspace-only", "paperclip-runner-workspace-read-only"]) { + const rules = args.find((arg) => arg.startsWith(`permissions.${profile}.filesystem=`))!; + expect(rules).toContain('"/host/codex"="none"'); + expect(rules).toContain('"/host/codex/generated_images"="read"'); + } + // The existing key is reused by the provider, never exposed to the shell. + expect(serialized).not.toContain("OPENAI_API_KEY"); + expect(serialized).not.toContain("agent-provider-key"); + }); + + it("N-12: grants generated images in preconfigured mode only when CODEX_HOME is outside HOME", () => { + const inside = createIsolatedCodexAppServerArgs({ HOME: "/users/executor", CODEX_HOME: "/users/executor/.codex", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" }).join("\n"); + expect(inside).not.toContain("generated_images"); + const outside = createIsolatedCodexAppServerArgs({ HOME: "/users/executor", CODEX_HOME: "/var/lib/codex-home", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" }).join("\n"); + expect(outside.match(/"\/var\/lib\/codex-home\/generated_images"="read"/g)).toHaveLength(2); + expect(outside).not.toContain('"/var/lib/codex-home"='); + }); + + it("N-12: the pinned Codex binary keeps built-in image generation enabled under the isolated launch", () => { + const codexHome = mkdtempSync(join(tmpdir(), "paperclip-n12-codex-home-")); + try { + const args = createIsolatedCodexAppServerArgs({ PATH: process.env.PATH, HOME: codexHome, CODEX_HOME: codexHome }); + const output = execFileSync(resolvePinnedCodexCommand(), [...args.slice(0, -1), "features", "list"], { + env: { PATH: process.env.PATH, HOME: codexHome, CODEX_HOME: codexHome }, + encoding: "utf8", timeout: 30_000, maxBuffer: 256 * 1024, + }); + expect(output.split("\n").find((line) => /^image_generation\s/.test(line))).toMatch(/\strue\s*$/); + } finally { + rmSync(codexHome, { recursive: true, force: true }); + } + }); + it("keeps automatic execution inside the workspace without host credentials and with normal network access", () => { const args = createIsolatedCodexAppServerArgs( { diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index 0121dce2e86..0130c7ba024 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -190,7 +190,10 @@ const SKILLLESS_BASE_CONFIG = { "features.plugins": false, "features.multi_agent": false, "features.memories": false, - "features.image_generation": false, + // Image generation stays on: Codex's built-in tool runs in the trusted + // app-server with the agent's existing provider auth. Disabling it makes the + // agent fall back to an OPENAI_API_KEY script the sandboxed shell cannot see, + // so it asks the user for a second key (N-12). } as const; export function codexCommandEnvironment( @@ -288,6 +291,16 @@ export function createIsolatedCodexAppServerArgs( .map((value) => resolve(value)), ), ]; + // Codex saves built-in image results under CODEX_HOME/generated_images and + // tells the agent to copy them into the workspace. Expose only that folder. + // A preconfigured HOME is already granted, so add the rule only when the + // Codex home sits outside it (never narrow an existing HOME write grant). + const generatedImagesRoot = typeof source.CODEX_HOME === "string" && source.CODEX_HOME.trim() + ? join(resolve(source.CODEX_HOME), "generated_images") : undefined; + const preconfiguredHome = usesPreconfiguredHome(source) && source.HOME ? resolve(source.HOME) : undefined; + const generatedImageRoots = generatedImagesRoot + && !(preconfiguredHome && codexPathInside(preconfiguredHome, generatedImagesRoot)) + ? [generatedImagesRoot] : []; const filesystemRules = [ `":root"="none"`, `":minimal"="read"`, @@ -296,6 +309,7 @@ export function createIsolatedCodexAppServerArgs( ...protectedPaths.map((path) => `${tomlString(resolve(path))}="none"`), ...(usesPreconfiguredHome(source) && source.HOME ? [`${tomlString(source.HOME)}="write"`] : []), ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), + ...generatedImageRoots.map((path) => `${tomlString(path)}="read"`), ...gitRoots.read.map((path) => `${tomlString(path)}="read"`), ...gitRoots.write.map((path) => `${tomlString(path)}="write"`), ...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="write"`] : []), @@ -311,6 +325,7 @@ export function createIsolatedCodexAppServerArgs( ...protectedPaths.map((path) => `${tomlString(resolve(path))}="none"`), ...(usesPreconfiguredHome(source) && source.HOME ? [`${tomlString(source.HOME)}="read"`] : []), ...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`), + ...generatedImageRoots.map((path) => `${tomlString(path)}="read"`), ...[...gitRoots.read, ...gitRoots.write].map((path) => `${tomlString(path)}="read"`), ...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="read"`] : []), ...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR @@ -358,8 +373,6 @@ export function createIsolatedCodexAppServerArgs( // as the turn plan (`turn/plan/updated` -> `plan.updated`). "-c", CODEX_UPDATE_PLAN_TOOL_CONFIG, - "--disable", - "image_generation", ...(externalRunnerSandbox ? ["--dangerously-bypass-approvals-and-sandbox"] : []),