@trim21/personal-pi-extensions 0.1.612 → 0.1.619
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/aft/config.ts +9 -3
- package/src/aft/index.ts +6 -2
- package/src/aft/logger.ts +9 -3
- package/src/aft/tools.ts +3 -1
- package/src/bwrap/README.md +19 -10
- package/src/bwrap/approval-rules.ts +42 -14
- package/src/bwrap/approval-suggest.ts +12 -4
- package/src/bwrap/core.ts +39 -13
- package/src/bwrap/dcg-scan.ts +16 -6
- package/src/bwrap/holder.ts +3 -1
- package/src/bwrap/mihomo-config.ts +18 -6
- package/src/bwrap/network-stack.ts +69 -24
- package/src/bwrap/runtime.ts +30 -10
- package/src/bwrap/sandbox.ts +1 -1
- package/src/claude-code/common.ts +6 -2
- package/src/claude-code/edit-utils.ts +3 -1
- package/src/claude-code/files.ts +40 -14
- package/src/claude-code/glob.ts +12 -4
- package/src/claude-code/grep.ts +41 -14
- package/src/claude-code/session-tools.ts +18 -6
- package/src/claude-code/shell.ts +9 -3
- package/src/gh/base.ts +72 -24
- package/src/gh/tools/download-release-assets.ts +12 -4
- package/src/gh/tools/list-releases.ts +3 -1
- package/src/gh/tools/list-workflow-runs.ts +9 -3
- package/src/gh/tools/read-ci-logs.ts +21 -7
- package/src/gh/tools/read-repo.ts +3 -1
- package/src/lib/cli-args.ts +6 -2
- package/src/lib/cli.ts +48 -16
- package/src/lib/file-reads.ts +24 -8
- package/src/lib/github.ts +35 -12
- package/src/lib/lsp/adapter.ts +6 -2
- package/src/lib/lsp/bin.ts +12 -4
- package/src/lib/lsp/client.ts +205 -69
- package/src/lib/lsp/diagnostic.ts +6 -2
- package/src/lib/lsp/inspect-tool.ts +25 -9
- package/src/lib/lsp/lsp.ts +138 -46
- package/src/lib/lsp/rename-tool.ts +11 -4
- package/src/lib/lsp/rename.ts +25 -8
- package/src/lib/lsp/server-config.ts +41 -14
- package/src/lib/lsp/watcher.ts +51 -17
- package/src/lib/path.ts +18 -6
- package/src/lib/proc.ts +3 -1
- package/src/lib/proxy.ts +9 -3
- package/src/lib/request-policy.ts +3 -1
- package/src/lib/seq-state.ts +3 -1
- package/src/lib/ui.ts +23 -8
- package/src/lib/write-guard.ts +21 -7
- package/src/openai-cost/config.ts +3 -1
- package/src/openai-cost/cost.ts +54 -18
- package/src/openai-cost/index.ts +3 -1
- package/src/openai-cost/provider.ts +6 -2
- package/src/opencode/bash.ts +9 -3
- package/src/opencode/edit-engine.ts +24 -8
- package/src/opencode/files.ts +30 -10
- package/src/opencode/glob.ts +3 -1
- package/src/opencode/grep.ts +12 -4
- package/src/opencode/question.ts +3 -1
- package/src/opencode/ripgrep.ts +18 -6
- package/src/opencode/todo.ts +3 -1
- package/src/session-name.ts +39 -13
- package/src/spawn-agent-agents.ts +18 -6
- package/src/spawn-agent.ts +53 -18
- package/src/system-prompt/index.ts +9 -3
- package/src/talk/core.ts +143 -48
- package/src/talk/format.ts +12 -4
- package/src/talk/group.ts +9 -3
- package/src/talk/index.ts +30 -10
- package/src/talk/mailbox.ts +30 -10
- package/src/talk/policy.ts +6 -2
- package/src/talk/registry.ts +33 -11
- package/src/talk/storage.ts +3 -1
- package/src/vision-agent.ts +41 -14
- package/src/web/config.ts +3 -1
- package/src/web/fetch.ts +75 -25
- package/src/web/search.ts +30 -10
package/package.json
CHANGED
package/src/aft/config.ts
CHANGED
|
@@ -86,8 +86,12 @@ function remoteOf(semantic: RawConfig["semantic"]): SemanticRemote | undefined {
|
|
|
86
86
|
const backend = trimmedOrUndefined(semantic?.backend);
|
|
87
87
|
// 去掉尾斜杠:aft 侧按 base_url + /embeddings 拼请求。
|
|
88
88
|
const baseUrl = trimmedOrUndefined(semantic?.base_url)?.replace(/\/+$/, "");
|
|
89
|
-
if (backend === undefined || baseUrl === undefined)
|
|
90
|
-
|
|
89
|
+
if (backend === undefined || baseUrl === undefined) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
if (!isRemoteBackend(backend)) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
91
95
|
return {
|
|
92
96
|
backend,
|
|
93
97
|
baseUrl,
|
|
@@ -110,7 +114,9 @@ function toReadConfig(raw: RawConfig): AftReadConfig {
|
|
|
110
114
|
*/
|
|
111
115
|
export function loadAftConfig(userConfigPath: string): AftReadConfig {
|
|
112
116
|
try {
|
|
113
|
-
if (!existsSync(userConfigPath))
|
|
117
|
+
if (!existsSync(userConfigPath)) {
|
|
118
|
+
return DEFAULTS;
|
|
119
|
+
}
|
|
114
120
|
const parsed: unknown = parse(readFileSync(userConfigPath, "utf8"));
|
|
115
121
|
return toReadConfig(parseWithSchema(rawConfigSchema, parsed ?? {}));
|
|
116
122
|
} catch {
|
package/src/aft/index.ts
CHANGED
|
@@ -34,7 +34,9 @@ import {
|
|
|
34
34
|
export default function aftReadTools(pi: ExtensionAPI): void {
|
|
35
35
|
const cwd = process.cwd();
|
|
36
36
|
const cfg = loadAftConfig(resolveCortexKitConfigPaths(cwd).userConfigPath);
|
|
37
|
-
if (!cfg.enabled)
|
|
37
|
+
if (!cfg.enabled) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
38
40
|
|
|
39
41
|
// bridge 状态跟 session 生命周期走,作用域就是本工厂闭包,不落到模块级。
|
|
40
42
|
let state: Awaited<ReturnType<typeof createAftState>> | null = null;
|
|
@@ -88,7 +90,9 @@ export default function aftReadTools(pi: ExtensionAPI): void {
|
|
|
88
90
|
const shutdown = async (): Promise<void> => {
|
|
89
91
|
const current = state;
|
|
90
92
|
state = null;
|
|
91
|
-
if (!current)
|
|
93
|
+
if (!current) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
92
96
|
try {
|
|
93
97
|
await shutdownAftPool(current.pool);
|
|
94
98
|
await current.logger.drain();
|
package/src/aft/logger.ts
CHANGED
|
@@ -43,7 +43,9 @@ export function createAftLogger(sessionId?: string): AftLogger {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function flush(): void {
|
|
46
|
-
if (buffer.length === 0)
|
|
46
|
+
if (buffer.length === 0) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
47
49
|
const data = buffer.join("");
|
|
48
50
|
buffer = [];
|
|
49
51
|
try {
|
|
@@ -54,7 +56,9 @@ export function createAftLogger(sessionId?: string): AftLogger {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function scheduleFlush(): void {
|
|
57
|
-
if (flushTimer)
|
|
59
|
+
if (flushTimer) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
58
62
|
flushTimer = setTimeout(() => {
|
|
59
63
|
flushTimer = null;
|
|
60
64
|
flush();
|
|
@@ -92,7 +96,9 @@ export function createAftLogger(sessionId?: string): AftLogger {
|
|
|
92
96
|
getLogFilePath: logFilePath,
|
|
93
97
|
drain: async (): Promise<void> => {
|
|
94
98
|
flush();
|
|
95
|
-
if (sink)
|
|
99
|
+
if (sink) {
|
|
100
|
+
await sink.drain();
|
|
101
|
+
}
|
|
96
102
|
},
|
|
97
103
|
};
|
|
98
104
|
}
|
package/src/aft/tools.ts
CHANGED
|
@@ -444,7 +444,9 @@ export function subscribeBridgeStatus(
|
|
|
444
444
|
bridge: AftProjectTransport,
|
|
445
445
|
listener: (snapshot: StatusSnapshot) => void,
|
|
446
446
|
): (() => void) | undefined {
|
|
447
|
-
if (!isStatusSubscribable(bridge))
|
|
447
|
+
if (!isStatusSubscribable(bridge)) {
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
448
450
|
return bridge.subscribeStatus(listener);
|
|
449
451
|
}
|
|
450
452
|
|
package/src/bwrap/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 进程模型
|
|
8
8
|
|
|
9
|
-
`network: limited`
|
|
9
|
+
`network: limited` 模式下,每条命令的网络栈进程树(宿主侧视角,共 4 个):
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
pi 进程(network-stack.ts)
|
|
@@ -21,7 +21,7 @@ pi 进程(network-stack.ts)
|
|
|
21
21
|
必须在宿主 netns 启动(原因见「设计约束」);持 exit-fd 读端 + tapfd。
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
每条命令的短命子树(命令结束即退):
|
|
25
25
|
|
|
26
26
|
```
|
|
27
27
|
nsenter -U -n --preserve-credentials -t <①的pid> \
|
|
@@ -29,7 +29,8 @@ nsenter -U -n --preserve-credentials -t <①的pid> \
|
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
nsenter 进入 holder 的 userns/netns,bwrap 在里面再嵌套创建自己的 user/pid
|
|
32
|
-
ns
|
|
32
|
+
ns 跑命令。网络栈是每条命令现建现停(起栈 ~40ms、停栈 ~100ms),不跨命令复用:
|
|
33
|
+
allowlist 变更因此即时生效,代价是每条命令重新启动一次 mihomo。
|
|
33
34
|
|
|
34
35
|
## 网络路径
|
|
35
36
|
|
|
@@ -54,14 +55,16 @@ exit-fd(socketpair)是 slirp4netns 与 holder 之间唯一的生命周期绑
|
|
|
54
55
|
访问器(`stdio[3].fd` 恒为 undefined),只能经 `_handle.fd` 取原始 fd 再
|
|
55
56
|
dup 给 slirp4netns,且仅在子进程存活期间有效。
|
|
56
57
|
|
|
57
|
-
| 触发
|
|
58
|
-
|
|
|
59
|
-
| 正常 `stop()`
|
|
60
|
-
| pi 进程被 SIGKILL
|
|
61
|
-
| 单独 kill
|
|
62
|
-
| 单独 kill ②
|
|
58
|
+
| 触发 | 清理链路 |
|
|
59
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
60
|
+
| 正常 `stop()` | SIGTERM ④(先杀,它 pin 着 netns)→ SIGKILL ① → PDEATHSIG 给 ② SIGTERM → ② 杀 ③ 退出 → 内核清 pid ns |
|
|
61
|
+
| pi 进程被 SIGKILL | stdin 写端关闭 → ② EOF 自杀 → pid ns 清理 → exit-fd 写端关闭 → ④ HUP 自杀 → tapfd 释放 → netns 销毁 |
|
|
62
|
+
| 单独 kill ①(SIGKILL) | PDEATHSIG → ② SIGTERM → 同上;① wait 结束退出 → 写端全关 → ④ 退 |
|
|
63
|
+
| 单独 kill ② | pid-ns init 死 → 内核清 ③;① 退出 → 写端全关 → ④ 退 |
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
四条路径下网络栈全部进程收敛、netns 引用归零。注意 `--kill-child` 不是信号转发:
|
|
66
|
+
它的实现是 unshare fork 出的子进程给自己设 PDEATHSIG(unshare 死亡时收到 SIGTERM),
|
|
67
|
+
所以终止 ① 只能靠 SIGKILL(见「设计约束」第 5 条)。
|
|
65
68
|
|
|
66
69
|
## 设计约束与教训
|
|
67
70
|
|
|
@@ -81,6 +84,12 @@ dup 给 slirp4netns,且仅在子进程存活期间有效。
|
|
|
81
84
|
日志;`nsenter -U -n --preserve-credentials -t <holderPid>` 可手动进入
|
|
82
85
|
netns 用 AF_PACKET 抓 tap0 / 检查 `ip rule`(注意:沙盒里看不到宿主机
|
|
83
86
|
进程,宿主机诊断必须在沙盒外做)。
|
|
87
|
+
5. **终止 unshare 只能用 SIGKILL**。util-linux 的 unshare 在 fork 前
|
|
88
|
+
`sigprocmask(SIG_BLOCK, {SIGINT, SIGTERM})`,且只在子进程里恢复掩码:
|
|
89
|
+
父进程永久阻塞这两个信号且不装 handler,发给它的 SIGTERM 只会 pending
|
|
90
|
+
永不投递(实测 3s 后仍存活)。曾因此在 `stop()` 里白等 `waitForExit`
|
|
91
|
+
的 2000ms 默认超时,把每条命令的沙箱开销从 ~180ms 抬到 ~2.09s。SIGKILL
|
|
92
|
+
立即生效,PDEATHSIG 再把 SIGTERM 交给 ② 走优雅退出。
|
|
84
93
|
|
|
85
94
|
## 调试入口
|
|
86
95
|
|
|
@@ -106,7 +106,9 @@ function extractParts(node: Node): { name: string; args: string[] } {
|
|
|
106
106
|
};
|
|
107
107
|
for (let i = 0; i < node.childCount; i++) {
|
|
108
108
|
const child = node.child(i);
|
|
109
|
-
if (child)
|
|
109
|
+
if (child) {
|
|
110
|
+
visit(child);
|
|
111
|
+
}
|
|
110
112
|
}
|
|
111
113
|
return { name: name.join(" "), args };
|
|
112
114
|
}
|
|
@@ -117,24 +119,34 @@ const FILE_OUTPUT_REDIRECT_OPS = new Set([">", ">>", ">|", "&>", "&>>"]);
|
|
|
117
119
|
function fileRedirectWritesToFile(node: Node): boolean {
|
|
118
120
|
for (let i = 0; i < node.childCount; i++) {
|
|
119
121
|
const child = node.child(i);
|
|
120
|
-
if (!child)
|
|
121
|
-
|
|
122
|
+
if (!child) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (FILE_OUTPUT_REDIRECT_OPS.has(child.type)) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
122
128
|
// `<>` 被解析成 `<` + 含 `>` 的 ERROR
|
|
123
|
-
if (child.type === "ERROR" && child.text.includes(">"))
|
|
129
|
+
if (child.type === "ERROR" && child.text.includes(">")) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
124
132
|
}
|
|
125
133
|
return false;
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
function treeHasFileOutputRedirect(root: Node): boolean {
|
|
129
137
|
for (const node of root.descendantsOfType("file_redirect")) {
|
|
130
|
-
if (fileRedirectWritesToFile(node))
|
|
138
|
+
if (fileRedirectWritesToFile(node)) {
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
131
141
|
}
|
|
132
142
|
return false;
|
|
133
143
|
}
|
|
134
144
|
|
|
135
145
|
function collectCommand(node: Node, all: Node[]): BashCommand | undefined {
|
|
136
146
|
const { name, args } = extractParts(node);
|
|
137
|
-
if (!name)
|
|
147
|
+
if (!name) {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
138
150
|
// 嵌套命令 = 完全落在本命令范围内的其他 command 节点(含 `$(...)` 内的)。
|
|
139
151
|
// 用位置判断而非 descendantsOfType 递归:0.26 的 descendantsOfType 会包含
|
|
140
152
|
// 自身且每次返回新 wrapper,`===` 比较失效会无限递归。
|
|
@@ -166,7 +178,9 @@ export async function parseBashCommands(command: string): Promise<ParsedBash> {
|
|
|
166
178
|
const commands: BashCommand[] = [];
|
|
167
179
|
for (const node of all) {
|
|
168
180
|
const parsed = collectCommand(node, all);
|
|
169
|
-
if (parsed)
|
|
181
|
+
if (parsed) {
|
|
182
|
+
commands.push(parsed);
|
|
183
|
+
}
|
|
170
184
|
}
|
|
171
185
|
return { commands, hasFileOutputRedirect: treeHasFileOutputRedirect(tree.rootNode) };
|
|
172
186
|
} catch (error) {
|
|
@@ -189,7 +203,9 @@ export function matchRule(input: string, pattern: string): boolean {
|
|
|
189
203
|
.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`)
|
|
190
204
|
.replaceAll("*", ".*")
|
|
191
205
|
.replaceAll("?", ".");
|
|
192
|
-
if (escaped.endsWith(" .*"))
|
|
206
|
+
if (escaped.endsWith(" .*")) {
|
|
207
|
+
escaped = escaped.slice(0, -3) + "( .*)?";
|
|
208
|
+
}
|
|
193
209
|
return new RegExp(`^${escaped}$`, "s").test(input);
|
|
194
210
|
}
|
|
195
211
|
|
|
@@ -214,16 +230,28 @@ export async function evaluateBashApproval(
|
|
|
214
230
|
const raws: string[] = [];
|
|
215
231
|
const visit = (cmd: BashCommand) => {
|
|
216
232
|
raws.push(cmd.raw);
|
|
217
|
-
for (const nested of cmd.nested)
|
|
233
|
+
for (const nested of cmd.nested) {
|
|
234
|
+
visit(nested);
|
|
235
|
+
}
|
|
218
236
|
};
|
|
219
|
-
for (const cmd of parsed.commands)
|
|
220
|
-
|
|
237
|
+
for (const cmd of parsed.commands) {
|
|
238
|
+
visit(cmd);
|
|
239
|
+
}
|
|
240
|
+
if (raws.length === 0) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
221
243
|
let allowed = 0;
|
|
222
244
|
for (const raw of raws) {
|
|
223
245
|
const rule = rules.findLast((r) => matchRule(raw, r.pattern));
|
|
224
|
-
if (rule?.action === "deny")
|
|
225
|
-
|
|
246
|
+
if (rule?.action === "deny") {
|
|
247
|
+
return "deny";
|
|
248
|
+
}
|
|
249
|
+
if (rule?.action === "allow") {
|
|
250
|
+
allowed++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (parsed.hasFileOutputRedirect) {
|
|
254
|
+
return;
|
|
226
255
|
}
|
|
227
|
-
if (parsed.hasFileOutputRedirect) return;
|
|
228
256
|
return allowed === raws.length ? "allow" : undefined;
|
|
229
257
|
}
|
|
@@ -30,9 +30,13 @@ export function commandPattern(command: BashCommand): string {
|
|
|
30
30
|
for (let len = tokens.length; len > 0; len--) {
|
|
31
31
|
const prefix = tokens.slice(0, len).join(" ");
|
|
32
32
|
const arity = ARITY[prefix];
|
|
33
|
-
if (arity !== undefined)
|
|
33
|
+
if (arity !== undefined) {
|
|
34
|
+
return [...tokens.slice(0, arity), "*"].join(" ");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (tokens.length === 0) {
|
|
38
|
+
return "*";
|
|
34
39
|
}
|
|
35
|
-
if (tokens.length === 0) return "*";
|
|
36
40
|
return [tokens[0], "*"].join(" ");
|
|
37
41
|
}
|
|
38
42
|
|
|
@@ -41,9 +45,13 @@ function patternsFromCommands(commands: BashCommand[]): string[] {
|
|
|
41
45
|
const flat: string[] = [];
|
|
42
46
|
const visit = (cmd: BashCommand) => {
|
|
43
47
|
flat.push(commandPattern(cmd));
|
|
44
|
-
for (const nested of cmd.nested)
|
|
48
|
+
for (const nested of cmd.nested) {
|
|
49
|
+
visit(nested);
|
|
50
|
+
}
|
|
45
51
|
};
|
|
46
|
-
for (const cmd of commands)
|
|
52
|
+
for (const cmd of commands) {
|
|
53
|
+
visit(cmd);
|
|
54
|
+
}
|
|
47
55
|
return flat;
|
|
48
56
|
}
|
|
49
57
|
|
package/src/bwrap/core.ts
CHANGED
|
@@ -226,7 +226,9 @@ export function loadBwrapConfig(cwd: string, paths = getBwrapConfigPaths(cwd)):
|
|
|
226
226
|
// 去重:调用方用同一路径表达「只读这一个文件」时不做二次合并
|
|
227
227
|
// (否则 extraWritablePaths / approvalRules 会被重复拼接)
|
|
228
228
|
for (const path of new Set([paths.global, paths.project])) {
|
|
229
|
-
if (!existsSync(path))
|
|
229
|
+
if (!existsSync(path)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
230
232
|
config = deepMerge(config, parseBwrapConfigFile(path));
|
|
231
233
|
}
|
|
232
234
|
return Value.Parse(bwrapConfigSchema, config);
|
|
@@ -241,14 +243,18 @@ function findDefaultBwrap(): string {
|
|
|
241
243
|
const pathEnv = process.env.PATH ?? "";
|
|
242
244
|
for (const directory of pathEnv.split(delimiter)) {
|
|
243
245
|
const candidate = join(directory, "bwrap");
|
|
244
|
-
if (existsSync(candidate))
|
|
246
|
+
if (existsSync(candidate)) {
|
|
247
|
+
return candidate;
|
|
248
|
+
}
|
|
245
249
|
}
|
|
246
250
|
for (const candidate of [
|
|
247
251
|
"/usr/bin/bwrap",
|
|
248
252
|
"/usr/local/bin/bwrap",
|
|
249
253
|
"/run/current-system/sw/bin/bwrap",
|
|
250
254
|
]) {
|
|
251
|
-
if (existsSync(candidate))
|
|
255
|
+
if (existsSync(candidate)) {
|
|
256
|
+
return candidate;
|
|
257
|
+
}
|
|
252
258
|
}
|
|
253
259
|
throw new Error(
|
|
254
260
|
"bwrap (bubblewrap) not found in PATH. Install it:\n" +
|
|
@@ -272,7 +278,9 @@ function findCommandInPath(name: string, hint: string): string {
|
|
|
272
278
|
const pathEnv = process.env.PATH ?? "";
|
|
273
279
|
for (const directory of pathEnv.split(delimiter)) {
|
|
274
280
|
const candidate = join(directory, name);
|
|
275
|
-
if (existsSync(candidate))
|
|
281
|
+
if (existsSync(candidate)) {
|
|
282
|
+
return candidate;
|
|
283
|
+
}
|
|
276
284
|
}
|
|
277
285
|
throw new Error(hint);
|
|
278
286
|
}
|
|
@@ -335,7 +343,9 @@ class ScanLimitError extends Error {}
|
|
|
335
343
|
export async function findGitDirs(root: string): Promise<string[]> {
|
|
336
344
|
const found: string[] = [];
|
|
337
345
|
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
338
|
-
if (depth > MAX_GIT_SCAN_DEPTH)
|
|
346
|
+
if (depth > MAX_GIT_SCAN_DEPTH) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
339
349
|
let entries: Dirent[];
|
|
340
350
|
try {
|
|
341
351
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -343,19 +353,25 @@ export async function findGitDirs(root: string): Promise<string[]> {
|
|
|
343
353
|
return;
|
|
344
354
|
}
|
|
345
355
|
for (const entry of entries) {
|
|
346
|
-
if (found.length >= MAX_GIT_DIRS)
|
|
356
|
+
if (found.length >= MAX_GIT_DIRS) {
|
|
357
|
+
throw new ScanLimitError();
|
|
358
|
+
}
|
|
347
359
|
if (entry.name === ".git" && entry.isDirectory()) {
|
|
348
360
|
found.push(join(dir, ".git"));
|
|
349
361
|
continue;
|
|
350
362
|
}
|
|
351
|
-
if (!entry.isDirectory() || GIT_DIR_SCAN_SKIP.has(entry.name))
|
|
363
|
+
if (!entry.isDirectory() || GIT_DIR_SCAN_SKIP.has(entry.name)) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
352
366
|
await walk(join(dir, entry.name), depth + 1);
|
|
353
367
|
}
|
|
354
368
|
};
|
|
355
369
|
try {
|
|
356
370
|
await walk(root, 0);
|
|
357
371
|
} catch (error) {
|
|
358
|
-
if (!(error instanceof ScanLimitError))
|
|
372
|
+
if (!(error instanceof ScanLimitError)) {
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
359
375
|
}
|
|
360
376
|
return found;
|
|
361
377
|
}
|
|
@@ -403,7 +419,9 @@ export async function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): Prom
|
|
|
403
419
|
args.push("--ro-bind-try", "/dev/null", target);
|
|
404
420
|
}
|
|
405
421
|
}
|
|
406
|
-
if (resolved.network === "block")
|
|
422
|
+
if (resolved.network === "block") {
|
|
423
|
+
args.push("--unshare-net");
|
|
424
|
+
}
|
|
407
425
|
// fs allow-all:整棵根可写,不做保护绑定(.pi/.agent/.git 的只读覆盖与「完整可写」矛盾)。
|
|
408
426
|
if (resolved.fs !== "allow-all") {
|
|
409
427
|
// --ro-bind-try:目录不存在(或已被删除)时自动忽略
|
|
@@ -432,7 +450,9 @@ export async function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): Prom
|
|
|
432
450
|
}
|
|
433
451
|
|
|
434
452
|
function killChild(child: ChildProcess): void {
|
|
435
|
-
if (!child.pid)
|
|
453
|
+
if (!child.pid) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
436
456
|
try {
|
|
437
457
|
process.kill(-child.pid, "SIGKILL");
|
|
438
458
|
} catch {
|
|
@@ -450,7 +470,9 @@ export async function createNetworkStack(
|
|
|
450
470
|
resolved: ResolvedBwrap,
|
|
451
471
|
log?: NetworkStackLog,
|
|
452
472
|
): Promise<NetworkStack | undefined> {
|
|
453
|
-
if (resolved.network !== "limited")
|
|
473
|
+
if (resolved.network !== "limited") {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
454
476
|
return startNetworkStack({
|
|
455
477
|
allowlist: resolved.networkAllowlist,
|
|
456
478
|
dnsServers: await resolveDnsServers(),
|
|
@@ -584,7 +606,9 @@ export function createBwrapBashOperations(
|
|
|
584
606
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
585
607
|
child.once("error", reject);
|
|
586
608
|
child.once("close", (exitCode) => {
|
|
587
|
-
if (timeoutHandle)
|
|
609
|
+
if (timeoutHandle) {
|
|
610
|
+
clearTimeout(timeoutHandle);
|
|
611
|
+
}
|
|
588
612
|
signal?.removeEventListener("abort", onAbort);
|
|
589
613
|
// 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
|
|
590
614
|
if (signal?.aborted) {
|
|
@@ -596,7 +620,9 @@ export function createBwrapBashOperations(
|
|
|
596
620
|
} else if (timedOut) {
|
|
597
621
|
// 超时:name=TimeoutError(对齐标准错误分类)
|
|
598
622
|
reject(new TimeoutError(timeout));
|
|
599
|
-
} else
|
|
623
|
+
} else {
|
|
624
|
+
resolve({ exitCode });
|
|
625
|
+
}
|
|
600
626
|
});
|
|
601
627
|
});
|
|
602
628
|
},
|
package/src/bwrap/dcg-scan.ts
CHANGED
|
@@ -50,7 +50,9 @@ export async function dcgSuggestion(command: string): Promise<DcgScanOutcome> {
|
|
|
50
50
|
try {
|
|
51
51
|
stdout = await runDcgScan(command);
|
|
52
52
|
} catch (error) {
|
|
53
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
53
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
54
|
+
return { kind: "not-installed" };
|
|
55
|
+
}
|
|
54
56
|
return { kind: "failed", detail: error instanceof Error ? error.message : String(error) };
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -102,11 +104,16 @@ function runDcgScan(command: string): Promise<string> {
|
|
|
102
104
|
settle(new Error("dcg scan timed out"));
|
|
103
105
|
}, DCG_SCAN_TIMEOUT_MS);
|
|
104
106
|
const settle = (error?: Error) => {
|
|
105
|
-
if (settled)
|
|
107
|
+
if (settled) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
106
110
|
settled = true;
|
|
107
111
|
clearTimeout(timeoutId);
|
|
108
|
-
if (error)
|
|
109
|
-
|
|
112
|
+
if (error) {
|
|
113
|
+
reject(error);
|
|
114
|
+
} else {
|
|
115
|
+
resolve(stdout);
|
|
116
|
+
}
|
|
110
117
|
};
|
|
111
118
|
proc.stdout.setEncoding("utf8");
|
|
112
119
|
proc.stdout.on("data", (chunk: string) => {
|
|
@@ -119,8 +126,11 @@ function runDcgScan(command: string): Promise<string> {
|
|
|
119
126
|
proc.on("close", (code) => {
|
|
120
127
|
// dcg 的退出码是决策结果(deny 时非 0),不是失败标志:只要 stdout
|
|
121
128
|
// 有内容就交给上层解析;真正出错时(参数错误等)stdout 为空。
|
|
122
|
-
if (code === 0 || stdout.length > 0)
|
|
123
|
-
|
|
129
|
+
if (code === 0 || stdout.length > 0) {
|
|
130
|
+
settle();
|
|
131
|
+
} else {
|
|
132
|
+
settle(new Error(`dcg exited with code ${String(code)}`));
|
|
133
|
+
}
|
|
124
134
|
});
|
|
125
135
|
proc.stdin.end(command);
|
|
126
136
|
});
|
package/src/bwrap/holder.ts
CHANGED
|
@@ -34,7 +34,9 @@ async function waitFor(
|
|
|
34
34
|
predicate: () => Promise<boolean>,
|
|
35
35
|
): Promise<boolean> {
|
|
36
36
|
for (let i = 0; i < attempts; i++) {
|
|
37
|
-
if (await predicate())
|
|
37
|
+
if (await predicate()) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
38
40
|
await sleep(delayMs);
|
|
39
41
|
}
|
|
40
42
|
return false;
|
|
@@ -58,7 +58,9 @@ function isIp(host: string): boolean {
|
|
|
58
58
|
|
|
59
59
|
/** 单 IP 补掩码为 CIDR(IPv4 /32、IPv6 /128),带掩码原样返回。 */
|
|
60
60
|
function toCidr(host: string): string {
|
|
61
|
-
if (host.includes("/"))
|
|
61
|
+
if (host.includes("/")) {
|
|
62
|
+
return host;
|
|
63
|
+
}
|
|
62
64
|
return host.includes(":") ? `${host}/128` : `${host}/32`;
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -79,7 +81,9 @@ function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
|
79
81
|
let port: number | undefined;
|
|
80
82
|
if (entry.startsWith("[")) {
|
|
81
83
|
const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
|
|
82
|
-
if (match?.[1] === undefined)
|
|
84
|
+
if (match?.[1] === undefined) {
|
|
85
|
+
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
86
|
+
}
|
|
83
87
|
host = match[1];
|
|
84
88
|
// 端口组 (?::(\d+))? 可选:无端口时 at(2) 是 undefined
|
|
85
89
|
const portPart = match.at(2);
|
|
@@ -87,13 +91,19 @@ function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
|
87
91
|
} else {
|
|
88
92
|
const colon = entry.lastIndexOf(":");
|
|
89
93
|
if (colon === -1) {
|
|
90
|
-
if (entry.length === 0)
|
|
94
|
+
if (entry.length === 0) {
|
|
95
|
+
throw new Error(`Invalid allowlist entry ""`);
|
|
96
|
+
}
|
|
91
97
|
host = entry;
|
|
92
98
|
} else {
|
|
93
99
|
const portPart = entry.slice(colon + 1);
|
|
94
|
-
if (!/^\d+$/.test(portPart))
|
|
100
|
+
if (!/^\d+$/.test(portPart)) {
|
|
101
|
+
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
102
|
+
}
|
|
95
103
|
host = entry.slice(0, colon);
|
|
96
|
-
if (host.length === 0)
|
|
104
|
+
if (host.length === 0) {
|
|
105
|
+
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
106
|
+
}
|
|
97
107
|
if (host.includes(":")) {
|
|
98
108
|
throw new Error(`IPv6 addresses must be wrapped in brackets, e.g. "[${host}]:${portPart}"`);
|
|
99
109
|
}
|
|
@@ -101,7 +111,9 @@ function parseAllowlistEntry(entry: string): AllowlistEntry {
|
|
|
101
111
|
}
|
|
102
112
|
}
|
|
103
113
|
if (isIp(host)) {
|
|
104
|
-
if (!IP_CHARS_PATTERN.test(host))
|
|
114
|
+
if (!IP_CHARS_PATTERN.test(host)) {
|
|
115
|
+
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
116
|
+
}
|
|
105
117
|
} else if (!DOMAIN_PATTERN.test(host)) {
|
|
106
118
|
throw new Error(`Invalid allowlist entry "${entry}"`);
|
|
107
119
|
}
|