@trim21/personal-pi-extensions 0.1.442 → 0.1.444

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.442",
3
+ "version": "0.1.444",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -24,6 +24,7 @@
24
24
  "test": "vitest run",
25
25
  "test:coverage": "vitest run --coverage",
26
26
  "sandbox": "tsx bin/sandbox.ts",
27
+ "build:holder": "esbuild src/bwrap/holder.ts --format=esm --target=node24 --outfile=src/bwrap/holder.js",
27
28
  "prepare": "husky"
28
29
  },
29
30
  "peerDependencies": {
@@ -42,6 +43,7 @@
42
43
  "@types/turndown": "^5.0.6",
43
44
  "@typescript/native": "npm:typescript@^7.0.2",
44
45
  "@vitest/coverage-v8": "^4.1.10",
46
+ "esbuild": "^0.28.2",
45
47
  "eslint": "^10.8.1",
46
48
  "eslint-config-prettier": "10.1.8",
47
49
  "eslint-plugin-erasable-syntax-only": "0.7.1",
@@ -0,0 +1,47 @@
1
+ import { spawn } from "node:child_process";
2
+ import { dirname } from "node:path";
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ const args = process.argv.slice(2);
5
+ const configPath = args[0];
6
+ const mihomoPath = args[1];
7
+ if (!configPath || !mihomoPath) {
8
+ process.exit(2);
9
+ }
10
+ function tap0Exists() {
11
+ return new Promise((resolve) => {
12
+ const child = spawn("ip", ["link", "show", "tap0"], { stdio: "ignore" });
13
+ child.once("exit", (code) => resolve(code === 0));
14
+ child.once("error", () => resolve(false));
15
+ });
16
+ }
17
+ async function waitFor(attempts, delayMs, predicate) {
18
+ for (let i = 0; i < attempts; i++) {
19
+ if (await predicate()) return true;
20
+ await sleep(delayMs);
21
+ }
22
+ return false;
23
+ }
24
+ async function main() {
25
+ await waitFor(100, 100, tap0Exists);
26
+ const mihomo = spawn(mihomoPath, ["-d", dirname(configPath), "-f", configPath], {
27
+ stdio: ["ignore", "inherit", "inherit"]
28
+ });
29
+ const stop = () => {
30
+ mihomo.kill("SIGTERM");
31
+ };
32
+ process.on("SIGTERM", () => {
33
+ stop();
34
+ process.exit(0);
35
+ });
36
+ process.on("SIGINT", () => {
37
+ stop();
38
+ process.exit(0);
39
+ });
40
+ await new Promise((resolve) => mihomo.once("exit", () => resolve()));
41
+ }
42
+ try {
43
+ await main();
44
+ } catch (error) {
45
+ console.error(error);
46
+ process.exit(1);
47
+ }
@@ -44,7 +44,9 @@ export interface NetworkStackExecOptions {
44
44
  }
45
45
 
46
46
  const NAMESERVER_PATTERN = /^\s*nameserver\s+(\S+)/;
47
- const HOLDER_PATH = fileURLToPath(new URL("holder.ts", import.meta.url));
47
+ // 构建产物 holder.js(esbuild 编译):node 对 node_modules 下的 .ts 拒绝 type stripping,
48
+ // 扩展从 npm 包加载时 holder.ts 落在 node_modules 下,直接运行会 ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING
49
+ const HOLDER_PATH = fileURLToPath(new URL("holder.js", import.meta.url));
48
50
  // ip 通常位于 /usr/sbin 或 /sbin,进程默认 PATH 不含它们;node 则依赖宿主完整 PATH
49
51
  const SBIN_PATH_SUFFIX = "/usr/local/sbin:/usr/sbin:/sbin";
50
52
 
@@ -57,6 +59,29 @@ function killProcess(pid: number | undefined): void {
57
59
  }
58
60
  }
59
61
 
62
+ /**
63
+ * SIGTERM 后等待进程退出;超时仍未退出则 SIGKILL。
64
+ * holder/slirp 持有 userns/netns 引用,进程不退 ns 就不会释放。
65
+ */
66
+ async function terminateProcess(pid: number | undefined, timeoutMs = 2000): Promise<void> {
67
+ if (!pid) return;
68
+ killProcess(pid);
69
+ const deadline = Date.now() + timeoutMs;
70
+ while (Date.now() < deadline) {
71
+ try {
72
+ process.kill(pid, 0);
73
+ } catch {
74
+ return;
75
+ }
76
+ await new Promise((resolve) => setTimeout(resolve, 50));
77
+ }
78
+ try {
79
+ process.kill(pid, "SIGKILL");
80
+ } catch {
81
+ // 已退出
82
+ }
83
+ }
84
+
60
85
  /** 读宿主机 /etc/resolv.conf 的全部 IPv4 nameserver,按声明顺序返回。 */
61
86
  export async function resolveDnsServers(): Promise<string[]> {
62
87
  const content = await readFile("/etc/resolv.conf", "utf8");
@@ -181,114 +206,125 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
181
206
  const config = generateMihomoConfig({ allowlist, dnsServers });
182
207
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
183
208
 
184
- const holder = spawn("unshare", ["-Urn", "--", "node", HOLDER_PATH, configPath, mihomoPath], {
185
- env: {
186
- HOME: process.env.HOME ?? "",
187
- PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
188
- },
189
- // mihomo 日志经 holder 透传到这里的 stdout/stderr,用于判定就绪
190
- stdio: ["ignore", "pipe", "pipe"],
191
- });
192
- forwardOutput(holder, holderOutput);
193
- if (holder.pid === undefined) {
194
- throw new Error("Failed to start network namespace holder");
195
- }
196
- const holderPid = holder.pid;
197
- await waitForNewUserns(holderPid);
209
+ let holder: ChildProcess | undefined;
210
+ let slirp: ChildProcess | undefined;
211
+ try {
212
+ holder = spawn("unshare", ["-Urn", "--", "node", HOLDER_PATH, configPath, mihomoPath], {
213
+ env: {
214
+ HOME: process.env.HOME ?? "",
215
+ PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
216
+ },
217
+ // mihomo 日志经 holder 透传到这里的 stdout/stderr,用于判定就绪
218
+ stdio: ["ignore", "pipe", "pipe"],
219
+ });
220
+ forwardOutput(holder, holderOutput);
221
+ if (holder.pid === undefined) {
222
+ throw new Error("Failed to start network namespace holder");
223
+ }
224
+ const holderPid = holder.pid;
225
+ await waitForNewUserns(holderPid);
198
226
 
199
- const slirp = spawn(
200
- slirp4netnsPath,
201
- [
202
- "-c",
203
- `--mtu=${TUN_MTU}`,
204
- `--userns-path=/proc/${holderPid}/ns/user`,
205
- "--netns-type=pid",
206
- String(holderPid),
207
- "tap0",
208
- ],
209
- // 默认丢弃 slirp4netns 日志;诊断时改为管道转发
210
- { stdio: slirpOutput ? ["ignore", "pipe", "pipe"] : "ignore" },
211
- );
212
- forwardOutput(slirp, slirpOutput);
213
- const slirpPid = slirp.pid;
214
- await waitForMihomoStarted(holder);
227
+ slirp = spawn(
228
+ slirp4netnsPath,
229
+ [
230
+ "-c",
231
+ `--mtu=${TUN_MTU}`,
232
+ `--userns-path=/proc/${holderPid}/ns/user`,
233
+ "--netns-type=pid",
234
+ String(holderPid),
235
+ "tap0",
236
+ ],
237
+ // 默认丢弃 slirp4netns 日志;诊断时改为管道转发
238
+ { stdio: slirpOutput ? ["ignore", "pipe", "pipe"] : "ignore" },
239
+ );
240
+ forwardOutput(slirp, slirpOutput);
241
+ const slirpPid = slirp.pid;
242
+ await waitForMihomoStarted(holder);
215
243
 
216
- const state: NetworkStackState = { holderPid, slirpPid, directory };
217
- const stack: NetworkStack = {
218
- exec: async (execOptions: NetworkStackExecOptions) => {
219
- const child = spawn(
220
- "nsenter",
221
- [
222
- "-U",
223
- "-n",
224
- "--preserve-credentials",
225
- "-t",
226
- String(holderPid),
227
- "--",
228
- execOptions.bwrapPath,
229
- ...execOptions.bwrapArgs,
230
- "--",
231
- execOptions.shell,
232
- "-lc",
233
- execOptions.command,
234
- ],
235
- {
236
- cwd: execOptions.cwd,
237
- detached: true,
238
- stdio: ["ignore", "pipe", "pipe"],
239
- env: execOptions.env,
240
- },
241
- );
244
+ const state: NetworkStackState = { holderPid, slirpPid, directory };
245
+ const stack: NetworkStack = {
246
+ exec: async (execOptions: NetworkStackExecOptions) => {
247
+ const child = spawn(
248
+ "nsenter",
249
+ [
250
+ "-U",
251
+ "-n",
252
+ "--preserve-credentials",
253
+ "-t",
254
+ String(holderPid),
255
+ "--",
256
+ execOptions.bwrapPath,
257
+ ...execOptions.bwrapArgs,
258
+ "--",
259
+ execOptions.shell,
260
+ "-lc",
261
+ execOptions.command,
262
+ ],
263
+ {
264
+ cwd: execOptions.cwd,
265
+ detached: true,
266
+ stdio: ["ignore", "pipe", "pipe"],
267
+ env: execOptions.env,
268
+ },
269
+ );
242
270
 
243
- return new Promise<{ exitCode: number | null }>((resolve, reject) => {
244
- let timedOut = false;
245
- let settled = false;
246
- const timeoutHandle = execOptions.timeout
247
- ? setTimeout(() => {
248
- timedOut = true;
249
- killChild(child.pid);
250
- }, execOptions.timeout * 1000)
251
- : undefined;
252
- const onAbort = (): void => {
253
- killChild(child.pid);
254
- };
271
+ return new Promise<{ exitCode: number | null }>((resolve, reject) => {
272
+ let timedOut = false;
273
+ let settled = false;
274
+ const timeoutHandle = execOptions.timeout
275
+ ? setTimeout(() => {
276
+ timedOut = true;
277
+ killChild(child.pid);
278
+ }, execOptions.timeout * 1000)
279
+ : undefined;
280
+ const onAbort = (): void => {
281
+ killChild(child.pid);
282
+ };
255
283
 
256
- child.stdout?.on("data", execOptions.onData);
257
- child.stderr?.on("data", execOptions.onData);
258
- execOptions.signal?.addEventListener("abort", onAbort, { once: true });
284
+ child.stdout?.on("data", execOptions.onData);
285
+ child.stderr?.on("data", execOptions.onData);
286
+ execOptions.signal?.addEventListener("abort", onAbort, { once: true });
259
287
 
260
- child.once("error", (error) => {
261
- if (settled) return;
262
- settled = true;
263
- reject(error);
288
+ child.once("error", (error) => {
289
+ if (settled) return;
290
+ settled = true;
291
+ reject(error);
292
+ });
293
+ child.once("close", (exitCode) => {
294
+ if (settled) return;
295
+ settled = true;
296
+ if (timeoutHandle) clearTimeout(timeoutHandle);
297
+ execOptions.signal?.removeEventListener("abort", onAbort);
298
+ // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
299
+ if (execOptions.signal?.aborted) {
300
+ reject(
301
+ execOptions.signal.reason instanceof Error
302
+ ? execOptions.signal.reason
303
+ : new Error("The operation was aborted"),
304
+ );
305
+ } else if (timedOut) {
306
+ // 超时:name=TimeoutError(对齐标准错误分类)
307
+ reject(new TimeoutError(execOptions.timeout));
308
+ } else resolve({ exitCode });
309
+ });
264
310
  });
265
- child.once("close", (exitCode) => {
266
- if (settled) return;
267
- settled = true;
268
- if (timeoutHandle) clearTimeout(timeoutHandle);
269
- execOptions.signal?.removeEventListener("abort", onAbort);
270
- // 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
271
- if (execOptions.signal?.aborted) {
272
- reject(
273
- execOptions.signal.reason instanceof Error
274
- ? execOptions.signal.reason
275
- : new Error("The operation was aborted"),
276
- );
277
- } else if (timedOut) {
278
- // 超时:name=TimeoutError(对齐标准错误分类)
279
- reject(new TimeoutError(execOptions.timeout));
280
- } else resolve({ exitCode });
281
- });
282
- });
283
- },
284
- stop: async () => {
285
- killProcess(state.slirpPid);
286
- killProcess(state.holderPid);
287
- await rm(state.directory, { recursive: true, force: true }).catch(() => false);
288
- },
289
- holderPid,
290
- configPath,
291
- };
292
- stackFinalizer.register(stack, state);
293
- return stack;
311
+ },
312
+ stop: async () => {
313
+ await terminateProcess(state.slirpPid);
314
+ await terminateProcess(state.holderPid);
315
+ await rm(state.directory, { recursive: true, force: true }).catch(() => false);
316
+ },
317
+ holderPid,
318
+ configPath,
319
+ };
320
+ stackFinalizer.register(stack, state);
321
+ return stack;
322
+ } catch (error) {
323
+ // 失败清理:holder/slirp 持有 userns/netns 引用,不 kill 就会锁住 namespace;
324
+ // 调用方(runInSandbox)拿不到 stack,这里的清理只能靠自己
325
+ if (slirp?.pid) await terminateProcess(slirp.pid);
326
+ if (holder?.pid) await terminateProcess(holder.pid);
327
+ await rm(directory, { recursive: true, force: true }).catch(() => false);
328
+ throw error;
329
+ }
294
330
  }