Files
PaperClipAI/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver.test-support.ts
T
DottaandPaperclip 3b550c80fa fix(codex): correct startup trust, history reads, and resume usage (#13110)
## Thinking Path

> - Paperclip runs Codex locally and in remote sandboxes.
> - The runner must preserve startup configuration and session identity.
> - Missing project trust can disable repository configuration.
> - Full-history requests use deprecated provider fields.
> - Resume usage describes old work and must not become new run usage.
> - This change corrects startup trust, state reads, and usage
classification.

## Linked Issues or Issue Description

**What happened?**

Normal Codex runs could show repository-trust and history-deprecation
warnings.
Resume could report the preceding turn's token snapshot as a late-turn
warning.
The historical last-usage value could also be attributed to the new run.

**Expected behavior**

Trust the server-selected startup root in isolated configuration. Read
lightweight
provider state and paginated evidence. Use historical cumulative usage
as a
baseline without a new charge or user-facing warning.

**Steps to reproduce**

1. Start a native Codex task in a selected repository.
2. Finish the turn and resume the provider thread.
3. Inspect provider notices, history requests, and per-run usage.
4. Repeat startup and cold resume inside a Daytona sandbox.

**Paperclip version or commit**

Codex CLI 0.153.4 is the pinned runtime and reproduced baseline.
Replayed onto master at 6abeb6733. Related authority work: Refs #13092.
This PR retains its startup cleanup and protocol-integrity checks.

**Deployment mode**

Local source checkout and disposable Daytona sandbox.

## What Changed

- Classify the exact historical resume usage event before the generic
stale-turn warning.
- Persist cumulative usage baselines across recovery of the same run.
- Use excludeTurns on resume and lightweight thread reads.
- Page turn metadata and selected turn items with cursor and identity
validation.
- Reject unsupported or incomplete history instead of guessing that
execution is idle.
- Trust the startup execution root on its host, including Git worktree
trust keys.
- Start Codex in that root and retain the selected sandbox profile on
later turns.
- Keep unrelated isolated configuration and Codex's separate hook trust
policy.
- Add Rust, TypeScript, accounting, native integration, and local
run-log documentation.

## Verification

- Codex and native-transport TypeScript: 333 passed before PR replay.
- Adjacent OpenCode/ACPX driver and accounting tests: 49 passed.
- Rust library, serialized: 226 passed. Native Codex integration: 72
passed, 1 ignored, plus two pagination regressions.
- Repository typecheck and build passed. All repository test groups have
passing coverage after fixture and resource retests; the initial
monolithic command was not clean.
- Fresh real Codex native browser tasks returned correct answers without
the three targeted notices. Answers persisted after refresh and restart.
- Real same-thread TypeScript driver tests passed locally and in
Daytona, including cold resume, configuration, skills, and an approved
harmless hook.
- Local usage summed to 64,607 tokens. Daytona usage summed to 42,737
tokens. Each sum matched its final session total exactly.
- See doc/plans/2026-09-09-codex-integration-acceptance.md for the scope
and limits of the live tests.
- After replay onto current master and review fixes: 334 Codex, backend,
and live-session tests passed, including checkpoint serialization and
real-runner process restart. TypeScript checks passed.
- The native Codex integration run passed 83 tests; the large lineage
test passed separately with the release runner (its debug build exceeded
the test deadline).
- All GitHub checks passed on the final PR head. Greptile is 5/5 with no
unresolved review threads. CI regenerates the lockfile for the added
TOML dependency, per repository policy.
- The first server shard hit a timing-dependent duplicate-key failure in
the unchanged artifact-document concurrency test. Its focused 11-test
suite passed locally. One CI retry on the same head passed all 103 files
and 1,405 tests (2 skipped): [retry
result](https://github.com/paperclipai/paperclip/actions/runs/34398832930/job/102631274667).

## Risks

- Trust applies only to the server-selected startup root and isolated
configuration. Sandbox and tool permissions remain authoritative.
- Codex still requires approval of individual hook hashes. This change
does not bypass that policy.
- Providers without the required history APIs fail explicitly.
- Daytona acceptance used the production TypeScript driver. Remote
Paperclip UI and remote Rust execution were not tested.
- No new public API, database state, recovery policy, or UI control is
included.

## Model Used

OpenAI Codex, GPT-6 (`gpt-6-astra`). Used for reasoning, code edits,
tool use,
and test execution. The exact context-window limit is not exposed in
this
session. Real-provider acceptance used Codex CLI 0.153.4 with
`gpt-5.6-sol`.

## 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
#` 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-09 15:35:18 -05:00

442 lines
13 KiB
TypeScript

import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
CODEX_BLOCK_RESULT_OUTPUT_SCHEMA,
CODEX_RESULT_OUTPUT_SCHEMA,
createCodexTaskEnvelope,
isSkilllessCodexContext,
} from "../../contracts/codex.js";
import {
HarnessCapabilityUnavailableError,
HarnessOperationAlreadyTerminalError,
HarnessReconciliationError,
HarnessStaleTurnError,
type HarnessRuntimeRequestResolution,
} from "../../contracts/harness-driver.js";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
} from "../../reducer/session-reducer.js";
import {
validatePrpEvent,
type PrpCapabilities,
type PrpEvent,
type PrpStructuredRunResult,
} from "../../protocol/replay-contract.js";
import { loadLiveConsoleConformanceFixture } from "../../protocol/live-console-fixture.js";
import {
CodexAppServerDriver,
createIsolatedCodexAppServerArgs,
} from "./codex-app-server-driver.js";
import {
runCodexCodexTracer,
replayPersistedCodexEvents,
validateCodexResultProposal,
} from "../../mock-core/codex-runner.js";
import {
CODEX_INVALID_REQUEST,
CODEX_METHOD_NOT_FOUND,
CodexRpcError,
type CodexAppServerTransport,
type CodexRpcNotification,
type CodexRpcServerRequest,
type CodexServerRequestHandler,
type CodexTraceInterpretation,
} from "./app-server-transport.js";
export const WORKSPACE = realpathSync.native(process.cwd());
export class TestQueue<T> implements AsyncIterable<T> {
values: T[] = [];
waiters: Array<{
resolve: (value: IteratorResult<T>) => void;
reject: (error: Error) => void;
}> = [];
closed = false;
error: Error | null = null;
push(value: T): void {
const waiter = this.waiters.shift();
if (waiter) waiter.resolve({ value, done: false });
else this.values.push(value);
}
close(): void {
this.closed = true;
for (const waiter of this.waiters.splice(0))
waiter.resolve({ value: undefined, done: true });
}
fail(error: Error): void {
this.error = error;
for (const waiter of this.waiters.splice(0)) waiter.reject(error);
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: async () => {
const value = this.values.shift();
if (value) return { value, done: false };
if (this.error) throw this.error;
if (this.closed) return { value: undefined, done: true };
return new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
},
};
}
}
export class FakeCodexTransport implements CodexAppServerTransport {
readonly calls: Array<{ method: string; params: Record<string, unknown> }> =
[];
readonly sentNotifications: Array<{
method: string;
params?: Record<string, unknown>;
}> = [];
readonly traceInterpretations: CodexTraceInterpretation[] = [];
readonly queue = new TestQueue<CodexRpcNotification>();
handler: CodexServerRequestHandler = async () => ({});
rejectMethods = new Map<string, Error>();
readResponse: Record<string, unknown> | null = null;
turnStartResponse: Promise<Record<string, unknown>> | null = null;
goalState: Record<string, unknown> | null = null;
confirmCollaborationMode = true;
runtimeRequestResolver: ((input: {
requestId: string;
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}) => Promise<void>) | null = null;
constructor(
readonly threadId = "thread-1",
readonly providerSessionId = "provider-session-1",
readonly providerIdentity?: Record<string, unknown>,
) {}
async request(
method: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
this.calls.push({ method, params: structuredClone(params) });
const rejection = this.rejectMethods.get(method);
if (rejection) throw rejection;
if (method === "initialize") {
return {
userAgent: "codex-cli/0.132.0",
codexHome: "/isolated/codex",
platformFamily: "unix",
platformOs: "linux",
};
}
if (method === "collaborationMode/list") {
return this.confirmCollaborationMode
? {
data: [
{
name: "Plan",
mode: "plan",
model: "gpt-test",
reasoning_effort: "high",
},
],
}
: { data: [{ name: "Default", mode: "default", model: "gpt-test" }] };
}
if (method === "thread/start" || method === "thread/resume") {
const planMode =
params.permissions === "paperclip-runner-workspace-read-only";
return {
thread: {
id: this.threadId,
sessionId: this.providerSessionId,
...(this.providerIdentity === undefined
? {}
: { providerIdentity: structuredClone(this.providerIdentity) }),
modelProvider: "openai",
cwd: WORKSPACE,
turns: [],
activePermissionProfile: {
id: params.permissions ?? (planMode
? "paperclip-runner-workspace-read-only"
: "paperclip-runner-workspace-only"),
},
},
model: "gpt-test",
modelProvider: "openai",
cwd: WORKSPACE,
sandbox: { type: "workspaceWrite" },
approvalPolicy: params.approvalPolicy,
instructionSources: [],
};
}
if (method === "turn/start") {
return (
this.turnStartResponse ?? {
turn: { id: "turn-1", status: "inProgress", items: [] },
}
);
}
if (method === "thread/goal/get") return { goal: this.goalState };
if (method === "thread/goal/set") {
this.goalState = {
threadId: this.threadId,
objective:
typeof params.objective === "string"
? params.objective
: String(
this.goalState?.objective ?? "Ship the Live console tracer",
),
status: params.status ?? this.goalState?.status ?? "active",
tokenBudget: params.tokenBudget ?? this.goalState?.tokenBudget ?? null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 2,
};
return { goal: this.goalState };
}
if (method === "thread/goal/clear") {
this.goalState = null;
return {};
}
if (method === "thread/turns/list") {
const snapshot = this.readResponse ?? { thread: { turns: [{ id: "turn-1", status: "inProgress", items: [] }] } };
const turns = (snapshot.thread as Record<string, unknown>).turns;
return { data: Array.isArray(turns) ? turns.map(turn => ({ ...turn, items: [], itemsView: "notLoaded" })) : turns, nextCursor: null };
}
if (method === "thread/items/list") {
const turns = ((this.readResponse?.thread as Record<string, unknown> | undefined)?.turns ?? []) as Array<Record<string, unknown>>;
const turn = turns.find(value => value.id === params.turnId);
return { data: ((turn?.items ?? []) as Array<Record<string, unknown>>).map(item => ({ turnId: params.turnId, item })), nextCursor: null };
}
if (method === "thread/read") {
return structuredClone(
this.readResponse ?? {
thread: {
id: this.threadId,
sessionId: this.providerSessionId,
cwd: WORKSPACE,
turns: [{ id: "turn-1", status: "inProgress", items: [] }],
},
}
);
}
return {};
}
notify(method: string, params?: Record<string, unknown>): void {
this.sentNotifications.push(
params === undefined ? { method } : { method, params },
);
}
notifications(): AsyncIterable<CodexRpcNotification> {
return this.queue;
}
setServerRequestHandler(handler: CodexServerRequestHandler): void {
this.handler = handler;
}
async resolveRuntimeRequest(input: {
requestId: string;
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}): Promise<void> {
await this.runtimeRequestResolver?.(input);
}
recordTraceInterpretation(input: CodexTraceInterpretation): void {
this.traceInterpretations.push(structuredClone(input));
}
async close(): Promise<void> {
this.queue.close();
}
push(method: string, params: Record<string, unknown>): void {
this.queue.push({ method, params });
}
pushTraced(
method: string,
params: Record<string, unknown>,
sourceEventId: string,
sourceEventType: string,
): void {
this.queue.push({
method,
params,
paperclipTrace: { sourceEventId, sourceEventType },
});
}
invoke(request: CodexRpcServerRequest): Promise<Record<string, unknown>> {
return this.handler(request);
}
}
export const envelope = createCodexTaskEnvelope({
objective: "Create hello.txt with the text hello.",
criteria: [{ id: "file", requirement: "hello.txt contains hello" }],
});
export const liveConsoleFixturePath = fileURLToPath(
new URL(
"../../../protocol/fixtures/codex-driver/driver-conformance.json",
import.meta.url,
),
);
export const result: PrpStructuredRunResult = {
schema: "paperclip.run_result.v1",
reportedWorkDisposition: "done",
summary: "Created hello.txt.",
completionClaim: {
contractRevision: "codex-demo-v1",
objectiveSatisfied: true,
criteria: [
{ criterionId: "file", status: "satisfied", evidenceRefs: ["hello.txt"] },
],
remainingWork: [],
},
evidence: [{ ref: "hello.txt" }],
verification: [{ commandOrCheck: "read hello.txt", status: "passed" }],
attentionRequests: [],
artifacts: [{ kind: "file", ref: "hello.txt" }],
};
export function makeDriver(
transports: FakeCodexTransport[],
options: Record<string, unknown> = {},
) {
let index = 0;
return new CodexAppServerDriver({
taskEnvelope: envelope,
environment: {
PATH: "/bin",
HOME: "/isolated/home",
CODEX_HOME: "/isolated/codex",
LANG: "C.UTF-8",
PAPERCLIP_API_KEY: "must-not-pass",
RANDOM_SKILL_PATH: "/skills/unrelated",
},
now: () => new Date("2026-08-08T12:00:00.000Z"),
transportFactory: () => transports[index++]!,
...options,
});
}
export async function collectUntilTerminal(
events: AsyncIterable<PrpEvent>,
): Promise<PrpEvent[]> {
const collected: PrpEvent[] = [];
for await (const event of events) {
collected.push(event);
if (
[
"turn.completed",
"turn.failed",
"turn.interrupted",
"turn.cancelled",
].includes(event.eventType)
)
break;
}
return collected;
}
export function reverseObjectKeys(value: unknown): unknown {
if (Array.isArray(value)) return value.map(reverseObjectKeys);
if (typeof value !== "object" || value === null) return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.reverse()
.map(([key, entry]) => [key, reverseObjectKeys(entry)]),
);
}
export async function traceCompletedProposal(
proposal: PrpStructuredRunResult | null,
options: {
runId?: string;
normalizedSessionId?: string;
capabilities?: Record<string, boolean>;
steer?: string;
interrupt?: boolean;
} = {},
) {
const transport = new FakeCodexTransport();
const driver = makeDriver([transport], {
capabilities: options.capabilities,
});
const traced = runCodexCodexTracer({
driver,
taskEnvelope: envelope,
workingDirectory: WORKSPACE,
runId: options.runId,
normalizedSessionId: options.normalizedSessionId,
steer: options.steer,
interrupt: options.interrupt,
});
while (!transport.calls.some((call) => call.method === "turn/start")) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
transport.push("turn/started", {
threadId: "thread-1",
turn: { id: "turn-1", status: "inProgress" },
});
if (proposal !== null) {
transport.push("item/completed", {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "answer-1",
type: "agentMessage",
text: JSON.stringify(proposal),
},
});
}
transport.push("turn/completed", {
threadId: "thread-1",
turn: { id: "turn-1", status: "completed", items: [] },
});
return { trace: await traced, transport };
}
export { describe, expect, it, vi } from "vitest";
export {
CODEX_BLOCK_RESULT_OUTPUT_SCHEMA,
CODEX_RESULT_OUTPUT_SCHEMA,
createCodexTaskEnvelope,
isSkilllessCodexContext,
HarnessCapabilityUnavailableError,
HarnessOperationAlreadyTerminalError,
HarnessReconciliationError,
HarnessStaleTurnError,
applyPrpEvent,
createSessionSnapshotFromMetadata,
validatePrpEvent,
loadLiveConsoleConformanceFixture,
CodexAppServerDriver,
createIsolatedCodexAppServerArgs,
runCodexCodexTracer,
replayPersistedCodexEvents,
validateCodexResultProposal,
CODEX_INVALID_REQUEST,
CODEX_METHOD_NOT_FOUND,
CodexRpcError,
};
export type {
HarnessRuntimeRequestResolution,
PrpCapabilities,
PrpEvent,
PrpStructuredRunResult,
CodexAppServerTransport,
CodexRpcNotification,
CodexRpcServerRequest,
CodexServerRequestHandler,
CodexTraceInterpretation,
};