dsh-ssh-vault 0.0.0-stage → 2.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/index.js ADDED
@@ -0,0 +1,1180 @@
1
+ /**
2
+ * SSH 保险库插件 —— 让智能体通过别名使用 SSH,同时把主机地址、端口、账号与口令
3
+ * 全部保存在加密保险库中。
4
+ *
5
+ * 暴露四个模型面向工具:
6
+ * - `ssh_hosts` 管理保险库里的主机(列出 / 查看 / 新增 / 修改 / 删除)
7
+ * - `ssh_run` 在已保存主机上执行命令,可选后台运行
8
+ * - `ssh_transfer` 在本地与主机之间传文件
9
+ * - `ssh_probe` 连接自检并回读远端系统信息
10
+ *
11
+ * @module dsh-ssh-vault
12
+ */
13
+
14
+ import { mkdir, readFile } from 'node:fs/promises'
15
+ import { dirname, isAbsolute, resolve } from 'node:path'
16
+ import { registerSshJob } from './lib/background.js'
17
+ import { LOCALE_SETTINGS_NAMESPACE, applyPreference, onLocaleChange, readLocalePreference, t } from './lib/i18n.js'
18
+ import { assertSupportedPlatform } from './lib/platform.js'
19
+ import { MAX_TIMEOUT_MS, buildDetachScript, buildWaitScript, ensureAskpass, looksLikeNaturalLanguage, parseExitStatus, probeCommand, renderTerminalBlock, runRemote, shellKind, transferFile } from './lib/ssh.js'
20
+ import { API_PREFIX, SETTINGS_PROBE_TIMEOUT_MS, createVaultApi } from './lib/vault-api.js'
21
+ import { Vault, defaultVaultDir, maskHost, maskUser } from './lib/vault.js'
22
+
23
+ /** 插件名(同时是 Loader 行的 id)。 */
24
+ export const name = 'ssh-vault'
25
+ /** 依赖的 Host 服务:没有工具注册表就无法工作。 */
26
+ export const inject = ['tools']
27
+
28
+ /** 默认单次命令超时。 */
29
+ const DEFAULT_TIMEOUT_MS = 120000
30
+ /** 默认保留的输出上限(字符)。 */
31
+ const DEFAULT_MAX_OUTPUT = 200000
32
+
33
+ /**
34
+ * 把一个字符串结果包装成最小可用的工具定义。
35
+ * @param options - 工具名、描述、参数与执行函数。
36
+ * @returns 可直接注册的工具定义。
37
+ */
38
+ function textTool({ name: toolName, description, parameters, execute, terminal, presentCall, presentResult }) {
39
+ return {
40
+ name: toolName,
41
+ description,
42
+ parameters,
43
+ output: {
44
+ schema: { type: 'string' },
45
+ render: (_args, value) => {
46
+ const text = typeof value === 'string' ? value : String(value)
47
+ return [{ type: 'text', text: terminal === true ? renderTerminalBlock(text) : text }]
48
+ },
49
+ },
50
+ execute,
51
+ ...(presentCall !== undefined ? { presentCall } : {}),
52
+ ...(presentResult !== undefined ? { presentResult } : {}),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 把一个工具的文本结果渲染成对话里的终端卡片。
58
+ *
59
+ * 用的是内置 shell 工具同一种卡片,所以在对话里呈现为「命令行 + 输出 + 退出码状态」。
60
+ * 形状不符时返回 `undefined`,交回通用渲染。
61
+ *
62
+ * @param result - 工具结果,`content` 为展示块。
63
+ * @returns 终端卡片视图,或 `undefined`。
64
+ */
65
+ function terminalResult(result) {
66
+ const block = Array.isArray(result?.content) && result.content.length === 1 ? result.content[0] : undefined
67
+ if (block === undefined || block.type !== 'text') return undefined
68
+ const { body, ...exit } = parseExitStatus(block.text)
69
+ return { card: 'terminal', output: body, ...exit }
70
+ }
71
+
72
+ /**
73
+ * 一次 SSH 调用的调用中卡片:标题是命令行本身,副标题标明走的是哪台主机。
74
+ * @param args - 工具参数。
75
+ * @param label - 用于副标题的动作前缀。
76
+ * @returns 终端卡片视图。
77
+ */
78
+ function terminalCall(title, host) {
79
+ return { card: 'terminal', title, description: `ssh ${host ?? ''}` }
80
+ }
81
+
82
+ /**
83
+ * 在录入前检查私钥文件,把公钥、PPK、路径不可读等问题挡在连接之前。
84
+ * @param keyPath - 模型给出的私钥路径。
85
+ * @returns 问题描述;可用时返回 `undefined`。
86
+ */
87
+ async function inspectPrivateKey(keyPath) {
88
+ const target = typeof keyPath === 'string' ? keyPath.trim() : ''
89
+ if (target.length === 0) return t('err.keyEmpty')
90
+ if (/\.pub$/i.test(target)) {
91
+ return t('err.keyPublicSuffix', { path: target })
92
+ }
93
+ let content
94
+ try {
95
+ content = await readFile(target, 'utf8')
96
+ } catch (error) {
97
+ return t('err.keyReadFailed', { path: target, message: error instanceof Error ? error.message : String(error) })
98
+ }
99
+ const firstLine = (content.split(/\r?\n/, 1)[0] ?? '').trim()
100
+ if (/^(ssh-rsa|ssh-ed25519|ssh-dss|ecdsa-sha2-|sk-ssh-|sk-ecdsa-)\b/.test(firstLine)) {
101
+ return t('err.keyPublicContent', { path: target, prefix: firstLine.split(/\s+/)[0] })
102
+ }
103
+ if (/^PuTTY-User-Key-File/.test(firstLine)) {
104
+ return t('err.keyPpkPath', { path: target })
105
+ }
106
+ if (!/PRIVATE KEY/.test(content.slice(0, 8192))) {
107
+ return t('err.keyNotPemPath', { path: target })
108
+ }
109
+ return undefined
110
+ }
111
+
112
+ /**
113
+ * 列出主机时的单行摘要。
114
+ * @param record - 主机记录。
115
+ * @param reveal - 是否显示完整地址与账号。
116
+ * @returns 面向模型的摘要行。
117
+ */
118
+ function describeHost(record, reveal) {
119
+ const endpoint = reveal ? `${record.host}:${record.port}` : `${maskHost(record.host)}:${record.port}`
120
+ const account = reveal ? record.username : maskUser(record.username)
121
+ const auth = record.auth?.type ?? 'agent'
122
+ const source = typeof record.bindAddress === 'string' && record.bindAddress.length > 0 ? ` via ${record.bindAddress}` : ''
123
+ const tags = Array.isArray(record.tags) && record.tags.length > 0 ? ` [${record.tags.join(', ')}]` : ''
124
+ const note = typeof record.description === 'string' && record.description.length > 0 ? ` — ${record.description}` : ''
125
+ return `- ${record.alias} (${endpoint}, ${account}, ${auth}${source})${tags}${note}`
126
+ }
127
+
128
+ /**
129
+ * 把一次远程执行的结果整理成模型可见的文本,保持与内置 shell 工具一致的标记风格。
130
+ *
131
+ * 无论是正常结束、超时还是被取消,已收到的输出都会照常呈现——中断不等于失明。
132
+ *
133
+ * @param result - 执行结果。
134
+ * @returns 面向模型的文本。
135
+ */
136
+ function renderRun(result) {
137
+ const parts = []
138
+ const out = result.stdout ?? ''
139
+ const err = result.stderr ?? ''
140
+ if (out.length > 0) parts.push(out.replace(/\n+$/, ''))
141
+ if (err.length > 0) parts.push(`[stderr]\n${err.replace(/\n+$/, '')}`)
142
+ if (parts.length === 0) parts.push('(no output)')
143
+ let body = parts.join('\n')
144
+ if (result.stdoutTruncated === true || result.stderrTruncated === true) {
145
+ body += '\n[output truncated]'
146
+ }
147
+ const markers = []
148
+ if (result.timedOut === true) markers.push(t('marker.timedOut'))
149
+ if (result.aborted === true) markers.push(t('marker.cancelled'))
150
+ if (result.exitCode !== 0 && result.exitCode !== null) markers.push(`[exit code: ${result.exitCode}]`)
151
+ if (Array.isArray(result.notes)) markers.push(...result.notes)
152
+ if (markers.length > 0) body += `\n${markers.join('\n')}`
153
+ return body
154
+ }
155
+
156
+ /**
157
+ * 插件入口。
158
+ * @param ctx - 插件上下文。
159
+ * @param config - Loader 行中的配置。
160
+ */
161
+ export function apply(ctx, config = {}) {
162
+ // Windows-only by design: DPAPI protects the data key and the askpass wrapper is a .cmd.
163
+ // Refuse to load elsewhere instead of silently degrading the key to a plaintext file.
164
+ // `package.json` `os: ["win32"]` blocks installation up front; this is the runtime backstop.
165
+ assertSupportedPlatform()
166
+
167
+ const vaultDir = typeof config.vaultDir === 'string' && config.vaultDir.length > 0
168
+ ? config.vaultDir
169
+ : defaultVaultDir()
170
+ const keyProtection = config.keyProtection ?? 'dpapi'
171
+ const connectTimeoutSec = Number.isFinite(config.connectTimeoutSec) ? config.connectTimeoutSec : 10
172
+ const defaultTimeoutMs = Number.isFinite(config.defaultTimeoutMs) ? config.defaultTimeoutMs : DEFAULT_TIMEOUT_MS
173
+ const maxOutputBytes = Number.isFinite(config.maxOutputBytes) ? config.maxOutputBytes : DEFAULT_MAX_OUTPUT
174
+ const sshExecutable = config.sshExecutable ?? 'ssh'
175
+ const scpExecutable = config.scpExecutable ?? 'scp'
176
+ const sshConfigFile = typeof config.sshConfigFile === 'string' && config.sshConfigFile.length > 0
177
+ ? config.sshConfigFile
178
+ : undefined
179
+ const revealByDefault = config.maskHostAddress === false
180
+
181
+ const vault = new Vault({
182
+ dir: vaultDir,
183
+ keyProtection,
184
+ masterPassword: config.masterPassword,
185
+ logger: ctx.logger,
186
+ })
187
+
188
+ // —— 界面语言:跟随 DSH 的「设置 → 常规 → 语言」——
189
+ //
190
+ // 用户的显式选择由 `@deepseek-ai/dsh-client-locale` 写进 Host 设置(`locale.preference`),
191
+ // 所以 Host 侧也能读到——不靠浏览器语言猜,否则「DSH 切成英文、系统是中文」时永远猜错。
192
+ // settings 服务可能比本插件晚就绪,因此首次读取重试几次;之后的切换由文档更新事件驱动。
193
+ ctx.inject(['settings'], (settingsCtx) => {
194
+ const sync = () => {
195
+ try {
196
+ const preference = readLocalePreference(settingsCtx.settings)
197
+ if (preference !== undefined) applyPreference(preference)
198
+ } catch {
199
+ // settings 尚未就绪:留给下一次重试或文档更新事件。
200
+ }
201
+ }
202
+ settingsCtx.effect(() => {
203
+ const off = settingsCtx.on('settings/document-updated', (namespace) => {
204
+ if (String(namespace) !== LOCALE_SETTINGS_NAMESPACE) return
205
+ sync()
206
+ })
207
+ // 先同步读一次:多数情况下 settings 已经就绪,命令的初始注册就能用对语言。
208
+ sync()
209
+ // 首次激活时 settings 可能还没就绪;几次重试足够覆盖启动窗口。定时器不阻止进程退出。
210
+ const timers = [250, 1000, 3000].map((delay) => {
211
+ const timer = setTimeout(sync, delay)
212
+ timer.unref?.()
213
+ return timer
214
+ })
215
+ return () => {
216
+ off()
217
+ for (const timer of timers) clearTimeout(timer)
218
+ }
219
+ }, 'ssh-vault: locale preference')
220
+ })
221
+
222
+ // known_hosts 默认落在保险库目录里,因此永不触碰用户的 ~/.ssh。
223
+ const knownHostsPath = typeof config.knownHostsFile === 'string' && config.knownHostsFile.length > 0
224
+ ? config.knownHostsFile
225
+ : vault.knownHostsPath
226
+
227
+ /** 本次插件实例内缓存的 askpass 路径。 */
228
+ let askpassPath
229
+ /** 串行化 askpass 文件的准备过程。 */
230
+ let askpassReady
231
+
232
+ /**
233
+ * 惰性准备保险库目录与 askpass 辅助脚本。
234
+ * @returns askpass 包装器的绝对路径。
235
+ */
236
+ const prepare = () => {
237
+ if (askpassReady === undefined) {
238
+ askpassReady = vault.ensureDir().then((dir) => ensureAskpass(dir)).then((p) => {
239
+ askpassPath = p
240
+ return p
241
+ })
242
+ }
243
+ return askpassReady
244
+ }
245
+
246
+ /**
247
+ * 解析一次连接需要的口令:优先使用 DSH 凭据服务中的引用,其次使用保险库内密文。
248
+ * @param record - 主机记录。
249
+ * @returns 口令;使用密钥或 ssh-agent 时为 `undefined`。
250
+ */
251
+ const resolveSecret = async (record) => {
252
+ const storedPassword = record.auth?.type === 'password'
253
+ && typeof record.auth.password === 'string' && record.auth.password.length > 0
254
+ ? record.auth.password
255
+ : undefined
256
+ if (typeof record.credentialRef === 'string' && record.credentialRef.length > 0) {
257
+ const credentials = ctx.get('credentials')
258
+ if (credentials !== undefined) {
259
+ const resolved = await credentials.resolve(record.credentialRef)
260
+ if (resolved !== undefined) return { value: resolved.value }
261
+ }
262
+ // 凭据引用不可用属于「环境问题」,不该让整台主机连坐。保险库里还有口令就回退,
263
+ // 并把回退这件事写进结果——否则它会被误读成认证失败,掩盖真正的原因。
264
+ if (storedPassword !== undefined) {
265
+ return {
266
+ value: storedPassword,
267
+ note: t('err.credentialRefFallback', {
268
+ alias: record.alias,
269
+ ref: record.credentialRef,
270
+ reason: credentials === undefined
271
+ ? t('err.credentialsServiceAbsent')
272
+ : t('err.credentialsNotConfigured'),
273
+ }),
274
+ }
275
+ }
276
+ throw new Error(t('err.credentialRefUnavailable', { alias: record.alias, ref: record.credentialRef }))
277
+ }
278
+ if (record.auth?.type === 'password') {
279
+ if (storedPassword === undefined) {
280
+ throw new Error(t('err.noPassword', { alias: record.alias }))
281
+ }
282
+ return { value: storedPassword }
283
+ }
284
+ if (record.auth?.type === 'key' && typeof record.auth.passphrase === 'string' && record.auth.passphrase.length > 0) {
285
+ return { value: record.auth.passphrase }
286
+ }
287
+ return { value: undefined }
288
+ }
289
+
290
+ /**
291
+ * 解析一次 sudo 需要的口令:优先主机记录里的 sudoPassword,其次复用登录口令。
292
+ * @param record - 主机记录。
293
+ * @returns 口令;缺失时为 `undefined`。
294
+ */
295
+ const resolveSudoPassword = async (record) => {
296
+ if (typeof record.sudoPassword === 'string' && record.sudoPassword.length > 0) return { value: record.sudoPassword }
297
+ try {
298
+ return await resolveSecret(record)
299
+ } catch {
300
+ return { value: undefined }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * 取出主机记录,不存在时给出可操作的提示。
306
+ * @param alias - 主机别名。
307
+ * @returns 主机记录。
308
+ */
309
+ const requireHost = async (alias) => {
310
+ if (typeof alias !== 'string' || alias.trim().length === 0) {
311
+ throw new Error(t('err.aliasRequired'))
312
+ }
313
+ const record = await vault.get(alias)
314
+ if (record === undefined) {
315
+ const known = (await vault.list()).map((h) => h.alias)
316
+ throw new Error(t('err.aliasNotFound', {
317
+ alias,
318
+ hint: known.length > 0 ? t('err.knownHosts', { list: known.join(', ') }) : t('err.vaultEmptyHint'),
319
+ }))
320
+ }
321
+ return record
322
+ }
323
+
324
+ /**
325
+ * 组装一次连接所需的公共参数。
326
+ * @param record - 主机记录。
327
+ * @param timeoutMs - 本次调用的超时;`0` 表示不限时。
328
+ * @param bindAddress - 本次调用覆盖的出口源地址。
329
+ * @returns 传给执行层的参数。
330
+ */
331
+ const connectionFor = async (record, timeoutMs, bindAddress, extraNotes) => {
332
+ const askpass = await prepare()
333
+ const { value: secret, note: secretNote } = await resolveSecret(record)
334
+ return {
335
+ record,
336
+ secret,
337
+ askpassPath: askpass,
338
+ knownHostsPath,
339
+ connectTimeoutSec,
340
+ timeoutMs: timeoutMs === 0 ? 0 : Math.min(timeoutMs, MAX_TIMEOUT_MS),
341
+ maxOutputBytes,
342
+ bindAddress: bindAddress ?? record.bindAddress,
343
+ sshConfigFile,
344
+ sshExecutable,
345
+ scpExecutable,
346
+ extraNotes: [
347
+ ...(secretNote !== undefined ? [secretNote] : []),
348
+ ...(Array.isArray(extraNotes) ? extraNotes : []),
349
+ ],
350
+ }
351
+ }
352
+
353
+ /** 把调用方给的超时收敛到合法范围。 */
354
+ const normalizeTimeout = (value) => {
355
+ if (!Number.isFinite(value) || value <= 0) return defaultTimeoutMs
356
+ return Math.min(value, MAX_TIMEOUT_MS)
357
+ }
358
+
359
+ // —— 同一主机上的连接串行化 ——
360
+ //
361
+ // 每次调用都新建 SSH 连接。短时间并发一批(尤其 scp)会触发 sshd 的连接数限制;更糟的是
362
+ // 反复的失败登录会累积失败计数,把账户推向锁定——Windows 上账号一旦被锁,连密钥登录都会
363
+ // 一起失效,整条管理通道断掉。
364
+ //
365
+ // 这里按别名排队,让同一台主机同一时刻只有一个连接在跑。后台任务不进这条队列,否则一个
366
+ // 长任务会把该主机上所有后续调用堵死。
367
+ /** alias -> 队尾 Promise */
368
+ const hostQueues = new Map()
369
+
370
+ /**
371
+ * 在同一主机的串行队列里执行任务。
372
+ * @param alias - 主机别名。
373
+ * @param task - 要执行的任务。
374
+ * @returns 任务结果。
375
+ */
376
+ function withHostLock(alias, task) {
377
+ const previous = hostQueues.get(alias) ?? Promise.resolve()
378
+ // 前一个任务的成败不影响后一个入队,队列只负责排序。
379
+ const next = previous.catch(() => {}).then(task)
380
+ hostQueues.set(alias, next.catch(() => {}))
381
+ return next
382
+ }
383
+
384
+ /**
385
+ * 前台执行一次命令,并在调用被取消时仍然交回已收到的输出。
386
+ * @param spec - 连接与命令参数。
387
+ * @param signal - 工具调用的取消信号。
388
+ * @returns 面向模型的文本。
389
+ */
390
+ const runForeground = async (spec, signal) => {
391
+ const result = await withHostLock(spec.record.alias, () => runRemote({ ...spec, signal }))
392
+ if (result.aborted) {
393
+ const error = new Error(t('msg.cancelledWithOutput', { output: renderRun(result) }))
394
+ error.name = 'AbortError'
395
+ throw error
396
+ }
397
+ return renderRun(result)
398
+ }
399
+
400
+ ctx.effect(() => ctx.tools.register(textTool({
401
+ name: 'ssh_hosts',
402
+ description: 'Manage the encrypted SSH vault that stores host address, port, account, and password. '
403
+ + 'Actions: list, show, add, update, remove. Passwords are never returned; addresses and accounts are '
404
+ + 'masked unless reveal is true. Work with host aliases everywhere else — the vault is what makes them resolvable.',
405
+ parameters: {
406
+ type: 'object',
407
+ additionalProperties: false,
408
+ properties: {
409
+ action: {
410
+ type: 'string',
411
+ enum: ['list', 'show', 'add', 'update', 'remove'],
412
+ description: 'list returns every saved host; show details one host; add creates one; update changes fields of an existing one; remove deletes it.',
413
+ },
414
+ alias: {
415
+ type: 'string',
416
+ description: 'Short name other SSH tools use to address this host. Required for show, add, update, and remove.',
417
+ },
418
+ host: {
419
+ type: 'string',
420
+ description: 'Address the SSH client connects to, such as 192.0.2.10 or a hostname. Required by add.',
421
+ },
422
+ port: {
423
+ type: 'integer',
424
+ description: 'SSH port. Defaults to 22 when omitted on add.',
425
+ },
426
+ username: {
427
+ type: 'string',
428
+ description: 'Account used to sign in. Required by add.',
429
+ },
430
+ password: {
431
+ type: 'string',
432
+ description: 'Password to store. Prefer credentialRef when the user has not already written the password in this '
433
+ + 'conversation: a password passed here is recorded in the session log before it is encrypted. Omit for key or agent authentication.',
434
+ },
435
+ privateKeyPath: {
436
+ type: 'string',
437
+ description: 'Absolute path to a private key file on this machine. Use instead of password for key authentication. '
438
+ + 'A .pub file or a PuTTY .ppk is rejected at save time.',
439
+ },
440
+ passphrase: {
441
+ type: 'string',
442
+ description: 'Passphrase protecting privateKeyPath, when that key is encrypted.',
443
+ },
444
+ credentialRef: {
445
+ type: 'string',
446
+ description: 'Name of a credential already configured in the Harness credential store; the password is then read from '
447
+ + 'there at connect time and never enters the conversation. Takes precedence over password.',
448
+ },
449
+ sudoPassword: {
450
+ type: 'string',
451
+ description: 'Password used to prime sudo when a command runs with sudo=true. Set it only when the sudo password '
452
+ + 'differs from the login password. Stored encrypted and never returned.',
453
+ },
454
+ shell: {
455
+ type: 'string',
456
+ description: 'Remote shell used to run commands: bash by default, /bin/sh for Linux hosts without bash, '
457
+ + 'cmd or powershell for Windows targets.',
458
+ },
459
+ bindAddress: {
460
+ type: 'string',
461
+ description: 'Local address to connect from, for a host with several routes or for out-of-band management. '
462
+ + 'Applies to every later command and transfer on this host unless a call overrides it.',
463
+ },
464
+ description: {
465
+ type: 'string',
466
+ description: 'Short human-readable note about this host, shown in list output.',
467
+ },
468
+ tags: {
469
+ type: 'array',
470
+ items: { type: 'string' },
471
+ description: 'Free-form labels used to group hosts.',
472
+ },
473
+ reveal: {
474
+ type: 'boolean',
475
+ description: 'On list and show, return the full address and account instead of a mask. Passwords are never returned either way.',
476
+ },
477
+ },
478
+ required: ['action'],
479
+ },
480
+ execute: async (args) => {
481
+ const action = typeof args.action === 'string' ? args.action : 'list'
482
+ const reveal = args.reveal === true || (args.reveal === undefined && revealByDefault)
483
+ if (action === 'list') {
484
+ const hosts = await vault.list()
485
+ if (hosts.length === 0) {
486
+ return `SSH vault is empty (${vaultDir}). Add a host with ssh_hosts action=add.`
487
+ }
488
+ return [
489
+ `Saved SSH hosts (${hosts.length}) — credentials stay encrypted; key protection: ${await vaultKeyMode(vault)}`,
490
+ ...hosts.map((record) => describeHost(record, reveal)),
491
+ ].join('\n')
492
+ }
493
+ if (action === 'show') {
494
+ const record = await requireHost(args.alias)
495
+ const lines = [
496
+ `alias: ${record.alias}`,
497
+ `address: ${reveal ? `${record.host}:${record.port}` : `${maskHost(record.host)}:${record.port}`}`,
498
+ `account: ${reveal ? record.username : maskUser(record.username)}`,
499
+ `auth: ${record.auth?.type ?? 'agent'}`,
500
+ ...(record.auth?.type === 'key' ? [`privateKey: ${record.auth.privateKeyPath}`, `keyPassphrase: ${record.auth.passphrase ? 'set' : 'none'}`] : []),
501
+ ...(record.auth?.type === 'password' ? [`password: ${record.auth.password ? 'set (stored encrypted)' : 'missing'}`] : []),
502
+ ...(record.credentialRef ? [`credentialRef: ${record.credentialRef}`] : []),
503
+ ...(record.sudoPassword ? ['sudoPassword: set (stored encrypted)'] : []),
504
+ `shell: ${record.shell ?? 'bash'}`,
505
+ ...(record.bindAddress ? [`bindAddress: ${record.bindAddress}`] : []),
506
+ ...(record.description ? [`description: ${record.description}`] : []),
507
+ ...(Array.isArray(record.tags) && record.tags.length > 0 ? [`tags: ${record.tags.join(', ')}`] : []),
508
+ `updatedAt: ${record.updatedAt ?? '(unknown)'}`,
509
+ ]
510
+ return lines.join('\n')
511
+ }
512
+ if (action === 'remove') {
513
+ const removed = await vault.remove(args.alias)
514
+ return removed
515
+ ? `Removed ${args.alias} from the SSH vault. The known_hosts entry stays; delete it only if the host was rebuilt.`
516
+ : `No host named ${args.alias} exists in the vault.`
517
+ }
518
+ if (action === 'add' || action === 'update') {
519
+ if (typeof args.alias !== 'string' || args.alias.trim().length === 0) {
520
+ throw new Error('alias is required for add and update.')
521
+ }
522
+ const existing = await vault.get(args.alias)
523
+ if (action === 'add' && existing !== undefined) {
524
+ throw new Error(`Host ${args.alias} already exists. Use action=update to change it, or pick another alias.`)
525
+ }
526
+ if (action === 'update' && existing === undefined) {
527
+ throw new Error(`Host ${args.alias} does not exist. Use action=add to create it.`)
528
+ }
529
+ const base = existing ?? {
530
+ alias: args.alias,
531
+ host: undefined,
532
+ port: 22,
533
+ username: undefined,
534
+ auth: { type: 'agent' },
535
+ shell: 'bash',
536
+ }
537
+ const next = {
538
+ ...base,
539
+ alias: args.alias,
540
+ host: args.host ?? base.host,
541
+ port: Number.isFinite(args.port) ? args.port : base.port,
542
+ username: args.username ?? base.username,
543
+ shell: args.shell ?? base.shell,
544
+ ...(args.description !== undefined ? { description: args.description } : {}),
545
+ ...(args.tags !== undefined ? { tags: args.tags } : {}),
546
+ ...(args.bindAddress !== undefined ? { bindAddress: args.bindAddress } : {}),
547
+ }
548
+ if (args.credentialRef !== undefined && args.credentialRef !== null) {
549
+ next.credentialRef = args.credentialRef
550
+ // 改由凭据存储提供口令后,保险库里不再保留口令副本。
551
+ if (next.auth?.type === 'password' && next.auth.password !== undefined) {
552
+ next.auth = { type: 'password' }
553
+ }
554
+ }
555
+ if (args.sudoPassword !== undefined && args.sudoPassword !== null) {
556
+ next.sudoPassword = args.sudoPassword
557
+ }
558
+ if (args.privateKeyPath !== undefined && args.privateKeyPath !== null) {
559
+ const problem = await inspectPrivateKey(args.privateKeyPath)
560
+ if (problem !== undefined) throw new Error(problem)
561
+ next.auth = { type: 'key', privateKeyPath: args.privateKeyPath, ...(args.passphrase !== undefined ? { passphrase: args.passphrase } : {}) }
562
+ delete next.credentialRef
563
+ } else if (args.password !== undefined && args.password !== null) {
564
+ next.auth = { type: 'password', password: args.password }
565
+ delete next.credentialRef
566
+ } else if (existing === undefined && args.credentialRef !== undefined) {
567
+ next.auth = { type: 'password' }
568
+ }
569
+ if (typeof next.host !== 'string' || next.host.length === 0) {
570
+ throw new Error('host is required when creating a host.')
571
+ }
572
+ if (typeof next.username !== 'string' || next.username.length === 0) {
573
+ throw new Error('username is required when creating a host.')
574
+ }
575
+ const saved = await vault.put(next)
576
+ return [
577
+ `${action === 'add' ? 'Added' : 'Updated'} ${saved.alias} in the encrypted vault (key protection: ${await vaultKeyMode(vault)}).`,
578
+ describeHost(saved, reveal),
579
+ saved.auth?.type === 'password' && saved.auth.password === undefined && saved.credentialRef === undefined
580
+ ? 'Warning: no password and no credentialRef is stored, so authentication will fall back to ssh-agent or a key.'
581
+ : '',
582
+ ].filter((line) => line.length > 0).join('\n')
583
+ }
584
+ throw new Error(`Unknown action ${action}.`)
585
+ },
586
+ })))
587
+
588
+ ctx.effect(() => ctx.tools.register(textTool({
589
+ name: 'ssh_run',
590
+ terminal: true,
591
+ presentCall: (args) => terminalCall(args.command, args.host),
592
+ presentResult: (_args, result) => terminalResult(result),
593
+ description: 'Run a shell command on a saved SSH host and return its stdout and stderr. '
594
+ + 'The command runs through the remote shell named in the host record (bash by default, cmd or powershell for '
595
+ + 'Windows targets). The connection address, account, and password come from the encrypted vault, so refer to the '
596
+ + 'host by alias only. A non-zero remote exit is reported as an [exit code: N] marker rather than an error. '
597
+ + 'Output received before a timeout or a cancelled call is still returned.',
598
+ parameters: {
599
+ type: 'object',
600
+ additionalProperties: false,
601
+ properties: {
602
+ host: {
603
+ type: 'string',
604
+ description: 'Alias of a host saved in the SSH vault.',
605
+ },
606
+ command: {
607
+ type: 'string',
608
+ description: 'Shell command to run on the remote host, written for that host\'s shell.',
609
+ },
610
+ run_in_background: {
611
+ type: 'boolean',
612
+ description: 'Run the command as a background job and return its job id immediately; read output with job_output '
613
+ + 'and stop it with job_kill. Use this for anything that may outlive one call — firewall changes, installs, '
614
+ + 'long scans — so an interrupted call cannot lose the result. No timeout applies.',
615
+ },
616
+ sudo: {
617
+ type: 'boolean',
618
+ description: 'Prime the remote sudo credential cache with the password stored in the vault before running the command, '
619
+ + 'so the command can use plain `sudo ...`. The password then never enters this conversation, the command text, '
620
+ + 'the local or remote command line, or the remote process list. Needs a stored password on the host record '
621
+ + '(sudoPassword, or the login password). POSIX shells only.',
622
+ },
623
+ bindAddress: {
624
+ type: 'string',
625
+ description: 'Local address to connect from for this call, overriding the host record. Use it when a rule change '
626
+ + 'may cut the default management path.',
627
+ },
628
+ timeoutMs: {
629
+ type: 'number',
630
+ description: `Timeout in milliseconds before the connection is interrupted. Defaults to ${defaultTimeoutMs}.`,
631
+ },
632
+ wait_for: {
633
+ type: 'object',
634
+ additionalProperties: false,
635
+ required: ['path'],
636
+ description: 'Wait for a remote condition instead of returning immediately: one call waits until the path exists, or until '
637
+ + 'its content matches regex, then reports the matched line. Replaces `sleep` + read-the-log polling. Combine with '
638
+ + 'run_in_background so the wait itself costs no foreground call. When wait_for is given, command may be omitted.',
639
+ properties: {
640
+ path: {
641
+ type: 'string',
642
+ description: 'Remote path to wait for.',
643
+ },
644
+ regex: {
645
+ type: 'string',
646
+ description: 'Extended regular expression the file content must match. Omit to wait only for the file to exist.',
647
+ },
648
+ timeoutMs: {
649
+ type: 'number',
650
+ description: 'How long to keep waiting, in milliseconds. Defaults to 600000.',
651
+ },
652
+ intervalMs: {
653
+ type: 'number',
654
+ description: 'Polling interval in milliseconds. Defaults to 3000.',
655
+ },
656
+ },
657
+ },
658
+ detach: {
659
+ type: 'boolean',
660
+ description: 'Run the command on the remote host under setsid, detached from this SSH session, with output redirected to a '
661
+ + 'file under /tmp; returns the pid and log path immediately. Use it for upgrades, disk work, or scans that outlive one '
662
+ + 'connection: the remote process survives a dropped link or a cancelled call, and you can inspect it later.',
663
+ },
664
+ },
665
+ required: ['host'],
666
+ },
667
+ execute: async (args, exec) => {
668
+ const wantsWait = args.wait_for !== undefined && args.wait_for !== null
669
+ if (!wantsWait && (typeof args.command !== 'string' || args.command.trim().length === 0)) {
670
+ throw new Error('command must be a non-empty string, unless wait_for is given.')
671
+ }
672
+ const record = await requireHost(args.host)
673
+ const notes = []
674
+
675
+ // 手写 `sudo -S`:提醒一次,口令其实不必进命令文本。
676
+ if (args.sudo !== true && typeof args.command === 'string' && /sudo\s+-S/.test(args.command)) {
677
+ notes.push(t('note.sudoInline'))
678
+ }
679
+
680
+ // 会改账户或锁定策略的命令:先把风险说清楚,再执行。
681
+ if (typeof args.command === 'string'
682
+ && /net\s+accounts|account\s*lockout|lockout\s*threshold|net\s+user\s+\S+\s+\/active:no|usermod\s+-L|passwd\s+-l|faillock/i.test(args.command)) {
683
+ notes.push(t('note.accountLockRisk'))
684
+ }
685
+
686
+ let sudoPassword
687
+ if (args.sudo === true) {
688
+ if (shellKind(record.shell) !== 'posix') {
689
+ throw new Error(t('err.noSudoOnWindows', { alias: record.alias, shell: record.shell }))
690
+ }
691
+ const resolvedSudo = await resolveSudoPassword(record)
692
+ sudoPassword = resolvedSudo.value
693
+ if (resolvedSudo.note !== undefined) notes.push(resolvedSudo.note)
694
+ if (typeof sudoPassword !== 'string' || sudoPassword.length === 0) {
695
+ throw new Error(t('err.noSudoPassword', { alias: record.alias }))
696
+ }
697
+ }
698
+
699
+ // 等待条件与远端脱离都只是「换一段远端脚本」,其余流程完全一致。
700
+ let command = args.command
701
+ let callTimeoutMs = normalizeTimeout(args.timeoutMs)
702
+ if (wantsWait) {
703
+ const waitTimeoutMs = Number.isFinite(args.wait_for.timeoutMs) ? args.wait_for.timeoutMs : 600000
704
+ command = buildWaitScript({
705
+ path: args.wait_for.path,
706
+ regex: args.wait_for.regex,
707
+ timeoutMs: waitTimeoutMs,
708
+ intervalMs: args.wait_for.intervalMs,
709
+ })
710
+ // 别让调用自身的超时先于等待条件生效。
711
+ callTimeoutMs = Math.min(waitTimeoutMs + 15000, MAX_TIMEOUT_MS)
712
+ } else if (args.detach === true) {
713
+ command = buildDetachScript({
714
+ command: args.command,
715
+ token: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
716
+ })
717
+ }
718
+
719
+ if (args.run_in_background === true) {
720
+ const registry = ctx.get('jobs')
721
+ if (registry === undefined) {
722
+ throw new Error(t('err.jobsUnavailable'))
723
+ }
724
+ const connection = await connectionFor(record, 0, args.bindAddress, notes)
725
+ let jobId
726
+ try {
727
+ jobId = registerSshJob(registry, { ...connection, command, sudoPassword }, { owner: exec.agent?.id })
728
+ } catch (error) {
729
+ throw new Error(t('err.backgroundRegister', {
730
+ message: error instanceof Error ? error.message : String(error),
731
+ }))
732
+ }
733
+ return `started background job ${jobId}\n`
734
+ + `The command keeps running on ${record.alias}. Read newer output with job_output and stop it with job_kill.`
735
+ }
736
+ const connection = await connectionFor(record, callTimeoutMs, args.bindAddress, notes)
737
+ return runForeground({ ...connection, command, sudoPassword }, exec.signal)
738
+ },
739
+ })))
740
+
741
+ ctx.effect(() => ctx.tools.register(textTool({
742
+ name: 'ssh_transfer',
743
+ terminal: true,
744
+ presentCall: (args) => terminalCall(
745
+ `scp ${args.direction === 'upload' ? `${args.local} → ${args.remote}` : `${args.remote} → ${args.local}`}`,
746
+ args.host,
747
+ ),
748
+ presentResult: (_args, result) => terminalResult(result),
749
+ description: 'Copy files between this machine and a saved SSH host over SCP. '
750
+ + 'Relative local paths resolve against the session working directory. The host is addressed by vault alias, '
751
+ + 'and the connection address, account, and password come from the encrypted vault.',
752
+ parameters: {
753
+ type: 'object',
754
+ additionalProperties: false,
755
+ properties: {
756
+ host: {
757
+ type: 'string',
758
+ description: 'Alias of a host saved in the SSH vault.',
759
+ },
760
+ direction: {
761
+ type: 'string',
762
+ enum: ['upload', 'download'],
763
+ description: 'upload sends the local path to the host; download fetches the remote path to this machine.',
764
+ },
765
+ local: {
766
+ type: 'string',
767
+ description: 'Path on this machine, absolute or relative to the session working directory.',
768
+ },
769
+ remote: {
770
+ type: 'string',
771
+ description: 'Path on the remote host, using its own path form.',
772
+ },
773
+ recursive: {
774
+ type: 'boolean',
775
+ description: 'Copy directories recursively. Required for a directory on either side.',
776
+ },
777
+ bindAddress: {
778
+ type: 'string',
779
+ description: 'Local address to connect from for this transfer, overriding the host record.',
780
+ },
781
+ timeoutMs: {
782
+ type: 'number',
783
+ description: `Timeout in milliseconds for the whole transfer. Defaults to ${defaultTimeoutMs}.`,
784
+ },
785
+ },
786
+ required: ['host', 'direction', 'local', 'remote'],
787
+ },
788
+ execute: async (args, exec) => {
789
+ const record = await requireHost(args.host)
790
+ const cwd = exec.agent?.session?.header?.cwd
791
+ const local = isAbsolute(args.local) || cwd === undefined ? args.local : resolve(cwd, args.local)
792
+ // 下载时自动创建本地父目录。scp 自己不会建,原先用户必须先手工 mkdir 一次,
793
+ // 而且那时候失败信息还会把原因引向"远端路径不存在"。
794
+ if (args.direction === 'download') {
795
+ await mkdir(dirname(local), { recursive: true })
796
+ }
797
+ const connection = await connectionFor(record, normalizeTimeout(args.timeoutMs), args.bindAddress)
798
+ const result = await withHostLock(record.alias, () => transferFile({
799
+ ...connection,
800
+ direction: args.direction,
801
+ local,
802
+ remote: args.remote,
803
+ recursive: args.recursive === true,
804
+ signal: exec.signal,
805
+ }))
806
+ if (result.aborted) {
807
+ throw new Error(t('err.transferCancelled', { stderr: result.stderr?.trim() ?? '' }))
808
+ }
809
+ const verb = args.direction === 'upload' ? 'Uploaded to' : 'Downloaded from'
810
+ const head = result.exitCode === 0
811
+ ? `${verb} ${record.alias}: ${args.direction === 'upload' ? `${local} -> ${args.remote}` : `${args.remote} -> ${local}`}`
812
+ : `Transfer to ${record.alias} failed`
813
+ const detail = result.stderr?.trim()
814
+ const notes = Array.isArray(result.notes) ? result.notes : []
815
+ return [head, detail, ...notes].filter((line) => typeof line === 'string' && line.length > 0).join('\n')
816
+ + (result.exitCode === 0 ? '' : `\n[exit code: ${result.exitCode}]`)
817
+ },
818
+ })))
819
+
820
+ ctx.effect(() => ctx.tools.register(textTool({
821
+ name: 'ssh_probe',
822
+ terminal: true,
823
+ presentCall: (args) => terminalCall(`ssh_probe ${args.host}`, args.host),
824
+ presentResult: (_args, result) => terminalResult(result),
825
+ description: 'Check that a saved SSH host is reachable and report what the remote system is. '
826
+ + 'It adapts to the host record\'s shell, so it works for both Linux and Windows targets. '
827
+ + 'Use it before the first real command on a host, or when a command fails in a way that suggests connectivity or authentication problems.',
828
+ parameters: {
829
+ type: 'object',
830
+ additionalProperties: false,
831
+ properties: {
832
+ host: {
833
+ type: 'string',
834
+ description: 'Alias of a host saved in the SSH vault.',
835
+ },
836
+ bindAddress: {
837
+ type: 'string',
838
+ description: 'Local address to connect from for this probe, overriding the host record.',
839
+ },
840
+ timeoutMs: {
841
+ type: 'number',
842
+ description: `Timeout in milliseconds. Defaults to ${defaultTimeoutMs}.`,
843
+ },
844
+ },
845
+ required: ['host'],
846
+ },
847
+ execute: async (args, exec) => {
848
+ const record = await requireHost(args.host)
849
+ const connection = await connectionFor(record, normalizeTimeout(args.timeoutMs), args.bindAddress)
850
+ const result = await withHostLock(record.alias, () => runRemote({ ...connection, command: probeCommand(record.shell), signal: exec.signal }))
851
+ if (result.aborted) throw new Error(t('err.probeCancelled', { output: renderRun(result) }))
852
+ const reachable = result.exitCode === 0
853
+ const endpoint = revealByDefault ? `${record.host}:${record.port}` : `${maskHost(record.host)}:${record.port}`
854
+ const account = revealByDefault ? record.username : maskUser(record.username)
855
+ const header = reachable
856
+ ? `Connected to ${record.alias} (${endpoint}, ${account}, ${record.auth?.type ?? 'agent'}, shell ${record.shell ?? 'bash'}; vault key protection: ${await vaultKeyMode(vault)})`
857
+ : `Could not verify ${record.alias}`
858
+ const facts = (result.stdout ?? '')
859
+ .split('\n')
860
+ .map((line) => line.replace(/^##(\w+)##\s*/, (_m, key) => `${key}: `))
861
+ .map((line) => line.replace(/^(\w+):\s*$/, ''))
862
+ .filter((line) => line.trim().length > 0)
863
+ return reachable
864
+ ? [header, ...facts].join('\n')
865
+ : [header, renderRun(result)].join('\n')
866
+ },
867
+ })))
868
+
869
+ /**
870
+ * 一次连接自检,供设置界面的「测试连接」、卡片自动体检与对话里的 `/ssh` 复用。
871
+ * @param alias - 主机别名。
872
+ * @param timeoutMs - 可选超时;设置页体检会传入一个短值,避免一台不通的主机挂很久。
873
+ * @returns 归一化的探测结果。
874
+ */
875
+ const probeHost = async (alias, timeoutMs) => {
876
+ try {
877
+ const record = await requireHost(alias)
878
+ const connection = await connectionFor(record, normalizeTimeout(timeoutMs), undefined)
879
+ const result = await withHostLock(record.alias, () => runRemote({ ...connection, command: probeCommand(record.shell) }))
880
+ if (result.exitCode === 0) {
881
+ const facts = (result.stdout ?? '')
882
+ .split('\n')
883
+ .map((line) => line.replace(/^##(\w+)##\s*/, (_m, key) => `${key}: `))
884
+ .map((line) => line.replace(/^(\w+):\s*$/, ''))
885
+ .filter((line) => line.trim().length > 0)
886
+ return { ok: true, detail: facts.join('\n'), notes: result.notes ?? [], exitCode: 0 }
887
+ }
888
+ return {
889
+ ok: false,
890
+ // 超时常常意味着"主机活着但 SSH 端口不通"或"地址变了",而不是命令本身失败;
891
+ // 退回一句 (no output) 会把这个区别抹掉,让人以为是插件的问题。
892
+ detail: (result.stderr ?? '').trim() || (result.timedOut === true
893
+ ? t('probe.timeoutDetail', { ms: normalizeTimeout(timeoutMs) })
894
+ : '(no output)'),
895
+ notes: result.notes ?? [],
896
+ exitCode: result.exitCode,
897
+ }
898
+ } catch (error) {
899
+ return {
900
+ ok: false,
901
+ detail: error instanceof Error ? error.message : String(error),
902
+ notes: [],
903
+ exitCode: null,
904
+ }
905
+ }
906
+ }
907
+
908
+ // —— 连通性缓存 ——
909
+ //
910
+ // 一次探测要真连 SSH,不能在用户敲命令时现测。缓存结果、后台定期刷新,
911
+ // `/ssh` 的输入提示与无参输出都读这份缓存。
912
+ /** alias -> { ok, at } */
913
+ const healthCache = new Map()
914
+ /** 后台体检间隔。 */
915
+ const HEALTH_REFRESH_MS = 5 * 60 * 1000
916
+ /** `/hosts` 里分号后那条命令的超时——它可能是 apt upgrade 这类长活。 */
917
+ const HOSTS_COMMAND_TIMEOUT_MS = 120000
918
+ /** `/hosts` 单台主机最多回显多少行输出,防止一条命令刷满整段对话。 */
919
+ const HOSTS_OUTPUT_MAX_LINES = 40
920
+ /**
921
+ * 无参 `/hosts` 的探测超时,比设置页的 15 秒紧得多。
922
+ *
923
+ * 无参调用是在对话里等结果的,用户能容忍的等待只有几秒;并行探测之下总耗时约等于这个
924
+ * 值而不是「台数 × 超时」。探不完的主机按离线处理,重新执行即可再测一次。
925
+ */
926
+ const HOSTS_LIST_PROBE_TIMEOUT_MS = 4000
927
+ /**
928
+ * 生成一条分节标记,形如 `─── 连接状态:ok ───`。
929
+ *
930
+ * Client 端按这些标记把一次 `/hosts` 拆成两张卡片,并**各自**判断该亮绿还是红——
931
+ * 「连上了没有」和「命令跑成功没有」是两个独立的事实,混用一个标志会让连通的卡片
932
+ * 因为命令失败而变红。段名走 `t()`,所以切换界面语言后卡片标题也跟着变。
933
+ *
934
+ * @param title - 段名。
935
+ * @param failed - 该段是否有失败。
936
+ * @returns 标记行。
937
+ */
938
+ const sectionMark = (title, failed) => `─── ${title}:${failed ? 'fail' : 'ok'} ───`
939
+
940
+ /**
941
+ * 体检全部主机并写入缓存。
942
+ *
943
+ * 各主机之间**并行**探测:`withHostLock` 是按别名的,不同主机本来就互不相干,串行只会
944
+ * 让总耗时变成「台数 × 超时」。真正需要串行的是同一台主机上的连接,那里由锁保证。
945
+ *
946
+ * @param timeoutMs - 单台探测超时;默认沿用设置页的宽松值。
947
+ * @returns 是否拿到了结果。
948
+ */
949
+ const refreshHealth = async (timeoutMs = SETTINGS_PROBE_TIMEOUT_MS) => {
950
+ try {
951
+ const hosts = await vault.list()
952
+ await Promise.all(hosts.map(async (host) => {
953
+ const result = await probeHost(host.alias, timeoutMs)
954
+ healthCache.set(host.alias, { ok: result.ok === true, at: Date.now() })
955
+ }))
956
+ const live = new Set(hosts.map((host) => host.alias))
957
+ for (const alias of [...healthCache.keys()]) {
958
+ if (!live.has(alias)) healthCache.delete(alias)
959
+ }
960
+ return true
961
+ } catch {
962
+ return false
963
+ }
964
+ }
965
+
966
+ // —— 设置界面的管理接口:口令经 HTTP body 提交,不进入会话日志 ——
967
+ ctx.inject(['webServer'], (webCtx) => {
968
+ const api = createVaultApi({
969
+ vault,
970
+ vaultDir,
971
+ probe: probeHost,
972
+ logger: ctx.logger,
973
+ // 这条路由能读写凭据与私钥,必须和 /api 一样过宿主的 Connection 信任栅栏:
974
+ // Host/Origin 校验(防 DNS rebinding / 跨站调用)+ 浏览器登录认证。
975
+ // connection 服务缺失时(非 Web 组合)退回放行,由宿主自身的鉴权层兜底。
976
+ requestRejection: (req) => ctx.get('connection')?.requestRejection?.(req),
977
+ })
978
+ webCtx.effect(() => webCtx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler: api }))
979
+ })
980
+
981
+ // —— 对话里的 `/ssh <别名>` 快捷调用 ——
982
+ ctx.inject(['commands'], (cmdCtx) => {
983
+ /** 当前 `/hosts` 的注销句柄。 */
984
+ let disposeHostsCommand
985
+
986
+ /**
987
+ * `/hosts` 的命令定义。
988
+ *
989
+ * 做成工厂函数是为了语言切换时能拿到新文案:`description` 与 `input.hint` 都是注册时捕获的
990
+ * 静态字符串(`CommandInputDescriptor` 没有 thunk 形态),要跟着界面语言走只能重新注册。
991
+ *
992
+ * 历史教训:早先版本按体检结果**定时**重注册,用户正输入参数时定义被换掉,输入框失去
993
+ * 命令上下文(退格失灵,只能重启界面)。所以这里**只在 locale 真的变化时**重注册一次——
994
+ * 那发生在设置页切语言的那一刻,输入框里没有进行中的命令。
995
+ *
996
+ * 代价不变:hint 里放不下在线主机的动态列表,因此无参执行时才列出来。
997
+ *
998
+ * 命令名用 `hosts` 而不是 `ssh`:输入框是 Lexical 编辑器,普通文本节点仍受浏览器拼写
999
+ * 检查约束,`ssh` 会被标红,而 `hosts` 是词典里的词。
1000
+ */
1001
+ const hostsCommandDefinition = () => ({
1002
+ name: 'hosts',
1003
+ description: t('hosts.description'),
1004
+ input: { hint: t('hosts.hint') },
1005
+ handler: async (invocation) => {
1006
+ // 格式:`/hosts 别名[,别名][; shell 命令]`。
1007
+ // 分号之前是主机选择器;分号之后**如果是 shell 命令**就在每台可达主机上执行。
1008
+ // 中英文标点都认:中文输入法下打出来的是全角「,」和「;」。
1009
+ const segments = invocation.rawInput.split(/[;;]/)
1010
+ const names = segments[0].split(/[,,]/).map((part) => part.trim()).filter((part) => part.length > 0)
1011
+ // 命令里若还有分号,原样拼回去,只把第一个分号当作分隔。
1012
+ const remoteCommand = segments.slice(1).join(';').trim()
1013
+ const usage = t('hosts.usage')
1014
+ try {
1015
+ // 分号后不一定是命令。命令 handler 的产出不会回流给模型,所以这里**不能**把
1016
+ // 一句中文当 shell 命令硬跑——那只会得到一条看不懂的语法错误。识别出来就停下,
1017
+ // 把话交还给用户,让它在下一轮以自然语言发给模型。
1018
+ if (remoteCommand.length > 0 && looksLikeNaturalLanguage(remoteCommand)) {
1019
+ return {
1020
+ kind: 'success',
1021
+ text: t('hosts.notNatural', {
1022
+ text: remoteCommand,
1023
+ alias: names.length > 0 ? names.join(',') : t('hosts.aliasPlaceholder'),
1024
+ }),
1025
+ }
1026
+ }
1027
+ if (names.length === 0 && remoteCommand.length === 0) {
1028
+ const hosts = await vault.list()
1029
+ if (hosts.length === 0) {
1030
+ return { kind: 'success', text: t('hosts.vaultEmpty') }
1031
+ }
1032
+ // 还没有体检结果就现测一遍;已有结果就直接用,别让无参调用白等一轮探测。
1033
+ // 这里刻意用短超时:这是对话里等结果的地方,不能让 5 台不可达主机把总耗时
1034
+ // 拉成 75 秒——那在界面上就是「输入了回车但没有任何反应」。
1035
+ if (healthCache.size === 0) await refreshHealth(HOSTS_LIST_PROBE_TIMEOUT_MS)
1036
+ const online = []
1037
+ const offline = []
1038
+ for (const record of hosts) {
1039
+ const line = t('hosts.listLine', {
1040
+ alias: record.alias,
1041
+ host: maskHost(record.host),
1042
+ port: record.port,
1043
+ user: maskUser(record.username),
1044
+ auth: record.auth?.type ?? 'agent',
1045
+ })
1046
+ const entry = healthCache.get(record.alias)
1047
+ if (entry !== undefined && entry.ok === true) online.push(line)
1048
+ else offline.push(line)
1049
+ }
1050
+ // 状态只由标题行的圆点表达,正文保持纯文本——emoji 色点和终端卡片上的实心圆点
1051
+ // 并排出现时,前者明显更丑,也重复表达了同一件事。
1052
+ const sections = [online.length > 0
1053
+ ? `${t('hosts.onlineCount', { n: online.length })}\n${online.join('\n')}`
1054
+ : t('hosts.noReachable')]
1055
+ if (offline.length > 0) sections.push(`${t('hosts.offlineCount', { n: offline.length })}\n${offline.join('\n')}`)
1056
+ sections.push(usage)
1057
+ return { kind: offline.length > 0 ? 'error' : 'success', text: sections.join('\n\n') }
1058
+ }
1059
+ if (names.length === 0) {
1060
+ return { kind: 'error', text: t('hosts.needHostWithUsage', { head: t('hosts.needHost'), usage }) }
1061
+ }
1062
+
1063
+ // 先剔掉不存在的别名,免得把「找不到」混进连接失败里。
1064
+ const known = new Set((await vault.list()).map((host) => host.alias))
1065
+ const unknown = names.filter((alias) => !known.has(alias))
1066
+ // 探测与命令执行分两段输出,各自带一个状态标记:对话里渲染成两张卡片,
1067
+ // 「连上了没有」和「命令跑了什么」不但分开显示,也各自亮各自的灯。
1068
+ const probeLines = []
1069
+ const execLines = []
1070
+ let probeFailed = false
1071
+ let execFailed = false
1072
+ for (const alias of names) {
1073
+ if (!known.has(alias)) continue
1074
+ const probe = await probeHost(alias, SETTINGS_PROBE_TIMEOUT_MS)
1075
+ healthCache.set(alias, { ok: probe.ok === true, at: Date.now() })
1076
+ if (!probe.ok) {
1077
+ probeFailed = true
1078
+ probeLines.push(`${alias} — ${probe.detail.split('\n')[0].trim() || t('hosts.probeFailed')}`)
1079
+ continue
1080
+ }
1081
+ // 多行 facts 压成一行:一台机器不该把整段对话撑满。
1082
+ const facts = probe.detail.split('\n').map((line) => line.trim()).filter((line) => line.length > 0)
1083
+ probeLines.push(`${alias}${facts.length > 0 ? ` — ${facts.join(' · ')}` : ''}`)
1084
+ if (remoteCommand.length === 0) continue
1085
+
1086
+ execLines.push(`${alias} $ ${remoteCommand}`)
1087
+ const record = await requireHost(alias)
1088
+ // 命令以 sudo 开头就预置 sudo 缓存:否则非交互 SSH 下会干等密码直到超时。
1089
+ let sudoPassword
1090
+ if (/^\s*sudo(\s|$)/.test(remoteCommand) && shellKind(record.shell) === 'posix') {
1091
+ const resolved = await resolveSudoPassword(record)
1092
+ sudoPassword = resolved.value
1093
+ }
1094
+ const connection = await connectionFor(record, normalizeTimeout(HOSTS_COMMAND_TIMEOUT_MS), undefined)
1095
+ const run = await withHostLock(alias, () => runRemote({ ...connection, command: remoteCommand, sudoPassword }))
1096
+ const output = [run.stdout, run.stderr]
1097
+ .filter((part) => typeof part === 'string' && part.trim().length > 0)
1098
+ .join('\n')
1099
+ .trim()
1100
+ if (output.length > 0) {
1101
+ const clipped = output.split('\n').slice(0, HOSTS_OUTPUT_MAX_LINES)
1102
+ execLines.push(clipped.map((line) => ` ${line}`).join('\n'))
1103
+ if (output.split('\n').length > HOSTS_OUTPUT_MAX_LINES) execLines.push(` ${t('hosts.outputTruncated')}`)
1104
+ }
1105
+ if (run.timedOut === true) {
1106
+ execFailed = true
1107
+ execLines.push(` ${t('hosts.timeout', { ms: HOSTS_COMMAND_TIMEOUT_MS })}`)
1108
+ } else if (run.exitCode !== 0 && run.exitCode !== null) {
1109
+ execFailed = true
1110
+ execLines.push(` [exit ${run.exitCode}]`)
1111
+ }
1112
+ }
1113
+ if (unknown.length > 0) {
1114
+ probeFailed = true
1115
+ probeLines.push(t('hosts.unknownWithUsage', {
1116
+ list: unknown.join(t('hosts.listSeparator')),
1117
+ usage,
1118
+ }))
1119
+ }
1120
+ const sections = [`${sectionMark(t('hosts.probeSection'), probeFailed)}\n${probeLines.join('\n')}`]
1121
+ if (execLines.length > 0) {
1122
+ sections.push(`${sectionMark(t('hosts.execSection'), execFailed)}\n${execLines.join('\n')}`)
1123
+ }
1124
+ // 整体成败仍交给 kind(会话卡片以外的通用呈现会用到),但两张卡片各自按分段标记亮灯。
1125
+ return { kind: probeFailed || execFailed ? 'error' : 'success', text: sections.join('\n\n') }
1126
+ } catch (error) {
1127
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
1128
+ }
1129
+ },
1130
+ })
1131
+
1132
+ /** 注册(或按当前语言重新注册)`/hosts`。 */
1133
+ const registerHostsCommand = () => {
1134
+ disposeHostsCommand?.()
1135
+ disposeHostsCommand = cmdCtx.commands.register(hostsCommandDefinition())
1136
+ }
1137
+ registerHostsCommand()
1138
+
1139
+ // 语言切换后换一份定义,让 `/` 菜单里的说明与输入提示立刻跟着换语言。
1140
+ // 命令定义的生命周期归这个 effect 管;体检定时器只管缓存,不碰命令定义。
1141
+ cmdCtx.effect(() => {
1142
+ const off = onLocaleChange(() => registerHostsCommand())
1143
+ return () => {
1144
+ off()
1145
+ disposeHostsCommand?.()
1146
+ }
1147
+ }, 'ssh-vault: localized command definition')
1148
+
1149
+ // 后台只刷新连通性缓存,不动命令定义。
1150
+ cmdCtx.effect(() => {
1151
+ let stopped = false
1152
+ const tick = async () => {
1153
+ if (stopped) return
1154
+ await refreshHealth()
1155
+ }
1156
+ // 首次延后几秒:别让插件激活时的启动过程被一串 SSH 握手拖住。
1157
+ const warmup = setTimeout(tick, 3000)
1158
+ const timer = setInterval(tick, HEALTH_REFRESH_MS)
1159
+ return () => {
1160
+ stopped = true
1161
+ clearTimeout(warmup)
1162
+ clearInterval(timer)
1163
+ }
1164
+ })
1165
+ })
1166
+ }
1167
+
1168
+ /**
1169
+ * 读取保险库当前的数据密钥保护方式,用于在结果里说明安全边界。
1170
+ * @param vault - 保险库实例。
1171
+ * @returns 保护方式名称。
1172
+ */
1173
+ async function vaultKeyMode(vault) {
1174
+ try {
1175
+ await vault.list()
1176
+ return vault.keyMode ?? 'unknown'
1177
+ } catch {
1178
+ return 'unavailable'
1179
+ }
1180
+ }