fix(opencode): correlate closed finish validation stages

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-03 07:40:33 -06:00
1 parent 17c89f48f6
commit 7bbb1939c8
3 files changed
+40 -16

No files matched your search

+3 -1
View File
@@ -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.
@@ -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 () => {
@@ -44,6 +44,12 @@ export interface OpenCodeMcpBridge {
close(): Promise<void>;
}
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<string>;
maxBodyBytes: number;
diagnoseFinishInput: (stage: "offered" | "input_rejected", input?: unknown) => void;
diagnoseFinishInput: (rejection?: FinishInputRejection) => void;
},
): Promise<void> {
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,