mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
配置统一:权限策略、硬防御、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>
300 lines
9.8 KiB
TypeScript
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({});
|
|
});
|
|
});
|