@trim21/personal-pi-extensions 0.1.588 → 0.1.591
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/README.md +343 -192
- package/package.json +1 -1
- package/src/bwrap/README.md +3 -3
- package/src/bwrap/core.ts +123 -95
- package/src/bwrap/runtime.ts +135 -77
- package/src/bwrap/sandbox.ts +16 -7
- package/src/spawn-agent-agents.ts +6 -5
- package/src/spawn-agent.ts +40 -9
package/src/bwrap/runtime.ts
CHANGED
|
@@ -24,11 +24,12 @@ import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-r
|
|
|
24
24
|
import { commandPatternsFor } from "./approval-suggest.js";
|
|
25
25
|
import {
|
|
26
26
|
type BwrapConfig,
|
|
27
|
-
type BwrapMode,
|
|
28
27
|
findBwrap,
|
|
29
28
|
findMihomo,
|
|
29
|
+
type FsMode,
|
|
30
30
|
getBwrapConfigPaths,
|
|
31
31
|
loadBwrapConfig,
|
|
32
|
+
type NetworkMode,
|
|
32
33
|
resolveBwrap,
|
|
33
34
|
resolveBwrapPath,
|
|
34
35
|
type ResolvedBwrap,
|
|
@@ -260,18 +261,30 @@ class BashOutput {
|
|
|
260
261
|
}
|
|
261
262
|
}
|
|
262
263
|
|
|
263
|
-
|
|
264
|
+
/** 模式切换的两轴变更:fs / network 各自独立,至少给一个。 */
|
|
265
|
+
export interface SandboxChange {
|
|
266
|
+
fs?: FsMode;
|
|
267
|
+
network?: NetworkMode;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const FS_LABELS: Record<FsMode, string> = {
|
|
271
|
+
readonly: "fs readonly: read-only filesystem",
|
|
272
|
+
"workspace-write": "fs workspace-write: / is read-only, /tmp/ and the workspace are writable",
|
|
273
|
+
"allow-all": "fs allow-all: filesystem fully writable",
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const NETWORK_LABELS: Record<NetworkMode, string> = {
|
|
277
|
+
block: "network block: network off",
|
|
278
|
+
limited: "network limited: network filtered by allowlist",
|
|
279
|
+
"allow-all": "network allow-all: network unrestricted",
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
function notifyChange(
|
|
264
283
|
ctx: { ui: { notify: (message: string, type?: "info" | "warning" | "error") => void } },
|
|
265
|
-
|
|
284
|
+
change: SandboxChange,
|
|
266
285
|
): void {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
"workspace-write": "workspace-write: sandbox on, network off",
|
|
270
|
-
"allow-net": "allow-net: sandbox on, network on, workspace writable",
|
|
271
|
-
"net-allowlist": "net-allowlist: sandbox on, network filtered by allowlist",
|
|
272
|
-
readonly: "readonly: sandbox on, network off, read-only fs",
|
|
273
|
-
};
|
|
274
|
-
ctx.ui.notify(labels[mode], "info");
|
|
286
|
+
if (change.fs !== undefined) ctx.ui.notify(FS_LABELS[change.fs], "info");
|
|
287
|
+
if (change.network !== undefined) ctx.ui.notify(NETWORK_LABELS[change.network], "info");
|
|
275
288
|
}
|
|
276
289
|
|
|
277
290
|
/** 沙箱默认写边界:根只读、工作区与 /tmp 可写、.git 只读。不展开用户配置的额外可写路径。 */
|
|
@@ -279,15 +292,27 @@ const SANDBOX_WRITE_RULES = "/ is read-only, /tmp/ and ./ are writable, ./.git/
|
|
|
279
292
|
|
|
280
293
|
/** 网络层级:关 / 只放行白名单 / 完全放开(白名单域名本身不列出,对判断失败无用)。 */
|
|
281
294
|
function describeNetwork(resolved: ResolvedBwrap): string {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
295
|
+
switch (resolved.network) {
|
|
296
|
+
case "block": {
|
|
297
|
+
return "network access is off";
|
|
298
|
+
}
|
|
299
|
+
case "limited": {
|
|
300
|
+
return "network access is limited to a white list";
|
|
301
|
+
}
|
|
302
|
+
case "allow-all": {
|
|
303
|
+
return "network access is unrestricted";
|
|
304
|
+
}
|
|
305
|
+
}
|
|
286
306
|
}
|
|
287
307
|
|
|
288
308
|
/** 沙箱作用域一句话:写边界 + 网络层级(沙箱状态块与沙盒内执行提醒共用)。 */
|
|
289
309
|
function describeLimits(resolved: ResolvedBwrap): string {
|
|
290
|
-
const writes =
|
|
310
|
+
const writes =
|
|
311
|
+
resolved.fs === "readonly"
|
|
312
|
+
? "the filesystem is read-only"
|
|
313
|
+
: resolved.fs === "allow-all"
|
|
314
|
+
? "the filesystem is fully writable"
|
|
315
|
+
: SANDBOX_WRITE_RULES;
|
|
291
316
|
return `${writes}; ${describeNetwork(resolved)}`;
|
|
292
317
|
}
|
|
293
318
|
|
|
@@ -306,7 +331,7 @@ const SANDBOX_ESCAPE_HATCH =
|
|
|
306
331
|
* 命令失败时作为独立信息块附上的沙箱状态:默认写边界 + 网络层级,以及在沙盒外重跑的手段。
|
|
307
332
|
* 写边界只说沙箱布局(不展开用户配置的可写路径),网络只说层级(不列白名单域名)。
|
|
308
333
|
* 整块包在 `<system-reminder>` 里,与其它系统注入的提示同一形态,模型不会把它当成命令输出。
|
|
309
|
-
* 命令没经沙箱(allow-all、审批通过的全权限、Windows)时返回 undefined:
|
|
334
|
+
* 命令没经沙箱(fs 与 network 均 allow-all、审批通过的全权限、Windows)时返回 undefined:
|
|
310
335
|
* 没有沙箱就没什么可提示的。
|
|
311
336
|
*/
|
|
312
337
|
export function describeSandbox(
|
|
@@ -346,7 +371,7 @@ export function sandboxHintBlock(hint: string | undefined): { type: "text"; text
|
|
|
346
371
|
export class BwrapRuntime {
|
|
347
372
|
private resolved: ResolvedBwrap | undefined;
|
|
348
373
|
private bwrapUnavailable = false;
|
|
349
|
-
/**
|
|
374
|
+
/** network limited 首次执行时解析一次 mihomo 路径,之后随 runtime 复用,不逐命令扫描 PATH。 */
|
|
350
375
|
private mihomoPath: string | undefined;
|
|
351
376
|
/**
|
|
352
377
|
* 非沙盒请求策略由创建方注入:聚合入口把它与文件工具的 write-guard 共享同一实例,
|
|
@@ -382,8 +407,8 @@ export class BwrapRuntime {
|
|
|
382
407
|
findBwrap(runtime.bwrapPath);
|
|
383
408
|
} catch (error) {
|
|
384
409
|
// Fail closed: a missing bwrap binary must not silently degrade to an
|
|
385
|
-
// unsandboxed
|
|
386
|
-
//
|
|
410
|
+
// unsandboxed session. Commands are refused until the user explicitly
|
|
411
|
+
// opts out via /bwrap-fs-allow-all and /bwrap-network-allow-all.
|
|
387
412
|
this.bwrapUnavailable = true;
|
|
388
413
|
this.resolved = undefined;
|
|
389
414
|
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("error", "bwrap: unavailable"));
|
|
@@ -391,11 +416,11 @@ export class BwrapRuntime {
|
|
|
391
416
|
return;
|
|
392
417
|
}
|
|
393
418
|
}
|
|
394
|
-
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime
|
|
419
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime)));
|
|
395
420
|
ctx.ui.notify(
|
|
396
421
|
runtime.bwrapEnabled
|
|
397
|
-
? `bwrap initialized (${runtime
|
|
398
|
-
: `bwrap
|
|
422
|
+
? `bwrap initialized (${this.modeText(runtime)})`
|
|
423
|
+
: `bwrap sandbox off (${this.modeText(runtime)})`,
|
|
399
424
|
"info",
|
|
400
425
|
);
|
|
401
426
|
});
|
|
@@ -412,12 +437,12 @@ export class BwrapRuntime {
|
|
|
412
437
|
if (isWindows) {
|
|
413
438
|
modeText = "Every bash command requires user approval before it runs.";
|
|
414
439
|
} else if (this.config === undefined) {
|
|
415
|
-
modeText = `Current bwrap
|
|
440
|
+
modeText = `Current bwrap sandbox: filesystem **${runtime.fs}**, network **${runtime.network}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.`;
|
|
416
441
|
} else {
|
|
417
|
-
modeText = `Current bwrap
|
|
442
|
+
modeText = `Current bwrap sandbox: filesystem **${runtime.fs}**, network **${runtime.network}**, fixed by the agent's declared sandbox config. Unsandboxed execution requests are refused without approval.`;
|
|
418
443
|
}
|
|
419
444
|
} else {
|
|
420
|
-
modeText = `Current bwrap
|
|
445
|
+
modeText = `Current bwrap sandbox: filesystem **${runtime.fs}**, network **${runtime.network}**. This headless session cannot ask for approval: commands that require user approval are denied.`;
|
|
421
446
|
}
|
|
422
447
|
const unavailableText =
|
|
423
448
|
!isWindows && this.bwrapUnavailable
|
|
@@ -439,11 +464,23 @@ export class BwrapRuntime {
|
|
|
439
464
|
if (this.config === undefined) this.registerCommands(pi);
|
|
440
465
|
}
|
|
441
466
|
|
|
442
|
-
setMode(cwd: string,
|
|
443
|
-
this.
|
|
444
|
-
this.
|
|
445
|
-
|
|
446
|
-
|
|
467
|
+
setMode(cwd: string, change: SandboxChange): ResolvedBwrap {
|
|
468
|
+
if (this.config === undefined) {
|
|
469
|
+
this.resolved = loadSandboxConfig({
|
|
470
|
+
workspace: cwd,
|
|
471
|
+
...(change.fs !== undefined && { fsMode: change.fs }),
|
|
472
|
+
...(change.network !== undefined && { networkMode: change.network }),
|
|
473
|
+
});
|
|
474
|
+
} else {
|
|
475
|
+
this.resolved = resolveBwrap({
|
|
476
|
+
...this.config,
|
|
477
|
+
fs: { ...this.config.fs, ...(change.fs !== undefined && { mode: change.fs }) },
|
|
478
|
+
network: {
|
|
479
|
+
...this.config.network,
|
|
480
|
+
...(change.network !== undefined && { mode: change.network }),
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
}
|
|
447
484
|
return this.resolved;
|
|
448
485
|
}
|
|
449
486
|
|
|
@@ -465,7 +502,7 @@ export class BwrapRuntime {
|
|
|
465
502
|
) {
|
|
466
503
|
throw new Error(
|
|
467
504
|
"bwrap (bubblewrap) not found; refusing to execute commands without sandboxing. " +
|
|
468
|
-
"Install bubblewrap and restart the session, or disable
|
|
505
|
+
"Install bubblewrap and restart the session, or disable sandboxing explicitly with /bwrap-fs-allow-all and /bwrap-network-allow-all.",
|
|
469
506
|
);
|
|
470
507
|
}
|
|
471
508
|
// workspace 恒为 session 工作区;cwd 只是本次命令的进程执行目录,
|
|
@@ -473,7 +510,7 @@ export class BwrapRuntime {
|
|
|
473
510
|
const workspace = request.ctx.cwd;
|
|
474
511
|
const execCwd = request.cwd ?? workspace;
|
|
475
512
|
// 需要人工审批:非 Windows 仅 requestFullAccess;Windows 上默认所有命令
|
|
476
|
-
// (allow-all
|
|
513
|
+
// (fs 与 network 均 allow-all 是显式 opt-out,仍直接执行)。
|
|
477
514
|
const needsApproval = request.requestFullAccess === true || (isWindows && runtime.bwrapEnabled);
|
|
478
515
|
// 用户在审批框选「Run this in sandbox」拒绝提权:命令降级为沙盒内执行
|
|
479
516
|
let userChoseSandbox = false;
|
|
@@ -502,12 +539,12 @@ export class BwrapRuntime {
|
|
|
502
539
|
)) === "sandbox";
|
|
503
540
|
}
|
|
504
541
|
}
|
|
505
|
-
// 不经沙箱的三种情形:Windows(无 bubblewrap)、审批通过的全权限、
|
|
506
|
-
//
|
|
542
|
+
// 不经沙箱的三种情形:Windows(无 bubblewrap)、审批通过的全权限、fs 与 network 均
|
|
543
|
+
// allow-all;例外是用户选「Run this in sandbox」:提权被拒,命令照常进沙箱
|
|
507
544
|
const local = !userChoseSandbox && (isWindows || needsApproval || !runtime.bwrapEnabled);
|
|
508
|
-
// mihomoPath 是 ResolvedBwrap 的 override 语义:首次
|
|
545
|
+
// mihomoPath 是 ResolvedBwrap 的 override 语义:首次 limited 执行时解析
|
|
509
546
|
// 一次存入私有字段,之后写回 resolved 直达 createNetworkStack,不逐命令扫描 PATH
|
|
510
|
-
if (runtime.network
|
|
547
|
+
if (runtime.network === "limited") {
|
|
511
548
|
runtime.mihomoPath ??= this.mihomoPath ?? findMihomo();
|
|
512
549
|
this.mihomoPath = runtime.mihomoPath;
|
|
513
550
|
}
|
|
@@ -813,34 +850,40 @@ export class BwrapRuntime {
|
|
|
813
850
|
description: "Show bwrap sandbox configuration",
|
|
814
851
|
flags: Type.Object({}),
|
|
815
852
|
},
|
|
816
|
-
"bwrap-
|
|
817
|
-
name: "bwrap-
|
|
853
|
+
"bwrap-fs-readonly": {
|
|
854
|
+
name: "bwrap-fs-readonly",
|
|
855
|
+
usage: "",
|
|
856
|
+
description: "Filesystem: read-only (no writes)",
|
|
857
|
+
flags: Type.Object({}),
|
|
858
|
+
},
|
|
859
|
+
"bwrap-fs-workspace-write": {
|
|
860
|
+
name: "bwrap-fs-workspace-write",
|
|
818
861
|
usage: "",
|
|
819
|
-
description: "
|
|
862
|
+
description: "Filesystem: / read-only, /tmp and workspace writable",
|
|
820
863
|
flags: Type.Object({}),
|
|
821
864
|
},
|
|
822
|
-
"bwrap-
|
|
823
|
-
name: "bwrap-
|
|
865
|
+
"bwrap-fs-allow-all": {
|
|
866
|
+
name: "bwrap-fs-allow-all",
|
|
824
867
|
usage: "",
|
|
825
|
-
description: "
|
|
868
|
+
description: "Filesystem: fully writable",
|
|
826
869
|
flags: Type.Object({}),
|
|
827
870
|
},
|
|
828
|
-
"bwrap-
|
|
829
|
-
name: "bwrap-
|
|
871
|
+
"bwrap-network-block": {
|
|
872
|
+
name: "bwrap-network-block",
|
|
830
873
|
usage: "",
|
|
831
|
-
description: "
|
|
874
|
+
description: "Network: off",
|
|
832
875
|
flags: Type.Object({}),
|
|
833
876
|
},
|
|
834
|
-
"bwrap-
|
|
835
|
-
name: "bwrap-
|
|
877
|
+
"bwrap-network-limited": {
|
|
878
|
+
name: "bwrap-network-limited",
|
|
836
879
|
usage: "",
|
|
837
|
-
description: "
|
|
880
|
+
description: "Network: only allowlisted addresses",
|
|
838
881
|
flags: Type.Object({}),
|
|
839
882
|
},
|
|
840
|
-
"bwrap-
|
|
841
|
-
name: "bwrap-
|
|
883
|
+
"bwrap-network-allow-all": {
|
|
884
|
+
name: "bwrap-network-allow-all",
|
|
842
885
|
usage: "",
|
|
843
|
-
description: "
|
|
886
|
+
description: "Network: unrestricted",
|
|
844
887
|
flags: Type.Object({}),
|
|
845
888
|
},
|
|
846
889
|
"bwrap-reload": {
|
|
@@ -876,7 +919,7 @@ export class BwrapRuntime {
|
|
|
876
919
|
return;
|
|
877
920
|
}
|
|
878
921
|
if (!runtime.bwrapEnabled) {
|
|
879
|
-
commandCtx.ui.notify(`bwrap
|
|
922
|
+
commandCtx.ui.notify(`bwrap sandbox off (${this.modeText(runtime)})`, "info");
|
|
880
923
|
return;
|
|
881
924
|
}
|
|
882
925
|
const writable = runtime.writablePaths.map((path) =>
|
|
@@ -884,24 +927,25 @@ export class BwrapRuntime {
|
|
|
884
927
|
);
|
|
885
928
|
const deny = runtime.denyPaths.map((path) => resolveBwrapPath(path, commandCtx.cwd));
|
|
886
929
|
commandCtx.ui.notify(
|
|
887
|
-
`bwrap
|
|
930
|
+
`bwrap fs:${runtime.fs} net:${runtime.network} write:[${writable.join(", ")}] deny:[${deny.join(", ") || "-"}]`,
|
|
888
931
|
"info",
|
|
889
932
|
);
|
|
890
933
|
}),
|
|
891
934
|
});
|
|
892
935
|
|
|
893
|
-
for (const [name,
|
|
894
|
-
["bwrap-
|
|
895
|
-
["bwrap-workspace-write", "workspace-write"],
|
|
896
|
-
["bwrap-allow-
|
|
897
|
-
["bwrap-
|
|
898
|
-
["bwrap-
|
|
936
|
+
for (const [name, change] of [
|
|
937
|
+
["bwrap-fs-readonly", { fs: "readonly" }],
|
|
938
|
+
["bwrap-fs-workspace-write", { fs: "workspace-write" }],
|
|
939
|
+
["bwrap-fs-allow-all", { fs: "allow-all" }],
|
|
940
|
+
["bwrap-network-block", { network: "block" }],
|
|
941
|
+
["bwrap-network-limited", { network: "limited" }],
|
|
942
|
+
["bwrap-network-allow-all", { network: "allow-all" }],
|
|
899
943
|
] as const) {
|
|
900
944
|
pi.registerCommand(name, {
|
|
901
945
|
description: specs[name].description,
|
|
902
946
|
handler: (args, ctx) =>
|
|
903
947
|
this.runCommand(pi, specs[name], args, ctx, (commandCtx) =>
|
|
904
|
-
this.switchMode(pi,
|
|
948
|
+
this.switchMode(pi, change, commandCtx),
|
|
905
949
|
),
|
|
906
950
|
});
|
|
907
951
|
}
|
|
@@ -932,28 +976,42 @@ export class BwrapRuntime {
|
|
|
932
976
|
this.resolved = undefined;
|
|
933
977
|
this.bwrapUnavailable = false;
|
|
934
978
|
const runtime = this.resolve(ctx);
|
|
935
|
-
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime
|
|
936
|
-
ctx.ui.notify(`bwrap config reloaded (
|
|
979
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime)));
|
|
980
|
+
ctx.ui.notify(`bwrap config reloaded (${this.modeText(runtime)})`, "info");
|
|
937
981
|
}
|
|
938
982
|
|
|
939
|
-
private switchMode(pi: ExtensionAPI,
|
|
983
|
+
private switchMode(pi: ExtensionAPI, change: SandboxChange, ctx: ExtensionCommandContext): void {
|
|
940
984
|
if (!ctx.hasUI) {
|
|
941
|
-
ctx.ui.notify("bwrap
|
|
985
|
+
ctx.ui.notify("bwrap modes cannot be changed without an interactive UI", "warning");
|
|
942
986
|
return;
|
|
943
987
|
}
|
|
944
|
-
this.setMode(ctx.cwd,
|
|
945
|
-
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
988
|
+
const runtime = this.setMode(ctx.cwd, change);
|
|
989
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime)));
|
|
990
|
+
notifyChange(ctx, change);
|
|
991
|
+
if (change.fs !== undefined) {
|
|
992
|
+
pi.sendMessage({
|
|
993
|
+
customType: "info",
|
|
994
|
+
content: `Bwrap filesystem mode changed to "${change.fs}".`,
|
|
995
|
+
display: true,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
if (change.network !== undefined) {
|
|
999
|
+
pi.sendMessage({
|
|
1000
|
+
customType: "info",
|
|
1001
|
+
content: `Bwrap network mode changed to "${change.network}".`,
|
|
1002
|
+
display: true,
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** 模式文案:fs 与 network 两轴。 */
|
|
1008
|
+
private modeText(runtime: ResolvedBwrap): string {
|
|
1009
|
+
return `fs: ${runtime.fs}, network: ${runtime.network}`;
|
|
952
1010
|
}
|
|
953
1011
|
|
|
954
|
-
/**
|
|
955
|
-
private statusLabel(
|
|
956
|
-
return `bwrap:
|
|
1012
|
+
/** 状态行文案:两轴模式 + 非沙盒请求策略(拒绝时标注,便于解释模型的请求为何被拒)。 */
|
|
1013
|
+
private statusLabel(runtime: ResolvedBwrap): string {
|
|
1014
|
+
return `bwrap: fs=${runtime.fs} net=${runtime.network}${this.policy.deniesRequests() ? " (requests denied)" : ""}`;
|
|
957
1015
|
}
|
|
958
1016
|
|
|
959
1017
|
/**
|
|
@@ -970,7 +1028,7 @@ export class BwrapRuntime {
|
|
|
970
1028
|
);
|
|
971
1029
|
const runtime = this.resolve(ctx);
|
|
972
1030
|
if (ctx.hasUI && runtime.bwrapEnabled && !this.bwrapUnavailable) {
|
|
973
|
-
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime
|
|
1031
|
+
ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("accent", this.statusLabel(runtime)));
|
|
974
1032
|
}
|
|
975
1033
|
}
|
|
976
1034
|
|
package/src/bwrap/sandbox.ts
CHANGED
|
@@ -19,11 +19,12 @@ import {
|
|
|
19
19
|
buildBwrapInvocation,
|
|
20
20
|
bwrapArgv,
|
|
21
21
|
type BwrapConfig,
|
|
22
|
-
type BwrapMode,
|
|
23
22
|
createBwrapBashOperations,
|
|
24
23
|
createNetworkStack,
|
|
24
|
+
type FsMode,
|
|
25
25
|
getBwrapConfigPaths,
|
|
26
26
|
loadBwrapConfig,
|
|
27
|
+
type NetworkMode,
|
|
27
28
|
type NetworkStackLog,
|
|
28
29
|
resolveBwrap,
|
|
29
30
|
resolveBwrapPath,
|
|
@@ -36,8 +37,10 @@ export interface SandboxConfigInput {
|
|
|
36
37
|
workspace: string;
|
|
37
38
|
/** 只使用该配置文件(跳过全局 + 项目两级合并)。 */
|
|
38
39
|
configPath?: string;
|
|
39
|
-
/** 覆盖配置中的 mode(等价于会话内切 /bwrap 模式)。 */
|
|
40
|
-
|
|
40
|
+
/** 覆盖配置中的 fs.mode(等价于会话内切 /bwrap-fs-* 模式)。 */
|
|
41
|
+
fsMode?: FsMode;
|
|
42
|
+
/** 覆盖配置中的 network.mode(等价于会话内切 /bwrap-network-* 模式)。 */
|
|
43
|
+
networkMode?: NetworkMode;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
export interface SandboxRunOptions {
|
|
@@ -53,7 +56,7 @@ export interface SandboxRunOptions {
|
|
|
53
56
|
signal?: AbortSignal;
|
|
54
57
|
/** 超时秒数:kill 整个进程组并以 TimeoutError 结束。 */
|
|
55
58
|
timeout?: number;
|
|
56
|
-
/** 调用方已决定不经沙箱(审批通过的全权限、Windows)。allow-all
|
|
59
|
+
/** 调用方已决定不经沙箱(审批通过的全权限、Windows)。fs 与 network 均 allow-all 时无需显式设置。 */
|
|
57
60
|
unsandboxed?: boolean;
|
|
58
61
|
/** 网络栈子进程输出转发,仅诊断用(默认丢弃)。 */
|
|
59
62
|
log?: NetworkStackLog;
|
|
@@ -92,13 +95,19 @@ export function loadSandboxConfig(input: SandboxConfigInput): ResolvedBwrap {
|
|
|
92
95
|
: // 同一文件两侧:loadBwrapConfig 内部去重,等价于「只读这一个文件」
|
|
93
96
|
{ global: configPath, project: configPath };
|
|
94
97
|
const config: BwrapConfig = loadBwrapConfig(workspace, paths);
|
|
95
|
-
const configured: BwrapConfig =
|
|
96
|
-
|
|
98
|
+
const configured: BwrapConfig = {
|
|
99
|
+
...config,
|
|
100
|
+
fs: { ...config.fs, ...(input.fsMode !== undefined && { mode: input.fsMode }) },
|
|
101
|
+
network: {
|
|
102
|
+
...config.network,
|
|
103
|
+
...(input.networkMode !== undefined && { mode: input.networkMode }),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
97
106
|
return resolveSandboxPaths(resolveBwrap(configured), workspace);
|
|
98
107
|
}
|
|
99
108
|
|
|
100
109
|
/**
|
|
101
|
-
* 在 resolved 描述的沙箱里执行一条命令;
|
|
110
|
+
* 在 resolved 描述的沙箱里执行一条命令;network limited 模式下现建现停网络栈。
|
|
102
111
|
* 超时与取消的错误语义由底层 operations.exec 抛出(TimeoutError / AbortError)。
|
|
103
112
|
*/
|
|
104
113
|
export async function runInSandbox(
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* model: claude-haiku-4-5 # optional; overrides the global default
|
|
18
18
|
* thinkingLevel: high # optional; overrides the global default
|
|
19
19
|
* sandbox: # optional; fixed bwrap sandbox for the bash tool
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* fs:
|
|
21
|
+
* mode: readonly
|
|
22
|
+
* extraWritablePaths:
|
|
23
|
+
* - /tmp
|
|
23
24
|
* ---
|
|
24
25
|
* System prompt for the agent goes here.
|
|
25
26
|
*
|
|
@@ -34,8 +35,8 @@
|
|
|
34
35
|
* so the user's bwrap.json is not consulted for that agent. Unsandboxed
|
|
35
36
|
* execution requests are refused and /bwrap-* mode commands are not registered.
|
|
36
37
|
* Fields are optional and fall back to the standard bwrap defaults (e.g.
|
|
37
|
-
* `sandbox: {mode: readonly}` alone is enough for a read-only shell);
|
|
38
|
-
* `extraWritablePaths` is the writable escape hatch under a read-only mode.
|
|
38
|
+
* `sandbox: { fs: { mode: readonly } }` alone is enough for a read-only shell);
|
|
39
|
+
* `fs.extraWritablePaths` is the writable escape hatch under a read-only fs mode.
|
|
39
40
|
* Without `sandbox`, the subagent's bash follows the user bwrap config.
|
|
40
41
|
*
|
|
41
42
|
* Global defaults for provider/model/thinkingLevel come from
|
package/src/spawn-agent.ts
CHANGED
|
@@ -22,14 +22,17 @@
|
|
|
22
22
|
* so one log entry is always exactly one rendered line. The final line is
|
|
23
23
|
* always the subagent name as a code span (`` `scout` ``), followed by the
|
|
24
24
|
* live usage stats when there are any; it rides outside the rolling window so
|
|
25
|
-
* it is never trimmed.
|
|
25
|
+
* it is never trimmed. While the model is thinking, a transient
|
|
26
|
+
* `thinking ( N chars )` line sits between the log and the footer showing the
|
|
27
|
+
* live character count of the streamed thinking; it disappears when the
|
|
28
|
+
* thinking block ends.
|
|
26
29
|
*
|
|
27
30
|
* Security default: without an explicit `tools:` in the frontmatter, the
|
|
28
31
|
* subagent only gets read-only tools (read/grep/find/ls) — no bash/write/edit.
|
|
29
32
|
* A frontmatter `sandbox:` gives the bash tool a fixed bwrap sandbox config
|
|
30
|
-
* (bwrap.json shape, e.g. mode readonly): it is passed to the subagent's
|
|
31
|
-
* runtime as the complete config, unsandboxed execution requests are
|
|
32
|
-
* and /bwrap-*
|
|
33
|
+
* (bwrap.json shape, e.g. fs.mode readonly): it is passed to the subagent's
|
|
34
|
+
* bwrap runtime as the complete config, unsandboxed execution requests are
|
|
35
|
+
* refused, and /bwrap-* commands are not registered (see spawn-agent-agents.ts).
|
|
33
36
|
*/
|
|
34
37
|
|
|
35
38
|
import { existsSync } from "node:fs";
|
|
@@ -418,6 +421,9 @@ export async function runAgent(
|
|
|
418
421
|
// 不同名按调用顺序罗列;任何非工具行都会打断合并。
|
|
419
422
|
let toolLineSegments: string[] = [];
|
|
420
423
|
let toolLine: { name: string; count: number } | undefined;
|
|
424
|
+
// 思考中状态:thinking_start 打开、thinking_delta 累计字符数、thinking_end
|
|
425
|
+
// 关闭;非 undefined 时 emitUpdate 在 footer 上方插一行瞬态 thinking 状态。
|
|
426
|
+
let thinkingChars: number | undefined;
|
|
421
427
|
|
|
422
428
|
const pushLogLine = (line: string) => {
|
|
423
429
|
logLines.push(line);
|
|
@@ -460,8 +466,11 @@ export async function runAgent(
|
|
|
460
466
|
const usageLine = formatUsageStats(result.usage, result.model);
|
|
461
467
|
const name = sanitizeProgressLine(result.agent);
|
|
462
468
|
const footer = usageLine ? `\`${name}\` ${usageLine}` : `\`${name}\``;
|
|
469
|
+
const lines = [...logLines];
|
|
470
|
+
if (thinkingChars !== undefined) lines.push(`thinking ( ${thinkingChars} chars )`);
|
|
471
|
+
lines.push(footer);
|
|
463
472
|
onUpdate?.({
|
|
464
|
-
content: [{ type: "text", text:
|
|
473
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
465
474
|
details: {},
|
|
466
475
|
});
|
|
467
476
|
};
|
|
@@ -470,11 +479,33 @@ export async function runAgent(
|
|
|
470
479
|
switch (event.type) {
|
|
471
480
|
case "message_update": {
|
|
472
481
|
// A completed text block (text_end carries the full content) becomes a
|
|
473
|
-
// `text:` log line. Deltas
|
|
482
|
+
// `text:` log line. Deltas are intentionally not logged; thinking
|
|
483
|
+
// deltas feed the transient `thinking ( N chars )` status line instead.
|
|
474
484
|
const delta = event.assistantMessageEvent;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
485
|
+
switch (delta.type) {
|
|
486
|
+
case "text_end": {
|
|
487
|
+
pushLogLine(`text: ${foldProgressLine(sanitizeProgressLine(delta.content))}`);
|
|
488
|
+
emitUpdate();
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
case "thinking_start": {
|
|
492
|
+
// 思考横跨轮次边界:打断工具行合并,下一批工具调用另起一行。
|
|
493
|
+
toolLine = undefined;
|
|
494
|
+
thinkingChars = 0;
|
|
495
|
+
emitUpdate();
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
case "thinking_delta": {
|
|
499
|
+
thinkingChars = (thinkingChars ?? 0) + delta.delta.length;
|
|
500
|
+
emitUpdate();
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
case "thinking_end": {
|
|
504
|
+
thinkingChars = undefined;
|
|
505
|
+
emitUpdate();
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
// No default
|
|
478
509
|
}
|
|
479
510
|
|
|
480
511
|
break;
|