@trim21/personal-pi-extensions 0.0.431 → 0.0.433
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/core.ts +29 -15
- package/src/bwrap/holder.js +6 -6
- package/src/bwrap/holder.ts +8 -8
- package/src/bwrap/mihomo-config.ts +183 -0
- package/src/bwrap/network-stack.ts +35 -18
- package/src/bwrap/runtime.ts +73 -22
- package/src/claude-code/common.ts +0 -4
- package/src/claude-code/files.ts +9 -10
- package/src/claude-code/glob.ts +2 -3
- package/src/claude-code/grep.ts +2 -2
- package/src/claude-code/session-tools.ts +1 -1
- package/src/claude-code/shell.ts +40 -23
- package/src/lib/path.ts +1 -1
- package/src/opencode/bash.ts +29 -16
- package/src/bwrap/singbox-config.ts +0 -164
package/package.json
CHANGED
package/src/bwrap/core.ts
CHANGED
|
@@ -11,7 +11,12 @@ import { Value } from "typebox/value";
|
|
|
11
11
|
import { parseWithSchema } from "../lib/parse-with-schema.js";
|
|
12
12
|
import { expandHome } from "../lib/path.js";
|
|
13
13
|
import { type ApprovalRule } from "./approval-rules.js";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type NetworkStack,
|
|
16
|
+
resolveDnsServers,
|
|
17
|
+
startNetworkStack,
|
|
18
|
+
TimeoutError,
|
|
19
|
+
} from "./network-stack.js";
|
|
15
20
|
|
|
16
21
|
const PROTECTED_DIRS = [".pi", ".agent"];
|
|
17
22
|
|
|
@@ -40,7 +45,7 @@ const bwrapConfigProperties = {
|
|
|
40
45
|
networkAllowlist: Type.Array(
|
|
41
46
|
Type.String({ description: "允许直连的域名 / IP / CIDR,可带 :port" }),
|
|
42
47
|
),
|
|
43
|
-
|
|
48
|
+
mihomoPath: Type.Optional(Type.String()),
|
|
44
49
|
slirp4netnsPath: Type.Optional(Type.String()),
|
|
45
50
|
approvalRules: Type.Optional(
|
|
46
51
|
Type.Array(
|
|
@@ -78,9 +83,9 @@ export interface ResolvedBwrap {
|
|
|
78
83
|
/** 沙箱内隐藏的路径:以 / 结尾为目录(挂空 tmpfs),否则为文件(--ro-bind-try /dev/null)。 */
|
|
79
84
|
denyPaths: string[];
|
|
80
85
|
extraArgs: string[];
|
|
81
|
-
/** 允许直连的域名 / IP / IP:port 白名单(非空 = 启用
|
|
86
|
+
/** 允许直连的域名 / IP / IP:port 白名单(非空 = 启用 mihomo 网络过滤)。 */
|
|
82
87
|
networkAllowlist: string[];
|
|
83
|
-
|
|
88
|
+
mihomoPath?: string;
|
|
84
89
|
slirp4netnsPath?: string;
|
|
85
90
|
/** 全权限执行的自动审批规则(allow/deny 命令模式)。 */
|
|
86
91
|
approvalRules: ApprovalRule[];
|
|
@@ -104,7 +109,7 @@ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
|
104
109
|
denyPaths: config.denyPaths ?? [],
|
|
105
110
|
extraArgs: config.extraArgs ?? [],
|
|
106
111
|
networkAllowlist: config.networkAllowlist ?? [],
|
|
107
|
-
|
|
112
|
+
mihomoPath: config.mihomoPath,
|
|
108
113
|
slirp4netnsPath: config.slirp4netnsPath,
|
|
109
114
|
approvalRules: config.approvalRules ?? [],
|
|
110
115
|
};
|
|
@@ -147,7 +152,7 @@ function deepMerge(base: BwrapConfig, overrides: Partial<BwrapConfig>): BwrapCon
|
|
|
147
152
|
denyPaths: overrides.denyPaths ?? base.denyPaths,
|
|
148
153
|
extraArgs: overrides.extraArgs ?? base.extraArgs,
|
|
149
154
|
networkAllowlist: overrides.networkAllowlist ?? base.networkAllowlist,
|
|
150
|
-
|
|
155
|
+
mihomoPath: overrides.mihomoPath ?? base.mihomoPath,
|
|
151
156
|
slirp4netnsPath: overrides.slirp4netnsPath ?? base.slirp4netnsPath,
|
|
152
157
|
approvalRules: [...(base.approvalRules ?? []), ...(overrides.approvalRules ?? [])],
|
|
153
158
|
};
|
|
@@ -234,16 +239,16 @@ function findCommandInPath(name: string, hint: string): string {
|
|
|
234
239
|
throw new Error(hint);
|
|
235
240
|
}
|
|
236
241
|
|
|
237
|
-
export function
|
|
242
|
+
export function findMihomo(override?: string): string {
|
|
238
243
|
if (override) {
|
|
239
244
|
if (!existsSync(override)) {
|
|
240
|
-
throw new Error(`
|
|
245
|
+
throw new Error(`mihomo not found at configured path: ${override}`);
|
|
241
246
|
}
|
|
242
247
|
return override;
|
|
243
248
|
}
|
|
244
249
|
return findCommandInPath(
|
|
245
|
-
"
|
|
246
|
-
"
|
|
250
|
+
"mihomo",
|
|
251
|
+
"mihomo not found in PATH. Install it from https://github.com/MetaCubeX/mihomo",
|
|
247
252
|
);
|
|
248
253
|
}
|
|
249
254
|
|
|
@@ -378,7 +383,7 @@ export async function createNetworkStack(
|
|
|
378
383
|
return startNetworkStack({
|
|
379
384
|
allowlist: resolved.networkAllowlist,
|
|
380
385
|
dnsServers: await resolveDnsServers(),
|
|
381
|
-
|
|
386
|
+
mihomoPath: findMihomo(resolved.mihomoPath),
|
|
382
387
|
slirp4netnsPath: findSlirp4netns(resolved.slirp4netnsPath),
|
|
383
388
|
});
|
|
384
389
|
}
|
|
@@ -399,7 +404,8 @@ export function createBwrapBashOperations(
|
|
|
399
404
|
await fsAccess(cwd, constants.F_OK).catch(() => {
|
|
400
405
|
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`);
|
|
401
406
|
});
|
|
402
|
-
|
|
407
|
+
// 已中断(signal.reason 是 name=AbortError 的 DOMException):直接抛,不再执行
|
|
408
|
+
signal?.throwIfAborted();
|
|
403
409
|
|
|
404
410
|
// 干净环境:不继承父进程 env/PATH,由 bash -lc 从 /etc/profile 与用户 profile 重建
|
|
405
411
|
const home = process.env.HOME;
|
|
@@ -470,9 +476,17 @@ export function createBwrapBashOperations(
|
|
|
470
476
|
child.once("close", (exitCode) => {
|
|
471
477
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
472
478
|
signal?.removeEventListener("abort", onAbort);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
479
|
+
// 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
|
|
480
|
+
if (signal?.aborted) {
|
|
481
|
+
reject(
|
|
482
|
+
signal.reason instanceof Error
|
|
483
|
+
? signal.reason
|
|
484
|
+
: new Error("The operation was aborted"),
|
|
485
|
+
);
|
|
486
|
+
} else if (timedOut) {
|
|
487
|
+
// 超时:name=TimeoutError(对齐标准错误分类)
|
|
488
|
+
reject(new TimeoutError(timeout));
|
|
489
|
+
} else resolve({ exitCode });
|
|
476
490
|
});
|
|
477
491
|
});
|
|
478
492
|
},
|
package/src/bwrap/holder.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
4
|
const args = process.argv.slice(2);
|
|
4
5
|
const configPath = args[0];
|
|
5
|
-
const
|
|
6
|
-
if (!configPath || !
|
|
6
|
+
const mihomoPath = args[1];
|
|
7
|
+
if (!configPath || !mihomoPath) {
|
|
7
8
|
process.exit(2);
|
|
8
9
|
}
|
|
9
10
|
function tap0Exists() {
|
|
@@ -22,12 +23,11 @@ async function waitFor(attempts, delayMs, predicate) {
|
|
|
22
23
|
}
|
|
23
24
|
async function main() {
|
|
24
25
|
await waitFor(100, 100, tap0Exists);
|
|
25
|
-
const
|
|
26
|
-
env: { ...process.env, ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER: "true" },
|
|
26
|
+
const mihomo = spawn(mihomoPath, ["-d", dirname(configPath), "-f", configPath], {
|
|
27
27
|
stdio: ["ignore", "inherit", "inherit"]
|
|
28
28
|
});
|
|
29
29
|
const stop = () => {
|
|
30
|
-
|
|
30
|
+
mihomo.kill("SIGTERM");
|
|
31
31
|
};
|
|
32
32
|
process.on("SIGTERM", () => {
|
|
33
33
|
stop();
|
|
@@ -37,7 +37,7 @@ async function main() {
|
|
|
37
37
|
stop();
|
|
38
38
|
process.exit(0);
|
|
39
39
|
});
|
|
40
|
-
await new Promise((resolve) =>
|
|
40
|
+
await new Promise((resolve) => mihomo.once("exit", () => resolve()));
|
|
41
41
|
}
|
|
42
42
|
try {
|
|
43
43
|
await main();
|
package/src/bwrap/holder.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/* eslint-disable no-console, unicorn/no-process-exit -- 独立 CLI 脚本,由 unshare 直接执行 */
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { dirname } from "node:path";
|
|
3
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
5
|
|
|
5
|
-
// 用法:unshare -Urn node holder.ts <config> <
|
|
6
|
+
// 用法:unshare -Urn node holder.ts <config> <mihomo>
|
|
6
7
|
const args = process.argv.slice(2);
|
|
7
8
|
const configPath = args[0];
|
|
8
|
-
const
|
|
9
|
-
if (!configPath || !
|
|
9
|
+
const mihomoPath = args[1];
|
|
10
|
+
if (!configPath || !mihomoPath) {
|
|
10
11
|
process.exit(2);
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -34,14 +35,13 @@ async function main(): Promise<void> {
|
|
|
34
35
|
// slirp4netns 由宿主侧 spawn,这里等它建好 tap0
|
|
35
36
|
await waitFor(100, 100, tap0Exists);
|
|
36
37
|
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
env: { ...process.env, ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER: "true" },
|
|
38
|
+
// mihomo 日志透传到 holder 的 stdout/stderr,宿主侧监听 "Tun adapter listening" 判定就绪
|
|
39
|
+
const mihomo = spawn(mihomoPath, ["-d", dirname(configPath), "-f", configPath], {
|
|
40
40
|
stdio: ["ignore", "inherit", "inherit"],
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
const stop = (): void => {
|
|
44
|
-
|
|
44
|
+
mihomo.kill("SIGTERM");
|
|
45
45
|
};
|
|
46
46
|
process.on("SIGTERM", () => {
|
|
47
47
|
stop();
|
|
@@ -52,7 +52,7 @@ async function main(): Promise<void> {
|
|
|
52
52
|
process.exit(0);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
await new Promise<void>((resolve) =>
|
|
55
|
+
await new Promise<void>((resolve) => mihomo.once("exit", () => resolve()));
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
try {
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const FAKEIP_RANGE = "198.18.0.1/16";
|
|
2
|
+
const TUN_MTU = 1500;
|
|
3
|
+
|
|
4
|
+
export interface MihomoConfigOptions {
|
|
5
|
+
/** 允许直连的条目列表(域名 / IP / CIDR,可带 :port;空列表 = 默认拒绝一切出网)。 */
|
|
6
|
+
readonly allowlist: readonly string[];
|
|
7
|
+
/** 真实 DNS 服务器地址列表(IPv4,UDP 53),按顺序 fallback,至少一个。 */
|
|
8
|
+
readonly dnsServers: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AllowlistEntry {
|
|
12
|
+
readonly host: string;
|
|
13
|
+
readonly port?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** mihomo 配置以 JSON 序列化输出(JSON 是 YAML 子集,-f 加载无差别)。 */
|
|
17
|
+
export interface MihomoConfig {
|
|
18
|
+
"mixed-port": 0;
|
|
19
|
+
mode: "rule";
|
|
20
|
+
"log-level": "info";
|
|
21
|
+
ipv6: false;
|
|
22
|
+
dns: {
|
|
23
|
+
enable: true;
|
|
24
|
+
ipv6: false;
|
|
25
|
+
"enhanced-mode": "fake-ip";
|
|
26
|
+
"fake-ip-range": string;
|
|
27
|
+
nameserver: string[];
|
|
28
|
+
"default-nameserver": string[];
|
|
29
|
+
"fake-ip-filter"?: string[];
|
|
30
|
+
};
|
|
31
|
+
tun: {
|
|
32
|
+
enable: true;
|
|
33
|
+
stack: "mixed";
|
|
34
|
+
mtu: number;
|
|
35
|
+
"auto-route": true;
|
|
36
|
+
"strict-route": true;
|
|
37
|
+
"auto-detect-interface": true;
|
|
38
|
+
"dns-hijack": string[];
|
|
39
|
+
};
|
|
40
|
+
rules: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}(?:\/\d{1,2})?$/;
|
|
44
|
+
/** 合法 DNS 主机名(标签 1-63 字符,字母数字加连字符,不得以连字符开头/结尾)。 */
|
|
45
|
+
const DOMAIN_PATTERN =
|
|
46
|
+
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
|
47
|
+
/** IP/CIDR 的字符集校验(IPv4/IPv6),防止非法字符进入规则。 */
|
|
48
|
+
const IP_CHARS_PATTERN = /^[0-9a-fA-F:.]+(?:\/\d{1,3})?$/;
|
|
49
|
+
|
|
50
|
+
function isIp(host: string): boolean {
|
|
51
|
+
return IPV4_PATTERN.test(host) || host.includes(":");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 单 IP 补掩码为 CIDR(IPv4 /32、IPv6 /128),带掩码原样返回。 */
|
|
55
|
+
function toCidr(host: string): string {
|
|
56
|
+
if (host.includes("/")) return host;
|
|
57
|
+
return host.includes(":") ? `${host}/128` : `${host}/32`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parsePort(value: string): number {
|
|
61
|
+
const port = Number(value);
|
|
62
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
63
|
+
throw new Error(`Invalid port "${value}"`);
|
|
64
|
+
}
|
|
65
|
+
return port;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 解析 allowlist 条目:域名 / IPv4 / CIDR,可带 :port;IPv6 必须用 [] 包裹
|
|
70
|
+
* (如 `[::1]:80`),裸 IPv6 会报错提示补方括号。
|
|
71
|
+
*/
|
|
72
|
+
function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
73
|
+
let host: string;
|
|
74
|
+
let port: number | undefined;
|
|
75
|
+
if (entry.startsWith("[")) {
|
|
76
|
+
const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
|
|
77
|
+
if (match?.[1] === undefined) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
78
|
+
host = match[1];
|
|
79
|
+
port = match[2] === undefined ? undefined : parsePort(match[2]);
|
|
80
|
+
} else {
|
|
81
|
+
const colon = entry.lastIndexOf(":");
|
|
82
|
+
if (colon === -1) {
|
|
83
|
+
if (entry.length === 0) throw new Error(`Invalid allowlist entry ""`);
|
|
84
|
+
host = entry;
|
|
85
|
+
} else {
|
|
86
|
+
const portPart = entry.slice(colon + 1);
|
|
87
|
+
if (!/^\d+$/.test(portPart)) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
88
|
+
host = entry.slice(0, colon);
|
|
89
|
+
if (host.length === 0) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
90
|
+
if (host.includes(":")) {
|
|
91
|
+
throw new Error(`IPv6 addresses must be wrapped in brackets, e.g. "[${host}]:${portPart}"`);
|
|
92
|
+
}
|
|
93
|
+
port = parsePort(portPart);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (isIp(host)) {
|
|
97
|
+
if (!IP_CHARS_PATTERN.test(host)) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
98
|
+
} else if (!DOMAIN_PATTERN.test(host)) {
|
|
99
|
+
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
100
|
+
}
|
|
101
|
+
return { host, port };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** IP 条目的 mihomo 规则(IPv6 用 IP-CIDR6;no-resolve 跳过反向解析)。 */
|
|
105
|
+
function ipRule(host: string): string {
|
|
106
|
+
const cidr = toCidr(host);
|
|
107
|
+
const kind = cidr.includes(":") ? "IP-CIDR6" : "IP-CIDR";
|
|
108
|
+
return `${kind},${cidr},DIRECT,no-resolve`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface BuiltRules {
|
|
112
|
+
rules: string[];
|
|
113
|
+
fakeIpFilter: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* allowlist 条目 → mihomo 规则:
|
|
118
|
+
* - 域名(含 :port 条目里的域名)进 fake-ip-filter,真实解析避免 DIRECT 出站
|
|
119
|
+
* 拿到 fakeip 再进 TUN 形成环(loopback detector 会拒绝);
|
|
120
|
+
* - 无端口条目直接匹配;带端口条目用 AND 组合(域名/IP + DST-PORT)精确放行;
|
|
121
|
+
* - 最后以 MATCH,REJECT 兜底实现 deny-by-default。
|
|
122
|
+
*/
|
|
123
|
+
function buildRules(allowlist: readonly string[]): BuiltRules {
|
|
124
|
+
const rules: string[] = [];
|
|
125
|
+
const fakeIpFilter: string[] = [];
|
|
126
|
+
for (const entry of allowlist) {
|
|
127
|
+
const { host, port } = parseAllowlistEntry(entry);
|
|
128
|
+
if (!isIp(host)) {
|
|
129
|
+
fakeIpFilter.push(`+.${host}`);
|
|
130
|
+
if (port === undefined) {
|
|
131
|
+
rules.push(`DOMAIN-SUFFIX,${host},DIRECT`);
|
|
132
|
+
} else {
|
|
133
|
+
rules.push(`AND,(DOMAIN-SUFFIX,${host},DIRECT),(DST-PORT,${port},DIRECT),DIRECT`);
|
|
134
|
+
}
|
|
135
|
+
} else if (port === undefined) {
|
|
136
|
+
rules.push(ipRule(host));
|
|
137
|
+
} else {
|
|
138
|
+
rules.push(`AND,(${ipRule(host)}),(DST-PORT,${port},DIRECT),DIRECT`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
rules.push("MATCH,REJECT");
|
|
142
|
+
return { rules, fakeIpFilter };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 生成 mihomo(Clash Meta)配置对象:TUN + fakeip + deny-by-default allowlist。
|
|
147
|
+
*
|
|
148
|
+
* - auto-detect-interface 让 mihomo 出站绑定 slirp4netns 的 tap0,否则它自己的
|
|
149
|
+
* DNS 查询会被 auto_route 送回 TUN 形成环;
|
|
150
|
+
* - allowlist 域名走 fake-ip-filter 真实解析(见 buildRules 注释)。
|
|
151
|
+
*/
|
|
152
|
+
export function generateMihomoConfig(options: MihomoConfigOptions): MihomoConfig {
|
|
153
|
+
const { allowlist, dnsServers } = options;
|
|
154
|
+
if (dnsServers.length === 0) {
|
|
155
|
+
throw new Error("At least one DNS server is required");
|
|
156
|
+
}
|
|
157
|
+
const { rules, fakeIpFilter } = buildRules(allowlist);
|
|
158
|
+
return {
|
|
159
|
+
"mixed-port": 0,
|
|
160
|
+
mode: "rule",
|
|
161
|
+
"log-level": "info",
|
|
162
|
+
ipv6: false,
|
|
163
|
+
dns: {
|
|
164
|
+
enable: true,
|
|
165
|
+
ipv6: false,
|
|
166
|
+
"enhanced-mode": "fake-ip",
|
|
167
|
+
"fake-ip-range": FAKEIP_RANGE,
|
|
168
|
+
nameserver: [...dnsServers],
|
|
169
|
+
"default-nameserver": [...dnsServers],
|
|
170
|
+
...(fakeIpFilter.length > 0 && { "fake-ip-filter": fakeIpFilter }),
|
|
171
|
+
},
|
|
172
|
+
tun: {
|
|
173
|
+
enable: true,
|
|
174
|
+
stack: "mixed",
|
|
175
|
+
mtu: TUN_MTU,
|
|
176
|
+
"auto-route": true,
|
|
177
|
+
"strict-route": true,
|
|
178
|
+
"auto-detect-interface": true,
|
|
179
|
+
"dns-hijack": ["any:53"],
|
|
180
|
+
},
|
|
181
|
+
rules,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -4,14 +4,22 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { generateMihomoConfig } from "./mihomo-config.js";
|
|
8
|
+
|
|
9
|
+
/** 命令超时错误:name=TimeoutError(对齐标准错误分类),message 保留 timeout:N 格式。 */
|
|
10
|
+
export class TimeoutError extends Error {
|
|
11
|
+
constructor(timeout: number | undefined) {
|
|
12
|
+
super(`timeout:${timeout}`);
|
|
13
|
+
this.name = "TimeoutError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
export interface NetworkStackOptions {
|
|
10
18
|
/** 允许直连的域名 / IP:port 列表(每次命令从配置重新读取)。 */
|
|
11
19
|
readonly allowlist: readonly string[];
|
|
12
20
|
/** 真实 DNS 服务器列表,按顺序 fallback。 */
|
|
13
21
|
readonly dnsServers: readonly string[];
|
|
14
|
-
readonly
|
|
22
|
+
readonly mihomoPath: string;
|
|
15
23
|
readonly slirp4netnsPath: string;
|
|
16
24
|
}
|
|
17
25
|
|
|
@@ -72,18 +80,18 @@ async function waitForNewUserns(pid: number, timeoutMs = 5000): Promise<void> {
|
|
|
72
80
|
throw new Error("Timed out waiting for sandbox network namespace");
|
|
73
81
|
}
|
|
74
82
|
|
|
75
|
-
/** 监听 holder 的 stdout/stderr(
|
|
76
|
-
function
|
|
83
|
+
/** 监听 holder 的 stdout/stderr(mihomo 日志透传),以 "Tun adapter listening" 作为就绪标志。 */
|
|
84
|
+
function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<void> {
|
|
77
85
|
return new Promise((resolve, reject) => {
|
|
78
86
|
let settled = false;
|
|
79
87
|
const timer = setTimeout(() => {
|
|
80
88
|
if (settled) return;
|
|
81
89
|
settled = true;
|
|
82
|
-
reject(new Error("Timed out waiting for
|
|
90
|
+
reject(new Error("Timed out waiting for mihomo to start"));
|
|
83
91
|
}, timeoutMs);
|
|
84
92
|
const onData = (data: Buffer): void => {
|
|
85
93
|
if (settled) return;
|
|
86
|
-
if (data.toString().includes("
|
|
94
|
+
if (data.toString().includes("Tun adapter listening")) {
|
|
87
95
|
settled = true;
|
|
88
96
|
clearTimeout(timer);
|
|
89
97
|
resolve();
|
|
@@ -95,7 +103,7 @@ function waitForSingboxStarted(holder: ChildProcess, timeoutMs = 20000): Promise
|
|
|
95
103
|
if (settled) return;
|
|
96
104
|
settled = true;
|
|
97
105
|
clearTimeout(timer);
|
|
98
|
-
reject(new Error(`Sandbox holder exited before
|
|
106
|
+
reject(new Error(`Sandbox holder exited before mihomo started (code ${code})`));
|
|
99
107
|
});
|
|
100
108
|
});
|
|
101
109
|
}
|
|
@@ -132,22 +140,23 @@ const stackFinalizer = new FinalizationRegistry<NetworkStackState>((state) => {
|
|
|
132
140
|
});
|
|
133
141
|
|
|
134
142
|
/**
|
|
135
|
-
* 启动网络栈:holder 进程持有 netns(内部跑
|
|
143
|
+
* 启动网络栈:holder 进程持有 netns(内部跑 mihomo 的
|
|
136
144
|
* TUN + fakeip + deny-by-default),slirp4netns 提供 egress。返回前
|
|
137
|
-
* netns/
|
|
145
|
+
* netns/mihomo/slirp4netns 均已就绪,命令通过 nsenter 进入该 netns 执行。
|
|
138
146
|
*/
|
|
139
147
|
export async function startNetworkStack(options: NetworkStackOptions): Promise<NetworkStack> {
|
|
140
|
-
const { allowlist, dnsServers,
|
|
148
|
+
const { allowlist, dnsServers, mihomoPath, slirp4netnsPath } = options;
|
|
141
149
|
const directory = await mkdtemp(join(tmpdir(), "pi-netns-"));
|
|
142
|
-
const configPath = join(directory, "
|
|
143
|
-
|
|
150
|
+
const configPath = join(directory, "mihomo.json");
|
|
151
|
+
const config = generateMihomoConfig({ allowlist, dnsServers });
|
|
152
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
144
153
|
|
|
145
|
-
const holder = spawn("unshare", ["-Urn", "--", "node", HOLDER_PATH, configPath,
|
|
154
|
+
const holder = spawn("unshare", ["-Urn", "--", "node", HOLDER_PATH, configPath, mihomoPath], {
|
|
146
155
|
env: {
|
|
147
156
|
HOME: process.env.HOME ?? "",
|
|
148
157
|
PATH: `${process.env.PATH ?? ""}:${SBIN_PATH_SUFFIX}`,
|
|
149
158
|
},
|
|
150
|
-
//
|
|
159
|
+
// mihomo 日志经 holder 透传到这里的 stdout/stderr,用于判定就绪
|
|
151
160
|
stdio: ["ignore", "pipe", "pipe"],
|
|
152
161
|
});
|
|
153
162
|
if (holder.pid === undefined) {
|
|
@@ -168,7 +177,7 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
168
177
|
{ stdio: "ignore" },
|
|
169
178
|
);
|
|
170
179
|
const slirpPid = slirp.pid;
|
|
171
|
-
await
|
|
180
|
+
await waitForMihomoStarted(holder);
|
|
172
181
|
|
|
173
182
|
const state: NetworkStackState = { holderPid, slirpPid, directory };
|
|
174
183
|
const stack: NetworkStack = {
|
|
@@ -224,9 +233,17 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
224
233
|
settled = true;
|
|
225
234
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
226
235
|
execOptions.signal?.removeEventListener("abort", onAbort);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
236
|
+
// 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
|
|
237
|
+
if (execOptions.signal?.aborted) {
|
|
238
|
+
reject(
|
|
239
|
+
execOptions.signal.reason instanceof Error
|
|
240
|
+
? execOptions.signal.reason
|
|
241
|
+
: new Error("The operation was aborted"),
|
|
242
|
+
);
|
|
243
|
+
} else if (timedOut) {
|
|
244
|
+
// 超时:name=TimeoutError(对齐标准错误分类)
|
|
245
|
+
reject(new TimeoutError(execOptions.timeout));
|
|
246
|
+
} else resolve({ exitCode });
|
|
230
247
|
});
|
|
231
248
|
});
|
|
232
249
|
},
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -108,6 +108,35 @@ export interface BwrapExecutionResult {
|
|
|
108
108
|
truncation: TruncationResult;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 超时/中断时命令终止前已捕获的部分输出快照(截断后的文本 + 落盘信息)。
|
|
113
|
+
* 展示格式(输出在前、状态在最后)由上层 Bash 工具按各自风格拼接。
|
|
114
|
+
*/
|
|
115
|
+
export interface BashExecutionPartial {
|
|
116
|
+
output: string;
|
|
117
|
+
truncation: TruncationResult;
|
|
118
|
+
fullOutputPath?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 命令超时或中断(abort signal)时抛出的错误,携带部分输出供上层展示。 */
|
|
122
|
+
export class BashInterruptedError extends Error {
|
|
123
|
+
readonly kind: "timeout" | "aborted";
|
|
124
|
+
readonly partial: BashExecutionPartial;
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
kind: "timeout" | "aborted",
|
|
128
|
+
message: string,
|
|
129
|
+
partial: BashExecutionPartial,
|
|
130
|
+
cause: unknown,
|
|
131
|
+
) {
|
|
132
|
+
super(message, { cause });
|
|
133
|
+
this.kind = kind;
|
|
134
|
+
this.partial = partial;
|
|
135
|
+
// 对齐标准错误分类:中断=AbortError(用户取消),超时=TimeoutError
|
|
136
|
+
this.name = kind === "aborted" ? "AbortError" : "TimeoutError";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
111
140
|
function escapeHtml(text: string): string {
|
|
112
141
|
return text
|
|
113
142
|
.replaceAll("&", "&")
|
|
@@ -402,35 +431,35 @@ export class BwrapRuntime {
|
|
|
402
431
|
signal: request.signal,
|
|
403
432
|
timeout: request.timeout,
|
|
404
433
|
});
|
|
405
|
-
await
|
|
406
|
-
const truncation = truncateTail(output.tailText());
|
|
407
|
-
// 未截断:完整输出已直接返回给模型,临时文件没有用途,删掉避免
|
|
408
|
-
// agent-dir/tmp 堆积无主文件(删除失败只残留文件,不影响命令结果)
|
|
409
|
-
if (!truncation.truncated && output.filePath) {
|
|
410
|
-
try {
|
|
411
|
-
await unlink(output.filePath);
|
|
412
|
-
} catch {
|
|
413
|
-
// 删除失败(如沙箱只读):best-effort,命令结果不受影响
|
|
414
|
-
}
|
|
415
|
-
output.filePath = undefined;
|
|
416
|
-
}
|
|
434
|
+
const partial = await this.finalizeOutput(output);
|
|
417
435
|
return {
|
|
418
436
|
exitCode,
|
|
419
|
-
output:
|
|
420
|
-
...(
|
|
421
|
-
|
|
422
|
-
truncation: { ...truncation, ...output.stats },
|
|
437
|
+
output: partial.output,
|
|
438
|
+
...(partial.fullOutputPath && { fullOutputPath: partial.fullOutputPath }),
|
|
439
|
+
truncation: partial.truncation,
|
|
423
440
|
};
|
|
424
441
|
} catch (error) {
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
442
|
+
// 超时/中断:把命令终止前已捕获的输出附在错误上(文本 + 落盘路径),
|
|
443
|
+
// 展示时输出在前、状态在最后(对齐 pi 内置 bash),避免只报超时丢输出
|
|
444
|
+
// 超时识别:优先 name=TimeoutError(对齐标准错误分类),
|
|
445
|
+
// 兼容 pi local ops 抛的 `timeout:N`(name=Error)
|
|
446
|
+
if (
|
|
447
|
+
error instanceof Error &&
|
|
448
|
+
(error.name === "TimeoutError" || error.message.startsWith("timeout:"))
|
|
449
|
+
) {
|
|
450
|
+
const partial = await this.finalizeOutput(output);
|
|
451
|
+
throw new BashInterruptedError(
|
|
452
|
+
"timeout",
|
|
428
453
|
`Command timed out after ${error.message.slice("timeout:".length)} seconds`,
|
|
429
|
-
|
|
454
|
+
partial,
|
|
455
|
+
error,
|
|
430
456
|
);
|
|
431
457
|
}
|
|
432
|
-
|
|
433
|
-
|
|
458
|
+
// 中断识别:优先 name=AbortError(throwIfAborted/signal.reason),
|
|
459
|
+
// 兼容 pi local ops 抛的 new Error("aborted")
|
|
460
|
+
if (error instanceof Error && (error.name === "AbortError" || error.message === "aborted")) {
|
|
461
|
+
const partial = await this.finalizeOutput(output);
|
|
462
|
+
throw new BashInterruptedError("aborted", "Command aborted", partial, error);
|
|
434
463
|
}
|
|
435
464
|
throw error;
|
|
436
465
|
} finally {
|
|
@@ -439,6 +468,28 @@ export class BwrapRuntime {
|
|
|
439
468
|
}
|
|
440
469
|
}
|
|
441
470
|
|
|
471
|
+
/** 关闭输出流并返回截断后的快照;未截断时删除临时文件(成功与超时/中断路径共用)。 */
|
|
472
|
+
private async finalizeOutput(output: BashOutput): Promise<BashExecutionPartial> {
|
|
473
|
+
await output.close();
|
|
474
|
+
const truncation = truncateTail(output.tailText());
|
|
475
|
+
// 未截断:完整输出已直接返回给模型,临时文件没有用途,删掉避免
|
|
476
|
+
// agent-dir/tmp 堆积无主文件(删除失败只残留文件,不影响命令结果)
|
|
477
|
+
if (!truncation.truncated && output.filePath) {
|
|
478
|
+
try {
|
|
479
|
+
await unlink(output.filePath);
|
|
480
|
+
} catch {
|
|
481
|
+
// 删除失败(如沙箱只读):best-effort,命令结果不受影响
|
|
482
|
+
}
|
|
483
|
+
output.filePath = undefined;
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
output: truncation.content,
|
|
487
|
+
...(output.filePath && { fullOutputPath: output.filePath }),
|
|
488
|
+
// 用精确统计值覆盖尾部缓冲的估算(提示文本的行数/字节数要准确)
|
|
489
|
+
truncation: { ...truncation, ...output.stats },
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
442
493
|
private resolve(ctx: Pick<ExtensionContext, "cwd" | "hasUI">): ResolvedBwrap {
|
|
443
494
|
const config = loadBwrapConfig(ctx.cwd);
|
|
444
495
|
if (!ctx.hasUI) return resolveHeadlessBwrap(config);
|
|
@@ -53,10 +53,6 @@ export function toRelativePath(filePath: string, cwd: string): string {
|
|
|
53
53
|
return relativePath.startsWith("..") ? filePath : relativePath;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
57
|
-
if (signal?.aborted) throw new Error("Operation aborted");
|
|
58
|
-
}
|
|
59
|
-
|
|
60
56
|
export function snapshotsEqual(left: FileSnapshot, right: FileSnapshot): boolean {
|
|
61
57
|
return left.digest === right.digest;
|
|
62
58
|
}
|
package/src/claude-code/files.ts
CHANGED
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
type FileSnapshot,
|
|
28
28
|
requireAbsolutePath,
|
|
29
29
|
snapshotsEqual,
|
|
30
|
-
throwIfAborted,
|
|
31
30
|
} from "./common.js";
|
|
32
31
|
import { convertLeadingTabsToSpaces, findActualString, preserveQuoteStyle } from "./edit-utils.js";
|
|
33
32
|
|
|
@@ -256,7 +255,7 @@ export function registerFileTools(
|
|
|
256
255
|
{ additionalProperties: false },
|
|
257
256
|
),
|
|
258
257
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
259
|
-
throwIfAborted(
|
|
258
|
+
signal?.throwIfAborted();
|
|
260
259
|
const filePath = requireAbsolutePath(params.file_path);
|
|
261
260
|
if (
|
|
262
261
|
params.offset !== undefined &&
|
|
@@ -280,7 +279,7 @@ export function registerFileTools(
|
|
|
280
279
|
}
|
|
281
280
|
throw error;
|
|
282
281
|
}
|
|
283
|
-
throwIfAborted(
|
|
282
|
+
signal?.throwIfAborted();
|
|
284
283
|
|
|
285
284
|
const extension = extname(filePath).toLowerCase();
|
|
286
285
|
if (extension === ".pdf") {
|
|
@@ -392,7 +391,7 @@ export function registerFileTools(
|
|
|
392
391
|
{ additionalProperties: false },
|
|
393
392
|
),
|
|
394
393
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
395
|
-
throwIfAborted(
|
|
394
|
+
signal?.throwIfAborted();
|
|
396
395
|
const filePath = requireAbsolutePath(params.file_path);
|
|
397
396
|
await guardWriteAccess(ctx, {
|
|
398
397
|
toolName: "Edit",
|
|
@@ -438,7 +437,7 @@ export function registerFileTools(
|
|
|
438
437
|
const key = await readStateKey(filePath);
|
|
439
438
|
state.reads.set(key, snapshot);
|
|
440
439
|
const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
|
|
441
|
-
throwIfAborted(
|
|
440
|
+
signal?.throwIfAborted();
|
|
442
441
|
const {
|
|
443
442
|
text: diagnosticText,
|
|
444
443
|
errorCount,
|
|
@@ -495,7 +494,7 @@ export function registerFileTools(
|
|
|
495
494
|
const key = await readStateKey(filePath);
|
|
496
495
|
requireCurrentRead(state, key, filePath, content);
|
|
497
496
|
await access(filePath, constants.R_OK | constants.W_OK);
|
|
498
|
-
throwIfAborted(
|
|
497
|
+
signal?.throwIfAborted();
|
|
499
498
|
const original = content.toString("utf8");
|
|
500
499
|
|
|
501
500
|
// CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
|
|
@@ -541,7 +540,7 @@ export function registerFileTools(
|
|
|
541
540
|
const text = replaceAll
|
|
542
541
|
? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
|
|
543
542
|
: `The file ${filePath} has been updated successfully.`;
|
|
544
|
-
throwIfAborted(
|
|
543
|
+
signal?.throwIfAborted();
|
|
545
544
|
const {
|
|
546
545
|
text: diagnosticText,
|
|
547
546
|
errorCount,
|
|
@@ -602,7 +601,7 @@ export function registerFileTools(
|
|
|
602
601
|
{ additionalProperties: false },
|
|
603
602
|
),
|
|
604
603
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
605
|
-
throwIfAborted(
|
|
604
|
+
signal?.throwIfAborted();
|
|
606
605
|
const filePath = requireAbsolutePath(params.file_path);
|
|
607
606
|
await guardWriteAccess(ctx, {
|
|
608
607
|
toolName: "Write",
|
|
@@ -628,7 +627,7 @@ export function registerFileTools(
|
|
|
628
627
|
throw error;
|
|
629
628
|
}
|
|
630
629
|
}
|
|
631
|
-
throwIfAborted(
|
|
630
|
+
signal?.throwIfAborted();
|
|
632
631
|
await mkdir(dirname(filePath), { recursive: true });
|
|
633
632
|
await writeFile(filePath, params.content, "utf8");
|
|
634
633
|
const snapshot = snapshotOf(params.content);
|
|
@@ -640,7 +639,7 @@ export function registerFileTools(
|
|
|
640
639
|
original === undefined
|
|
641
640
|
? `File created successfully at: ${filePath}`
|
|
642
641
|
: `The file ${filePath} has been updated successfully.`;
|
|
643
|
-
throwIfAborted(
|
|
642
|
+
signal?.throwIfAborted();
|
|
644
643
|
const {
|
|
645
644
|
text: diagnosticText,
|
|
646
645
|
errorCount,
|
package/src/claude-code/glob.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { promisify } from "node:util";
|
|
|
15
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
|
|
18
|
-
import { searchRoot, suggestPathUnderCwd,
|
|
18
|
+
import { searchRoot, suggestPathUnderCwd, toRelativePath } from "./common.js";
|
|
19
19
|
|
|
20
20
|
const GLOB_RESULT_LIMIT = 100;
|
|
21
21
|
|
|
@@ -58,7 +58,7 @@ export async function globFiles(
|
|
|
58
58
|
cwd: string,
|
|
59
59
|
signal?: AbortSignal,
|
|
60
60
|
): Promise<{ files: string[]; truncated: boolean }> {
|
|
61
|
-
throwIfAborted(
|
|
61
|
+
signal?.throwIfAborted();
|
|
62
62
|
let searchDir = cwd;
|
|
63
63
|
let searchPattern = pattern;
|
|
64
64
|
if (isAbsolute(pattern)) {
|
|
@@ -87,7 +87,6 @@ export async function globFiles(
|
|
|
87
87
|
});
|
|
88
88
|
stdout = result.stdout;
|
|
89
89
|
} catch (error) {
|
|
90
|
-
if (signal?.aborted) throwIfAborted(signal);
|
|
91
90
|
// rg exit code 1 = 搜索完成但无匹配,对齐 Claude Code 的 ripGrep(正常空结果)
|
|
92
91
|
const code = (error as { code?: unknown }).code;
|
|
93
92
|
if (code === 1) return { files: [], truncated: false };
|
package/src/claude-code/grep.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { type Static, Type } from "typebox";
|
|
19
19
|
|
|
20
|
-
import { searchRoot, suggestPathUnderCwd,
|
|
20
|
+
import { searchRoot, suggestPathUnderCwd, toRelativePath } from "./common.js";
|
|
21
21
|
|
|
22
22
|
const GREP_OUTPUT_MODES = ["content", "files_with_matches", "count"] as const;
|
|
23
23
|
|
|
@@ -272,7 +272,7 @@ export function registerGrepTool(pi: ExtensionAPI): void {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
const result = await pi.exec("rg", buildGrepArguments(params, ctx.cwd), { signal });
|
|
275
|
-
throwIfAborted(
|
|
275
|
+
signal?.throwIfAborted();
|
|
276
276
|
if (result.code !== 0 && result.code !== 1) {
|
|
277
277
|
throw new Error(result.stderr.trim() || `ripgrep exited with code ${result.code}`);
|
|
278
278
|
}
|
|
@@ -193,7 +193,7 @@ export function registerSessionTools(pi: ExtensionAPI): void {
|
|
|
193
193
|
if (!ctx.hasUI) throw new Error("Cannot ask questions: interactive UI is not available");
|
|
194
194
|
const answers: Record<string, string> = {};
|
|
195
195
|
for (const question of params.questions) {
|
|
196
|
-
|
|
196
|
+
signal?.throwIfAborted();
|
|
197
197
|
answers[question.question] = question.multiSelect
|
|
198
198
|
? await askMultiple(question, ctx, signal)
|
|
199
199
|
: await askSingle(question, ctx, signal);
|
package/src/claude-code/shell.ts
CHANGED
|
@@ -6,10 +6,11 @@ import {
|
|
|
6
6
|
type BashToolDetails,
|
|
7
7
|
type ExtensionAPI,
|
|
8
8
|
formatSize,
|
|
9
|
+
type TruncationResult,
|
|
9
10
|
} from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { Type } from "typebox";
|
|
11
12
|
|
|
12
|
-
import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
|
|
13
|
+
import { BashInterruptedError, type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
|
|
13
14
|
import { resolveWorkdir } from "../lib/path.js";
|
|
14
15
|
|
|
15
16
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -36,6 +37,25 @@ function formatBashError(exitCode: number | null, output: string): string {
|
|
|
36
37
|
);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/** 输出被截断时附加的 `[Showing lines...]` 提示(成功、超时、中断路径共用)。 */
|
|
41
|
+
function appendTruncationNotice(
|
|
42
|
+
text: string,
|
|
43
|
+
truncation: TruncationResult,
|
|
44
|
+
fullOutputPath: string | undefined,
|
|
45
|
+
): string {
|
|
46
|
+
if (!fullOutputPath || !truncation.truncated) return text;
|
|
47
|
+
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
48
|
+
const endLine = truncation.totalLines;
|
|
49
|
+
if (truncation.lastLinePartial) {
|
|
50
|
+
const lastLineSize = formatSize(text.length - text.lastIndexOf("\n", text.length - 2) - 1);
|
|
51
|
+
return `${text}\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${fullOutputPath}]`;
|
|
52
|
+
}
|
|
53
|
+
if (truncation.truncatedBy === "lines") {
|
|
54
|
+
return `${text}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${fullOutputPath}]`;
|
|
55
|
+
}
|
|
56
|
+
return `${text}\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${fullOutputPath}]`;
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
/**
|
|
40
60
|
* 成功路径:消费 runtime 的截断结果(输出已由 runtime 截断并落盘),
|
|
41
61
|
* 截断时追加 `[Showing lines X-Y of N. Full output: path]` 提示。
|
|
@@ -46,21 +66,7 @@ export function formatBashSuccess(result: Awaited<ReturnType<BwrapRuntime["execu
|
|
|
46
66
|
details: BashToolDetails | undefined;
|
|
47
67
|
} {
|
|
48
68
|
const { output, truncation, fullOutputPath } = result;
|
|
49
|
-
|
|
50
|
-
if (fullOutputPath && truncation.truncated) {
|
|
51
|
-
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
52
|
-
const endLine = truncation.totalLines;
|
|
53
|
-
if (truncation.lastLinePartial) {
|
|
54
|
-
const lastLineSize = formatSize(
|
|
55
|
-
output.length - output.lastIndexOf("\n", output.length - 2) - 1,
|
|
56
|
-
);
|
|
57
|
-
text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${fullOutputPath}]`;
|
|
58
|
-
} else if (truncation.truncatedBy === "lines") {
|
|
59
|
-
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${fullOutputPath}]`;
|
|
60
|
-
} else {
|
|
61
|
-
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(truncation.maxBytes)} limit). Full output: ${fullOutputPath}]`;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
69
|
+
const text = appendTruncationNotice(output || "(no output)", truncation, fullOutputPath);
|
|
64
70
|
return {
|
|
65
71
|
content: [{ type: "text", text }],
|
|
66
72
|
details: fullOutputPath && truncation.truncated ? { truncation, fullOutputPath } : undefined,
|
|
@@ -133,13 +139,24 @@ export function registerShellTools(
|
|
|
133
139
|
});
|
|
134
140
|
} catch (error) {
|
|
135
141
|
if (!(error instanceof Error)) throw error;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
error.
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
if (error instanceof BashInterruptedError) {
|
|
143
|
+
// 输出在前(必要时带截断提示),状态文本在最后
|
|
144
|
+
const text = appendTruncationNotice(
|
|
145
|
+
error.partial.output || "",
|
|
146
|
+
error.partial.truncation,
|
|
147
|
+
error.partial.fullOutputPath,
|
|
148
|
+
);
|
|
149
|
+
if (error.kind === "aborted") {
|
|
150
|
+
// 用户取消:直接返回已捕获的输出,不抛错
|
|
151
|
+
const full = text ? `${text}\n\nCommand aborted` : "Command aborted";
|
|
152
|
+
return { content: [{ type: "text", text: full }], details: undefined };
|
|
153
|
+
}
|
|
154
|
+
const full = text
|
|
155
|
+
? `${text}\n\nCommand timed out after ${timeout} milliseconds`
|
|
156
|
+
: `Command timed out after ${timeout} milliseconds`;
|
|
157
|
+
throw new Error(full, { cause: error });
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
// 对齐 Claude Code:非 0 退出码视为错误(不做 grep/find 等命令语义化特判,
|
package/src/lib/path.ts
CHANGED
|
@@ -61,7 +61,7 @@ export function formatDisplayPath(cwd: string, filePath: string): string {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** subtitle 中路径的最大显示长度,超过时退化为 `parent/basename`。 */
|
|
64
|
-
export const MAX_SUBTITLE_PATH_LENGTH =
|
|
64
|
+
export const MAX_SUBTITLE_PATH_LENGTH = 30;
|
|
65
65
|
|
|
66
66
|
function shortenSubtitlePath(filePath: string, display: string): string {
|
|
67
67
|
if (display.length <= MAX_SUBTITLE_PATH_LENGTH) return display;
|
package/src/opencode/bash.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
|
|
4
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
|
|
7
|
-
import { type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
|
|
7
|
+
import { BashInterruptedError, type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
|
|
8
8
|
import { resolveWorkdir } from "../lib/path.js";
|
|
9
9
|
|
|
10
10
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -16,6 +16,18 @@ const CAPTURE_TRUNCATED_NOTICE = "[output capture truncated at the in-memory saf
|
|
|
16
16
|
/** Bash tool guidance, kept in markdown so it reads like documentation. */
|
|
17
17
|
const BASH_PROMPT = readFileSync(fileURLToPath(new URL("bash.md", import.meta.url)), "utf8").trim();
|
|
18
18
|
|
|
19
|
+
/** opencode 风格截断提示(成功、超时、中断路径共用)。 */
|
|
20
|
+
function appendTruncationNotice(
|
|
21
|
+
text: string,
|
|
22
|
+
truncation: TruncationResult,
|
|
23
|
+
fullOutputPath: string | undefined,
|
|
24
|
+
): string {
|
|
25
|
+
if (!truncation.truncated) return text;
|
|
26
|
+
let out = `${text}\n\n${CAPTURE_TRUNCATED_NOTICE}`;
|
|
27
|
+
if (fullOutputPath) out += `\nFull output: ${fullOutputPath}`;
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
/**
|
|
20
32
|
* 对齐上游 opencode(packages/core/src/tool/bash.ts):
|
|
21
33
|
* 命令失败(非 0 退出码)与超时都不抛错,输出与状态文本一起返回,
|
|
@@ -89,17 +101,21 @@ export default function opencodeBash(
|
|
|
89
101
|
});
|
|
90
102
|
} catch (error) {
|
|
91
103
|
if (!(error instanceof Error)) throw error;
|
|
92
|
-
|
|
93
|
-
|
|
104
|
+
if (error instanceof BashInterruptedError) {
|
|
105
|
+
const text = appendTruncationNotice(
|
|
106
|
+
error.partial.output || "",
|
|
107
|
+
error.partial.truncation,
|
|
108
|
+
error.partial.fullOutputPath,
|
|
109
|
+
);
|
|
110
|
+
const status =
|
|
111
|
+
error.kind === "timeout"
|
|
112
|
+
? `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
|
113
|
+
: "Command aborted";
|
|
114
|
+
const full = text ? `${text}\n\n${status}` : status;
|
|
115
|
+
// 对齐上游 opencode:超时与中断都不抛错,输出与状态文本一起返回
|
|
94
116
|
return {
|
|
95
|
-
content: [
|
|
96
|
-
|
|
97
|
-
type: "text" as const,
|
|
98
|
-
text: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
|
99
|
-
},
|
|
100
|
-
{ type: "text" as const, text: "Command timed out before completion." },
|
|
101
|
-
],
|
|
102
|
-
details: { timeout: true },
|
|
117
|
+
content: [{ type: "text" as const, text: full }],
|
|
118
|
+
details: error.kind === "timeout" ? { timeout: true } : {},
|
|
103
119
|
};
|
|
104
120
|
}
|
|
105
121
|
throw error;
|
|
@@ -107,10 +123,7 @@ export default function opencodeBash(
|
|
|
107
123
|
|
|
108
124
|
// 命令失败(非 0 退出码)不抛错:输出与状态文本一起返回
|
|
109
125
|
let text = result.output || "(no output)";
|
|
110
|
-
|
|
111
|
-
text += `\n\n${CAPTURE_TRUNCATED_NOTICE}`;
|
|
112
|
-
if (result.fullOutputPath) text += `\nFull output: ${result.fullOutputPath}`;
|
|
113
|
-
}
|
|
126
|
+
text = appendTruncationNotice(text, result.truncation, result.fullOutputPath);
|
|
114
127
|
return {
|
|
115
128
|
content: [
|
|
116
129
|
{ type: "text" as const, text },
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
const FAKEIP_RANGE = "198.18.0.0/15";
|
|
2
|
-
const TUN_ADDRESS = "198.18.0.1/16";
|
|
3
|
-
const EGRESS_INTERFACE = "tap0";
|
|
4
|
-
|
|
5
|
-
export interface SingboxConfigOptions {
|
|
6
|
-
/** 允许直连的条目列表(域名 / IP / CIDR,可带 :port;空列表 = 默认拒绝一切出网)。 */
|
|
7
|
-
readonly allowlist: readonly string[];
|
|
8
|
-
/** 真实 DNS 服务器地址列表(IPv4,UDP 53),按顺序 fallback,至少一个。 */
|
|
9
|
-
readonly dnsServers: readonly string[];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
interface AllowlistEntry {
|
|
13
|
-
readonly host: string;
|
|
14
|
-
readonly port?: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface AllowlistRule {
|
|
18
|
-
readonly domain?: readonly string[];
|
|
19
|
-
readonly ip_cidr?: readonly string[];
|
|
20
|
-
readonly port?: readonly number[];
|
|
21
|
-
readonly outbound: "direct";
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const IPV4_PATTERN = /^\d{1,3}(?:\.\d{1,3}){3}(?:\/\d{1,2})?$/;
|
|
25
|
-
|
|
26
|
-
function isIp(host: string): boolean {
|
|
27
|
-
return IPV4_PATTERN.test(host) || host.includes(":");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** 单 IP 补掩码为 CIDR(IPv4 /32、IPv6 /128),带掩码原样返回。 */
|
|
31
|
-
function toCidr(host: string): string {
|
|
32
|
-
if (host.includes("/")) return host;
|
|
33
|
-
return host.includes(":") ? `${host}/128` : `${host}/32`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function parsePort(value: string): number {
|
|
37
|
-
const port = Number(value);
|
|
38
|
-
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
39
|
-
throw new Error(`Invalid port "${value}"`);
|
|
40
|
-
}
|
|
41
|
-
return port;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* 解析 allowlist 条目:域名 / IPv4 / CIDR,可带 :port;IPv6 必须用 [] 包裹
|
|
46
|
-
* (如 `[::1]:80`),裸 IPv6 会报错提示补方括号。
|
|
47
|
-
*/
|
|
48
|
-
function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
49
|
-
if (entry.startsWith("[")) {
|
|
50
|
-
const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
|
|
51
|
-
if (match?.[1] === undefined) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
52
|
-
return { host: match[1], port: match[2] === undefined ? undefined : parsePort(match[2]) };
|
|
53
|
-
}
|
|
54
|
-
const colon = entry.lastIndexOf(":");
|
|
55
|
-
if (colon === -1) {
|
|
56
|
-
if (entry.length === 0) throw new Error(`Invalid allowlist entry ""`);
|
|
57
|
-
return { host: entry };
|
|
58
|
-
}
|
|
59
|
-
const portPart = entry.slice(colon + 1);
|
|
60
|
-
if (!/^\d+$/.test(portPart)) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
61
|
-
const host = entry.slice(0, colon);
|
|
62
|
-
if (host.length === 0) throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
63
|
-
if (host.includes(":")) {
|
|
64
|
-
throw new Error(`IPv6 addresses must be wrapped in brackets, e.g. "[${host}]:${portPart}"`);
|
|
65
|
-
}
|
|
66
|
-
return { host, port: parsePort(portPart) };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function buildAllowlistRules(allowlist: readonly string[]): AllowlistRule[] {
|
|
70
|
-
const noPortDomains: string[] = [];
|
|
71
|
-
const noPortIps: string[] = [];
|
|
72
|
-
const byPort = new Map<number, { domains: string[]; ips: string[] }>();
|
|
73
|
-
for (const entry of allowlist) {
|
|
74
|
-
const { host, port } = parseAllowlistEntry(entry);
|
|
75
|
-
if (port === undefined) {
|
|
76
|
-
(isIp(host) ? noPortIps : noPortDomains).push(host);
|
|
77
|
-
} else {
|
|
78
|
-
const bucket = byPort.get(port) ?? { domains: [], ips: [] };
|
|
79
|
-
(isIp(host) ? bucket.ips : bucket.domains).push(host);
|
|
80
|
-
byPort.set(port, bucket);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
const rules: AllowlistRule[] = [];
|
|
84
|
-
if (noPortDomains.length > 0) rules.push({ domain: noPortDomains, outbound: "direct" });
|
|
85
|
-
if (noPortIps.length > 0) {
|
|
86
|
-
rules.push({ ip_cidr: noPortIps.map((host) => toCidr(host)), outbound: "direct" });
|
|
87
|
-
}
|
|
88
|
-
for (const [port, bucket] of byPort) {
|
|
89
|
-
rules.push({
|
|
90
|
-
outbound: "direct",
|
|
91
|
-
port: [port],
|
|
92
|
-
...(bucket.domains.length > 0 && { domain: bucket.domains }),
|
|
93
|
-
...(bucket.ips.length > 0 && { ip_cidr: bucket.ips.map((host) => toCidr(host)) }),
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
return rules;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* 生成 sing-box 1.13 配置:TUN + fakeip + deny-by-default allowlist。
|
|
101
|
-
*
|
|
102
|
-
* - 出站绑定到 slirp4netns 的 tap0,避免 auto_route 把 sing-box 自身出站
|
|
103
|
-
* 流量(尤其 DNS 解析)重新路由回 TUN 形成环。
|
|
104
|
-
* - 真实解析不设 domain_resolver(它只接受单个 server 且绕过规则),而是
|
|
105
|
-
* 用 DNS rules 的顺序 fallback 链:前 N-1 个 nameserver 带 ip_accept_any
|
|
106
|
-
* 过滤(响应不合格时落到下一条),最后一个作为 dns.final 兜底。
|
|
107
|
-
* 该用法在 1.13 已标记 deprecated,运行需设置
|
|
108
|
-
* ENABLE_DEPRECATED_MISSING_DOMAIN_RESOLVER=true。
|
|
109
|
-
*/
|
|
110
|
-
export function generateSingboxConfig(options: SingboxConfigOptions): string {
|
|
111
|
-
const { allowlist, dnsServers } = options;
|
|
112
|
-
const lastServer = dnsServers.at(-1);
|
|
113
|
-
if (!lastServer) {
|
|
114
|
-
throw new Error("At least one DNS server is required");
|
|
115
|
-
}
|
|
116
|
-
const config = {
|
|
117
|
-
log: { level: "info", timestamp: false },
|
|
118
|
-
dns: {
|
|
119
|
-
servers: [
|
|
120
|
-
...dnsServers.map((server, index) => ({
|
|
121
|
-
type: "udp",
|
|
122
|
-
tag: `remote-${index}`,
|
|
123
|
-
server,
|
|
124
|
-
server_port: 53,
|
|
125
|
-
})),
|
|
126
|
-
{ type: "fakeip", tag: "fakeip", inet4_range: FAKEIP_RANGE },
|
|
127
|
-
],
|
|
128
|
-
rules: [
|
|
129
|
-
{ query_type: ["A", "AAAA"], server: "fakeip" },
|
|
130
|
-
...dnsServers.slice(0, -1).map((_, index) => ({
|
|
131
|
-
server: `remote-${index}`,
|
|
132
|
-
ip_accept_any: true,
|
|
133
|
-
})),
|
|
134
|
-
],
|
|
135
|
-
final: `remote-${dnsServers.length - 1}`,
|
|
136
|
-
strategy: "prefer_ipv4",
|
|
137
|
-
independent_cache: true,
|
|
138
|
-
},
|
|
139
|
-
inbounds: [
|
|
140
|
-
{
|
|
141
|
-
type: "tun",
|
|
142
|
-
tag: "tun-in",
|
|
143
|
-
stack: "gvisor",
|
|
144
|
-
address: [TUN_ADDRESS],
|
|
145
|
-
auto_route: true,
|
|
146
|
-
strict_route: true,
|
|
147
|
-
},
|
|
148
|
-
],
|
|
149
|
-
outbounds: [
|
|
150
|
-
{ type: "direct", tag: "direct" },
|
|
151
|
-
{ type: "block", tag: "block" },
|
|
152
|
-
],
|
|
153
|
-
route: {
|
|
154
|
-
default_interface: EGRESS_INTERFACE,
|
|
155
|
-
final: "block",
|
|
156
|
-
rules: [
|
|
157
|
-
{ action: "sniff" },
|
|
158
|
-
{ protocol: "dns", action: "hijack-dns" },
|
|
159
|
-
...buildAllowlistRules(allowlist),
|
|
160
|
-
],
|
|
161
|
-
},
|
|
162
|
-
};
|
|
163
|
-
return `${JSON.stringify(config, null, 2)}\n`;
|
|
164
|
-
}
|