@zhushanwen/pi-permission 0.0.1
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 +455 -0
- package/index.ts +2 -0
- package/package.json +52 -0
- package/src/__tests__/approval.test.ts +456 -0
- package/src/__tests__/commands.test.ts +487 -0
- package/src/__tests__/config.test.ts +229 -0
- package/src/__tests__/e2e-modes.test.ts +253 -0
- package/src/__tests__/extension.test.ts +170 -0
- package/src/__tests__/index-integration.test.ts +282 -0
- package/src/__tests__/model-picker.test.ts +371 -0
- package/src/__tests__/pipeline.test.ts +976 -0
- package/src/__tests__/production.test.ts +102 -0
- package/src/__tests__/rule-editor-component.test.ts +475 -0
- package/src/__tests__/rule-editor.test.ts +252 -0
- package/src/__tests__/rule-templates.test.ts +264 -0
- package/src/__tests__/statusline.test.ts +221 -0
- package/src/__tests__/types.test.ts +59 -0
- package/src/approval.ts +352 -0
- package/src/ast/__tests__/analyzer.test.ts +262 -0
- package/src/ast/__tests__/loader.test.ts +136 -0
- package/src/ast/analyzer.ts +326 -0
- package/src/ast/index.ts +7 -0
- package/src/ast/loader.ts +113 -0
- package/src/classifier/__tests__/classifier.test.ts +285 -0
- package/src/classifier/__tests__/json-parser.test.ts +118 -0
- package/src/classifier/__tests__/model-resolver.test.ts +173 -0
- package/src/classifier/__tests__/prompt.test.ts +59 -0
- package/src/classifier/classifier.ts +242 -0
- package/src/classifier/index.ts +28 -0
- package/src/classifier/json-parser.ts +127 -0
- package/src/classifier/model-resolver.ts +295 -0
- package/src/classifier/prompt.ts +52 -0
- package/src/commands.ts +234 -0
- package/src/config.ts +216 -0
- package/src/index.ts +291 -0
- package/src/model-picker.ts +398 -0
- package/src/pipeline.ts +541 -0
- package/src/production.ts +102 -0
- package/src/rule-editor-component.ts +883 -0
- package/src/rule-editor.ts +262 -0
- package/src/rule-templates.ts +332 -0
- package/src/rules/__tests__/builtins.test.ts +614 -0
- package/src/rules/__tests__/index.test.ts +26 -0
- package/src/rules/__tests__/matcher.test.ts +378 -0
- package/src/rules/__tests__/wildcard.test.ts +84 -0
- package/src/rules/builtins.ts +680 -0
- package/src/rules/index.ts +27 -0
- package/src/rules/matcher.ts +160 -0
- package/src/rules/wildcard.ts +43 -0
- package/src/statusline.ts +226 -0
- package/src/types.ts +249 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* statusline.test.ts — W6 T10: createPermissionFooter + renderPermissionLine 测试。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(4 case):
|
|
5
|
+
* 1. renderPermissionLine:四档模式 + enabled/disabled 渲染正确
|
|
6
|
+
* 2. createPermissionFooter:factory 返回 FooterHandle(dispose/invalidate/render)
|
|
7
|
+
* 3. render 缓存:同 width 第二次返回缓存;invalidate 后重算
|
|
8
|
+
* 4. registerPermissionFooter:duck typing(无 setFooter/theme 时跳过)
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createPermissionFooter,
|
|
14
|
+
type FooterFactory,
|
|
15
|
+
type FooterHandle,
|
|
16
|
+
MODE_ACCENT_TOKEN,
|
|
17
|
+
paletteFromTheme,
|
|
18
|
+
registerPermissionFooter,
|
|
19
|
+
renderPermissionLine,
|
|
20
|
+
type PermissionPalette,
|
|
21
|
+
} from "../statusline.js";
|
|
22
|
+
import type { PermissionMode } from "../types.js";
|
|
23
|
+
import { MODE_LABELS } from "../types.js";
|
|
24
|
+
|
|
25
|
+
// ──────────────────────── mock helpers ────────────────────────
|
|
26
|
+
|
|
27
|
+
/** 透传 palette(不加 ANSI,便于断言原始字符串)。 */
|
|
28
|
+
function passthroughPalette(): PermissionPalette {
|
|
29
|
+
return {
|
|
30
|
+
dim: (s) => s,
|
|
31
|
+
text: (s) => s,
|
|
32
|
+
accent: (s) => s,
|
|
33
|
+
success: (s) => s,
|
|
34
|
+
warning: (s) => s,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** mock theme(fg 透传,记录 token)。 */
|
|
39
|
+
function mockTheme(): { fg: (token: string, text: string) => string; calls: Array<{ token: string; text: string }> } {
|
|
40
|
+
const calls: Array<{ token: string; text: string }> = [];
|
|
41
|
+
return {
|
|
42
|
+
fg(token: string, text: string): string {
|
|
43
|
+
calls.push({ token, text });
|
|
44
|
+
return text;
|
|
45
|
+
},
|
|
46
|
+
calls,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ALL_MODES: PermissionMode[] = ["yolo", "auto", "approve", "strict"];
|
|
51
|
+
|
|
52
|
+
// ──────────────────────── case 1: renderPermissionLine ────────────────────────
|
|
53
|
+
|
|
54
|
+
describe("W6 T10 case 1: renderPermissionLine(四档模式 + enabled)", () => {
|
|
55
|
+
it("每个模式渲染对应的 MODE_LABELS", () => {
|
|
56
|
+
const palette = passthroughPalette();
|
|
57
|
+
for (const mode of ALL_MODES) {
|
|
58
|
+
const lines = renderPermissionLine(mode, true, palette);
|
|
59
|
+
expect(lines).toHaveLength(1);
|
|
60
|
+
expect(lines[0]).toContain(MODE_LABELS[mode]);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("enabled=true 渲染 'enabled' + 模式 detail", () => {
|
|
65
|
+
const palette = passthroughPalette();
|
|
66
|
+
const lines = renderPermissionLine("strict", true, palette);
|
|
67
|
+
expect(lines[0]).toContain("enabled");
|
|
68
|
+
expect(lines[0]).toContain("all require approval");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("enabled=false 渲染 'disabled' + passthrough 提示", () => {
|
|
72
|
+
const palette = passthroughPalette();
|
|
73
|
+
const lines = renderPermissionLine("yolo", false, palette);
|
|
74
|
+
expect(lines[0]).toContain("disabled");
|
|
75
|
+
expect(lines[0]).toContain("passthrough");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("yolo 模式 detail 是 'all tools allowed'", () => {
|
|
79
|
+
const lines = renderPermissionLine("yolo", true, passthroughPalette());
|
|
80
|
+
expect(lines[0]).toContain("all tools allowed");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("auto 模式 detail 是 'AST + rules + AI classifier'", () => {
|
|
84
|
+
const lines = renderPermissionLine("auto", true, passthroughPalette());
|
|
85
|
+
expect(lines[0]).toContain("AST + rules + AI classifier");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("行含 [pi-permission] 前缀(grep 友好)", () => {
|
|
89
|
+
const lines = renderPermissionLine("auto", true, passthroughPalette());
|
|
90
|
+
expect(lines[0]).toContain("[pi-permission]");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("MODE_ACCENT_TOKEN 映射完整(四档模式都有对应 token)", () => {
|
|
94
|
+
for (const mode of ALL_MODES) {
|
|
95
|
+
expect(MODE_ACCENT_TOKEN[mode]).toBeDefined();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ──────────────────────── case 2: createPermissionFooter factory ────────────────────────
|
|
101
|
+
|
|
102
|
+
describe("W6 T10 case 2: createPermissionFooter(factory 返回 FooterHandle)", () => {
|
|
103
|
+
it("factory 返回含 dispose/invalidate/render 的 handle", () => {
|
|
104
|
+
const palette = passthroughPalette();
|
|
105
|
+
const factory = createPermissionFooter(() => "auto", () => true, palette);
|
|
106
|
+
const handle = factory({ requestRender() {} }, {}, {});
|
|
107
|
+
expect(typeof handle.dispose).toBe("function");
|
|
108
|
+
expect(typeof handle.invalidate).toBe("function");
|
|
109
|
+
expect(typeof handle.render).toBe("function");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("dispose 不抛异常(noop)", () => {
|
|
113
|
+
const palette = passthroughPalette();
|
|
114
|
+
const factory = createPermissionFooter(() => "yolo", () => true, palette);
|
|
115
|
+
const handle = factory({ requestRender() {} }, {}, {});
|
|
116
|
+
expect(() => handle.dispose()).not.toThrow();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("render 返回行数组(含当前模式)", () => {
|
|
120
|
+
const palette = passthroughPalette();
|
|
121
|
+
const factory = createPermissionFooter(() => "strict", () => true, palette);
|
|
122
|
+
const handle = factory({ requestRender() {} }, {}, {});
|
|
123
|
+
const lines = handle.render(80);
|
|
124
|
+
expect(Array.isArray(lines)).toBe(true);
|
|
125
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
126
|
+
expect(lines.join(" ")).toContain("Strict");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("getMode/getEnabled 闭包读最新值(mode 变化反映到 render)", () => {
|
|
130
|
+
let currentMode: PermissionMode = "yolo";
|
|
131
|
+
const palette = passthroughPalette();
|
|
132
|
+
const factory = createPermissionFooter(() => currentMode, () => true, palette);
|
|
133
|
+
const handle = factory({ requestRender() {} }, {}, {});
|
|
134
|
+
const lines1 = handle.render(80);
|
|
135
|
+
expect(lines1.join(" ")).toContain("YOLO");
|
|
136
|
+
|
|
137
|
+
// 切换 mode 后 invalidate + 重 render
|
|
138
|
+
currentMode = "strict";
|
|
139
|
+
handle.invalidate();
|
|
140
|
+
const lines2 = handle.render(80);
|
|
141
|
+
expect(lines2.join(" ")).toContain("Strict");
|
|
142
|
+
expect(lines2.join(" ")).not.toContain("YOLO");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ──────────────────────── case 3: render 缓存 ────────────────────────
|
|
147
|
+
|
|
148
|
+
describe("W6 T10 case 3: render 缓存(同 width 复用,invalidate 重算)", () => {
|
|
149
|
+
it("同 width 第二次 render 返回缓存(同一引用)", () => {
|
|
150
|
+
const palette = passthroughPalette();
|
|
151
|
+
const factory = createPermissionFooter(() => "auto", () => true, palette);
|
|
152
|
+
const handle = factory({ requestRender() {} }, {}, {});
|
|
153
|
+
const first = handle.render(100);
|
|
154
|
+
const second = handle.render(100);
|
|
155
|
+
expect(second).toBe(first); // 同一引用(缓存命中)
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("不同 width → 重新渲染(缓存 key 含 width)", () => {
|
|
159
|
+
const palette = passthroughPalette();
|
|
160
|
+
const factory = createPermissionFooter(() => "auto", () => true, palette);
|
|
161
|
+
const handle = factory({ requestRender() {} }, {}, {});
|
|
162
|
+
const first = handle.render(80);
|
|
163
|
+
const second = handle.render(120);
|
|
164
|
+
expect(second).not.toBe(first); // 不同引用(width 变了)
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("invalidate 后同 width → 重新渲染", () => {
|
|
168
|
+
const palette = passthroughPalette();
|
|
169
|
+
const factory = createPermissionFooter(() => "auto", () => true, palette);
|
|
170
|
+
const handle = factory({ requestRender() {} }, {}, {});
|
|
171
|
+
const first = handle.render(80);
|
|
172
|
+
handle.invalidate();
|
|
173
|
+
const second = handle.render(80);
|
|
174
|
+
expect(second).not.toBe(first); // invalidate 后重新渲染
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ──────────────────────── case 4: registerPermissionFooter duck typing ────────────────────────
|
|
179
|
+
|
|
180
|
+
describe("W6 T10 case 4: registerPermissionFooter(duck typing 跳过)", () => {
|
|
181
|
+
it("ctx.ui 含 setFooter + theme → 调用 setFooter 注册 factory", () => {
|
|
182
|
+
const setFooter = vi.fn();
|
|
183
|
+
const theme = mockTheme();
|
|
184
|
+
registerPermissionFooter({ setFooter, theme }, () => "auto", () => true);
|
|
185
|
+
expect(setFooter).toHaveBeenCalledOnce();
|
|
186
|
+
const factory = setFooter.mock.calls[0]![0] as FooterFactory;
|
|
187
|
+
expect(typeof factory).toBe("function");
|
|
188
|
+
// 验证 factory 产出可用
|
|
189
|
+
const handle: FooterHandle = factory({ requestRender() {} }, {}, {});
|
|
190
|
+
expect(handle.render(80).length).toBeGreaterThan(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("ctx.ui 无 setFooter → 跳过(不抛异常)", () => {
|
|
194
|
+
const theme = mockTheme();
|
|
195
|
+
// 只有 theme,没有 setFooter
|
|
196
|
+
expect(() => registerPermissionFooter({ theme }, () => "auto", () => true)).not.toThrow();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("ctx.ui 无 theme → 跳过", () => {
|
|
200
|
+
const setFooter = vi.fn();
|
|
201
|
+
// 只有 setFooter,没有 theme
|
|
202
|
+
registerPermissionFooter({ setFooter }, () => "auto", () => true);
|
|
203
|
+
expect(setFooter).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("ctx.ui.theme 无 fg 函数 → 跳过", () => {
|
|
207
|
+
const setFooter = vi.fn();
|
|
208
|
+
const theme = { fg: "not-a-function" };
|
|
209
|
+
registerPermissionFooter({ setFooter, theme }, () => "auto", () => true);
|
|
210
|
+
expect(setFooter).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("paletteFromTheme 从 theme 构造 palette(透传 fg 调用)", () => {
|
|
214
|
+
const theme = mockTheme();
|
|
215
|
+
const palette = paletteFromTheme(theme);
|
|
216
|
+
palette.dim("test-dim");
|
|
217
|
+
palette.accent("test-accent");
|
|
218
|
+
expect(theme.calls.some((c) => c.token === "dim" && c.text === "test-dim")).toBe(true);
|
|
219
|
+
expect(theme.calls.some((c) => c.token === "accent" && c.text === "test-accent")).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WT6: PermissionMode 枚举完整性测试
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_CONFIG,
|
|
8
|
+
isValidPermissionMode,
|
|
9
|
+
MODE_DESCRIPTIONS,
|
|
10
|
+
MODE_LABELS,
|
|
11
|
+
PERMISSION_MODES,
|
|
12
|
+
} from "../types.js";
|
|
13
|
+
|
|
14
|
+
describe("WT6: PermissionMode 枚举完整性", () => {
|
|
15
|
+
it("PERMISSION_MODES 包含四档且顺序正确(严格等级低→高)", () => {
|
|
16
|
+
expect(PERMISSION_MODES).toEqual(["yolo", "auto", "approve", "strict"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("MODE_DESCRIPTIONS 四个 key 都有值", () => {
|
|
20
|
+
for (const mode of PERMISSION_MODES) {
|
|
21
|
+
expect(MODE_DESCRIPTIONS[mode]).toBeTruthy();
|
|
22
|
+
expect(typeof MODE_DESCRIPTIONS[mode]).toBe("string");
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("MODE_LABELS 四个 key 都有值", () => {
|
|
27
|
+
for (const mode of PERMISSION_MODES) {
|
|
28
|
+
expect(MODE_LABELS[mode]).toBeTruthy();
|
|
29
|
+
expect(typeof MODE_LABELS[mode]).toBe("string");
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("isValidPermissionMode 对四档返回 true", () => {
|
|
34
|
+
for (const mode of PERMISSION_MODES) {
|
|
35
|
+
expect(isValidPermissionMode(mode)).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("isValidPermissionMode 对非法值返回 false", () => {
|
|
40
|
+
expect(isValidPermissionMode("Yolo")).toBe(false); // 大小写敏感
|
|
41
|
+
expect(isValidPermissionMode("read-only")).toBe(false);
|
|
42
|
+
expect(isValidPermissionMode("")).toBe(false);
|
|
43
|
+
expect(isValidPermissionMode(undefined)).toBe(false);
|
|
44
|
+
expect(isValidPermissionMode(null)).toBe(false);
|
|
45
|
+
expect(isValidPermissionMode(123)).toBe(false);
|
|
46
|
+
expect(isValidPermissionMode({ mode: "yolo" })).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("DEFAULT_CONFIG 有合理默认值", () => {
|
|
50
|
+
expect(DEFAULT_CONFIG.mode).toBe("yolo");
|
|
51
|
+
expect(DEFAULT_CONFIG.enabled).toBe(true);
|
|
52
|
+
expect(DEFAULT_CONFIG.classifier.enabled).toBe(true);
|
|
53
|
+
expect(DEFAULT_CONFIG.classifier.model).toBe("auto");
|
|
54
|
+
expect(DEFAULT_CONFIG.classifier.timeout).toBe(90);
|
|
55
|
+
expect(DEFAULT_CONFIG.classifier.autoApproveLowRisk).toBe(true);
|
|
56
|
+
expect(DEFAULT_CONFIG.classifier.autoDenyHighRisk).toBe(true);
|
|
57
|
+
expect(DEFAULT_CONFIG.userRules).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/approval.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用户审批 UI(W5 集成层 + W6 T9 Reject-with-Reason)。
|
|
3
|
+
*
|
|
4
|
+
* 三分支(按 ctx.mode 分发,ctx 由 caller 从 Pi ExtensionContext 提取):
|
|
5
|
+
* - tui:ctx.ui.custom 自定义 Component(ApprovalComponent,G4 补 invalidate)。
|
|
6
|
+
* G3:调 ctx.ui.custom 前先检查 signal.aborted 短路(AI 先于 UI factory 执行时
|
|
7
|
+
* controller.abort() 触发但 comp 尚未创建,cancel 落空)。
|
|
8
|
+
* - rpc:ctx.ui.select(GUI 对话框)。M2:接收 signal,abort 短路 + 透传给 ui.select。
|
|
9
|
+
* - headless(json/print):无交互 UI。M1:返回永不主动 resolve 的 Promise(让 Racing 中的
|
|
10
|
+
* AI 有机会赢),仅在 signal abort 时 fail-closed deny(避免 headless 下 auto 退化为 strict)。
|
|
11
|
+
*
|
|
12
|
+
* ApprovalComponent 是简化版 TUI 组件(参考 ask-user AskUserComponent):
|
|
13
|
+
* - 显示工具名 + 命令 + 触发原因 + 可选 AI 预分类。
|
|
14
|
+
* - y/approve、n/deny、Esc/cancel。
|
|
15
|
+
* - signal abort → comp.cancel()(复用 _resolved 守卫,避免二次 done)。
|
|
16
|
+
*
|
|
17
|
+
* W6 T9 G3 Reject-with-Reason:用户拒绝时,若 ctx.ui.input 存在则弹出文本输入框
|
|
18
|
+
* 采集真实拒绝理由(回传给 agent,辅助理解为何被拒);ctx.ui.input 不存在则 fallback
|
|
19
|
+
* 用固定 "denied via rpc/tui" 文案。当前 RPC 分支已完整接入 ctx.ui.input;
|
|
20
|
+
* TUI 分支因 pi-tui Input 组件集成成本较高,暂保留简化 deny(TODO 后续迭代)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { type Component, matchesKey, type SelectItem, truncateToWidth } from "@earendil-works/pi-tui";
|
|
24
|
+
|
|
25
|
+
import type { ApprovalRequest } from "./pipeline.js";
|
|
26
|
+
import type { ToolInvocationContext, UserDecision } from "./types.js";
|
|
27
|
+
|
|
28
|
+
// ──────────────────────── ApprovalContext(从 ExtensionContext 提取的最小子集) ────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 审批 UI 上下文(从 Pi ExtensionContext 提取的最小子集)。
|
|
32
|
+
*
|
|
33
|
+
* 提取为独立接口便于:
|
|
34
|
+
* - 测试 mock(不依赖完整 ExtensionContext)。
|
|
35
|
+
* - 明确 requestUserApproval 只用 mode + ui.{custom,select,notify,input}。
|
|
36
|
+
*
|
|
37
|
+
* mode 来自 ExtensionContext.mode("tui"|"rpc"|"json"|"print")。
|
|
38
|
+
* headless = mode !== "tui" && mode !== "rpc"(json/print 无交互 UI)。
|
|
39
|
+
*
|
|
40
|
+
* W6 T9 G3:ui.input 可选(SDK 提供,但测试 mock 可能缺失)。
|
|
41
|
+
* Reject-with-Reason 的「受阻」定义为可观测条件:
|
|
42
|
+
* - ui.input 存在(typeof === 'function')→ 采集真实 reason
|
|
43
|
+
* - ui.input 缺失 → fallback 固定文案
|
|
44
|
+
*/
|
|
45
|
+
export interface ApprovalContext {
|
|
46
|
+
mode: "tui" | "rpc" | "json" | "print";
|
|
47
|
+
ui: {
|
|
48
|
+
notify(msg: string, type?: "info" | "warning" | "error"): void;
|
|
49
|
+
select(title: string, options: string[], opts?: { signal?: AbortSignal; timeout?: number }): Promise<string | undefined>;
|
|
50
|
+
custom<T = void>(
|
|
51
|
+
factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => Component,
|
|
52
|
+
options?: { overlay?: boolean },
|
|
53
|
+
): Promise<T>;
|
|
54
|
+
/** W6 T9 G3:可选文本输入(Reject-with-Reason 用)。SDK 提供,mock 可能缺失。 */
|
|
55
|
+
input?(title: string, placeholder?: string, opts?: { signal?: AbortSignal; timeout?: number }): Promise<string | undefined>;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ──────────────────────── requestUserApproval ────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 请求用户审批(按 mode 分发)。
|
|
63
|
+
*
|
|
64
|
+
* @param req 审批请求数据(工具名 + 命令 + 原因 + 可选 AI 预分类)
|
|
65
|
+
* @param _ctxUnused 未使用(保留签名对称性;实际 UI ctx 在 approvalCtx)
|
|
66
|
+
* @param signal abort signal(racing 用:AI 赢时 abort,UI 短路)
|
|
67
|
+
* @param approvalCtx UI 上下文(mode + ui.*)
|
|
68
|
+
* @returns UserDecision(approved + reason)
|
|
69
|
+
*/
|
|
70
|
+
export async function requestUserApproval(
|
|
71
|
+
req: ApprovalRequest,
|
|
72
|
+
_ctxUnused: ToolInvocationContext,
|
|
73
|
+
signal: AbortSignal | undefined,
|
|
74
|
+
approvalCtx: ApprovalContext,
|
|
75
|
+
): Promise<UserDecision> {
|
|
76
|
+
switch (approvalCtx.mode) {
|
|
77
|
+
case "tui":
|
|
78
|
+
return await requestTui(req, signal, approvalCtx);
|
|
79
|
+
case "rpc":
|
|
80
|
+
// M2:传 signal 给 requestRpc(abort 时短路 + 透传给 ui.select 关闭对话框)
|
|
81
|
+
return await requestRpc(req, signal, approvalCtx);
|
|
82
|
+
case "json":
|
|
83
|
+
case "print":
|
|
84
|
+
default:
|
|
85
|
+
// headless 立即 fail-closed deny(无 UI 无 AI 可判)。
|
|
86
|
+
// auto 模式的 Racing 在 headless 下不调用此路径(见 runLayer3WithRacing 的 isHeadless 分支)。
|
|
87
|
+
return requestHeadless(req, approvalCtx);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ──────────────────────── TUI 分支(G3 + G4) ────────────────────────
|
|
92
|
+
|
|
93
|
+
/** TUI 模式:ctx.ui.custom + ApprovalComponent。G3:先检查 signal.aborted。 */
|
|
94
|
+
async function requestTui(
|
|
95
|
+
req: ApprovalRequest,
|
|
96
|
+
signal: AbortSignal | undefined,
|
|
97
|
+
approvalCtx: ApprovalContext,
|
|
98
|
+
): Promise<UserDecision> {
|
|
99
|
+
// G3 修正:AI 先于 UI factory 执行时,controller.abort() 已触发但 comp 未创建。
|
|
100
|
+
// 在调 ctx.ui.custom 前检查 signal.aborted,短路返回 deny(fail-closed)。
|
|
101
|
+
if (signal?.aborted) {
|
|
102
|
+
approvalCtx.ui.notify(`[pi-permission] approval aborted before prompt: ${req.toolName}`, "warning");
|
|
103
|
+
return { approved: false, reason: "aborted before prompt (AI won the race)" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return approvalCtx.ui.custom<UserDecision>((tui, _theme, _kb, done) => {
|
|
107
|
+
const comp = new ApprovalComponent(req, tui as TuiLike, done);
|
|
108
|
+
// signal abort → comp.cancel()(复用 _resolved 守卫,避免二次 done)
|
|
109
|
+
if (signal) {
|
|
110
|
+
signal.addEventListener("abort", () => comp.cancel(), { once: true });
|
|
111
|
+
}
|
|
112
|
+
return comp;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ──────────────────────── RPC 分支 ────────────────────────
|
|
117
|
+
|
|
118
|
+
/** RPC 模式:ctx.ui.select(GUI 单选对话框)+ W6 T9 G3 Reject-with-Reason。 */
|
|
119
|
+
async function requestRpc(
|
|
120
|
+
req: ApprovalRequest,
|
|
121
|
+
signal: AbortSignal | undefined,
|
|
122
|
+
approvalCtx: ApprovalContext,
|
|
123
|
+
): Promise<UserDecision> {
|
|
124
|
+
const title = formatTitle(req);
|
|
125
|
+
// TODO: spec 未要求 session/always scope,当前仅支持 once。
|
|
126
|
+
// 未来如需扩展,options 加 "Approve (session)" / "Approve (always)",UserDecision.scope 透传。
|
|
127
|
+
const options = ["Approve (once)", "Deny"];
|
|
128
|
+
// M2:与 requestTui 一致——调 select 前检查 signal.aborted 短路(AI 先于对话框弹出赢 race 时,
|
|
129
|
+
// controller.abort() 已触发但 select 尚未发起,这里 fail-closed deny 避免弹出无意义对话框)。
|
|
130
|
+
if (signal?.aborted) {
|
|
131
|
+
approvalCtx.ui.notify(`[pi-permission] approval aborted before prompt: ${req.toolName}`, "warning");
|
|
132
|
+
return { approved: false, reason: "aborted before prompt (AI won the race)" };
|
|
133
|
+
}
|
|
134
|
+
// M2:把 signal 透传给 ui.select(SDK 支持 { signal } options),AI 赢 race abort 时关闭对话框。
|
|
135
|
+
const choice = await approvalCtx.ui.select(title, options, signal ? { signal } : undefined);
|
|
136
|
+
if (choice === undefined) {
|
|
137
|
+
return { approved: false, reason: "user dismissed the prompt" };
|
|
138
|
+
}
|
|
139
|
+
if (choice.startsWith("Approve")) {
|
|
140
|
+
return { approved: true, reason: "approved via rpc", scope: "once" };
|
|
141
|
+
}
|
|
142
|
+
// W6 T9 G3:Reject-with-Reason。用户选 Deny 后,若 ctx.ui.input 存在则采集真实理由。
|
|
143
|
+
// 「受阻」可观测条件:typeof ctx.ui.input === 'function'。
|
|
144
|
+
return { approved: false, reason: await collectRejectReason(req, approvalCtx) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* W6 T9 G3:采集拒绝理由(Reject-with-Reason)。
|
|
149
|
+
*
|
|
150
|
+
* 可观测条件:ctx.ui.input 存在(typeof === 'function')→ 弹文本输入框采集真实 reason。
|
|
151
|
+
* 否则 fallback 固定文案("denied via <mode>")。
|
|
152
|
+
*
|
|
153
|
+
* 空输入(用户直接回车)也 fallback 固定文案(不强制要求理由)。
|
|
154
|
+
*
|
|
155
|
+
* @param req 审批请求(用于构造提示标题)
|
|
156
|
+
* @param approvalCtx UI 上下文
|
|
157
|
+
* @returns 拒绝理由字符串(真实采集或 fallback)
|
|
158
|
+
*/
|
|
159
|
+
export async function collectRejectReason(
|
|
160
|
+
req: ApprovalRequest,
|
|
161
|
+
approvalCtx: ApprovalContext,
|
|
162
|
+
): Promise<string> {
|
|
163
|
+
// 「受阻」可观测条件:ctx.ui.input 必须是函数
|
|
164
|
+
if (typeof approvalCtx.ui.input !== "function") {
|
|
165
|
+
return `denied via ${approvalCtx.mode}`;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const reason = await approvalCtx.ui.input(
|
|
169
|
+
`[pi-permission] Reason for denying ${req.toolName}${req.command ? `: ${req.command}` : ""} (optional, press Enter to skip)`,
|
|
170
|
+
"Why are you denying this?",
|
|
171
|
+
);
|
|
172
|
+
// 空输入或 undefined(用户跳过)→ fallback
|
|
173
|
+
if (reason === undefined || reason.trim().length === 0) {
|
|
174
|
+
return `denied via ${approvalCtx.mode}`;
|
|
175
|
+
}
|
|
176
|
+
return `denied via ${approvalCtx.mode}: ${reason.trim()}`;
|
|
177
|
+
} catch {
|
|
178
|
+
// input 调用异常 → fail-soft fallback(不阻塞 deny)
|
|
179
|
+
return `denied via ${approvalCtx.mode}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ──────────────────────── headless 分支 ────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* headless(json/print):无交互 UI → fail-closed deny。
|
|
187
|
+
*
|
|
188
|
+
* 此函数服务于 strict/approve 的 `askUser` 路径——这些路径无 AI 层可兜底,
|
|
189
|
+
* headless 下既无法问用户也无法判风险,立即 deny 是唯一正确行为。
|
|
190
|
+
*
|
|
191
|
+
* auto 模式的 Racing(`runLayer3WithRacing`)在 headless 下不调用此函数——
|
|
192
|
+
* `CheckPermissionDeps.isHeadless()` 让 Racing 跳过 user promise,纯等 AI 判定
|
|
193
|
+
* (M1 修正:headless 下 auto 应让 AI classifier 有机会赢,而非立即 deny 退化成 strict)。
|
|
194
|
+
*/
|
|
195
|
+
function requestHeadless(req: ApprovalRequest, approvalCtx: ApprovalContext): UserDecision {
|
|
196
|
+
approvalCtx.ui.notify(`[pi-permission] headless mode auto-deny: ${req.reason}`, "warning");
|
|
197
|
+
return { approved: false, reason: `headless mode (${approvalCtx.mode}): cannot prompt, auto-deny` };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ──────────────────────── 辅助:标题格式化 ────────────────────────
|
|
201
|
+
|
|
202
|
+
/** 格式化审批标题(RPC select / TUI 顶部)。 */
|
|
203
|
+
function formatTitle(req: ApprovalRequest): string {
|
|
204
|
+
const lines: string[] = [];
|
|
205
|
+
lines.push(`[pi-permission] Approval required`);
|
|
206
|
+
lines.push(`Tool: ${req.toolName}`);
|
|
207
|
+
if (req.command !== undefined && req.command.length > 0) {
|
|
208
|
+
lines.push(`Command: ${req.command}`);
|
|
209
|
+
}
|
|
210
|
+
lines.push(`Reason: ${req.reason}`);
|
|
211
|
+
if (req.preClassification) {
|
|
212
|
+
const pc = req.preClassification;
|
|
213
|
+
lines.push(`AI: risk=${pc.risk_level} outcome=${pc.outcome} (conf=${pc.confidence})`);
|
|
214
|
+
}
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ──────────────────────── ApprovalComponent(TUI 组件,G4 invalidate) ────────────────────────
|
|
219
|
+
|
|
220
|
+
/** 最小 TUI 接口(满足真实 TUI 和测试 stub)。 */
|
|
221
|
+
interface TuiLike {
|
|
222
|
+
requestRender(): void;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* TUI 审批组件(简化版,参考 ask-user AskUserComponent)。
|
|
227
|
+
*
|
|
228
|
+
* 显示:标题 + 工具名 + 命令 + 原因 + 可选 AI 预分类 + 操作提示。
|
|
229
|
+
* 键位:y/Enter → approve;n/Esc → deny/cancel。
|
|
230
|
+
*
|
|
231
|
+
* G4:implements Component,补 invalidate()(调 tui.requestRender())。
|
|
232
|
+
* signal abort → cancel()(复用 _resolved 守卫)。
|
|
233
|
+
*
|
|
234
|
+
* TODO W6:若需更丰富的 TUI(方向键选择 once/session/always scope、高亮风险等级、
|
|
235
|
+
* Reject-with-Reason 的内联文本输入),参考 ask-user AskUserComponent 的 SelectList
|
|
236
|
+
* + pi-tui Input 组件集成。当前简化版的 deny 走固定文案(RPC 分支已接入 ctx.ui.input
|
|
237
|
+
* 采集真实理由,TUI 分支因 Input 组件集成成本较高暂保留简化 deny,后续迭代补齐)。
|
|
238
|
+
*/
|
|
239
|
+
export class ApprovalComponent implements Component {
|
|
240
|
+
private readonly req: ApprovalRequest;
|
|
241
|
+
private readonly tui: TuiLike;
|
|
242
|
+
private readonly done: (result: UserDecision) => void;
|
|
243
|
+
private _resolved = false;
|
|
244
|
+
|
|
245
|
+
// 渲染缓存(width → lines),invalidate 清空。
|
|
246
|
+
private cachedWidth: number | undefined;
|
|
247
|
+
private cachedLines: string[] | undefined;
|
|
248
|
+
|
|
249
|
+
constructor(req: ApprovalRequest, tui: TuiLike, done: (result: UserDecision) => void) {
|
|
250
|
+
this.req = req;
|
|
251
|
+
this.tui = tui;
|
|
252
|
+
this.done = done;
|
|
253
|
+
this.invalidate();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** G4:Component 接口要求。失效渲染缓存 + 请求重绘。 */
|
|
257
|
+
invalidate(): void {
|
|
258
|
+
this.cachedWidth = undefined;
|
|
259
|
+
this.cachedLines = undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private rerender(): void {
|
|
263
|
+
this.invalidate();
|
|
264
|
+
this.tui.requestRender();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
render(width: number): string[] {
|
|
268
|
+
if (this.cachedWidth === width && this.cachedLines) {
|
|
269
|
+
return this.cachedLines;
|
|
270
|
+
}
|
|
271
|
+
const lines = renderApprovalView(this.req, width);
|
|
272
|
+
this.cachedWidth = width;
|
|
273
|
+
this.cachedLines = lines;
|
|
274
|
+
return lines;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
handleInput(data: string): void {
|
|
278
|
+
if (this._resolved) return;
|
|
279
|
+
// Enter → approve
|
|
280
|
+
if (matchesKey(data, "enter")) {
|
|
281
|
+
this.approve();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Esc → deny
|
|
285
|
+
if (matchesKey(data, "escape")) {
|
|
286
|
+
this.deny();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// 其他键 no-op(不泄漏)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private approve(): void {
|
|
293
|
+
if (this._resolved) return;
|
|
294
|
+
this._resolved = true;
|
|
295
|
+
// TODO: spec 未要求 session/always scope,当前硬编码 "once"。未来如需扩展,
|
|
296
|
+
// 增加 handleInput 对应键位(如 's' → session、'a' → always)并透传 UserDecision.scope。
|
|
297
|
+
this.done({ approved: true, reason: "approved via tui", scope: "once" });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private deny(): void {
|
|
301
|
+
if (this._resolved) return;
|
|
302
|
+
this._resolved = true;
|
|
303
|
+
this.done({ approved: false, reason: "denied via tui" });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 取消(signal abort 调用)。复用 _resolved 守卫,避免二次 done。 */
|
|
307
|
+
cancel(): void {
|
|
308
|
+
if (this._resolved) return;
|
|
309
|
+
this._resolved = true;
|
|
310
|
+
this.done({ approved: false, reason: "cancelled (signal abort)" });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** box 边框左右各占用 1 列(│ × 2) */
|
|
315
|
+
const BORDER_OVERHEAD = 2;
|
|
316
|
+
|
|
317
|
+
/** 渲染审批视图(纯函数,便于单测)。 */
|
|
318
|
+
export function renderApprovalView(req: ApprovalRequest, width: number): string[] {
|
|
319
|
+
const innerWidth = Math.max(0, width - BORDER_OVERHEAD); // 减去左右边框
|
|
320
|
+
const inner: string[] = [];
|
|
321
|
+
inner.push("[pi-permission] Approval required");
|
|
322
|
+
inner.push("");
|
|
323
|
+
inner.push(`Tool: ${req.toolName}`);
|
|
324
|
+
if (req.command !== undefined && req.command.length > 0) {
|
|
325
|
+
inner.push(`Command: ${req.command}`);
|
|
326
|
+
}
|
|
327
|
+
inner.push(`Reason: ${req.reason}`);
|
|
328
|
+
if (req.preClassification) {
|
|
329
|
+
const pc = req.preClassification;
|
|
330
|
+
inner.push("");
|
|
331
|
+
inner.push(`AI classification:`);
|
|
332
|
+
inner.push(` risk: ${pc.risk_level}`);
|
|
333
|
+
inner.push(` outcome: ${pc.outcome}`);
|
|
334
|
+
inner.push(` confidence: ${pc.confidence}`);
|
|
335
|
+
inner.push(` reasoning: ${pc.reasoning}`);
|
|
336
|
+
}
|
|
337
|
+
inner.push("");
|
|
338
|
+
inner.push("[Enter] Approve [Esc] Deny");
|
|
339
|
+
|
|
340
|
+
// 用 box 边框包裹
|
|
341
|
+
const lines: string[] = [];
|
|
342
|
+
lines.push(`┌${"─".repeat(innerWidth)}┐`);
|
|
343
|
+
for (const line of inner) {
|
|
344
|
+
const padded = truncateToWidth(line, innerWidth, "", true);
|
|
345
|
+
lines.push(`│${padded}│`);
|
|
346
|
+
}
|
|
347
|
+
lines.push(`└${"─".repeat(innerWidth)}┘`);
|
|
348
|
+
return lines;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// re-export SelectItem 仅为测试便利(mock ctx.ui.select 时构造选项)
|
|
352
|
+
export type { SelectItem };
|