mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
291 lines
9.5 KiB
TypeScript
291 lines
9.5 KiB
TypeScript
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> = {},
|
|
): 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> = {},
|
|
): 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<string | undefined> => undefined,
|
|
input: async (): Promise<string | undefined> => 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({});
|
|
});
|
|
});
|