@trim21/personal-pi-extensions 0.1.443 → 0.1.445

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.443",
3
+ "version": "0.1.445",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/bwrap/core.ts CHANGED
@@ -380,7 +380,6 @@ function killChild(child: ChildProcess): void {
380
380
  /** 网络栈子进程输出转发通道,仅用于诊断(默认丢弃)。 */
381
381
  export interface NetworkStackLog {
382
382
  holder?: (chunk: string) => void;
383
- slirp?: (chunk: string) => void;
384
383
  }
385
384
 
386
385
  /** 为 net-allowlist 模式创建网络栈;非该模式返回 undefined。每次命令现建现停。 */
@@ -395,7 +394,6 @@ export async function createNetworkStack(
395
394
  mihomoPath: findMihomo(resolved.mihomoPath),
396
395
  slirp4netnsPath: findSlirp4netns(resolved.slirp4netnsPath),
397
396
  ...(log?.holder && { onHolderOutput: log.holder }),
398
- ...(log?.slirp && { onSlirpOutput: log.slirp }),
399
397
  });
400
398
  }
401
399
 
@@ -1,10 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { rmSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
2
4
  import { dirname } from "node:path";
3
5
  import { setTimeout as sleep } from "node:timers/promises";
4
6
  const args = process.argv.slice(2);
5
7
  const configPath = args[0];
6
8
  const mihomoPath = args[1];
7
- if (!configPath || !mihomoPath) {
9
+ const slirp4netnsPath = args[2];
10
+ const tunMtu = Number(args[3]);
11
+ if (!configPath || !mihomoPath || !slirp4netnsPath || !tunMtu) {
8
12
  process.exit(2);
9
13
  }
10
14
  function tap0Exists() {
@@ -22,6 +26,19 @@ async function waitFor(attempts, delayMs, predicate) {
22
26
  return false;
23
27
  }
24
28
  async function main() {
29
+ process.stdin.resume();
30
+ process.stdin.on("end", () => {
31
+ rmSync(dirname(configPath), { recursive: true, force: true });
32
+ process.exit(0);
33
+ });
34
+ const status = await readFile("/proc/self/status", "utf8");
35
+ const hostPid = /^NSpid:\s+(.+)$/m.exec(status)?.[1].split(/\s+/, 1)[0];
36
+ if (!hostPid) {
37
+ throw new Error("Failed to resolve host pid from NSpid");
38
+ }
39
+ spawn(slirp4netnsPath, ["-c", `--mtu=${tunMtu}`, "--netns-type=pid", hostPid, "tap0"], {
40
+ stdio: ["ignore", "inherit", "inherit"]
41
+ });
25
42
  await waitFor(100, 100, tap0Exists);
26
43
  const mihomo = spawn(mihomoPath, ["-d", dirname(configPath), "-f", configPath], {
27
44
  stdio: ["ignore", "inherit", "inherit"]
@@ -1,13 +1,19 @@
1
1
  /* eslint-disable no-console, unicorn/no-process-exit -- 独立 CLI 脚本,由 unshare 直接执行 */
2
2
  import { spawn } from "node:child_process";
3
+ import { rmSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
3
5
  import { dirname } from "node:path";
4
6
  import { setTimeout as sleep } from "node:timers/promises";
5
7
 
6
- // 用法:unshare -Urn node holder.ts <config> <mihomo>
8
+ // 用法:unshare -Urnp --fork --kill-child node holder.ts <config> <mihomo> <slirp4netns> <mtu>
9
+ // 本进程是 pid namespace 的 init:无论以何种方式退出(含 SIGKILL),内核都会清理该 pid ns 内的
10
+ // slirp4netns / mihomo,userns/netns 引用随之归零。
7
11
  const args = process.argv.slice(2);
8
12
  const configPath = args[0];
9
13
  const mihomoPath = args[1];
10
- if (!configPath || !mihomoPath) {
14
+ const slirp4netnsPath = args[2];
15
+ const tunMtu = Number(args[3]);
16
+ if (!configPath || !mihomoPath || !slirp4netnsPath || !tunMtu) {
11
17
  process.exit(2);
12
18
  }
13
19
 
@@ -32,10 +38,31 @@ async function waitFor(
32
38
  }
33
39
 
34
40
  async function main(): Promise<void> {
35
- // slirp4netns 由宿主侧 spawn,这里等它建好 tap0
36
- await waitFor(100, 100, tap0Exists);
41
+ // 宿主(pi)进程死亡检测:宿主以任何方式退出(含 SIGKILL)都会关闭它持有的 stdin 写端,
42
+ // 这里读到 EOF 即自杀(init 退出即触发内核清理整个 pid ns),顺带删除配置目录。
43
+ process.stdin.resume();
44
+ process.stdin.on("end", () => {
45
+ rmSync(dirname(configPath), { recursive: true, force: true });
46
+ process.exit(0);
47
+ });
48
+
49
+ // 宿主视角 pid(NSpid 第一项):/proc 挂载是宿主的(-Urnp 不含 -m,宿主视角下
50
+ // /proc/1 是 systemd 而非本 pid ns 的 init),slirp4netns 的 setns 必须走宿主 /proc
51
+ const status = await readFile("/proc/self/status", "utf8");
52
+ const hostPid = /^NSpid:\s+(.+)$/m.exec(status)?.[1].split(/\s+/, 1)[0];
53
+ if (!hostPid) {
54
+ throw new Error("Failed to resolve host pid from NSpid");
55
+ }
56
+
57
+ // slirp4netns 提供 egress:本进程已在目标 userns/netns 内,setns 到自身 netns(no-op);
58
+ // 日志 inherit 到 holder 的 stdout/stderr,宿主侧诊断(--verbose)可见
59
+ spawn(slirp4netnsPath, ["-c", `--mtu=${tunMtu}`, "--netns-type=pid", hostPid, "tap0"], {
60
+ stdio: ["ignore", "inherit", "inherit"],
61
+ });
37
62
 
38
63
  // mihomo 日志透传到 holder 的 stdout/stderr,宿主侧监听 "Tun adapter listening" 判定就绪
64
+ await waitFor(100, 100, tap0Exists);
65
+
39
66
  const mihomo = spawn(mihomoPath, ["-d", dirname(configPath), "-f", configPath], {
40
67
  stdio: ["ignore", "inherit", "inherit"],
41
68
  });
@@ -1,9 +1,10 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { mkdtemp, readFile, readlink, rm, writeFile } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
3
  import { join } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
5
 
6
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
+
7
8
  import { generateMihomoConfig, TUN_MTU } from "./mihomo-config.js";
8
9
 
9
10
  /** 命令超时错误:name=TimeoutError(对齐标准错误分类),message 保留 timeout:N 格式。 */
@@ -26,8 +27,6 @@ export interface NetworkStackOptions {
26
27
  * 因此启动失败时只剩 "exited before mihomo started",诊断需要它。
27
28
  */
28
29
  readonly onHolderOutput?: (chunk: string) => void;
29
- /** slirp4netns 输出透传;不传时其 stdio 保持 ignore。 */
30
- readonly onSlirpOutput?: (chunk: string) => void;
31
30
  }
32
31
 
33
32
  export interface NetworkStackExecOptions {
@@ -50,15 +49,38 @@ const HOLDER_PATH = fileURLToPath(new URL("holder.js", import.meta.url));
50
49
  // ip 通常位于 /usr/sbin 或 /sbin,进程默认 PATH 不含它们;node 则依赖宿主完整 PATH
51
50
  const SBIN_PATH_SUFFIX = "/usr/local/sbin:/usr/sbin:/sbin";
52
51
 
53
- function killProcess(pid: number | undefined): void {
52
+ function killProcess(pid: number | undefined, signal: NodeJS.Signals = "SIGTERM"): void {
54
53
  if (!pid) return;
55
54
  try {
56
- process.kill(pid, "SIGTERM");
55
+ process.kill(pid, signal);
57
56
  } catch {
58
57
  // 已退出
59
58
  }
60
59
  }
61
60
 
61
+ /** 轮询等待进程退出(进程消失即返回)。 */
62
+ async function waitForExit(pid: number, timeoutMs = 2000): Promise<void> {
63
+ const deadline = Date.now() + timeoutMs;
64
+ while (Date.now() < deadline) {
65
+ try {
66
+ process.kill(pid, 0);
67
+ } catch {
68
+ return;
69
+ }
70
+ await new Promise((resolve) => setTimeout(resolve, 50));
71
+ }
72
+ }
73
+
74
+ /** 读取进程的直接子进程 pid:--kill-child 只转发信号给 fork 的子进程,兜底直接 SIGKILL 用。 */
75
+ async function readChildPids(pid: number): Promise<number[]> {
76
+ try {
77
+ const content = await readFile(`/proc/${pid}/task/${pid}/children`, "utf8");
78
+ return content.trim().split(/\s+/).filter(Boolean).map(Number);
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
83
+
62
84
  /** 读宿主机 /etc/resolv.conf 的全部 IPv4 nameserver,按声明顺序返回。 */
63
85
  export async function resolveDnsServers(): Promise<string[]> {
64
86
  const content = await readFile("/etc/resolv.conf", "utf8");
@@ -74,7 +96,7 @@ export async function resolveDnsServers(): Promise<string[]> {
74
96
  return ipv4;
75
97
  }
76
98
 
77
- /** 等待目标进程进入新的 user namespace(unshare 完成),返回后 slirp4netns 才能 setns。 */
99
+ /** 等待目标进程进入新的 user namespace(unshare 完成),返回后命令才能 nsenter 进入。 */
78
100
  async function waitForNewUserns(pid: number, timeoutMs = 5000): Promise<void> {
79
101
  const self = await readlink("/proc/self/ns/user");
80
102
  const deadline = Date.now() + timeoutMs;
@@ -153,13 +175,12 @@ export interface NetworkStack {
153
175
 
154
176
  interface NetworkStackState {
155
177
  holderPid: number;
156
- slirpPid: number | undefined;
157
178
  directory: string;
158
179
  }
159
180
 
160
181
  /** 兜底:调用方忘记 stop() 时,对象被 GC 回收后 kill 残留进程并清理目录。 */
161
182
  const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
162
- killProcess(state.slirpPid);
183
+ // SIGTERM 经 unshare --kill-child 转发给 init,内核清理 pid ns 内全部进程
163
184
  killProcess(state.holderPid);
164
185
  void rm(state.directory, { recursive: true, force: true }).catch(() => false);
165
186
  });
@@ -176,121 +197,148 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
176
197
  mihomoPath,
177
198
  slirp4netnsPath,
178
199
  onHolderOutput: holderOutput,
179
- onSlirpOutput: slirpOutput,
180
200
  } = options;
181
- const directory = await mkdtemp(join(tmpdir(), "pi-netns-"));
201
+ // 临时目录放在 agent-dir/tmp(pi 自有临时区),不污染系统 /tmp
202
+ const directory = await mkdtemp(join(getAgentDir(), "tmp", "pi-netns-"));
182
203
  const configPath = join(directory, "mihomo.json");
183
204
  const config = generateMihomoConfig({ allowlist, dnsServers });
184
205
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
185
206
 
186
- const holder = spawn("unshare", ["-Urn", "--", "node", HOLDER_PATH, configPath, mihomoPath], {
187
- env: {
188
- HOME: process.env.HOME ?? "",
189
- PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
190
- },
191
- // mihomo 日志经 holder 透传到这里的 stdout/stderr,用于判定就绪
192
- stdio: ["ignore", "pipe", "pipe"],
193
- });
194
- forwardOutput(holder, holderOutput);
195
- if (holder.pid === undefined) {
196
- throw new Error("Failed to start network namespace holder");
197
- }
198
- const holderPid = holder.pid;
199
- await waitForNewUserns(holderPid);
207
+ let holder: ChildProcess | undefined;
208
+ try {
209
+ // unshare -p --fork:node 成为 pid namespace 的 init,任何方式退出(含 SIGKILL)
210
+ // 内核都会清理 pid ns 内全部进程(slirp4netns/mihomo),ns 引用随之归零;
211
+ // --kill-child=SIGTERM:宿主侧 SIGTERM unshare 时转发给 init 走优雅退出
212
+ holder = spawn(
213
+ "unshare",
214
+ [
215
+ "-Urnp",
216
+ "--fork",
217
+ "--kill-child=SIGTERM",
218
+ "--",
219
+ "node",
220
+ HOLDER_PATH,
221
+ configPath,
222
+ mihomoPath,
223
+ slirp4netnsPath,
224
+ String(TUN_MTU),
225
+ ],
226
+ {
227
+ env: {
228
+ HOME: process.env.HOME ?? "",
229
+ PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
230
+ },
231
+ // stdin 保持 pipe:本进程持有写端,进程退出(含 SIGKILL)时内核关闭 fd,
232
+ // holder 读到 EOF 即自杀(init 退出 → 内核清理 pid ns)
233
+ stdio: ["pipe", "pipe", "pipe"],
234
+ },
235
+ );
236
+ forwardOutput(holder, holderOutput);
237
+ if (holder.pid === undefined) {
238
+ throw new Error("Failed to start network namespace holder");
239
+ }
240
+ const holderPid = holder.pid;
241
+ await waitForNewUserns(holderPid);
200
242
 
201
- const slirp = spawn(
202
- slirp4netnsPath,
203
- [
204
- "-c",
205
- `--mtu=${TUN_MTU}`,
206
- `--userns-path=/proc/${holderPid}/ns/user`,
207
- "--netns-type=pid",
208
- String(holderPid),
209
- "tap0",
210
- ],
211
- // 默认丢弃 slirp4netns 日志;诊断时改为管道转发
212
- { stdio: slirpOutput ? ["ignore", "pipe", "pipe"] : "ignore" },
213
- );
214
- forwardOutput(slirp, slirpOutput);
215
- const slirpPid = slirp.pid;
216
- await waitForMihomoStarted(holder);
243
+ await waitForMihomoStarted(holder);
217
244
 
218
- const state: NetworkStackState = { holderPid, slirpPid, directory };
219
- const stack: NetworkStack = {
220
- exec: async (execOptions: NetworkStackExecOptions) => {
221
- const child = spawn(
222
- "nsenter",
223
- [
224
- "-U",
225
- "-n",
226
- "--preserve-credentials",
227
- "-t",
228
- String(holderPid),
229
- "--",
230
- execOptions.bwrapPath,
231
- ...execOptions.bwrapArgs,
232
- "--",
233
- execOptions.shell,
234
- "-lc",
235
- execOptions.command,
236
- ],
237
- {
238
- cwd: execOptions.cwd,
239
- detached: true,
240
- stdio: ["ignore", "pipe", "pipe"],
241
- env: execOptions.env,
242
- },
243
- );
245
+ const state: NetworkStackState = { holderPid, directory };
246
+ const stack: NetworkStack = {
247
+ exec: async (execOptions: NetworkStackExecOptions) => {
248
+ const child = spawn(
249
+ "nsenter",
250
+ [
251
+ "-U",
252
+ "-n",
253
+ "--preserve-credentials",
254
+ "-t",
255
+ String(holderPid),
256
+ "--",
257
+ execOptions.bwrapPath,
258
+ ...execOptions.bwrapArgs,
259
+ "--",
260
+ execOptions.shell,
261
+ "-lc",
262
+ execOptions.command,
263
+ ],
264
+ {
265
+ cwd: execOptions.cwd,
266
+ detached: true,
267
+ stdio: ["ignore", "pipe", "pipe"],
268
+ env: execOptions.env,
269
+ },
270
+ );
244
271
 
245
- return new Promise<{ exitCode: number | null }>((resolve, reject) => {
246
- let timedOut = false;
247
- let settled = false;
248
- const timeoutHandle = execOptions.timeout
249
- ? setTimeout(() => {
250
- timedOut = true;
251
- killChild(child.pid);
252
- }, execOptions.timeout * 1000)
253
- : undefined;
254
- const onAbort = (): void => {
255
- killChild(child.pid);
256
- };
272
+ return new Promise<{ exitCode: number | null }>((resolve, reject) => {
273
+ let timedOut = false;
274
+ let settled = false;
275
+ const timeoutHandle = execOptions.timeout
276
+ ? setTimeout(() => {
277
+ timedOut = true;
278
+ killChild(child.pid);
279
+ }, execOptions.timeout * 1000)
280
+ : undefined;
281
+ const onAbort = (): void => {
282
+ killChild(child.pid);
283
+ };
257
284
 
258
- child.stdout?.on("data", execOptions.onData);
259
- child.stderr?.on("data", execOptions.onData);
260
- execOptions.signal?.addEventListener("abort", onAbort, { once: true });
285
+ child.stdout?.on("data", execOptions.onData);
286
+ child.stderr?.on("data", execOptions.onData);
287
+ execOptions.signal?.addEventListener("abort", onAbort, { once: true });
261
288
 
262
- child.once("error", (error) => {
263
- if (settled) return;
264
- settled = true;
265
- reject(error);
266
- });
267
- child.once("close", (exitCode) => {
268
- if (settled) return;
269
- settled = true;
270
- if (timeoutHandle) clearTimeout(timeoutHandle);
271
- execOptions.signal?.removeEventListener("abort", onAbort);
272
- // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
273
- if (execOptions.signal?.aborted) {
274
- reject(
275
- execOptions.signal.reason instanceof Error
276
- ? execOptions.signal.reason
277
- : new Error("The operation was aborted"),
278
- );
279
- } else if (timedOut) {
280
- // 超时:name=TimeoutError(对齐标准错误分类)
281
- reject(new TimeoutError(execOptions.timeout));
282
- } else resolve({ exitCode });
289
+ child.once("error", (error) => {
290
+ if (settled) return;
291
+ settled = true;
292
+ reject(error);
293
+ });
294
+ child.once("close", (exitCode) => {
295
+ if (settled) return;
296
+ settled = true;
297
+ if (timeoutHandle) clearTimeout(timeoutHandle);
298
+ execOptions.signal?.removeEventListener("abort", onAbort);
299
+ // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
300
+ if (execOptions.signal?.aborted) {
301
+ reject(
302
+ execOptions.signal.reason instanceof Error
303
+ ? execOptions.signal.reason
304
+ : new Error("The operation was aborted"),
305
+ );
306
+ } else if (timedOut) {
307
+ // 超时:name=TimeoutError(对齐标准错误分类)
308
+ reject(new TimeoutError(execOptions.timeout));
309
+ } else resolve({ exitCode });
310
+ });
283
311
  });
284
- });
285
- },
286
- stop: async () => {
287
- killProcess(state.slirpPid);
288
- killProcess(state.holderPid);
289
- await rm(state.directory, { recursive: true, force: true }).catch(() => false);
290
- },
291
- holderPid,
292
- configPath,
293
- };
294
- stackFinalizer.register(stack, state);
295
- return stack;
312
+ },
313
+ stop: async () => {
314
+ const children = await readChildPids(state.holderPid);
315
+ // SIGTERM unshare → --kill-child 转发 SIGTERM 给 init(pid ns 的 pid 1),
316
+ // init 优雅停 mihomo 后退出,内核清理 pid ns 内全部进程,ns 引用随之归零
317
+ killProcess(state.holderPid);
318
+ await waitForExit(state.holderPid);
319
+ // 兜底:init 未在超时内退出 → SIGKILL init → 内核清 pid ns
320
+ for (const pid of children) {
321
+ killProcess(pid, "SIGKILL");
322
+ }
323
+ await rm(state.directory, { recursive: true, force: true }).catch(() => false);
324
+ },
325
+ holderPid,
326
+ configPath,
327
+ };
328
+ stackFinalizer.register(stack, state);
329
+ return stack;
330
+ } catch (error) {
331
+ // 失败清理:holder(unshare)的 SIGTERM 经 --kill-child 转发给 init,
332
+ // init 退出时内核清理 pid ns 内全部进程;调用方拿不到 stack,这里只能靠自己
333
+ if (holder?.pid) {
334
+ const children = await readChildPids(holder.pid);
335
+ killProcess(holder.pid);
336
+ await waitForExit(holder.pid);
337
+ for (const pid of children) {
338
+ killProcess(pid, "SIGKILL");
339
+ }
340
+ }
341
+ await rm(directory, { recursive: true, force: true }).catch(() => false);
342
+ throw error;
343
+ }
296
344
  }