diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 06b8c83a84e..745b58d5309 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -280,7 +280,7 @@ describe("NativeExecutionInputV1", () => { })).toThrow("eventExpiryDays"); }); - it.each([1, 2, 3] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { + it.each([1, 2, 3, 4] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { const provider = { kind: "acpx", agent: "pi", @@ -312,7 +312,7 @@ describe("NativeExecutionInputV1", () => { profile: provider.profile, }); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 4, 1.5, "3", null]) { + for (const unsupportedVersion of [0, 5, 1.5, "4", null]) { expect(() => parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "acpx_runtime" }, diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 0416a4cf603..b6ae5e4fe2e 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -90,7 +90,7 @@ export interface NativeAcpxProfileSnapshot { protocolVersion: 1; acpxVersion: "0.13.1"; agent: NativeAcpxAgent; - agentProfileVersion: 1 | 2 | 3; + agentProfileVersion: 1 | 2 | 3 | 4; agentServerPackage: string; agentServerVersion: string; agentRuntimePackage: string | null; @@ -596,7 +596,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput || profile.protocolVersion !== 1 || profile.acpxVersion !== "0.13.1" || profile.agent !== provider.agent - || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3) + || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4) ) { throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 902707dea44..5d9aa016c61 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -11,7 +11,7 @@ export interface QualifiedAcpxProfile { readonly protocolVersion: typeof ACPX_DRIVER_PROTOCOL_VERSION; readonly acpxVersion: typeof QUALIFIED_ACPX_VERSION; readonly agent: QualifiedAcpxAgent; - readonly agentProfileVersion: 1 | 2 | 3; + readonly agentProfileVersion: 1 | 2 | 3 | 4; readonly qualificationStatus?: "pending"; readonly modelPolicy?: "explicit-provider-verified"; /** Wire identity: an npm package name or a runner-owned builtin: identifier. */ diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index aba6ede9097..bad027bc29b 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -7379,6 +7379,79 @@ it("admits only exact live Pi turn controls", () => { expect(() => parseAcpxTurnControlCapabilities({ steering: true, queuedFollowUp: false }, agent)).toThrow("cannot advertise"); } }); + +async function withPreparedOpenCodeCleanup(input: { + run: () => Promise; + closeSession: () => Promise; + closeTransport: () => Promise; + removeRoot: () => Promise; + evidence: () => Pick< + ReturnType["evidence"]>, + "runnerPid" | "runnerExited" | "runnerExitCode" | "runnerSignal" | "diagnostics" + >; +}): Promise { + const failures: { stage: string; error: unknown }[] = []; + for (const [stage, operation] of [ + ["run", input.run], + ["session.close", input.closeSession], + ["transport.close", input.closeTransport], + ["removeRoot", input.removeRoot], + ] as const) { + try { + await operation(); + } catch (error) { + failures.push({ stage, error }); + } + } + if (failures.length === 0) return; + const evidence = input.evidence(); + // Never dump stderr, environment values, prompts, paths, or arbitrary + // diagnostic text. Keep only bounded process/settlement facts alongside + // the original errors, so a finally failure cannot hide the primary one. + const summary = { + failedStages: failures.map(({ stage }) => stage), + runnerStarted: evidence.runnerPid !== null, + runnerExited: evidence.runnerExited, + runnerExitCode: evidence.runnerExitCode, + runnerSignalled: evidence.runnerSignal !== null, + diagnosticCount: evidence.diagnostics.length, + suspensionRejected: evidence.diagnostics.includes( + "runner did not prove durable suspension before checkpoint", + ), + }; + throw new AggregateError( + failures.map(({ error }) => error), + `Prepared OpenCode fixture failed: ${JSON.stringify(summary)}`, + { cause: failures[0]!.error }, + ); +} + +it.each([true, false])("preserves prepared OpenCode cleanup errors (primary failure: %s)", async (failRun) => { + const primary = new Error("prepared input assertion failed"); + const cleanup = new NativeSessionCloseUnrecoverableError(); + const calls: string[] = []; + const evidence = { + runnerPid: 123, + runnerExited: true, + runnerExitCode: 1, + runnerSignal: null, + diagnostics: ["Bearer fixture-secret", "runner did not prove durable suspension before checkpoint"], + }; + const failure = await withPreparedOpenCodeCleanup({ + run: async () => { calls.push("run"); if (failRun) throw primary; }, + closeSession: async () => { calls.push("session.close"); throw cleanup; }, + closeTransport: async () => { calls.push("transport.close"); }, + removeRoot: async () => { calls.push("removeRoot"); }, + evidence: () => evidence, + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as AggregateError).errors).toEqual(failRun ? [primary, cleanup] : [cleanup]); + expect((failure as AggregateError).cause).toBe(failRun ? primary : cleanup); + expect(calls).toEqual(["run", "session.close", "transport.close", "removeRoot"]); + expect((failure as Error).message).toContain('"suspensionRejected":true'); + expect((failure as Error).message).not.toContain("fixture-secret"); +}); + it("preserves prepared input through runnerd and the real OpenCode proxy boundary", async () => { const root = await mkdtemp(join(tmpdir(), "runnerd-prepared-opencode-")); // The qualified launch boundary unlinks its executable after exec. Use a @@ -7432,19 +7505,21 @@ it("preserves prepared input through runnerd and the real OpenCode proxy boundar task: { prompt: "Keep this request unchanged." }, completionContract: { revision: "prepared-v1", criteria: task.completionContract.criteria }, }); - try { - session = await driver.openSession({ runId: "prepared-opencode", normalizedSessionId: "prepared-opencode", workingDirectory: root }); - await session.startTurn({ message: { role: "user", text: prepared } }); - for await (const event of session.events()) { - if (event.eventType === "turn.completed") break; - } - const sessionRoots = (await readdir(runtime, { withFileTypes: true })).filter((entry) => entry.isDirectory()); - expect(sessionRoots).toHaveLength(1); - const requests = (await readFile(join(runtime, sessionRoots[0]!.name, "data/fake-prompt-requests.ndjson"), "utf8")).trim().split("\n").map((line) => JSON.parse(line)); - expect(requests.map((request) => request.parts)).toEqual([[{ type: "text", text: prepared }]]); - } finally { - await session?.close(); - await bundle.transport.close(); - await rm(root, { recursive: true, force: true }); - } + await withPreparedOpenCodeCleanup({ + run: async () => { + session = await driver.openSession({ runId: "prepared-opencode", normalizedSessionId: "prepared-opencode", workingDirectory: root }); + await session.startTurn({ message: { role: "user", text: prepared } }); + for await (const event of session.events()) { + if (event.eventType === "turn.completed") break; + } + const sessionRoots = (await readdir(runtime, { withFileTypes: true })).filter((entry) => entry.isDirectory()); + expect(sessionRoots).toHaveLength(1); + const requests = (await readFile(join(runtime, sessionRoots[0]!.name, "data/fake-prompt-requests.ndjson"), "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + expect(requests.map((request) => request.parts)).toEqual([[{ type: "text", text: prepared }]]); + }, + closeSession: async () => { await session?.close(); }, + closeTransport: () => bundle.transport.close(), + removeRoot: () => rm(root, { recursive: true, force: true }), + evidence: () => bundle.evidence(), + }); }, 30_000);