diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 2d6385ae77..33bf4cf9f1 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -56,6 +56,7 @@ import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, i import { mcpArgsFromToolCall } from "./protobuf-events"; import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; import { + cursorNativeExecRedirectHint, handleCursorNativeExec, handleCursorNativeKv, releaseCursorBlobRequestScope, @@ -697,6 +698,7 @@ class LiveCursorTransport implements CursorTransport { clientToolDefs, rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice), structuredEditAvailable: syntheticStructuredEditToolNames.size > 0, + nativeExecRedirectHint: cursorNativeExecRedirectHint(cursorVisibleTools, this.execContext.mcpToolDefs), }; const toolSchemas = new Map(); const cursorToolNameMap = new Map(); diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index 265bfc7b92..2558f91fac 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -49,11 +49,11 @@ function codexNativeMutationRefusal(operation: "write" | "delete", structuredEdi const NATIVE_LOCAL_EXEC_DISABLED = "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call."; -export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectReadExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "readResult", create(ReadResultSchema, { - result: { case: "error", value: create(ReadErrorSchema, { path, error: NATIVE_LOCAL_EXEC_DISABLED }) }, + result: { case: "error", value: create(ReadErrorSchema, { path, error: hint ?? NATIVE_LOCAL_EXEC_DISABLED }) }, })); } @@ -98,13 +98,13 @@ export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structu })); } -export function rejectWriteExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "writeArgs") throw new Error("invalid write exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "writeResult", create(WriteResultSchema, { result: { case: "rejected", - value: create(WriteRejectedSchema, { path, reason: `${NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), + value: create(WriteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), }, })); } @@ -147,13 +147,13 @@ export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, struct })); } -export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "deleteArgs") throw new Error("invalid delete exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "deleteResult", create(DeleteResultSchema, { result: { case: "rejected", - value: create(DeleteRejectedSchema, { path, reason: `${NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), + value: create(DeleteRejectedSchema, { path, reason: `${hint ?? NATIVE_LOCAL_EXEC_DISABLED} No file was changed.` }), }, })); } @@ -188,11 +188,11 @@ export function deleteExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectLsExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectLsExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "lsArgs") throw new Error("invalid ls exec"); const path = resolve(execMsg.message.value.path); return execBytes(execMsg, "lsResult", create(LsResultSchema, { - result: { case: "error", value: create(LsErrorSchema, { path, error: NATIVE_LOCAL_EXEC_DISABLED }) }, + result: { case: "error", value: create(LsErrorSchema, { path, error: hint ?? NATIVE_LOCAL_EXEC_DISABLED }) }, })); } @@ -256,8 +256,8 @@ function grepError(execMsg: ExecServerMessage, error: string): Uint8Array { })); } -export function rejectGrepExecForPolicy(execMsg: ExecServerMessage): Uint8Array { - return grepError(execMsg, NATIVE_LOCAL_EXEC_DISABLED); +export function rejectGrepExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { + return grepError(execMsg, hint ?? NATIVE_LOCAL_EXEC_DISABLED); } export function grepExec(execMsg: ExecServerMessage): Uint8Array { diff --git a/src/adapters/cursor/native-exec-network.ts b/src/adapters/cursor/native-exec-network.ts index f259f284ef..49f1142e97 100644 --- a/src/adapters/cursor/native-exec-network.ts +++ b/src/adapters/cursor/native-exec-network.ts @@ -9,11 +9,11 @@ export interface CursorNativeNetworkDeps { const NATIVE_FETCH_DISABLED = "Re-issue this fetch NOW through the catalog shell tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do NOT narrate this redirect or comment on tool availability — just make the bridge call."; -export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectFetchExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec"); const args = execMsg.message.value; return execBytes(execMsg, "fetchResult", create(FetchResultSchema, { - result: { case: "error", value: create(FetchErrorSchema, { url: args.url, error: NATIVE_FETCH_DISABLED }) }, + result: { case: "error", value: create(FetchErrorSchema, { url: args.url, error: hint ?? NATIVE_FETCH_DISABLED }) }, })); } diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index 6fb7688302..411aa7f07c 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -82,7 +82,8 @@ let unresolvedKills = 0; let killFailures = 0; /** Rejection text when Cursor-native shell is denied by policy (issue #604). */ -export function nativeShellDisabledMessage(): string { +export function nativeShellDisabledMessage(hint?: string): string { + if (hint) return hint; // Do not insist on "the same command" — that steers models into replaying bash/CMD // idioms through the Codex bridge on Windows PowerShell 5.1 and looping (#604). // Keep this host-shell-neutral: OpenCodex may run on a different OS than the Codex @@ -98,7 +99,7 @@ export function nativeShellDisabledMessage(): string { ); } -function rejectedShellResult(command: string, cwd: string, started: number) { +function rejectedShellResult(command: string, cwd: string, started: number, hint?: string) { return create(ShellResultSchema, { result: { case: "failure", @@ -108,7 +109,7 @@ function rejectedShellResult(command: string, cwd: string, started: number) { exitCode: 1, signal: "", stdout: "", - stderr: nativeShellDisabledMessage(), + stderr: nativeShellDisabledMessage(hint), executionTime: Date.now() - started, aborted: true, }), @@ -116,10 +117,10 @@ function rejectedShellResult(command: string, cwd: string, started: number) { }); } -export function rejectShellExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectShellExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array { if (execMsg.message.case !== "shellArgs") throw new Error("invalid shell exec"); const args = execMsg.message.value; - return execBytes(execMsg, "shellResult", rejectedShellResult(args.command, resolve(args.workingDirectory || process.cwd()), Date.now())); + return execBytes(execMsg, "shellResult", rejectedShellResult(args.command, resolve(args.workingDirectory || process.cwd()), Date.now(), hint)); } export function shellExec(execMsg: ExecServerMessage): Uint8Array { @@ -157,7 +158,7 @@ export function shellExec(execMsg: ExecServerMessage): Uint8Array { })); } -export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint8Array[] { +export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage, hint?: string): Uint8Array[] { if (execMsg.message.case !== "shellStreamArgs") throw new Error("invalid shell stream exec"); const args = execMsg.message.value; const cwd = resolve(args.workingDirectory || process.cwd()); @@ -167,12 +168,12 @@ export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint event: { case: "start", value: create(ShellStreamStartSchema, { sandboxPolicy: args.requestedSandboxPolicy }) }, })), execBytes(execMsg, "shellStream", create(ShellStreamSchema, { - event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: nativeShellDisabledMessage() }) }, + event: { case: "stderr", value: create(ShellStreamStderrSchema, { data: nativeShellDisabledMessage(hint) }) }, })), execBytes(execMsg, "shellStream", create(ShellStreamSchema, { event: { case: "exit", value: create(ShellStreamExitSchema, { code: 1, cwd, aborted: true }) }, })), - execBytes(execMsg, "shellResult", rejectedShellResult(args.command, cwd, started)), + execBytes(execMsg, "shellResult", rejectedShellResult(args.command, cwd, started, hint)), execStreamCloseBytes(execMsg), ]; } @@ -263,12 +264,12 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise[] | undefined, + mcpToolDefs: readonly Pick[] = [], +): string | undefined { + const clientTools = tools ?? []; + if (cursorRequestHasShellAlias(clientTools) || cursorRequestHasExecutionPath(clientTools)) return undefined; + // Client tools are advertised under OCX_RESPONSES_TOOL_PROVIDER, so the harness shows them as + // `mcp__`; configured MCP servers are advertised under their own provider id. + // A request with no client tools but configured MCP tools still gets those named; a request that + // advertises nothing at all keeps the default bridge wording. + const names = [...new Set([ + ...clientTools.map(cursorToolWireName), + ...mcpToolDefs.map(def => `mcp_${def.providerIdentifier}_${def.name}`), + ])]; + if (names.length === 0) return undefined; + const shown = names.slice(0, REDIRECT_HINT_MAX_TOOLS).map(name => `\`${name}\``).join(", "); + const more = names.length > REDIRECT_HINT_MAX_TOOLS ? ` (+${names.length - REDIRECT_HINT_MAX_TOOLS} more)` : ""; + return ( + `Re-issue this operation NOW through one of the tools listed in this request's catalog: ${shown}${more} ` + + `(the harness displays a \`${OCX_RESPONSES_TOOL_PROVIDER}\` entry as \`mcp_${OCX_RESPONSES_TOOL_PROVIDER}_\`; that is the same tool). ` + + "Cursor-native Read/Glob/Grep/LS/Shell/Write/Fetch are not part of this request's catalog; do not retry them. " + + "Pick the listed tool that fits the operation — a listed file, search, or fetch tool if there is one, otherwise the listed tool that delegates work to a worker agent. " + + "Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the catalog tool call." + ); } export function cursorUnsafeNativeLocalExecEnabled(input: Pick = {}): boolean { @@ -634,16 +675,16 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C }))]; } if (!cursorUnsafeNativeLocalExecEnabled(deps)) { - if (execCase === "readArgs") return [rejectReadExecForPolicy(execMsg)]; - if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg)]; - if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg)]; - if (execCase === "lsArgs") return [rejectLsExecForPolicy(execMsg)]; - if (execCase === "grepArgs") return [rejectGrepExecForPolicy(execMsg)]; - if (execCase === "shellArgs") return [rejectShellExecForPolicy(execMsg)]; - if (execCase === "shellStreamArgs") return rejectShellStreamExecForPolicy(execMsg); - if (execCase === "backgroundShellSpawnArgs") return [rejectBackgroundShellSpawnExecForPolicy(execMsg)]; - if (execCase === "writeShellStdinArgs") return [rejectWriteShellStdinExecForPolicy(execMsg)]; - if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg)]; + if (execCase === "readArgs") return [rejectReadExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "lsArgs") return [rejectLsExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "grepArgs") return [rejectGrepExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "shellArgs") return [rejectShellExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "shellStreamArgs") return rejectShellStreamExecForPolicy(execMsg, deps.nativeExecRedirectHint); + if (execCase === "backgroundShellSpawnArgs") return [rejectBackgroundShellSpawnExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "writeShellStdinArgs") return [rejectWriteShellStdinExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; + if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg, deps.nativeExecRedirectHint)]; } if (execCase === "readArgs") return [readExec(execMsg)]; if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : writeExec(execMsg)]; diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 55f017685b..0cef62c03e 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -20,6 +20,18 @@ approval and sandbox path. `nativeLocalExec: "on"` is the explicit config-owner local experiments; `off` and the backwards-compatible `codex-sandbox` spelling both fail closed. MCP, screen recording, and computer-use stay on their separate explicit executor/MCP config paths. +The denial payload is a silent redirect whose wording follows the request catalog. When the catalog +carries a shell bridge or unified `exec`, the model is redirected to `shell_command` / +`exec_command`. When it carries neither — a delegation-only client that exposes nothing but its own +Responses tools — `cursorNativeExecRedirectHint` in `src/adapters/cursor/native-exec.ts` names the +request's actual wire names instead: bare client tools as `ocx_client_*`, namespaced tools as the +`namespacedToolName` form (for example `mcp__docker__ps`), both registered under the +`opencodex-responses` provider so the harness displays them as `mcp_opencodex-responses_`, +plus any configured MCP server tools advertised through `prepareMcp` as `mcp__`. The +text stays neutral about what those tools can do — it never claims the request has no read or fetch +tool — and the live transport injects it into the per-request exec context, so a model that tried +Cursor-native Read/Shell is steered to a tool that exists rather than to an alias it cannot see. + > Decision record: [ADR-0047](../decisions/ADR-0047-cursor-native-exec.md) Cursor's generic tool-use prompt filter must preserve every Responses-owned execution-path tool diff --git a/tests/providers/cursor/cursor-native-exec-policy.test.ts b/tests/providers/cursor/cursor-native-exec-policy.test.ts index 24b63e28ea..0d66e704cd 100644 --- a/tests/providers/cursor/cursor-native-exec-policy.test.ts +++ b/tests/providers/cursor/cursor-native-exec-policy.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { create, fromBinary } from "@bufbuild/protobuf"; @@ -12,19 +12,31 @@ import { import { AgentClientMessageSchema, BackgroundShellSpawnArgsSchema, + DeleteArgsSchema, ExecServerMessageSchema, FetchArgsSchema, + GrepArgsSchema, + LsArgsSchema, ReadArgsSchema, ShellArgsSchema, + WriteArgsSchema, + WriteShellStdinArgsSchema, } from "../../../src/adapters/cursor/gen/agent_pb"; -import { handleCursorNativeExec } from "../../../src/adapters/cursor/native-exec"; +import { createLiveCursorTransport } from "../../../src/adapters/cursor/live-transport"; import { + cursorNativeExecRedirectHint, + handleCursorNativeExec, + resetCursorBlobStateForTests, +} from "../../../src/adapters/cursor/native-exec"; +import { + nativeShellDisabledMessage, resetBackgroundShellStateForTests, setBackgroundShellRuntimeForTests, } from "../../../src/adapters/cursor/native-exec-shell"; import type { CursorTransportFactoryInput } from "../../../src/adapters/cursor/transport"; import { parseRequest } from "../../../src/responses/parser"; -import type { OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import type { OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../../src/types"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; const fullAccessDeclaration = "`sandbox_mode` is `danger-full-access`"; @@ -384,3 +396,173 @@ describe("Cursor native exec sandbox policy", () => { }); }); + +/** + * A delegation-only client (an orchestrator that exposes nothing but its own Responses tools — + * no shell bridge, no unified exec) still gets Cursor-native Read/Shell attempts from the model. + * The default denial steers the model to `shell_command` / `exec_command`; when those are not in + * the catalog the model concludes every tool is unavailable and gives up. The hint names the + * catalog that actually exists instead. + */ +describe("Cursor native exec catalog-aware redirect hint", () => { + const SILENT_REDIRECT_FORBIDDEN = [/blocked/i, /\bdisabled\b/i, /not executed/i, /\bdenied\b/i, /cannot execute/i, /차단/]; + type CatalogTool = { name: string; namespace?: string; freeform?: boolean }; + const delegationOnlyCatalog: CatalogTool[] = [{ name: "task" }, { name: "ask_user" }]; + + function stringifyReplies(replies: Uint8Array[]): string { + return replies.map(bytes => stringify(fromBinary(AgentClientMessageSchema, bytes))).join("\n"); + } + + test("names the request's client wire names when the catalog has no shell bridge or execution path", () => { + const hint = cursorNativeExecRedirectHint(delegationOnlyCatalog); + expect(hint).toBeDefined(); + expect(hint).toContain("`ocx_client_task`"); + expect(hint).toContain("`ocx_client_ask_user`"); + expect(hint).toContain("mcp_opencodex-responses_"); + expect(hint).toContain("Do NOT narrate"); + expect(hint).not.toContain("shell_command"); + expect(hint).not.toContain("exec_command"); + // Neutral about capabilities: a listed file/search/fetch tool must never be contradicted. + expect(hint).not.toMatch(/no (shell|read|grep|ls|write|fetch) tool/i); + expect(hint).not.toMatch(/ONLY callable/i); + for (const pattern of SILENT_REDIRECT_FORBIDDEN) expect(hint).not.toMatch(pattern); + }); + + test("names configured MCP tools advertised for the turn by their harness display form", () => { + const hint = cursorNativeExecRedirectHint( + [{ name: "task" }], + [{ name: "read_file", providerIdentifier: "opencodex" }], + ) ?? ""; + expect(hint).toContain("`ocx_client_task`"); + expect(hint).toContain("`mcp_opencodex_read_file`"); + // No client tools at all, but configured MCP tools: those are the catalog, so name them. + const mcpOnly = cursorNativeExecRedirectHint(undefined, [{ name: "read_file", providerIdentifier: "opencodex" }]) ?? ""; + expect(mcpOnly).toContain("`mcp_opencodex_read_file`"); + expect(mcpOnly).not.toContain("ocx_client_"); + expect(mcpOnly).not.toContain("shell_command"); + // Nothing advertised anywhere keeps the default bridge wording. + expect(cursorNativeExecRedirectHint(undefined, [])).toBeUndefined(); + expect(cursorNativeExecRedirectHint([], [])).toBeUndefined(); + }); + + test.each<[string, CatalogTool[] | undefined]>([ + ["an undefined catalog", undefined], + ["an empty catalog", []], + ["a bare exec_command bridge", [{ name: "exec_command" }]], + ["a bare shell_command bridge next to client tools", [{ name: "task" }, { name: "shell_command" }]], + ["unified exec next to client tools", [{ name: "task" }, { name: "exec", freeform: true }]], + ])("keeps the default bridge wording for %s", (_name, tools) => { + expect(cursorNativeExecRedirectHint(tools)).toBeUndefined(); + }); + + test("lists namespaced tools by wire name and caps a long catalog", () => { + const hint = cursorNativeExecRedirectHint([{ namespace: "mcp__docker", name: "ps" }, { name: "task" }]) ?? ""; + expect(hint).toContain("`mcp__docker__ps`"); + expect(hint).toContain("`ocx_client_task`"); + const capped = cursorNativeExecRedirectHint(Array.from({ length: 20 }, (_, index) => ({ name: `tool_${index}` }))) ?? ""; + expect(capped).toContain("`ocx_client_tool_15`"); + expect(capped).not.toContain("`ocx_client_tool_16`"); + expect(capped).toContain("(+4 more)"); + }); + + test("without a hint the bridge wording is unchanged", () => { + expect(nativeShellDisabledMessage()).toContain("shell_command"); + expect(nativeShellDisabledMessage("custom hint")).toBe("custom hint"); + }); + + test("every denied native fs, shell, and fetch frame carries the hint and executes nothing", async () => { + const hint = cursorNativeExecRedirectHint(delegationOnlyCatalog); + expect(hint).toBeDefined(); + const dir = mkdtempSync(join(tmpdir(), "ocx-cursor-hint-")); + const existing = join(dir, "grounding.txt"); + const content = "HINT-GROUNDING-01 must not leak"; + writeFileSync(existing, content); + const newPath = join(dir, "must-not-exist.txt"); + let fetchCalled = false; + const deps = { + unsafeAllowNativeLocalExec: false, + nativeExecRedirectHint: hint, + fetch: async () => { + fetchCalled = true; + return new Response("SHOULD_NOT_FETCH"); + }, + }; + const frames = [ + execMessage({ case: "readArgs", value: create(ReadArgsSchema, { path: existing }) }), + execMessage({ case: "lsArgs", value: create(LsArgsSchema, { path: dir }) }), + execMessage({ case: "grepArgs", value: create(GrepArgsSchema, { pattern: "HINT", path: dir }) }), + execMessage({ case: "writeArgs", value: create(WriteArgsSchema, { path: newPath, fileText: "SHOULD_NOT_WRITE" }) }), + execMessage({ case: "deleteArgs", value: create(DeleteArgsSchema, { path: existing }) }), + execMessage({ case: "shellArgs", value: create(ShellArgsSchema, { command: "printf RAN_%s MARKER", workingDirectory: dir, hardTimeout: 2000 }) }), + execMessage({ case: "shellStreamArgs", value: create(ShellArgsSchema, { command: "printf RAN_%s MARKER", workingDirectory: dir }) }), + execMessage({ case: "backgroundShellSpawnArgs", value: create(BackgroundShellSpawnArgsSchema, { command: "printf RAN_%s MARKER", workingDirectory: dir }) }), + execMessage({ case: "writeShellStdinArgs", value: create(WriteShellStdinArgsSchema, { shellId: 999, chars: "SHOULD_NOT_WRITE" }) }), + execMessage({ case: "fetchArgs", value: create(FetchArgsSchema, { url: "https://metadata.invalid/latest" }) }), + ]; + for (const frame of frames) { + const text = stringifyReplies(await handleCursorNativeExec(frame, deps)); + expect(text).toContain("`ocx_client_task`"); + expect(text).toContain("Do NOT narrate"); + expect(text).not.toContain("shell_command"); + expect(text).not.toContain("exec_command"); + expect(text).not.toContain(content); + // Denied shell frames echo the command text; only an executed command could produce the joined marker. + expect(text).not.toContain("RAN_MARKER"); + expect(text).not.toContain("SHOULD_NOT_WRITE"); + expect(text).not.toContain("SHOULD_NOT_FETCH"); + } + expect(fetchCalled).toBe(false); + expect(existsSync(existing)).toBe(true); + expect(existsSync(newPath)).toBe(false); + }); + + test("the live transport derives the hint from each turn's visible catalog", async () => { + type OpenFn = ( + encoded: Uint8Array, + signal: AbortSignal | undefined, + state: unknown, + push: unknown, + fail: (error: Error) => void, + finish: () => void, + ) => void; + const runWithTools = async (tools: OcxTool[]): Promise => { + resetCursorBlobStateForTests(); + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + headers: new Headers(), + }); + let failTurn!: (error: Error) => void; + let onOpened!: () => void; + const opened = new Promise(resolve => { onOpened = resolve; }); + (transport as unknown as { open: OpenFn }).open = (_encoded, _signal, _state, _push, fail) => { + failTurn = fail; + onOpened(); + }; + (transport as unknown as { execContext: { mcpToolDefs?: unknown[] } }).execContext.mcpToolDefs = [ + { name: "read_file", providerIdentifier: "opencodex" }, + ]; + const iterator = transport.run({ + modelId: "composer-2.5", + conversationId: `redirect-hint-${tools.length}`, + system: [], + messages: [{ role: "user", content: "hi" }], + tools, + })[Symbol.asyncIterator](); + const pending = iterator.next(); + await opened; + const hint = (transport as unknown as { execContext: { nativeExecRedirectHint?: string } }).execContext.nativeExecRedirectHint; + failTurn(new Error("fixture closed")); + await pending.catch(() => {}); + await transport.close?.(); + resetCursorBlobStateForTests(); + return hint; + }; + const task: OcxTool = { name: "task", description: "Delegate work to a worker agent.", parameters: { type: "object" } }; + const bridge: OcxTool = { name: "exec_command", description: "Run a shell command.", parameters: { type: "object" } }; + const hint = await runWithTools([task]); + expect(hint).toContain("`ocx_client_task`"); + expect(hint).toContain("`mcp_opencodex_read_file`"); + expect(await runWithTools([task, bridge])).toBeUndefined(); + }); +});