@trim21/personal-pi-extensions 0.1.613 → 0.1.619

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.613",
3
+ "version": "0.1.619",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 进程模型
8
8
 
9
- `network: limited` 模式下,一个沙箱 session 的常驻进程树(宿主侧视角,共 4 个):
9
+ `network: limited` 模式下,每条命令的网络栈进程树(宿主侧视角,共 4 个):
10
10
 
11
11
  ```
12
12
  pi 进程(network-stack.ts)
@@ -21,7 +21,7 @@ pi 进程(network-stack.ts)
21
21
  必须在宿主 netns 启动(原因见「设计约束」);持 exit-fd 读端 + tapfd。
22
22
  ```
23
23
 
24
- 每条命令的短命子树(命令结束即退,与常驻栈无关):
24
+ 每条命令的短命子树(命令结束即退):
25
25
 
26
26
  ```
27
27
  nsenter -U -n --preserve-credentials -t <①的pid> \
@@ -29,7 +29,8 @@ nsenter -U -n --preserve-credentials -t <①的pid> \
29
29
  ```
30
30
 
31
31
  nsenter 进入 holder 的 userns/netns,bwrap 在里面再嵌套创建自己的 user/pid
32
- ns 跑命令。一个 session 内 N 条命令复用同一套常驻栈。
32
+ ns 跑命令。网络栈是每条命令现建现停(起栈 ~40ms、停栈 ~100ms),不跨命令复用:
33
+ allowlist 变更因此即时生效,代价是每条命令重新启动一次 mihomo。
33
34
 
34
35
  ## 网络路径
35
36
 
@@ -54,14 +55,16 @@ exit-fd(socketpair)是 slirp4netns 与 holder 之间唯一的生命周期绑
54
55
  访问器(`stdio[3].fd` 恒为 undefined),只能经 `_handle.fd` 取原始 fd 再
55
56
  dup 给 slirp4netns,且仅在子进程存活期间有效。
56
57
 
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 死 → 内核清 ③;① 退出 → 写端全关 → ④ 退 |
58
+ | 触发 | 清理链路 |
59
+ | ---------------------- | ---------------------------------------------------------------------------------------------------- |
60
+ | 正常 `stop()` | SIGTERM ④(先杀,它 pin 着 netns)→ SIGKILL ① → PDEATHSIG 给 ② SIGTERM → ② 杀 ③ 退出 → 内核清 pid ns |
61
+ | pi 进程被 SIGKILL | stdin 写端关闭 → ② EOF 自杀 → pid ns 清理 → exit-fd 写端关闭 → ④ HUP 自杀 → tapfd 释放 → netns 销毁 |
62
+ | 单独 kill ①(SIGKILL) | PDEATHSIG → ② SIGTERM → 同上;① wait 结束退出 → 写端全关 → ④ 退 |
63
+ | 单独 kill ② | pid-ns init 死 → 内核清 ③;① 退出 → 写端全关 → ④ 退 |
63
64
 
64
- 四条路径下常驻进程全部收敛、netns 引用归零。
65
+ 四条路径下网络栈全部进程收敛、netns 引用归零。注意 `--kill-child` 不是信号转发:
66
+ 它的实现是 unshare fork 出的子进程给自己设 PDEATHSIG(unshare 死亡时收到 SIGTERM),
67
+ 所以终止 ① 只能靠 SIGKILL(见「设计约束」第 5 条)。
65
68
 
66
69
  ## 设计约束与教训
67
70
 
@@ -81,6 +84,12 @@ dup 给 slirp4netns,且仅在子进程存活期间有效。
81
84
  日志;`nsenter -U -n --preserve-credentials -t <holderPid>` 可手动进入
82
85
  netns 用 AF_PACKET 抓 tap0 / 检查 `ip rule`(注意:沙盒里看不到宿主机
83
86
  进程,宿主机诊断必须在沙盒外做)。
87
+ 5. **终止 unshare 只能用 SIGKILL**。util-linux 的 unshare 在 fork 前
88
+ `sigprocmask(SIG_BLOCK, {SIGINT, SIGTERM})`,且只在子进程里恢复掩码:
89
+ 父进程永久阻塞这两个信号且不装 handler,发给它的 SIGTERM 只会 pending
90
+ 永不投递(实测 3s 后仍存活)。曾因此在 `stop()` 里白等 `waitForExit`
91
+ 的 2000ms 默认超时,把每条命令的沙箱开销从 ~180ms 抬到 ~2.09s。SIGKILL
92
+ 立即生效,PDEATHSIG 再把 SIGTERM 交给 ② 走优雅退出。
84
93
 
85
94
  ## 调试入口
86
95
 
@@ -1,6 +1,6 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdir, readFile, readlink, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, readlink, rm, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
@@ -94,6 +94,17 @@ function killProcess(pid: number | undefined, signal: NodeJS.Signals = "SIGTERM"
94
94
  }
95
95
  }
96
96
 
97
+ /**
98
+ * 终止 holder(unshare 包装进程)必须用 SIGKILL:util-linux 的 unshare 在 fork 前
99
+ * `sigprocmask(SIG_BLOCK, {SIGINT, SIGTERM})`,且只在子进程里恢复掩码——父进程永久
100
+ * 阻塞这两个信号,发给它的 SIGTERM 只会 pending 永不投递(实测 3s 后仍存活)。
101
+ * SIGKILL 不可阻塞,unshare 立即退出;其子进程(pid ns 的 init)经 --kill-child 的
102
+ * PDEATHSIG 收到 SIGTERM,走优雅退出并触发内核清理整个 pid ns。
103
+ */
104
+ function killHolder(pid: number | undefined): void {
105
+ killProcess(pid, "SIGKILL");
106
+ }
107
+
97
108
  /** 轮询等待进程退出(进程消失即返回)。 */
98
109
  async function waitForExit(pid: number, timeoutMs = 2000): Promise<void> {
99
110
  const deadline = Date.now() + timeoutMs;
@@ -107,7 +118,7 @@ async function waitForExit(pid: number, timeoutMs = 2000): Promise<void> {
107
118
  }
108
119
  }
109
120
 
110
- /** 读取进程的直接子进程 pid:--kill-child 只转发信号给 fork 的子进程,兜底直接 SIGKILL 用。 */
121
+ /** 读取 holder 的直接子进程 pid(pid ns 的 init):PDEATHSIG 未生效时的 SIGKILL 兜底用。 */
111
122
  async function readChildPids(pid: number): Promise<number[]> {
112
123
  try {
113
124
  const content = await readFile(`/proc/${pid}/task/${pid}/children`, "utf8");
@@ -235,14 +246,19 @@ export interface NetworkStack {
235
246
  interface NetworkStackState {
236
247
  holderPid: number;
237
248
  slirpPid: number;
249
+ /** 过滤进程的工作目录:GC 兜底路径也要把它删掉。 */
250
+ mihomoHome: string;
238
251
  }
239
252
 
240
- /** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程。 */
253
+ /** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程并清掉工作目录。 */
241
254
  const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
242
- // SIGTERM 经 unshare --kill-child 转发给 init,内核清理 pid ns 内全部进程;
255
+ // holder 退出触发内核清理 pid ns 内全部进程;
243
256
  // slirp4netns 在宿主侧持有 tap fd,单独终止
244
- killProcess(state.holderPid);
257
+ killHolder(state.holderPid);
245
258
  killProcess(state.slirpPid);
259
+ // FinalizationRegistry 回调不能 await:尽力而为,删不掉就留下(宿主崩溃时同样如此)
260
+ // eslint-disable-next-line unicorn/no-useless-undefined
261
+ void rm(state.mihomoHome, { recursive: true, force: true }).catch(() => undefined);
246
262
  });
247
263
 
248
264
  /**
@@ -264,7 +280,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
264
280
 
265
281
  // mihomo 工作目录(-d):cache.db 等落在这里,而不是它默认的 ~/.config/mihomo/
266
282
  //(后者不存在时 mihomo 每次启动都告警且 fakeip 映射无持久化)。每次启动用独立
267
- // uuid 目录,避免并发的多个 holder 争抢 bbolt 文件锁。
283
+ // uuid 目录,避免并发的多个 holder 争抢 bbolt 文件锁;正常停栈与 GC 兜底删除该
284
+ // 目录(见 stop / stackFinalizer),启动失败时保留作诊断材料(见 catch)。
268
285
  const mihomoHome = join(getAgentDir(), "tmp", `mihomo-${randomUUID()}`);
269
286
  await mkdir(mihomoHome, { recursive: true });
270
287
 
@@ -275,7 +292,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
275
292
  try {
276
293
  // unshare -p --fork:node 成为 pid namespace 的 init,任何方式退出(含 SIGKILL)
277
294
  // 内核都会清理 pid ns 内全部进程(mihomo),ns 引用随之归零;
278
- // --kill-child=SIGTERM:宿主侧 SIGTERM unshare 时转发给 init 走优雅退出
295
+ // --kill-child=SIGTERM:init 拿到 PR_SET_PDEATHSIG,unshare 死亡时收到 SIGTERM
296
+ // 走优雅退出(注意 unshare 自身阻塞 SIGINT/SIGTERM,终止它只能靠 SIGKILL,见 killHolder)
279
297
  holder = spawn(
280
298
  "unshare",
281
299
  [
@@ -371,7 +389,7 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
371
389
  mihomoReady.catch(() => undefined);
372
390
  });
373
391
 
374
- const state: NetworkStackState = { holderPid, slirpPid: slirp.pid };
392
+ const state: NetworkStackState = { holderPid, slirpPid: slirp.pid, mihomoHome };
375
393
  const stack: NetworkStack = {
376
394
  exec: async (execOptions: NetworkStackExecOptions) => {
377
395
  const child = spawn(
@@ -452,35 +470,42 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
452
470
  // slirp4netns 持有 tap fd(pin 住 netns),必须随 holder 一起显式终止;
453
471
  // 先杀它再杀 holder,避免 stop() 与 exit-fd HUP 的收尾时序竞争
454
472
  killProcess(state.slirpPid);
455
- // SIGTERM unshare → --kill-child 转发 SIGTERM 给 init(pid ns 的 pid 1),
456
- // init 优雅停 mihomo 后退出,内核清理 pid ns 内全部进程,ns 引用随之归零
457
- killProcess(state.holderPid);
473
+ // SIGKILL holder → init 经 PDEATHSIG 收到 SIGTERM,优雅停 mihomo 后退出,
474
+ // 内核清理 pid ns 内全部进程,ns 引用随之归零(毫秒级,见 killHolder)
475
+ killHolder(state.holderPid);
458
476
  await waitForExit(state.holderPid);
459
477
  await waitForExit(state.slirpPid);
460
478
  // 兜底:init 未在超时内退出 → SIGKILL init → 内核清 pid ns
461
479
  for (const pid of children) {
462
480
  killProcess(pid, "SIGKILL");
463
481
  }
482
+ // 工作目录只服务本次实例(mihomo 的 cache.db 等运行时缓存),随停栈删除;
483
+ // best-effort:删除失败不外抛,避免掩盖命令结果
484
+ // eslint-disable-next-line unicorn/no-useless-undefined
485
+ await rm(state.mihomoHome, { recursive: true, force: true }).catch(() => undefined);
464
486
  },
465
487
  holderPid,
466
488
  };
467
489
  stackFinalizer.register(stack, state);
468
490
  return stack;
469
491
  } catch (error) {
470
- // 失败清理:holder(unshare)的 SIGTERM 经 --kill-child 转发给 init,
471
- // init 退出时内核清理 pid ns 内全部进程;slirp4netns 在宿主侧,需单独终止
492
+ // 失败清理:SIGKILL holder(unshare)→ init 经 PDEATHSIG 收到 SIGTERM 后退出,
493
+ // 内核清理 pid ns 内全部进程;slirp4netns 在宿主侧,需单独终止
472
494
  //(exit-fd 写端也会随 holder 死亡关闭,这里主动杀只是不等到 HUP 轮询)
473
495
  if (slirp?.pid) {
474
496
  killProcess(slirp.pid);
475
497
  }
476
498
  if (holder?.pid) {
477
499
  const children = await readChildPids(holder.pid);
478
- killProcess(holder.pid);
500
+ killHolder(holder.pid);
479
501
  await waitForExit(holder.pid);
480
502
  for (const pid of children) {
481
503
  killProcess(pid, "SIGKILL");
482
504
  }
483
505
  }
506
+ // 这里刻意不删 mihomoHome:启动失败时它属于现场材料,与下面落盘的诊断日志
507
+ //(holder / slirp 输出 + 错误本身)配套保留,便于事后排查;失败路径罕见,
508
+ // 留一个目录不构成泄漏
484
509
  const logPath = await writeFailureLog(error, [holderLog.join(""), slirpLog.join("")]);
485
510
  if (logPath !== undefined && error instanceof Error) {
486
511
  throw new Error(`${error.message}\n(sandbox startup diagnostics: ${logPath})`, {
@@ -117,7 +117,7 @@ export async function runInSandbox(
117
117
  const workspace = expandHome(options.workspace);
118
118
  const commandCwd = expandHome(options.commandCwd ?? workspace);
119
119
  const local = options.unsandboxed === true || !resolved.bwrapEnabled;
120
- // 每次执行现建网络栈(启动约 140ms),作用域结束即停栈:allowlist 变更即时生效
120
+ // 每次执行现建现停网络栈(起栈 ~40ms、停栈 ~100ms),作用域结束即停栈:allowlist 变更即时生效
121
121
  const stack = local ? undefined : await createNetworkStack(resolved, options.log);
122
122
  try {
123
123
  if (stack) {