Files
my-pi/pi-permission-system/test/permission-events.test.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2aa1be0b54 feat: 组合包配置以包内 config/ 为唯一来源,并新增受保护写入人工审批
配置统一:权限策略、硬防御、Tool Search、提示词封面以及新迁入的
RTK、pi-compaction、pi-worktree、pi-hermes-memory 配置全部由扩展
直接读取已安装组合包内的 config/,pi install / pi update 即完成部署;
删除 rebuild-bundle-config.mjs 与 bundle-manifest.json,my-pi/ 只保留
日志与缓存。update.sh 成功升级后将默认 git 来源的已安装副本
reset 回发布版本。AGENTS.md 新增「组合包配置原则」。

脚本整合:search_config.sh / ssh_config.sh 合并为 configure.sh
search|ssh;install.sh / update.sh 共享函数抽到 scripts/lib.sh,远程
安装器改为先下载到临时文件再执行,下载失败可被检测。

权限:hard-guards 新增 protectedWrites,写入 Pi 凭据/设置、已安装
组合包与扩展目录、pi-ssh 凭据库时一律人工确认(跳过 AutoReview、
yolo 与 Session grant,无 UI 时拒绝);放行 ls、LSP 查询、ctx 只读、
web_search、Chrome 只读与 git 只读命令,skill_manage 改为 ask。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-20 01:05:31 -07:00

300 lines
9.8 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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",
"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;
// Policy and hard-guard files are injected through the factory options so
// the bundle `config/` files are never touched.
let policyPath: string;
let hardGuardsPath: string;
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-perm-events-test-"));
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
policyPath = join(baseDir, "pi-permission-policy.jsonc");
hardGuardsPath = join(baseDir, "pi-permission-hard-guards.jsonc");
mkdirSync(join(baseDir, "agents"), { recursive: true });
writeFileSync(
policyPath,
`${JSON.stringify({ permission: { "*": "ask" } })}\n`,
"utf8",
);
writeFileSync(
hardGuardsPath,
JSON.stringify({ version: 1, permission: { "*": "allow" } }),
);
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, {
configPath: policyPath,
hardGuardConfigPath: hardGuardsPath,
});
// 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({});
});
});