Files
oh-my-pi/packages/coding-agent/test/agent-session-acp-permission.test.ts
T
2026-05-13 06:00:45 +02:00

310 lines
12 KiB
TypeScript

/**
* Tests for the ACP permission gate in AgentSession.
*
* Verifies that sensitive tools (bash, edit, write, ast_edit, delete, move) are gated behind
* `ClientBridge.requestPermission` when a bridge is set, and that allow/reject
* decisions are cached appropriately for allow_always / reject_always.
*/
import { afterEach, beforeEach, expect, it, spyOn } from "bun:test";
import * as path from "node:path";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { getBundledModel } from "@oh-my-pi/pi-ai";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import type {
ClientBridge,
ClientBridgePermissionOutcome,
ClientBridgePermissionToolCall,
} from "@oh-my-pi/pi-coding-agent/session/client-bridge";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
class MockAssistantStream extends AssistantMessageEventStream {}
// ---------------------------------------------------------------------------
// Shared setup
// ---------------------------------------------------------------------------
let tempDir: TempDir;
let authStorage: AuthStorage | undefined;
let session: AgentSession;
/** Fake bash tool that records execute calls. */
function makeFakeTool(name: string): AgentTool & { executeCalls: number } {
const tool = {
name,
label: name,
description: `Fake ${name}`,
parameters: Type.Object({ command: Type.Optional(Type.String()) }),
executeCalls: 0,
async execute() {
tool.executeCalls++;
return { content: [{ type: "text" as const, text: "ok" }] };
},
};
return tool;
}
/** Build a minimal ClientBridge whose requestPermission resolves to the given outcome. */
function makeBridge(outcome: ClientBridgePermissionOutcome): ClientBridge {
return {
capabilities: { requestPermission: true },
async requestPermission(_toolCall, _options, _signal) {
return outcome;
},
};
}
async function createSession(tools: AgentTool[], bridge?: ClientBridge): Promise<AgentSession> {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
const settings = Settings.isolated({ "compaction.enabled": false });
const sessionManager = SessionManager.inMemory(tempDir.path());
const registry = new ModelRegistry(authStorage!, path.join(tempDir.path(), "models.yml"));
const agent = new Agent({
getApiKey: () => "test-key",
initialState: {
model,
systemPrompt: ["Test"],
tools,
messages: [],
},
convertToLlm,
streamFn: () => new MockAssistantStream(),
});
const sess = new AgentSession({
agent,
sessionManager,
settings,
modelRegistry: registry,
toolRegistry: new Map(tools.map(t => [t.name, t])),
});
if (bridge) sess.setClientBridge(bridge);
return sess;
}
beforeEach(async () => {
tempDir = TempDir.createSync("@pi-acp-permission-test-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
});
afterEach(async () => {
await session?.dispose();
authStorage?.close();
authStorage = undefined;
tempDir.removeSync();
});
// ---------------------------------------------------------------------------
// 1. Allow once: bridge called once, underlying execute called once
// ---------------------------------------------------------------------------
it("allow_once: calls bridge once and executes the underlying tool", async () => {
const bashTool = makeFakeTool("bash");
const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" });
const permissionSpy = spyOn(bridge, "requestPermission");
session = await createSession([bashTool], bridge);
await session.setActiveToolsByName(["bash"]);
// Get the wrapped tool from the agent's active set.
const wrappedBash = session.agent.state.tools.find(t => t.name === "bash");
expect(wrappedBash).toBeDefined();
await wrappedBash!.execute("call-1", { command: "echo hi" }, undefined, undefined as never, undefined as never);
expect(permissionSpy).toHaveBeenCalledTimes(1);
expect(bashTool.executeCalls).toBe(1);
});
it("delete and move tools request ACP permission before executing", async () => {
const deleteTool = makeFakeTool("delete");
const moveTool = makeFakeTool("move");
const requests: ClientBridgePermissionToolCall[] = [];
const bridge: ClientBridge = {
capabilities: { requestPermission: true },
async requestPermission(toolCall, _options, _signal) {
requests.push(toolCall);
return { outcome: "selected", optionId: "allow_once", kind: "allow_once" };
},
};
const permissionSpy = spyOn(bridge, "requestPermission");
session = await createSession([deleteTool, moveTool], bridge);
await session.setActiveToolsByName(["delete", "move"]);
const wrappedDelete = session.agent.state.tools.find(t => t.name === "delete");
const wrappedMove = session.agent.state.tools.find(t => t.name === "move");
expect(wrappedDelete).toBeDefined();
expect(wrappedMove).toBeDefined();
await wrappedDelete!.execute(
"call-delete",
{ path: "/tmp/gone.ts" },
undefined,
undefined as never,
undefined as never,
);
await wrappedMove!.execute(
"call-move",
{ oldPath: "/tmp/old.ts", newPath: "/tmp/new.ts" },
undefined,
undefined as never,
undefined as never,
);
expect(permissionSpy).toHaveBeenCalledTimes(2);
expect(requests.map(({ toolName, title, locations }) => ({ toolName, title, locations }))).toEqual([
{ toolName: "delete", title: "Delete /tmp/gone.ts", locations: [{ path: "/tmp/gone.ts" }] },
{
toolName: "move",
title: "Move /tmp/old.ts to /tmp/new.ts",
locations: [{ path: "/tmp/old.ts" }, { path: "/tmp/new.ts" }],
},
]);
expect(deleteTool.executeCalls).toBe(1);
expect(moveTool.executeCalls).toBe(1);
});
it("setClientBridge wraps tools that were already active", async () => {
const bashTool = makeFakeTool("bash");
const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" });
const permissionSpy = spyOn(bridge, "requestPermission");
session = await createSession([bashTool]);
session.setClientBridge(bridge);
const wrappedBash = session.agent.state.tools.find(t => t.name === "bash");
expect(wrappedBash).toBeDefined();
await wrappedBash!.execute("call-1", { command: "echo hi" }, undefined, undefined as never, undefined as never);
expect(permissionSpy).toHaveBeenCalledTimes(1);
expect(bashTool.executeCalls).toBe(1);
});
it("aborting an open permission request rejects without executing the tool", async () => {
const bashTool = makeFakeTool("bash");
const pending = Promise.withResolvers<ClientBridgePermissionOutcome>();
const bridge: ClientBridge = {
capabilities: { requestPermission: true },
requestPermission: async () => pending.promise,
};
session = await createSession([bashTool], bridge);
await session.setActiveToolsByName(["bash"]);
const wrappedBash = session.agent.state.tools.find(t => t.name === "bash");
expect(wrappedBash).toBeDefined();
const abortController = new AbortController();
const execution = wrappedBash!.execute(
"call-1",
{ command: "echo hi" },
abortController.signal,
undefined as never,
undefined as never,
);
abortController.abort();
await expect(execution).rejects.toThrow(/Permission request cancelled/);
expect(bashTool.executeCalls).toBe(0);
pending.resolve({ outcome: "cancelled" });
});
// ---------------------------------------------------------------------------
// 2. Reject once: throws, underlying execute never called
// ---------------------------------------------------------------------------
it("reject_once: throws ToolError and never calls underlying execute", async () => {
const editTool = makeFakeTool("edit");
const bridge = makeBridge({ outcome: "selected", optionId: "reject_once", kind: "reject_once" });
session = await createSession([editTool], bridge);
await session.setActiveToolsByName(["edit"]);
const wrappedEdit = session.agent.state.tools.find(t => t.name === "edit");
expect(wrappedEdit).toBeDefined();
await expect(
wrappedEdit!.execute("call-1", { path: "/tmp/foo.ts" }, undefined, undefined as never, undefined as never),
).rejects.toThrow(/rejected by user/);
expect(editTool.executeCalls).toBe(0);
});
it("unknown selected permission option ID fails closed without executing", async () => {
const editTool = makeFakeTool("edit");
const bridge = makeBridge({ outcome: "selected", optionId: "allow_typo" });
session = await createSession([editTool], bridge);
await session.setActiveToolsByName(["edit"]);
const wrappedEdit = session.agent.state.tools.find(t => t.name === "edit");
expect(wrappedEdit).toBeDefined();
await expect(
wrappedEdit!.execute("call-unknown", { path: "/tmp/foo.ts" }, undefined, undefined as never, undefined as never),
).rejects.toThrow(/unknown option ID/);
expect(editTool.executeCalls).toBe(0);
});
// ---------------------------------------------------------------------------
// 3. Always allow caches: bridge called exactly once across two executions
// ---------------------------------------------------------------------------
it("allow_always: caches decision and calls bridge only once for subsequent executes", async () => {
const writeTool = makeFakeTool("write");
const bridge = makeBridge({ outcome: "selected", optionId: "allow_always", kind: "allow_always" });
const permissionSpy = spyOn(bridge, "requestPermission");
session = await createSession([writeTool], bridge);
await session.setActiveToolsByName(["write"]);
const wrappedWrite = session.agent.state.tools.find(t => t.name === "write");
expect(wrappedWrite).toBeDefined();
// First call — bridge is consulted, decision cached.
await wrappedWrite!.execute("call-1", { path: "/tmp/a.ts" }, undefined, undefined as never, undefined as never);
// Second call — must skip the bridge entirely.
await wrappedWrite!.execute("call-2", { path: "/tmp/b.ts" }, undefined, undefined as never, undefined as never);
expect(permissionSpy).toHaveBeenCalledTimes(1);
expect(writeTool.executeCalls).toBe(2);
});
// ---------------------------------------------------------------------------
// 4. Read tool not gated: bridge never called even when bridge is set
// ---------------------------------------------------------------------------
it("read tool: requestPermission is never called for non-gated tools", async () => {
const readTool = makeFakeTool("read");
const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" });
const permissionSpy = spyOn(bridge, "requestPermission");
session = await createSession([readTool], bridge);
await session.setActiveToolsByName(["read"]);
const wrappedRead = session.agent.state.tools.find(t => t.name === "read");
expect(wrappedRead).toBeDefined();
await wrappedRead!.execute("call-1", {}, undefined, undefined as never, undefined as never);
expect(permissionSpy).toHaveBeenCalledTimes(0);
expect(readTool.executeCalls).toBe(1);
});
// ---------------------------------------------------------------------------
// 5. No bridge → original tool object identity preserved (no wrapping)
// ---------------------------------------------------------------------------
it("no bridge: original tool object is returned unchanged", async () => {
const bashTool = makeFakeTool("bash");
session = await createSession([bashTool]); // no bridge
await session.setActiveToolsByName(["bash"]);
const activeBash = session.agent.state.tools.find(t => t.name === "bash");
expect(activeBash).toBe(bashTool);
});