@trim21/personal-pi-extensions 0.1.20261012031237 → 0.1.20261012035337
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 +1 -1
- package/src/bwrap/holder.js +8 -2
- package/src/bwrap/holder.ts +14 -3
- package/src/bwrap/network-stack.ts +107 -50
- package/src/bwrap/runtime.ts +14 -2
- package/src/bwrap/sandbox.ts +35 -7
- package/src/lib/proc.ts +40 -0
package/package.json
CHANGED
package/src/bwrap/holder.js
CHANGED
|
@@ -28,7 +28,10 @@ async function waitFor(attempts, delayMs, predicate) {
|
|
|
28
28
|
async function main() {
|
|
29
29
|
process.stdin.resume();
|
|
30
30
|
process.stdin.on("end", () => process.exit(0));
|
|
31
|
-
await waitFor(100, 100, tap0Exists)
|
|
31
|
+
if (!await waitFor(100, 100, tap0Exists)) {
|
|
32
|
+
console.error("slirp4netns did not create tap0 within 10s");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
32
35
|
const mihomo = spawn(mihomoPath, ["-d", mihomoHome, "-config", configBase64], {
|
|
33
36
|
stdio: ["ignore", "inherit", "inherit"]
|
|
34
37
|
});
|
|
@@ -43,7 +46,10 @@ async function main() {
|
|
|
43
46
|
stop();
|
|
44
47
|
process.exit(0);
|
|
45
48
|
});
|
|
46
|
-
await new Promise((resolve) =>
|
|
49
|
+
const exitCode = await new Promise((resolve) => {
|
|
50
|
+
mihomo.once("exit", (code) => resolve(code));
|
|
51
|
+
});
|
|
52
|
+
process.exit(exitCode ?? 1);
|
|
47
53
|
}
|
|
48
54
|
try {
|
|
49
55
|
await main();
|
package/src/bwrap/holder.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
8
8
|
// 否则出站流量会绕回 mihomo 的 TUN 被 dns-hijack 自劫持),它通过 exit-fd 绑定本进程的生命周期:
|
|
9
9
|
// fd 3 是 exit-fd 的写端(network-stack 经 unshare 传入),本进程存活期间保持打开,死亡即关闭,
|
|
10
10
|
// slirp4netns 侧收到 HUP 自行退出并释放 tap fd,netns 引用随之归零。
|
|
11
|
+
// 常规停栈也走这条 stdin EOF 通道(宿主关闭写端而不是按 pid 发信号):检查 pid 与发信号
|
|
12
|
+
// 之间不是原子的,用 pid 终止可能误伤复用同一 pid 的无关进程。
|
|
11
13
|
const args = process.argv.slice(2);
|
|
12
14
|
const configBase64 = args[0];
|
|
13
15
|
const mihomoPath = args[1];
|
|
@@ -45,12 +47,17 @@ async function waitFor(
|
|
|
45
47
|
async function main(): Promise<void> {
|
|
46
48
|
// 宿主(pi)进程死亡检测:宿主以任何方式退出(含 SIGKILL)都会关闭它持有的 stdin 写端,
|
|
47
49
|
// 这里读到 EOF 即自杀(init 退出即触发内核清理整个 pid ns,顺带关闭 exit-fd 写端)。
|
|
50
|
+
// 宿主侧的正常停栈同样只是关闭这个写端——这条通道绑定 fd 而非 pid,不存在 pid 复用问题。
|
|
48
51
|
process.stdin.resume();
|
|
49
52
|
process.stdin.on("end", () => process.exit(0));
|
|
50
53
|
|
|
51
54
|
// tap0 由宿主侧的 slirp4netns(fork 进本 netns)创建,就绪后再拉起 mihomo。
|
|
52
|
-
// mihomo
|
|
53
|
-
|
|
55
|
+
// 接口没建起来就启动 mihomo 必然失败(配置把出站绑在 tap0 上),宿主侧只会看到
|
|
56
|
+
// 「过滤进程就绪超时」;这里直接以带原因的失败结束,让真实死因秒级进入诊断日志。
|
|
57
|
+
if (!(await waitFor(100, 100, tap0Exists))) {
|
|
58
|
+
console.error("slirp4netns did not create tap0 within 10s");
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
54
61
|
|
|
55
62
|
const mihomo = spawn(mihomoPath, ["-d", mihomoHome, "-config", configBase64], {
|
|
56
63
|
stdio: ["ignore", "inherit", "inherit"],
|
|
@@ -68,7 +75,11 @@ async function main(): Promise<void> {
|
|
|
68
75
|
process.exit(0);
|
|
69
76
|
});
|
|
70
77
|
|
|
71
|
-
|
|
78
|
+
// 传播 mihomo 的退出码:宿主侧据此区分「过滤进程崩溃」(非零)与「停栈时的优雅退出」
|
|
79
|
+
const exitCode = await new Promise<number | null>((resolve) => {
|
|
80
|
+
mihomo.once("exit", (code) => resolve(code));
|
|
81
|
+
});
|
|
82
|
+
process.exit(exitCode ?? 1);
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
try {
|
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
|
|
7
7
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
|
|
9
|
-
import { forEachLine } from "../lib/proc.js";
|
|
9
|
+
import { childHasExited, forEachLine, killChild, waitForChildExit } from "../lib/proc.js";
|
|
10
10
|
import { generateMihomoConfig, TUN_MTU } from "./mihomo-config.js";
|
|
11
11
|
|
|
12
12
|
export interface NetworkStackOptions {
|
|
@@ -24,6 +24,8 @@ export interface NetworkStackOptions {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const NAMESERVER_PATTERN = /^\s*nameserver\s+(\S+)/;
|
|
27
|
+
/** 停栈时等待单个子进程退出的上限(毫秒)。 */
|
|
28
|
+
const STOP_WAIT_TIMEOUT_MS = 2000;
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* 启动失败时把收集到的子进程完整 stdout/stderr 与错误本身落盘到
|
|
@@ -62,40 +64,40 @@ const HOLDER_PATH = fileURLToPath(new URL("holder.js", import.meta.url));
|
|
|
62
64
|
// ip 通常位于 /usr/sbin 或 /sbin,进程默认 PATH 不含它们;node 则依赖宿主完整 PATH
|
|
63
65
|
const SBIN_PATH_SUFFIX = "/usr/local/sbin:/usr/sbin:/sbin";
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
/** 兜底:对 holder 存活期间读到的 pid ns init pid 发 SIGKILL。 */
|
|
68
|
+
function killInitProcess(pid: number | undefined): void {
|
|
66
69
|
if (!pid) {
|
|
67
70
|
return;
|
|
68
71
|
}
|
|
69
72
|
try {
|
|
70
|
-
process.kill(pid,
|
|
73
|
+
process.kill(pid, "SIGKILL");
|
|
71
74
|
} catch {
|
|
72
75
|
// 已退出
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
/**
|
|
77
|
-
*
|
|
80
|
+
* 请求 holder 自行退出:关闭我们持有的 stdin 写端,holder 读到 EOF 即退出(见
|
|
81
|
+
* holder.ts),内核随之清理整个 pid namespace,unshare 包装进程 waitpid 后自行退出,
|
|
82
|
+
* slirp4netns 经 exit-fd HUP 退出。这条通道绑定的是 fd 而不是 pid,不存在「检查时
|
|
83
|
+
* 还活着、发信号时 pid 已被复用」的窗口——常规停栈走这里,不发任何信号。
|
|
84
|
+
*/
|
|
85
|
+
function requestHolderShutdown(holder: ChildProcess): void {
|
|
86
|
+
holder.stdin?.destroy();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 兜底强杀 holder(unshare 包装进程)。只在 fd 通道超时(holder 卡死)时使用:Node 的
|
|
91
|
+
* kill 最终按 pid 调 kill(2),仍有极小但非零的复用窗口,见 killChild 的说明。
|
|
92
|
+
*
|
|
93
|
+
* 必须用 SIGKILL:util-linux 的 unshare 在 fork 前
|
|
78
94
|
* `sigprocmask(SIG_BLOCK, {SIGINT, SIGTERM})`,且只在子进程里恢复掩码——父进程永久
|
|
79
95
|
* 阻塞这两个信号,发给它的 SIGTERM 只会 pending 永不投递(实测 3s 后仍存活)。
|
|
80
96
|
* SIGKILL 不可阻塞,unshare 立即退出;其子进程(pid ns 的 init)经 --kill-child 的
|
|
81
97
|
* PDEATHSIG 收到 SIGTERM,走优雅退出并触发内核清理整个 pid ns。
|
|
82
98
|
*/
|
|
83
|
-
function killHolder(
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** 轮询等待进程退出(进程消失即返回)。 */
|
|
88
|
-
async function waitForExit(pid: number): Promise<void> {
|
|
89
|
-
const timeoutMs = 2000;
|
|
90
|
-
const deadline = Date.now() + timeoutMs;
|
|
91
|
-
while (Date.now() < deadline) {
|
|
92
|
-
try {
|
|
93
|
-
process.kill(pid, 0);
|
|
94
|
-
} catch {
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
98
|
-
}
|
|
99
|
+
function killHolder(holder: ChildProcess): boolean {
|
|
100
|
+
return killChild(holder, "SIGKILL");
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/** 读取 holder 的直接子进程 pid(pid ns 的 init):PDEATHSIG 未生效时的 SIGKILL 兜底用。 */
|
|
@@ -201,25 +203,37 @@ function forwardOutput(
|
|
|
201
203
|
});
|
|
202
204
|
}
|
|
203
205
|
|
|
206
|
+
/** 停栈结果:网络栈在命令运行期间自行终止时,调用方据此拿到落盘的诊断日志。 */
|
|
207
|
+
export interface NetworkStackStopResult {
|
|
208
|
+
/** holder 在本次停栈之前已自行退出(没有收到本次停止信号)。 */
|
|
209
|
+
readonly exitedEarly: boolean;
|
|
210
|
+
/** 运行期异常退出时落盘的诊断日志路径。 */
|
|
211
|
+
readonly logPath?: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
204
214
|
export interface NetworkStack {
|
|
205
|
-
|
|
215
|
+
/** 停止网络栈;幂等,重复调用返回同一结果。 */
|
|
216
|
+
stop(): Promise<NetworkStackStopResult>;
|
|
206
217
|
/** holder pid:可 `nsenter -U -n --preserve-credentials -t <pid>` 手动进入该 netns 排查。 */
|
|
207
218
|
readonly holderPid: number;
|
|
208
219
|
}
|
|
209
220
|
|
|
210
221
|
interface NetworkStackState {
|
|
211
|
-
|
|
212
|
-
|
|
222
|
+
/** unshare 包装进程句柄:发信号必须经句柄,pid 退出后可能被内核复用。 */
|
|
223
|
+
holder: ChildProcess;
|
|
224
|
+
/** slirp4netns 句柄(宿主 netns,exit-fd 绑定 holder 生命周期)。 */
|
|
225
|
+
slirp: ChildProcess;
|
|
213
226
|
/** 过滤进程的工作目录:GC 兜底路径也要把它删掉。 */
|
|
214
227
|
mihomoHome: string;
|
|
215
228
|
}
|
|
216
229
|
|
|
217
|
-
/** 兜底:调用方忘记 stop() 时,对象被 GC
|
|
230
|
+
/** 兜底:调用方忘记 stop() 时,对象被 GC 回收后让残留进程自行退出并清掉工作目录。 */
|
|
218
231
|
const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
|
|
219
|
-
// holder
|
|
220
|
-
// slirp4netns
|
|
221
|
-
|
|
222
|
-
|
|
232
|
+
// 只走 fd 通道:关闭 holder 的 stdin 写端让它自己退出(内核随之清理 pid ns),
|
|
233
|
+
// slirp4netns 由 exit-fd HUP 退出。GC 回调不能 await,更不能按 pid 发信号——对象被
|
|
234
|
+
// 回收时这些 pid 可能早已被内核复用给无关进程。holder 卡死时这条兜底清理不掉,
|
|
235
|
+
// 留给宿主退出或人工处理,这是刻意的取舍。
|
|
236
|
+
requestHolderShutdown(state.holder);
|
|
223
237
|
// FinalizationRegistry 回调不能 await:尽力而为,删不掉就留下(宿主崩溃时同样如此)
|
|
224
238
|
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
225
239
|
void rm(state.mihomoHome, { recursive: true, force: true }).catch(() => undefined);
|
|
@@ -353,44 +367,87 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
353
367
|
mihomoReady.catch(() => undefined);
|
|
354
368
|
});
|
|
355
369
|
|
|
356
|
-
const state: NetworkStackState = {
|
|
370
|
+
const state: NetworkStackState = { holder, slirp, mihomoHome };
|
|
371
|
+
const finalizerToken: object = {};
|
|
372
|
+
let stopResult: NetworkStackStopResult | undefined;
|
|
357
373
|
const stack: NetworkStack = {
|
|
358
374
|
stop: async () => {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
//
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
375
|
+
if (stopResult !== undefined) {
|
|
376
|
+
return stopResult;
|
|
377
|
+
}
|
|
378
|
+
// 兜底只服务「调用方忘记停栈」:正常路径在这里注销,不再触发第二条终止路径
|
|
379
|
+
stackFinalizer.unregister(finalizerToken);
|
|
380
|
+
// holder 已自行退出(过滤进程崩溃、unshare 被外在因素终止):这次不是我们停的
|
|
381
|
+
const exitedEarly = childHasExited(state.holder);
|
|
382
|
+
// 常规停栈走 fd 通道:关掉 holder 的 stdin 写端,它读到 EOF 自己退出 → 内核清理
|
|
383
|
+
// pid ns → unshare waitpid 后退出 → slirp4netns 经 exit-fd HUP 退出。全程不按
|
|
384
|
+
// pid 发信号,因此没有「检查时还活着、发信号时已被复用」的窗口。
|
|
385
|
+
requestHolderShutdown(state.holder);
|
|
386
|
+
const holderExited = await waitForChildExit(state.holder, STOP_WAIT_TIMEOUT_MS);
|
|
387
|
+
const slirpExited = await waitForChildExit(state.slirp, STOP_WAIT_TIMEOUT_MS);
|
|
388
|
+
// 兜底:fd 通道超时(holder 卡死,EOF 未被 event loop 处理)才强杀。这是唯一
|
|
389
|
+
// 仍按 pid 发信号的路径(Node 的 kill 最终落到 kill(2)),窗口极小但非零
|
|
390
|
+
if (!holderExited) {
|
|
391
|
+
killHolder(state.holder);
|
|
392
|
+
const killed = await waitForChildExit(state.holder, STOP_WAIT_TIMEOUT_MS);
|
|
393
|
+
if (!killed) {
|
|
394
|
+
// holder 仍存活 → 它的 pid ns init 必然仍属本网络栈,pid 兜底才安全
|
|
395
|
+
for (const pid of await readChildPids(holderPid)) {
|
|
396
|
+
killInitProcess(pid);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (!slirpExited) {
|
|
401
|
+
killChild(state.slirp);
|
|
402
|
+
await waitForChildExit(state.slirp, STOP_WAIT_TIMEOUT_MS);
|
|
371
403
|
}
|
|
372
404
|
// 工作目录只服务本次实例(mihomo 的 cache.db 等运行时缓存),随停栈删除;
|
|
373
405
|
// best-effort:删除失败不外抛,避免掩盖命令结果
|
|
374
406
|
// eslint-disable-next-line unicorn/no-useless-undefined
|
|
375
407
|
await rm(state.mihomoHome, { recursive: true, force: true }).catch(() => undefined);
|
|
408
|
+
// 运行期异常退出:缓冲里的 holder/slirp 输出是唯一的死因材料,落盘并把
|
|
409
|
+
// 路径交给调用方;成功路径不落盘
|
|
410
|
+
const logPath = exitedEarly
|
|
411
|
+
? await writeFailureLog(
|
|
412
|
+
new Error(
|
|
413
|
+
`Network stack exited during command execution (holder exit code ${String(state.holder.exitCode)}${state.holder.signalCode === null ? "" : `, signal ${state.holder.signalCode}`})`,
|
|
414
|
+
),
|
|
415
|
+
[holderLog.join(""), slirpLog.join("")],
|
|
416
|
+
)
|
|
417
|
+
: undefined;
|
|
418
|
+
stopResult = { exitedEarly, ...(logPath !== undefined && { logPath }) };
|
|
419
|
+
return stopResult;
|
|
376
420
|
},
|
|
377
421
|
holderPid,
|
|
378
422
|
};
|
|
379
|
-
stackFinalizer.register(stack, state);
|
|
423
|
+
stackFinalizer.register(stack, state, finalizerToken);
|
|
380
424
|
return stack;
|
|
381
425
|
} catch (error) {
|
|
382
426
|
// 失败清理:SIGKILL holder(unshare)→ init 经 PDEATHSIG 收到 SIGTERM 后退出,
|
|
383
427
|
// 内核清理 pid ns 内全部进程;slirp4netns 在宿主侧,需单独终止
|
|
384
428
|
//(exit-fd 写端也会随 holder 死亡关闭,这里主动杀只是不等到 HUP 轮询)
|
|
385
|
-
|
|
386
|
-
|
|
429
|
+
// 启动失败清理同样优先走 fd 通道:关 holder 的 stdin 让它自己退出,slirp4netns
|
|
430
|
+
// 随之经 exit-fd HUP 退出;只有超时(卡死)才强杀
|
|
431
|
+
if (holder !== undefined) {
|
|
432
|
+
requestHolderShutdown(holder);
|
|
433
|
+
}
|
|
434
|
+
if (slirp !== undefined) {
|
|
435
|
+
const slirpExited = await waitForChildExit(slirp, STOP_WAIT_TIMEOUT_MS);
|
|
436
|
+
if (!slirpExited) {
|
|
437
|
+
killChild(slirp);
|
|
438
|
+
}
|
|
387
439
|
}
|
|
388
|
-
if (holder
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
440
|
+
if (holder !== undefined && !childHasExited(holder)) {
|
|
441
|
+
const holderExited = await waitForChildExit(holder, STOP_WAIT_TIMEOUT_MS);
|
|
442
|
+
if (!holderExited) {
|
|
443
|
+
killHolder(holder);
|
|
444
|
+
const killed = await waitForChildExit(holder, STOP_WAIT_TIMEOUT_MS);
|
|
445
|
+
if (!killed && holder.pid !== undefined) {
|
|
446
|
+
// holder 仍存活 → 这些 pid 必然仍属本网络栈
|
|
447
|
+
for (const pid of await readChildPids(holder.pid)) {
|
|
448
|
+
killInitProcess(pid);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
394
451
|
}
|
|
395
452
|
}
|
|
396
453
|
// 这里刻意不删 mihomoHome:启动失败时它属于现场材料,与下面落盘的诊断日志
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -389,6 +389,18 @@ export function sandboxHintBlock(hint: string | undefined): { type: "text"; text
|
|
|
389
389
|
return hint === undefined ? [] : [{ type: "text", text: hint }];
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
/** 网络栈在命令运行期间异常退出时,把落盘的诊断日志路径附进沙箱状态提示。 */
|
|
393
|
+
function withNetworkStackLog(
|
|
394
|
+
hint: string | undefined,
|
|
395
|
+
logPath: string | undefined,
|
|
396
|
+
): string | undefined {
|
|
397
|
+
if (logPath === undefined) {
|
|
398
|
+
return hint;
|
|
399
|
+
}
|
|
400
|
+
const line = `The sandbox network stack exited during the command; diagnostics: ${logPath}`;
|
|
401
|
+
return hint === undefined ? line : `${hint}\n${line}`;
|
|
402
|
+
}
|
|
403
|
+
|
|
392
404
|
/**
|
|
393
405
|
* 项目配置文件形状非法时的错误:消息带文件路径与字段位置,cause 保留校验错误
|
|
394
406
|
* (Value.Check 只回答是否合法,详情用同一 schema 解析一次取到)。
|
|
@@ -618,7 +630,7 @@ export class BwrapRuntime {
|
|
|
618
630
|
);
|
|
619
631
|
|
|
620
632
|
try {
|
|
621
|
-
const { exitCode } = await runInSandbox(runtime, {
|
|
633
|
+
const { exitCode, networkStackLog } = await runInSandbox(runtime, {
|
|
622
634
|
workspace,
|
|
623
635
|
commandCwd: execCwd,
|
|
624
636
|
command: request.command,
|
|
@@ -633,7 +645,7 @@ export class BwrapRuntime {
|
|
|
633
645
|
const partial = await this.finalizeOutput(output);
|
|
634
646
|
return {
|
|
635
647
|
exitCode,
|
|
636
|
-
sandboxHint,
|
|
648
|
+
sandboxHint: withNetworkStackLog(sandboxHint, networkStackLog),
|
|
637
649
|
...(sandboxReminder && { sandboxReminder }),
|
|
638
650
|
output: partial.output,
|
|
639
651
|
...(partial.fullOutputPath && { fullOutputPath: partial.fullOutputPath }),
|
package/src/bwrap/sandbox.ts
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
type ResolvedBwrap,
|
|
29
29
|
} from "./core.js";
|
|
30
30
|
import { buildBwrapInvocation, createBwrapBashOperations, invocationArgv } from "./exec.js";
|
|
31
|
-
import type { NetworkStack } from "./network-stack.js";
|
|
31
|
+
import type { NetworkStack, NetworkStackStopResult } from "./network-stack.js";
|
|
32
32
|
|
|
33
33
|
export interface SandboxConfigInput {
|
|
34
34
|
/** 沙箱工作区:默认配置文件的查找目录,也是 writablePaths 中 "." 的解析基准。 */
|
|
@@ -65,6 +65,8 @@ export interface SandboxRunOptions {
|
|
|
65
65
|
export interface SandboxRunResult {
|
|
66
66
|
/** 命令退出码;被信号杀死时为 null。 */
|
|
67
67
|
exitCode: number | null;
|
|
68
|
+
/** 命令运行期间网络栈异常退出时落盘的诊断日志路径(正常停栈没有这个字段)。 */
|
|
69
|
+
networkStackLog?: string;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** 把 "."/"~" 形式的路径收敛成绝对路径,使执行策略自成一体、可断言。 */
|
|
@@ -117,6 +119,8 @@ export async function runInSandbox(
|
|
|
117
119
|
const local = options.unsandboxed === true || !resolved.bwrapEnabled;
|
|
118
120
|
// 每次执行现建现停网络栈(起栈 ~40ms、停栈 ~100ms),作用域结束即停栈:allowlist 变更即时生效
|
|
119
121
|
const stack = local ? undefined : await createNetworkStack(resolved, options.log);
|
|
122
|
+
let result: SandboxRunResult | undefined;
|
|
123
|
+
let failure: unknown;
|
|
120
124
|
try {
|
|
121
125
|
if (stack) {
|
|
122
126
|
options.onNetworkStack?.(stack);
|
|
@@ -129,14 +133,38 @@ export async function runInSandbox(
|
|
|
129
133
|
signal: options.signal,
|
|
130
134
|
timeout: options.timeout,
|
|
131
135
|
});
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
result = { exitCode };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
failure = error;
|
|
139
|
+
}
|
|
140
|
+
// 停栈放在命令结束之后、结果返回之前:网络栈若在命令运行期间异常退出,停栈返回的
|
|
141
|
+
// 日志路径要能附到失败信息上(成功路径不打扰用户,也不改变命令结果)
|
|
142
|
+
let stopResult: NetworkStackStopResult | undefined;
|
|
143
|
+
try {
|
|
144
|
+
stopResult = await stack?.stop();
|
|
145
|
+
} catch {
|
|
146
|
+
// best-effort:停栈失败(进程已退出/目录删除失败)不掩盖命令结果
|
|
147
|
+
}
|
|
148
|
+
if (failure !== undefined) {
|
|
149
|
+
if (!(failure instanceof Error)) {
|
|
150
|
+
// 非 Error 抛出(字符串 / 对象):保留字符串内容,其余不渲染成 [object Object]
|
|
151
|
+
throw new Error(
|
|
152
|
+
typeof failure === "string" ? failure : "Sandbox command failed (non-error value thrown)",
|
|
153
|
+
);
|
|
138
154
|
}
|
|
155
|
+
if (stopResult?.exitedEarly === true) {
|
|
156
|
+
const detail =
|
|
157
|
+
stopResult.logPath === undefined
|
|
158
|
+
? "(network stack exited during the command; diagnostics could not be written)"
|
|
159
|
+
: `(network stack diagnostics: ${stopResult.logPath})`;
|
|
160
|
+
failure.message = `${failure.message}\n${detail}`;
|
|
161
|
+
}
|
|
162
|
+
throw failure;
|
|
139
163
|
}
|
|
164
|
+
return {
|
|
165
|
+
...(result ?? { exitCode: null }),
|
|
166
|
+
...(stopResult?.logPath !== undefined && { networkStackLog: stopResult.logPath }),
|
|
167
|
+
};
|
|
140
168
|
}
|
|
141
169
|
|
|
142
170
|
/** 一步式入口:按配置加载策略并在对应沙箱里执行命令(CLI 与集成测试用)。 */
|
package/src/lib/proc.ts
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
|
|
4
|
+
/** 子进程是否已结束(正常退出或被信号杀死)。 */
|
|
5
|
+
export function childHasExited(child: ChildProcess): boolean {
|
|
6
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 向自己 spawn 的子进程强杀。已结束(Node 已回收)的子进程不会再收到信号。
|
|
11
|
+
*
|
|
12
|
+
* 注意这不是「保证不会误杀」的手段:子进程可能在我们检查之后、发信号之前退出,
|
|
13
|
+
* 而 Node 最终仍按 pid 调 kill(2)(libuv 的 uv_process_kill),此时该 pid 可能已被
|
|
14
|
+
* 内核复用给无关进程。只应作为「进程卡死」时的最后手段,常规终止请走不依赖 pid 的
|
|
15
|
+
* 通道(例如关闭子进程持有的管道写端让它自行退出)。
|
|
16
|
+
*/
|
|
17
|
+
export function killChild(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): boolean {
|
|
18
|
+
if (childHasExited(child)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return child.kill(signal);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 等待子进程结束,返回是否在超时内结束;已结束的立即返回 true。 */
|
|
25
|
+
export async function waitForChildExit(child: ChildProcess, timeoutMs: number): Promise<boolean> {
|
|
26
|
+
if (childHasExited(child)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
// AbortSignal.timeout 的定时器不保持事件循环活跃,子进程先退出时它自行失效
|
|
30
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
31
|
+
const timedOut = new Promise<false>((resolve) => {
|
|
32
|
+
timeout.addEventListener("abort", () => resolve(false), { once: true });
|
|
33
|
+
});
|
|
34
|
+
// spawn 失败(error 事件)也算「已经结束」,避免把失败挂成未处理的 rejection
|
|
35
|
+
const exited = once(child, "exit")
|
|
36
|
+
.then(() => true)
|
|
37
|
+
.catch(() => true);
|
|
38
|
+
return Promise.race([exited, timedOut]);
|
|
39
|
+
}
|
|
40
|
+
|
|
1
41
|
/**
|
|
2
42
|
* 按行消费输出流:data 事件按 buffer 边界切分,行可能跨多个 chunk,
|
|
3
43
|
* 这里拼接缓冲后按 \n 逐行回调(回调不含换行符)。
|