Files
PaperClipAI/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.context-control.test.ts
T
DottaandClaude Opus 5.5 30a0b5982e 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>
2026-11-06 11:48:54 -06:00

887 lines
35 KiB
TypeScript

import {
CODEX_BLOCK_RESULT_OUTPUT_SCHEMA,
CODEX_INVALID_REQUEST,
CODEX_METHOD_NOT_FOUND,
CODEX_RESULT_OUTPUT_SCHEMA,
CodexAppServerDriver,
CodexRpcError,
FakeCodexTransport,
HarnessCapabilityUnavailableError,
HarnessOperationAlreadyTerminalError,
HarnessReconciliationError,
HarnessStaleTurnError,
WORKSPACE,
applyPrpEvent,
collectUntilTerminal,
createCodexTaskEnvelope,
createIsolatedCodexAppServerArgs,
createSessionSnapshotFromMetadata,
describe,
envelope,
expect,
isSkilllessCodexContext,
it,
liveConsoleFixturePath,
loadLiveConsoleConformanceFixture,
makeDriver,
replayPersistedCodexEvents,
result,
reverseObjectKeys,
runCodexCodexTracer,
traceCompletedProposal,
validateCodexResultProposal,
validatePrpEvent,
vi,
type CodexAppServerTransport,
type CodexRpcNotification,
type CodexRpcServerRequest,
type CodexServerRequestHandler,
type CodexTraceInterpretation,
type HarnessRuntimeRequestResolution,
type PrpCapabilities,
type PrpEvent,
type PrpStructuredRunResult,
} from "./codex-app-server-driver.test-support.js";
import { rehydrateRunnerdItemNotification } from "../../live/runnerd-codex-transport.js";
describe("Codex app-server Codex driver", () => {
it.each(["default", "disabled"] as const)("rejects unsupported reasoning %s without sending a turn RPC", async reasoningMode => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-reasoning", normalizedSessionId: "normalized-reasoning", workingDirectory: WORKSPACE,
});
const message = { role: "user" as const, text: "Hi" };
await expect(session.startTurn({ message, reasoningMode })).rejects.toThrow("Per-turn reasoning is not supported");
expect(transport.calls.filter(call => call.method === "turn/start")).toHaveLength(0);
await session.startTurn({ message });
expect(transport.calls.filter(call => call.method === "turn/start")).toHaveLength(1);
await session.close({ reason: "fixture complete" });
});
it("starts a compaction turn through thread/compact/start without submitting a message", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-compact", normalizedSessionId: "normalized-compact", workingDirectory: WORKSPACE,
});
const started = await session.startTurn({ message: { role: "user", text: "" }, continuation: true, compact: true });
expect(started.turnId).toBe("turn-compact");
expect(transport.calls.filter(call => call.method === "turn/start")).toHaveLength(0);
expect(transport.calls.find(call => call.method === "thread/compact/start")?.params).toEqual({ threadId: "thread-1" });
await session.close({ reason: "fixture complete" });
});
it("retains one correlation-bound steering acknowledgement after the rehydrated runnerd echo", async () => {
const transport = new FakeCodexTransport("thread-root");
const session = await makeDriver([transport]).openSession({
runId: "run-steering-echo", normalizedSessionId: "normalized-steering-echo", workingDirectory: WORKSPACE,
});
const { turnId } = await session.startTurn({ message: { role: "user", text: "Start." } });
await session.steer?.({ turnId, message: { role: "user", text: "Stay concise." }, correlationId: "queued-comment-1" });
transport.push("item/completed", rehydrateRunnerdItemNotification({
provider: "acpx", providerTurnId: turnId, itemId: `acpx-control-${"a".repeat(64)}`,
kind: "steering_acknowledgement", mode: "steer", status: "acknowledged",
text: "Steering acknowledged for the active turn.",
}, "thread-root", turnId));
await new Promise(resolve => setTimeout(resolve, 0));
await session.close({ reason: "fixture complete" });
const events: PrpEvent[] = [];
for await (const event of session.events()) events.push(event);
expect(events.filter(event => event.eventType === "item.completed" && event.payload.kind === "steering_acknowledgement"))
.toEqual([expect.objectContaining({ turnId, itemId: `${turnId}:steer:queued-comment-1`,
payload: expect.objectContaining({ kind: "steering_acknowledgement", status: "acknowledged" }) })]);
});
it("captures an exact skillless model/environment snapshot with credentials absent", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-context",
normalizedSessionId: "normalized-context",
workingDirectory: WORKSPACE,
});
const iterator = session.events()[Symbol.asyncIterator]();
const first = await iterator.next();
const context = first.value?.payload.context as Parameters<
typeof isSkilllessCodexContext
>[0];
expect(context).toMatchObject({
codexVersion: "codex-cli/0.132.0",
model: "gpt-test",
modelProvider: "openai",
workingDirectory: WORKSPACE,
approvalPolicy: "never",
instructionSources: [],
instructionPolicy: {
skillInstructions: false,
appInstructions: false,
collaborationInstructions: true,
},
environmentKeys: ["LANG", "PATH"],
envelope,
});
expect(isSkilllessCodexContext(context)).toBe(true);
expect(JSON.stringify(context)).not.toContain("must-not-pass");
expect(JSON.stringify(transport.calls)).not.toContain("RANDOM_SKILL_PATH");
expect(
transport.calls.find((call) => call.method === "thread/start")?.params,
).toMatchObject({
approvalPolicy: "never",
config: {
"skills.include_instructions": false,
include_apps_instructions: false,
include_collaboration_mode_instructions: true,
},
permissions: "paperclip-runner-workspace-only",
runtimeWorkspaceRoots: [WORKSPACE],
dynamicTools: [{ name: "paperclip_finish" }, { name: "paperclip_block" }],
});
const commandPolicy = transport.calls.find(
(call) => call.method === "thread/start",
)?.params.config as Record<string, unknown>;
expect(JSON.stringify(commandPolicy)).not.toContain("/isolated/home");
expect(JSON.stringify(commandPolicy)).not.toContain("/isolated/codex");
expect(JSON.stringify(commandPolicy)).not.toContain("CODEX_HOME");
const appServerArgs = createIsolatedCodexAppServerArgs({
PATH: "/bin",
LANG: "C.UTF-8",
HOME: "/isolated/home",
CODEX_HOME: "/isolated/codex",
});
expect(appServerArgs).toEqual(
expect.arrayContaining([
expect.stringContaining(
'default_permissions="paperclip-runner-workspace-only"',
),
expect.stringContaining(
"permissions.paperclip-runner-workspace-only.filesystem=",
),
"permissions.paperclip-runner-workspace-only.network.enabled=false",
'shell_environment_policy.inherit="none"',
expect.stringContaining(
'shell_environment_policy.set={PATH="/bin",LANG="C.UTF-8"}',
),
]),
);
expect(
appServerArgs.find((arg) => arg.startsWith("permissions.")),
).toContain('"/isolated/home"="none"');
expect(
appServerArgs.find((arg) => arg.startsWith("permissions.")),
).toContain('"/isolated/codex"="none"');
expect(JSON.stringify(appServerArgs)).not.toContain("HOME=");
expect(CODEX_RESULT_OUTPUT_SCHEMA.properties.schema).toEqual({
type: "string",
const: "paperclip.run_result.v1",
});
expect(
CODEX_BLOCK_RESULT_OUTPUT_SCHEMA.properties.reportedWorkDisposition,
).toEqual({
type: "string",
const: "blocked",
});
});
it.each(["never", "on-request", "untrusted"] as const)(
"pins approval policy %s for both thread start and resume",
async (approvalPolicy) => {
const first = new FakeCodexTransport();
const second = new FakeCodexTransport();
const driver = makeDriver([first, second], { approvalPolicy });
const original = await driver.openSession({
runId: `run-policy-${approvalPolicy}`,
normalizedSessionId: `normalized-policy-${approvalPolicy}`,
workingDirectory: WORKSPACE,
});
const started = await original.events()[Symbol.asyncIterator]().next();
const snapshot = await original.snapshot();
await original.close({ reason: "policy recovery test" });
const recovered = await driver.recoverSession?.(snapshot);
expect(recovered).toMatchObject({ recovered: true });
const resumed = await recovered!.session!.events()[Symbol.asyncIterator]().next();
expect(first.calls.find((call) => call.method === "thread/start")?.params)
.toMatchObject({ approvalPolicy });
expect(second.calls.find((call) => call.method === "thread/resume")?.params)
.toMatchObject({ approvalPolicy });
expect(started.value).toMatchObject({ eventType: "session.started", payload: { context: { approvalPolicy } } });
expect(resumed.value).toMatchObject({ eventType: "session.resumed", payload: { context: { approvalPolicy } } });
await recovered?.session?.close({ reason: "policy recovery complete" });
},
);
it("allows eval fixtures to opt out of Codex collaboration instructions", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport], {
includeCollaborationModeInstructions: false,
}).openSession({
runId: "run-no-collaboration-instructions",
normalizedSessionId: "session-no-collaboration-instructions",
workingDirectory: WORKSPACE,
});
const first = await session.events()[Symbol.asyncIterator]().next();
expect(first.value?.payload.context).toMatchObject({
instructionPolicy: { collaborationInstructions: false },
});
expect(
transport.calls.find((call) => call.method === "thread/start")?.params,
).toMatchObject({
config: { include_collaboration_mode_instructions: false },
});
});
it("negotiates genuine plan mode with collaboration instructions and read-only workspace access", async () => {
const transport = new FakeCodexTransport();
const driver = makeDriver([transport], {
requestedCollaborationMode: "plan",
});
const session = await driver.openSession({
runId: "run-plan",
normalizedSessionId: "session-plan",
workingDirectory: WORKSPACE,
});
expect(
transport.calls.find((call) => call.method === "thread/start")?.params,
).toMatchObject({
permissions: "paperclip-runner-workspace-read-only",
config: { include_collaboration_mode_instructions: true },
});
expect(
transport.calls.find((call) => call.method === "thread/start")?.params,
).not.toHaveProperty("collaborationMode");
expect(
transport.calls.some((call) => call.method === "collaborationMode/list"),
).toBe(true);
await expect(
session.startTurn({
message: { role: "user", text: "Author a plan." },
requestedCollaborationMode: "plan",
}),
).resolves.toMatchObject({ effectiveCollaborationMode: "plan" });
expect(
transport.calls.find((call) => call.method === "turn/start")?.params,
).toMatchObject({
collaborationMode: {
mode: "plan",
settings: {
model: "gpt-test",
reasoning_effort: "high",
developer_instructions: null,
},
},
permissions: "paperclip-runner-workspace-read-only",
});
});
it("fails closed when the installed app-server does not confirm plan mode", async () => {
const transport = new FakeCodexTransport();
transport.confirmCollaborationMode = false;
await expect(
makeDriver([transport], {
requestedCollaborationMode: "plan",
}).openSession({
runId: "run-plan-unsupported",
normalizedSessionId: "session-plan-unsupported",
workingDirectory: WORKSPACE,
}),
).rejects.toThrow("planning_mode_unsupported");
});
it("refuses to turn host credential roots into model-writable workspaces", async () => {
await expect(
makeDriver([], {
environment: {
HOME: "/isolated/home",
CODEX_HOME: WORKSPACE,
},
}).openSession({
runId: "run-codex-home",
normalizedSessionId: "normalized-codex-home",
workingDirectory: WORKSPACE,
}),
).rejects.toThrow("cannot overlap host CODEX_HOME");
for (const hostHome of [WORKSPACE, `${WORKSPACE}/host-home`]) {
await expect(
makeDriver([], {
environment: {
HOME: hostHome,
CODEX_HOME: "/isolated/codex",
},
}).openSession({
runId: "run-host-home",
normalizedSessionId: "normalized-host-home",
workingDirectory: WORKSPACE,
}),
).rejects.toThrow("cannot contain the host HOME");
}
await expect(
makeDriver([]).openSession({
runId: "run-root",
normalizedSessionId: "normalized-root",
workingDirectory: "/",
}),
).rejects.toThrow("cannot be a filesystem root");
});
it("steers and interrupts an active turn without replacing the session", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-controls",
normalizedSessionId: "normalized-controls",
workingDirectory: WORKSPACE,
});
const { turnId } = await session.startTurn({
message: { role: "user", text: "Start." },
});
await session.steer?.({
turnId,
message: { role: "user", text: "Use a shorter answer." },
});
await session.interrupt?.({ turnId, reason: "operator requested" });
expect(transport.calls.map((call) => call.method)).toEqual([
"initialize",
"thread/start",
"thread/goal/get",
"turn/start",
"turn/steer",
"turn/interrupt",
]);
expect(session.ids()).toEqual({
driverSessionId: "thread-1",
providerSessionId: "provider-session-1",
displayId: "thread-1",
});
});
it("loads the deterministic Live console wire fixture", async () => {
const fixture = await loadLiveConsoleConformanceFixture(
liveConsoleFixturePath,
);
expect(
fixture.runtimeRequests.map(({ requestKind }) => requestKind),
).toEqual([
"command_approval",
"file_approval",
"permission_approval",
"user_input",
"elicitation",
]);
expect(fixture.goals.map(({ action }) => action)).toEqual([
"get",
"set",
"pause",
"resume",
"clear",
]);
});
it("holds browser-resolved upstream requests and returns the exact fixture responses", async () => {
const fixture = await loadLiveConsoleConformanceFixture(
liveConsoleFixturePath,
);
for (const scenario of fixture.runtimeRequests) {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: `run-${scenario.id}`,
normalizedSessionId: `normalized-${scenario.id}`,
workingDirectory: WORKSPACE,
});
const { turnId } = await session.startTurn({
message: { role: "user", text: "Exercise request." },
});
const response = transport.invoke({
id: scenario.id,
method: scenario.method,
params: {
threadId: "thread-1",
turnId,
itemId: `item-${scenario.id}`,
reason: `fixture ${scenario.id}`,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(session.pendingRuntimeRequests?.()).toEqual([
expect.objectContaining({
requestId: scenario.id,
requestKind: scenario.requestKind,
method: scenario.method,
turnId,
}),
]);
await expect(
session.resolveRuntimeRequest?.({
requestId: scenario.id,
turnId,
resolution: {
action: "forged",
} as unknown as HarnessRuntimeRequestResolution,
}),
).rejects.toThrow("unsupported action");
expect(session.pendingRuntimeRequests?.()).toHaveLength(1);
await session.resolveRuntimeRequest?.({
requestId: scenario.id,
turnId,
resolution: scenario.resolution as HarnessRuntimeRequestResolution,
});
expect(await response).toEqual(scenario.expectedResponse);
expect(session.pendingRuntimeRequests?.()).toEqual([]);
await expect(
session.resolveRuntimeRequest?.({
requestId: scenario.id,
turnId,
resolution: { action: "cancel" },
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
await session.close({ reason: "fixture complete" });
}
});
it("acknowledges same-turn steering and rejects stale or child steering", async () => {
const fixture = await loadLiveConsoleConformanceFixture(
liveConsoleFixturePath,
);
const transport = new FakeCodexTransport("thread-root");
const session = await makeDriver([transport]).openSession({
runId: "run-live-console-controls",
normalizedSessionId: "normalized-live-console-controls",
workingDirectory: WORKSPACE,
});
const { turnId } = await session.startTurn({
message: { role: "user", text: "Start." },
});
await session.steer?.({
turnId,
message: { role: "user", text: "Stay concise." },
correlationId: "queued-comment-1",
});
transport.push("item/completed", {
kind: "steering_acknowledgement",
status: "acknowledged",
commandId: "runnerd-steer-command",
});
await new Promise((resolve) => setTimeout(resolve, 0));
await session.steer?.({
turnId,
message: { role: "user", text: "Stay concise." },
correlationId: "queued-comment-1",
});
expect(
transport.calls.find((call) => call.method === "turn/steer")?.params,
).toMatchObject({ correlationId: "queued-comment-1" });
expect(
transport.calls.filter((call) => call.method === "turn/steer"),
).toHaveLength(1);
transport.rejectMethods.set(
"turn/steer",
new Error("provider rejected steering"),
);
const rejectedSteering = await session
.steer?.({
turnId,
message: { role: "user", text: "Try this again later." },
correlationId: "queued-comment-2",
})
.then(
() => null,
(error: unknown) => error,
);
expect(rejectedSteering).toBeInstanceOf(Error);
expect(rejectedSteering).not.toBeInstanceOf(
HarnessCapabilityUnavailableError,
);
expect(String(rejectedSteering)).toContain("provider rejected steering");
transport.rejectMethods.delete("turn/steer");
await expect(
session.steer?.({
turnId: fixture.controls.staleTurnSteer.turnId!,
message: { role: "user", text: "Stale." },
}),
).rejects.toBeInstanceOf(HarnessStaleTurnError);
transport.push("thread/started", { thread: fixture.lineage.childThread });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(session.lineage?.()).toEqual(
expect.arrayContaining([
expect.objectContaining({
threadId: "thread-root",
parentThreadId: null,
depth: 0,
}),
expect.objectContaining({
threadId: "thread-child",
parentThreadId: "thread-root",
depth: 1,
nickname: "Scout",
role: "researcher",
}),
]),
);
await expect(
session.steer?.({
turnId: "thread-child",
message: { role: "user", text: "Do not emulate child steering." },
}),
).rejects.toBeInstanceOf(HarnessStaleTurnError);
const events = session.events()[Symbol.asyncIterator]();
const observed: PrpEvent[] = [];
// PRP v2 adds a capability and authoritative goal snapshot immediately
// after session start, so include those two events in this bounded read.
for (let index = 0; index < 11; index += 1) {
const next = await events.next();
if (next.done) break;
observed.push(next.value);
}
expect(observed).toEqual(
expect.arrayContaining([
expect.objectContaining({
eventType: "item.completed",
itemId: `${turnId}:steer:queued-comment-1`,
payload: expect.objectContaining({
kind: "steering_acknowledgement",
status: "acknowledged",
}),
}),
expect.objectContaining({
eventType: "harness.diagnostic",
payload: expect.objectContaining({ code: "stale_turn_rejected" }),
}),
expect.objectContaining({
eventType: "item.started",
payload: expect.objectContaining({ kind: "thread_lineage" }),
}),
]),
);
expect(observed.some((event) => event.eventType === "session.failed"))
.toBe(false);
await session.close({ reason: "fixture complete" });
});
it("queues an interrupt before turn identity and reports a terminal race precisely", async () => {
const transport = new FakeCodexTransport();
let releaseStart!: (value: Record<string, unknown>) => void;
transport.turnStartResponse = new Promise((resolve) => {
releaseStart = resolve;
});
const session = await makeDriver([transport]).openSession({
runId: "run-interrupt-races",
normalizedSessionId: "normalized-interrupt-races",
workingDirectory: WORKSPACE,
});
const starting = session.startTurn({
message: { role: "user", text: "Start slowly." },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await session.interrupt?.({ reason: "operator" });
releaseStart({ turn: { id: "turn-1", status: "inProgress", items: [] } });
await expect(starting).resolves.toEqual({
turnId: "turn-1",
effectiveCollaborationMode: "default",
});
expect(transport.calls.map(({ method }) => method)).toEqual(
expect.arrayContaining(["turn/start", "turn/interrupt"]),
);
transport.push("turn/completed", {
threadId: "thread-1",
turn: { id: "turn-1", status: "interrupted", items: [] },
});
await new Promise((resolve) => setTimeout(resolve, 0));
await expect(
session.interrupt?.({ turnId: "turn-1" }),
).rejects.toBeInstanceOf(HarnessOperationAlreadyTerminalError);
await session.close({ reason: "fixture complete" });
});
it("maps all goal operations and advertises an exact unsupported state", async () => {
const fixture = await loadLiveConsoleConformanceFixture(
liveConsoleFixturePath,
);
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-goals",
normalizedSessionId: "normalized-goals",
workingDirectory: WORKSPACE,
});
await session.goal?.({ action: "get" });
await session.goal?.({
action: "set",
objective: "Ship the Live console tracer",
tokenBudget: 4096,
});
await session.goal?.({ action: "pause" });
await session.goal?.({ action: "resume" });
await session.goal?.({ action: "clear" });
const goalCalls = transport.calls.filter(({ method }) =>
method.startsWith("thread/goal/"),
);
expect(
goalCalls.slice(1).map(({ method, params }) => ({
method,
params: Object.fromEntries(
Object.entries(params).filter(([key]) => key !== "threadId"),
),
})),
).toEqual(fixture.goals.map(({ method, params }) => ({ method, params })));
expect(
(await makeDriver([new FakeCodexTransport()]).descriptor()).capabilities,
).toMatchObject({ goals: true });
// Both denials a real app-server sends: the method is absent, and the
// build has the feature switched off.
for (const { denial, availability } of [
{ denial: new CodexRpcError(
'{"code":-32601,"message":"method not found"}', CODEX_METHOD_NOT_FOUND,
), availability: "unsupported" },
{ denial: new CodexRpcError(
'{"code":-32600,"message":"goals feature is disabled"}', CODEX_INVALID_REQUEST,
), availability: "policy_disabled" },
{ denial: new CodexRpcError(
'{"code":-32004,"message":"goals feature is disabled by policy"}', -32_004,
), availability: "policy_disabled" },
]) {
const unsupportedTransport = new FakeCodexTransport();
unsupportedTransport.rejectMethods.set("thread/goal/get", denial);
const unsupportedDriver = makeDriver([unsupportedTransport]);
const unsupported = await unsupportedDriver.openSession({
runId: "run-goals-unsupported",
normalizedSessionId: "normalized-goals-unsupported",
workingDirectory: WORKSPACE,
});
expect((await unsupportedDriver.descriptor()).capabilities).toMatchObject(
{ goals: false },
);
const unsupportedEvents = unsupported.events()[Symbol.asyncIterator]();
await unsupportedEvents.next();
await expect(unsupportedEvents.next()).resolves.toMatchObject({
value: { eventType: "session.capabilities.updated", payload: { sessionGoals: { availability } } },
});
await expect(
unsupported.goal?.({ action: "get" }),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
for (const action of ["set", "pause", "resume", "clear"] as const) {
await expect(
unsupported.goal?.(action === "set" ? { action, objective: "Must not reach the provider" } : { action }),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
}
expect(unsupportedTransport.calls.filter(({ method }) =>
method === "thread/goal/set" || method === "thread/goal/clear",
)).toEqual([]);
await unsupported.close({ reason: "fixture complete" });
}
await session.close({ reason: "fixture complete" });
});
it("keeps goal support advertised when the probe fails transiently", async () => {
// A transport or protocol failure is evidence about this call, not about
// what the provider implements, so it must not retire the capability.
for (const failure of [
new Error("codex app-server transport closed"),
new CodexRpcError('{"code":-32603,"message":"internal error"}', -32_603),
]) {
const transport = new FakeCodexTransport();
transport.rejectMethods.set("thread/goal/get", failure);
const driver = makeDriver([transport]);
const session = await driver.openSession({
runId: "run-goals-transient",
normalizedSessionId: "normalized-goals-transient",
workingDirectory: WORKSPACE,
});
expect((await driver.descriptor()).capabilities).toMatchObject({
goals: true,
});
// The capability survives, so the operation is still offered and the
// next call reaches the provider instead of failing closed locally.
transport.rejectMethods.delete("thread/goal/get");
await expect(session.goal?.({ action: "get" })).resolves.toBeNull();
expect(
transport.calls.filter(({ method }) => method === "thread/goal/get"),
).toHaveLength(2);
await session.close({ reason: "fixture complete" });
}
});
it.each([false, true])("requires trusted continuation metadata before omitting task context (%s)", async (continuation) => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport], {
skillInputs: [{ type: "skill", name: "first-task", path: "/skills/first-task/SKILL.md" }],
}).openSession({ runId: "run-delta", normalizedSessionId: "session-delta", workingDirectory: WORKSPACE });
const text = JSON.stringify({ schema: "paperclip.native-continuation.v1", events: '{"messages":[{"body":"Go ahead"}]}', completion: { revision: "2", criterionIds: ["comment"] } });
await session.startTurn({ message: { role: "user", text }, ...(continuation ? { continuation: true as const } : {}) });
const params = transport.calls.find((call) => call.method === "turn/start")!.params;
if (continuation) {
expect(params.input).toEqual([{ type: "text", text, text_elements: [] }]);
expect(JSON.stringify(params.input)).not.toContain("constraints");
expect(JSON.stringify(params.input)).not.toContain("first-task");
} else {
expect(JSON.stringify(params.input)).toContain("constraints");
expect(params.input).toContainEqual({ type: "skill", name: "first-task", path: "/skills/first-task/SKILL.md" });
}
await session.close({ reason: "test complete" });
});
it("sends the selected reasoning effort with the Codex turn", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport], { model: "gpt-6-astra", reasoningEffort: "ultra" }).openSession({
runId: "run-effort",
normalizedSessionId: "session-effort",
workingDirectory: WORKSPACE,
});
await session.startTurn({ message: { role: "user", text: "Continue" } });
expect(transport.calls.find((call) => call.method === "turn/start")?.params).toMatchObject({ effort: "ultra" });
await session.close({ reason: "test complete" });
});
it("uses negotiated ACP controls through the Codex transport facade", async () => {
let controls = { steering: false, queuedFollowUp: false };
const transport = Object.assign(new FakeCodexTransport(), { turnControlCapabilities: () => ({ ...controls }) });
const session = await makeDriver([transport], {
driverIdentity: { kind: "acpx_runtime", displayName: "Pi ACP", version: "test" },
capabilities: { steering: false },
}).openSession({ runId: "run-pi", normalizedSessionId: "session-pi", workingDirectory: WORKSPACE });
try {
expect(session.turnControlCapabilities?.()).toEqual(controls);
const { turnId } = await session.startTurn({ message: { role: "user", text: "Work" } });
await expect(session.steer?.({ turnId, message: { role: "user", text: "Change" } })).rejects.toThrow("not negotiated");
controls = { steering: true, queuedFollowUp: true };
expect(session.turnControlCapabilities?.()).toEqual(controls);
const input = { turnId, correlationId: "queued-1", mode: "follow_up" as const, message: { role: "user" as const, text: "Then validate" } };
await session.steer?.(input);
expect(transport.calls.find(call => call.method === "turn/steer")?.params).toMatchObject({ expectedTurnId: turnId, mode: "follow_up", correlationId: "queued-1" });
await expect(session.steer?.(input)).rejects.toThrow("already acknowledged");
expect(transport.calls.filter(call => call.method === "turn/steer")).toHaveLength(1);
} finally { await session.close({ reason: "verified" }); }
});
it("keeps native follow-up disabled for the Codex app-server driver", async () => {
const transport = Object.assign(new FakeCodexTransport(), { turnControlCapabilities: () => ({ steering: true, queuedFollowUp: true }) });
const session = await makeDriver([transport]).openSession({ runId: "run-codex", normalizedSessionId: "session-codex", workingDirectory: WORKSPACE });
try {
expect(session.turnControlCapabilities?.()).toEqual({ steering: true, queuedFollowUp: false });
const { turnId } = await session.startTurn({ message: { role: "user", text: "Work" } });
await expect(session.steer?.({ turnId, mode: "follow_up", message: { role: "user", text: "Queue" } })).rejects.toThrow("not negotiated");
expect(transport.calls.filter(call => call.method === "turn/steer")).toHaveLength(0);
} finally { await session.close({ reason: "verified" }); }
});
it("preserves an inactive goal status while editing its objective", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-goal-inactive-edit",
normalizedSessionId: "normalized-goal-inactive-edit",
workingDirectory: WORKSPACE,
});
await session.goal?.({
action: "set",
objective: "Updated while paused",
status: "paused",
});
expect(
transport.calls.filter(({ method }) => method === "thread/goal/set"),
).toContainEqual({
method: "thread/goal/set",
params: {
threadId: "thread-1",
objective: "Updated while paused",
status: "paused",
},
});
await session.close({ reason: "fixture complete" });
});
it("rolls back only a definitely rejected idle goal autostart", async () => {
const definiteTransport = new FakeCodexTransport();
const definiteSession = await makeDriver([definiteTransport]).openSession({
runId: "run-goal-autostart-definite-rejection",
normalizedSessionId: "normalized-goal-autostart-definite-rejection",
workingDirectory: WORKSPACE,
});
definiteTransport.rejectMethods.set(
"thread/goal/set",
new CodexRpcError('{"code":-32603,"message":"internal error"}', -32_603),
);
await expect(
definiteSession.goal?.({
action: "set",
objective: "Rejected before a turn starts",
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
definiteTransport.rejectMethods.delete("thread/goal/set");
await expect(
definiteSession.startTurn({
message: { role: "user", text: "A normal turn may still start." },
}),
).resolves.toMatchObject({ turnId: "turn-1" });
await definiteSession.close({ reason: "fixture complete" });
const ambiguousTransport = new FakeCodexTransport();
const ambiguousSession = await makeDriver([ambiguousTransport]).openSession({
runId: "run-goal-autostart-ambiguous",
normalizedSessionId: "normalized-goal-autostart-ambiguous",
workingDirectory: WORKSPACE,
});
ambiguousTransport.rejectMethods.set(
"thread/goal/set",
new Error("codex app-server transport closed"),
);
await expect(
ambiguousSession.goal?.({
action: "set",
objective: "The provider may have started this goal",
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
ambiguousTransport.rejectMethods.delete("thread/goal/set");
await expect(
ambiguousSession.startTurn({
message: { role: "user", text: "Do not create competing work." },
}),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
await ambiguousSession.close({ reason: "fixture complete" });
});
it("preserves a provider-neutral goal action subset through the Codex transport facade", async () => {
const transport = new FakeCodexTransport();
const driver = makeDriver([transport], {
goalCapability: {
actions: ["set", "clear"],
autonomousUpdates: true,
persistentAcrossResume: true,
maxObjectiveChars: 4_000,
tokenBudgetControl: false,
usageReporting: true,
},
});
const session = await driver.openSession({
runId: "run-goals-action-subset",
normalizedSessionId: "normalized-goals-action-subset",
workingDirectory: WORKSPACE,
});
const events = session.events()[Symbol.asyncIterator]();
await events.next();
await expect(events.next()).resolves.toMatchObject({
value: {
eventType: "session.capabilities.updated",
payload: {
sessionGoals: {
availability: "available",
actions: ["set", "clear"],
tokenBudgetControl: false,
},
},
},
});
await expect(
session.goal?.({ action: "pause" }),
).rejects.toBeInstanceOf(HarnessCapabilityUnavailableError);
expect(
transport.calls.filter(({ method }) => method === "thread/goal/set"),
).toHaveLength(0);
await expect(
session.goal?.({ action: "set", objective: "Finish the task" }),
).resolves.toMatchObject({ objective: "Finish the task" });
await expect(session.goal?.({ action: "clear" })).resolves.toBeNull();
await session.close({ reason: "fixture complete" });
});
});