diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index fb22826912..18735c3c28 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -47,7 +47,7 @@ function codexNativeMutationRefusal(operation: "write" | "delete", structuredEdi } const NATIVE_LOCAL_EXEC_DISABLED = - "Route filesystem work through the Codex Responses bridge shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with equivalent shell commands (cat, head, ls, rg, grep), or `apply_patch` for file edits. Do not tell the user shell or read access is blocked, disabled, or denied — silently call that bridge tool."; + "Cursor-native filesystem tools are not executed locally. Use a catalog tool for this work: `shell_command` / `exec_command` (or the listed `mcp_opencodex-responses_*` display alias) with host-shell-safe equivalents: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits."; export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array { if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec"); diff --git a/src/adapters/cursor/native-exec-network.ts b/src/adapters/cursor/native-exec-network.ts index 7d5b9c6617..39483987ec 100644 --- a/src/adapters/cursor/native-exec-network.ts +++ b/src/adapters/cursor/native-exec-network.ts @@ -7,7 +7,7 @@ export interface CursorNativeNetworkDeps { } const NATIVE_FETCH_DISABLED = - "Route this through the Codex shell bridge tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do not tell the user network or shell access is blocked, disabled, or denied — silently call that bridge tool."; + "Cursor-native fetch is not executed locally. Use the Codex shell bridge tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget."; export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array { if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec"); diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index a022f18339..a708117cb6 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -89,7 +89,6 @@ export function nativeShellDisabledMessage(): string { // client that executes the bridge (LAN/SSH remote-proxy). return ( "Route this through the Codex bridge shell tool from the current catalog (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` display name if listed). " - + "Do not tell the user shell access is blocked, disabled, or denied — silently call that bridge tool. " + "Adapt the command for the Codex client host shell before calling the bridge " + "(Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs; `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`). " + "Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands." diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 7858b5238f..b1d577fcb7 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -122,6 +122,10 @@ function rootBlobCandidate( }; } +function toolResultRootPayload(text: string): { role: "assistant"; content: [{ type: "text"; text: string }] } { + return { role: "assistant", content: [{ type: "text", text }] }; +} + function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): RootBlobCandidate | null { if (entry.byteLength <= maxBytes) return entry; if (entry.role !== "toolResult" || entry.text === undefined) return null; @@ -134,7 +138,7 @@ function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): Roo while (end > 0 && end < encoded.byteLength && (encoded[end]! & 0xc0) === 0x80) end -= 1; const truncated = `${decoder.decode(encoded.subarray(0, end))}${marker}`; const result = rootBlobCandidate( - { role: "user", content: [{ type: "text", text: truncated }] }, + toolResultRootPayload(truncated), "toolResult", { messageIndex: entry.messageIndex, text: truncated }, ); @@ -143,7 +147,7 @@ function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): Roo keepBytes = Math.max(0, end - (result.byteLength - maxBytes) - 16); } const markerOnly = rootBlobCandidate( - { role: "user", content: [{ type: "text", text: marker.trimStart() }] }, + toolResultRootPayload(marker.trimStart()), "toolResult", { messageIndex: entry.messageIndex, text: marker.trimStart() }, ); @@ -175,9 +179,8 @@ function assistantRootText( // Cursor builds the actual model prompt from rootPromptMessagesJson (turns[] is UI/display metadata), // so prior history — including assistant tool calls and tool results — must be replayed here or a // ResumeAction has nothing model-visible to continue from. The active user message is excluded -// because it travels in the action. Tool results are rendered as user-role text with a marker, and -// each entry is a SHA-256 blob ID (Cursor fetches the bytes back via getBlobArgs). Mirrors the -// danger-pi reference buildRootPromptMessagesJson. +// because it travels in the action. Tool results are assistant-role text with a [Tool Result] +// or [Tool Error] marker so Cursor does not wrap them as `` (#1992). Each entry is a SHA-256 blob ID. function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken): { ids: Uint8Array[]; byteLength: number; @@ -232,7 +235,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR const prefix = message.isError ? "[Tool Error]" : "[Tool Result]"; const text = `${prefix}\n${toolResultToText(message)}`; entries.push(rootBlobCandidate( - { role: "user", content: [{ type: "text", text }] }, + toolResultRootPayload(text), "toolResult", { messageIndex: i, text }, )); diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index e3a13fbca7..0930f08f26 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -17,8 +17,15 @@ export const CURSOR_STRUCTURED_EDIT_TOOLS = [CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI export const CURSOR_EXEC_COMMAND_TOOL = CODEX_EXEC_COMMAND_TOOL; export const CODEX_SHELL_BRIDGE_TOOL_NAMES = [CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL] as const; export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE = - 'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell; never say native shell is blocked.'; + 'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell.'; const NEIGHBOR_AGENT_TOOL_NAMES = ["Read", "Grep", "Glob", "Bash", "LS"] as const; +const NEIGHBOR_AGENT_TOOL_ALIASES: Record<(typeof NEIGHBOR_AGENT_TOOL_NAMES)[number], readonly string[]> = { + Read: ["read", "read_file"], + Grep: ["grep"], + Glob: ["glob", "find"], + Bash: ["bash", "shell"], + LS: ["ls"], +}; export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [ "For generic tool-use/count demos, satisfy the request with repeated Codex shell bridge calls (`shell_command` or `exec_command`) for harmless commands.", @@ -29,8 +36,7 @@ export const CURSOR_GENERIC_TOOL_USE_USER_HINT = [ "The Cursor bridge may suspend after the first returned bridge tool call, so emit sibling calls together before any result is needed.", "If parallel emission is unavailable, continue with separate shell-bridge calls until the requested count has returned.", "Do not use `tool_search`, external MCP, or resource discovery just to pad the count unless explicitly asked.", - "Do not suggest or switch to neighboring-agent tools such as `Grep`, `Read`, `Glob`, `Bash`, or `LS` unless this turn's catalog lists those exact names.", - "Never tell the user that shell or read access is blocked, disabled, or denied unless the Codex shell bridge tool itself fails. Do not narrate Cursor-native Shell/Read routing.", + "Do not suggest or switch to neighboring-agent tools such as `Grep`, `Read`, `Glob`, `Bash`, or `LS` unless this turn's catalog lists those exact names or an equivalent listed client tool.", ].join(" "); export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = { @@ -563,9 +569,14 @@ function quotedNames(names: readonly string[]): string { return names.map(name => `\`${name}\``).join(", "); } +function advertisedCoversNeighbor(wireNames: readonly string[], neighbor: (typeof NEIGHBOR_AGENT_TOOL_NAMES)[number]): boolean { + const advertised = new Set(wireNames.map(name => name.toLowerCase())); + if (advertised.has(neighbor.toLowerCase())) return true; + return NEIGHBOR_AGENT_TOOL_ALIASES[neighbor].some(alias => advertised.has(alias.toLowerCase())); +} + function unavailableNeighborAgentToolNames(wireNames: readonly string[]): string[] { - const advertised = new Set(wireNames); - return NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertised.has(name)); + return NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertisedCoversNeighbor(wireNames, name)); } function discoveryToolLabel(wireNames: readonly string[]): string | undefined { @@ -632,7 +643,7 @@ export function buildCursorToolGuidanceSystemNote( ? "Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user." : undefined, hasBareExec - ? "Never tell the user that shell or read access is blocked, disabled, or denied unless the Codex shell bridge tool itself fails. Prefer the bridge over Cursor-native Shell/Read; do not narrate phrases like \"Native shell access is blocked\" — silently call `shell_command` / `exec_command`." + ? `Prefer the Codex shell bridge over Cursor-native Shell/Read. If a Cursor-native file read, directory listing, grep, or shell operation is rejected, continue with the listed catalog tool ${shellBridgeLabel}.` : undefined, hostShellNote, "Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise.", @@ -656,7 +667,7 @@ export function buildCursorToolGuidanceSystemNote( : undefined, "Do not count or report a tool call unless a tool result was actually returned.", hasBareExec - ? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, silently use ${shellBridgeLabel} with an equivalent host-shell-safe command (POSIX: \`cat\`/\`ls\`/\`rg\`; Windows PowerShell: \`Get-Content\`/\`Get-ChildItem\`/\`Select-String\`). Do not tell the user access is blocked. For file edits, use ${structuredEditNames.length > 0 ? `the structured edit tools (${quotedNames(structuredEditNames)}) or ` : ""}\`apply_patch\` when available.` + ? `If a Cursor-native file read, directory listing, grep, or shell operation is rejected by the runtime, use ${shellBridgeLabel} with an equivalent host-shell-safe command (POSIX: \`cat\`/\`ls\`/\`rg\`; Windows PowerShell: \`Get-Content\`/\`Get-ChildItem\`/\`Select-String\`). For file edits, use ${structuredEditNames.length > 0 ? `the structured edit tools (${quotedNames(structuredEditNames)}) or ` : ""}\`apply_patch\` when available.` : undefined, ].filter((note): note is string => typeof note === "string"); return notes.join(" "); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index e8df19da3e..8af5a7c250 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -592,6 +592,8 @@ describe("Cursor blob handshake", () => { const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; const historicalUser = roots.find(root => root.role === "user"); expect(historicalUser?.content).toEqual([{ type: "text", text: "read a file" }]); + const toolResultRoot = roots.find(root => JSON.stringify(root).includes("[Tool Result]")); + expect(toolResultRoot?.role).toBe("assistant"); expect(run?.action?.action.case).toBe("resumeAction"); expect(JSON.stringify(roots)).toContain("contents"); expect(JSON.stringify(roots)).not.toContain("hidden reasoning"); diff --git a/tests/cursor-native-exec-policy.test.ts b/tests/cursor-native-exec-policy.test.ts index 33dfb54d99..1cf33ba21b 100644 --- a/tests/cursor-native-exec-policy.test.ts +++ b/tests/cursor-native-exec-policy.test.ts @@ -134,9 +134,12 @@ describe("Cursor native exec sandbox policy", () => { expect(deniedText).toContain("exec_command"); expect(deniedText).toContain("mcp_opencodex-responses_*"); expect(deniedText).toContain("cat"); + expect(deniedText).toContain("Get-Content"); + expect(deniedText).toContain("Get-ChildItem"); + expect(deniedText).toContain("Select-String"); expect(deniedText).toContain("apply_patch"); - expect(deniedText).toContain("silently call"); - expect(deniedText).toContain("Do not tell the user"); + expect(deniedText).not.toContain("silently call"); + expect(deniedText).not.toContain("Do not tell the user"); expect(deniedText).not.toContain("disabled by OpenCodex policy"); expect(deniedText).not.toContain("sandbox denial"); expect(deniedText).not.toContain(content); @@ -146,11 +149,11 @@ describe("Cursor native exec sandbox policy", () => { value: create(ShellArgsSchema, { command: "printf SHOULD_NOT_RUN", workingDirectory: dir, hardTimeout: 2000 }), }), { unsafeAllowNativeLocalExec }))[0]); const deniedShellText = stringify(deniedShell); - expect(deniedShellText).toContain("silently call"); + expect(deniedShellText).not.toContain("silently call"); expect(deniedShellText).toContain("shell_command"); expect(deniedShellText).toContain("exec_command"); expect(deniedShellText).toContain("mcp_opencodex-responses_*"); - expect(deniedShellText).toContain("Do not tell the user"); + expect(deniedShellText).not.toContain("Do not tell the user"); expect(deniedShellText).not.toContain("with the same command"); expect(deniedShellText).toContain("at most one corrected bridge attempt"); expect(deniedShellText).toContain("if ($?)"); @@ -178,7 +181,8 @@ describe("Cursor native exec sandbox policy", () => { }))[0]); expect(fetchCalled).toBe(false); const deniedFetchText = stringify(deniedFetch); - expect(deniedFetchText).toContain("silently call"); + expect(deniedFetchText).not.toContain("silently call"); + expect(deniedFetchText).not.toContain("Do not tell the user"); expect(deniedFetchText).toContain("shell_command"); expect(deniedFetchText).toContain("curl"); expect(deniedFetchText).toContain("wget"); diff --git a/tests/cursor-native-exec.test.ts b/tests/cursor-native-exec.test.ts index 58e44aa577..e3365ce040 100644 --- a/tests/cursor-native-exec.test.ts +++ b/tests/cursor-native-exec.test.ts @@ -116,9 +116,12 @@ describe("Cursor native exec bridge", () => { expect(deniedRead.message.value.result.value.error).toContain("shell_command"); expect(deniedRead.message.value.result.value.error).toContain("exec_command"); expect(deniedRead.message.value.result.value.error).toContain("cat"); + expect(deniedRead.message.value.result.value.error).toContain("Get-Content"); + expect(deniedRead.message.value.result.value.error).toContain("Get-ChildItem"); + expect(deniedRead.message.value.result.value.error).toContain("Select-String"); expect(deniedRead.message.value.result.value.error).toContain("apply_patch"); - expect(deniedRead.message.value.result.value.error).toContain("silently call"); - expect(deniedRead.message.value.result.value.error).toContain("Do not tell the user"); + expect(deniedRead.message.value.result.value.error).not.toContain("silently call"); + expect(deniedRead.message.value.result.value.error).not.toContain("Do not tell the user"); expect(deniedRead.message.value.result.value.error).not.toContain("disabled by OpenCodex policy"); expect(deniedRead.message.value.result.value.error).not.toContain("sandbox denial"); } @@ -133,7 +136,8 @@ describe("Cursor native exec bridge", () => { expect(deniedShell.message.value.result.value.stderr).toContain("shell_command"); expect(deniedShell.message.value.result.value.stderr).toContain("exec_command"); expect(deniedShell.message.value.result.value.stderr).toContain("mcp_opencodex-responses_*"); - expect(deniedShell.message.value.result.value.stderr).toContain("Do not tell the user"); + expect(deniedShell.message.value.result.value.stderr).not.toContain("Do not tell the user"); + expect(deniedShell.message.value.result.value.stderr).not.toContain("silently call"); expect(deniedShell.message.value.result.value.stderr).not.toContain("disabled by OpenCodex policy"); expect(deniedShell.message.value.result.value.stderr).not.toContain("sandbox denial"); } @@ -150,7 +154,8 @@ describe("Cursor native exec bridge", () => { expect(streamText).toContain("shell_command"); expect(streamText).toContain("exec_command"); expect(streamText).toContain("mcp_opencodex-responses_*"); - expect(streamText).toContain("Do not tell the user"); + expect(streamText).not.toContain("Do not tell the user"); + expect(streamText).not.toContain("silently call"); expect(streamText).not.toContain("sandbox denial"); const deniedBackground = decode((await handleCursorNativeExec(execMessage({ @@ -162,7 +167,7 @@ describe("Cursor native exec bridge", () => { if (deniedBackground.message.value.result.case === "error") { expect(deniedBackground.message.value.result.value.error).toContain("shell_command"); expect(deniedBackground.message.value.result.value.error).toContain("exec_command"); - expect(deniedBackground.message.value.result.value.error).toContain("Do not tell the user"); + expect(deniedBackground.message.value.result.value.error).not.toContain("Do not tell the user"); } const deniedStdin = decode((await handleCursorNativeExec(execMessage({ @@ -183,7 +188,8 @@ describe("Cursor native exec bridge", () => { expect(deniedFetch.message.case).toBe("fetchResult"); expect(deniedFetch.message.value.result.case).toBe("error"); if (deniedFetch.message.value.result.case === "error") { - expect(deniedFetch.message.value.result.value.error).toContain("silently call"); + expect(deniedFetch.message.value.result.value.error).not.toContain("silently call"); + expect(deniedFetch.message.value.result.value.error).not.toContain("Do not tell the user"); expect(deniedFetch.message.value.result.value.error).toContain("shell_command"); expect(deniedFetch.message.value.result.value.error).toContain("curl"); expect(deniedFetch.message.value.result.value.error).toContain("wget"); @@ -228,7 +234,7 @@ describe("Cursor native exec bridge", () => { expect(shell.message.value.result.value.stderr).toContain("shell_command"); expect(shell.message.value.result.value.stderr).toContain("exec_command"); expect(shell.message.value.result.value.stderr).toContain("mcp_opencodex-responses_*"); - expect(shell.message.value.result.value.stderr).toContain("Do not tell the user"); + expect(shell.message.value.result.value.stderr).not.toContain("Do not tell the user"); expect(shell.message.value.result.value.stderr).not.toContain("sandbox denial"); } }); diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index f5fed40bcd..8eb0becf18 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -334,7 +334,11 @@ describe("Cursor tool definitions", () => { expect(note).toContain("current tool catalog as ground truth"); expect(note).toContain("This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Glob`, `Bash`, `LS`"); expect(note).toContain("not an external MCP server tool"); - expect(note).toContain("Never tell the user that shell or read access is blocked"); + expect(note).toContain("Prefer the Codex shell bridge over Cursor-native Shell/Read"); + expect(note).toContain("continue with the listed catalog tool `exec_command`"); + expect(note).not.toContain("such as `shell_command` / `exec_command`"); + expect(note).not.toContain("Never tell the user"); + expect(note).not.toContain("silently call"); expect(note).toContain("prefer one response containing multiple tool calls"); expect(note).toContain("Use MCP only for explicit discovery/resource tasks"); expect(note).toContain("not generic tool-count demos"); @@ -349,7 +353,10 @@ describe("Cursor tool definitions", () => { expect(note).toContain("`shell_command`"); expect(note).toContain("`shell_command` and `exec_command` are aliases of the same bridge"); expect(note).toContain("mcp_opencodex-responses_shell_command"); - expect(note).toContain("Never tell the user that shell or read access is blocked"); + expect(note).toContain("Prefer the Codex shell bridge over Cursor-native Shell/Read"); + expect(note).toContain("continue with the listed catalog tool `shell_command`"); + expect(note).not.toContain("Never tell the user"); + expect(note).not.toContain("silently call"); }); test("adds host-shell-neutral PowerShell and one-retry-stop guidance (#604)", () => { @@ -403,6 +410,25 @@ describe("Cursor tool definitions", () => { expect(note).not.toContain("`Read`, `Grep`, `Glob`, `Bash`, `LS`"); }); + test("treats GJC lowercase read/find/bash as covering neighboring-agent names (#1992)", () => { + const tools: OcxTool[] = [ + { name: "exec_command", description: "Run", parameters: {} }, + { name: "read", description: "Read a file", parameters: {} }, + { name: "find", description: "Find files", parameters: {} }, + { name: "bash", description: "Run a command", parameters: {} }, + ]; + + const note = buildCursorToolGuidanceSystemNote(tools); + expect(note).toBeDefined(); + if (!note) throw new Error("Expected Cursor tool guidance note"); + + expect(note).toContain("available tool names are exactly `exec_command`, `read`, `find`, `bash`"); + expect(note).toContain("This turn does not expose neighboring-agent tool names `Grep`, `LS`"); + expect(note).not.toContain("`Read`"); + expect(note).not.toContain("`Glob`"); + expect(note).not.toContain("`Bash`"); + }); + test("omits Cursor tool guidance when no tools are advertised", () => { const tools: OcxTool[] = [ { name: "read_file", namespace: "mcp__fs", description: "Read", parameters: {} },