diff --git a/packages/pi-plugin/src/issue-880-cursor-receipt.test.ts b/packages/pi-plugin/src/issue-880-cursor-receipt.test.ts new file mode 100644 index 0000000000..ba8db48968 --- /dev/null +++ b/packages/pi-plugin/src/issue-880-cursor-receipt.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + piConversationFields, + piCursorManagedReceipt, + piHasManagedSignature, +} from "./pi-dispatch-identity"; + +const signature = ""; +const managed = [ + { + role: "user", + content: [{ type: "text", text: `${signature}prepared` }], + }, + { role: "user", content: "older" }, +]; +const root = [ + { role: "system", content: "instructions" }, + { + role: "user", + content: [{ type: "text", text: `${signature}prepared` }], + }, + { role: "user", content: [{ type: "text", text: "older" }] }, +]; +const rootIds = root.map((message) => + createHash("sha256").update(JSON.stringify(message), "utf8").digest(), +); + +function cursorPayload(activeText = "current prompt", messageId = "random-1") { + return { + conversationState: { + rootPromptMessagesJson: rootIds, + turns: [], + }, + action: { + action: { + case: "userMessageAction", + value: { + userMessage: { text: activeText, messageId }, + }, + }, + }, + }; +} + +test("issue 880 cursor-agent: managed root blobs are accepted through the receipt", () => { + const receipt = piCursorManagedReceipt(managed); + expect(receipt).toHaveLength(1); + expect(piHasManagedSignature(cursorPayload(), signature, receipt)).toBe(true); + expect(piHasManagedSignature(cursorPayload(), signature)).toBe(false); +}); + +test("issue 880 cursor-agent: a missing managed root blob is rejected", () => { + const receipt = piCursorManagedReceipt(managed); + const payload = cursorPayload(); + payload.conversationState.rootPromptMessagesJson = [rootIds[0], rootIds[2]]; + expect(piHasManagedSignature(payload, signature, receipt)).toBe(false); +}); + +test("issue 880 cursor-agent: inline root JSON remains readable", () => { + const payload = cursorPayload(); + payload.conversationState.rootPromptMessagesJson = root.map((message) => + Buffer.from(JSON.stringify(message), "utf8"), + ); + expect(piHasManagedSignature(payload, signature)).toBe(true); +}); + +test("issue 880 cursor-agent: action identity ignores Cursor's random message id", () => { + const fields = piConversationFields(cursorPayload()); + expect(fields).toBeDefined(); + expect( + piConversationFields(cursorPayload("current prompt", "random-2")), + ).toEqual(fields); + expect(piConversationFields(cursorPayload("a different turn"))).not.toEqual( + fields, + ); +}); + +test("issue 880 cursor-agent: standard message payloads still work", () => { + const payload = { + messages: [ + { role: "user", content: `${signature}managed` }, + ], + }; + expect(piHasManagedSignature(payload, signature)).toBe(true); + expect(piConversationFields(payload)).toBeDefined(); +}); diff --git a/packages/pi-plugin/src/issue-880-provider-fence.test.ts b/packages/pi-plugin/src/issue-880-provider-fence.test.ts new file mode 100644 index 0000000000..1bc809759b --- /dev/null +++ b/packages/pi-plugin/src/issue-880-provider-fence.test.ts @@ -0,0 +1,105 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + clearPiContextReceipt, + registerPiGuardedContext, +} from "./pi-context-refusal"; +import { piCursorManagedReceipt } from "./pi-dispatch-identity"; +import { __setPiHarnessKindForTesting } from "./pi-harness-kind"; +import { fakeContext, userMessage } from "./test-utils.test"; + +const session = "issue-880"; +const signature = ""; +const managed = [ + userMessage(`${signature}prepared`, 1), + userMessage("current prompt", 2), +]; + +function cursorPayload() { + const receipt = piCursorManagedReceipt(managed); + if (!receipt) throw new Error("managed Cursor receipt missing"); + const system = createHash("sha256") + .update(JSON.stringify({ role: "system", content: "instructions" }), "utf8") + .digest("hex"); + return { + conversationState: { + rootPromptMessagesJson: [system, ...receipt], + turns: [], + }, + action: { + action: { + case: "userMessageAction", + value: { + userMessage: { + text: "current prompt", + messageId: "random-cursor-message-id", + }, + }, + }, + }, + }; +} + +function harness() { + __setPiHarnessKindForTesting("omp"); + const handlers = new Map unknown>(); + const refusals: string[] = []; + let aborts = 0; + const ctx = { + ...fakeContext(session), + model: { api: "cursor-agent", id: "claude-opus-5-5", provider: "cursor" }, + abort: () => aborts++, + }; + registerPiGuardedContext( + { + on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => + handlers.set(name, handler), + appendEntry: (_type: string, data: { message: string }) => + refusals.push(data.message), + } as never, + async () => ({ messages: structuredClone(managed) }), + ); + const emit = (name: string, event: unknown = {}) => { + const handler = handlers.get(name); + if (!handler) throw new Error(`missing handler ${name}`); + return handler(event, ctx); + }; + return { + refusals, + get aborts() { + return aborts; + }, + async prepare() { + emit("agent_start"); + await emit("context", { messages: [userMessage("current prompt", 2)] }); + }, + dispatch(payload: unknown) { + return emit("before_provider_request", { payload }); + }, + }; +} + +afterEach(() => { + clearPiContextReceipt(session); + __setPiHarnessKindForTesting(undefined); +}); + +test("issue 880 cursor-agent: managed AgentRunRequest is admitted", async () => { + const h = harness(); + await h.prepare(); + h.dispatch(cursorPayload()); + expect(h.aborts).toBe(0); + expect(h.refusals).toEqual([]); +}); + +test("issue 880 cursor-agent: a raw AgentRunRequest without the receipt is refused", async () => { + const h = harness(); + await h.prepare(); + const payload = cursorPayload(); + payload.conversationState.rootPromptMessagesJson = [ + payload.conversationState.rootPromptMessagesJson[0], + ]; + h.dispatch(payload); + expect(h.aborts).toBe(1); + expect(h.refusals[0]).toContain("missing current receipt signature"); +}); diff --git a/packages/pi-plugin/src/pi-context-refusal.ts b/packages/pi-plugin/src/pi-context-refusal.ts index 938bffb80b..efd23ecdd9 100644 --- a/packages/pi-plugin/src/pi-context-refusal.ts +++ b/packages/pi-plugin/src/pi-context-refusal.ts @@ -39,6 +39,7 @@ import { } from "./pi-dispatch-chain"; import { piConversationFields as conversationFields, + piCursorManagedReceipt, piHasManagedSignature, piManagedSignature, } from "./pi-dispatch-identity"; @@ -81,6 +82,7 @@ interface Operation { dispatchFields?: readonly LkgContentField[]; chainDispatchFields?: readonly LkgContentField[]; managedSignature?: string; + managedCursorReceipt?: readonly string[]; preparedStructure?: PiPreparedStructure; // Recorded only for serializers that can chain through previous_response_id. preparedHistory?: PiPreparedHistory; @@ -139,6 +141,7 @@ function releasePiOperationPayloads(operation: Operation): void { operation.dispatchFields = undefined; operation.chainDispatchFields = undefined; operation.managedSignature = undefined; + operation.managedCursorReceipt = undefined; operation.preparedStructure = undefined; operation.preparedHistory = undefined; } @@ -661,7 +664,13 @@ export function registerPiGuardedContext( checked = expanded.payload; chainInput = expanded.input; } - if (!piHasManagedSignature(checked, active.managedSignature)) + if ( + !piHasManagedSignature( + checked, + active.managedSignature, + active.managedCursorReceipt, + ) + ) throw new Error( "provider request does not match this pass's managed dispatch (missing current receipt signature)", ); @@ -679,13 +688,15 @@ export function registerPiGuardedContext( // are not detected on this pass when no structural plan can be built. const body = checked as { messages?: unknown; input?: unknown }; const skipReason = active.preparedStructure - ? (active.preparedStructure.structuralSkipReason ?? - (active.preparedStructure.api && - body?.messages === undefined && - Array.isArray(body?.input) && - !active.preparedStructure.responsesMessages - ? "provider selected Responses items without a matching history plan" - : undefined)) + ? active.preparedStructure.api === "cursor-agent" + ? "Cursor AgentRunRequest root history is not modeled" + : (active.preparedStructure.structuralSkipReason ?? + (active.preparedStructure.api && + body?.messages === undefined && + Array.isArray(body?.input) && + !active.preparedStructure.responsesMessages + ? "provider selected Responses items without a matching history plan" + : undefined)) : "prepared history representation is not modeled"; if (chained && skipReason) throw new Error( @@ -883,6 +894,10 @@ export function registerPiGuardedContext( if (prepared.messages !== result.messages) result.messages = prepared.messages; active.managedSignature = piManagedSignature(result.messages); + active.managedCursorReceipt = + ctx.model?.api === "cursor-agent" + ? piCursorManagedReceipt(result.messages) + : undefined; active.preparedStructure = prepared.structure; if ( fenceEnabled && diff --git a/packages/pi-plugin/src/pi-dispatch-identity.ts b/packages/pi-plugin/src/pi-dispatch-identity.ts index 6f7ea384e6..9f13eeaa85 100644 --- a/packages/pi-plugin/src/pi-dispatch-identity.ts +++ b/packages/pi-plugin/src/pi-dispatch-identity.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { LKG_SNAPSHOT_ARRAY, LKG_SNAPSHOT_BOOLEAN, @@ -25,6 +26,173 @@ type Position = | "block" | "other"; +type PiCursorPayload = Record & { + conversationState: { rootPromptMessagesJson: readonly unknown[] }; +}; + +function piObject(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** Cursor's provider request carries history as serialized root prompt blobs. */ +function piCursorPayload(payload: unknown): PiCursorPayload | undefined { + const body = piObject(payload); + const conversationState = piObject(body?.conversationState); + if ( + !conversationState || + !Array.isArray(conversationState.rootPromptMessagesJson) + ) + return undefined; + return body as PiCursorPayload; +} + +function piCursorBlobId(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (value instanceof Uint8Array) + return Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); + if (value instanceof ArrayBuffer) + return piCursorBlobId(new Uint8Array(value)); + return undefined; +} + +function piCursorJsonText(value: unknown): string | undefined { + if (typeof value === "string") return value; + try { + if (value instanceof Uint8Array) return new TextDecoder().decode(value); + if (value instanceof ArrayBuffer) + return new TextDecoder().decode(new Uint8Array(value)); + } catch { + return undefined; + } + return undefined; +} + +type PiCursorRootContentPart = + | { type: "text"; text: string } + | { type: "image"; image: string; mediaType: string }; + +function piCursorRootContent(content: unknown): PiCursorRootContentPart[] { + if (typeof content === "string") { + const text = content.trim(); + return text ? [{ type: "text", text }] : []; + } + if (!Array.isArray(content)) return []; + const parts: PiCursorRootContentPart[] = []; + for (const value of content) { + const item = piObject(value); + if (item?.type === "text" && typeof item.text === "string") { + const text = item.text.trim(); + if (text) parts.push({ type: "text", text }); + } else if ( + item?.type === "image" && + typeof item.mimeType === "string" && + typeof item.data === "string" + ) { + parts.push({ + type: "image", + image: `data:${item.mimeType};base64,${item.data}`, + mediaType: item.mimeType, + }); + } + } + return parts; +} + +/** Content-addressed receipts for the managed m[0]/m[1] Cursor root blobs. */ +export function piCursorManagedReceipt( + messages: readonly unknown[], +): string[] | undefined { + const hashes: string[] = []; + for (const value of messages.slice(0, 8)) { + const message = piObject(value); + if (message?.role !== "user" && message?.role !== "developer") continue; + const content = piCursorRootContent(message.content); + const text = content.find((part) => part.type === "text")?.text; + if ( + !text || + !/^(|||||||||§\d+§)/.test( + text, + ) + ) + continue; + hashes.push( + createHash("sha256") + .update(JSON.stringify({ role: "user", content }), "utf8") + .digest("hex"), + ); + } + return hashes.length ? hashes : undefined; +} + +function piCursorImageId(value: unknown): string | undefined { + const data = + value instanceof Uint8Array + ? value + : value instanceof ArrayBuffer + ? new Uint8Array(value) + : typeof value === "string" + ? value + : undefined; + if (data === undefined) return undefined; + return createHash("sha256").update(data).digest("hex"); +} + +function piCursorActionFields( + payload: PiCursorPayload, +): Record | undefined { + const wrapper = piObject(payload.action); + const action = piObject(wrapper?.action) ?? wrapper; + if (!action) return undefined; + if (action.case !== "userMessageAction") + return { + case: typeof action.case === "string" ? action.case : undefined, + }; + const user = piObject(piObject(action.value)?.userMessage); + if (!user) return { case: "userMessageAction" }; + const images: Array<{ mimeType?: string; data?: string }> = []; + const selected = piObject(user.selectedContext)?.selectedImages; + if (Array.isArray(selected)) { + for (const value of selected) { + const image = piObject(value); + if (!image) continue; + const blob = piObject(image.dataOrBlobId); + const data = + blob?.case === "data" || blob?.case === "blobId" + ? blob.value + : image.dataOrBlobId; + const id = piCursorImageId(data); + const mimeType = + typeof image.mimeType === "string" ? image.mimeType : undefined; + if (mimeType || id) + images.push({ + ...(mimeType ? { mimeType } : {}), + ...(id ? { data: id } : {}), + }); + } + } + return { + case: "userMessageAction", + text: typeof user.text === "string" ? user.text : "", + ...(images.length ? { images } : {}), + }; +} + +function piCursorConversation( + payload: PiCursorPayload, +): Record | undefined { + const ids = + payload.conversationState.rootPromptMessagesJson.map(piCursorBlobId); + if (ids.some((id) => id === undefined)) return undefined; + return { + rootPromptMessagesJson: ids, + action: piCursorActionFields(payload), + }; +} + /** Compare typed conversation fields, allowing only Anthropic cache-only representations. */ export function piConversationFields( payload: unknown, @@ -32,6 +200,7 @@ export function piConversationFields( try { if (!payload || typeof payload !== "object") return undefined; const body = payload as Record; + const cursor = piCursorPayload(payload); let conversation: Record; let anthropic = false; if (Array.isArray(body.messages) && body.input === undefined) { @@ -48,6 +217,10 @@ export function piConversationFields( conversation: body.conversation, prompt: body.prompt, }; + } else if (cursor) { + const cursorConversation = piCursorConversation(cursor); + if (!cursorConversation) return undefined; + conversation = cursorConversation; } else { const google = piGoogleConversation(payload); if (!google) return undefined; @@ -191,8 +364,25 @@ export function piManagedSignature( export function piHasManagedSignature( payload: unknown, signature: string | undefined, + cursorReceipt?: readonly string[], ): boolean { if (!signature || !payload || typeof payload !== "object") return false; + const cursor = piCursorPayload(payload); + if (cursor) { + for (const entry of cursor.conversationState.rootPromptMessagesJson.slice( + 0, + 8, + )) { + const text = piCursorJsonText(entry); + if (text?.slice(0, 4096).includes(signature)) return true; + } + if (!Array.isArray(cursorReceipt) || cursorReceipt.length === 0) + return false; + const ids = new Set( + cursor.conversationState.rootPromptMessagesJson.map(piCursorBlobId), + ); + return cursorReceipt.some((id) => ids.has(id)); + } const body = payload as { messages?: unknown; input?: unknown;