dsh-remote 0.8.71 → 0.8.73

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/pool.js CHANGED
@@ -1,886 +1,963 @@
1
- // Persistent SSH/SFTP connection pool (one identity → one live client).
2
- import ssh2 from 'ssh2'
3
- import { readFileSync } from 'node:fs'
4
- import { homedir } from 'node:os'
5
- import path from 'node:path'
6
- import { truncate } from './paths.js'
7
- import { createHostKeyGuard } from './hostkey.js'
8
- import { buildSftpSurface } from './sftp-surface.js'
9
- import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
10
-
11
- const { Client } = ssh2
12
-
13
- /**
14
- * Build the pool for a config.
15
- *
16
- * Two backends exist (issue #51): the native one below, which speaks SSH itself
17
- * via ssh2, and the "system OpenSSH" one (lib/openssh.js), which delegates to
18
- * the local `ssh` executable so GSSAPI/Kerberos and the rest of the user's
19
- * `~/.ssh/config` are honoured. Both expose one identical surface, so every
20
- * caller can keep using `new SshPool(...)`-shaped code — this factory only has
21
- * to decide which class to instantiate.
22
- */
23
- /** Hard cap on proxy-chain depth. OpenSSH's ProxyJump has no explicit limit;
24
- * this exists to turn a config CYCLE into a clear error instead of unbounded
25
- * recursion. A legitimate chain is 1-3 hops.
26
- * @type {number} */
27
- export const MAX_JUMP_HOPS = 8
28
-
29
- export function createPool(config, opts) {
30
- return isSystemOpenSshBackend(config)
31
- ? new SystemOpenSshPool(config, opts)
32
- : new SshPool(config, opts)
33
- }
34
-
35
- /**
36
- * Run one `forwardOut` and resolve with the resulting channel.
37
- *
38
- * ssh2 reports a dead connection by THROWING synchronously —
39
- * `if (!this._sock || !isWritable(this._sock)) throw new Error('Not connected')`
40
- * (ssh2 >= 1.16, `Client.forwardOut`) — and never calls the callback, so a
41
- * callback-only error path cannot guard this call (issue #54). Two further
42
- * consequences justify a shared helper rather than an inline try/catch:
43
- *
44
- * • inside a `new Promise(executor)` a bare throw rejects nothing: it becomes
45
- * an unhandled REJECTION and leaves the promise pending forever;
46
- * • called from a `net.Server` connection handler the throw escapes to the
47
- * process top level and kills the host.
48
- *
49
- * This guards the CALLER's side of the contract rather than assuming one
50
- * backend's behaviour: the system-OpenSSH backend (lib/openssh.js `forwardOut`)
51
- * already converts its own spawn failure into a callback error, so for it the
52
- * try/catch below is dead code — but a backend is free to throw, and the one
53
- * that does must not be able to take the process down.
54
- *
55
- * @param {object} client - an ssh2 Client or OpenSshConnection.
56
- * @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
57
- * @returns {Promise<object>} the forwarded channel / duplex stream.
58
- */
59
- export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
60
- return new Promise((resolve, reject) => {
61
- // `reject` is called at most once: a callback that fires after a successful
62
- // throw-path rejection would be a no-op, which Promise guarantees.
63
- try {
64
- client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
65
- } catch (err) {
66
- reject(err)
67
- }
68
- })
69
- }
70
-
71
- /**
72
- * The ACTIVE pool: one long-lived object whose concrete implementation can be
73
- * swapped when the current machine's transport backend changes.
74
- *
75
- * Why a delegating facade instead of just constructing the right class once:
76
- * `backend` is a PER-MACHINE field (an enterprise Kerberos host needs
77
- * `openssh` while every other machine stays on `native`), but the plugin keeps
78
- * exactly one active pool for its whole lifetime and hangs `passwordResolver` /
79
- * `onReady` / `onCloseHook` off it once at startup. Replacing the object
80
- * wholesale on every machine switch would silently drop those hooks — the port
81
- * forwards and keychain passwords would stop working after the first switch.
82
- *
83
- * So the facade owns the hooks and the shared `config` object, and forwards
84
- * them to whichever concrete pool is right for the current config. Everything
85
- * else is a straight delegation, which is why callers cannot tell the
86
- * difference (and why the class is deliberately not part of the public story).
87
- */
88
- export class ActivePool {
89
- constructor(config, opts = {}) {
90
- this.config = config
91
- this.opts = opts
92
- // The hooks are assigned by the plugin AFTER construction, so they are
93
- // stored here and re-applied to every delegate this facade ever creates.
94
- this._passwordResolver = null
95
- this._onReady = null
96
- this._onCloseHook = null
97
- this._delegate = null
98
- this._sync()
99
- }
100
-
101
- /** Which concrete class the current config asks for. */
102
- _wantsSystemOpenSsh() {
103
- return isSystemOpenSshBackend(this.config)
104
- }
105
-
106
- _delegateIsCurrent() {
107
- if (!this._delegate) return false
108
- const isOpenssh = this._delegate instanceof SystemOpenSshPool
109
- return isOpenssh === this._wantsSystemOpenSsh()
110
- }
111
-
112
- /** Create (or replace) the delegate so it matches the current config. */
113
- _sync() {
114
- if (this._delegateIsCurrent()) return this._delegate
115
- const previous = this._delegate
116
- const next = createPool(this.config, this.opts)
117
- next.passwordResolver = this._passwordResolver
118
- next.onReady = this._onReady
119
- next.onCloseHook = this._onCloseHook
120
- this._delegate = next
121
- // Tear the old backend down LAST, and never let a teardown error mask the
122
- // new pool: the connection it was holding belongs to the old host anyway.
123
- if (previous) { try { previous.close() } catch { /* already gone */ } }
124
- return next
125
- }
126
-
127
- get delegate() { return this._sync() }
128
-
129
- // ── hooks: stored here, mirrored onto every delegate ──────────────────────
130
- get passwordResolver() { return this._passwordResolver }
131
- set passwordResolver(fn) {
132
- this._passwordResolver = fn
133
- if (this._delegate) this._delegate.passwordResolver = fn
134
- }
135
- get onReady() { return this._onReady }
136
- set onReady(fn) {
137
- this._onReady = fn
138
- if (this._delegate) this._delegate.onReady = fn
139
- }
140
- get onCloseHook() { return this._onCloseHook }
141
- set onCloseHook(fn) {
142
- this._onCloseHook = fn
143
- if (this._delegate) this._delegate.onCloseHook = fn
144
- }
145
-
146
- // ── pass-through state the plugin reads directly ──────────────────────────
147
- get client() { return this._delegate ? this._delegate.client : null }
148
- // Assignable because the plugin's own tests arm a fake live connection on the
149
- // active pool; a getter-only facade would make that assignment a silent
150
- // no-op in sloppy mode and a TypeError in the strict-mode ESM tests.
151
- set client(v) { if (this._sync()) this._delegate.client = v }
152
- get connecting() { return this._delegate ? this._delegate.connecting : null }
153
- get proxyPool() { return this._delegate ? this._delegate.proxyPool : null }
154
- get epoch() { return this._delegate ? this._delegate.epoch : 0 }
155
- get platform() { return this._delegate ? this._delegate.platform : 'unknown' }
156
- set platform(v) { if (this._sync()) this._delegate.platform = v }
157
- get gitBashPath() { return this._delegate ? this._delegate.gitBashPath : '' }
158
- set gitBashPath(v) { if (this._sync()) this._delegate.gitBashPath = v }
159
- get shellMode() { return this._delegate ? this._delegate.shellMode : 'native' }
160
- set shellMode(v) { if (this._sync()) this._delegate.shellMode = v }
161
-
162
- // ── delegated operations ──────────────────────────────────────────────────
163
- /** Re-point at a new target, switching backend first if the machine asks. */
164
- setTarget(fields, previous) {
165
- // The backend may have changed with the machine, so apply that part of the
166
- // request to `config` BEFORE picking the delegate: _sync() chooses the class
167
- // from config.backend, and the old backend must not be handed a host it can
168
- // no longer reach. The delegate then receives the whole request as usual.
169
- if (fields && fields.backend !== undefined) this.config.backend = String(fields.backend)
170
- if (fields && fields.sshBinary !== undefined) this.config.sshBinary = String(fields.sshBinary)
171
- const d = this._sync()
172
- return d.setTarget(fields, previous)
173
- }
174
- connect() { return this.delegate.connect() }
175
- exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
176
- sftp() { return this.delegate.sftp() }
177
- shell(opts) { return this.delegate.shell(opts) }
178
- detect() { return this.delegate.detect() }
179
- invalidate() { return this.delegate.invalidate() }
180
- close() { return this.delegate.close() }
181
- }
182
-
183
- export class SshPool {
184
- constructor(config, opts = {}) {
185
- this.config = config
186
- this._knownHostsFile = opts.knownHostsFile
187
- this.client = null
188
- this.connecting = null
189
- this.proxyPool = null
190
- // Generational token: bumped on every target change / close so a stale
191
- // in-flight connect can never hand this pool a connection to an old host.
192
- this.epoch = 0
193
- /** Auto-detected remote platform: unknown | windows | posix (per target). */
194
- this.platform = 'unknown'
195
- /** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
196
- this.gitBashPath = ''
197
- /** Resolved terminal strategy: native | git-bash. */
198
- this.shellMode = 'native'
199
- /** In-flight platform detection promise (cached). */
200
- this._detecting = null
201
- /** Optional async resolver for a machine-stored (keychain) password. */
202
- this.passwordResolver = null
203
- /** Optional hook called with the live client after a successful connect. */
204
- this.onReady = null
205
- /** Optional hook called when the pool closes. */
206
- this.onCloseHook = null
207
- /** Consecutive SFTP subchannel-open timeouts (issue #65). */
208
- this._sftpTimeouts = 0
209
- }
210
-
211
- resolveKeyPath() {
212
- const p = this.config.privateKeyPath
213
- if (!p) return ''
214
- if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
215
- return p
216
- }
217
-
218
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
219
- // Collect the requested changes first: an UNCHANGED target is a no-op. The
220
- // old behavior closed the live connection and re-ran platform detection
221
- // even for an identical target, so every picker keystroke
222
- // (POST /current → applyActiveMachine → setTarget) paid a full SSH
223
- // reconnect + re-detect — the "slow remote path autocomplete" bug.
224
- //
225
- // `previous` is the diff BASELINE. Callers whose shared config was already
226
- // mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
227
- // live config IN PLACE, and pool.config IS that config object) pass a
228
- // pre-mutation snapshot — otherwise the comparison runs the new values
229
- // against themselves, is always equal and never closes even for a machine
230
- // switch (the wrong-host hazard of issue #25). Defaults to the current
231
- // config for callers that pass fresh values read independently of it.
232
- const base = previous || this.config
233
- const wanted = []
234
- if (host !== undefined) wanted.push(['host', String(host)])
235
- if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
236
- if (username !== undefined) wanted.push(['username', String(username)])
237
- if (password !== undefined && password !== null) wanted.push(['password', String(password)])
238
- if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
239
- if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
240
- if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
241
- if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
242
- if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
243
- if (proxy !== undefined) wanted.push(['proxy', proxy])
244
- if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
245
- // Issue #51: the transport backend is part of the target identity — changing
246
- // it must close the live connection exactly like changing the host does.
247
- if (backend !== undefined) wanted.push(['backend', String(backend)])
248
- if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
249
- const differs = ([k, v]) => (k === 'proxy'
250
- ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
251
- : base[k] !== v)
252
- // Something actually changed → apply it and force a fresh connection.
253
- // (A caller that WANTS a reconnect for an identical target uses
254
- // invalidate(); setTarget semantics stay "re-point when moved".)
255
- if (wanted.some(differs)) {
256
- for (const [k, v] of wanted) this.config[k] = v
257
- // the new target may be a different OS — re-detect on the next command
258
- this.platform = 'unknown'
259
- this.gitBashPath = ''
260
- this.shellMode = 'native'
261
- this._detecting = null
262
- this.close()
263
- }
264
- return this
265
- }
266
-
267
- connect() {
268
- if (this.client) return Promise.resolve(this.client)
269
- if (this.connecting) return this.connecting
270
- const epoch = this.epoch
271
- const pending = this._doConnect(epoch)
272
- this.connecting = pending
273
- const clear = () => {
274
- if (this.epoch === epoch && this.connecting === pending) this.connecting = null
275
- }
276
- pending.then(clear, clear)
277
- return pending
278
- }
279
-
280
- async _doConnect(epoch) {
281
- const isCurrent = () => this.epoch === epoch
282
- const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
283
- const guard = createHostKeyGuard(this.config, knownHostsFile)
284
- const client = new Client()
285
- let settled = false
286
- const fail = (err) => {
287
- if (settled) return
288
- settled = true
289
- if (isCurrent() && this.client === client) this.client = null
290
- throw guard.lastError ? new Error(guard.lastError) : err
291
- }
292
-
293
- // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
294
- //
295
- // MULTI-HOP (OpenSSH `ProxyJump a,b,c` semantics): each bastion may itself
296
- // carry a `proxy`, and the chain is built from the FARTHEST hop inward — hop
297
- // N is dialed through the already-open connection to hop N-1. The recursion
298
- // IS the mechanism, so there is no depth limit beyond what the config
299
- // expresses; MAX_JUMP_HOPS is a cycle/typo guard, not a feature limit.
300
- let sock = null
301
- const proxyCfg = this.config.proxy
302
- if (proxyCfg && proxyCfg.host) {
303
- try {
304
- const depth = Number(this.config._hopDepth) || 0
305
- if (depth >= MAX_JUMP_HOPS) {
306
- throw new Error(`proxy chain is deeper than ${MAX_JUMP_HOPS} hops — check the machine config for a proxy cycle`)
307
- }
308
- this.proxyPool = new SshPool({
309
- ...this.config,
310
- host: proxyCfg.host,
311
- port: Number(proxyCfg.port) || 22,
312
- username: proxyCfg.username || this.config.username || 'root',
313
- // CREDENTIALS DO NOT INHERIT ACROSS HOPS. `...this.config` brings the
314
- // TARGET's key/passphrase along, which is wrong in two ways: it may
315
- // not be authorized on the bastion, and presenting the target's key to
316
- // an intermediate host hands that host a credential it should never
317
- // see. A hop with no key of its own falls back to the agent/default
318
- // discovery path, which is what an operator's own ssh setup provides.
319
- password: proxyCfg.password || '',
320
- privateKeyPath: proxyCfg.privateKeyPath || '',
321
- passphrase: proxyCfg.passphrase || '',
322
- ...(proxyCfg.useAgent !== undefined ? { useAgent: !!proxyCfg.useAgent } : {}),
323
- ...(proxyCfg.keyboardInteractive !== undefined ? { keyboardInteractive: !!proxyCfg.keyboardInteractive } : {}),
324
- ...(proxyCfg.hostKeyMode ? { hostKeyMode: proxyCfg.hostKeyMode } : {}),
325
- // The nested hop keeps its OWN proxy — that is what builds the chain —
326
- // and carries an incremented depth so a cycle is reported instead of
327
- // recursing until the process dies.
328
- proxy: proxyCfg.proxy && proxyCfg.proxy.host ? proxyCfg.proxy : undefined,
329
- _hopDepth: depth + 1,
330
- }, { knownHostsFile: this._knownHostsFile })
331
- const pclient = await this.proxyPool.connect()
332
- if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
333
- try {
334
- sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
335
- } catch (e) {
336
- throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
337
- }
338
- } catch (err) {
339
- return fail(err)
340
- }
341
- }
342
-
343
- return new Promise((resolve, reject) => {
344
- const rejectOnce = (err) => {
345
- if (settled) return
346
- settled = true
347
- if (isCurrent() && this.client === client) this.client = null
348
- reject(guard.lastError ? new Error(guard.lastError) : err)
349
- }
350
- client.on('ready', () => {
351
- if (settled) return
352
- settled = true
353
- if (!isCurrent()) {
354
- try { client.end() } catch {}
355
- reject(new Error('ssh target changed during connect'))
356
- return
357
- }
358
- this.client = client
359
- this._sftpTimeouts = 0
360
- resolve(client)
361
- if (this.onReady) { try { this.onReady(client) } catch {} }
362
- })
363
- client.on('error', (e) => rejectOnce(e))
364
- client.on('close', () => {
365
- if (isCurrent() && this.client === client) this.client = null
366
- rejectOnce(new Error('ssh connection closed'))
367
- })
368
-
369
- const buildOpts = async () => {
370
- const opts = {
371
- host: this.config.host,
372
- port: this.config.port,
373
- username: this.config.username,
374
- readyTimeout: this.config.connectTimeoutMs,
375
- keepaliveInterval: 15000,
376
- keepaliveCountMax: 3,
377
- hostVerifier: (key) => guard.verifier(key),
378
- }
379
- if (sock) opts.sock = sock
380
- // `useAgent` asks for agent auth, but ssh2 can only offer it when this
381
- // PROCESS has an agent socket. On Windows there is no agent by default,
382
- // so the flag alone used to fall through to the generic "no credentials"
383
- // error — which reads as "the plugin lost my settings" rather than "this
384
- // machine has no agent". Track the intent so the failure can say which.
385
- let agentRequested = false
386
- if (this.config.useAgent) {
387
- const sockPath = process.env.SSH_AUTH_SOCK
388
- if (sockPath) opts.agent = sockPath
389
- else agentRequested = true
390
- }
391
- let password = this.config.password || ''
392
- if (!password && this.passwordResolver) {
393
- try { password = (await this.passwordResolver()) || '' } catch {}
394
- }
395
- if (password) {
396
- opts.password = password
397
- opts.tryKeyboard = true
398
- } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
399
- opts.tryKeyboard = true
400
- }
401
- if (this.config.privateKeyPath) {
402
- const keyPath = this.resolveKeyPath()
403
- if (!keyPath) {
404
- throw new Error('no credentials: set a password or a privateKeyPath to connect')
405
- }
406
- let key
407
- try {
408
- key = readFileSync(keyPath)
409
- } catch (err) {
410
- throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
411
- }
412
- opts.privateKey = key
413
- opts.passphrase = this.config.passphrase || undefined
414
- } else if (!password && !opts.agent) {
415
- throw new Error(agentRequested
416
- ? 'no SSH agent available: useAgent is enabled for this machine but SSH_AUTH_SOCK is not set in the DSH host process (Windows has no agent by default) — start an agent (or Pageant) before launching DSH, or save a password / private key for this machine'
417
- : 'no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
418
- }
419
- return opts
420
- }
421
-
422
- buildOpts().then(
423
- (opts) => {
424
- if (opts.tryKeyboard) {
425
- client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
426
- finish(prompts.map(() => this.config.password || ''))
427
- })
428
- }
429
- // ssh2 parses the private key SYNCHRONOUSLY inside connect() and
430
- // throws on an unparseable key (the classic case: an encrypted
431
- // OpenSSH key without a passphrase). Left uncaught, that throw
432
- // rejects this orphaned .then chain — an unhandled rejection that
433
- // crashes the whole host process (issue #48: "dsh: fatal load
434
- // failure" on every boot, because boot-restore re-dials the bad
435
- // machine). It also leaves the outer promise pending forever.
436
- // Convert it into a normal rejection so every caller's try/catch —
437
- // boot restore, test-connect, rw_connect — sees a regular failure.
438
- try {
439
- client.connect(opts)
440
- } catch (err) {
441
- rejectOnce(err)
442
- }
443
- },
444
- (err) => rejectOnce(err),
445
- )
446
- })
447
- }
448
-
449
- /** Detect the remote platform + locate Git Bash once; cached per target. */
450
- detect() {
451
- if (this.platform !== 'unknown') return Promise.resolve()
452
- if (this._detecting) return this._detecting
453
- this._detecting = this._detect().finally(() => {
454
- this._detecting = null
455
- })
456
- return this._detecting
457
- }
458
-
459
- async _detect() {
460
- let res
461
- try {
462
- // `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
463
- // "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
464
- res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
465
- } catch (err) {
466
- this.platform = 'unknown'
467
- this.shellMode = 'native'
468
- return
469
- }
470
- const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
471
- if (res.code === 0 && /microsoft windows/i.test(out)) {
472
- this.platform = 'windows'
473
- await this._resolveGitBash()
474
- return
475
- }
476
- // inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
477
- try {
478
- const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
479
- if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
480
- this.platform = 'windows'
481
- await this._resolveGitBash()
482
- return
483
- }
484
- } catch {}
485
- this.platform = 'posix'
486
- this.shellMode = 'native'
487
- this.gitBashPath = ''
488
- }
489
-
490
- /** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
491
- async _resolveGitBash() {
492
- const cfg = String(this.config.shell || '').trim()
493
- if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
494
- if (await this._cmdExists(cfg)) {
495
- this.gitBashPath = cfg
496
- this.shellMode = 'git-bash'
497
- return
498
- }
499
- }
500
- if (cfg === 'native') {
501
- this.shellMode = 'native'
502
- this.gitBashPath = ''
503
- return
504
- }
505
- try {
506
- const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
507
- const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
508
- if (first) {
509
- this.gitBashPath = first
510
- this.shellMode = 'git-bash'
511
- return
512
- }
513
- } catch {}
514
- const candidates = [
515
- 'C:\\Program Files\\Git\\bin\\bash.exe',
516
- 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
517
- ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
518
- '%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
519
- ]
520
- for (const c of candidates) {
521
- if (await this._cmdExists(c)) {
522
- this.gitBashPath = c
523
- this.shellMode = 'git-bash'
524
- return
525
- }
526
- }
527
- this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
528
- this.gitBashPath = ''
529
- }
530
-
531
- async _cmdExists(p) {
532
- try {
533
- const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
534
- return /Y/.test(String(r.stdout || ''))
535
- } catch {
536
- return false
537
- }
538
- }
539
-
540
- /** Run one remote command; resolves { code, signal, stdout, stderr }.
541
- * On Windows remotes with Git Bash the script is piped to `bash -s` over the
542
- * exec-channel stdin — no shell quoting round-trip, so any content (quotes,
543
- * backslashes, newlines) survives verbatim. */
544
- exec(command, timeoutMsOrOpts) {
545
- const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
546
- return this.detect().then(() => {
547
- if (this.platform === 'windows' && this.gitBashPath) {
548
- const script = String(command)
549
- return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
550
- try { stream.end(script) } catch {}
551
- })
552
- }
553
- return this._execRaw(command, opts)
554
- })
555
- }
556
-
557
- /** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
558
- * the remote process stdin (used by the Git Bash `-s` mode). */
559
- _execRaw(command, opts, stdinWriter) {
560
- const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
561
- const signal = opts && opts.signal
562
- // Streaming tap for background jobs (issue #70): a promoted command's value
563
- // is its LIVE output, which the job ring must receive as it arrives rather
564
- // than only as a return value the caller already abandoned. Called with
565
- // ('stdout'|'stderr', chunk); failures inside the tap must never break the
566
- // command itself, so every call site guards it.
567
- const onData = opts && typeof opts.onData === 'function' ? opts.onData : null
568
- return this.connect().then(
569
- (client) =>
570
- new Promise((resolve, reject) => {
571
- let retried = false
572
- // Caller cancellation (issue #44): a tool that hands us exec.signal
573
- // expects the remote command to stop when the turn is cancelled.
574
- // Resolve the same shape as a timeout so callers need no new branch.
575
- let onAbort = null
576
- const cleanupAbort = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort) }
577
- const runOn = (c) => {
578
- const execOpts = {}
579
- if (opts && opts.pty) execOpts.pty = true
580
- if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
581
- c.exec(command, execOpts, (err, stream) => {
582
- if (err) {
583
- // Channel-open failure — or a session termination — usually
584
- // means the pooled connection died server-side (idle timeout /
585
- // network reset) while keepalive hadn't noticed. Drop it and
586
- // retry ONCE on a fresh connection.
587
- if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
588
- retried = true
589
- this.invalidate()
590
- return this.connect().then(
591
- (fresh) => runOn(fresh),
592
- (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
593
- )
594
- }
595
- return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
596
- }
597
- let stdout = ''
598
- let stderr = ''
599
- let settled = false
600
- let exitCode = null
601
- let exitSignal = null
602
- const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
603
- const settle = () => {
604
- if (settled) return
605
- settled = true
606
- clearTimeout(timer)
607
- cleanupAbort()
608
- resolve({
609
- code: exitCode,
610
- signal: exitSignal,
611
- stdout: truncate(stdout, this.config.maxOutputChars),
612
- stderr: truncate(stderr, this.config.maxOutputChars),
613
- })
614
- }
615
- const timer = setTimeout(() => {
616
- if (settled) return
617
- exitCode = -1
618
- exitSignal = 'TIMEOUT'
619
- // Kill the remote command (SIGTERM) rather than just dropping the
620
- // channel, so a runaway process cannot keep running and holding
621
- // the SSH connection after we've given up on its output.
622
- try {
623
- if (typeof stream.signal === 'function') stream.signal('SIGTERM')
624
- } catch {}
625
- const hardClose = setTimeout(() => {
626
- try { stream.close() } catch {}
627
- }, 800)
628
- if (typeof hardClose.unref === 'function') hardClose.unref()
629
- settle()
630
- }, timeoutMs)
631
- if (signal) {
632
- if (signal.aborted) {
633
- try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
634
- try { stream.close() } catch {}
635
- exitCode = -1
636
- exitSignal = 'ABORTED'
637
- return settle()
638
- }
639
- onAbort = () => {
640
- if (settled) return
641
- exitCode = -1
642
- exitSignal = 'ABORTED'
643
- try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
644
- const hardClose = setTimeout(() => { try { stream.close() } catch {} }, 500)
645
- if (typeof hardClose.unref === 'function') hardClose.unref()
646
- settle()
647
- }
648
- signal.addEventListener('abort', onAbort, { once: true })
649
- }
650
- stream.on('close', (code, sigName) => {
651
- if (settled) return
652
- exitCode = code
653
- // `sigName` is the signal NAME that killed the remote process
654
- // (e.g. 'SIGTERM'), not the caller's AbortSignal. The rename
655
- // matters: `signal` in this scope is the AbortSignal, and
656
- // assigning it here would leak an object into the result.
657
- exitSignal = sigName
658
- settle()
659
- })
660
- stream.on('data', (d) => {
661
- if (stdout.length < hardCap) stdout += d
662
- if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
663
- })
664
- stream.stderr.on('data', (d) => {
665
- if (stderr.length < hardCap) stderr += d
666
- if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
667
- })
668
- stream.on('error', (e) => {
669
- if (settled) return
670
- settled = true
671
- clearTimeout(timer)
672
- cleanupAbort()
673
- reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
674
- })
675
- if (stdinWriter) {
676
- try { stdinWriter(stream) } catch {}
677
- }
678
- })
679
- }
680
- runOn(client)
681
- }),
682
- )
683
- }
684
-
685
- /**
686
- * Open an INTERACTIVE shell channel on the remote host (PTY-backed).
687
- *
688
- * This is what an embedded terminal needs and what `exec` cannot provide:
689
- * `exec` runs one command and closes, with no PTY and no stdin after the
690
- * writer returns. A shell channel keeps the session alive across commands, so
691
- * `vim`, `top`, `sudo` prompts and anything else that talks to a terminal
692
- * works the way it does in an ssh session.
693
- *
694
- * The returned stream is ssh2's channel: write to it for keystrokes, listen
695
- * for `data` for output, call `setWindow(rows, cols, height, width)` on
696
- * resize, and `end()`/`close()` to hang up. Columns/rows are negotiated per
697
- * channel, so two terminals on the same pooled connection stay independent.
698
- *
699
- * @param {{cols?: number, rows?: number, term?: string, pty?: boolean}} [opts]
700
- * @returns {Promise<object>} the ssh2 Channel (interactive stream).
701
- */
702
- shell(opts = {}) {
703
- const cols = Math.max(2, Math.min(Number(opts.cols) || 80, 1000))
704
- const rows = Math.max(2, Math.min(Number(opts.rows) || 24, 1000))
705
- const term = String(opts.term || 'xterm-256color')
706
- return this.connect().then(
707
- (client) =>
708
- new Promise((resolve, reject) => {
709
- let retried = false
710
- const runOn = (c) => {
711
- const wndopts = { cols, rows, height: 0, width: 0, term }
712
- // A shell on a Windows remote is a cmd/PowerShell via the default
713
- // sshd shell; the PTY request is identical either way.
714
- c.shell(wndopts, opts.pty === false ? {} : { pty: wndopts }, (err, stream) => {
715
- if (err) {
716
- // Same stale-connection recovery as exec/sftp: a session
717
- // termination means the pooled client died server-side.
718
- if (!retried && /channel open failure|open failed|session termination|disconnect/i.test(String((err && err.message) || err))) {
719
- retried = true
720
- this.invalidate()
721
- return this.connect().then(
722
- (fresh) => runOn(fresh),
723
- (e2) => reject(new Error('ssh shell failed (reconnect): ' + ((e2 && e2.message) || e2))),
724
- )
725
- }
726
- return reject(new Error('ssh shell failed: ' + ((err && err.message) || err)))
727
- }
728
- // An idle shell must not be killed by the connection's own idle
729
- // handling: keepalive is already on, and the channel lives until
730
- // the client calls end(). Nothing to schedule here.
731
- resolve(stream)
732
- })
733
- }
734
- runOn(client)
735
- }),
736
- )
737
- }
738
-
739
- /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
740
- sftp() {
741
- return this.connect().then(
742
- (client) =>
743
- new Promise((resolve, reject) => {
744
- let retried = false
745
- const runOn = (c) => {
746
- // Bound the subchannel open itself, per attempt. Without this, a
747
- // remote whose sftp subsystem is unresponsive (overloaded sshd,
748
- // exceeded MaxSessions, half-open TCP) leaves `c.sftp(cb)` parked
749
- // forever with no callback — issue #44 point 3. ssh2 only calls back
750
- // on success or on a channel error, and a wedged peer produces
751
- // neither.
752
- //
753
- // The timer must be created per attempt, not once outside: the
754
- // callback clears it, so a single outer timer would leave the retry
755
- // (below) with no timeout at all.
756
- let timedOut = false
757
- const attemptTimer = setTimeout(() => {
758
- timedOut = true
759
- reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
760
- }, this.config.commandTimeoutMs)
761
- // ★ 这里**不能 unref**。
762
- //
763
- // 这个定时器是"本次 await 的 sftp 打开"的**唯一结算者**:要么它超时 reject,
764
- // 要么 ssh2 回调 resolve。若把它 unref,而进程此刻没有别的 pending 句柄,
765
- // Node 会认为无事可做而**直接退出**,调用方永远等不到结果。
766
- // 实测症状:CI 上该用例之后的 4 个用例被标记 `cancelledByParent`
767
- // (同文件子进程提前退出),而本地因运行器恰好持着句柄而侥幸通过。
768
- // 注意与上面 exec() 的超时定时器保持一致 —— 那个本来就没有 unref。
769
- // 下面的 hardClose 仍然 unref,因为它只做清理、不负责结算。
770
- const clearAttempt = () => { clearTimeout(attemptTimer) }
771
- try {
772
- c.sftp((err, sftp) => {
773
- clearAttempt()
774
- if (timedOut) return
775
- if (err) {
776
- // Same dead-connection recovery as exec(): a channel open
777
- // failure — or a session termination (remote closed the
778
- // SFTP subchannel, e.g. transient network blip / sshd idle
779
- // drop) — means the pooled connection is stale: drop it and
780
- // retry ONCE on a fresh connection.
781
- if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
782
- retried = true
783
- this.invalidate()
784
- return this.connect().then(
785
- (fresh) => runOn(fresh),
786
- (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
787
- )
788
- }
789
- return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
790
- }
791
- // A successful open proves the connection healthy: the strike
792
- // counter counts CONSECUTIVE timeouts, so it resets here —
793
- // two unrelated transient blips hours apart must not tear down
794
- // a fine connection.
795
- this._sftpTimeouts = 0
796
- // The promisified surface (timeouts, dead-channel fail-fast,
797
- // range reads) is shared verbatim with the system-OpenSSH
798
- // backend — see lib/sftp-surface.js for the rules it encodes.
799
- resolve(buildSftpSurface(sftp, {
800
- commandTimeoutMs: this.config.commandTimeoutMs,
801
- onTimeout: () => {
802
- // A timed-out SFTP subchannel is dead: close it so the next
803
- // operation does not queue behind the wedged one, and count
804
- // the strike so a second consecutive timeout tears down the
805
- // whole pooled connection (issue #65).
806
- try { sftp.end() } catch { /* already gone */ }
807
- this._noteSftpTimeout()
808
- },
809
- }))
810
- })
811
- } catch (e) {
812
- clearAttempt()
813
- reject(new Error('ssh sftp failed: ' + ((e && e.message) || e)))
814
- }
815
- }
816
- runOn(client)
817
- }),
818
- )
819
- }
820
-
821
- /**
822
- * Count a timed-out SFTP subchannel open. A second consecutive timeout means
823
- * the pooled connection itself is wedged (issue #65): tear it down so the
824
- * next sftp() call re-handshakes instead of queueing behind the dead one.
825
- */
826
- _noteSftpTimeout() {
827
- this._sftpTimeouts = (this._sftpTimeouts || 0) + 1
828
- if (this._sftpTimeouts >= 2) this.invalidate()
829
- }
830
-
831
- /**
832
- * Drop the cached client and force a fresh connection on the next call.
833
- * Called when a channel open fails (e.g. "Channel open failure: open
834
- * failed") — the pooled SSH connection is usually dead server-side while
835
- * keepalive has not yet noticed, and reusing it keeps failing. The epoch
836
- * bump orphans any in-flight connect; the client is ended so ssh2 frees
837
- * its sockets.
838
- */
839
- invalidate() {
840
- this.epoch++
841
- const client = this.client
842
- this.client = null
843
- const pending = this.connecting
844
- this.connecting = null
845
- if (pending && typeof pending.catch === 'function') {
846
- try { pending.catch(() => {}) } catch {}
847
- }
848
- if (this.proxyPool) {
849
- try { this.proxyPool.close() } catch {}
850
- this.proxyPool = null
851
- }
852
- if (client) {
853
- try { client.end() } catch {}
854
- }
855
- // Same teardown as close(): the forwards manager must detach from the
856
- // dying client, or its stale `close -> stopAll()` listener later tears
857
- // down forwards that were re-attached to the NEW client (onReady does
858
- // the re-attach on the next successful connect).
859
- if (this.onCloseHook) {
860
- try { this.onCloseHook() } catch {}
861
- }
862
- }
863
-
864
- close() {
865
- this.epoch++
866
- const client = this.client
867
- this.client = null
868
- const pending = this.connecting
869
- this.connecting = null
870
- if (pending && typeof pending.catch === 'function') {
871
- try { pending.catch(() => {}) } catch {}
872
- }
873
- if (this.proxyPool) {
874
- try { this.proxyPool.close() } catch {}
875
- this.proxyPool = null
876
- }
877
- if (client) {
878
- try {
879
- client.end()
880
- } catch {}
881
- }
882
- if (this.onCloseHook) {
883
- try { this.onCloseHook() } catch {}
884
- }
885
- }
886
- }
1
+ // Persistent SSH/SFTP connection pool (one identity → one live client).
2
+ import ssh2 from 'ssh2'
3
+ import { readFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import path from 'node:path'
6
+ import { truncate } from './paths.js'
7
+ import { createHostKeyGuard } from './hostkey.js'
8
+ import { buildSftpSurface } from './sftp-surface.js'
9
+ import { friendlyMessage } from './errors.js'
10
+ import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
11
+
12
+ const { Client } = ssh2
13
+
14
+ /**
15
+ * A key path that names a PUBLIC key. `IdentityFile ~/.ssh/x.pub` is OpenSSH's
16
+ * idiom for "the private key lives in the agent (1Password, Bitwarden, rbw…);
17
+ * use the agent key matching this file" — it must never be handed to ssh2 as
18
+ * a private key.
19
+ */
20
+ export function isPublicKeyPath(p) {
21
+ return /\.pub$/i.test(String(p || ''))
22
+ }
23
+
24
+ /** The wire blob of an OpenSSH public key line (`type base64 [comment]`). */
25
+ export function publicKeyBlob(text) {
26
+ const parts = String(text || '').trim().split(/\s+/)
27
+ if (parts.length < 2) return null
28
+ const blob = Buffer.from(parts[1], 'base64')
29
+ return blob.length ? blob : null
30
+ }
31
+
32
+ /**
33
+ * An agent that offers the key matching `wantBlob` FIRST — and, with
34
+ * `only` (IdentitiesOnly yes), nothing else. Without the ordering ssh2 offers
35
+ * every agent key in turn, and an agent holding many keys trips the server's
36
+ * MaxAuthTries before reaching the right one.
37
+ */
38
+ export class PreferredKeyAgent extends ssh2.BaseAgent {
39
+ constructor(inner, wantBlob, only) {
40
+ super()
41
+ this.inner = inner
42
+ this.wantBlob = wantBlob
43
+ this.only = !!only
44
+ }
45
+
46
+ getIdentities(cb) {
47
+ this.inner.getIdentities((err, keys) => {
48
+ if (err) return cb(err)
49
+ const list = Array.isArray(keys) ? keys : []
50
+ const isWanted = (k) => {
51
+ try { return Buffer.from(k.getPublicSSH()).equals(this.wantBlob) } catch { return false }
52
+ }
53
+ const wanted = list.filter(isWanted)
54
+ cb(null, this.only ? wanted : [...wanted, ...list.filter((k) => !isWanted(k))])
55
+ })
56
+ }
57
+
58
+ sign(...args) {
59
+ return this.inner.sign(...args)
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Build the pool for a config.
65
+ *
66
+ * Two backends exist (issue #51): the native one below, which speaks SSH itself
67
+ * via ssh2, and the "system OpenSSH" one (lib/openssh.js), which delegates to
68
+ * the local `ssh` executable so GSSAPI/Kerberos and the rest of the user's
69
+ * `~/.ssh/config` are honoured. Both expose one identical surface, so every
70
+ * caller can keep using `new SshPool(...)`-shaped code — this factory only has
71
+ * to decide which class to instantiate.
72
+ */
73
+ /** Hard cap on proxy-chain depth. OpenSSH's ProxyJump has no explicit limit;
74
+ * this exists to turn a config CYCLE into a clear error instead of unbounded
75
+ * recursion. A legitimate chain is 1-3 hops.
76
+ * @type {number} */
77
+ export const MAX_JUMP_HOPS = 8
78
+
79
+ export function createPool(config, opts) {
80
+ return isSystemOpenSshBackend(config)
81
+ ? new SystemOpenSshPool(config, opts)
82
+ : new SshPool(config, opts)
83
+ }
84
+
85
+ /**
86
+ * Run one `forwardOut` and resolve with the resulting channel.
87
+ *
88
+ * ssh2 reports a dead connection by THROWING synchronously —
89
+ * `if (!this._sock || !isWritable(this._sock)) throw new Error('Not connected')`
90
+ * (ssh2 >= 1.16, `Client.forwardOut`) — and never calls the callback, so a
91
+ * callback-only error path cannot guard this call (issue #54). Two further
92
+ * consequences justify a shared helper rather than an inline try/catch:
93
+ *
94
+ * • inside a `new Promise(executor)` a bare throw rejects nothing: it becomes
95
+ * an unhandled REJECTION and leaves the promise pending forever;
96
+ * • called from a `net.Server` connection handler the throw escapes to the
97
+ * process top level and kills the host.
98
+ *
99
+ * This guards the CALLER's side of the contract rather than assuming one
100
+ * backend's behaviour: the system-OpenSSH backend (lib/openssh.js `forwardOut`)
101
+ * already converts its own spawn failure into a callback error, so for it the
102
+ * try/catch below is dead code — but a backend is free to throw, and the one
103
+ * that does must not be able to take the process down.
104
+ *
105
+ * @param {object} client - an ssh2 Client or OpenSshConnection.
106
+ * @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
107
+ * @returns {Promise<object>} the forwarded channel / duplex stream.
108
+ */
109
+ export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
110
+ return new Promise((resolve, reject) => {
111
+ // `reject` is called at most once: a callback that fires after a successful
112
+ // throw-path rejection would be a no-op, which Promise guarantees.
113
+ try {
114
+ client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
115
+ } catch (err) {
116
+ reject(err)
117
+ }
118
+ })
119
+ }
120
+
121
+ /**
122
+ * The ACTIVE pool: one long-lived object whose concrete implementation can be
123
+ * swapped when the current machine's transport backend changes.
124
+ *
125
+ * Why a delegating facade instead of just constructing the right class once:
126
+ * `backend` is a PER-MACHINE field (an enterprise Kerberos host needs
127
+ * `openssh` while every other machine stays on `native`), but the plugin keeps
128
+ * exactly one active pool for its whole lifetime and hangs `passwordResolver` /
129
+ * `onReady` / `onCloseHook` off it once at startup. Replacing the object
130
+ * wholesale on every machine switch would silently drop those hooks — the port
131
+ * forwards and keychain passwords would stop working after the first switch.
132
+ *
133
+ * So the facade owns the hooks and the shared `config` object, and forwards
134
+ * them to whichever concrete pool is right for the current config. Everything
135
+ * else is a straight delegation, which is why callers cannot tell the
136
+ * difference (and why the class is deliberately not part of the public story).
137
+ */
138
+ export class ActivePool {
139
+ constructor(config, opts = {}) {
140
+ this.config = config
141
+ this.opts = opts
142
+ // The hooks are assigned by the plugin AFTER construction, so they are
143
+ // stored here and re-applied to every delegate this facade ever creates.
144
+ this._passwordResolver = null
145
+ this._onReady = null
146
+ this._onCloseHook = null
147
+ this._delegate = null
148
+ this._sync()
149
+ }
150
+
151
+ /** Which concrete class the current config asks for. */
152
+ _wantsSystemOpenSsh() {
153
+ return isSystemOpenSshBackend(this.config)
154
+ }
155
+
156
+ _delegateIsCurrent() {
157
+ if (!this._delegate) return false
158
+ const isOpenssh = this._delegate instanceof SystemOpenSshPool
159
+ return isOpenssh === this._wantsSystemOpenSsh()
160
+ }
161
+
162
+ /** Create (or replace) the delegate so it matches the current config. */
163
+ _sync() {
164
+ if (this._delegateIsCurrent()) return this._delegate
165
+ const previous = this._delegate
166
+ const next = createPool(this.config, this.opts)
167
+ next.passwordResolver = this._passwordResolver
168
+ next.onReady = this._onReady
169
+ next.onCloseHook = this._onCloseHook
170
+ this._delegate = next
171
+ // Tear the old backend down LAST, and never let a teardown error mask the
172
+ // new pool: the connection it was holding belongs to the old host anyway.
173
+ if (previous) { try { previous.close() } catch { /* already gone */ } }
174
+ return next
175
+ }
176
+
177
+ get delegate() { return this._sync() }
178
+
179
+ // ── hooks: stored here, mirrored onto every delegate ──────────────────────
180
+ get passwordResolver() { return this._passwordResolver }
181
+ set passwordResolver(fn) {
182
+ this._passwordResolver = fn
183
+ if (this._delegate) this._delegate.passwordResolver = fn
184
+ }
185
+ get onReady() { return this._onReady }
186
+ set onReady(fn) {
187
+ this._onReady = fn
188
+ if (this._delegate) this._delegate.onReady = fn
189
+ }
190
+ get onCloseHook() { return this._onCloseHook }
191
+ set onCloseHook(fn) {
192
+ this._onCloseHook = fn
193
+ if (this._delegate) this._delegate.onCloseHook = fn
194
+ }
195
+
196
+ // ── pass-through state the plugin reads directly ──────────────────────────
197
+ get client() { return this._delegate ? this._delegate.client : null }
198
+ // Assignable because the plugin's own tests arm a fake live connection on the
199
+ // active pool; a getter-only facade would make that assignment a silent
200
+ // no-op in sloppy mode and a TypeError in the strict-mode ESM tests.
201
+ set client(v) { if (this._sync()) this._delegate.client = v }
202
+ get connecting() { return this._delegate ? this._delegate.connecting : null }
203
+ get proxyPool() { return this._delegate ? this._delegate.proxyPool : null }
204
+ get epoch() { return this._delegate ? this._delegate.epoch : 0 }
205
+ get platform() { return this._delegate ? this._delegate.platform : 'unknown' }
206
+ set platform(v) { if (this._sync()) this._delegate.platform = v }
207
+ get gitBashPath() { return this._delegate ? this._delegate.gitBashPath : '' }
208
+ set gitBashPath(v) { if (this._sync()) this._delegate.gitBashPath = v }
209
+ get shellMode() { return this._delegate ? this._delegate.shellMode : 'native' }
210
+ set shellMode(v) { if (this._sync()) this._delegate.shellMode = v }
211
+
212
+ // ── delegated operations ──────────────────────────────────────────────────
213
+ /** Re-point at a new target, switching backend first if the machine asks. */
214
+ setTarget(fields, previous) {
215
+ // The backend may have changed with the machine, so apply that part of the
216
+ // request to `config` BEFORE picking the delegate: _sync() chooses the class
217
+ // from config.backend, and the old backend must not be handed a host it can
218
+ // no longer reach. The delegate then receives the whole request as usual.
219
+ if (fields && fields.backend !== undefined) this.config.backend = String(fields.backend)
220
+ if (fields && fields.sshBinary !== undefined) this.config.sshBinary = String(fields.sshBinary)
221
+ const d = this._sync()
222
+ return d.setTarget(fields, previous)
223
+ }
224
+ connect() { return this.delegate.connect() }
225
+ exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
226
+ sftp() { return this.delegate.sftp() }
227
+ shell(opts) { return this.delegate.shell(opts) }
228
+ detect() { return this.delegate.detect() }
229
+ invalidate() { return this.delegate.invalidate() }
230
+ close() { return this.delegate.close() }
231
+ }
232
+
233
+ export class SshPool {
234
+ constructor(config, opts = {}) {
235
+ this.config = config
236
+ this._knownHostsFile = opts.knownHostsFile
237
+ this.client = null
238
+ this.connecting = null
239
+ this.proxyPool = null
240
+ // Generational token: bumped on every target change / close so a stale
241
+ // in-flight connect can never hand this pool a connection to an old host.
242
+ this.epoch = 0
243
+ /** Auto-detected remote platform: unknown | windows | posix (per target). */
244
+ this.platform = 'unknown'
245
+ /** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
246
+ this.gitBashPath = ''
247
+ /** Resolved terminal strategy: native | git-bash. */
248
+ this.shellMode = 'native'
249
+ /** In-flight platform detection promise (cached). */
250
+ this._detecting = null
251
+ /** Optional async resolver for a machine-stored (keychain) password. */
252
+ this.passwordResolver = null
253
+ /** Optional hook called with the live client after a successful connect. */
254
+ this.onReady = null
255
+ /** Optional hook called when the pool closes. */
256
+ this.onCloseHook = null
257
+ /** Consecutive SFTP subchannel-open timeouts (issue #65). */
258
+ this._sftpTimeouts = 0
259
+ }
260
+
261
+ resolveKeyPath() {
262
+ const p = this.config.privateKeyPath
263
+ if (!p) return ''
264
+ if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
265
+ return p
266
+ }
267
+
268
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
269
+ // Collect the requested changes first: an UNCHANGED target is a no-op. The
270
+ // old behavior closed the live connection and re-ran platform detection
271
+ // even for an identical target, so every picker keystroke
272
+ // (POST /current → applyActiveMachine → setTarget) paid a full SSH
273
+ // reconnect + re-detect — the "slow remote path autocomplete" bug.
274
+ //
275
+ // `previous` is the diff BASELINE. Callers whose shared config was already
276
+ // mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
277
+ // live config IN PLACE, and pool.config IS that config object) pass a
278
+ // pre-mutation snapshot — otherwise the comparison runs the new values
279
+ // against themselves, is always equal and never closes even for a machine
280
+ // switch (the wrong-host hazard of issue #25). Defaults to the current
281
+ // config for callers that pass fresh values read independently of it.
282
+ const base = previous || this.config
283
+ const wanted = []
284
+ if (host !== undefined) wanted.push(['host', String(host)])
285
+ if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
286
+ if (username !== undefined) wanted.push(['username', String(username)])
287
+ if (password !== undefined && password !== null) wanted.push(['password', String(password)])
288
+ if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
289
+ if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
290
+ if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
291
+ if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
292
+ if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
293
+ if (proxy !== undefined) wanted.push(['proxy', proxy])
294
+ if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
295
+ // Issue #51: the transport backend is part of the target identity — changing
296
+ // it must close the live connection exactly like changing the host does.
297
+ if (backend !== undefined) wanted.push(['backend', String(backend)])
298
+ if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
299
+ const differs = ([k, v]) => (k === 'proxy'
300
+ ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
301
+ : base[k] !== v)
302
+ // Something actually changed → apply it and force a fresh connection.
303
+ // (A caller that WANTS a reconnect for an identical target uses
304
+ // invalidate(); setTarget semantics stay "re-point when moved".)
305
+ if (wanted.some(differs)) {
306
+ for (const [k, v] of wanted) this.config[k] = v
307
+ // the new target may be a different OS — re-detect on the next command
308
+ this.platform = 'unknown'
309
+ this.gitBashPath = ''
310
+ this.shellMode = 'native'
311
+ this._detecting = null
312
+ this.close()
313
+ }
314
+ return this
315
+ }
316
+
317
+ connect() {
318
+ if (this.client) return Promise.resolve(this.client)
319
+ if (this.connecting) return this.connecting
320
+ const epoch = this.epoch
321
+ const pending = this._doConnect(epoch)
322
+ this.connecting = pending
323
+ const clear = () => {
324
+ if (this.epoch === epoch && this.connecting === pending) this.connecting = null
325
+ }
326
+ pending.then(clear, clear)
327
+ return pending
328
+ }
329
+
330
+ async _doConnect(epoch) {
331
+ const isCurrent = () => this.epoch === epoch
332
+ const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
333
+ const guard = createHostKeyGuard(this.config, knownHostsFile)
334
+ const client = new Client()
335
+ let settled = false
336
+ // Every connect-stage failure is classified HERE, at the layer that knows
337
+ // which machine was being dialed: a bare ssh2 errno ("Timed out while
338
+ // waiting for handshake") names no host, and on a multi-machine plugin the
339
+ // user cannot tell WHICH box failed. friendlyMessage is idempotent, so
340
+ // callers that classify again (rw_connect, routes) do not double-wrap.
341
+ const classify = (err) => new Error(friendlyMessage(err, { host: this.config.host, port: this.config.port }))
342
+ const fail = (err) => {
343
+ if (settled) return
344
+ settled = true
345
+ if (isCurrent() && this.client === client) this.client = null
346
+ throw classify(guard.lastError ? new Error(guard.lastError) : err)
347
+ }
348
+
349
+ // Proxy jump: SSH to the bastion first, then tunnel to the target through it.
350
+ //
351
+ // MULTI-HOP (OpenSSH `ProxyJump a,b,c` semantics): each bastion may itself
352
+ // carry a `proxy`, and the chain is built from the FARTHEST hop inward — hop
353
+ // N is dialed through the already-open connection to hop N-1. The recursion
354
+ // IS the mechanism, so there is no depth limit beyond what the config
355
+ // expresses; MAX_JUMP_HOPS is a cycle/typo guard, not a feature limit.
356
+ let sock = null
357
+ const proxyCfg = this.config.proxy
358
+ if (proxyCfg && proxyCfg.host) {
359
+ try {
360
+ const depth = Number(this.config._hopDepth) || 0
361
+ if (depth >= MAX_JUMP_HOPS) {
362
+ throw new Error(`proxy chain is deeper than ${MAX_JUMP_HOPS} hops — check the machine config for a proxy cycle`)
363
+ }
364
+ this.proxyPool = new SshPool({
365
+ ...this.config,
366
+ host: proxyCfg.host,
367
+ port: Number(proxyCfg.port) || 22,
368
+ username: proxyCfg.username || this.config.username || 'root',
369
+ // CREDENTIALS DO NOT INHERIT ACROSS HOPS. `...this.config` brings the
370
+ // TARGET's key/passphrase along, which is wrong in two ways: it may
371
+ // not be authorized on the bastion, and presenting the target's key to
372
+ // an intermediate host hands that host a credential it should never
373
+ // see. A hop with no key of its own falls back to the agent/default
374
+ // discovery path, which is what an operator's own ssh setup provides.
375
+ password: proxyCfg.password || '',
376
+ privateKeyPath: proxyCfg.privateKeyPath || '',
377
+ passphrase: proxyCfg.passphrase || '',
378
+ agentSocket: proxyCfg.agentSocket || '',
379
+ identitiesOnly: !!proxyCfg.identitiesOnly,
380
+ ...(proxyCfg.useAgent !== undefined ? { useAgent: !!proxyCfg.useAgent } : {}),
381
+ ...(proxyCfg.keyboardInteractive !== undefined ? { keyboardInteractive: !!proxyCfg.keyboardInteractive } : {}),
382
+ ...(proxyCfg.hostKeyMode ? { hostKeyMode: proxyCfg.hostKeyMode } : {}),
383
+ // The nested hop keeps its OWN proxy — that is what builds the chain —
384
+ // and carries an incremented depth so a cycle is reported instead of
385
+ // recursing until the process dies.
386
+ proxy: proxyCfg.proxy && proxyCfg.proxy.host ? proxyCfg.proxy : undefined,
387
+ _hopDepth: depth + 1,
388
+ }, { knownHostsFile: this._knownHostsFile })
389
+ const pclient = await this.proxyPool.connect()
390
+ if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
391
+ try {
392
+ sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
393
+ } catch (e) {
394
+ throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
395
+ }
396
+ } catch (err) {
397
+ return fail(err)
398
+ }
399
+ }
400
+
401
+ return new Promise((resolve, reject) => {
402
+ const rejectOnce = (err) => {
403
+ if (settled) return
404
+ settled = true
405
+ if (isCurrent() && this.client === client) this.client = null
406
+ reject(classify(guard.lastError ? new Error(guard.lastError) : err))
407
+ }
408
+ client.on('ready', () => {
409
+ if (settled) return
410
+ settled = true
411
+ if (!isCurrent()) {
412
+ try { client.end() } catch {}
413
+ reject(classify(new Error('ssh target changed during connect')))
414
+ return
415
+ }
416
+ this.client = client
417
+ this._sftpTimeouts = 0
418
+ resolve(client)
419
+ if (this.onReady) { try { this.onReady(client) } catch {} }
420
+ })
421
+ client.on('error', (e) => rejectOnce(e))
422
+ client.on('close', () => {
423
+ if (isCurrent() && this.client === client) this.client = null
424
+ rejectOnce(new Error('ssh connection closed'))
425
+ })
426
+
427
+ const buildOpts = async () => {
428
+ const opts = {
429
+ host: this.config.host,
430
+ port: this.config.port,
431
+ username: this.config.username,
432
+ readyTimeout: this.config.connectTimeoutMs,
433
+ keepaliveInterval: 15000,
434
+ keepaliveCountMax: 3,
435
+ hostVerifier: (key) => guard.verifier(key),
436
+ }
437
+ if (sock) opts.sock = sock
438
+ // `useAgent` asks for agent auth, but ssh2 can only offer it when this
439
+ // PROCESS has an agent socket. On Windows there is no agent by default,
440
+ // so the flag alone used to fall through to the generic "no credentials"
441
+ // error — which reads as "the plugin lost my settings" rather than "this
442
+ // machine has no agent". Track the intent so the failure can say which.
443
+ let agentRequested = false
444
+ // `IdentityAgent` from ~/.ssh/config (agentSocket) wins over the
445
+ // process SSH_AUTH_SOCK, exactly as in ssh; `none` turns the agent off.
446
+ const agentSocket = String(this.config.agentSocket || '')
447
+ const sockPath = agentSocket === 'none' ? '' : (agentSocket || process.env.SSH_AUTH_SOCK || '')
448
+ const keyPathRaw = this.config.privateKeyPath ? this.resolveKeyPath() : ''
449
+ const agentKeyHint = isPublicKeyPath(keyPathRaw)
450
+ if (this.config.useAgent || agentKeyHint || (agentSocket && agentSocket !== 'none')) {
451
+ if (sockPath) opts.agent = sockPath
452
+ else agentRequested = true
453
+ }
454
+ let password = this.config.password || ''
455
+ if (!password && this.passwordResolver) {
456
+ try { password = (await this.passwordResolver()) || '' } catch {}
457
+ }
458
+ if (password) {
459
+ opts.password = password
460
+ opts.tryKeyboard = true
461
+ } else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
462
+ opts.tryKeyboard = true
463
+ }
464
+ if (agentKeyHint) {
465
+ // The key file is a PUBLIC key: authenticate through the agent with
466
+ // the matching key (see isPublicKeyPath).
467
+ if (!opts.agent) {
468
+ throw new Error(`no SSH agent available: the identity "${keyPathRaw}" is a public key, so its private key must come from an SSH agent, but neither IdentityAgent nor SSH_AUTH_SOCK gives one to the DSH host process`)
469
+ }
470
+ let blob
471
+ try {
472
+ blob = publicKeyBlob(readFileSync(keyPathRaw, 'utf8'))
473
+ } catch (err) {
474
+ throw new Error(`cannot read public key "${keyPathRaw}": ${err && err.message}`)
475
+ }
476
+ if (!blob) throw new Error(`cannot parse public key "${keyPathRaw}"`)
477
+ opts.agent = new PreferredKeyAgent(ssh2.createAgent(opts.agent), blob, this.config.identitiesOnly)
478
+ } else if (this.config.privateKeyPath) {
479
+ const keyPath = this.resolveKeyPath()
480
+ if (!keyPath) {
481
+ throw new Error('no credentials: set a password or a privateKeyPath to connect')
482
+ }
483
+ let key
484
+ try {
485
+ key = readFileSync(keyPath)
486
+ } catch (err) {
487
+ throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
488
+ }
489
+ opts.privateKey = key
490
+ opts.passphrase = this.config.passphrase || undefined
491
+ } else if (!password && !opts.agent) {
492
+ throw new Error(agentRequested
493
+ ? 'no SSH agent available: useAgent is enabled for this machine but SSH_AUTH_SOCK is not set in the DSH host process (Windows has no agent by default) — start an agent (or Pageant) before launching DSH, or save a password / private key for this machine'
494
+ : 'no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
495
+ }
496
+ return opts
497
+ }
498
+
499
+ buildOpts().then(
500
+ (opts) => {
501
+ if (opts.tryKeyboard) {
502
+ client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
503
+ finish(prompts.map(() => this.config.password || ''))
504
+ })
505
+ }
506
+ // ssh2 parses the private key SYNCHRONOUSLY inside connect() and
507
+ // throws on an unparseable key (the classic case: an encrypted
508
+ // OpenSSH key without a passphrase). Left uncaught, that throw
509
+ // rejects this orphaned .then chain — an unhandled rejection that
510
+ // crashes the whole host process (issue #48: "dsh: fatal load
511
+ // failure" on every boot, because boot-restore re-dials the bad
512
+ // machine). It also leaves the outer promise pending forever.
513
+ // Convert it into a normal rejection so every caller's try/catch —
514
+ // boot restore, test-connect, rw_connect — sees a regular failure.
515
+ try {
516
+ client.connect(opts)
517
+ } catch (err) {
518
+ rejectOnce(err)
519
+ }
520
+ },
521
+ (err) => rejectOnce(err),
522
+ )
523
+ })
524
+ }
525
+
526
+ /** Detect the remote platform + locate Git Bash once; cached per target. */
527
+ detect() {
528
+ if (this.platform !== 'unknown') return Promise.resolve()
529
+ if (this._detecting) return this._detecting
530
+ this._detecting = this._detect().finally(() => {
531
+ this._detecting = null
532
+ })
533
+ return this._detecting
534
+ }
535
+
536
+ async _detect() {
537
+ let res
538
+ try {
539
+ // `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
540
+ // "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
541
+ res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
542
+ } catch (err) {
543
+ this.platform = 'unknown'
544
+ this.shellMode = 'native'
545
+ return
546
+ }
547
+ const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
548
+ if (res.code === 0 && /microsoft windows/i.test(out)) {
549
+ this.platform = 'windows'
550
+ await this._resolveGitBash()
551
+ return
552
+ }
553
+ // inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
554
+ try {
555
+ const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
556
+ if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
557
+ this.platform = 'windows'
558
+ await this._resolveGitBash()
559
+ return
560
+ }
561
+ } catch {}
562
+ this.platform = 'posix'
563
+ this.shellMode = 'native'
564
+ this.gitBashPath = ''
565
+ }
566
+
567
+ /** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
568
+ async _resolveGitBash() {
569
+ const cfg = String(this.config.shell || '').trim()
570
+ if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
571
+ if (await this._cmdExists(cfg)) {
572
+ this.gitBashPath = cfg
573
+ this.shellMode = 'git-bash'
574
+ return
575
+ }
576
+ }
577
+ if (cfg === 'native') {
578
+ this.shellMode = 'native'
579
+ this.gitBashPath = ''
580
+ return
581
+ }
582
+ try {
583
+ const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
584
+ const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
585
+ if (first) {
586
+ this.gitBashPath = first
587
+ this.shellMode = 'git-bash'
588
+ return
589
+ }
590
+ } catch {}
591
+ const candidates = [
592
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
593
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
594
+ ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
595
+ '%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
596
+ ]
597
+ for (const c of candidates) {
598
+ if (await this._cmdExists(c)) {
599
+ this.gitBashPath = c
600
+ this.shellMode = 'git-bash'
601
+ return
602
+ }
603
+ }
604
+ this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
605
+ this.gitBashPath = ''
606
+ }
607
+
608
+ async _cmdExists(p) {
609
+ try {
610
+ const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
611
+ return /Y/.test(String(r.stdout || ''))
612
+ } catch {
613
+ return false
614
+ }
615
+ }
616
+
617
+ /** Run one remote command; resolves { code, signal, stdout, stderr }.
618
+ * On Windows remotes with Git Bash the script is piped to `bash -s` over the
619
+ * exec-channel stdin — no shell quoting round-trip, so any content (quotes,
620
+ * backslashes, newlines) survives verbatim. */
621
+ exec(command, timeoutMsOrOpts) {
622
+ const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
623
+ return this.detect().then(() => {
624
+ if (this.platform === 'windows' && this.gitBashPath) {
625
+ const script = String(command)
626
+ return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
627
+ try { stream.end(script) } catch {}
628
+ })
629
+ }
630
+ return this._execRaw(command, opts)
631
+ })
632
+ }
633
+
634
+ /** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
635
+ * the remote process stdin (used by the Git Bash `-s` mode). */
636
+ _execRaw(command, opts, stdinWriter) {
637
+ const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
638
+ const signal = opts && opts.signal
639
+ // Streaming tap for background jobs (issue #70): a promoted command's value
640
+ // is its LIVE output, which the job ring must receive as it arrives rather
641
+ // than only as a return value the caller already abandoned. Called with
642
+ // ('stdout'|'stderr', chunk); failures inside the tap must never break the
643
+ // command itself, so every call site guards it.
644
+ const onData = opts && typeof opts.onData === 'function' ? opts.onData : null
645
+ return this.connect().then(
646
+ (client) =>
647
+ new Promise((resolve, reject) => {
648
+ let retried = false
649
+ // Caller cancellation (issue #44): a tool that hands us exec.signal
650
+ // expects the remote command to stop when the turn is cancelled.
651
+ // Resolve the same shape as a timeout so callers need no new branch.
652
+ let onAbort = null
653
+ const cleanupAbort = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort) }
654
+ const runOn = (c) => {
655
+ const execOpts = {}
656
+ if (opts && opts.pty) execOpts.pty = true
657
+ if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
658
+ c.exec(command, execOpts, (err, stream) => {
659
+ if (err) {
660
+ // Channel-open failure — or a session termination — usually
661
+ // means the pooled connection died server-side (idle timeout /
662
+ // network reset) while keepalive hadn't noticed. Drop it and
663
+ // retry ONCE on a fresh connection.
664
+ if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
665
+ retried = true
666
+ this.invalidate()
667
+ return this.connect().then(
668
+ (fresh) => runOn(fresh),
669
+ (e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
670
+ )
671
+ }
672
+ return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
673
+ }
674
+ let stdout = ''
675
+ let stderr = ''
676
+ let settled = false
677
+ let exitCode = null
678
+ let exitSignal = null
679
+ const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
680
+ const settle = () => {
681
+ if (settled) return
682
+ settled = true
683
+ clearTimeout(timer)
684
+ cleanupAbort()
685
+ resolve({
686
+ code: exitCode,
687
+ signal: exitSignal,
688
+ stdout: truncate(stdout, this.config.maxOutputChars),
689
+ stderr: truncate(stderr, this.config.maxOutputChars),
690
+ })
691
+ }
692
+ const timer = setTimeout(() => {
693
+ if (settled) return
694
+ exitCode = -1
695
+ exitSignal = 'TIMEOUT'
696
+ // Kill the remote command (SIGTERM) rather than just dropping the
697
+ // channel, so a runaway process cannot keep running and holding
698
+ // the SSH connection after we've given up on its output.
699
+ try {
700
+ if (typeof stream.signal === 'function') stream.signal('SIGTERM')
701
+ } catch {}
702
+ const hardClose = setTimeout(() => {
703
+ try { stream.close() } catch {}
704
+ }, 800)
705
+ if (typeof hardClose.unref === 'function') hardClose.unref()
706
+ settle()
707
+ }, timeoutMs)
708
+ if (signal) {
709
+ if (signal.aborted) {
710
+ try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
711
+ try { stream.close() } catch {}
712
+ exitCode = -1
713
+ exitSignal = 'ABORTED'
714
+ return settle()
715
+ }
716
+ onAbort = () => {
717
+ if (settled) return
718
+ exitCode = -1
719
+ exitSignal = 'ABORTED'
720
+ try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
721
+ const hardClose = setTimeout(() => { try { stream.close() } catch {} }, 500)
722
+ if (typeof hardClose.unref === 'function') hardClose.unref()
723
+ settle()
724
+ }
725
+ signal.addEventListener('abort', onAbort, { once: true })
726
+ }
727
+ stream.on('close', (code, sigName) => {
728
+ if (settled) return
729
+ exitCode = code
730
+ // `sigName` is the signal NAME that killed the remote process
731
+ // (e.g. 'SIGTERM'), not the caller's AbortSignal. The rename
732
+ // matters: `signal` in this scope is the AbortSignal, and
733
+ // assigning it here would leak an object into the result.
734
+ exitSignal = sigName
735
+ settle()
736
+ })
737
+ stream.on('data', (d) => {
738
+ if (stdout.length < hardCap) stdout += d
739
+ if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
740
+ })
741
+ stream.stderr.on('data', (d) => {
742
+ if (stderr.length < hardCap) stderr += d
743
+ if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
744
+ })
745
+ stream.on('error', (e) => {
746
+ if (settled) return
747
+ settled = true
748
+ clearTimeout(timer)
749
+ cleanupAbort()
750
+ reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
751
+ })
752
+ if (stdinWriter) {
753
+ try { stdinWriter(stream) } catch {}
754
+ }
755
+ })
756
+ }
757
+ runOn(client)
758
+ }),
759
+ )
760
+ }
761
+
762
+ /**
763
+ * Open an INTERACTIVE shell channel on the remote host (PTY-backed).
764
+ *
765
+ * This is what an embedded terminal needs and what `exec` cannot provide:
766
+ * `exec` runs one command and closes, with no PTY and no stdin after the
767
+ * writer returns. A shell channel keeps the session alive across commands, so
768
+ * `vim`, `top`, `sudo` prompts and anything else that talks to a terminal
769
+ * works the way it does in an ssh session.
770
+ *
771
+ * The returned stream is ssh2's channel: write to it for keystrokes, listen
772
+ * for `data` for output, call `setWindow(rows, cols, height, width)` on
773
+ * resize, and `end()`/`close()` to hang up. Columns/rows are negotiated per
774
+ * channel, so two terminals on the same pooled connection stay independent.
775
+ *
776
+ * @param {{cols?: number, rows?: number, term?: string, pty?: boolean}} [opts]
777
+ * @returns {Promise<object>} the ssh2 Channel (interactive stream).
778
+ */
779
+ shell(opts = {}) {
780
+ const cols = Math.max(2, Math.min(Number(opts.cols) || 80, 1000))
781
+ const rows = Math.max(2, Math.min(Number(opts.rows) || 24, 1000))
782
+ const term = String(opts.term || 'xterm-256color')
783
+ return this.connect().then(
784
+ (client) =>
785
+ new Promise((resolve, reject) => {
786
+ let retried = false
787
+ const runOn = (c) => {
788
+ const wndopts = { cols, rows, height: 0, width: 0, term }
789
+ // A shell on a Windows remote is a cmd/PowerShell via the default
790
+ // sshd shell; the PTY request is identical either way.
791
+ c.shell(wndopts, opts.pty === false ? {} : { pty: wndopts }, (err, stream) => {
792
+ if (err) {
793
+ // Same stale-connection recovery as exec/sftp: a session
794
+ // termination means the pooled client died server-side.
795
+ if (!retried && /channel open failure|open failed|session termination|disconnect/i.test(String((err && err.message) || err))) {
796
+ retried = true
797
+ this.invalidate()
798
+ return this.connect().then(
799
+ (fresh) => runOn(fresh),
800
+ (e2) => reject(new Error('ssh shell failed (reconnect): ' + ((e2 && e2.message) || e2))),
801
+ )
802
+ }
803
+ return reject(new Error('ssh shell failed: ' + ((err && err.message) || err)))
804
+ }
805
+ // An idle shell must not be killed by the connection's own idle
806
+ // handling: keepalive is already on, and the channel lives until
807
+ // the client calls end(). Nothing to schedule here.
808
+ resolve(stream)
809
+ })
810
+ }
811
+ runOn(client)
812
+ }),
813
+ )
814
+ }
815
+
816
+ /** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
817
+ sftp() {
818
+ return this.connect().then(
819
+ (client) =>
820
+ new Promise((resolve, reject) => {
821
+ let retried = false
822
+ const runOn = (c) => {
823
+ // Bound the subchannel open itself, per attempt. Without this, a
824
+ // remote whose sftp subsystem is unresponsive (overloaded sshd,
825
+ // exceeded MaxSessions, half-open TCP) leaves `c.sftp(cb)` parked
826
+ // forever with no callback — issue #44 point 3. ssh2 only calls back
827
+ // on success or on a channel error, and a wedged peer produces
828
+ // neither.
829
+ //
830
+ // The timer must be created per attempt, not once outside: the
831
+ // callback clears it, so a single outer timer would leave the retry
832
+ // (below) with no timeout at all.
833
+ let timedOut = false
834
+ const attemptTimer = setTimeout(() => {
835
+ timedOut = true
836
+ reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
837
+ }, this.config.commandTimeoutMs)
838
+ // ★ 这里**不能 unref**。
839
+ //
840
+ // 这个定时器是"本次 await 的 sftp 打开"的**唯一结算者**:要么它超时 reject,
841
+ // 要么 ssh2 回调 resolve。若把它 unref,而进程此刻没有别的 pending 句柄,
842
+ // Node 会认为无事可做而**直接退出**,调用方永远等不到结果。
843
+ // 实测症状:CI 上该用例之后的 4 个用例被标记 `cancelledByParent`
844
+ // (同文件子进程提前退出),而本地因运行器恰好持着句柄而侥幸通过。
845
+ // 注意与上面 exec() 的超时定时器保持一致 —— 那个本来就没有 unref。
846
+ // 下面的 hardClose 仍然 unref,因为它只做清理、不负责结算。
847
+ const clearAttempt = () => { clearTimeout(attemptTimer) }
848
+ try {
849
+ c.sftp((err, sftp) => {
850
+ clearAttempt()
851
+ if (timedOut) return
852
+ if (err) {
853
+ // Same dead-connection recovery as exec(): a channel open
854
+ // failure — or a session termination (remote closed the
855
+ // SFTP subchannel, e.g. transient network blip / sshd idle
856
+ // drop) — means the pooled connection is stale: drop it and
857
+ // retry ONCE on a fresh connection.
858
+ if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
859
+ retried = true
860
+ this.invalidate()
861
+ return this.connect().then(
862
+ (fresh) => runOn(fresh),
863
+ (e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
864
+ )
865
+ }
866
+ return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
867
+ }
868
+ // A successful open proves the connection healthy: the strike
869
+ // counter counts CONSECUTIVE timeouts, so it resets here —
870
+ // two unrelated transient blips hours apart must not tear down
871
+ // a fine connection.
872
+ this._sftpTimeouts = 0
873
+ // The promisified surface (timeouts, dead-channel fail-fast,
874
+ // range reads) is shared verbatim with the system-OpenSSH
875
+ // backend — see lib/sftp-surface.js for the rules it encodes.
876
+ resolve(buildSftpSurface(sftp, {
877
+ commandTimeoutMs: this.config.commandTimeoutMs,
878
+ onTimeout: () => {
879
+ // A timed-out SFTP subchannel is dead: close it so the next
880
+ // operation does not queue behind the wedged one, and count
881
+ // the strike so a second consecutive timeout tears down the
882
+ // whole pooled connection (issue #65).
883
+ try { sftp.end() } catch { /* already gone */ }
884
+ this._noteSftpTimeout()
885
+ },
886
+ }))
887
+ })
888
+ } catch (e) {
889
+ clearAttempt()
890
+ reject(new Error('ssh sftp failed: ' + ((e && e.message) || e)))
891
+ }
892
+ }
893
+ runOn(client)
894
+ }),
895
+ )
896
+ }
897
+
898
+ /**
899
+ * Count a timed-out SFTP subchannel open. A second consecutive timeout means
900
+ * the pooled connection itself is wedged (issue #65): tear it down so the
901
+ * next sftp() call re-handshakes instead of queueing behind the dead one.
902
+ */
903
+ _noteSftpTimeout() {
904
+ this._sftpTimeouts = (this._sftpTimeouts || 0) + 1
905
+ if (this._sftpTimeouts >= 2) this.invalidate()
906
+ }
907
+
908
+ /**
909
+ * Drop the cached client and force a fresh connection on the next call.
910
+ * Called when a channel open fails (e.g. "Channel open failure: open
911
+ * failed") — the pooled SSH connection is usually dead server-side while
912
+ * keepalive has not yet noticed, and reusing it keeps failing. The epoch
913
+ * bump orphans any in-flight connect; the client is ended so ssh2 frees
914
+ * its sockets.
915
+ */
916
+ invalidate() {
917
+ this.epoch++
918
+ const client = this.client
919
+ this.client = null
920
+ const pending = this.connecting
921
+ this.connecting = null
922
+ if (pending && typeof pending.catch === 'function') {
923
+ try { pending.catch(() => {}) } catch {}
924
+ }
925
+ if (this.proxyPool) {
926
+ try { this.proxyPool.close() } catch {}
927
+ this.proxyPool = null
928
+ }
929
+ if (client) {
930
+ try { client.end() } catch {}
931
+ }
932
+ // Same teardown as close(): the forwards manager must detach from the
933
+ // dying client, or its stale `close -> stopAll()` listener later tears
934
+ // down forwards that were re-attached to the NEW client (onReady does
935
+ // the re-attach on the next successful connect).
936
+ if (this.onCloseHook) {
937
+ try { this.onCloseHook() } catch {}
938
+ }
939
+ }
940
+
941
+ close() {
942
+ this.epoch++
943
+ const client = this.client
944
+ this.client = null
945
+ const pending = this.connecting
946
+ this.connecting = null
947
+ if (pending && typeof pending.catch === 'function') {
948
+ try { pending.catch(() => {}) } catch {}
949
+ }
950
+ if (this.proxyPool) {
951
+ try { this.proxyPool.close() } catch {}
952
+ this.proxyPool = null
953
+ }
954
+ if (client) {
955
+ try {
956
+ client.end()
957
+ } catch {}
958
+ }
959
+ if (this.onCloseHook) {
960
+ try { this.onCloseHook() } catch {}
961
+ }
962
+ }
963
+ }