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 22bc067af2b..ac9892a58f1 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -1,3 +1,4 @@ +import { appendSemanticToolReceipt } from "../drivers/semantic-tool-receipt.js"; import { acpxUsageEstimateNotice, persistedAcpxTurnUsage, persistedCursorUsageNotice } from "../drivers/acpx/usage-accounting.js"; import { stripTypeScriptTypes } from "node:module"; import { cursorPlanToolIdentity, cursorToolIdentity } from "../drivers/acpx/cursor-plan-tool-identity.js"; @@ -175,10 +176,10 @@ describe("qualified ACPX runtime sidecar", () => { const emitted: unknown[] = []; const create = new Function("createCopilotToolEvidence", "createCursorToolEvidence", "validateAcpxRichEvent", "emit", "agent", ` const activeHost = { identity: () => ({ backendSessionId: "session" }) }; - let host = activeHost, turnId = "turn"; + let host = activeHost, turnId = "turn", activeCopilotEvidence; const currentTurnId = "turn", openParams = { agent, workingDirectory: "/workspace" }; const diagnostic = () => {}; - ${source.slice(start, end).replaceAll("openParams!", "openParams")} + ${stripTypeScriptTypes(source.slice(start, end))} return { evidence: toolEvidence, retire: () => { turnId = null; } }; `)(createCopilotToolEvidence, createCursorToolEvidence, validateAcpxRichEvent, (...args: unknown[]) => emitted.push(args), agent); const tool = { type: "tool_call", tag: "tool_call", toolCallId: "tool", kind: "execute", status: "pending", rawInput: { command: "printf private-value" } }; @@ -193,6 +194,25 @@ describe("qualified ACPX runtime sidecar", () => { expect(emitted).toHaveLength(agent === "pi" ? 0 : 1); }); + it("captures the actual sidecar bridge's Copilot receipt callback before turn replacement", () => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(" semanticTools: {"); + const end = source.indexOf(" onGoalUpdate:", start); + expect(start).toBeGreaterThan(0); expect(end).toBeGreaterThan(start); + const events: unknown[] = []; + let active = true; + const evidence = createCopilotToolEvidence({ sessionId: "session-a", turnId: "turn-a", workingDirectory: "/workspace", active: () => active, emit: event => { validateAcpxRichEvent(event); events.push(event); } }); + const create = new Function("params", "activeCopilotEvidence", "waitForTool", `return ({ ${source.slice(start, end)} }).semanticTools;`); + const options = create({ agent: "copilot", tools: [] }, evidence, () => undefined); + const captured = options.captureSemanticReceipt(); + const receipt = appendSemanticToolReceipt({ tool: "get_task_context", callId: "1", arguments: {} }, { content: [{ type: "text", text: "{}" }] }).receipt; + captured(receipt); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ payload: { category: "paperclip_semantic_tool_receipt_v1", provenance: { sessionId: "session-a", turnId: "turn-a" } } }); + active = false; captured(receipt); expect(events).toHaveLength(1); + for (const agent of ["cursor", "codex", "pi"]) expect(create({ agent, tools: [] }, evidence, () => undefined).captureSemanticReceipt).toBeUndefined(); + }); + 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")); diff --git a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts index e2b89a8b691..f62f3c731c8 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -152,6 +152,7 @@ let failedAdmissionCleanup: Promise | null = null; let openParams: AcpxSidecarOpenParams | null = null; let runId: string | null = null; let turnId: string | null = null; +let activeCopilotEvidence: CopilotToolEvidence | undefined; let sequence = 0; let requestSequence = 0; let closing = false; @@ -300,6 +301,7 @@ async function dispatch( semanticTools: { tools: params.tools, handler: waitForTool, + ...(params.agent === "copilot" ? { captureSemanticReceipt: () => activeCopilotEvidence?.captureSemanticReceipt() } : {}), }, onGoalUpdate: (goal) => { // Admission can emit a snapshot before the verified host is assigned. @@ -383,6 +385,7 @@ async function dispatch( emit: event => { validateAcpxRichEvent(event); emit("runtime.rich_event", { ...event }, currentTurnId); }, unavailable: () => diagnostic(`${openParams!.agent}_evidence_unavailable`, "ACP tool evidence is incomplete; permission and terminal outcomes are unchanged."), }); + activeCopilotEvidence = toolEvidence && "captureSemanticReceipt" in toolEvidence ? toolEvidence as CopilotToolEvidence : undefined; let usageBefore: unknown; try { usageBefore = await readSidecarHostStatusWithin(activeHost); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts index 79bbdb80ef6..e0775fb7418 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.test.ts @@ -1,3 +1,4 @@ +import { appendSemanticToolReceipt } from "../semantic-tool-receipt.js"; import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; import { cursorToolIdentity } from "./cursor-plan-tool-identity.js"; @@ -25,6 +26,31 @@ import type { import type { AcpxRecoveryWorkspaceLease } from "./runtime-sandbox.js"; describe("Codex ACPX harness driver", () => { + it.each(["copilot", "cursor", "pi", "codex"] as const)("scopes optional semantic receipts to the actual %s invocation turn", async agent => { + const fixture = driverFixture({ agent, model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); + const session = await fixture.driver.openSession({ runId: "run-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + const first = await session.startTurn({ message: { role: "user", text: "First" } }); + const capture = fixture.hostOptions!.semanticTools!.captureSemanticReceipt; + expect(typeof capture).toBe(agent === "copilot" ? "function" : "undefined"); + const oldCallback = capture?.(); + const bound = appendSemanticToolReceipt({ tool: "get_task_context", callId: "1", arguments: {} }, { content: [{ type: "text", text: "{}" }] }); + oldCallback?.(bound.receipt); + fixture.finishTurn({ status: "completed" }); + const firstEvents = await collectUntil(session.events(), "turn.completed"); + const receipts = firstEvents.filter(e => e.eventType === "provider.notice.recorded" && e.payload.category === "paperclip_semantic_tool_receipt_v1"); + expect(receipts).toHaveLength(agent === "copilot" ? 1 : 0); + if (agent === "copilot") expect(receipts[0]).toMatchObject({ runId: "run-receipt", turnId: first.turnId, payload: { provenance: { sessionId: "backend-1", turnId: first.turnId } } }); + const transcript = JSON.parse(JSON.stringify(await session.transcript!())); + for (const event of transcript.events) validatePrpEvent(event); + expect(transcript.events.filter((e: PrpEvent) => e.payload.category === "paperclip_semantic_tool_receipt_v1")).toEqual(receipts); + await session.startTurn({ message: { role: "user", text: "Second" } }); + oldCallback?.(bound.receipt); + fixture.finishTurn({ status: "completed" }); + const secondEvents = await collectUntil(session.events(), "turn.completed"); + expect(secondEvents.filter(e => e.payload.category === "paperclip_semantic_tool_receipt_v1")).toEqual([]); + await session.close({ reason: "receipt turn scope verified" }); + }); + it.each(["cursor", "copilot"] as const)("emits partial Cursor metadata only for admitted Cursor, preserving %s settlement", async agent => { const fixture = driverFixture({ agent, model: "explicit-test-model", providerPolicy: { readOnly: true } }, { runtimeEvents: [] }); const session = await fixture.driver.openSession({ runId: "run-native-usage", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts index 601508a7112..03fdf234fc3 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -472,6 +472,7 @@ export class CodexAcpxDriver implements HarnessDriver { : {}), ...(input.signal === undefined ? {} : { signal: input.signal }), semanticTools: { + ...(this.#options.agent === "copilot" ? { captureSemanticReceipt: () => session?.captureSemanticReceipt() } : {}), tools: this.#options.dynamicTools ?? [], handler: (call) => { if (!session) { @@ -754,6 +755,7 @@ class CodexAcpxSession implements HarnessSession { >; #sourceSequence = 0; #activeTurnId: string | null = null; + #copilotToolEvidence: CopilotToolEvidence | undefined; #semanticResult: PrpStructuredRunResult | null = null; #semanticFingerprint: string | null = null; #semanticCallId: string | null = null; @@ -913,6 +915,7 @@ class CodexAcpxSession implements HarnessSession { if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("ACP tool activity could not be retained"); }, }); + this.#copilotToolEvidence = toolEvidence && "captureSemanticReceipt" in toolEvidence ? toolEvidence as CopilotToolEvidence : undefined; let turn: AcpxRuntimeTurn; const usageBefore = await readUsageStatus(this.#host); try { @@ -1109,6 +1112,10 @@ class CodexAcpxSession implements HarnessSession { return { result: "handed_off", cleanup }; } + captureSemanticReceipt() { + return this.#copilotToolEvidence?.captureSemanticReceipt(); + } + async dispatchTool(call: RunnerToolCall): Promise { this.#assertOpen(); if (this.#pendingTerminal) { 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 1db0de9a711..1f1147d1299 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 @@ -1,3 +1,4 @@ +import { appendSemanticToolReceipt } from "../semantic-tool-receipt.js"; import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; @@ -6,9 +7,9 @@ import type { CanonicalProviderEvent } from "../../provider-events.js"; import { createCopilotToolEvidence } from "./copilot-tool-evidence.js"; const fixture = JSON.parse(readFileSync(new URL("./fixtures/copilot-tool-evidence.json", import.meta.url), "utf8")); const details = (e: CanonicalProviderEvent) => Object.fromEntries((e.payload.details as Array<{ name: string; value: string }>).map(d => [d.name, d.value])); -function harness(sessionId = "session") { +function harness(sessionId = "session", turnId = "turn") { const events: CanonicalProviderEvent[] = []; let active = true; - const projector = createCopilotToolEvidence({ sessionId, turnId: "turn", workingDirectory: "/fixture/workspace", active: () => active, + const projector = createCopilotToolEvidence({ sessionId, turnId, workingDirectory: "/fixture/workspace", active: () => active, emit: event => { validateAcpxRichEvent(event); events.push(event); } }); return { projector, events, stop: () => { active = false; } }; } @@ -120,3 +121,49 @@ describe("Copilot active-turn tool evidence", () => { for (let n = 0; n < 2200; n++) h.projector.tool(update("t0", "")); expect(h.events).toHaveLength(2048); }); }); + +describe("Copilot authoritative semantic receipt correlation", () => { + const args = { summary: "PRIVATE", completionClaim: { objectiveSatisfied: true } }; + const make = (operation = "paperclip_finish", isError = false) => appendSemanticToolReceipt( + { tool: operation, callId: "3", arguments: args }, { content: [{ type: "text", text: '{"accepted":false}' }], ...(isError ? { isError } : {}) }); + const terminal = (id: string, content: unknown, status = "completed") => ({ type: "tool_call", tag: "tool_call_update", toolCallId: id, status, rawOutput: { contents: content } }); + it.each(["paperclip_finish", "report_progress", "get_task_context"])("correlates %s with trusted callback, not native display name", operation => { + const h = harness(); const bound = make(operation); + h.projector.tool({ ...tool("native", args, "other"), title: "unrelated display" }); + h.projector.captureSemanticReceipt()!(bound.receipt); + h.projector.tool(terminal("native", bound.result.content)); + const authoritative = h.events.find(e => e.payload.category === "paperclip_semantic_tool_receipt_v1")!; + expect(details(authoritative)).toMatchObject({ operationId: operation, callIdentitySha256: bound.receipt.callIdentitySha256, outcome: "returned" }); + expect(authoritative.payload.provenance).toMatchObject({ method: "paperclip/semantic_tool_result", sessionId: "session", turnId: "turn" }); + expect(details(h.events.at(-1)!)).toMatchObject({ semanticOperationId: operation, semanticOutcome: "returned", semanticResultSha256: bound.receipt.resultSha256 }); + expect(JSON.stringify(h.events)).not.toMatch(/PRIVATE|accepted|completionClaim/); + }); + it.each(["untrusted", "different-input", "different-result", "foreign-session", "foreign-turn", "duplicate-native", "duplicate-authority", "early-terminal", "wrong-status"])("rejects %s authority", scenario => { + const h = harness(), other = harness(scenario === "foreign-turn" ? "session" : "foreign", scenario === "foreign-turn" ? "other-turn" : "turn"); const bound = make(); + h.projector.tool({ ...tool("native", scenario === "different-input" ? {} : args, "other"), title: "paperclip_finish" }); + if (scenario === "early-terminal") h.projector.tool(terminal("native", bound.result.content)); + if (scenario === "foreign-session" || scenario === "foreign-turn") other.projector.captureSemanticReceipt()!(bound.receipt); + else if (scenario !== "untrusted") h.projector.captureSemanticReceipt()!(bound.receipt); + if (scenario === "duplicate-authority") h.projector.captureSemanticReceipt()!(bound.receipt); + if (scenario === "duplicate-native") { + h.projector.tool(terminal("native", bound.result.content)); + h.projector.tool(tool("second", args, "other")); + } + const output = structuredClone(bound.result.content); + if (scenario === "different-result") output[0]!.text = "changed"; + h.projector.tool(terminal(scenario === "duplicate-native" ? "second" : "native", output, scenario === "wrong-status" ? "failed" : "completed")); + expect(details(h.events.at(-1)!)).not.toHaveProperty("semanticOperationId"); + if (scenario === "duplicate-native") expect(h.events.map(details)).toContainEqual(expect.objectContaining({ reason: "semantic_receipt_conflict" })); + }); + it("preserves admitted errors and fences late captured callbacks instead of rebinding", () => { + const h = harness(); const bound = make("get_task_context", true); + const captured = h.projector.captureSemanticReceipt()!; + h.projector.tool(tool("native", args, "other")); captured(bound.receipt); + h.projector.tool(terminal("native", bound.result.content, "failed")); + expect(details(h.events.at(-1)!)).toMatchObject({ semanticOutcome: "error", status: "failed" }); + const count = h.events.length; h.stop(); captured(bound.receipt); + expect(h.events).toHaveLength(count); + const next = harness(); next.projector.tool(tool("native", args, "other")); next.projector.tool(terminal("native", bound.result.content, "failed")); + expect(details(next.events.at(-1)!)).not.toHaveProperty("semanticOperationId"); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts index de004db7a23..e2eb8489b84 100644 --- a/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts +++ b/packages/paperclip-runner/src/drivers/acpx/copilot-tool-evidence.ts @@ -1,3 +1,4 @@ +import { parseSemanticToolReceipt, readNativeSemanticReceipt, sameSemanticReceipt, semanticInputSha256, type SemanticToolReceipt } from "../semantic-tool-receipt.js"; import { updateSingleReadEvidence, type SingleReadEvidence } from "./single-read-evidence.js"; import { createHash } from "node:crypto"; import { redactPaperclipSemanticValue } from "../../semantic-tools/redaction.js"; @@ -7,7 +8,7 @@ import { safeCopilotEditTarget } from "./copilot-permission-context.js"; const LIMIT = 256; const CATEGORY = "copilot_tool_evidence_v1"; type Fields = Record; -interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; read?: SingleReadEvidence } +interface Tool { kind?: string; input?: string; fields: Fields; invalid?: boolean; semanticInput?: string; semanticReceipt?: SemanticToolReceipt; read?: SingleReadEvidence } const record = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : {}; const identity = (v: unknown): v is string => typeof v === "string" && v.length > 0 && v.length <= 240 && !/[\u0000-\u001f\u007f]/u.test(v); const shellIdentity = (v: unknown): v is string => typeof v === "string" && /^[A-Za-z0-9_.-]{1,80}$/u.test(v); @@ -27,6 +28,8 @@ export function createCopilotToolEvidence(binding: { const tools = new Map(); const shells = new Map(); const permissionTools = new Set(); + const semanticReceipts = new Map(); + const matchedReceipts = new Map(); let sequence = 0; let incomplete = false; let broken = false; @@ -40,7 +43,7 @@ export function createCopilotToolEvidence(binding: { return; } } - function notice(stage: string, toolCallId: string, fields: Fields, method = "session/update") { + function notice(stage: string, toolCallId: string | undefined, fields: Fields, method = "session/update", category = CATEGORY) { if (!binding.active() || !identity(binding.sessionId) || !identity(binding.turnId)) return; if (sequence >= 2048) { if (incomplete) return; @@ -49,8 +52,8 @@ export function createCopilotToolEvidence(binding: { const itemId = `copilot-evidence-${digest(`${binding.sessionId}:${binding.turnId}`).slice(0, 24)}-${++sequence}`; binding.emit({ eventType: "provider.notice.recorded", itemId, payload: redactPaperclipSemanticValue({ schema: "paperclip.provider.notice.v1", noticeId: itemId, severity: "info", - category: CATEGORY, scope: "turn", recoverable: true, userActionable: false, - summary: stage === "evidence_incomplete" ? "Some Copilot activity details are unavailable." + category, scope: "turn", recoverable: true, userActionable: false, + summary: stage === "semantic_result" ? "Paperclip returned a semantic tool result." : stage === "evidence_incomplete" ? "Some Copilot activity details are unavailable." : stage === "permission_requested" ? "Copilot requested permission." : stage === "permission_delivered" ? (fields.outcome === "reject_once" ? "Copilot received your denial." : "Copilot received your permission decision.") : fields.shellState === "started" ? (fields.detach === false ? "Copilot started an attached command." : "Copilot started a background command.") @@ -58,7 +61,7 @@ export function createCopilotToolEvidence(binding: { : fields.operation === "edit" ? `Copilot file operation: ${fields.status}.` : `Copilot tool operation: ${fields.status}.`, provenance: { method, eventType: stage, sessionId: binding.sessionId, turnId: binding.turnId }, - details: Object.entries({ stage, toolCallId, ...fields }).map(([name, value]) => ({ name, value: String(value) })), + details: Object.entries({ stage, ...(toolCallId === undefined ? {} : { toolCallId }), ...fields }).map(([name, value]) => ({ name, value: String(value) })), }) as Record }); } function inputFields(call: Record): Fields { @@ -93,12 +96,18 @@ export function createCopilotToolEvidence(binding: { state = { fields: {} }; tools.set(id, state); } else if (call.tag === "tool_call") { state.invalid = true; notice("evidence_incomplete", id, { reason: "reused_tool_origin" }); } if (state.invalid) return; + if (state.semanticReceipt) { state.invalid = true; notice("evidence_incomplete", id, { reason: "reused_semantic_lifecycle" }); return; } if (typeof call.kind === "string") { if (state.kind && state.kind !== call.kind) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_kind" }); return; } state.kind = call.kind; } if (state.kind === "read") state.read = updateSingleReadEvidence(state.read, call, binding.workingDirectory); if (call.rawInput !== undefined) { + // Retain only a bounded digest of the native arguments, never their text. + let inputDigest: string | undefined; + try { if (Buffer.byteLength(JSON.stringify(call.rawInput)) <= 1024 * 1024) inputDigest = semanticInputSha256(call.rawInput); } catch { /* Malformed input cannot correlate. */ } + if (state.semanticInput && inputDigest !== state.semanticInput) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_input" }); return; } + state.semanticInput = inputDigest; const fields = inputFields({ ...call, kind: state.kind }); const fingerprint = JSON.stringify(fields); if (state.input && state.input !== fingerprint) { state.invalid = true; notice("evidence_incomplete", id, { reason: "changed_tool_input" }); return; } @@ -127,6 +136,27 @@ export function createCopilotToolEvidence(binding: { } } } + if (call.rawOutput !== undefined) { + const candidate = readNativeSemanticReceipt(call.rawOutput); + if (candidate) { + const authoritative = semanticReceipts.get(candidate.callIdentitySha256); + const previous = matchedReceipts.get(candidate.callIdentitySha256); + if ((status !== "completed" && status !== "failed") || !authoritative + || !sameSemanticReceipt(authoritative, candidate) || state.semanticInput !== candidate.inputSha256 + || (status === "failed") !== (candidate.outcome === "error") || previous !== undefined) { + state.invalid = true; + notice("evidence_incomplete", id, { reason: "semantic_receipt_conflict" }); + return; + } + matchedReceipts.set(candidate.callIdentitySha256, id); + state.semanticReceipt = candidate; + fields.semanticOperationId = candidate.operationId; + fields.semanticCallIdentitySha256 = candidate.callIdentitySha256; + fields.semanticInputSha256 = candidate.inputSha256; + fields.semanticResultSha256 = candidate.resultSha256; + fields.semanticOutcome = candidate.outcome; + } + } notice("tool", id, fields); }, permission(request: unknown, requestId: string, offeredActions: readonly string[]) { @@ -148,6 +178,21 @@ export function createCopilotToolEvidence(binding: { }, }; return { + captureSemanticReceipt(): ((receipt: SemanticToolReceipt) => void) | undefined { + if (!binding.active()) return undefined; + // This closure belongs to this turn, even if a successor becomes active. + return receipt => { safely(() => { + if (!binding.active()) return; + const parsed = parseSemanticToolReceipt(receipt); + if (!parsed || semanticReceipts.size >= LIMIT || semanticReceipts.has(parsed.callIdentitySha256)) { + if (parsed && semanticReceipts.has(parsed.callIdentitySha256)) semanticReceipts.set(parsed.callIdentitySha256, null); + notice("evidence_incomplete", "semantic", { reason: "ambiguous_semantic_receipt" }); + return; + } + semanticReceipts.set(parsed.callIdentitySha256, parsed); + notice("semantic_result", undefined, { ...parsed }, "paperclip/semantic_tool_result", "paperclip_semantic_tool_receipt_v1"); + }); }; + }, tool: (event: unknown) => { safely(() => projection.tool(event)); }, permission: (request: unknown, requestId: string, offeredActions: readonly string[]) => safely(() => projection.permission(request, requestId, offeredActions)), }; 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 7d8c3741e0a..1f9323be1fd 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-host.test.ts @@ -1,3 +1,7 @@ +import { createCopilotToolEvidence } from "./copilot-tool-evidence.js"; +import { readNativeSemanticReceipt, type SemanticToolResult } from "../semantic-tool-receipt.js"; +import { validateAcpxRichEvent } from "./profile-extensions.js"; +import type { CanonicalProviderEvent } from "../../provider-events.js"; import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -736,6 +740,30 @@ describe("ACPX runtime host", () => { expect(fixture.commandClose).toHaveBeenCalledOnce(); }); + it("forwards semantic receipt capture through the real runtime host and authenticated bridge", async () => { + const fixture = await hostFixture(); + const events: CanonicalProviderEvent[] = []; + const evidence = createCopilotToolEvidence({ sessionId: "backend-1", turnId: "turn-1", workingDirectory: fixture.options.workingDirectory, + active: () => true, emit: event => { validateAcpxRichEvent(event); events.push(JSON.parse(JSON.stringify(event))); } }); + let bridge: AcpxRuntimePortOpenOptions["mcpServers"][number] | undefined; + const host = await AcpxRuntimeHost.open({ ...fixture.options, agent: "copilot", model: "gpt-5.6-sol", + permissionMode: "deny-all", environment: { COPILOT_GITHUB_TOKEN: "fixture-not-a-real-credential" }, + semanticTools: { tools: [{ name: "get_task_context", inputSchema: { type: "object", properties: {}, additionalProperties: false } }], + handler: async () => ({ accepted: false }), captureSemanticReceipt: () => evidence.captureSemanticReceipt() }, + }, fixture.dependencies({ openRuntime: async options => { bridge = options.mcpServers[0]; return runtimePort(); } })); + try { + const response = await fetch(bridge!.url, { method: "POST", headers: { Authorization: `Bearer ${bridge!.bearerToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "get_task_context", arguments: {} } }) }); + const body = await response.json() as { result: SemanticToolResult }; + 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 }]) }); + } finally { await host.close({ reason: "receipt forwarding verified" }); } + await expect(fetch(bridge!.url)).rejects.toThrow(); + }); + it("owns an authenticated semantic bridge without persisting its secret", async () => { const fixture = await hostFixture(); const handler = vi.fn(async ({ tool }) => ({ tool, ok: true })); diff --git a/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts b/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts index ff2a412d3ba..f41d95b3000 100644 --- a/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts +++ b/packages/paperclip-runner/src/drivers/runner-tool-bridge.test.ts @@ -1,3 +1,4 @@ +import { readNativeSemanticReceipt, type SemanticToolReceipt, type SemanticToolResult } from "./semantic-tool-receipt.js"; import { connect } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -465,3 +466,44 @@ describe("runner semantic MCP bridge", () => { function tool(name: string): Readonly> { return { name, inputSchema: { type: "object" } }; } + +describe("Copilot semantic receipt opt-in", () => { + it.each(["small", "chunked", "tagged", "error"])("preserves %s result encoding and captures scope before dispatch", async encoding => { + const receipts: SemanticToolReceipt[] = []; + let resolve!: (value: unknown) => void; + let reject!: (error: Error) => void; + const pending = new Promise((yes, no) => { resolve = yes; reject = no; }); + const capture = vi.fn(() => (receipt: SemanticToolReceipt) => receipts.push(receipt)); + const handler = vi.fn(() => pending); + const bridge = await startRunnerToolBridge({ tools: [tool("documents.read")], handler, captureSemanticReceipt: capture }); + bridges.push(bridge); + const request = { id: 3, method: "tools/call", params: { name: "documents.read", arguments: {} } }; + const response = rpc(bridge, request); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(capture).toHaveBeenCalledTimes(1); + if (encoding === "error") reject(new Error("bounded failure")); + else resolve(encoding === "chunked" ? "x".repeat(70000) : encoding === "tagged" ? { bigint: 1n } : { accepted: false }); + const body = await (await response).json() as { result: SemanticToolResult }; + expect(receipts).toHaveLength(1); + expect(readNativeSemanticReceipt({ contents: body.result.content })).toEqual(receipts[0]); + expect(body.result.isError === true).toBe(encoding === "error"); + if (encoding === "small") expect(JSON.parse(body.result.content[0].text)).toEqual({ accepted: false }); + if (encoding === "error") expect(body.result.content[0].text).toBe("bounded failure"); + if (encoding === "chunked") expect(body.result.content.length).toBeGreaterThan(2); + const replay = await (await rpc(bridge, request)).json(); + expect(replay).toEqual(body); + expect(receipts).toHaveLength(1); + expect(handler).toHaveBeenCalledTimes(1); + }); + it("does not receipt unauthorized/unadmitted calls or let diagnostic errors change results", async () => { + const capture = vi.fn(() => () => { throw new Error("diagnostic only"); }); + const bridge = await startRunnerToolBridge({ tools: [tool("documents.read")], handler: async () => ({ ok: true }), captureSemanticReceipt: capture }); + bridges.push(bridge); + await rpc(bridge, { id: 1, method: "tools/call", params: { name: "documents.read" } }, "wrong"); + await rpc(bridge, { id: 2, method: "tools/call", params: { name: "unknown" } }); + expect(capture).not.toHaveBeenCalled(); + const body = await (await rpc(bridge, { id: 3, method: "tools/call", params: { name: "documents.read" } })).json() as { result: SemanticToolResult }; + expect(JSON.parse(body.result.content[0].text)).toEqual({ ok: true }); + expect(readNativeSemanticReceipt({ contents: body.result.content })).not.toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts index e59535171be..ff59048654a 100644 --- a/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts +++ b/packages/paperclip-runner/src/drivers/runner-tool-bridge.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { appendSemanticToolReceipt, semanticCanonicalJson as canonicalJson, type SemanticToolReceipt } from "./semantic-tool-receipt.js"; import { createServer, type IncomingMessage, @@ -33,6 +34,8 @@ export interface RunnerToolBridgeOptions { /** Runner-owned operations that are callable but never model-visible. */ privateTools?: readonly Readonly>[]; handler(call: RunnerToolCall): Promise; + /** Copilot-only opt-in; capture the active invocation scope before dispatch. */ + captureSemanticReceipt?: () => ((receipt: SemanticToolReceipt) => void) | undefined; timeoutMs?: number; privateToolTimeoutMs?: number; maxBodyBytes?: number; @@ -58,6 +61,7 @@ interface RunnerToolTextContent { } interface RunnerToolCallResult { + isError?: boolean; content: RunnerToolTextContent[]; } @@ -121,6 +125,7 @@ export async function startRunnerToolBridge( calls, controllers, handler: options.handler, + captureSemanticReceipt: options.captureSemanticReceipt, timeoutMs: positiveBoundedInteger( options.timeoutMs, DEFAULT_TIMEOUT_MS, @@ -190,6 +195,7 @@ async function handleRequest( calls: Map; controllers: Map; handler: RunnerToolBridgeOptions["handler"]; + captureSemanticReceipt: RunnerToolBridgeOptions["captureSemanticReceipt"]; timeoutMs: number; privateToolTimeoutMs: number; privateToolNames: ReadonlySet; @@ -319,6 +325,16 @@ async function handleRequest( } } const controller = existing === undefined ? new AbortController() : undefined; + let observeReceipt: ((receipt: SemanticToolReceipt) => void) | undefined; + if (!existing) { try { observeReceipt = context.captureSemanticReceipt?.(); } catch { /* Optional evidence cannot prevent dispatch. */ } } + const withReceipt = (result: RunnerToolCallResult): RunnerToolCallResult => { + if (!context.captureSemanticReceipt) return result; + try { + const bound = appendSemanticToolReceipt({ tool, callId, arguments: args }, result); + try { observeReceipt?.(bound.receipt); } catch { /* Evidence cannot change the tool outcome. */ } + return bound.result; + } catch { return result; } + }; const execution: Promise = existing?.promise ?? withCancellationAndTimeout( @@ -336,7 +352,10 @@ async function handleRequest( context.privateToolNames.has(tool) ? context.privateToolTimeoutMs : context.timeoutMs, - ); + ).then(withReceipt, error => { + if (!context.captureSemanticReceipt) throw error; + return withReceipt({ isError: true, content: [{ type: "text", text: safeError(error) }] }); + }); if (!existing) { context.calls.set(callKey, { fingerprint, promise: execution }); context.controllers.set(callKey, controller!); @@ -811,16 +830,6 @@ function taggedObjectType(value: object): string { : "Object"; } -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - if (isRecord(value)) { - return `{${Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) - .join(",")}}`; - } - return JSON.stringify(value) ?? "undefined"; -} function isJsonContentType(value: string | undefined): boolean { return value?.split(";", 1)[0]?.trim().toLowerCase() === "application/json"; diff --git a/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts new file mode 100644 index 00000000000..758a51cbf57 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { appendSemanticToolReceipt, parseSemanticToolReceipt, readNativeSemanticReceipt, semanticInputSha256 } from "./semantic-tool-receipt.js"; +const call = { tool: "paperclip_finish", callId: "3", arguments: { revision: "current", summary: "private" } }; +const original = { content: [{ type: "text" as const, text: '{"accepted":false}' }] }; +describe("bounded semantic receipt carrier", () => { + it("preserves existing content and transport-vs-semantic acceptance", () => { + const { result, receipt } = appendSemanticToolReceipt(call, original); + expect(result.content.slice(0, -1)).toEqual(original.content); + expect(receipt.outcome).toBe("returned"); + expect(JSON.stringify(receipt)).not.toContain("private"); + expect(readNativeSemanticReceipt({ contents: result.content })).toEqual(receipt); + expect(semanticInputSha256({ a: 1, b: 2 })).toBe(semanticInputSha256({ b: 2, a: 1 })); + }); + it("preserves error content without converting error into returned acceptance", () => { + const { result, receipt } = appendSemanticToolReceipt(call, { ...original, isError: true }); + expect(result.isError).toBe(true); + expect(receipt.outcome).toBe("error"); + expect(readNativeSemanticReceipt({ contents: result.content })).toEqual(receipt); + }); + it.each(["unknown", "malformed", "oversized", "modified", "duplicate", "missing", "type", "reordered"])("rejects %s native carrier", kind => { + const { result } = appendSemanticToolReceipt(call, original); + const blocks = structuredClone(result.content); + const receipt = JSON.parse(blocks.at(-1)!.text); + if (kind === "unknown") blocks.at(-1)!.text = JSON.stringify({ ...receipt, extra: "private" }); + if (kind === "malformed") blocks.at(-1)!.text = "{"; + if (kind === "oversized") blocks.at(-1)!.text = "x".repeat(2049); + if (kind === "modified") blocks[0]!.text = "different"; + if (kind === "duplicate") blocks.unshift(blocks.at(-1)!); + if (kind === "missing") blocks.pop(); + if (kind === "type") Object.assign(blocks[0]!, { type: "image" }); + if (kind === "reordered") blocks.reverse(); + expect(readNativeSemanticReceipt({ contents: blocks })).toBeNull(); + }); + it("bounds the whole repeated native output while still serializing large-result receipts", () => { + const bound = appendSemanticToolReceipt(call, { content: [{ type: "text", text: "x".repeat(90_000) }] }); + expect(readNativeSemanticReceipt({ contents: bound.result.content })).toEqual(bound.receipt); + expect(readNativeSemanticReceipt({ contents: bound.result.content, content: "x".repeat(90_000), detailedContent: "x".repeat(90_000) })).toBeNull(); + expect(bound.result.content.at(-1)!.text.length).toBeLessThan(2048); + }); + it("rejects unknown fields and unsafe identities without truncation", () => { + const { receipt } = appendSemanticToolReceipt(call, original); + expect(parseSemanticToolReceipt({ ...receipt, operationId: "bad\nname" })).toBeNull(); + expect(parseSemanticToolReceipt({ ...receipt, inputSha256: "a".repeat(65) })).toBeNull(); + expect(parseSemanticToolReceipt({ ...receipt, outcome: "accepted" })).toBeNull(); + }); +}); diff --git a/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts new file mode 100644 index 00000000000..b67cf0c6513 --- /dev/null +++ b/packages/paperclip-runner/src/drivers/semantic-tool-receipt.ts @@ -0,0 +1,71 @@ +import { createHash } from "node:crypto"; + +export const SEMANTIC_RECEIPT_SCHEMA = "paperclip.semantic_tool_receipt.v1"; +export const MAX_SEMANTIC_RECEIPT_BYTES = 2048; +const MAX_NATIVE_BYTES = 256 * 1024; +export interface SemanticToolReceipt { + schema: typeof SEMANTIC_RECEIPT_SCHEMA; + operationId: string; + callIdentitySha256: string; + inputSha256: string; + resultSha256: string; + /** Transport outcome only: a returned value may explicitly reject a request. */ + outcome: "returned" | "error"; +} +export interface SemanticToolResult { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +} +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +const object = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); +/** Same canonical JSON used by the bridge's admitted-call fingerprint. */ +export function semanticCanonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(semanticCanonicalJson).join(",")}]`; + if (object(value)) return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${semanticCanonicalJson(value[key])}`).join(",")}}`; + return JSON.stringify(value) ?? "undefined"; +} +export const semanticInputSha256 = (value: unknown): string => hash(semanticCanonicalJson(value)); +export function parseSemanticToolReceipt(value: unknown): SemanticToolReceipt | null { + if (!object(value) || Object.keys(value).sort().join(",") !== "callIdentitySha256,inputSha256,operationId,outcome,resultSha256,schema" + || value.schema !== SEMANTIC_RECEIPT_SCHEMA + || typeof value.operationId !== "string" || !/^[A-Za-z0-9_.:-]{1,256}$/.test(value.operationId) + || ![value.callIdentitySha256, value.inputSha256, value.resultSha256].every(v => typeof v === "string" && /^[a-f0-9]{64}$/.test(v)) + || (value.outcome !== "returned" && value.outcome !== "error") + || Buffer.byteLength(JSON.stringify(value)) > MAX_SEMANTIC_RECEIPT_BYTES) return null; + return { schema: SEMANTIC_RECEIPT_SCHEMA, operationId: value.operationId, + callIdentitySha256: value.callIdentitySha256 as string, inputSha256: value.inputSha256 as string, + resultSha256: value.resultSha256 as string, outcome: value.outcome }; +} +function resultDigest(result: SemanticToolResult): string { + return semanticInputSha256({ content: result.content, isError: result.isError === true }); +} +/** Append, never rewrite, the original admitted result encoding and content. */ +export function appendSemanticToolReceipt(call: { tool: string; callId: string; arguments: unknown }, result: SemanticToolResult) { + const receipt = parseSemanticToolReceipt({ schema: SEMANTIC_RECEIPT_SCHEMA, operationId: call.tool, + callIdentitySha256: hash(call.callId), inputSha256: semanticInputSha256(call.arguments), + resultSha256: resultDigest(result), outcome: result.isError ? "error" : "returned" }); + if (!receipt) throw new Error("Invalid semantic receipt identity"); + return { receipt, result: { ...result, content: [...result.content, { type: "text" as const, text: JSON.stringify(receipt) }] } }; +} +/** + * A native echo is only a candidate. Authority comes from the invocation callback. + * The 256 KiB bound applies to the WHOLE native rawOutput, including repeated + * content/detailedContent. Larger results still receive bridge receipts, but + * intentionally provide no native correlation evidence; never truncate to match. + */ +export function readNativeSemanticReceipt(raw: unknown): SemanticToolReceipt | null { + try { + if (!object(raw) || Buffer.byteLength(JSON.stringify(raw)) > MAX_NATIVE_BYTES || !Array.isArray(raw.contents) + || raw.contents.length < 2 || raw.contents.length > 128) return null; + const blocks = raw.contents; + if (!blocks.every(b => object(b) && Object.keys(b).sort().join(",") === "text,type" && b.type === "text" && typeof b.text === "string")) return null; + const last = blocks.at(-1) as { text: string }; + if (Buffer.byteLength(last.text) > MAX_SEMANTIC_RECEIPT_BYTES) return null; + const receipt = parseSemanticToolReceipt(JSON.parse(last.text)); + if (!receipt) return null; + const original = blocks.slice(0, -1) as SemanticToolResult["content"]; + if (original.some(b => { try { return parseSemanticToolReceipt(JSON.parse(b.text)) !== null; } catch { return false; } })) return null; + return resultDigest({ content: original, isError: receipt.outcome === "error" }) === receipt.resultSha256 ? receipt : null; + } catch { return null; } +} +export const sameSemanticReceipt = (a: SemanticToolReceipt, b: SemanticToolReceipt): boolean => semanticCanonicalJson(a) === semanticCanonicalJson(b); diff --git a/server/src/services/native-runtime/native-completion-feedback.test.ts b/server/src/services/native-runtime/native-completion-feedback.test.ts new file mode 100644 index 00000000000..59971449917 --- /dev/null +++ b/server/src/services/native-runtime/native-completion-feedback.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { heartbeatRuns, issues, issueThreadInteractions, issueApprovals, type Db } from "@paperclipai/db"; +import type { PrpStructuredRunResult } from "../../vendor/paperclip-runner/index.js"; +import { nativeCompletionFeedback } from "./native-completion-feedback.js"; + +const state = vi.hoisted(() => ({ blockers: 0 })); +vi.mock("./native-deliverable-feedback.js", () => ({ validateNativeDeliverableEvidence: vi.fn(async () => undefined) })); +vi.mock("./automatic-completion-reviews.js", () => ({ findAutomaticCompletionReviews: vi.fn(async () => []) })); +vi.mock("../agent-invokability.js", () => ({ evaluateAgentInvokabilityFromDb: vi.fn() })); +vi.mock("../issues.js", () => ({ issueService: () => ({ getDependencyReadiness: async () => ({ unresolvedBlockerCount: state.blockers }) }) })); +vi.mock("./native-review-participant.js", () => ({ readNativeReviewAssignmentContext: () => null, getNativeReviewAssignment: vi.fn() })); +vi.mock("../../vendor/paperclip-runner/index.js", () => ({ normalizePrpResultSignals: () => ({ verification: [], actionableAttentionRequests: [] }) })); + +const result = { reportedWorkDisposition: "done", summary: "EXACT_PRIVATE_MARKER", completionClaim: { objectiveSatisfied: true, criteria: [], remainingWork: [] } } as unknown as PrpStructuredRunResult; +function database(options: { interaction?: boolean; approval?: boolean; review?: boolean } = {}): Db { + // Pure query double: no client, PostgreSQL process, or network is created. + return { select: () => ({ from: (table: unknown) => { + const rows = table === heartbeatRuns ? [{ id: "run", companyId: "company", nativeIssueId: "issue", contextSnapshot: {} }] + : table === issues ? [{ id: "issue", companyId: "company", status: "in_progress", ...(options.review ? { executionState: { status: "pending" } } : {}) }] + : table === issueThreadInteractions && options.interaction ? [{ id: "request", kind: "request_confirmation", title: "Decision" }] + : table === issueApprovals && options.approval ? [{ id: "approval" }] : []; + const query = { where: () => query, limit: () => query, innerJoin: () => query, then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve) }; + return query; + } }) } as unknown as Db; +} +beforeEach(() => { state.blockers = 0; }); +describe("native completion feedback format precedence", () => { + it("honors requested final format only after normal completion acceptance without echoing the summary", async () => { + const feedback = await nativeCompletionFeedback(database(), "run", result); + expect(feedback).toContain("Follow the user's explicitly requested final-response format, including an exact response when requested."); + expect(feedback).toContain("Task status will be committed after this turn and workspace finalization finish."); + expect(feedback).toContain("Do not claim an approval is needed unless one was requested."); + expect(feedback).not.toContain(result.summary); + }); + it.each(["interaction", "approval", "review", "dependency"] as const)("preserves %s governance feedback instead of granting format precedence", async mode => { + state.blockers = mode === "dependency" ? 1 : 0; + const feedback = await nativeCompletionFeedback(database({ interaction: mode === "interaction", approval: mode === "approval", review: mode === "review" }), "run", result); + expect(feedback).not.toContain("final-response format"); + expect(feedback).toMatch(/still waiting|still pending|unresolved dependencies/); + }); +}); diff --git a/server/src/services/native-runtime/native-completion-feedback.ts b/server/src/services/native-runtime/native-completion-feedback.ts index 06d5d87be7b..45845eaeb9c 100644 --- a/server/src/services/native-runtime/native-completion-feedback.ts +++ b/server/src/services/native-runtime/native-completion-feedback.ts @@ -183,5 +183,5 @@ export async function nativeCompletionFeedback( throw new Error("The named reviewer is not available in this company. Choose an available reviewer or report the concrete blocker."); } } - return "Completion report accepted. Task status will be committed after this turn and workspace finalization finish. Describe the completed work and any explicitly requested reviewer action; do not claim an approval is needed unless one was requested."; + return "Completion report accepted. Task status will be committed after this turn and workspace finalization finish. Follow the user's explicitly requested final-response format, including an exact response when requested. Otherwise describe the completed work and any explicitly requested reviewer action. Do not claim an approval is needed unless one was requested."; }