diff --git a/doc/architecture/runner-copilot-capabilities.md b/doc/architecture/runner-copilot-capabilities.md index 8c130f87c1e..a9093af0c1c 100644 --- a/doc/architecture/runner-copilot-capabilities.md +++ b/doc/architecture/runner-copilot-capabilities.md @@ -15,7 +15,12 @@ its repeated text fields. Large and chunked bridge results still carry a receipt but output above that bound has no native correlation evidence. Error results retain `isError`; deterministic tests cover errors, while the actual pinned ARM64 executable has only been checked with a small two-block success response -and a local mock model. This is not paid or cross-platform qualification. +and a local mock model. The captured pending/completed ACP frames are retained +in `src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.json`, with +executable/capture hashes and sanitization recorded alongside. A regression +replays those frames through the production projector with the separately +owned receipt, plus missing-authority, altered-input/result, missing-content +and duplicate-terminal negatives. This is not paid or cross-platform qualification. Native executable and distribution bytes are unchanged. The profile binds the receipt, bridge and projector sources plus the complete permission-policy import closure; retained v9 sessions are incompatible. Fresh packs and local/Daytona diff --git a/doc/architecture/runner-cursor-capabilities.md b/doc/architecture/runner-cursor-capabilities.md index e63e0557c91..a089e90edef 100644 --- a/doc/architecture/runner-cursor-capabilities.md +++ b/doc/architecture/runner-cursor-capabilities.md @@ -1,9 +1,15 @@ # Cursor ACP capability inventory Current source candidate (2026-10-01): **Cursor profile v10 is unqualified**. -Native tool IDs containing control characters now use the same bounded hash in -tool activity, permission details and correlated evidence. Safe IDs stay unchanged; -distinct native IDs stay distinct. The original ACP request and option identity +Native tool IDs containing C0, C1 or DEL control characters now use the same +bounded hash in permission details, passive evidence and the sidecar tool event. +Blank permission IDs are rejected because ACPX drops blank tool-event identity. +Other admitted IDs stay unchanged at this boundary; distinct IDs stay distinct. +Canonical tool execution then applies the existing Rust opaque-ID conversion. +For example, `tool/1` remains the native permission/evidence key while its +execution ID is the deterministic opaque hash. Lifecycle readers explicitly +convert between these keys; they must not compare them directly. Both-order +bridge tests cover this distinction, including 161-character IDs. The original ACP request and option identity remain intact for response delivery. The declaration binds the identity helper, permission adapter and evidence projector. Retained v9 sessions are incompatible. The native distribution and its usage limits are unchanged. Deterministic tests diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md index 702fabd19de..a1088b0ae20 100644 --- a/doc/architecture/runner-rich-acp-capabilities.md +++ b/doc/architecture/runner-rich-acp-capabilities.md @@ -1,8 +1,9 @@ # Rich ACP integration and qualification report Current source checkpoint (2026-10-01): **Cursor v10, Copilot v10 and Pi v10 -remain unqualified**. Cursor now shares the exact opaque native tool-ID mapping -across activity and permissions. Copilot correlates native tool results with +remain unqualified**. Cursor now shares the bounded native tool-ID mapping +across sidecar activity and permissions, then deterministically joins that key +to the canonical opaque execution ID. Copilot correlates native tool results with authenticated, turn-bound semantic receipts instead of display names. Both new profile identities reject v9 warm sessions. Their native distribution bytes are unchanged; new sidecar assets, runner admission digests and fresh local/Daytona 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 c3c6dc2feba..d5f31a01d71 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 @@ -215,7 +215,7 @@ impl AcpxProviderDescriptor { "2026.09.26-dd393fe", None, None, - "sha256:e4ffecec5a7d43252f7f79cd681df582492b41a40dd33c1ad1a8354a178ccddb", + "sha256:1df2a15b93bc3a14fa47fa3315344ba023fe2412048047cdc6f32096a6336564", ), "copilot" => ( self.model.as_str(), @@ -223,7 +223,7 @@ impl AcpxProviderDescriptor { "1.0.88", None, None, - "sha256:2d501724197e00564fb8cd8ce7b0f951c32fb6c1d3bfdde2dd0531171e3db266", + "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231", ), "grok" => ( "grok-4.7", @@ -2803,7 +2803,7 @@ mod tests { "cursor", "cursor-agent", "2026.09.26-dd393fe", - "sha256:e4ffecec5a7d43252f7f79cd681df582492b41a40dd33c1ad1a8354a178ccddb", + "sha256:1df2a15b93bc3a14fa47fa3315344ba023fe2412048047cdc6f32096a6336564", None, None, "explicit-model", @@ -2812,7 +2812,7 @@ mod tests { "copilot", "@github/copilot", "1.0.88", - "sha256:2d501724197e00564fb8cd8ce7b0f951c32fb6c1d3bfdde2dd0531171e3db266", + "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231", 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 b7c7e632874..bcb52261941 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:2d501724197e00564fb8cd8ce7b0f951c32fb6c1d3bfdde2dd0531171e3db266"; +const PROFILE_DIGEST = "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231"; /** 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/scripts/candidate-provider-pack.mjs b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs index 863806e1184..5351f58165f 100644 --- a/packages/paperclip-runner/scripts/candidate-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/candidate-provider-pack.mjs @@ -28,7 +28,7 @@ export async function materializeCandidateProviderPack({ provider, outputRoot }) if (provider === "cursor") { const result = await materializePinnedCursorDistribution({ destination: outputRoot }); return { version: result.version, - profileDigest: "sha256:e4ffecec5a7d43252f7f79cd681df582492b41a40dd33c1ad1a8354a178ccddb", + profileDigest: "sha256:1df2a15b93bc3a14fa47fa3315344ba023fe2412048047cdc6f32096a6336564", closureDigest: `sha256:${result.closureSha256}` }; } throw new Error(`The ${provider} candidate distribution builder is not included in this source revision`); 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 ac9892a58f1..3f7be639145 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -111,7 +111,7 @@ describe("qualified ACPX runtime sidecar", () => { const start = source.indexOf(" const { signal } = context;", source.indexOf("async function waitForPermission")); const end = source.indexOf("\nasync function waitForInput", start); const observedIds: string[] = []; - for (const rawId of ["native\u0000tool", "native\u007ftool", "safe-tool-1"]) { + for (const rawId of ["native\u0000tool", "native\u007ftool", "native\u0085tool", "tool/1", "x".repeat(161), "safe-tool-1"]) { const permissions = new Map(); const emitted: any[] = []; const notices: any[] = []; const evidence = createCursorToolEvidence({ sessionId: "session", turnId: "turn-1", workingDirectory: "/workspace", active: () => true, emit: event => { validateAcpxRichEvent(event); notices.push(event); }, @@ -122,7 +122,10 @@ describe("qualified ACPX runtime sidecar", () => { return async function(activeTurnId, agent, request, context, toolEvidence) { ${source.slice(start, end)} `)(permissions, normalizeAcpxPermission, (_event: string, payload: unknown) => emitted.push(payload)); const origin = { type: "tool_call", tag: "tool_call", toolCallId: rawId, kind: "execute", status: "pending", rawInput: { command: "printf private-command" } }; - const activity = canonicalProviderEventsFromAcpxRuntimeEvent(bound(origin), "unrelated-fallback")[0]!; + // The sidecar emits this bounded runtime event; Rust applies the opaque + // execution-ID transform later. The direct TS driver normalizer is not + // this boundary and has a different fallback policy. + const activity = bound(origin); const native = { sessionId: "session", inferredKind: "execute", raw: { sessionId: "session", toolCall: { toolCallId: rawId, kind: "execute" }, options: [{ kind: "reject_once", optionId: "native-original-denial", name: "Deny" }], } }; @@ -133,7 +136,7 @@ describe("qualified ACPX runtime sidecar", () => { if (order === "permission-first") { expect(notices).toEqual([]); evidence.tool(origin); } const projectedId = emitted[0].toolCallId; observedIds.push(projectedId); - expect(projectedId).toBe(activity.payload.executionId); + expect(projectedId).toBe(activity.toolCallId); expect(notices.map(event => Object.fromEntries(event.payload.details.map((field: any) => [field.name, field.value])))).toEqual([ expect.objectContaining({ stage: "tool", toolCallId: projectedId }), expect.objectContaining({ stage: "permission_requested", toolCallId: projectedId, requestId: "request-1" }), @@ -146,8 +149,8 @@ describe("qualified ACPX runtime sidecar", () => { expect(native.raw.toolCall.toolCallId).toBe(rawId); expect(JSON.stringify([activity, emitted, notices])).not.toContain("private-command"); } - expect(new Set(observedIds).size).toBe(3); - expect(observedIds[2]).toBe("safe-tool-1"); + expect(new Set(observedIds).size).toBe(6); + expect(observedIds.at(-1)).toBe("safe-tool-1"); }); it("emits the native plan tool identity from the actual sidecar input boundary", async () => { const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts index 07a6e2b0bb8..99dc9803bc1 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.test.ts @@ -13,7 +13,7 @@ describe("ACP permission normalization", () => { const native = request(); native.raw.toolCall.toolCallId = "native\u0000tool"; expect(normalizeAcpxPermission(native, { provider }).toolCallId).toBe("native\u0000tool"); }); - it.each([undefined, null, "", "x".repeat(241)])("rejects missing or oversized Cursor identities without deriving one from other fields", toolCallId => { + it.each([undefined, null, "", " ", "x".repeat(241)])("rejects missing or oversized Cursor identities without deriving one from other fields", toolCallId => { const native = request(); Object.assign(native.raw.toolCall, { toolCallId, itemId: "fallback", title: "safe-tool" }); expect(() => normalizeAcpxPermission(native, { provider: "cursor" })).toThrow("tool identity"); diff --git a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts index 2da80813e73..fede31d3641 100644 --- a/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts +++ b/packages/paperclip-runner/src/drivers/acpx/acp-permission-adapter.ts @@ -33,7 +33,8 @@ export function normalizeAcpxPermission(request: AcpPermissionRequest, options: byKind.set(option.kind, { optionId: option.optionId, name: option.name }); } const call = raw.toolCall; - if (!call || typeof call.toolCallId !== "string" || !call.toolCallId || call.toolCallId.length > 240) { + if (!call || typeof call.toolCallId !== "string" || !call.toolCallId || call.toolCallId.length > 240 + || (options.provider === "cursor" && !call.toolCallId.trim())) { throw new Error("ACP permission request omitted its tool identity"); } const needsEditContext = options.provider === "copilot" && call.kind === "edit"; diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts index 1f1147d1299..3dc0c169036 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.test.ts @@ -167,3 +167,42 @@ describe("Copilot authoritative semantic receipt correlation", () => { expect(details(next.events.at(-1)!)).not.toHaveProperty("semanticOperationId"); }); }); + + +describe("captured Copilot 1.0.88 native MCP receipt carrier", () => { + const bytes = readFileSync(new URL("./fixtures/copilot-1.0.88-mcp-receipt-captured.json", import.meta.url)); + const captured = JSON.parse(bytes.toString()); + const provenance = JSON.parse(readFileSync(new URL("./fixtures/copilot-1.0.88-mcp-receipt-captured.provenance.json", import.meta.url), "utf8")); + const nativeEvent = (frame: any) => ({ ...frame.params.update, type: "tool_call", tag: frame.params.update.sessionUpdate }); + it("replays native frames with the separately owned receipt and preserves rejection content", () => { + expect(createHash("sha256").update(bytes).digest("hex")).toBe(provenance.fixtureSha256); + expect(provenance).toMatchObject({ nativeVersion: "1.0.88", paidProviderCalls: 0, qualification: false }); + expect(captured.pending.params.sessionId).toBe(captured.completed.params.sessionId); + const h = harness(captured.pending.params.sessionId); + h.projector.tool(nativeEvent(captured.pending)); + h.projector.captureSemanticReceipt()!(captured.authoritativeReceipt); + h.projector.tool(nativeEvent(captured.completed)); + const output = captured.completed.params.update.rawOutput; + expect(output.content).toBe(output.contents.map((block: { text: string }) => block.text).join("")); + expect(output.detailedContent).toBe(output.content); + expect(JSON.parse(output.contents[0].text)).toMatchObject({ accepted: false }); + expect(details(h.events.at(-1)!)).toMatchObject({ stage: "tool", status: "completed", + semanticOperationId: captured.authoritativeReceipt.operationId, + semanticCallIdentitySha256: captured.authoritativeReceipt.callIdentitySha256, + semanticInputSha256: captured.authoritativeReceipt.inputSha256, + semanticResultSha256: captured.authoritativeReceipt.resultSha256, semanticOutcome: "returned" }); + expect(JSON.stringify(h.events)).not.toContain("known-small-result"); + }); + it.each(["missing-authority", "altered-block", "different-input", "missing-contents", "duplicate-terminal"])("does not trust captured native output alone: %s", scenario => { + const value = structuredClone(captured), h = harness(value.pending.params.sessionId); + if (scenario === "different-input") value.pending.params.update.rawInput = { changed: true }; + if (scenario === "altered-block") value.completed.params.update.rawOutput.contents[0].text = '{"accepted":true}'; + if (scenario === "missing-contents") delete value.completed.params.update.rawOutput.contents; + h.projector.tool(nativeEvent(value.pending)); + if (scenario !== "missing-authority") h.projector.captureSemanticReceipt()!(value.authoritativeReceipt); + h.projector.tool(nativeEvent(value.completed)); + if (scenario === "duplicate-terminal") h.projector.tool(nativeEvent(value.completed)); + expect(details(h.events.at(-1)!)).not.toHaveProperty("semanticOperationId"); + if (scenario === "duplicate-terminal") expect(details(h.events.at(-1)!)).toMatchObject({ stage: "evidence_incomplete", reason: "reused_semantic_lifecycle" }); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts index 31d83034903..57462493056 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-permission-identity.test.ts @@ -9,11 +9,11 @@ import { cursorToolIdentity } from "./cursor-plan-tool-identity.js"; import { createCursorToolEvidence } from "./cursor-tool-evidence.js"; import { validateAcpxRichEvent } from "./profile-extensions.js"; -const rawIds = ["native\u0000tool", "native\u007ftool", "safe-tool-1"]; +const rawIds = ["native\u0000tool", "native\u007ftool", "native\u0085tool", "tool/1", "x".repeat(161), "safe-tool-1"]; // Synthetic protocol fixture, not a claim about the unretained paid wire bytes. // Native permission deliberately omits rawInput, matching pinned Cursor's shape. const peer = String.raw` -const ids=['native\u0000tool','native\u007ftool','safe-tool-1']; +const ids=['native\u0000tool','native\u007ftool','native\u0085tool','tool/1','x'.repeat(161),'safe-tool-1']; const send=x=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...x})+'\n'); let promptId, index=0; function next() { @@ -87,7 +87,7 @@ it.each(["tool-first", "permission-first"])("real ACPX streams Cursor tool origi } await expect(turn.result).resolves.toMatchObject({ status: "completed" }); await drain; - expect(new Set(fields.map(row => row.toolCallId)).size).toBe(3); + expect(new Set(fields.map(row => row.toolCallId)).size).toBe(rawIds.length); expect(fields.some(row => row.stage === "evidence_incomplete")).toBe(false); } finally { pending.forEach(value => value.release()); diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-plan-tool-identity.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-plan-tool-identity.ts index 45f7c537db0..8350bb02978 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-plan-tool-identity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-plan-tool-identity.ts @@ -3,7 +3,7 @@ import type { AcpxExtensionInput } from "./profile-extensions.js"; // Match the sidecar's provider identity boundary before Rust's opaque tool ID. export function cursorToolIdentity(value: string): string { - if (Buffer.byteLength(value) <= 240 && !/[\u0000-\u001f\u007f]/.test(value)) return value; + if (Buffer.byteLength(value) <= 240 && !/[\u0000-\u001f\u007f-\u009f]/.test(value)) return value; return `acpx-tool-${createHash("sha256").update("paperclip.acpx.provider-identity.v1\0tool\0").update(value).digest("hex")}`; } diff --git a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts index 5817212e3d3..0c208001bc6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/cursor-tool-evidence.test.ts @@ -25,13 +25,13 @@ it("waits for the original iterator event when permission arrives before the que const s = setup(); const delivered = s.p.permission(request, "request", ["decline"]); expect(s.events).toHaveLength(0); s.p.tool(initial); delivered!("reject_once"); expect(s.fields().map(x => x.stage)).toEqual(["tool", "permission_requested", "permission_delivered"]); }); -it.each(["tool-first", "permission-first"])("correlates opaque C0/DEL native identities in %s order without changing native requests", order => { +it.each(["tool-first", "permission-first"])("correlates opaque C0/C1/DEL native identities in %s order without changing native requests", order => { const s = setup(); - const rawIds = ["native\u0000tool", "native\u007ftool", "safe-tool-1"]; + const rawIds = ["native\u0000tool", "native\u007ftool", "native\u0085tool", "tool/1", "x".repeat(161), "safe-tool-1"]; const expectedIds = rawIds.map(cursorToolIdentity); - expect(new Set(expectedIds).size).toBe(3); - expect(expectedIds.slice(0, 2).every(value => /^acpx-tool-[a-f0-9]{64}$/.test(value))).toBe(true); - expect(expectedIds[2]).toBe("safe-tool-1"); + expect(new Set(expectedIds).size).toBe(rawIds.length); + expect(expectedIds.slice(0, 3).every(value => /^acpx-tool-[a-f0-9]{64}$/.test(value))).toBe(true); + expect(expectedIds.at(-1)).toBe("safe-tool-1"); for (const [index, toolCallId] of rawIds.entries()) { const native = { sessionId: "session", inferredKind: "execute" as const, raw: { sessionId: "session", toolCall: { toolCallId, kind: "execute" }, diff --git a/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.json b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.json new file mode 100644 index 00000000000..bf947d5b407 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.json @@ -0,0 +1,60 @@ +{ + "pending": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "captured-fixture-session", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "fixture-tool", + "title": "receipt-receipt_fixture", + "kind": "other", + "status": "pending", + "rawInput": {} + } + } + }, + "completed": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "captured-fixture-session", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "fixture-tool", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "{\"fixture\":\"known-small-result\",\"accepted\":false}{\"schema\":\"paperclip.semantic_tool_receipt.v1\",\"operationId\":\"receipt_fixture\",\"callIdentitySha256\":\"8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508\",\"inputSha256\":\"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",\"resultSha256\":\"b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c\",\"outcome\":\"returned\"}" + } + } + ], + "rawOutput": { + "content": "{\"fixture\":\"known-small-result\",\"accepted\":false}{\"schema\":\"paperclip.semantic_tool_receipt.v1\",\"operationId\":\"receipt_fixture\",\"callIdentitySha256\":\"8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508\",\"inputSha256\":\"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",\"resultSha256\":\"b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c\",\"outcome\":\"returned\"}", + "detailedContent": "{\"fixture\":\"known-small-result\",\"accepted\":false}{\"schema\":\"paperclip.semantic_tool_receipt.v1\",\"operationId\":\"receipt_fixture\",\"callIdentitySha256\":\"8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508\",\"inputSha256\":\"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",\"resultSha256\":\"b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c\",\"outcome\":\"returned\"}", + "contents": [ + { + "type": "text", + "text": "{\"fixture\":\"known-small-result\",\"accepted\":false}" + }, + { + "type": "text", + "text": "{\"schema\":\"paperclip.semantic_tool_receipt.v1\",\"operationId\":\"receipt_fixture\",\"callIdentitySha256\":\"8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508\",\"inputSha256\":\"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",\"resultSha256\":\"b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c\",\"outcome\":\"returned\"}" + } + ] + } + } + } + }, + "authoritativeReceipt": { + "schema": "paperclip.semantic_tool_receipt.v1", + "operationId": "receipt_fixture", + "callIdentitySha256": "8880622e75a82af5d9b588823af9d706f2bf3a04dce96ee9aab2fa4a91c55508", + "inputSha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "resultSha256": "b325c663fc4bb7617a745373367ca8cbc7a3445f1b2fdb3a2a151da39f73553c", + "outcome": "returned" + } +} diff --git a/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.provenance.json b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.provenance.json new file mode 100644 index 00000000000..acff9e5520a --- /dev/null +++ b/packages/paperclip-runner/src/drivers/acpx/fixtures/copilot-1.0.88-mcp-receipt-captured.provenance.json @@ -0,0 +1,49 @@ +{ + "schema": "paperclip.native-mcp-capture-provenance.v1", + "nativeVersion": "1.0.88", + "nativeExecutableSha256": "a9ff8babb10b7e443182ae96a8bc50a9c826ef1c773e1344c396eb5bf7f512c3", + "platform": "darwin-arm64", + "sourceCommit": "cd2bd6f755a7685320766a71093320d7221172e9", + "captureKind": "real pinned native executable with deterministic loopback model and MCP server", + "paidProviderCalls": 0, + "credentialReads": false, + "qualification": false, + "scope": "One small successful two-text-block MCP result, original accepted=false; not native proof for every size/error encoding.", + "wireIndices": [ + 9, + 11 + ], + "sanitization": [ + "Replaced only params.sessionId in each selected frame with captured-fixture-session.", + "Excluded all other wire frames and model/configuration discovery.", + "Selected update objects and rawOutput preserved exactly.", + "authoritativeReceipt copied from owned fixture carrier.json; it is not an authority inferred from native output." + ], + "originalEvidence": [ + { + "name": "stdout.json", + "sha256": "95deeefff204b8e8db9c46b882cdea6fbe9b5c0de68efb56b4e9359cd0987208" + }, + { + "name": "carrier.json", + "sha256": "c4f4fc333dfbc5efe33b46435a88c3c8ceff74237083e6eb03ad16bb8e783807" + }, + { + "name": "carrier-proof.json", + "sha256": "4ed85f32732c2995506e4ffd23e85f79f26388aac6d8f47efefb5258c7b71da5" + }, + { + "name": "execution-plan.json", + "sha256": "ae4435b5ed2c23a27e2d9c47efe599e3720b800a66e4f9ef56886428f21ee998" + }, + { + "name": "probe.py", + "sha256": "264d39c1f6c36dadcd343b406a9f3ec851b29a55208c08cc3f6e26640ebeadd7" + }, + { + "name": "HANDOFF.json", + "sha256": "e9859debd7274b42f5e58c28cc025bb2e3a090bb583e71ad460f2f482f6c3dae" + } + ], + "fixtureSha256": "4af7345f219463813b382e4c6863003f9f957e147a96dc9c197b97ecd3e20cfb" +} diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index 828191b8a61..ec7f91e374e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -64,7 +64,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< acpxVersion: QUALIFIED_ACPX_VERSION, agent: "cursor", agentProfileVersion: 10, agentServerPackage: "cursor-agent", agentServerVersion: "2026.09.26-dd393fe", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:e4ffecec5a7d43252f7f79cd681df582492b41a40dd33c1ad1a8354a178ccddb", + commandDigest: "sha256:1df2a15b93bc3a14fa47fa3315344ba023fe2412048047cdc6f32096a6336564", // 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", @@ -75,7 +75,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< acpxVersion: QUALIFIED_ACPX_VERSION, agent: "copilot", agentProfileVersion: 10, agentServerPackage: "@github/copilot", agentServerVersion: "1.0.88", agentRuntimePackage: null, agentRuntimeVersion: null, - commandDigest: "sha256:2d501724197e00564fb8cd8ce7b0f951c32fb6c1d3bfdde2dd0531171e3db266", + commandDigest: "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231", // 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/live/runnerd-acpx-permission-ordering.test.ts b/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts index 3d48678b09f..3da30eae8f7 100644 --- a/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-acpx-permission-ordering.test.ts @@ -48,13 +48,22 @@ vi.mock("../control-plane/durable-prp-control-plane.js", async (importOriginal) }, }; }); +import { cursorToolIdentity, cursorToolExecutionId } from "../drivers/acpx/cursor-plan-tool-identity.js"; +import { normalizeAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js"; import { createCapabilityRunnerdCodexTransport } from "./runnerd-codex-transport.js"; import { makeDriver, WORKSPACE } from "../drivers/codex/codex-app-server-driver.test-support.js"; import type { PrpEvent } from "../protocol/replay-contract.js"; describe("permission and tool order through the actual transport and harness", () => { - it.each(["tool-first", "permission-first"])("preserves committed batch order with an unanswered permission: %s", async order => { + it.each(["tool-first", "permission-first"].flatMap(order => ["native-tool-7", "native\u0080tool", "native\u0085tool", "native\u009ftool", "tool/1", "x".repeat(161)].map(rawId => ({ order, rawId }))))("preserves committed batch order and native identity with an unanswered permission: $order $rawId", async ({ order, rawId }) => { + const toolCallId = cursorToolIdentity(rawId); + const executionId = cursorToolExecutionId(rawId); + const normalized = normalizeAcpxPermission({ sessionId: "provider-session-1", inferredKind: "execute", raw: { + sessionId: "provider-session-1", toolCall: { toolCallId: rawId, kind: "execute" }, + options: [{ kind: "reject_once", optionId: "original-native-denial", name: "Deny" }], + } }, { provider: "cursor" }); + expect(normalized.toolCallId).toBe(toolCallId); const root = mkdtempSync(join(tmpdir(), "permission-ordering-")); const binary = join(root, "unexecuted-runner"); writeFileSync(binary, "not executable: process boundary is mocked"); @@ -75,14 +84,14 @@ describe("permission and tool order through the actual transport and harness", ( if (seen.some(e => e.eventType === "runtime_request.created") && seen.some(e => e.eventType === "tool.execution.started")) break; } })(); const tool = () => peer.current!.emit("tool.execution.started", { - schema: "paperclip.tool.execution.v1", executionId: "native-tool-7", transport: "builtin", namespace: null, + schema: "paperclip.tool.execution.v1", executionId, transport: "builtin", namespace: null, name: "Run command", operation: "execute", target: null, status: "running", readOnly: false, inputUpdated: true, output: null, outputBytes: 0, outputTruncated: false, outputDigest: null, durationMs: null, exitCode: null, progress: [], }); const permission = () => peer.current!.emit("runtime_request.created", { request: { requestId: "permission-7", type: "permission", requestKind: "permission_approval", - itemId: "opaque-item-7", prompt: "Run command", details: { toolCallId: "native-tool-7" }, + itemId: "opaque-item-7", prompt: "Run command", details: { toolCallId: normalized.toolCallId }, choices: [{ key: "decline", label: "Deny" }, { key: "cancel", label: "Cancel" }], origin: { adapter: "acpx-runtime-sidecar", provider: "cursor", method: "session/request_permission" }, } }); @@ -92,8 +101,12 @@ describe("permission and tool order through the actual transport and harness", ( await vi.waitFor(() => expect(seen.filter(e => ["tool.execution.started", "runtime_request.created"].includes(e.eventType))).toHaveLength(2), { timeout: 1_000, interval: 5 }); const nativeOrder = peer.current!.store.state.committedEvents.filter(e => ["tool.execution.started", "runtime_request.created"].includes(e.eventType)).map(e => e.eventType); const projectedOrder = seen.filter(e => ["tool.execution.started", "runtime_request.created"].includes(e.eventType)).map(e => e.eventType); - expect(session.pendingRuntimeRequests!()).toMatchObject([{ requestId: "permission-7", turnId, status: "pending" }]); + expect(session.pendingRuntimeRequests!()).toMatchObject([{ requestId: "permission-7", turnId, status: "pending", details: { toolCallId } }]); expect(projectedOrder).toEqual(nativeOrder); + // Permission details retain bounded native identity; activity retains + // Rust's opaque execution ID. Their deterministic join is intentional. + expect(seen.find(e => e.eventType === "tool.execution.started")?.payload).toMatchObject({ executionId }); + if (rawId === "tool/1" || rawId.length === 161) expect(executionId).not.toBe(toolCallId); } finally { // This test owns no actual process or database. Retire the mocked peer // and its controller timer without inventing a provider terminal event. diff --git a/packages/paperclip-runner/test/fixtures/copilot-profile-v10-identity.json b/packages/paperclip-runner/test/fixtures/copilot-profile-v10-identity.json index 3ecc5414a84..a4fb2572016 100644 --- a/packages/paperclip-runner/test/fixtures/copilot-profile-v10-identity.json +++ b/packages/paperclip-runner/test/fixtures/copilot-profile-v10-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:2d501724197e00564fb8cd8ce7b0f951c32fb6c1d3bfdde2dd0531171e3db266", + "commandDigest": "sha256:c6741d7e49cd1af4ecad9e07181ef96305bde48b56dce2958547f2f066666231", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "copilot", @@ -20,11 +20,11 @@ "ownedDistributionDelivery": "COPILOT_CLI_DIST_DIR:lease-owned-guarded-inner-distribution:v1", "permissionContextContract": "copilot-edit-permission-context-v1", "permissionContextSourceSha256": "fe1bb611bea289d81b1c7c973fbb01b93de133a7caa8fe78b7ff4ee1f485b224", - "permissionAdapterSourceSha256": "cef0777f90837bc87748de1dff5b17ab03b89e7e5df115037e82318baf4e02ff", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", "permissionLocationsSourceSha256": "f39a81da395b6e23aa2a1a45572a28846e5e81bfd3d764d9a456f74a79c752e9", "permissionRedactionSourceSha256": "687270d7bbe3dccce6c2fed8664e76cda724b43e545cb153b9f54b018de52927", "permissionClassifierSourceSha256": "1161eabd82e26133e3b3db3ab567f65d52808b03f176e93497e02c8330349fb4", - "permissionIdentitySourceSha256": "a23ea952e6d760b2692c85c4d5bf0c620682ff2efb62ac5c2e3af0cf0d95eafc", + "permissionIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", "semanticToolReceiptContract": "paperclip.semantic_tool_receipt.v1", "semanticReceiptSourceSha256": "3d3d0fe5ce0e4dbfc78cf95b1bfcff4d4b3760c548e74c6c12db5bcacdd46ae2", "semanticBridgeSourceSha256": "ab8f872344b2beb50e3f9970e0631c451a5493e66d259582ecc9a214624269b5", diff --git a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v10-identity.json b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v10-identity.json index 819b3eb018e..d7d2b1e6b06 100644 --- a/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v10-identity.json +++ b/packages/paperclip-runner/test/fixtures/cursor-acp/profile-v10-identity.json @@ -1,5 +1,5 @@ { - "commandDigest": "sha256:e4ffecec5a7d43252f7f79cd681df582492b41a40dd33c1ad1a8354a178ccddb", + "commandDigest": "sha256:1df2a15b93bc3a14fa47fa3315344ba023fe2412048047cdc6f32096a6336564", "declaration": { "schema": "paperclip.acpx_profile_declaration.v1", "agent": "cursor", @@ -52,8 +52,8 @@ "sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1", "nativePlanToolIdentity": "request-item-id-bound-lifecycle-v1", "nativePermissionToolIdentity": "opaque-native-tool-id-sha256-v1", - "toolIdentitySourceSha256": "a23ea952e6d760b2692c85c4d5bf0c620682ff2efb62ac5c2e3af0cf0d95eafc", - "permissionAdapterSourceSha256": "cef0777f90837bc87748de1dff5b17ab03b89e7e5df115037e82318baf4e02ff", + "toolIdentitySourceSha256": "770b47305e571a81f344883057d0e4a482a1cd59ac77d045836593a8e739deb1", + "permissionAdapterSourceSha256": "49421a779c76e286ce65d75052026eab9630b1995af9ccf045a2d43d284633ce", "toolEvidenceSourceSha256": "8310308ed9605415cb28b7c350d83defef68e90cd30bc2f0491d3d09825e8cd9" } } diff --git a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts index 8745debf7d1..9d71ed4f0d4 100644 --- a/server/src/services/native-runtime/native-cursor-plan-wait.test.ts +++ b/server/src/services/native-runtime/native-cursor-plan-wait.test.ts @@ -94,7 +94,8 @@ describe("accepted Cursor plan passive-wait authority", () => { const facts = fixture(); expect(nativeCursorPlanWaitFromFacts(facts)).not.toBeNull(); const current = resolveQualifiedAcpxProfile("cursor", "gpt-5.6-luna[context=272k,reasoning=medium,fast=false]"); - const next = { ...current, agentProfileVersion: 10, commandDigest: `sha256:${"b".repeat(64)}` } satisfies typeof current; + // Model a future catalog revision without tying this test to today's version. + const next = { ...current, agentProfileVersion: (current.agentProfileVersion + 1) as typeof current.agentProfileVersion, commandDigest: `sha256:${"b".repeat(64)}` } satisfies typeof current; expect(next.agentProfileVersion).toBeGreaterThan(current.agentProfileVersion); const resolver = vi.spyOn(runner, "resolveQualifiedAcpxProfile").mockReturnValue(next); try { expect(nativeCursorPlanWaitFromFacts(facts)).toBeNull(); }