diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1f480bc109..715555f847 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -414,6 +414,16 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and and `desktopExecutor` integrations have separate opt-ins; `nativeLocalExec: "on"` enables the broader built-in executor and bypasses Codex approval/sandbox semantics, and legacy `unsafeAllowNativeLocalExec: true` remains equivalent only when `nativeLocalExec` is unset. +- The denial reply is a silent redirect whose wording follows the request catalog. A catalog that + carries `shell_command`/`exec_command` or a unified `exec` keeps the bridge wording; a catalog + that carries neither — an orchestrator client exposing only its own Responses tools, for example — + is redirected to the request's actual wire names, so the model is pointed at a tool that exists + rather than at an alias it cannot see. +- A recognized Cursor data-policy gate is reported with its title, the action it requires, and the + Cursor Dashboard review URL instead of a bare `failed_precondition: Error`. Recognition is limited + to the known structured detail: unknown or malformed details keep the generic Connect error, no + upstream text, button, URL, or consent action is forwarded or executed, and the failure stays + non-retryable. Reviewing and accepting a data policy remains a user action in Cursor itself. Codex-compatible shell schemas retain sandbox permissions, justification, reusable prefix rules and login mode. Freeform tools expose one required string `input` diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index fba44b2f99..c06039e467 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -298,7 +298,7 @@ export function classifyCursorError(message: string, sizeContext?: CursorSizeCon ) return "Cursor authentication failed"; // gRPC FAILED_PRECONDITION is deterministic and non-retryable (unlike UNAVAILABLE): - // the backend rejected the call because the account/plan state does not allow it — + // the backend rejected the call because account/plan or policy-consent state does not allow it — // seen live when a plan-gated model (e.g. claude-fable-5) runs on a plan without it. // Leaving it as "Cursor upstream error" (502) made clients retry it as overload. // diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 2d6385ae77..cff471ba0d 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -53,9 +53,11 @@ import { } from "./gen/agent_pb"; import { debugProviderDiagnostic } from "../../lib/debug"; import { classifyCursorError, CursorUnexpectedCancelError, isCursorAbortError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors"; +import { cursorPolicyErrorExplanation } from "./policy-error"; import { mcpArgsFromToolCall } from "./protobuf-events"; import { OCX_RESPONSES_TOOL_PROVIDER } from "./tool-definitions"; import { + cursorNativeExecRedirectHint, handleCursorNativeExec, handleCursorNativeKv, releaseCursorBlobRequestScope, @@ -209,7 +211,8 @@ export function parseConnectEndStreamError(payload: Uint8Array): Error | null { try { const parsed = JSON.parse(new TextDecoder().decode(payload)) as { error?: { code?: string; message?: string } }; if (parsed?.error) { - return new Error(`Cursor Connect error ${parsed.error.code ?? "unknown"}: ${parsed.error.message ?? "Unknown error"}`); + const explanation = cursorPolicyErrorExplanation(parsed.error); + return new Error(`Cursor Connect error ${parsed.error.code ?? "unknown"}: ${explanation ?? parsed.error.message ?? "Unknown error"}`); } return null; } catch { @@ -697,6 +700,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/src/adapters/cursor/policy-error.ts b/src/adapters/cursor/policy-error.ts new file mode 100644 index 0000000000..060ea586d9 --- /dev/null +++ b/src/adapters/cursor/policy-error.ts @@ -0,0 +1,75 @@ +import { BinaryReader, WireType } from "@bufbuild/protobuf/wire"; + +const POLICY_TITLE = "Review Data Policy"; +const POLICY_DETAIL = "You must acknowledge Claude Fable 5's data retention policy to use the model."; +const POLICY_REVIEW_URL = "https://cursor.com/dashboard/restricted_models/claude-fable-5"; +const MAX_VALUE_CHARS = 16_384; +const MAX_FIELDS = 128; + +/** + * Minimal read-only projection of Cursor's aiserver.v1.ErrorDetails: + * error=1 (MODEL_BLOCKED=58), details=2; CustomErrorDetails title=1, detail=2. + * Verified against the native CLI schema and the #4508 Connect binary response. + * Skip buttons, URLs, analytics and dashboardAction rather than interpreting them. + */ +function isFablePolicyError(bytes: Uint8Array, custom = false): boolean { + const reader = new BinaryReader(bytes); + let error: number | undefined; + let details: Uint8Array | undefined; + let title: string | undefined; + let detail: string | undefined; + let fields = 0; + while (reader.pos < reader.len) { + if (++fields > MAX_FIELDS) return false; + const [field, wire] = reader.tag(); + // Groups are not part of this proto3 projection; avoid recursive unknown-field skips. + if (wire === WireType.StartGroup || wire === WireType.EndGroup) return false; + if (!custom && field === 1) { + if (wire !== WireType.Varint || error !== undefined) return false; + error = reader.uint32(); + } else if (!custom && field === 2) { + if (wire !== WireType.LengthDelimited || details !== undefined) return false; + details = reader.bytes(); + } else if (custom && (field === 1 || field === 2)) { + if (wire !== WireType.LengthDelimited) return false; + const value = reader.bytes(); + if (value.length > 256) return false; + const text = new TextDecoder("utf-8", { fatal: true }).decode(value); + if (field === 1) { + if (title !== undefined) return false; + title = text; + } else { + if (detail !== undefined) return false; + detail = text; + } + } else { + reader.skip(wire); + } + } + return custom + ? title === POLICY_TITLE && detail === POLICY_DETAIL + : error === 58 && details !== undefined && isFablePolicyError(details, true); +} + +/** Recognize this policy gate, but never forward arbitrary upstream text or consent actions. */ +export function cursorPolicyErrorExplanation(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const envelope = error as { code?: unknown; details?: unknown }; + if (envelope.code !== "failed_precondition" || !Array.isArray(envelope.details)) return undefined; + for (const entry of envelope.details.slice(0, 8)) { + if (!entry || typeof entry !== "object") continue; + const { type, value } = entry as { type?: unknown; value?: unknown }; + if (type !== "aiserver.v1.ErrorDetails" || typeof value !== "string" + || value.length === 0 || value.length > MAX_VALUE_CHARS + || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) continue; + try { + const bytes = Buffer.from(value, "base64"); + if (bytes.toString("base64").replace(/=+$/, "") !== value.replace(/=+$/, "")) continue; + if (isFablePolicyError(bytes)) { + // Code-owned copy cannot inject credentials or alter downstream keyword classification. + return `${POLICY_TITLE}: ${POLICY_DETAIL} Review and accept using the same Cursor account at ${POLICY_REVIEW_URL}, then retry.`; + } + } catch { /* Unknown/malformed details retain the existing generic Connect error. */ } + } + return undefined; +} diff --git a/tests/providers/cursor/cursor-live-transport.test.ts b/tests/providers/cursor/cursor-live-transport.test.ts index 134fcad1c4..ce82f7d5a9 100644 --- a/tests/providers/cursor/cursor-live-transport.test.ts +++ b/tests/providers/cursor/cursor-live-transport.test.ts @@ -2,10 +2,14 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { BinaryWriter } from "@bufbuild/protobuf/wire"; import { afterEach, describe, expect, test } from "bun:test"; import { createLiveCursorTransport, CursorMissingCredentialError, parseConnectEndStreamError, resolveCursorToken } from "../../../src/adapters/cursor/live-transport"; +import { safeCursorErrorMessage } from "../../../src/adapters/cursor/cursor-errors"; +import { isRetryableCursorError } from "../../../src/adapters/cursor/transport-retry"; import { createTestTranslatorBudget } from "../../helpers/translator-budget"; import { CURSOR_EXTERNAL_ROOT_BLOB_LIMIT, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT, CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT, prepareCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { classifyError, inferHttpStatusFromAdapterMessage } from "../../../src/lib/errors"; import { estimateTokens } from "../../../src/lib/token-estimate"; import type { OcxMessage } from "../../../src/types"; import type { CursorRunRequest } from "../../../src/adapters/cursor/types"; @@ -26,6 +30,82 @@ import { import { AgentClientMessageSchema, BackgroundShellSpawnArgsSchema, ConversationStateStructureSchema, ExecServerMessageSchema, GetBlobArgsSchema, KvServerMessageSchema, type AgentRunRequest } from "../../../src/adapters/cursor/gen/agent_pb"; import type { CursorProtobufEventState } from "../../../src/adapters/cursor/protobuf-events"; +describe("Cursor Fable policy gate details (#4508)", () => { + const title = "Review Data Policy"; + const detail = "You must acknowledge Claude Fable 5's data retention policy to use the model."; + const reviewUrl = "https://cursor.com/dashboard/restricted_models/claude-fable-5"; + function binary(overrides: { title?: string; detail?: string; error?: number; extras?: Uint8Array } = {}): string { + const custom = new BinaryWriter().uint32(10).string(overrides.title ?? title) + .uint32(18).string(overrides.detail ?? detail) + .uint32(32).bool(false).uint32(40).bool(false); + if (overrides.extras) custom.raw(overrides.extras); + return Buffer.from(new BinaryWriter().uint32(8).uint32(overrides.error ?? 58) + .uint32(18).bytes(custom.finish()).uint32(24).bool(true).finish()).toString("base64"); + } + function parse(details: unknown, code = "failed_precondition", message = "Error") { + return parseConnectEndStreamError(new TextEncoder().encode(JSON.stringify({ error: { code, message, details } })))!; + } + function entry(value = binary()) { return { type: "aiserver.v1.ErrorDetails", value }; } + const fallback = "Cursor Connect error failed_precondition: Error"; + + test("binary without debug produces the review path and preserves 400/non-retryable behaviour", () => { + const error = parse([entry()]); + const message = safeCursorErrorMessage(error.message); + expect(message).toContain(title); + expect(message).toContain(detail); + expect(message).toContain(reviewUrl); + expect(message.length).toBeLessThan(500); + expect(inferHttpStatusFromAdapterMessage(message)).toBe(400); + expect(classifyError(400, "", message)).toMatchObject({ type: "invalid_request_error", code: "invalid_request_error" }); + expect(isRetryableCursorError(error)).toBe(false); + }); + + test("does not forward upstream messages, buttons, actions, URLs or debug text", () => { + const extras = new BinaryWriter().uint32(66).bytes(new TextEncoder().encode("secret-token consent-action https://untrusted.invalid")) + .uint32(82).bytes(new TextEncoder().encode("private-analytics")).finish(); + const error = parse([{ ...entry(binary({ extras })), debug: { title: "rate limit Bearer secret-token" } }], "failed_precondition", "Bearer another-secret"); + expect(error.message).toContain(reviewUrl); + for (const text of ["secret-token", "another-secret", "consent-action", "untrusted.invalid", "private-analytics", "rate limit"]) { + expect(error.message).not.toContain(text); + } + }); + + test("does not trust debug in place of the binary value", () => { + expect(parse([{ type: "aiserver.v1.ErrorDetails", debug: { error: "ERROR_MODEL_BLOCKED", details: { title, detail } } }]).message).toBe(fallback); + }); + + test("unknown type, error kind, policy text and other Connect codes retain generic behaviour", () => { + expect(parse([{ type: "other.ErrorDetails", value: binary() }]).message).toBe(fallback); + expect(parse([entry(binary({ error: 1 }))]).message).toBe(fallback); + expect(parse([entry(binary({ title: "Another policy" }))]).message).toBe(fallback); + expect(parse([entry(binary({ detail: "rate limit or quota exhausted" }))]).message).toBe(fallback); + expect(parse([entry()], "resource_exhausted").message).toBe("Cursor Connect error resource_exhausted: Error"); + expect(parse([entry()], "unauthenticated").message).toBe("Cursor Connect error unauthenticated: Error"); + }); + + test("malformed and oversized protobuf/base64 values fall back without throwing", () => { + for (const value of ["!invalid!", "", "Cg==", "A".repeat(16385), "Cg////8P", "Cw==", "AA==", "____"]) { + expect(parse([entry(value)]).message).toBe(fallback); + } + expect(parse([entry(binary({ title: "a".repeat(257) }))]).message).toBe(fallback); + expect(parse([entry(binary({ extras: new Uint8Array([0x0a, 0x00]) }))]).message).toBe(fallback); + expect(parse([entry(binary({ extras: new Uint8Array([0x0b]) }))]).message).toBe(fallback); + }); + + test("limits scanned fields and entries and skips unrecognized entries", () => { + const extras = new BinaryWriter(); + for (let i = 0; i < 129; i++) extras.uint32(80).uint32(0); + expect(parse([entry(binary({ extras: extras.finish() }))]).message).toBe(fallback); + expect(parse([null, {}, entry()]).message).toContain(reviewUrl); + expect(parse([...Array(8).fill(null), entry()]).message).toBe(fallback); + for (const details of [null, {}, "not-an-array"]) expect(parse(details).message).toBe(fallback); + }); + + test("accepts equivalent unpadded base64", () => { + expect(parse([entry(binary().replace(/=+$/, ""))]).message).toContain(reviewUrl); + }); +}); + class TransportFakeChild extends EventEmitter { readonly stdin = new PassThrough(); readonly stdout = new PassThrough(); diff --git a/tests/providers/cursor/cursor-native-exec-policy.test.ts b/tests/providers/cursor/cursor-native-exec-policy.test.ts index 24b63e28ea..4fddf2e533 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, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { create, fromBinary } from "@bufbuild/protobuf"; @@ -12,13 +12,22 @@ 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 { + cursorNativeExecRedirectHint, + handleCursorNativeExec, +} from "../../../src/adapters/cursor/native-exec"; +import { + nativeShellDisabledMessage, resetBackgroundShellStateForTests, setBackgroundShellRuntimeForTests, } from "../../../src/adapters/cursor/native-exec-shell"; @@ -384,3 +393,143 @@ 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); + }); + + // The hint only helps if the live transport actually derives it per request. Asserting that + // through LiveCursorTransport means stubbing a private method, which pins a seam rather than + // the production path; read the production path instead. Both carried contributor PRs were + // drafts whose hosted suite never ran, so nothing else proves this line exists. + test("the live transport derives the hint from each turn's visible catalog", async () => { + const { repoPath } = await import("../../helpers/repo-root"); + const source = readFileSync(repoPath("src/adapters/cursor/live-transport.ts"), "utf8"); + const assignment = source.match(/nativeExecRedirectHint:\s*cursorNativeExecRedirectHint\(([^)]*)\)/)?.[1]; + expect(assignment).toBeDefined(); + // Derived from THIS turn's visible catalog and advertised MCP tools, not from the raw request + // or a value cached across turns: a catalog that gains or loses a shell alias must re-derive. + expect(assignment).toContain("cursorVisibleTools"); + expect(assignment).toContain("mcpToolDefs"); + // Inside the per-request execContext assignment, not module or constructor scope. + const perRequest = source.indexOf("rejectNativeFileMutations: cursorRequestAdvertisesApplyPatch"); + const hint = source.indexOf("nativeExecRedirectHint: cursorNativeExecRedirectHint"); + expect(perRequest).toBeGreaterThan(-1); + expect(Math.abs(hint - perRequest)).toBeLessThan(400); + }); +});