From 6dd788ca7870a32d86614cbf27175bbe4542452b Mon Sep 17 00:00:00 2001 From: sunss Date: Wed, 9 Sep 2026 13:47:13 +0800 Subject: [PATCH] fix(cursor): reject WebFetch requests in band without hosted execution When a Cursor-backed model requests hosted WebFetch, the provider previously aborted the entire Run. This adds an in-band rejected response without granting hosted execution authority, lets the Run continue to Pi-owned tools or textual explanation, and bounds repeated requests to 3 rejections per Run. Closes #519 Related to #234, #484 --- extensions/ai-providers/cursor/proto.ts | 69 +++- extensions/ai-providers/cursor/provider.ts | 44 +++ tests/extensions/ai-providers/cursor.test.ts | 321 ++++++++++++++++++- 3 files changed, 423 insertions(+), 11 deletions(-) diff --git a/extensions/ai-providers/cursor/proto.ts b/extensions/ai-providers/cursor/proto.ts index c1188889..aa43745e 100644 --- a/extensions/ai-providers/cursor/proto.ts +++ b/extensions/ai-providers/cursor/proto.ts @@ -17,6 +17,7 @@ export interface AgentClientMessage extends ProtoMessage { | { case: "execClientMessage"; value: ExecClientMessage } | { case: "execClientControlMessage"; value: ExecClientControlMessage } | { case: "kvClientMessage"; value: KvClientMessage } + | { case: "interactionResponse"; value: InteractionResponse } | { case: "clientHeartbeat"; value: ClientHeartbeat }; } @@ -50,6 +51,12 @@ export const AgentClientMessageSchema: MessageCodec = kind: "message", T: () => KvClientMessageSchema, }, + { + no: 6, + name: "interactionResponse", + kind: "message", + T: () => InteractionResponseSchema, + }, { no: 7, name: "clientHeartbeat", @@ -903,10 +910,66 @@ export const AgentServerMessageSchema: MessageCodec = ]); /** - * Queries require an interactive client answer. This provider has no UI or - * tool execution channel, so it recognizes the envelope and fails the turn - * explicitly instead of silently dropping a server request. + * Queries require a client answer. Only WebFetch rejection is supported; + * other queries still fail explicitly rather than silently hanging the Run. + * Response fields come from oh-my-pi@856d9375, + * packages/ai/src/providers/cursor/proto/agent.proto (MIT). */ +export interface WebFetchRequestRejected extends ProtoMessage { + reason: string; +} + +export const WebFetchRequestRejectedSchema: MessageCodec = + pb("agent.v1.WebFetchRequestResponse_Rejected", [ + { no: 1, name: "reason", kind: "string" }, + ]); + +export interface WebFetchRequestResponse extends ProtoMessage { + result: + | { case: undefined; value?: undefined } + | { case: "rejected"; value: WebFetchRequestRejected }; +} + +export const WebFetchRequestResponseSchema: MessageCodec = + pb("agent.v1.WebFetchRequestResponse", [ + { + kind: "oneof", + name: "result", + variants: [ + { + no: 2, + name: "rejected", + kind: "message", + T: () => WebFetchRequestRejectedSchema, + }, + ], + }, + ]); + +export interface InteractionResponse extends ProtoMessage { + id: number; + result: + | { case: undefined; value?: undefined } + | { case: "webFetchRequestResponse"; value: WebFetchRequestResponse }; +} + +export const InteractionResponseSchema: MessageCodec = + pb("agent.v1.InteractionResponse", [ + { no: 1, name: "id", kind: "uint32" }, + { + kind: "oneof", + name: "result", + variants: [ + { + no: 9, + name: "webFetchRequestResponse", + kind: "message", + T: () => WebFetchRequestResponseSchema, + }, + ], + }, + ]); + export interface InteractionQuery extends ProtoMessage { id: number; query: diff --git a/extensions/ai-providers/cursor/provider.ts b/extensions/ai-providers/cursor/provider.ts index d2d3a040..653cf176 100644 --- a/extensions/ai-providers/cursor/provider.ts +++ b/extensions/ai-providers/cursor/provider.ts @@ -43,6 +43,7 @@ import { ExecClientStreamCloseSchema, ExecClientThrowSchema, GetBlobResultSchema, + InteractionResponseSchema, type InteractionUpdate, KvClientMessageSchema, type KvServerMessage, @@ -70,6 +71,8 @@ import { SetBlobResultSchema, UserMessageActionSchema, UserMessageSchema, + WebFetchRequestRejectedSchema, + WebFetchRequestResponseSchema, } from "./proto.ts"; import { create, encodeJsonValue, fromBinary, toBinary } from "./protobuf.ts"; import { connectCursorHttp2 } from "./proxy.ts"; @@ -86,6 +89,7 @@ const MAX_CONNECT_FRAME_BYTES = 16 * 1024 * 1024; const HEARTBEAT_INTERVAL_MS = 5_000; const PROXY_TUNNEL_TIMEOUT_MS = 30_000; const MAX_NATIVE_EXEC_REJECTIONS = 3; +const MAX_WEB_FETCH_REJECTIONS = 3; export const CURSOR_CHAT_ONLY_SYSTEM_PROMPT = "This Cursor provider is running in chat-only mode. No filesystem, shell, code modification, MCP, web, or user-interaction tools are available. Never emit tool calls or interaction queries. Images attached to the user message are already available for direct analysis. If required information is unavailable, explain the limitation in text instead of attempting a tool."; @@ -715,6 +719,7 @@ export function streamCursor( const pendingCalls = new Map(); let handingOffTools = false; let nativeExecRejections = 0; + let webFetchRejections = 0; const closeBlocks = () => { if (currentText) { @@ -991,6 +996,45 @@ export function streamCursor( return; } if (message.message.case === "interactionQuery") { + const query = message.message.value; + if (query.query.case === "webFetchRequestQuery") { + // Deny hosted execution without aborting the Run. Pi still owns + // any subsequent tool call and its permissions, including fetches. + const reply = create(AgentClientMessageSchema, { + message: { + case: "interactionResponse", + value: create(InteractionResponseSchema, { + id: query.id, + result: { + case: "webFetchRequestResponse", + value: create(WebFetchRequestResponseSchema, { + result: { + case: "rejected", + value: create(WebFetchRequestRejectedSchema, { + reason: context.tools?.length + ? "Cursor-hosted web fetching is unavailable. Use an advertised Pi tool if it supports the request; otherwise explain that you cannot fetch the page. Do not retry Cursor-hosted fetching." + : "Web fetching is unavailable in this chat-only provider. Explain that you cannot fetch the page. Do not retry Cursor-hosted fetching.", + }), + }, + }), + }, + }), + }, + }); + const recover = ++webFetchRejections <= MAX_WEB_FETCH_REJECTIONS; + if (!recover) { + terminalError = new Error( + `Cursor WebFetch recovery limit (${MAX_WEB_FETCH_REJECTIONS}) exceeded; last query id ${query.id}`, + ); + } + h2Request?.write( + frameConnectMessage(toBinary(AgentClientMessageSchema, reply)), + () => { + if (!recover) settle(terminalError); + }, + ); + return; + } throw new Error( `Cursor interaction query ${message.message.value.query.case ?? "unknown"} is unavailable ${context.tools?.length ? "outside Pi's interaction lifecycle" : "in chat-only mode"}`, ); diff --git a/tests/extensions/ai-providers/cursor.test.ts b/tests/extensions/ai-providers/cursor.test.ts index 46a59ff7..5513494e 100644 --- a/tests/extensions/ai-providers/cursor.test.ts +++ b/tests/extensions/ai-providers/cursor.test.ts @@ -19,6 +19,7 @@ import type { } from "@earendil-works/pi-ai/compat"; import { AgentSession, + createReadTool, type ExtensionContext, VERSION as PI_VERSION, } from "@earendil-works/pi-coding-agent"; @@ -41,6 +42,7 @@ import { CursorToolCallSchema, ExecServerMessageSchema, GetUsableModelsResponseSchema, + InteractionResponseSchema, InteractionQueryPayloadSchema, InteractionQuerySchema, InteractionUpdateSchema, @@ -54,6 +56,8 @@ import { ThinkingDetailsSchema, TokenDeltaUpdateSchema, TurnEndedUpdateSchema, + WebFetchRequestRejectedSchema, + WebFetchRequestResponseSchema, } from "../../../extensions/ai-providers/cursor/proto.ts"; import { create, @@ -1030,8 +1034,21 @@ test("Cursor request_context succeeds with global rules and empty tools; other e }); test("Cursor tool interaction and interactionQuery fail explicitly without a Pi toolCall", async () => { - const cases: Array<"tool" | "query"> = ["tool", "query"]; - for (const kind of cases) { + const cases: Array<{ + kind: "tool" | "query"; + queryCase?: "askQuestionInteractionQuery"; + tools?: Context["tools"]; + }> = [ + { kind: "tool" }, + { kind: "query", queryCase: "askQuestionInteractionQuery" }, + { + kind: "query", + queryCase: "askQuestionInteractionQuery", + tools: [LOOKUP], + }, + { kind: "query", tools: [LOOKUP] }, + ]; + for (const { kind, queryCase, tools } of cases) { const server = await startServer((stream) => { stream.respond({ ":status": 200, @@ -1062,10 +1079,12 @@ test("Cursor tool interaction and interactionQuery fail explicitly without a Pi case: "interactionQuery", value: create(InteractionQuerySchema, { id: 1, - query: { - case: "askQuestionInteractionQuery", - value: create(InteractionQueryPayloadSchema, {}), - }, + query: queryCase + ? { + case: queryCase, + value: create(InteractionQueryPayloadSchema, {}), + } + : { case: undefined }, }), }, }); @@ -1074,13 +1093,23 @@ test("Cursor tool interaction and interactionQuery fail explicitly without a Pi }); servers.push(server); const events = await collectEvents( - streamCursor(localModel(server.baseUrl), CONTEXT, { apiKey: "token" }), + streamCursor( + localModel(server.baseUrl), + tools ? { ...CONTEXT, tools } : CONTEXT, + { apiKey: "token" }, + ), ); const error = events.find((event) => event.type === "error"); assert.ok(error?.type === "error"); assert.match( error.error.errorMessage ?? "", - /unavailable in chat-only mode/, + tools && kind === "query" + ? /outside Pi's interaction lifecycle/ + : /unavailable in chat-only mode/, + ); + assert.equal( + events.some((event) => event.type === "done"), + false, ); assert.equal( events.some((event) => event.type.startsWith("toolcall")), @@ -1741,6 +1770,282 @@ function completedText(text: string) { ]); } +function webFetchQuery(id = 17) { + return frameServerMessage( + create(AgentServerMessageSchema, { + message: { + case: "interactionQuery", + value: create(InteractionQuerySchema, { + id, + query: { + case: "webFetchRequestQuery", + value: create(InteractionQueryPayloadSchema, {}), + }, + }), + }, + }), + ); +} + +test("Cursor WebFetch rejection keeps the in-band wire field numbers", () => { + const wire = Uint8Array.from([ + 0x32, 0x09, 0x08, 0x07, 0x4a, 0x05, 0x12, 0x03, 0x0a, 0x01, 0x78, + ]); + const message = create(AgentClientMessageSchema, { + message: { + case: "interactionResponse", + value: create(InteractionResponseSchema, { + id: 7, + result: { + case: "webFetchRequestResponse", + value: create(WebFetchRequestResponseSchema, { + result: { + case: "rejected", + value: create(WebFetchRequestRejectedSchema, { reason: "x" }), + }, + }), + }, + }), + }, + }); + assert.deepEqual([...toBinary(AgentClientMessageSchema, message)], [...wire]); + + const decoded = fromBinary(AgentClientMessageSchema, wire); + assert.ok(decoded.message.case === "interactionResponse"); + assert.equal(decoded.message.value.id, 7); + assert.equal(decoded.message.value.result.case, "webFetchRequestResponse"); + const result = decoded.message.value.result.value.result; + assert.equal(result.case, "rejected"); + assert.equal(result.value.reason, "x"); +}); + +test("Cursor rejects WebFetch in band and continues to text with or without Pi tools", { + timeout: 3000, +}, async () => { + const cases: Array<{ + tools?: Context["tools"]; + reason: RegExp; + text: string; + }> = [ + { + reason: /unavailable in this chat-only provider/, + text: "WebFetch rejected without tools.", + }, + { + tools: [LOOKUP], + reason: /Use an advertised Pi tool/, + text: "WebFetch rejected with tools.", + }, + ]; + + for (const { tools, reason, text } of cases) { + let requests = 0; + const replies: AgentClientMessage["message"][] = []; + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + receiveClient(peer, (message) => { + if (message.case === "runRequest") { + assert.equal(++requests, 1); + peer.write(webFetchQuery()); + return; + } + if (message.case !== "interactionResponse") return; + replies.push(message); + peer.end(completedText(text)); + }); + }); + servers.push(server); + + const events = await collectEvents( + streamCursor( + localModel(server.baseUrl), + tools ? { ...CONTEXT, tools } : CONTEXT, + { apiKey: "token" }, + ), + ); + + assert.equal(requests, 1); + assert.equal(replies.length, 1); + const reply = replies[0]!; + assert.ok(reply.case === "interactionResponse"); + assert.equal(reply.value.id, 17); + assert.equal(reply.value.result.case, "webFetchRequestResponse"); + const response = reply.value.result.value; + assert.equal(response.result.case, "rejected"); + assert.match(response.result.value.reason, reason); + + const terminal = events.at(-1); + assert.ok(terminal?.type === "done"); + assert.deepEqual(terminal.message.content, [{ type: "text", text }]); + assert.equal( + events.some((event) => event.type.startsWith("toolcall")), + false, + ); + } +}); + +test("Cursor WebFetch rejection can recover through the real Pi read tool", { + timeout: 3000, +}, async () => { + const directory = await mkdtemp(join(tmpdir(), "cursor-webfetch-read-")); + tempDirectories.push(directory); + const filePath = join(directory, "fixture.txt"); + await writeFile(filePath, "Pi read fixture\n"); + + let requests = 0; + let webFetchResponses = 0; + let replay = ""; + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + receiveClient(peer, (message) => { + if (message.case === "runRequest") { + requests++; + if (requests === 1) { + peer.write(webFetchQuery(23)); + } else { + assert.equal(requests, 2); + assert.equal(message.value.action?.action.case, "resumeAction"); + peer.end(completedText("Pi read result received.")); + } + return; + } + if (message.case === "interactionResponse") { + webFetchResponses++; + assert.equal(message.value.id, 23); + assert.equal(message.value.result.case, "webFetchRequestResponse"); + const response = message.value.result.value; + assert.equal(response.result.case, "rejected"); + assert.match(response.result.value.reason, /Use an advertised Pi tool/); + peer.write( + mcpExec({ + name: "read", + toolName: "read", + toolCallId: "read-1", + args: { path: encodeJsonValue(filePath) }, + }), + ); + return; + } + }); + }); + servers.push(server); + + const lifecycle: string[] = []; + const agent = new Agent({ + initialState: { + model: localModel(server.baseUrl), + systemPrompt: "Use read once after hosted fetching is rejected.", + tools: [createReadTool(directory)], + }, + getApiKey: () => "token", + streamFn: async (model, context, options) => { + if (context.messages.at(-1)?.role === "toolResult") { + const built = await buildCursorRequest(model, context, options); + replay = built.conversationState.rootPromptMessagesJson + .map((id) => + Buffer.from( + built.blobStore.get(Buffer.from(id).toString("hex"))!, + ).toString(), + ) + .join("\n"); + } + return streamCursor(model, context, options); + }, + }); + agent.subscribe((event) => { + lifecycle.push(event.type); + }); + await agent.prompt("Read the fixture file."); + + assert.equal(requests, 2); + assert.equal(webFetchResponses, 1); + assert.equal( + lifecycle.filter((type) => type === "tool_execution_start").length, + 1, + ); + assert.equal( + lifecycle.filter((type) => type === "tool_execution_end").length, + 1, + ); + const toolResult = agent.state.messages.find( + (message) => + message.role === "toolResult" && message.toolCallId === "read-1", + ); + assert.ok(toolResult?.role === "toolResult"); + assert.deepEqual(toolResult.content, [ + { type: "text", text: "Pi read fixture\n" }, + ]); + assert.match(replay, /Pi read fixture/); + const last = agent.state.messages.at(-1); + assert.ok(last?.role === "assistant"); + assert.deepEqual(last.content, [ + { type: "text", text: "Pi read result received." }, + ]); +}); + +test("Cursor sends the fourth WebFetch rejection before its bounded error", { + timeout: 3000, +}, async () => { + const replies: AgentClientMessage["message"][] = []; + const fourthReplyReceived = Promise.withResolvers(); + const server = await startServer((peer) => { + peer.respond({ ":status": 200 }); + receiveClient(peer, (message) => { + if (message.case === "runRequest") { + peer.write( + Buffer.concat([ + ...[1, 2, 3, 4].map((id) => webFetchQuery(id)), + mcpExec(), + completedText("Must not finish after the limit."), + ]), + ); + return; + } + if (message.case !== "interactionResponse") return; + replies.push(message); + if (replies.length === 4) fourthReplyReceived.resolve(); + }); + }); + servers.push(server); + + const events = await collectEvents( + streamCursor( + localModel(server.baseUrl), + { ...CONTEXT, tools: [LOOKUP] }, + { apiKey: "token" }, + ), + ); + await fourthReplyReceived.promise; + + assert.deepEqual( + replies.map((reply) => { + assert.ok(reply.case === "interactionResponse"); + const response = reply.value.result; + assert.equal(response.case, "webFetchRequestResponse"); + const rejected = response.value.result; + assert.equal(rejected.case, "rejected"); + assert.match(rejected.value.reason, /Use an advertised Pi tool/); + return reply.value.id; + }), + [1, 2, 3, 4], + ); + const terminal = events.at(-1); + assert.ok(terminal?.type === "error"); + assert.equal(terminal.reason, "error"); + assert.match( + terminal.error.errorMessage ?? "", + /Cursor WebFetch recovery limit \(3\) exceeded/, + ); + assert.equal( + events.some((event) => event.type === "done"), + false, + ); + assert.equal( + events.some((event) => event.type.startsWith("toolcall")), + false, + ); +}); + test("Cursor rejects native execution in band and recovers through a real Pi tool roundtrip", { timeout: 3000, }, async () => {