Files
PaperClipAI/server/src/services/native-runtime/native-codex-runner.integration.test.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

351 lines
11 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
heartbeatRunEvents,
heartbeatRuns,
issues,
nativeRunFinalizations,
nativeRunResults,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "../../__tests__/helpers/embedded-postgres.js";
import {
runnerPrpWebSocketInternals,
setupRunnerPrpWebSocketServer,
} from "../../realtime/runner-prp-ws.js";
import { executeNativeCodexRunner } from "./native-codex-runner.js";
import { prepareNativeHeartbeatRun } from "./prepare-native-run.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping native Codex vertical-slice test on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
const runnerWorkspace = resolve(
import.meta.dirname,
"../../../../packages/paperclip-runner/runner",
);
const executableSuffix = process.platform === "win32" ? ".exe" : "";
const runnerBinary = resolve(
runnerWorkspace,
"target",
"release",
`paperclip-runnerd${executableSuffix}`,
);
const fakeCodexBinary = resolve(
runnerWorkspace,
"target",
"release",
`fake-codex-app-server${executableSuffix}`,
);
function ensureRunnerTestBinaries(): void {
// Cargo's freshness check is necessary even when the files exist: an older
// fake provider can otherwise exercise a different protocol than the source.
execFileSync("cargo", [
"build",
"--release",
"--locked",
"-p",
"paperclip-runner-core",
"--bin",
"paperclip-runnerd",
"--bin",
"fake-codex-app-server",
], {
cwd: runnerWorkspace,
stdio: "inherit",
// A clean release build includes every qualified managed-provider SDK.
// Keep this below the CI job deadline while allowing that cold compile to
// finish on GitHub-hosted runners.
timeout: 600_000,
});
}
async function closeServer(server: Server | null): Promise<void> {
if (!server) return;
server.closeAllConnections();
if (!server.listening) return;
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => error ? rejectClose(error) : resolveClose());
});
}
describeEmbeddedPostgres("native Codex server vertical slice", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let runtimeRoot: string | null = null;
let server: Server | null = null;
beforeAll(async () => {
ensureRunnerTestBinaries();
temporary = await startEmbeddedPostgresTestDatabase("native-codex-vertical-slice-");
runtimeRoot = await mkdtemp(resolve(tmpdir(), "native-codex-runtime-"));
server = createServer();
await new Promise<void>((resolveListen) => server!.listen(0, "127.0.0.1", resolveListen));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
setupRunnerPrpWebSocketServer(server, {
apiUrl: `http://127.0.0.1:${address.port}`,
});
}, 660_000);
afterAll(async () => {
runnerPrpWebSocketInternals.resetForTests();
await closeServer(server);
await temporary?.cleanup();
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
});
it("returns a durable result and resumes the provider session on the next run", async () => {
if (!temporary || !runtimeRoot) throw new Error("Vertical-slice fixture was not initialized");
const db = createDb(temporary.connectionString);
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Native Codex vertical slice",
issuePrefix: "NCV",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Native Codex",
role: "engineer",
status: "active",
adapterType: "paperclip_runner",
adapterConfig: { provider: "codex" },
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "NCV-1",
title: "Complete the native Codex vertical slice",
description: "Return a bound structured completion result.",
status: "in_progress",
priority: "medium",
workMode: "standard",
assigneeAgentId: agentId,
});
const [run] = await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId },
}).returning();
if (!run) throw new Error("Failed to seed native run");
await db
.update(issues)
.set({ executionRunId: runId })
.where(eq(issues.id, issueId));
const native = await prepareNativeHeartbeatRun({
db,
run,
issue: {
id: issueId,
title: "Complete the native Codex vertical slice",
description: "Return a bound structured completion result.",
reviewPolicy: null,
},
environmentLeaseId: "lease-native-codex-e2e",
});
const logs: string[] = [];
const execute = executeNativeCodexRunner({
db,
companyId,
issueId,
runId,
agentId,
runnerInstanceId: native.runnerInstanceId,
environmentLeaseId: native.environmentLeaseId,
normalizedSessionId: native.normalizedSessionId,
turnId: native.turnId,
itemId: native.itemId,
cwd: tmpdir(),
prompt: "Complete the fake native Codex turn.",
model: "test-model",
resumeProviderSessionId: null,
completionContract: native.completionContract,
timeoutMs: 30_000,
environment: {},
runnerBinary,
runtimeRoot,
providerLaunch: {
command: fakeCodexBinary,
args: [
"--state-file",
resolve(runtimeRoot, "fake-codex-state.json"),
"--call-log",
resolve(runtimeRoot, "fake-codex-calls.log"),
"--require-dynamic-tool",
"--emit-tool-call",
"--expected-canonical-task-context",
JSON.stringify({
companyId,
actorId: agentId,
taskId: issueId,
runId,
}),
],
providerVersion: "fake-codex-v1",
},
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
onSpawn: async () => undefined,
});
const result = await execute.catch((error) => {
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n${logs.join("")}`,
);
});
expect(result).toMatchObject({
exitCode: 0,
signal: null,
timedOut: false,
provider: "codex",
sessionParams: { sessionId: "codex-thread-1" },
summary: "Codex completed the fake turn.",
resultJson: {
nativeRunner: {
result: {
schema: "paperclip.run_result.v1",
completionClaim: {
contractRevision: "1",
objectiveSatisfied: true,
criteria: [{ criterionId: "objective", status: "satisfied" }],
},
},
terminal: {
schema: "paperclip.prp.terminal.v1",
runTerminalState: "succeeded",
},
},
},
});
expect(logs.join("\n")).not.toContain("PAPERCLIP_RUNNER_BOOTSTRAP_TICKET");
const [persistedResult] = await db
.select()
.from(nativeRunResults)
.where(eq(nativeRunResults.runId, runId));
expect(persistedResult).toMatchObject({ schemaStatus: "accepted" });
const [finalization] = await db
.select()
.from(nativeRunFinalizations)
.where(eq(nativeRunFinalizations.runId, runId));
expect(finalization).toMatchObject({ phase: "workspace_finalizing" });
const eventTypes = await db
.select({ eventType: heartbeatRunEvents.eventType })
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId));
expect(eventTypes.map((event) => event.eventType)).toEqual(expect.arrayContaining([
"semantic_tool.input",
"semantic_tool.result",
"turn.completed",
"run.result.proposed",
"run.terminal",
]));
const resumedRunId = randomUUID();
const [resumedRun] = await db.insert(heartbeatRuns).values({
id: resumedRunId,
companyId,
agentId,
status: "running",
invocationSource: "assignment",
triggerDetail: "system",
contextSnapshot: { issueId },
}).returning();
if (!resumedRun) throw new Error("Failed to seed resumed native run");
await db
.update(issues)
.set({ executionRunId: resumedRunId })
.where(eq(issues.id, issueId));
const resumedNative = await prepareNativeHeartbeatRun({
db,
run: resumedRun,
issue: {
id: issueId,
title: "Complete the native Codex vertical slice",
description: "Return a bound structured completion result.",
reviewPolicy: null,
},
environmentLeaseId: "lease-native-codex-resume",
});
const resumed = await executeNativeCodexRunner({
db,
companyId,
issueId,
runId: resumedRunId,
agentId,
runnerInstanceId: resumedNative.runnerInstanceId,
environmentLeaseId: resumedNative.environmentLeaseId,
normalizedSessionId: resumedNative.normalizedSessionId,
turnId: resumedNative.turnId,
itemId: resumedNative.itemId,
cwd: tmpdir(),
prompt: "Continue the fake native Codex session.",
model: "test-model",
resumeProviderSessionId: "codex-thread-1",
completionContract: resumedNative.completionContract,
timeoutMs: 30_000,
environment: {},
runnerBinary,
runtimeRoot,
providerLaunch: {
command: fakeCodexBinary,
args: [
"--state-file",
resolve(runtimeRoot, "fake-codex-state.json"),
"--call-log",
resolve(runtimeRoot, "fake-codex-calls.log"),
],
providerVersion: "fake-codex-v1",
},
onLog: async (_stream, chunk) => {
logs.push(chunk);
},
onSpawn: async () => undefined,
});
expect(resumed).toMatchObject({
exitCode: 0,
sessionParams: { sessionId: "codex-thread-1" },
});
const providerCalls = await readFile(
resolve(runtimeRoot, "fake-codex-calls.log"),
"utf8",
);
expect(providerCalls.match(/^thread\/start$/gm)).toHaveLength(1);
expect(providerCalls.match(/^thread\/resume$/gm)).toHaveLength(1);
}, 60_000);
});