@zhushanwen/pi-permission 1.4.7 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-permission",
3
- "version": "1.4.7",
3
+ "version": "1.4.9",
4
4
  "description": "Pi permission extension — four permission modes (yolo/auto/approve/strict) with three-layer pipeline (AST + rules + AI classifier).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -50,9 +50,9 @@
50
50
  "dependencies": {
51
51
  "tree-sitter-bash": "^0.25.1",
52
52
  "web-tree-sitter": "^0.26.11",
53
- "@zhushanwen/pi-ext-guards": "0.4.1",
53
+ "@zhushanwen/pi-ext-guards": "0.4.2",
54
54
  "@zhushanwen/pi-extension-logger": "0.6.1",
55
- "@zhushanwen/pi-llm-shared": "0.9.0"
55
+ "@zhushanwen/pi-llm-shared": "0.10.1"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@earendil-works/pi-ai": "^0.84.4",
@@ -12,7 +12,7 @@
12
12
  * 用 PI_CODING_AGENT_DIR 指向临时目录,写入 controlled permission.json,
13
13
  * 让 loadAndWatchConfig 读到指定 mode。mock pi 对象记录 handler 调用。
14
14
  */
15
- import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
15
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, statSync } from "node:fs";
16
16
  import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
 
@@ -22,6 +22,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
22
  import permissionExtension from "../index.js";
23
23
  import { FOOTER_HANDSHAKE_KEY, REQUEST_RENDER_KEY } from "../footer-provider.js";
24
24
 
25
+ // migrateLegacyConfig 包 spy 计数(透传真实实现,行为零改变):迁移接线用例的
26
+ // 操作级去重断言(双派发 spy = 1)经此观测,文件面断言(mtime/内容不变)同源有效。
27
+ const { spyMigrateLegacyConfig } = vi.hoisted(() => ({
28
+ spyMigrateLegacyConfig: vi.fn(),
29
+ }));
30
+
31
+ vi.mock("@zhushanwen/pi-llm-shared", async (importOriginal) => {
32
+ const actual = await importOriginal<typeof import("@zhushanwen/pi-llm-shared")>();
33
+ spyMigrateLegacyConfig.mockImplementation((agentDir: string, oldRel: string, newRel: string) =>
34
+ actual.migrateLegacyConfig(agentDir, oldRel, newRel));
35
+ return { ...actual, migrateLegacyConfig: spyMigrateLegacyConfig };
36
+ });
37
+
25
38
  // ──────────────────────── mock pi ────────────────────────
26
39
 
27
40
  /** tool_call handler 的最小签名(返回 Promise<block 结果 | undefined>)。 */
@@ -276,24 +289,43 @@ function getPermissionHandler(calls: MockPiCalls): (args: string, ctx: unknown)
276
289
  }
277
290
 
278
291
  describe("W8 /permission rule 命令集成", () => {
279
- it("headless(json)→ notify 降级提示,不改 config", async () => {
292
+ it("headless(json)→ rule 参数走 rule 路径:notify 降级提示,config 字节不变", async () => {
280
293
  writeConfig("yolo");
294
+ const configBefore = readFileSync(CONFIG_PATH, "utf-8");
281
295
  const { pi, calls } = createMockPi();
282
296
  permissionExtension(pi);
283
297
  const handler = getPermissionHandler(calls);
284
- const ctx = makeCtx("json");
298
+ // notify spy 注入 ctx(commands.test.ts 同款 vi.fn 形态):headless 降级提示的可观测面
299
+ const notify = vi.fn();
300
+ const ctx = {
301
+ mode: "json",
302
+ cwd: "/tmp",
303
+ ui: {
304
+ notify,
305
+ select(): Promise<string | undefined> {
306
+ return Promise.resolve(undefined);
307
+ },
308
+ custom(): Promise<unknown> {
309
+ return Promise.resolve(undefined);
310
+ },
311
+ },
312
+ signal: undefined,
313
+ };
285
314
  // 不应抛错
286
- await handler("rule", ctx);
287
- });
288
-
289
- it("permission handler 分流:rule 参数走 rule 路径(headless 降级)", async () => {
290
- writeConfig("yolo");
291
- const { pi, calls } = createMockPi();
292
- permissionExtension(pi);
293
- const handler = getPermissionHandler(calls);
294
- const ctx = makeCtx("json");
295
- // rule 参数应走 headless 降级(notify)
296
- await expect(handler("rule", ctx)).resolves.not.toThrow();
315
+ await expect(handler("rule", ctx)).resolves.toBeUndefined();
316
+
317
+ // 分流断言:warning 文案只产自 rule 编辑器的 headless 分支(editRulesViaOverlay),
318
+ // 同步路径(mode 切换/status)只发 info——命中即证明「rule 参数走了 rule 路径」。
319
+ // 第二次 notify = 编辑器返回 undefined 后的 no-changes 提示。
320
+ expect(notify).toHaveBeenCalledTimes(2);
321
+ expect(notify).toHaveBeenNthCalledWith(
322
+ 1,
323
+ expect.stringContaining("not available in headless mode"),
324
+ "warning",
325
+ );
326
+ expect(notify).toHaveBeenNthCalledWith(2, "[pi-permission] No changes applied.", "info");
327
+ // 降级路径不得改 config(编辑器未打开、save 未触发):字节级不变
328
+ expect(readFileSync(CONFIG_PATH, "utf-8")).toBe(configBefore);
297
329
  });
298
330
  });
299
331
 
@@ -377,6 +409,12 @@ describe("footer line 注册", () => {
377
409
  // 迁移经 oncePerProcess 守卫(u-audit-fix 收编原内联 once flag),守卫的模块级 Map
378
410
  // 跨用例残留:用 vi.resetModules() + 动态 import 取新鲜模块实例,否则前面用例已触发过
379
411
  // session_start,迁移分支永不执行(静默不测)。
412
+ // 末两条为守卫双派发实测(u-audit-fix 探针):
413
+ // a) 操作级去重:双派发下 spy migrateLegacyConfig 调用 = 1(warn 在 migrateLegacyConfig
414
+ // 调用点内部,调用 = 1 构造性蕴含 warn ≤ 1,不另设断言);
415
+ // b) 文件面:第二次派发后重建的旧文件原样保留、新路径 mtime/内容严格不变;
416
+ // c) 豁免项防误伤:footer 注册为纯内存初始化(registry.register 同 id 覆盖),不在
417
+ // 包装范围——双派发仍每次执行(pending push ×2)。
380
418
  describe("session_start 配置路径迁移接线", () => {
381
419
  const LEGACY_PATH = join(AGENT_DIR, "permission-config.json");
382
420
 
@@ -459,4 +497,59 @@ describe("session_start 配置路径迁移接线", () => {
459
497
  expect(() => handler({}, makeCtx("json"))).not.toThrow();
460
498
  expect(existsSync(CONFIG_PATH)).toBe(false); // 迁移不凭空造新配置
461
499
  });
500
+
501
+ // ── oncePerProcess 守卫双派发实测(u-audit-fix 探针)──
502
+ it("双派发下 migrateLegacyConfig 仅执行一次:spy = 1,文件面首次迁移到位、第二次 mtime/内容不变", async () => {
503
+ // spy 是文件级单例(跨 resetModules 保留计数),前面用例已触发过迁移 → 先清零
504
+ spyMigrateLegacyConfig.mockClear();
505
+ // 预置旧路径文件(新路径不存在 → 走 renameSync 迁移分支)
506
+ rmSync(CONFIG_PATH, { force: true });
507
+ writeLegacyConfig("strict");
508
+
509
+ const extension = await freshExtension();
510
+ const { pi, calls } = createMockPi();
511
+ extension(pi);
512
+ const handler = getSessionStartHandler(calls);
513
+
514
+ // 第一次派发:迁移发生(旧文件消失 + 新路径出现,内容来自旧文件)
515
+ handler({}, makeCtx("json"));
516
+ expect(existsSync(LEGACY_PATH)).toBe(false);
517
+ expect(existsSync(CONFIG_PATH)).toBe(true);
518
+ expect(JSON.parse(readFileSync(CONFIG_PATH, "utf-8")).mode).toBe("strict");
519
+ const mtimeAfterFirst = statSync(CONFIG_PATH).mtimeMs;
520
+ const contentAfterFirst = readFileSync(CONFIG_PATH, "utf-8");
521
+
522
+ // 第二次派发(factory 二调/handler 累积形态:同一 handler 引用再调):
523
+ // 重建旧路径文件模拟外部残留——守卫已消费 key,不得再次触碰任何文件。
524
+ writeLegacyConfig("yolo");
525
+ handler({}, makeCtx("json"));
526
+
527
+ // 探针 a:操作级去重(spy 透传真实实现,文件面与计数同源)
528
+ expect(spyMigrateLegacyConfig).toHaveBeenCalledTimes(1);
529
+ // 探针 b:第二次后重建的旧文件原样保留、新路径 mtime/内容严格不变
530
+ expect(existsSync(LEGACY_PATH)).toBe(true);
531
+ expect(statSync(CONFIG_PATH).mtimeMs).toBe(mtimeAfterFirst);
532
+ expect(readFileSync(CONFIG_PATH, "utf-8")).toBe(contentAfterFirst);
533
+ });
534
+
535
+ it("豁免项防误伤:footer 注册不包装,双派发仍每次执行(pending push ×2)", async () => {
536
+ writeLegacyConfig("strict");
537
+
538
+ const extension = await freshExtension();
539
+ const { pi, calls } = createMockPi();
540
+ extension(pi);
541
+ const handler = getSessionStartHandler(calls);
542
+
543
+ handler({}, makeCtx("json"));
544
+ handler({}, makeCtx("json"));
545
+
546
+ // 纯内存初始化(registry.register 同 id 覆盖 / pending push)保持每 session_start
547
+ // 执行——清单「粒度边界」明令不包装,挂进程级 flag 会杀 session 级语义。
548
+ const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY) as {
549
+ pending: Array<{ id: string; renderer: unknown }>;
550
+ } | undefined;
551
+ expect(slot).toBeDefined();
552
+ expect(slot!.pending.length).toBe(2);
553
+ expect(slot!.pending[0]!.id).toBe("pi-permission");
554
+ });
462
555
  });
@@ -1,146 +0,0 @@
1
- /**
2
- * session-start-once-guard.test.ts — u-audit-fix 探针实测(permission 侧)。
3
- *
4
- * 判定「必须接入」:migrateLegacyConfig 写 agentDir 全局配置文件,属跨 session 副作用,
5
- * 已由 oncePerProcess 守卫收编原内联 once flag。本文件按清单探针验证点双派发实测:
6
- * a) 双派发(同一 factory 闭包直接调 handler 两次)下 spy migrateLegacyConfig 调用 = 1;
7
- * b) 文件面:tmp agentDir 预置旧路径文件 → 第一次 handler 后旧文件消失 + 新路径出现,
8
- * 第二次 handler 后新路径 mtime/内容不变(守卫拦截 = 不触碰);
9
- * c) migrated warn 仅一条:构造性蕴含于 a——warn 在 migrateLegacyConfig 调用点内部
10
- * (llm-shared migrate.ts "migrated" 分支),调用 = 1 ⟹ warn ≤ 1,不另设断言。
11
- * 反向断言(豁免项防误伤):footer 注册为纯内存初始化(registry.register 同 id 覆盖),
12
- * 不在包装范围——双派发仍每次执行(pending push ×2)。
13
- */
14
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
-
18
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
-
21
- import { FOOTER_HANDSHAKE_KEY } from "../footer-provider.js";
22
-
23
- // ── mock:migrateLegacyConfig 包 spy 计数(透传真实实现,文件面断言保留) ──
24
-
25
- const { spyMigrateLegacyConfig } = vi.hoisted(() => ({
26
- spyMigrateLegacyConfig: vi.fn(),
27
- }));
28
-
29
- vi.mock("@zhushanwen/pi-llm-shared", async (importOriginal) => {
30
- const actual = await importOriginal<typeof import("@zhushanwen/pi-llm-shared")>();
31
- spyMigrateLegacyConfig.mockImplementation((agentDir: string, oldRel: string, newRel: string) =>
32
- actual.migrateLegacyConfig(agentDir, oldRel, newRel));
33
- return { ...actual, migrateLegacyConfig: spyMigrateLegacyConfig };
34
- });
35
-
36
- // ── mock pi(对齐 index-integration.test.ts 自包含 helper 形态) ──
37
-
38
- function createMockPi(): { pi: Pick<ExtensionAPI, "on" | "registerCommand">; calls: { eventHandlers: Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>> } } {
39
- const eventHandlers = new Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>>();
40
- const pi = {
41
- registerCommand(): void {
42
- /* noop */
43
- },
44
- on(event: string, handler: (event?: unknown, ctx?: unknown) => unknown): void {
45
- if (!eventHandlers.has(event)) eventHandlers.set(event, []);
46
- eventHandlers.get(event)!.push(handler);
47
- },
48
- };
49
- return { pi: pi as Pick<ExtensionAPI, "on" | "registerCommand">, calls: { eventHandlers } };
50
- }
51
-
52
- function getSessionStartHandler(calls: { eventHandlers: Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>> }): (event: unknown, ctx: unknown) => unknown {
53
- const handlers = calls.eventHandlers.get("session_start");
54
- if (!handlers || handlers.length === 0) {
55
- throw new Error("session_start handler not registered");
56
- }
57
- return handlers[0]!;
58
- }
59
-
60
- // ── tmp agentDir(getAgentDir 经 PI_CODING_AGENT_DIR 推导,对齐 index-integration.test.ts) ──
61
-
62
- const TMP_ROOT = join(tmpdir(), "pi-perm-once-guard-" + process.pid);
63
- const AGENT_DIR = join(TMP_ROOT, "agent");
64
- const LEGACY_PATH = join(AGENT_DIR, "permission-config.json");
65
- const CONFIG_PATH = join(AGENT_DIR, "config", "permission-ext-config.json");
66
-
67
- function writeLegacyConfig(mode: string): void {
68
- mkdirSync(AGENT_DIR, { recursive: true });
69
- writeFileSync(
70
- LEGACY_PATH,
71
- JSON.stringify({ mode, enabled: true, classifier: { enabled: true }, userRules: [] }, null, 2) + "\n",
72
- { mode: 0o600 },
73
- );
74
- }
75
-
76
- /** oncePerProcess 的 Map 是模块级状态:每个用例 resetModules 取新鲜模块实例(对齐迁移接线测试先例)。 */
77
- async function freshExtension(): Promise<(pi: Pick<ExtensionAPI, "on" | "registerCommand">) => void> {
78
- vi.resetModules();
79
- const mod = await import("../index.js");
80
- return mod.default as (pi: Pick<ExtensionAPI, "on" | "registerCommand">) => void;
81
- }
82
-
83
- beforeEach(() => {
84
- Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
85
- process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
86
- mkdirSync(AGENT_DIR, { recursive: true });
87
- });
88
-
89
- afterEach(() => {
90
- delete process.env.PI_CODING_AGENT_DIR;
91
- Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
92
- if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
93
- });
94
-
95
- describe("session_start 双派发幂等守卫(oncePerProcess,u-audit-fix)", () => {
96
- it("双派发下 migrateLegacyConfig 仅执行一次:spy = 1,文件面首次迁移到位、第二次 mtime/内容不变", async () => {
97
- // 预置旧路径文件(新路径不存在 → 走 renameSync 迁移分支)
98
- writeLegacyConfig("strict");
99
-
100
- const extension = await freshExtension();
101
- const { pi, calls } = createMockPi();
102
- extension(pi);
103
- const handler = getSessionStartHandler(calls);
104
-
105
- // 第一次派发:迁移发生(旧文件消失 + 新路径出现,内容来自旧文件)
106
- handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
107
- expect(existsSync(LEGACY_PATH)).toBe(false);
108
- expect(existsSync(CONFIG_PATH)).toBe(true);
109
- expect(JSON.parse(readFileSync(CONFIG_PATH, "utf-8")).mode).toBe("strict");
110
- const mtimeAfterFirst = statSync(CONFIG_PATH).mtimeMs;
111
- const contentAfterFirst = readFileSync(CONFIG_PATH, "utf-8");
112
-
113
- // 第二次派发(factory 二调/handler 累积形态:同一 handler 引用再调):
114
- // 重建旧路径文件模拟外部残留——守卫已消费 key,不得再次触碰任何文件。
115
- writeLegacyConfig("yolo");
116
- handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
117
-
118
- // 探针 a:操作级去重(spy 透传真实实现,文件面与计数同源)
119
- expect(spyMigrateLegacyConfig).toHaveBeenCalledTimes(1);
120
- // 探针 b:第二次后重建的旧文件原样保留、新路径 mtime/内容严格不变
121
- expect(existsSync(LEGACY_PATH)).toBe(true);
122
- expect(statSync(CONFIG_PATH).mtimeMs).toBe(mtimeAfterFirst);
123
- expect(readFileSync(CONFIG_PATH, "utf-8")).toBe(contentAfterFirst);
124
- });
125
-
126
- it("豁免项防误伤:footer 注册不包装,双派发仍每次执行(pending push ×2)", async () => {
127
- writeLegacyConfig("strict");
128
-
129
- const extension = await freshExtension();
130
- const { pi, calls } = createMockPi();
131
- extension(pi);
132
- const handler = getSessionStartHandler(calls);
133
-
134
- handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
135
- handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
136
-
137
- // 纯内存初始化(registry.register 同 id 覆盖 / pending push)保持每 session_start
138
- // 执行——清单「粒度边界」明令不包装,挂进程级 flag 会杀 session 级语义。
139
- const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY) as {
140
- pending: Array<{ id: string; renderer: unknown }>;
141
- } | undefined;
142
- expect(slot).toBeDefined();
143
- expect(slot!.pending.length).toBe(2);
144
- expect(slot!.pending[0]!.id).toBe("pi-permission");
145
- });
146
- });