import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getGlobalConfigPath } from "#src/config-paths"; import piPermissionSystemExtension from "#src/index"; import type { PermissionDecisionEvent, PermissionsReadyEvent, PermissionUiPromptEvent, } from "#src/permission-events"; import { emitDecisionEvent, emitReadyEvent, emitUiPromptEvent, PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, } from "#src/permission-events"; import { makePromptPayload } from "#test/helpers/prompt-details-fixtures"; // ── Minimal EventBus stub ────────────────────────────────────────────────── function makeEventBus() { return { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined), }; } // ── Constants ────────────────────────────────────────────────────────────── describe("constants", () => { it("channel names have the correct values", () => { expect(PERMISSIONS_READY_CHANNEL).toBe("permissions:ready"); expect(PERMISSIONS_UI_PROMPT_CHANNEL).toBe("permissions:ui_prompt"); expect(PERMISSIONS_DECISION_CHANNEL).toBe("permissions:decision"); }); }); // ── emitReadyEvent ───────────────────────────────────────────────────────── describe("emitReadyEvent", () => { it("emits an empty payload on the permissions:ready channel", () => { const bus = makeEventBus(); emitReadyEvent(bus); expect(bus.emit).toHaveBeenCalledOnce(); expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {}); }); it("carries no protocolVersion (the broadcast contract is types + semver)", () => { const bus = makeEventBus(); emitReadyEvent(bus); const payload = bus.emit.mock.calls[0][1] as PermissionsReadyEvent; expect(payload).not.toHaveProperty("protocolVersion"); }); it("swallows event bus errors because broadcasts are best-effort", () => { const bus = { emit: vi.fn(() => { throw new Error("listener failed"); }), on: vi.fn().mockReturnValue(() => undefined), }; expect(() => emitReadyEvent(bus)).not.toThrow(); }); }); // ── emitUiPromptEvent ────────────────────────────────────────────────────── describe("emitUiPromptEvent", () => { function makeUiPromptEvent( overrides: Partial = {}, ): PermissionUiPromptEvent { return { requestId: "req-123", source: "tool_call", surface: "bash", value: "git status", agentName: "Explore", request: makePromptPayload().request, forwarding: null, ...overrides, }; } it("emits on the permissions:ui_prompt channel", () => { const bus = makeEventBus(); emitUiPromptEvent(bus, makeUiPromptEvent()); expect(bus.emit).toHaveBeenCalledOnce(); expect(bus.emit.mock.calls[0][0]).toBe("permissions:ui_prompt"); }); it("forwards the full payload unchanged", () => { const bus = makeEventBus(); const event = makeUiPromptEvent({ forwarding: { requesterAgentName: "Worker", requesterSessionId: "child" }, }); emitUiPromptEvent(bus, event); expect(bus.emit.mock.calls[0][1]).toEqual(event); }); it("swallows event bus errors because UI prompt broadcasts are observational", () => { const bus = { emit: vi.fn(() => { throw new Error("listener failed"); }), on: vi.fn().mockReturnValue(() => undefined), }; expect(() => emitUiPromptEvent(bus, makeUiPromptEvent())).not.toThrow(); }); }); // ── emitDecisionEvent ────────────────────────────────────────────────────── describe("emitDecisionEvent", () => { function makeDecisionEvent( overrides: Partial = {}, ): PermissionDecisionEvent { return { requestId: "perm-00000000-0000-4000-8000-000000000000", surface: "bash", value: "git status", result: "allow", resolution: "policy_allow", origin: "global", agentName: null, matchedPattern: "*", ...overrides, }; } it("carries the request id that identifies the decided request", () => { const bus = makeEventBus(); emitDecisionEvent(bus, makeDecisionEvent({ requestId: "perm-abc" })); const payload = bus.emit.mock.calls[0][1] as PermissionDecisionEvent; expect(payload.requestId).toBe("perm-abc"); }); it("emits on the permissions:decision channel", () => { const bus = makeEventBus(); emitDecisionEvent(bus, makeDecisionEvent()); expect(bus.emit).toHaveBeenCalledOnce(); expect(bus.emit.mock.calls[0][0]).toBe("permissions:decision"); }); it("forwards the full payload unchanged", () => { const bus = makeEventBus(); const event = makeDecisionEvent({ surface: "mcp", value: "exa:search", result: "deny", resolution: "policy_deny", origin: "project", agentName: "Worker", matchedPattern: "exa:*", }); emitDecisionEvent(bus, event); expect(bus.emit.mock.calls[0][1]).toEqual(event); }); it("accepts all defined resolution values", () => { const resolutions: PermissionDecisionEvent["resolution"][] = [ "policy_allow", "policy_deny", "session_approved", "infrastructure_auto_allowed", "user_approved", "user_approved_for_session", "user_denied", "auto_approved", "confirmation_unavailable", ]; const bus = makeEventBus(); for (const resolution of resolutions) { emitDecisionEvent(bus, makeDecisionEvent({ resolution })); } expect(bus.emit).toHaveBeenCalledTimes(resolutions.length); }); it("accepts null for optional fields", () => { const bus = makeEventBus(); emitDecisionEvent( bus, makeDecisionEvent({ origin: null, agentName: null, matchedPattern: null, }), ); const payload = bus.emit.mock.calls[0][1] as PermissionDecisionEvent; expect(payload.origin).toBeNull(); expect(payload.agentName).toBeNull(); expect(payload.matchedPattern).toBeNull(); }); it("swallows event bus errors because broadcasts are best-effort", () => { const bus = { emit: vi.fn(() => { throw new Error("listener failed"); }), on: vi.fn().mockReturnValue(() => undefined), }; expect(() => emitDecisionEvent(bus, makeDecisionEvent())).not.toThrow(); }); }); // ── piPermissionSystemExtension emits permissions:ready ──────────────────── describe("piPermissionSystemExtension ready event wiring", () => { let baseDir: string; let originalAgentDir: string | undefined; beforeEach(() => { baseDir = mkdtempSync(join(tmpdir(), "pi-perm-events-test-")); originalAgentDir = process.env.PI_CODING_AGENT_DIR; const globalConfigPath = getGlobalConfigPath(baseDir); mkdirSync(dirname(globalConfigPath), { recursive: true }); mkdirSync(join(baseDir, "agents"), { recursive: true }); writeFileSync( globalConfigPath, `${JSON.stringify({ permission: { "*": "ask" } })}\n`, "utf8", ); process.env.PI_CODING_AGENT_DIR = baseDir; }); afterEach(() => { if (originalAgentDir === undefined) { delete process.env.PI_CODING_AGENT_DIR; } else { process.env.PI_CODING_AGENT_DIR = originalAgentDir; } rmSync(baseDir, { recursive: true, force: true }); }); it("emits permissions:ready at session_start", async () => { const emitSpy = vi.fn(); const handlers = new Map< string, (event: unknown, ctx: unknown) => unknown >(); piPermissionSystemExtension({ on: vi.fn( (event: string, handler: (e: unknown, c: unknown) => unknown) => { handlers.set(event, handler); }, ), registerCommand: vi.fn(), getAllTools: vi.fn().mockReturnValue([]), getActiveTools: vi.fn().mockReturnValue([]), setActiveTools: vi.fn(), registerProvider: vi.fn(), events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) }, } as never); // ready is not emitted at load — only after session_start publishes. expect( emitSpy.mock.calls.filter(([c]) => c === PERMISSIONS_READY_CHANNEL), ).toHaveLength(0); const ctx = { cwd: baseDir, hasUI: false, isProjectTrusted: (): boolean => true, sessionManager: { getEntries: (): unknown[] => [], getSessionId: (): string => "top-session", getSessionDir: (): string => baseDir, }, ui: { notify: (): void => {}, setStatus: (): void => {}, select: async (): Promise => undefined, input: async (): Promise => undefined, }, }; await handlers.get("session_start")?.({ reason: "start" }, ctx); const readyCalls = emitSpy.mock.calls.filter( ([channel]) => channel === PERMISSIONS_READY_CHANNEL, ); expect(readyCalls).toHaveLength(1); expect(readyCalls[0][1]).toEqual({}); }); });