From 17c89f48f69ae97556fc658b7b2151824d5ebbf8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 3 Nov 2026 07:37:09 -0600 Subject: [PATCH] fix(opencode): retain closed finish input diagnostics Co-Authored-By: Paperclip --- doc/execution-semantics.md | 7 ++ .../opencode/mcp-bridge-diagnostics.test.ts | 89 +++++++++++++++++++ .../src/drivers/opencode/mcp-bridge.ts | 34 ++++++- .../opencode/opencode-server-driver.test.ts | 3 + .../opencode/opencode-server-driver.ts | 1 + 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 60b1ddb5c10..0f4291f2f4c 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2057,6 +2057,13 @@ pending and continue to block a new run; a longer wait does not certify an outco The one-hour client deadline does not renew with the Runner lease. If the client deadline expires while Runner still owns a call, its effect remains unknown until authoritative reconciliation. Expiry permits neither replay nor attachment. +The OpenCode bridge emits a closed finish-input diagnostic through its existing +optional diagnostic sink. It records the effective offered schema's SHA-256 and, +for at most eight rejected inputs per bridge, the argument root and continuation +presence/type enums. It records no argument values, field names, schema text or +validator messages. A failed sink does not change admission or cancellation. +These facts distinguish a schema/input-shape rejection from a pending MCP call; +they do not identify the submitted value in older attempts without that evidence. An adopted transport can receive authenticated queued semantic calls before the driver publishes its recovered session and tool handler. It holds those calls until the handler is installed, then rechecks the original authority and diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts new file mode 100644 index 00000000000..cb96639a867 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts @@ -0,0 +1,89 @@ +import { createHash } from "node:crypto"; +import { afterEach, expect, it, vi } from "vitest"; +import { startOpenCodeMcpBridge, type OpenCodeMcpBridge } from "./mcp-bridge.js"; + +const bridges: OpenCodeMcpBridge[] = []; +afterEach(async () => { await Promise.all(bridges.splice(0).map(bridge => bridge.close())); }); + +const report = { + reportedWorkDisposition: "done", summary: "Completed the synthetic request.", + completionClaim: { contractRevision: "1", objectiveSatisfied: true, + criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }], remainingWork: [] }, + evidence: [], verification: [], +}; + +async function rpc(bridge: OpenCodeMcpBridge, id: number, method: string, params?: unknown) { + return (await fetch(bridge.url, { method: "POST", headers: { + Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json", + }, body: JSON.stringify({ jsonrpc: "2.0", id, method, params }) })).json(); +} + +it("binds closed rejection shapes to the actual offered finish schema without recording values", async () => { + const messages: string[] = [], handler = vi.fn(async () => ({ accepted: true })); + const bridge = await startOpenCodeMcpBridge({ handler, onDiagnostic: message => messages.push(message) }); + bridges.push(bridge); + const listed = await rpc(bridge, 1, "tools/list"); + const schema = listed.result.tools.find((tool: { name: string }) => tool.name === "paperclip_finish").inputSchema; + const digest = createHash("sha256").update(JSON.stringify(schema)).digest("hex"); + for (const [index, input] of [report, { ...report, continuation: null }].entries()) { + const result = await rpc(bridge, index + 2, "tools/call", { name: "paperclip_finish", arguments: input }); + expect(result.result.isError).not.toBe(true); + } + for (const [index, continuation] of ["PRIVATE_INPUT_VALUE", [], 42, false].entries()) { + const rejected = await rpc(bridge, index + 4, "tools/call", { + name: "paperclip_finish", arguments: { ...report, continuation }, + }); + expect(rejected.result.isError).toBe(true); + } + expect(handler).toHaveBeenCalledTimes(2); + expect(messages.map(message => JSON.parse(message))).toEqual([ + { schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "offered", schemaSha256: digest }, + ...["string", "array", "number", "boolean"].map(continuationShape => ({ + schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected", + schemaSha256: digest, rootShape: "object", continuationShape, + })), + ]); + expect(JSON.stringify(messages)).not.toMatch(/PRIVATE_INPUT_VALUE|Completed|criteria|continuation.*must|instancePath/); +}); + +it("reports the effective supplied schema and bounds diagnostics without admitting rejected inputs", async () => { + const messages: string[] = [], handler = vi.fn(); + const inputSchema = { type: "object", required: ["continuation"], properties: { continuation: { type: "object" } } }; + const bridge = await startOpenCodeMcpBridge({ + tools: [{ name: "paperclip_finish", inputSchema }], handler, + onDiagnostic: message => messages.push(message), + }); + bridges.push(bridge); + for (let index = 0; index < 12; index++) { + const rejected = await rpc(bridge, index, "tools/call", { + name: "paperclip_finish", arguments: index === 0 ? [] : { continuation: null }, + }); + expect(rejected.result.isError).toBe(true); + } + expect(handler).not.toHaveBeenCalled(); + expect(messages).toHaveLength(9); + expect(JSON.parse(messages[0]!)).toMatchObject({ schemaSha256: createHash("sha256").update(JSON.stringify(inputSchema)).digest("hex") }); + expect(JSON.parse(messages[1]!)).toMatchObject({ rootShape: "array", continuationShape: "omitted" }); + expect(JSON.parse(messages[2]!)).toMatchObject({ rootShape: "object", continuationShape: "null" }); + expect(messages.every(message => Buffer.byteLength(message) < 320)).toBe(true); +}); + +it("keeps diagnostics optional and nonfatal while preserving cancellation", async () => { + const aborted = vi.fn(); + let started!: () => void; + const begun = new Promise(resolve => { started = resolve; }); + const bridge = await startOpenCodeMcpBridge({ timeoutMs: null, + onDiagnostic: () => { throw new Error("unavailable diagnostic sink"); }, + handler: ({ signal }) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { aborted(); reject(new Error("cancelled")); }); + started(); + }), + }); + bridges.push(bridge); + const pending = rpc(bridge, 1, "tools/call", { name: "paperclip_finish", arguments: report }); + await begun; + await fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 1 } }) }); + expect((await pending).result.isError).toBe(true); + expect(aborted).toHaveBeenCalledOnce(); +}); diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts index 40377fe56ad..ec28b35cf62 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts @@ -1,4 +1,4 @@ -import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { Ajv2020, type ValidateFunction } from "ajv/dist/2020.js"; @@ -35,6 +35,7 @@ export interface OpenCodeMcpBridgeOptions { privateToolTimeoutMs?: number; maxBodyBytes?: number; secret?: string; + onDiagnostic?: (message: string) => void; } export interface OpenCodeMcpBridge { @@ -66,6 +67,22 @@ export async function startOpenCodeMcpBridge( const privateTools = normalizeTools(options.privateTools ?? [], false); const admittedTools = [...tools, ...privateTools.filter((candidate) => !tools.some((tool) => tool.name === candidate.name))]; const validators = compileValidators(admittedTools); + const finishSchema = admittedTools.find(tool => tool.name === PRP_COMPLETION_TOOL_NAME)?.inputSchema; + const finishSchemaSha256 = finishSchema && options.onDiagnostic + ? createHash("sha256").update(JSON.stringify(finishSchema)).digest("hex") : undefined; + let rejectionDiagnostics = 0; + const diagnoseFinishInput = (stage: "offered" | "input_rejected", input?: unknown) => { + if (!finishSchemaSha256 || !options.onDiagnostic) return; + if (stage === "input_rejected" && rejectionDiagnostics++ >= 8) return; + const diagnostic = { + schema: "paperclip.opencode.finish-input-diagnostic/v1", stage, schemaSha256: finishSchemaSha256, + ...(stage === "input_rejected" ? { + rootShape: inputShape(input), + continuationShape: inputShape(isRecord(input) ? input.continuation : undefined), + } : {}), + }; + try { options.onDiagnostic(JSON.stringify(diagnostic)); } catch { /* Optional diagnostics never change admission. */ } + }; const calls = new Map }>(); const controllers = new Map(); const server = createServer((request, response) => { @@ -77,6 +94,7 @@ export async function startOpenCodeMcpBridge( calls, controllers, handler: options.handler, + diagnoseFinishInput, timeoutMs: options.timeoutMs === null ? null : options.timeoutMs ?? DEFAULT_TIMEOUT_MS, privateToolTimeoutMs: options.privateToolTimeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS, privateToolNames: new Set(privateTools.map((tool) => tool.name)), @@ -95,6 +113,7 @@ export async function startOpenCodeMcpBridge( await closeServer(server); throw new Error("OpenCode MCP bridge failed to bind a loopback port"); } + diagnoseFinishInput("offered"); return { url: `http://127.0.0.1:${address.port}/mcp`, secret, @@ -105,6 +124,17 @@ export async function startOpenCodeMcpBridge( }; } +function inputShape(input: unknown): "omitted" | "null" | "array" | "object" | "string" | "number" | "boolean" | "other" { + if (input === undefined) return "omitted"; + if (input === null) return "null"; + if (Array.isArray(input)) return "array"; + if (typeof input === "object") return "object"; + if (typeof input === "string") return "string"; + if (typeof input === "number") return "number"; + if (typeof input === "boolean") return "boolean"; + return "other"; +} + function normalizeTools( input: readonly Readonly>[], includeTerminalTools = true, @@ -151,6 +181,7 @@ async function handleRequest( privateToolTimeoutMs: number; privateToolNames: ReadonlySet; maxBodyBytes: number; + diagnoseFinishInput: (stage: "offered" | "input_rejected", input?: unknown) => void; }, ): Promise { setSecurityHeaders(response); @@ -236,6 +267,7 @@ async function handleRequest( const args = params.arguments ?? {}; const validator = context.validators.get(tool); if (validator === undefined || !validator(args)) { + if (tool === PRP_COMPLETION_TOOL_NAME) context.diagnoseFinishInput("input_rejected", params.arguments); const detail = validator?.errors?.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`).join("; "); writeRpc(response, id, { isError: true, 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 12880cf0453..885e1de53b4 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 @@ -2340,6 +2340,7 @@ describe("OpenCodeServerDriver", () => { const continuation = { kind, summary: "Wait for the saved task path.", idempotencyKey: "task-wait:saved" }; const reads: string[] = []; const accepted: unknown[] = []; + const diagnostics: string[] = []; 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" }, @@ -2349,11 +2350,13 @@ describe("OpenCodeServerDriver", () => { completionContinuation: eligible ? continuation : null }; }, completionFeedback: async result => { accepted.push(result); return "Accepted monitor wait."; }, + onDiagnostic: message => diagnostics.push(message), }); const session = await driver.openSession({ runId: "monitor-default", normalizedSessionId: "monitor-default", workingDirectory: workspace }); try { await session.startTurn({ message: { role: "user", text: "monitor-default" } }); const events = await collectTurnEvents(session.events()); + expect(diagnostics).toContainEqual(expect.stringContaining('"schema":"paperclip.opencode.finish-input-diagnostic/v1","stage":"offered"')); expect(reads).toEqual(["get_task_context"]); if (eligible) { expect(accepted).toMatchObject([{ reportedWorkDisposition: "yielded", continuation }]); 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 3a850a75153..159e793cd08 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2426,6 +2426,7 @@ async function startRuntime(input: { const bridge = await startOpenCodeMcpBridge({ tools: input.options.dynamicTools, handler: input.dispatch, + onDiagnostic: input.options.onDiagnostic, ...(input.options.durableToolRequests ? { timeoutMs: null } : {}), }).catch(async (error) => { await removeIsolatedHome(isolatedHome).catch(() => undefined);