@trim21/personal-pi-extensions 0.1.611 → 0.1.613
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 +2 -2
- 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 +5 -3
- package/src/bwrap/approval-rules.ts +51 -18
- 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 +30 -10
- package/src/bwrap/runtime.ts +36 -14
- 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 +55 -22
- 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 +154 -58
- 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 +63 -41
- 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 +23 -9
- package/src/opencode/todo.ts +3 -1
- package/src/session-name.ts +40 -16
- package/src/spawn-agent-agents.ts +18 -6
- package/src/spawn-agent.ts +58 -21
- 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 +10 -5
- package/src/talk/registry.ts +35 -12
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.613",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"eslint-plugin-import-x": "^4.17.1",
|
|
53
53
|
"eslint-plugin-promise": "7.3.0",
|
|
54
54
|
"eslint-plugin-simple-import-sort": "14.0.0",
|
|
55
|
-
"eslint-plugin-unicorn": "
|
|
55
|
+
"eslint-plugin-unicorn": "76.0.0",
|
|
56
56
|
"eslint-plugin-unused-imports": "4.4.1",
|
|
57
57
|
"husky": "^9.1.7",
|
|
58
58
|
"jiti": "^2.7.0",
|
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
|
@@ -152,7 +152,7 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
|
|
|
152
152
|
}
|
|
153
153
|
const rawArgs = compactArgs({
|
|
154
154
|
target: filesMode ? target : resolved,
|
|
155
|
-
files: filesMode
|
|
155
|
+
files: filesMode || undefined,
|
|
156
156
|
includeTests: params.includeTests,
|
|
157
157
|
});
|
|
158
158
|
|
|
@@ -232,7 +232,7 @@ export function registerZoomTool(pi: ExtensionAPI, ctx: AftToolContext): void {
|
|
|
232
232
|
1,
|
|
233
233
|
Number.MAX_SAFE_INTEGER,
|
|
234
234
|
),
|
|
235
|
-
callgraph: coerceBoolean(params.callgraph)
|
|
235
|
+
callgraph: coerceBoolean(params.callgraph) || undefined,
|
|
236
236
|
});
|
|
237
237
|
|
|
238
238
|
const subtitle = buildZoomSubtitle(extCtx.cwd, params);
|
|
@@ -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
|
|
|
@@ -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,33 +119,48 @@ 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,`===` 比较失效会无限递归。
|
|
141
153
|
const nested: BashCommand[] = [];
|
|
142
154
|
for (const descendant of all) {
|
|
143
|
-
if (descendant === node)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
if (descendant === node) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (descendant.startIndex < node.startIndex || descendant.endIndex > node.endIndex) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const inner = collectCommand(descendant, all);
|
|
162
|
+
if (inner) {
|
|
163
|
+
nested.push(inner);
|
|
147
164
|
}
|
|
148
165
|
}
|
|
149
166
|
return { name, args, raw: node.text, nested };
|
|
@@ -161,7 +178,9 @@ export async function parseBashCommands(command: string): Promise<ParsedBash> {
|
|
|
161
178
|
const commands: BashCommand[] = [];
|
|
162
179
|
for (const node of all) {
|
|
163
180
|
const parsed = collectCommand(node, all);
|
|
164
|
-
if (parsed)
|
|
181
|
+
if (parsed) {
|
|
182
|
+
commands.push(parsed);
|
|
183
|
+
}
|
|
165
184
|
}
|
|
166
185
|
return { commands, hasFileOutputRedirect: treeHasFileOutputRedirect(tree.rootNode) };
|
|
167
186
|
} catch (error) {
|
|
@@ -184,7 +203,9 @@ export function matchRule(input: string, pattern: string): boolean {
|
|
|
184
203
|
.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`)
|
|
185
204
|
.replaceAll("*", ".*")
|
|
186
205
|
.replaceAll("?", ".");
|
|
187
|
-
if (escaped.endsWith(" .*"))
|
|
206
|
+
if (escaped.endsWith(" .*")) {
|
|
207
|
+
escaped = escaped.slice(0, -3) + "( .*)?";
|
|
208
|
+
}
|
|
188
209
|
return new RegExp(`^${escaped}$`, "s").test(input);
|
|
189
210
|
}
|
|
190
211
|
|
|
@@ -209,16 +230,28 @@ export async function evaluateBashApproval(
|
|
|
209
230
|
const raws: string[] = [];
|
|
210
231
|
const visit = (cmd: BashCommand) => {
|
|
211
232
|
raws.push(cmd.raw);
|
|
212
|
-
for (const nested of cmd.nested)
|
|
233
|
+
for (const nested of cmd.nested) {
|
|
234
|
+
visit(nested);
|
|
235
|
+
}
|
|
213
236
|
};
|
|
214
|
-
for (const cmd of parsed.commands)
|
|
215
|
-
|
|
237
|
+
for (const cmd of parsed.commands) {
|
|
238
|
+
visit(cmd);
|
|
239
|
+
}
|
|
240
|
+
if (raws.length === 0) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
216
243
|
let allowed = 0;
|
|
217
244
|
for (const raw of raws) {
|
|
218
245
|
const rule = rules.findLast((r) => matchRule(raw, r.pattern));
|
|
219
|
-
if (rule?.action === "deny")
|
|
220
|
-
|
|
246
|
+
if (rule?.action === "deny") {
|
|
247
|
+
return "deny";
|
|
248
|
+
}
|
|
249
|
+
if (rule?.action === "allow") {
|
|
250
|
+
allowed++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (parsed.hasFileOutputRedirect) {
|
|
254
|
+
return;
|
|
221
255
|
}
|
|
222
|
-
if (parsed.hasFileOutputRedirect) return;
|
|
223
256
|
return allowed === raws.length ? "allow" : undefined;
|
|
224
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
|
}
|
|
@@ -84,7 +84,9 @@ const HOLDER_PATH = fileURLToPath(new URL("holder.js", import.meta.url));
|
|
|
84
84
|
const SBIN_PATH_SUFFIX = "/usr/local/sbin:/usr/sbin:/sbin";
|
|
85
85
|
|
|
86
86
|
function killProcess(pid: number | undefined, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
87
|
-
if (!pid)
|
|
87
|
+
if (!pid) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
88
90
|
try {
|
|
89
91
|
process.kill(pid, signal);
|
|
90
92
|
} catch {
|
|
@@ -136,7 +138,9 @@ async function waitForNewUserns(pid: number, timeoutMs = 5000): Promise<void> {
|
|
|
136
138
|
const deadline = Date.now() + timeoutMs;
|
|
137
139
|
while (Date.now() < deadline) {
|
|
138
140
|
try {
|
|
139
|
-
if ((await readlink(`/proc/${pid}/ns/user`)) !== self)
|
|
141
|
+
if ((await readlink(`/proc/${pid}/ns/user`)) !== self) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
140
144
|
} catch {
|
|
141
145
|
// /proc/<pid> 尚未就绪,重试
|
|
142
146
|
}
|
|
@@ -153,12 +157,16 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
|
|
|
153
157
|
return new Promise((resolve, reject) => {
|
|
154
158
|
let settled = false;
|
|
155
159
|
const timer = setTimeout(() => {
|
|
156
|
-
if (settled)
|
|
160
|
+
if (settled) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
157
163
|
settled = true;
|
|
158
164
|
reject(new Error("Timed out waiting for mihomo to start"));
|
|
159
165
|
}, timeoutMs);
|
|
160
166
|
const onReady = (): void => {
|
|
161
|
-
if (settled)
|
|
167
|
+
if (settled) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
162
170
|
settled = true;
|
|
163
171
|
clearTimeout(timer);
|
|
164
172
|
resolve();
|
|
@@ -168,7 +176,9 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
|
|
|
168
176
|
if (stream) {
|
|
169
177
|
// 不提前停止:holder 生命周期内持续消费输出,避免流无消费者触发背压
|
|
170
178
|
void forEachLine(stream, (line) => {
|
|
171
|
-
if (line.includes("Tun adapter listening"))
|
|
179
|
+
if (line.includes("Tun adapter listening")) {
|
|
180
|
+
onReady();
|
|
181
|
+
}
|
|
172
182
|
}).catch(() => {
|
|
173
183
|
// 就绪判定由超时与 holder exit 兜底,流的 error 忽略
|
|
174
184
|
});
|
|
@@ -178,7 +188,9 @@ function waitForMihomoStarted(holder: ChildProcess, timeoutMs = 20000): Promise<
|
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
function killChild(pid: number | undefined): void {
|
|
181
|
-
if (!pid)
|
|
191
|
+
if (!pid) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
182
194
|
try {
|
|
183
195
|
process.kill(-pid, "SIGKILL");
|
|
184
196
|
} catch {
|
|
@@ -404,14 +416,20 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
404
416
|
execOptions.signal?.addEventListener("abort", onAbort, { once: true });
|
|
405
417
|
|
|
406
418
|
child.once("error", (error) => {
|
|
407
|
-
if (settled)
|
|
419
|
+
if (settled) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
408
422
|
settled = true;
|
|
409
423
|
reject(error);
|
|
410
424
|
});
|
|
411
425
|
child.once("close", (exitCode) => {
|
|
412
|
-
if (settled)
|
|
426
|
+
if (settled) {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
413
429
|
settled = true;
|
|
414
|
-
if (timeoutHandle)
|
|
430
|
+
if (timeoutHandle) {
|
|
431
|
+
clearTimeout(timeoutHandle);
|
|
432
|
+
}
|
|
415
433
|
execOptions.signal?.removeEventListener("abort", onAbort);
|
|
416
434
|
// 中断:reject signal.reason(默认是 name=AbortError 的 DOMException)
|
|
417
435
|
if (execOptions.signal?.aborted) {
|
|
@@ -423,7 +441,9 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
|
|
|
423
441
|
} else if (timedOut) {
|
|
424
442
|
// 超时:name=TimeoutError(对齐标准错误分类)
|
|
425
443
|
reject(new TimeoutError(execOptions.timeout));
|
|
426
|
-
} else
|
|
444
|
+
} else {
|
|
445
|
+
resolve({ exitCode });
|
|
446
|
+
}
|
|
427
447
|
});
|
|
428
448
|
});
|
|
429
449
|
},
|