@trim21/personal-pi-extensions 0.1.626 → 0.1.628
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/README.md +6 -3
- package/src/bwrap/core.ts +4 -168
- package/src/bwrap/exec.ts +246 -0
- package/src/bwrap/network-stack.ts +0 -111
- package/src/bwrap/sandbox.ts +6 -18
- package/src/spawn-agent.ts +15 -9
package/package.json
CHANGED
package/src/bwrap/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# bwrap 沙箱与网络栈架构
|
|
2
2
|
|
|
3
3
|
本文档描述 `network: limited` 模式下的进程模型、网络路径与生命周期管理。
|
|
4
|
-
基础沙箱(bwrap 文件系统隔离)见 `core.ts
|
|
4
|
+
基础沙箱(bwrap 文件系统隔离)见 `core.ts`(配置与 argv 内容)/ `exec.ts`
|
|
5
|
+
(命令行组装与进程生命周期)/ `sandbox.ts`(加载配置并运行);本文聚焦网络栈
|
|
5
6
|
(`network-stack.ts` / `holder.ts` / `mihomo-config.ts`)。
|
|
6
7
|
|
|
7
8
|
## 进程模型
|
|
@@ -21,7 +22,7 @@ pi 进程(network-stack.ts)
|
|
|
21
22
|
必须在宿主 netns 启动(原因见「设计约束」);持 exit-fd 读端 + tapfd。
|
|
22
23
|
```
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
每条命令的短命子树(命令结束即退),由 `exec.ts` 组装并 spawn:
|
|
25
26
|
|
|
26
27
|
```
|
|
27
28
|
nsenter -U -n --preserve-credentials -t <①的pid> \
|
|
@@ -29,7 +30,9 @@ nsenter -U -n --preserve-credentials -t <①的pid> \
|
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
nsenter 进入 holder 的 userns/netns,bwrap 在里面再嵌套创建自己的 user/pid
|
|
32
|
-
ns
|
|
33
|
+
ns 跑命令。网络栈对外只提供 holder pid(`NetworkStack.holderPid`)与停止,
|
|
34
|
+
命令行怎么拼属于执行层——`--print-args` 预览与真正执行共用同一段组装。
|
|
35
|
+
网络栈是每条命令现建现停(起栈 ~40ms、停栈 ~100ms),不跨命令复用:
|
|
33
36
|
allowlist 变更因此即时生效,代价是每条命令重新启动一次 mihomo。
|
|
34
37
|
|
|
35
38
|
## 网络路径
|
package/src/bwrap/core.ts
CHANGED
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
import {
|
|
3
|
-
import { access as fsAccess, readdir, realpath, stat } from "node:fs/promises";
|
|
1
|
+
import { type Dirent, existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
4
3
|
import { delimiter, join } from "node:path";
|
|
5
4
|
import process from "node:process";
|
|
6
5
|
|
|
7
6
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
8
|
-
import {
|
|
7
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
8
|
import { type Static, Type } from "typebox";
|
|
10
9
|
import { Value } from "typebox/value";
|
|
11
10
|
|
|
12
11
|
import { parseWithSchema } from "../lib/parse-with-schema.js";
|
|
13
12
|
import { expandHome } from "../lib/path.js";
|
|
14
13
|
import { type ApprovalRule } from "./approval-rules.js";
|
|
15
|
-
import {
|
|
16
|
-
type NetworkStack,
|
|
17
|
-
resolveDnsServers,
|
|
18
|
-
startNetworkStack,
|
|
19
|
-
TimeoutError,
|
|
20
|
-
} from "./network-stack.js";
|
|
14
|
+
import { type NetworkStack, resolveDnsServers, startNetworkStack } from "./network-stack.js";
|
|
21
15
|
|
|
22
16
|
const PROTECTED_DIRS = [".pi", ".agent"];
|
|
23
17
|
|
|
@@ -452,17 +446,6 @@ export async function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): Prom
|
|
|
452
446
|
return args;
|
|
453
447
|
}
|
|
454
448
|
|
|
455
|
-
function killChild(child: ChildProcess): void {
|
|
456
|
-
if (!child.pid) {
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
try {
|
|
460
|
-
process.kill(-child.pid, "SIGKILL");
|
|
461
|
-
} catch {
|
|
462
|
-
child.kill("SIGKILL");
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
449
|
/** 网络栈子进程输出转发通道,仅用于诊断(默认丢弃)。 */
|
|
467
450
|
export interface NetworkStackLog {
|
|
468
451
|
holder?: (chunk: string) => void;
|
|
@@ -484,150 +467,3 @@ export async function createNetworkStack(
|
|
|
484
467
|
...(log?.holder && { onHolderOutput: log.holder }),
|
|
485
468
|
});
|
|
486
469
|
}
|
|
487
|
-
|
|
488
|
-
/** 一次 bwrap 调用的完整组装结果:argv 与干净环境。 */
|
|
489
|
-
export interface BwrapInvocation {
|
|
490
|
-
/** bwrap 可执行文件路径 */
|
|
491
|
-
file: string;
|
|
492
|
-
/** bwrap 参数(不含结尾的 `-- shell -lc command`) */
|
|
493
|
-
args: string[];
|
|
494
|
-
/** 沙箱内 shell 的绝对路径 */
|
|
495
|
-
shell: string;
|
|
496
|
-
/** 交给 shell 的命令 */
|
|
497
|
-
command: string;
|
|
498
|
-
/** 命令执行目录 */
|
|
499
|
-
cwd: string;
|
|
500
|
-
/** 沙箱内环境(不继承父进程) */
|
|
501
|
-
env: Record<string, string>;
|
|
502
|
-
/** network limited 模式:命令需先经 nsenter 进入 holder 的 netns。 */
|
|
503
|
-
needsNetworkStack: boolean;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* 组装一次 bwrap 调用。实际执行(createBwrapBashOperations)与调试打印共用这里,
|
|
508
|
-
* 保证 `--print-args` 输出的命令行与真正跑的那条完全一致。
|
|
509
|
-
*/
|
|
510
|
-
export async function buildBwrapInvocation(
|
|
511
|
-
resolved: ResolvedBwrap,
|
|
512
|
-
workspace: string,
|
|
513
|
-
command: string,
|
|
514
|
-
cwd: string,
|
|
515
|
-
): Promise<BwrapInvocation> {
|
|
516
|
-
// 干净环境:不继承父进程 env/PATH,由 bash -lc 从 /etc/profile 与用户 profile 重建
|
|
517
|
-
const home = process.env.HOME;
|
|
518
|
-
if (home === undefined) {
|
|
519
|
-
throw new Error("HOME is not set; refusing to run bash in a clean environment");
|
|
520
|
-
}
|
|
521
|
-
return {
|
|
522
|
-
// 沙箱内不透传 PATH,execvp 的默认路径可能找不到 bash(如 NixOS),故在父进程解析绝对路径
|
|
523
|
-
shell: getShellConfig().shell,
|
|
524
|
-
file: findBwrap(resolved.bwrapPath),
|
|
525
|
-
args: [
|
|
526
|
-
"--ro-bind",
|
|
527
|
-
"/",
|
|
528
|
-
"/",
|
|
529
|
-
...(await buildBwrapArgs(resolved, workspace)),
|
|
530
|
-
"--dev",
|
|
531
|
-
"/dev",
|
|
532
|
-
"--proc",
|
|
533
|
-
"/proc",
|
|
534
|
-
],
|
|
535
|
-
command,
|
|
536
|
-
cwd,
|
|
537
|
-
env: {
|
|
538
|
-
HOME: home,
|
|
539
|
-
SHELL: "/bin/bash",
|
|
540
|
-
TERM: "dumb",
|
|
541
|
-
LANG: "C.UTF-8",
|
|
542
|
-
// 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
|
|
543
|
-
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
544
|
-
},
|
|
545
|
-
needsNetworkStack: resolved.network === "limited",
|
|
546
|
-
};
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/** 完整 argv(`[bwrap, ...args, "--", shell, "-lc", command]`),spawn 与打印共用。 */
|
|
550
|
-
export function bwrapArgv(invocation: BwrapInvocation): string[] {
|
|
551
|
-
return [invocation.file, ...invocation.args, "--", invocation.shell, "-lc", invocation.command];
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* @param workspace session 工作区:writablePaths 的 "." 与 PROTECTED_DIRS 都基于它解析,
|
|
556
|
-
* 与当次命令的 cwd(仅作为进程执行目录)解耦,避免 workdir 参数漂移可写边界。
|
|
557
|
-
*/
|
|
558
|
-
export function createBwrapBashOperations(
|
|
559
|
-
resolved: ResolvedBwrap,
|
|
560
|
-
workspace: string,
|
|
561
|
-
networkStack?: NetworkStack,
|
|
562
|
-
): BashOperations {
|
|
563
|
-
return {
|
|
564
|
-
async exec(command, cwd, { onData, signal, timeout }) {
|
|
565
|
-
await fsAccess(cwd, constants.F_OK).catch(() => {
|
|
566
|
-
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
|
|
567
|
-
});
|
|
568
|
-
// 已中断(signal.reason 是 name=AbortError 的 DOMException):直接抛,不再执行
|
|
569
|
-
signal?.throwIfAborted();
|
|
570
|
-
|
|
571
|
-
const invocation = await buildBwrapInvocation(resolved, workspace, command, cwd);
|
|
572
|
-
|
|
573
|
-
if (invocation.needsNetworkStack) {
|
|
574
|
-
if (!networkStack) {
|
|
575
|
-
throw new Error("Network stack is not initialized for network limited mode");
|
|
576
|
-
}
|
|
577
|
-
return networkStack.exec({
|
|
578
|
-
command,
|
|
579
|
-
cwd,
|
|
580
|
-
bwrapPath: invocation.file,
|
|
581
|
-
bwrapArgs: invocation.args,
|
|
582
|
-
shell: invocation.shell,
|
|
583
|
-
env: invocation.env,
|
|
584
|
-
onData,
|
|
585
|
-
signal,
|
|
586
|
-
timeout,
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
const argv = bwrapArgv(invocation);
|
|
591
|
-
const child = spawn(argv[0], argv.slice(1), {
|
|
592
|
-
cwd,
|
|
593
|
-
detached: true,
|
|
594
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
595
|
-
env: invocation.env,
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
return new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
599
|
-
let timedOut = false;
|
|
600
|
-
const timeoutHandle = timeout
|
|
601
|
-
? setTimeout(() => {
|
|
602
|
-
timedOut = true;
|
|
603
|
-
killChild(child);
|
|
604
|
-
}, timeout * 1000)
|
|
605
|
-
: undefined;
|
|
606
|
-
const onAbort = () => killChild(child);
|
|
607
|
-
child.stdout.on("data", onData);
|
|
608
|
-
child.stderr.on("data", onData);
|
|
609
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
610
|
-
child.once("error", reject);
|
|
611
|
-
child.once("close", (exitCode) => {
|
|
612
|
-
if (timeoutHandle) {
|
|
613
|
-
clearTimeout(timeoutHandle);
|
|
614
|
-
}
|
|
615
|
-
signal?.removeEventListener("abort", onAbort);
|
|
616
|
-
// 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
|
|
617
|
-
if (signal?.aborted) {
|
|
618
|
-
reject(
|
|
619
|
-
signal.reason instanceof Error
|
|
620
|
-
? signal.reason
|
|
621
|
-
: new Error("The operation was aborted"),
|
|
622
|
-
);
|
|
623
|
-
} else if (timedOut) {
|
|
624
|
-
// 超时:name=TimeoutError(对齐标准错误分类)
|
|
625
|
-
reject(new TimeoutError(timeout));
|
|
626
|
-
} else {
|
|
627
|
-
resolve({ exitCode });
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
});
|
|
631
|
-
},
|
|
632
|
-
};
|
|
633
|
-
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 执行层:把一次组装好的 bwrap 调用变成进程。
|
|
3
|
+
*
|
|
4
|
+
* 完整命令行只在这里组装(`invocationArgv`),子进程生命周期只有一份实现
|
|
5
|
+
* (`execInvocation`)——direct 与经 `nsenter` 进 holder netns 两条路径共用,
|
|
6
|
+
* 预览(`--print-args`)也走同一次组装,超时 / 中断 / 启动失败的错误语义与
|
|
7
|
+
* 打印出的命令行因此不会漂移。argv 内容与配置解析由 core.ts 提供;holder pid
|
|
8
|
+
* 由 network-stack.ts 提供,网络栈本身不知道 bwrap 参数怎么拼。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { constants } from "node:fs";
|
|
13
|
+
import { access as fsAccess } from "node:fs/promises";
|
|
14
|
+
|
|
15
|
+
import { type BashOperations, getShellConfig } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
import { buildBwrapArgs, findBwrap, type ResolvedBwrap } from "./core.js";
|
|
18
|
+
import type { NetworkStack } from "./network-stack.js";
|
|
19
|
+
|
|
20
|
+
/** 命令超时错误:name=TimeoutError(对齐标准错误分类),message 保留 timeout:N 格式。 */
|
|
21
|
+
export class TimeoutError extends Error {
|
|
22
|
+
constructor(timeout: number | undefined) {
|
|
23
|
+
super(`timeout:${timeout}`);
|
|
24
|
+
this.name = "TimeoutError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 一次 bwrap 调用的完整组装结果:argv 与干净环境。 */
|
|
29
|
+
export interface BwrapInvocation {
|
|
30
|
+
/** bwrap 可执行文件路径 */
|
|
31
|
+
file: string;
|
|
32
|
+
/** bwrap 参数(不含结尾的 `-- shell -lc command`) */
|
|
33
|
+
args: string[];
|
|
34
|
+
/** 沙箱内 shell 的绝对路径 */
|
|
35
|
+
shell: string;
|
|
36
|
+
/** 交给 shell 的命令 */
|
|
37
|
+
command: string;
|
|
38
|
+
/** 沙箱内环境(不继承父进程) */
|
|
39
|
+
env: Record<string, string>;
|
|
40
|
+
/** network limited 模式:命令需先经 nsenter 进入 holder 的 netns。 */
|
|
41
|
+
needsNetworkStack: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 组装一次 bwrap 调用。实际执行(`execInvocation`)与调试打印共用这里,
|
|
46
|
+
* 保证 `--print-args` 输出的命令行与真正跑的那条完全一致。
|
|
47
|
+
*/
|
|
48
|
+
export async function buildBwrapInvocation(
|
|
49
|
+
resolved: ResolvedBwrap,
|
|
50
|
+
workspace: string,
|
|
51
|
+
command: string,
|
|
52
|
+
): Promise<BwrapInvocation> {
|
|
53
|
+
// 干净环境:不继承父进程 env/PATH,由 bash -lc 从 /etc/profile 与用户 profile 重建
|
|
54
|
+
const home = process.env.HOME;
|
|
55
|
+
if (home === undefined) {
|
|
56
|
+
throw new Error("HOME is not set; refusing to run bash in a clean environment");
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
// 沙箱内不透传 PATH,execvp 的默认路径可能找不到 bash(如 NixOS),故在父进程解析绝对路径
|
|
60
|
+
shell: getShellConfig().shell,
|
|
61
|
+
file: findBwrap(resolved.bwrapPath),
|
|
62
|
+
args: [
|
|
63
|
+
"--ro-bind",
|
|
64
|
+
"/",
|
|
65
|
+
"/",
|
|
66
|
+
...(await buildBwrapArgs(resolved, workspace)),
|
|
67
|
+
"--dev",
|
|
68
|
+
"/dev",
|
|
69
|
+
"--proc",
|
|
70
|
+
"/proc",
|
|
71
|
+
],
|
|
72
|
+
command,
|
|
73
|
+
env: {
|
|
74
|
+
HOME: home,
|
|
75
|
+
SHELL: "/bin/bash",
|
|
76
|
+
TERM: "dumb",
|
|
77
|
+
LANG: "C.UTF-8",
|
|
78
|
+
// 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
|
|
79
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
80
|
+
},
|
|
81
|
+
needsNetworkStack: resolved.network === "limited",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 完整命令行:`[bwrap, ...args, "--", shell, "-lc", command]`;传 `nsenterPid` 时
|
|
87
|
+
* 前置 `nsenter` 前缀进入该 holder 的 userns + netns。
|
|
88
|
+
*
|
|
89
|
+
* 预览与实际执行都调用这里——`nsenterPid` 传数字是真实 holder pid,传字符串是
|
|
90
|
+
* 尚未启动时的占位符(打印用),不传即纯 bwrap 命令行。
|
|
91
|
+
*/
|
|
92
|
+
export function invocationArgv(
|
|
93
|
+
invocation: BwrapInvocation,
|
|
94
|
+
nsenterPid?: number | string,
|
|
95
|
+
): string[] {
|
|
96
|
+
const argv = [
|
|
97
|
+
invocation.file,
|
|
98
|
+
...invocation.args,
|
|
99
|
+
"--",
|
|
100
|
+
invocation.shell,
|
|
101
|
+
"-lc",
|
|
102
|
+
invocation.command,
|
|
103
|
+
];
|
|
104
|
+
if (nsenterPid === undefined) {
|
|
105
|
+
return argv;
|
|
106
|
+
}
|
|
107
|
+
return ["nsenter", "-U", "-n", "--preserve-credentials", "-t", String(nsenterPid), "--", ...argv];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function killChild(pid: number | undefined): void {
|
|
111
|
+
if (!pid) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
process.kill(-pid, "SIGKILL");
|
|
116
|
+
} catch {
|
|
117
|
+
try {
|
|
118
|
+
process.kill(pid, "SIGKILL");
|
|
119
|
+
} catch {
|
|
120
|
+
// 已退出
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ExecInvocationOptions {
|
|
126
|
+
/** 命令执行目录(进程 cwd);沙箱内可写边界由 invocation 的挂载项决定,与此无关。 */
|
|
127
|
+
cwd: string;
|
|
128
|
+
/** netns holder pid:invocation 需要网络栈时必填,缺失即抛错。 */
|
|
129
|
+
holderPid?: number;
|
|
130
|
+
/** 流式输出回调(stdout 与 stderr 已合并)。 */
|
|
131
|
+
onData: (data: Buffer) => void;
|
|
132
|
+
/** 取消:终止整个进程组并以 signal.reason(默认 AbortError)结束。 */
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
/** 超时秒数:终止整个进程组并以 TimeoutError 结束。 */
|
|
135
|
+
timeout?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 执行一次组装好的调用(唯一一处 spawn 与生命周期)。
|
|
140
|
+
*
|
|
141
|
+
* 需要网络栈时自动前置 `nsenter` 前缀;进程以独立进程组启动,超时与取消都
|
|
142
|
+
* 终止整组,启动失败(spawn error)立即结束且不残留超时定时器。
|
|
143
|
+
*/
|
|
144
|
+
export function execInvocation(
|
|
145
|
+
invocation: BwrapInvocation,
|
|
146
|
+
options: ExecInvocationOptions,
|
|
147
|
+
): Promise<{ exitCode: number | null }> {
|
|
148
|
+
if (invocation.needsNetworkStack && options.holderPid === undefined) {
|
|
149
|
+
return Promise.reject(new Error("Network stack is not initialized for network limited mode"));
|
|
150
|
+
}
|
|
151
|
+
const argv = invocationArgv(
|
|
152
|
+
invocation,
|
|
153
|
+
invocation.needsNetworkStack ? options.holderPid : undefined,
|
|
154
|
+
);
|
|
155
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
156
|
+
cwd: options.cwd,
|
|
157
|
+
detached: true,
|
|
158
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
159
|
+
env: invocation.env,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
163
|
+
let timedOut = false;
|
|
164
|
+
let settled = false;
|
|
165
|
+
const timeoutHandle = options.timeout
|
|
166
|
+
? setTimeout(() => {
|
|
167
|
+
timedOut = true;
|
|
168
|
+
killChild(child.pid);
|
|
169
|
+
}, options.timeout * 1000)
|
|
170
|
+
: undefined;
|
|
171
|
+
const onAbort = (): void => {
|
|
172
|
+
killChild(child.pid);
|
|
173
|
+
};
|
|
174
|
+
// 结算一次:无论走 error 还是 close,都清掉超时定时器与 abort 监听——挂着的
|
|
175
|
+
// 定时器会白占事件循环到超时那一刻。
|
|
176
|
+
const settle = (): boolean => {
|
|
177
|
+
if (settled) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
settled = true;
|
|
181
|
+
if (timeoutHandle) {
|
|
182
|
+
clearTimeout(timeoutHandle);
|
|
183
|
+
}
|
|
184
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
185
|
+
return true;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
child.stdout.on("data", options.onData);
|
|
189
|
+
child.stderr.on("data", options.onData);
|
|
190
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
191
|
+
|
|
192
|
+
child.once("error", (error) => {
|
|
193
|
+
if (!settle()) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
reject(error);
|
|
197
|
+
});
|
|
198
|
+
child.once("close", (exitCode) => {
|
|
199
|
+
if (!settle()) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
|
|
203
|
+
if (options.signal?.aborted) {
|
|
204
|
+
reject(
|
|
205
|
+
options.signal.reason instanceof Error
|
|
206
|
+
? options.signal.reason
|
|
207
|
+
: new Error("The operation was aborted"),
|
|
208
|
+
);
|
|
209
|
+
} else if (timedOut) {
|
|
210
|
+
// 超时:name=TimeoutError(对齐标准错误分类)
|
|
211
|
+
reject(new TimeoutError(options.timeout));
|
|
212
|
+
} else {
|
|
213
|
+
resolve({ exitCode });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param workspace session 工作区:writablePaths 的 "." 与 PROTECTED_DIRS 都基于它解析,
|
|
221
|
+
* 与当次命令的 cwd(仅作为进程执行目录)解耦,避免 workdir 参数漂移可写边界。
|
|
222
|
+
*/
|
|
223
|
+
export function createBwrapBashOperations(
|
|
224
|
+
resolved: ResolvedBwrap,
|
|
225
|
+
workspace: string,
|
|
226
|
+
networkStack?: NetworkStack,
|
|
227
|
+
): BashOperations {
|
|
228
|
+
return {
|
|
229
|
+
async exec(command, cwd, { onData, signal, timeout }) {
|
|
230
|
+
await fsAccess(cwd, constants.F_OK).catch(() => {
|
|
231
|
+
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
|
|
232
|
+
});
|
|
233
|
+
// 已中断(signal.reason 是 name=AbortError 的 DOMException):直接抛,不再执行
|
|
234
|
+
signal?.throwIfAborted();
|
|
235
|
+
|
|
236
|
+
const invocation = await buildBwrapInvocation(resolved, workspace, command);
|
|
237
|
+
return execInvocation(invocation, {
|
|
238
|
+
cwd,
|
|
239
|
+
holderPid: networkStack?.holderPid,
|
|
240
|
+
onData,
|
|
241
|
+
signal,
|
|
242
|
+
timeout,
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
@@ -9,14 +9,6 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
9
9
|
import { forEachLine } from "../lib/proc.js";
|
|
10
10
|
import { generateMihomoConfig, TUN_MTU } from "./mihomo-config.js";
|
|
11
11
|
|
|
12
|
-
/** 命令超时错误:name=TimeoutError(对齐标准错误分类),message 保留 timeout:N 格式。 */
|
|
13
|
-
export class TimeoutError extends Error {
|
|
14
|
-
constructor(timeout: number | undefined) {
|
|
15
|
-
super(`timeout:${timeout}`);
|
|
16
|
-
this.name = "TimeoutError";
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
12
|
export interface NetworkStackOptions {
|
|
21
13
|
/** 允许直连的域名 / IP:port 列表(每次命令从配置重新读取)。 */
|
|
22
14
|
readonly allowlist: readonly string[];
|
|
@@ -31,19 +23,6 @@ export interface NetworkStackOptions {
|
|
|
31
23
|
readonly onHolderOutput?: (chunk: string) => void;
|
|
32
24
|
}
|
|
33
25
|
|
|
34
|
-
interface NetworkStackExecOptions {
|
|
35
|
-
readonly command: string;
|
|
36
|
-
readonly cwd: string;
|
|
37
|
-
readonly bwrapPath: string;
|
|
38
|
-
/** bwrap 的完整参数(不含 -- 后的 shell 与命令),由 core.ts 组装。 */
|
|
39
|
-
readonly bwrapArgs: readonly string[];
|
|
40
|
-
readonly shell: string;
|
|
41
|
-
readonly env: Readonly<Record<string, string>>;
|
|
42
|
-
readonly onData: (data: Buffer) => void;
|
|
43
|
-
readonly signal?: AbortSignal;
|
|
44
|
-
readonly timeout?: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
26
|
const NAMESERVER_PATTERN = /^\s*nameserver\s+(\S+)/;
|
|
48
27
|
|
|
49
28
|
/**
|
|
@@ -198,21 +177,6 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
|
|
|
198
177
|
});
|
|
199
178
|
}
|
|
200
179
|
|
|
201
|
-
function killChild(pid: number | undefined): void {
|
|
202
|
-
if (!pid) {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
try {
|
|
206
|
-
process.kill(-pid, "SIGKILL");
|
|
207
|
-
} catch {
|
|
208
|
-
try {
|
|
209
|
-
process.kill(pid, "SIGKILL");
|
|
210
|
-
} catch {
|
|
211
|
-
// 已退出
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
180
|
/**
|
|
217
181
|
* 额外转发子进程输出给诊断回调(就绪探测的监听器不受影响),并把全部输出
|
|
218
182
|
* 收进 collect:启动失败时落盘,否则没有别的渠道能看到 holder 的真实死因。
|
|
@@ -237,7 +201,6 @@ function forwardOutput(
|
|
|
237
201
|
}
|
|
238
202
|
|
|
239
203
|
export interface NetworkStack {
|
|
240
|
-
exec(options: NetworkStackExecOptions): Promise<{ exitCode: number | null }>;
|
|
241
204
|
stop(): Promise<void>;
|
|
242
205
|
/** holder pid:可 `nsenter -U -n --preserve-credentials -t <pid>` 手动进入该 netns 排查。 */
|
|
243
206
|
readonly holderPid: number;
|
|
@@ -391,80 +354,6 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
391
354
|
|
|
392
355
|
const state: NetworkStackState = { holderPid, slirpPid: slirp.pid, mihomoHome };
|
|
393
356
|
const stack: NetworkStack = {
|
|
394
|
-
exec: async (execOptions: NetworkStackExecOptions) => {
|
|
395
|
-
const child = spawn(
|
|
396
|
-
"nsenter",
|
|
397
|
-
[
|
|
398
|
-
"-U",
|
|
399
|
-
"-n",
|
|
400
|
-
"--preserve-credentials",
|
|
401
|
-
"-t",
|
|
402
|
-
String(holderPid),
|
|
403
|
-
"--",
|
|
404
|
-
execOptions.bwrapPath,
|
|
405
|
-
...execOptions.bwrapArgs,
|
|
406
|
-
"--",
|
|
407
|
-
execOptions.shell,
|
|
408
|
-
"-lc",
|
|
409
|
-
execOptions.command,
|
|
410
|
-
],
|
|
411
|
-
{
|
|
412
|
-
cwd: execOptions.cwd,
|
|
413
|
-
detached: true,
|
|
414
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
415
|
-
env: execOptions.env,
|
|
416
|
-
},
|
|
417
|
-
);
|
|
418
|
-
|
|
419
|
-
return new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
420
|
-
let timedOut = false;
|
|
421
|
-
let settled = false;
|
|
422
|
-
const timeoutHandle = execOptions.timeout
|
|
423
|
-
? setTimeout(() => {
|
|
424
|
-
timedOut = true;
|
|
425
|
-
killChild(child.pid);
|
|
426
|
-
}, execOptions.timeout * 1000)
|
|
427
|
-
: undefined;
|
|
428
|
-
const onAbort = (): void => {
|
|
429
|
-
killChild(child.pid);
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
child.stdout.on("data", execOptions.onData);
|
|
433
|
-
child.stderr.on("data", execOptions.onData);
|
|
434
|
-
execOptions.signal?.addEventListener("abort", onAbort, { once: true });
|
|
435
|
-
|
|
436
|
-
child.once("error", (error) => {
|
|
437
|
-
if (settled) {
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
settled = true;
|
|
441
|
-
reject(error);
|
|
442
|
-
});
|
|
443
|
-
child.once("close", (exitCode) => {
|
|
444
|
-
if (settled) {
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
settled = true;
|
|
448
|
-
if (timeoutHandle) {
|
|
449
|
-
clearTimeout(timeoutHandle);
|
|
450
|
-
}
|
|
451
|
-
execOptions.signal?.removeEventListener("abort", onAbort);
|
|
452
|
-
// 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
|
|
453
|
-
if (execOptions.signal?.aborted) {
|
|
454
|
-
reject(
|
|
455
|
-
execOptions.signal.reason instanceof Error
|
|
456
|
-
? execOptions.signal.reason
|
|
457
|
-
: new Error("The operation was aborted"),
|
|
458
|
-
);
|
|
459
|
-
} else if (timedOut) {
|
|
460
|
-
// 超时:name=TimeoutError(对齐标准错误分类)
|
|
461
|
-
reject(new TimeoutError(execOptions.timeout));
|
|
462
|
-
} else {
|
|
463
|
-
resolve({ exitCode });
|
|
464
|
-
}
|
|
465
|
-
});
|
|
466
|
-
});
|
|
467
|
-
},
|
|
468
357
|
stop: async () => {
|
|
469
358
|
const children = await readChildPids(state.holderPid);
|
|
470
359
|
// slirp4netns 持有 tap fd(pin 住 netns),必须随 holder 一起显式终止;
|
package/src/bwrap/sandbox.ts
CHANGED
|
@@ -16,10 +16,7 @@ import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
|
16
16
|
|
|
17
17
|
import { expandHome } from "../lib/path.js";
|
|
18
18
|
import {
|
|
19
|
-
buildBwrapInvocation,
|
|
20
|
-
bwrapArgv,
|
|
21
19
|
type BwrapConfig,
|
|
22
|
-
createBwrapBashOperations,
|
|
23
20
|
createNetworkStack,
|
|
24
21
|
type FsMode,
|
|
25
22
|
getBwrapConfigPaths,
|
|
@@ -30,6 +27,7 @@ import {
|
|
|
30
27
|
resolveBwrapPath,
|
|
31
28
|
type ResolvedBwrap,
|
|
32
29
|
} from "./core.js";
|
|
30
|
+
import { buildBwrapInvocation, createBwrapBashOperations, invocationArgv } from "./exec.js";
|
|
33
31
|
import type { NetworkStack } from "./network-stack.js";
|
|
34
32
|
|
|
35
33
|
export interface SandboxConfigInput {
|
|
@@ -161,7 +159,7 @@ export interface SandboxPreview {
|
|
|
161
159
|
}
|
|
162
160
|
|
|
163
161
|
/**
|
|
164
|
-
* 打印将要执行的命令行(`--print-args`)。与
|
|
162
|
+
* 打印将要执行的命令行(`--print-args`)。与 `execInvocation` 共用同一段组装,
|
|
165
163
|
* 不存在「打印的是一回事、跑的是另一回事」的漂移。
|
|
166
164
|
*/
|
|
167
165
|
export async function previewSandboxCommand(
|
|
@@ -171,23 +169,13 @@ export async function previewSandboxCommand(
|
|
|
171
169
|
},
|
|
172
170
|
): Promise<SandboxPreview> {
|
|
173
171
|
const workspace = expandHome(options.workspace);
|
|
174
|
-
const
|
|
175
|
-
const invocation = await buildBwrapInvocation(resolved, workspace, options.command, commandCwd);
|
|
172
|
+
const invocation = await buildBwrapInvocation(resolved, workspace, options.command);
|
|
176
173
|
if (!invocation.needsNetworkStack || options.unsandboxed === true) {
|
|
177
|
-
return { argv:
|
|
174
|
+
return { argv: invocationArgv(invocation), env: invocation.env, needsNetworkStack: false };
|
|
178
175
|
}
|
|
179
176
|
return {
|
|
180
|
-
//
|
|
181
|
-
argv:
|
|
182
|
-
"nsenter",
|
|
183
|
-
"-U",
|
|
184
|
-
"-n",
|
|
185
|
-
"--preserve-credentials",
|
|
186
|
-
"-t",
|
|
187
|
-
options.holderPid === undefined ? HOLDER_PID_PLACEHOLDER : String(options.holderPid),
|
|
188
|
-
"--",
|
|
189
|
-
...bwrapArgv(invocation),
|
|
190
|
-
],
|
|
177
|
+
// holder 未启动时用占位符标出 pid 的位置
|
|
178
|
+
argv: invocationArgv(invocation, options.holderPid ?? HOLDER_PID_PLACEHOLDER),
|
|
191
179
|
env: invocation.env,
|
|
192
180
|
needsNetworkStack: true,
|
|
193
181
|
};
|
package/src/spawn-agent.ts
CHANGED
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
* `MAX_PROGRESS_LINES` lines. Consecutive tool calls are merged into a
|
|
17
17
|
* single `tool:` line (`read x 2, glob`) and over-long line content is
|
|
18
18
|
* folded to the first/last 9 chars joined by `…`, so a burst of tool calls
|
|
19
|
-
* or a long text block does not flood the window;
|
|
20
|
-
* new line
|
|
21
|
-
*
|
|
19
|
+
* or a long text block does not flood the window; only a text block starts
|
|
20
|
+
* a new line — thinking is a transient status line, so it does not break the
|
|
21
|
+
* merge across turn boundaries. Line content is sanitized first: markdown
|
|
22
|
+
* marker characters are stripped and whitespace (including newlines) is
|
|
23
|
+
* collapsed to single spaces,
|
|
22
24
|
* so one log entry is always exactly one rendered line. The final line is
|
|
23
25
|
* always the subagent name as a code span (`` `scout` ``), followed by the
|
|
24
26
|
* live usage stats when there are any; it rides outside the rolling window so
|
|
@@ -91,8 +93,11 @@ export const SUBAGENT_DEFAULT_SANDBOX: BwrapConfig = completeBwrapConfig({
|
|
|
91
93
|
fs: { mode: "readonly" },
|
|
92
94
|
network: { mode: "block" },
|
|
93
95
|
});
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Progress log keeps only the most recent lines (rolling window).
|
|
98
|
+
* 展开后面板最多 6 行内容:4 行日志 + 可能的一行瞬态 thinking + 固定的 metadata 行。
|
|
99
|
+
*/
|
|
100
|
+
const MAX_PROGRESS_LINES = 4;
|
|
96
101
|
/** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
|
|
97
102
|
const MAX_PROGRESS_CHARS_PER_LINE = 21;
|
|
98
103
|
/**
|
|
@@ -464,7 +469,8 @@ export async function runAgent(
|
|
|
464
469
|
let logLines: string[] = [];
|
|
465
470
|
// 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
|
|
466
471
|
// (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,
|
|
467
|
-
//
|
|
472
|
+
// 不同名按调用顺序罗列;只有写入日志行的事件(text 块)打断合并,thinking
|
|
473
|
+
// 不写日志行、也不打断合并。
|
|
468
474
|
let toolLineSegments: string[] = [];
|
|
469
475
|
let toolLine: { name: string; count: number } | undefined;
|
|
470
476
|
// 思考中状态:thinking_start 打开、thinking_delta 累计字符数、thinking_end
|
|
@@ -476,7 +482,7 @@ export async function runAgent(
|
|
|
476
482
|
if (logLines.length > MAX_PROGRESS_LINES) {
|
|
477
483
|
logLines = logLines.slice(-MAX_PROGRESS_LINES);
|
|
478
484
|
}
|
|
479
|
-
//
|
|
485
|
+
// 写进日志的新行都会打断工具调用合并,下一批调用另起一行。
|
|
480
486
|
toolLine = undefined;
|
|
481
487
|
};
|
|
482
488
|
|
|
@@ -537,8 +543,8 @@ export async function runAgent(
|
|
|
537
543
|
break;
|
|
538
544
|
}
|
|
539
545
|
case "thinking_start": {
|
|
540
|
-
//
|
|
541
|
-
|
|
546
|
+
// thinking 不产生日志行,因此不打断工具行合并:跨轮次的连续工具调用
|
|
547
|
+
// 仍累加到同一 `tool:` 行。
|
|
542
548
|
thinkingChars = 0;
|
|
543
549
|
emitUpdate();
|
|
544
550
|
break;
|