fix(cursor): route MCP resource frames and gate native delete

`list_mcp_resources` / `read_mcp_resource` answered as though this
client hosted no MCP servers - a hardcoded empty catalog and
`not_found`. The same session reads those resources through `mcp://`
via `MCPManager.getServerResources` / `readServerResource`, so a Cursor
model could not see resources its own session was connected to.
`CursorExecHandlers` gained `listMcpResources`/`readMcpResource`, the
bridge answers them from the manager's live connections, and the
no-handler fallback is unchanged. A throwing lookup surfaces as an
error: an empty success claims "asked, none exist", which the model
cannot retry.

The native `delete` frame also bypassed approval. Unlike every other
frame it calls `fs.rmSync` directly rather than running a registry
tool, so no `ExtensionToolWrapper` sat in front of it, and
`allowNativeDelete` only answers whether a mutating tool was granted -
not whether the user's policy allows the call. It now resolves the
write tier against the session's approval mode and per-tool policies,
failing closed on `always-ask`, which this channel cannot prompt in.

(cherry picked from commit 44d36d1e8d35b0038d00e0202454d68fbcc53bce)
This commit is contained in:
Diogo Soares Rodrigues
2026-07-27 13:23:33 -03:00
committed by can1357
parent a363f95009
commit 0601ee7324
8 changed files with 431 additions and 10 deletions
+1
View File
@@ -78,6 +78,7 @@
### Fixed
- Fixed four Cursor exec frames answering with a result whose oneof was never set. In proto3 that is not an empty result — the server reads it as "the tool ran and produced nothing", indistinguishable from real success. `listMcpResourcesExecResult`, `readMcpResourceExecResult`, `recordScreenResult` and `computerUseResult` now send `ListMcpResourcesSuccess{resources: []}`, `ReadMcpResourceNotFound{uri}`, `RecordScreenFailure` and `ComputerUseError` respectively.
- The MCP resource frames now answer from the host instead of a fixed verdict. `CursorExecHandlers` gained `listMcpResources`/`readMcpResource`, so a host holding live MCP connections advertises them; the empty catalog and `not_found` above remain the answer when no handler is supplied. A handler that throws surfaces as `ListMcpResourcesError`/`ReadMcpResourceError` rather than collapsing into "none exist", which the model cannot retry.
- Fixed Cursor `connect_scm` calls losing their repository and settling on a fabricated verdict. The target rides in the `ConnectScmArgs.target` oneof, so reading a flat `github` property always saw `undefined`; and the authoritative `success`/`error`/`rejected` result only arrives on the completion frame, so answering at the announcement persisted a fixed failure for every call — including the ones the server went on to accept. The block now opens on the start frame and settles from the completion's decoded result.
- Fixed interleaved Cursor tool calls corrupting each other. The stream decoder tracked a single "current" block and settled it on any `toolCallCompleted`, ignoring the envelope's `call_id`: a completion for one call closed whichever block happened to be open and paired it with the wrong result, and `start A, start B` orphaned A entirely so its own completion settled B while A was never paired — which strips the whole interaction from every rebuilt transcript. Open blocks are now retained per envelope `call_id`, and end-of-stream closes all of them rather than only the last.
- Fixed a Cursor `search_conversations` call leaving no transcript block. The frame is answered from a fixed verdict, so nothing downstream pairs a result for it, and an unpaired call takes its whole interaction out of every rebuilt transcript.
+79 -10
View File
@@ -61,6 +61,9 @@ import {
GrepUnionResultSchema,
KvClientMessageSchema,
type KvServerMessage,
ListMcpResourcesErrorSchema,
type ListMcpResourcesExecResult,
ListMcpResourcesExecResult_McpResourceSchema,
ListMcpResourcesExecResultSchema,
ListMcpResourcesSuccessSchema,
type LsDirectoryTreeNode,
@@ -82,8 +85,11 @@ import {
McpToolResultContentItemSchema,
ModelDetailsSchema,
ReadErrorSchema,
ReadMcpResourceErrorSchema,
type ReadMcpResourceExecResult,
ReadMcpResourceExecResultSchema,
ReadMcpResourceNotFoundSchema,
ReadMcpResourceSuccessSchema,
ReadRejectedSchema,
ReadResultSchema,
ReadSuccessSchema,
@@ -1535,21 +1541,84 @@ async function handleExecServerMessage(
return;
}
case "listMcpResourcesExecArgs": {
// This client hosts no MCP servers, so it exposes no resources. An
// unset-oneof `ListMcpResourcesExecResult` would read as "the call
// produced nothing"; an explicit empty success says "asked, none exist".
const execResult = create(ListMcpResourcesExecResultSchema, {
result: { case: "success", value: create(ListMcpResourcesSuccessSchema, { resources: [] }) },
});
// A host holding live MCP connections answers from them; without a
// handler the honest answer is an explicit empty success. An
// unset-oneof result would read as "the call produced nothing".
const args = execMsg.message.value;
let execResult: ListMcpResourcesExecResult;
try {
const resources = (await execHandlers?.listMcpResources?.({ server: args.server })) ?? [];
execResult = create(ListMcpResourcesExecResultSchema, {
result: {
case: "success",
value: create(ListMcpResourcesSuccessSchema, {
resources: resources.map(resource =>
create(ListMcpResourcesExecResult_McpResourceSchema, {
uri: resource.uri,
name: resource.name,
description: resource.description,
mimeType: resource.mimeType,
server: resource.server,
}),
),
}),
},
});
} catch (error) {
execResult = create(ListMcpResourcesExecResultSchema, {
result: {
case: "error",
value: create(ListMcpResourcesErrorSchema, {
error: error instanceof Error ? error.message : String(error),
}),
},
});
}
sendExecClientMessage(h2Request, execMsg, "listMcpResourcesExecResult", execResult);
return;
}
case "readMcpResourceExecArgs": {
const args = execMsg.message.value;
// No resources are advertised, so every uri is genuinely not found.
const execResult = create(ReadMcpResourceExecResultSchema, {
result: { case: "notFound", value: create(ReadMcpResourceNotFoundSchema, { uri: args.uri }) },
});
let execResult: ReadMcpResourceExecResult;
try {
// `null` is the handler's "no such server or uri", which is exactly
// `not_found`; a throw is a real failure and must not masquerade as
// a missing resource.
const content = await execHandlers?.readMcpResource?.({ server: args.server, uri: args.uri });
execResult = content
? create(ReadMcpResourceExecResultSchema, {
result: {
case: "success",
value: create(ReadMcpResourceSuccessSchema, {
uri: content.uri,
name: content.name,
description: content.description,
mimeType: content.mimeType,
// The wire's content oneof carries one of the two; text
// wins when a host supplies both.
content:
content.text !== undefined
? { case: "text", value: content.text }
: content.blob !== undefined
? { case: "blob", value: content.blob }
: { case: undefined },
}),
},
})
: create(ReadMcpResourceExecResultSchema, {
result: { case: "notFound", value: create(ReadMcpResourceNotFoundSchema, { uri: args.uri }) },
});
} catch (error) {
execResult = create(ReadMcpResourceExecResultSchema, {
result: {
case: "error",
value: create(ReadMcpResourceErrorSchema, {
uri: args.uri,
error: error instanceof Error ? error.message : String(error),
}),
},
});
}
sendExecClientMessage(h2Request, execMsg, "readMcpResourceExecResult", execResult);
return;
}
+36
View File
@@ -1018,6 +1018,31 @@ export interface CursorPiCall<TArgs> {
toolCallId: string;
}
/** One resource a host's MCP servers advertise. */
export interface CursorMcpResource {
uri: string;
name?: string;
description?: string;
mimeType?: string;
/** The server advertising it; Cursor addresses reads by this name. */
server: string;
}
/**
* The content of one resource read.
*
* `text` and `blob` are the wire's content oneof: exactly one is sent, with
* `text` winning when a host supplies both.
*/
export interface CursorMcpResourceContent {
uri: string;
name?: string;
description?: string;
mimeType?: string;
text?: string;
blob?: Uint8Array;
}
export interface CursorExecHandlers {
read?: (args: ReadArgs) => Promise<CursorExecHandlerResult<ReadResult>>;
ls?: (args: LsArgs) => Promise<CursorExecHandlerResult<LsResult>>;
@@ -1043,6 +1068,17 @@ export interface CursorExecHandlers {
piGrep?: (call: CursorPiCall<PiGrepExecArgs>) => Promise<CursorExecHandlerResult<PiGrepExecResult>>;
piFind?: (call: CursorPiCall<PiFindExecArgs>) => Promise<CursorExecHandlerResult<PiFindExecResult>>;
piLs?: (call: CursorPiCall<PiLsExecArgs>) => Promise<CursorExecHandlerResult<PiLsExecResult>>;
/**
* The resources the host's MCP servers advertise, optionally filtered to one
* server. Without a handler the provider answers an empty catalog, which
* hides resources a host is in fact holding live connections to.
*/
listMcpResources?: (args: { server?: string }) => Promise<CursorMcpResource[]>;
/**
* Read one resource. `null` means the server or uri is genuinely unknown,
* which the provider answers as `not_found`; throwing surfaces as `error`.
*/
readMcpResource?: (args: { server: string; uri: string }) => Promise<CursorMcpResourceContent | null>;
/** Mirror Cursor's server-owned todo list into local session state. */
todoSync?: CursorTodoSyncHandler;
onToolResult?: CursorToolResultHandler;
+117
View File
@@ -518,6 +518,123 @@ describe("Cursor modern exec frames: no answer carries an unset oneof", () => {
});
});
describe("Cursor MCP resource frames answer from the host's servers", () => {
it("returns the resources the host advertises, with their server names", async () => {
// The empty catalog above is the no-handler fallback. A host holding live
// MCP connections must answer from them, or its resources are invisible
// to Cursor even though the same session is connected to the servers.
const { frames } = await dispatchExec(
buildExecMessage({ case: "listMcpResourcesExecArgs", value: create(ListMcpResourcesExecArgsSchema, {}) }),
{
execHandlers: {
listMcpResources: async () => [
{ uri: "docs://readme", name: "README", mimeType: "text/markdown", server: "docs" },
],
},
},
);
const answer = soleResult(frames);
if (answer.case !== "listMcpResourcesExecResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`);
expect(answer.value.result.value.resources).toHaveLength(1);
const [resource] = answer.value.result.value.resources;
expect(resource.uri).toBe("docs://readme");
// Cursor addresses the follow-up read by this name.
expect(resource.server).toBe("docs");
expect(resource.mimeType).toBe("text/markdown");
});
it("passes the frame's server filter through to the host", async () => {
let sawFilter: string | undefined;
await dispatchExec(
buildExecMessage({
case: "listMcpResourcesExecArgs",
value: create(ListMcpResourcesExecArgsSchema, { server: "issues" }),
}),
{
execHandlers: {
listMcpResources: async ({ server }) => {
sawFilter = server;
return [];
},
},
},
);
expect(sawFilter).toBe("issues");
});
it("answers a read with the host's text content", async () => {
const { frames } = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://readme" }),
}),
{
execHandlers: {
readMcpResource: async ({ uri }) => ({ uri, mimeType: "text/markdown", text: "# Title" }),
},
},
);
const answer = soleResult(frames);
if (answer.case !== "readMcpResourceExecResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "success") throw new Error(`got ${answer.value.result.case}`);
expect(answer.value.result.value.content).toEqual({ case: "text", value: "# Title" });
});
it("distinguishes a missing resource from a failing host", async () => {
// `null` is "no such server or uri", which is `not_found`. A throw is a
// real failure and must not masquerade as a missing resource — the model
// would retry a different uri instead of surfacing the fault.
const missing = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://gone" }),
}),
{ execHandlers: { readMcpResource: async () => null } },
);
const missingAnswer = soleResult(missing.frames);
if (missingAnswer.case !== "readMcpResourceExecResult") throw new Error(`got ${missingAnswer.case}`);
expect(missingAnswer.value.result.case).toBe("notFound");
const broken = await dispatchExec(
buildExecMessage({
case: "readMcpResourceExecArgs",
value: create(ReadMcpResourceExecArgsSchema, { server: "docs", uri: "docs://readme" }),
}),
{
execHandlers: {
readMcpResource: async () => {
throw new Error("server disconnected");
},
},
},
);
const brokenAnswer = soleResult(broken.frames);
if (brokenAnswer.case !== "readMcpResourceExecResult") throw new Error(`got ${brokenAnswer.case}`);
if (brokenAnswer.value.result.case !== "error") throw new Error(`got ${brokenAnswer.value.result.case}`);
expect(brokenAnswer.value.result.value.error).toContain("server disconnected");
});
it("reports a failing list as an error, not an empty catalog", async () => {
// An empty success says "asked, none exist" — a lie when the lookup
// failed, and one the model cannot retry.
const { frames } = await dispatchExec(
buildExecMessage({ case: "listMcpResourcesExecArgs", value: create(ListMcpResourcesExecArgsSchema, {}) }),
{
execHandlers: {
listMcpResources: async () => {
throw new Error("registry unavailable");
},
},
},
);
const answer = soleResult(frames);
if (answer.case !== "listMcpResourcesExecResult") throw new Error(`got ${answer.case}`);
if (answer.value.result.case !== "error") throw new Error(`got ${answer.value.result.case}`);
expect(answer.value.result.value.error).toContain("registry unavailable");
});
});
describe("Cursor modern exec frames: status and precheck answers", () => {
it("reports NOT_FOUND for force-background requests, since nothing runs in the background", async () => {
const shell = await dispatchExec(
+2
View File
@@ -143,6 +143,8 @@
- Fixed `pi_bash` killing commands that explicitly asked for no deadline. `timeout` is `optional int32` and `bash` documents `0` as "disables the command deadline", but a truthiness check folded a supplied `0` into unset, applying the 300s default instead. A present `0` now passes through; negatives, which have no local meaning and would otherwise clamp to the 1s floor, still fall back to the default.
- Fixed the Cursor exec bridge granting `edit` and `grep` to sessions that withheld them. Both bridge-only tools are constructed rather than looked up, and `executeTool` prefers a constructed override over the registry, so a restricted tool set (`toolNames` without them, or `restrictToolNames`) still got a working `pi_edit`/`pi_grep` — native frames arrive regardless of the advertised catalog. Both are now gated on the session having actually granted the tool, matching the `delete` frame's existing check (issue #5680).
- Fixed Cursor advisor bridge tools bypassing approval settings. The advisor's `pi_edit`/`pi_grep` instances are approval-wrapped, but the wrapper reads `tools.approvalMode`, per-tool `tools.approval.<tool>` policies and `autoApprove` only from the execute-time tool context — which the advisor bridge never supplied, so every native advisor frame resolved as `yolo` with empty policies and ran past a configured `ask` or `deny`. Advisors now receive the same context store as the primary bridge.
- Fixed Cursor's `list_mcp_resources`/`read_mcp_resource` frames answering as though the client hosted no MCP servers. The bridge hardcoded an empty catalog and `not_found`, so resources from servers the session held live connections to were invisible to the model even while the same session read them through `mcp://`. Both frames now answer from the session's `MCPManager`; a lookup failure surfaces as an error rather than an empty catalog, which would read as "asked, none exist".
- Fixed the Cursor native `delete` frame bypassing approval settings. Unlike every other frame it removes the file directly instead of running a registry tool, so no approval wrapper sat in front of it — `allowNativeDelete` answers whether a mutating tool was granted, which is a different question from whether the user's policy allows the call. A configured `tools.approval.delete: deny`, or an `always-ask` session that this channel cannot prompt in, now refuses the frame and keeps the file.
## [17.1.5] - 2026-07-27
+91
View File
@@ -9,6 +9,8 @@ import type {
} from "@oh-my-pi/pi-agent-core";
import type {
CursorMcpCall,
CursorMcpResource,
CursorMcpResourceContent,
CursorShellStreamCallbacks,
CursorTodoSnapshot,
CursorExecHandlers as ICursorExecHandlers,
@@ -23,6 +25,9 @@ import {
piTimeout,
} from "@oh-my-pi/pi-ai/providers/cursor/exec-modern";
import { sanitizeText } from "@oh-my-pi/pi-utils";
import type { MCPResourceReadResult } from "./mcp/types";
import type { ApprovalMode } from "./tools/approval";
import { resolveApproval } from "./tools/approval";
import { resolveToCwd } from "./tools/path-utils";
import type { TodoPhase, TodoStatus } from "./tools/todo";
@@ -79,6 +84,20 @@ interface CursorExecBridgeOptions {
* goes through.
*/
createGrepTool?(options: { context?: number; totalMatchLimit?: number }): CursorBridgeTool | undefined;
/**
* The session's live MCP connections, for Cursor's resource frames.
*
* `list_mcp_resources` / `read_mcp_resource` ask what this client's servers
* advertise. Without this the bridge answers an empty catalog and
* `not_found`, hiding resources the session is in fact connected to.
*/
mcpResources?: {
serverNames(): string[];
getServerResources(
name: string,
): { resources: { uri: string; name?: string; description?: string; mimeType?: string }[] } | undefined;
readServerResource(name: string, uri: string): Promise<MCPResourceReadResult | undefined>;
};
}
function createToolResultMessage(
@@ -171,6 +190,31 @@ async function executeDelete(options: CursorExecBridgeOptions, pathArg: string,
return createToolResultMessage(toolCallId, toolName, result, true);
}
// Unlike every other frame, this one mutates the filesystem directly instead
// of running a registry tool, so no approval wrapper sits in front of it.
// `allowNativeDelete` answers "was a mutating tool granted", which is a
// different question from "does the user's policy allow this call" — without
// this, a configured `deny` or an `always-ask` session still lost the file.
// `write` is the tier a file removal belongs to.
const context = options.getToolContext?.();
const settings = context?.settings;
const approvalMode: ApprovalMode =
context?.autoApprove === true ? "yolo" : (settings?.get("tools.approvalMode") ?? "yolo");
const approval = resolveApproval(
{ name: toolName, approval: "write" },
{ path: pathArg },
approvalMode,
(settings?.get("tools.approval") ?? {}) as Record<string, unknown>,
);
if (approval.policy !== "allow") {
const detail =
approval.policy === "deny"
? `Tool "${toolName}" is blocked by user policy.`
: `Tool "${toolName}" requires approval, which this channel cannot request.`;
const result = buildToolErrorResult(detail);
return createToolResultMessage(toolCallId, toolName, result, true);
}
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } });
const absolutePath = resolveToCwd(pathArg, options.getCwd?.() ?? options.cwd);
@@ -544,6 +588,53 @@ export class CursorExecHandlers implements ICursorExecHandlers {
return await executeTool(this.options, "read", call.toolCallId, { path: piLsPath(call.args.path) });
}
/**
* The resources this client's MCP servers advertise.
*
* Cursor addresses a later read by `server`, so every entry carries the name
* it came from. An absent `server` filter means "all of them".
*/
async listMcpResources({ server }: { server?: string }): Promise<CursorMcpResource[]> {
const mcp = this.options.mcpResources;
if (!mcp) return [];
const names = server ? [server] : mcp.serverNames();
const listed: CursorMcpResource[] = [];
for (const name of names) {
for (const resource of mcp.getServerResources(name)?.resources ?? []) {
listed.push({
uri: resource.uri,
name: resource.name,
description: resource.description,
mimeType: resource.mimeType,
server: name,
});
}
}
return listed;
}
/**
* Read one resource, or `null` when the server or uri is unknown.
*
* MCP returns a list of content items; the wire carries exactly one text or
* blob. Text items are joined, since a multi-part text resource is one
* document; otherwise the first blob stands in. `blob` arrives base64 and
* the wire wants bytes.
*/
async readMcpResource({ server, uri }: { server: string; uri: string }): Promise<CursorMcpResourceContent | null> {
const mcp = this.options.mcpResources;
if (!mcp) return null;
const read = await mcp.readServerResource(server, uri);
if (!read) return null;
const texts = read.contents.filter(item => item.text !== undefined).map(item => item.text as string);
if (texts.length > 0) {
return { uri, mimeType: read.contents[0]?.mimeType, text: texts.join("\n") };
}
const blob = read.contents.find(item => item.blob !== undefined)?.blob;
if (blob === undefined) return null;
return { uri, mimeType: read.contents[0]?.mimeType, blob: Buffer.from(blob, "base64") };
}
/**
* Settle a completed native Cursor todo call, mirroring its list when the
* server supplied an authoritative one.
+7
View File
@@ -2656,6 +2656,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
tools: toolRegistry,
getExecutableTool: resolveDeviceTool,
getToolContext: () => toolContextStore.getContext(),
// Cursor's resource frames ask what THIS client's servers advertise;
// only live connections have any.
mcpResources: mcpManager && {
serverNames: () => mcpManager.getConnectedServers(),
getServerResources: name => mcpManager.getServerResources(name),
readServerResource: (name, uri) => mcpManager.readServerResource(name, uri),
},
emitEvent: event => cursorEventEmitter?.(event),
getTodoPhases: () => session.getTodoPhases(),
setTodoPhases: phases => session.setTodoPhases(phases),
@@ -607,6 +607,62 @@ describe("CursorExecHandlers mounted tool bridge", () => {
expect(executed).toBe(false);
expect(result.content.find(block => block.type === "text")?.text).toContain("blocked by user policy");
});
it("lists resources from the session's live MCP servers", async () => {
// The provider used to answer an empty catalog unconditionally, hiding
// resources the session holds live connections to. Every entry must
// carry the server name, since that is how Cursor addresses the read.
const handlers = new CursorExecHandlers({
cwd: ".",
tools: new Map(),
mcpResources: {
serverNames: () => ["docs", "issues"],
getServerResources: name =>
name === "docs"
? { resources: [{ uri: "docs://readme", name: "README", mimeType: "text/markdown" }] }
: { resources: [{ uri: "issues://open" }] },
readServerResource: async () => undefined,
},
});
expect(await handlers.listMcpResources({})).toEqual([
{ uri: "docs://readme", name: "README", description: undefined, mimeType: "text/markdown", server: "docs" },
{ uri: "issues://open", name: undefined, description: undefined, mimeType: undefined, server: "issues" },
]);
// A server filter narrows to that server alone.
expect((await handlers.listMcpResources({ server: "issues" })).map(r => r.uri)).toEqual(["issues://open"]);
});
it("reads a resource and decodes a blob payload into wire bytes", async () => {
// MCP hands back a list of content items with base64 blobs; the wire
// carries one text or one byte payload.
const handlers = new CursorExecHandlers({
cwd: ".",
tools: new Map(),
mcpResources: {
serverNames: () => ["files"],
getServerResources: () => undefined,
readServerResource: async (name, uri) =>
name === "files" && uri === "files://logo"
? { contents: [{ uri, mimeType: "image/png", blob: Buffer.from("PNG").toString("base64") }] }
: undefined,
},
});
const read = await handlers.readMcpResource({ server: "files", uri: "files://logo" });
expect(read?.mimeType).toBe("image/png");
expect(read?.blob && Buffer.from(read.blob).toString()).toBe("PNG");
// An unknown uri is genuinely not found, not an error.
expect(await handlers.readMcpResource({ server: "files", uri: "files://missing" })).toBeNull();
});
it("answers nothing when the session has no MCP manager", async () => {
// A host without MCP must still answer truthfully rather than throwing:
// an empty catalog and `not_found` are the honest responses.
const handlers = new CursorExecHandlers({ cwd: ".", tools: new Map() });
expect(await handlers.listMcpResources({})).toEqual([]);
expect(await handlers.readMcpResource({ server: "docs", uri: "docs://x" })).toBeNull();
});
});
function cursorAssistantMessage(): AssistantMessage {
@@ -830,6 +886,48 @@ describe("CursorExecHandlers native delete gating (issue #5680)", () => {
expect(await Bun.file(originalTarget).exists()).toBe(true);
expect(await Bun.file(movedTarget).exists()).toBe(false);
});
it("refuses a native delete the user's policy blocks", async () => {
// `allowNativeDelete` answers "was a mutating tool granted", not "does
// policy allow this call". The frame removes the file with `fs.rmSync`
// instead of running a registry tool, so no approval wrapper sits in
// front of it — a configured `deny` still lost the file.
const target = path.join(cwd, "protected.txt");
await Bun.write(target, "keep me\n");
const settings = Settings.isolated({ "tools.approval": { delete: "deny" } });
const handlers = new CursorExecHandlers({
cwd,
tools: new Map(),
allowNativeDelete: true,
getToolContext: () => ({ settings }) as AgentToolContext,
});
const result = await handlers.delete(
create(DeleteArgsSchema, { toolCallId: "call-deny", path: "protected.txt" }),
);
expect(result.isError).toBe(true);
expect(await Bun.file(target).exists()).toBe(true);
});
it("refuses a native delete in always-ask mode, which has no prompt channel", async () => {
// The exec channel cannot raise an interactive approval, so a mode that
// demands one must fail closed rather than silently auto-approving.
const target = path.join(cwd, "asked.txt");
await Bun.write(target, "keep me\n");
const settings = Settings.isolated({ "tools.approvalMode": "always-ask" });
const handlers = new CursorExecHandlers({
cwd,
tools: new Map(),
allowNativeDelete: true,
getToolContext: () => ({ settings }) as AgentToolContext,
});
const result = await handlers.delete(create(DeleteArgsSchema, { toolCallId: "call-ask", path: "asked.txt" }));
expect(result.isError).toBe(true);
expect(await Bun.file(target).exists()).toBe(true);
});
});
// The Pi frames (`ExecServerMessage` 45-51) are a separate wire family from the