@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 +3 -1
- package/src/bwrap/holder.js +47 -0
- package/src/bwrap/network-stack.ts +141 -105
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
}
|