diff --git a/devlog/_plan/260906_d_integrations_delivery/031_cursor_refresh.md b/devlog/_plan/260906_d_integrations_delivery/031_cursor_refresh.md new file mode 100644 index 0000000000..599084c6e0 --- /dev/null +++ b/devlog/_plan/260906_d_integrations_delivery/031_cursor_refresh.md @@ -0,0 +1,9 @@ +# Cursor schema cycle P refresh + +Parent: d6bfb044a5dc6494cba57c1238ded7c23faf5586, open PR #3702. Original #3628 remains at 37e6115c8a2ad3ffe20fee1e5a1e79a054625a56, author SB Yoon (yansigit). + +The source commits 1b29236c5bee9dd166b9d23983a2f1f1c2f0b793 and 37e6115c8a2ad3ffe20fee1e5a1e79a054625a56 are prepared as mailbox patches with only production diff paths mapped from tool-definitions.ts to current tool-schemas.ts. `git apply --check` accepted the first mapped patch. Apply both in order during B, retaining their original author/date/message. Main then adds the new constant to the existing public re-export, closes the freeform object with additionalProperties:false, strengthens literal/protobuf assertions and updates the planned docs/structure. + +The current naming path preserves namespaces through namespacedToolName; the existing bare-shell helper remains the authority for the original rejection. No tool execution or approval policy changes are introduced. Read current 030 for all activation cases and complete scope. + +Main owns authored patch application, public facade and documentation edits, commits and PR publication. An inherited worker may amend only tool-schemas.ts and cursor-tool-definitions.test.ts after A; no Git or local tests/typecheck. Independent review plus current-head remote full/typecheck/docs and hosted CI supply proof. The candidate can remain open in the stack while shipping/closure criteria remain separately pending. diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index c603326699..5e75db3c59 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -365,6 +365,12 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and broader built-in executor and bypasses Codex approval/sandbox semantics, and legacy `unsafeAllowNativeLocalExec: true` remains equivalent only when `nativeLocalExec` is unset. +Codex-compatible shell schemas retain sandbox permissions, justification, reusable +prefix rules and login mode. Freeform tools expose one required string `input`; +bare `exec_command` and `shell_command` names are reserved for non-freeform shell +bridges. Namespace a custom freeform tool that uses either name. These schema +declarations do not grant approval or change execution policy. + ## `azure-openai` (alias: `azure`) **Targets:** **Azure OpenAI**. Wraps `openai-responses` (so also `passthrough: true`). diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 164b05433d..12be95cdf5 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -5,7 +5,7 @@ import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from import { CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, cursorRequestAdvertisesApplyPatch, cursorToolAllowedByChoice, cursorToolWireName, OCX_RESPONSES_TOOL_PROVIDER } from "./tool-naming"; import { CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, cursorToolInputSchema } from "./tool-schemas"; export { OCX_RESPONSES_TOOL_PROVIDER, CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL, CODEX_UNIFIED_EXEC_TOOL, CODEX_WAIT_TOOL, CODEX_APPLY_PATCH_TOOL, CODEX_TOOL_SEARCH_TOOL, CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, CURSOR_STRUCTURED_EDIT_TOOLS, CURSOR_EXEC_COMMAND_TOOL, CODEX_SHELL_BRIDGE_TOOL_NAMES, isCodexShellBridgeToolName, resolveShellBridgeAliasKey, cursorToolChoiceAliases, isBareCodexShellBridgeTool, isCursorExecutionPathTool, isCursorWaitTool, isCursorCodeModeExecTool, cursorRequestUsesCodeMode, cursorRequestHasShellAlias, cursorRequestAdvertisesApplyPatch, isCursorStructuredEditToolName, isCursorSyntheticStructuredEditTool, cursorToolWireName, normalizeCursorWireName, normalizeCursorTextToolMarkers, responsesToolNameFromCursorWire, cursorToolAllowedByChoice } from "./tool-naming"; -export { CURSOR_EXEC_COMMAND_INPUT_SCHEMA, CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, cursorToolInputSchema, cursorToolArgNormalizeSchema, shellBridgeRequiredCommandKeys, defaultShellBridgeArgNormalizeSchema, cursorShellBridgeDropError, nonEmptyShellBridgeCommandFromArgs, cursorShellBridgeArgsValid } from "./tool-schemas"; +export { CURSOR_EXEC_COMMAND_INPUT_SCHEMA, CURSOR_FREEFORM_INPUT_SCHEMA, CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, cursorToolInputSchema, cursorToolArgNormalizeSchema, shellBridgeRequiredCommandKeys, defaultShellBridgeArgNormalizeSchema, cursorShellBridgeDropError, nonEmptyShellBridgeCommandFromArgs, cursorShellBridgeArgsValid } from "./tool-schemas"; export { CURSOR_SHELL_ALIAS_SYSTEM_NOTE, CURSOR_GENERIC_TOOL_USE_USER_HINT, isGenericToolUseCountDemoPrompt, requestedCursorToolUseCount, shouldAppendCursorGenericToolUseHint, appendCursorGenericToolUseHint, shouldUseNativeExecOnlyForGenericToolUse, cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote } from "./tool-guidance"; /** diff --git a/src/adapters/cursor/tool-schemas.ts b/src/adapters/cursor/tool-schemas.ts index 96ad3dfa63..ff2b2aa4ea 100644 --- a/src/adapters/cursor/tool-schemas.ts +++ b/src/adapters/cursor/tool-schemas.ts @@ -10,11 +10,37 @@ export const CURSOR_EXEC_COMMAND_INPUT_SCHEMA = { tty: { type: "boolean", description: "True allocates a PTY for the command; false or omitted uses plain pipes." }, yield_time_ms: { type: "number", description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms." }, max_output_tokens: { type: "number", description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy." }, + sandbox_permissions: { + type: "string", + enum: ["use_default", "require_escalated"], + description: "Per-command sandbox override. Defaults to use_default; use require_escalated for unsandboxed execution.", + }, + justification: { + type: "string", + description: "User-facing approval question for require_escalated; omit otherwise.", + }, + prefix_rule: { + type: "array", + items: { type: "string" }, + description: "Reusable approval prefix for cmd, only with sandbox_permissions: require_escalated.", + }, + login: { + type: "boolean", + description: "True runs the shell with login semantics; false disables them. Defaults to true.", + }, }, required: ["cmd"], additionalProperties: false, } as const; +/** Cursor represents a Responses freeform tool body as one string-valued input field. */ +export const CURSOR_FREEFORM_INPUT_SCHEMA = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, +} as const; + /** * Structured single-replacement schema advertised to Cursor models in addition to the freeform * `apply_patch` tool. Cursor-trained models reliably emit exact-match replacements (the native @@ -71,6 +97,10 @@ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = { yield_time_ms: { type: "number", description: "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms." }, max_output_tokens: { type: "number", description: "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy." }, max_output_chars: { type: "number", description: "Output character budget when the Responses tool uses chars instead of tokens." }, + sandbox_permissions: { type: "string", enum: ["use_default", "require_escalated"] }, + justification: { type: "string" }, + prefix_rule: { type: "array", items: { type: "string" } }, + login: { type: "boolean" }, }, required: ["command"], } as const; @@ -78,6 +108,12 @@ export const CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA = { /** Schema advertised to Cursor for this tool (may use Cursor-preferred field names like `cmd`). */ export function cursorToolInputSchema(tool: OcxTool): unknown { + if (tool.freeform) { + if (isBareCodexShellBridgeTool(tool)) { + throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`); + } + return CURSOR_FREEFORM_INPUT_SCHEMA; + } return isBareCodexExecCommandTool(tool) ? CURSOR_EXEC_COMMAND_INPUT_SCHEMA : (tool.parameters ?? {}); } @@ -87,6 +123,12 @@ export function cursorToolInputSchema(tool: OcxTool): unknown { * treating `cmd` as canonical prevents the `cmd` → `command` rewrite Codex requires (#399). */ export function cursorToolArgNormalizeSchema(tool: OcxTool): unknown { + if (tool.freeform) { + if (isBareCodexShellBridgeTool(tool)) { + throw new Error(`freeform Cursor tools cannot use reserved shell bridge name ${tool.name}; use a namespace`); + } + return CURSOR_FREEFORM_INPUT_SCHEMA; + } if (isBareCodexShellBridgeTool(tool)) { return shellBridgeArgNormalizeSchema(tool); } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 003ff35638..2b4279721a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1528,6 +1528,17 @@ parallel/colliding identities, distinct unsafe raw JSON index literals, the maxi safe-integer boundary, invalid index types, missing/null continuations and UTF-8 byte-limit boundaries. +## Cursor executable tool schema ownership + +`src/adapters/cursor/tool-schemas.ts` owns advertised and argument-normalization +schemas; `tool-definitions.ts` remains the public facade and protobuf encoder. +Advertisement and normalization intentionally differ for shell bridges: Cursor may +emit `cmd`, while the declared Responses contract decides whether it becomes +`command`. Both paths preserve execution-control fields. Freeform tools use one +required string `input`; bare shell bridge names are rejected on the freeform path. +Namespaced tools do not acquire bare-shell behavior. Regression coverage lives in +`tests/providers/cursor/cursor-tool-definitions.test.ts`. + ## Sidecars Web search and vision sidecars run only when the main request needs that capability and a usable diff --git a/tests/providers/cursor/cursor-tool-definitions.test.ts b/tests/providers/cursor/cursor-tool-definitions.test.ts index a2bd4150fe..94b4e58022 100644 --- a/tests/providers/cursor/cursor-tool-definitions.test.ts +++ b/tests/providers/cursor/cursor-tool-definitions.test.ts @@ -7,7 +7,9 @@ import { buildCursorToolDefinitions, cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote, + CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, CURSOR_EXEC_COMMAND_INPUT_SCHEMA, + CURSOR_FREEFORM_INPUT_SCHEMA, cursorRequestAdvertisesApplyPatch, cursorRequestUsesCodeMode, isCursorCodeModeExecTool, @@ -128,6 +130,95 @@ describe("Cursor tool definitions", () => { expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(CURSOR_EXEC_COMMAND_INPUT_SCHEMA); }); + test("preserves sandbox escalation controls in shell advertisement and normalization", () => { + const advertised = CURSOR_EXEC_COMMAND_INPUT_SCHEMA.properties; + const normalized = CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA.properties; + + expect(advertised.sandbox_permissions.enum).toEqual(["use_default", "require_escalated"]); + expect(advertised.justification.type).toBe("string"); + expect(advertised.prefix_rule.items).toEqual({ type: "string" }); + expect(advertised.login.type).toBe("boolean"); + expect(normalized.sandbox_permissions.enum).toEqual(["use_default", "require_escalated"]); + expect(normalized.justification.type).toBe("string"); + expect(normalized.prefix_rule.items).toEqual({ type: "string" }); + expect(normalized.login.type).toBe("boolean"); + }); + + test("advertises and normalizes freeform tools as one required string input", () => { + // Independent wire contract: using the production constant as the expected value + // would let an incorrect constant validate both schema selection and protobuf output. + const expectedSchema = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + }; + const tool: OcxTool = { + name: "apply_patch", + description: "Apply a patch", + parameters: {}, + freeform: true, + }; + + expect(CURSOR_FREEFORM_INPUT_SCHEMA).toEqual(expectedSchema); + expect(cursorToolInputSchema(tool)).toEqual(expectedSchema); + expect(cursorToolArgNormalizeSchema(tool)).toEqual(expectedSchema); + const defs = buildCursorToolDefinitions([tool]); + expect(defs).toHaveLength(1); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(expectedSchema); + + const codeModeExec: OcxTool = { name: "exec", description: "Run JavaScript", freeform: true }; + expect(cursorToolInputSchema(codeModeExec)).toEqual(expectedSchema); + expect(cursorToolArgNormalizeSchema(codeModeExec)).toEqual(expectedSchema); + const execDefs = buildCursorToolDefinitions([codeModeExec]); + expect(execDefs).toHaveLength(1); + expect(toJson(ValueSchema, fromBinary(ValueSchema, execDefs[0]!.inputSchema))).toEqual(expectedSchema); + }); + + test("rejects freeform tools that reuse bare shell bridge names", () => { + for (const name of ["exec_command", "shell_command"]) { + const tool: OcxTool = { name, description: "Custom", parameters: {}, freeform: true }; + + expect(() => cursorToolInputSchema(tool)).toThrow(`freeform Cursor tools cannot use reserved shell bridge name ${name}`); + expect(() => cursorToolArgNormalizeSchema(tool)).toThrow(`freeform Cursor tools cannot use reserved shell bridge name ${name}`); + expect(() => buildCursorToolDefinitions([tool])).toThrow(`freeform Cursor tools cannot use reserved shell bridge name ${name}`); + } + }); + + test("preserves namespaced shell names and ordinary freeform/non-freeform contracts", () => { + const expectedFreeformSchema = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + additionalProperties: false, + }; + const namespacedFreeform: OcxTool = { + name: "exec_command", + namespace: "mcp__custom", + description: "Custom", + parameters: {}, + freeform: true, + }; + expect(cursorToolInputSchema(namespacedFreeform)).toEqual(expectedFreeformSchema); + expect(cursorToolArgNormalizeSchema(namespacedFreeform)).toEqual(expectedFreeformSchema); + const defs = buildCursorToolDefinitions([namespacedFreeform]); + expect(defs).toHaveLength(1); + expect(defs[0]?.toolName).toBe("mcp__custom__exec_command"); + expect(toJson(ValueSchema, fromBinary(ValueSchema, defs[0]!.inputSchema))).toEqual(expectedFreeformSchema); + + const ordinaryFreeform: OcxTool = { name: "apply_patch", description: "Patch", parameters: {}, freeform: true }; + expect(cursorToolInputSchema(ordinaryFreeform)).toEqual(expectedFreeformSchema); + expect(cursorToolArgNormalizeSchema(ordinaryFreeform)).toEqual(expectedFreeformSchema); + + const ordinaryFunction: OcxTool = { + name: "exec_command", + description: "Run", + parameters: { type: "object", properties: { cmd: { type: "string" } }, required: ["cmd"] }, + }; + expect(cursorToolInputSchema(ordinaryFunction)).toEqual(CURSOR_EXEC_COMMAND_INPUT_SCHEMA); + expect(cursorToolArgNormalizeSchema(ordinaryFunction)).toEqual(ordinaryFunction.parameters); + }); + test("normalizes advertised shell_command cmd args to Responses command before Codex sees them", () => { // Live #399 failure: Cursor advertisement requires `cmd`, models send `cmd`, but Codex // shell_command validates `command` → "missing field `command`". Normalization must use the @@ -154,6 +245,19 @@ describe("Cursor tool definitions", () => { expect(normalizeArgKeys({ command: "git status" }, cursorToolArgNormalizeSchema(tool))).toEqual({ command: "git status", }); + expect(normalizeArgKeys({ + cmd: "git status", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }, cursorToolArgNormalizeSchema(tool))).toEqual({ + command: "git status", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }); }); test("preserves cmd-only exec_command schemas during Responses normalization", () => { @@ -182,6 +286,19 @@ describe("Cursor tool definitions", () => { cmd: "git status", workdir: "C:/repo", }); + expect(normalizeArgKeys({ + cmd: "git fetch", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }, cursorToolArgNormalizeSchema(tool))).toEqual({ + cmd: "git fetch", + sandbox_permissions: "require_escalated", + justification: "Fetch the requested upstream ref", + prefix_rule: ["git", "fetch"], + login: false, + }); }); test("shell bridge command validation honors the schema-required command key", () => {