9478e3cc5c
- Replaced usage of `ReturnType<typeof setTimeout>` and `ReturnType<typeof setInterval>` with the explicit `Timer` type across the codebase. - Updated several type definitions and function signatures to use concrete types instead of inferred return types for improved clarity and maintainability.
194 lines
7.1 KiB
TypeScript
194 lines
7.1 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
|
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk";
|
|
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
import { Snowflake } from "@oh-my-pi/pi-utils";
|
|
|
|
const BASE_SETTINGS = {
|
|
"async.enabled": false,
|
|
"bash.autoBackground.enabled": false,
|
|
"bashInterceptor.enabled": false,
|
|
} as const;
|
|
|
|
function emptyWorkspaceTree(cwd: string) {
|
|
return { rootPath: cwd, rendered: ".\n", truncated: false, totalLines: 1, agentsMdFiles: [] };
|
|
}
|
|
|
|
function textOf(result: { content?: ReadonlyArray<{ type: string; text?: string }> }): string {
|
|
const blocks = result.content ?? [];
|
|
for (const block of blocks) {
|
|
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
describe("tools.approvalMode setting", () => {
|
|
// The per-tool approval gate (ExtensionToolWrapper) reads approvalMode / tools.approval /
|
|
// autoApprove exclusively from the execute-time AgentToolContext, never from the session's
|
|
// own settings. So a single shared session exercises every mode — we only vary the context
|
|
// settings per assertion. This avoids paying createAgentSession's cost (model registry,
|
|
// auth-storage discovery, settings init) nine times over.
|
|
let tempDir: string;
|
|
let session: AgentSession;
|
|
|
|
beforeAll(async () => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-approval-mode-${Snowflake.next()}-`));
|
|
const cwd = path.join(tempDir, "cwd");
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
const sessionManager = SessionManager.create(cwd, path.join(tempDir, "sessions"));
|
|
const created = await createAgentSession({
|
|
cwd,
|
|
agentDir: tempDir,
|
|
sessionManager,
|
|
settings: Settings.isolated(BASE_SETTINGS),
|
|
model: getBundledModel("openai", "gpt-4o-mini"),
|
|
disableExtensionDiscovery: true,
|
|
skills: [],
|
|
contextFiles: [],
|
|
workspaceTree: emptyWorkspaceTree(cwd),
|
|
promptTemplates: [],
|
|
slashCommands: [],
|
|
enableMCP: false,
|
|
enableLsp: false,
|
|
toolNames: ["bash"],
|
|
});
|
|
session = created.session;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await session.dispose();
|
|
// Windows can briefly hold tempdir handles after session.dispose(); retry a few times.
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
try {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
break;
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code;
|
|
if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "EPERM") throw err;
|
|
if (attempt === 4) break; // best-effort: OS will reclaim
|
|
await Bun.sleep(50 * (attempt + 1));
|
|
}
|
|
}
|
|
});
|
|
|
|
function approvalSettings(extraSettings: Record<string, unknown> = {}): Settings {
|
|
return Settings.isolated({ ...BASE_SETTINGS, ...extraSettings });
|
|
}
|
|
|
|
function bashTool() {
|
|
const bash = session.getToolByName("bash");
|
|
if (!bash) throw new Error("Expected bash tool");
|
|
return bash;
|
|
}
|
|
|
|
it("yolo mode (default) bypasses approval for non-overriding tool calls", async () => {
|
|
const settings = approvalSettings();
|
|
const result = await bashTool().execute("yolo", { command: "echo ok" }, undefined, undefined, {
|
|
settings,
|
|
} as AgentToolContext);
|
|
expect(textOf(result)).toContain("ok");
|
|
});
|
|
|
|
it("always-ask mode rejects exec tools when no UI is available", async () => {
|
|
const settings = approvalSettings({ "tools.approvalMode": "always-ask" });
|
|
await expect(
|
|
bashTool().execute("always-ask", { command: "echo blocked" }, undefined, undefined, {
|
|
settings,
|
|
} as AgentToolContext),
|
|
).rejects.toThrow(/requires approval but no interactive UI available/);
|
|
});
|
|
|
|
it("per-tool allow overrides are honored in every mode", async () => {
|
|
const settings = approvalSettings({
|
|
"tools.approvalMode": "always-ask",
|
|
"tools.approval": { bash: "allow" },
|
|
});
|
|
const result = await bashTool().execute("always-ask-allow", { command: "echo allowed" }, undefined, undefined, {
|
|
settings,
|
|
} as AgentToolContext);
|
|
expect(textOf(result)).toContain("allowed");
|
|
});
|
|
|
|
it("per-tool prompt overrides can tighten yolo mode", async () => {
|
|
const settings = approvalSettings({
|
|
"tools.approvalMode": "yolo",
|
|
"tools.approval": { bash: "prompt" },
|
|
});
|
|
await expect(
|
|
bashTool().execute("yolo-prompt", { command: "echo blocked" }, undefined, undefined, {
|
|
settings,
|
|
} as AgentToolContext),
|
|
).rejects.toThrow(/requires approval but no interactive UI available/);
|
|
});
|
|
|
|
it("write mode still prompts exec-tier tools", async () => {
|
|
const settings = approvalSettings({
|
|
"tools.approvalMode": "write",
|
|
"tools.approval": {},
|
|
});
|
|
await expect(
|
|
bashTool().execute("write-mode", { command: "echo unconfigured" }, undefined, undefined, {
|
|
settings,
|
|
} as AgentToolContext),
|
|
).rejects.toThrow(/requires approval but no interactive UI available/);
|
|
});
|
|
|
|
it("critical bash patterns do not prompt in yolo mode with bash allowed", async () => {
|
|
const settings = approvalSettings({
|
|
"tools.approvalMode": "yolo",
|
|
"tools.approval": { bash: "allow" },
|
|
});
|
|
const result = await bashTool().execute(
|
|
"critical",
|
|
{ command: "rm -f /tmp/bun-fake-timer-probe.test.ts" },
|
|
undefined,
|
|
undefined,
|
|
{
|
|
settings,
|
|
} as AgentToolContext,
|
|
);
|
|
expect(textOf(result)).toContain("(no output)");
|
|
});
|
|
|
|
it("CLI --auto-approve forces yolo mode for non-overriding tool calls", async () => {
|
|
const settings = approvalSettings({ "tools.approvalMode": "always-ask" });
|
|
const result = await bashTool().execute("cli-override", { command: "echo override" }, undefined, undefined, {
|
|
settings,
|
|
autoApprove: true,
|
|
} as AgentToolContext);
|
|
expect(textOf(result)).toContain("override");
|
|
});
|
|
|
|
it("CLI --auto-approve also bypasses safety-override patterns", async () => {
|
|
const settings = approvalSettings({ "tools.approvalMode": "always-ask" });
|
|
const result = await bashTool().execute(
|
|
"cli-critical",
|
|
{ command: "rm -f /tmp/bun-fake-timer-probe.test.ts" },
|
|
undefined,
|
|
undefined,
|
|
{
|
|
settings,
|
|
autoApprove: true,
|
|
} as AgentToolContext,
|
|
);
|
|
expect(textOf(result)).toContain("(no output)");
|
|
});
|
|
|
|
it("constructs an extensionRunner unconditionally so the approval gate is always installed", async () => {
|
|
// Regression lock for the architectural fix: the per-tool approval gate is implemented
|
|
// inside `ExtensionToolWrapper`, which is only attached when `session.extensionRunner` exists.
|
|
// Historically the runner was conditional on `extensionsResult.extensions.length > 0`, which
|
|
// meant the entire approval system silently disappeared for users with no extensions loaded —
|
|
// any non-yolo approval mode setting would be a no-op without feedback. The
|
|
// fix is to construct the runner unconditionally; this test makes that contract explicit so
|
|
// a future change to make the runner optional again cannot silently re-open the hole.
|
|
expect(session.extensionRunner).toBeDefined();
|
|
});
|
|
});
|