From 30a0b5982e03bc9a21da16da8600d88a728f886d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:48:54 -0600 Subject: [PATCH] fix(runner): let Codex agents generate images with their existing provider key (#19516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Paperclip runner launches Codex agents in an isolated app-server with a sandboxed shell > - On the Codex API path, the agent's OpenAI API key reaches only the trusted app-server process, never the shell > - The runner launched Codex with `--disable image_generation`, so Codex's built-in image tool was not available > - Codex then falls back to its `image_gen.py` script, which needs `OPENAI_API_KEY` in the shell, so the agent asked the user for a second key > - This pull request keeps Codex's built-in image tool on, so it uses the agent's existing provider auth, and lets the sandbox read only the folder where Codex saves the images > - The benefit is that a Codex agent can make images without a second key, and the provider key still does not reach the shell ## Linked Issues or Issue Description **What happened?** Manual onboarding test on Paperclip Cloud staging (Codex API path): "Open AI requests another api key for image model even though it has an active api key powering the agent." **Steps to reproduce** 1. Create a company with a Codex agent that uses an OpenAI API key. 2. Ask the agent to generate an image. 3. The agent asks for another OpenAI API key. **Expected behavior** The agent uses the OpenAI API key that already powers it. **Root cause** `createIsolatedCodexAppServerArgs` passed `--disable image_generation` and the thread config set `features.image_generation: false`. The pinned Codex 0.160.0 binary has two image paths. Its own instructions say the built-in `image_gen` tool "Does not require `OPENAI_API_KEY`". The fallback CLI path "Requires `OPENAI_API_KEY`". The runner removes `OPENAI_API_KEY` from the shell on purpose (`codex-shell-environment.ts`), so the fallback can never work and the agent asks the user. ## What Changed - `codex-security-config.ts`: remove `--disable image_generation` and `features.image_generation: false`. Codex's built-in tool runs in the trusted app-server with the agent's existing auth. - `codex-security-config.ts`: in the workspace and plan permission profiles, grant **read** on `CODEX_HOME/generated_images` only. Codex saves images there and tells the agent to copy them into the workspace. The rest of `CODEX_HOME` (for example `auth.json`) stays denied. Preconfigured-home mode is unchanged (it does not deny `CODEX_HOME`). - `eval-gateway-runtime.ts`: set `image_generation = false` in the controller-gateway Codex config. That gateway admits no hosted tools (`unqualified_hosted_tool`), so qualified evals keep the earlier behavior. - Tests: two new N-12 tests, and updates to the tests that asserted the old disable flag. ## Verification Red on `master` (819420a60a), same test file before the source change: ``` × N-12: lets an API-key Codex agent generate images with its existing provider key instead of asking for another 5ms × N-12: the pinned Codex binary keeps built-in image generation enabled under the isolated launch 4400ms ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯ FAIL src/drivers/codex/codex-security-config.test.ts > Codex security configuration > N-12: lets an API-key Codex agent generate images with its existing provider key instead of asking for another AssertionError: expected [ '-c', …(22) ] to not include 'image_generation' 378| expect(args).not.toContain("image_generation"); FAIL src/drivers/codex/codex-security-config.test.ts > Codex security configuration > N-12: the pinned Codex binary keeps built-in image generation enabled under the isolated launch AssertionError: expected 'image_generation …' to match /\strue\s*$/ + Received: "image_generation stable false" Tests 2 failed | 17 skipped (19) ``` Green after the fix: ``` Tests 2 passed | 17 skipped (19) ``` Neighbouring suites (`src/drivers/codex`, `src/cli/eval-gateway-runtime.test.ts`, `src/provider-events.test.ts`): ``` Test Files 25 passed (25) Tests 429 passed (429) ``` Real sandbox check with the pinned Codex 0.160.0 binary (`codex sandbox` with the generated permission profile, macOS seatbelt): ``` generated_images/t1/img.png status=0 "PNGDATA" # agent can read the saved image auth.json status=1 "cat: ...: Operation not permitted" # rest of CODEX_HOME stays denied cp status=0 # agent can copy the image into the workspace ``` - `pnpm exec tsc --noEmit -p packages/paperclip-runner`: no errors in changed files (only pre-existing errors from the unbuilt `@paperclipai/paperclip-eval-kernel` package). - `pnpm verify` exits 2 (changes outside resolved test graphs); required CI supplies the broad coverage. - **Test scope.** The red/green tests assert the launch configuration: the disable flag, the thread config, the sandbox grant, and the effective `image_generation` state that the pinned Codex binary reports. They do not drive a model. So they do not directly observe the reported "asks for a second key" behavior. The link from the flag to that behavior comes from the pinned Codex 0.160.0 bundled instructions: the built-in `image_gen` tool "Does not require `OPENAI_API_KEY`", and the fallback `scripts/image_gen.py` CLI "Requires `OPENAI_API_KEY`". The runner removes `OPENAI_API_KEY` from the shell. - Not run: a live paid image generation with an API-key Codex agent. There is no Codex staging company. ## Risks - Image generation uses the agent's OpenAI account. Images cost money on API-key billing. This is the expected behavior when the user asks for an image. - The agent can read images it (or an earlier thread of the same Codex home) generated. It cannot read other files in `CODEX_HOME`. - Codex's built-in `view_image` tool was already enabled, so the trusted process already reads image files for the model. This change does not add a new kind of file access to the provider. - If a future Codex release changes the image save folder, the agent can still see the image inline but cannot copy it. The N-12 binary test pins the feature state for the current Codex version. ### Security policy change (needs maintainer sign-off) **Why it was disabled.** #12364 ("isolate Codex security configuration") disabled host apps, plugins, memories, multi-agent behavior, and image generation as one group. Its goal was a closed launch configuration that does not depend on ambient Codex defaults. The PR records no specific threat for image generation. This PR keeps the other features disabled. **Why re-enabling it is safe.** - The tool is a hosted tool. It runs at OpenAI inside the same model request that the trusted app-server already makes. It adds no new network endpoint, process, or shell capability. - No credential moves. `OPENAI_API_KEY` stays out of the shell, argv, and serialized config (the N-12 test asserts this). - The new sandbox grant is read-only and covers only `CODEX_HOME/generated_images`. The rest of `CODEX_HOME` (`auth.json`, config, sessions) stays denied. The real `codex sandbox` check above confirms this. In plan mode the grant is also read-only. - Codex's `view_image` tool was already enabled. So the trusted process already sends local image content to the provider. - Controller-gateway evals keep the tool off (`image_generation = false`), because that gateway admits no hosted tools. **Credentials and billing.** - API-key path: the hosted tool uses the agent's existing OpenAI API key through the app-server. OpenAI bills image generation to that API account at image-model rates. - ChatGPT-subscription path: the tool uses the agent's existing ChatGPT sign-in. Use counts against that ChatGPT plan's limits. There is no separate API bill. - These two points follow from how Codex sends hosted tools in the provider request. I did not observe them in a live paid run. - Open question: Paperclip cost tracking uses provider token usage. I did not confirm that image-generation charges appear in Paperclip's per-agent cost and budget totals. If they do not, budget hard-stops could undercount image spend. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../src/cli/eval-gateway-runtime.test.ts | 1 + .../src/cli/eval-gateway-runtime.ts | 4 +- ...-app-server-driver.context-control.test.ts | 3 - .../codex/codex-security-config.test.ts | 55 ++++++++++++++++++- .../drivers/codex/codex-security-config.ts | 19 ++++++- 5 files changed, 73 insertions(+), 9 deletions(-) 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"] : []),