@trim21/personal-pi-extensions 0.1.627 → 0.1.630

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.627",
3
+ "version": "0.1.630",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -1,7 +1,8 @@
1
1
  # bwrap 沙箱与网络栈架构
2
2
 
3
3
  本文档描述 `network: limited` 模式下的进程模型、网络路径与生命周期管理。
4
- 基础沙箱(bwrap 文件系统隔离)见 `core.ts` / `sandbox.ts`;本文聚焦网络栈
4
+ 基础沙箱(bwrap 文件系统隔离)见 `core.ts`(配置与 argv 内容)/ `exec.ts`
5
+ (命令行组装与进程生命周期)/ `sandbox.ts`(加载配置并运行);本文聚焦网络栈
5
6
  (`network-stack.ts` / `holder.ts` / `mihomo-config.ts`)。
6
7
 
7
8
  ## 进程模型
@@ -21,7 +22,7 @@ pi 进程(network-stack.ts)
21
22
  必须在宿主 netns 启动(原因见「设计约束」);持 exit-fd 读端 + tapfd。
22
23
  ```
23
24
 
24
- 每条命令的短命子树(命令结束即退):
25
+ 每条命令的短命子树(命令结束即退),由 `exec.ts` 组装并 spawn:
25
26
 
26
27
  ```
27
28
  nsenter -U -n --preserve-credentials -t <①的pid> \
@@ -29,7 +30,9 @@ nsenter -U -n --preserve-credentials -t <①的pid> \
29
30
  ```
30
31
 
31
32
  nsenter 进入 holder 的 userns/netns,bwrap 在里面再嵌套创建自己的 user/pid
32
- ns 跑命令。网络栈是每条命令现建现停(起栈 ~40ms、停栈 ~100ms),不跨命令复用:
33
+ ns 跑命令。网络栈对外只提供 holder pid(`NetworkStack.holderPid`)与停止,
34
+ 命令行怎么拼属于执行层——`--print-args` 预览与真正执行共用同一段组装。
35
+ 网络栈是每条命令现建现停(起栈 ~40ms、停栈 ~100ms),不跨命令复用:
33
36
  allowlist 变更因此即时生效,代价是每条命令重新启动一次 mihomo。
34
37
 
35
38
  ## 网络路径
@@ -3,9 +3,11 @@
3
3
  * 用 tree-sitter 解析命令(含嵌套 `$(...)`),对每条命令的原文做通配
4
4
  * 匹配,对 allow/deny 规则求值。规则匹配不经过任何命令归一——BashArity
5
5
  * 归一模式只用于 "allow forever" 的建议规则(见 approval-suggest.ts)。
6
- * 接入 bwrap 的 `dangerouslyDisableSandbox` 审批:命中规则自动
7
- * 放行/拒绝,未命中才弹审批对话框。文件输出重定向(`>` / `>>` / `&>`
8
- * 等)不会因命令规则自动放行,避免 `echo *` 把 `echo '' > file` 带过。
6
+ * 接入 bwrap 的 `dangerouslyDisableSandbox` 审批:审批规则集
7
+ * (createApprovalRuleSet)独占「自动放行 / 自动拒绝 / 交人审」的判定、
8
+ * allow 覆盖查询、待允许模式与规则追加,调用方只描述用户动作。
9
+ * 文件输出重定向(`>` / `>>` / `&>` 等)不会因命令规则自动放行,
10
+ * 避免 `echo *` 把 `echo '' > file` 带过。
9
11
  *
10
12
  * 参考实现:
11
13
  * - opencode packages/core/src/util/wildcard.ts(通配匹配)
@@ -209,27 +211,17 @@ export function matchRule(input: string, pattern: string): boolean {
209
211
  return new RegExp(`^${escaped}$`, "s").test(input);
210
212
  }
211
213
 
212
- // ── 规则求值 ────────────────────────────────────────────────────────────────
214
+ // ── 命令树展平 ──────────────────────────────────────────────────────────────
213
215
 
214
216
  /**
215
- * 对命令(含所有嵌套命令)求值:
216
- * - deny 优先:任一命令命中 deny 规则即整体拒绝
217
- * - 文件输出重定向不自动放行:即使命令规则全匹配,也返回 undefined 交人审
218
- * - allow 需全量:所有命令都命中 allow 规则才整体放行,否则返回
219
- * undefined(有命令未命中规则,交给人审),避免未允许的命令被同链放行带过。
220
- * - 匹配输入是命令原文(tree-sitter command 节点 text,含嵌套逐条展开),
221
- * 对齐 opencode shell.ts 的 patterns;通配规则按字面写,`--` 与普通
222
- * token 无区别。
223
- * 规则内后写优先(findLast,对齐 opencode PermissionV2)。
217
+ * 展平命令树:每条命令本身 + 其所有嵌套命令(`$(...)` 内的),父在子前。
218
+ * 求值与建议模式生成(approval-suggest.ts)共用——后者依赖本模块,
219
+ * 反向 import 会成环,所以展平放在这里。
224
220
  */
225
- export async function evaluateBashApproval(
226
- command: string,
227
- rules: readonly ApprovalRule[],
228
- ): Promise<ApprovalAction | undefined> {
229
- const parsed = await parseBashCommands(command);
230
- const raws: string[] = [];
221
+ export function flattenCommands(parsed: ParsedBash): BashCommand[] {
222
+ const flat: BashCommand[] = [];
231
223
  const visit = (cmd: BashCommand) => {
232
- raws.push(cmd.raw);
224
+ flat.push(cmd);
233
225
  for (const nested of cmd.nested) {
234
226
  visit(nested);
235
227
  }
@@ -237,21 +229,101 @@ export async function evaluateBashApproval(
237
229
  for (const cmd of parsed.commands) {
238
230
  visit(cmd);
239
231
  }
240
- if (raws.length === 0) {
241
- return;
232
+ return flat;
233
+ }
234
+
235
+ // ── 审批规则集 ──────────────────────────────────────────────────────────────
236
+
237
+ export interface ApprovalRuleSetOptions {
238
+ /** 当前规则(读取时取最新,追加后无需重建规则集)。规则数组即优先级:靠后优先。 */
239
+ rules: () => readonly ApprovalRule[];
240
+ /** 命令 → 候选模式(BashArity);注入以免与 approval-suggest.ts 成环。 */
241
+ suggestPatterns: (command: string) => Promise<readonly string[]>;
242
+ /** 追加 allow 规则并持久化;resolve 后 rules() 必须能看到它们,抛错即视为未追加。 */
243
+ persist: (rules: readonly ApprovalRule[]) => Promise<void>;
244
+ }
245
+
246
+ export interface ApprovalRuleSet {
247
+ /**
248
+ * 对命令(含所有嵌套命令)求值:deny 优先 / allow 需全量 / 含文件输出
249
+ * 重定向不自动放行。返回 undefined 表示未命中规则,交人工审批。
250
+ */
251
+ evaluate(command: string): Promise<ApprovalAction | undefined>;
252
+ /**
253
+ * 该字符串(命令原文或候选模式)是否被最后一条匹配的规则允许。
254
+ * 只回答 allow 覆盖,不考虑 deny 与重定向——放行判定一律用 evaluate。
255
+ */
256
+ isAllowed(pattern: string): boolean;
257
+ /** 命令的候选模式(去重)中尚未被 allow 规则覆盖的那些;审批子菜单的唯一来源。 */
258
+ pendingPatterns(command: string): Promise<string[]>;
259
+ /** 追加 allow 规则并持久化;persist 成功后立即生效。 */
260
+ addAllowRules(patterns: readonly string[]): Promise<void>;
261
+ }
262
+
263
+ /**
264
+ * 审批规则集:命令自动判定、allow 覆盖查询、待允许模式与规则追加的唯一 owner。
265
+ * 规则数组的顺序即优先级:靠后优先(项目规则经 deepMerge 排在全局规则之后)。
266
+ * 三条协作方由调用方注入:规则 getter(reload 或追加后无需重建规则集)、
267
+ * 候选模式生成、持久化副作用(文件读写留在调用方)。
268
+ */
269
+ export function createApprovalRuleSet(options: ApprovalRuleSetOptions): ApprovalRuleSet {
270
+ const { rules, suggestPatterns, persist } = options;
271
+
272
+ /** 最后一条匹配 input 的规则;数组靠后者优先(对齐 opencode PermissionV2)。 */
273
+ function lastMatch(input: string): ApprovalRule | undefined {
274
+ return rules().findLast((rule) => matchRule(input, rule.pattern));
275
+ }
276
+
277
+ function isAllowed(pattern: string): boolean {
278
+ return lastMatch(pattern)?.action === "allow";
242
279
  }
243
- let allowed = 0;
244
- for (const raw of raws) {
245
- const rule = rules.findLast((r) => matchRule(raw, r.pattern));
246
- if (rule?.action === "deny") {
247
- return "deny";
280
+
281
+ async function evaluate(command: string): Promise<ApprovalAction | undefined> {
282
+ const parsed = await parseBashCommands(command);
283
+ // 匹配输入是命令原文(tree-sitter command 节点 text,含嵌套逐条展开),
284
+ // 对齐 opencode shell.ts 的 patterns;通配规则按字面写,`--` 与普通 token 无区别。
285
+ const raws = flattenCommands(parsed).map((cmd) => cmd.raw);
286
+ if (raws.length === 0) {
287
+ return; // 空命令或解析失败:没有可匹配的命令,交人审
288
+ }
289
+ let allowed = 0;
290
+ for (const raw of raws) {
291
+ const rule = lastMatch(raw);
292
+ if (rule?.action === "deny") {
293
+ return "deny";
294
+ }
295
+ if (rule?.action === "allow") {
296
+ allowed++;
297
+ }
298
+ }
299
+ // 文件输出重定向不自动放行:即使命令规则全匹配也交人审
300
+ if (parsed.hasFileOutputRedirect) {
301
+ return;
248
302
  }
249
- if (rule?.action === "allow") {
250
- allowed++;
303
+ // allow 需全量:有命令未命中规则时交人审,避免未允许的命令被同链放行带过
304
+ return allowed === raws.length ? "allow" : undefined;
305
+ }
306
+
307
+ async function pendingPatterns(command: string): Promise<string[]> {
308
+ const pending: string[] = [];
309
+ const seen = new Set<string>();
310
+ for (const pattern of await suggestPatterns(command)) {
311
+ if (seen.has(pattern) || isAllowed(pattern)) {
312
+ continue;
313
+ }
314
+ seen.add(pattern);
315
+ pending.push(pattern);
251
316
  }
317
+ return pending;
252
318
  }
253
- if (parsed.hasFileOutputRedirect) {
254
- return;
319
+
320
+ async function addAllowRules(patterns: readonly string[]): Promise<void> {
321
+ const additions: ApprovalRule[] = patterns.map((pattern) => ({ action: "allow", pattern }));
322
+ if (additions.length === 0) {
323
+ return; // 解析失败或未勾选:本次处理,不写配置
324
+ }
325
+ await persist(additions);
255
326
  }
256
- return allowed === raws.length ? "allow" : undefined;
327
+
328
+ return { evaluate, isAllowed, pendingPatterns, addAllowRules };
257
329
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { readFileSync } from "node:fs";
10
10
 
11
- import { type BashCommand, parseBashCommands } from "./approval-rules.js";
11
+ import { type BashCommand, flattenCommands, parseBashCommands } from "./approval-rules.js";
12
12
 
13
13
  /**
14
14
  * 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
@@ -41,21 +41,7 @@ export function commandPattern(command: BashCommand): string {
41
41
  }
42
42
 
43
43
  /** 命令(含所有嵌套命令)的 BashArity 建议模式列表(命令替换里的命令也展开)。 */
44
- function patternsFromCommands(commands: BashCommand[]): string[] {
45
- const flat: string[] = [];
46
- const visit = (cmd: BashCommand) => {
47
- flat.push(commandPattern(cmd));
48
- for (const nested of cmd.nested) {
49
- visit(nested);
50
- }
51
- };
52
- for (const cmd of commands) {
53
- visit(cmd);
54
- }
55
- return flat;
56
- }
57
-
58
44
  export async function commandPatternsFor(command: string): Promise<string[]> {
59
45
  const parsed = await parseBashCommands(command);
60
- return patternsFromCommands(parsed.commands);
46
+ return flattenCommands(parsed).map((cmd) => commandPattern(cmd));
61
47
  }
package/src/bwrap/core.ts CHANGED
@@ -1,23 +1,17 @@
1
- import { type ChildProcess, spawn } from "node:child_process";
2
- import { constants, type Dirent, existsSync, readFileSync } from "node:fs";
3
- import { access as fsAccess, readdir, realpath, stat } from "node:fs/promises";
1
+ import { type Dirent, existsSync, readFileSync } from "node:fs";
2
+ import { readdir, realpath, stat } from "node:fs/promises";
4
3
  import { delimiter, join } from "node:path";
5
4
  import process from "node:process";
6
5
 
7
6
  import { StringEnum } from "@earendil-works/pi-ai";
8
- import { type BashOperations, getAgentDir, getShellConfig } from "@earendil-works/pi-coding-agent";
7
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
8
  import { type Static, Type } from "typebox";
10
9
  import { Value } from "typebox/value";
11
10
 
12
11
  import { parseWithSchema } from "../lib/parse-with-schema.js";
13
12
  import { expandHome } from "../lib/path.js";
14
13
  import { type ApprovalRule } from "./approval-rules.js";
15
- import {
16
- type NetworkStack,
17
- resolveDnsServers,
18
- startNetworkStack,
19
- TimeoutError,
20
- } from "./network-stack.js";
14
+ import { type NetworkStack, resolveDnsServers, startNetworkStack } from "./network-stack.js";
21
15
 
22
16
  const PROTECTED_DIRS = [".pi", ".agent"];
23
17
 
@@ -452,17 +446,6 @@ export async function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): Prom
452
446
  return args;
453
447
  }
454
448
 
455
- function killChild(child: ChildProcess): void {
456
- if (!child.pid) {
457
- return;
458
- }
459
- try {
460
- process.kill(-child.pid, "SIGKILL");
461
- } catch {
462
- child.kill("SIGKILL");
463
- }
464
- }
465
-
466
449
  /** 网络栈子进程输出转发通道,仅用于诊断(默认丢弃)。 */
467
450
  export interface NetworkStackLog {
468
451
  holder?: (chunk: string) => void;
@@ -484,150 +467,3 @@ export async function createNetworkStack(
484
467
  ...(log?.holder && { onHolderOutput: log.holder }),
485
468
  });
486
469
  }
487
-
488
- /** 一次 bwrap 调用的完整组装结果:argv 与干净环境。 */
489
- export interface BwrapInvocation {
490
- /** bwrap 可执行文件路径 */
491
- file: string;
492
- /** bwrap 参数(不含结尾的 `-- shell -lc command`) */
493
- args: string[];
494
- /** 沙箱内 shell 的绝对路径 */
495
- shell: string;
496
- /** 交给 shell 的命令 */
497
- command: string;
498
- /** 命令执行目录 */
499
- cwd: string;
500
- /** 沙箱内环境(不继承父进程) */
501
- env: Record<string, string>;
502
- /** network limited 模式:命令需先经 nsenter 进入 holder 的 netns。 */
503
- needsNetworkStack: boolean;
504
- }
505
-
506
- /**
507
- * 组装一次 bwrap 调用。实际执行(createBwrapBashOperations)与调试打印共用这里,
508
- * 保证 `--print-args` 输出的命令行与真正跑的那条完全一致。
509
- */
510
- export async function buildBwrapInvocation(
511
- resolved: ResolvedBwrap,
512
- workspace: string,
513
- command: string,
514
- cwd: string,
515
- ): Promise<BwrapInvocation> {
516
- // 干净环境:不继承父进程 env/PATH,由 bash -lc 从 /etc/profile 与用户 profile 重建
517
- const home = process.env.HOME;
518
- if (home === undefined) {
519
- throw new Error("HOME is not set; refusing to run bash in a clean environment");
520
- }
521
- return {
522
- // 沙箱内不透传 PATH,execvp 的默认路径可能找不到 bash(如 NixOS),故在父进程解析绝对路径
523
- shell: getShellConfig().shell,
524
- file: findBwrap(resolved.bwrapPath),
525
- args: [
526
- "--ro-bind",
527
- "/",
528
- "/",
529
- ...(await buildBwrapArgs(resolved, workspace)),
530
- "--dev",
531
- "/dev",
532
- "--proc",
533
- "/proc",
534
- ],
535
- command,
536
- cwd,
537
- env: {
538
- HOME: home,
539
- SHELL: "/bin/bash",
540
- TERM: "dumb",
541
- LANG: "C.UTF-8",
542
- // 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
543
- PATH: "/usr/local/bin:/usr/bin:/bin",
544
- },
545
- needsNetworkStack: resolved.network === "limited",
546
- };
547
- }
548
-
549
- /** 完整 argv(`[bwrap, ...args, "--", shell, "-lc", command]`),spawn 与打印共用。 */
550
- export function bwrapArgv(invocation: BwrapInvocation): string[] {
551
- return [invocation.file, ...invocation.args, "--", invocation.shell, "-lc", invocation.command];
552
- }
553
-
554
- /**
555
- * @param workspace session 工作区:writablePaths 的 "." 与 PROTECTED_DIRS 都基于它解析,
556
- * 与当次命令的 cwd(仅作为进程执行目录)解耦,避免 workdir 参数漂移可写边界。
557
- */
558
- export function createBwrapBashOperations(
559
- resolved: ResolvedBwrap,
560
- workspace: string,
561
- networkStack?: NetworkStack,
562
- ): BashOperations {
563
- return {
564
- async exec(command, cwd, { onData, signal, timeout }) {
565
- await fsAccess(cwd, constants.F_OK).catch(() => {
566
- throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
567
- });
568
- // 已中断(signal.reason 是 name=AbortError 的 DOMException):直接抛,不再执行
569
- signal?.throwIfAborted();
570
-
571
- const invocation = await buildBwrapInvocation(resolved, workspace, command, cwd);
572
-
573
- if (invocation.needsNetworkStack) {
574
- if (!networkStack) {
575
- throw new Error("Network stack is not initialized for network limited mode");
576
- }
577
- return networkStack.exec({
578
- command,
579
- cwd,
580
- bwrapPath: invocation.file,
581
- bwrapArgs: invocation.args,
582
- shell: invocation.shell,
583
- env: invocation.env,
584
- onData,
585
- signal,
586
- timeout,
587
- });
588
- }
589
-
590
- const argv = bwrapArgv(invocation);
591
- const child = spawn(argv[0], argv.slice(1), {
592
- cwd,
593
- detached: true,
594
- stdio: ["ignore", "pipe", "pipe"],
595
- env: invocation.env,
596
- });
597
-
598
- return new Promise<{ exitCode: number | null }>((resolve, reject) => {
599
- let timedOut = false;
600
- const timeoutHandle = timeout
601
- ? setTimeout(() => {
602
- timedOut = true;
603
- killChild(child);
604
- }, timeout * 1000)
605
- : undefined;
606
- const onAbort = () => killChild(child);
607
- child.stdout.on("data", onData);
608
- child.stderr.on("data", onData);
609
- signal?.addEventListener("abort", onAbort, { once: true });
610
- child.once("error", reject);
611
- child.once("close", (exitCode) => {
612
- if (timeoutHandle) {
613
- clearTimeout(timeoutHandle);
614
- }
615
- signal?.removeEventListener("abort", onAbort);
616
- // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
617
- if (signal?.aborted) {
618
- reject(
619
- signal.reason instanceof Error
620
- ? signal.reason
621
- : new Error("The operation was aborted"),
622
- );
623
- } else if (timedOut) {
624
- // 超时:name=TimeoutError(对齐标准错误分类)
625
- reject(new TimeoutError(timeout));
626
- } else {
627
- resolve({ exitCode });
628
- }
629
- });
630
- });
631
- },
632
- };
633
- }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * 执行层:把一次组装好的 bwrap 调用变成进程。
3
+ *
4
+ * 完整命令行只在这里组装(`invocationArgv`),子进程生命周期只有一份实现
5
+ * (`execInvocation`)——direct 与经 `nsenter` 进 holder netns 两条路径共用,
6
+ * 预览(`--print-args`)也走同一次组装,超时 / 中断 / 启动失败的错误语义与
7
+ * 打印出的命令行因此不会漂移。argv 内容与配置解析由 core.ts 提供;holder pid
8
+ * 由 network-stack.ts 提供,网络栈本身不知道 bwrap 参数怎么拼。
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { constants } from "node:fs";
13
+ import { access as fsAccess } from "node:fs/promises";
14
+
15
+ import { type BashOperations, getShellConfig } from "@earendil-works/pi-coding-agent";
16
+
17
+ import { buildBwrapArgs, findBwrap, type ResolvedBwrap } from "./core.js";
18
+ import type { NetworkStack } from "./network-stack.js";
19
+
20
+ /** 命令超时错误:name=TimeoutError(对齐标准错误分类),message 保留 timeout:N 格式。 */
21
+ export class TimeoutError extends Error {
22
+ constructor(timeout: number | undefined) {
23
+ super(`timeout:${timeout}`);
24
+ this.name = "TimeoutError";
25
+ }
26
+ }
27
+
28
+ /** 一次 bwrap 调用的完整组装结果:argv 与干净环境。 */
29
+ export interface BwrapInvocation {
30
+ /** bwrap 可执行文件路径 */
31
+ file: string;
32
+ /** bwrap 参数(不含结尾的 `-- shell -lc command`) */
33
+ args: string[];
34
+ /** 沙箱内 shell 的绝对路径 */
35
+ shell: string;
36
+ /** 交给 shell 的命令 */
37
+ command: string;
38
+ /** 沙箱内环境(不继承父进程) */
39
+ env: Record<string, string>;
40
+ /** network limited 模式:命令需先经 nsenter 进入 holder 的 netns。 */
41
+ needsNetworkStack: boolean;
42
+ }
43
+
44
+ /**
45
+ * 组装一次 bwrap 调用。实际执行(`execInvocation`)与调试打印共用这里,
46
+ * 保证 `--print-args` 输出的命令行与真正跑的那条完全一致。
47
+ */
48
+ export async function buildBwrapInvocation(
49
+ resolved: ResolvedBwrap,
50
+ workspace: string,
51
+ command: string,
52
+ ): Promise<BwrapInvocation> {
53
+ // 干净环境:不继承父进程 env/PATH,由 bash -lc 从 /etc/profile 与用户 profile 重建
54
+ const home = process.env.HOME;
55
+ if (home === undefined) {
56
+ throw new Error("HOME is not set; refusing to run bash in a clean environment");
57
+ }
58
+ return {
59
+ // 沙箱内不透传 PATH,execvp 的默认路径可能找不到 bash(如 NixOS),故在父进程解析绝对路径
60
+ shell: getShellConfig().shell,
61
+ file: findBwrap(resolved.bwrapPath),
62
+ args: [
63
+ "--ro-bind",
64
+ "/",
65
+ "/",
66
+ ...(await buildBwrapArgs(resolved, workspace)),
67
+ "--dev",
68
+ "/dev",
69
+ "--proc",
70
+ "/proc",
71
+ ],
72
+ command,
73
+ env: {
74
+ HOME: home,
75
+ SHELL: "/bin/bash",
76
+ TERM: "dumb",
77
+ LANG: "C.UTF-8",
78
+ // 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
79
+ PATH: "/usr/local/bin:/usr/bin:/bin",
80
+ },
81
+ needsNetworkStack: resolved.network === "limited",
82
+ };
83
+ }
84
+
85
+ /**
86
+ * 完整命令行:`[bwrap, ...args, "--", shell, "-lc", command]`;传 `nsenterPid` 时
87
+ * 前置 `nsenter` 前缀进入该 holder 的 userns + netns。
88
+ *
89
+ * 预览与实际执行都调用这里——`nsenterPid` 传数字是真实 holder pid,传字符串是
90
+ * 尚未启动时的占位符(打印用),不传即纯 bwrap 命令行。
91
+ */
92
+ export function invocationArgv(
93
+ invocation: BwrapInvocation,
94
+ nsenterPid?: number | string,
95
+ ): string[] {
96
+ const argv = [
97
+ invocation.file,
98
+ ...invocation.args,
99
+ "--",
100
+ invocation.shell,
101
+ "-lc",
102
+ invocation.command,
103
+ ];
104
+ if (nsenterPid === undefined) {
105
+ return argv;
106
+ }
107
+ return ["nsenter", "-U", "-n", "--preserve-credentials", "-t", String(nsenterPid), "--", ...argv];
108
+ }
109
+
110
+ function killChild(pid: number | undefined): void {
111
+ if (!pid) {
112
+ return;
113
+ }
114
+ try {
115
+ process.kill(-pid, "SIGKILL");
116
+ } catch {
117
+ try {
118
+ process.kill(pid, "SIGKILL");
119
+ } catch {
120
+ // 已退出
121
+ }
122
+ }
123
+ }
124
+
125
+ export interface ExecInvocationOptions {
126
+ /** 命令执行目录(进程 cwd);沙箱内可写边界由 invocation 的挂载项决定,与此无关。 */
127
+ cwd: string;
128
+ /** netns holder pid:invocation 需要网络栈时必填,缺失即抛错。 */
129
+ holderPid?: number;
130
+ /** 流式输出回调(stdout 与 stderr 已合并)。 */
131
+ onData: (data: Buffer) => void;
132
+ /** 取消:终止整个进程组并以 signal.reason(默认 AbortError)结束。 */
133
+ signal?: AbortSignal;
134
+ /** 超时秒数:终止整个进程组并以 TimeoutError 结束。 */
135
+ timeout?: number;
136
+ }
137
+
138
+ /**
139
+ * 执行一次组装好的调用(唯一一处 spawn 与生命周期)。
140
+ *
141
+ * 需要网络栈时自动前置 `nsenter` 前缀;进程以独立进程组启动,超时与取消都
142
+ * 终止整组,启动失败(spawn error)立即结束且不残留超时定时器。
143
+ */
144
+ export function execInvocation(
145
+ invocation: BwrapInvocation,
146
+ options: ExecInvocationOptions,
147
+ ): Promise<{ exitCode: number | null }> {
148
+ if (invocation.needsNetworkStack && options.holderPid === undefined) {
149
+ return Promise.reject(new Error("Network stack is not initialized for network limited mode"));
150
+ }
151
+ const argv = invocationArgv(
152
+ invocation,
153
+ invocation.needsNetworkStack ? options.holderPid : undefined,
154
+ );
155
+ const child = spawn(argv[0], argv.slice(1), {
156
+ cwd: options.cwd,
157
+ detached: true,
158
+ stdio: ["ignore", "pipe", "pipe"],
159
+ env: invocation.env,
160
+ });
161
+
162
+ return new Promise<{ exitCode: number | null }>((resolve, reject) => {
163
+ let timedOut = false;
164
+ let settled = false;
165
+ const timeoutHandle = options.timeout
166
+ ? setTimeout(() => {
167
+ timedOut = true;
168
+ killChild(child.pid);
169
+ }, options.timeout * 1000)
170
+ : undefined;
171
+ const onAbort = (): void => {
172
+ killChild(child.pid);
173
+ };
174
+ // 结算一次:无论走 error 还是 close,都清掉超时定时器与 abort 监听——挂着的
175
+ // 定时器会白占事件循环到超时那一刻。
176
+ const settle = (): boolean => {
177
+ if (settled) {
178
+ return false;
179
+ }
180
+ settled = true;
181
+ if (timeoutHandle) {
182
+ clearTimeout(timeoutHandle);
183
+ }
184
+ options.signal?.removeEventListener("abort", onAbort);
185
+ return true;
186
+ };
187
+
188
+ child.stdout.on("data", options.onData);
189
+ child.stderr.on("data", options.onData);
190
+ options.signal?.addEventListener("abort", onAbort, { once: true });
191
+
192
+ child.once("error", (error) => {
193
+ if (!settle()) {
194
+ return;
195
+ }
196
+ reject(error);
197
+ });
198
+ child.once("close", (exitCode) => {
199
+ if (!settle()) {
200
+ return;
201
+ }
202
+ // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
203
+ if (options.signal?.aborted) {
204
+ reject(
205
+ options.signal.reason instanceof Error
206
+ ? options.signal.reason
207
+ : new Error("The operation was aborted"),
208
+ );
209
+ } else if (timedOut) {
210
+ // 超时:name=TimeoutError(对齐标准错误分类)
211
+ reject(new TimeoutError(options.timeout));
212
+ } else {
213
+ resolve({ exitCode });
214
+ }
215
+ });
216
+ });
217
+ }
218
+
219
+ /**
220
+ * @param workspace session 工作区:writablePaths 的 "." 与 PROTECTED_DIRS 都基于它解析,
221
+ * 与当次命令的 cwd(仅作为进程执行目录)解耦,避免 workdir 参数漂移可写边界。
222
+ */
223
+ export function createBwrapBashOperations(
224
+ resolved: ResolvedBwrap,
225
+ workspace: string,
226
+ networkStack?: NetworkStack,
227
+ ): BashOperations {
228
+ return {
229
+ async exec(command, cwd, { onData, signal, timeout }) {
230
+ await fsAccess(cwd, constants.F_OK).catch(() => {
231
+ throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
232
+ });
233
+ // 已中断(signal.reason 是 name=AbortError 的 DOMException):直接抛,不再执行
234
+ signal?.throwIfAborted();
235
+
236
+ const invocation = await buildBwrapInvocation(resolved, workspace, command);
237
+ return execInvocation(invocation, {
238
+ cwd,
239
+ holderPid: networkStack?.holderPid,
240
+ onData,
241
+ signal,
242
+ timeout,
243
+ });
244
+ },
245
+ };
246
+ }
@@ -9,14 +9,6 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
9
  import { forEachLine } from "../lib/proc.js";
10
10
  import { generateMihomoConfig, TUN_MTU } from "./mihomo-config.js";
11
11
 
12
- /** 命令超时错误:name=TimeoutError(对齐标准错误分类),message 保留 timeout:N 格式。 */
13
- export class TimeoutError extends Error {
14
- constructor(timeout: number | undefined) {
15
- super(`timeout:${timeout}`);
16
- this.name = "TimeoutError";
17
- }
18
- }
19
-
20
12
  export interface NetworkStackOptions {
21
13
  /** 允许直连的域名 / IP:port 列表(每次命令从配置重新读取)。 */
22
14
  readonly allowlist: readonly string[];
@@ -31,19 +23,6 @@ export interface NetworkStackOptions {
31
23
  readonly onHolderOutput?: (chunk: string) => void;
32
24
  }
33
25
 
34
- interface NetworkStackExecOptions {
35
- readonly command: string;
36
- readonly cwd: string;
37
- readonly bwrapPath: string;
38
- /** bwrap 的完整参数(不含 -- 后的 shell 与命令),由 core.ts 组装。 */
39
- readonly bwrapArgs: readonly string[];
40
- readonly shell: string;
41
- readonly env: Readonly<Record<string, string>>;
42
- readonly onData: (data: Buffer) => void;
43
- readonly signal?: AbortSignal;
44
- readonly timeout?: number;
45
- }
46
-
47
26
  const NAMESERVER_PATTERN = /^\s*nameserver\s+(\S+)/;
48
27
 
49
28
  /**
@@ -198,21 +177,6 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
198
177
  });
199
178
  }
200
179
 
201
- function killChild(pid: number | undefined): void {
202
- if (!pid) {
203
- return;
204
- }
205
- try {
206
- process.kill(-pid, "SIGKILL");
207
- } catch {
208
- try {
209
- process.kill(pid, "SIGKILL");
210
- } catch {
211
- // 已退出
212
- }
213
- }
214
- }
215
-
216
180
  /**
217
181
  * 额外转发子进程输出给诊断回调(就绪探测的监听器不受影响),并把全部输出
218
182
  * 收进 collect:启动失败时落盘,否则没有别的渠道能看到 holder 的真实死因。
@@ -237,7 +201,6 @@ function forwardOutput(
237
201
  }
238
202
 
239
203
  export interface NetworkStack {
240
- exec(options: NetworkStackExecOptions): Promise<{ exitCode: number | null }>;
241
204
  stop(): Promise<void>;
242
205
  /** holder pid:可 `nsenter -U -n --preserve-credentials -t <pid>` 手动进入该 netns 排查。 */
243
206
  readonly holderPid: number;
@@ -391,80 +354,6 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
391
354
 
392
355
  const state: NetworkStackState = { holderPid, slirpPid: slirp.pid, mihomoHome };
393
356
  const stack: NetworkStack = {
394
- exec: async (execOptions: NetworkStackExecOptions) => {
395
- const child = spawn(
396
- "nsenter",
397
- [
398
- "-U",
399
- "-n",
400
- "--preserve-credentials",
401
- "-t",
402
- String(holderPid),
403
- "--",
404
- execOptions.bwrapPath,
405
- ...execOptions.bwrapArgs,
406
- "--",
407
- execOptions.shell,
408
- "-lc",
409
- execOptions.command,
410
- ],
411
- {
412
- cwd: execOptions.cwd,
413
- detached: true,
414
- stdio: ["ignore", "pipe", "pipe"],
415
- env: execOptions.env,
416
- },
417
- );
418
-
419
- return new Promise<{ exitCode: number | null }>((resolve, reject) => {
420
- let timedOut = false;
421
- let settled = false;
422
- const timeoutHandle = execOptions.timeout
423
- ? setTimeout(() => {
424
- timedOut = true;
425
- killChild(child.pid);
426
- }, execOptions.timeout * 1000)
427
- : undefined;
428
- const onAbort = (): void => {
429
- killChild(child.pid);
430
- };
431
-
432
- child.stdout.on("data", execOptions.onData);
433
- child.stderr.on("data", execOptions.onData);
434
- execOptions.signal?.addEventListener("abort", onAbort, { once: true });
435
-
436
- child.once("error", (error) => {
437
- if (settled) {
438
- return;
439
- }
440
- settled = true;
441
- reject(error);
442
- });
443
- child.once("close", (exitCode) => {
444
- if (settled) {
445
- return;
446
- }
447
- settled = true;
448
- if (timeoutHandle) {
449
- clearTimeout(timeoutHandle);
450
- }
451
- execOptions.signal?.removeEventListener("abort", onAbort);
452
- // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
453
- if (execOptions.signal?.aborted) {
454
- reject(
455
- execOptions.signal.reason instanceof Error
456
- ? execOptions.signal.reason
457
- : new Error("The operation was aborted"),
458
- );
459
- } else if (timedOut) {
460
- // 超时:name=TimeoutError(对齐标准错误分类)
461
- reject(new TimeoutError(execOptions.timeout));
462
- } else {
463
- resolve({ exitCode });
464
- }
465
- });
466
- });
467
- },
468
357
  stop: async () => {
469
358
  const children = await readChildPids(state.holderPid);
470
359
  // slirp4netns 持有 tap fd(pin 住 netns),必须随 holder 一起显式终止;
@@ -14,17 +14,24 @@ import {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import { throttle } from "lodash-es";
16
16
  import { type TObject, Type } from "typebox";
17
+ import { Value } from "typebox/value";
17
18
 
18
19
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
19
20
  import { fenceCodeBlock } from "../lib/markdown.js";
20
21
  import { isUnknownArray } from "../lib/narrow.js";
22
+ import { parseWithSchema } from "../lib/parse-with-schema.js";
21
23
  import { formatDisplayPath } from "../lib/path.js";
22
24
  import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
23
25
  import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
24
- import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
26
+ import {
27
+ type ApprovalRule,
28
+ type ApprovalRuleSet,
29
+ createApprovalRuleSet,
30
+ } from "./approval-rules.js";
25
31
  import { commandPatternsFor } from "./approval-suggest.js";
26
32
  import {
27
33
  type BwrapConfig,
34
+ bwrapConfigFileSchema,
28
35
  findBwrap,
29
36
  findMihomo,
30
37
  type FsMode,
@@ -381,6 +388,20 @@ export function sandboxHintBlock(hint: string | undefined): { type: "text"; text
381
388
  return hint === undefined ? [] : [{ type: "text", text: hint }];
382
389
  }
383
390
 
391
+ /**
392
+ * 项目配置文件形状非法时的错误:消息带文件路径与字段位置,cause 保留校验错误
393
+ * (Value.Check 只回答是否合法,详情用同一 schema 解析一次取到)。
394
+ */
395
+ function invalidConfigError(path: string, raw: unknown): Error {
396
+ try {
397
+ parseWithSchema(bwrapConfigFileSchema, raw);
398
+ } catch (error) {
399
+ const detail = error instanceof Error ? error.message : String(error);
400
+ return new Error(`Invalid bwrap configuration at ${path}: ${detail}`, { cause: error });
401
+ }
402
+ return new Error(`Invalid bwrap configuration at ${path}: schema validation failed`);
403
+ }
404
+
384
405
  export class BwrapRuntime {
385
406
  private resolved: ResolvedBwrap | undefined;
386
407
  private bwrapUnavailable = false;
@@ -539,8 +560,14 @@ export class BwrapRuntime {
539
560
  ) {
540
561
  throw new Error(UNSANDBOXED_DENIED);
541
562
  }
542
- // 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框
543
- const decision = await evaluateBashApproval(request.command, runtime.approvalRules);
563
+ // 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框。
564
+ // 规则集跟随 this.resolved,reload 或追加规则后无需重建
565
+ const ruleSet = createApprovalRuleSet({
566
+ rules: () => this.resolve(request.ctx).approvalRules,
567
+ suggestPatterns: commandPatternsFor,
568
+ persist: (rules) => this.persistAllowRules(request.ctx, rules),
569
+ });
570
+ const decision = await ruleSet.evaluate(request.command);
544
571
  if (decision === "deny") {
545
572
  throw new Error(`Command denied by bwrap approval rule: ${request.command}`);
546
573
  }
@@ -551,6 +578,7 @@ export class BwrapRuntime {
551
578
  request.command,
552
579
  request.description,
553
580
  execCwd,
581
+ ruleSet,
554
582
  )) === "sandbox";
555
583
  }
556
584
  }
@@ -686,12 +714,13 @@ export class BwrapRuntime {
686
714
  command: string,
687
715
  reason: string | undefined,
688
716
  execCwd: string,
717
+ ruleSet: ApprovalRuleSet,
689
718
  ): Promise<FullAccessGrant> {
690
719
  // hasUI 判定推迟到审批时刻:无 UI 会话弹不了审批框,按用户点 Deny 的标准文案拒绝
691
720
  if (!ctx.hasUI) {
692
721
  throw new Error(UNSANDBOXED_DENIED);
693
722
  }
694
- const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd);
723
+ const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd, ruleSet);
695
724
  // 关闭对话框 = 中断并拒绝,不循环重问
696
725
  if (decision === undefined) {
697
726
  ctx.abort();
@@ -706,13 +735,13 @@ export class BwrapRuntime {
706
735
  }
707
736
  case DENY: {
708
737
  if (foreverApprovedPattern.length > 0) {
709
- await this.persistAllowRule(ctx, command, foreverApprovedPattern);
738
+ await ruleSet.addAllowRules(foreverApprovedPattern);
710
739
  }
711
740
  throw new Error(UNSANDBOXED_DENIED);
712
741
  }
713
742
  case DENY_WITH_REASON: {
714
743
  if (foreverApprovedPattern.length > 0) {
715
- await this.persistAllowRule(ctx, command, foreverApprovedPattern);
744
+ await ruleSet.addAllowRules(foreverApprovedPattern);
716
745
  }
717
746
  const feedback = decision.reason?.trim() ?? "";
718
747
  throw new Error(
@@ -723,7 +752,7 @@ export class BwrapRuntime {
723
752
  }
724
753
  case ALLOW_ONCE: {
725
754
  if (foreverApprovedPattern.length > 0) {
726
- await this.persistAllowRule(ctx, command, foreverApprovedPattern);
755
+ await ruleSet.addAllowRules(foreverApprovedPattern);
727
756
  }
728
757
  return "full-access";
729
758
  }
@@ -744,22 +773,14 @@ export class BwrapRuntime {
744
773
  command: string,
745
774
  reason: string | undefined,
746
775
  execCwd: string,
776
+ ruleSet: ApprovalRuleSet,
747
777
  ): Promise<FullAccessUIDecision | undefined> {
748
778
  // 弹框前解析命令的持久化规则:`echo 1 | head` → `echo *`、`head *`。
749
779
  // 持久化规则的勾选折叠进 EDIT_RULES 子菜单,主决策列表只保留放行/拒绝,
750
780
  // 避免一屏 checkbox 淹没决策项。
751
- const patterns = await commandPatternsFor(command);
752
781
  // 子菜单只列出未命中 allow 规则的 pattern:已提前允许的部分自动放行,
753
782
  // 无需再展示或重复勾选持久化(deny 命中的命令在 evaluate 阶段已被拒绝)。
754
- const rules = this.resolve(ctx).approvalRules;
755
- const unallowedPatterns = [
756
- ...new Set(
757
- patterns.filter((pattern) => {
758
- const rule = rules.findLast((r) => matchRule(pattern, r.pattern));
759
- return rule?.action !== "allow";
760
- }),
761
- ),
762
- ];
783
+ const unallowedPatterns = await ruleSet.pendingPatterns(command);
763
784
  // dcg 扫描建议是可选的参考文本:未安装时静默跳过;已安装但扫描失败
764
785
  // 时 notify 提示,弹窗本身与无 dcg 时一致
765
786
  const outcome = await dcgSuggestion(command);
@@ -837,23 +858,27 @@ export class BwrapRuntime {
837
858
  }
838
859
  }
839
860
 
840
- /** 把命令的权限模式写入项目 sandbox.json 的 approvalRules(allow forever)。 */
841
- private async persistAllowRule(
861
+ /**
862
+ * 规则集的持久化实现:把勾选的 allow 规则追加进项目 sandbox.json 的
863
+ * approvalRules,并刷新缓存的规则使其立即生效。
864
+ */
865
+ private async persistAllowRules(
842
866
  ctx: ExtensionContext,
843
- command: string,
844
- patterns?: string[],
867
+ newRules: readonly ApprovalRule[],
845
868
  ): Promise<void> {
846
- const rulePatterns = patterns ?? (await commandPatternsFor(command));
847
- if (rulePatterns.length === 0) {
848
- return; // 解析失败:本次处理,不写规则
849
- }
850
- const newRules: ApprovalRule[] = rulePatterns.map((pattern) => ({ action: "allow", pattern }));
851
869
  const { project } = getBwrapConfigPaths(ctx.cwd);
852
870
  let config: Record<string, unknown> = {};
853
871
  if (existsSync(project)) {
854
- config = JSON.parse(readFileSync(project, "utf8")) as Record<string, unknown>;
872
+ const raw: unknown = JSON.parse(readFileSync(project, "utf8"));
873
+ // 会话开始时已校验过配置文件:这里再查一次形状(Value.Check 同时收窄类型),
874
+ // 避免把新规则追加进一个后续加载必然失败的文件(只在配置文件被中途改坏时可达)
875
+ if (!Value.Check(bwrapConfigFileSchema, raw)) {
876
+ throw invalidConfigError(project, raw);
877
+ }
878
+ config = raw;
855
879
  }
856
- // 既有规则按不透明值原样保留(形状不认识也不丢),只追加本次允许的规则。
880
+ // 既有规则按不透明值原样保留(形状不认识也不丢),只追加本次允许的规则;
881
+ // 与规则无关的字段(含不认识的)同样原样写回,不经过 schema 往返
857
882
  const existing = isUnknownArray(config.approvalRules) ? config.approvalRules : [];
858
883
  config.approvalRules = [...existing, ...newRules];
859
884
  await mkdir(dirname(project), { recursive: true });
@@ -16,10 +16,7 @@ import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
16
16
 
17
17
  import { expandHome } from "../lib/path.js";
18
18
  import {
19
- buildBwrapInvocation,
20
- bwrapArgv,
21
19
  type BwrapConfig,
22
- createBwrapBashOperations,
23
20
  createNetworkStack,
24
21
  type FsMode,
25
22
  getBwrapConfigPaths,
@@ -30,6 +27,7 @@ import {
30
27
  resolveBwrapPath,
31
28
  type ResolvedBwrap,
32
29
  } from "./core.js";
30
+ import { buildBwrapInvocation, createBwrapBashOperations, invocationArgv } from "./exec.js";
33
31
  import type { NetworkStack } from "./network-stack.js";
34
32
 
35
33
  export interface SandboxConfigInput {
@@ -161,7 +159,7 @@ export interface SandboxPreview {
161
159
  }
162
160
 
163
161
  /**
164
- * 打印将要执行的命令行(`--print-args`)。与 runInSandbox 共用同一段组装逻辑,
162
+ * 打印将要执行的命令行(`--print-args`)。与 `execInvocation` 共用同一段组装,
165
163
  * 不存在「打印的是一回事、跑的是另一回事」的漂移。
166
164
  */
167
165
  export async function previewSandboxCommand(
@@ -171,23 +169,13 @@ export async function previewSandboxCommand(
171
169
  },
172
170
  ): Promise<SandboxPreview> {
173
171
  const workspace = expandHome(options.workspace);
174
- const commandCwd = expandHome(options.commandCwd ?? workspace);
175
- const invocation = await buildBwrapInvocation(resolved, workspace, options.command, commandCwd);
172
+ const invocation = await buildBwrapInvocation(resolved, workspace, options.command);
176
173
  if (!invocation.needsNetworkStack || options.unsandboxed === true) {
177
- return { argv: bwrapArgv(invocation), env: invocation.env, needsNetworkStack: false };
174
+ return { argv: invocationArgv(invocation), env: invocation.env, needsNetworkStack: false };
178
175
  }
179
176
  return {
180
- // 与 network-stack.ts 的实际 spawn 一致;holder 未启动时用占位符标出 pid 的位置
181
- argv: [
182
- "nsenter",
183
- "-U",
184
- "-n",
185
- "--preserve-credentials",
186
- "-t",
187
- options.holderPid === undefined ? HOLDER_PID_PLACEHOLDER : String(options.holderPid),
188
- "--",
189
- ...bwrapArgv(invocation),
190
- ],
177
+ // holder 未启动时用占位符标出 pid 的位置
178
+ argv: invocationArgv(invocation, options.holderPid ?? HOLDER_PID_PLACEHOLDER),
191
179
  env: invocation.env,
192
180
  needsNetworkStack: true,
193
181
  };
@@ -1123,6 +1123,56 @@ export async function create(input: CreateInput): Promise<LspClient> {
1123
1123
  });
1124
1124
  }
1125
1125
 
1126
+ /** 轮询的归宿:谁先让等待收敛,以及没有收敛时是哪一种耗尽。 */
1127
+ type PollOutcome = "pulled" | "pushed" | "pullTimedOut" | "budgetExhausted";
1128
+
1129
+ /**
1130
+ * 等「当前文档版本」的诊断就绪,两种等待模式的唯一一份轮询实现。
1131
+ * pull 与 push 语义相同(都是等当前文档版本的诊断结果):先 pull,拿到就绪结论
1132
+ * 即返回;pull 超时说明服务器未响应,不再重试 pull,把「是否退回等 push」交给
1133
+ * 调用方;否则进入三路竞速——版本匹配的 push / 服务器注册表变化 / pull 重试间隔,
1134
+ * 除 push 命中外都再来一轮,直到预算耗尽、连接关闭或等待被中断。
1135
+ */
1136
+ async function pollUntilSettled(request: {
1137
+ path: string;
1138
+ /** 本次等待的预算(毫秒)。 */
1139
+ budgetMs: number;
1140
+ /** 预算起点(调用方传 request.after ?? Date.now())。 */
1141
+ startedAt: number;
1142
+ /** 版本匹配的 push 兜底,由调用方按自己的预算创建。 */
1143
+ pushWait: Promise<boolean>;
1144
+ /** pull 入口(两种模式各自的那一个)。 */
1145
+ pull: (path: string) => Promise<PullResult>;
1146
+ /** 该模式的就绪判据。 */
1147
+ isSettled: (result: PullResult) => boolean;
1148
+ signal?: AbortSignal;
1149
+ }): Promise<PollOutcome> {
1150
+ while (!connectionClosed && !request.signal?.aborted) {
1151
+ const remaining = request.budgetMs - (Date.now() - request.startedAt);
1152
+ if (remaining <= 0) {
1153
+ return "budgetExhausted";
1154
+ }
1155
+ const result = await request.pull(request.path);
1156
+ if (request.isSettled(result)) {
1157
+ return "pulled";
1158
+ }
1159
+ if (result.timedOut) {
1160
+ return "pullTimedOut";
1161
+ }
1162
+ const next = await Promise.race([
1163
+ request.pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
1164
+ waitForRegistrationChange(remaining).then((changed) =>
1165
+ changed ? ("registration" as const) : ("timeout" as const),
1166
+ ),
1167
+ sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
1168
+ ]);
1169
+ if (next === "push") {
1170
+ return "pushed";
1171
+ }
1172
+ }
1173
+ return "budgetExhausted";
1174
+ }
1175
+
1126
1176
  /**
1127
1177
  * 等待「当前文档版本」的诊断结论。返回值表示就绪是否被证实:true = 拿到了
1128
1178
  * 当前版本的诊断结果(push / pull / 已有结论);false = 等满预算或连接关闭
@@ -1150,9 +1200,6 @@ export async function create(input: CreateInput): Promise<LspClient> {
1150
1200
  files[request.path]?.lastPushEmpty === true
1151
1201
  ? Math.min(diagnosticsDocumentWaitTimeoutMs, diagnosticsSilentWaitTimeoutMs)
1152
1202
  : diagnosticsDocumentWaitTimeoutMs;
1153
- // pull 与 push 语义相同:都是等「当前文档版本」的诊断结果,统一一个循环。
1154
- // 先 pull(拿到即返回);pull 超时说明服务器未响应,不再重试 pull,只等
1155
- // 版本匹配的 push 兜底;版本不匹配的 push 一律忽略(防迟到旧结果)。
1156
1203
  const pushWait = waitForFreshPush({
1157
1204
  path: request.path,
1158
1205
  version: request.version,
@@ -1160,30 +1207,24 @@ export async function create(input: CreateInput): Promise<LspClient> {
1160
1207
  timeout: budget,
1161
1208
  });
1162
1209
 
1163
- while (!connectionClosed && !request.signal?.aborted) {
1164
- const remaining = budget - (Date.now() - startedAt);
1165
- if (remaining <= 0) {
1166
- return false;
1167
- }
1168
- const result = await requestDocumentDiagnostics(request.path);
1169
- if (result.matched) {
1170
- return true;
1171
- }
1172
- if (result.timedOut) {
1173
- return await pushWait;
1174
- }
1175
- const next = await Promise.race([
1176
- pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
1177
- waitForRegistrationChange(remaining).then((changed) =>
1178
- changed ? ("registration" as const) : ("timeout" as const),
1179
- ),
1180
- sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
1181
- ]);
1182
- if (next === "push") {
1183
- return true;
1184
- }
1185
- }
1186
- return false;
1210
+ const outcome = await pollUntilSettled({
1211
+ path: request.path,
1212
+ budgetMs: budget,
1213
+ startedAt,
1214
+ pushWait,
1215
+ pull: requestDocumentDiagnostics,
1216
+ isSettled: (result) => result.matched,
1217
+ signal: request.signal,
1218
+ });
1219
+ // 对应重构前的返回路径:pulled / pushed → `return true`(pull 命中或等到
1220
+ // 版本匹配的 push);pullTimedOut → `return await pushWait`(pull 挂起不重试,
1221
+ // 但继续等 push 到预算结束,返回是否等到);budgetExhausted(含连接关闭 /
1222
+ // 中断)→ 循环尾 `return false`。
1223
+ return (
1224
+ outcome === "pulled" ||
1225
+ outcome === "pushed" ||
1226
+ (outcome === "pullTimedOut" && (await pushWait))
1227
+ );
1187
1228
  }
1188
1229
 
1189
1230
  async function waitForFullDiagnostics(request: {
@@ -1200,29 +1241,20 @@ export async function create(input: CreateInput): Promise<LspClient> {
1200
1241
  timeout: diagnosticsFullWaitTimeoutMs,
1201
1242
  });
1202
1243
 
1203
- while (!connectionClosed && !request.signal?.aborted) {
1204
- const remaining = diagnosticsFullWaitTimeoutMs - (Date.now() - startedAt);
1205
- if (remaining <= 0) {
1206
- return;
1207
- }
1208
- const result = await requestFullDiagnostics(request.path);
1209
- if (result.handled || result.matched) {
1210
- return;
1211
- }
1212
- if (result.timedOut) {
1213
- await pushWait;
1214
- return;
1215
- }
1216
- const next = await Promise.race([
1217
- pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
1218
- waitForRegistrationChange(remaining).then((changed) =>
1219
- changed ? ("registration" as const) : ("timeout" as const),
1220
- ),
1221
- sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
1222
- ]);
1223
- if (next === "push") {
1224
- return;
1225
- }
1244
+ const outcome = await pollUntilSettled({
1245
+ path: request.path,
1246
+ budgetMs: diagnosticsFullWaitTimeoutMs,
1247
+ startedAt,
1248
+ pushWait,
1249
+ pull: requestFullDiagnostics,
1250
+ isSettled: (result) => result.handled || result.matched,
1251
+ signal: request.signal,
1252
+ });
1253
+ // 对应重构前的返回路径:pulled / pushed / budgetExhausted 都对应 `return`
1254
+ // (就绪、等到 push 或耗尽预算);pullTimedOut → `await pushWait; return`,
1255
+ // 即 pull 挂起后仍等 push 兜底到预算结束,结果丢弃(本函数返回 void)。
1256
+ if (outcome === "pullTimedOut") {
1257
+ await pushWait;
1226
1258
  }
1227
1259
  }
1228
1260