diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 0f4291f2f4c..dcd06e88c89 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2060,7 +2060,9 @@ 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 +presence/type enums. A SHA-256 of the existing call identity permits correlation; +a closed root/continuation/other category distinguishes the failing boundary. +It records no raw call identity, 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. 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 index cb96639a867..4c3b253a83c 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge-diagnostics.test.ts @@ -35,15 +35,23 @@ it("binds closed rejection shapes to the actual offered finish schema without re }); expect(rejected.result.isError).toBe(true); } + const unrelated = await rpc(bridge, 8, "tools/call", { + name: "paperclip_finish", arguments: { ...report, summary: 42, continuation: null, PRIVATE_FIELD: "PRIVATE_INPUT_VALUE" }, + }); + expect(unrelated.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 => ({ + ...["string", "array", "number", "boolean"].map((continuationShape, index) => ({ schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected", - schemaSha256: digest, rootShape: "object", continuationShape, + schemaSha256: digest, callIdSha256: createHash("sha256").update(String(index + 4)).digest("hex"), + validationCategory: "continuation", rootShape: "object", continuationShape, })), + { schema: "paperclip.opencode.finish-input-diagnostic/v1", stage: "input_rejected", + schemaSha256: digest, callIdSha256: createHash("sha256").update("8").digest("hex"), + validationCategory: "other", rootShape: "object", continuationShape: "null" }, ]); - expect(JSON.stringify(messages)).not.toMatch(/PRIVATE_INPUT_VALUE|Completed|criteria|continuation.*must|instancePath/); + expect(JSON.stringify(messages)).not.toMatch(/PRIVATE_INPUT_VALUE|PRIVATE_FIELD|Completed|criteria|continuation.*must|instancePath/); }); it("reports the effective supplied schema and bounds diagnostics without admitting rejected inputs", async () => { @@ -63,9 +71,9 @@ it("reports the effective supplied schema and bounds diagnostics without admitti 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); + expect(JSON.parse(messages[1]!)).toMatchObject({ rootShape: "array", continuationShape: "omitted", validationCategory: "root" }); + expect(JSON.parse(messages[2]!)).toMatchObject({ rootShape: "object", continuationShape: "null", validationCategory: "continuation" }); + expect(messages.every(message => Buffer.byteLength(message) < 448)).toBe(true); }); it("keeps diagnostics optional and nonfatal while preserving cancellation", async () => { diff --git a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts index ec28b35cf62..419943a8dfd 100644 --- a/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts +++ b/packages/paperclip-runner/src/drivers/opencode/mcp-bridge.ts @@ -44,6 +44,12 @@ export interface OpenCodeMcpBridge { close(): Promise; } +type FinishInputRejection = { + input: unknown; + callId: string; + validationCategory: "root" | "continuation" | "other"; +}; + const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_BODY_BYTES = 1_048_576; // Native app results/pages are capped at 500 KB before reaching this bridge. @@ -71,14 +77,17 @@ export async function startOpenCodeMcpBridge( 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) => { + const diagnoseFinishInput = (rejection?: FinishInputRejection) => { if (!finishSchemaSha256 || !options.onDiagnostic) return; - if (stage === "input_rejected" && rejectionDiagnostics++ >= 8) return; + if (rejection && 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), + schema: "paperclip.opencode.finish-input-diagnostic/v1", + stage: rejection ? "input_rejected" : "offered", schemaSha256: finishSchemaSha256, + ...(rejection ? { + callIdSha256: createHash("sha256").update(rejection.callId).digest("hex"), + validationCategory: rejection.validationCategory, + rootShape: inputShape(rejection.input), + continuationShape: inputShape(isRecord(rejection.input) ? rejection.input.continuation : undefined), } : {}), }; try { options.onDiagnostic(JSON.stringify(diagnostic)); } catch { /* Optional diagnostics never change admission. */ } @@ -113,7 +122,7 @@ export async function startOpenCodeMcpBridge( await closeServer(server); throw new Error("OpenCode MCP bridge failed to bind a loopback port"); } - diagnoseFinishInput("offered"); + diagnoseFinishInput(); return { url: `http://127.0.0.1:${address.port}/mcp`, secret, @@ -181,7 +190,7 @@ async function handleRequest( privateToolTimeoutMs: number; privateToolNames: ReadonlySet; maxBodyBytes: number; - diagnoseFinishInput: (stage: "offered" | "input_rejected", input?: unknown) => void; + diagnoseFinishInput: (rejection?: FinishInputRejection) => void; }, ): Promise { setSecurityHeaders(response); @@ -267,7 +276,12 @@ 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); + if (tool === PRP_COMPLETION_TOOL_NAME) { + const errors = validator?.errors ?? []; + const validationCategory = errors.some(error => error.instancePath === "/continuation" || error.instancePath.startsWith("/continuation/")) + ? "continuation" : errors.some(error => error.instancePath === "") ? "root" : "other"; + context.diagnoseFinishInput({ input: params.arguments, callId, validationCategory }); + } const detail = validator?.errors?.map((error) => `${error.instancePath || "/"} ${error.message ?? "is invalid"}`).join("; "); writeRpc(response, id, { isError: true,