diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index c9d535e87c8..d5db167e550 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -11,7 +11,11 @@ failed even though its owned processes, sandbox and IPC retired cleanly. Receipt v2 preserves the original `inputSha256` and separately records `normalizedInputSha256` at the same invocation's actual validated forwarding boundary. The capture commits only after that emission succeeds; absent or -invalid capture remains null. Native result matching still requires the exact +invalid capture remains null. The sidecar rejects an oversized frame or semantic +body that its Unicode encoder would change before committing the digest. +Rejected frames consume no stream sequence. Writable backpressure still means +the bytes were accepted into the stream buffer; it does not prove semantic +acceptance. Native result matching still requires the exact invocation-owned receipt and all original call/input/result hashes. Acceptance must independently match the normalized digest and the unique proposed/accepted result bodies. Transport return alone never proves acceptance. The server diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index 33da6b76481..d6badb5be00 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -4,6 +4,8 @@ Current source checkpoint (2026-10-01): **Cursor v10, Copilot v11 and Pi v10 remain unqualified**. Copilot receipt v2 distinguishes original provider arguments from the strictly validated input actually forwarded for completion. A same-call capture binds both hashes without reconstructing omitted defaults in the grader. +Oversized frames and semantic bodies changed by Unicode repair fail before +capture commitment, and dropped frames do not consume stream sequence numbers. The server's JWT heuristic now preserves only the fixed schema literal in a validated semantic-receipt notice. Native call/result correlation and independent canonical acceptance remain required. The failed v10 Daytona attempt is retained; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index e362169031d..8e51d77bc42 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -223,7 +223,7 @@ impl AcpxProviderDescriptor { "1.0.88", None, None, - "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd", + "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99", ), "grok" => ( "grok-4.7", @@ -2812,7 +2812,7 @@ mod tests { "copilot", "@github/copilot", "1.0.88", - "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd", + "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99", None, None, "explicit-model", diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs index bd45ea62178..f16ec294f0f 100644 --- a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs +++ b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs @@ -7,7 +7,7 @@ import { COPILOT_VERSION, materializePinnedCopilotBinary, resolveCopilotDistribu const MAX_ARCHIVE_BYTES = 128 * 1024 * 1024; const MAX_EXPANDED_BYTES = 384 * 1024 * 1024; -const PROFILE_DIGEST = "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd"; +const PROFILE_DIGEST = "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99"; /** Build-time only; outputRoot is the exact runner-owned platform asset directory. */ export async function buildPinnedCopilotDistribution({ outputRoot, platform = process.platform, architecture = process.arch }, { fetchImpl = fetch } = {}) { diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts index 08e5982e334..a22d4d167e7 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -15,7 +15,7 @@ import { deliverAcpxResponse } from "../drivers/acpx/response-delivery.js"; import { normalizeAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js"; import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; -import { ACPX_SIDECAR_PROTOCOL_VERSION } from "../drivers/acpx/sidecar-protocol.js"; +import { ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES, stringifyAcpxSidecarFrame } from "../drivers/acpx/sidecar-protocol.js"; import { canonicalProviderEventsFromAcpxRuntimeEvent } from "../provider-events.js"; import { createPiMessageProjection } from "../drivers/acpx/pi-message-projection.js"; import { createCopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js"; @@ -280,6 +280,67 @@ describe("qualified ACPX runtime sidecar", () => { expect(emitted).toEqual([]); }); + it("rejects normalized/envelope expansion at the actual frame writer before committing a receipt", async () => { + const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [{ ref: "" }], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + const request = { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }; + raw.evidence[0]!.ref = "x".repeat(ACPX_SIDECAR_MAX_FRAME_BYTES - 1 - Buffer.byteLength(JSON.stringify(request))); + expect(Buffer.byteLength(JSON.stringify(request))).toBe(ACPX_SIDECAR_MAX_FRAME_BYTES - 1); + const validation = validatePrpStructuredRunResult(raw); + if (!validation.ok) throw new Error("Invalid fixture"); + expect(Buffer.byteLength(JSON.stringify(validation.result))).toBeLessThanOrEqual(ACPX_SIDECAR_MAX_FRAME_BYTES); + const wire = loadActualSidecarWriter(); + const tools = new Map(), emitted: unknown[] = []; + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit }); + const bridge = await startRunnerToolBridge({ handler, captureSemanticReceipt: () => () => {} }); + try { + const body = await (await fetch(bridge.url, { method: "POST", headers: { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }, + body: JSON.stringify(request) })).json(); + expect(body.result.isError).toBe(true); + expect(body.result.content[0].text).toBe("ACPX semantic tool call exceeds the sidecar frame limit"); + expect(readNativeSemanticReceipt({ contents: body.result.content })).toMatchObject({ outcome: "error", normalizedInputSha256: null }); + expect(tools.size).toBe(0); + expect(emitted).toEqual([]); + expect(wire.writes).toEqual([]); + expect(wire.errors).toEqual(["[paperclip-acpx-sidecar] output_frame_too_large\n"]); + expect(wire.sequence()).toBe(0); + expect(wire.emit("runtime.diagnostic", { code: "bounded", message: "rejected" })).toBe(true); + expect(JSON.parse(wire.writes[0]!)).toMatchObject({ sequence: 1, eventType: "runtime.diagnostic" }); + } finally { await bridge.close(); } + }); + + it.each(["backpressure", "throws"])("uses actual writer acceptance, preserving %s semantics", async mode => { + const wire = loadActualSidecarWriter(mode); + const tools = new Map(), emitted: any[] = [], commit = vi.fn(), capture = vi.fn((_input: unknown) => commit); + const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit }); + const pending = handler({ tool: "paperclip_finish", callId: "3", arguments: raw, signal: new AbortController().signal, captureNormalizedInput: capture }); + if (mode === "throws") { + await expect(pending).rejects.toThrow("fixture write failed"); + expect(commit).not.toHaveBeenCalled(); expect(tools.size).toBe(0); expect(wire.sequence()).toBe(0); + } else { + expect(commit).toHaveBeenCalledOnce(); expect(tools.has("3")).toBe(true); expect(wire.sequence()).toBe(1); + expect(JSON.parse(wire.writes[0]!).payload.input).toEqual(capture.mock.calls[0]![0]); + tools.get("3").settle({ accepted: true }); tools.get("3").cleanup(); + await expect(pending).resolves.toEqual({ accepted: true }); + } + }); + + it.each(["value", "property", "collision"])("rejects semantic %s Unicode rewriting before capture or write", async kind => { + const raw = { reportedWorkDisposition: "done", summary: kind === "value" ? "\ud800" : "Complete", evidence: [{ ref: "fixture" } as Record], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + if (kind !== "value") raw.evidence[0]!["\udc00"] = "one"; + if (kind === "collision") raw.evidence[0]!["\ufffd"] = "two"; + expect(validatePrpStructuredRunResult(raw).ok).toBe(true); + const wire = loadActualSidecarWriter(), tools = new Map(), emitted: unknown[] = [], capture = vi.fn(() => vi.fn()); + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit }); + await expect(handler({ tool: "paperclip_finish", callId: "3", arguments: raw, signal: new AbortController().signal, + captureNormalizedInput: capture })).rejects.toThrow("cannot be encoded without changing its identity"); + expect(capture).not.toHaveBeenCalled(); expect(tools.size).toBe(0); expect(emitted).toEqual([]); + expect(wire.writes).toEqual([]); expect(wire.sequence()).toBe(0); + }); + it("passes only validated Pi native boundaries and history through the real text sanitizer", () => { const source = readFileSync(fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), "utf8"); const start = source.indexOf(' if (event.type === "text_delta") {', source.indexOf("function sanitizeRuntimeEvent")); @@ -1062,7 +1123,7 @@ function loadWaitForTool(input: { tools: Map; emitted: unknown[]; validate?: typeof validatePrpStructuredRunResult; - emit?: () => void; + emit?: (eventType: string, payload: unknown) => boolean; }): (call: RunnerToolCall) => Promise { const source = readFileSync( fileURLToPath(new URL("./acpx-runtime-sidecar.ts", import.meta.url)), @@ -1079,14 +1140,14 @@ function loadWaitForTool(input: { ); const factory = new Function( "boundedIdentity", "tools", "turnId", "emit", "PRP_COMPLETION_TOOL_NAME", - "PRP_BLOCK_TOOL_NAME", "validatePrpStructuredRunResult", "boundedSidecarValue", "record", "MAX_PENDING_TOOLS", + "PRP_BLOCK_TOOL_NAME", "validatePrpStructuredRunResult", "boundedSidecarValue", "record", "MAX_PENDING_TOOLS", "stringifyAcpxSidecarFrame", `return (${functionSource});`, ); return factory( (value: string) => value, input.tools, "test-turn", - (_eventType: string, payload: unknown) => { input.emit?.(); input.emitted.push(payload); }, + (eventType: string, payload: unknown) => { const accepted = input.emit?.(eventType, payload) ?? true; if (accepted) input.emitted.push(payload); return accepted; }, "paperclip_finish", "paperclip_block", input.validate ?? ((argumentsValue: unknown) => ({ @@ -1096,5 +1157,26 @@ function loadWaitForTool(input: { (value: unknown) => value, (value: unknown) => value, 512, + stringifyAcpxSidecarFrame, ); } + +/** Actual producer and encoder, with only the destination stream replaced. */ +function loadActualSidecarWriter(mode: "normal" | "backpressure" | "throws" = "normal"): { + emit: (eventType: string, payload: unknown) => boolean; sequence: () => number; writes: string[]; errors: string[]; +} { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const emitStart = source.indexOf("function emit("), emitEnd = source.indexOf("\nfunction diagnostic", emitStart); + const writeStart = source.indexOf("function writeFrame("), writeEnd = source.indexOf("\nfunction requireHost", writeStart); + if (emitStart < 0 || emitEnd < 0 || writeStart < 0 || writeEnd < 0) throw new Error("Actual sidecar writer source not found"); + const writes: string[] = [], errors: string[] = []; + const process = { stdout: { write: (line: string) => { if (mode === "throws") throw new Error("fixture write failed"); writes.push(line); return mode !== "backpressure"; } }, + stderr: { write: (line: string) => { errors.push(line); return true; } } }; + const writer = new Function("process", "stringifyAcpxSidecarFrame", "ACPX_SIDECAR_PROTOCOL_VERSION", "ACPX_SIDECAR_MAX_FRAME_BYTES", ` + let sequence=0; const runId="test-run", turnId="test-turn"; + ${stripTypeScriptTypes(source.slice(emitStart, emitEnd))} + ${stripTypeScriptTypes(source.slice(writeStart, writeEnd))} + return { emit, sequence: () => sequence }; + `)(process, stringifyAcpxSidecarFrame, ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES); + return { ...writer, writes, errors }; +} diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index 789ec0d2f47..f635452e562 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -711,6 +711,15 @@ async function waitForTool(call: RunnerToolCall): Promise { "ACPX semantic result disposition does not match its terminal operation", ); } + // Semantic result identity must survive the wire encoder unchanged. Its + // display-text Unicode repair is unsuitable for authenticated result data. + try { + if (stringifyAcpxSidecarFrame(validation.result) !== JSON.stringify(validation.result)) { + throw new Error("Semantic input changes during encoding"); + } + } catch { + throw new Error("ACPX semantic result cannot be encoded without changing its identity"); + } // The authenticated runner bridge must admit this built-in invocation // before the provider sees a result. Keep the call pending until runnerd // sends tool.resolve after the server's completion feedback accepts it. @@ -718,7 +727,7 @@ async function waitForTool(call: RunnerToolCall): Promise { // local semantic_result here would let an invalid review handoff appear // accepted before the server has checked it. const commitNormalizedInput = call.captureNormalizedInput?.(validation.result); - emit( + const forwarded = emit( "runtime.tool_called", { callId, @@ -727,6 +736,7 @@ async function waitForTool(call: RunnerToolCall): Promise { }, activeTurnId, ); + if (!forwarded) throw new Error("ACPX semantic tool call exceeds the sidecar frame limit"); commitNormalizedInput?.(); return await new Promise((settle, reject) => { const abort = () => { @@ -1327,18 +1337,21 @@ function emit( eventType: AcpxSidecarEvent["eventType"], payload: Record, eventTurnId: string | null = turnId, -): void { +): boolean { if (sequence >= Number.MAX_SAFE_INTEGER) { throw new Error("ACPX sidecar event sequence exhausted"); } - writeFrame({ + const accepted = writeFrame({ protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, - sequence: ++sequence, + sequence: sequence + 1, eventType, runId, turnId: eventTurnId, payload, }); + // A dropped frame never entered the stream and must not consume an identity. + if (accepted) sequence++; + return accepted; } function diagnostic(code: string, message: string): void { @@ -1362,13 +1375,16 @@ function response( }); } -function writeFrame(value: AcpxSidecarEvent | AcpxSidecarResponse): void { +function writeFrame(value: AcpxSidecarEvent | AcpxSidecarResponse): boolean { const line = stringifyAcpxSidecarFrame(value); if (Buffer.byteLength(line) > ACPX_SIDECAR_MAX_FRAME_BYTES) { process.stderr.write("[paperclip-acpx-sidecar] output_frame_too_large\n"); - return; + return false; } process.stdout.write(`${line}\n`); + // Writable.write(false) accepted the bytes into its queue; it signals + // backpressure, not a rejected frame. A synchronous write error still throws. + return true; } function requireHost( diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts index ba44b155e0d..4dc37380c50 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -24,6 +24,7 @@ const SEMANTIC_RECEIPT_SOURCES = [ ["../runner-tool-bridge.ts", "semanticBridgeSourceSha256"], ["copilot-tool-evidence.ts", "toolEvidenceSourceSha256"], ["../../cli/acpx-runtime-sidecar.ts", "semanticSidecarSourceSha256"], + ["sidecar-protocol.ts", "semanticSidecarProtocolSourceSha256"], ["codex-acpx-driver.ts", "semanticDirectDriverSourceSha256"], ["../../protocol/replay-contract.ts", "semanticValidationSourceSha256"], ["../../protocol/result-normalization.ts", "semanticNormalizationSourceSha256"], diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 35c3a738f00..761ef6ced29 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -75,7 +75,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 11, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd", + commandDigest: "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99", // Authenticated discovery has not established a qualification model. Never // turn this empty declaration into a default; callers must select an ID. qualificationModel: "", reportedModelId: "", permissionPolicy: "interactive", diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts index 1f9323be1fd..e80e8aa7718 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -758,8 +758,8 @@ describe("ACPX runtime host", () => { const receipt = readNativeSemanticReceipt({ contents: body.result.content }); expect(receipt).not.toBeNull(); expect(events).toHaveLength(1); - expect(events[0]!.payload).toMatchObject({ category: "paperclip_semantic_tool_receipt_v1", provenance: { sessionId: "backend-1", turnId: "turn-1" }, - details: expect.arrayContaining([{ name: "callIdentitySha256", value: receipt!.callIdentitySha256 }, { name: "resultSha256", value: receipt!.resultSha256 }]) }); + expect(events[0]!.payload).toMatchObject({ category: "paperclip_semantic_tool_receipt_v2", provenance: { sessionId: "backend-1", turnId: "turn-1" }, + details: expect.arrayContaining([{ name: "schema", value: "paperclip.semantic_tool_receipt.v2" }, { name: "normalizedInputSha256", value: "null" }, { name: "callIdentitySha256", value: receipt!.callIdentitySha256 }, { name: "resultSha256", value: receipt!.resultSha256 }]) }); } finally { await host.close({ reason: "receipt forwarding verified" }); } await expect(fetch(bridge!.url)).rejects.toThrow(); }); diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json index e92b7c8d8dd..12ddfd88caa 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v11-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:e3057abcc2e6eb0db3a5c683a15e32159ac3caa78ba3d4c2a5d9221929e867dd", + "commandDigest": "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "copilot", @@ -30,12 +30,13 @@ "semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d", "toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75", "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", - "semanticSidecarSourceSha256": "6c7dedca55f0f031570349b010ede1cd8015c25e9a52afac01ce9de8beb966c2", + "semanticSidecarSourceSha256": "a261497560840da66613a5f4a3dafeba05e53d3a8647c180c9604b3cf983dc23", "semanticDirectDriverSourceSha256": "31e36917ee3592fa6d8f4b86632129fdffae20c45aed2e0e367ccf523e064f88", "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", - "semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2" + "semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2", + "semanticSidecarProtocolSourceSha256": "165cad76f224d60f0dc7928736d47a16787eaa8ab9e4c2222488da5fc8ad632e" } }