Skip to content
Merged
11 changes: 11 additions & 0 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
cursorToolArgNormalizeSchema,
cursorToolWireName,
cursorToolsForActivePrompt,
isCursorSyntheticStructuredEditTool,
isGenericToolUseCountDemoPrompt,
requestedCursorToolUseCount,
} from "./tool-definitions";
Expand Down Expand Up @@ -540,10 +541,19 @@ class LiveCursorTransport implements CursorTransport {
this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(request, this.clientToolFinalizeGraceMs);
const cursorVisibleTools = cursorToolsForActivePrompt(request.tools, activeText, request.toolChoice);
const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, request.toolChoice);
// `request.tools` is the catalog already filtered and budgeted by request-builder. Derive
// conversion provenance only from tagged synthetic tools that also survive this final prompt
// filter; a client tool with the same wire name can never opt into conversion by collision.
const syntheticStructuredEditToolNames = new Set(
(cursorVisibleTools ?? [])
.filter(isCursorSyntheticStructuredEditTool)
.map(cursorToolWireName),
);
this.execContext = {
...this.execContext,
clientToolDefs,
rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch(request.tools, request.toolChoice),
structuredEditAvailable: syntheticStructuredEditToolNames.size > 0,
};
const toolSchemas = new Map<string, unknown>();
const cursorToolNameMap = new Map<string, string>();
Expand Down Expand Up @@ -571,6 +581,7 @@ class LiveCursorTransport implements CursorTransport {
parallelToolCalls: request.parallelToolCalls,
toolSchemas,
cursorToolNameMap,
syntheticStructuredEditToolNames,
translatorBudget: this.translatorBudget,
contextUsage,
...(prepared.estimatedInputTokens !== undefined
Expand Down
15 changes: 9 additions & 6 deletions src/adapters/cursor/native-exec-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,11 @@ const MAX_GREP_FILES = 500;
const MAX_GREP_RESULTS = 200;
const MAX_FILE_BYTES = 1_000_000;

function codexNativeMutationRefusal(operation: "write" | "delete"): string {
return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available. Use the apply_patch tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout. No file was changed.`;
function codexNativeMutationRefusal(operation: "write" | "delete", structuredEditAvailable: boolean): string {
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.";
return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available.${structuredHint} No file was changed.`;
}

const NATIVE_LOCAL_EXEC_DISABLED =
Expand Down Expand Up @@ -84,13 +87,13 @@ export function readExec(execMsg: ExecServerMessage): Uint8Array {
}
}

export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage): Uint8Array {
export function rejectWriteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): 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") }),
value: create(WriteRejectedSchema, { path, reason: codexNativeMutationRefusal("write", structuredEditAvailable) }),
},
}));
}
Expand Down Expand Up @@ -133,13 +136,13 @@ export function writeExec(execMsg: ExecServerMessage): Uint8Array {
}
}

export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage): Uint8Array {
export function rejectDeleteExecForApplyPatch(execMsg: ExecServerMessage, structuredEditAvailable = false): 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") }),
value: create(DeleteRejectedSchema, { path, reason: codexNativeMutationRefusal("delete", structuredEditAvailable) }),
},
}));
}
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/cursor/native-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export interface CursorNativeExecContext extends CursorNativeExecDeps {
unsafeAllowNativeLocalExec?: boolean;
/** apply_patch is visible for this request; Cursor-native write/delete must not bypass Codex. */
rejectNativeFileMutations?: boolean;
/** The synthetic exact-match edit tools (edit_file / multi_edit) are advertised this request. */
structuredEditAvailable?: boolean;
}

export function cursorUnsafeNativeLocalExecEnabled(input: Pick<CursorNativeExecContext, "unsafeAllowNativeLocalExec"> = {}): boolean {
Expand Down Expand Up @@ -514,8 +516,8 @@ export async function handleCursorNativeExec(execMsg: ExecServerMessage, deps: C
if (execCase === "fetchArgs") return [rejectFetchExecForPolicy(execMsg)];
}
if (execCase === "readArgs") return [readExec(execMsg)];
if (execCase === "writeArgs") return [deps.rejectNativeFileMutations ? rejectWriteExecForApplyPatch(execMsg) : writeExec(execMsg)];
if (execCase === "deleteArgs") return [deps.rejectNativeFileMutations ? rejectDeleteExecForApplyPatch(execMsg) : deleteExec(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 === "lsArgs") return [lsExec(execMsg)];
if (execCase === "grepArgs") return [grepExec(execMsg)];
if (execCase === "shellArgs") return [shellExec(execMsg)];
Expand Down
180 changes: 176 additions & 4 deletions src/adapters/cursor/protobuf-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import type { AgentServerMessage, McpArgs, ToolCall } from "./gen/agent_pb";
import { decodeCursorArgsMap } from "./arg-codec";
import { normalizeArgKeys } from "./arg-normalize";
import {
CODEX_APPLY_PATCH_TOOL,
CURSOR_MULTI_EDIT_TOOL,
cursorShellBridgeArgsValid,
cursorShellBridgeDropError,
defaultShellBridgeArgNormalizeSchema,
isCodexShellBridgeToolName,
isCursorStructuredEditToolName,
normalizeCursorWireName,
OCX_RESPONSES_TOOL_PROVIDER,
resolveShellBridgeAliasKey,
Expand Down Expand Up @@ -161,14 +164,41 @@ export interface CursorProtobufEventState {
toolSchemas?: Map<string, unknown>;
/** Cursor wire-name → original Responses/Codex tool name for this request. */
cursorToolNameMap?: Map<string, string>;
/**
* Bare names WE advertised as synthetic structured-edit tools on this request.
* See structuredEditCallIsOurs: conversion is gated on provenance, not on the name.
*/
syntheticStructuredEditToolNames?: ReadonlySet<string>;
translatorBudget?: TranslatorBudget;
}


/**
* Did WE advertise this bare tool name as a synthetic structured-edit tool on this request?
*
* Provenance, not a name test. `edit_file` / `multi_edit` are ordinary names a client or MCP
* server may legitimately expose, and `cursorStructuredEditTools` already refuses to shadow one
* that exists. Converting on the name alone would undo that refusal at the other end of the
* request: the client's own call would be silently re-emitted as `apply_patch`, or dropped with
* an error naming a conversion the user never asked for.
*
* Absent set = we advertised nothing, so nothing converts. Fail-closed in the safe direction:
* an unconverted structured call is a visible, recoverable failure; a wrongly converted one
* edits a file.
*/
function structuredEditCallIsOurs(
advertised: ReadonlySet<string> | undefined,
toolName: string,
): boolean {
return advertised?.has(toolName) === true;
}

export function createCursorProtobufEventState(options: {
clientToolNames?: Iterable<string>;
parallelToolCalls?: boolean;
toolSchemas?: Map<string, unknown>;
cursorToolNameMap?: Map<string, string>;
syntheticStructuredEditToolNames?: Iterable<string>;
contextUsage?: CursorContextUsageControls;
/**
* Request-local input estimate derived from the payload actually sent. Used only
Expand All @@ -185,6 +215,9 @@ export function createCursorProtobufEventState(options: {
openToolCalls: new Map(),
completedToolCalls: new Set(),
...(options.clientToolNames ? { clientToolNames: new Set(options.clientToolNames) } : {}),
...(options.syntheticStructuredEditToolNames
? { syntheticStructuredEditToolNames: new Set(options.syntheticStructuredEditToolNames) }
: {}),
...(options.parallelToolCalls !== undefined ? { parallelToolCalls: options.parallelToolCalls } : {}),
startedClientToolCalls: 0,
...(options.toolSchemas ? { toolSchemas: options.toolSchemas } : {}),
Expand Down Expand Up @@ -311,6 +344,117 @@ function resolveCompletedArgs(buffered: string, args: McpArgs | undefined, state
return "";
}

const PATCH_BEGIN = "*** Begin Patch";
const PATCH_END = "*** End Patch";

function firstStringArg(args: Record<string, unknown>, keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = args[key];
if (typeof value === "string") return value;
}
return undefined;
}

/** Split a replacement into patch lines, ignoring one trailing newline (line-based patch semantics). */
function patchLines(text: string): string[] {
const lines = text.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines;
Comment on lines +360 to +362

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve CRLF when converting structured edits

When the target uses CRLF line endings, splitting only on \n leaves \r in the generated removal and addition lines, and Codex apply_patch subsequently rewrites the file with LF endings; even a one-block structured edit can therefore produce a whole-file line-ending diff on Windows repositories. Detect the target's line-ending style and preserve it through conversion, or reject this structured path rather than silently normalizing the file.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}

/** One `@@` hunk replacing `oldString` with `newString`. */
function replacementHunk(oldString: string, newString: string): { hunk: string } | { error: string } {
if (oldString.length === 0) {
return {
error:
"structured edit requires a non-empty old_string to locate the replacement; for new files or insertions without existing text, call apply_patch with an `*** Add File` / context hunk or use the shell bridge",
};
}
const oldLines = patchLines(oldString);
const newLines = patchLines(newString);
// Line-based patch semantics cannot express an edit that only adds or removes the file's
// final newline, and an old/new pair that normalizes to the same lines is a silent no-op —
// reject it rather than emitting an empty hunk that apply_patch would drop.
if (oldLines.length === 0 && newLines.length === 0) {
return { error: "structured edit requires a non-empty old_string; an empty replacement is not a valid edit" };
}
if (oldLines.length === newLines.length && oldLines.every((line, i) => line === newLines[i])) {
return { error: "structured edit old_string and new_string are identical after line normalization; the replacement is a no-op and was dropped" };
}
const removed = oldLines.map(line => `-${line}`);
const added = newLines.map(line => `+${line}`);
return { hunk: ["@@", ...removed, ...added].join("\n") };
Comment on lines +384 to +386

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate exact replacements before emitting patch hunks

When old_string is duplicated or differs only in whitespace, this context-free @@ hunk does not preserve the advertised exact-match semantics: Codex apply_patch accepts ambiguous input by editing the first occurrence and can fall back to whitespace-stripped matching, potentially changing the wrong block. In translateStructuredEditCall, resolve old_string against the target file first and reject zero or multiple exact matches before constructing the patch.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}

/**
* Convert a completed Cursor structured edit call (`edit_file` / `multi_edit`) into a valid Codex
* apply_patch freeform payload (#1017). Cursor-trained models cannot emit Codex's freeform patch
* grammar, so the adapter advertises exact-match replacement tools and performs the grammar here.
* Returns `{ patch }` for a valid conversion, `{ error }` for a malformed call (which must never be
* relayed verbatim: Codex would reject it locally after the HTTP 200, the reported failure mode),
* and `undefined` for tools that are not structured edits.
*/
export type StructuredEditTranslation =
| { patch: string; error?: undefined }
| { error: string; patch?: undefined };

export function translateStructuredEditCall(
toolName: string,
argsText: string,
): StructuredEditTranslation | undefined {
if (!isCursorStructuredEditToolName(toolName)) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(argsText);
} catch {
return {
error: `${toolName} arguments were not valid JSON; the call was dropped. ${
toolName === CURSOR_MULTI_EDIT_TOOL
? "Use file_path and edits[] (each edit with old_string and new_string)."
: "Use file_path, old_string and new_string."
}`,
};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { error: `${toolName} arguments must be a JSON object; the call was dropped.` };
}
const args = parsed as Record<string, unknown>;
const path = firstStringArg(args, ["file_path", "filePath", "path", "filepath", "filename"]);
if (!path || path.trim().length === 0) {
return { error: `${toolName} is missing a non-empty file_path; the call was dropped.` };
}
const hunks: string[] = [];
const addReplacement = (record: Record<string, unknown>): StructuredEditTranslation => {
const oldString = firstStringArg(record, ["old_string", "oldString", "oldtext", "old_text"]);
const newString = firstStringArg(record, ["new_string", "newString", "newtext", "new_text"]);
if (oldString === undefined || newString === undefined) {
return { error: `${toolName} requires old_string and new_string; the call was dropped.` };
}
const hunk = replacementHunk(oldString, newString);
if ("error" in hunk) return { error: hunk.error };
return { patch: hunk.hunk as string };
};
if (toolName === CURSOR_MULTI_EDIT_TOOL) {
const edits = args.edits;
if (!Array.isArray(edits) || edits.length === 0) {
return { error: "multi_edit requires a non-empty edits array; the call was dropped." };
}
for (const edit of edits) {
if (!edit || typeof edit !== "object" || Array.isArray(edit)) {
return { error: "multi_edit edits entries must be objects with old_string and new_string; the call was dropped." };
}
const editResult = addReplacement(edit as Record<string, unknown>);
if (editResult.error !== undefined) return editResult;
hunks.push(editResult.patch);
Comment on lines +446 to +448

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Order multi-edit hunks by their source positions

When a valid multi_edit lists unique replacements in reverse file order, appending hunks in request order makes Codex apply_patch advance past the later match and then fail to find the earlier one, so the whole edit is rejected even though every replacement exists in the original file. Resolve each replacement's original offset and sort the emitted hunks by source position while preserving the documented original-file matching semantics.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}
} else {
const editResult = addReplacement(args);
if (editResult.error !== undefined) return editResult;
hunks.push(editResult.patch);
}
return { patch: [PATCH_BEGIN, `*** Update File: ${path}`, ...hunks, PATCH_END].join("\n") };
}

export function mapSyntheticMcpExecToToolEvents(
args: McpArgs,
fallbackCallId = "cursor_mcp_exec",
Expand Down Expand Up @@ -341,9 +485,20 @@ export function mapSyntheticMcpExecToToolEvents(
}
}
// Stateless fallback (no shared event state): emit a complete, self-contained tool call.
//
// No conversion happens here by design (#1036 review). Structured-edit translation is gated on
// provenance — did WE advertise this bare name on THIS request — and that record lives on the
// request state, which this branch does not have. Converting anyway would reinstate the exact
// hazard the gate exists to close: a client or MCP tool legitimately named `edit_file` would be
// rewritten into an apply_patch it never asked for. The live path always carries state
// (live-transport seeds it), so this only affects direct/unit callers.
const emittedName = responsesName;
const emittedArgs = normalizedArgs;
return [
{ type: "tool_call_start", id: callId, name: responsesName },
...(normalizedArgs.length > 2 ? [{ type: "tool_call_delta" as const, arguments: normalizedArgs }] : []),
{ type: "tool_call_start", id: callId, name: emittedName },
...(emittedArgs.length > 2
? [{ type: "tool_call_delta" as const, arguments: emittedArgs }]
: []),
{ type: "tool_call_end", id: callId },
];
}
Expand Down Expand Up @@ -385,13 +540,28 @@ function dropShellBridgeCall(state: CursorProtobufEventState, callId: string, to
return [{ type: "error", message: cursorShellBridgeDropError(toolName) }];
}

function dropStructuredEditCall(state: CursorProtobufEventState, callId: string, toolName: string, reason: string): CursorServerMessage[] {
state.openToolCalls.delete(callId);
state.translatorBudget?.closeCall(callId);
state.completedToolCalls.add(callId);
return [{ type: "error", message: `${toolName} call was not converted to apply_patch: ${reason}` }];
}

function commitToolCall(state: CursorProtobufEventState, callId: string, finalArgs: string): CursorServerMessage[] {
const open = state.openToolCalls.get(callId);
if (!open) return [];
const schema = toolSchemaForWireName(state, open.name);
if (!cursorShellBridgeArgsValid(finalArgs, open.name, schema)) {
if (isCodexShellBridgeToolName(open.name)) return dropShellBridgeCall(state, callId, open.name);
}
// Structured edit calls are converted to apply_patch here so both the interactionUpdate and the
// native-exec mcpArgs paths emit the same valid freeform payload (#1017).
const translation = structuredEditCallIsOurs(state.syntheticStructuredEditToolNames, open.name)
? translateStructuredEditCall(open.name, finalArgs)
: undefined;
if (translation?.error !== undefined) {
return dropStructuredEditCall(state, callId, open.name, translation.error);
}
if (finalArgs !== open.args) {
const previousBytes = Buffer.byteLength(open.args);
const reservation = state.translatorBudget?.reserveTransient(
Expand All @@ -402,8 +572,10 @@ function commitToolCall(state: CursorProtobufEventState, callId: string, finalAr
reservation?.commitRetained();
state.translatorBudget?.releaseRetained(previousBytes, { kind: "tool_args", callId });
}
const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: open.name }];
if (finalArgs.length > 0) out.push({ type: "tool_call_delta", arguments: finalArgs });
const emittedName = translation ? CODEX_APPLY_PATCH_TOOL : open.name;
const emittedArgs = translation ? JSON.stringify({ input: translation.patch }) : finalArgs;
const out: CursorServerMessage[] = [{ type: "tool_call_start", id: callId, name: emittedName }];
if (emittedArgs.length > 0) out.push({ type: "tool_call_delta", arguments: emittedArgs });
out.push(...endToolCall(state, callId));
return out;
}
Expand Down
Loading
Loading