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/lib/ssh.js ADDED
@@ -0,0 +1,723 @@
1
+ /**
2
+ * OpenSSH 驱动 —— 让模型可以通过别名调用本机 `ssh` / `scp`,而口令从不出现在命令行。
3
+ *
4
+ * 密码注入原理:Windows OpenSSH 9.x 支持 `SSH_ASKPASS_REQUIRE=force`,
5
+ * 在没有终端时改用 `SSH_ASKPASS` 指定的辅助程序索取口令。我们生成一个
6
+ * `.cmd` 包装器,由它以 Node 模式启动辅助脚本,脚本把口令写到标准输出。
7
+ * 口令仅存在于本次 `ssh` 子进程的环境变量中,既不落盘也不进入命令行。
8
+ *
9
+ * @module dsh-ssh-vault/ssh
10
+ */
11
+
12
+ import { spawn } from 'node:child_process'
13
+ import { writeFile } from 'node:fs/promises'
14
+ import path from 'node:path'
15
+ import { t } from './i18n.js'
16
+
17
+ /** 生成 askpass 辅助脚本时使用的文件基名。 */
18
+ const ASKPASS_JS = 'askpass.js'
19
+ const ASKPASS_CMD = 'askpass.cmd'
20
+
21
+ /**
22
+ * 在保险库目录中准备好 askpass 辅助脚本(内容固定、不含任何机密)。
23
+ * @param dir - 保险库目录。
24
+ * @returns `.cmd` 包装器的绝对路径,供 `SSH_ASKPASS` 使用。
25
+ */
26
+ export async function ensureAskpass(dir) {
27
+ const jsPath = path.join(dir, ASKPASS_JS)
28
+ const cmdPath = path.join(dir, ASKPASS_CMD)
29
+ await writeFile(
30
+ jsPath,
31
+ '// 由 ssh 以 askpass 身份调用:把本次连接的口令写到标准输出。\n'
32
+ + 'process.stdout.write(process.env.DSH_SSH_ASKPASS_SECRET ?? "");\n'
33
+ + 'process.exit(0);\n',
34
+ 'utf8',
35
+ )
36
+ // 以 Node 模式启动宿主可执行文件;路径中的 % 需要在批处理中转义。
37
+ const exe = process.execPath.replace(/%/g, '%%')
38
+ const cmd = [
39
+ '@echo off',
40
+ 'setlocal',
41
+ 'set ELECTRON_RUN_AS_NODE=1',
42
+ `"${exe}" "${jsPath.replace(/%/g, '%%')}"`,
43
+ '',
44
+ ].join('\r\n')
45
+ await writeFile(cmdPath, cmd, 'ascii')
46
+ return cmdPath
47
+ }
48
+
49
+ /**
50
+ * PowerShell 的错误流压平器。
51
+ *
52
+ * `powershell.exe` 在输出被重定向时会把 error 流序列化成 CLIXML 写进 stderr——连脚本源码
53
+ * 都被当成错误上下文回显,真正的错误信息淹没在一大段 XML 里。`-OutputFormat` 只作用于
54
+ * stdout,压不住它。所以脚本内部就把 error 流并进 stdout,再用这个过滤器把 ErrorRecord
55
+ * 压成一行 `[error] 消息`,避免 CategoryInfo 之类的框架细节。
56
+ */
57
+ const POWERSHELL_ERROR_FILTER = 'ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) '
58
+ + '{ "[error] " + $_.Exception.Message } else { $_ } }'
59
+
60
+ /**
61
+ * 把 shell 名称翻译成远端要执行的命令与参数。
62
+ *
63
+ * POSIX shell 统一用 `<shell> -s`,从标准输入读脚本;`cmd.exe` 也直接读标准输入。
64
+ *
65
+ * **PowerShell 例外**:`-Command -` 是从标准输入逐行读取的,here-string(`@'…'@`)、
66
+ * `foreach (…) {…}` 这类多行块会在读的过程中被拆散——拆散后产生的语法错误不会以非零
67
+ * 退出码回传,于是整条命令看起来"执行了但什么都没发生"。`-EncodedCommand` 把整段脚本
68
+ * 作为 Base64(UTF-16LE) 参数一次传过去,由 PowerShell 一次性解析,没有 stdin 依赖。
69
+ *
70
+ * @param shell - 主机记录里的 shell 字段。
71
+ * @param script - 要执行的脚本;PowerShell 需要它来编码,其它 shell 忽略。
72
+ * @returns 远端命令行数组。
73
+ */
74
+ export function shellArgv(shell, script = '') {
75
+ const name = typeof shell === 'string' && shell.trim().length > 0 ? shell.trim() : 'bash'
76
+ const lower = name.toLowerCase()
77
+ if (lower === 'cmd' || lower === 'cmd.exe') return ['cmd.exe', '/Q']
78
+ if (lower.startsWith('powershell') || lower.startsWith('pwsh')) {
79
+ // 脚本头部:固定输出编码(否则非 ASCII 变乱码)、关掉进度偏好。
80
+ const head = '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8\n'
81
+ + '$ProgressPreference="SilentlyContinue"\n'
82
+ const body = script.endsWith('\n') || script.length === 0 ? script : `${script}\n`
83
+ // **在脚本内部把 error 流并入 stdout**。powershell.exe 在输出被重定向时会把 error 流
84
+ // 序列化成 CLIXML 写到 stderr,连脚本源码都被当成错误上下文回显;-OutputFormat 只管
85
+ // stdout,压不住它。合并之后这条路径就不再被触发,Out-String -Stream 同时保住对象的
86
+ // 常规文本形态,不至于让 Get-Process 之类变成一行类型名。
87
+ const wrapped = `${head}& {\n${body}} 2>&1 | ${POWERSHELL_ERROR_FILTER} | Out-String -Stream\n`
88
+ const encoded = Buffer.from(wrapped, 'utf16le').toString('base64')
89
+ return [name, '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded]
90
+ }
91
+ return [name, '-s']
92
+ }
93
+
94
+ /**
95
+ * 远端 shell 是否从标准输入读取脚本。
96
+ *
97
+ * PowerShell 走 `-EncodedCommand`,脚本已经在参数里了;再往 stdin 写内容不会被执行,
98
+ * 只会被当成输入残留。
99
+ *
100
+ * @param shell - 主机记录里的 shell 字段。
101
+ * @returns PowerShell 目标返回 false。
102
+ */
103
+ export function shellReadsStdin(shell) {
104
+ return shellKind(shell) !== 'powershell'
105
+ }
106
+
107
+ /**
108
+ * 归一化 shell 名称,用于选择探测命令与说明文字。
109
+ * @param shell - 主机记录里的 shell。
110
+ * @returns `cmd` / `powershell` / `posix` 之一。
111
+ */
112
+ export function shellKind(shell) {
113
+ const lower = (typeof shell === 'string' && shell.trim().length > 0 ? shell.trim() : 'bash').toLowerCase()
114
+ if (lower === 'cmd' || lower === 'cmd.exe') return 'cmd'
115
+ if (lower.startsWith('powershell') || lower.startsWith('pwsh')) return 'powershell'
116
+ return 'posix'
117
+ }
118
+
119
+ /**
120
+ * 按目标平台的 shell 生成自检命令。
121
+ *
122
+ * Windows 的 cmd 与 PowerShell 不认 `uname` / `id`,因此自检必须换一套命令,
123
+ * 否则在 Windows 目标上探测会永远失败。
124
+ *
125
+ * @param shell - 主机记录里的 shell。
126
+ * @returns 远端自检脚本。
127
+ */
128
+ export function probeCommand(shell) {
129
+ const kind = shellKind(shell)
130
+ if (kind === 'cmd') {
131
+ return 'echo ##identity## %USERNAME%@%COMPUTERNAME%'
132
+ + ' & echo ##kernel## & ver'
133
+ + ' & echo ##os## & wmic os get Caption /value 2>nul'
134
+ + ' & echo ##cwd## %CD%'
135
+ }
136
+ if (kind === 'powershell') {
137
+ return 'Write-Output "##identity## $env:USERNAME@$env:COMPUTERNAME";'
138
+ + ' Write-Output "##kernel## $([Environment]::OSVersion.VersionString)";'
139
+ + ' Write-Output "##os## $((Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).Caption)";'
140
+ + ' Write-Output "##uptime## $((Get-Date) - (Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).LastBootUpTime)";'
141
+ + ' Write-Output "##cwd## $($PWD.Path)"'
142
+ }
143
+ return 'echo "##identity## $(id -un)@$(hostname)"; echo "##kernel## $(uname -srm)"; '
144
+ + 'echo "##os## $( (cat /etc/os-release 2>/dev/null | grep -E \'^(PRETTY_NAME|NAME)=\' | head -1) || cat /etc/redhat-release 2>/dev/null || echo unknown)"; '
145
+ + 'echo "##uptime## $(uptime -p 2>/dev/null || uptime)"; echo "##cwd## $(pwd)"'
146
+ }
147
+
148
+ /**
149
+ * 还原 OpenSSH 用 vis 风格八进制转义写出的非 ASCII 路径。
150
+ *
151
+ * ssh 客户端会把错误信息里的中文路径写成 `\346\223\215\344\275\234` 这样的形式,
152
+ * 直接展示几乎无法阅读。这里把连续的八进制转义还原成 UTF-8 文本;只有确实解出
153
+ * 非 ASCII 内容时才替换,避免破坏正常路径。
154
+ *
155
+ * @param text - 原始文本。
156
+ * @returns 可读文本。
157
+ */
158
+ export function decodeOctalEscapes(text) {
159
+ if (typeof text !== 'string' || !text.includes('\\')) return text
160
+ return text.replace(/(?:\\[0-7]{3})+/g, (match) => {
161
+ const bytes = []
162
+ for (const part of match.matchAll(/\\([0-7]{3})/g)) bytes.push(Number.parseInt(part[1], 8))
163
+ if (!bytes.some((byte) => byte > 0x7f)) return match
164
+ const decoded = Buffer.from(bytes).toString('utf8')
165
+ return decoded.includes('\uFFFD') ? match : decoded
166
+ })
167
+ }
168
+
169
+ /**
170
+ * 组装一次 SSH 调用的认证选项。
171
+ * @param auth - 主机认证配置。
172
+ * @returns 追加到命令行末尾的选项数组。
173
+ */
174
+ function authOptions(auth) {
175
+ switch (auth?.type) {
176
+ case 'password':
177
+ return ['-o', 'PreferredAuthentications=password,keyboard-interactive', '-o', 'PubkeyAuthentication=no']
178
+ case 'key':
179
+ return [
180
+ '-i', auth.privateKeyPath,
181
+ '-o', 'PreferredAuthentications=publickey',
182
+ '-o', 'IdentitiesOnly=yes',
183
+ ]
184
+ default:
185
+ return []
186
+ }
187
+ }
188
+
189
+ /**
190
+ * 公共连接选项:连接超时、主机密钥策略、保活与保险库私有的 known_hosts。
191
+ *
192
+ * `HashKnownHosts` 让 known_hosts 只保存主机名的哈希,避免地址以明文落盘;
193
+ * `UserKnownHostsFile` 永远指向保险库目录,因此不会去写用户的 `~/.ssh`。
194
+ * `ServerAliveInterval` 让长时间无输出的命令不被中间设备静默切断。
195
+ *
196
+ * @param options - 连接与超时设置。
197
+ * @returns 选项数组。
198
+ */
199
+ function connectionOptions({ knownHostsPath, connectTimeoutSec, sshConfigFile }) {
200
+ return [
201
+ ...(typeof sshConfigFile === 'string' && sshConfigFile.length > 0 ? ['-F', sshConfigFile] : []),
202
+ '-o', `ConnectTimeout=${connectTimeoutSec}`,
203
+ '-o', 'StrictHostKeyChecking=accept-new',
204
+ '-o', 'HashKnownHosts=yes',
205
+ '-o', `UserKnownHostsFile=${knownHostsPath}`,
206
+ '-o', 'NumberOfPasswordPrompts=1',
207
+ '-o', 'ServerAliveInterval=30',
208
+ '-o', 'ServerAliveCountMax=4',
209
+ '-o', 'LogLevel=ERROR',
210
+ ]
211
+ }
212
+
213
+ /**
214
+ * 组装一次 `ssh` 调用的完整参数。
215
+ * @param options - 主机记录、连接设置与要执行的远端命令。
216
+ * @returns 传给 ssh 的参数数组。
217
+ */
218
+ export function buildSshArgs({ record, knownHostsPath, connectTimeoutSec, sshConfigFile, bindAddress, remoteCommand = '' }) {
219
+ return [
220
+ '-T',
221
+ ...connectionOptions({ knownHostsPath, connectTimeoutSec, sshConfigFile }),
222
+ ...authOptions(record.auth),
223
+ ...(typeof bindAddress === 'string' && bindAddress.length > 0 ? ['-b', bindAddress] : []),
224
+ '-p', String(record.port),
225
+ '--',
226
+ `${record.username}@${record.host}`,
227
+ ...shellArgv(record.shell, remoteCommand),
228
+ ]
229
+ }
230
+
231
+ /**
232
+ * 组装一次 `scp` 调用的完整参数。
233
+ * @param options - 主机记录、传输方向与连接设置。
234
+ * @returns 传给 scp 的参数数组。
235
+ */
236
+ export function buildScpArgs({
237
+ record, direction, local, remote, recursive,
238
+ knownHostsPath, connectTimeoutSec, sshConfigFile, bindAddress,
239
+ }) {
240
+ const remoteSpec = `${record.username}@${record.host}:${remote}`
241
+ return [
242
+ '-P', String(record.port),
243
+ ...connectionOptions({ knownHostsPath, connectTimeoutSec, sshConfigFile }),
244
+ ...authOptions(record.auth),
245
+ ...(typeof bindAddress === 'string' && bindAddress.length > 0 ? ['-o', `BindAddress=${bindAddress}`] : []),
246
+ ...(recursive === true ? ['-r'] : []),
247
+ '--',
248
+ ...(direction === 'upload' ? [local, remoteSpec] : [remoteSpec, local]),
249
+ ]
250
+ }
251
+
252
+ /**
253
+ * 把子进程的输出收集成有界的字符串。
254
+ * @param stream - 可读流。
255
+ * @param limit - 保留的最大字节数。
256
+ * @param channel - 该流的通道名。
257
+ * @param onData - 可选的实时回调,用于把输出同步送进后台任务的环形缓冲。
258
+ * @returns 读取最终文本的函数。
259
+ */
260
+ function collector(stream, limit, channel, onData) {
261
+ let text = ''
262
+ let truncated = false
263
+ stream.setEncoding('utf8')
264
+ stream.on('data', (chunk) => {
265
+ try {
266
+ onData?.(chunk, channel)
267
+ } catch {
268
+ // 后台任务可能已经结算,实时回调失败不影响本次调用结果。
269
+ }
270
+ if (text.length >= limit) {
271
+ truncated = true
272
+ return
273
+ }
274
+ text += chunk
275
+ if (text.length > limit) {
276
+ text = text.slice(0, limit)
277
+ truncated = true
278
+ }
279
+ })
280
+ return () => ({ text, truncated })
281
+ }
282
+
283
+ /**
284
+ * 执行一次子进程并等待结束,支持超时、取消与实时输出回调。
285
+ *
286
+ * 无论超时还是取消,已收到的输出都会随结果返回——中断不等于失明。
287
+ *
288
+ * @param exe - 可执行文件。
289
+ * @param args - 参数数组。
290
+ * @param options - 标准输入、环境变量、超时、取消信号与回调。
291
+ * @returns 退出码、标准输出、标准错误、是否超时或取消。
292
+ */
293
+ export function runProcess(exe, args, { input, env, timeoutMs, signal, maxOutputBytes, onSpawn, onData }) {
294
+ return new Promise((resolve, reject) => {
295
+ const child = spawn(exe, args, {
296
+ stdio: ['pipe', 'pipe', 'pipe'],
297
+ windowsHide: true,
298
+ env,
299
+ })
300
+ const readOut = collector(child.stdout, maxOutputBytes, 'stdout', onData)
301
+ const readErr = collector(child.stderr, maxOutputBytes, 'stderr', onData)
302
+ let timedOut = false
303
+ let aborted = false
304
+ let settled = false
305
+ const finish = (fn, value) => {
306
+ if (settled) return
307
+ settled = true
308
+ clearTimeout(timer)
309
+ signal?.removeEventListener('abort', onAbort)
310
+ fn(value)
311
+ }
312
+ const kill = () => { try { child.kill() } catch { /* 进程可能已退出 */ } }
313
+ // timeoutMs 为 0 表示不限时,供后台任务使用。
314
+ const timer = timeoutMs > 0 ? setTimeout(() => {
315
+ timedOut = true
316
+ kill()
317
+ }, timeoutMs) : undefined
318
+ const onAbort = () => {
319
+ aborted = true
320
+ kill()
321
+ }
322
+ if (signal !== undefined) {
323
+ if (signal.aborted) onAbort()
324
+ else signal.addEventListener('abort', onAbort, { once: true })
325
+ }
326
+ try {
327
+ onSpawn?.(child)
328
+ } catch {
329
+ // 回调失败不应让本次调用挂起。
330
+ }
331
+ child.on('error', (error) => finish(reject, error))
332
+ child.on('close', (code) => {
333
+ const out = readOut()
334
+ const err = readErr()
335
+ finish(resolve, {
336
+ exitCode: code,
337
+ stdout: out.text,
338
+ stdoutTruncated: out.truncated,
339
+ stderr: err.text,
340
+ stderrTruncated: err.truncated,
341
+ timedOut,
342
+ aborted,
343
+ })
344
+ })
345
+ if (input !== undefined) child.stdin.end(input)
346
+ else child.stdin.end()
347
+ })
348
+ }
349
+
350
+ /**
351
+ * 把一次失败翻译成可执行的诊断建议。
352
+ *
353
+ * 顺序很重要:本地密钥文件的问题(权限过宽、误传公钥、路径不存在)常常与
354
+ * `Permission denied` 同时出现,必须先于服务器侧结论给出,否则会把本地问题
355
+ * 误报成“账号或口令不正确”。
356
+ *
357
+ * @param result - 子进程结果。
358
+ * @param target - 连接目标描述。
359
+ * @returns 去重后的诊断行数组。
360
+ */
361
+ export function diagnose(result, target) {
362
+ const err = decodeOctalEscapes(result.stderr ?? '')
363
+ const local = []
364
+ const server = []
365
+ const transport = []
366
+
367
+ // —— 本地密钥与配置:ssh 客户端在发起认证前就失败了 ——
368
+ if (/UNPROTECTED PRIVATE KEY FILE|are too open|bad permissions|Permissions for/i.test(err)) {
369
+ local.push(t('ssh.keyPermissionsTooOpen'))
370
+ }
371
+ if (/Load key .*(invalid format|error in libcrypto)|invalid format/i.test(err)) {
372
+ local.push(t('ssh.publicKeyAsPrivate'))
373
+ }
374
+ if (/Could not open a connection to your authentication agent|No such file or directory.*(private|identity)/i.test(err)) {
375
+ local.push(t('ssh.keyUnavailable'))
376
+ }
377
+ if (/a terminal is required to read the password|no tty present|askpass program specified|需要密码|sudo: .*password/i.test(err)) {
378
+ local.push(t('ssh.sudoNoPassword'))
379
+ }
380
+
381
+ // —— 服务器侧认证 ——
382
+ // `Permission denied (publickey,password,...)` 只是服务器在列举它接受的方式,不能据此断言公钥被拒;
383
+ // 只有确实在用密钥认证(且本地密钥已被加载),或列表里只剩下 publickey 时,公钥结论才成立。
384
+ const localKeyProblem = /UNPROTECTED PRIVATE KEY FILE|are too open|bad permissions|Permissions for|invalid format|Load key/i.test(err)
385
+ const denied = err.match(/Permission denied \(([^)]*)\)/i)
386
+ const methods = denied === null
387
+ ? []
388
+ : denied[1].split(',').map((method) => method.trim().toLowerCase()).filter((method) => method.length > 0)
389
+ const publickeyOnly = methods.length > 0 && methods.every((method) => method === 'publickey')
390
+ if (!localKeyProblem && /Permission denied/i.test(err) && (target.authType === 'key' || publickeyOnly)) {
391
+ server.push(t('ssh.publicKeyNotInstalled'))
392
+ }
393
+ if (/Too many authentication failures/i.test(err)) {
394
+ server.push(t('ssh.tooManyAuthAttempts'))
395
+ }
396
+ if (/no mutual signature algorithm|unsupported public key algorithm/i.test(err)) {
397
+ server.push(t('ssh.algorithmMismatch'))
398
+ }
399
+ // 直接出现的锁定提示(部分平台会明确报出来)。
400
+ if (/account is locked|account has been locked|Account active\s+Locked|账户.*锁定/i.test(err)) {
401
+ server.push(t('ssh.accountLocked'))
402
+ }
403
+
404
+ // —— 传输层 ——
405
+ if (/bind .*: (Unknown error|Cannot assign requested address)|bind: Cannot assign|Cannot assign requested address/i.test(err)) {
406
+ transport.push(t('ssh.bindUnavailable', {
407
+ address: target.bindAddress ? t('ssh.bindAddressSuffix', { address: target.bindAddress }) : '',
408
+ }))
409
+ }
410
+ if (/Connection refused/i.test(err)) {
411
+ transport.push(t('ssh.portRefused', { port: target.port }))
412
+ }
413
+ if (/Connection timed out|No route to host|Network is unreachable|Connection timed out during banner exchange/i.test(err)) {
414
+ transport.push(t('ssh.networkUnreachable', {
415
+ bound: target.bindAddress ? t('ssh.boundSuffix', { address: target.bindAddress }) : '',
416
+ }))
417
+ }
418
+ if (/Connection reset by peer|Connection closed by .* port|kex_exchange_identification/i.test(err)) {
419
+ // Windows OpenSSH 对**已锁定**的账户是直接断开连接,而不是回 Permission denied,
420
+ // 因此这里不能只归因于防火墙——否则「端口通、banner 正常、就是连不上」会被引向错误方向。
421
+ transport.push(t('ssh.handshakeClosed'))
422
+ }
423
+ if (/REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(err)) {
424
+ transport.push(t('ssh.hostKeyChanged'))
425
+ } else if (/Host key verification failed/i.test(err)) {
426
+ transport.push(t('ssh.hostKeyRejected'))
427
+ }
428
+ if (/command not found|No such file or directory.*shell|is not recognized as an internal/i.test(err)) {
429
+ transport.push(t('ssh.shellMissing'))
430
+ }
431
+ if (result.timedOut) {
432
+ transport.push(t('ssh.timeout', { ms: target.timeoutMs }))
433
+ }
434
+
435
+ // 只有在没有更具体结论时,才给出“账号或口令不正确”这一通用判断。
436
+ if (local.length === 0 && server.length === 0 && /Permission denied/i.test(err)) {
437
+ server.push(t('ssh.authFailed'))
438
+ }
439
+ return [...local, ...server, ...transport]
440
+ }
441
+
442
+ /** 用单引号把一段文本安全地嵌进 POSIX shell 脚本。 */
443
+ function shellQuote(text) {
444
+ return `'${String(text).replace(/'/g, `'\\''`)}'`
445
+ }
446
+
447
+ /**
448
+ * 生成 sudo 预热脚本。
449
+ *
450
+ * 口令以字面量写进脚本,脚本本身通过 SSH 的**标准输入**送进远端 shell,因此口令
451
+ * 不出现在本地命令行、不出现在 ssh 进程参数、不出现在远端的 `ps` 输出里,也不落盘。
452
+ * 预热后 `sudo` 在时间戳窗口内免密,用户命令里照常写 `sudo ...` 即可。
453
+ *
454
+ * @param password - 用于 sudo 的口令。
455
+ * @returns 拼在用户命令之前的脚本片段。
456
+ */
457
+ export function sudoPrelude(password) {
458
+ return `printf '%s\\n' ${shellQuote(password)} | sudo -S -p '' -v 2>/dev/null`
459
+ + ' || { echo "[dsh-ssh-vault] sudo 凭据校验失败:保存的口令可能不是该账户的 sudo 口令,'
460
+ + '或目标 sudoers 要求 tty(Defaults requiretty)。" >&2; exit 1; }\n'
461
+ }
462
+
463
+ /**
464
+ * 生成远端条件等待脚本。
465
+ *
466
+ * 长任务(升级、扩容、扫描)不必再用「sleep + 读日志」轮询:把等待条件交给远端,
467
+ * 一次调用就等到满足或超时,命中时回传匹配到的最后一行。配合 run_in_background
468
+ * 使用时,等待本身也不占用一次前台调用的额度。
469
+ *
470
+ * @param options - 目标路径、可选正则、超时与轮询间隔。
471
+ * @returns 远端脚本。
472
+ */
473
+ export function buildWaitScript({ path, regex, timeoutMs, intervalMs }) {
474
+ const waitSeconds = Math.max(1, Math.ceil((Number.isFinite(timeoutMs) ? timeoutMs : 600000) / 1000))
475
+ const interval = Math.max(1, Math.ceil((Number.isFinite(intervalMs) ? intervalMs : 3000) / 1000))
476
+ const hasPattern = typeof regex === 'string' && regex.length > 0
477
+ const lines = [
478
+ `DSH_TARGET=${shellQuote(path)}`,
479
+ `DSH_DEADLINE=$(( $(date +%s) + ${waitSeconds} ))`,
480
+ ]
481
+ if (hasPattern) lines.push(`DSH_PATTERN=${shellQuote(regex)}`)
482
+ lines.push('while :; do')
483
+ lines.push(' if [ -e "$DSH_TARGET" ]; then')
484
+ if (hasPattern) {
485
+ lines.push(' if grep -qE "$DSH_PATTERN" "$DSH_TARGET" 2>/dev/null; then')
486
+ lines.push(' echo "[dsh-ssh-vault] 条件满足,匹配行:"')
487
+ lines.push(' grep -E "$DSH_PATTERN" "$DSH_TARGET" | tail -1')
488
+ lines.push(' exit 0')
489
+ lines.push(' fi')
490
+ } else {
491
+ lines.push(' echo "[dsh-ssh-vault] 目标已出现:$DSH_TARGET"')
492
+ lines.push(' exit 0')
493
+ }
494
+ lines.push(' fi')
495
+ lines.push(' if [ "$(date +%s)" -ge "$DSH_DEADLINE" ]; then')
496
+ lines.push(' echo "[dsh-ssh-vault] 等待超时:$DSH_TARGET 未满足条件" >&2')
497
+ lines.push(' exit 124')
498
+ lines.push(' fi')
499
+ lines.push(` sleep ${interval}`)
500
+ lines.push('done')
501
+ return lines.join('\n')
502
+ }
503
+
504
+ /**
505
+ * 生成远端脱离脚本:用 `setsid` 让命令脱离 SSH 会话独立运行,输出落到日志文件。
506
+ *
507
+ * 与「本地长连接式后台」相比,这种方式不怕本地连接断开、代理超时或调用被取消——
508
+ * 远端进程活着,pid 与日志文件就在,随时可以回来查状态、读增量输出。
509
+ *
510
+ * @param options - 要执行的命令与本次标记。
511
+ * @returns 远端脚本。
512
+ */
513
+ export function buildDetachScript({ command, token }) {
514
+ const log = `/tmp/.dsh-ssh-${token}.log`
515
+ const pidFile = `/tmp/.dsh-ssh-${token}.pid`
516
+ return [
517
+ `DSH_LOG=${shellQuote(log)}`,
518
+ `DSH_PID=${shellQuote(pidFile)}`,
519
+ `setsid sh -c ${shellQuote(command)} > "$DSH_LOG" 2>&1 < /dev/null &`,
520
+ 'echo $! > "$DSH_PID"',
521
+ 'sleep 1',
522
+ 'if kill -0 "$(cat "$DSH_PID")" 2>/dev/null; then',
523
+ ' echo "[dsh-ssh-vault] 已在远端后台启动:pid=$(cat "$DSH_PID")"',
524
+ 'else',
525
+ ' echo "[dsh-ssh-vault] 远端进程启动后已退出,请读日志确认结果" >&2',
526
+ 'fi',
527
+ 'echo "[dsh-ssh-vault] 日志:$DSH_LOG"',
528
+ 'echo "[dsh-ssh-vault] 存活查询:ps -p $(cat "$DSH_PID") >/dev/null 2>&1 && echo ALIVE || echo GONE"',
529
+ 'echo "[dsh-ssh-vault] 结束标记:在命令末尾追加 `echo DONE > ${DSH_LOG}.done`,再用 wait_for 等待它"',
530
+ ].join('\n')
531
+ }
532
+
533
+ /**
534
+ * 在一台已保存的主机上执行命令。
535
+ * @param options - 调用参数。
536
+ * @returns 归一化后的执行结果。
537
+ */
538
+ export async function runRemote({
539
+ record,
540
+ secret,
541
+ command,
542
+ askpassPath,
543
+ knownHostsPath,
544
+ connectTimeoutSec,
545
+ timeoutMs,
546
+ maxOutputBytes,
547
+ signal,
548
+ bindAddress,
549
+ sshConfigFile,
550
+ sudoPassword,
551
+ extraNotes,
552
+ sshExecutable = 'ssh',
553
+ env = process.env,
554
+ onSpawn,
555
+ onData,
556
+ }) {
557
+ const childEnv = { ...env, ...askpassEnvironment(askpassPath, secret) }
558
+ const body = command.endsWith('\n') ? command : `${command}\n`
559
+ const inputScript = typeof sudoPassword === 'string' && sudoPassword.length > 0
560
+ ? `${sudoPrelude(sudoPassword)}${body}`
561
+ : body
562
+ // PowerShell 目标把完整脚本(含可能的前置)编进 -EncodedCommand;其余 shell 仍走 stdin。
563
+ const readsStdin = shellReadsStdin(record.shell)
564
+ const args = buildSshArgs({
565
+ record,
566
+ knownHostsPath,
567
+ connectTimeoutSec,
568
+ sshConfigFile,
569
+ bindAddress,
570
+ remoteCommand: readsStdin ? '' : inputScript,
571
+ })
572
+ const result = await runProcess(sshExecutable, args, {
573
+ input: readsStdin ? inputScript : undefined,
574
+ env: childEnv,
575
+ timeoutMs,
576
+ signal,
577
+ maxOutputBytes,
578
+ onSpawn,
579
+ onData,
580
+ })
581
+ return {
582
+ ...result,
583
+ stderr: decodeOctalEscapes(result.stderr ?? ''),
584
+ notes: [
585
+ ...(Array.isArray(extraNotes) ? extraNotes : []),
586
+ ...diagnose(result, { port: record.port, timeoutMs, bindAddress, authType: record.auth?.type }),
587
+ ],
588
+ }
589
+ }
590
+
591
+ /**
592
+ * 在主机与本地之间传输文件。
593
+ * @param options - 调用参数。
594
+ * @returns 归一化后的传输结果。
595
+ */
596
+ export async function transferFile({
597
+ record,
598
+ secret,
599
+ direction,
600
+ local,
601
+ remote,
602
+ recursive,
603
+ askpassPath,
604
+ knownHostsPath,
605
+ connectTimeoutSec,
606
+ timeoutMs,
607
+ maxOutputBytes,
608
+ signal,
609
+ bindAddress,
610
+ sshConfigFile,
611
+ scpExecutable = 'scp',
612
+ env = process.env,
613
+ onSpawn,
614
+ onData,
615
+ }) {
616
+ const args = buildScpArgs({
617
+ record, direction, local, remote, recursive, knownHostsPath, connectTimeoutSec, sshConfigFile, bindAddress,
618
+ })
619
+ const childEnv = { ...env, ...askpassEnvironment(askpassPath, secret) }
620
+ const result = await runProcess(scpExecutable, args, {
621
+ env: childEnv,
622
+ timeoutMs,
623
+ signal,
624
+ maxOutputBytes,
625
+ onSpawn,
626
+ onData,
627
+ })
628
+ const err = decodeOctalEscapes(result.stderr ?? '')
629
+ const notes = diagnose(result, { port: record.port, timeoutMs, bindAddress, authType: record.auth?.type })
630
+ // 连接层面的错误优先。`No such file or directory` 在同一次失败里可能只是连接中断的连带
631
+ // 结果,此时再提示"路径不存在"会把排查引向外层——先给出传输层结论就够了。
632
+ const transportBroken = /Connection (reset|closed|refused|timed out)|kex_exchange_identification|No route to host/i.test(err)
633
+ if (!transportBroken && /No such file or directory/i.test(err)) {
634
+ notes.push(t('ssh.transferPathMissing'))
635
+ }
636
+ return { ...result, stderr: err, notes }
637
+ }
638
+
639
+ /**
640
+ * 构造把口令交给 askpass 的环境变量。
641
+ * @param askpassPath - askpass 包装器路径。
642
+ * @param secret - 本次连接使用的口令。
643
+ * @returns 需要并入子进程环境的环境变量。
644
+ */
645
+ function askpassEnvironment(askpassPath, secret) {
646
+ if (typeof secret !== 'string' || secret.length === 0) return {}
647
+ return {
648
+ SSH_ASKPASS: askpassPath,
649
+ SSH_ASKPASS_REQUIRE: 'force',
650
+ DSH_SSH_ASKPASS_SECRET: secret,
651
+ }
652
+ }
653
+
654
+ /**
655
+ * 从渲染好的结果文本里取出退出状态,供对话里的终端卡片显示状态标签。
656
+ *
657
+ * 与 `@deepseek-ai/dsh-shell` 的同名函数行为一致,但不引入那个依赖;区别只在匹配
658
+ * 更宽松——我们的标记后面还可能跟着诊断建议,所以不要求它在整个文本的最末尾。
659
+ *
660
+ * @param text - 工具的展示文本。
661
+ * @returns 去掉状态标记的正文,以及退出码或终止信号。
662
+ */
663
+ export function parseExitStatus(text) {
664
+ const signal = /\n\[killed by signal: ([^\]\n]+)\](?:\n|$)/.exec(text)
665
+ if (signal?.[1] !== undefined) {
666
+ return { body: text.replace(signal[0], '\n').replace(/\n+$/, ''), signal: signal[1] }
667
+ }
668
+ const exit = /\n\[exit code: (\d+)\](?:\n|$)/.exec(text)
669
+ if (exit?.[1] !== undefined) {
670
+ return { body: text.replace(exit[0], '\n').replace(/\n+$/, ''), exitCode: Number(exit[1]) }
671
+ }
672
+ return { body: text.replace(/\n+$/, ''), exitCode: 0 }
673
+ }
674
+
675
+ /**
676
+ * 把工具输出包成终端样式的 Markdown 代码块。
677
+ *
678
+ * `presentCall` / `presentResult` 才是官方的「终端卡片」契约,但它们在本机安装里
679
+ * 找不到消费方——只在各工具包中被定义、被 `defineTool` 包装,没有任何代码调用它们,
680
+ * 因此不能作为插件可依赖的路径。内置 pwsh 工具在后台任务那条路上返回的正是这种
681
+ * ```console 代码块,所以它一定会被渲染。
682
+ *
683
+ * @param text - 工具的展示文本。
684
+ * @returns 带 console 语言标记的围栏代码块。
685
+ */
686
+ export function renderTerminalBlock(text) {
687
+ return `\`\`\`console\n${String(text).replace(/\n+$/, '')}\n\`\`\``
688
+ }
689
+
690
+ /**
691
+ * 一句话开头是常见命令名时,即使带中文参数也仍按命令处理。
692
+ *
693
+ * 判据只能靠启发式:`echo 你好` 和「帮我看看磁盘」都含中文、都没有元字符。用白名单
694
+ * 认命令的开头是唯一能拉开两者的办法。漏判的方向是安全的——漏掉一条命令只会多跑一次
695
+ * shell 并报语法错;误判成自然语言则会让一条本该执行的命令被静默拦下。
696
+ */
697
+ const COMMON_COMMAND_HEAD = /^(?:sudo|doas|su|apt|apt-get|apk|dnf|yum|pacman|zypper|snap|ls|ll|la|cat|tac|cd|pwd|cp|mv|rm|rmdir|mkdir|touch|chmod|chown|chgrp|ln|echo|printf|grep|egrep|find|locate|awk|sed|sort|uniq|head|tail|wc|cut|tr|xargs|tee|du|df|free|ps|pgrep|pkill|kill|killall|top|htop|systemctl|service|journalctl|dmesg|ip|ifconfig|ss|netstat|ping|traceroute|dig|nslookup|curl|wget|tar|zip|unzip|gzip|gunzip|python|python3|pip|pip3|node|npm|pnpm|yarn|git|docker|podman|kubectl|helm|mysql|psql|redis-cli|vim|vi|nano|emacs|date|hostname|whoami|id|groups|uname|uptime|hostnamectl|mount|umount|lsblk|blkid|fdisk|ufw|iptables|firewall-cmd|useradd|userdel|usermod|groupadd|passwd|ssh|scp|sftp|rsync|bash|sh|zsh|fish|env|export|source|which|whereis|type|file|stat|readlink|md5sum|sha1sum|sha256sum|openssl|crontab|at|watch|nohup|screen|tmux|reboot|shutdown|sleep|yes|seq|basename|dirname|realpath|test|expr)\b/i
698
+
699
+ /**
700
+ * 判断一段文字看起来是自然语言,而不是 shell 命令。
701
+ *
702
+ * 先看是不是明确的命令形态:shell 元字符、路径前缀、或常见命令名开头——三者任一命中
703
+ * 就是命令,哪怕参数里带中文。都不命中且含中日韩文字时,才认为是在让模型做事。
704
+ *
705
+ * 用在 `/hosts 别名; …` 的后半段上:命令的产出不会回流给模型,所以识别出自然语言时
706
+ * 只能停下并交还给用户,不能硬当成命令执行。
707
+ *
708
+ * @param text - 分号之后的内容。
709
+ * @returns 看起来是自然语言时为 true。
710
+ */
711
+ export function looksLikeNaturalLanguage(text) {
712
+ const trimmed = String(text ?? '').trim()
713
+ if (trimmed.length === 0) return false
714
+ // shell 元字符,或者以路径开头:就是命令,哪怕参数里带中文。
715
+ if (/[|&<>$()`]/.test(trimmed)) return false
716
+ if (/^(?:\.{0,2}\/|~\/)/.test(trimmed)) return false
717
+ // 以常见命令名开头:也是命令,例如 `echo 你好`。
718
+ if (COMMON_COMMAND_HEAD.test(trimmed)) return false
719
+ return /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(trimmed)
720
+ }
721
+
722
+ /** 一次 SSH 调用的超时上限,防止误配置导致永久挂起。 */
723
+ export const MAX_TIMEOUT_MS = 30 * 60 * 1000