From ee2affda66bc6d4a8e356456a3fc721bf5756bc9 Mon Sep 17 00:00:00 2001 From: zirdev <3856578+Zireael@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:00:57 +0100 Subject: [PATCH] fix(pi): accept Cursor AgentRunRequest managed receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve managed-dispatch signatures for OMP's Cursor wire shape by matching content-addressed m0/m1 root blob receipts, and derive retry identity from Cursor's root blob IDs plus the active user message. Skip Cursor's structural row comparison with an explicit diagnostic until its root serializer is modeled; the receipt and identity checks remain enforced. Refs #880 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../src/issue-880-cursor-receipt.test.ts | 87 ++++++++ .../src/issue-880-provider-fence.test.ts | 105 ++++++++++ packages/pi-plugin/src/pi-context-refusal.ts | 31 ++- .../pi-plugin/src/pi-dispatch-identity.ts | 190 ++++++++++++++++++ 4 files changed, 405 insertions(+), 8 deletions(-) create mode 100644 packages/pi-plugin/src/issue-880-cursor-receipt.test.ts create mode 100644 packages/pi-plugin/src/issue-880-provider-fence.test.ts 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;