@trim21/personal-pi-extensions 0.0.421 → 0.0.423
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 +1 -1
- package/src/bwrap/approval-rules.ts +18 -57
- package/src/bwrap/approval-suggest.ts +53 -0
- package/src/bwrap/runtime.ts +2 -6
- package/src/lib/lsp/lsp.ts +110 -5
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* approval-rules —— bash
|
|
3
|
-
* 用 tree-sitter 解析命令(含嵌套 `$(...)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* approval-rules —— bash 命令匹配引擎(对齐 opencode 的权限方法):
|
|
3
|
+
* 用 tree-sitter 解析命令(含嵌套 `$(...)`),对每条命令的原文做通配
|
|
4
|
+
* 匹配,对 allow/deny 规则求值。规则匹配不经过任何命令归一——BashArity
|
|
5
|
+
* 归一模式只用于 "allow forever" 的建议规则(见 approval-suggest.ts)。
|
|
6
|
+
* 接入 bwrap 的 `dangerouslyDisableSandbox` 审批:命中规则自动
|
|
6
7
|
* 放行/拒绝,未命中才弹审批对话框。文件输出重定向(`>` / `>>` / `&>`
|
|
7
8
|
* 等)不会因命令规则自动放行,避免 `echo *` 把 `echo '' > file` 带过。
|
|
8
9
|
*
|
|
9
10
|
* 参考实现:
|
|
10
|
-
* - opencode packages/opencode/src/permission/arity.ts(BashArity 表)
|
|
11
11
|
* - opencode packages/core/src/util/wildcard.ts(通配匹配)
|
|
12
12
|
* - opencode packages/opencode/src/tool/shell.ts(tree-sitter 命令提取)
|
|
13
13
|
*/
|
|
@@ -173,34 +173,6 @@ export async function parseBashCommands(command: string): Promise<ParsedBash> {
|
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// ── BashArity:命令前缀 → token 数(参考 opencode arity.ts)─────────────────
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
|
|
180
|
-
* 权限模式取前 2 个 token + `*`(`git checkout *`),避免具体参数进规则。
|
|
181
|
-
* 表来自 opencode packages/opencode/src/permission/arity.ts(Apache-2.0),
|
|
182
|
-
* 数据存放在 arity.json(所有 key 带引号)。
|
|
183
|
-
*/
|
|
184
|
-
const ARITY = JSON.parse(readFileSync(new URL("arity.json", import.meta.url), "utf8")) as Record<
|
|
185
|
-
string,
|
|
186
|
-
number
|
|
187
|
-
>;
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* 生成命令的权限模式:BashArity 前缀 + `*`。
|
|
191
|
-
* `git checkout main` → `git checkout *`;未收录的命令 → 命令名 + `*`。
|
|
192
|
-
*/
|
|
193
|
-
export function commandPattern(command: BashCommand): string {
|
|
194
|
-
const tokens = [command.name, ...command.args];
|
|
195
|
-
for (let len = tokens.length; len > 0; len--) {
|
|
196
|
-
const prefix = tokens.slice(0, len).join(" ");
|
|
197
|
-
const arity = ARITY[prefix];
|
|
198
|
-
if (arity !== undefined) return [...tokens.slice(0, arity), "*"].join(" ");
|
|
199
|
-
}
|
|
200
|
-
if (tokens.length === 0) return "*";
|
|
201
|
-
return [tokens[0], "*"].join(" ");
|
|
202
|
-
}
|
|
203
|
-
|
|
204
176
|
// ── 通配匹配(参考 opencode wildcard.ts)────────────────────────────────────
|
|
205
177
|
|
|
206
178
|
/**
|
|
@@ -218,31 +190,15 @@ export function matchRule(input: string, pattern: string): boolean {
|
|
|
218
190
|
|
|
219
191
|
// ── 规则求值 ────────────────────────────────────────────────────────────────
|
|
220
192
|
|
|
221
|
-
/**
|
|
222
|
-
* 命令(含所有嵌套命令)的权限模式列表(命令替换里的命令也展开)。
|
|
223
|
-
* 供规则求值与"allow forever"写规则复用。
|
|
224
|
-
*/
|
|
225
|
-
function patternsFromCommands(commands: BashCommand[]): string[] {
|
|
226
|
-
const flat: string[] = [];
|
|
227
|
-
const visit = (cmd: BashCommand) => {
|
|
228
|
-
flat.push(commandPattern(cmd));
|
|
229
|
-
for (const nested of cmd.nested) visit(nested);
|
|
230
|
-
};
|
|
231
|
-
for (const cmd of commands) visit(cmd);
|
|
232
|
-
return flat;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export async function commandPatternsFor(command: string): Promise<string[]> {
|
|
236
|
-
const parsed = await parseBashCommands(command);
|
|
237
|
-
return patternsFromCommands(parsed.commands);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
193
|
/**
|
|
241
194
|
* 对命令(含所有嵌套命令)求值:
|
|
242
195
|
* - deny 优先:任一命令命中 deny 规则即整体拒绝
|
|
243
196
|
* - 文件输出重定向不自动放行:即使命令规则全匹配,也返回 undefined 交人审
|
|
244
197
|
* - allow 需全量:所有命令都命中 allow 规则才整体放行,否则返回
|
|
245
198
|
* undefined(有命令未命中规则,交给人审),避免未允许的命令被同链放行带过。
|
|
199
|
+
* - 匹配输入是命令原文(tree-sitter command 节点 text,含嵌套逐条展开),
|
|
200
|
+
* 对齐 opencode shell.ts 的 patterns;通配规则按字面写,`--` 与普通
|
|
201
|
+
* token 无区别。
|
|
246
202
|
* 规则内后写优先(findLast,对齐 opencode PermissionV2)。
|
|
247
203
|
*/
|
|
248
204
|
export async function evaluateBashApproval(
|
|
@@ -250,14 +206,19 @@ export async function evaluateBashApproval(
|
|
|
250
206
|
rules: readonly ApprovalRule[],
|
|
251
207
|
): Promise<ApprovalAction | undefined> {
|
|
252
208
|
const parsed = await parseBashCommands(command);
|
|
253
|
-
const
|
|
254
|
-
|
|
209
|
+
const raws: string[] = [];
|
|
210
|
+
const visit = (cmd: BashCommand) => {
|
|
211
|
+
raws.push(cmd.raw);
|
|
212
|
+
for (const nested of cmd.nested) visit(nested);
|
|
213
|
+
};
|
|
214
|
+
for (const cmd of parsed.commands) visit(cmd);
|
|
215
|
+
if (raws.length === 0) return;
|
|
255
216
|
let allowed = 0;
|
|
256
|
-
for (const
|
|
257
|
-
const rule = rules.findLast((r) => matchRule(
|
|
217
|
+
for (const raw of raws) {
|
|
218
|
+
const rule = rules.findLast((r) => matchRule(raw, r.pattern));
|
|
258
219
|
if (rule?.action === "deny") return "deny";
|
|
259
220
|
if (rule?.action === "allow") allowed++;
|
|
260
221
|
}
|
|
261
222
|
if (parsed.hasFileOutputRedirect) return;
|
|
262
|
-
return allowed ===
|
|
223
|
+
return allowed === raws.length ? "allow" : undefined;
|
|
263
224
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* approval-suggest —— "allow forever" 的建议规则生成:
|
|
3
|
+
* 用 BashArity 表把命令归一成模式(`git checkout main` → `git checkout *`),
|
|
4
|
+
* 供用户选择"永久允许"时写入配置。归一模式不参与规则判定——匹配引擎
|
|
5
|
+
* (approval-rules)按命令原文做通配匹配。
|
|
6
|
+
*
|
|
7
|
+
* 参考实现:opencode packages/opencode/src/permission/arity.ts(Apache-2.0)
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
import { type BashCommand, parseBashCommands } from "./approval-rules.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
|
|
15
|
+
* 权限模式取前 2 个 token + `*`(`git checkout *`),避免具体参数进规则。
|
|
16
|
+
* 表来自 opencode packages/opencode/src/permission/arity.ts(Apache-2.0),
|
|
17
|
+
* 数据存放在 arity.json(所有 key 带引号)。
|
|
18
|
+
*/
|
|
19
|
+
const ARITY = JSON.parse(readFileSync(new URL("arity.json", import.meta.url), "utf8")) as Record<
|
|
20
|
+
string,
|
|
21
|
+
number
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 生成命令的 BashArity 建议模式:BashArity 前缀 + `*`。
|
|
26
|
+
* `git checkout main` → `git checkout *`;未收录的命令 → 命令名 + `*`。
|
|
27
|
+
*/
|
|
28
|
+
export function commandPattern(command: BashCommand): string {
|
|
29
|
+
const tokens = [command.name, ...command.args];
|
|
30
|
+
for (let len = tokens.length; len > 0; len--) {
|
|
31
|
+
const prefix = tokens.slice(0, len).join(" ");
|
|
32
|
+
const arity = ARITY[prefix];
|
|
33
|
+
if (arity !== undefined) return [...tokens.slice(0, arity), "*"].join(" ");
|
|
34
|
+
}
|
|
35
|
+
if (tokens.length === 0) return "*";
|
|
36
|
+
return [tokens[0], "*"].join(" ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 命令(含所有嵌套命令)的 BashArity 建议模式列表(命令替换里的命令也展开)。 */
|
|
40
|
+
function patternsFromCommands(commands: BashCommand[]): string[] {
|
|
41
|
+
const flat: string[] = [];
|
|
42
|
+
const visit = (cmd: BashCommand) => {
|
|
43
|
+
flat.push(commandPattern(cmd));
|
|
44
|
+
for (const nested of cmd.nested) visit(nested);
|
|
45
|
+
};
|
|
46
|
+
for (const cmd of commands) visit(cmd);
|
|
47
|
+
return flat;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function commandPatternsFor(command: string): Promise<string[]> {
|
|
51
|
+
const parsed = await parseBashCommands(command);
|
|
52
|
+
return patternsFromCommands(parsed.commands);
|
|
53
|
+
}
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -23,12 +23,8 @@ import {
|
|
|
23
23
|
selectCheckboxActions,
|
|
24
24
|
selectWithOptionalInput,
|
|
25
25
|
} from "../lib/ui.js";
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
commandPatternsFor,
|
|
29
|
-
evaluateBashApproval,
|
|
30
|
-
matchRule,
|
|
31
|
-
} from "./approval-rules.js";
|
|
26
|
+
import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
|
|
27
|
+
import { commandPatternsFor } from "./approval-suggest.js";
|
|
32
28
|
import {
|
|
33
29
|
type BwrapMode,
|
|
34
30
|
createBwrapBashOperations,
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -175,6 +175,8 @@ interface LspState {
|
|
|
175
175
|
broken: Set<string>;
|
|
176
176
|
spawning: Map<string, Promise<LspClient | undefined>>;
|
|
177
177
|
closing: boolean;
|
|
178
|
+
/** /lsp-stop 置 true:所有工具调用不再 spawn 服务器,直到 start/reload。 */
|
|
179
|
+
disabled: boolean;
|
|
178
180
|
/** root+serverID → 服务器状态,用于 footer status 显示。 */
|
|
179
181
|
servers: Map<string, { serverID: string; root: string; state: "running" | "broken" }>;
|
|
180
182
|
}
|
|
@@ -214,6 +216,17 @@ export interface LspService {
|
|
|
214
216
|
options?: LspRequestOptions,
|
|
215
217
|
): Promise<{ text: string; errorCount: number; warningCount: number }>;
|
|
216
218
|
shutdownAll(): Promise<void>;
|
|
219
|
+
/** 停止全部服务器并禁用 LSP:之后工具调用不再 spawn,直到 start/reload。 */
|
|
220
|
+
stop(): Promise<void>;
|
|
221
|
+
/** 解除禁用并清空 broken 缓存;服务器在下次工具调用时惰性启动。 */
|
|
222
|
+
start(): void;
|
|
223
|
+
/**
|
|
224
|
+
* 重启指定服务器:关闭其全部 client、清除对应 broken 记录并解除禁用;
|
|
225
|
+
* 其余服务器不受影响。配置在下次工具调用时重新读取。
|
|
226
|
+
*/
|
|
227
|
+
reload(serverID: string): Promise<void>;
|
|
228
|
+
/** 已知服务器 id(running 或 broken 的去重集合),供命令补全与提示。 */
|
|
229
|
+
serverIDs(): string[];
|
|
217
230
|
/** 注入 status 渲染回调;传入 undefined 表示不再渲染。 */
|
|
218
231
|
attachStatus(render: StatusRenderer | undefined): void;
|
|
219
232
|
/** 用当前服务器状态主动刷新一次 status(agent start/end 等生命周期边界)。 */
|
|
@@ -247,6 +260,7 @@ export function createLspService(
|
|
|
247
260
|
broken: new Set(),
|
|
248
261
|
spawning: new Map(),
|
|
249
262
|
closing: false,
|
|
263
|
+
disabled: false,
|
|
250
264
|
servers: new Map(),
|
|
251
265
|
};
|
|
252
266
|
|
|
@@ -255,6 +269,10 @@ export function createLspService(
|
|
|
255
269
|
/** 汇总当前所有 LSP server 状态并渲染到 footer status。 */
|
|
256
270
|
function updateStatusText(): void {
|
|
257
271
|
if (!renderStatus) return;
|
|
272
|
+
if (state.disabled) {
|
|
273
|
+
renderStatus("lsp: disabled");
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
258
276
|
if (state.servers.size === 0) {
|
|
259
277
|
renderStatus(undefined);
|
|
260
278
|
return;
|
|
@@ -275,7 +293,7 @@ export function createLspService(
|
|
|
275
293
|
cwd: string,
|
|
276
294
|
notify?: ExtensionUIContext["notify"],
|
|
277
295
|
): Promise<LspClient[]> {
|
|
278
|
-
if (state.closing) return [];
|
|
296
|
+
if (state.closing || state.disabled) return [];
|
|
279
297
|
if (!containsPath(file, cwd)) return [];
|
|
280
298
|
const config = await loadLspConfig(cwd, globalConfigPath);
|
|
281
299
|
const timeout = timeoutOptions(config);
|
|
@@ -326,7 +344,7 @@ export function createLspService(
|
|
|
326
344
|
diagnosticsDocumentWaitTimeoutMs:
|
|
327
345
|
adapter.diagnosticsWaitMs ?? timeout.diagnosticsDocumentWaitTimeoutMs,
|
|
328
346
|
});
|
|
329
|
-
if (state.closing) {
|
|
347
|
+
if (state.closing || state.disabled) {
|
|
330
348
|
await client.shutdown();
|
|
331
349
|
return;
|
|
332
350
|
}
|
|
@@ -426,9 +444,8 @@ export function createLspService(
|
|
|
426
444
|
return { text: report(normalized, issues), errorCount, warningCount };
|
|
427
445
|
}
|
|
428
446
|
|
|
429
|
-
/**
|
|
430
|
-
async function
|
|
431
|
-
if (state.closing) return;
|
|
447
|
+
/** 关闭全部 client 并清空缓存;closing 置 true 让 in-flight spawn 自行退出。 */
|
|
448
|
+
async function closeAll(): Promise<void> {
|
|
432
449
|
state.closing = true;
|
|
433
450
|
await Promise.all(state.clients.map((client) => client.shutdown())).catch(() => {
|
|
434
451
|
// 个别进程退出失败不阻止清理流程
|
|
@@ -439,11 +456,55 @@ export function createLspService(
|
|
|
439
456
|
updateStatusText();
|
|
440
457
|
}
|
|
441
458
|
|
|
459
|
+
/** 终止全部服务器进程(session_shutdown 时调用,终态)。 */
|
|
460
|
+
async function shutdownAll(): Promise<void> {
|
|
461
|
+
await closeAll();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function stop(): Promise<void> {
|
|
465
|
+
await closeAll();
|
|
466
|
+
state.closing = false;
|
|
467
|
+
state.disabled = true;
|
|
468
|
+
updateStatusText();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function start(): void {
|
|
472
|
+
state.closing = false;
|
|
473
|
+
state.disabled = false;
|
|
474
|
+
state.broken.clear();
|
|
475
|
+
updateStatusText();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function reload(serverID: string): Promise<void> {
|
|
479
|
+
state.closing = true;
|
|
480
|
+
const targets = state.clients.filter((client) => client.serverID === serverID);
|
|
481
|
+
await Promise.all(targets.map((client) => client.shutdown())).catch(() => {
|
|
482
|
+
// 个别进程退出失败不阻止清理流程
|
|
483
|
+
});
|
|
484
|
+
state.clients = state.clients.filter((client) => client.serverID !== serverID);
|
|
485
|
+
for (const [key, server] of state.servers) {
|
|
486
|
+
if (server.serverID !== serverID) continue;
|
|
487
|
+
state.broken.delete(key);
|
|
488
|
+
state.servers.delete(key);
|
|
489
|
+
}
|
|
490
|
+
state.closing = false;
|
|
491
|
+
state.disabled = false;
|
|
492
|
+
updateStatusText();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function serverIDs(): string[] {
|
|
496
|
+
return [...new Set([...state.servers.values()].map((server) => server.serverID))];
|
|
497
|
+
}
|
|
498
|
+
|
|
442
499
|
return {
|
|
443
500
|
touchFile,
|
|
444
501
|
diagnostics,
|
|
445
502
|
lspDiagnosticsForFile,
|
|
446
503
|
shutdownAll,
|
|
504
|
+
stop,
|
|
505
|
+
start,
|
|
506
|
+
reload,
|
|
507
|
+
serverIDs,
|
|
447
508
|
attachStatus,
|
|
448
509
|
refreshStatus: updateStatusText,
|
|
449
510
|
};
|
|
@@ -476,5 +537,49 @@ export function registerLsp(pi: ExtensionAPI, options?: LspServiceOptions): LspS
|
|
|
476
537
|
// spawn(首次工具调用),start/end 时保证 footer 反映当前实际状态。
|
|
477
538
|
pi.on?.("agent_start", () => service.refreshStatus());
|
|
478
539
|
pi.on?.("agent_end", () => service.refreshStatus());
|
|
540
|
+
|
|
541
|
+
pi.registerCommand?.("lsp-stop", {
|
|
542
|
+
description: "Stop all LSP servers and disable LSP until /lsp-start or /lsp-reload",
|
|
543
|
+
handler: async (_args, ctx) => {
|
|
544
|
+
await service.stop();
|
|
545
|
+
ctx.ui.notify?.("LSP disabled: all servers stopped", "info");
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
pi.registerCommand?.("lsp-start", {
|
|
550
|
+
description: "Re-enable LSP; servers start on the next tool call",
|
|
551
|
+
handler: (_args, ctx) => {
|
|
552
|
+
service.start();
|
|
553
|
+
ctx.ui.notify?.("LSP enabled: servers will start on the next tool call", "info");
|
|
554
|
+
return Promise.resolve();
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
pi.registerCommand?.("lsp-reload", {
|
|
559
|
+
description: "Restart a specific LSP server: /lsp-reload <server-id>",
|
|
560
|
+
getArgumentCompletions: (prefix) =>
|
|
561
|
+
service
|
|
562
|
+
.serverIDs()
|
|
563
|
+
.toSorted()
|
|
564
|
+
.filter((id) => id.startsWith(prefix))
|
|
565
|
+
.map((id) => ({ value: id, label: id })),
|
|
566
|
+
handler: async (args, ctx) => {
|
|
567
|
+
const serverID = args.trim();
|
|
568
|
+
const known = service.serverIDs().toSorted();
|
|
569
|
+
if (!serverID) {
|
|
570
|
+
ctx.ui.notify?.(
|
|
571
|
+
`usage: /lsp-reload <server-id>${known.length > 0 ? ` (known: ${known.join(", ")})` : ""}`,
|
|
572
|
+
"warning",
|
|
573
|
+
);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
await service.reload(serverID);
|
|
577
|
+
ctx.ui.notify?.(
|
|
578
|
+
`LSP server "${serverID}" reloaded: will restart on the next tool call`,
|
|
579
|
+
"info",
|
|
580
|
+
);
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
|
|
479
584
|
return service;
|
|
480
585
|
}
|