@trim21/personal-pi-extensions 0.1.454 → 0.1.459

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.454",
3
+ "version": "0.1.459",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "src",
15
+ "vendor",
15
16
  "README.md"
16
17
  ],
17
18
  "publishConfig": {
@@ -24,6 +25,7 @@
24
25
  "test": "vitest run",
25
26
  "test:coverage": "vitest run --coverage",
26
27
  "sandbox": "tsx bin/sandbox.ts",
28
+ "aft-search": "tsx bin/aft-search.ts",
27
29
  "build:holder": "esbuild src/bwrap/holder.ts --format=esm --target=node24 --outfile=src/bwrap/holder.js",
28
30
  "prepare": "husky"
29
31
  },
@@ -43,6 +45,7 @@
43
45
  "@types/turndown": "^5.0.6",
44
46
  "@typescript/native": "npm:typescript@^7.0.2",
45
47
  "@vitest/coverage-v8": "^4.1.10",
48
+ "citty": "^0.2.2",
46
49
  "esbuild": "^0.28.2",
47
50
  "eslint": "^10.8.1",
48
51
  "eslint-config-prettier": "10.1.8",
@@ -0,0 +1,92 @@
1
+ # bwrap 沙箱与网络栈架构
2
+
3
+ 本文档描述 `net-allowlist` 模式下的进程模型、网络路径与生命周期管理。
4
+ 基础沙箱(bwrap 文件系统隔离)见 `core.ts` / `sandbox.ts`;本文聚焦网络栈
5
+ (`network-stack.ts` / `holder.ts` / `mihomo-config.ts`)。
6
+
7
+ ## 进程模型
8
+
9
+ `net-allowlist` 模式下,一个沙箱 session 的常驻进程树(宿主侧视角,共 4 个):
10
+
11
+ ```
12
+ pi 进程(network-stack.ts)
13
+ ├─ ① unshare -Urnp --fork --kill-child=SIGTERM -- node holder.js …
14
+ │ 创建 user+net+pid 三个 ns;--fork 后由子进程 exec node;
15
+ │ 自己留在原地 wait;--kill-child=SIGTERM 给 ② 设 PDEATHSIG。
16
+ │ 持 exit-fd 写端(fd 3)。
17
+ │ └─ ② node holder.js (pid-ns 的 init,即该 pid-ns 里的 PID 1)
18
+ │ 读 stdin(EOF 自杀)、等 tap0、拉起 ③;持 exit-fd 写端 fd 3。
19
+ │ └─ ③ mihomo (netns 内:TUN "Meta" + 策略路由 + fakeip DNS)
20
+ └─ ④ slirp4netns -c --userns-path=…/ns/user --netns-type=pid <①的pid> tap0 -e 3
21
+ 必须在宿主 netns 启动(原因见「设计约束」);持 exit-fd 读端 + tapfd。
22
+ ```
23
+
24
+ 每条命令的短命子树(命令结束即退,与常驻栈无关):
25
+
26
+ ```
27
+ nsenter -U -n --preserve-credentials -t <①的pid> \
28
+ -- bwrap --unshare-user --unshare-pid … -- bash -lc '<command>'
29
+ ```
30
+
31
+ nsenter 进入 holder 的 userns/netns,bwrap 在里面再嵌套创建自己的 user/pid
32
+ ns 跑命令。一个 session 内 N 条命令复用同一套常驻栈。
33
+
34
+ ## 网络路径
35
+
36
+ - **mihomo(③)**:TUN(`auto-route` + `strict-route`)+ fakeip +
37
+ deny-by-default。allowlist 域名进 `fake-ip-filter`(真实解析),DNS 层
38
+ `DOMAIN-SUFFIX,…,DIRECT`;连接层未命中 allowlist 的流量 `MATCH,REJECT`。
39
+ 注意:fakeip 对不在 filter 里的域名**直接本地应答**,不会走到
40
+ `dns.rules` 的 REJECT——未允许域名是先拿 fakeip、连接层再被拒。
41
+ - **slirp4netns(④)**:egress NAT。它 fork helper 进 netns 创建 tap0 并把
42
+ tapfd 传回主进程,真正的出站 socket 在宿主 netns。
43
+ - **interface-name: tap0**:mihomo 出站静态绑定 slirp 接口。不能用
44
+ `auto-detect-interface` 顶替——启动瞬间 tap0 可能尚未就绪,monitor 事后
45
+ 纠正但 DNS 拨号已走错接口,上游查询进自己的 TUN 被 `dns-hijack` 自劫持。
46
+ - **mihomo `-d <uuid 目录>`**:cache.db 等落盘位置,放
47
+ `<agentDir>/tmp/mihomo-<uuid>/`,每次启动独立目录避免并发争抢 bbolt 锁。
48
+
49
+ ## 生命周期与清理
50
+
51
+ exit-fd(socketpair)是 slirp4netns 与 holder 之间唯一的生命周期绑定:
52
+ 读端给 slirp4netns(`-e 3`),写端由 holder 进程持有(network-stack 经
53
+ `unshare` 的额外 stdio 传入 fd 3)。Node 对额外 stdio pipe 没有公开的 fd
54
+ 访问器(`stdio[3].fd` 恒为 undefined),只能经 `_handle.fd` 取原始 fd 再
55
+ dup 给 slirp4netns,且仅在子进程存活期间有效。
56
+
57
+ | 触发 | 清理链路 |
58
+ | ----------------- | ----------------------------------------------------------------------------------------------------- |
59
+ | 正常 `stop()` | SIGTERM ④(先杀,它 pin 着 netns)→ SIGTERM ① → `--kill-child` 转发给 ② → ② 杀 ③ 退出 → 内核清 pid ns |
60
+ | pi 进程被 SIGKILL | stdin 写端关闭 → ② EOF 自杀 → pid ns 清理 → exit-fd 写端关闭 → ④ HUP 自杀 → tapfd 释放 → netns 销毁 |
61
+ | 单独 kill ① | PDEATHSIG → ② SIGTERM → 同上;① wait 结束退出 → 写端全关 → ④ 退 |
62
+ | 单独 kill ② | pid-ns init 死 → 内核清 ③;① 退出 → 写端全关 → ④ 退 |
63
+
64
+ 四条路径下常驻进程全部收敛、netns 引用归零。
65
+
66
+ ## 设计约束与教训
67
+
68
+ 1. **slirp4netns 必须在宿主 netns 启动**。它的 egress socket 决定出站视角;
69
+ 若留在沙盒 netns 里(holder 内启动),出站流量会被 mihomo 的 TUN 策略
70
+ 路由 + `dns-hijack any:53` 自劫持成环:上游 DNS 查询自己劫自己,
71
+ mihomo 对劫持查询回 SERVFAIL(源 IP 伪装成原目的地址),allowlist 域名
72
+ 全部 `ENOTFOUND`,而未 allowlist 域名反而"正常"(fakeip 本地应答)。
73
+ 宿主侧启动后必须用 exit-fd 绑定生命周期,否则 holder 死后 slirp4netns
74
+ 持 tapfd 泄漏 netns。
75
+ 2. **tap fd pin 住 netns**:slirp4netns 持有 tapfd 期间 netns 不会销毁,
76
+ 所以任何架构下 slirp4netns 的终止都必须显式保证(stop() / exit-fd)。
77
+ 3. **fakeip 短路**:`dns.rules` 的 REJECT 拦不住 fakeip 应答,deny-by-default
78
+ 实际由连接层 `MATCH,REJECT` 兜底。诊断时不要把"未允许域名能解析出
79
+ 198.18.x.x"当成 DNS 层放行。
80
+ 4. **诊断手段**:`pnpm sandbox --verbose` 透传 holder(mihomo/slirp4netns)
81
+ 日志;`nsenter -U -n --preserve-credentials -t <holderPid>` 可手动进入
82
+ netns 用 AF_PACKET 抓 tap0 / 检查 `ip rule`(注意:沙盒里看不到宿主机
83
+ 进程,宿主机诊断必须在沙盒外做)。
84
+
85
+ ## 调试入口
86
+
87
+ ```sh
88
+ # 诊断执行(与扩展同一条代码路径)
89
+ pnpm sandbox --verbose -- '<command>'
90
+ # 修改 holder.ts 后需重新构建构建产物 holder.js
91
+ pnpm build:holder
92
+ ```
package/src/bwrap/core.ts CHANGED
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { constants, type Dirent, existsSync, readFileSync } from "node:fs";
3
3
  import { access as fsAccess, readdir, stat } from "node:fs/promises";
4
4
  import { delimiter, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
 
6
7
  import { StringEnum } from "@earendil-works/pi-ai";
7
8
  import { type BashOperations, getAgentDir, getShellConfig } from "@earendil-works/pi-coding-agent";
@@ -241,16 +242,28 @@ function findCommandInPath(name: string, hint: string): string {
241
242
  throw new Error(hint);
242
243
  }
243
244
 
244
- export function findMihomo(override?: string): string {
245
+ // npm 包内自带的静态编译 mihomo(linux-x64),发布时由 CI 下载到 vendor/;
246
+ // 源码方式运行(vendor 不存在)时自然跳过,走 PATH 查找。
247
+ const VENDORED_MIHOMO_PATH = fileURLToPath(
248
+ new URL("../../vendor/mihomo-linux-x64", import.meta.url),
249
+ );
250
+
251
+ export function findMihomo(override?: string, bundledPath = VENDORED_MIHOMO_PATH): string {
245
252
  if (override) {
246
253
  if (!existsSync(override)) {
247
254
  throw new Error(`mihomo not found at configured path: ${override}`);
248
255
  }
249
256
  return override;
250
257
  }
251
- return findCommandInPath(
252
- "mihomo",
253
- "mihomo not found in PATH. Install it from https://github.com/MetaCubeX/mihomo",
258
+ // 优先全局 PATH(用户自装的 mihomo),没有时回退到包内自带二进制
259
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
260
+ const candidate = join(directory, "mihomo");
261
+ if (existsSync(candidate)) return candidate;
262
+ }
263
+ if (existsSync(bundledPath)) return bundledPath;
264
+ throw new Error(
265
+ "mihomo not found in PATH and the package ships no bundled binary. " +
266
+ "Install it from https://github.com/MetaCubeX/mihomo",
254
267
  );
255
268
  }
256
269
 
@@ -1,12 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
- import { readFile } from "node:fs/promises";
3
2
  import { setTimeout as sleep } from "node:timers/promises";
4
3
  const args = process.argv.slice(2);
5
4
  const configBase64 = args[0];
6
5
  const mihomoPath = args[1];
7
- const slirp4netnsPath = args[2];
8
- const tunMtu = Number(args[3]);
9
- if (!configBase64 || !mihomoPath || !slirp4netnsPath || !tunMtu) {
6
+ const tunMtu = Number(args[2]);
7
+ const mihomoHome = args[3];
8
+ if (!configBase64 || !mihomoPath || !tunMtu || !mihomoHome) {
10
9
  process.exit(2);
11
10
  }
12
11
  process.chdir("/");
@@ -27,16 +26,8 @@ async function waitFor(attempts, delayMs, predicate) {
27
26
  async function main() {
28
27
  process.stdin.resume();
29
28
  process.stdin.on("end", () => process.exit(0));
30
- const status = await readFile("/proc/self/status", "utf8");
31
- const hostPid = /^NSpid:\s+(.+)$/m.exec(status)?.[1].split(/\s+/, 1)[0];
32
- if (!hostPid) {
33
- throw new Error("Failed to resolve host pid from NSpid");
34
- }
35
- spawn(slirp4netnsPath, ["-c", `--mtu=${tunMtu}`, "--netns-type=pid", hostPid, "tap0"], {
36
- stdio: ["ignore", "inherit", "inherit"]
37
- });
38
29
  await waitFor(100, 100, tap0Exists);
39
- const mihomo = spawn(mihomoPath, ["-config", configBase64], {
30
+ const mihomo = spawn(mihomoPath, ["-d", mihomoHome, "-config", configBase64], {
40
31
  stdio: ["ignore", "inherit", "inherit"]
41
32
  });
42
33
  const stop = () => {
@@ -1,17 +1,19 @@
1
1
  /* eslint-disable no-console, unicorn/no-process-exit -- 独立 CLI 脚本,由 unshare 直接执行 */
2
2
  import { spawn } from "node:child_process";
3
- import { readFile } from "node:fs/promises";
4
3
  import { setTimeout as sleep } from "node:timers/promises";
5
4
 
6
- // 用法:unshare -Urnp --fork --kill-child node holder.ts <config-base64> <mihomo> <slirp4netns> <mtu>
5
+ // 用法:unshare -Urnp --fork --kill-child node holder.ts <config-base64> <mihomo> <mtu> <mihomo-home-dir>
7
6
  // 本进程是 pid namespace 的 init:无论以何种方式退出(含 SIGKILL),内核都会清理该 pid ns 内的
8
- // slirp4netns / mihomo,userns/netns 引用随之归零。
7
+ // mihomo,userns/netns 引用随之归零。slirp4netns 由宿主侧启动(egress 必须留在宿主 netns,
8
+ // 否则出站流量会绕回 mihomo 的 TUN 被 dns-hijack 自劫持),它通过 exit-fd 绑定本进程的生命周期:
9
+ // fd 3 是 exit-fd 的写端(network-stack 经 unshare 传入),本进程存活期间保持打开,死亡即关闭,
10
+ // slirp4netns 侧收到 HUP 自行退出并释放 tap fd,netns 引用随之归零。
9
11
  const args = process.argv.slice(2);
10
12
  const configBase64 = args[0];
11
13
  const mihomoPath = args[1];
12
- const slirp4netnsPath = args[2];
13
- const tunMtu = Number(args[3]);
14
- if (!configBase64 || !mihomoPath || !slirp4netnsPath || !tunMtu) {
14
+ const tunMtu = Number(args[2]);
15
+ const mihomoHome = args[3];
16
+ if (!configBase64 || !mihomoPath || !tunMtu || !mihomoHome) {
15
17
  process.exit(2);
16
18
  }
17
19
 
@@ -40,28 +42,15 @@ async function waitFor(
40
42
 
41
43
  async function main(): Promise<void> {
42
44
  // 宿主(pi)进程死亡检测:宿主以任何方式退出(含 SIGKILL)都会关闭它持有的 stdin 写端,
43
- // 这里读到 EOF 即自杀(init 退出即触发内核清理整个 pid ns)。
45
+ // 这里读到 EOF 即自杀(init 退出即触发内核清理整个 pid ns,顺带关闭 exit-fd 写端)。
44
46
  process.stdin.resume();
45
47
  process.stdin.on("end", () => process.exit(0));
46
48
 
47
- // 宿主视角 pid(NSpid 第一项):/proc 挂载是宿主的(-Urnp 不含 -m,宿主视角下
48
- // /proc/1 是 systemd 而非本 pid ns 的 init),slirp4netns 的 setns 必须走宿主 /proc
49
- const status = await readFile("/proc/self/status", "utf8");
50
- const hostPid = /^NSpid:\s+(.+)$/m.exec(status)?.[1].split(/\s+/, 1)[0];
51
- if (!hostPid) {
52
- throw new Error("Failed to resolve host pid from NSpid");
53
- }
54
-
55
- // slirp4netns 提供 egress:本进程已在目标 userns/netns 内,setns 到自身 netns(no-op);
56
- // 日志 inherit 到 holder 的 stdout/stderr,宿主侧诊断(--verbose)可见
57
- spawn(slirp4netnsPath, ["-c", `--mtu=${tunMtu}`, "--netns-type=pid", hostPid, "tap0"], {
58
- stdio: ["ignore", "inherit", "inherit"],
59
- });
60
-
61
- // mihomo 日志透传到 holder 的 stdout/stderr,宿主侧监听 "Tun adapter listening" 判定就绪
49
+ // tap0 由宿主侧的 slirp4netns(fork 进本 netns)创建,就绪后再拉起 mihomo。
50
+ // mihomo 日志透传到 holder 的 stdout/stderr,宿主侧监听 "Tun adapter listening" 判定就绪。
62
51
  await waitFor(100, 100, tap0Exists);
63
52
 
64
- const mihomo = spawn(mihomoPath, ["-config", configBase64], {
53
+ const mihomo = spawn(mihomoPath, ["-d", mihomoHome, "-config", configBase64], {
65
54
  stdio: ["ignore", "inherit", "inherit"],
66
55
  });
67
56
 
@@ -20,6 +20,8 @@ export interface MihomoConfig {
20
20
  mode: "rule";
21
21
  "log-level": "info";
22
22
  ipv6: false;
23
+ /** 出站静态绑定 slirp 接口(固定名 tap0)。 */
24
+ "interface-name": string;
23
25
  dns: {
24
26
  enable: true;
25
27
  ipv6: false;
@@ -173,6 +175,11 @@ export function generateMihomoConfig(options: MihomoConfigOptions): MihomoConfig
173
175
  mode: "rule",
174
176
  "log-level": "info",
175
177
  ipv6: false,
178
+ // 静态绑定 tap0:auto-detect-interface 在启动瞬间可能探测到未就绪的接口
179
+ //(tap0 尚由 slirp4netns 创建),monitor 事后纠正但 DNS 拨号已经走错接口,
180
+ // 上游查询进自己的 TUN 被 dns-hijack 自劫持形成回环(allowlist 域名全部
181
+ // SERVFAIL)。slirp 接口名固定是 tap0,直接写死最可靠。
182
+ "interface-name": "tap0",
176
183
  dns: {
177
184
  enable: true,
178
185
  ipv6: false,
@@ -1,7 +1,11 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
- import { readFile, readlink } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, readFile, readlink } from "node:fs/promises";
4
+ import { join } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
 
7
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
8
+
5
9
  import { forEachLine } from "../lib/proc.js";
6
10
  import { generateMihomoConfig, TUN_MTU } from "./mihomo-config.js";
7
11
 
@@ -178,12 +182,15 @@ export interface NetworkStack {
178
182
 
179
183
  interface NetworkStackState {
180
184
  holderPid: number;
185
+ slirpPid: number;
181
186
  }
182
187
 
183
188
  /** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程。 */
184
189
  const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
185
- // SIGTERM 经 unshare --kill-child 转发给 init,内核清理 pid ns 内全部进程
190
+ // SIGTERM 经 unshare --kill-child 转发给 init,内核清理 pid ns 内全部进程;
191
+ // slirp4netns 在宿主侧持有 tap fd,单独终止
186
192
  killProcess(state.holderPid);
193
+ killProcess(state.slirpPid);
187
194
  });
188
195
 
189
196
  /**
@@ -203,10 +210,17 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
203
210
  const config = generateMihomoConfig({ allowlist, dnsServers });
204
211
  const configBase64 = Buffer.from(JSON.stringify(config)).toString("base64");
205
212
 
213
+ // mihomo 工作目录(-d):cache.db 等落在这里,而不是它默认的 ~/.config/mihomo/
214
+ //(后者不存在时 mihomo 每次启动都告警且 fakeip 映射无持久化)。每次启动用独立
215
+ // uuid 目录,避免并发的多个 holder 争抢 bbolt 文件锁。
216
+ const mihomoHome = join(getAgentDir(), "tmp", `mihomo-${randomUUID()}`);
217
+ await mkdir(mihomoHome, { recursive: true });
218
+
206
219
  let holder: ChildProcess | undefined;
220
+ let slirp: ChildProcess | undefined;
207
221
  try {
208
222
  // unshare -p --fork:node 成为 pid namespace 的 init,任何方式退出(含 SIGKILL)
209
- // 内核都会清理 pid ns 内全部进程(slirp4netns/mihomo),ns 引用随之归零;
223
+ // 内核都会清理 pid ns 内全部进程(mihomo),ns 引用随之归零;
210
224
  // --kill-child=SIGTERM:宿主侧 SIGTERM unshare 时转发给 init 走优雅退出
211
225
  holder = spawn(
212
226
  "unshare",
@@ -219,8 +233,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
219
233
  HOLDER_PATH,
220
234
  configBase64,
221
235
  mihomoPath,
222
- slirp4netnsPath,
223
236
  String(TUN_MTU),
237
+ mihomoHome,
224
238
  ],
225
239
  {
226
240
  env: {
@@ -228,8 +242,9 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
228
242
  PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
229
243
  },
230
244
  // stdin 保持 pipe:本进程持有写端,进程退出(含 SIGKILL)时内核关闭 fd,
231
- // holder 读到 EOF 即自杀(init 退出 → 内核清理 pid ns)
232
- stdio: ["pipe", "pipe", "pipe"],
245
+ // holder 读到 EOF 即自杀(init 退出 → 内核清理 pid ns)。
246
+ // fd 3:exit-fd 写端,传给 holder 持有(holder 死亡 → 读端 HUP → slirp4netns 退出)。
247
+ stdio: ["pipe", "pipe", "pipe", "pipe"],
233
248
  },
234
249
  );
235
250
  forwardOutput(holder, holderOutput);
@@ -239,9 +254,45 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
239
254
  const holderPid = holder.pid;
240
255
  await waitForNewUserns(holderPid);
241
256
 
257
+ // slirp4netns 提供 egress,必须在宿主 netns 启动:它的 egress socket 决定出站
258
+ // 视角,留在沙盒 netns 里会被 mihomo 的 TUN 策略路由 + dns-hijack 自劫持成环
259
+ //(上游 DNS 查询自己劫自己,allowlist 域名全部 SERVFAIL)。tap fd 会被它持有
260
+ // 而 pin 住 netns,因此用 exit-fd(holder 的 fd 3 写端的读端)绑定 holder 生命
261
+ // 周期:holder 退出 → 读端 HUP → slirp4netns 退出 → tap fd 释放。
262
+ // --userns-path / --netns-type=pid 都指向 unshare 进程:它创建并持有目标
263
+ // userns/netns(node holder 是它的子进程,同 ns)。
264
+ // exit-fd 的读端 fd:从 holder 的额外 stdio pipe 取父进程侧的原始 fd。
265
+ // Node 没有公开 API 拿它(stdio[3].fd 恒为 undefined,只能读 _handle,且仅在
266
+ // 子进程存活期间有效);pipe 要跨两个子进程共享(holder 持写端、slirp4netns
267
+ // 持读端),所以必须把父进程侧的 fd 重新 dup 给 slirp4netns。
268
+ const exitReadFd = (holder.stdio[3] as unknown as { _handle?: { fd?: number } } | null)?._handle
269
+ ?.fd;
270
+ if (typeof exitReadFd !== "number") {
271
+ throw new TypeError("Failed to resolve exit-fd from holder stdio");
272
+ }
273
+ slirp = spawn(
274
+ slirp4netnsPath,
275
+ [
276
+ "-c",
277
+ `--mtu=${TUN_MTU}`,
278
+ `--userns-path=/proc/${String(holderPid)}/ns/user`,
279
+ "--netns-type=pid",
280
+ String(holderPid),
281
+ "tap0",
282
+ // exit-fd:本子进程的 fd 3(stdio 第 4 项 dup 为 fd 3)
283
+ "-e",
284
+ "3",
285
+ ],
286
+ { stdio: ["ignore", "pipe", "pipe", exitReadFd] },
287
+ );
288
+ forwardOutput(slirp, holderOutput);
289
+ if (slirp.pid === undefined) {
290
+ throw new Error("Failed to start slirp4netns");
291
+ }
292
+
242
293
  await waitForMihomoStarted(holder);
243
294
 
244
- const state: NetworkStackState = { holderPid };
295
+ const state: NetworkStackState = { holderPid, slirpPid: slirp.pid };
245
296
  const stack: NetworkStack = {
246
297
  exec: async (execOptions: NetworkStackExecOptions) => {
247
298
  const child = spawn(
@@ -311,10 +362,14 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
311
362
  },
312
363
  stop: async () => {
313
364
  const children = await readChildPids(state.holderPid);
365
+ // slirp4netns 持有 tap fd(pin 住 netns),必须随 holder 一起显式终止;
366
+ // 先杀它再杀 holder,避免 stop() 与 exit-fd HUP 的收尾时序竞争
367
+ killProcess(state.slirpPid);
314
368
  // SIGTERM unshare → --kill-child 转发 SIGTERM 给 init(pid ns 的 pid 1),
315
369
  // init 优雅停 mihomo 后退出,内核清理 pid ns 内全部进程,ns 引用随之归零
316
370
  killProcess(state.holderPid);
317
371
  await waitForExit(state.holderPid);
372
+ await waitForExit(state.slirpPid);
318
373
  // 兜底:init 未在超时内退出 → SIGKILL init → 内核清 pid ns
319
374
  for (const pid of children) {
320
375
  killProcess(pid, "SIGKILL");
@@ -326,7 +381,11 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
326
381
  return stack;
327
382
  } catch (error) {
328
383
  // 失败清理:holder(unshare)的 SIGTERM 经 --kill-child 转发给 init,
329
- // init 退出时内核清理 pid ns 内全部进程;调用方拿不到 stack,这里只能靠自己
384
+ // init 退出时内核清理 pid ns 内全部进程;slirp4netns 在宿主侧,需单独终止
385
+ //(exit-fd 写端也会随 holder 死亡关闭,这里主动杀只是不等到 HUP 轮询)
386
+ if (slirp?.pid) {
387
+ killProcess(slirp.pid);
388
+ }
330
389
  if (holder?.pid) {
331
390
  const children = await readChildPids(holder.pid);
332
391
  killProcess(holder.pid);
@@ -28,6 +28,7 @@ import { commandPatternsFor } from "./approval-suggest.js";
28
28
  import {
29
29
  type BwrapMode,
30
30
  findBwrap,
31
+ findMihomo,
31
32
  getBwrapConfigPaths,
32
33
  loadBwrapConfig,
33
34
  resolveBwrap,
@@ -273,6 +274,8 @@ export class BwrapRuntime {
273
274
  private resolved: ResolvedBwrap | undefined;
274
275
  private sandboxDisabled = false;
275
276
  private bwrapUnavailable = false;
277
+ /** net-allowlist 首次执行时解析一次 mihomo 路径,之后随 runtime 复用,不逐命令扫描 PATH。 */
278
+ private mihomoPath: string | undefined;
276
279
 
277
280
  setup(pi: ExtensionAPI): void {
278
281
  pi.registerFlag("no-bwrap", {
@@ -389,6 +392,12 @@ export class BwrapRuntime {
389
392
  }
390
393
  // 不经沙箱的三种情形:Windows(无 bubblewrap)、审批通过的全权限、allow-all 模式
391
394
  const local = isWindows || needsApproval || !runtime.bwrapEnabled;
395
+ // mihomoPath 是 ResolvedBwrap 的 override 语义:首次 net-allowlist 执行时解析
396
+ // 一次存入私有字段,之后写回 resolved 直达 createNetworkStack,不逐命令扫描 PATH
397
+ if (runtime.network && runtime.networkAllowlist.length > 0) {
398
+ runtime.mihomoPath ??= this.mihomoPath ?? findMihomo();
399
+ this.mihomoPath = runtime.mihomoPath;
400
+ }
392
401
  await using output = new BashOutput(request.ctx.sessionManager.getSessionId());
393
402
  const { onUpdate } = request;
394
403
 
@@ -1,10 +1,10 @@
1
1
  /**
2
- * 诊断报告格式化:报告 ERROR 与 WARN,每个文件最多 20 条。
2
+ * 诊断报告格式化:报告 ERROR 与 WARN,每个文件最多 5 条。
3
3
  */
4
4
 
5
5
  import type { Diagnostic } from "./client.js";
6
6
 
7
- const MAX_PER_FILE = 20;
7
+ const MAX_PER_FILE = 5;
8
8
 
9
9
  const SEVERITY_LABELS: Record<number, string> = {
10
10
  1: "ERROR",
Binary file