@trim21/personal-pi-extensions 0.1.519 → 0.1.520

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.519",
3
+ "version": "0.1.520",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -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 } from "node:fs/promises";
3
+ import { mkdir, readFile, readlink, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
@@ -25,8 +25,8 @@ export interface NetworkStackOptions {
25
25
  readonly mihomoPath: string;
26
26
  readonly slirp4netnsPath: string;
27
27
  /**
28
- * holder(unshare + mihomo)输出透传。默认只用于就绪探测、内容丢弃,
29
- * 因此启动失败时只剩 "exited before mihomo started",诊断需要它。
28
+ * holder(unshare + mihomo)输出透传。输出同时始终写入诊断缓冲,启动
29
+ * 失败时落盘到 agent-dir/tmp 并把路径附进错误信息,没有它也能拿到死因。
30
30
  */
31
31
  readonly onHolderOutput?: (chunk: string) => void;
32
32
  }
@@ -45,6 +45,38 @@ export interface NetworkStackExecOptions {
45
45
  }
46
46
 
47
47
  const NAMESERVER_PATTERN = /^\s*nameserver\s+(\S+)/;
48
+
49
+ /**
50
+ * 启动失败时把收集到的子进程完整 stdout/stderr 与错误本身落盘到
51
+ * agent-dir/tmp,返回日志路径;写入失败(如目录不可写)静默返回 undefined,
52
+ * 不掩盖原错误。日志不进工具结果文本:holder 输出可能很长且与命令无关,
53
+ * 只回路径。
54
+ */
55
+ async function writeFailureLog(
56
+ error: unknown,
57
+ logs: readonly string[],
58
+ ): Promise<string | undefined> {
59
+ const sections = logs.map(
60
+ (log, index) => `## child ${index}\n${log.length > 0 ? log : "(no output captured)"}`,
61
+ );
62
+ const content = [
63
+ `# ${new Date().toISOString()}`,
64
+ "",
65
+ error instanceof Error ? (error.stack ?? error.message) : String(error),
66
+ "",
67
+ ...sections,
68
+ "",
69
+ ].join("\n");
70
+ const dir = join(getAgentDir(), "tmp");
71
+ const path = join(dir, `bwrap-netstack-${randomUUID()}.log`);
72
+ try {
73
+ await mkdir(dir, { recursive: true });
74
+ await writeFile(path, content);
75
+ return path;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
48
80
  // 构建产物 holder.js(esbuild 编译):node 对 node_modules 下的 .ts 拒绝 type stripping,
49
81
  // 扩展从 npm 包加载时 holder.ts 落在 node_modules 下,直接运行会 ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING
50
82
  const HOLDER_PATH = fileURLToPath(new URL("holder.js", import.meta.url));
@@ -113,7 +145,10 @@ async function waitForNewUserns(pid: number, timeoutMs = 5000): Promise<void> {
113
145
  throw new Error("Timed out waiting for sandbox network namespace");
114
146
  }
115
147
 
116
- /** 监听 holder 的 stdout/stderr(mihomo 日志透传),以 "Tun adapter listening" 作为就绪标志。 */
148
+ /**
149
+ * 监听 holder 的 stdout/stderr(mihomo 日志透传),以 "Tun adapter listening" 作为就绪标志。
150
+ * holder 提前退出的情形由 startNetworkStack 统一注册的 exit 监听兜底。
151
+ */
117
152
  function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<void> {
118
153
  return new Promise((resolve, reject) => {
119
154
  let settled = false;
@@ -139,12 +174,6 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
139
174
  });
140
175
  }
141
176
  }
142
- holder.once("exit", (code) => {
143
- if (settled) return;
144
- settled = true;
145
- clearTimeout(timer);
146
- reject(new Error(`Sandbox holder exited before mihomo started (code ${code})`));
147
- });
148
177
  });
149
178
  }
150
179
 
@@ -162,15 +191,26 @@ function killChild(pid: number | undefined): void {
162
191
  }
163
192
 
164
193
  /**
165
- * 额外转发子进程输出给诊断回调(就绪探测的监听器不受影响)。
194
+ * 额外转发子进程输出给诊断回调(就绪探测的监听器不受影响),并把全部输出
195
+ * 收进 collect:启动失败时落盘,否则没有别的渠道能看到 holder 的真实死因。
166
196
  * 注册 error 监听器后 spawn 失败(如 unshare 缺失)不再以未捕获异常结束进程。
167
197
  */
168
- function forwardOutput(child: ChildProcess, onOutput: ((chunk: string) => void) | undefined): void {
169
- if (!onOutput) return;
170
- const write = (chunk: Buffer): void => onOutput(chunk.toString());
198
+ function forwardOutput(
199
+ child: ChildProcess,
200
+ onOutput: ((chunk: string) => void) | undefined,
201
+ collect: string[],
202
+ ): void {
203
+ const write = (chunk: Buffer): void => {
204
+ const text = chunk.toString();
205
+ collect.push(text);
206
+ onOutput?.(text);
207
+ };
171
208
  child.stdout?.on("data", write);
172
209
  child.stderr?.on("data", write);
173
- child.once("error", (error) => onOutput(String(error)));
210
+ child.once("error", (error) => {
211
+ collect.push(String(error));
212
+ onOutput?.(String(error));
213
+ });
174
214
  }
175
215
 
176
216
  export interface NetworkStack {
@@ -218,6 +258,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
218
258
 
219
259
  let holder: ChildProcess | undefined;
220
260
  let slirp: ChildProcess | undefined;
261
+ const holderLog: string[] = [];
262
+ const slirpLog: string[] = [];
221
263
  try {
222
264
  // unshare -p --fork:node 成为 pid namespace 的 init,任何方式退出(含 SIGKILL)
223
265
  // 内核都会清理 pid ns 内全部进程(mihomo),ns 引用随之归零;
@@ -247,12 +289,34 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
247
289
  stdio: ["pipe", "pipe", "pipe", "pipe"],
248
290
  },
249
291
  );
250
- forwardOutput(holder, holderOutput);
251
- if (holder.pid === undefined) {
252
- throw new Error("Failed to start network namespace holder");
292
+ forwardOutput(holder, holderOutput, holderLog);
293
+ let holderPid = holder.pid;
294
+ if (holderPid === undefined) {
295
+ // spawn 失败(如 unshare 缺失):error 事件异步到达,等一拍让它落进诊断缓冲
296
+ await new Promise((resolve) => setImmediate(resolve));
297
+ holderPid = holder.pid;
298
+ if (holderPid === undefined) {
299
+ throw new Error("Failed to start network namespace holder");
300
+ }
253
301
  }
254
- const holderPid = holder.pid;
255
- await waitForNewUserns(holderPid);
302
+ // holder 提前退出是启动失败最常见的形态(unshare 被拒、node 崩溃、mihomo 起
303
+ // 不来):立即注册 exit 监听并参与后续所有等待的 race,避免「进程秒死却被
304
+ // 呈现为 5s/20s 超时」。stop() 正常杀 holder 也走这里,下方 catch 防
305
+ // unhandled rejection。
306
+ const { promise: holderExited, reject: rejectHolderExited } = Promise.withResolvers<never>();
307
+ holder.once("exit", (code) => {
308
+ rejectHolderExited(new Error(`Sandbox holder exited before mihomo started (code ${code})`));
309
+ });
310
+ // stop() 正常终止 holder 也会 reject:吞掉,防 unhandled rejection
311
+ // eslint-disable-next-line unicorn/no-useless-undefined
312
+ holderExited.catch(() => undefined);
313
+ // race 输掉的 promise 之后仍可能迟到 reject(如 userns 轮询到点才超时),
314
+ // 补 no-op catch 防止 unhandled rejection 让进程崩溃
315
+ const usernsReady = waitForNewUserns(holderPid);
316
+ await Promise.race([usernsReady, holderExited]).finally(() => {
317
+ // eslint-disable-next-line unicorn/no-useless-undefined
318
+ usernsReady.catch(() => undefined);
319
+ });
256
320
 
257
321
  // slirp4netns 提供 egress,必须在宿主 netns 启动:它的 egress socket 决定出站
258
322
  // 视角,留在沙盒 netns 里会被 mihomo 的 TUN 策略路由 + dns-hijack 自劫持成环
@@ -285,12 +349,16 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
285
349
  ],
286
350
  { stdio: ["ignore", "pipe", "pipe", exitReadFd] },
287
351
  );
288
- forwardOutput(slirp, holderOutput);
352
+ forwardOutput(slirp, holderOutput, slirpLog);
289
353
  if (slirp.pid === undefined) {
290
354
  throw new Error("Failed to start slirp4netns");
291
355
  }
292
356
 
293
- await waitForMihomoStarted(holder);
357
+ const mihomoReady = waitForMihomoStarted(holder);
358
+ await Promise.race([mihomoReady, holderExited]).finally(() => {
359
+ // eslint-disable-next-line unicorn/no-useless-undefined
360
+ mihomoReady.catch(() => undefined);
361
+ });
294
362
 
295
363
  const state: NetworkStackState = { holderPid, slirpPid: slirp.pid };
296
364
  const stack: NetworkStack = {
@@ -394,6 +462,12 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
394
462
  killProcess(pid, "SIGKILL");
395
463
  }
396
464
  }
465
+ const logPath = await writeFailureLog(error, [holderLog.join(""), slirpLog.join("")]);
466
+ if (logPath !== undefined && error instanceof Error) {
467
+ throw new Error(`${error.message}\n(sandbox startup diagnostics: ${logPath})`, {
468
+ cause: error,
469
+ });
470
+ }
397
471
  throw error;
398
472
  }
399
473
  }