Files
oh-my-pi/packages/coding-agent/test/cursor-exec.test.ts
T
roboomp 8b0402b32c fix(cursor): gated mounted device execution through approval
Built-in xd:// devices are mounted before the SDK wraps registry tools in ExtensionToolWrapper, so Cursor executed them via tool.execute() without the deny/prompt approval gate that write xd:// enforces. Wrap unwrapped devices in the Cursor resolver, skipping already-wrapped dynamic mounts.

Fixes #5650
2026-07-16 03:31:13 +00:00

194 lines
6.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { create } from "@bufbuild/protobuf";
import type { AgentEvent, AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { ReadArgsSchema, ShellArgsSchema } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { CursorExecHandlers } from "@oh-my-pi/pi-coding-agent/cursor";
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { ExtensionToolWrapper } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
import { GrepTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
function createTestSession(cwd: string, overrides: Partial<ToolSession> = {}): ToolSession {
return {
cwd,
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
...overrides,
};
}
describe("CursorExecHandlers.grep bridge", () => {
let cwd: string;
let searchTool: GrepTool;
let handlers: CursorExecHandlers;
beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-exec-test-"));
await Bun.write(path.join(cwd, "sample.txt"), "Hello World\nhello world\n");
searchTool = new GrepTool(createTestSession(cwd));
handlers = new CursorExecHandlers({
cwd,
tools: new Map([["grep", searchTool as any]]),
});
});
afterEach(async () => {
await removeWithRetries(cwd);
});
it("maps caseInsensitive parameter correctly through the grep bridge", async () => {
// 1. By default/omitted caseInsensitive, should be case-sensitive (match count 1 for "hello")
const defaultResult = await handlers.grep({
toolCallId: "call-1",
path: cwd,
pattern: "hello",
} as any);
expect((defaultResult.details as { matchCount?: number } | undefined)?.matchCount).toBe(1);
// 2. If caseInsensitive: true, should be case-insensitive (match count 2 for "hello")
const insensitiveResult = await handlers.grep({
toolCallId: "call-2",
path: cwd,
pattern: "hello",
caseInsensitive: true,
} as any);
expect((insensitiveResult.details as { matchCount?: number } | undefined)?.matchCount).toBe(2);
// 3. If caseInsensitive: false, should be case-sensitive (match count 1 for "hello")
const sensitiveResult = await handlers.grep({
toolCallId: "call-3",
path: cwd,
pattern: "hello",
caseInsensitive: false,
} as any);
expect((sensitiveResult.details as { matchCount?: number } | undefined)?.matchCount).toBe(1);
});
});
describe("CursorExecHandlers error results", () => {
const rewrittenErrorTool = (name: string): AgentTool => ({
name,
label: name,
description: "returns a rewritten tool failure",
parameters: type({}),
execute: async () => ({
content: [{ type: "text", text: "Enriched recovery guidance" }],
details: { enriched: true },
isError: true,
}),
});
it("propagates returned isError through the standard exec bridge", async () => {
const events: AgentEvent[] = [];
const handlers = new CursorExecHandlers({
cwd: ".",
tools: new Map([["read", rewrittenErrorTool("read")]]),
emitEvent: event => events.push(event),
});
const result = await handlers.read(create(ReadArgsSchema, { toolCallId: "call-read", path: "ignored" }));
expect(result.isError).toBe(true);
expect(result.content).toEqual([{ type: "text", text: "Enriched recovery guidance" }]);
const end = events.find(event => event.type === "tool_execution_end");
expect(end?.isError).toBe(true);
});
it("propagates returned isError through the shell stream bridge", async () => {
const events: AgentEvent[] = [];
const stdout: string[] = [];
const handlers = new CursorExecHandlers({
cwd: ".",
tools: new Map([["bash", rewrittenErrorTool("bash")]]),
emitEvent: event => events.push(event),
});
const result = await handlers.shellStream(
create(ShellArgsSchema, { toolCallId: "call-shell", command: "ignored" }),
{
onStdout: data => stdout.push(data),
onStderr: () => {},
},
);
expect(result.isError).toBe(true);
expect(result.content).toEqual([{ type: "text", text: "Enriched recovery guidance" }]);
expect(stdout).toEqual(["Enriched recovery guidance"]);
const end = events.find(event => event.type === "tool_execution_end");
expect(end?.isError).toBe(true);
});
});
describe("CursorExecHandlers mounted tool bridge", () => {
it("executes MCP tools resolved from the xd:// registry", async () => {
const mountedTool: AgentTool = {
name: "mcp__fixture_report",
label: "Fixture Report",
description: "reports a fixture result",
parameters: type({}),
async execute() {
return { content: [{ type: "text", text: "reported" }], details: {} };
},
};
const handlers = new CursorExecHandlers({
cwd: ".",
tools: new Map(),
getTool: name => (name === mountedTool.name ? mountedTool : undefined),
});
const result = await handlers.mcp({
name: mountedTool.name,
providerIdentifier: "pi-agent",
toolName: mountedTool.name,
toolCallId: "call-mounted",
args: {},
rawArgs: {},
});
expect(result.isError).toBe(false);
expect(result.content).toEqual([{ type: "text", text: "reported" }]);
});
it("routes wrapped mounted devices through the approval gate", async () => {
let executed = false;
const device: AgentTool = {
name: "ast_edit",
label: "AST Edit",
description: "structural edit device",
parameters: type({}),
async execute() {
executed = true;
return { content: [{ type: "text", text: "edited" }], details: {} };
},
};
// The deny path throws inside resolveApproval before the runner is touched,
// so a bare runner stub suffices to prove the gate runs.
const wrapped = new ExtensionToolWrapper(device, {} as unknown as ExtensionRunner);
const settings = Settings.isolated({ "tools.approval": { ast_edit: "deny" } });
const handlers = new CursorExecHandlers({
cwd: ".",
tools: new Map(),
getTool: name => (name === device.name ? (wrapped as unknown as AgentTool) : undefined),
getToolContext: () => ({ settings }) as AgentToolContext,
});
const result = await handlers.mcp({
name: device.name,
providerIdentifier: "pi-agent",
toolName: device.name,
toolCallId: "call-denied",
args: {},
rawArgs: {},
});
expect(result.isError).toBe(true);
expect(executed).toBe(false);
expect(result.content.find(block => block.type === "text")?.text).toContain("blocked by user policy");
});
});