@trim21/personal-pi-extensions 0.1.439 → 0.1.441
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 +4 -1
- package/src/bwrap/core.ts +91 -42
- package/src/bwrap/network-stack.ts +37 -2
- package/src/bwrap/runtime.ts +9 -20
- package/src/bwrap/sandbox.ts +190 -0
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.441",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"format": "prettier --write .",
|
|
24
24
|
"test": "vitest run",
|
|
25
25
|
"test:coverage": "vitest run --coverage",
|
|
26
|
+
"sandbox": "tsx bin/sandbox.ts",
|
|
26
27
|
"prepare": "husky"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
@@ -52,6 +53,8 @@
|
|
|
52
53
|
"husky": "^9.1.7",
|
|
53
54
|
"lint-staged": "^17.3.0",
|
|
54
55
|
"prettier": "^3.9.6",
|
|
56
|
+
"shlex": "^3.0.0",
|
|
57
|
+
"tsx": "^4.23.12",
|
|
55
58
|
"typebox": "1.3.1",
|
|
56
59
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
57
60
|
"typescript-eslint": "^8.67.0",
|
package/src/bwrap/core.ts
CHANGED
|
@@ -187,7 +187,9 @@ export function getBwrapConfigPaths(cwd: string): BwrapConfigPaths {
|
|
|
187
187
|
|
|
188
188
|
export function loadBwrapConfig(cwd: string, paths = getBwrapConfigPaths(cwd)): BwrapConfig {
|
|
189
189
|
let config = DEFAULT_CONFIG;
|
|
190
|
-
|
|
190
|
+
// 去重:调用方用同一路径表达「只读这一个文件」时不做二次合并
|
|
191
|
+
// (否则 extraWritablePaths / approvalRules 会被重复拼接)
|
|
192
|
+
for (const path of new Set([paths.global, paths.project])) {
|
|
191
193
|
if (!existsSync(path)) continue;
|
|
192
194
|
config = deepMerge(config, parseBwrapConfigFile(path));
|
|
193
195
|
}
|
|
@@ -375,9 +377,16 @@ function killChild(child: ChildProcess): void {
|
|
|
375
377
|
}
|
|
376
378
|
}
|
|
377
379
|
|
|
380
|
+
/** 网络栈子进程输出转发通道,仅用于诊断(默认丢弃)。 */
|
|
381
|
+
export interface NetworkStackLog {
|
|
382
|
+
holder?: (chunk: string) => void;
|
|
383
|
+
slirp?: (chunk: string) => void;
|
|
384
|
+
}
|
|
385
|
+
|
|
378
386
|
/** 为 net-allowlist 模式创建网络栈;非该模式返回 undefined。每次命令现建现停。 */
|
|
379
387
|
export async function createNetworkStack(
|
|
380
388
|
resolved: ResolvedBwrap,
|
|
389
|
+
log?: NetworkStackLog,
|
|
381
390
|
): Promise<NetworkStack | undefined> {
|
|
382
391
|
if (!resolved.network || resolved.networkAllowlist.length === 0) return undefined;
|
|
383
392
|
return startNetworkStack({
|
|
@@ -385,9 +394,77 @@ export async function createNetworkStack(
|
|
|
385
394
|
dnsServers: await resolveDnsServers(),
|
|
386
395
|
mihomoPath: findMihomo(resolved.mihomoPath),
|
|
387
396
|
slirp4netnsPath: findSlirp4netns(resolved.slirp4netnsPath),
|
|
397
|
+
...(log?.holder && { onHolderOutput: log.holder }),
|
|
398
|
+
...(log?.slirp && { onSlirpOutput: log.slirp }),
|
|
388
399
|
});
|
|
389
400
|
}
|
|
390
401
|
|
|
402
|
+
/** 一次 bwrap 调用的完整组装结果:argv 与干净环境。 */
|
|
403
|
+
export interface BwrapInvocation {
|
|
404
|
+
/** bwrap 可执行文件路径 */
|
|
405
|
+
file: string;
|
|
406
|
+
/** bwrap 参数(不含结尾的 `-- shell -lc command`) */
|
|
407
|
+
args: string[];
|
|
408
|
+
/** 沙箱内 shell 的绝对路径 */
|
|
409
|
+
shell: string;
|
|
410
|
+
/** 交给 shell 的命令 */
|
|
411
|
+
command: string;
|
|
412
|
+
/** 命令执行目录 */
|
|
413
|
+
cwd: string;
|
|
414
|
+
/** 沙箱内环境(不继承父进程) */
|
|
415
|
+
env: Record<string, string>;
|
|
416
|
+
/** net-allowlist 模式:命令需先经 nsenter 进入 holder 的 netns。 */
|
|
417
|
+
needsNetworkStack: boolean;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 组装一次 bwrap 调用。实际执行(createBwrapBashOperations)与调试打印共用这里,
|
|
422
|
+
* 保证 `--print-args` 输出的命令行与真正跑的那条完全一致。
|
|
423
|
+
*/
|
|
424
|
+
export async function buildBwrapInvocation(
|
|
425
|
+
resolved: ResolvedBwrap,
|
|
426
|
+
workspace: string,
|
|
427
|
+
command: string,
|
|
428
|
+
cwd: string,
|
|
429
|
+
): Promise<BwrapInvocation> {
|
|
430
|
+
// 干净环境:不继承父进程 env/PATH,由 bash -lc 从 /etc/profile 与用户 profile 重建
|
|
431
|
+
const home = process.env.HOME;
|
|
432
|
+
if (home === undefined) {
|
|
433
|
+
throw new Error("HOME is not set; refusing to run bash in a clean environment");
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
// 沙箱内不透传 PATH,execvp 的默认路径可能找不到 bash(如 NixOS),故在父进程解析绝对路径
|
|
437
|
+
shell: getShellConfig().shell,
|
|
438
|
+
file: findBwrap(resolved.bwrapPath),
|
|
439
|
+
args: [
|
|
440
|
+
"--ro-bind",
|
|
441
|
+
"/",
|
|
442
|
+
"/",
|
|
443
|
+
...(await buildBwrapArgs(resolved, workspace)),
|
|
444
|
+
"--dev",
|
|
445
|
+
"/dev",
|
|
446
|
+
"--proc",
|
|
447
|
+
"/proc",
|
|
448
|
+
],
|
|
449
|
+
command,
|
|
450
|
+
cwd,
|
|
451
|
+
env: {
|
|
452
|
+
HOME: home,
|
|
453
|
+
SHELL: "/bin/bash",
|
|
454
|
+
TERM: "dumb",
|
|
455
|
+
LANG: "C.UTF-8",
|
|
456
|
+
// 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
|
|
457
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
458
|
+
},
|
|
459
|
+
needsNetworkStack: resolved.network && resolved.networkAllowlist.length > 0,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 完整 argv(`[bwrap, ...args, "--", shell, "-lc", command]`),spawn 与打印共用。 */
|
|
464
|
+
export function bwrapArgv(invocation: BwrapInvocation): string[] {
|
|
465
|
+
return [invocation.file, ...invocation.args, "--", invocation.shell, "-lc", invocation.command];
|
|
466
|
+
}
|
|
467
|
+
|
|
391
468
|
/**
|
|
392
469
|
* @param workspace session 工作区:writablePaths 的 "." 与 PROTECTED_DIRS 都基于它解析,
|
|
393
470
|
* 与当次命令的 cwd(仅作为进程执行目录)解耦,避免 workdir 参数漂移可写边界。
|
|
@@ -397,8 +474,6 @@ export function createBwrapBashOperations(
|
|
|
397
474
|
workspace: string,
|
|
398
475
|
networkStack?: NetworkStack,
|
|
399
476
|
): BashOperations {
|
|
400
|
-
// 沙箱内不透传 PATH,execvp 的默认路径可能找不到 bash(如 NixOS),故在父进程解析绝对路径
|
|
401
|
-
const shell = getShellConfig().shell;
|
|
402
477
|
return {
|
|
403
478
|
async exec(command, cwd, { onData, signal, timeout }) {
|
|
404
479
|
await fsAccess(cwd, constants.F_OK).catch(() => {
|
|
@@ -407,58 +482,32 @@ export function createBwrapBashOperations(
|
|
|
407
482
|
// 已中断(signal.reason 是 name=AbortError 的 DOMException):直接抛,不再执行
|
|
408
483
|
signal?.throwIfAborted();
|
|
409
484
|
|
|
410
|
-
|
|
411
|
-
const home = process.env.HOME;
|
|
412
|
-
if (home === undefined) {
|
|
413
|
-
throw new Error("HOME is not set; refusing to run bash in a clean environment");
|
|
414
|
-
}
|
|
485
|
+
const invocation = await buildBwrapInvocation(resolved, workspace, command, cwd);
|
|
415
486
|
|
|
416
|
-
|
|
417
|
-
"--ro-bind",
|
|
418
|
-
"/",
|
|
419
|
-
"/",
|
|
420
|
-
...(await buildBwrapArgs(resolved, workspace)),
|
|
421
|
-
"--dev",
|
|
422
|
-
"/dev",
|
|
423
|
-
"--proc",
|
|
424
|
-
"/proc",
|
|
425
|
-
];
|
|
426
|
-
const env = {
|
|
427
|
-
HOME: home,
|
|
428
|
-
SHELL: "/bin/bash",
|
|
429
|
-
TERM: "dumb",
|
|
430
|
-
LANG: "C.UTF-8",
|
|
431
|
-
// 基础 PATH:profile 加载阶段(设置 PATH 前)需要系统命令(如 id),由 profile 随后覆盖;不含 sbin
|
|
432
|
-
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
433
|
-
};
|
|
434
|
-
|
|
435
|
-
if (resolved.network && resolved.networkAllowlist.length > 0) {
|
|
487
|
+
if (invocation.needsNetworkStack) {
|
|
436
488
|
if (!networkStack) {
|
|
437
489
|
throw new Error("Network stack is not initialized for net-allowlist mode");
|
|
438
490
|
}
|
|
439
491
|
return networkStack.exec({
|
|
440
492
|
command,
|
|
441
493
|
cwd,
|
|
442
|
-
bwrapPath:
|
|
443
|
-
bwrapArgs:
|
|
444
|
-
shell,
|
|
445
|
-
env,
|
|
494
|
+
bwrapPath: invocation.file,
|
|
495
|
+
bwrapArgs: invocation.args,
|
|
496
|
+
shell: invocation.shell,
|
|
497
|
+
env: invocation.env,
|
|
446
498
|
onData,
|
|
447
499
|
signal,
|
|
448
500
|
timeout,
|
|
449
501
|
});
|
|
450
502
|
}
|
|
451
503
|
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
env,
|
|
460
|
-
},
|
|
461
|
-
);
|
|
504
|
+
const argv = bwrapArgv(invocation);
|
|
505
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
506
|
+
cwd,
|
|
507
|
+
detached: true,
|
|
508
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
509
|
+
env: invocation.env,
|
|
510
|
+
});
|
|
462
511
|
|
|
463
512
|
return new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
464
513
|
let timedOut = false;
|
|
@@ -21,6 +21,13 @@ export interface NetworkStackOptions {
|
|
|
21
21
|
readonly dnsServers: readonly string[];
|
|
22
22
|
readonly mihomoPath: string;
|
|
23
23
|
readonly slirp4netnsPath: string;
|
|
24
|
+
/**
|
|
25
|
+
* holder(unshare + mihomo)输出透传。默认只用于就绪探测、内容丢弃,
|
|
26
|
+
* 因此启动失败时只剩 "exited before mihomo started",诊断需要它。
|
|
27
|
+
*/
|
|
28
|
+
readonly onHolderOutput?: (chunk: string) => void;
|
|
29
|
+
/** slirp4netns 输出透传;不传时其 stdio 保持 ignore。 */
|
|
30
|
+
readonly onSlirpOutput?: (chunk: string) => void;
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export interface NetworkStackExecOptions {
|
|
@@ -121,9 +128,25 @@ function killChild(pid: number | undefined): void {
|
|
|
121
128
|
}
|
|
122
129
|
}
|
|
123
130
|
|
|
131
|
+
/**
|
|
132
|
+
* 额外转发子进程输出给诊断回调(就绪探测的监听器不受影响)。
|
|
133
|
+
* 注册 error 监听器后 spawn 失败(如 unshare 缺失)不再以未捕获异常结束进程。
|
|
134
|
+
*/
|
|
135
|
+
function forwardOutput(child: ChildProcess, onOutput: ((chunk: string) => void) | undefined): void {
|
|
136
|
+
if (!onOutput) return;
|
|
137
|
+
const write = (chunk: Buffer): void => onOutput(chunk.toString());
|
|
138
|
+
child.stdout?.on("data", write);
|
|
139
|
+
child.stderr?.on("data", write);
|
|
140
|
+
child.once("error", (error) => onOutput(String(error)));
|
|
141
|
+
}
|
|
142
|
+
|
|
124
143
|
export interface NetworkStack {
|
|
125
144
|
exec(options: NetworkStackExecOptions): Promise<{ exitCode: number | null }>;
|
|
126
145
|
stop(): Promise<void>;
|
|
146
|
+
/** holder pid:可 `nsenter -U -n --preserve-credentials -t <pid>` 手动进入该 netns 排查。 */
|
|
147
|
+
readonly holderPid: number;
|
|
148
|
+
/** 生成的 mihomo 配置路径(stop() 后随临时目录一起删除)。 */
|
|
149
|
+
readonly configPath: string;
|
|
127
150
|
}
|
|
128
151
|
|
|
129
152
|
interface NetworkStackState {
|
|
@@ -145,7 +168,14 @@ const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
|
|
|
145
168
|
* netns/mihomo/slirp4netns 均已就绪,命令通过 nsenter 进入该 netns 执行。
|
|
146
169
|
*/
|
|
147
170
|
export async function startNetworkStack(options: NetworkStackOptions): Promise<NetworkStack> {
|
|
148
|
-
const {
|
|
171
|
+
const {
|
|
172
|
+
allowlist,
|
|
173
|
+
dnsServers,
|
|
174
|
+
mihomoPath,
|
|
175
|
+
slirp4netnsPath,
|
|
176
|
+
onHolderOutput: holderOutput,
|
|
177
|
+
onSlirpOutput: slirpOutput,
|
|
178
|
+
} = options;
|
|
149
179
|
const directory = await mkdtemp(join(tmpdir(), "pi-netns-"));
|
|
150
180
|
const configPath = join(directory, "mihomo.json");
|
|
151
181
|
const config = generateMihomoConfig({ allowlist, dnsServers });
|
|
@@ -159,6 +189,7 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
159
189
|
// mihomo 日志经 holder 透传到这里的 stdout/stderr,用于判定就绪
|
|
160
190
|
stdio: ["ignore", "pipe", "pipe"],
|
|
161
191
|
});
|
|
192
|
+
forwardOutput(holder, holderOutput);
|
|
162
193
|
if (holder.pid === undefined) {
|
|
163
194
|
throw new Error("Failed to start network namespace holder");
|
|
164
195
|
}
|
|
@@ -175,8 +206,10 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
175
206
|
String(holderPid),
|
|
176
207
|
"tap0",
|
|
177
208
|
],
|
|
178
|
-
|
|
209
|
+
// 默认丢弃 slirp4netns 日志;诊断时改为管道转发
|
|
210
|
+
{ stdio: slirpOutput ? ["ignore", "pipe", "pipe"] : "ignore" },
|
|
179
211
|
);
|
|
212
|
+
forwardOutput(slirp, slirpOutput);
|
|
180
213
|
const slirpPid = slirp.pid;
|
|
181
214
|
await waitForMihomoStarted(holder);
|
|
182
215
|
|
|
@@ -253,6 +286,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
253
286
|
killProcess(state.holderPid);
|
|
254
287
|
await rm(state.directory, { recursive: true, force: true }).catch(() => false);
|
|
255
288
|
},
|
|
289
|
+
holderPid,
|
|
290
|
+
configPath,
|
|
256
291
|
};
|
|
257
292
|
stackFinalizer.register(stack, state);
|
|
258
293
|
return stack;
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -5,7 +5,6 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
type AgentToolUpdateCallback,
|
|
8
|
-
createLocalBashOperations,
|
|
9
8
|
type ExtensionAPI,
|
|
10
9
|
type ExtensionCommandContext,
|
|
11
10
|
type ExtensionContext,
|
|
@@ -28,8 +27,6 @@ import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-r
|
|
|
28
27
|
import { commandPatternsFor } from "./approval-suggest.js";
|
|
29
28
|
import {
|
|
30
29
|
type BwrapMode,
|
|
31
|
-
createBwrapBashOperations,
|
|
32
|
-
createNetworkStack,
|
|
33
30
|
findBwrap,
|
|
34
31
|
getBwrapConfigPaths,
|
|
35
32
|
loadBwrapConfig,
|
|
@@ -39,6 +36,7 @@ import {
|
|
|
39
36
|
resolveHeadlessBwrap,
|
|
40
37
|
} from "./core.js";
|
|
41
38
|
import { dcgSuggestion } from "./dcg-scan.js";
|
|
39
|
+
import { loadSandboxConfig, runInSandbox } from "./sandbox.js";
|
|
42
40
|
|
|
43
41
|
export type EscalationDecision = { kind: "dialog" } | { kind: "deny"; reason: string };
|
|
44
42
|
|
|
@@ -345,7 +343,7 @@ export class BwrapRuntime {
|
|
|
345
343
|
}
|
|
346
344
|
|
|
347
345
|
setMode(cwd: string, mode: BwrapMode): ResolvedBwrap {
|
|
348
|
-
this.resolved =
|
|
346
|
+
this.resolved = loadSandboxConfig({ workspace: cwd, mode });
|
|
349
347
|
this.sandboxDisabled = false;
|
|
350
348
|
return this.resolved;
|
|
351
349
|
}
|
|
@@ -389,21 +387,8 @@ export class BwrapRuntime {
|
|
|
389
387
|
await this.approveFullAccess(request.ctx, request.command, request.description, execCwd);
|
|
390
388
|
}
|
|
391
389
|
}
|
|
390
|
+
// 不经沙箱的三种情形:Windows(无 bubblewrap)、审批通过的全权限、allow-all 模式
|
|
392
391
|
const local = isWindows || needsApproval || !runtime.bwrapEnabled;
|
|
393
|
-
// 每次命令现建网络栈(启动约 140ms),作用域结束自动停栈;allowlist 变化即时生效
|
|
394
|
-
const networkStack = local ? undefined : await createNetworkStack(runtime);
|
|
395
|
-
await using _stack = {
|
|
396
|
-
async [Symbol.asyncDispose]() {
|
|
397
|
-
try {
|
|
398
|
-
await networkStack?.stop();
|
|
399
|
-
} catch {
|
|
400
|
-
// best-effort:停栈失败(进程已退出/目录删除失败)不掩盖命令结果
|
|
401
|
-
}
|
|
402
|
-
},
|
|
403
|
-
};
|
|
404
|
-
const operations = local
|
|
405
|
-
? createLocalBashOperations()
|
|
406
|
-
: createBwrapBashOperations(runtime, workspace, networkStack);
|
|
407
392
|
await using output = new BashOutput(request.ctx.sessionManager.getSessionId());
|
|
408
393
|
const { onUpdate } = request;
|
|
409
394
|
|
|
@@ -420,8 +405,12 @@ export class BwrapRuntime {
|
|
|
420
405
|
);
|
|
421
406
|
|
|
422
407
|
try {
|
|
423
|
-
|
|
424
|
-
const { exitCode } = await
|
|
408
|
+
onUpdate?.({ content: [], details: undefined });
|
|
409
|
+
const { exitCode } = await runInSandbox(runtime, {
|
|
410
|
+
workspace,
|
|
411
|
+
commandCwd: execCwd,
|
|
412
|
+
command: request.command,
|
|
413
|
+
unsandboxed: local,
|
|
425
414
|
onData: (data) => {
|
|
426
415
|
output.append(data);
|
|
427
416
|
emitUpdate();
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纯沙箱执行层:加载配置 → 解析执行策略 → 需要时现建 netns 网络栈 → 在 bwrap 里跑命令。
|
|
3
|
+
*
|
|
4
|
+
* 这里刻意不含 UI、审批与会话上下文(那些属于扩展层 runtime.ts):
|
|
5
|
+
* pi 的 Bash 工具、`node bin/sandbox.ts` 和集成测试共用这条路径,
|
|
6
|
+
* 因此沙箱行为可以脱离 agent 循环单独复现和验证。
|
|
7
|
+
*
|
|
8
|
+
* 唯一的调用方交互是 `onData`(流式输出)与 `signal` / `timeout`(取消);
|
|
9
|
+
* `log` / `onNetworkStack` 是诊断出口,用于看 holder(unshare + mihomo)与
|
|
10
|
+
* slirp4netns 的原始日志——网络栈起不来时只有它们能说明原因。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
import { expandHome } from "../lib/path.js";
|
|
18
|
+
import {
|
|
19
|
+
buildBwrapInvocation,
|
|
20
|
+
bwrapArgv,
|
|
21
|
+
type BwrapConfig,
|
|
22
|
+
type BwrapMode,
|
|
23
|
+
createBwrapBashOperations,
|
|
24
|
+
createNetworkStack,
|
|
25
|
+
getBwrapConfigPaths,
|
|
26
|
+
loadBwrapConfig,
|
|
27
|
+
type NetworkStackLog,
|
|
28
|
+
resolveBwrap,
|
|
29
|
+
resolveBwrapPath,
|
|
30
|
+
type ResolvedBwrap,
|
|
31
|
+
resolveHeadlessBwrap,
|
|
32
|
+
} from "./core.js";
|
|
33
|
+
import type { NetworkStack } from "./network-stack.js";
|
|
34
|
+
|
|
35
|
+
export interface SandboxConfigInput {
|
|
36
|
+
/** 沙箱工作区:默认配置文件的查找目录,也是 writablePaths 中 "." 的解析基准。 */
|
|
37
|
+
workspace: string;
|
|
38
|
+
/** 只使用该配置文件(跳过全局 + 项目两级合并)。 */
|
|
39
|
+
configPath?: string;
|
|
40
|
+
/** 覆盖配置中的 mode(等价于会话内切 /bwrap 模式)。 */
|
|
41
|
+
mode?: BwrapMode;
|
|
42
|
+
/** 无 UI 会话策略:无论配置如何都强制 readonly。 */
|
|
43
|
+
headless?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SandboxRunOptions {
|
|
47
|
+
/** 沙箱工作区(可写边界与 .git 只读保护的基准),与命令执行目录解耦。 */
|
|
48
|
+
workspace: string;
|
|
49
|
+
/** 交给 `shell -lc` 的命令字符串。 */
|
|
50
|
+
command: string;
|
|
51
|
+
/** 命令执行目录,缺省同 workspace。 */
|
|
52
|
+
commandCwd?: string;
|
|
53
|
+
/** 流式输出回调(stdout 与 stderr 已合并)。 */
|
|
54
|
+
onData: (data: Buffer) => void;
|
|
55
|
+
/** 取消执行:kill 整个进程组并以 AbortError 结束。 */
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
/** 超时秒数:kill 整个进程组并以 TimeoutError 结束。 */
|
|
58
|
+
timeout?: number;
|
|
59
|
+
/** 调用方已决定不经沙箱(审批通过的全权限、Windows)。allow-all 模式无需显式设置。 */
|
|
60
|
+
unsandboxed?: boolean;
|
|
61
|
+
/** 网络栈子进程输出转发,仅诊断用(默认丢弃)。 */
|
|
62
|
+
log?: NetworkStackLog;
|
|
63
|
+
/** 网络栈就绪回调:取 holder pid 与 mihomo 配置路径手动排查。 */
|
|
64
|
+
onNetworkStack?: (stack: NetworkStack) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SandboxRunResult {
|
|
68
|
+
/** 命令退出码;被信号杀死时为 null。 */
|
|
69
|
+
exitCode: number | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 把 "."/"~" 形式的路径收敛成绝对路径,使执行策略自成一体、可断言。 */
|
|
73
|
+
function resolveSandboxPaths(resolved: ResolvedBwrap, workspace: string): ResolvedBwrap {
|
|
74
|
+
const expand = (paths: string[]): string[] =>
|
|
75
|
+
paths.map((path) => resolveBwrapPath(path, workspace));
|
|
76
|
+
return {
|
|
77
|
+
...resolved,
|
|
78
|
+
writablePaths: expand(resolved.writablePaths),
|
|
79
|
+
extraWritablePaths: expand(resolved.extraWritablePaths),
|
|
80
|
+
denyPaths: expand(resolved.denyPaths),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 加载 bwrap 配置并解析成执行策略(配置文件缺失字段回落到默认值)。 */
|
|
85
|
+
export function loadSandboxConfig(input: SandboxConfigInput): ResolvedBwrap {
|
|
86
|
+
const workspace = expandHome(input.workspace);
|
|
87
|
+
const configPath = input.configPath === undefined ? undefined : expandHome(input.configPath);
|
|
88
|
+
// 显式指定的配置文件必须存在:拼错路径时静默回落到默认值会让调试结论失效
|
|
89
|
+
if (configPath !== undefined && !existsSync(configPath)) {
|
|
90
|
+
throw new Error(`bwrap configuration file not found: ${configPath}`);
|
|
91
|
+
}
|
|
92
|
+
const paths =
|
|
93
|
+
configPath === undefined
|
|
94
|
+
? getBwrapConfigPaths(workspace)
|
|
95
|
+
: // 同一文件两侧:loadBwrapConfig 内部去重,等价于「只读这一个文件」
|
|
96
|
+
{ global: configPath, project: configPath };
|
|
97
|
+
const config: BwrapConfig = loadBwrapConfig(workspace, paths);
|
|
98
|
+
const configured: BwrapConfig =
|
|
99
|
+
input.mode === undefined ? config : { ...config, mode: input.mode };
|
|
100
|
+
const resolved =
|
|
101
|
+
input.headless === true ? resolveHeadlessBwrap(configured) : resolveBwrap(configured);
|
|
102
|
+
return resolveSandboxPaths(resolved, workspace);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 在 resolved 描述的沙箱里执行一条命令;net-allowlist 模式下现建现停网络栈。
|
|
107
|
+
* 超时与取消的错误语义由底层 operations.exec 抛出(TimeoutError / AbortError)。
|
|
108
|
+
*/
|
|
109
|
+
export async function runInSandbox(
|
|
110
|
+
resolved: ResolvedBwrap,
|
|
111
|
+
options: SandboxRunOptions,
|
|
112
|
+
): Promise<SandboxRunResult> {
|
|
113
|
+
const workspace = expandHome(options.workspace);
|
|
114
|
+
const commandCwd = expandHome(options.commandCwd ?? workspace);
|
|
115
|
+
const local = options.unsandboxed === true || !resolved.bwrapEnabled;
|
|
116
|
+
// 每次执行现建网络栈(启动约 140ms),作用域结束即停栈:allowlist 变更即时生效
|
|
117
|
+
const stack = local ? undefined : await createNetworkStack(resolved, options.log);
|
|
118
|
+
try {
|
|
119
|
+
if (stack) {
|
|
120
|
+
options.onNetworkStack?.(stack);
|
|
121
|
+
}
|
|
122
|
+
const operations = local
|
|
123
|
+
? createLocalBashOperations()
|
|
124
|
+
: createBwrapBashOperations(resolved, workspace, stack);
|
|
125
|
+
const { exitCode } = await operations.exec(options.command, commandCwd, {
|
|
126
|
+
onData: options.onData,
|
|
127
|
+
signal: options.signal,
|
|
128
|
+
timeout: options.timeout,
|
|
129
|
+
});
|
|
130
|
+
return { exitCode };
|
|
131
|
+
} finally {
|
|
132
|
+
try {
|
|
133
|
+
await stack?.stop();
|
|
134
|
+
} catch {
|
|
135
|
+
// best-effort:停栈失败(进程已退出/目录删除失败)不掩盖命令结果
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 一步式入口:按配置加载策略并在对应沙箱里执行命令(CLI 与集成测试用)。 */
|
|
141
|
+
export async function runSandboxCommand(
|
|
142
|
+
options: SandboxConfigInput & Omit<SandboxRunOptions, "unsandboxed">,
|
|
143
|
+
): Promise<SandboxRunResult> {
|
|
144
|
+
return runInSandbox(loadSandboxConfig(options), options);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** holder 尚未启动时,预览 argv 中标记 holder pid 位置的占位符。 */
|
|
148
|
+
export const HOLDER_PID_PLACEHOLDER = "<HOLDER_PID>";
|
|
149
|
+
|
|
150
|
+
export interface SandboxPreview {
|
|
151
|
+
/** 完整 argv,逐项给出(结构化输出,不拼 shell 文本,无需 quoting 即可读懂)。 */
|
|
152
|
+
argv: string[];
|
|
153
|
+
/** 沙箱内环境(不继承父进程)。 */
|
|
154
|
+
env: Record<string, string>;
|
|
155
|
+
/** 需要先有 netns holder 才能真正执行(此时 argv 中的 pid 可能是 HOLDER_PID_PLACEHOLDER)。 */
|
|
156
|
+
needsNetworkStack: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 打印将要执行的命令行(`--print-args`)。与 runInSandbox 共用同一段组装逻辑,
|
|
161
|
+
* 不存在「打印的是一回事、跑的是另一回事」的漂移。
|
|
162
|
+
*/
|
|
163
|
+
export async function previewSandboxCommand(
|
|
164
|
+
resolved: ResolvedBwrap,
|
|
165
|
+
options: Pick<SandboxRunOptions, "workspace" | "command" | "commandCwd" | "unsandboxed"> & {
|
|
166
|
+
holderPid?: number;
|
|
167
|
+
},
|
|
168
|
+
): Promise<SandboxPreview> {
|
|
169
|
+
const workspace = expandHome(options.workspace);
|
|
170
|
+
const commandCwd = expandHome(options.commandCwd ?? workspace);
|
|
171
|
+
const invocation = await buildBwrapInvocation(resolved, workspace, options.command, commandCwd);
|
|
172
|
+
if (!invocation.needsNetworkStack || options.unsandboxed === true) {
|
|
173
|
+
return { argv: bwrapArgv(invocation), env: invocation.env, needsNetworkStack: false };
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
// 与 network-stack.ts 的实际 spawn 一致;holder 未启动时用占位符标出 pid 的位置
|
|
177
|
+
argv: [
|
|
178
|
+
"nsenter",
|
|
179
|
+
"-U",
|
|
180
|
+
"-n",
|
|
181
|
+
"--preserve-credentials",
|
|
182
|
+
"-t",
|
|
183
|
+
options.holderPid === undefined ? HOLDER_PID_PLACEHOLDER : String(options.holderPid),
|
|
184
|
+
"--",
|
|
185
|
+
...bwrapArgv(invocation),
|
|
186
|
+
],
|
|
187
|
+
env: invocation.env,
|
|
188
|
+
needsNetworkStack: true,
|
|
189
|
+
};
|
|
190
|
+
}
|