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