@trim21/personal-pi-extensions 0.1.627 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.627",
3
+ "version": "0.1.628",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -1,7 +1,8 @@
1
1
  # bwrap 沙箱与网络栈架构
2
2
 
3
3
  本文档描述 `network: limited` 模式下的进程模型、网络路径与生命周期管理。
4
- 基础沙箱(bwrap 文件系统隔离)见 `core.ts` / `sandbox.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 跑命令。网络栈是每条命令现建现停(起栈 ~40ms、停栈 ~100ms),不跨命令复用:
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 ChildProcess, spawn } from "node:child_process";
2
- import { constants, type Dirent, existsSync, readFileSync } from "node:fs";
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 { type BashOperations, getAgentDir, getShellConfig } from "@earendil-works/pi-coding-agent";
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 一起显式终止;
@@ -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`)。与 runInSandbox 共用同一段组装逻辑,
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 commandCwd = expandHome(options.commandCwd ?? workspace);
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: bwrapArgv(invocation), env: invocation.env, needsNetworkStack: false };
174
+ return { argv: invocationArgv(invocation), env: invocation.env, needsNetworkStack: false };
178
175
  }
179
176
  return {
180
- // 与 network-stack.ts 的实际 spawn 一致;holder 未启动时用占位符标出 pid 的位置
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
  };