From 22c78242a4e0c2369fecf0c2dc4e7600fbad6706 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 1 Oct 2026 00:14:01 -0500 Subject: [PATCH] fix(runner): require receiver acknowledgment for semantic receipt --- .../runner-copilot-capabilities.md | 46 ++++----- .../runner-rich-acp-capabilities.md | 27 +++--- .../runner-core/src/acpx_provider_backend.rs | 11 ++- .../runner-core/src/bin/fake-acpx-sidecar.rs | 43 ++++++++ .../tests/acpx_provider_session.rs | 97 +++++++++++++++++++ .../scripts/build-copilot-distribution.mjs | 2 +- .../backends/native-backend-factory.test.ts | 1 + .../src/cli/acpx-runtime-sidecar.test.ts | 87 ++++++++++++++++- .../src/cli/acpx-runtime-sidecar.ts | 9 +- .../src/contracts/native-execution.test.ts | 4 +- .../src/contracts/native-execution.ts | 4 +- .../drivers/acpx/copilot-installation.test.ts | 7 +- .../src/drivers/acpx/qualified-profiles.ts | 4 +- .../drivers/acpx/recovery-identity.test.ts | 1 + .../copilot-profile-v12-identity.json | 43 ++++++++ 15 files changed, 335 insertions(+), 51 deletions(-) create mode 100644 packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index efde5b43f9b..d3dae77617b 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -1,28 +1,30 @@ # Copilot 1.0.88 rich ACP capability audit -Current source candidate (2026-10-01): **Copilot profile v11 is unqualified**. -The authenticated Daytona v10 attempt exposed two independent receipt-boundary -failures. Generic JWT redaction removed the public receipt schema name from a -notice detail. Separately, a legitimate completion omitted optional fields that -the strict result validator fills before forwarding; its original argument hash -therefore differed from the proposed canonical result. The failed attempt remains -failed even though its owned processes, sandbox and IPC retired cleanly. +Current source candidate (2026-10-01): **Copilot profile v12 is unqualified**. +Receipt v2 preserves the original `inputSha256` and separately captures +`normalizedInputSha256` from the same invocation's validated outgoing body. +For the native sidecar, only a successful, correlated `tool.resolve` commits +that capture. A pipe write alone cannot attest delivery: Rust can reject an +otherwise valid frame at its smaller payload admission limit. Explicit errors, +cancellation, timeout, missing responses and stale or foreign responses leave +the capture null. A returned `accepted:false` witnesses delivery but still +rejects completion. The direct driver commits only after its proposal emission. -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. 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 -preserves only the fixed schema literal inside a validated receipt notice; -adjacent credentials and JWT-shaped values remain redacted. Retained v10 warm -sessions are incompatible. Native executable bytes are unchanged; new runtime -packs and fresh local/Daytona qualification are required. +Delivery does not prove canonical equality or completion acceptance. The grader +must independently match the normalized digest with the unique proposed and +accepted result bodies; receiver sanitization does not relax that comparison. +The sidecar also rejects oversized frames or bodies its Unicode encoder would +change. Dropped frames consume no stream sequence; writable backpressure is +handled as an accepted buffer write, without claiming receiver admission. + +The server preserves only the fixed schema literal inside a validated receipt +notice; adjacent credentials and JWT-shaped values remain redacted. The paid +v10 Daytona failure remains failed. The v11 build and offline CI evidence is +historical and does not qualify this corrected source. Retained v10 and v11 +warm sessions are incompatible. Native executable bytes are unchanged; fresh +runtime packs and local/Daytona qualification are required. Tests exercise the +actual parsed sidecar command handler and the Rust subprocess transport, +including rejection of a 300 KiB payload before pending-tool admission. Historical source candidate (2026-10-01): **Copilot profile v10 was unqualified**. Admitted Paperclip MCP calls append a bounded `paperclip.semantic_tool_receipt.v1` diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index ea07a80dba4..d4861134cbe 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -1,16 +1,21 @@ # Rich ACP integration and qualification report -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; -fresh v11 runtime packaging and local/Daytona evidence are still required. -The [Copilot inventory](runner-copilot-capabilities.md) records both boundaries. +Current source checkpoint (2026-10-01): **Cursor v10, Copilot v12 and Pi v10 +remain unqualified**. Copilot receipt v2 distinguishes original provider +arguments from the validated outgoing completion input. The sidecar commits +the captured normalized digest only after its exact pending call receives a +successful, turn-bound `tool.resolve`. Pipe-write success alone is insufficient: +Rust may reject a frame at its smaller payload admission limit. Errors, +cancellation, timeout and stale responses cannot attest delivery. + +Delivery remains separate from canonical equality and acceptance. The grader +still requires the normalized digest to match the unique proposed and accepted +result bodies. Oversized frames and Unicode repair fail before commitment; +dropped frames do not consume stream sequence numbers. The server preserves +only the fixed schema literal inside a validated semantic-receipt notice. +The failed v10 Daytona attempt and held v11 builds remain historical. Fresh +v12 runtime packaging and local/Daytona evidence are required. The +[Copilot inventory](runner-copilot-capabilities.md) records these boundaries. Historical source checkpoint (2026-10-01): **Cursor v10, Copilot v10 and Pi v10 remain unqualified**. Cursor now shares the bounded native tool-ID mapping 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 8e51d77bc42..5be9d92292c 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:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99", + "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8", ), "grok" => ( "grok-4.7", @@ -2812,7 +2812,7 @@ mod tests { "copilot", "@github/copilot", "1.0.88", - "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99", + "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8", None, None, "explicit-model", @@ -2914,6 +2914,13 @@ mod tests { assert!(wrong_agent.validate(&context()).is_err()); } if agent == "copilot" { + let mut previous_v11 = value.clone(); + previous_v11["commandDigest"] = json!( + "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99" + ); + let previous_v11: AcpxProviderDescriptor = + serde_json::from_value(previous_v11).unwrap(); + assert!(previous_v11.validate(&context()).is_err()); let mut previous_v10 = value.clone(); previous_v10["commandDigest"] = json!( "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231" diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs index c52cb86c339..b20eb4a61f6 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-acpx-sidecar.rs @@ -25,6 +25,21 @@ fn run() -> Result<(), Box> { .find(|pair| pair[0] == "--profile-digest") .map(|pair| pair[1].as_str()) .unwrap_or("sha256:1111111111111111111111111111111111111111111111111111111111111111"); + // Only the receiver-admission fixture writes this task-owned command journal. + let mut admission_journal = if matches!(mode, "admission-tool" | "admission-tool-oversized") { + let path = args + .windows(2) + .find(|pair| pair[0] == "--journal") + .ok_or("admission journal is missing")?; + Some( + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path[1])?, + ) + } else { + None + }; let stdin = io::stdin(); let mut stdout = io::stdout().lock(); let mut next_sequence = 1_u64; @@ -39,6 +54,34 @@ fn run() -> Result<(), Box> { .get("command") .and_then(Value::as_str) .ok_or("request command is missing")?; + if let Some(journal) = admission_journal.as_mut() { + write_json(journal, &json!({"request":request}))?; + let response = if command == "tool.resolve" { + json!({"protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + "id":id,"ok":true,"result":{"resolved": + request["params"]["callId"] == "call-admission" + && request["params"]["turnId"] == "turn-admission" + && request["params"]["result"] == json!({"id":"issue-1"}) + && request["params"]["error"].is_null()}}) + } else { + bootstrap_success(id, command, &request, mode, profile_digest) + }; + write_json(&mut stdout, &response)?; + if command == "turn.start" { + let frame = json!({ + "protocolVersion":GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, + "sequence":next_sequence,"eventType":"runtime.tool_called", + "runId":"run-1","turnId":request["params"]["turnId"], + "payload":{"callId":"call-admission","operationId":"issues.read", + "input":{"id":"issue-1","padding":"x".repeat( + if mode == "admission-tool-oversized" { 300 * 1024 } else { 8 })}}, + }); + write_json(journal, &json!({"emitted":frame}))?; + write_json(&mut stdout, &frame)?; + next_sequence += 1; + } + continue; + } if mode == "goals" && command.starts_with("session.goal.") { let params = request.get("params").cloned().unwrap_or_else(|| json!({})); match command { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs index 6a7cfe27a13..e42e7a29083 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_provider_session.rs @@ -227,3 +227,100 @@ fn rejects_non_utf8_directories_before_spawning() { assert!(error.contains("must be valid UTF-8"), "{error}"); } + +/// Exercise the subprocess transport, real event decoder/reducer and tool bridge. +/// The command journal distinguishes no admission from a late sidecar rejection. +fn check_tool_receiver_admission(oversized: bool) { + let root = std::env::temp_dir().join(format!("acpx-admission-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + let journal = root.join("commands.jsonl"); + let mut fixture = config(if oversized { + "admission-tool-oversized" + } else { + "admission-tool" + }); + fixture + .transport + .args + .extend(["--journal".to_owned(), journal.to_str().unwrap().to_owned()]); + let mut session = AcpxProviderSession::start(&fixture).unwrap(); + session + .start_turn("turn-admission", "Read the issue", &std::env::temp_dir()) + .unwrap(); + let result = paperclip_runner_core::provider_bridge::ToolResult { + call_id: "call-admission".to_owned(), + operation_id: "issues.read".to_owned(), + result: json!({"id":"issue-1"}), + is_error: false, + }; + if oversized { + let error = session + .poll_event(Duration::from_secs(1)) + .unwrap_err() + .to_string(); + assert!(error.contains("256 KiB admission limit"), "{error}"); + assert!(!session.state().has_pending_tools()); + assert!(session.state().pending_tool("call-admission").is_none()); + // Rejection cannot be bypassed by supplying a matching callback result. + assert!(session.deliver_tool_result(&result).is_err()); + } else { + let events = session.poll_event(Duration::from_secs(1)).unwrap().unwrap(); + assert_eq!(events.len(), 1); + assert!(session.state().pending_tool("call-admission").is_some()); + let mut foreign = result.clone(); + foreign.call_id = "another-call".to_owned(); + assert!(session.deliver_tool_result(&foreign).is_err()); + assert!(session.state().pending_tool("call-admission").is_some()); + session.deliver_tool_result(&result).unwrap(); + assert!(!session.state().has_pending_tools()); + assert!(session.deliver_tool_result(&result).is_err()); + } + session.shutdown("admission test complete").unwrap(); + let rows: Vec = std::fs::read_to_string(&journal) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let emitted: Vec<_> = rows.iter().filter_map(|row| row.get("emitted")).collect(); + assert_eq!(emitted.len(), 1); + let frame = emitted[0]; + assert_eq!(frame["eventType"], "runtime.tool_called"); + let payload_bytes = serde_json::to_vec(&frame["payload"]).unwrap().len(); + let frame_bytes = serde_json::to_vec(frame).unwrap().len() + 1; + assert!( + frame_bytes < 1024 * 1024, + "must reach payload admission, not the line limit" + ); + assert_eq!(payload_bytes > 256 * 1024, oversized); + let resolutions: Vec<_> = rows + .iter() + .filter_map(|row| row.get("request")) + .filter(|request| request["command"] == "tool.resolve") + .collect(); + if oversized { + assert!( + resolutions.is_empty(), + "rejected event must never send tool.resolve" + ); + } else { + assert_eq!(resolutions.len(), 1); + assert_eq!( + resolutions[0]["params"], + json!({ + "callId":"call-admission","turnId":"turn-admission", + "result":{"id":"issue-1"},"error":null, + }) + ); + } + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn receiver_rejects_sub_megabyte_tool_event_before_pending_admission_or_resolution() { + check_tool_receiver_admission(true); +} + +#[test] +fn receiver_admits_normal_tool_event_and_resolves_only_correlated_call_once() { + check_tool_receiver_admission(false); +} diff --git a/packages/paperclip-runner/scripts/build-copilot-distribution.mjs b/packages/paperclip-runner/scripts/build-copilot-distribution.mjs index f16ec294f0f..00fcfe6fc2f 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:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99"; +const PROFILE_DIGEST = "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8"; /** 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/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 4b7a8d977fd..006df396fbb 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -563,6 +563,7 @@ describe("native backend factory", () => { ["copilot", "../../test/fixtures/copilot-profile-v7-identity.json"], ["copilot", "../../test/fixtures/copilot-profile-v9-identity.json"], ["copilot", "../../test/fixtures/copilot-profile-v10-identity.json"], + ["copilot", "../../test/fixtures/copilot-profile-v11-identity.json"], ["pi", "../../test-fixtures/pi-acp/profile-v9-identity.json"], ] as const)("rejects the exact historical %s identity before runtime startup", (agent, path) => { const historical = JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")); 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 a22d4d167e7..fc9e474a9b4 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, ACPX_SIDECAR_MAX_FRAME_BYTES, stringifyAcpxSidecarFrame } from "../drivers/acpx/sidecar-protocol.js"; +import { ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES, stringifyAcpxSidecarFrame, parseAcpxSidecarRequest, record, text } 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"; @@ -23,6 +23,7 @@ import { createCursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.j import { validateAcpxRichEvent } from "../drivers/acpx/profile-extensions.js"; import { awaitSidecarCleanupWithin, + boundedIdentity, closeActiveSidecarHostWithin, closeSidecarHostForCommand, combineSidecarAdmissionCleanups, @@ -251,7 +252,7 @@ describe("qualified ACPX runtime sidecar", () => { await vi.waitFor(() => expect(tools.has("3")).toBe(true)); expect(emitted).toEqual([{ callId: "3", operationId: "paperclip_finish", input: validated.result }]); // The receipt hashes the actual emitted input, not a second normalization. - tools.get("3").settle({ accepted: true }); + await loadActualToolCommands(tools).resolve({ callId: "3", turnId: "test-turn", result: { accepted: true }, error: null }); } const body = await (await response).json(); const receipt = readNativeSemanticReceipt({ contents: body.result.content }); @@ -320,13 +321,70 @@ describe("qualified ACPX runtime sidecar", () => { 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(commit).not.toHaveBeenCalled(); 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 loadActualToolCommands(tools).resolve({ callId: "3", turnId: "test-turn", result: { accepted: true }, error: null }); await expect(pending).resolves.toEqual({ accepted: true }); + expect(commit).toHaveBeenCalledOnce(); } }); + + it.each(["success", "accepted-false", "error", "cancel", "timeout", "receiver-rejected"])( + "commits normalized delivery only after the actual tool.resolve handler succeeds: %s", + async mode => { + const raw = { reportedWorkDisposition: "done", summary: "Complete", evidence: [{ ref: mode === "receiver-rejected" ? "x".repeat(300 * 1024) : "fixture" }], verification: [], + completionClaim: { contractRevision: "1", objectiveSatisfied: true, criteria: [], remainingWork: [] } }; + const validation = validatePrpStructuredRunResult(raw); + if (!validation.ok) throw new Error("Invalid fixture"); + const wire = loadActualSidecarWriter(), tools = new Map(), emitted: unknown[] = [], receipts: unknown[] = []; + const commands = loadActualToolCommands(tools); + const handler = loadWaitForTool({ tools, emitted, validate: validatePrpStructuredRunResult, emit: wire.emit }); + const bridge = await startRunnerToolBridge({ handler, timeoutMs: 500, captureSemanticReceipt: () => receipt => receipts.push(receipt) }); + const headers = { Authorization: `Bearer ${bridge.secret}`, "Content-Type": "application/json" }; + const params = { callId: "3", turnId: "test-turn", result: { accepted: mode !== "accepted-false" }, error: null }; + try { + const response = fetch(bridge.url, { method: "POST", headers, + body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "paperclip_finish", arguments: raw } }) }); + await vi.waitFor(() => expect(tools.has("3")).toBe(true), { interval: 5, timeout: 400 }); + expect(wire.writes).toHaveLength(1); + expect(JSON.parse(wire.writes[0]!).payload.input).toEqual(validation.result); + expect(receipts).toEqual([]); + // These all enter the actual parsed command handler. None may consume + // the pending call or attest delivery for a different request/turn. + await expect(commands.resolve({ ...params, callId: "missing" })).rejects.toThrow("stale or unknown"); + await expect(commands.resolve({ ...params, turnId: "foreign" })).rejects.toThrow("stale or unknown"); + commands.setTurn("foreign"); + await expect(commands.resolve(params)).rejects.toThrow("stale or unknown"); + commands.setTurn("test-turn"); + expect(tools.has("3")).toBe(true); + if (mode === "success" || mode === "accepted-false") { + await expect(commands.resolve(params)).resolves.toEqual({ resolved: true }); + } else if (mode === "error") { + await expect(commands.resolve({ ...params, result: undefined, error: { message: "Receiver rejected completion" } })).resolves.toEqual({ resolved: true }); + } else if (mode === "cancel") { + const cancelled = await fetch(bridge.url, { method: "POST", headers, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 3 } }) }); + expect(cancelled.status).toBe(202); + } else if (mode === "receiver-rejected") { + // Actual writer acceptance is not receiver admission. This frame is + // over Rust's payload bound; no correlated resolution arrives. + expect(Buffer.byteLength(JSON.stringify(JSON.parse(wire.writes[0]!).payload))).toBeGreaterThan(256 * 1024); + expect(Buffer.byteLength(wire.writes[0]!)).toBeLessThan(ACPX_SIDECAR_MAX_FRAME_BYTES); + } + const body = await (await response).json(); + const delivered = mode === "success" || mode === "accepted-false"; + const receipt = readNativeSemanticReceipt({ contents: body.result.content }); + expect(receipt).toMatchObject({ normalizedInputSha256: delivered ? semanticInputSha256(validation.result) : null, + inputSha256: semanticInputSha256(raw), outcome: delivered ? "returned" : "error" }); + expect(receipts).toEqual([receipt]); + expect(tools.size).toBe(0); + await expect(commands.resolve(params)).rejects.toThrow("stale or unknown"); + expect(receipts).toEqual([receipt]); + } finally { await bridge.close(); } + }, + ); + 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: [] } }; @@ -1180,3 +1238,24 @@ function loadActualSidecarWriter(mode: "normal" | "backpressure" | "throws" = "n `)(process, stringifyAcpxSidecarFrame, ACPX_SIDECAR_PROTOCOL_VERSION, ACPX_SIDECAR_MAX_FRAME_BYTES); return { ...writer, writes, errors }; } + +/** Actual parsed command dispatcher; only the owned pending map/turn are supplied. */ +function loadActualToolCommands(tools: Map): { + resolve: (params: Record) => Promise>; + setTurn: (turnId: string | null) => void; +} { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf("async function dispatch("), end = source.indexOf("\nasync function pumpTurn(", start); + const safeStart = source.indexOf("function safeText("), safeEnd = source.indexOf("\nfunction safeMessage(", safeStart); + if (start < 0 || end < 0 || safeStart < 0 || safeEnd < 0) throw new Error("Actual sidecar command dispatcher not found"); + const dispatcher = new Function("tools", "boundedIdentity", "record", "text", "parseAcpxSidecarRequest", "ACPX_SIDECAR_PROTOCOL_VERSION", ` + let turnId="test-turn", requestId=0; + ${stripTypeScriptTypes(source.slice(safeStart, safeEnd))} + ${stripTypeScriptTypes(source.slice(start, end))} + return { + resolve: params => dispatch(parseAcpxSidecarRequest({ protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id: ++requestId, command: "tool.resolve", params })), + setTurn: value => { turnId=value; } + }; + `)(tools, boundedIdentity, record, text, parseAcpxSidecarRequest, ACPX_SIDECAR_PROTOCOL_VERSION); + return dispatcher; +} diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index f635452e562..ec513f192fb 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -737,7 +737,6 @@ 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 = () => { const pending = tools.get(callId); @@ -748,7 +747,13 @@ async function waitForTool(call: RunnerToolCall): Promise { call.signal.addEventListener("abort", abort, { once: true }); tools.set(callId, { turnId: activeTurnId, - settle, + settle: (result) => { + // A pipe write alone does not prove receiver admission. Only this + // call's turn-bound tool.resolve success confirms runnerd accepted + // the validated body; rejection, cancellation and timeout stay null. + commitNormalizedInput?.(); + settle(result); + }, reject, cleanup: () => call.signal.removeEventListener("abort", abort), }); diff --git a/packages/paperclip-runner/src/contracts/native-execution.test.ts b/packages/paperclip-runner/src/contracts/native-execution.test.ts index 746eed86320..b4ee6305911 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.test.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.test.ts @@ -296,7 +296,7 @@ describe("NativeExecutionInputV1", () => { })).toThrow("eventExpiryDays"); }); - it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => { const provider = { kind: "acpx", agent: "pi", @@ -328,7 +328,7 @@ describe("NativeExecutionInputV1", () => { profile: provider.profile, }); expect(parseNativeExecutionInput(parsed)).toEqual(parsed); - for (const unsupportedVersion of [0, 12, 1.5, "11", null]) { + for (const unsupportedVersion of [0, 13, 1.5, "12", null]) { expect(() => parseNativeExecutionInput({ ...input, session: { ...input.session, driverKind: "acpx_runtime" }, diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 16d57ed8883..611391bc93b 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -91,7 +91,7 @@ export interface NativeAcpxProfileSnapshot { protocolVersion: 1; acpxVersion: "0.13.1"; agent: NativeAcpxAgent; - agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11; + agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; agentServerPackage: string; agentServerVersion: string; agentRuntimePackage: string | null; @@ -608,7 +608,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput || profile.protocolVersion !== 1 || profile.acpxVersion !== "0.13.1" || profile.agent !== provider.agent - || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10 && profile.agentProfileVersion !== 11) + || (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7 && profile.agentProfileVersion !== 8 && profile.agentProfileVersion !== 9 && profile.agentProfileVersion !== 10 && profile.agentProfileVersion !== 11 && profile.agentProfileVersion !== 12) ) { throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile"); } 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 4dc37380c50..a183c39cb1c 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-installation.test.ts @@ -59,13 +59,14 @@ async function permissionPolicyClosure(extraClassifierImport = false): Promise ({ verifyNativeAcpxInstallation: vi.fn() })); describe("Copilot build-owned installation", () => { - it("admits the current v11 declaration and binds its source policy, receipts and patch hashes", () => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v11-identity.json", import.meta.url), "utf8")); + it("admits the current v12 declaration and binds its source policy, receipts and patch hashes", () => { + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); expect(identity.declaration.systemInstructionDelivery).toBe(COPILOT_SYSTEM_INSTRUCTION_DELIVERY); expect(identity.declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.declaration.permissionContextContract).toBe(COPILOT_PERMISSION_CONTEXT_CONTRACT); expect(identity.declaration.semanticToolReceiptContract).toBe("paperclip.semantic_tool_receipt.v2"); expect(identity.declaration.semanticNormalizedInputContract).toBe("validated-forwarded-input-commit-v1"); + expect(identity.declaration.semanticSidecarReceiptCommitContract).toBe("correlated-tool-resolve-v1"); expect(identity.declaration.messageIdentityContract).toBe("copilot-native-message-id-v1"); expect(identity.declaration.ownedDistributionDelivery).toBe("COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1"); const inventory = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-message-identity-distributions-1.0.88.json", import.meta.url), "utf8")); @@ -90,7 +91,7 @@ describe("Copilot build-owned installation", () => { expect([...closure].filter(path => !bound.has(path))).toEqual(["negative-control:unbound-policy"]); }); it.each([...PERMISSION_POLICY_SOURCES, ...SEMANTIC_RECEIPT_SOURCES])("changing %s changes the candidate identity", (_path, field) => { - const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v11-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(readFileSync(new URL("../../../test/fixtures/copilot-profile-v12-identity.json", import.meta.url), "utf8")); identity.declaration[field] = "0".repeat(64); const sorted = Object.fromEntries(Object.entries(identity.declaration).sort(([a], [b]) => a.localeCompare(b))); expect(`sha256:${createHash("sha256").update(JSON.stringify(sorted)).digest("hex")}`).not.toBe(identity.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 761ef6ced29..b515910ca92 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -72,10 +72,10 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< }, copilot: { driverKind: ACPX_DRIVER_KIND, protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION, - acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 11, + acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 12, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:d56589c43437277b527ed61155de7d882e3ae5316d67502d8b98a0d435f69f99", + commandDigest: "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8", // 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/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index 1a2a364ad23..20c94240ee9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -34,6 +34,7 @@ describe("ACPX recovery identity", () => { ["copilot", "../../../test/fixtures/copilot-profile-v8-identity.json"], ["copilot", "../../../test/fixtures/copilot-profile-v9-identity.json"], ["copilot", "../../../test/fixtures/copilot-profile-v10-identity.json"], + ["copilot", "../../../test/fixtures/copilot-profile-v11-identity.json"], ["pi", "../../../test-fixtures/pi-acp/profile-v9-identity.json"], ] as const)("rejects retained %s sessions after the execution identity changes", async (agent, path) => { const fixture = await recoveryFixture(); diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json new file mode 100644 index 00000000000..82b2ad0f4d3 --- /dev/null +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v12-identity.json @@ -0,0 +1,43 @@ +{ + "commandDigest": "sha256:48cecd8dc77a5533240fcf2f29d19be05380da4a79f8e5061480f94241db75a8", + "declaration": { + "schema": "paperclip.acpx_profile_declaration.v1", + "agent": "copilot", + "agentProfileVersion": 12, + "acpxVersion": "0.13.1", + "agentServerVersion": "1.0.88", + "protocolPolicyRevision": "copilot-agent-manual-v1", + "acpxPatchSha256": "bd5393058a218040d217fa85449d59a6f30507de54cd645bf0ef21422823f85e", + "policySha256": "a4be69be43db84052560420a3ca546a665c9165573c24d543eb33d144ba138a3", + "distributionSourceSha256": "c0a65e247fc47b263bd369a3d5ab505c4b323d4e6493be8a9ecbeb0e9dbb9107", + "agentFilesBinding": "registered-runtime-context-v1", + "systemInstructionDelivery": "COPILOT_HOME/copilot-instructions.md:replace-under-lifetime-lease-before-launch:v1", + "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", + "innerDistributionSourceSha256": "9bb08347eed01f1ab44e065c8b06ead9be3a25973d6e6329dc4c0a099096279a", + "upstreamAppSha256": "7b48282a19b5b0814a0c96ad5e3a125173d792cc55f9525b2effea962f6063c0", + "patchedAppSha256": "61f8eebe0c30cc03b311c6818b2acb158a6d16ca58c8bfac5ebd42db095c2679", + "messageIdentityContract": "copilot-native-message-id-v1", + "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", + "permissionContextContract": "copilot-edit-permission-context-v1", + "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", + "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", + "permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", + "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v2", + "semanticReceiptSourceSha256": "b2795726b2234a663fec1a6ca11f0551ae69882fe9e82aa39686e952d036e791", + "semanticBridgeSourceSha256": "8a43ebafb7a85dd1714ed6945670472666dcb63e8af32b4a74644163fa0e370d", + "toolEvidenceSourceSha256": "4b517eed447615b3cc820af348ccc360978175149caa0bbc838d1f78332abe75", + "semanticNormalizedInputContract": "validated-forwarded-input-commit-v1", + "semanticSidecarSourceSha256": "d58076d59ec23fe2261cba3aa0dca3e9f189105f0a5fa7ca3eeaac05cb338f31", + "semanticDirectDriverSourceSha256": "31e36917ee3592fa6d8f4b86632129fdffae20c45aed2e0e367ccf523e064f88", + "semanticValidationSourceSha256": "039fca52266fe21ba74ca64660d21228314d8ed737b19ba67941bfb25086e4d5", + "semanticNormalizationSourceSha256": "b1ce59251bf9c671f66e0e5173646190b9a2d1bfc5bdb7fb123824213986b92b", + "semanticCompletionContractSourceSha256": "00a684f9aea87f68eabf0974681c52461bc308631a1252a89c4ed087d7158b28", + "semanticValidatorsSourceSha256": "950b49b233d8dd62ff858398becfc55f0a181160cb8a434d9221b5c17816867c", + "semanticSchemaBundleSourceSha256": "bb55e18c5563bf5ec226e301c3399229304f295e986865e96554f193535368f2", + "semanticSidecarProtocolSourceSha256": "165cad76f224d60f0dc7928736d47a16787eaa8ab9e4c2222488da5fc8ad632e", + "semanticSidecarReceiptCommitContract": "correlated-tool-resolve-v1" + } +}