dsh-ssh-workbench 0.0.0-stage → 1.6.4

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/SECURITY.md +25 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +124 -0
  8. package/docs/quickstart.md +94 -0
  9. package/lib/client.js +4977 -0
  10. package/lib/core/activity.js +83 -0
  11. package/lib/core/background.js +69 -0
  12. package/lib/core/broker.js +799 -0
  13. package/lib/core/dialect.js +28 -0
  14. package/lib/core/errors.js +138 -0
  15. package/lib/core/gate-pwsh.js +88 -0
  16. package/lib/core/gate.js +572 -0
  17. package/lib/core/guard.js +71 -0
  18. package/lib/core/known-hosts.js +74 -0
  19. package/lib/core/local-file-api.js +174 -0
  20. package/lib/core/localfs.js +381 -0
  21. package/lib/core/paths.js +104 -0
  22. package/lib/core/posix-quote.js +4 -0
  23. package/lib/core/prefs.js +38 -0
  24. package/lib/core/remote-bash-tool.js +321 -0
  25. package/lib/core/remote-bash.js +337 -0
  26. package/lib/core/secrets.js +96 -0
  27. package/lib/core/server-reference.js +299 -0
  28. package/lib/core/shell-adapters.js +48 -0
  29. package/lib/core/ssh.js +761 -0
  30. package/lib/core/store.js +83 -0
  31. package/lib/core/targets.js +250 -0
  32. package/lib/core/terminals.js +299 -0
  33. package/lib/core/win32-adapter.js +141 -0
  34. package/lib/index.js +450 -0
  35. package/lib/routes.js +733 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +88 -4
  39. package/vendor/NOTICE.md +50 -0
  40. package/vendor/XTERM-LICENSE +21 -0
  41. package/vendor/node_modules/asn1/LICENSE +19 -0
  42. package/vendor/node_modules/asn1/README.md +50 -0
  43. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  44. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  45. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  46. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  47. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  48. package/vendor/node_modules/asn1/lib/index.js +20 -0
  49. package/vendor/node_modules/asn1/package.json +31 -0
  50. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  51. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  52. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  53. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  54. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  55. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  56. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  57. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  58. package/vendor/node_modules/safer-buffer/package.json +34 -0
  59. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  60. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  61. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  62. package/vendor/node_modules/tweetnacl/README.md +459 -0
  63. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  64. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  65. package/vendor/node_modules/tweetnacl/package.json +58 -0
  66. package/vendor/ssh2/LICENSE +19 -0
  67. package/vendor/ssh2/lib/Channel.js +295 -0
  68. package/vendor/ssh2/lib/agent.js +1123 -0
  69. package/vendor/ssh2/lib/client.js +2176 -0
  70. package/vendor/ssh2/lib/http-agents.js +84 -0
  71. package/vendor/ssh2/lib/index.js +44 -0
  72. package/vendor/ssh2/lib/keygen.js +582 -0
  73. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  74. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  75. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  76. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  77. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  78. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  79. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  80. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  81. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  82. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  83. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  84. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  85. package/vendor/ssh2/lib/server.js +1380 -0
  86. package/vendor/ssh2/lib/utils.js +336 -0
  87. package/vendor/ssh2/package.json +34 -0
  88. package/vendor/xterm.js +53 -0
@@ -0,0 +1,799 @@
1
+ /**
2
+ * Session broker: one pooled SSH connection per managed server.
3
+ *
4
+ * Why: every `exec`/SFTP call used to pay a full TCP + handshake + auth
5
+ * (~300ms) plus channel/SFTP setup (~150-250ms). The broker keeps one ssh2
6
+ * Client per server (SSH multiplexes channels natively), reuses the SFTP
7
+ * subsystem, enforces bounded concurrency, evicts idle connections, and
8
+ * reconnects once when the connection (not the command) fails.
9
+ *
10
+ * Invariants:
11
+ * - Never fake success: a connection lost mid-command is reported as
12
+ * `unconfirmed`/failed, never as a clean exit.
13
+ * - Never hang: every operation has a timeout; SFTP errors are always
14
+ * normalized and rejected (no recursive normalizer).
15
+ * - Never leak: idle sweep + plugin unload (`closeAll`) close every client.
16
+ */
17
+ import { createHash } from 'node:crypto'
18
+ import fs from 'node:fs'
19
+ import os from 'node:os'
20
+ import path from 'node:path'
21
+
22
+ import {
23
+ SshError,
24
+ classifyExecFailure,
25
+ errorResult,
26
+ isBenignReadOnlyExit,
27
+ isConnectionLevelError,
28
+ normalizeError,
29
+ suggestedActionFor,
30
+ toSftpError,
31
+ } from './errors.js'
32
+ import { effectiveDialect } from './dialect.js'
33
+
34
+ const BROKER_DEFAULTS = {
35
+ connectTimeoutMs: 15000,
36
+ commandTimeoutMs: 60000,
37
+ sftpTimeoutMs: 30000,
38
+ maxOutputBytes: 100 * 1024,
39
+ // stderr is mostly noise/diagnostics; keeping it well below stdout protects
40
+ // the model context from a single chatty (or failing) command.
41
+ maxStderrBytes: 16 * 1024,
42
+ keepaliveInterval: 30000,
43
+ keepaliveCountMax: 3,
44
+ poolIdleMs: 90000,
45
+ poolMaxServers: 8,
46
+ maxChannelsPerServer: 6,
47
+ sweepIntervalMs: 15000,
48
+ }
49
+
50
+
51
+ /** POSIX single-quote escaping for a remote path. */
52
+ function quotePosix(value) {
53
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
54
+ }
55
+
56
+ /** PowerShell single-quote escaping. */
57
+ function quotePowerShell(value) {
58
+ return `'${String(value).replace(/'/g, "''")}'`
59
+ }
60
+
61
+ /**
62
+ * Wrap a command so it runs inside `cwd` (SSH exec channels have no cwd of
63
+ * their own). POSIX uses `cd -- <dir> && ...`; Windows uses cmd or PowerShell
64
+ * depending on the configured shell.
65
+ */
66
+ function withCwd(command, cwd, server = {}) {
67
+ const dir = String(cwd ?? '').trim()
68
+ if (!dir) return command
69
+ if (effectiveDialect(server) === 'windows') {
70
+ if (/powershell/i.test(String(server.defaultShell ?? ''))) {
71
+ return `Set-Location -LiteralPath ${quotePowerShell(dir)}; ${command}`
72
+ }
73
+ return `cd /d "${String(dir).replace(/"/g, '""')}" && ${command}`
74
+ }
75
+ return `cd -- ${quotePosix(dir)} && ${command}`
76
+ }
77
+
78
+ function expandHome(input) {
79
+ if (typeof input !== 'string') return ''
80
+ if (input === '~') return os.homedir()
81
+ if (input.startsWith('~/')) return path.join(os.homedir(), input.slice(2))
82
+ return input
83
+ }
84
+
85
+ function createCapture(cap) {
86
+ const headCap = Math.floor(cap / 2)
87
+ let head = ''
88
+ let tail = ''
89
+ let original = 0
90
+ return {
91
+ add(text) {
92
+ original += Buffer.byteLength(text, 'utf8')
93
+ let rest = text
94
+ if (head.length < headCap) {
95
+ const room = headCap - head.length
96
+ if (rest.length <= room) {
97
+ head += rest
98
+ return
99
+ }
100
+ head += rest.slice(0, room)
101
+ rest = rest.slice(room)
102
+ }
103
+ if (rest.length > 0) {
104
+ tail += rest
105
+ if (tail.length > cap) tail = tail.slice(-Math.floor(cap / 2))
106
+ }
107
+ },
108
+ finish() {
109
+ const truncated = original > cap
110
+ const text = truncated ? `${head}\n...[truncated ${original - head.length - tail.length} bytes]...\n${tail}` : head + tail
111
+ return { text, truncated, original }
112
+ },
113
+ }
114
+ }
115
+
116
+
117
+ /**
118
+ * @param {object} deps
119
+ * @param {(key: string) => string|undefined} [deps.getSecret]
120
+ * @param {{ verify: Function, has: Function }} [deps.knownHosts]
121
+ * @param {object} [deps.config] plugin config slice
122
+ * @param {(message: string, fields?: object) => void} [deps.log]
123
+ * @param {new () => any} [deps.ClientCtor] test seam (defaults to ssh2.Client)
124
+ */
125
+ export function createSessionBroker({ getSecret, knownHosts, config = {}, log = () => {}, ClientCtor } = {}) {
126
+ const settings = { ...BROKER_DEFAULTS, ...Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined)) }
127
+ // Kill switch: `connectionPool: false` restores the old "one connection per
128
+ // call" behaviour without touching the rest of the pipeline.
129
+ const poolEnabled = settings.connectionPool !== false
130
+ const now = () => Date.now()
131
+
132
+ let ssh2Promise
133
+ const loadSsh2 = async () => {
134
+ if (ClientCtor) return { Client: ClientCtor }
135
+ if (!ssh2Promise) {
136
+ // ssh2 is vendored (pure JS) so installing the plugin never invokes
137
+ // node-gyp or downloads packages at runtime; see vendor/NOTICE.md.
138
+ ssh2Promise = import('../../vendor/ssh2/lib/index.js').then((module) => {
139
+ // ssh2 is CommonJS: depending on the Node interop the named exports may
140
+ // be absent, leaving the whole module under `default`.
141
+ const lib = module && (module.Client || module.Server) ? module : module?.default
142
+ if (!lib || typeof lib.Client !== 'function') throw new SshError('EXEC_FAILED', 'ssh2 module has no Client export')
143
+ return lib
144
+ })
145
+ }
146
+ return ssh2Promise
147
+ }
148
+
149
+ /** @type {Map<string, any>} server.id → entry */
150
+ const entries = new Map()
151
+ function authFor(server) {
152
+ const ref = server.authRef
153
+ if (!ref || ref.type !== 'sshKey') {
154
+ const password = getSecret?.(`server:${server.id}:password`)
155
+ if (!password) throw new SshError('AUTH_MISSING', `no password stored for ${server.name}`)
156
+ return { password }
157
+ }
158
+ const keyPath = expandHome(ref.path)
159
+ let privateKey
160
+ try {
161
+ privateKey = fs.readFileSync(keyPath)
162
+ } catch (error) {
163
+ throw new SshError('AUTH_FAILED', `cannot read private key ${ref.path}: ${error instanceof Error ? error.message : String(error)}`)
164
+ }
165
+ const passphrase = getSecret?.(`server:${server.id}:passphrase`)
166
+ return { privateKey, ...(passphrase ? { passphrase } : {}) }
167
+ }
168
+
169
+ function serverKey(server) {
170
+ const ref = server.authRef ?? {}
171
+ let auth
172
+ if (ref.type === 'sshKey') auth = `key:${ref.path ?? ''}`
173
+ else {
174
+ const secret = getSecret?.(`server:${server.id}:password`) ?? ''
175
+ auth = `pwd:${createHash('sha256').update(secret).digest('hex').slice(0, 16)}`
176
+ }
177
+ return `${server.host}|${server.port}|${server.username}|${auth}`
178
+ }
179
+
180
+ function connectOptions(server, state) {
181
+ const hostKeyMode = settings.hostKeyMode === 'accept-new' ? 'accept-new' : 'strict'
182
+ return {
183
+ host: server.host,
184
+ port: server.port,
185
+ username: server.username,
186
+ readyTimeout: settings.connectTimeoutMs,
187
+ keepaliveInterval: settings.keepaliveInterval,
188
+ keepaliveCountMax: settings.keepaliveCountMax,
189
+ hostHash: 'sha256',
190
+ hostVerifier: (hashedKey) => {
191
+ const fingerprint = Buffer.isBuffer(hashedKey) ? hashedKey.toString('hex') : String(hashedKey)
192
+ state.fingerprint = fingerprint
193
+ return knownHosts ? knownHosts.verify(server, fingerprint, hostKeyMode) : hostKeyMode === 'accept-new'
194
+ },
195
+ ...authFor(server),
196
+ }
197
+ }
198
+
199
+ /** Open a fresh client and resolve on `ready`. */
200
+ async function connectClient(server) {
201
+ const { Client } = await loadSsh2()
202
+ const state = {}
203
+ const client = new Client()
204
+ const started = now()
205
+ return await new Promise((resolve, reject) => {
206
+ let settled = false
207
+ const fail = (error) => {
208
+ if (settled) return
209
+ settled = true
210
+ try {
211
+ client.end()
212
+ } catch {
213
+ // socket may already be gone
214
+ }
215
+ reject(normalizeError(error, state.fingerprint))
216
+ }
217
+ client.once('ready', () => {
218
+ if (settled) return
219
+ settled = true
220
+ resolve({ client, fingerprint: state.fingerprint, connectMs: now() - started })
221
+ })
222
+ client.once('error', fail)
223
+ // Keep a listener after `ready`: ssh2 can emit a second error while the
224
+ // socket tears down, and an unhandled 'error' would crash the host.
225
+ client.on('error', (error) => {
226
+ log('ssh client error', { server: server.name, error: error instanceof Error ? error.message : String(error) })
227
+ })
228
+ try {
229
+ client.connect(connectOptions(server, state))
230
+ } catch (error) {
231
+ fail(error)
232
+ }
233
+ })
234
+ }
235
+
236
+ function markDead(entry, reason = 'connection lost') {
237
+ if (entry.state === 'dead') return
238
+ entry.state = 'dead'
239
+ if (entries.get(entry.id) === entry) entries.delete(entry.id)
240
+ try {
241
+ entry.sftp?.end?.()
242
+ } catch {
243
+ // best effort
244
+ }
245
+ try {
246
+ entry.client.end()
247
+ } catch {
248
+ // best effort
249
+ }
250
+ const waiters = entry.waiters.splice(0)
251
+ for (const wake of waiters) wake()
252
+ log('ssh connection marked dead', { server: entry.name, reason })
253
+ }
254
+
255
+ function evict(entry, reason = 'evicted') {
256
+ if (!entry) return
257
+ if (entry.state !== 'dead') entry.state = 'dead'
258
+ if (entries.get(entry.id) === entry) entries.delete(entry.id)
259
+ try {
260
+ entry.sftp?.end?.()
261
+ } catch {
262
+ // best effort
263
+ }
264
+ try {
265
+ entry.client.end()
266
+ } catch {
267
+ // best effort
268
+ }
269
+ const waiters = entry.waiters.splice(0)
270
+ for (const wake of waiters) wake()
271
+ log('ssh connection evicted', { server: entry.name, reason })
272
+ }
273
+
274
+ function trimIdle() {
275
+ const idle = [...entries.values()].filter((entry) => entry.channels === 0 && entry.state === 'ready')
276
+ while (entries.size > settings.poolMaxServers && idle.length > 0) {
277
+ const oldest = idle.sort((left, right) => left.lastUsed - right.lastUsed)[0]
278
+ idle.shift()
279
+ evict(oldest, 'pool capacity')
280
+ }
281
+ }
282
+
283
+
284
+ async function acquire(server) {
285
+ const id = server.id ?? server.name
286
+ const key = serverKey(server)
287
+ let entry = entries.get(id)
288
+ let warm = false
289
+
290
+ if (entry && entry.key !== key) {
291
+ evict(entry, 'server settings changed')
292
+ entry = undefined
293
+ }
294
+ if (entry && entry.state === 'dead') {
295
+ entries.delete(id)
296
+ entry = undefined
297
+ }
298
+ if (entry && entry.state === 'connecting' && entry.pending) {
299
+ const ready = await entry.pending
300
+ return { entry: ready, warm: true }
301
+ }
302
+ if (entry && entry.state === 'ready' && !poolEnabled) {
303
+ evict(entry, 'pool disabled')
304
+ entry = undefined
305
+ }
306
+ if (entry && entry.state === 'ready') {
307
+ entry.lastUsed = now()
308
+ warm = true
309
+ return { entry, warm }
310
+ }
311
+
312
+ trimIdle()
313
+ const created = {
314
+ id,
315
+ name: server.name,
316
+ key,
317
+ client: null,
318
+ fingerprint: undefined,
319
+ sftp: null,
320
+ sftpPending: null,
321
+ state: 'connecting',
322
+ pending: null,
323
+ channels: 0,
324
+ waiters: [],
325
+ lastUsed: now(),
326
+ createdAt: now(),
327
+ connectMs: 0,
328
+ }
329
+ created.pending = (async () => {
330
+ try {
331
+ const { client, fingerprint, connectMs } = await connectClient(server)
332
+ created.client = client
333
+ created.fingerprint = fingerprint
334
+ created.connectMs = connectMs
335
+ created.state = 'ready'
336
+ created.pending = null
337
+ created.lastUsed = now()
338
+ client.on('close', () => markDead(created, 'connection closed'))
339
+ return created
340
+ } catch (error) {
341
+ created.state = 'dead'
342
+ created.pending = null
343
+ if (entries.get(id) === created) entries.delete(id)
344
+ throw error
345
+ }
346
+ })()
347
+ entries.set(id, created)
348
+ try {
349
+ const ready = await created.pending
350
+ return { entry: ready, warm: false }
351
+ } catch (error) {
352
+ throw error instanceof SshError ? error : normalizeError(error)
353
+ }
354
+ }
355
+
356
+ function acquireChannel(entry) {
357
+ if (entry.channels < settings.maxChannelsPerServer) {
358
+ entry.channels += 1
359
+ return Promise.resolve()
360
+ }
361
+ return new Promise((resolve) => {
362
+ entry.waiters.push(resolve)
363
+ }).then(() => {
364
+ entry.channels += 1
365
+ })
366
+ }
367
+
368
+ function releaseChannel(entry) {
369
+ if (entry.channels > 0) entry.channels -= 1
370
+ const wake = entry.waiters.shift()
371
+ if (wake) wake()
372
+ }
373
+
374
+ function ensureSftp(entry) {
375
+ if (entry.sftp) return Promise.resolve(entry.sftp)
376
+ if (entry.sftpPending) return entry.sftpPending
377
+ entry.sftpPending = new Promise((resolve, reject) => {
378
+ try {
379
+ entry.client.sftp((error, session) => (error ? reject(toSftpError(error)) : resolve(session)))
380
+ } catch (error) {
381
+ reject(toSftpError(error))
382
+ }
383
+ })
384
+ return entry.sftpPending
385
+ .then((session) => {
386
+ entry.sftp = session
387
+ return session
388
+ })
389
+ .finally(() => {
390
+ entry.sftpPending = null
391
+ })
392
+ }
393
+
394
+ /**
395
+ * Run one command on an established entry. Never throws for command-level
396
+ * failures; returns `{ result, retryable }`.
397
+ */
398
+ function runOnEntry(entry, command, options = {}, startedAt = now()) {
399
+ const timeoutMs = options.timeoutMs ?? settings.commandTimeoutMs
400
+ const cap = options.cap ?? settings.maxOutputBytes
401
+ const stderrCap = options.stderrCap ?? settings.maxStderrBytes
402
+ const out = createCapture(cap)
403
+ const err = createCapture(stderrCap)
404
+ const warm = options.warm === true
405
+ const connectMs = entry.connectMs ?? 0
406
+ const channelStarted = now()
407
+
408
+ return acquireChannel(entry).then(
409
+ () =>
410
+ new Promise((resolve) => {
411
+ const channelMs = now() - channelStarted
412
+ const commandStarted = now()
413
+ let settled = false
414
+ let exitCode = null
415
+ let exitSignal = null
416
+ let retryable = false
417
+ let stream
418
+ let killTimer
419
+ let firstDataAt = 0
420
+
421
+ const finish = (forcedCode, forcedMessage) => {
422
+ if (settled) return
423
+ settled = true
424
+ clearTimeout(timer)
425
+ if (killTimer) clearTimeout(killTimer)
426
+ options.signal?.removeEventListener?.('abort', onAbort)
427
+ releaseChannel(entry)
428
+ const stdout = out.finish()
429
+ const stderr = err.finish()
430
+ const durationMs = now() - startedAt
431
+ const timings = {
432
+ connectMs: Math.round(connectMs),
433
+ channelMs: Math.round(channelMs),
434
+ commandMs: Math.round(now() - commandStarted),
435
+ totalMs: Math.round(durationMs),
436
+ }
437
+ if (forcedCode) {
438
+ resolve({
439
+ result: errorResult(forcedCode, forcedMessage ?? 'operation failed', {
440
+ stdout: stdout.text,
441
+ stderr: stderr.text,
442
+ durationMs,
443
+ truncated: { stdout: stdout.truncated, stderr: stderr.truncated },
444
+ warm,
445
+ timings,
446
+ ...(firstDataAt ? { firstDataAt: Math.round(firstDataAt - startedAt) } : {}),
447
+ }),
448
+ retryable,
449
+ })
450
+ return
451
+ }
452
+ // An exec channel may close without an explicit exit status (the
453
+ // server called end() instead of exit()). Treat that as success
454
+ // unless a terminating signal was observed.
455
+ const missingCode = exitCode === null || exitCode === undefined
456
+ // The remote `timeout` wrapper exits 124 when its own deadline fired
457
+ // before the client-side timer did (e.g. the host event loop was
458
+ // busy). Report that as a timeout, not as an ordinary exit 124.
459
+ if (!missingCode && exitCode === 124 && now() - commandStarted >= timeoutMs - 1500) {
460
+ resolve({
461
+ result: {
462
+ outcome: 'timeout',
463
+ exitCode: null,
464
+ signal: null,
465
+ stdout: stdout.text,
466
+ stderr: stderr.text,
467
+ durationMs,
468
+ truncated: { stdout: stdout.truncated, stderr: stderr.truncated },
469
+ warm,
470
+ timings,
471
+ ...(firstDataAt ? { firstDataAt: Math.round(firstDataAt - startedAt) } : {}),
472
+ note: 'remote timeout deadline reached',
473
+ },
474
+ retryable: false,
475
+ })
476
+ return
477
+ }
478
+ const failed = (!missingCode && exitCode !== 0) || Boolean(exitSignal)
479
+ const benign = failed && isBenignReadOnlyExit({ readOnly: options.readOnly === true, exitCode, stderr: stderr.text, signal: exitSignal })
480
+ resolve({
481
+ result: {
482
+ outcome: failed && !benign ? 'failed' : 'success',
483
+ exitCode,
484
+ signal: exitSignal,
485
+ stdout: stdout.text,
486
+ stderr: stderr.text,
487
+ durationMs,
488
+ truncated: { stdout: stdout.truncated, stderr: stderr.truncated },
489
+ warm,
490
+ timings,
491
+ ...(firstDataAt ? { firstDataAt: Math.round(firstDataAt - startedAt) } : {}),
492
+ ...(benign
493
+ ? { note: 'exit code 1 with empty stderr is normal for grep/diff (no match or difference); treated as a successful read' }
494
+ : failed
495
+ ? (() => {
496
+ const failureCode = classifyExecFailure({ exitCode, stderr: stderr.text, signal: exitSignal })
497
+ return { errorCode: failureCode, suggestedAction: suggestedActionFor(failureCode) }
498
+ })()
499
+ : {}),
500
+ },
501
+ retryable: false,
502
+ })
503
+ }
504
+
505
+ const onAbort = () => {
506
+ finish('CANCELLED', 'operation cancelled')
507
+ closeStream()
508
+ }
509
+ const closeStream = () => {
510
+ try {
511
+ stream?.close?.()
512
+ } catch {
513
+ // channel may already be closed
514
+ }
515
+ try {
516
+ stream?.destroy?.()
517
+ } catch {
518
+ // best effort
519
+ }
520
+ // A command that ignores channel close must not pin the pooled
521
+ // connection forever: evict after a short grace period.
522
+ killTimer = setTimeout(() => {
523
+ if (entry.state === 'ready') markDead(entry, 'timed-out command left the channel open')
524
+ }, 2000)
525
+ if (killTimer.unref) killTimer.unref()
526
+ }
527
+
528
+ const timer = setTimeout(() => {
529
+ finish('TIMEOUT', `command timed out after ${timeoutMs}ms`)
530
+ closeStream()
531
+ }, timeoutMs)
532
+ if (timer.unref) timer.unref()
533
+
534
+ if (options.signal?.aborted) {
535
+ finish('CANCELLED', 'operation aborted before start')
536
+ return
537
+ }
538
+ options.signal?.addEventListener?.('abort', onAbort, { once: true })
539
+
540
+ try {
541
+ entry.client.exec(command, (error, nextStream) => {
542
+ if (error) {
543
+ retryable = isConnectionLevelError(error)
544
+ if (retryable) markDead(entry, 'exec channel failed')
545
+ finish('EXEC_FAILED', error instanceof Error ? error.message : String(error))
546
+ return
547
+ }
548
+ stream = nextStream
549
+ stream.on('data', (chunk) => {
550
+ if (!firstDataAt) firstDataAt = now()
551
+ out.add(chunk.toString('utf8'))
552
+ })
553
+ stream.stderr.on('data', (chunk) => err.add(chunk.toString('utf8')))
554
+ stream.once('close', (code, signal) => {
555
+ exitCode = code === undefined ? null : code
556
+ exitSignal = signal === undefined ? null : signal
557
+ finish()
558
+ })
559
+ stream.once('error', (streamError) => {
560
+ retryable = isConnectionLevelError(streamError)
561
+ if (retryable) markDead(entry, 'exec stream failed')
562
+ finish('EXEC_FAILED', streamError instanceof Error ? streamError.message : String(streamError))
563
+ })
564
+ // The broker never writes to a command's stdin. Sending EOF right
565
+ // away turns stdin-reading commands (`cat`, `read`, interactive
566
+ // installers) into immediate EOF instead of hanging until the
567
+ // timeout; it is equivalent to a remote `</dev/null`.
568
+ try {
569
+ stream.end?.()
570
+ } catch {
571
+ // channel may already be half-closed
572
+ }
573
+ })
574
+ } catch (error) {
575
+ retryable = isConnectionLevelError(error)
576
+ if (retryable) markDead(entry, 'exec threw')
577
+ finish('EXEC_FAILED', error instanceof Error ? error.message : String(error))
578
+ }
579
+ }),
580
+ )
581
+ }
582
+
583
+ async function exec(server, command, options = {}) {
584
+ const startedAt = now()
585
+ // SSH exec channels start in the login home directory; `cwd` is applied as
586
+ // a `cd` prefix so callers can keep a working directory per call.
587
+ const remoteCommand = options.cwd ? withCwd(command, options.cwd, server) : command
588
+ let attempt = 0
589
+ for (;;) {
590
+ let acquired
591
+ try {
592
+ acquired = await acquire(server)
593
+ } catch (error) {
594
+ const normalized = error instanceof SshError ? error : normalizeError(error)
595
+ const result = errorResult(normalized.code, normalized.message, { durationMs: now() - startedAt })
596
+ if (normalized.fingerprint) result.fingerprint = normalized.fingerprint
597
+ return result
598
+ }
599
+ const { entry, warm } = acquired
600
+ const { result, retryable } = await runOnEntry(entry, remoteCommand, { ...options, warm }, startedAt)
601
+ // Retry once only for idempotent read-only commands whose failure was a
602
+ // connection-level error before any output arrived. Never retry a write:
603
+ // the remote side may already have executed it.
604
+ const canRetry =
605
+ retryable && attempt === 0 && options.readOnly === true && !result.stdout && result.outcome === 'failed'
606
+ if (canRetry) {
607
+ attempt += 1
608
+ evict(entry, 'retry after connection error')
609
+ continue
610
+ }
611
+ if (!poolEnabled) evict(entry, 'pool disabled')
612
+ return result
613
+ }
614
+ }
615
+
616
+ async function withSftp(server, handler, options = {}) {
617
+ const timeoutMs = options.timeoutMs ?? settings.sftpTimeoutMs
618
+ let acquired
619
+ try {
620
+ acquired = await acquire(server)
621
+ } catch (error) {
622
+ throw toSftpError(error)
623
+ }
624
+ const { entry } = acquired
625
+ await acquireChannel(entry)
626
+ let timer
627
+ try {
628
+ const sftp = await ensureSftp(entry)
629
+ const work = Promise.resolve().then(() => handler(sftp))
630
+ work.catch(() => {})
631
+ const guarded = new Promise((_, reject) => {
632
+ timer = setTimeout(() => reject(new SshError('TIMEOUT', `SFTP operation timed out after ${timeoutMs}ms`)), timeoutMs)
633
+ if (timer.unref) timer.unref()
634
+ })
635
+ return await Promise.race([work, guarded])
636
+ } catch (error) {
637
+ if (isConnectionLevelError(error)) markDead(entry, 'sftp failed')
638
+ throw toSftpError(error)
639
+ } finally {
640
+ clearTimeout(timer)
641
+ releaseChannel(entry)
642
+ if (!poolEnabled) evict(entry, 'pool disabled')
643
+ }
644
+ }
645
+
646
+ async function test(server) {
647
+ const started = now()
648
+ let acquired
649
+ try {
650
+ acquired = await acquire(server)
651
+ } catch (error) {
652
+ const normalized = error instanceof SshError ? error : normalizeError(error)
653
+ return {
654
+ ok: false,
655
+ errorCode: normalized.code,
656
+ message: normalized.message,
657
+ latencyMs: now() - started,
658
+ fingerprint: normalized.fingerprint,
659
+ }
660
+ }
661
+ const { entry, warm } = acquired
662
+ const probe = effectiveDialect(server) === 'windows' ? 'cmd /c ver' : 'uname -s'
663
+ const { result } = await runOnEntry(
664
+ entry,
665
+ probe,
666
+ { timeoutMs: Math.min(settings.commandTimeoutMs, 15000), readOnly: true, warm },
667
+ started,
668
+ )
669
+ if (!poolEnabled) evict(entry, 'pool disabled')
670
+ return {
671
+ ok: result.outcome === 'success',
672
+ latencyMs: now() - started,
673
+ platform: effectiveDialect(server),
674
+ hostKey: knownHosts?.has(server) ? 'trusted' : 'unknown',
675
+ probe: String(result.stdout ?? '').trim().split('\n')[0] ?? '',
676
+ errorCode: result.errorCode,
677
+ fingerprint: entry.fingerprint,
678
+ warm,
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Interactive PTY shell on a dedicated connection (not pooled): terminals are
684
+ * long-lived and must not share a connection that an idle sweep could close.
685
+ */
686
+ async function shell(server, options = {}) {
687
+ const { client } = await connectClient(server)
688
+ const rows = Number.isInteger(options.rows) ? options.rows : 40
689
+ const cols = Number.isInteger(options.cols) ? options.cols : 120
690
+ let stream
691
+ try {
692
+ stream = await new Promise((resolve, reject) => {
693
+ client.shell({ term: options.term ?? 'xterm-256color', rows, cols }, (error, channel) => {
694
+ if (error) reject(normalizeError(error))
695
+ else resolve(channel)
696
+ })
697
+ })
698
+ } catch (error) {
699
+ try {
700
+ client.end()
701
+ } catch {
702
+ // socket may already be gone
703
+ }
704
+ throw error
705
+ }
706
+ let closed = false
707
+ return {
708
+ stream,
709
+ write(data) {
710
+ if (closed) return
711
+ try {
712
+ stream.write(String(data))
713
+ } catch (error) {
714
+ log('ssh shell write failed', { server: server.name, error: error instanceof Error ? error.message : String(error) })
715
+ }
716
+ },
717
+ onData(handler) {
718
+ stream.on('data', handler)
719
+ },
720
+ resize(cols, rows) {
721
+ if (closed) return
722
+ try {
723
+ stream.setWindow(Number(rows) || 24, Number(cols) || 80)
724
+ } catch (error) {
725
+ log('ssh shell resize failed', { server: server.name, error: error instanceof Error ? error.message : String(error) })
726
+ }
727
+ },
728
+ onClose(handler) {
729
+ stream.once('close', handler)
730
+ },
731
+ onError(handler) {
732
+ stream.once('error', handler)
733
+ },
734
+ async close() {
735
+ if (closed) return
736
+ closed = true
737
+ try {
738
+ stream.end()
739
+ } catch {
740
+ // channel may already be closed
741
+ }
742
+ try {
743
+ client.end()
744
+ } catch {
745
+ // socket may already be closed
746
+ }
747
+ },
748
+ }
749
+ }
750
+
751
+ /** Fire-and-forget connection warm-up. */
752
+ function warm(server) {
753
+ acquire(server).catch((error) => {
754
+ log('ssh warm-up failed', { server: server.name, error: error instanceof Error ? error.message : String(error) })
755
+ })
756
+ }
757
+
758
+ function invalidate(serverId) {
759
+ const entry = entries.get(serverId)
760
+ if (entry) evict(entry, 'invalidated')
761
+ }
762
+
763
+
764
+
765
+ async function closeAll() {
766
+ clearInterval(sweeper)
767
+ for (const entry of [...entries.values()]) evict(entry, 'plugin unloaded')
768
+ entries.clear()
769
+ }
770
+
771
+ async function preload() {
772
+ await loadSsh2()
773
+ }
774
+
775
+ /** Dedicated (unpooled) connection for interactive shells and chunked transfers. */
776
+ async function connectDedicated(server) {
777
+ return await connectClient(server)
778
+ }
779
+
780
+ const sweeper = setInterval(() => {
781
+ const cutoff = now() - settings.poolIdleMs
782
+ for (const entry of [...entries.values()]) {
783
+ if (entry.state === 'ready' && entry.channels === 0 && entry.lastUsed < cutoff) evict(entry, 'idle')
784
+ }
785
+ }, settings.sweepIntervalMs)
786
+ if (sweeper.unref) sweeper.unref()
787
+
788
+ return {
789
+ exec,
790
+ withSftp,
791
+ test,
792
+ shell,
793
+ warm,
794
+ invalidate,
795
+ closeAll,
796
+ preload,
797
+ connectDedicated,
798
+ }
799
+ }