dsh-remote 0.8.42 → 0.8.44

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/openssh.js ADDED
@@ -0,0 +1,838 @@
1
+ // dsh-remote — the "system OpenSSH" SSH backend (issue #51).
2
+ //
3
+ // ## Why this exists
4
+ //
5
+ // The default backend (lib/pool.js) speaks SSH itself through ssh2, so it can
6
+ // only use the authentication methods ssh2 implements: password, private key,
7
+ // ssh-agent and keyboard-interactive. Enterprise hosts that only allow
8
+ // GSSAPI/Kerberos are unreachable, even though `ssh <alias>` in the user's own
9
+ // terminal works — the local Kerberos ticket (kinit) is simply not something
10
+ // ssh2 knows how to present.
11
+ //
12
+ // This backend delegates the transport to the LOCAL `ssh` executable, so every
13
+ // `~/.ssh/config` feature the user already relies on applies: gssapi-with-mic,
14
+ // ProxyJump, ProxyCommand, ControlPath, Host aliases, IdentityFile,
15
+ // PKCS11Provider, Match blocks… Anything OpenSSH can do, this backend can do,
16
+ // because OpenSSH is the one doing it.
17
+ //
18
+ // ## How each primitive is implemented
19
+ //
20
+ // exec → `ssh <opts> -- <dest> <command>` (one process per call)
21
+ // sftp → `ssh <opts> -s <dest> sftp` + ssh2's OWN SFTP protocol engine
22
+ // driven over the resulting raw byte stream
23
+ // forwardOut → `ssh <opts> -W <host>:<port> <dest>` (one process per tunnel)
24
+ // reverse → `ssh <opts> -N -R <bind>:<port>:<h>:<p> <dest>`
25
+ //
26
+ // The SFTP one deserves a note, because it is the reason this backend did not
27
+ // have to reimplement file transfer: `ssh -s <dest> sftp` on stdio is a PLAIN
28
+ // SFTP v3 byte stream (verified against a real host — the subsystem replies
29
+ // with its VERSION packet and nothing else on stdout). ssh2 already ships a
30
+ // complete, battle-tested SFTP implementation that speaks exactly that protocol
31
+ // over a duplex stream, so this module gives it a minimal `_protocol` shim and
32
+ // hands it the child process's stdio. File transfer therefore has the same
33
+ // semantics — and the same large-packet optimisations — as the native backend.
34
+ //
35
+ // ## Trade-offs (documented, not hidden)
36
+ //
37
+ // • spawn-per-operation: unlike the native pool there is no long-lived
38
+ // connection multiplexed across commands, so each `exec`/`forwardOut` pays
39
+ // a fresh SSH handshake. That is inherent to delegating to the `ssh` CLI:
40
+ // Windows OpenSSH rejects ControlMaster ("getsockname failed: Not a
41
+ // socket", measured), and a portable multiplexing layer would be a second
42
+ // implementation of what the native backend already does. The SFTP path IS
43
+ // kept persistent, because the workspace picker and the explorer hammer it.
44
+ // • the passphrase/password prompt: OpenSSH asks for both through the same
45
+ // askpass channel, so when a secret is configured this backend offers it
46
+ // for whichever prompt appears (password first, then passphrase). A host
47
+ // needing two DIFFERENT secrets is not representable here — use the native
48
+ // backend for that.
49
+ // • reverse forwards need their target up front (the CLI takes it as an
50
+ // argument), so they are exposed as `reverseForward()` and ForwardManager
51
+ // prefers it when present.
52
+ // • host keys are checked by ssh itself against the user's own known_hosts,
53
+ // not against the plugin's TOFU store: making `ssh` use a different trust
54
+ // anchor per machine is exactly the kind of override that breaks the
55
+ // enterprise setups this backend exists to serve. `hostKeyMode` maps onto
56
+ // StrictHostKeyChecking instead.
57
+
58
+ import { spawn } from 'node:child_process'
59
+ import { accessSync, chmodSync, constants as fsConstants, mkdirSync, writeFileSync } from 'node:fs'
60
+ import { createRequire } from 'node:module'
61
+ import { EventEmitter } from 'node:events'
62
+ import { homedir, tmpdir } from 'node:os'
63
+ import path from 'node:path'
64
+ import { Duplex } from 'node:stream'
65
+ import { buildSftpSurface } from './sftp-surface.js'
66
+ import { truncate } from './paths.js'
67
+
68
+ // The SFTP protocol engine lives at lib/protocol/SFTP.js inside ssh2 and is not
69
+ // part of its public API. Creating it through createRequire keeps this module
70
+ // ESM-clean while still reaching it; if a future ssh2 moves it, this fails with
71
+ // an actionable message instead of a bare MODULE_NOT_FOUND at connect time.
72
+ const require_ = createRequire(import.meta.url)
73
+ let SftpEngine = null
74
+ export function loadSftpEngine() {
75
+ if (SftpEngine) return SftpEngine
76
+ try {
77
+ const entry = require_.resolve('ssh2')
78
+ const sftpPath = entry.replace(/lib[\\/]index\.js$/, 'lib/protocol/SFTP.js')
79
+ SftpEngine = require_(sftpPath).SFTP
80
+ } catch (err) {
81
+ throw new Error("system-OpenSSH backend could not load ssh2's SFTP engine: " + ((err && err.message) || err))
82
+ }
83
+ if (typeof SftpEngine !== 'function') {
84
+ throw new Error("system-OpenSSH backend found no SFTP engine in this ssh2 install")
85
+ }
86
+ return SftpEngine
87
+ }
88
+
89
+ /** The `ssh` executable this backend drives (config override wins). */
90
+ export function resolveSshBinary(config = {}) {
91
+ return String(config.sshBinary || '').trim() || 'ssh'
92
+ }
93
+
94
+ /**
95
+ * Path of the user's own known_hosts. Exposed for the UI; the backend itself
96
+ * never points ssh at a different file (see the header).
97
+ */
98
+ export function defaultKnownHostsPath() {
99
+ return path.join(homedir(), '.ssh', 'known_hosts')
100
+ }
101
+
102
+ /**
103
+ * Translate `hostKeyMode` into OpenSSH's StrictHostKeyChecking.
104
+ *
105
+ * `accept-new` is ssh's own default on modern OpenSSH and matches the plugin's
106
+ * TOFU default; `verify` refuses unknown hosts; `off` accepts anything.
107
+ */
108
+ export function hostKeyArgs(config = {}) {
109
+ const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new'
110
+ const strict = mode === 'verify' ? 'yes' : mode === 'off' ? 'no' : 'accept-new'
111
+ return ['-o', `StrictHostKeyChecking=${strict}`]
112
+ }
113
+
114
+ /**
115
+ * Where the generated askpass helper lives. One stable directory per user, so
116
+ * the helper is written once and reused; the helper carries NO secret (it reads
117
+ * the secret from the environment), which is why writing it to disk is safe.
118
+ */
119
+ export function askpassDir() {
120
+ return path.join(tmpdir(), 'dsh-remote-askpass')
121
+ }
122
+
123
+ /**
124
+ * Ensure an askpass helper exists and return its path.
125
+ *
126
+ * The helper prints $DSH_REMOTE_SSH_SECRET. Passing the secret through the
127
+ * environment (rather than as an argument or a file) keeps it out of `ps`,
128
+ * argv and the filesystem. The Windows helper is a .cmd that invokes Node via
129
+ * `process.execPath`, because `@echo %VAR%` would mangle secrets containing cmd
130
+ * metacharacters (&, %, ^ …) and a bare `echo` is not a shell we control.
131
+ */
132
+ export function ensureAskpassHelper() {
133
+ const dir = askpassDir()
134
+ try { mkdirSync(dir, { recursive: true }) } catch { /* already there */ }
135
+ if (process.platform === 'win32') {
136
+ const js = path.join(dir, 'askpass.js')
137
+ const cmd = path.join(dir, 'askpass.cmd')
138
+ try {
139
+ writeFileSync(js, 'process.stdout.write((process.env.DSH_REMOTE_SSH_SECRET || "") + "\\n")\n')
140
+ writeFileSync(cmd, '@echo off\r\n"' + process.execPath + '" "' + js + '"\r\n')
141
+ } catch (err) {
142
+ throw new Error('system-OpenSSH backend could not write its askpass helper: ' + ((err && err.message) || err))
143
+ }
144
+ return cmd
145
+ }
146
+ const sh = path.join(dir, 'askpass.sh')
147
+ try {
148
+ writeFileSync(sh, '#!/bin/sh\nprintf \'%s\\n\' "$DSH_REMOTE_SSH_SECRET"\n')
149
+ try { chmodSync(sh, 0o700) } catch { /* best effort on odd filesystems */ }
150
+ } catch (err) {
151
+ throw new Error('system-OpenSSH backend could not write its askpass helper: ' + ((err && err.message) || err))
152
+ }
153
+ return sh
154
+ }
155
+
156
+ /** Env that hands the secret to the askpass helper (never argv, never a file). */
157
+ export function askpassEnv(secret) {
158
+ return {
159
+ ...process.env,
160
+ SSH_ASKPASS: ensureAskpassHelper(),
161
+ SSH_ASKPASS_REQUIRE: 'force',
162
+ DISPLAY: process.env.DISPLAY || ':0',
163
+ DSH_REMOTE_SSH_SECRET: String(secret),
164
+ }
165
+ }
166
+
167
+ /** Is `ssh` runnable? Used to fail with an actionable message, not ENOENT. */
168
+ export function sshBinaryAvailable(binary) {
169
+ if (binary.includes('/') || binary.includes('\\')) {
170
+ try { accessSync(binary, fsConstants.X_OK); return true } catch { return false }
171
+ }
172
+ const exts = process.platform === 'win32'
173
+ ? String(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(Boolean)
174
+ : ['']
175
+ for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
176
+ if (!dir) continue
177
+ for (const ext of exts) {
178
+ try { accessSync(path.join(dir, binary + ext), fsConstants.X_OK); return true } catch { /* keep looking */ }
179
+ }
180
+ }
181
+ return false
182
+ }
183
+
184
+ /**
185
+ * Options shared by every `ssh` invocation for this target.
186
+ *
187
+ * Deliberately NOT passed: PreferredAuthentications / GSSAPIAuthentication /
188
+ * PubkeyAcceptedKeyTypes / IdentitiesOnly-from-the-alias / ProxyJump-from-the-
189
+ * alias. Those must come from the user's `~/.ssh/config` — overriding them is
190
+ * precisely what would break the enterprise setups this backend exists for.
191
+ *
192
+ * @param {object} config - the pool config.
193
+ * @param {object} [opts]
194
+ * @param {boolean} [opts.forSecret] - true when a secret will be offered via
195
+ * askpass, which means BatchMode must be OFF (BatchMode forbids every
196
+ * interactive prompt, askpass included) and the prompt count bounded instead.
197
+ */
198
+ export function baseSshArgs(config, { forSecret = false } = {}) {
199
+ const args = []
200
+ if (config.port && Number(config.port) !== 22) args.push('-p', String(Number(config.port)))
201
+ if (config.username) args.push('-l', String(config.username))
202
+ if (config.privateKeyPath) args.push('-i', String(config.privateKeyPath))
203
+ // An explicit jump host — the same shape the native backend's `proxy` takes.
204
+ // A jump host that comes from ~/.ssh/config needs nothing here.
205
+ const proxy = config.proxy
206
+ if (proxy && proxy.host) {
207
+ const user = proxy.username ? `${proxy.username}@` : ''
208
+ const port = proxy.port && Number(proxy.port) !== 22 ? `:${Number(proxy.port)}` : ''
209
+ args.push('-J', `${user}${proxy.host}${port}`)
210
+ }
211
+ args.push(...hostKeyArgs(config))
212
+ const connectSecs = Math.max(1, Math.round((Number(config.connectTimeoutMs) || 15000) / 1000))
213
+ args.push('-o', `ConnectTimeout=${connectSecs}`, '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3')
214
+ // Without a configured secret we must not hang on an interactive prompt:
215
+ // BatchMode makes ssh fail fast instead, and leaves key/agent/GSSAPI intact.
216
+ if (!forSecret) args.push('-o', 'BatchMode=yes')
217
+ else args.push('-o', 'NumberOfPasswordPrompts=1')
218
+ return args
219
+ }
220
+
221
+ /**
222
+ * One live system-OpenSSH connection.
223
+ *
224
+ * This deliberately mimics the ssh2 Client surface the rest of the plugin
225
+ * consumes — `forwardOut` plus a reverse-forward capability — so
226
+ * ForwardManager/WebAttach work unchanged. It is an EventEmitter because the
227
+ * native pool's client is one and callers attach listeners to it.
228
+ */
229
+ export class OpenSshConnection extends EventEmitter {
230
+ constructor(pool, { sshBinary, secret }) {
231
+ super()
232
+ this.pool = pool
233
+ this.sshBinary = sshBinary
234
+ this.secret = secret
235
+ this.closed = false
236
+ /** Live child processes to reap on close. */
237
+ this.children = new Set()
238
+ }
239
+
240
+ shortDescription() {
241
+ return `${this.pool.destination()} via ${this.sshBinary}`
242
+ }
243
+
244
+ spawnSsh(extraArgs, { tty = false } = {}) {
245
+ if (this.closed) throw new Error('ssh connection closed')
246
+ const args = [
247
+ ...baseSshArgs(this.pool.config, { forSecret: !!this.secret }),
248
+ ...extraArgs,
249
+ ]
250
+ const child = spawn(this.sshBinary, args, {
251
+ stdio: ['pipe', 'pipe', 'pipe'],
252
+ env: this.secret ? askpassEnv(this.secret) : undefined,
253
+ })
254
+ this.children.add(child)
255
+ const reap = () => this.children.delete(child)
256
+ child.on('close', reap)
257
+ child.on('error', reap)
258
+ if (tty) { /* no pty support through the CLI without a tty helper */ }
259
+ return child
260
+ }
261
+
262
+ /**
263
+ * Local forward: carry bytes to <destHost>:<destPort> as seen FROM the
264
+ * remote, through `ssh -W`.
265
+ *
266
+ * @returns {boolean} true synchronously, so capability probes can be sync.
267
+ */
268
+ forwardOut(_srcHost, _srcPort, destHost, destPort, cb) {
269
+ let child
270
+ try {
271
+ child = this.spawnSsh(['-W', `${destHost}:${destPort}`, '--', this.pool.destination()])
272
+ } catch (err) {
273
+ setImmediate(() => cb(err))
274
+ return true
275
+ }
276
+ // `ssh -W` reports failures on stderr and then exits; surface that as the
277
+ // channel error so the caller destroys its socket instead of hanging.
278
+ let stderr = ''
279
+ let handed = false
280
+ const channel = new Duplex({
281
+ read() {},
282
+ write(chunk, _enc, done) { try { child.stdin.write(chunk, done) } catch (e) { done(e) } },
283
+ final(done) { try { child.stdin.end(done) } catch { done() } },
284
+ destroy(err, done) { try { child.kill() } catch {} ; done(err) },
285
+ })
286
+ channel.close = () => { try { child.kill() } catch {} }
287
+ child.stderr.on('data', (d) => { stderr += d })
288
+ child.stdout.on('data', (d) => channel.push(d))
289
+ child.stdout.on('end', () => channel.push(null))
290
+ child.on('error', (e) => { if (!handed) { handed = true; cb(e); return } channel.destroy(e) })
291
+ child.on('close', (code) => {
292
+ if (handed) { channel.push(null); return }
293
+ handed = true
294
+ if (code === 0) { cb(null, channel); return }
295
+ cb(new Error(`ssh -W to ${destHost}:${destPort} failed (exit ${code})${stderr ? ': ' + stderr.trim() : ''}`))
296
+ })
297
+ return true
298
+ }
299
+
300
+ /** Tells ForwardManager that the CLI-specific reverse path is available. */
301
+ get supportsReverseForward() { return true }
302
+
303
+ /**
304
+ * Reverse forward: ask the REMOTE to listen on <bindHost>:<listenPort> and
305
+ * carry connections to <targetHost>:<targetPort>. Unlike ssh2's two-phase
306
+ * `forwardIn` + `tcpip`, the `ssh -R` CLI takes the final target as an
307
+ * argument, so nothing has to be resolved lazily.
308
+ *
309
+ * @returns {Promise<{child: object, close: Function}>}
310
+ */
311
+ reverseForward({ bindHost = '127.0.0.1', listenPort, targetHost = '127.0.0.1', targetPort }) {
312
+ return new Promise((resolve, reject) => {
313
+ const spec = `${bindHost}:${listenPort}:${targetHost}:${targetPort}`
314
+ let child
315
+ try {
316
+ child = this.spawnSsh(['-N', '-o', 'ExitOnForwardFailure=yes', '-R', spec, '--', this.pool.destination()])
317
+ } catch (err) { return reject(err) }
318
+ let stderr = ''
319
+ let settled = false
320
+ const fail = (err) => {
321
+ if (settled) return
322
+ settled = true
323
+ clearTimeout(timer)
324
+ try { child.kill() } catch {}
325
+ reject(err)
326
+ }
327
+ child.stderr.on('data', (d) => {
328
+ stderr += d
329
+ // Nothing prints on success; an error before the grace window is the
330
+ // only signal `ssh -N` gives that the forward did not take.
331
+ if (/warning|error|failed|refused|denied|cannot|not permitted|unable/i.test(String(d))) {
332
+ fail(new Error(`reverse forward ${spec} failed: ${stderr.trim()}`))
333
+ }
334
+ })
335
+ child.on('error', fail)
336
+ // `ssh -N` stays in the foreground; a process that outlives the grace
337
+ // window means the remote listener was accepted.
338
+ const timer = setTimeout(() => {
339
+ if (settled) return
340
+ settled = true
341
+ resolve({ child, close: () => { try { child.kill() } catch {} } })
342
+ }, 700)
343
+ child.on('close', (code) => {
344
+ if (settled) return
345
+ settled = true
346
+ clearTimeout(timer)
347
+ reject(new Error(`reverse forward ${spec} exited early (${code})${stderr ? ': ' + stderr.trim() : ''}`))
348
+ })
349
+ })
350
+ }
351
+
352
+ /** Superseded by reverseForward() (the CLI needs the target up front). */
353
+ forwardIn(_host, _port, cb) {
354
+ setImmediate(() => cb(new Error(
355
+ 'the system-OpenSSH backend opens reverse forwards with `ssh -R`, which needs the target up front — use reverseForward()',
356
+ )))
357
+ }
358
+
359
+ close() {
360
+ this.closed = true
361
+ for (const child of [...this.children]) {
362
+ try { child.kill() } catch {}
363
+ }
364
+ this.children.clear()
365
+ }
366
+ }
367
+
368
+ /**
369
+ * A system-OpenSSH pool: the same public surface as lib/pool.js's SshPool,
370
+ * backed by the local `ssh` CLI. Callers cannot tell the two apart — see
371
+ * test/openssh-backend.test.js, which drives both through one shared contract.
372
+ */
373
+ export class SystemOpenSshPool {
374
+ constructor(config, opts = {}) {
375
+ this.config = config
376
+ this._knownHostsFile = opts.knownHostsFile
377
+ this.client = null
378
+ this.connecting = null
379
+ this.proxyPool = null
380
+ // Generational token, exactly as the native pool: a stale in-flight connect
381
+ // must never hand this pool a connection to an old host.
382
+ this.epoch = 0
383
+ this.platform = 'unknown'
384
+ this.gitBashPath = ''
385
+ this.shellMode = 'native'
386
+ this._detecting = null
387
+ this.passwordResolver = null
388
+ this.onReady = null
389
+ this.onCloseHook = null
390
+ /** The persistent `ssh -s ... sftp` session (kept across calls). */
391
+ this._sftpSession = null
392
+ this._sftpChild = null
393
+ /** Memoized askpass secret for this target (null = not resolved yet). */
394
+ this._secret = null
395
+ this._secretPromise = null
396
+ }
397
+
398
+ /** The destination OpenSSH should connect to (an alias passes through). */
399
+ destination() {
400
+ // A bare alias resolves through ~/.ssh/config inside ssh itself, so it is
401
+ // handed over untouched; user/port ride on `-l`/`-p` when recorded.
402
+ return String(this.config.host || '')
403
+ }
404
+
405
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
406
+ const base = previous || this.config
407
+ const wanted = []
408
+ if (host !== undefined) wanted.push(['host', String(host)])
409
+ if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
410
+ if (username !== undefined) wanted.push(['username', String(username)])
411
+ if (password !== undefined && password !== null) wanted.push(['password', String(password)])
412
+ if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
413
+ if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
414
+ if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
415
+ if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
416
+ if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
417
+ if (proxy !== undefined) wanted.push(['proxy', proxy])
418
+ if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
419
+ // Issue #51: kept in step with the native pool's setTarget — the two
420
+ // backends share one contract (see test/issue51-openssh-backend.test.js), so
421
+ // a field one of them diffs must not be silently ignored by the other.
422
+ if (backend !== undefined) wanted.push(['backend', String(backend)])
423
+ if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
424
+ const differs = ([k, v]) => (k === 'proxy'
425
+ ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
426
+ : base[k] !== v)
427
+ if (wanted.some(differs)) {
428
+ for (const [k, v] of wanted) this.config[k] = v
429
+ this.platform = 'unknown'
430
+ this.gitBashPath = ''
431
+ this.shellMode = 'native'
432
+ this._detecting = null
433
+ this.close()
434
+ }
435
+ return this
436
+ }
437
+
438
+ /**
439
+ * The secret to offer askpass, resolved once per target.
440
+ *
441
+ * `connect()` no longer has to run first: `exec`/`sftp` call this themselves,
442
+ * so a pool that is only ever used for file listing still authenticates.
443
+ */
444
+ _resolveSecret() {
445
+ if (this._secret !== null) return Promise.resolve(this._secret)
446
+ if (this._secretPromise) return this._secretPromise
447
+ const epoch = this.epoch
448
+ this._secretPromise = (async () => {
449
+ let secret = this.config.password || ''
450
+ if (!secret && this.passwordResolver) {
451
+ try { secret = (await this.passwordResolver()) || '' } catch { /* fall through */ }
452
+ }
453
+ if (!secret) secret = this.config.passphrase || ''
454
+ // A target switch invalidates the answer; leave the memo for the new one.
455
+ if (this.epoch === epoch) this._secret = String(secret || '')
456
+ this._secretPromise = null
457
+ return String(secret || '')
458
+ })()
459
+ return this._secretPromise
460
+ }
461
+
462
+ /** Validate the environment; there is no socket to hold open. */
463
+ connect() {
464
+ if (this.client) return Promise.resolve(this.client)
465
+ if (this.connecting) return this.connecting
466
+ const epoch = this.epoch
467
+ const pending = this._doConnect(epoch)
468
+ this.connecting = pending
469
+ const clear = () => { if (this.epoch === epoch && this.connecting === pending) this.connecting = null }
470
+ pending.then(clear, clear)
471
+ return pending
472
+ }
473
+
474
+ async _doConnect(epoch) {
475
+ const isCurrent = () => this.epoch === epoch
476
+ const sshBinary = resolveSshBinary(this.config)
477
+ if (!this.config.host) throw new Error('no host configured')
478
+ if (!sshBinaryAvailable(sshBinary)) {
479
+ throw new Error(
480
+ `the system-OpenSSH backend needs a \`${sshBinary}\` executable on PATH`
481
+ + ' — install OpenSSH, or set the backend back to "native"',
482
+ )
483
+ }
484
+ const secret = await this._resolveSecret()
485
+ if (!isCurrent()) throw new Error('ssh target changed during connect')
486
+ // Native semantics are "connect() throws when the SSH connection fails", and
487
+ // a probe also gives the settings page a real latency number. The CLI has no
488
+ // socket to keep, so this is the only point where the target is proven.
489
+ const probe = await this._runSsh(['--', this.destination(), 'true'], { timeoutMs: this.config.connectTimeoutMs || 15000 })
490
+ if (!isCurrent()) throw new Error('ssh target changed during connect')
491
+ if (probe.code !== 0) {
492
+ throw new Error(`ssh failed (exit ${probe.code})${String(probe.stderr || '').trim() ? ': ' + String(probe.stderr).trim() : ''}`)
493
+ }
494
+ void secret
495
+ const conn = new OpenSshConnection(this, { sshBinary, secret })
496
+ this.client = conn
497
+ if (this.onReady) { try { this.onReady(conn) } catch {} }
498
+ return conn
499
+ }
500
+
501
+ /**
502
+ * Run one `ssh` invocation and collect its result.
503
+ *
504
+ * @param {string[]} extraArgs - arguments appended after the shared base.
505
+ * @param {object} [opts] - `timeoutMs`, `signal` (AbortSignal), `stdin`.
506
+ * @returns {Promise<{code: number|null, signal: string|null, stdout: string, stderr: string}>}
507
+ */
508
+ async _runSsh(extraArgs, opts = {}) {
509
+ if (!this.config.host) throw new Error('no host configured')
510
+ const secret = await this._resolveSecret()
511
+ const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
512
+ const sshBinary = resolveSshBinary(this.config)
513
+ const args = [...baseSshArgs(this.config, { forSecret: !!secret }), ...extraArgs]
514
+ return new Promise((resolve) => {
515
+ let child
516
+ try {
517
+ child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
518
+ } catch (err) {
519
+ return resolve({ code: -1, signal: 'SPAWN_FAILED', stdout: '', stderr: String((err && err.message) || err) })
520
+ }
521
+ let stdout = ''
522
+ let stderr = ''
523
+ let settled = false
524
+ let exitCode = null
525
+ let exitSignal = null
526
+ const cap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
527
+ const cleanupAbort = () => { if (opts.signal && onAbort) opts.signal.removeEventListener('abort', onAbort) }
528
+ let onAbort = null
529
+ const finish = () => {
530
+ if (settled) return
531
+ settled = true
532
+ clearTimeout(timer)
533
+ cleanupAbort()
534
+ resolve({
535
+ code: exitCode,
536
+ signal: exitSignal,
537
+ stdout: truncate(stdout, this.config.maxOutputChars),
538
+ stderr: truncate(stderr, this.config.maxOutputChars),
539
+ })
540
+ }
541
+ const killHard = () => { try { setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 800).unref?.() } catch {} }
542
+ const timer = setTimeout(() => {
543
+ if (settled) return
544
+ exitCode = -1
545
+ exitSignal = 'TIMEOUT'
546
+ try { child.kill() } catch {}
547
+ killHard()
548
+ finish()
549
+ }, timeoutMs)
550
+ if (opts.signal) {
551
+ if (opts.signal.aborted) {
552
+ try { child.kill() } catch {}
553
+ exitCode = -1; exitSignal = 'ABORTED'
554
+ return finish()
555
+ }
556
+ onAbort = () => {
557
+ if (settled) return
558
+ exitCode = -1; exitSignal = 'ABORTED'
559
+ try { child.kill() } catch {}
560
+ killHard()
561
+ finish()
562
+ }
563
+ opts.signal.addEventListener('abort', onAbort, { once: true })
564
+ }
565
+ child.stdout.on('data', (d) => { if (stdout.length < cap) stdout += d })
566
+ child.stderr.on('data', (d) => { if (stderr.length < cap) stderr += d })
567
+ child.on('error', (e) => {
568
+ if (settled) return
569
+ stderr += String((e && e.message) || e)
570
+ exitCode = -1
571
+ finish()
572
+ })
573
+ child.on('close', (code, sig) => {
574
+ if (settled) return
575
+ exitCode = code
576
+ exitSignal = sig || null
577
+ finish()
578
+ })
579
+ try { child.stdin.end(opts.stdin === undefined || opts.stdin === null ? undefined : String(opts.stdin)) } catch {}
580
+ })
581
+ }
582
+
583
+ /** Detect the remote platform + locate Git Bash once; cached per target. */
584
+ detect() {
585
+ if (this.platform !== 'unknown') return Promise.resolve()
586
+ if (this._detecting) return this._detecting
587
+ this._detecting = this._detect().finally(() => { this._detecting = null })
588
+ return this._detecting
589
+ }
590
+
591
+ async _detect() {
592
+ let res
593
+ try {
594
+ res = await this._runSsh(['--', this.destination(), 'cmd /c ver'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
595
+ } catch {
596
+ this.platform = 'unknown'
597
+ this.shellMode = 'native'
598
+ return
599
+ }
600
+ const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
601
+ if (res.code === 0 && /microsoft windows/i.test(out)) {
602
+ this.platform = 'windows'
603
+ await this._resolveGitBash()
604
+ return
605
+ }
606
+ try {
607
+ const u = await this._runSsh(['--', this.destination(), 'uname -s'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
608
+ if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
609
+ this.platform = 'windows'
610
+ await this._resolveGitBash()
611
+ return
612
+ }
613
+ } catch { /* inconclusive — fall through to posix */ }
614
+ this.platform = 'posix'
615
+ this.shellMode = 'native'
616
+ this.gitBashPath = ''
617
+ }
618
+
619
+ async _resolveGitBash() {
620
+ const cfg = String(this.config.shell || '').trim()
621
+ const exists = async (p) => {
622
+ const r = await this._runSsh(['--', this.destination(), `cmd /c if exist "${p}" (echo Y) else (echo N)`], { timeoutMs: 8000 })
623
+ return /Y/.test(String(r.stdout || ''))
624
+ }
625
+ if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
626
+ if (await exists(cfg)) { this.gitBashPath = cfg; this.shellMode = 'git-bash'; return }
627
+ }
628
+ if (cfg === 'native') { this.shellMode = 'native'; this.gitBashPath = ''; return }
629
+ try {
630
+ const r = await this._runSsh(['--', this.destination(), 'cmd /c where bash'], { timeoutMs: 8000 })
631
+ const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
632
+ if (first) { this.gitBashPath = first; this.shellMode = 'git-bash'; return }
633
+ } catch { /* fall through to the candidate list */ }
634
+ const candidates = [
635
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
636
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
637
+ ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
638
+ ]
639
+ for (const c of candidates) {
640
+ if (await exists(c)) { this.gitBashPath = c; this.shellMode = 'git-bash'; return }
641
+ }
642
+ this.shellMode = 'git-bash'
643
+ this.gitBashPath = ''
644
+ }
645
+
646
+ /** Run one remote command (Windows + Git Bash is wrapped exactly as native). */
647
+ exec(command, timeoutMsOrOpts) {
648
+ const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
649
+ return this.detect().then(() => {
650
+ const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
651
+ if (this.platform === 'windows' && this.gitBashPath) {
652
+ // Pipe the script to `bash -s` over stdin: no quoting round-trip, so
653
+ // quotes/backslashes/newlines survive verbatim (same as the native pool).
654
+ return this._runSsh(['--', this.destination(), `"${this.gitBashPath}" -s`], { timeoutMs, signal: opts.signal, stdin: String(command) })
655
+ }
656
+ return this._runSsh(['--', this.destination(), String(command)], { timeoutMs, signal: opts.signal })
657
+ })
658
+ }
659
+
660
+ /** Open a fresh `ssh -s <dest> sftp` session and its ssh2 SFTP engine. */
661
+ async _openSftpChannel() {
662
+ const Sftp = loadSftpEngine()
663
+ const secret = await this._resolveSecret()
664
+ const sshBinary = resolveSshBinary(this.config)
665
+ const args = [...baseSshArgs(this.config, { forSecret: !!secret }), '-s', this.destination(), 'sftp']
666
+ const child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
667
+
668
+ // ssh2's SFTP engine only reaches `_protocol` for channelData/channelClose,
669
+ // and reads `_remoteIdentRaw` to pick its packet sizes.
670
+ //
671
+ // ## Flow control, the subtle part
672
+ //
673
+ // Over real SSH the engine's send window is topped up by CHANNEL_WINDOW_ADJUST
674
+ // messages from the peer. On a raw `ssh -s sftp` pipe THERE ARE NO SUCH
675
+ // MESSAGES — the local ssh owns the SSH-layer window and the server's grants,
676
+ // and all we have is a byte pipe with OS-level backpressure. So the engine's
677
+ // window has to be mapped onto the pipe's writability:
678
+ //
679
+ // • a write the pipe accepts → more will fit;
680
+ // • the pipe fills (`stdin.write` returns false) → clamp the window so the
681
+ // engine buffers the remainder instead of queueing it all in memory;
682
+ // • the pipe drains → grant the window back and let the engine flush.
683
+ //
684
+ // The trap, and why the constants below are what they are: ssh2 arms its
685
+ // flush callback (`_waitWindow` / `_chunkcb = drainBuffer`) ONLY from inside
686
+ // its own subtraction, at the moment that subtraction takes the window to
687
+ // exactly 0. So the window must be handed over in EXACT MULTIPLES of
688
+ // packetSize and then left alone: the engine's own arithmetic then lands on 0
689
+ // during normal operation and arms the flush callback we depend on.
690
+ //
691
+ // Do NOT "help" by clamping the window to 0 from inside channelData when the
692
+ // pipe reports itself full. That sets the window to 0 AFTER the subtraction
693
+ // has already happened, so the callback is never armed and the buffered
694
+ // remainder is never flushed — the transfer deadlocks with a full window.
695
+ // (Measured: a 4 MB upload stopped dead after 32 KB, with `_buffer.length`
696
+ // stuck at 1 and no callback pending.) The pipe's own `drain` event is the
697
+ // only place the window is refilled.
698
+ //
699
+ // Also tempting-but-wrong: adding each received packet's length to the
700
+ // window. That happens to work for a DOWNLOAD (the peer's data is the
701
+ // window) and deadlocks an upload, because a write's acknowledgements are
702
+ // tiny status packets while each send consumes ~32 KB.
703
+ //
704
+ // See scripts/integration-openssh-large.mjs, which is what caught all of this.
705
+ const PACKET = 32768
706
+ const GRANT = PACKET * 8 // 256 KB per burst, and a multiple of packetSize
707
+ const chan = { sftp: null }
708
+ const fakeClient = {
709
+ _protocol: {
710
+ _remoteIdentRaw: Buffer.from('SSH-2.0-OpenSSH'),
711
+ // Hand the bytes to the pipe and nothing else. Backpressure is expressed
712
+ // through the engine's own window draining (see above); the OS pipe
713
+ // buffer plus Node's write buffer absorb the in-flight remainder.
714
+ channelData(_id, data) { try { child.stdin.write(data) } catch { /* close handles it */ } },
715
+ channelClose() { try { child.stdin.end() } catch {} },
716
+ },
717
+ }
718
+ const chanInfo = {
719
+ type: 'sftp',
720
+ incoming: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
721
+ outgoing: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
722
+ }
723
+ const sftp = new Sftp(fakeClient, chanInfo, {})
724
+ chan.sftp = sftp
725
+
726
+ // The pipe has room again: grant the window back and let the engine flush
727
+ // whatever it buffered while the pipe was full.
728
+ const onStdinDrain = () => {
729
+ if (!chan.sftp) return
730
+ chan.sftp.outgoing.window = GRANT
731
+ if (chan.sftp._waitWindow) {
732
+ chan.sftp._waitWindow = false
733
+ const cb = chan.sftp._chunkcb
734
+ chan.sftp._chunkcb = undefined
735
+ if (typeof cb === 'function') cb.call(chan.sftp)
736
+ }
737
+ }
738
+ child.stdin.on('drain', onStdinDrain)
739
+
740
+ return new Promise((resolve, reject) => {
741
+ let stderr = ''
742
+ let settled = false
743
+ const done = (err) => {
744
+ if (settled) return
745
+ settled = true
746
+ clearTimeout(timer)
747
+ if (err) { try { child.kill() } catch {} ; reject(err) } else resolve({ child, sftp })
748
+ }
749
+ const timer = setTimeout(() => {
750
+ done(new Error(`sftp session to ${this.destination()} timed out${stderr ? ': ' + stderr.trim() : ''}`))
751
+ }, this.config.commandTimeoutMs)
752
+ child.stderr.on('data', (d) => { stderr += d })
753
+ sftp.on('error', (e) => done(new Error('sftp session failed: ' + ((e && e.message) || e))))
754
+ sftp.on('ready', () => done(null))
755
+ child.on('error', (e) => done(e))
756
+ child.on('close', (code) => done(new Error(`ssh sftp exited (${code})${stderr ? ': ' + stderr.trim() : ''}`)))
757
+ // Inbound is simpler: the local ssh already did the SSH-level window
758
+ // accounting before handing us these bytes, so they just go straight in.
759
+ child.stdout.on('data', (d) => { sftp.push(d) })
760
+ child.stdout.on('end', () => { try { sftp.push(null) } catch { /* already gone */ } })
761
+ sftp._init()
762
+ })
763
+ }
764
+
765
+ /**
766
+ * A persistent SFTP session, kept across calls: the picker and the explorer
767
+ * issue one listing per interaction, and re-handshaking each time would be
768
+ * visibly slow. A session that dies is dropped so the next call reconnects.
769
+ */
770
+ sftp() {
771
+ if (this._sftpSession) return this._sftpSession
772
+ const epoch = this.epoch
773
+ const session = this._openSftpChannel().then(({ child, sftp }) => {
774
+ const drop = () => {
775
+ if (this._sftpSession === session) this._sftpSession = null
776
+ if (this._sftpChild === child) this._sftpChild = null
777
+ }
778
+ child.on('close', drop)
779
+ sftp.on('end', drop)
780
+ sftp.on('error', drop)
781
+ // A target switch during the handshake must not adopt the old host's
782
+ // session (the same generational guard connect() uses).
783
+ if (this.epoch !== epoch) {
784
+ try { child.kill() } catch {}
785
+ throw new Error('ssh target changed while opening sftp')
786
+ }
787
+ this._sftpChild = child
788
+ return buildSftpSurface(sftp, { commandTimeoutMs: this.config.commandTimeoutMs })
789
+ })
790
+ // Never cache a rejected open: the next call must retry rather than get the
791
+ // same error back forever (a failed handshake is usually transient).
792
+ session.catch(() => { if (this._sftpSession === session) this._sftpSession = null })
793
+ this._sftpSession = session
794
+ return session
795
+ }
796
+
797
+ invalidate() {
798
+ this.epoch++
799
+ const client = this.client
800
+ this.client = null
801
+ this.connecting = null
802
+ this._secret = null
803
+ this._secretPromise = null
804
+ if (client) { try { client.close() } catch {} }
805
+ this._dropSftpSession()
806
+ if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
807
+ if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
808
+ }
809
+
810
+ _dropSftpSession() {
811
+ const session = this._sftpSession
812
+ this._sftpSession = null
813
+ if (this._sftpChild) {
814
+ try { this._sftpChild.kill() } catch {}
815
+ this._sftpChild = null
816
+ }
817
+ // Swallow the rejection: nobody awaits a session we are tearing down.
818
+ if (session && typeof session.catch === 'function') session.catch(() => {})
819
+ }
820
+
821
+ close() {
822
+ this.epoch++
823
+ const client = this.client
824
+ this.client = null
825
+ this.connecting = null
826
+ this._secret = null
827
+ this._secretPromise = null
828
+ if (client) { try { client.close() } catch {} }
829
+ this._dropSftpSession()
830
+ if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
831
+ if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
832
+ }
833
+ }
834
+
835
+ /** Pick the pool class for a config: 'openssh' backend, else the native pool. */
836
+ export function isSystemOpenSshBackend(config = {}) {
837
+ return String(config.backend || '').toLowerCase() === 'openssh'
838
+ }