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;