mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
fix(runner): let Codex agents generate images with their existing provider key (#19516)
## 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
19239c11d1
commit
30a0b5982e
5 files changed
+73
-9
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
-3
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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"]
|
||||
: []),
|
||||
|
||||
Reference in new issue
Block a user