@zhushanwen/pi-permission 0.1.0 → 1.1.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/README.md CHANGED
@@ -12,7 +12,6 @@ Pi permission 扩展 — 四档权限模式(yolo / auto / approve / strict)+
12
12
  - **AI Classifier**:auto 模式下用 LLM 评估未知命令风险(low/medium/high)
13
13
  - **用户审批 UI**:TUI(自定义 Component)/ RPC(select 对话框)/ headless(fail-closed deny)
14
14
  - **Reject-with-Reason**:用户拒绝时可输入真实理由(回传 agent 辅助理解)
15
- - **statusline 集成**:TUI 底部 footer 显示当前权限模式标签(通过 globalThis Symbol 握手协议向 statusline 注册一行 footer line renderer)
16
15
  - **fail-closed**:任何异常路径 → block(绝不静默放行)
17
16
 
18
17
  ## 安装
@@ -28,7 +27,7 @@ ln -s /path/to/xyz-pi-extensions-workspace/feat-permission-and-auto-mode/extensi
28
27
 
29
28
  ## 配置
30
29
 
31
- 配置文件位置:`~/.pi/agent/permission-config.json`(首次运行自动创建默认配置)。
30
+ 配置文件位置:`<agentDir>/config/permission-ext-config.json`(`<agentDir>` = pi agent 目录,`PI_CODING_AGENT_DIR` 覆盖,默认 `~/.pi/agent`;首次运行自动创建默认配置)。
32
31
 
33
32
  可通过 `PI_CODING_AGENT_DIR` 环境变量覆盖基础路径。
34
33
 
@@ -54,7 +53,7 @@ ln -s /path/to/xyz-pi-extensions-workspace/feat-permission-and-auto-mode/extensi
54
53
  | `mode` | `"yolo"` | 当前权限模式(yolo/auto/approve/strict) |
55
54
  | `enabled` | `true` | 扩展是否启用(false=完全放行,等同 yolo 但保留配置) |
56
55
  | `classifier.enabled` | `true` | 是否启用 AI 层(auto 模式自动 true) |
57
- | `classifier.model` | `"auto"` | AI 模型(`auto` 选最便宜,或 `provider/model-id`) |
56
+ | `classifier.model` | `"auto"` | AI 模型(`auto` = scoped:取 settings.json enabledModels 首个可用,或 `provider/model-id`) |
58
57
  | `classifier.timeout` | `90` | AI 分类超时秒数 |
59
58
  | `classifier.autoApproveLowRisk` | `true` | 低风险是否自动放行(false=转人工) |
60
59
  | `classifier.autoDenyHighRisk` | `true` | 高风险是否自动拦截(true=强制 deny) |
@@ -118,7 +117,7 @@ ln -s /path/to/xyz-pi-extensions-workspace/feat-permission-and-auto-mode/extensi
118
117
  |------|-------------|------|------|------|
119
118
  | 内置安全白名单 | `builtin-safe` | 50 无条件 + 9 条件 | 函数实现(`isKnownSafeCommand`),不进 `Rule[]` 数组 | 不可改 |
120
119
  | 内置危险规则 | `builtin-danger` | 12 条正则 | `BUILTIN_DANGER_RULES` 常量,代码硬编码 | 不可改 |
121
- | 用户自定义规则 | `user` | 任意 | `permission-config.json` 的 `userRules` 数组 | 可改 |
120
+ | 用户自定义规则 | `user` | 任意 | `config/permission-ext-config.json` 的 `userRules` 数组 | 可改 |
122
121
 
123
122
  规则层在整个权限管道中的位置(auto / approve 模式):
124
123
 
@@ -197,7 +196,7 @@ wc / whereis / who / whoami / which
197
196
 
198
197
  ### 4. 自定义规则(用户编辑指南)
199
198
 
200
- 在 `~/.pi/agent/permission-config.json` 的 `userRules` 数组中添加。完整示例:
199
+ 在 `<agentDir>/config/permission-ext-config.json` 的 `userRules` 数组中添加。完整示例:
201
200
 
202
201
  ```json
203
202
  {
@@ -342,7 +341,7 @@ publish 规则在后,last-match-wins 时 deny 胜出。
342
341
 
343
342
  ### 10. 配置文件管理
344
343
 
345
- - **路径**:`~/.pi/agent/permission-config.json`,可用 `PI_CODING_AGENT_DIR` 环境变量覆盖基础目录
344
+ - **路径**:`<agentDir>/config/permission-ext-config.json`(`<agentDir>` 可用 `PI_CODING_AGENT_DIR` 环境变量覆盖,默认 `~/.pi/agent`)
346
345
  - **首次创建**:扩展启动时若文件不存在,自动写入默认配置(`mode: "yolo"`、空 `userRules`)
347
346
  - **权限**:`0o600`(原子写:先写 `.tmp` 再 rename,避免半写状态)
348
347
  - **编辑方式**:目前需手动编辑 JSON 文件(未来计划提供 `/permission add-rule` 命令辅助编辑)
@@ -360,7 +359,7 @@ publish 规则在后,last-match-wins 时 deny 胜出。
360
359
 
361
360
  auto 模式下层 3 用 LLM 评估未知命令风险:
362
361
 
363
- - **模型**:`classifier.model`(`auto` 自动选最便宜,或指定 `provider/model-id`)
362
+ - **模型**:`classifier.model`(`auto` = scoped:取 enabledModels 首个可用(空则 fallback available),或指定 `provider/model-id`)
364
363
  - **输出**:`risk_level`(low/medium/high)+ `outcome`(allow/deny/ask)+ `reasoning` + `confidence`
365
364
  - **override**(WT7 偏差补丁):
366
365
  - `low + allow + autoApproveLowRisk=false` → 强制 `ask`(转人工)
@@ -371,7 +370,7 @@ auto 模式下层 3 用 LLM 评估未知命令风险:
371
370
 
372
371
  `/permission model` 弹出 overlay 选择 AI classifier 使用的模型,写回 `classifier.model`:
373
372
 
374
- - **第一级 provider 选择**:列出 `Auto`(自动选最便宜可用模型)+ 所有可用 provider(来自 `~/.pi/agent/models.json`,按字母序)。当前 `classifier.model` 预选高亮。
373
+ - **第一级 provider 选择**:列出 `Auto`(自动:scoped 取 enabledModels 首个可用)+ 所有可用 provider(来自 `~/.pi/agent/models.json`,按字母序)。当前 `classifier.model` 预选高亮。
375
374
  - **第二级 model 选择**:选中具体 provider 后,列出该 provider 下所有可用 model(按 `cost.input` 升序,并列按 id 字母序)。`Esc` 回退到 provider 列表。
376
375
  - **键位**:`↑/↓` 导航、`Enter` 确认、`Esc` 取消(provider stage)或回退(model stage)。
377
376
  - **三模式分发**:
@@ -381,42 +380,8 @@ auto 模式下层 3 用 LLM 评估未知命令风险:
381
380
  - **无可用模型**:`models.json` 不存在 / 无 provider 配 `apiKey` / 解析失败时,`listAvailableModels` 返回空 Map,命令降级为提示 `No available models. Configure ~/.pi/agent/models.json first.`(不阻塞,不修改配置)。
382
381
  - **结果写回**:选中后 `classifier.model` 更新为 `auto` 或 `provider/model-id`,其余字段(mode/enabled/timeout/userRules)保留。
383
382
 
384
- ## statusline 集成
385
-
386
- permission 不再自管 footer,而是通过 **globalThis Symbol 握手协议** 向 `@zhushanwen/pi-statusline` 注册一行 footer line renderer(`session_start` 时调用 `registerPermissionFooterLine`):
387
-
388
- - **statusline 是 footer canonical owner**:唯一创建 footer registry(`getOrCreateFooterRegistry`)。
389
- - **permission 是 consumer**:永不创建 registry 实例,永不写 `slot.registry` 字段;只通过 `FOOTER_HANDSHAKE_KEY = Symbol.for('@zhushanwen/pi-statusline.footerHandshake')` 读写 slot。
390
- - **插入位置**:renderer `order=2`,聚合排序后落在 line2(model)和 line3(ctx)之间。
391
- - **加载顺序无关**:slot 形状 `{version, registry?, pending:[]}`。registry 未就绪时 consumer 只 push pending,等 statusline 后到时 flush(沿用 ask-user 的 pending-flush 修复模式)。
392
- - **无代码层 import**:permission 仅对 statusline 做运行时 `globalThis` 反射,statusline 是可选 `peerDep`,未安装时静态 import 不会破坏 permission。
393
- - **重绘触发**:mode/enabled 切换后调用 `requestFooterRender()`(`REQUEST_RENDER_KEY`),statusline 立即重绘 footer。
394
-
395
- footer line 内容(精简版,避免 footer 拥挤):
396
-
397
- ```
398
- [permission] Auto · enabled
399
- [permission] disabled
400
- ```
401
-
402
- 模式标签:YOLO / Auto / Approve / Strict(对应 yolo/auto/approve/strict)。
403
-
404
- **未安装 statusline 时 silent 降级**:permission 功能完整,仅 footer 不显示 mode 标签。可用 `/permission status` 查看 mode。
405
-
406
- ## 升级须知
407
-
408
- **v0.0.1 → v0.1.0 breaking change**:本扩展不再自管 footer,权限模式标签现在由 `@zhushanwen/pi-statusline` 聚合提供(通过 globalThis Symbol 握手协议)。此前 footer 由各扩展各自调用 `ctx.ui.setFooter` 单例渲染、互相覆盖。
409
-
410
- - **同时安装 statusline 的用户**:无需任何操作。mode 标签自动作为 statusline footer 的一行出现(line2 和 line3 之间)。
411
- - **未安装 statusline 的用户**:footer 不再显示 mode 标签。两种恢复方式:
412
- - 安装 statusline:`pi install npm:@zhushanwen/pi-statusline`,mode 标签回到 footer。
413
- - 或随时用 `/permission status` 查看/切换 mode(不依赖 footer)。
414
-
415
- 详见下方「statusline 集成」章节。
416
-
417
383
  ## 已知限制
418
384
 
419
- - **Footer 由 statusline 聚合**:本扩展不再自管 footer,mode 标签通过 globalThis Symbol 握手协议向 `@zhushanwen/pi-statusline` 注册(见「statusline 集成」)。未安装 statusline 时 footer 不显示 mode 标签(silent 降级,不影响功能)。
420
385
  - **TUI Reject-with-Reason**:当前 RPC 分支已完整接入 `ctx.ui.input` 采集拒绝理由;TUI 分支因 pi-tui Input 组件集成成本较高,暂保留简化 deny(固定文案),后续迭代补齐内联文本输入。
421
386
  - **headless 模式**:json/print 模式无交互 UI,所有审批请求 fail-closed deny(不阻塞自动化流程,但 strict/approve 模式下无法放行)。
422
387
  - **wasm 加载**:AST 分析依赖 tree-sitter-bash wasm,加载失败时 fail-closed(clean=false, parseError=true)。
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-permission",
3
- "version": "0.1.0",
3
+ "version": "1.1.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",
7
7
  "pi": {
8
8
  "extensions": [
9
9
  "./index.ts"
10
+ ],
11
+ "skills": [
12
+ "./skills"
10
13
  ]
11
14
  },
12
15
  "keywords": [
@@ -20,31 +23,33 @@
20
23
  "license": "MIT",
21
24
  "files": [
22
25
  "src/",
26
+ "skills/",
23
27
  "index.ts"
24
28
  ],
25
29
  "dependencies": {
26
30
  "tree-sitter-bash": "^0.25.1",
27
- "web-tree-sitter": "^0.26.11"
31
+ "web-tree-sitter": "^0.26.11",
32
+ "@zhushanwen/pi-llm-shared": "0.2.0"
28
33
  },
29
34
  "peerDependencies": {
30
35
  "@earendil-works/pi-ai": "*",
31
36
  "@earendil-works/pi-coding-agent": "*",
32
- "@earendil-works/pi-tui": "*",
33
- "@zhushanwen/pi-statusline": "*"
37
+ "@earendil-works/pi-tui": "*"
34
38
  },
35
39
  "peerDependenciesMeta": {
36
40
  "@earendil-works/pi-ai": {
37
41
  "optional": true
38
42
  },
39
- "@earendil-works/pi-tui": {
43
+ "@earendil-works/pi-coding-agent": {
40
44
  "optional": true
41
45
  },
42
- "@zhushanwen/pi-statusline": {
46
+ "@earendil-works/pi-tui": {
43
47
  "optional": true
44
48
  }
45
49
  },
46
50
  "devDependencies": {
47
51
  "@earendil-works/pi-ai": "*",
52
+ "@earendil-works/pi-coding-agent": "*",
48
53
  "@earendil-works/pi-tui": "*",
49
54
  "vitest": "^4.1.8"
50
55
  },
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: permission-ext-config
3
+ description: "配置 @zhushanwen/pi-permission(四档权限模式 + AI 风险分类)时加载。含配置文件路径、PermissionConfig/ClassifierConfig schema、权限模式、classifier model、默认值、示例、userRules、enabled 反直觉行为、热重载。触发词:配置权限、permission 配置、权限模式、classifier model、permission.json、permission 配置文件、auto/approve/strict/yolo 模式、热重载、enabled。"
4
+ ---
5
+
6
+ # permission 配置指南
7
+
8
+ > @zhushanwen/pi-permission:四档权限模式(yolo/auto/approve/strict)+ 三层管道(AST 分析 + 规则匹配 + AI 风险分类)。
9
+
10
+ ## ⚠ 先看:`enabled` 字段的反直觉行为
11
+
12
+ **`enabled: false` 不是「锁定/安全模式」,而是「扩展完全不介入」= 全部放行(等同 yolo,仅保留配置文件)。**
13
+
14
+ 想提高安全性,改 `mode`(`auto`/`approve`/`strict`),**不要**把 `enabled` 设 false。把 `enabled` 设 false 是最危险的误配置——用户以为关掉了扩展等于安全锁死,实际等于完全无防护。(源码:`pipeline.ts`「config.enabled=false 等同 yolo」)
15
+
16
+ ## 配置文件位置
17
+
18
+ `<agentDir>/config/permission-ext-config.json`
19
+
20
+ - `<agentDir>` = pi agent 目录(`PI_CODING_AGENT_DIR` 覆盖,默认 `~/.pi/agent`;xyz-agent 隔离环境为 `~/.xyz-agent/pi/agent`)
21
+ - 走 llm-shared 泛型 config(config/ 子目录 + getAgentDir 派生 + mtime+size 缓存 + 原子写)
22
+ - 文件缺失/坏 JSON 返回默认值,不抛错;首次加载自动创建默认配置文件
23
+ - [HISTORICAL] 旧路径 `<agentDir>/permission-config.json`(agentDir 根):**session_start hook 运行时迁移**到新路径(幂等,过渡性,Added in v1.0.0, remove after v2.0.0)
24
+
25
+ ## 热重载:改文件即生效
26
+
27
+ 配置走 **mtime+size 读时刷新(pull-based)**:每次权限检查(每个 tool_call)都 stat 文件,变了就读新配置,**无需重启 session**。
28
+
29
+ - 在编辑器改 JSON 保存 → 下一次工具调用立即按新配置拦截
30
+ - `/permission` 命令执行时也会读最新
31
+ - 不要担心性能:文件没变时只做一次 `statSync`(metadata 读,不读文件内容),开销可忽略
32
+
33
+ > 这是 llm-shared config 框架统一提供的热重载能力。早期版本曾用闭包缓存架空了它(同一 session 改文件不生效),已修复——现在每次 tool_call 直接读最新。
34
+
35
+ ## Schema
36
+
37
+ ```ts
38
+ interface PermissionConfig {
39
+ mode: "yolo" | "auto" | "approve" | "strict"; // 权限模式,默认 "yolo"
40
+ enabled: boolean; // 扩展总开关(false=全部放行,见上方警告),默认 true
41
+ classifier: ClassifierConfig; // AI 分类器配置(仅 auto 模式生效,见下)
42
+ userRules: Rule[]; // 用户自定义规则
43
+ }
44
+
45
+ interface ClassifierConfig {
46
+ enabled: boolean; // AI 层开关,默认 true
47
+ model: string; // 'auto' 或 'provider/model-id',默认 "auto"
48
+ timeout: number; // 超时秒,默认 90
49
+ autoApproveLowRisk: boolean; // 低风险自动放行,默认 true
50
+ autoDenyHighRisk: boolean; // 高风险转人工审批,默认 true
51
+ }
52
+ ```
53
+
54
+ ### 权限模式
55
+
56
+ | 模式 | 行为 | classifier 是否介入 |
57
+ |---|---|---|
58
+ | yolo | 完全无防护,全部放行 | 否 |
59
+ | auto | 安全命令规则直通 + 非安全过 AI 审查 | **是(仅此模式)** |
60
+ | approve | 规则直通安全 + 非安全直接人工审批(无 AI) | 否 |
61
+ | strict | 全部审批 | 否 |
62
+
63
+ > `classifier.*` 配置**仅在 `mode: "auto"` 下生效**。在 approve/strict/yolo 模式下调 classifier 字段(timeout/model 等)无任何效果。
64
+
65
+ ### classifier.model(只接受 string)
66
+
67
+ - `"auto"`(默认)→ scoped:读 settings.json enabledModels 取首个可用;空则 fallback available(getAvailable 首个)
68
+ - `"provider/model-id"`(如 `"zhipu/glm-4-flash"`)→ 精确匹配
69
+ - 传对象形式会被 console.warn 忽略,回落 auto
70
+
71
+ ### auto 模式的风险判定(autoApproveLowRisk / autoDenyHighRisk)
72
+
73
+ AI classifier 输出三档风险,对应处理:
74
+
75
+ | 风险 | autoApproveLowRisk=true | autoDenyHighRisk=true | 实际动作 |
76
+ |---|---|---|---|
77
+ | low | ✓ 自动放行 | — | allow |
78
+ | **medium** | — | — | **ask(转人工审批)** |
79
+ | high | — | ✓ 转人工审批 | ask |
80
+
81
+ > medium 风险**总是转人工审批**,不受这两个开关影响。两个开关只拨动 low/high 的边界。
82
+
83
+ ## 默认值
84
+
85
+ ```json
86
+ {
87
+ "mode": "yolo",
88
+ "enabled": true,
89
+ "classifier": {
90
+ "enabled": true,
91
+ "model": "auto",
92
+ "timeout": 90,
93
+ "autoApproveLowRisk": true,
94
+ "autoDenyHighRisk": true
95
+ },
96
+ "userRules": []
97
+ }
98
+ ```
99
+
100
+ > ⚠ 默认 `mode: "yolo"` = 安装后默认**无任何防护**。生产/敏感环境务必改为 `auto` 或 `strict`。
101
+
102
+ ## 验证配置已生效
103
+
104
+ 改完配置文件后,用 `/permission` 命令确认:
105
+
106
+ - `/permission` — 显示当前模式 + 可用模式
107
+ - `/permission status` — 显示详细配置(mode/classifier/userRules 全貌)
108
+ - `/permission <mode>` — 切换模式(yolo/auto/approve/strict)
109
+
110
+ `/permission status` 的输出应与你改后的 JSON 一致;不一致说明文件没保存成功或 JSON 语法错误(此时会回落默认值并 console.warn)。
111
+
112
+ ## 配置示例
113
+
114
+ auto 模式 + 指定 classifier 用 glm-4-flash + 自定义危险规则:
115
+ ```json
116
+ {
117
+ "mode": "auto",
118
+ "enabled": true,
119
+ "classifier": {
120
+ "enabled": true,
121
+ "model": "zhipu/glm-4-flash",
122
+ "timeout": 60
123
+ },
124
+ "userRules": [
125
+ {
126
+ "tool": "bash",
127
+ "pattern": "rm -rf *",
128
+ "action": "deny",
129
+ "source": "user",
130
+ "description": "禁止递归删除当前目录"
131
+ }
132
+ ]
133
+ }
134
+ ```
135
+
136
+ ## userRules 字段
137
+
138
+ 每条规则:`{ id?, tool, pattern, action, source, description? }`
139
+ - tool:工具名(`*` 匹配全部,`bash` 精确)
140
+ - pattern:命令模式(wildcard,仅 bash 用)
141
+ - action:`allow` | `deny` | `ask`
142
+ - source:`user`(用户规则;内置规则 builtin-safe/builtin-danger 在代码里不进配置)
143
+ - id 省略时自动分配 `user-<n>`
144
+
145
+ ## classifier LLM 调用
146
+
147
+ - 走 llm-shared callLLM(凭证 getApiKeyAndHeaders,含 OAuth/env/auth.json 三源合并)
148
+ - model 不可用 / 调用失败 / 超时 → fail-closed 降级为 `ask`(转人工审批)
149
+ - classifier 的 stopReason=error/aborted 同样降级 ask
@@ -217,21 +217,28 @@ function makeModelEntry(provider: string, id: string, inputCost: number): Resolv
217
217
  name: id,
218
218
  api: "openai-completions",
219
219
  cost: { input: inputCost, output: 0, cacheRead: 0, cacheWrite: 0 },
220
- hasApiKey: true,
221
220
  };
222
221
  }
223
222
 
224
- /** 构造 mock ctx(mode + ui.notify/custom/select)。 */
223
+ /** 构造 mock ctx(mode + modelRegistry + ui.notify/custom/select)。 */
225
224
  function makeModelPickerCtx(overrides: Partial<ModelPickerContext> = {}): ModelPickerContext {
226
225
  const base: ModelPickerContext = {
227
226
  mode: "rpc",
227
+ modelRegistry: {
228
+ getAll: vi.fn(() => []),
229
+ hasConfiguredAuth: vi.fn(() => false),
230
+ },
228
231
  ui: {
229
232
  notify: vi.fn(),
230
233
  select: vi.fn(() => Promise.resolve("Auto")),
231
234
  custom: vi.fn(() => Promise.resolve(undefined)),
232
235
  },
233
236
  };
234
- return { mode: overrides.mode ?? base.mode, ui: { ...base.ui, ...overrides.ui } };
237
+ return {
238
+ mode: overrides.mode ?? base.mode,
239
+ modelRegistry: overrides.modelRegistry ?? base.modelRegistry,
240
+ ui: { ...base.ui, ...overrides.ui },
241
+ };
235
242
  }
236
243
 
237
244
  /** 构造 mock deps(listModels + save)。 */
@@ -1,12 +1,13 @@
1
1
  /**
2
- * WT1-WT5/WT7: 配置加载/保存/mtime 缓存测试
2
+ * WT1-WT5/WT7: 配置加载/保存/mtime 缓存测试(委托 llm-shared 泛型 config)
3
3
  *
4
- * 用真实 fs + 临时目录(os.tmpdir + 随机子目录),不用 mock fs。
4
+ * 用真实 fs + 临时目录(os.tmpdir + 随机子目录),PI_CODING_AGENT_DIR 指向临时目录隔离,
5
+ * 配置文件路径 = <temp>/config/permission-ext-config.json(llm-shared getConfigPath 推导)。
5
6
  */
6
7
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
7
8
  import { tmpdir } from "node:os";
8
9
  import { join } from "node:path";
9
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
11
 
11
12
  import { DEFAULT_CONFIG } from "../types.js";
12
13
  import {
@@ -17,23 +18,27 @@ import {
17
18
  } from "../config.js";
18
19
 
19
20
  let tempDir: string;
21
+ let configPath: string;
20
22
 
21
23
  beforeEach(() => {
22
24
  tempDir = mkdtempSync(join(tmpdir(), "pi-perm-test-"));
25
+ process.env.PI_CODING_AGENT_DIR = tempDir;
26
+ mkdirSync(join(tempDir, "config"), { recursive: true });
27
+ configPath = join(tempDir, "config", "permission-ext-config.json");
23
28
  clearConfigCache();
24
29
  });
25
30
 
26
31
  afterEach(() => {
32
+ delete process.env.PI_CODING_AGENT_DIR;
27
33
  rmSync(tempDir, { recursive: true, force: true });
28
34
  clearConfigCache();
29
35
  });
30
36
 
31
37
  describe("WT1: 默认配置生成(首次无配置文件)", () => {
32
38
  it("文件不存在时创建默认配置并返回", () => {
33
- const configPath = join(tempDir, "permission-config.json");
34
39
  expect(existsSync(configPath)).toBe(false);
35
40
 
36
- const config = loadAndWatchConfig(configPath);
41
+ const config = loadAndWatchConfig();
37
42
 
38
43
  expect(config.mode).toBe("yolo");
39
44
  expect(config.enabled).toBe(true);
@@ -49,8 +54,7 @@ describe("WT1: 默认配置生成(首次无配置文件)", () => {
49
54
  });
50
55
 
51
56
  it("创建的文件是合法 JSON", () => {
52
- const configPath = join(tempDir, "permission-config.json");
53
- loadAndWatchConfig(configPath);
57
+ loadAndWatchConfig();
54
58
 
55
59
  const content = readFileSync(configPath, "utf-8");
56
60
  expect(() => JSON.parse(content)).not.toThrow();
@@ -59,7 +63,6 @@ describe("WT1: 默认配置生成(首次无配置文件)", () => {
59
63
 
60
64
  describe("WT2: 配置解析(合法配置)", () => {
61
65
  it("正确解析完整配置", () => {
62
- const configPath = join(tempDir, "permission-config.json");
63
66
  const rawConfig = {
64
67
  mode: "auto",
65
68
  enabled: true,
@@ -77,7 +80,7 @@ describe("WT2: 配置解析(合法配置)", () => {
77
80
  };
78
81
  writeFileSync(configPath, JSON.stringify(rawConfig), "utf-8");
79
82
 
80
- const config = loadAndWatchConfig(configPath);
83
+ const config = loadAndWatchConfig();
81
84
 
82
85
  expect(config.mode).toBe("auto");
83
86
  expect(config.classifier.enabled).toBe(false);
@@ -93,11 +96,10 @@ describe("WT2: 配置解析(合法配置)", () => {
93
96
 
94
97
  describe("WT3: 配置解析容错(malformed JSON)", () => {
95
98
  it("malformed JSON fallback 到默认配置,不 throw", () => {
96
- const configPath = join(tempDir, "permission-config.json");
97
99
  writeFileSync(configPath, "{ invalid json missing quotes:", "utf-8");
98
100
 
99
101
  const warnings: string[] = [];
100
- const config = loadAndWatchConfig(configPath, (msg) => warnings.push(msg));
102
+ const config = loadAndWatchConfig((msg) => warnings.push(msg));
101
103
 
102
104
  expect(config.mode).toBe("yolo"); // 默认值
103
105
  expect(config.enabled).toBe(true);
@@ -106,25 +108,64 @@ describe("WT3: 配置解析容错(malformed JSON)", () => {
106
108
  });
107
109
 
108
110
  it("mode 字段非法时 fallback 到 yolo", () => {
109
- const configPath = join(tempDir, "permission-config.json");
110
111
  writeFileSync(configPath, JSON.stringify({ mode: "unknown-mode" }), "utf-8");
111
112
 
112
- const config = loadAndWatchConfig(configPath);
113
+ const config = loadAndWatchConfig();
113
114
  expect(config.mode).toBe("yolo");
114
115
  });
115
116
 
116
117
  it("classifier 字段缺失时用默认值", () => {
117
- const configPath = join(tempDir, "permission-config.json");
118
118
  writeFileSync(configPath, JSON.stringify({ mode: "strict" }), "utf-8");
119
119
 
120
- const config = loadAndWatchConfig(configPath);
120
+ const config = loadAndWatchConfig();
121
121
  expect(config.mode).toBe("strict");
122
122
  expect(config.classifier.model).toBe("auto"); // 默认
123
123
  expect(config.classifier.timeout).toBe(90); // 默认
124
124
  });
125
125
 
126
+ it("TC6 (C3b): classifier.model 传对象形式 → console.warn + 回落默认 auto", () => {
127
+ // 用户照旧设计文档配对象形式 selector(不受支持)
128
+ writeFileSync(configPath, JSON.stringify({ classifier: { model: { type: "available" } } }), "utf-8");
129
+
130
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
131
+ try {
132
+ const config = loadAndWatchConfig();
133
+ // 回落默认 auto(不再静默)
134
+ expect(config.classifier.model).toBe("auto");
135
+ // warn 提示忽略无效 classifier.model
136
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Ignoring invalid classifier.model"));
137
+ } finally {
138
+ warnSpy.mockRestore();
139
+ }
140
+ });
141
+
142
+ it("classifier.model 传数字 → console.warn + 回落默认 auto", () => {
143
+ writeFileSync(configPath, JSON.stringify({ classifier: { model: 42 } }), "utf-8");
144
+
145
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
146
+ try {
147
+ const config = loadAndWatchConfig();
148
+ expect(config.classifier.model).toBe("auto");
149
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Ignoring invalid classifier.model"));
150
+ } finally {
151
+ warnSpy.mockRestore();
152
+ }
153
+ });
154
+
155
+ it("classifier.model 合法 string('auto' / 'provider/model-id')→ 不 warn", () => {
156
+ writeFileSync(configPath, JSON.stringify({ classifier: { model: "zhipu/glm-4-flash" } }), "utf-8");
157
+
158
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
159
+ try {
160
+ const config = loadAndWatchConfig();
161
+ expect(config.classifier.model).toBe("zhipu/glm-4-flash");
162
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Ignoring invalid classifier.model"));
163
+ } finally {
164
+ warnSpy.mockRestore();
165
+ }
166
+ });
167
+
126
168
  it("userRules 含非法条目时过滤掉", () => {
127
- const configPath = join(tempDir, "permission-config.json");
128
169
  writeFileSync(configPath, JSON.stringify({
129
170
  mode: "yolo",
130
171
  userRules: [
@@ -135,7 +176,7 @@ describe("WT3: 配置解析容错(malformed JSON)", () => {
135
176
  ],
136
177
  }), "utf-8");
137
178
 
138
- const config = loadAndWatchConfig(configPath);
179
+ const config = loadAndWatchConfig();
139
180
  expect(config.userRules).toHaveLength(1);
140
181
  expect(config.userRules[0].pattern).toBe("ls");
141
182
  });
@@ -143,12 +184,11 @@ describe("WT3: 配置解析容错(malformed JSON)", () => {
143
184
 
144
185
  describe("WT4: mtime 缓存(文件未变化时不重读)", () => {
145
186
  it("连续两次调用,第二次返回缓存(同 mtime)", () => {
146
- const configPath = join(tempDir, "permission-config.json");
147
- const original = loadAndWatchConfig(configPath);
187
+ const original = loadAndWatchConfig();
148
188
  const originalMode = original.mode;
149
189
 
150
190
  // 不修改文件,第二次调用
151
- const cached = loadAndWatchConfig(configPath);
191
+ const cached = loadAndWatchConfig();
152
192
 
153
193
  // 返回的 config 应该与第一次一致(深相等)
154
194
  expect(cached).toEqual(original);
@@ -158,8 +198,7 @@ describe("WT4: mtime 缓存(文件未变化时不重读)", () => {
158
198
 
159
199
  describe("WT5: mtime 缓存(文件变化时重读)", () => {
160
200
  it("修改文件后第二次调用返回新内容", () => {
161
- const configPath = join(tempDir, "permission-config.json");
162
- const first = loadAndWatchConfig(configPath);
201
+ const first = loadAndWatchConfig();
163
202
  expect(first.mode).toBe("yolo");
164
203
 
165
204
  // 修改文件(写入新 mode)
@@ -170,17 +209,16 @@ describe("WT5: mtime 缓存(文件变化时重读)", () => {
170
209
  const future = new Date(Date.now() + 2000);
171
210
  utimesSync(configPath, future, future);
172
211
 
173
- const second = loadAndWatchConfig(configPath);
212
+ const second = loadAndWatchConfig();
174
213
  expect(second.mode).toBe("strict");
175
214
  });
176
215
  });
177
216
 
178
217
  describe("WT7: 保存配置", () => {
179
218
  it("saveConfig 写入文件并更新缓存", () => {
180
- const configPath = join(tempDir, "permission-config.json");
181
219
  const newConfig = { ...DEFAULT_CONFIG, mode: "strict" as const };
182
220
 
183
- const result = saveConfig(newConfig, configPath);
221
+ const result = saveConfig(newConfig);
184
222
  expect(result.success).toBe(true);
185
223
 
186
224
  // 文件被写入
@@ -190,16 +228,18 @@ describe("WT7: 保存配置", () => {
190
228
  expect(parsed.mode).toBe("strict");
191
229
 
192
230
  // 后续 loadAndWatchConfig 返回新配置(缓存已更新)
193
- const loaded = loadAndWatchConfig(configPath);
231
+ const loaded = loadAndWatchConfig();
194
232
  expect(loaded.mode).toBe("strict");
195
233
  });
196
234
 
197
235
  it("saveConfig 失败时返回 error(只读目录)", () => {
198
236
  const readOnlyDir = join(tempDir, "readonly");
199
237
  mkdirSync(readOnlyDir, { mode: 0o500 });
200
- const configPath = join(readOnlyDir, "permission-config.json");
238
+ // 直接把 agentDir 指向只读目录(config/ 建不出来 / tmp 写不进)
239
+ process.env.PI_CODING_AGENT_DIR = readOnlyDir;
240
+ clearConfigCache();
201
241
 
202
- const result = saveConfig(DEFAULT_CONFIG, configPath);
242
+ const result = saveConfig(DEFAULT_CONFIG);
203
243
  // 可能成功也可能失败取决于系统权限实现,主要验证不 throw
204
244
  expect(typeof result.success).toBe("boolean");
205
245
  if (!result.success) {
@@ -208,8 +248,7 @@ describe("WT7: 保存配置", () => {
208
248
  });
209
249
 
210
250
  it("saveConfig 后文件权限是 0o600(仅用户可读写)", () => {
211
- const configPath = join(tempDir, "permission-config.json");
212
- saveConfig(DEFAULT_CONFIG, configPath);
251
+ saveConfig(DEFAULT_CONFIG);
213
252
 
214
253
  const stat = statSync(configPath);
215
254
  // macOS/Linux 下 mode & 0o777 应该是 0o600
@@ -221,9 +260,38 @@ describe("WT7: 保存配置", () => {
221
260
  });
222
261
 
223
262
  describe("getConfigPath", () => {
224
- it("返回 ~/.pi/agent/permission-config.json 路径", () => {
263
+ it("返回 <agentDir>/config/permission-ext-config.json 路径", () => {
225
264
  const path = getConfigPath();
226
- expect(path).toContain("permission-config.json");
227
- expect(path).toContain(".pi");
265
+ expect(path).toContain("config");
266
+ expect(path).toContain("permission-ext-config.json");
267
+ // 受 PI_CODING_AGENT_DIR 隔离(本测试 beforeEach 已指向临时目录)
268
+ expect(path.startsWith(tempDir)).toBe(true);
269
+ });
270
+ });
271
+
272
+ describe("[MIGRATION] legacy config 降级告警(ensureConfigFile 兜底)", () => {
273
+ it("旧路径残留 + 新路径缺失 → console.warn 提醒 strict→yolo 降级", () => {
274
+ // 写 legacy 文件到 agentDir 根(PI_CODING_AGENT_DIR=tempDir)
275
+ const legacyPath = join(tempDir, "permission-config.json");
276
+ writeFileSync(legacyPath, JSON.stringify({ mode: "strict" }), "utf-8");
277
+
278
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
279
+ try {
280
+ loadAndWatchConfig();
281
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Legacy config detected"));
282
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("downgrade"));
283
+ } finally {
284
+ warnSpy.mockRestore();
285
+ }
286
+ });
287
+
288
+ it("旧路径不存在 → 不触发 legacy 告警", () => {
289
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
290
+ try {
291
+ loadAndWatchConfig();
292
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Legacy config detected"));
293
+ } finally {
294
+ warnSpy.mockRestore();
295
+ }
228
296
  });
229
297
  });