finch-remote-host 0.0.0-stage → 1.0.0

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/dist/index.js ADDED
@@ -0,0 +1,1645 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join, posix } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ // finchapp/remote-host/src/audit.js
6
+ const OUTPUT_CHARS = 4e3;
7
+ const FILE_SCAN = 7;
8
+ /** 审计目录,按需创建。 */
9
+ function auditDir(ctx) {
10
+ const dir = join(ctx.storagePath, "audit");
11
+ mkdirSync(dir, { recursive: true });
12
+ return dir;
13
+ }
14
+ /** 追加一条审计:时间、主机、会话、命令、判定、结果。 */
15
+ function record(ctx, entry) {
16
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
17
+ const row = {
18
+ ts,
19
+ ...entry,
20
+ output: String(entry.output ?? "").slice(0, OUTPUT_CHARS)
21
+ };
22
+ appendFileSync(join(auditDir(ctx), `${ts.slice(0, 10)}.jsonl`), `${JSON.stringify(row)}\n`, "utf8");
23
+ }
24
+ /** 清掉某台主机的全部记录:逐文件过滤重写,别的主机一行不动。 */
25
+ function clearHost(ctx, hostId) {
26
+ const dir = auditDir(ctx);
27
+ for (const name of readdirSync(dir).filter((item) => item.endsWith(".jsonl"))) {
28
+ const path = join(dir, name);
29
+ const kept = readFileSync(path, "utf8").split("\n").filter(Boolean).filter((line) => {
30
+ try {
31
+ return JSON.parse(line).hostId !== hostId;
32
+ } catch {
33
+ return true;
34
+ }
35
+ });
36
+ if (kept.length) writeFileSync(path, `${kept.join("\n")}\n`, "utf8");
37
+ else rmSync(path, { force: true });
38
+ }
39
+ }
40
+ /** 读最近记录:从最新文件往回翻,凑够 limit 条为止。 */
41
+ function recent(ctx, { hostId, limit = 20 } = {}) {
42
+ const dir = auditDir(ctx);
43
+ const files = readdirSync(dir).filter((name) => name.endsWith(".jsonl")).toSorted().toReversed().slice(0, FILE_SCAN);
44
+ const rows = [];
45
+ for (const name of files) {
46
+ const lines = readFileSync(join(dir, name), "utf8").split("\n").filter(Boolean).toReversed();
47
+ for (const line of lines) {
48
+ let item;
49
+ try {
50
+ item = JSON.parse(line);
51
+ } catch {
52
+ continue;
53
+ }
54
+ if (hostId && item.hostId !== hostId) continue;
55
+ rows.push(item);
56
+ if (rows.length >= limit) return rows;
57
+ }
58
+ }
59
+ return rows;
60
+ }
61
+ // finchapp/remote-host/src/policy.js
62
+ const MODES = [
63
+ "query",
64
+ "readonly",
65
+ "confirm",
66
+ "trusted"
67
+ ];
68
+ const DELETE_COMMANDS = /* @__PURE__ */ new Set(["rm", "rmdir"]);
69
+ const ARG_STOPS = /* @__PURE__ */ new Set([
70
+ ";",
71
+ "&",
72
+ "&&",
73
+ "|",
74
+ "||",
75
+ "(",
76
+ ")",
77
+ ">",
78
+ ">>",
79
+ "<",
80
+ "2>",
81
+ "2>&1"
82
+ ]);
83
+ /** 一级目标:`/`、`/var`、`/var/`、`/var/*`、不带路径的通配,以及家目录的三种写法。 */
84
+ function isTopLevelPath(token) {
85
+ const bare = token.replace(/\/+$/, "").replace(/\/\*+$/, "");
86
+ if (bare === "" || bare === "*" || bare === "**") return true;
87
+ if (/^(?:~|\$HOME|\$\{HOME\})$/.test(bare)) return true;
88
+ return /^\/[^/*]+$/.test(bare);
89
+ }
90
+ function commandWord(token) {
91
+ return token.split("/").at(-1);
92
+ }
93
+ const PROTECTED_TREE = [
94
+ "/etc",
95
+ "/boot",
96
+ "/bin",
97
+ "/sbin",
98
+ "/lib",
99
+ "/lib32",
100
+ "/lib64",
101
+ "/libx32",
102
+ "/usr",
103
+ "/root",
104
+ "/var/www",
105
+ "/var/spool"
106
+ ];
107
+ const PROTECTED_CHILD = [
108
+ "/home",
109
+ "/opt",
110
+ "/srv",
111
+ "/var/lib"
112
+ ];
113
+ const PROTECTED_NAMES = [".ssh"];
114
+ /** 目标路径是否命中保护名单。 */
115
+ function isProtectedPath(token) {
116
+ const path = token.replace(/\/+$/, "");
117
+ if (!path) return false;
118
+ const parts = path.split("/");
119
+ if (PROTECTED_NAMES.some((name) => parts.includes(name))) return true;
120
+ if (PROTECTED_TREE.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return true;
121
+ return PROTECTED_CHILD.some((prefix) => path.startsWith(`${prefix}/`) && !path.slice(prefix.length + 1).includes("/"));
122
+ }
123
+ const DESTRUCTIVE_COMMANDS = /* @__PURE__ */ new Set([
124
+ "rm",
125
+ "rmdir",
126
+ "unlink",
127
+ "shred",
128
+ "truncate"
129
+ ]);
130
+ /**
131
+ * 命令是否在毁坏系统关键文件或目录:删除、清空、截断、写入。
132
+ * 与删除判定一样先去掉引号再分段,`sh -c "truncate -s 0 /etc/passwd"` 也能看见。
133
+ */
134
+ function touchesProtected(command) {
135
+ const text = String(command).replaceAll(/["']/g, " ");
136
+ for (const segment of splitSegments(text)) {
137
+ for (const match of segment.matchAll(/>{1,2}\s*(\S+)/g)) if (isProtectedPath(match[1])) return true;
138
+ const tokens = segment.split(/\s+/).filter(Boolean);
139
+ for (const [index, token] of tokens.entries()) {
140
+ const word = commandWord(token);
141
+ const args = tokens.slice(index + 1).filter((item) => !item.startsWith("-"));
142
+ if (word === "dd") {
143
+ if (args.some((item) => item.startsWith("of=") && isProtectedPath(item.slice(3)))) return true;
144
+ continue;
145
+ }
146
+ if (word === "find") {
147
+ if (tokens.includes("-delete") && args[0] && isProtectedPath(args[0])) return true;
148
+ continue;
149
+ }
150
+ if (!DESTRUCTIVE_COMMANDS.has(word)) continue;
151
+ for (const item of args) {
152
+ if (ARG_STOPS.has(item)) break;
153
+ if (isProtectedPath(item)) return true;
154
+ }
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+ /**
160
+ * 命令里是否在删根目录或某个一级目录。
161
+ * 先去引号再分段扫描,`sh -c "rm -rf /var"` 、`sudo rm -rf /var` 这类包装也能看见。
162
+ */
163
+ function deletesTopLevel(command) {
164
+ const text = String(command).replaceAll(/["']/g, " ");
165
+ for (const segment of splitSegments(text)) {
166
+ const tokens = segment.split(/\s+/).filter(Boolean);
167
+ for (const [index, token] of tokens.entries()) {
168
+ if (!DELETE_COMMANDS.has(commandWord(token))) continue;
169
+ for (const target of tokens.slice(index + 1)) {
170
+ if (ARG_STOPS.has(target)) break;
171
+ if (target.startsWith("-")) continue;
172
+ if (isTopLevelPath(target)) return true;
173
+ }
174
+ }
175
+ }
176
+ return false;
177
+ }
178
+ const HARD_DENY = [
179
+ {
180
+ name: "删除根目录或一级目录",
181
+ detail: "rm -rf /、rm -rf /var、rm -rf /var/*、rm -rf *",
182
+ test: deletesTopLevel
183
+ },
184
+ {
185
+ name: "毁坏系统关键路径",
186
+ detail: "删除、清空、截断、写入系统目录与密钥目录",
187
+ test: touchesProtected
188
+ },
189
+ {
190
+ name: "格式化文件系统",
191
+ detail: "mkfs 系列",
192
+ test: (text) => /\bmkfs(?:\.\w+)?\b/.test(text)
193
+ },
194
+ {
195
+ name: "写裸盘",
196
+ detail: "dd of=/dev/sd*、of=/dev/nvme* 等块设备",
197
+ test: (text) => /\bdd\b[^|;&]*\bof=\/dev\/(?:sd|nvme|hd|vd|mmcblk)/.test(text)
198
+ },
199
+ {
200
+ name: "重定向写裸盘",
201
+ detail: "> /dev/sd* 直接覆盖块设备",
202
+ test: (text) => />\s*\/dev\/(?:sd|nvme|hd|vd|mmcblk)/.test(text)
203
+ },
204
+ {
205
+ name: "fork 炸弹",
206
+ detail: ":(){ :|:& };: 这类无限分叉",
207
+ test: (text) => /:\s*\(\s*\)\s*\{[^}]*\|[^}]*&/.test(text)
208
+ },
209
+ {
210
+ name: "关机或重启",
211
+ detail: "shutdown、reboot、halt、poweroff、init 0",
212
+ test: (text) => /^\s*(?:shutdown|reboot|halt|poweroff)\b|^\s*init\s+0\b/.test(text)
213
+ },
214
+ {
215
+ name: "放开根目录权限",
216
+ detail: "chmod 777 /",
217
+ test: (text) => /\bchmod\s+(?:-[A-Za-z]+\s+)*777\s+\/(?=\s|$)/.test(text)
218
+ }
219
+ ];
220
+ /**
221
+ * 内置强制规则的可展示版:面板直接拿它渲染,不另拄一份文案。
222
+ * 这些规则在任何模式下都生效,用户改不了,自定义规则只能在它们之外起作用。
223
+ */
224
+ const BUILTIN_RULES = {
225
+ deny: HARD_DENY.map((rule) => ({
226
+ name: rule.name,
227
+ detail: rule.detail
228
+ })),
229
+ protectedTree: PROTECTED_TREE,
230
+ protectedChild: PROTECTED_CHILD,
231
+ protectedNames: PROTECTED_NAMES,
232
+ destructive: [...DESTRUCTIVE_COMMANDS]
233
+ };
234
+ const READONLY_COMMANDS = /* @__PURE__ */ new Set([
235
+ "ls",
236
+ "dir",
237
+ "cat",
238
+ "head",
239
+ "tail",
240
+ "grep",
241
+ "rg",
242
+ "wc",
243
+ "stat",
244
+ "file",
245
+ "du",
246
+ "df",
247
+ "free",
248
+ "uptime",
249
+ "whoami",
250
+ "id",
251
+ "pwd",
252
+ "hostname",
253
+ "uname",
254
+ "ps",
255
+ "date",
256
+ "echo",
257
+ "which",
258
+ "whereis",
259
+ "ss",
260
+ "netstat",
261
+ "lsof",
262
+ "ping",
263
+ "dig",
264
+ "nslookup",
265
+ "host",
266
+ "tree",
267
+ "jq",
268
+ "sort",
269
+ "uniq",
270
+ "cut",
271
+ "tr",
272
+ "md5sum",
273
+ "sha256sum",
274
+ "basename",
275
+ "dirname",
276
+ "realpath",
277
+ "readlink",
278
+ "nproc",
279
+ "arch",
280
+ "lsblk",
281
+ "w",
282
+ "users",
283
+ "last",
284
+ "vmstat",
285
+ "iostat"
286
+ ]);
287
+ const WRITE_ARGUMENTS = [
288
+ {
289
+ name: "sort 写文件",
290
+ match: /^sort\b[^|]*\s-o\b/
291
+ },
292
+ {
293
+ name: "uniq 写文件",
294
+ match: /^uniq\b\s+\S+\s+\S+/
295
+ },
296
+ {
297
+ name: "date 改系统时间",
298
+ match: /^date\b[^|]*\s-[A-Za-z]*s/
299
+ },
300
+ {
301
+ name: "hostname 改名",
302
+ match: /^hostname\s+\S/
303
+ },
304
+ {
305
+ name: "find 带写动作",
306
+ match: /^find\b[^|]*\s-(?:fprint|fprint0|fls|delete|exec|execdir|ok)\b/
307
+ },
308
+ {
309
+ name: "ping 洪水",
310
+ match: /^ping\b[^|]*\s-f\b/
311
+ }
312
+ ];
313
+ const READONLY_PHRASES = [
314
+ /^systemctl\s+(?:status|is-active|is-enabled|is-failed|list-units|list-timers|show)\b/,
315
+ /^journalctl\b/,
316
+ /^docker\s+(?:ps|images|logs|inspect|stats|version|info)\b/,
317
+ /^podman\s+(?:ps|images|logs|inspect)\b/,
318
+ /^git\s+(?:status|log|diff|show|branch|remote|rev-parse|describe)\b/,
319
+ /^pm2\s+(?:list|status|logs|describe)\b/,
320
+ /^nginx\s+-t\b/,
321
+ /^mysqladmin\s+status\b/
322
+ ];
323
+ const WRITE_MARKERS = [
324
+ ">",
325
+ "<",
326
+ "tee ",
327
+ "--delete",
328
+ "-delete",
329
+ "-exec",
330
+ "sed -i",
331
+ "chmod ",
332
+ "chown ",
333
+ "$(",
334
+ "`",
335
+ "${",
336
+ "<<"
337
+ ];
338
+ const SENSITIVE = [
339
+ /(^|\s)\.env\b/,
340
+ /\bid_(?:rsa|ed25519|ecdsa|dsa)\b/,
341
+ /\.pem\b/,
342
+ /\/etc\/(?:shadow|sudoers)/,
343
+ /\bauthorized_keys\b/
344
+ ];
345
+ /**
346
+ * glob 匹配:优先用 Node 内置的 path.posix.matchesGlob,取不到时退回自写实现。
347
+ * 固定 posix 语义,Windows 上也一样:`*` 不跨 `/`,跨层级用 `**`。
348
+ */
349
+ const matchesGlob = typeof posix.matchesGlob === "function" ? posix.matchesGlob : (text, pattern) => patternToRegExp(pattern).test(text);
350
+ /** 把 glob 编译成正则,语义与 posix.matchesGlob 对齐,留给老运行时兜底。 */
351
+ function patternToRegExp(pattern) {
352
+ let source = "^";
353
+ for (let i = 0; i < pattern.length; i += 1) {
354
+ const char = pattern[i];
355
+ const next = pattern[i + 1];
356
+ if (char === "*" && next === "*") {
357
+ if (pattern[i + 2] === "/") {
358
+ source += "(?:.*/)?";
359
+ i += 2;
360
+ } else {
361
+ source += ".*";
362
+ i += 1;
363
+ }
364
+ } else if (char === "*") source += "[^/]*";
365
+ else if (char === "?") source += "[^/]";
366
+ else source += char.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&");
367
+ }
368
+ return new RegExp(`${source}$`);
369
+ }
370
+ /** 把多行文本或数组统一成规则列表,空行丢掉,顺手去重。 */
371
+ function toRules(value) {
372
+ const list = Array.isArray(value) ? value : String(value ?? "").split(/\r?\n/).map((item) => item.trim());
373
+ const rules = [];
374
+ for (const item of list) {
375
+ const rule = String(item ?? "").trim();
376
+ if (rule && !rules.includes(rule)) rules.push(rule);
377
+ }
378
+ return rules;
379
+ }
380
+ /**
381
+ * 按顶层分隔符切段:引号、转义、括号与反引号内部的符号不算分隔。
382
+ * 拿不准时宁可不切——切不开的那段会被当成整段判断,失败方向是更严。
383
+ */
384
+ function splitSegments(command) {
385
+ const segments = [];
386
+ let buffer = "";
387
+ let quote = "";
388
+ let depth = 0;
389
+ let tick = false;
390
+ for (let i = 0; i < command.length; i += 1) {
391
+ const char = command[i];
392
+ const next = command[i + 1];
393
+ if (tick) {
394
+ buffer += char;
395
+ if (char === "\\") {
396
+ buffer += next ?? "";
397
+ i += 1;
398
+ } else if (char === "`") tick = false;
399
+ continue;
400
+ }
401
+ if (quote === "'") {
402
+ buffer += char;
403
+ if (char === "'") quote = "";
404
+ continue;
405
+ }
406
+ if (quote === "\"") {
407
+ buffer += char;
408
+ if (char === "\\") {
409
+ buffer += next ?? "";
410
+ i += 1;
411
+ } else if (char === "\"") quote = "";
412
+ continue;
413
+ }
414
+ if (char === "'" || char === "\"") {
415
+ quote = char;
416
+ buffer += char;
417
+ continue;
418
+ }
419
+ if (char === "\\") {
420
+ buffer += char + (next ?? "");
421
+ i += 1;
422
+ continue;
423
+ }
424
+ if (char === "`") {
425
+ tick = true;
426
+ buffer += char;
427
+ continue;
428
+ }
429
+ if (char === "(") {
430
+ depth += 1;
431
+ buffer += char;
432
+ continue;
433
+ }
434
+ if (char === ")") {
435
+ depth = Math.max(0, depth - 1);
436
+ buffer += char;
437
+ continue;
438
+ }
439
+ if (depth > 0) {
440
+ buffer += char;
441
+ continue;
442
+ }
443
+ const pair = char + (next ?? "");
444
+ if (pair === "&&" || pair === "||") {
445
+ segments.push(buffer);
446
+ buffer = "";
447
+ i += 1;
448
+ continue;
449
+ }
450
+ if (char === ";" || char === "|" || char === "&" || char === "\n") {
451
+ segments.push(buffer);
452
+ buffer = "";
453
+ continue;
454
+ }
455
+ buffer += char;
456
+ }
457
+ segments.push(buffer);
458
+ return segments.map((item) => item.trim()).filter(Boolean);
459
+ }
460
+ /** 单段是否只读:白名单命令、无写标记、不碰写参数。 */
461
+ function isReadonlySegment(segment) {
462
+ if (!segment) return false;
463
+ if (WRITE_MARKERS.some((marker) => segment.includes(marker))) return false;
464
+ if (WRITE_ARGUMENTS.some((rule) => rule.match.test(segment))) return false;
465
+ if (READONLY_PHRASES.some((rule) => rule.test(segment))) return true;
466
+ return READONLY_COMMANDS.has(segment.split(/\s+/)[0]);
467
+ }
468
+ /** 按模式给一段命令定性;返回 null 表示这一段需要确认。 */
469
+ function judgeByMode(segment, mode) {
470
+ const readonly = isReadonlySegment(segment);
471
+ const sensitive = SENSITIVE.some((rule) => rule.test(segment));
472
+ if (mode === "trusted") return {
473
+ decision: "allow",
474
+ reason: "信任模式"
475
+ };
476
+ if (sensitive) return mode === "query" ? {
477
+ decision: "deny",
478
+ reason: "查询模式不允许读凭据文件"
479
+ } : {
480
+ decision: "confirm",
481
+ reason: "涉及凭据文件"
482
+ };
483
+ if (readonly) return {
484
+ decision: "allow",
485
+ reason: "只读命令"
486
+ };
487
+ if (mode === "query" || mode === "readonly") return {
488
+ decision: "deny",
489
+ reason: `当前为${mode === "query" ? "查询" : "只读"}模式,这条命令可能写磁盘`
490
+ };
491
+ return {
492
+ decision: "confirm",
493
+ reason: "非只读命令"
494
+ };
495
+ }
496
+ /**
497
+ * 给一条命令定性。
498
+ * 返回 { decision: "allow" | "confirm" | "deny", hits: string[], pending: string[] },
499
+ * hits 是逐段理由(给人看),pending 只装需要确认的那几段。
500
+ */
501
+ function classify(command, policy = {}) {
502
+ const text = String(command ?? "").trim();
503
+ if (!text) return {
504
+ decision: "deny",
505
+ hits: ["空命令"],
506
+ pending: []
507
+ };
508
+ for (const rule of HARD_DENY) if (rule.test(text)) return {
509
+ decision: "deny",
510
+ hits: [`硬拒:${rule.name}`],
511
+ pending: []
512
+ };
513
+ const denyRules = toRules(policy.denyGlob);
514
+ const allowRules = toRules(policy.allowGlob);
515
+ const mode = MODES.includes(policy.mode) ? policy.mode : "confirm";
516
+ const segments = splitSegments(text);
517
+ if (!segments.length) return {
518
+ decision: "deny",
519
+ hits: ["空命令"],
520
+ pending: []
521
+ };
522
+ const hits = [];
523
+ const pending = [];
524
+ let confirmNeeded = false;
525
+ for (const [index, segment] of segments.entries()) {
526
+ const label = `第 ${index + 1} 段「${segment.length > 60 ? `${segment.slice(0, 60)}…` : segment}」`;
527
+ const denied = denyRules.find((rule) => matchesGlob(segment, rule));
528
+ if (denied) return {
529
+ decision: "deny",
530
+ hits: [`${label}命中拒绝规则 ${denied}`],
531
+ pending: []
532
+ };
533
+ const allowed = allowRules.find((rule) => matchesGlob(segment, rule));
534
+ if (allowed) {
535
+ hits.push(`${label}命中放行规则 ${allowed}`);
536
+ continue;
537
+ }
538
+ const verdict = judgeByMode(segment, mode);
539
+ hits.push(`${label}${verdict.reason}`);
540
+ if (verdict.decision === "deny") return {
541
+ decision: "deny",
542
+ hits,
543
+ pending: []
544
+ };
545
+ if (verdict.decision === "confirm") {
546
+ confirmNeeded = true;
547
+ pending.push(`${label}${verdict.reason}`);
548
+ }
549
+ }
550
+ return {
551
+ decision: confirmNeeded ? "confirm" : "allow",
552
+ hits,
553
+ pending
554
+ };
555
+ }
556
+ // finchapp/remote-host/src/hosts.js
557
+ const HOSTS_KEY = "hosts";
558
+ const RUNS_KEY = "runs";
559
+ const AUTH_MODES = ["key", "password"];
560
+ const HOST_PATTERN = /^[A-Za-z0-9._:[\]-]+$/;
561
+ const USER_PATTERN = /^[A-Za-z0-9._-]+$/;
562
+ /** 读全部主机;数据缺失或格式坏掉时当作空清单,顺手把策略字段补齐。 */
563
+ async function listHosts(ctx) {
564
+ const stored = await ctx.storage.get(HOSTS_KEY);
565
+ if (!Array.isArray(stored)) return [];
566
+ return stored.filter((item) => item && typeof item.id === "string" && typeof item.host === "string").map((item) => ({
567
+ ...item,
568
+ policy: {
569
+ mode: MODES.includes(item.policy?.mode) ? item.policy.mode : "confirm",
570
+ allowGlob: toRules(item.policy?.allowGlob),
571
+ denyGlob: toRules(item.policy?.denyGlob)
572
+ }
573
+ }));
574
+ }
575
+ /** 按 id 或名称找主机;名称不区分大小写。 */
576
+ async function findHost(ctx, key) {
577
+ const needle = String(key ?? "").trim().toLowerCase();
578
+ if (!needle) return;
579
+ const hosts = await listHosts(ctx);
580
+ return hosts.find((item) => item.id.toLowerCase() === needle) ?? hosts.find((item) => String(item.name).toLowerCase() === needle);
581
+ }
582
+ /** 规整并校验一条主机配置,非法值直接抛错(菜单里会原样显示)。 */
583
+ function normalizeHost(input, previous) {
584
+ const name = String(input.name ?? "").trim() || previous?.name || "未命名主机";
585
+ const host = String(input.host ?? "").trim();
586
+ if (!HOST_PATTERN.test(host)) throw new Error(`地址不合法:${host || "空"}`);
587
+ const user = String(input.user ?? "").trim();
588
+ if (!USER_PATTERN.test(user)) throw new Error(`用户名不合法:${user || "空"}`);
589
+ const port = Number(input.port ?? previous?.port ?? 22);
590
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`端口不合法:${input.port}`);
591
+ const auth = AUTH_MODES.includes(input.auth) ? input.auth : previous?.auth ?? "key";
592
+ let keyPath = "";
593
+ if (auth === "key") {
594
+ keyPath = String(input.keyPath ?? "").trim();
595
+ if (!isAbsolute(keyPath)) throw new Error("密钥必须是绝对路径");
596
+ }
597
+ const mode = MODES.includes(input.mode) ? input.mode : previous?.policy?.mode ?? "confirm";
598
+ return {
599
+ name,
600
+ host,
601
+ port,
602
+ user,
603
+ auth,
604
+ keyPath,
605
+ policy: {
606
+ mode,
607
+ allowGlob: toRules(input.allowGlob ?? input.allow ?? previous?.policy?.allowGlob),
608
+ denyGlob: toRules(input.denyGlob ?? input.deny ?? previous?.policy?.denyGlob)
609
+ }
610
+ };
611
+ }
612
+ /** 密码在 secrets 里的 key,与 manifest 声明的 host.* 前缀一致。 */
613
+ function passwordKey(id) {
614
+ return `host.${id}.password`;
615
+ }
616
+ /** 该主机是否已存过密码。 */
617
+ async function hasPassword(ctx, id) {
618
+ return await ctx.secrets.get(passwordKey(id)) !== void 0;
619
+ }
620
+ /** 读密码;密钥认证的主机返回 undefined。 */
621
+ async function readPassword(ctx, id) {
622
+ return ctx.secrets.get(passwordKey(id));
623
+ }
624
+ /** 保存主机:同 id 覆盖,否则追加。密码同步写进或清出 secrets。 */
625
+ async function saveHost(ctx, input) {
626
+ const hosts = await listHosts(ctx);
627
+ const previous = input.id ? hosts.find((item) => item.id === input.id) : void 0;
628
+ const record = {
629
+ ...normalizeHost(input, previous),
630
+ id: previous?.id ?? randomUUID().slice(0, 8)
631
+ };
632
+ const password = String(input.password ?? "");
633
+ if (record.auth === "password") {
634
+ if (password) await ctx.secrets.set(passwordKey(record.id), password);
635
+ else if (!await hasPassword(ctx, record.id)) throw new Error("密码认证需要填一次密码");
636
+ } else await ctx.secrets.delete(passwordKey(record.id));
637
+ const next = previous ? hosts.map((item) => item.id === previous.id ? record : item) : [...hosts, record];
638
+ await ctx.storage.set(HOSTS_KEY, next);
639
+ return record;
640
+ }
641
+ /** 删除主机,连同它的最近执行记录与密码。 */
642
+ async function removeHost(ctx, id) {
643
+ const hosts = await listHosts(ctx);
644
+ await ctx.storage.set(HOSTS_KEY, hosts.filter((item) => item.id !== id));
645
+ const runs = await listRuns(ctx);
646
+ delete runs[id];
647
+ await ctx.storage.set(RUNS_KEY, runs);
648
+ await ctx.secrets.delete(passwordKey(id));
649
+ }
650
+ /** 记录某台主机最近一次执行:退出码、耗时、命令摘要。 */
651
+ async function stampRun(ctx, id, run) {
652
+ const runs = await listRuns(ctx);
653
+ runs[id] = {
654
+ at: (/* @__PURE__ */ new Date()).toISOString(),
655
+ ...run
656
+ };
657
+ await ctx.storage.set(RUNS_KEY, runs);
658
+ }
659
+ /** 读最近执行表,缺省为空对象。 */
660
+ async function listRuns(ctx) {
661
+ const runs = await ctx.storage.get(RUNS_KEY);
662
+ return runs && typeof runs === "object" && !Array.isArray(runs) ? runs : {};
663
+ }
664
+ // finchapp/remote-host/src/ssh.js
665
+ const queues = /* @__PURE__ */ new Map();
666
+ const HEAD_CHARS = 6e3;
667
+ const TAIL_CHARS = 3e3;
668
+ const SSH_FAILURE = 255;
669
+ const CAPTURE_CHARS = 4194304;
670
+ const REPORT_MS = 1200;
671
+ const COMMON_ARGS = [
672
+ "-T",
673
+ "-o",
674
+ "StrictHostKeyChecking=accept-new",
675
+ "-o",
676
+ "ConnectTimeout=10",
677
+ "-o",
678
+ "ServerAliveInterval=30",
679
+ "-o",
680
+ "ServerAliveCountMax=4",
681
+ "-o",
682
+ "LogLevel=ERROR"
683
+ ];
684
+ /** 定位系统 OpenSSH 客户端:Windows 用系统自带那份,其余平台交给 PATH 里的 ssh。 */
685
+ function sshBinary() {
686
+ if (process.platform !== "win32") return "ssh";
687
+ const root = process.env.SystemRoot;
688
+ if (!root) throw new Error("读不到 SystemRoot,无法定位系统 ssh");
689
+ const file = join(root, "System32", "OpenSSH", "ssh.exe");
690
+ if (!existsSync(file)) throw new Error(`系统没有 OpenSSH 客户端:${file}`);
691
+ return file;
692
+ }
693
+ /**
694
+ * askpass 助手:ssh 需要密码时执行它,它只回显环境变量。
695
+ * 文件本身不含密码,密码通过子进程环境变量传入,用完即随进程消失。
696
+ * Windows 是 .bat,其余平台是带可执行位的小脚本——ssh 会直接 exec 它。
697
+ */
698
+ function askpassHelper(storagePath) {
699
+ mkdirSync(storagePath, { recursive: true });
700
+ const windows = process.platform === "win32";
701
+ const file = join(storagePath, windows ? "askpass.bat" : "askpass.sh");
702
+ if (!existsSync(file)) {
703
+ writeFileSync(file, windows ? "@echo off\r\necho %FINCH_SSH_PASS%\r\n" : "#!/bin/sh\nprintf '%s\\n' \"$FINCH_SSH_PASS\"\n", "utf8");
704
+ if (!windows) chmodSync(file, 448);
705
+ }
706
+ return file;
707
+ }
708
+ /** 按认证方式拼参数与子进程环境。 */
709
+ function buildInvocation(host, command, { askpassPath, password }) {
710
+ const target = `${host.user}@${host.host}`;
711
+ const port = ["-p", String(host.port)];
712
+ if (host.auth === "password") return {
713
+ args: [
714
+ ...COMMON_ARGS,
715
+ "-o",
716
+ "BatchMode=no",
717
+ "-o",
718
+ "NumberOfPasswordPrompts=1",
719
+ "-o",
720
+ "PreferredAuthentications=password,keyboard-interactive",
721
+ "-o",
722
+ "PubkeyAuthentication=no",
723
+ ...port,
724
+ target,
725
+ command
726
+ ],
727
+ env: {
728
+ ...process.env,
729
+ SSH_ASKPASS: askpassPath,
730
+ SSH_ASKPASS_REQUIRE: "force",
731
+ DISPLAY: ":0",
732
+ FINCH_SSH_PASS: String(password ?? "")
733
+ }
734
+ };
735
+ return {
736
+ args: [
737
+ ...COMMON_ARGS,
738
+ "-o",
739
+ "BatchMode=yes",
740
+ "-o",
741
+ "PreferredAuthentications=publickey",
742
+ "-i",
743
+ host.keyPath,
744
+ ...port,
745
+ target,
746
+ command
747
+ ],
748
+ env: process.env
749
+ };
750
+ }
751
+ /**
752
+ * 单次执行:边跑边收集,超时或取消时杀掉本地进程。
753
+ * `onProgress` 只在运行期间收到节流后的快照,供调用方刷进度文案。
754
+ */
755
+ function execute(host, { command, timeoutMs, signal, askpassPath, password, onProgress }) {
756
+ const { args, env } = buildInvocation(host, command, {
757
+ askpassPath,
758
+ password
759
+ });
760
+ return new Promise((resolve, reject) => {
761
+ const child = spawn(sshBinary(), args, {
762
+ windowsHide: true,
763
+ env
764
+ });
765
+ const startedAt = Date.now();
766
+ let stdout = "";
767
+ let stderr = "";
768
+ let overflow = false;
769
+ let lastReport = 0;
770
+ let settled = false;
771
+ /** 只结算一次,并清掉计时器与监听。 */
772
+ const finish = (fn, value) => {
773
+ if (settled) return;
774
+ settled = true;
775
+ clearTimeout(timer);
776
+ signal?.removeEventListener("abort", stop);
777
+ fn(value);
778
+ };
779
+ /** 杀掉本地 ssh;远端会随连接断开一起结束。 */
780
+ const stop = () => child.kill();
781
+ const timer = setTimeout(() => {
782
+ child.kill();
783
+ finish(reject, /* @__PURE__ */ new Error(`命令超时(${Math.round(timeoutMs / 1e3)} 秒),已在本地中断`));
784
+ }, timeoutMs);
785
+ if (signal?.aborted) stop();
786
+ else signal?.addEventListener("abort", stop, { once: true });
787
+ /** 追加一块输出;超过上限就停下不再扩大内存。 */
788
+ const append = (key, chunk) => {
789
+ const text = String(chunk);
790
+ if (overflow) return;
791
+ if (stdout.length + stderr.length + text.length > CAPTURE_CHARS) {
792
+ overflow = true;
793
+ return;
794
+ }
795
+ if (key === "out") stdout += text;
796
+ else stderr += text;
797
+ };
798
+ const tick = () => {
799
+ const now = Date.now();
800
+ if (now - lastReport < REPORT_MS) return;
801
+ lastReport = now;
802
+ const all = `${stdout}\n${stderr}`.trimEnd();
803
+ const lines = all ? all.split("\n").length : 0;
804
+ const last = all ? all.split("\n").at(-1).trim().slice(0, 80) : "";
805
+ onProgress?.({
806
+ lines,
807
+ chars: stdout.length + stderr.length,
808
+ last,
809
+ seconds: Math.round((now - startedAt) / 1e3)
810
+ });
811
+ };
812
+ child.stdout.on("data", (chunk) => {
813
+ append("out", chunk);
814
+ tick();
815
+ });
816
+ child.stderr.on("data", (chunk) => {
817
+ append("err", chunk);
818
+ tick();
819
+ });
820
+ child.on("error", (error) => finish(reject, new Error(error.code === "ENOENT" ? "系统没有 OpenSSH 客户端,ssh 不在 PATH 里" : error.message)));
821
+ child.on("close", (code) => {
822
+ const output = [stdout, stderr].filter(Boolean).join("\n").trim();
823
+ const tail = overflow ? `${output}\n…(输出超过 ${CAPTURE_CHARS} 字符,后续已丢弃)` : output;
824
+ if (code === SSH_FAILURE) {
825
+ finish(reject, new Error(tail || "ssh 连接失败(地址、端口、密钥或密码不对)"));
826
+ return;
827
+ }
828
+ finish(resolve, {
829
+ exitCode: code ?? 0,
830
+ output: tail
831
+ });
832
+ });
833
+ });
834
+ }
835
+ /** 在目标主机上跑一条命令;同一台主机排队,失败不影响队列。 */
836
+ function runRemote(host, options) {
837
+ const task = (queues.get(host.id) ?? Promise.resolve()).then(() => execute(host, options), () => execute(host, options));
838
+ queues.set(host.id, task.then(() => {}, () => {}));
839
+ return task;
840
+ }
841
+ /** 输出超长时只留头尾,并标出截掉了多少字符。 */
842
+ function clip(text) {
843
+ const value = String(text ?? "");
844
+ if (value.length <= 9e3) return value;
845
+ const cut = value.length - HEAD_CHARS - TAIL_CHARS;
846
+ return `${value.slice(0, HEAD_CHARS)}\n…(截断 ${cut} 字符)…\n${value.slice(-3e3)}`;
847
+ }
848
+ // finchapp/remote-host/src/tasks.js
849
+ const TASKS_KEY = "tasks";
850
+ const MAX_TASKS = 50;
851
+ /** 读全部任务,最新的在前。 */
852
+ async function listTasks(ctx) {
853
+ const stored = await ctx.storage.get(TASKS_KEY);
854
+ if (!Array.isArray(stored)) return [];
855
+ return stored.filter((item) => item && typeof item.id === "string" && typeof item.pid === "number");
856
+ }
857
+ /** 按 id 找任务。 */
858
+ async function findTask(ctx, id) {
859
+ return (await listTasks(ctx)).find((item) => item.id === id);
860
+ }
861
+ /** 新增一条任务记录(id 由调用方带进来),顺手把最老的记录挤掉。 */
862
+ async function createTask(ctx, task) {
863
+ const tasks = await listTasks(ctx);
864
+ const record = {
865
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
866
+ ...task
867
+ };
868
+ const next = [record, ...tasks].slice(0, MAX_TASKS);
869
+ await ctx.storage.set(TASKS_KEY, next);
870
+ return record;
871
+ }
872
+ /** 局部更新一条任务,找不到就当无事发生。 */
873
+ async function updateTask(ctx, id, patch) {
874
+ const next = (await listTasks(ctx)).map((item) => item.id === id ? {
875
+ ...item,
876
+ ...patch
877
+ } : item);
878
+ await ctx.storage.set(TASKS_KEY, next);
879
+ return next.find((item) => item.id === id);
880
+ }
881
+ // finchapp/remote-host/src/index.js
882
+ const ACTIONS = [
883
+ "hosts",
884
+ "exec",
885
+ "start",
886
+ "status",
887
+ "stop",
888
+ "audit"
889
+ ];
890
+ const TIMEOUT_DEFAULT_S = 120;
891
+ const TIMEOUT_MAX_S = 600;
892
+ const AUDIT_MAX = 200;
893
+ const AUDIT_PANEL = 60;
894
+ const AUDIT_PANEL_CHARS = 2e3;
895
+ const PROBE_COMMAND = "echo finch-ok";
896
+ const TOOL_DESCRIPTION = `在用户已配置的远程 Linux 主机上跑命令(走本机 OpenSSH,密钥与密码两种认证都支持)。
897
+
898
+ 什么时候用:要读服务器上的文件、查服务状态、看日志、跑运维命令时用;本机命令用本地终端工具,不要走这里。
899
+
900
+ action:
901
+ hosts 列主机:id、名称、用户@地址、认证方式、策略模式、上次结果。第一次动手前先调它
902
+ exec 前台跑一条命令(需 command),默认最多 ${TIMEOUT_DEFAULT_S} 秒
903
+ start 远端后台跑(需 command),立即返回任务 id;预计超过 10 分钟就用它
904
+ status 查后台任务:还活着吗、行数、日志尾巴、退出码;不传 taskId 就列最近任务
905
+ stop 停后台任务(先 TERM 再 KILL),clean=true 顺手删远端日志
906
+ audit 最近执行记录:命令、判定、退出码、耗时;可按 hostId 过滤
907
+
908
+ 主机:不传 hostId 就用输入框下方选定的那台。没有可用主机时让用户到「远程主机」面板添加,Agent 不能新增或修改主机与策略,也不要向用户索要密码或私钥。
909
+
910
+ 策略:判定为确认会弹卡片,用户点了才跑;返回「策略拒绝执行」表示当前模式不允许,不要换写法绕过,直接告诉用户当前模式与实际需求。
911
+
912
+ 输出:头 ${HEAD_CHARS} 字符 + 尾 ${TAIL_CHARS} 字符,中间截断,命令里自己过滤(tail / grep / --max-count)比事后翻更省事。`;
913
+ /** 统一包装成功输出。 */
914
+ function text(value) {
915
+ return { content: [{
916
+ type: "text",
917
+ text: value
918
+ }] };
919
+ }
920
+ /** 统一包装失败输出:告诉模型错在哪,方便它自己修正或转告用户。 */
921
+ function fail(value) {
922
+ return {
923
+ content: [{
924
+ type: "text",
925
+ text: value
926
+ }],
927
+ isError: true
928
+ };
929
+ }
930
+ /** 策略模式的中文名。 */
931
+ function modeLabel(mode) {
932
+ return {
933
+ query: "仅查询",
934
+ readonly: "只读",
935
+ confirm: "每次确认",
936
+ trusted: "完全信任"
937
+ }[mode] ?? mode;
938
+ }
939
+ /** 认证方式的中文名。 */
940
+ function authLabel(auth) {
941
+ return auth === "password" ? "密码" : "密钥";
942
+ }
943
+ /** 时间戳压成 yyyy-mm-dd hh:mm;审计与状态行共用。 */
944
+ function shortTime(iso) {
945
+ return String(iso ?? "").slice(0, 16).replace("T", " ");
946
+ }
947
+ /** 把主机清单渲染成模型能直接读的文本;selectedId 标记当前会话选定的那台。 */
948
+ function renderHosts(hosts, runs, selectedId) {
949
+ if (!hosts.length) return "还没有配置任何主机。需要用户在「远程主机」小程序的面板里新增,Agent 无法自行添加。";
950
+ const lines = hosts.map((host) => {
951
+ const run = runs[host.id];
952
+ const last = run ? `;上次退出码 ${run.exitCode},${shortTime(run.at)}` : ";还没执行过";
953
+ const mark = host.id === selectedId ? "(当前会话)" : "";
954
+ return `- ${host.name}${mark}(id: ${host.id})${host.user}@${host.host}:${host.port},${authLabel(host.auth)}认证,模式 ${modeLabel(host.policy.mode)}${last}`;
955
+ });
956
+ return `共 ${hosts.length} 台主机:\n${lines.join("\n")}`;
957
+ }
958
+ /** 把审计记录渲染成一行一条的简表。 */
959
+ function renderAudit(rows) {
960
+ if (!rows.length) return "没有审计记录。";
961
+ return rows.map((row) => {
962
+ const command = String(row.command ?? "").replace(/\s+/g, " ").slice(0, 120);
963
+ const code = row.exitCode === null || row.exitCode === void 0 ? "-" : row.exitCode;
964
+ return `${shortTime(row.ts)} ${row.hostName}|${row.decision}|退出码 ${code}|${command}`;
965
+ }).join("\n");
966
+ }
967
+ /** 把执行结果渲染成模型能直接读的文本。 */
968
+ function renderResult(host, command, result, durationMs) {
969
+ const output = clip(result.output) || "(无输出)";
970
+ return [
971
+ `${host.name} ${host.user}@${host.host}`,
972
+ `$ ${command}`,
973
+ `退出码 ${result.exitCode},耗时 ${(durationMs / 1e3).toFixed(1)} 秒`,
974
+ "---",
975
+ output
976
+ ].join("\n");
977
+ }
978
+ /** 用单引号包住一段远端 shell 文本,单引号本身转义成 '\''。 */
979
+ function shellQuote(value) {
980
+ return `'${String(value).replaceAll("'", `'\\''`)}'`;
981
+ }
982
+ /** 后台任务在远端的三份伴生文件:输出日志、退出码、真实 worker pid。 */
983
+ function taskPaths(id) {
984
+ return {
985
+ logPath: `/tmp/finch-${id}.log`,
986
+ exitPath: `/tmp/finch-${id}.exit`,
987
+ pidPath: `/tmp/finch-${id}.pid`
988
+ };
989
+ }
990
+ /** 进程号要大于 1 才算数:0 和 1 是内核进程,拿它们按组 kill 会出事。 */
991
+ function validPid(value) {
992
+ const pid = Number(value);
993
+ return Number.isInteger(pid) && pid > 1 ? pid : 0;
994
+ }
995
+ /** 把任务状态渲染成模型能直接读的文本。 */
996
+ function renderTask(task, state) {
997
+ return [
998
+ `任务 ${task.id}|${task.hostName}|${state.alive ? "运行中" : "已结束"}`,
999
+ [
1000
+ `pid ${task.pid}`,
1001
+ `日志 ${task.logPath}`,
1002
+ state.exit === void 0 ? "" : `退出码 ${state.exit}`
1003
+ ].filter(Boolean).join("|"),
1004
+ `$ ${task.command}`,
1005
+ state.tail ? `---\n${state.tail}` : ""
1006
+ ].filter(Boolean).join("\n");
1007
+ }
1008
+ function activate(ctx) {
1009
+ /** 取文案;占位符由 ctx.i18n.t 自己插值。 */
1010
+ const t = (key, values) => ctx.i18n.t(key, values);
1011
+ /** 取主机密码:密钥认证返回空串。 */
1012
+ async function passwordOf(host) {
1013
+ return host.auth === "password" ? await readPassword(ctx, host.id) ?? "" : "";
1014
+ }
1015
+ /** 会话选定的主机表:{ sessionId: hostId }。 */
1016
+ async function readSelections() {
1017
+ const value = await ctx.storage.get("selection");
1018
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1019
+ }
1020
+ /** 记下某个会话选定的主机;hostId 为空则清除。 */
1021
+ async function setSelection(sessionId, hostId) {
1022
+ if (!sessionId) return;
1023
+ const selections = await readSelections();
1024
+ if (hostId) selections[sessionId] = hostId;
1025
+ else delete selections[sessionId];
1026
+ await ctx.storage.set("selection", selections);
1027
+ }
1028
+ /** 某个会话当前选定的主机 id,没选过返回空串。 */
1029
+ async function selectionOf(sessionId) {
1030
+ if (!sessionId) return "";
1031
+ return (await readSelections())[sessionId] ?? "";
1032
+ }
1033
+ /** 删除主机后把指向它的会话选择清掉,避免悬空 id。 */
1034
+ async function forgetSelection(hostId) {
1035
+ const selections = await readSelections();
1036
+ let changed = false;
1037
+ for (const [sessionId, id] of Object.entries(selections)) if (id === hostId) {
1038
+ delete selections[sessionId];
1039
+ changed = true;
1040
+ }
1041
+ if (changed) await ctx.storage.set("selection", selections);
1042
+ }
1043
+ /** 判定为 confirm 时弹卡片等用户点,deny 直接拦下;返回 null 表示可以继续。 */
1044
+ async function gate(exec, host, command, verdict, entry) {
1045
+ if (verdict.decision === "deny") {
1046
+ record(ctx, {
1047
+ ...entry,
1048
+ decision: "拒绝",
1049
+ exitCode: null
1050
+ });
1051
+ return fail(`策略拒绝执行:${verdict.hits.join(";")}。主机「${host.name}」当前模式为${modeLabel(host.policy.mode)}。`);
1052
+ }
1053
+ if (verdict.decision !== "confirm") return null;
1054
+ const form = await exec.ui.requestForm({
1055
+ title: t("confirm.title", { host: host.name }),
1056
+ description: t("confirm.description", {
1057
+ target: `${host.user}@${host.host}:${host.port}`,
1058
+ hits: (verdict.pending.length ? verdict.pending : verdict.hits).join(";"),
1059
+ command
1060
+ }),
1061
+ submitLabel: t("confirm.submit"),
1062
+ cancelLabel: t("confirm.cancel"),
1063
+ fields: [{
1064
+ key: "ok",
1065
+ label: t("confirm.tick"),
1066
+ type: "boolean",
1067
+ required: true
1068
+ }]
1069
+ });
1070
+ if (form.submitted && form.values?.ok === true) return null;
1071
+ const background = form.reason === "background";
1072
+ record(ctx, {
1073
+ ...entry,
1074
+ decision: background ? "无人确认" : "取消",
1075
+ exitCode: null
1076
+ });
1077
+ return fail(background ? "当前会话跑在后台,没有人能确认这条危险命令,已放弃执行。" : "用户没有确认,命令没有执行。不要重复发起同一条命令,先问清用户想做什么。");
1078
+ }
1079
+ /** 在目标主机上执行一条命令:先定性,再决定直接跑、等确认还是拒绝。 */
1080
+ async function runExec(input, exec) {
1081
+ const key = input.hostId || await selectionOf(exec.sessionId);
1082
+ if (!key) return fail("没指定主机,当前会话也没选。让用户在输入框下方的「远程主机」里选一台,或调 hosts 看已配置的主机。");
1083
+ const host = await findHost(ctx, key);
1084
+ if (!host) return fail(`没有主机「${key}」。先调 hosts 看已配置的主机;新增主机只能由用户在面板里操作。`);
1085
+ const command = String(input.command ?? "").trim();
1086
+ if (!command) return fail("command 不能为空。");
1087
+ const password = await passwordOf(host);
1088
+ if (host.auth === "password" && !password) return fail(`主机「${host.name}」用密码认证,但还没存密码,请用户到面板里补上。`);
1089
+ const verdict = classify(command, host.policy);
1090
+ const entry = {
1091
+ hostId: host.id,
1092
+ hostName: host.name,
1093
+ target: `${host.user}@${host.host}:${host.port}`,
1094
+ auth: host.auth,
1095
+ sessionId: exec.sessionId,
1096
+ toolCallId: exec.toolCallId,
1097
+ command,
1098
+ hits: verdict.hits
1099
+ };
1100
+ const blocked = await gate(exec, host, command, verdict, entry);
1101
+ if (blocked) return blocked;
1102
+ const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds) || TIMEOUT_DEFAULT_S, 1), TIMEOUT_MAX_S);
1103
+ exec.progress.report({
1104
+ stage: "running",
1105
+ message: t("progress.running", { host: host.name })
1106
+ });
1107
+ const started = Date.now();
1108
+ try {
1109
+ const result = await runRemote(host, {
1110
+ command,
1111
+ timeoutMs: timeoutSeconds * 1e3,
1112
+ signal: exec.signal,
1113
+ askpassPath: askpassHelper(ctx.storagePath),
1114
+ password,
1115
+ onProgress: ({ lines, last, seconds }) => exec.progress.report({
1116
+ stage: "running",
1117
+ message: t("progress.streaming", {
1118
+ host: host.name,
1119
+ seconds,
1120
+ lines,
1121
+ last: last || t("progress.waiting")
1122
+ })
1123
+ })
1124
+ });
1125
+ const durationMs = Date.now() - started;
1126
+ record(ctx, {
1127
+ ...entry,
1128
+ decision: verdict.decision === "confirm" ? "确认后执行" : "直接执行",
1129
+ exitCode: result.exitCode,
1130
+ durationMs,
1131
+ output: result.output
1132
+ });
1133
+ await stampRun(ctx, host.id, {
1134
+ exitCode: result.exitCode,
1135
+ durationMs,
1136
+ command: command.slice(0, 120)
1137
+ });
1138
+ return text(renderResult(host, command, result, durationMs));
1139
+ } catch (error) {
1140
+ const durationMs = Date.now() - started;
1141
+ const message = error instanceof Error ? error.message : String(error);
1142
+ record(ctx, {
1143
+ ...entry,
1144
+ decision: "失败",
1145
+ exitCode: null,
1146
+ durationMs,
1147
+ output: message
1148
+ });
1149
+ return fail(`执行失败:${message}${message.includes("超时") ? `。更久的命令改用 action=start 起后台任务,或者把 timeoutSeconds 调大(上限 ${TIMEOUT_MAX_S})。` : ""}`);
1150
+ }
1151
+ }
1152
+ /** 起一个受管后台任务:远端脱离会话跑,pid、日志、退出码都记下来。 */
1153
+ async function startTask(input, exec) {
1154
+ const key = input.hostId || await selectionOf(exec.sessionId);
1155
+ if (!key) return fail("没指定主机,当前会话也没选。让用户在输入框下方的「远程主机」里选一台。");
1156
+ const host = await findHost(ctx, key);
1157
+ if (!host) return fail(`没有主机「${key}」。先调 hosts 看已配置的主机。`);
1158
+ const command = String(input.command ?? "").trim();
1159
+ if (!command) return fail("command 不能为空。");
1160
+ const password = await passwordOf(host);
1161
+ if (host.auth === "password" && !password) return fail(`主机「${host.name}」还没存密码,请用户到面板里补上。`);
1162
+ const verdict = classify(command, host.policy);
1163
+ const entry = {
1164
+ hostId: host.id,
1165
+ hostName: host.name,
1166
+ target: `${host.user}@${host.host}:${host.port}`,
1167
+ auth: host.auth,
1168
+ sessionId: exec.sessionId,
1169
+ toolCallId: exec.toolCallId,
1170
+ command,
1171
+ hits: verdict.hits
1172
+ };
1173
+ const blocked = await gate(exec, host, command, verdict, entry);
1174
+ if (blocked) return blocked;
1175
+ const id = randomUUID().slice(0, 8);
1176
+ const { logPath, exitPath, pidPath } = taskPaths(id);
1177
+ const launch = `rm -f ${pidPath} ${exitPath}; if command -v setsid >/dev/null 2>&1; then SET=setsid; else SET=; fi; $SET sh -c ${shellQuote(`echo $$ > ${pidPath}; ${command}; echo $? > ${exitPath}`)} > ${logPath} 2>&1 < /dev/null & i=0; while [ $i -lt 25 ] && [ ! -s ${pidPath} ]; do sleep 0.2; i=$((i+1)); done; cat ${pidPath} 2>/dev/null`;
1178
+ try {
1179
+ const result = await runRemote(host, {
1180
+ command: launch,
1181
+ timeoutMs: 2e4,
1182
+ signal: exec.signal,
1183
+ askpassPath: askpassHelper(ctx.storagePath),
1184
+ password
1185
+ });
1186
+ const pid = Number(result.output.trim().split("\n").at(-1)?.trim());
1187
+ if (!Number.isInteger(pid) || pid <= 1) return fail(`启动失败,远端没有返回有效进程号:${clip(result.output) || "(空)"}`);
1188
+ const task = await createTask(ctx, {
1189
+ id,
1190
+ hostId: host.id,
1191
+ hostName: host.name,
1192
+ target: `${host.user}@${host.host}`,
1193
+ command,
1194
+ pid,
1195
+ logPath,
1196
+ exitPath,
1197
+ pidPath,
1198
+ sessionId: exec.sessionId,
1199
+ alive: true
1200
+ });
1201
+ record(ctx, {
1202
+ ...entry,
1203
+ decision: "后台启动",
1204
+ exitCode: null,
1205
+ output: `pid=${pid}`
1206
+ });
1207
+ return text([
1208
+ `后台任务 ${task.id} 已启动,pid ${pid}`,
1209
+ `日志 ${logPath}`,
1210
+ `接着用 action=status taskId=${task.id} 看进度,action=stop taskId=${task.id} 收尾。`
1211
+ ].join("\n"));
1212
+ } catch (error) {
1213
+ const message = error instanceof Error ? error.message : String(error);
1214
+ record(ctx, {
1215
+ ...entry,
1216
+ decision: "后台启动失败",
1217
+ exitCode: null,
1218
+ output: message
1219
+ });
1220
+ return fail(`启动失败:${message}`);
1221
+ }
1222
+ }
1223
+ /** 探一次任务状态:活着吗、多少行、日志尾巴、退出码。 */
1224
+ async function probeTask(task) {
1225
+ const host = await findHost(ctx, task.hostId);
1226
+ if (!host) return {
1227
+ alive: false,
1228
+ tail: "主机已删除"
1229
+ };
1230
+ const pid = validPid(task.pid);
1231
+ if (!pid) return {
1232
+ alive: false,
1233
+ tail: "进程号无效,按已结束处理"
1234
+ };
1235
+ const output = (await runRemote(host, {
1236
+ command: `sh -c ${shellQuote([
1237
+ `P=${pid}`,
1238
+ `if [ -n "$(ps -o pid= -g $P 2>/dev/null)" ] || kill -0 $P 2>/dev/null; then echo ALIVE; else echo DEAD; fi`,
1239
+ `[ -f ${task.exitPath} ] && echo "EXIT=$(cat ${task.exitPath})"`,
1240
+ `[ -f ${task.logPath} ] && echo "LINES=$(wc -l < ${task.logPath})" && echo --- && tail -n 20 ${task.logPath}`,
1241
+ "exit 0"
1242
+ ].join("; "))}`,
1243
+ timeoutMs: 2e4,
1244
+ askpassPath: askpassHelper(ctx.storagePath),
1245
+ password: await passwordOf(host)
1246
+ })).output;
1247
+ const exit = /EXIT=(-?\d+)/.exec(output)?.[1];
1248
+ const marker = output.indexOf("---");
1249
+ return {
1250
+ alive: output.includes("ALIVE"),
1251
+ exit: exit === void 0 ? void 0 : Number(exit),
1252
+ tail: marker !== -1 ? output.slice(marker + 3).trim() : ""
1253
+ };
1254
+ }
1255
+ /** 查任务:给 id 就探活取尾巴,不给就列最近的几个。 */
1256
+ async function taskStatus(input) {
1257
+ const tasks = await listTasks(ctx);
1258
+ if (!input.taskId) {
1259
+ if (!tasks.length) return text("没有后台任务。用 action=start 起一个。");
1260
+ const lines = tasks.slice(0, 10).map((task) => `- ${task.id}|${task.hostName}|${shortTime(task.startedAt)}|${task.command.replace(/\s+/g, " ").slice(0, 80)}`);
1261
+ return text(`最近 ${lines.length} 个后台任务:\n${lines.join("\n")}\n单个任务详情:action=status taskId=<id>`);
1262
+ }
1263
+ const task = tasks.find((item) => item.id === input.taskId);
1264
+ if (!task) return fail(`没有任务「${input.taskId}」。action=status 不带 taskId 可以看列表。`);
1265
+ try {
1266
+ const state = await probeTask(task);
1267
+ const patch = {
1268
+ alive: state.alive,
1269
+ exitCode: state.exit ?? task.exitCode ?? null
1270
+ };
1271
+ if (!state.alive) patch.finishedAt = task.finishedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1272
+ await updateTask(ctx, task.id, patch);
1273
+ return text(renderTask({
1274
+ ...task,
1275
+ ...patch
1276
+ }, state));
1277
+ } catch (error) {
1278
+ return fail(`查不到任务 ${task.id}:${error instanceof Error ? error.message : String(error)}`);
1279
+ }
1280
+ }
1281
+ /** 停任务:先 TERM 整个进程组,1 秒后还在就 KILL,可顺手删远端文件。 */
1282
+ async function stopTask(input, exec) {
1283
+ const task = await findTask(ctx, String(input.taskId ?? ""));
1284
+ if (!task) return fail(`没有任务「${input.taskId ?? ""}」。`);
1285
+ const host = await findHost(ctx, task.hostId);
1286
+ if (!host) return fail(`任务 ${task.id} 的主机已删除,无法停止。`);
1287
+ const pid = validPid(task.pid);
1288
+ if (!pid) return fail(`任务 ${task.id} 的进程号无效,不能停。`);
1289
+ const script = [
1290
+ `P=${pid}`,
1291
+ `kill -TERM -- -$P 2>/dev/null || kill -TERM $P 2>/dev/null`,
1292
+ `for c in $(ps -o pid= -g $P 2>/dev/null); do kill -TERM $c 2>/dev/null; done`,
1293
+ "sleep 1",
1294
+ `for c in $(ps -o pid= -g $P 2>/dev/null); do kill -KILL $c 2>/dev/null; done`,
1295
+ "sleep 1",
1296
+ `if [ -n "$(ps -o pid= -g $P 2>/dev/null)" ]; then echo ALIVE; else echo DEAD; fi`,
1297
+ input.clean ? `rm -f ${task.logPath} ${task.exitPath} ${task.pidPath ?? ""}` : "",
1298
+ "exit 0"
1299
+ ].filter(Boolean).join("; ");
1300
+ try {
1301
+ const result = await runRemote(host, {
1302
+ command: `sh -c ${shellQuote(script)}`,
1303
+ timeoutMs: 2e4,
1304
+ signal: exec.signal,
1305
+ askpassPath: askpassHelper(ctx.storagePath),
1306
+ password: await passwordOf(host)
1307
+ });
1308
+ const alive = result.output.includes("ALIVE");
1309
+ await updateTask(ctx, task.id, {
1310
+ alive,
1311
+ stoppedAt: (/* @__PURE__ */ new Date()).toISOString()
1312
+ });
1313
+ record(ctx, {
1314
+ hostId: host.id,
1315
+ hostName: host.name,
1316
+ target: `${host.user}@${host.host}`,
1317
+ sessionId: exec.sessionId,
1318
+ toolCallId: exec.toolCallId,
1319
+ command: `stop ${task.id}`,
1320
+ decision: "停止任务",
1321
+ exitCode: null,
1322
+ output: clip(result.output)
1323
+ });
1324
+ return text(alive ? `任务 ${task.id} 仍然活着,可能权限不足,需要用户到机器上手动处理。` : `任务 ${task.id} 已停止${input.clean ? ",远端日志已删" : ""}。`);
1325
+ } catch (error) {
1326
+ return fail(`停止失败:${error instanceof Error ? error.message : String(error)}`);
1327
+ }
1328
+ }
1329
+ /** 面板用的完整状态:密码只给「已存」标记,从不回传值。 */
1330
+ async function panelState() {
1331
+ const hosts = await listHosts(ctx);
1332
+ const items = [];
1333
+ for (const host of hosts) items.push({
1334
+ id: host.id,
1335
+ name: host.name,
1336
+ host: host.host,
1337
+ port: host.port,
1338
+ user: host.user,
1339
+ auth: host.auth,
1340
+ keyPath: host.keyPath,
1341
+ mode: host.policy.mode,
1342
+ allowGlob: host.policy.allowGlob,
1343
+ denyGlob: host.policy.denyGlob,
1344
+ hasPassword: await hasPassword(ctx, host.id)
1345
+ });
1346
+ return {
1347
+ type: "state",
1348
+ hosts: items,
1349
+ builtin: BUILTIN_RULES
1350
+ };
1351
+ }
1352
+ /** 测试连接:真连一次,只看能不能回显。 */
1353
+ async function testConnection(input, password) {
1354
+ try {
1355
+ const previous = input?.id ? await findHost(ctx, input.id) : void 0;
1356
+ const host = normalizeHost(input ?? {}, previous);
1357
+ const secret = String(password ?? "") || (previous ? await passwordOf(previous) : "");
1358
+ if (host.auth === "password" && !secret) return {
1359
+ ok: false,
1360
+ message: t("panel.needPassword")
1361
+ };
1362
+ const result = await runRemote({
1363
+ ...host,
1364
+ id: `probe-${Date.now()}`
1365
+ }, {
1366
+ command: PROBE_COMMAND,
1367
+ timeoutMs: 2e4,
1368
+ askpassPath: askpassHelper(ctx.storagePath),
1369
+ password: secret
1370
+ });
1371
+ return result.output.includes("finch-ok") ? {
1372
+ ok: true,
1373
+ message: t("panel.testOk")
1374
+ } : {
1375
+ ok: false,
1376
+ message: result.output || t("panel.testFail")
1377
+ };
1378
+ } catch (error) {
1379
+ return {
1380
+ ok: false,
1381
+ message: error instanceof Error ? error.message : String(error)
1382
+ };
1383
+ }
1384
+ }
1385
+ /** 处理面板发来的消息;页面只发 command,后端从不回密码。 */
1386
+ async function onPanelMessage(panel, message) {
1387
+ if (!message || typeof message !== "object") return;
1388
+ try {
1389
+ switch (message.type) {
1390
+ case "ready":
1391
+ await panel.postMessage(await panelState());
1392
+ return;
1393
+ case "save": {
1394
+ const host = await saveHost(ctx, {
1395
+ ...message.host,
1396
+ password: message.password
1397
+ });
1398
+ await panel.postMessage({
1399
+ type: "saved",
1400
+ id: host.id
1401
+ });
1402
+ await panel.postMessage(await panelState());
1403
+ return;
1404
+ }
1405
+ case "remove":
1406
+ await removeHost(ctx, message.id);
1407
+ await forgetSelection(message.id);
1408
+ await panel.postMessage(await panelState());
1409
+ return;
1410
+ case "test":
1411
+ await panel.postMessage({
1412
+ type: "tested",
1413
+ ...await testConnection(message.host, message.password)
1414
+ });
1415
+ return;
1416
+ case "preview":
1417
+ await panel.postMessage({
1418
+ type: "preview",
1419
+ ...classify(message.command, {
1420
+ mode: message.mode,
1421
+ allowGlob: message.allowGlob,
1422
+ denyGlob: message.denyGlob
1423
+ })
1424
+ });
1425
+ return;
1426
+ case "audit": {
1427
+ const rows = recent(ctx, {
1428
+ hostId: message.hostId,
1429
+ limit: AUDIT_PANEL
1430
+ }).map((row) => ({
1431
+ ts: row.ts,
1432
+ command: row.command,
1433
+ decision: row.decision ?? "",
1434
+ exitCode: row.exitCode ?? null,
1435
+ hits: row.hits ?? [],
1436
+ output: String(row.output ?? "").slice(0, AUDIT_PANEL_CHARS)
1437
+ }));
1438
+ await panel.postMessage({
1439
+ type: "audit",
1440
+ rows
1441
+ });
1442
+ return;
1443
+ }
1444
+ case "clearAudit":
1445
+ clearHost(ctx, message.hostId);
1446
+ await panel.postMessage({
1447
+ type: "audit",
1448
+ rows: []
1449
+ });
1450
+ return;
1451
+ default: return;
1452
+ }
1453
+ } catch (error) {
1454
+ await panel.postMessage({
1455
+ type: "error",
1456
+ message: error instanceof Error ? error.message : String(error)
1457
+ });
1458
+ }
1459
+ }
1460
+ /** 打开主机面板;带 id 时直接选中那台主机。 */
1461
+ async function openPanel(selectId) {
1462
+ await ctx.ui.createPanel({
1463
+ instanceMode: "single",
1464
+ payload: { selectId: selectId ?? null }
1465
+ });
1466
+ }
1467
+ /** 设置菜单:状态行在上,可点操作在下,主机行点开即定位到面板里的那台。 */
1468
+ async function getMenu() {
1469
+ const hosts = await listHosts(ctx);
1470
+ const runs = await listRuns(ctx);
1471
+ const items = [{
1472
+ id: "count",
1473
+ label: hosts.length ? t("menu.count", { count: hosts.length }) : t("menu.empty"),
1474
+ iconName: "square-terminal",
1475
+ disabled: true
1476
+ }];
1477
+ for (const host of hosts) {
1478
+ const run = runs[host.id];
1479
+ items.push({
1480
+ id: `open:${host.id}`,
1481
+ label: `${host.name} · ${host.user}@${host.host}`,
1482
+ description: run ? `${modeLabel(host.policy.mode)} · ${run.exitCode === 0 ? t("menu.ok") : t("menu.failed", { code: run.exitCode })}` : modeLabel(host.policy.mode),
1483
+ iconName: "square-terminal"
1484
+ });
1485
+ }
1486
+ items.push({
1487
+ id: "divider",
1488
+ label: "",
1489
+ separator: true
1490
+ });
1491
+ items.push({
1492
+ id: "panel",
1493
+ label: t("menu.panel"),
1494
+ iconName: "settings"
1495
+ });
1496
+ return items;
1497
+ }
1498
+ /** 菜单点击:主机行与面板都在同一个面板里定位。 */
1499
+ async function executeMenu(_context, itemId) {
1500
+ if (itemId === "panel") return openPanel();
1501
+ if (itemId.startsWith("open:")) return openPanel(itemId.slice(5));
1502
+ }
1503
+ ctx.subscriptions.push(ctx.tools.register({
1504
+ name: "remote_host",
1505
+ title: "远程主机",
1506
+ description: TOOL_DESCRIPTION,
1507
+ inputSchema: {
1508
+ type: "object",
1509
+ properties: {
1510
+ action: {
1511
+ type: "string",
1512
+ enum: ACTIONS,
1513
+ description: "要做的动作,各动作适用范围见上面 action 列表"
1514
+ },
1515
+ hostId: {
1516
+ type: "string",
1517
+ description: "主机 id 或名称;不传就用当前会话在输入框下方选定的那台"
1518
+ },
1519
+ command: {
1520
+ type: "string",
1521
+ description: "exec 与 start 必填:一条 shell 命令,可以带管道与 &&"
1522
+ },
1523
+ taskId: {
1524
+ type: "string",
1525
+ description: "status 与 stop 必填:start 返回的任务 id"
1526
+ },
1527
+ clean: {
1528
+ type: "boolean",
1529
+ description: "stop 用:true 时顺手删掉远端日志与退出文件"
1530
+ },
1531
+ timeoutSeconds: {
1532
+ type: "number",
1533
+ description: `exec 用:命令超时秒数,默认 ${TIMEOUT_DEFAULT_S},最大 ${TIMEOUT_MAX_S};到点本地中断`
1534
+ },
1535
+ limit: {
1536
+ type: "number",
1537
+ description: `audit 用:返回条数,默认 20,最大 ${AUDIT_MAX}`
1538
+ }
1539
+ },
1540
+ required: ["action"]
1541
+ },
1542
+ risk: "high",
1543
+ timeoutMs: 66e4,
1544
+ async execute(input, exec) {
1545
+ switch (input.action) {
1546
+ case "hosts": return text(renderHosts(await listHosts(ctx), await listRuns(ctx), await selectionOf(exec.sessionId)));
1547
+ case "audit": {
1548
+ const limit = Math.min(Math.max(Number(input.limit) || 20, 1), AUDIT_MAX);
1549
+ return text(renderAudit(recent(ctx, {
1550
+ hostId: input.hostId,
1551
+ limit
1552
+ })));
1553
+ }
1554
+ case "exec": return runExec(input, exec);
1555
+ case "start": return startTask(input, exec);
1556
+ case "status": return taskStatus(input);
1557
+ case "stop": return stopTask(input, exec);
1558
+ default: return fail(`未知 action:${input.action},可用值:${ACTIONS.join(" / ")}`);
1559
+ }
1560
+ }
1561
+ }));
1562
+ ctx.subscriptions.push(ctx.settingsMenu.register({
1563
+ getMenu,
1564
+ execute: executeMenu
1565
+ }));
1566
+ const hostAction = ctx.composerActions.register("host", {
1567
+ /** 徽标直接显示选中的主机名。 */
1568
+ async getBadge({ sessionId, surface }) {
1569
+ if (surface !== "session" || !sessionId) return;
1570
+ const host = await findHost(ctx, await selectionOf(sessionId));
1571
+ return host ? {
1572
+ text: host.name,
1573
+ active: true
1574
+ } : { text: t("badge.pick") };
1575
+ },
1576
+ /** 菜单:一台主机一行,当前那台打勾,末尾是面板入口。 */
1577
+ async getMenu({ sessionId }) {
1578
+ const hosts = await listHosts(ctx);
1579
+ const current = await selectionOf(sessionId);
1580
+ if (!hosts.length) return [{
1581
+ id: "no-hosts",
1582
+ label: t("menu.noHosts"),
1583
+ iconName: "square-terminal",
1584
+ disabled: true
1585
+ }, {
1586
+ id: "panel",
1587
+ label: t("menu.panel"),
1588
+ iconName: "settings"
1589
+ }];
1590
+ const items = hosts.map((host) => ({
1591
+ id: `host:${host.id}`,
1592
+ label: host.name,
1593
+ description: `${host.user}@${host.host} · ${modeLabel(host.policy.mode)}`,
1594
+ iconName: "square-terminal",
1595
+ current: host.id === current
1596
+ }));
1597
+ items.push({
1598
+ id: "composer-divider",
1599
+ label: "",
1600
+ separator: true
1601
+ });
1602
+ if (current) items.push({
1603
+ id: "clear",
1604
+ label: t("menu.clear"),
1605
+ iconName: "toggle-left"
1606
+ });
1607
+ items.push({
1608
+ id: "panel",
1609
+ label: t("menu.panel"),
1610
+ iconName: "settings"
1611
+ });
1612
+ return items;
1613
+ },
1614
+ /** 点选就记到当前会话上,徽标跟着刷新。 */
1615
+ async execute({ sessionId }, itemId) {
1616
+ if (itemId === "panel") {
1617
+ await openPanel();
1618
+ return;
1619
+ }
1620
+ if (itemId === "clear") {
1621
+ await setSelection(sessionId, "");
1622
+ hostAction.notifyUpdate();
1623
+ return;
1624
+ }
1625
+ if (itemId.startsWith("host:")) {
1626
+ await setSelection(sessionId, itemId.slice(5));
1627
+ hostAction.notifyUpdate();
1628
+ }
1629
+ },
1630
+ /** 每轮把选定主机告诉模型,省得它反复猜或每次都要 hostId。 */
1631
+ async getReminder({ sessionId, surface }) {
1632
+ if (surface !== "session" || !sessionId) return;
1633
+ const host = await findHost(ctx, await selectionOf(sessionId));
1634
+ if (!host) return;
1635
+ return `当前会话选定的远程主机是「${host.name}」(id: ${host.id},${host.user}@${host.host}:${host.port})。用户说「这台机器」时指它,remote_host 的 exec 可以不传 hostId。`;
1636
+ }
1637
+ });
1638
+ ctx.subscriptions.push(hostAction);
1639
+ ctx.subscriptions.push(ctx.ui.onDidOpenPanel((panel) => {
1640
+ const subscription = panel.onDidReceiveMessage((message) => onPanelMessage(panel, message));
1641
+ if (subscription) ctx.subscriptions.push(subscription);
1642
+ }));
1643
+ }
1644
+ function deactivate() {}
1645
+ export { activate, deactivate };