mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(opencode): correlate closed finish validation stages
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
17c89f48f6
commit
7bbb1939c8
3 files changed
+40
-16
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user