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 be172d4ae2..3179baf3ce 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.test.ts @@ -11,6 +11,9 @@ import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles. import { ACPX_SIDECAR_PROTOCOL_VERSION } 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"; +import { createCursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.js"; +import { validateAcpxRichEvent } from "../drivers/acpx/profile-extensions.js"; import { awaitSidecarCleanupWithin, closeActiveSidecarHostWithin, @@ -36,6 +39,32 @@ afterEach(async () => { }); describe("qualified ACPX runtime sidecar", () => { + it.each(["cursor", "copilot", "pi"])("binds native tool evidence to the active sidecar turn for %s", agent => { + const source = readFileSync(new URL("./acpx-runtime-sidecar.ts", import.meta.url), "utf8"); + const start = source.indexOf(" const evidenceFactory ="); + const end = source.indexOf(" let usageBefore:", start); + expect(start).toBeGreaterThan(0); + const emitted: unknown[] = []; + const create = new Function("createCopilotToolEvidence", "createCursorToolEvidence", "validateAcpxRichEvent", "emit", "agent", ` + const activeHost = { identity: () => ({ backendSessionId: "session" }) }; + let host = activeHost, turnId = "turn"; + const currentTurnId = "turn", openParams = { agent, workingDirectory: "/workspace" }; + const diagnostic = () => {}; + ${source.slice(start, end).replaceAll("openParams!", "openParams")} + 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" } }; + create.evidence?.tool(tool); + expect(emitted).toHaveLength(agent === "pi" ? 0 : 1); + if (agent !== "pi") expect(emitted[0]).toEqual(["runtime.rich_event", expect.objectContaining({ payload: expect.objectContaining({ + category: `${agent}_tool_evidence_v1`, provenance: expect.objectContaining({ sessionId: "session", turnId: "turn" }), + }) }), "turn"]); + expect(JSON.stringify(emitted)).not.toContain("private-value"); + create.retire(); + create.evidence?.tool({ ...tool, tag: "tool_call_update", status: "failed" }); + expect(emitted).toHaveLength(agent === "pi" ? 0 : 1); + }); + 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 1921a1e107..f6a3db693c 100644 --- a/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts +++ b/packages/paperclip-runner/src/cli/acpx-runtime-sidecar.ts @@ -63,6 +63,7 @@ import { } from "../drivers/acpx/sidecar-protocol.js"; import { safeAcpxLocations } from "./acpx-sidecar-locations.js"; import { createCopilotToolEvidence, type CopilotToolEvidence } from "../drivers/acpx/copilot-tool-evidence.js"; +import { createCursorToolEvidence, type CursorToolEvidence } from "../drivers/acpx/cursor-tool-evidence.js"; import { persistedAcpxTurnUsage, acpxUsageEstimateNotice, @@ -370,13 +371,15 @@ async function dispatch( waitForInput: (input, context) => waitForExtensionInput(currentTurnId, input, context), emit: event => emit("runtime.rich_event", { ...event }, currentTurnId), }); - const toolEvidence = openParams!.agent === "copilot" ? createCopilotToolEvidence({ + const evidenceFactory = openParams!.agent === "copilot" ? createCopilotToolEvidence + : openParams!.agent === "cursor" ? createCursorToolEvidence : undefined; + const toolEvidence = evidenceFactory?.({ sessionId: activeHost.identity().backendSessionId, turnId: currentTurnId, workingDirectory: openParams!.workingDirectory, active: () => turnId === currentTurnId && host === activeHost, emit: event => { validateAcpxRichEvent(event); emit("runtime.rich_event", { ...event }, currentTurnId); }, - unavailable: () => diagnostic("copilot_evidence_unavailable", "Copilot tool evidence is incomplete; permission and terminal outcomes are unchanged."), - }) : undefined; + unavailable: () => diagnostic(`${openParams!.agent}_evidence_unavailable`, "ACP tool evidence is incomplete; permission and terminal outcomes are unchanged."), + }); let usageBefore: unknown; try { usageBefore = await readSidecarHostStatusWithin(activeHost); @@ -602,7 +605,7 @@ async function pumpTurn( activeHost: AcpxRuntimeHost, usageBefore: unknown, drainExtensions: () => Promise, - toolEvidence?: CopilotToolEvidence, + toolEvidence?: CopilotToolEvidence | CursorToolEvidence, ): Promise { let terminal: Record; try { @@ -757,7 +760,7 @@ async function waitForPermission( activeTurnId: string, request: AcpPermissionRequest, context: { signal: AbortSignal; responseDelivery?: Promise }, - toolEvidence?: CopilotToolEvidence, + toolEvidence?: CopilotToolEvidence | CursorToolEvidence, ): Promise { const { signal } = context; if (turnId !== activeTurnId || signal.aborted || permissions.size >= MAX_PENDING_INPUTS) { 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 192b2666f5..e554978f4d 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,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import type { AcpRuntimeEvent } from "acpx/runtime"; @@ -1606,6 +1607,47 @@ describe("Codex ACPX harness driver", () => { await session.close({ reason: "receipt checked" }); }); + it.each(["written", "failed"] as const)("binds Cursor denial evidence to its original tool and response write: %s", async outcome => { + const command = "printf 'sensitive-value' > /workspace/denied.txt"; + const fixture = driverFixture({ agent: "cursor", model: "explicit-test-model", providerPolicy: { readOnly: false } }, { + runtimeEvents: [{ type: "tool_call", tag: "tool_call", toolCallId: "cursor-tool", title: "Run command", kind: "execute", status: "pending", rawInput: { command } }], + }); + const session = await fixture.driver.openSession({ runId: "run-cursor-receipt", normalizedSessionId: "session-1", workingDirectory: "/workspace" }); + const origin = collectUntil(session.events(), "provider.notice.recorded"); + const { turnId } = await session.startTurn({ message: { text: "Request the command." } }); + const originEvents = await origin; + const created = collectUntil(session.events(), "runtime_request.created"); + const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!; + const receipt = deferred(); + const providerResponse = callback({ inferredKind: "execute", raw: { + sessionId: "backend-1", toolCall: { toolCallId: "cursor-tool", title: "Run command", kind: "execute" }, + options: [{ optionId: "deny", kind: "reject_once", name: "Deny" }], + } } as Parameters[0], { signal: new AbortController().signal, responseDelivery: receipt.promise }); + const requested = await created; + const request = session.pendingRuntimeRequests!()[0]!; + const evidence = (events: PrpEvent[]) => events.filter(event => event.eventType === "provider.notice.recorded" && event.payload.category === "cursor_tool_evidence_v1"); + const fields = (event: PrpEvent) => Object.fromEntries((event.payload.details as Array<{ name: string; value: string }>).map(field => [field.name, field.value])); + const commandSha256 = `sha256:${createHash("sha256").update(command).digest("hex")}`; + expect(evidence(requested).map(fields)).toEqual([expect.objectContaining({ stage: "permission_requested", toolCallId: "cursor-tool", requestId: request.requestId, commandSha256, declineOffered: "true" })]); + const settled = collectUntil(session.events(), outcome === "written" ? "runtime_request.resolved" : "runtime_request.expired"); + let acknowledged = false; + const resolution = session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: "decline" } }).then(() => { acknowledged = true; }); + await expect(providerResponse).resolves.toEqual({ outcome: "reject_once" }); + expect(acknowledged).toBe(false); + if (outcome === "written") { receipt.resolve(); await resolution; } + else { + const failure = expect(resolution).rejects.toThrow("pipe failed"); + receipt.reject(new Error("pipe failed")); await failure; + } + const terminalEvents = await settled; + expect(evidence(terminalEvents).map(fields)).toEqual(outcome === "written" + ? [expect.objectContaining({ stage: "permission_delivered", outcome: "reject_once", commandSha256, requestId: request.requestId })] : []); + expect(JSON.stringify([...evidence(originEvents), ...evidence(requested), ...evidence(terminalEvents)])).not.toContain("sensitive-value"); + expect(session.pendingRuntimeRequests!()).toHaveLength(0); + if (outcome === "written") fixture.finishTurn({ status: "completed", stopReason: "end_turn" }); + await session.close({ reason: "Cursor receipt verified" }); + }); + it.each(["copilot", "codex"] as const)("preserves pinned attached-shell evidence only on the Copilot direct driver: %s", async agent => { const wire = JSON.parse(readFileSync(new URL("./fixtures/copilot-tool-evidence.json", import.meta.url), "utf8"))["attached-shell"]; const runtimeEvents = wire.filter((frame: any) => frame.method === "session/update").map((frame: any) => ({ ...frame.params.update, type: "tool_call", tag: frame.params.update.sessionUpdate })) as AcpRuntimeEvent[]; 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 ca0b296708..ccbe6abd90 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-acpx-driver.ts @@ -1,4 +1,5 @@ import { createCopilotToolEvidence, type CopilotToolEvidence } from "./copilot-tool-evidence.js"; +import { createCursorToolEvidence, type CursorToolEvidence } from "./cursor-tool-evidence.js"; import { requireAcpxResponseDelivery } from "./response-delivery.js"; import { createPiMessageProjection, piBoundaryClearsFinal, type PiProjectedMessageEvent } from "./pi-message-projection.js"; import { acpxProfileClientCapabilities, bindAcpxExtensionTurn, validateAcpxRichEvent, createAcpxProfileExtensionAdapter, type AcpxExtensionInput } from "./profile-extensions.js"; @@ -901,14 +902,16 @@ class CodexAcpxSession implements HarnessSession { } }, }); - const toolEvidence = this.#agent === "copilot" ? createCopilotToolEvidence({ + const evidenceFactory = this.#agent === "copilot" ? createCopilotToolEvidence + : this.#agent === "cursor" ? createCursorToolEvidence : undefined; + const toolEvidence = evidenceFactory?.({ sessionId: this.#host.identity().backendSessionId, turnId, workingDirectory: this.#input.workingDirectory, active: () => this.#activeTurnId === turnId && !this.#closingStarted, emit: event => { validateAcpxRichEvent(event); - if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("Copilot activity could not be retained"); + if (!this.#emit(event.eventType, event.payload, { turnId, itemId: event.itemId })) throw new Error("ACP tool activity could not be retained"); }, - }) : undefined; + }); let turn: AcpxRuntimeTurn; const usageBefore = await readUsageStatus(this.#host); try { @@ -1438,7 +1441,7 @@ class CodexAcpxSession implements HarnessSession { .catch(() => undefined); } - async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn, drainExtensions: () => Promise, usageBefore: unknown, toolEvidence?: CopilotToolEvidence): Promise { + async #pumpTurn(turnId: string, turn: AcpxRuntimeTurn, drainExtensions: () => Promise, usageBefore: unknown, toolEvidence?: CopilotToolEvidence | CursorToolEvidence): Promise { try { let index = 0; const normalizeToolEvent = @@ -1728,7 +1731,7 @@ class CodexAcpxSession implements HarnessSession { turnId: string, request: AcpPermissionRequest, context: { signal: AbortSignal; responseDelivery?: Promise }, - toolEvidence?: CopilotToolEvidence, + toolEvidence?: CopilotToolEvidence | CursorToolEvidence, ): Promise { const { signal } = context; if (this.#closed || this.#activeTurnId !== turnId || signal.aborted