diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index afc411e243..412cc77e57 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -66,6 +66,7 @@ import { desktopDepsFromConfig } from "./native-exec-desktop"; import { buildCursorToolDefinitions, cursorRequestAdvertisesApplyPatch, + cursorRequestUsesCodeMode, cursorRequestHasShellAlias, cursorToolArgNormalizeSchema, cursorToolWireName, @@ -557,6 +558,7 @@ class LiveCursorTransport implements CursorTransport { this.execContext = { ...this.execContext, clientToolDefs, + codeMode: cursorRequestUsesCodeMode(request.tools, request.toolChoice), rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice), structuredEditAvailable: syntheticStructuredEditToolNames.size > 0, }; diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index fb22826912..f423456750 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -34,12 +34,16 @@ import { type GrepArgs, } from "./gen/agent_pb"; import { errorText, execBytes, lineCount, textDecoder, textEncoder } from "./native-exec-common"; +import { codeModeBridgeGuidance } from "./native-exec-guidance"; const MAX_GREP_FILES = 500; const MAX_GREP_RESULTS = 200; const MAX_FILE_BYTES = 1_000_000; -function codexNativeMutationRefusal(operation: "write" | "delete", structuredEditAvailable: boolean): string { +function codexNativeMutationRefusal(operation: "write" | "delete", structuredEditAvailable: boolean, codeMode?: boolean): string { + if (codeMode === true) { + return `Cursor-native ${operation} is disabled for this Codex request because file mutations must stay on the Codex approval path. Call the top-level \`exec\` tool with JavaScript in its body, then call \`await tools.apply_patch(...)\` inside that body and emit the result with \`text(...)\`. Do not call \`apply_patch\` as a top-level tool in code mode. No file was changed.`; + } const structuredHint = structuredEditAvailable ? " Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout." : " Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout."; @@ -49,11 +53,15 @@ 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."; -export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +function nativeFilesystemDisabledMessage(codeMode?: boolean): string { + return codeModeBridgeGuidance(codeMode) ?? NATIVE_LOCAL_EXEC_DISABLED; +} + +export function rejectReadExecForPolicy(execMsg: ExecServerMessage, codeMode?: boolean): 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: nativeFilesystemDisabledMessage(codeMode) }) }, })); } @@ -87,24 +95,24 @@ export function readExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array { +export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false, codeMode?: boolean): 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: codexNativeMutationRefusal("write", structuredEditAvailable) }), + value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write", structuredEditAvailable, codeMode) }), }, })); } -export function rejectWriteExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectWriteExecForPolicy(execMsg: ExecServerMessage, codeMode?: boolean): 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: `${nativeFilesystemDisabledMessage(codeMode)} No file was changed.` }), }, })); } @@ -136,24 +144,24 @@ export function writeExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): Uint8Array { +export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false, codeMode?: boolean): 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: codexNativeMutationRefusal("delete", structuredEditAvailable) }), + value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete", structuredEditAvailable, codeMode) }), }, })); } -export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectDeleteExecForPolicy(execMsg: ExecServerMessage, codeMode?: boolean): 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: `${nativeFilesystemDisabledMessage(codeMode)} No file was changed.` }), }, })); } @@ -188,11 +196,11 @@ export function deleteExec(execMsg: ExecServerMessage): Uint8Array { } } -export function rejectLsExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectLsExecForPolicy(execMsg: ExecServerMessage, codeMode?: boolean): 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: nativeFilesystemDisabledMessage(codeMode) }) }, })); } @@ -256,8 +264,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, codeMode?: boolean): Uint8Array { + return grepError(execMsg, nativeFilesystemDisabledMessage(codeMode)); } export function grepExec(execMsg: ExecServerMessage): Uint8Array { diff --git a/src/adapters/cursor/native-exec-guidance.ts b/src/adapters/cursor/native-exec-guidance.ts new file mode 100644 index 0000000000..bad0d936b6 --- /dev/null +++ b/src/adapters/cursor/native-exec-guidance.ts @@ -0,0 +1,12 @@ +export const CODE_MODE_BRIDGE_GUIDANCE = + "This request uses Codex code mode. Call the top-level `exec` tool with JavaScript in its body, then call `await tools.exec_command({ ... })` inside that body and emit the result with `text(...)`. Do not call `shell_command`, `exec_command`, or `mcp_opencodex-responses_*` as top-level tools; those names are nested helpers in code mode."; + +export function codeModeBridgeGuidance(enabled: boolean | undefined): string | undefined { + return enabled === true ? CODE_MODE_BRIDGE_GUIDANCE : undefined; +} + +export function codeModeNestedHelperGuidance(enabled: boolean | undefined): string | undefined { + return enabled === true + ? "This request uses Codex code mode. Call the top-level `exec` tool with JavaScript in its body, then call the matching `await tools.(...)` listed in that tool's description and emit the result with `text(...)`. Do not call nested helper names as top-level tools." + : undefined; +} diff --git a/src/adapters/cursor/native-exec-network.ts b/src/adapters/cursor/native-exec-network.ts index 7d5b9c6617..7092a13cd7 100644 --- a/src/adapters/cursor/native-exec-network.ts +++ b/src/adapters/cursor/native-exec-network.ts @@ -1,6 +1,7 @@ import { create } from "@bufbuild/protobuf"; import { FetchErrorSchema, FetchResultSchema, FetchSuccessSchema, type ExecServerMessage } from "./gen/agent_pb"; import { errorText, execBytes } from "./native-exec-common"; +import { codeModeBridgeGuidance } from "./native-exec-guidance"; export interface CursorNativeNetworkDeps { fetch?: typeof fetch; @@ -9,11 +10,11 @@ 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."; -export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectFetchExecForPolicy(execMsg: ExecServerMessage, codeMode?: boolean): 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: codeModeBridgeGuidance(codeMode) ?? NATIVE_FETCH_DISABLED }) }, })); } diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index a022f18339..7bafbb2cfe 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -19,6 +19,7 @@ import { type ExecServerMessage, } from "./gen/agent_pb"; import { errorText, execBytes, execStreamCloseBytes } from "./native-exec-common"; +import { codeModeBridgeGuidance } from "./native-exec-guidance"; import { createAdmissionGate, type AdmissionLease, @@ -82,7 +83,9 @@ 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(codeMode?: boolean): string { + const codeModeGuidance = codeModeBridgeGuidance(codeMode); + if (codeModeGuidance) return codeModeGuidance; // 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 @@ -96,7 +99,7 @@ export function nativeShellDisabledMessage(): string { ); } -function rejectedShellResult(command: string, cwd: string, started: number) { +function rejectedShellResult(command: string, cwd: string, started: number, codeMode?: boolean) { return create(ShellResultSchema, { result: { case: "failure", @@ -106,7 +109,7 @@ function rejectedShellResult(command: string, cwd: string, started: number) { exitCode: 1, signal: "", stdout: "", - stderr: nativeShellDisabledMessage(), + stderr: nativeShellDisabledMessage(codeMode), executionTime: Date.now() - started, aborted: true, }), @@ -114,10 +117,10 @@ function rejectedShellResult(command: string, cwd: string, started: number) { }); } -export function rejectShellExecForPolicy(execMsg: ExecServerMessage): Uint8Array { +export function rejectShellExecForPolicy(execMsg: ExecServerMessage, codeMode?: boolean): 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(), codeMode)); } export function shellExec(execMsg: ExecServerMessage): Uint8Array { @@ -155,7 +158,7 @@ export function shellExec(execMsg: ExecServerMessage): Uint8Array { })); } -export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage): Uint8Array[] { +export function rejectShellStreamExecForPolicy(execMsg: ExecServerMessage, codeMode?: boolean): 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()); @@ -165,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(codeMode) }) }, })), 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, codeMode)), execStreamCloseBytes(execMsg), ]; } @@ -261,12 +264,12 @@ export async function shellStreamExec(execMsg: ExecServerMessage): Promise McpResult | Promise; @@ -30,6 +31,12 @@ export interface CursorNativeToolDeps { readMcpResource?: (args: ReadMcpResourceExecArgs) => ReadMcpResourceExecResult | Promise; computerUse?: (args: ComputerUseArgs) => ComputerUseResult | Promise; recordScreen?: (args: RecordScreenArgs) => RecordScreenResult | Promise; + codeMode?: boolean; +} + +function missingMcpResourceExecutorMessage(codeMode?: boolean): string { + const guidance = codeModeNestedHelperGuidance(codeMode); + return guidance ?? "No local MCP resource executor is configured inside opencodex."; } export async function mcpExec(execMsg: ExecServerMessage, deps: CursorNativeToolDeps): Promise { @@ -60,7 +67,7 @@ export async function listMcpResourcesExec(execMsg: ExecServerMessage, deps: Cur ? await deps.listMcpResources() : create(ListMcpResourcesExecResultSchema, { result: { case: "error", value: create(ListMcpResourcesErrorSchema, { - error: "No local MCP resource executor is configured inside opencodex.", + error: missingMcpResourceExecutorMessage(deps.codeMode), }) }, }); return execBytes(execMsg, "listMcpResourcesExecResult", result); @@ -73,7 +80,7 @@ export async function readMcpResourceExec(execMsg: ExecServerMessage, deps: Curs const result = deps.readMcpResource ? await deps.readMcpResource(args) : create(ReadMcpResourceExecResultSchema, { - result: { case: "error", value: create(ReadMcpResourceErrorSchema, { uri: args.uri, error: "No local MCP resource executor is configured inside opencodex." }) }, + result: { case: "error", value: create(ReadMcpResourceErrorSchema, { uri: args.uri, error: missingMcpResourceExecutorMessage(deps.codeMode) }) }, }); return execBytes(execMsg, "readMcpResourceExecResult", result); } catch (err) { diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index 52856b79e1..ad7bfcd012 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -504,20 +504,20 @@ 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.codeMode)]; + if (execCase === "writeArgs") return [rejectWriteExecForPolicy(execMsg, deps.codeMode)]; + if (execCase === "deleteArgs") return [rejectDeleteExecForPolicy(execMsg, deps.codeMode)]; + if (execCase === "lsArgs") return [rejectLsExecForPolicy(execMsg, deps.codeMode)]; + if (execCase === "grepArgs") return [rejectGrepExecForPolicy(execMsg, deps.codeMode)]; + if (execCase === "shellArgs") return [rejectShellExecForPolicy(execMsg, deps.codeMode)]; + if (execCase === "shellStreamArgs") return rejectShellStreamExecForPolicy(execMsg, deps.codeMode); + if (execCase === "backgroundShellSpawnArgs") return [rejectBackgroundShellSpawnExecForPolicy(execMsg, deps.codeMode)]; + if (execCase === "writeShellStdinArgs") return [rejectWriteShellStdinExecForPolicy(execMsg, deps.codeMode)]; + if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg, deps.codeMode)]; } if (execCase === "readArgs") return [readExec(execMsg)]; - if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : writeExec(execMsg)]; - if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true) : deleteExec(execMsg)]; + if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true, deps.codeMode) : writeExec(execMsg)]; + if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg, deps.structuredEditAvailable === true, deps.codeMode) : deleteExec(execMsg)]; if (execCase === "lsArgs") return [lsExec(execMsg)]; if (execCase === "grepArgs") return [grepExec(execMsg)]; if (execCase === "shellArgs") return [shellExec(execMsg)]; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 2f1dd73dd4..6b4d90e2fc 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -169,32 +169,39 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined { if (namespace) tool.namespace = namespace; out.push(tool); }; + const pushCustom = (t: Record, namespace?: string) => { + // Keep apply_patch grammar guidance scoped to apply_patch. Other freeform tools (notably + // code-mode exec) accept a different language and must not inherit patch syntax. + const inputDescription = t.name === "apply_patch" + ? "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope." + : "Raw freeform input for this tool."; + const tool: OcxTool = { + name: t.name as string, + description: (t.description as string) ?? "", + parameters: { type: "object", properties: { input: { type: "string", description: inputDescription } }, required: ["input"] }, + freeform: true, + }; + if (namespace) tool.namespace = namespace; + out.push(tool); + }; for (const t of tools) { if (!isObj(t)) continue; if (t.type === "function" && typeof t.name === "string") { pushFn(t); } else if (t.type === "namespace" && Array.isArray(t.tools)) { - // MCP tools arrive grouped under a namespace tool; flatten the inner function tools so - // chat-completions models receive them (round-trip restores the namespace in the bridge). - const ns = typeof t.name === "string" ? t.name : undefined; + // Codex 0.147 groups its ordinary client tools under the reserved `functions` namespace, + // including freeform custom tools such as code-mode `exec`. Those children are still + // top-level Responses tools, so flatten them without a namespace. Other namespace groups + // are MCP-style and keep their namespace for round-trip routing. + const builtinFunctions = t.name === "functions"; + const ns = typeof t.name === "string" && !builtinFunctions ? t.name : undefined; for (const inner of t.tools as unknown[]) { if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns); + else if (builtinFunctions && isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner); } } else if (t.type === "custom" && typeof t.name === "string") { - // Freeform custom tools are lowered to a single string `input` because chat models cannot - // emit Responses grammar payloads directly. Keep tool-specific input guidance scoped to the - // tool that owns it: leaking apply_patch syntax into `exec` or another freeform tool teaches - // routed models that the nested helper name is itself a callable top-level tool. - const inputDescription = t.name === "apply_patch" - ? "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope." - : "Raw freeform input for this tool."; - out.push({ - name: t.name, - description: (t.description as string) ?? "", - parameters: { type: "object", properties: { input: { type: "string", description: inputDescription } }, required: ["input"] }, - freeform: true, - }); + pushCustom(t); } else if (t.type === "tool_search") { // Client-executed tool discovery — the gateway to deferred tools (subagents, extra MCP tools). diff --git a/tests/cursor-live-transport.test.ts b/tests/cursor-live-transport.test.ts index 68610f7e77..34cd7ac83b 100644 --- a/tests/cursor-live-transport.test.ts +++ b/tests/cursor-live-transport.test.ts @@ -106,6 +106,36 @@ describe("Cursor live transport", () => { await transport.close?.(); }); + test("marks the native exec context when a turn advertises code mode", async () => { + const transport = createLiveCursorTransport({ + provider: { adapter: "cursor", baseUrl: "https://api2.cursor.sh", apiKey: "test-token" }, + translatorBudget: createTestTranslatorBudget(), + headers: new Headers(), + }); + const internals = transport as unknown as { + execContext: { codeMode?: boolean }; + open(): void; + }; + internals.open = () => { throw new Error("stop-after-context"); }; + + const iterator = transport.run({ + modelId: "composer-2.5", + conversationId: "code-mode-native-context", + system: [], + messages: [{ role: "user", content: "read the workspace" }], + tools: [{ + name: "exec", + description: "Run JavaScript code to orchestrate nested tool calls.", + parameters: {}, + freeform: true, + }], + })[Symbol.asyncIterator](); + + await expect(iterator.next()).rejects.toThrow("stop-after-context"); + expect(internals.execContext.codeMode).toBe(true); + await transport.close?.(); + }); + test("fails before network when no Cursor credential is configured", () => { const prev = process.env.OPENCODEX_CURSOR_TEST_TOKEN; delete process.env.OPENCODEX_CURSOR_TEST_TOKEN; diff --git a/tests/cursor-native-exec.test.ts b/tests/cursor-native-exec.test.ts index 58e44aa577..7e8629e31b 100644 --- a/tests/cursor-native-exec.test.ts +++ b/tests/cursor-native-exec.test.ts @@ -191,6 +191,56 @@ describe("Cursor native exec bridge", () => { } }); + test("routes denied native operations through the nested code-mode helpers", async () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-cursor-code-mode-")); + const path = join(dir, "note.txt"); + + const deniedRead = decode((await handleCursorNativeExec(execMessage({ + case: "readArgs", + value: create(ReadArgsSchema, { path }), + }), { codeMode: true }))[0]); + const deniedShell = decode((await handleCursorNativeExec(execMessage({ + case: "shellArgs", + value: create(ShellArgsSchema, { command: "printf blocked", workingDirectory: dir }), + }), { codeMode: true }))[0]); + const deniedFetch = decode((await handleCursorNativeExec(execMessage({ + case: "fetchArgs", + value: create(FetchArgsSchema, { url: "https://example.test/doc" }), + }), { codeMode: true }))[0]); + const missingResourceExecutor = decode((await handleCursorNativeExec(execMessage({ + case: "readMcpResourceExecArgs", + value: create(ReadMcpResourceExecArgsSchema, { server: "fixture", uri: "memory://doc" }), + }), { codeMode: true }))[0]); + + for (const reply of [deniedRead, deniedShell, deniedFetch]) { + const guidance = JSON.stringify(reply); + expect(guidance).toContain("top-level `exec`"); + expect(guidance).toContain("await tools.exec_command"); + expect(guidance).toContain("text(...)"); + expect(guidance).toContain("Do not call `shell_command`"); + expect(guidance).not.toContain("silently call that bridge tool"); + } + const resourceGuidance = JSON.stringify(missingResourceExecutor); + expect(resourceGuidance).toContain("top-level `exec`"); + expect(resourceGuidance).toContain("await tools."); + expect(resourceGuidance).toContain("text(...)"); + expect(resourceGuidance).toContain("Do not call nested helper names as top-level tools"); + + const deniedWrite = decode((await handleCursorNativeExec(execMessage({ + case: "writeArgs", + value: create(WriteArgsSchema, { path, fileText: "blocked" }), + }), { + unsafeAllowNativeLocalExec: true, + rejectNativeFileMutations: true, + codeMode: true, + }))[0]); + const writeGuidance = JSON.stringify(deniedWrite); + expect(writeGuidance).toContain("top-level `exec`"); + expect(writeGuidance).toContain("await tools.apply_patch"); + expect(writeGuidance).toContain("Do not call `apply_patch` as a top-level tool"); + expect(existsSync(path)).toBe(false); + }); + test("writes and reads files in a temp directory with unsafe opt-in", async () => { const dir = mkdtempSync(join(tmpdir(), "ocx-cursor-exec-")); const path = join(dir, "note.txt"); diff --git a/tests/responses-tool-conformance.test.ts b/tests/responses-tool-conformance.test.ts index 95bf30e550..aa23b3ee52 100644 --- a/tests/responses-tool-conformance.test.ts +++ b/tests/responses-tool-conformance.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { parseRequest } from "../src/responses/parser"; +import { cursorRequestUsesCodeMode } from "../src/adapters/cursor/tool-definitions"; import type { AdapterEvent } from "../src/types"; import { jsonItemTypes, jsonToolItems, streamedView } from "./helpers/responses-conformance"; @@ -64,6 +65,39 @@ describe("Responses Lite additional_tools declaration merge", () => { expect(parsed.context.tools?.find(tool => tool.name === "search")?.namespace).toBe("github"); }); + it("flattens Codex 0.147 built-in functions and preserves nested custom exec", () => { + const parsed = parseRequest(request([ + { + type: "additional_tools", + role: "developer", + tools: [ + { + type: "namespace", + name: "functions", + tools: [ + { type: "custom", name: "exec", description: "Run JavaScript with nested helpers." }, + { type: "function", name: "wait", parameters: { type: "object", properties: {} } }, + ], + }, + { + type: "namespace", + name: "collaboration", + tools: [{ type: "function", name: "spawn_agent", parameters: { type: "object", properties: {} } }], + }, + ], + }, + ])); + + const exec = parsed.context.tools?.find(tool => tool.name === "exec"); + const wait = parsed.context.tools?.find(tool => tool.name === "wait"); + const spawn = parsed.context.tools?.find(tool => tool.name === "spawn_agent"); + expect(exec).toMatchObject({ name: "exec", freeform: true }); + expect(exec?.namespace).toBeUndefined(); + expect(wait?.namespace).toBeUndefined(); + expect(spawn?.namespace).toBe("collaboration"); + expect(cursorRequestUsesCodeMode(parsed.context.tools)).toBe(true); + }); + it("preserves wire order across multiple additional_tools groups", () => { const parsed = parseRequest(request([ { type: "additional_tools", role: "developer", tools: [fnTool] },