diff --git a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts index 90e98ca0c2b..3782d09e129 100644 --- a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts +++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts @@ -11,7 +11,11 @@ import type { } from "../contracts/harness-driver.js"; import { OpenCodeServerDriver } from "../drivers/opencode/opencode-server-driver.js"; import { parseNativeRuntimeContext } from "../contracts/runtime-context.js"; -import { openCodeProxyCompletionContract, openCodeProxyTaskEnvelope } from "./opencode-proxy-task-envelope.js"; +import { + openCodeProxyCompletionContract, + openCodeProxyTaskEnvelope, + sameOpenCodeProxyCompletionContract, +} from "./opencode-proxy-task-envelope.js"; import { openCodeProxyItemNotification, openCodeProxyToolNotification, @@ -50,6 +54,11 @@ let cwd = ""; let activeModel = ""; let activeTurnId: string | null = null; let activeRunId = process.env.PAPERCLIP_RUN_ID ?? "runnerd-run"; +// The contract the open session checks finish claims against. The launch +// environment and the launch parameters can belong to different runs: runnerd +// restores a saved session with its saved contract while its environment +// already names the run being attached. +let activeCompletionContract: ReturnType = undefined; const announcedTurnIds = new Set(); const launchBinding = trustedOpenCodeLaunchBinding(process.argv.slice(2)); const usageLedger = new OpenCodeProxyUsageLedger(); @@ -166,6 +175,7 @@ async function open( workingDirectory: cwd, }); } + activeCompletionContract = openCodeProxyCompletionContract(params.completionContract); eventPump = pumpEvents(session); void eventPump.catch((error) => failProxy(error)); return threadResponse( @@ -354,13 +364,16 @@ async function handle(message: RpcMessage): Promise { const runId = params.paperclipRunId; if (typeof runId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(runId)) throw new Error("Invalid Paperclip run identity"); - if (runId !== activeRunId) { + // Runnerd sends the attached run's contract with its run identity; + // the launch parameters may still name an earlier run's contract, + // even when the launch environment already names this run. + const contract = openCodeProxyCompletionContract(params.paperclipCompletionContract); + if (runId !== activeRunId + || (contract !== undefined && !sameOpenCodeProxyCompletionContract(contract, activeCompletionContract))) { if (!session.attachRun) throw new Error("OpenCode run attachment unavailable"); - // Runnerd sends the attached run's contract with its run identity; - // the launch parameters still name the first run's contract. - const contract = openCodeProxyCompletionContract(params.paperclipCompletionContract); await session.attachRun({ runId, ...(contract ? { completionContract: contract } : {}) }); activeRunId = runId; + if (contract !== undefined) activeCompletionContract = contract; } } const inputItems = Array.isArray(params.input) diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts b/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts index e29ff22ba03..da64ce04f17 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-input.test.ts @@ -183,3 +183,82 @@ it.each([true, false])("settles a real proxy interrupt with controller feedback await rm(root, { recursive: true, force: true }); } }, 30_000); + +// Runnerd restores a saved session before it applies `run.attach`: the proxy +// launches with the saved run's contract while its environment already names +// the attached run. The attached run's contract still arrives on turn/start. +it("binds the current contract when a restored launch already names the attached run", async () => { + const { execFileSync, spawn } = await import("node:child_process"); + const { chmod, mkdir, mkdtemp, rm } = await import("node:fs/promises"); + const { closeSync, openSync } = await import("node:fs"); + const { randomUUID } = await import("node:crypto"); + const { tmpdir } = await import("node:os"); + const { join, resolve } = await import("node:path"); + const { createInterface } = await import("node:readline"); + const root = await mkdtemp(join(tmpdir(), "opencode-proxy-restored-contract-")); + const launchRoot = join(root, `.paperclip-verified-executable-${randomUUID().replaceAll("-", "")}`); + await mkdir(launchRoot, { mode: 0o700 }); + await chmod(launchRoot, 0o700); + const executable = join(launchRoot, "launch"); + const fixture = resolve("test/fixtures/fake-opencode-server.mjs"); + execFileSync("cc", ["-x", "c", "-o", executable, "-"], { input: `#include \n#include \nint main(int argc, char **argv) { char **args = calloc(argc + 2, sizeof(char *)); args[0] = ${JSON.stringify(process.execPath)}; args[1] = ${JSON.stringify(fixture)}; for (int i = 1; i < argc; i++) args[i + 1] = argv[i]; execv(args[0], args); return 127; }` }); + if (process.platform === "darwin") { + execFileSync("codesign", ["--force", "--sign", "-", executable]); + execFileSync("codesign", ["--verify", "--strict", executable]); + } + await chmod(executable, 0o500); + const proxy = join(root, "proxy.cjs"); + await (await import("node:fs/promises")).writeFile(proxy, execFileSync(process.execPath, ["--input-type=module", "-e", `import { bundleVerifiedProviderEntrypoints } from "./scripts/build-verified-provider-entrypoints.mjs"; const entries = await bundleVerifiedProviderEntrypoints({ write: false }); process.stdout.write(entries.find(({ entrypoint }) => entrypoint.name === "opencode-app-server-proxy").verifiedResult.outputFiles[0].contents);`], { maxBuffer: 16 * 1024 * 1024 })); + const fd = process.platform === "linux" ? openSync(executable, "r") : undefined; + const child = spawn(process.execPath, [proxy, "--paperclip-trusted-opencode-executable", fd === undefined ? executable : "/proc/self/fd/3"], { + env: { ...process.env, PAPERCLIP_OPENCODE_RUNTIME_DIR: join(root, "runtime"), OPENROUTER_API_KEY: "fixture-key", + PAPERCLIP_RUN_ID: "attached-run-2" }, + stdio: fd === undefined ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe", fd], + detached: process.platform !== "win32", + }); + if (fd !== undefined) closeSync(fd); + const messages: Array<{ id?: string | number; method?: string; params?: Record; result?: unknown; error?: unknown }> = []; + const input = createInterface({ input: child.stdout! }); + input.on("line", line => messages.push(JSON.parse(line))); + child.stderr!.resume(); + let exited = false; + const exit = new Promise(resolve => child.once("exit", () => { exited = true; resolve(); })); + const send = (message: unknown) => child.stdin!.write(`${JSON.stringify(message)}\n`); + const response = async (id: number) => { + await expect.poll(() => messages.find(message => message.id === id), { timeout: 5_000 }).toBeDefined(); + const value = messages.find(message => message.id === id)!; + expect(value.error).toBeUndefined(); + return value.result; + }; + try { + send({ id: 1, method: "initialize", params: {} }); + await response(1); + // Launch parameters carry the saved run's contract. + send({ id: 2, method: "thread/start", params: { cwd: root, model: "openrouter/deepseek/deepseek-v4-flash-0731", completionContract: { revision: "1", criterionIds: ["objective"] }, conversationMode: "prepared" } }); + await response(2); + send({ id: 3, method: "turn/start", params: { paperclipRunId: "attached-run-2", + paperclipCompletionContract: { revision: "2", criterionIds: ["pending_comment_1", "pending_comment_2"] }, + input: [{ type: "text", text: JSON.stringify({ schema: "paperclip.native-model-envelope.v3", task: { prompt: "completion-feedback" }, + completionContract: { revision: "2", criteria: [{ id: "pending_comment_1", requirement: "Use a readable font." }, { id: "pending_comment_2", requirement: "Add a contact section." }] } }) }] } }); + await response(3); + // The finish claim reaches the controller on its first try. + await expect.poll(() => messages.find(message => message.method === "item/tool/call"), { timeout: 5_000 }).toBeDefined(); + const call = messages.find(message => message.method === "item/tool/call")!; + expect(call.params).toMatchObject({ tool: "paperclip_finish", arguments: { completionClaim: { contractRevision: "2" } } }); + send({ id: call.id, result: { success: true, contentItems: [{ type: "inputText", text: "Accepted." }] } }); + await expect.poll(() => messages.filter(message => message.method === "paperclip/runResult").length, { timeout: 5_000 }).toBe(1); + expect(JSON.stringify(messages.find(message => message.method === "paperclip/runResult"))).toContain('"contractRevision":"2"'); + child.stdin!.end(); + await expect.poll(() => exited, { timeout: 5_000 }).toBe(true); + await exit; + } finally { + child.kill("SIGTERM"); + if (!exited) { + await Promise.race([exit, new Promise(resolve => setTimeout(resolve, 1_000))]); + if (!exited && child.pid) process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL"); + await exit; + } + input.close(); + await rm(root, { recursive: true, force: true }); + } +}, 30_000); diff --git a/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts b/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts index d19cbacb52b..a871a1cb0b7 100644 --- a/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts +++ b/packages/paperclip-runner/src/cli/opencode-proxy-task-envelope.ts @@ -22,6 +22,17 @@ export function openCodeProxyCompletionContract(value: unknown): { revision: str : undefined; } +/** Whether two per-turn contracts name the same revision and criterion ids, in order. */ +export function sameOpenCodeProxyCompletionContract( + left: { revision: string; criterionIds: readonly string[] } | undefined, + right: { revision: string; criterionIds: readonly string[] } | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right; + return left.revision === right.revision + && left.criterionIds.length === right.criterionIds.length + && left.criterionIds.every((id, index) => id === right.criterionIds[index]); +} + export function openCodeProxyTaskEnvelope(params: Record) { const completionContract = record(params.completionContract); const revision = text(completionContract.revision).trim(); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index 9afbe5c5a51..a1e31515dcf 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -2549,6 +2549,25 @@ describe("OpenCodeServerDriver", () => { await session.close({ reason: "test" }); }); + it("names the expected revision in a repairable stale-revision tool error", async () => { + await chmod(fixture, 0o755); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-revision-")); + const workspace = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-")); + roots.push(root, workspace); + const driver = new OpenCodeServerDriver({ model: "openrouter/deepseek/deepseek-v4-flash-0731", runtimeDirectory: root, command: fixture, environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" }, taskEnvelope: createCodexTaskEnvelope({ objective: "Apply the accepted decision", contractRevision: "approval-v2", criteria: [{ id: "human_response", requirement: "Apply the approved response" }] }) }); + const session = await driver.openSession({ runId: "revision-repair", normalizedSessionId: "revision-repair", workingDirectory: workspace }); + await session.startTurn({ message: { role: "user", text: "repair-revision" } }); + const events = await collectTurnEvents(session.events()); + const results = events.filter((event) => event.eventType === "run.result.proposed"); + expect(results).toHaveLength(1); + expect(results[0].payload).toMatchObject({ completionClaim: { contractRevision: "approval-v2" } }); + const files = await readdir(root, { recursive: true }); + const evidence = files.find((name) => name.endsWith("fake-revision-repair.json")); + expect(evidence).toBeDefined(); + expect(JSON.parse(await readFile(join(root, evidence!), "utf8"))).toMatchObject({ result: { isError: true, content: [{ text: expect.stringContaining('Use contractRevision "approval-v2"') }] } }); + await session.close({ reason: "test" }); + }); + it.each(["monitor", "response_wake"] as const)("resolves an omitted %s through the real OpenCode MCP boundary only from current server state", async kind => { for (const eligible of [true, false]) { await chmod(fixture, 0o755); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 72f2b5801f1..8d536ccaa78 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -1232,8 +1232,10 @@ class OpenCodeHarnessSession implements HarnessSession { validation.result.completionClaim.contractRevision !== this.#taskEnvelope.completionContract.revision ) { + // Name the expected revision so the provider can repair its claim + // in this turn, as the criteria error below does. throw new Error( - "Semantic result completion contract revision does not match", + `Semantic result completion contract revision does not match. Use contractRevision ${JSON.stringify(this.#taskEnvelope.completionContract.revision)}.`, ); } const expectedIds = this.#taskEnvelope.completionContract.criteria.map((criterion) => criterion.id); diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index f344de74f2d..83a81d42209 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -275,6 +275,12 @@ async function callTerminalTool(promptBody, signal) { const rejected = await mcpRequest("tools/call", { name: "paperclip_finish", arguments: bad }); await writeFile(join(process.env.XDG_DATA_HOME, "fake-criteria-repair.json"), JSON.stringify(rejected)); } + if (String(prompt.message ?? "").includes("repair-revision")) { + const stale = structuredClone(result); + stale.completionClaim.contractRevision = "stale-revision"; + const rejected = await mcpRequest("tools/call", { name: "paperclip_finish", arguments: stale }); + await writeFile(join(process.env.XDG_DATA_HOME, "fake-revision-repair.json"), JSON.stringify(rejected)); + } const call = () => mcpRequest("tools/call", { name: blocked ? "paperclip_block" : "paperclip_finish", arguments: result,