mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## 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>
442 lines
13 KiB
TypeScript
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,
|
|
};
|