@zhushanwen/pi-permission 1.4.8 → 1.5.0
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 +3 -3
- package/src/__tests__/index-integration.test.ts +107 -14
- package/src/classifier/__tests__/classifier.test.ts +23 -0
- package/src/classifier/classifier.ts +14 -3
- package/src/config.ts +3 -5
- package/src/types.ts +2 -3
- package/src/__tests__/session-start-once-guard.test.ts +0 -146
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-permission",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
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.
|
|
53
|
+
"@zhushanwen/pi-ext-guards": "0.4.2",
|
|
54
54
|
"@zhushanwen/pi-extension-logger": "0.6.1",
|
|
55
|
-
"@zhushanwen/pi-llm-shared": "0.
|
|
55
|
+
"@zhushanwen/pi-llm-shared": "0.11.0"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
});
|
|
@@ -282,4 +282,27 @@ describe("CT5: callLLM opts 构造(model/messages/timeoutMs/signal)", () =>
|
|
|
282
282
|
const opts = getLastOpts();
|
|
283
283
|
expect(opts?.signal).toBe(ac.signal);
|
|
284
284
|
});
|
|
285
|
+
|
|
286
|
+
it("配置 thinkingLevel 不被模型支持 → warn 留痕 + reasoning 不传(不静默降级)", async () => {
|
|
287
|
+
// FIXED_MODEL_OBJ reasoning:false → supportedLevels = ["off"],配置 "high" 判定落空;
|
|
288
|
+
// changeset pi-permission-thinking-level-datadriven 承诺 warn 留痕(同族 rename-session 对齐)
|
|
289
|
+
const onLog = vi.fn();
|
|
290
|
+
const { spy, getLastOpts } = capturingCallLLM(okResult('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}'));
|
|
291
|
+
const classifier = createClassifier(makeDeps({ callLLM: spy, onLog }));
|
|
292
|
+
const r = await classifier.classifyRisk(CTX, { ...CONFIG, thinkingLevel: "high" });
|
|
293
|
+
expect(r.outcome).toBe("allow");
|
|
294
|
+
expect(onLog).toHaveBeenCalledWith(
|
|
295
|
+
expect.stringContaining('configured thinkingLevel "high" is not supported by test-co/test-model; calling without a thinking level'),
|
|
296
|
+
);
|
|
297
|
+
expect(getLastOpts()?.reasoning).toBeUndefined();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("配置 thinkingLevel 被模型支持 → 命中透传,无 warn", async () => {
|
|
301
|
+
const onLog = vi.fn();
|
|
302
|
+
const { spy, getLastOpts } = capturingCallLLM(okResult('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}'));
|
|
303
|
+
const classifier = createClassifier(makeDeps({ callLLM: spy, onLog }));
|
|
304
|
+
await classifier.classifyRisk(CTX, { ...CONFIG, thinkingLevel: "off" });
|
|
305
|
+
expect(onLog).not.toHaveBeenCalledWith(expect.stringContaining("not supported"));
|
|
306
|
+
expect(getLastOpts()?.reasoning).toBe("off");
|
|
307
|
+
});
|
|
285
308
|
});
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* - fail-closed:timeout / 抛错 / 解析失败 / 无可用模型 → 一律 ask
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
20
21
|
import type { Api, Message, Model } from "@earendil-works/pi-ai";
|
|
21
22
|
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
22
23
|
import type { CallLLMOptions, CallLLMResult } from "@zhushanwen/pi-llm-shared";
|
|
@@ -133,15 +134,25 @@ function buildCallOptions(
|
|
|
133
134
|
config: ClassifierConfig,
|
|
134
135
|
timeoutMs: number | undefined,
|
|
135
136
|
signal: AbortSignal | undefined,
|
|
137
|
+
onLog?: (msg: string) => void,
|
|
136
138
|
): CallLLMOptions {
|
|
139
|
+
// 档位按「该模型自己的 supportedLevels」判定(pi-ai 数据驱动,本层不自持词表);
|
|
140
|
+
// 不支持则按「不传档位」处理,不静默换成别的档(clamp),并 warn 留痕
|
|
141
|
+
// (changeset pi-permission-thinking-level-datadriven 的承诺,与 rename-session 同族对齐)。
|
|
142
|
+
// llm-shared 会把 "off" 映射为不传。
|
|
143
|
+
const reasoning = getSupportedThinkingLevels(model).find((l) => l === config.thinkingLevel);
|
|
144
|
+
if (config.thinkingLevel !== undefined && config.thinkingLevel !== "" && reasoning === undefined) {
|
|
145
|
+
onLog?.(
|
|
146
|
+
`[pi-permission] classifier: configured thinkingLevel "${config.thinkingLevel}" is not supported by ${model.provider}/${model.id}; calling without a thinking level`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
137
149
|
return {
|
|
138
150
|
model,
|
|
139
151
|
systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
|
|
140
152
|
messages: buildMessages(ctx),
|
|
141
153
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
142
154
|
...(signal !== undefined ? { signal } : {}),
|
|
143
|
-
|
|
144
|
-
reasoning: config.thinkingLevel,
|
|
155
|
+
reasoning,
|
|
145
156
|
};
|
|
146
157
|
}
|
|
147
158
|
|
|
@@ -223,7 +234,7 @@ export function createClassifier(deps: ClassifierDeps): {
|
|
|
223
234
|
|
|
224
235
|
// 2. 构造 CallLLMOptions
|
|
225
236
|
const timeoutMs = config.timeout > 0 ? config.timeout * MILLIS_PER_SECOND : undefined;
|
|
226
|
-
const callPromise = callLLM(buildCallOptions(model, ctx, config, timeoutMs, signal));
|
|
237
|
+
const callPromise = callLLM(buildCallOptions(model, ctx, config, timeoutMs, signal, onLog));
|
|
227
238
|
|
|
228
239
|
// 3. 等待 callLLM + 外层超时/中止兜底(provider 不支持 timeoutMs/signal 时防永挂)
|
|
229
240
|
let settled: SettledRace;
|
package/src/config.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
|
22
22
|
import {
|
|
23
23
|
clearConfigCache,
|
|
24
24
|
getConfigPath as getLlmConfigPath,
|
|
25
|
-
|
|
25
|
+
normalizeThinkingLevel,
|
|
26
26
|
loadConfig,
|
|
27
27
|
saveConfig as saveLlmConfig,
|
|
28
28
|
} from "@zhushanwen/pi-llm-shared";
|
|
@@ -59,10 +59,8 @@ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
|
|
|
59
59
|
if (record.model !== undefined && !(typeof record.model === "string" && record.model.length > 0)) {
|
|
60
60
|
logger.warn("Ignoring invalid classifier.model (expected string 'auto' or 'provider/model-id'), using default auto");
|
|
61
61
|
}
|
|
62
|
-
// thinkingLevel
|
|
63
|
-
const thinkingLevel =
|
|
64
|
-
? record.thinkingLevel
|
|
65
|
-
: DEFAULT_CLASSIFIER_CONFIG.thinkingLevel;
|
|
62
|
+
// thinkingLevel:非空字符串即采信(合法档位由写入侧 UI 与 pi 判定;本层不再自持词表)
|
|
63
|
+
const thinkingLevel = normalizeThinkingLevel(record.thinkingLevel) ?? DEFAULT_CLASSIFIER_CONFIG.thinkingLevel;
|
|
66
64
|
return {
|
|
67
65
|
enabled: record.enabled !== false,
|
|
68
66
|
model: typeof record.model === "string" && record.model.length > 0 ? record.model : DEFAULT_CLASSIFIER_CONFIG.model,
|
package/src/types.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* 对应 slice plan 的 DM1-DM5 数据模型。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
9
8
|
|
|
10
9
|
// ──────────────────────── DM1: PermissionMode ────────────────────────
|
|
11
10
|
|
|
@@ -108,11 +107,11 @@ export interface ClassifierConfig {
|
|
|
108
107
|
/** 高风险是否强制 deny(不问人:high+allow 时直接拒绝) */
|
|
109
108
|
autoDenyHighRisk: boolean;
|
|
110
109
|
/**
|
|
111
|
-
* 标题生成 LLM 的 thinking
|
|
110
|
+
* 标题生成 LLM 的 thinking 级别(字符串透传;合法档位按模型 supportedLevels 在调用点判定)。
|
|
112
111
|
* 默认 "off":不传 pi-ai reasoning(provider 默认行为);
|
|
113
112
|
* "minimal"~"max" 透传给 SimpleStreamOptions.reasoning(provider 不支持时静默忽略)。
|
|
114
113
|
*/
|
|
115
|
-
thinkingLevel:
|
|
114
|
+
thinkingLevel: string;
|
|
116
115
|
}
|
|
117
116
|
|
|
118
117
|
// ──────────────────────── DM5: PermissionConfig ────────────────────────
|
|
@@ -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
|
-
});
|