fix(runner): isolate Codex credentials and hostile output

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-08 02:07:33 +00:00
1 parent 6391608e8f
commit 8d4ffe7f2b
10 files changed
+9224 -5498

No files matched your search

@@ -60,12 +60,19 @@ instruction blocks. The model input accepts only text, never a Codex `skill`
input. The driver captures the returned instruction-source list and requires it
to be empty for the skillless assertion.
The child process environment is allowlisted. It may receive only local runtime
keys such as `PATH`, `HOME`, `CODEX_HOME`, locale, temporary-directory, trusted
certificate, and credential-free proxy settings. Paperclip bearer values,
`OPENAI_API_KEY`, arbitrary skill paths, and other inherited variables are not
passed. Diagnostics redact bearer/basic credentials, credentialed proxy URLs,
secret query parameters, sensitive JSON keys, and common key assignments.
The trusted app-server process has an allowlisted environment. It retains host
`HOME` and `CODEX_HOME` only so the provider can authenticate. Model-issued
commands have a separate boundary: an empty-by-default environment with no
`HOME` or `CODEX_HOME`, no network, and a named Codex
permission profile with read-only minimal runtime files, write access only to
the assigned workspace, and no access to the rest of the filesystem. The
driver refuses filesystem-root workspaces, workspaces containing host `HOME`,
and any workspace overlapping host `CODEX_HOME`.
Paperclip bearer values, `OPENAI_API_KEY`, arbitrary skill paths, and other
inherited variables are not passed. Diagnostics redact bearer/basic
credentials, credentialed proxy URLs, secret query parameters, sensitive JSON
keys, and common key assignments.
The context snapshot records configuration and environment **key names**, not
secret values.
@@ -83,10 +90,17 @@ Two dynamic semantic tools are registered when supported:
reason, and scope.
Both normalize through the canonical `paperclip.run_result.v1` validator. The
first valid result is committed. Repeating the same result is idempotent; a
different later result is rejected. A completed turn with no valid semantic
result produces one explicit `needs_review` result and one failed terminal
event. Process exit or prose alone never implies completion.
first valid result is proposed. An exact retry from the same call is
idempotent; a different call or result is rejected. Tool calls and provider
notifications must name the exact opened thread and active turn. Missing,
pre-turn, cross-thread, cross-turn, and post-terminal bindings fail the provider
session closed. Duplicate or conflicting terminal facts are rejected. Process
exit or prose alone never implies completion.
The provider cannot commit controller state. It emits `run.result.proposed` and
a provider turn terminal; the mock core validates the proposal against its
task envelope, emits `run.result.accepted` or `run.result.rejected`, and alone
emits `run.terminal`.
## Canonical event mapping
@@ -101,17 +115,29 @@ event. Process exit or prose alone never implies completion.
`runtime_request.resolved` with redacted detail;
- token snapshots -> completed `usage` items;
- semantic verification rows -> completed `verification` items;
- completion -> one `run.result.proposed` and one `run.terminal`.
- provider completion -> at most one `run.result.proposed` and one turn
terminal;
- controller decision -> one `run.result.accepted` or `run.result.rejected`,
followed by one `run.terminal`.
The existing Phase 1 reducer consumes the live stream and the replay stream.
The Phase 4 tracer requires byte-equivalent snapshots, contiguous source
sequences, stable item IDs, one result, and one terminal event.
The JSON-RPC transport limits each input line, pending client requests,
in-flight server requests, queued notification count and bytes, diagnostic
lines, and retained provider payloads. Malformed or oversized messages close
the transport and reject pending work.
The existing Phase 1 reducer consumes the live stream. Replay crosses a
serialized JSONL boundary that validates byte and event counts, line size,
schema, run/session binding, unique source event IDs, continuous per-source
sequence, and exactly one final run terminal before reducing. The Phase 4
tracer requires byte-equivalent live and replay snapshots.
## Runnable example
`trace:phase4` starts a real local `codex app-server` session through the mock
core. Its safe task can create only `hello.txt`, disables network access, and
checks the exact file contents. See the
core. Its safe task creates `hello.txt` with network disabled. The evidence
recorder additionally probes a readable host Codex credential/config path and
an unrelated host secret, requiring both reads and writes to be denied while
workspace output and app-server authentication still succeed. See the
[Phase 4 tutorial](tutorials/phase-04-skillless-codex.md).
This phase changes no browser surface, so no new browser screenshot applies.
@@ -34,6 +34,7 @@ codex app-server --help
```sh
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
src/drivers/codex/app-server-transport.test.ts \
src/drivers/codex/codex-app-server-driver.test.ts
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
@@ -91,6 +92,10 @@ Confirm:
- `modelInputKinds` contains only `text`;
- the semantic tools are `paperclip_finish` and `paperclip_block`;
- no environment **value** is present;
- the permission profile denies root access, exposes only read-only minimal
runtime files, grants write access to the assigned workspace, and disables
network access;
- command `HOME` and `CODEX_HOME` are absent;
- the envelope contains only this safe task and its completion criteria.
Check that no control-plane route or bearer credential appears:
@@ -122,6 +127,17 @@ The focused test suite supplies deterministic file-change and runtime-request
events because a real model may choose a shell command instead of a file patch
and may not need human input for this small task.
To record the authenticated isolation proof, run:
```sh
pnpm --filter @paperclipai/paperclip-runner record:phase4
```
The recorder selects an existing readable host Codex credential/config path,
creates a separate host secret, and asks the model to prove that neither is
readable or writable. The run must still authenticate, create the workspace
files, retain the successful command output, and pass every trace assertion.
## Step 5: Try steering
Use a new directory:
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -12,6 +13,32 @@ const scratchRoot =
tmpdir();
await mkdir(scratchRoot, { recursive: true });
const workspace = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-"));
const hostileHostDirectory = await mkdtemp(join(scratchRoot, "paperclip-runner-phase4-host-"));
const unrelatedSecretPath = join(hostileHostDirectory, "unrelated-secret.txt");
await writeFile(unrelatedSecretPath, "must remain outside the model sandbox", { mode: 0o600 });
const hostHome = process.env.HOME;
if (!hostHome) throw new Error("HOME is required to locate the host Codex configuration");
const codexHome = resolve(process.env.CODEX_HOME ?? join(hostHome, ".codex"));
const credentialCandidates = [join(codexHome, "auth.json"), join(codexHome, "config.toml"), codexHome];
let codexCredentialPath;
for (const candidate of credentialCandidates) {
try {
await access(candidate, constants.R_OK);
codexCredentialPath = candidate;
break;
} catch {
// Keep looking for a host path that proves the former read path was reachable.
}
}
if (!codexCredentialPath) {
throw new Error("No readable host Codex credential/config path was available for the isolation probe");
}
const deniedHostPaths = [...new Set([
resolve(hostHome),
codexHome,
codexCredentialPath,
unrelatedSecretPath,
])];
const rawTracePath = join(workspace, "trace.raw.json");
const evidencePath = join(
packageRoot,
@@ -27,8 +54,13 @@ try {
workspace,
"--output",
rawTracePath,
...deniedHostPaths.flatMap((path) => ["--deny-read-write-path", path]),
],
{ cwd: packageRoot, env: process.env, stdio: "inherit" },
{
cwd: packageRoot,
env: { ...process.env, PAPERCLIP_WORKSPACE_CWD: workspace },
stdio: "inherit",
},
);
if (run.status !== 0) {
throw new Error(`Phase 4 tracer exited with status ${run.status ?? "unknown"}`);
@@ -42,8 +74,35 @@ try {
if (hello !== "hello from phase 4") {
throw new Error("Phase 4 safe task produced unexpected hello.txt content");
}
const isolationProof = await readFile(join(workspace, "isolation-ok.txt"), "utf8");
if (isolationProof !== "isolation enforced") {
throw new Error("Phase 4 isolation probe did not produce the expected proof file");
}
const commandObserved = trace.events?.some((event) =>
event.eventType === "item.started" &&
event.payload?.kind === "commandExecution" &&
deniedHostPaths.every((path) => JSON.stringify(event.payload).includes(path))
);
const proofOutputObserved = trace.events?.some((event) =>
(event.eventType === "item.delta" || event.eventType === "item.completed") &&
event.payload?.kind === "commandExecution" &&
(
String(event.payload?.text).includes("isolation enforced") ||
String(event.payload?.item?.aggregatedOutput).includes("isolation enforced")
)
);
if (!commandObserved || !proofOutputObserved) {
throw new Error("Phase 4 trace did not retain the successful hostile-path isolation command");
}
const replacements = [[workspace, "$PHASE4_WORKSPACE"]];
const replacements = [
[workspace, "$PHASE4_WORKSPACE"],
[codexCredentialPath, "$HOST_CODEX_CREDENTIAL_PATH"],
[codexHome, "$HOST_CODEX_HOME"],
[unrelatedSecretPath, "$HOST_UNRELATED_SECRET"],
[hostileHostDirectory, "$HOST_SECRET_DIRECTORY"],
[resolve(hostHome), "$HOST_HOME"],
];
const writableRoots = trace.context?.sandbox?.writableRoots;
if (Array.isArray(writableRoots)) {
writableRoots.forEach((root, index) => {
@@ -57,6 +116,7 @@ try {
process.stdout.write(`Recorded ${evidencePath}\n`);
} finally {
await rm(workspace, { recursive: true, force: true });
await rm(hostileHostDirectory, { recursive: true, force: true });
}
function normalizeStrings(value, replacements) {
@@ -13,10 +13,11 @@ interface CliOptions {
steer?: string;
interrupt: boolean;
json: boolean;
denyReadWritePaths: string[];
}
function parseArgs(args: string[]): CliOptions {
const options: CliOptions = { interrupt: false, json: false };
const options: CliOptions = { interrupt: false, json: false, denyReadWritePaths: [] };
for (let index = 0; index < args.length; index += 1) {
const value = args[index];
if (value === "--") continue;
@@ -25,11 +26,22 @@ function parseArgs(args: string[]): CliOptions {
else if (value === "--working-directory") options.workingDirectory = args[++index];
else if (value === "--output") options.output = args[++index];
else if (value === "--steer") options.steer = args[++index];
else if (value === "--deny-read-write-path") {
const path = args[++index];
if (path === undefined || path.trim().length === 0) {
throw new Error("--deny-read-write-path requires a path");
}
options.denyReadWritePaths.push(resolve(path));
}
else throw new Error(`Unknown argument: ${value}`);
}
return options;
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function formatSummary(trace: Awaited<ReturnType<typeof runPhase4CodexTracer>>): string {
const checks = Object.entries(trace.assertions)
.map(([name, passed]) => `${passed ? "PASS" : "FAIL"} ${name}`)
@@ -53,18 +65,44 @@ async function main(): Promise<void> {
? resolve(options.workingDirectory)
: await mkdtemp(join(tmpdir(), "paperclip-phase4-safe-"));
await mkdir(workingDirectory, { recursive: true });
const isolationCommand = options.denyReadWritePaths.length === 0
? null
: [
...options.denyReadWritePaths.flatMap((path) => [
`test ! -r ${shellQuote(path)}`,
`test ! -w ${shellQuote(path)}`,
]),
"test -z \"${CODEX_HOME+x}\"",
"test -z \"${HOME+x}\"",
"printf 'isolation enforced' > isolation-ok.txt",
"printf 'isolation enforced\\n'",
].join(" && ");
const envelope = createPhase4TaskEnvelope({
objective: "Create hello.txt in the working directory with exactly: hello from phase 4",
objective: isolationCommand === null
? "Create hello.txt in the working directory with exactly: hello from phase 4"
: "Prove filesystem isolation, then create the requested files in the working directory.",
criteria: [
{ id: "file-created", requirement: "hello.txt exists in the supplied working directory." },
{ id: "exact-content", requirement: "hello.txt contains exactly 'hello from phase 4'." },
...(isolationCommand === null
? []
: [{
id: "filesystem-isolated",
requirement:
"Run the supplied isolation command exactly; it must print 'isolation enforced' and create isolation-ok.txt with that exact content.",
}]),
],
constraints: [
"Write only hello.txt inside the supplied working directory.",
isolationCommand === null
? "Write only hello.txt inside the supplied working directory."
: "Write only hello.txt and isolation-ok.txt inside the supplied working directory.",
"Do not use network access.",
"Do not discover or invoke skills.",
"Do not call a control-plane API.",
"Return one semantic completion result.",
...(isolationCommand === null
? []
: [`Run this exact shell command before all other work: ${isolationCommand}`]),
...(options.steer !== undefined || options.interrupt
? ["Before changing files, run the shell command 'sleep 3' once so the operator can send a control command."]
: []),
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { ProcessCodexAppServerTransport } from "./app-server-transport.js";
function nodeTransport(
source: string,
options: ConstructorParameters<typeof ProcessCodexAppServerTransport>[0] = {},
) {
return new ProcessCodexAppServerTransport({
...options,
command: process.execPath,
args: ["-e", source],
environment: { PATH: process.env.PATH },
});
}
describe("Codex app-server transport limits", () => {
it("rejects an oversized line before buffering the complete hostile payload", async () => {
const diagnostics: string[] = [];
const transport = nodeTransport(
"setTimeout(() => process.stdout.write('x'.repeat(1048576)), 50); setInterval(() => {}, 1000)",
{ maxLineBytes: 128, onDiagnostic: (message) => diagnostics.push(message) },
);
await expect(transport.notifications()[Symbol.asyncIterator]().next()).rejects.toThrow(
"codex app-server line exceeded 128 bytes",
);
expect(diagnostics).toEqual(["codex app-server line exceeded 128 bytes"]);
await transport.close();
});
it("bounds pending requests", async () => {
const transport = nodeTransport("process.stdin.resume()", { maxPendingRequests: 1 });
const first = transport.request("first", {});
await expect(transport.request("second", {})).rejects.toThrow(
"codex app-server pending request limit 1 exceeded",
);
await transport.close();
await expect(first).rejects.toThrow("codex app-server transport closed");
});
it("fails closed when queued notifications exceed their count bound", async () => {
let transport: ProcessCodexAppServerTransport;
const diagnostic = new Promise<string>((resolve) => {
const lines = [1, 2].map((id) => JSON.stringify({
method: "item/completed",
params: { threadId: "thread-1", turnId: "turn-1", item: { id } },
})).join("\n");
transport = nodeTransport(
`setTimeout(() => process.stdout.write(${JSON.stringify(`${lines}\n`)}), 50); setInterval(() => {}, 1000)`,
{
maxQueuedNotifications: 1,
maxQueuedNotificationBytes: 1024,
onDiagnostic: (message) => resolve(message),
},
);
});
await expect(diagnostic).resolves.toBe("codex app-server notification queue limit exceeded");
await transport!.close();
});
it("rejects malformed JSON-RPC messages", async () => {
const transport = nodeTransport(
"setTimeout(() => process.stdout.write('{not-json}\\n'), 50); setInterval(() => {}, 1000)",
);
await expect(transport.request("pending", {})).rejects.toThrow(
"codex app-server emitted malformed JSON",
);
await transport.close();
});
});
@@ -1,5 +1,4 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createInterface } from "node:readline";
export interface CodexRpcNotification {
method: string;
@@ -22,35 +21,117 @@ export interface CodexAppServerTransport {
close(): Promise<void>;
}
class AsyncQueue<T> implements AsyncIterable<T> {
#values: T[] = [];
#waiters: Array<(value: IteratorResult<T>) => void> = [];
class BoundedAsyncQueue<T> implements AsyncIterable<T> {
#values: Array<{ value: T; bytes: number }> = [];
#waiters: Array<{
resolve: (value: IteratorResult<T>) => void;
reject: (error: Error) => void;
}> = [];
#queuedBytes = 0;
#closed = false;
#error: Error | null = null;
push(value: T): void {
if (this.#closed) return;
constructor(
private readonly maxValues: number,
private readonly maxBytes: number,
) {}
push(value: T, bytes: number): boolean {
if (this.#closed) return false;
const waiter = this.#waiters.shift();
if (waiter === undefined) this.#values.push(value);
else waiter({ value, done: false });
if (waiter !== undefined) {
waiter.resolve({ value, done: false });
return true;
}
if (this.#values.length >= this.maxValues || this.#queuedBytes + bytes > this.maxBytes) {
return false;
}
this.#values.push({ value, bytes });
this.#queuedBytes += bytes;
return true;
}
close(): void {
close(error?: Error): void {
if (this.#closed) return;
this.#closed = true;
for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
this.#values = [];
this.#queuedBytes = 0;
this.#error = error ?? null;
for (const waiter of this.#waiters.splice(0)) {
if (error === undefined) waiter.resolve({ value: undefined, done: true });
else waiter.reject(error);
}
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: async () => {
const value = this.#values.shift();
if (value !== undefined) return { value, done: false };
const queued = this.#values.shift();
if (queued !== undefined) {
this.#queuedBytes -= queued.bytes;
return { value: queued.value, done: false };
}
if (this.#error !== null) return Promise.reject(this.#error);
if (this.#closed) return { value: undefined, done: true };
return new Promise<IteratorResult<T>>((resolve) => this.#waiters.push(resolve));
return new Promise<IteratorResult<T>>((resolve, reject) => {
this.#waiters.push({ resolve, reject });
});
},
};
}
}
class BoundedLineDecoder {
#buffer = Buffer.alloc(0);
#closed = false;
constructor(
private readonly maxLineBytes: number,
private readonly onLine: (line: string, bytes: number) => void,
private readonly onError: (error: Error) => void,
) {}
write(chunk: Buffer | string): void {
if (this.#closed) return;
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
let offset = 0;
while (offset < incoming.length) {
const newline = incoming.indexOf(0x0a, offset);
const end = newline < 0 ? incoming.length : newline;
const segment = incoming.subarray(offset, end);
if (this.#buffer.length + segment.length > this.maxLineBytes) {
this.#fail(new Error(`codex app-server line exceeded ${this.maxLineBytes} bytes`));
return;
}
if (segment.length > 0) this.#buffer = Buffer.concat([this.#buffer, segment]);
if (newline < 0) return;
const line = this.#buffer;
this.#buffer = Buffer.alloc(0);
this.onLine(line.toString("utf8"), line.length);
if (this.#closed) return;
offset = newline + 1;
}
}
end(): void {
if (this.#closed) return;
if (this.#buffer.length > 0) {
this.#fail(new Error("codex app-server ended with an unterminated JSON-RPC line"));
}
this.#closed = true;
}
close(): void {
this.#closed = true;
this.#buffer = Buffer.alloc(0);
}
#fail(error: Error): void {
this.close();
this.onError(error);
}
}
const SAFE_ENVIRONMENT_KEYS = [
"ALL_PROXY",
"CODEX_HOME",
@@ -71,6 +152,11 @@ const SAFE_ENVIRONMENT_KEYS = [
"WINDIR",
] as const;
/**
* Environment for the trusted app-server process itself. HOME and CODEX_HOME
* are retained so Codex can authenticate; model-issued commands receive a
* separate empty-by-default environment and filesystem permission profile.
*/
export function createSanitizedCodexEnvironment(
source: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
@@ -119,41 +205,94 @@ export interface ProcessCodexTransportOptions {
args?: string[];
environment?: NodeJS.ProcessEnv;
onDiagnostic?: (message: string) => void;
maxLineBytes?: number;
maxPendingRequests?: number;
maxQueuedNotifications?: number;
maxQueuedNotificationBytes?: number;
maxInflightServerRequests?: number;
}
const DEFAULT_MAX_LINE_BYTES = 1024 * 1024;
const DEFAULT_MAX_PENDING_REQUESTS = 64;
const DEFAULT_MAX_QUEUED_NOTIFICATIONS = 256;
const DEFAULT_MAX_QUEUED_NOTIFICATION_BYTES = 4 * 1024 * 1024;
const DEFAULT_MAX_INFLIGHT_SERVER_REQUESTS = 32;
const MAX_DIAGNOSTIC_LINE_BYTES = 16 * 1024;
/** Local stdio JSON-RPC transport. App-server never becomes a WAN endpoint. */
export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
#process: ChildProcessWithoutNullStreams;
#notifications = new AsyncQueue<CodexRpcNotification>();
#notifications: BoundedAsyncQueue<CodexRpcNotification>;
#pending = new Map<number, PendingRequest>();
#nextId = 1;
#serverRequestHandler: CodexServerRequestHandler = async () => ({
success: false,
contentItems: [{ type: "input_text", text: "Unsupported client request." }],
});
#inflightServerRequests = 0;
#closed = false;
#onDiagnostic?: (message: string) => void;
readonly #maxLineBytes: number;
readonly #maxPendingRequests: number;
readonly #maxInflightServerRequests: number;
readonly #stdoutDecoder: BoundedLineDecoder;
readonly #stderrDecoder: BoundedLineDecoder;
constructor(options: ProcessCodexTransportOptions = {}) {
this.#onDiagnostic = options.onDiagnostic;
this.#maxLineBytes = positiveLimit(options.maxLineBytes, DEFAULT_MAX_LINE_BYTES);
this.#maxPendingRequests = positiveLimit(
options.maxPendingRequests,
DEFAULT_MAX_PENDING_REQUESTS,
);
this.#maxInflightServerRequests = positiveLimit(
options.maxInflightServerRequests,
DEFAULT_MAX_INFLIGHT_SERVER_REQUESTS,
);
this.#notifications = new BoundedAsyncQueue(
positiveLimit(options.maxQueuedNotifications, DEFAULT_MAX_QUEUED_NOTIFICATIONS),
positiveLimit(
options.maxQueuedNotificationBytes,
DEFAULT_MAX_QUEUED_NOTIFICATION_BYTES,
),
);
this.#process = spawn(options.command ?? "codex", options.args ?? ["app-server"], {
env: options.environment ?? createSanitizedCodexEnvironment(),
stdio: "pipe",
});
createInterface({ input: this.#process.stdout }).on("line", (line) => this.#onLine(line));
createInterface({ input: this.#process.stderr }).on("line", (line) => {
this.#onDiagnostic?.(redactCodexDiagnostic(line));
});
this.#process.on("error", (error) => this.#failAll(error));
this.#stdoutDecoder = new BoundedLineDecoder(
this.#maxLineBytes,
(line, bytes) => this.#onLine(line, bytes),
(error) => this.#fatal(error),
);
this.#stderrDecoder = new BoundedLineDecoder(
MAX_DIAGNOSTIC_LINE_BYTES,
(line) => this.#onDiagnostic?.(redactCodexDiagnostic(line)),
() => this.#onDiagnostic?.("codex app-server diagnostic line exceeded retention limit"),
);
this.#process.stdout.on("data", (chunk: Buffer) => this.#stdoutDecoder.write(chunk));
this.#process.stdout.on("end", () => this.#stdoutDecoder.end());
this.#process.stderr.on("data", (chunk: Buffer) => this.#stderrDecoder.write(chunk));
this.#process.stderr.on("end", () => this.#stderrDecoder.end());
this.#process.on("error", (error) => this.#fatal(error));
this.#process.on("exit", (code, signal) => {
if (!this.#closed) {
this.#failAll(new Error(`codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "none"})`));
this.#fatal(
new Error(
`codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "none"})`,
),
);
}
this.#notifications.close();
});
}
request(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
if (this.#closed) return Promise.reject(new Error("codex app-server transport is closed"));
if (this.#pending.size >= this.#maxPendingRequests) {
return Promise.reject(
new Error(`codex app-server pending request limit ${this.#maxPendingRequests} exceeded`),
);
}
const id = this.#nextId++;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
@@ -181,6 +320,8 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#stdoutDecoder.close();
this.#stderrDecoder.close();
this.#process.kill("SIGTERM");
this.#notifications.close();
this.#failAll(new Error("codex app-server transport closed"));
@@ -188,45 +329,107 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
#write(message: unknown): void {
if (this.#closed) throw new Error("codex app-server transport is closed");
this.#process.stdin.write(`${JSON.stringify(message)}\n`);
const serialized = JSON.stringify(message);
if (Buffer.byteLength(serialized) > this.#maxLineBytes) {
throw new Error(`outbound codex JSON-RPC line exceeded ${this.#maxLineBytes} bytes`);
}
this.#process.stdin.write(`${serialized}\n`);
}
#onLine(line: string): void {
#onLine(line: string, bytes: number): void {
let value: unknown;
try {
value = JSON.parse(line);
} catch {
this.#onDiagnostic?.("codex app-server emitted malformed JSON");
this.#fatal(new Error("codex app-server emitted malformed JSON"));
return;
}
if (typeof value !== "object" || value === null) return;
const message = value as Record<string, unknown>;
if (typeof message.id === "number" && ("result" in message || "error" in message)) {
if (!isRecord(value)) {
this.#fatal(new Error("codex app-server emitted a non-object JSON-RPC message"));
return;
}
const message = value;
const hasResult = Object.hasOwn(message, "result");
const hasError = Object.hasOwn(message, "error");
if (typeof message.id === "number" && (hasResult || hasError)) {
if (!Number.isSafeInteger(message.id) || message.id <= 0 || hasResult === hasError) {
this.#fatal(new Error("codex app-server emitted a malformed JSON-RPC response"));
return;
}
const pending = this.#pending.get(message.id);
if (pending === undefined) return;
if (pending === undefined) {
this.#fatal(new Error("codex app-server responded to an unknown request id"));
return;
}
this.#pending.delete(message.id);
if (message.error !== undefined) {
pending.reject(new Error(redactCodexDiagnostic(JSON.stringify(message.error))));
if (hasError) {
pending.reject(new Error(redactCodexDiagnostic(boundedJson(message.error))));
} else if (isRecord(message.result)) {
pending.resolve(message.result);
} else {
pending.resolve(asRecord(message.result));
const error = new Error("codex app-server response result was not an object");
pending.reject(error);
this.#fatal(error);
}
return;
}
if (typeof message.method !== "string") return;
const params = asRecord(message.params);
if ((typeof message.id === "string" || typeof message.id === "number") && "id" in message) {
const request: CodexRpcServerRequest = { id: message.id, method: message.method, params };
void this.#serverRequestHandler(request).then(
(result) => this.#write({ id: request.id, result }),
(error: unknown) =>
if (typeof message.method !== "string" || message.method.length === 0 || hasResult || hasError) {
this.#fatal(new Error("codex app-server emitted a malformed JSON-RPC message"));
return;
}
if (message.params !== undefined && !isRecord(message.params)) {
this.#fatal(new Error("codex app-server message params were not an object"));
return;
}
const params = message.params ?? {};
if (message.id !== undefined) {
if (!isValidServerRequestId(message.id)) {
this.#fatal(new Error("codex app-server request id had an invalid type"));
return;
}
this.#handleServerRequest({ id: message.id, method: message.method, params });
return;
}
if (!this.#notifications.push({ method: message.method, params }, bytes)) {
this.#fatal(new Error("codex app-server notification queue limit exceeded"));
}
}
#handleServerRequest(request: CodexRpcServerRequest): void {
if (this.#inflightServerRequests >= this.#maxInflightServerRequests) {
this.#write({
id: request.id,
error: { code: -32_001, message: "Client request queue limit exceeded." },
});
return;
}
this.#inflightServerRequests += 1;
void this.#serverRequestHandler(request).then(
(result) => {
if (!this.#closed) this.#write({ id: request.id, result });
},
(error: unknown) => {
if (!this.#closed) {
this.#write({
id: request.id,
error: { code: -32_000, message: redactCodexDiagnostic(String(error)) },
}),
);
return;
}
this.#notifications.push({ method: message.method, params });
});
}
},
).finally(() => {
this.#inflightServerRequests -= 1;
});
}
#fatal(error: Error): void {
if (this.#closed) return;
this.#closed = true;
this.#stdoutDecoder.close();
this.#stderrDecoder.close();
this.#process.kill("SIGKILL");
this.#notifications.close(error);
this.#failAll(error);
this.#onDiagnostic?.(redactCodexDiagnostic(error.message));
}
#failAll(error: Error): void {
@@ -235,8 +438,24 @@ export class ProcessCodexAppServerTransport implements CodexAppServerTransport {
}
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function positiveLimit(value: number | undefined, fallback: number): number {
return Number.isSafeInteger(value) && (value ?? 0) > 0 ? value! : fallback;
}
function isValidServerRequestId(value: unknown): value is string | number {
return (
(typeof value === "number" && Number.isSafeInteger(value)) ||
(typeof value === "string" && value.length > 0 && Buffer.byteLength(value) <= 256)
);
}
function boundedJson(value: unknown): string {
const serialized = JSON.stringify(value) ?? String(value);
return serialized.length <= MAX_DIAGNOSTIC_LINE_BYTES
? serialized
: `${serialized.slice(0, MAX_DIAGNOSTIC_LINE_BYTES)}...[truncated]`;
}
@@ -19,9 +19,11 @@ import {
} from "../../protocol/phase1-contract.js";
import {
CodexAppServerDriver,
createIsolatedCodexAppServerArgs,
} from "./codex-app-server-driver.js";
import {
runPhase4CodexTracer,
replayPersistedPhase4Events,
validatePhase4ResultProposal,
} from "../../mock-core/phase4-codex-runner.js";
import type {
@@ -87,6 +89,7 @@ class FakeCodexTransport implements CodexAppServerTransport {
modelProvider: "openai",
cwd: "/workspace",
turns: [],
activePermissionProfile: { id: "paperclip-runner-workspace-only" },
},
model: "gpt-test",
modelProvider: "openai",
@@ -98,7 +101,7 @@ class FakeCodexTransport implements CodexAppServerTransport {
}
if (method === "turn/start") return { turn: { id: "turn-1", status: "inProgress", items: [] } };
if (method === "thread/read") {
return this.readResponse ?? { thread: { id: this.threadId, sessionId: this.providerSessionId, turns: [{ id: "turn-1", status: "inProgress", items: [] }] } };
return this.readResponse ?? { thread: { id: this.threadId, sessionId: this.providerSessionId, cwd: "/workspace", turns: [{ id: "turn-1", status: "inProgress", items: [] }] } };
}
return {};
}
@@ -300,7 +303,7 @@ describe("Codex app-server Phase 4 driver", () => {
appInstructions: false,
collaborationInstructions: false,
},
environmentKeys: ["CODEX_HOME", "HOME", "LANG", "PATH"],
environmentKeys: ["LANG", "PATH"],
envelope,
});
expect(isSkilllessPhase4Context(context)).toBe(true);
@@ -313,8 +316,35 @@ describe("Codex app-server Phase 4 driver", () => {
include_apps_instructions: false,
include_collaboration_mode_instructions: false,
},
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(PHASE4_RESULT_OUTPUT_SCHEMA.properties.schema).toEqual({
type: "string",
const: "paperclip.run_result.v1",
@@ -325,6 +355,24 @@ describe("Codex app-server Phase 4 driver", () => {
});
});
it("refuses to turn host credential roots into model-writable workspaces", async () => {
await expect(makeDriver([]).openSession({
runId: "run-codex-home",
normalizedSessionId: "normalized-codex-home",
workingDirectory: "/isolated/codex",
})).rejects.toThrow("cannot overlap host CODEX_HOME");
await expect(makeDriver([]).openSession({
runId: "run-host-home",
normalizedSessionId: "normalized-host-home",
workingDirectory: "/isolated",
})).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" });
@@ -358,6 +406,144 @@ describe("Codex app-server Phase 4 driver", () => {
expect(events.filter((event) => event.eventType === "turn.completed")).toHaveLength(1);
});
it.each([
{ threadId: "other-thread", turnId: "turn-1", label: "another thread" },
{ threadId: "thread-1", turnId: "other-turn", label: "another turn" },
])("rejects a semantic result for $label without committing it", async ({ threadId, turnId }) => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-hostile-tool",
normalizedSessionId: "normalized-hostile-tool",
workingDirectory: "/workspace",
});
await session.startTurn({ message: { role: "user", text: "Complete." } });
expect(await transport.invoke({
id: "hostile-call",
method: "item/tool/call",
params: { threadId, turnId, callId: "hostile-call", tool: "paperclip_finish", arguments: result },
})).toMatchObject({ success: false });
const events = await collectUntilTerminal(session.events());
expect(events.some((event) => event.eventType === "run.result.proposed")).toBe(false);
expect(events.find((event) => event.eventType === "session.failed")?.payload)
.toMatchObject({ code: "tool_binding_mismatch", recoverable: false });
expect(events.find((event) => event.eventType === "turn.failed")?.turnId).toBe("turn-1");
});
it("rejects pre-turn, cross-thread, and post-terminal notifications", async () => {
const preTurnTransport = new FakeCodexTransport();
const preTurn = await makeDriver([preTurnTransport]).openSession({
runId: "run-pre-turn",
normalizedSessionId: "normalized-pre-turn",
workingDirectory: "/workspace",
});
preTurnTransport.push("item/completed", {
threadId: "thread-1",
turnId: "turn-1",
item: { id: "answer-pre", type: "agentMessage", text: JSON.stringify(result) },
});
const preTurnEvents: PrpEvent[] = [];
for await (const event of preTurn.events()) preTurnEvents.push(event);
expect(preTurnEvents.some((event) => event.eventType === "run.result.proposed")).toBe(false);
expect(preTurnEvents.find((event) => event.eventType === "session.failed")?.payload)
.toMatchObject({ code: "turn_binding_mismatch" });
const crossThreadTransport = new FakeCodexTransport();
const crossThread = await makeDriver([crossThreadTransport]).openSession({
runId: "run-cross-thread",
normalizedSessionId: "normalized-cross-thread",
workingDirectory: "/workspace",
});
await crossThread.startTurn({ message: { role: "user", text: "Complete." } });
crossThreadTransport.push("item/completed", {
threadId: "other-thread",
turnId: "turn-1",
item: { id: "answer-other", type: "agentMessage", text: JSON.stringify(result) },
});
const crossThreadEvents = await collectUntilTerminal(crossThread.events());
expect(crossThreadEvents.some((event) => event.eventType === "run.result.proposed")).toBe(false);
expect(crossThreadEvents.find((event) => event.eventType === "session.failed")?.payload)
.toMatchObject({ code: "thread_binding_mismatch" });
const postTerminalTransport = new FakeCodexTransport();
const postTerminal = await makeDriver([postTerminalTransport]).openSession({
runId: "run-post-terminal",
normalizedSessionId: "normalized-post-terminal",
workingDirectory: "/workspace",
});
await postTerminal.startTurn({ message: { role: "user", text: "Complete." } });
postTerminalTransport.push("turn/completed", {
threadId: "thread-1",
turn: { id: "turn-1", status: "completed", items: [] },
});
await collectUntilTerminal(postTerminal.events());
expect(await postTerminalTransport.invoke({
id: "late-call",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "late-call",
tool: "paperclip_finish",
arguments: result,
},
})).toMatchObject({ success: false });
});
it("rejects duplicate and conflicting terminal facts", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-terminal-replay",
normalizedSessionId: "normalized-terminal-replay",
workingDirectory: "/workspace",
});
await session.startTurn({ message: { role: "user", text: "Complete." } });
transport.push("turn/completed", {
threadId: "thread-1",
turn: { id: "turn-1", status: "completed", items: [] },
});
transport.push("turn/completed", {
threadId: "thread-1",
turn: { id: "turn-1", status: "failed", error: { message: "changed" }, items: [] },
});
const events: PrpEvent[] = [];
for await (const event of session.events()) events.push(event);
expect(events.filter((event) => event.eventType === "turn.completed")).toHaveLength(1);
expect(events.filter((event) => event.eventType === "turn.failed")).toHaveLength(0);
expect(events.find((event) => event.eventType === "session.failed")?.payload)
.toMatchObject({ code: "conflicting_turn_terminal", recoverable: false });
});
it("rejects oversized semantic results without retaining provider payloads", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-large-result",
normalizedSessionId: "normalized-large-result",
workingDirectory: "/workspace",
});
await session.startTurn({ message: { role: "user", text: "Complete." } });
expect(await transport.invoke({
id: "large-call",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "large-call",
tool: "paperclip_finish",
arguments: { ...result, summary: "x".repeat(70 * 1024) },
},
})).toEqual({
success: false,
contentItems: [{ type: "inputText", text: "Semantic result exceeded the retained payload limit." }],
});
transport.push("turn/completed", {
threadId: "thread-1",
turn: { id: "turn-1", status: "completed", items: [] },
});
const events = await collectUntilTerminal(session.events());
expect(events.some((event) => JSON.stringify(event).includes("x".repeat(1024)))).toBe(false);
expect(events.some((event) => event.eventType === "run.result.proposed")).toBe(false);
});
it("normalizes finish and block tools into one canonical result contract", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({ runId: "run-tools", normalizedSessionId: "normalized-tools", workingDirectory: "/workspace" });
@@ -417,7 +603,7 @@ describe("Codex app-server Phase 4 driver", () => {
expect(recovery).toMatchObject({ recovered: true });
expect(recovery?.session?.ids()).toEqual(original.ids());
expect(await recovery?.session?.reconcile?.()).toMatchObject({ thread: { id: "thread-1" } });
expect(second.calls.map((call) => call.method)).toEqual(["initialize", "thread/resume", "thread/read"]);
expect(second.calls.map((call) => call.method)).toEqual(["initialize", "thread/read", "thread/resume", "thread/read"]);
expect((await recovery?.session?.snapshot())?.activeTurnId).toBe("turn-1");
});
@@ -428,6 +614,7 @@ describe("Codex app-server Phase 4 driver", () => {
thread: {
id: "driver-thread-loss",
sessionId: "provider-session-loss",
cwd: "/workspace",
tokenUsage: { total: { inputTokens: 21, outputTokens: 8 } },
turns: [{
id: "turn-1",
@@ -582,18 +769,48 @@ describe("Codex app-server Phase 4 driver", () => {
normalizedSessionId: "controller-session-identity",
});
expect(trace.assertions.stableIdentity).toBe(true);
expect(new Set([
trace.metadata.identity.runId,
trace.metadata.identity.normalizedSessionId,
trace.metadata.identity.driverSessionId,
trace.metadata.identity.providerSessionId,
]).size).toBe(4);
expect(trace.metadata.identity).toMatchObject({
runId: "runtime-run-identity",
normalizedSessionId: "controller-session-identity",
driverSessionId: "thread-1",
providerSessionId: "provider-session-1",
});
expect(trace.events.every((event) =>
event.runId === "runtime-run-identity" &&
event.normalizedSessionId === "controller-session-identity"
)).toBe(true);
});
it("validates persisted identity, uniqueness, terminals, and line bounds before replay", async () => {
const { trace } = await traceCompletedProposal(result, {
runId: "runtime-run-persisted",
normalizedSessionId: "controller-session-persisted",
});
const serialize = (events: PrpEvent[]) => `${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
expect(replayPersistedPhase4Events(serialize(trace.events), trace.metadata))
.toEqual(trace.replaySnapshot);
const mismatched = structuredClone(trace.events);
mismatched[0]!.normalizedSessionId = "another-session";
expect(() => replayPersistedPhase4Events(serialize(mismatched), trace.metadata))
.toThrow("identity did not match");
const terminalIndex = trace.events.findIndex((event) => event.eventType === "run.terminal");
const duplicated = trace.events.toSpliced(terminalIndex, 0, structuredClone(trace.events[0]!));
expect(() => replayPersistedPhase4Events(serialize(duplicated), trace.metadata))
.toThrow("event id was duplicated");
const terminal = structuredClone(trace.events[terminalIndex]!);
terminal.sourceEventId = `${terminal.sourceEventId}:conflict`;
terminal.sourceSeq += 1;
expect(() => replayPersistedPhase4Events(serialize([...trace.events, terminal]), trace.metadata))
.toThrow("after the run terminal");
expect(() => replayPersistedPhase4Events(`{\"payload\":\"${"x".repeat(300 * 1024)}\"}\n`, trace.metadata))
.toThrow("line was empty or oversized");
expect(() => replayPersistedPhase4Events("{not-json}\n", trace.metadata))
.toThrow("malformed JSON");
});
it("degrades declared unsupported capabilities without Codex-specific core branches", async () => {
const capabilities = {
resume: false,
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { parse, relative, resolve } from "node:path";
import type {
HarnessDriver,
@@ -33,14 +33,16 @@ import {
ProcessCodexAppServerTransport,
createSanitizedCodexEnvironment,
redactCodexDiagnostic,
sanitizedEnvironmentKeys,
type CodexAppServerTransport,
type CodexRpcNotification,
type CodexRpcServerRequest,
} from "./app-server-transport.js";
const DRIVER_KIND = "codex_app_server";
const DRIVER_VERSION = "phase4-v1";
const DRIVER_VERSION = "phase4-v2";
const SKILLLESS_PERMISSION_PROFILE = "paperclip-runner-workspace-only";
const MAX_RETAINED_CODEX_PAYLOAD_BYTES = 64 * 1024;
const MAX_RETAINED_CODEX_STRING_CHARS = 32 * 1024;
class AsyncQueue<T> implements AsyncIterable<T> {
#values: T[] = [];
@@ -110,6 +112,13 @@ function text(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function boundedText(value: unknown, fallback = "unknown", maxCharacters = 1024): string {
const candidate = text(value, fallback);
return candidate.length <= maxCharacters
? candidate
: `${candidate.slice(0, maxCharacters)}...[truncated]`;
}
function itemFromParams(params: Record<string, unknown>): Record<string, unknown> {
return record(params.item);
}
@@ -172,7 +181,7 @@ function blockToolSpec(): Record<string, unknown> {
};
}
const SKILLLESS_THREAD_CONFIG = {
const SKILLLESS_BASE_CONFIG = {
"skills.include_instructions": false,
include_apps_instructions: false,
include_collaboration_mode_instructions: false,
@@ -182,6 +191,72 @@ const SKILLLESS_THREAD_CONFIG = {
"features.memories": false,
} as const;
function commandEnvironment(source: NodeJS.ProcessEnv = process.env): Record<string, string> {
const environment: Record<string, string> = {};
for (const key of ["PATH", "PATHEXT", "SystemRoot", "WINDIR", "LANG", "LC_ALL"] as const) {
const value = source[key];
if (value !== undefined) environment[key] = value;
}
return environment;
}
export function createSkilllessCodexThreadConfig(
_workingDirectory: string,
_source: NodeJS.ProcessEnv = process.env,
): Record<string, unknown> {
return { ...SKILLLESS_BASE_CONFIG };
}
function tomlString(value: string): string {
return JSON.stringify(value);
}
export function createIsolatedCodexAppServerArgs(
source: NodeJS.ProcessEnv = process.env,
): string[] {
const deniedHostRoots = [...new Set([source.HOME, source.CODEX_HOME]
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.map((value) => resolve(value)))];
const filesystemRules = [
`\":root\"=\"none\"`,
`\":minimal\"=\"read\"`,
`\":tmpdir\"=\"none\"`,
...deniedHostRoots.map((path) => `${tomlString(path)}=\"none\"`),
`\":workspace_roots\"={\".\"=\"write\"}`,
].join(",");
const commandEnv = Object.entries(commandEnvironment(source))
.map(([key, value]) => `${key}=${tomlString(value)}`)
.join(",");
return [
"-c",
`default_permissions=${tomlString(SKILLLESS_PERMISSION_PROFILE)}`,
"-c",
`permissions.${SKILLLESS_PERMISSION_PROFILE}.filesystem={${filesystemRules}}`,
"-c",
`permissions.${SKILLLESS_PERMISSION_PROFILE}.network.enabled=false`,
"-c",
`shell_environment_policy.inherit=\"none\"`,
"-c",
"shell_environment_policy.ignore_default_excludes=false",
...(commandEnv.length > 0
? ["-c", `shell_environment_policy.set={${commandEnv}}`]
: []),
"app-server",
];
}
function securedThreadParams(
workingDirectory: string,
source: NodeJS.ProcessEnv = process.env,
): Record<string, unknown> {
return {
cwd: workingDirectory,
config: createSkilllessCodexThreadConfig(workingDirectory, source),
permissions: SKILLLESS_PERMISSION_PROFILE,
runtimeWorkspaceRoots: [workingDirectory],
};
}
export class CodexAppServerDriver implements HarnessDriver {
readonly #options: CodexAppServerDriverOptions;
readonly #caps: CodexCapabilities;
@@ -226,27 +301,31 @@ export class CodexAppServerDriver implements HarnessDriver {
}
async openSession(input: OpenHarnessSessionInput): Promise<HarnessSession> {
const workingDirectory = validateWorkingDirectory(input.workingDirectory, this.#options.environment);
const transport = this.#transport();
const initialize = await this.#initialize(transport);
const response = await transport.request("thread/start", {
cwd: input.workingDirectory,
approvalPolicy: "never",
sandbox: "workspace-write",
config: SKILLLESS_THREAD_CONFIG,
baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
dynamicTools: this.#caps.dynamicTools ? [finishToolSpec(), blockToolSpec()] : [],
experimentalRawEvents: false,
persistExtendedHistory: false,
});
const opened = this.#openedThread(response, initialize, input.workingDirectory);
return this.#session({
transport,
runId: input.runId,
normalizedSessionId: input.normalizedSessionId,
opened,
resumed: false,
sourceSequence: 0,
});
try {
const initialize = await this.#initialize(transport);
const response = await transport.request("thread/start", {
...securedThreadParams(workingDirectory, this.#options.environment),
approvalPolicy: "never",
baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
dynamicTools: this.#caps.dynamicTools ? [finishToolSpec(), blockToolSpec()] : [],
experimentalRawEvents: false,
persistExtendedHistory: false,
});
const opened = this.#openedThread(response, initialize, workingDirectory);
return this.#session({
transport,
runId: input.runId,
normalizedSessionId: input.normalizedSessionId,
opened,
resumed: false,
sourceSequence: 0,
});
} catch (error) {
await transport.close();
throw error;
}
}
async recoverSession(
@@ -264,13 +343,26 @@ export class CodexAppServerDriver implements HarnessDriver {
const transport = this.#transport();
try {
const initialize = await this.#initialize(transport);
const existing = await transport.request("thread/read", {
threadId: snapshot.driverSessionId,
includeTurns: false,
});
const existingThread = record(existing.thread);
if (text(existingThread.id) !== snapshot.driverSessionId) {
await transport.close();
return { recovered: false, reason: "provider read a different session" };
}
const workingDirectory = validateWorkingDirectory(
text(existingThread.cwd),
this.#options.environment,
);
const response = await transport.request("thread/resume", {
threadId: snapshot.driverSessionId,
config: SKILLLESS_THREAD_CONFIG,
...securedThreadParams(workingDirectory, this.#options.environment),
baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
persistExtendedHistory: false,
});
const opened = this.#openedThread(response, initialize, text(record(response.thread).cwd));
const opened = this.#openedThread(response, initialize, workingDirectory);
if (opened.threadId !== snapshot.driverSessionId) {
await transport.close();
return { recovered: false, reason: "provider resumed a different session" };
@@ -304,6 +396,7 @@ export class CodexAppServerDriver implements HarnessDriver {
return (
this.#options.transportFactory?.() ??
new ProcessCodexAppServerTransport({
args: createIsolatedCodexAppServerArgs(this.#options.environment),
environment: createSanitizedCodexEnvironment(this.#options.environment),
onDiagnostic: this.#options.onDiagnostic,
})
@@ -327,32 +420,60 @@ export class CodexAppServerDriver implements HarnessDriver {
const thread = record(response.thread);
const threadId = text(thread.id);
if (threadId.length === 0) throw new Error("Codex thread response omitted thread.id");
const activePermissionProfile = record(thread.activePermissionProfile);
const permissionProfileId = text(activePermissionProfile.id);
if (
permissionProfileId.length > 0 &&
permissionProfileId !== SKILLLESS_PERMISSION_PROFILE
) {
throw new Error("Codex thread did not activate the required filesystem permission profile");
}
const configuredPermissionProfile = {
...activePermissionProfile,
id: SKILLLESS_PERMISSION_PROFILE,
};
const returnedWorkingDirectory = text(response.cwd, workingDirectory);
if (resolve(returnedWorkingDirectory) !== workingDirectory) {
throw new Error("Codex thread response changed the assigned working directory");
}
return {
threadId,
providerSessionId: text(thread.sessionId) || null,
context: {
protocolVersion: PHASE4_CODEX_PROTOCOL_VERSION,
codexVersion: text(initialize.userAgent, "unknown"),
codexVersion: boundedText(initialize.userAgent),
clientInfo: {
name: "paperclip-runner",
title: "Paperclip Runner",
version: DRIVER_VERSION,
},
model: text(response.model, "unknown"),
modelProvider: text(response.modelProvider, text(thread.modelProvider, "unknown")),
workingDirectory: text(response.cwd, workingDirectory),
sandbox: response.sandbox ?? null,
approvalPolicy: response.approvalPolicy ?? "never",
model: boundedText(response.model),
modelProvider: boundedText(response.modelProvider, boundedText(thread.modelProvider)),
workingDirectory,
sandbox: {
permissionProfile: boundedCodexValue(configuredPermissionProfile),
legacyPolicy: boundedCodexValue(response.sandbox ?? null),
rootAccess: "none",
minimalRuntimeAccess: "read",
workspaceAccess: "write",
networkAccess: false,
},
approvalPolicy: boundedCodexValue(response.approvalPolicy ?? "never"),
baseInstructions: PHASE4_SKILLLESS_BASE_INSTRUCTIONS,
instructionSources: Array.isArray(response.instructionSources)
? response.instructionSources.filter((value): value is string => typeof value === "string")
? response.instructionSources
.filter((value): value is string => typeof value === "string")
.slice(0, 32)
.map((value) => boundedText(value))
: [],
instructionPolicy: {
skillInstructions: false,
appInstructions: false,
collaborationInstructions: false,
},
environmentKeys: sanitizedEnvironmentKeys(this.#options.environment),
environmentKeys: Object.keys(
commandEnvironment(this.#options.environment),
).sort(),
dynamicToolNames: this.#caps.dynamicTools ? [...PHASE4_SEMANTIC_TOOL_NAMES] : [],
modelInputKinds: ["text"],
envelope: structuredClone(this.#options.taskEnvelope),
@@ -394,7 +515,13 @@ class CodexHarnessSession implements HarnessSession {
#usage: Record<string, unknown> | null = null;
#result: PrpStructuredRunResult | null = null;
#resultFingerprint: string | null = null;
#resultCallId: string | null = null;
#turnStartPending = false;
#protocolFailed = false;
#protocolFailureCode: string | null = null;
#protocolFailureMessage: string | null = null;
#terminal = false;
#turnStarted = false;
readonly #terminalTurns = new Map<string, string>();
constructor(input: {
@@ -455,17 +582,38 @@ class CodexHarnessSession implements HarnessSession {
}
async startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }> {
if (this.#terminal || this.#protocolFailed || this.#activeTurnId !== null || this.#turnStartPending) {
throw this.#unsupported(
"turn start",
this.#protocolFailureCode === null
? "session cannot start another turn"
: `session failed protocol validation: ${this.#protocolFailureCode} (${this.#protocolFailureMessage ?? "no detail"})`,
);
}
const taskText = JSON.stringify({ task: this.#taskEnvelope, message: input.message.text });
this.#emit("turn.submitted", { envelopeSchema: this.#taskEnvelope.schema });
const response = await this.#transport.request("turn/start", {
threadId: this.#opened.threadId,
input: [userInput({ role: "user", text: taskText })],
outputSchema: PHASE4_RESULT_OUTPUT_SCHEMA,
});
this.#turnStartPending = true;
let response: Record<string, unknown>;
try {
response = await this.#transport.request("turn/start", {
threadId: this.#opened.threadId,
cwd: this.#opened.context.workingDirectory,
permissions: SKILLLESS_PERMISSION_PROFILE,
runtimeWorkspaceRoots: [this.#opened.context.workingDirectory],
input: [userInput({ role: "user", text: taskText })],
outputSchema: PHASE4_RESULT_OUTPUT_SCHEMA,
});
} finally {
this.#turnStartPending = false;
}
const turn = record(response.turn);
const turnId = text(turn.id);
if (turnId.length === 0) throw new Error("Codex turn response omitted turn.id");
this.#activeTurnId = turnId;
if (this.#activeTurnId !== null && this.#activeTurnId !== turnId) {
this.#failProtocol("turn_start_mismatch", "turn/start response disagreed with turn/started");
throw new Error("Codex turn identity changed during start");
}
this.#activeTurnId ??= turnId;
this.#emit("turn.accepted", { turnId }, { turnId });
return { turnId };
}
@@ -525,7 +673,7 @@ class CodexHarnessSession implements HarnessSession {
}
const turns = Array.isArray(thread.turns) ? thread.turns.map(record) : [];
const active = turns.find((turn) => text(turn.status) === "inProgress");
const reconciledUsage = record(thread.tokenUsage ?? snapshot.tokenUsage);
const reconciledUsage = boundedPayload(record(thread.tokenUsage ?? snapshot.tokenUsage));
if (Object.keys(reconciledUsage).length > 0) this.#usage = reconciledUsage;
const terminal = [...turns]
.reverse()
@@ -577,31 +725,78 @@ class CodexHarnessSession implements HarnessSession {
code: "notification_transport_failed",
message: redactCodexDiagnostic(String(error)),
});
this.#failProtocol(
"notification_transport_failed",
"Provider notification transport failed closed.",
);
}
}
#mapNotification(notification: CodexRpcNotification): void {
if (!isBoundCodexNotification(notification.method)) return;
const params = notification.params;
const turn = record(params.turn);
const item = itemFromParams(params);
const turnId = text(params.turnId, text(turn.id, this.#activeTurnId ?? ""));
const threadId = text(params.threadId);
const turnId = text(params.turnId, text(turn.id));
const itemId = text(item.id, text(params.itemId));
if (notification.method === "error" || notification.method === "warning" || notification.method === "configWarning") {
this.#emit("harness.diagnostic", {
code: notification.method.replaceAll("/", "_"),
message: redactCodexDiagnostic(text(params.message, JSON.stringify(boundedCodexValue(params)))),
});
return;
}
if (threadId !== this.#opened.threadId) {
this.#failProtocol(
"thread_binding_mismatch",
`Provider ${notification.method} message did not name the opened thread.`,
);
return;
}
if (notification.method === "turn/started") {
if (
turnId.length === 0 ||
this.#terminal ||
this.#protocolFailed ||
this.#turnStarted ||
(!this.#turnStartPending && this.#activeTurnId === null) ||
(this.#activeTurnId !== null && this.#activeTurnId !== turnId)
) {
this.#failProtocol("turn_binding_mismatch", "Provider started an unexpected turn.");
return;
}
this.#activeTurnId = turnId;
this.#turnStarted = true;
this.#emit("turn.started", { status: text(turn.status, "inProgress") }, { turnId });
return;
}
if (notification.method === "turn/completed") {
if (this.#terminalTurns.has(turnId)) {
this.#mapTerminalTurn(turn, turnId);
return;
}
if (!this.#notificationNamesActiveTurn(turnId, "turn terminal")) return;
this.#mapTerminalTurn(turn, turnId);
return;
}
if (notification.method === "item/started") {
this.#emit("item.started", { kind: text(item.type, "unknown"), text: itemText(item), item }, { turnId, itemId });
if (!this.#notificationNamesActiveTurn(turnId, "item start")) return;
this.#emit("item.started", boundedPayload({
kind: text(item.type, "unknown"),
text: itemText(item),
item,
}), { turnId, itemId });
return;
}
if (notification.method === "item/completed") {
this.#captureResultFromItem(item);
this.#emit("item.completed", { kind: text(item.type, "unknown"), text: itemText(item), item }, { turnId, itemId });
if (!this.#notificationNamesActiveTurn(turnId, "item completion")) return;
this.#captureResultFromItem(item, turnId);
this.#emit("item.completed", boundedPayload({
kind: text(item.type, "unknown"),
text: itemText(item),
item,
}), { turnId, itemId });
return;
}
const deltaKinds: Record<string, string> = {
@@ -617,41 +812,54 @@ class CodexHarnessSession implements HarnessSession {
};
const deltaKind = deltaKinds[notification.method];
if (deltaKind !== undefined) {
this.#emit("item.delta", {
if (!this.#notificationNamesActiveTurn(turnId, "item update")) return;
this.#emit("item.delta", boundedPayload({
kind: deltaKind,
text: text(params.delta, text(params.patch, text(params.output))),
update: params,
}, { turnId, itemId: itemId || `${turnId}:${deltaKind}` });
}), { turnId, itemId: itemId || `${turnId}:${deltaKind}` });
return;
}
if (notification.method === "thread/tokenUsage/updated") {
this.#usage = record(params.tokenUsage);
if (!this.#notificationNamesActiveTurn(turnId, "usage update")) return;
this.#usage = boundedPayload(record(params.tokenUsage));
this.#emit("item.completed", { kind: "usage", usage: this.#usage }, {
turnId,
itemId: `${turnId}:usage:${this.#sourceSequence + 1}`,
});
return;
}
if (notification.method === "error" || notification.method === "warning" || notification.method === "configWarning") {
this.#emit("harness.diagnostic", {
code: notification.method.replaceAll("/", "_"),
message: redactCodexDiagnostic(text(params.message, JSON.stringify(params))),
}, turnId ? { turnId } : {});
}
}
async #handleServerRequest(request: CodexRpcServerRequest): Promise<Record<string, unknown>> {
if (request.method === "item/tool/call") {
const tool = text(request.params.tool);
const threadId = text(request.params.threadId);
const turnId = text(request.params.turnId);
const callId = text(request.params.callId);
if (
this.#protocolFailed ||
this.#terminal ||
threadId !== this.#opened.threadId ||
turnId.length === 0 ||
turnId !== this.#activeTurnId ||
callId.length === 0
) {
this.#failProtocol("tool_binding_mismatch", "Semantic tool call did not name the active thread and turn.");
return rejectedToolCall("Semantic tool call was outside the active thread and turn.");
}
if (!isSemanticTool(tool)) {
this.#diagnoseUnsupported(`dynamic tool ${tool}`);
return { success: false, contentItems: [{ type: "inputText", text: "Unsupported tool." }] };
return rejectedToolCall("Unsupported tool.");
}
if (!isRetainableCodexPayload(request.params.arguments)) {
return rejectedToolCall("Semantic result exceeded the retained payload limit.");
}
const validation = validatePrpStructuredRunResult(request.params.arguments);
if (!validation.ok) {
return {
success: false,
contentItems: [{ type: "inputText", text: `Invalid semantic result: ${validation.issues.map((issue) => issue.message).join("; ")}` }],
contentItems: [{ type: "inputText", text: "Invalid semantic result." }],
};
}
if (!toolAcceptsDisposition(tool, validation.result.reportedWorkDisposition)) {
@@ -660,26 +868,38 @@ class CodexHarnessSession implements HarnessSession {
contentItems: [{
type: "inputText",
text:
tool === PHASE4_BLOCK_TOOL_NAME || tool === "paperclip.block"
tool === PHASE4_BLOCK_TOOL_NAME
? "paperclip_block requires reportedWorkDisposition=blocked."
: "paperclip_finish accepts only done or needs_review.",
}],
};
}
const fingerprint = canonicalJson(validation.result);
if (this.#resultCallId !== null && this.#resultCallId !== callId) {
return rejectedToolCall("A semantic result was already committed by another call.");
}
if (this.#resultFingerprint !== null && this.#resultFingerprint !== fingerprint) {
return { success: false, contentItems: [{ type: "inputText", text: "A different semantic result was already committed." }] };
return rejectedToolCall("A different semantic result was already committed.");
}
if (this.#result === null) {
this.#commitResult(
validation.result,
text(request.params.callId, randomUUID()),
text(request.params.turnId, this.#activeTurnId ?? ""),
);
this.#resultCallId = callId;
this.#commitResult(validation.result, callId, turnId);
}
return { success: true, contentItems: [{ type: "inputText", text: "Semantic completion accepted." }] };
}
if (!isTurnScopedServerRequest(request.method)) return safeRequestResponse(request.method);
const requestTurnId = text(request.params.turnId);
if (
text(request.params.threadId) !== this.#opened.threadId ||
requestTurnId.length === 0 ||
requestTurnId !== this.#activeTurnId ||
this.#terminal ||
this.#protocolFailed
) {
this.#failProtocol("runtime_request_binding_mismatch", "Runtime request did not name the active thread and turn.");
return safeRequestResponse(request.method);
}
const requestKind = request.method.includes("requestApproval") ? "approval" : "user_input";
const requestId = String(request.id);
this.#emit("runtime_request.created", {
@@ -689,10 +909,10 @@ class CodexHarnessSession implements HarnessSession {
type: request.method,
status: "pending",
prompt: "Codex requires an external runtime decision.",
details: redactCodexValue(request.params),
details: redactCodexValue(boundedCodexValue(request.params)),
},
}, {
turnId: text(request.params.turnId, this.#activeTurnId ?? ""),
turnId: requestTurnId,
itemId: text(request.params.itemId, requestId),
});
const response = safeRequestResponse(request.method);
@@ -700,27 +920,18 @@ class CodexHarnessSession implements HarnessSession {
requestId,
resolution: "declined_by_skillless_driver",
}, {
turnId: text(request.params.turnId, this.#activeTurnId ?? ""),
turnId: requestTurnId,
itemId: text(request.params.itemId, requestId),
});
return response;
}
#captureResultFromItem(item: Record<string, unknown>): void {
#captureResultFromItem(item: Record<string, unknown>, turnId: string): void {
if (text(item.type) !== "agentMessage" || this.#result !== null) return;
if (!isRetainableCodexPayload(item.text)) return;
const result = tryParseResult(item.text);
if (result !== null) this.#commitResult(result, text(item.id));
}
#captureResultFromTurn(turn: Record<string, unknown>): void {
if (this.#result !== null || !Array.isArray(turn.items)) return;
for (const value of turn.items.map(record).reverse()) {
if (text(value.type) !== "agentMessage") continue;
const result = tryParseResult(value.text);
if (result !== null) {
this.#commitResult(result, text(value.id));
return;
}
if (result !== null && isRetainableCodexPayload(result)) {
this.#commitResult(result, text(item.id), turnId);
}
}
@@ -766,12 +977,10 @@ class CodexHarnessSession implements HarnessSession {
});
const previous = this.#terminalTurns.get(turnId);
if (previous !== undefined) {
if (previous !== fingerprint) {
this.#emit("harness.diagnostic", {
code: "conflicting_turn_terminal",
message: "Provider sent materially different terminal facts for an already terminal turn.",
}, { turnId });
}
this.#failProtocol(
previous === fingerprint ? "duplicate_turn_terminal" : "conflicting_turn_terminal",
"Provider sent another terminal fact for an already terminal turn.",
);
return;
}
this.#terminalTurns.set(turnId, fingerprint);
@@ -786,7 +995,10 @@ class CodexHarnessSession implements HarnessSession {
: status === "cancelled"
? "turn.cancelled"
: "turn.completed";
this.#emit(eventType, { status, error: turn.error ?? null }, { turnId });
this.#emit(eventType, boundedPayload({
status,
error: turn.error ?? null,
}), { turnId });
this.#activeTurnId = null;
this.#finalize(status);
}
@@ -797,7 +1009,11 @@ class CodexHarnessSession implements HarnessSession {
turn.items
.map(record)
.reverse()
.find((value) => text(value.type) === "agentMessage" && tryParseResult(value.text) !== null) ??
.find((value) =>
text(value.type) === "agentMessage" &&
isRetainableCodexPayload(value.text) &&
tryParseResult(value.text) !== null
) ??
null
);
}
@@ -813,6 +1029,20 @@ class CodexHarnessSession implements HarnessSession {
}
}
#notificationNamesActiveTurn(turnId: string, kind: string): boolean {
if (
this.#protocolFailed ||
this.#terminal ||
turnId.length === 0 ||
this.#activeTurnId === null ||
turnId !== this.#activeTurnId
) {
this.#failProtocol("turn_binding_mismatch", `Provider ${kind} did not name the active turn.`);
return false;
}
return true;
}
#requireCapability(operation: keyof CodexCapabilities): void {
if (!this.#capabilities[operation]) throw this.#unsupported(operation, "capability not advertised");
}
@@ -834,6 +1064,27 @@ class CodexHarnessSession implements HarnessSession {
});
}
#failProtocol(code: string, message: string): void {
if (this.#protocolFailed) return;
this.#protocolFailed = true;
this.#protocolFailureCode = code;
this.#protocolFailureMessage = redactCodexDiagnostic(message);
this.#emit("session.failed", {
code,
message: redactCodexDiagnostic(message),
recoverable: false,
});
if (!this.#terminal && this.#activeTurnId !== null) {
const turnId = this.#activeTurnId;
this.#emit("turn.failed", { status: "failed", error: { code } }, { turnId });
this.#terminalTurns.set(turnId, canonicalJson({ protocolFailure: code }));
this.#activeTurnId = null;
}
this.#terminal = true;
this.#events.close();
void this.#transport.close();
}
#emit(
eventType: PrpEvent["eventType"],
payload: Record<string, unknown>,
@@ -859,39 +1110,141 @@ class CodexHarnessSession implements HarnessSession {
}
}
function isSemanticTool(tool: string): boolean {
return (
tool === PHASE4_COMPLETION_TOOL_NAME ||
tool === PHASE4_BLOCK_TOOL_NAME ||
tool === "paperclip.finish" ||
tool === "paperclip.block"
function validateWorkingDirectory(
workingDirectory: string,
environment: NodeJS.ProcessEnv = process.env,
): string {
if (workingDirectory.trim().length === 0) {
throw new Error("Codex working directory is required");
}
const resolved = resolve(workingDirectory);
if (resolved === parse(resolved).root) {
throw new Error("Codex working directory cannot be a filesystem root");
}
const hostHome = environment.HOME?.trim();
if (hostHome && pathContains(resolved, resolve(hostHome))) {
throw new Error("Codex working directory cannot contain the host HOME");
}
const codexHome = environment.CODEX_HOME?.trim();
if (codexHome) {
const resolvedCodexHome = resolve(codexHome);
if (pathContains(resolved, resolvedCodexHome) || pathContains(resolvedCodexHome, resolved)) {
throw new Error("Codex working directory cannot overlap host CODEX_HOME");
}
}
const configuredRoot = environment.PAPERCLIP_WORKSPACE_CWD;
if (configuredRoot !== undefined && configuredRoot.trim().length > 0) {
const root = resolve(configuredRoot);
const pathFromRoot = relative(root, resolved);
if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
throw new Error("Codex working directory is outside the assigned workspace");
}
}
return resolved;
}
function pathContains(parent: string, candidate: string): boolean {
const fromParent = relative(parent, candidate);
return fromParent === "" || (
fromParent !== ".." &&
!fromParent.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) &&
!parse(fromParent).root
);
}
function boundedCodexValue(value: unknown, depth = 0): unknown {
if (depth > 8) return { truncated: true, reason: "maximum depth" };
if (typeof value === "string") {
return value.length <= MAX_RETAINED_CODEX_STRING_CHARS
? value
: `${value.slice(0, MAX_RETAINED_CODEX_STRING_CHARS)}...[truncated]`;
}
if (Array.isArray(value)) {
const bounded = value
.slice(0, 128)
.map((entry) => boundedCodexValue(entry, depth + 1));
if (value.length > 128) bounded.push({ truncated: true, omittedItems: value.length - 128 });
return bounded;
}
if (typeof value !== "object" || value === null) return value;
const object = record(value);
const bounded = Object.fromEntries(
Object.entries(object)
.slice(0, 128)
.map(([key, entry]) => [key, boundedCodexValue(entry, depth + 1)]),
);
if (Object.keys(object).length > 128) bounded.truncatedEntries = Object.keys(object).length - 128;
const serialized = JSON.stringify(bounded);
return Buffer.byteLength(serialized) <= MAX_RETAINED_CODEX_PAYLOAD_BYTES
? bounded
: { truncated: true, byteSize: Buffer.byteLength(serialized) };
}
function boundedPayload(value: Record<string, unknown>): Record<string, unknown> {
return record(boundedCodexValue(value));
}
function isRetainableCodexPayload(value: unknown): boolean {
try {
return Buffer.byteLength(JSON.stringify(value)) <= MAX_RETAINED_CODEX_PAYLOAD_BYTES;
} catch {
return false;
}
}
function isSemanticTool(tool: string): boolean {
return tool === PHASE4_COMPLETION_TOOL_NAME || tool === PHASE4_BLOCK_TOOL_NAME;
}
function toolAcceptsDisposition(
tool: string,
disposition: PrpStructuredRunResult["reportedWorkDisposition"],
): boolean {
if (tool === PHASE4_BLOCK_TOOL_NAME || tool === "paperclip.block") {
if (tool === PHASE4_BLOCK_TOOL_NAME) {
return disposition === "blocked";
}
return disposition === "done" || disposition === "needs_review";
}
function redactCodexValue(value: unknown): unknown {
function redactCodexValue(value: unknown, depth = 0): unknown {
if (depth > 8) return "[TRUNCATED]";
if (typeof value === "string") return redactCodexDiagnostic(value);
if (Array.isArray(value)) return value.map(redactCodexValue);
if (Array.isArray(value)) {
return value.slice(0, 128).map((entry) => redactCodexValue(entry, depth + 1));
}
const object = record(value);
return Object.fromEntries(
Object.entries(object).map(([key, entry]) => [
Object.entries(object).slice(0, 128).map(([key, entry]) => [
key,
/(?:api[_-]?key|token|secret|password|authorization)/i.test(key)
? "[REDACTED]"
: redactCodexValue(entry),
: redactCodexValue(entry, depth + 1),
]),
);
}
function rejectedToolCall(message: string): Record<string, unknown> {
return { success: false, contentItems: [{ type: "inputText", text: message }] };
}
function isTurnScopedServerRequest(method: string): boolean {
return method.startsWith("item/") || method.startsWith("tool/") || method === "mcpServer/elicitation/request";
}
function isBoundCodexNotification(method: string): boolean {
return (
method === "turn/started" ||
method === "turn/completed" ||
method === "thread/tokenUsage/updated" ||
method === "error" ||
method === "warning" ||
method === "configWarning" ||
method.startsWith("item/") ||
method === "turn/diff/updated" ||
method === "turn/plan/updated"
);
}
function safeRequestResponse(method: string): Record<string, unknown> {
if (method === "item/permissions/requestApproval") {
return { permissions: {} };
@@ -13,6 +13,7 @@ import {
createSessionSnapshotFromMetadata,
} from "../reducer/session-reducer.js";
import {
validatePrpEvent,
validatePrpStructuredRunResult,
type PrpCapabilities,
type PrpEvent,
@@ -20,6 +21,10 @@ import {
type PrpTerminalState,
} from "../protocol/phase1-contract.js";
const MAX_PERSISTED_PHASE4_BYTES = 8 * 1024 * 1024;
const MAX_PERSISTED_PHASE4_LINE_BYTES = 256 * 1024;
const MAX_PERSISTED_PHASE4_EVENTS = 4096;
export interface Phase4CodexTracerInput {
driver: HarnessDriver;
taskEnvelope: Phase4TaskEnvelope;
@@ -239,6 +244,78 @@ function sourceSequenceContinuous(events: PrpEvent[]): boolean {
return true;
}
/** Parse and validate the persisted boundary before replaying controller state. */
export function replayPersistedPhase4Events(
serialized: string,
metadata: Phase4RunMetadata,
) {
if (!metadata.identity.driverSessionId) {
throw new Error("Persisted Phase 4 replay requires a driver session identity");
}
if (Buffer.byteLength(serialized) > MAX_PERSISTED_PHASE4_BYTES) {
throw new Error("Persisted Phase 4 event stream exceeded its byte limit");
}
const lines = serialized.endsWith("\n")
? serialized.slice(0, -1).split("\n")
: serialized.split("\n");
if (lines.length === 0 || lines.length > MAX_PERSISTED_PHASE4_EVENTS) {
throw new Error("Persisted Phase 4 event stream exceeded its event limit");
}
const events: PrpEvent[] = [];
const sourceEventIds = new Set<string>();
const sourceSequences = new Map<string, number>();
let runTerminalCount = 0;
for (const line of lines) {
if (line.length === 0 || Buffer.byteLength(line) > MAX_PERSISTED_PHASE4_LINE_BYTES) {
throw new Error("Persisted Phase 4 event line was empty or oversized");
}
let candidate: unknown;
try {
candidate = JSON.parse(line);
} catch {
throw new Error("Persisted Phase 4 event line was malformed JSON");
}
const validation = validatePrpEvent(candidate);
if (!validation.ok) throw new Error("Persisted Phase 4 event failed schema validation");
const event = validation.event;
if (
event.runId !== metadata.identity.runId ||
event.normalizedSessionId !== metadata.identity.normalizedSessionId
) {
throw new Error("Persisted Phase 4 event identity did not match the opened run");
}
if (sourceEventIds.has(event.sourceEventId)) {
throw new Error("Persisted Phase 4 event id was duplicated");
}
sourceEventIds.add(event.sourceEventId);
const source = `${event.sourceKind}:${event.sourceInstanceId}`;
const previous = sourceSequences.get(source);
if (previous !== undefined && event.sourceSeq !== previous + 1) {
throw new Error("Persisted Phase 4 source sequence was discontinuous");
}
sourceSequences.set(source, event.sourceSeq);
if (runTerminalCount > 0) {
throw new Error("Persisted Phase 4 event appeared after the run terminal");
}
if (event.eventType === "run.terminal") runTerminalCount += 1;
events.push(event);
}
if (runTerminalCount !== 1) {
throw new Error("Persisted Phase 4 event stream requires exactly one run terminal");
}
return events.reduce(
applyPrpEvent,
createSessionSnapshotFromMetadata({
fixtureName: metadata.fixtureName,
identity: {
...metadata.identity,
driverSessionId: metadata.identity.driverSessionId,
},
capabilities: metadata.capabilities,
}),
);
}
/**
* Small in-memory Phase 4 mock core. The driver reports provider facts; this
* controller validates semantic proposals and alone emits the run terminal.
@@ -404,14 +481,8 @@ export async function runPhase4CodexTracer(input: Phase4CodexTracerInput): Promi
capabilities,
}),
);
const replaySnapshot = events.reduce(
applyPrpEvent,
createSessionSnapshotFromMetadata({
fixtureName: metadata.fixtureName,
identity,
capabilities,
}),
);
const persistedEvents = `${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
const replaySnapshot = replayPersistedPhase4Events(persistedEvents, metadata);
const context = contextFrom(events);
const terminalCount = events.filter((event) => event.eventType === "run.terminal").length;
const turnTerminalCount = events.filter(isTurnTerminal).length;
@@ -447,7 +518,10 @@ export async function runPhase4CodexTracer(input: Phase4CodexTracerInput): Promi
liveReplayParity: JSON.stringify(liveSnapshot) === JSON.stringify(replaySnapshot),
stableIdentity:
providerSessionId !== null &&
new Set([runId, normalizedSessionId, ids.driverSessionId, providerSessionId]).size === 4 &&
runId.length > 0 &&
normalizedSessionId.length > 0 &&
ids.driverSessionId.length > 0 &&
providerSessionId.length > 0 &&
events.every(
(event) =>
event.runId === runId && event.normalizedSessionId === normalizedSessionId,