mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 01:52:55 +01:00
## 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>
159 lines
10 KiB
TypeScript
159 lines
10 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import { createSanitizedCodexEnvironment } from "../drivers/codex/app-server-transport.js";
|
|
import { createSanitizedAcpxSpawnInput } from "../drivers/acpx/environment.js";
|
|
import { createCapabilityFixtureState } from "../mock-core/capability-control-plane-types.js";
|
|
import type { CapabilityLiveSessionSnapshot } from "../live/live-session.js";
|
|
import { parseEvalSessionRequest } from "./eval-session-contract.js";
|
|
import { prepareEvalGatewayRuntime } from "./eval-gateway-runtime.js";
|
|
import { runEvalSessionCli } from "./eval-session.js";
|
|
|
|
const roots: string[] = [];
|
|
afterEach(async () => {
|
|
vi.unstubAllEnvs();
|
|
for (const root of roots.splice(0)) {
|
|
for (const entry of await readdir(root).catch(() => [])) {
|
|
if (entry.startsWith(".paperclip-eval-runtime-context-")) await chmod(join(root, entry, "instructions"), 0o700).catch(() => undefined);
|
|
}
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
const token = "a".repeat(64);
|
|
async function fixture(provider = "codex", acpxAgent?: string) {
|
|
const root = await mkdtemp(join(tmpdir(), "runner-gateway-test-")); roots.push(root);
|
|
const model = acpxAgent === "claude" ? "claude-sonnet-5" : "gpt-5.6-sol";
|
|
const binary = join(root, "runnerd");
|
|
await writeFile(binary, "fake runnerd");
|
|
const request = parseEvalSessionRequest({
|
|
schema: "paperclip-runner/eval-session-request/v1", attemptId: "attempt-1", prompt: "Read the governed task.",
|
|
provider, ...(acpxAgent ? { acpxAgent } : {}), model,
|
|
runnerd: { path: binary, sha256: createHash("sha256").update("fake runnerd").digest("hex") },
|
|
limits: { turnTimeoutMs: 120000, maxAgentTurns: 1, maxEstimatedCostNanodollars: 100000000 },
|
|
session: { workingDirectory: root },
|
|
});
|
|
const descriptor = { schema: "paperclip.runner-eval.gateway/v1", provider: acpxAgent === "claude" ? "anthropic" : "openai",
|
|
model, baseUrl: "http://127.0.0.1:4567", campaignId: "campaign-1", executionId: "cell-1", attemptId: "attempt-1", upstreamRoute: "braintrust" };
|
|
const descriptorPath = join(root, "gateway.json");
|
|
await writeFile(descriptorPath, JSON.stringify(descriptor));
|
|
return { root, request, descriptor, descriptorPath };
|
|
}
|
|
|
|
it.each([["codex", undefined], ["acpx", "claude"]])("routes %s/%s with only its proxy credential and an owned home", async (provider, agent) => {
|
|
const f = await fixture(provider, agent);
|
|
const gateway = await prepareEvalGatewayRuntime(f.descriptorPath, f.request, {
|
|
PATH: process.env.PATH, HOME: "/ambient/home", CODEX_HOME: "/ambient/codex", CLAUDE_CONFIG_DIR: "/ambient/claude",
|
|
PAPERCLIP_RUNNER_EVAL_GATEWAY_TOKEN: token, OPENAI_API_KEY: "ambient-openai", CODEX_API_KEY: "ambient-codex",
|
|
ANTHROPIC_API_KEY: "ambient-anthropic", ANTHROPIC_AUTH_TOKEN: "ambient-oauth", CLAUDE_CODE_OAUTH_TOKEN: "ambient-claude",
|
|
BRAINTRUST_API_KEY: "ambient-braintrust", AWS_SECRET_ACCESS_KEY: "ambient-aws", GH_TOKEN: "ambient-github",
|
|
HTTPS_PROXY: "https://ambient-proxy", NODE_OPTIONS: "--require ambient-hook", PAPERCLIP_CONFIGURED_ENVIRONMENT_KEYS: '["GH_TOKEN"]',
|
|
});
|
|
const env = gateway.transportOptions.environment;
|
|
try {
|
|
expect(gateway.transportOptions.homeMode).toBe("preconfigured");
|
|
expect(JSON.stringify(env)).not.toContain("ambient");
|
|
expect(env.OPENAI_API_KEY).toBeUndefined();
|
|
expect(env.CODEX_API_KEY).toBeUndefined();
|
|
expect(env.BRAINTRUST_API_KEY).toBeUndefined();
|
|
expect(env.PAPERCLIP_RUNNER_EVAL_GATEWAY_TOKEN).toBeUndefined();
|
|
const child = provider === "codex" ? createSanitizedCodexEnvironment(env) : createSanitizedAcpxSpawnInput(env, agent as "codex" | "claude").env;
|
|
expect(child.HOME).toBe(env.HOME);
|
|
if (agent === "claude") {
|
|
expect(child.ANTHROPIC_API_KEY).toBe(token);
|
|
expect(child.ANTHROPIC_BASE_URL).toBe(f.descriptor.baseUrl);
|
|
expect(child.CLAUDE_CODE_SUBAGENT_MODEL).toBe(f.request.model);
|
|
expect(JSON.parse(await readFile(join(env.CLAUDE_CONFIG_DIR!, "settings.json"), "utf8"))).toEqual({ model: f.request.model, availableModels: [f.request.model] });
|
|
} else {
|
|
expect(child.PAPERCLIP_AI_PROVIDER_KEY).toBe(token);
|
|
const config = await readFile(join(env.CODEX_HOME!, "config.toml"), "utf8");
|
|
expect(config).toContain('base_url = "http://127.0.0.1:4567/v1"');
|
|
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);
|
|
}
|
|
expect(JSON.stringify(gateway.evidence)).not.toContain(token);
|
|
expect(gateway.evidence).toMatchObject({ mode: "controller_gateway", attemptId: f.request.attemptId, declaredUpstreamRoute: "braintrust" });
|
|
} finally { await gateway.close(); }
|
|
expect(existsSync(env.HOME!)).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
{ baseUrl: "https://api.openai.com" }, { baseUrl: "http://localhost:4567" },
|
|
{ baseUrl: "http://127.0.0.1:4567/v1" }, { baseUrl: "http://127.0.0.1:99999" },
|
|
{ baseUrl: "http://127.0.0.1:4567?token=secret" }, { attemptId: "another-attempt" },
|
|
{ model: "another-model" }, { campaignId: "bad identity" }, { unexpectedSecret: "must-not-print" },
|
|
])("rejects invalid descriptor binding %j", async (change) => {
|
|
const f = await fixture();
|
|
await writeFile(f.descriptorPath, JSON.stringify({ ...f.descriptor, ...change }));
|
|
await expect(prepareEvalGatewayRuntime(f.descriptorPath, f.request, { PAPERCLIP_RUNNER_EVAL_GATEWAY_TOKEN: token }))
|
|
.rejects.toThrow("Invalid controller gateway descriptor");
|
|
});
|
|
|
|
it("rejects missing tokens and unsupported provider routes before runtime creation", async () => {
|
|
const f = await fixture();
|
|
await expect(prepareEvalGatewayRuntime(f.descriptorPath, f.request, {})).rejects.toThrow("gateway token");
|
|
await expect(prepareEvalGatewayRuntime(f.descriptorPath, { ...f.request, provider: "opencode" }, { PAPERCLIP_RUNNER_EVAL_GATEWAY_TOKEN: token })).rejects.toThrow("does not support");
|
|
await writeFile(f.descriptorPath, JSON.stringify({ ...f.descriptor, provider: "anthropic" }));
|
|
await expect(prepareEvalGatewayRuntime(f.descriptorPath, f.request, { PAPERCLIP_RUNNER_EVAL_GATEWAY_TOKEN: token })).rejects.toThrow("does not support");
|
|
});
|
|
|
|
it.each([false, true])("the real CLI retains route identity and removes the owned home after failure=%s", async (fail) => {
|
|
const f = await fixture();
|
|
vi.stubEnv("PAPERCLIP_RUNNER_EVAL_GATEWAY_TOKEN", token);
|
|
vi.stubEnv("OPENAI_API_KEY", "upstream-must-not-reach-provider");
|
|
const requestPath = join(f.root, "request.json"), outputPath = join(f.root, "artifact.json");
|
|
await writeFile(requestPath, JSON.stringify(f.request));
|
|
const state = JSON.stringify(createCapabilityFixtureState({}));
|
|
const snapshot = {
|
|
sessionId: "session-1", revision: 1, providerThreadId: "provider-1", providerSessionId: "provider-1",
|
|
providerModel: { id: f.request.model, provider: "openai" }, status: "idle", activeTurnId: null,
|
|
createdAt: "2026-10-24T00:00:00Z", updatedAt: "2026-10-24T00:00:01Z",
|
|
authority: { companyId: "company-1", actorId: "actor-1", taskId: "task-1", runId: "run-1", sessionId: "session-1", scenarioId: "gateway-test" },
|
|
config: { provider: "codex", driver: "codex_app_server", seedState: state, workingDirectory: f.root, scenario: { id: "gateway-test" }, capabilities: [], explicitClaims: [], turnTimeoutMs: 1000 },
|
|
mockState: state, process: null, networkEvidence: { realPaperclipRequests: 0, childPaperclipEnvironmentKeys: [] },
|
|
transcript: [], evidence: [], authorizationRecords: [], attempts: [], terminalTurns: [{ turnId: "turn-1", status: "completed" }],
|
|
usageLedger: [{ receiptId: "receipt-1", turnId: "turn-1", providerCalls: 1, providerRequests: 1, inputTokens: 100, outputTokens: 10, cachedInputTokens: 0, reasoningTokens: 0, costNanodollars: 0 }], usageUnavailable: [],
|
|
} as unknown as CapabilityLiveSessionSnapshot;
|
|
let home: string | undefined;
|
|
const factory = vi.fn((_binary, options) => {
|
|
expect(options.homeMode).toBe("preconfigured");
|
|
expect(options.environment.OPENAI_API_KEY).toBeUndefined();
|
|
expect(options.environment.PAPERCLIP_AI_PROVIDER_KEY).toBe(token);
|
|
home = options.environment.HOME;
|
|
expect(existsSync(home!)).toBe(true);
|
|
return { create: async () => ({
|
|
sendMessage: async () => { if (fail) throw new Error("simulated provider failure"); return { turnId: "turn-1", status: "completed", snapshot }; },
|
|
completeAttempt: async () => undefined, shutdown: async () => { snapshot.status = "closed"; }, snapshot: () => snapshot,
|
|
}) } as never;
|
|
});
|
|
const result = await runEvalSessionCli(["--request", requestPath, "--output", outputPath, "--gateway-config", f.descriptorPath], { serviceFactory: factory });
|
|
expect(result).toBe(fail ? 2 : 0);
|
|
expect(factory).toHaveBeenCalledTimes(1);
|
|
expect(existsSync(home!)).toBe(false);
|
|
const raw = await readFile(outputPath, "utf8"), artifact = JSON.parse(raw);
|
|
expect(artifact.requestRouting).toMatchObject({ mode: "controller_gateway", provider: "openai", campaignId: "campaign-1", executionId: "cell-1", attemptId: "attempt-1" });
|
|
expect(raw).not.toContain(token);
|
|
expect(raw).not.toContain("upstream-must-not-reach-provider");
|
|
if (fail) expect(artifact.infrastructureError).toBe("simulated provider failure");
|
|
});
|
|
|
|
|
|
it.each(["codex", undefined])("rejects ACPX Codex (%s) before creating any provider service", async (agent) => {
|
|
const f = await fixture("acpx", agent);
|
|
vi.stubEnv("PAPERCLIP_RUNNER_EVAL_GATEWAY_TOKEN", token);
|
|
const requestPath = join(f.root, "request.json"), outputPath = join(f.root, "artifact.json");
|
|
await writeFile(requestPath, JSON.stringify(f.request));
|
|
const factory = vi.fn(() => { throw new Error("must not create a provider service"); });
|
|
await expect(runEvalSessionCli(["--request", requestPath, "--output", outputPath, "--gateway-config", f.descriptorPath], { serviceFactory: factory }))
|
|
.rejects.toThrow("Controller gateway does not support this eval provider");
|
|
expect(factory).not.toHaveBeenCalled();
|
|
expect(existsSync(outputPath)).toBe(false);
|
|
});
|