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/openssh.js CHANGED
@@ -1,879 +1,879 @@
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
- // An explicit known_hosts file (tests keep loopback keys OUT of the user's
212
- // real ~/.ssh/known_hosts with this; ssh ignores $HOME on some platforms).
213
- // Checking strength is unchanged — StrictHostKeyChecking below still applies.
214
- if (config.knownHostsFile) args.push('-o', `UserKnownHostsFile=${config.knownHostsFile}`)
215
- args.push(...hostKeyArgs(config))
216
- const connectSecs = Math.max(1, Math.round((Number(config.connectTimeoutMs) || 15000) / 1000))
217
- args.push('-o', `ConnectTimeout=${connectSecs}`, '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3')
218
- // Without a configured secret we must not hang on an interactive prompt:
219
- // BatchMode makes ssh fail fast instead, and leaves key/agent/GSSAPI intact.
220
- if (!forSecret) args.push('-o', 'BatchMode=yes')
221
- else args.push('-o', 'NumberOfPasswordPrompts=1')
222
- return args
223
- }
224
-
225
- /**
226
- * One live system-OpenSSH connection.
227
- *
228
- * This deliberately mimics the ssh2 Client surface the rest of the plugin
229
- * consumes — `forwardOut` plus a reverse-forward capability — so
230
- * ForwardManager/WebAttach work unchanged. It is an EventEmitter because the
231
- * native pool's client is one and callers attach listeners to it.
232
- */
233
- export class OpenSshConnection extends EventEmitter {
234
- constructor(pool, { sshBinary, secret }) {
235
- super()
236
- this.pool = pool
237
- this.sshBinary = sshBinary
238
- this.secret = secret
239
- this.closed = false
240
- /** Live child processes to reap on close. */
241
- this.children = new Set()
242
- }
243
-
244
- shortDescription() {
245
- return `${this.pool.destination()} via ${this.sshBinary}`
246
- }
247
-
248
- spawnSsh(extraArgs, { tty = false } = {}) {
249
- if (this.closed) throw new Error('ssh connection closed')
250
- const args = [
251
- ...baseSshArgs(this.pool._argConfig(), { forSecret: !!this.secret }),
252
- ...extraArgs,
253
- ]
254
- const child = spawn(this.sshBinary, args, {
255
- stdio: ['pipe', 'pipe', 'pipe'],
256
- env: this.secret ? askpassEnv(this.secret) : undefined,
257
- })
258
- this.children.add(child)
259
- const reap = () => this.children.delete(child)
260
- child.on('close', reap)
261
- child.on('error', reap)
262
- if (tty) { /* no pty support through the CLI without a tty helper */ }
263
- return child
264
- }
265
-
266
- /**
267
- * Local forward: carry bytes to <destHost>:<destPort> as seen FROM the
268
- * remote, through `ssh -W`.
269
- *
270
- * @returns {boolean} true synchronously, so capability probes can be sync.
271
- */
272
- forwardOut(_srcHost, _srcPort, destHost, destPort, cb) {
273
- let child
274
- try {
275
- child = this.spawnSsh(['-W', `${destHost}:${destPort}`, '--', this.pool.destination()])
276
- } catch (err) {
277
- setImmediate(() => cb(err))
278
- return true
279
- }
280
- // `ssh -W` reports failures on stderr and then exits; surface that as the
281
- // channel error so the caller destroys its socket instead of hanging.
282
- let stderr = ''
283
- let handed = false
284
- const channel = new Duplex({
285
- read() {},
286
- write(chunk, _enc, done) { try { child.stdin.write(chunk, done) } catch (e) { done(e) } },
287
- final(done) { try { child.stdin.end(done) } catch { done() } },
288
- destroy(err, done) { try { child.kill() } catch {} ; done(err) },
289
- })
290
- channel.close = () => { try { child.kill() } catch {} }
291
- child.stderr.on('data', (d) => { stderr += d })
292
- child.stdout.on('data', (d) => channel.push(d))
293
- child.stdout.on('end', () => channel.push(null))
294
- child.on('error', (e) => { if (!handed) { handed = true; cb(e); return } channel.destroy(e) })
295
- child.on('close', (code) => {
296
- if (handed) { channel.push(null); return }
297
- handed = true
298
- if (code === 0) { cb(null, channel); return }
299
- cb(new Error(`ssh -W to ${destHost}:${destPort} failed (exit ${code})${stderr ? ': ' + stderr.trim() : ''}`))
300
- })
301
- return true
302
- }
303
-
304
- /** Tells ForwardManager that the CLI-specific reverse path is available. */
305
- get supportsReverseForward() { return true }
306
-
307
- /**
308
- * Reverse forward: ask the REMOTE to listen on <bindHost>:<listenPort> and
309
- * carry connections to <targetHost>:<targetPort>. Unlike ssh2's two-phase
310
- * `forwardIn` + `tcpip`, the `ssh -R` CLI takes the final target as an
311
- * argument, so nothing has to be resolved lazily.
312
- *
313
- * @returns {Promise<{child: object, close: Function}>}
314
- */
315
- reverseForward({ bindHost = '127.0.0.1', listenPort, targetHost = '127.0.0.1', targetPort }) {
316
- return new Promise((resolve, reject) => {
317
- const spec = `${bindHost}:${listenPort}:${targetHost}:${targetPort}`
318
- let child
319
- try {
320
- child = this.spawnSsh(['-N', '-o', 'ExitOnForwardFailure=yes', '-R', spec, '--', this.pool.destination()])
321
- } catch (err) { return reject(err) }
322
- let stderr = ''
323
- let settled = false
324
- const fail = (err) => {
325
- if (settled) return
326
- settled = true
327
- clearTimeout(timer)
328
- try { child.kill() } catch {}
329
- reject(err)
330
- }
331
- child.stderr.on('data', (d) => {
332
- stderr += d
333
- // Nothing prints on success; an error before the grace window is the
334
- // only signal `ssh -N` gives that the forward did not take.
335
- if (/warning|error|failed|refused|denied|cannot|not permitted|unable/i.test(String(d))) {
336
- fail(new Error(`reverse forward ${spec} failed: ${stderr.trim()}`))
337
- }
338
- })
339
- child.on('error', fail)
340
- // `ssh -N` stays in the foreground; a process that outlives the grace
341
- // window means the remote listener was accepted.
342
- const timer = setTimeout(() => {
343
- if (settled) return
344
- settled = true
345
- resolve({ child, close: () => { try { child.kill() } catch {} } })
346
- }, 700)
347
- child.on('close', (code) => {
348
- if (settled) return
349
- settled = true
350
- clearTimeout(timer)
351
- reject(new Error(`reverse forward ${spec} exited early (${code})${stderr ? ': ' + stderr.trim() : ''}`))
352
- })
353
- })
354
- }
355
-
356
- /** Superseded by reverseForward() (the CLI needs the target up front). */
357
- forwardIn(_host, _port, cb) {
358
- setImmediate(() => cb(new Error(
359
- 'the system-OpenSSH backend opens reverse forwards with `ssh -R`, which needs the target up front — use reverseForward()',
360
- )))
361
- }
362
-
363
- close() {
364
- this.closed = true
365
- for (const child of [...this.children]) {
366
- try { child.kill() } catch {}
367
- }
368
- this.children.clear()
369
- }
370
- }
371
-
372
- /**
373
- * A system-OpenSSH pool: the same public surface as lib/pool.js's SshPool,
374
- * backed by the local `ssh` CLI. Callers cannot tell the two apart — see
375
- * test/openssh-backend.test.js, which drives both through one shared contract.
376
- */
377
- export class SystemOpenSshPool {
378
- constructor(config, opts = {}) {
379
- this.config = config
380
- this._knownHostsFile = opts.knownHostsFile
381
- this.client = null
382
- this.connecting = null
383
- this.proxyPool = null
384
- // Generational token, exactly as the native pool: a stale in-flight connect
385
- // must never hand this pool a connection to an old host.
386
- this.epoch = 0
387
- this.platform = 'unknown'
388
- this.gitBashPath = ''
389
- this.shellMode = 'native'
390
- this._detecting = null
391
- this.passwordResolver = null
392
- this.onReady = null
393
- this.onCloseHook = null
394
- /** The persistent `ssh -s ... sftp` session (kept across calls). */
395
- this._sftpSession = null
396
- this._sftpChild = null
397
- /** Memoized askpass secret for this target (null = not resolved yet). */
398
- this._secret = null
399
- this._secretPromise = null
400
- }
401
-
402
- /**
403
- * The config baseSshArgs should see. `opts.knownHostsFile` (a path, or a
404
- * function returning one, exactly like the native pool) used to be stored
405
- * and silently dropped — so a custom known_hosts never reached the CLI, and
406
- * tests had no way to keep loopback keys out of the user's real file.
407
- * Resolved per call so a function value is honored as late as the native
408
- * backend resolves it.
409
- */
410
- _argConfig() {
411
- const kh = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
412
- return kh ? { ...this.config, knownHostsFile: kh } : this.config
413
- }
414
-
415
- /** The destination OpenSSH should connect to (an alias passes through). */
416
- destination() {
417
- // A bare alias resolves through ~/.ssh/config inside ssh itself, so it is
418
- // handed over untouched; user/port ride on `-l`/`-p` when recorded.
419
- return String(this.config.host || '')
420
- }
421
-
422
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
423
- const base = previous || this.config
424
- const wanted = []
425
- if (host !== undefined) wanted.push(['host', String(host)])
426
- if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
427
- if (username !== undefined) wanted.push(['username', String(username)])
428
- if (password !== undefined && password !== null) wanted.push(['password', String(password)])
429
- if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
430
- if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
431
- if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
432
- if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
433
- if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
434
- if (proxy !== undefined) wanted.push(['proxy', proxy])
435
- if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
436
- // Issue #51: kept in step with the native pool's setTarget — the two
437
- // backends share one contract (see test/issue51-openssh-backend.test.js), so
438
- // a field one of them diffs must not be silently ignored by the other.
439
- if (backend !== undefined) wanted.push(['backend', String(backend)])
440
- if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
441
- const differs = ([k, v]) => (k === 'proxy'
442
- ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
443
- : base[k] !== v)
444
- if (wanted.some(differs)) {
445
- for (const [k, v] of wanted) this.config[k] = v
446
- this.platform = 'unknown'
447
- this.gitBashPath = ''
448
- this.shellMode = 'native'
449
- this._detecting = null
450
- this.close()
451
- }
452
- return this
453
- }
454
-
455
- /**
456
- * The secret to offer askpass, resolved once per target.
457
- *
458
- * `connect()` no longer has to run first: `exec`/`sftp` call this themselves,
459
- * so a pool that is only ever used for file listing still authenticates.
460
- */
461
- _resolveSecret() {
462
- if (this._secret !== null) return Promise.resolve(this._secret)
463
- if (this._secretPromise) return this._secretPromise
464
- const epoch = this.epoch
465
- this._secretPromise = (async () => {
466
- let secret = this.config.password || ''
467
- if (!secret && this.passwordResolver) {
468
- try { secret = (await this.passwordResolver()) || '' } catch { /* fall through */ }
469
- }
470
- if (!secret) secret = this.config.passphrase || ''
471
- // A target switch invalidates the answer; leave the memo for the new one.
472
- if (this.epoch === epoch) this._secret = String(secret || '')
473
- this._secretPromise = null
474
- return String(secret || '')
475
- })()
476
- return this._secretPromise
477
- }
478
-
479
- /** Validate the environment; there is no socket to hold open. */
480
- connect() {
481
- if (this.client) return Promise.resolve(this.client)
482
- if (this.connecting) return this.connecting
483
- const epoch = this.epoch
484
- const pending = this._doConnect(epoch)
485
- this.connecting = pending
486
- const clear = () => { if (this.epoch === epoch && this.connecting === pending) this.connecting = null }
487
- pending.then(clear, clear)
488
- return pending
489
- }
490
-
491
- async _doConnect(epoch) {
492
- const isCurrent = () => this.epoch === epoch
493
- const sshBinary = resolveSshBinary(this.config)
494
- if (!this.config.host) throw new Error('no host configured')
495
- if (!sshBinaryAvailable(sshBinary)) {
496
- throw new Error(
497
- `the system-OpenSSH backend needs a \`${sshBinary}\` executable on PATH`
498
- + ' — install OpenSSH, or set the backend back to "native"',
499
- )
500
- }
501
- const secret = await this._resolveSecret()
502
- if (!isCurrent()) throw new Error('ssh target changed during connect')
503
- // Native semantics are "connect() throws when the SSH connection fails", and
504
- // a probe also gives the settings page a real latency number. The CLI has no
505
- // socket to keep, so this is the only point where the target is proven.
506
- const probe = await this._runSsh(['--', this.destination(), 'true'], { timeoutMs: this.config.connectTimeoutMs || 15000 })
507
- if (!isCurrent()) throw new Error('ssh target changed during connect')
508
- if (probe.code !== 0) {
509
- throw new Error(`ssh failed (exit ${probe.code})${String(probe.stderr || '').trim() ? ': ' + String(probe.stderr).trim() : ''}`)
510
- }
511
- void secret
512
- const conn = new OpenSshConnection(this, { sshBinary, secret })
513
- this.client = conn
514
- if (this.onReady) { try { this.onReady(conn) } catch {} }
515
- return conn
516
- }
517
-
518
- /**
519
- * Run one `ssh` invocation and collect its result.
520
- *
521
- * @param {string[]} extraArgs - arguments appended after the shared base.
522
- * @param {object} [opts] - `timeoutMs`, `signal` (AbortSignal), `stdin`,
523
- * `onData(channel, chunk)` — a streaming tap the native backend has had
524
- * since issue #70. A background job's value is its LIVE output, which the
525
- * job ring must receive as it arrives; a backend that only returns the
526
- * accumulated text loses every byte the caller stopped waiting for
527
- * (issue #80). Tap errors are not command errors, so the call is guarded.
528
- * @returns {Promise<{code: number|null, signal: string|null, stdout: string, stderr: string}>}
529
- */
530
- async _runSsh(extraArgs, opts = {}) {
531
- if (!this.config.host) throw new Error('no host configured')
532
- const secret = await this._resolveSecret()
533
- const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
534
- const sshBinary = resolveSshBinary(this.config)
535
- const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), ...extraArgs]
536
- return new Promise((resolve) => {
537
- let child
538
- try {
539
- child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
540
- } catch (err) {
541
- return resolve({ code: -1, signal: 'SPAWN_FAILED', stdout: '', stderr: String((err && err.message) || err) })
542
- }
543
- let stdout = ''
544
- let stderr = ''
545
- let settled = false
546
- let exitCode = null
547
- let exitSignal = null
548
- const cap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
549
- const onData = typeof opts.onData === 'function' ? opts.onData : null
550
- const cleanupAbort = () => { if (opts.signal && onAbort) opts.signal.removeEventListener('abort', onAbort) }
551
- let onAbort = null
552
- const finish = () => {
553
- if (settled) return
554
- settled = true
555
- clearTimeout(timer)
556
- cleanupAbort()
557
- resolve({
558
- code: exitCode,
559
- signal: exitSignal,
560
- stdout: truncate(stdout, this.config.maxOutputChars),
561
- stderr: truncate(stderr, this.config.maxOutputChars),
562
- })
563
- }
564
- const killHard = () => { try { setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 800).unref?.() } catch {} }
565
- const timer = setTimeout(() => {
566
- if (settled) return
567
- exitCode = -1
568
- exitSignal = 'TIMEOUT'
569
- try { child.kill() } catch {}
570
- killHard()
571
- finish()
572
- }, timeoutMs)
573
- if (opts.signal) {
574
- if (opts.signal.aborted) {
575
- try { child.kill() } catch {}
576
- exitCode = -1; exitSignal = 'ABORTED'
577
- return finish()
578
- }
579
- onAbort = () => {
580
- if (settled) return
581
- exitCode = -1; exitSignal = 'ABORTED'
582
- try { child.kill() } catch {}
583
- killHard()
584
- finish()
585
- }
586
- opts.signal.addEventListener('abort', onAbort, { once: true })
587
- }
588
- // The tap fires even past the local cap, exactly like the native
589
- // backend's: a promoted job's ring has its own bounds and must keep
590
- // receiving output the truncated return value no longer holds.
591
- child.stdout.on('data', (d) => {
592
- if (stdout.length < cap) stdout += d
593
- if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
594
- })
595
- child.stderr.on('data', (d) => {
596
- if (stderr.length < cap) stderr += d
597
- if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
598
- })
599
- child.on('error', (e) => {
600
- if (settled) return
601
- stderr += String((e && e.message) || e)
602
- exitCode = -1
603
- finish()
604
- })
605
- child.on('close', (code, sig) => {
606
- if (settled) return
607
- exitCode = code
608
- exitSignal = sig || null
609
- finish()
610
- })
611
- try { child.stdin.end(opts.stdin === undefined || opts.stdin === null ? undefined : String(opts.stdin)) } catch {}
612
- })
613
- }
614
-
615
- /** Detect the remote platform + locate Git Bash once; cached per target. */
616
- detect() {
617
- if (this.platform !== 'unknown') return Promise.resolve()
618
- if (this._detecting) return this._detecting
619
- this._detecting = this._detect().finally(() => { this._detecting = null })
620
- return this._detecting
621
- }
622
-
623
- async _detect() {
624
- let res
625
- try {
626
- res = await this._runSsh(['--', this.destination(), 'cmd /c ver'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
627
- } catch {
628
- this.platform = 'unknown'
629
- this.shellMode = 'native'
630
- return
631
- }
632
- const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
633
- if (res.code === 0 && /microsoft windows/i.test(out)) {
634
- this.platform = 'windows'
635
- await this._resolveGitBash()
636
- return
637
- }
638
- try {
639
- const u = await this._runSsh(['--', this.destination(), 'uname -s'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
640
- if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
641
- this.platform = 'windows'
642
- await this._resolveGitBash()
643
- return
644
- }
645
- } catch { /* inconclusive — fall through to posix */ }
646
- this.platform = 'posix'
647
- this.shellMode = 'native'
648
- this.gitBashPath = ''
649
- }
650
-
651
- async _resolveGitBash() {
652
- const cfg = String(this.config.shell || '').trim()
653
- const exists = async (p) => {
654
- const r = await this._runSsh(['--', this.destination(), `cmd /c if exist "${p}" (echo Y) else (echo N)`], { timeoutMs: 8000 })
655
- return /Y/.test(String(r.stdout || ''))
656
- }
657
- if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
658
- if (await exists(cfg)) { this.gitBashPath = cfg; this.shellMode = 'git-bash'; return }
659
- }
660
- if (cfg === 'native') { this.shellMode = 'native'; this.gitBashPath = ''; return }
661
- try {
662
- const r = await this._runSsh(['--', this.destination(), 'cmd /c where bash'], { timeoutMs: 8000 })
663
- const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
664
- if (first) { this.gitBashPath = first; this.shellMode = 'git-bash'; return }
665
- } catch { /* fall through to the candidate list */ }
666
- const candidates = [
667
- 'C:\\Program Files\\Git\\bin\\bash.exe',
668
- 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
669
- ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
670
- ]
671
- for (const c of candidates) {
672
- if (await exists(c)) { this.gitBashPath = c; this.shellMode = 'git-bash'; return }
673
- }
674
- this.shellMode = 'git-bash'
675
- this.gitBashPath = ''
676
- }
677
-
678
- /** Run one remote command (Windows + Git Bash is wrapped exactly as native). */
679
- exec(command, timeoutMsOrOpts) {
680
- const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
681
- return this.detect().then(() => {
682
- const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
683
- if (this.platform === 'windows' && this.gitBashPath) {
684
- // Pipe the script to `bash -s` over stdin: no quoting round-trip, so
685
- // quotes/backslashes/newlines survive verbatim (same as the native pool).
686
- return this._runSsh(['--', this.destination(), `"${this.gitBashPath}" -s`], { timeoutMs, signal: opts.signal, stdin: String(command), onData: opts.onData })
687
- }
688
- return this._runSsh(['--', this.destination(), String(command)], { timeoutMs, signal: opts.signal, onData: opts.onData })
689
- })
690
- }
691
-
692
- /** Open a fresh `ssh -s <dest> sftp` session and its ssh2 SFTP engine. */
693
- async _openSftpChannel() {
694
- const Sftp = loadSftpEngine()
695
- const secret = await this._resolveSecret()
696
- const sshBinary = resolveSshBinary(this.config)
697
- const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), '-s', this.destination(), 'sftp']
698
- const child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
699
-
700
- // ssh2's SFTP engine only reaches `_protocol` for channelData/channelClose,
701
- // and reads `_remoteIdentRaw` to pick its packet sizes.
702
- //
703
- // ## Flow control, the subtle part
704
- //
705
- // Over real SSH the engine's send window is topped up by CHANNEL_WINDOW_ADJUST
706
- // messages from the peer. On a raw `ssh -s sftp` pipe THERE ARE NO SUCH
707
- // MESSAGES — the local ssh owns the SSH-layer window and the server's grants,
708
- // and all we have is a byte pipe with OS-level backpressure. So the engine's
709
- // window has to be mapped onto the pipe's writability:
710
- //
711
- // • a write the pipe accepts → more will fit;
712
- // • the pipe fills (`stdin.write` returns false) → clamp the window so the
713
- // engine buffers the remainder instead of queueing it all in memory;
714
- // • the pipe drains → grant the window back and let the engine flush.
715
- //
716
- // The trap, and why the constants below are what they are: ssh2 arms its
717
- // flush callback (`_waitWindow` / `_chunkcb = drainBuffer`) ONLY from inside
718
- // its own subtraction, at the moment that subtraction takes the window to
719
- // exactly 0. So the window must be handed over in EXACT MULTIPLES of
720
- // packetSize and then left alone: the engine's own arithmetic then lands on 0
721
- // during normal operation and arms the flush callback we depend on.
722
- //
723
- // Do NOT "help" by clamping the window to 0 from inside channelData when the
724
- // pipe reports itself full. That sets the window to 0 AFTER the subtraction
725
- // has already happened, so the callback is never armed and the buffered
726
- // remainder is never flushed — the transfer deadlocks with a full window.
727
- // (Measured: a 4 MB upload stopped dead after 32 KB, with `_buffer.length`
728
- // stuck at 1 and no callback pending.) The pipe's own `drain` event is the
729
- // only place the window is refilled.
730
- //
731
- // Also tempting-but-wrong: adding each received packet's length to the
732
- // window. That happens to work for a DOWNLOAD (the peer's data is the
733
- // window) and deadlocks an upload, because a write's acknowledgements are
734
- // tiny status packets while each send consumes ~32 KB.
735
- //
736
- // See scripts/integration-openssh-large.mjs, which is what caught all of this.
737
- const PACKET = 32768
738
- const GRANT = PACKET * 8 // 256 KB per burst, and a multiple of packetSize
739
- const chan = { sftp: null }
740
- const fakeClient = {
741
- _protocol: {
742
- _remoteIdentRaw: Buffer.from('SSH-2.0-OpenSSH'),
743
- // Hand the bytes to the pipe and nothing else. Backpressure is expressed
744
- // through the engine's own window draining (see above); the OS pipe
745
- // buffer plus Node's write buffer absorb the in-flight remainder.
746
- channelData(_id, data) { try { child.stdin.write(data) } catch { /* close handles it */ } },
747
- channelClose() { try { child.stdin.end() } catch {} },
748
- },
749
- }
750
- const chanInfo = {
751
- type: 'sftp',
752
- incoming: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
753
- outgoing: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
754
- }
755
- const sftp = new Sftp(fakeClient, chanInfo, {})
756
- chan.sftp = sftp
757
-
758
- // The pipe has room again: grant the window back and let the engine flush
759
- // whatever it buffered while the pipe was full.
760
- const onStdinDrain = () => {
761
- if (!chan.sftp) return
762
- chan.sftp.outgoing.window = GRANT
763
- if (chan.sftp._waitWindow) {
764
- chan.sftp._waitWindow = false
765
- const cb = chan.sftp._chunkcb
766
- chan.sftp._chunkcb = undefined
767
- if (typeof cb === 'function') cb.call(chan.sftp)
768
- }
769
- }
770
- child.stdin.on('drain', onStdinDrain)
771
-
772
- return new Promise((resolve, reject) => {
773
- let stderr = ''
774
- let settled = false
775
- const done = (err) => {
776
- if (settled) return
777
- settled = true
778
- clearTimeout(timer)
779
- if (err) { try { child.kill() } catch {} ; reject(err) } else resolve({ child, sftp })
780
- }
781
- const timer = setTimeout(() => {
782
- done(new Error(`sftp session to ${this.destination()} timed out${stderr ? ': ' + stderr.trim() : ''}`))
783
- }, this.config.commandTimeoutMs)
784
- child.stderr.on('data', (d) => { stderr += d })
785
- sftp.on('error', (e) => done(new Error('sftp session failed: ' + ((e && e.message) || e))))
786
- sftp.on('ready', () => done(null))
787
- child.on('error', (e) => done(e))
788
- child.on('close', (code) => done(new Error(`ssh sftp exited (${code})${stderr ? ': ' + stderr.trim() : ''}`)))
789
- // Inbound is simpler: the local ssh already did the SSH-level window
790
- // accounting before handing us these bytes, so they just go straight in.
791
- child.stdout.on('data', (d) => { sftp.push(d) })
792
- child.stdout.on('end', () => { try { sftp.push(null) } catch { /* already gone */ } })
793
- sftp._init()
794
- })
795
- }
796
-
797
- /**
798
- * A persistent SFTP session, kept across calls: the picker and the explorer
799
- * issue one listing per interaction, and re-handshaking each time would be
800
- * visibly slow. A session that dies is dropped so the next call reconnects.
801
- */
802
- sftp() {
803
- if (this._sftpSession) return this._sftpSession
804
- const epoch = this.epoch
805
- const session = this._openSftpChannel().then(({ child, sftp }) => {
806
- const drop = () => {
807
- if (this._sftpSession === session) this._sftpSession = null
808
- if (this._sftpChild === child) this._sftpChild = null
809
- }
810
- child.on('close', drop)
811
- sftp.on('end', drop)
812
- sftp.on('error', drop)
813
- // A target switch during the handshake must not adopt the old host's
814
- // session (the same generational guard connect() uses).
815
- if (this.epoch !== epoch) {
816
- try { child.kill() } catch {}
817
- throw new Error('ssh target changed while opening sftp')
818
- }
819
- this._sftpChild = child
820
- return buildSftpSurface(sftp, {
821
- commandTimeoutMs: this.config.commandTimeoutMs,
822
- onTimeout: () => {
823
- // A timed-out SFTP session is dead: kill the child and drop the cached
824
- // session so the next call re-handshakes instead of queueing behind the
825
- // wedged one (issue #65).
826
- try { child.kill() } catch { /* already gone */ }
827
- drop()
828
- },
829
- })
830
- })
831
- // Never cache a rejected open: the next call must retry rather than get the
832
- // same error back forever (a failed handshake is usually transient).
833
- session.catch(() => { if (this._sftpSession === session) this._sftpSession = null })
834
- this._sftpSession = session
835
- return session
836
- }
837
-
838
- invalidate() {
839
- this.epoch++
840
- const client = this.client
841
- this.client = null
842
- this.connecting = null
843
- this._secret = null
844
- this._secretPromise = null
845
- if (client) { try { client.close() } catch {} }
846
- this._dropSftpSession()
847
- if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
848
- if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
849
- }
850
-
851
- _dropSftpSession() {
852
- const session = this._sftpSession
853
- this._sftpSession = null
854
- if (this._sftpChild) {
855
- try { this._sftpChild.kill() } catch {}
856
- this._sftpChild = null
857
- }
858
- // Swallow the rejection: nobody awaits a session we are tearing down.
859
- if (session && typeof session.catch === 'function') session.catch(() => {})
860
- }
861
-
862
- close() {
863
- this.epoch++
864
- const client = this.client
865
- this.client = null
866
- this.connecting = null
867
- this._secret = null
868
- this._secretPromise = null
869
- if (client) { try { client.close() } catch {} }
870
- this._dropSftpSession()
871
- if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
872
- if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
873
- }
874
- }
875
-
876
- /** Pick the pool class for a config: 'openssh' backend, else the native pool. */
877
- export function isSystemOpenSshBackend(config = {}) {
878
- return String(config.backend || '').toLowerCase() === 'openssh'
879
- }
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
+ // An explicit known_hosts file (tests keep loopback keys OUT of the user's
212
+ // real ~/.ssh/known_hosts with this; ssh ignores $HOME on some platforms).
213
+ // Checking strength is unchanged — StrictHostKeyChecking below still applies.
214
+ if (config.knownHostsFile) args.push('-o', `UserKnownHostsFile=${config.knownHostsFile}`)
215
+ args.push(...hostKeyArgs(config))
216
+ const connectSecs = Math.max(1, Math.round((Number(config.connectTimeoutMs) || 15000) / 1000))
217
+ args.push('-o', `ConnectTimeout=${connectSecs}`, '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3')
218
+ // Without a configured secret we must not hang on an interactive prompt:
219
+ // BatchMode makes ssh fail fast instead, and leaves key/agent/GSSAPI intact.
220
+ if (!forSecret) args.push('-o', 'BatchMode=yes')
221
+ else args.push('-o', 'NumberOfPasswordPrompts=1')
222
+ return args
223
+ }
224
+
225
+ /**
226
+ * One live system-OpenSSH connection.
227
+ *
228
+ * This deliberately mimics the ssh2 Client surface the rest of the plugin
229
+ * consumes — `forwardOut` plus a reverse-forward capability — so
230
+ * ForwardManager/WebAttach work unchanged. It is an EventEmitter because the
231
+ * native pool's client is one and callers attach listeners to it.
232
+ */
233
+ export class OpenSshConnection extends EventEmitter {
234
+ constructor(pool, { sshBinary, secret }) {
235
+ super()
236
+ this.pool = pool
237
+ this.sshBinary = sshBinary
238
+ this.secret = secret
239
+ this.closed = false
240
+ /** Live child processes to reap on close. */
241
+ this.children = new Set()
242
+ }
243
+
244
+ shortDescription() {
245
+ return `${this.pool.destination()} via ${this.sshBinary}`
246
+ }
247
+
248
+ spawnSsh(extraArgs, { tty = false } = {}) {
249
+ if (this.closed) throw new Error('ssh connection closed')
250
+ const args = [
251
+ ...baseSshArgs(this.pool._argConfig(), { forSecret: !!this.secret }),
252
+ ...extraArgs,
253
+ ]
254
+ const child = spawn(this.sshBinary, args, {
255
+ stdio: ['pipe', 'pipe', 'pipe'],
256
+ env: this.secret ? askpassEnv(this.secret) : undefined,
257
+ })
258
+ this.children.add(child)
259
+ const reap = () => this.children.delete(child)
260
+ child.on('close', reap)
261
+ child.on('error', reap)
262
+ if (tty) { /* no pty support through the CLI without a tty helper */ }
263
+ return child
264
+ }
265
+
266
+ /**
267
+ * Local forward: carry bytes to <destHost>:<destPort> as seen FROM the
268
+ * remote, through `ssh -W`.
269
+ *
270
+ * @returns {boolean} true synchronously, so capability probes can be sync.
271
+ */
272
+ forwardOut(_srcHost, _srcPort, destHost, destPort, cb) {
273
+ let child
274
+ try {
275
+ child = this.spawnSsh(['-W', `${destHost}:${destPort}`, '--', this.pool.destination()])
276
+ } catch (err) {
277
+ setImmediate(() => cb(err))
278
+ return true
279
+ }
280
+ // `ssh -W` reports failures on stderr and then exits; surface that as the
281
+ // channel error so the caller destroys its socket instead of hanging.
282
+ let stderr = ''
283
+ let handed = false
284
+ const channel = new Duplex({
285
+ read() {},
286
+ write(chunk, _enc, done) { try { child.stdin.write(chunk, done) } catch (e) { done(e) } },
287
+ final(done) { try { child.stdin.end(done) } catch { done() } },
288
+ destroy(err, done) { try { child.kill() } catch {} ; done(err) },
289
+ })
290
+ channel.close = () => { try { child.kill() } catch {} }
291
+ child.stderr.on('data', (d) => { stderr += d })
292
+ child.stdout.on('data', (d) => channel.push(d))
293
+ child.stdout.on('end', () => channel.push(null))
294
+ child.on('error', (e) => { if (!handed) { handed = true; cb(e); return } channel.destroy(e) })
295
+ child.on('close', (code) => {
296
+ if (handed) { channel.push(null); return }
297
+ handed = true
298
+ if (code === 0) { cb(null, channel); return }
299
+ cb(new Error(`ssh -W to ${destHost}:${destPort} failed (exit ${code})${stderr ? ': ' + stderr.trim() : ''}`))
300
+ })
301
+ return true
302
+ }
303
+
304
+ /** Tells ForwardManager that the CLI-specific reverse path is available. */
305
+ get supportsReverseForward() { return true }
306
+
307
+ /**
308
+ * Reverse forward: ask the REMOTE to listen on <bindHost>:<listenPort> and
309
+ * carry connections to <targetHost>:<targetPort>. Unlike ssh2's two-phase
310
+ * `forwardIn` + `tcpip`, the `ssh -R` CLI takes the final target as an
311
+ * argument, so nothing has to be resolved lazily.
312
+ *
313
+ * @returns {Promise<{child: object, close: Function}>}
314
+ */
315
+ reverseForward({ bindHost = '127.0.0.1', listenPort, targetHost = '127.0.0.1', targetPort }) {
316
+ return new Promise((resolve, reject) => {
317
+ const spec = `${bindHost}:${listenPort}:${targetHost}:${targetPort}`
318
+ let child
319
+ try {
320
+ child = this.spawnSsh(['-N', '-o', 'ExitOnForwardFailure=yes', '-R', spec, '--', this.pool.destination()])
321
+ } catch (err) { return reject(err) }
322
+ let stderr = ''
323
+ let settled = false
324
+ const fail = (err) => {
325
+ if (settled) return
326
+ settled = true
327
+ clearTimeout(timer)
328
+ try { child.kill() } catch {}
329
+ reject(err)
330
+ }
331
+ child.stderr.on('data', (d) => {
332
+ stderr += d
333
+ // Nothing prints on success; an error before the grace window is the
334
+ // only signal `ssh -N` gives that the forward did not take.
335
+ if (/warning|error|failed|refused|denied|cannot|not permitted|unable/i.test(String(d))) {
336
+ fail(new Error(`reverse forward ${spec} failed: ${stderr.trim()}`))
337
+ }
338
+ })
339
+ child.on('error', fail)
340
+ // `ssh -N` stays in the foreground; a process that outlives the grace
341
+ // window means the remote listener was accepted.
342
+ const timer = setTimeout(() => {
343
+ if (settled) return
344
+ settled = true
345
+ resolve({ child, close: () => { try { child.kill() } catch {} } })
346
+ }, 700)
347
+ child.on('close', (code) => {
348
+ if (settled) return
349
+ settled = true
350
+ clearTimeout(timer)
351
+ reject(new Error(`reverse forward ${spec} exited early (${code})${stderr ? ': ' + stderr.trim() : ''}`))
352
+ })
353
+ })
354
+ }
355
+
356
+ /** Superseded by reverseForward() (the CLI needs the target up front). */
357
+ forwardIn(_host, _port, cb) {
358
+ setImmediate(() => cb(new Error(
359
+ 'the system-OpenSSH backend opens reverse forwards with `ssh -R`, which needs the target up front — use reverseForward()',
360
+ )))
361
+ }
362
+
363
+ close() {
364
+ this.closed = true
365
+ for (const child of [...this.children]) {
366
+ try { child.kill() } catch {}
367
+ }
368
+ this.children.clear()
369
+ }
370
+ }
371
+
372
+ /**
373
+ * A system-OpenSSH pool: the same public surface as lib/pool.js's SshPool,
374
+ * backed by the local `ssh` CLI. Callers cannot tell the two apart — see
375
+ * test/openssh-backend.test.js, which drives both through one shared contract.
376
+ */
377
+ export class SystemOpenSshPool {
378
+ constructor(config, opts = {}) {
379
+ this.config = config
380
+ this._knownHostsFile = opts.knownHostsFile
381
+ this.client = null
382
+ this.connecting = null
383
+ this.proxyPool = null
384
+ // Generational token, exactly as the native pool: a stale in-flight connect
385
+ // must never hand this pool a connection to an old host.
386
+ this.epoch = 0
387
+ this.platform = 'unknown'
388
+ this.gitBashPath = ''
389
+ this.shellMode = 'native'
390
+ this._detecting = null
391
+ this.passwordResolver = null
392
+ this.onReady = null
393
+ this.onCloseHook = null
394
+ /** The persistent `ssh -s ... sftp` session (kept across calls). */
395
+ this._sftpSession = null
396
+ this._sftpChild = null
397
+ /** Memoized askpass secret for this target (null = not resolved yet). */
398
+ this._secret = null
399
+ this._secretPromise = null
400
+ }
401
+
402
+ /**
403
+ * The config baseSshArgs should see. `opts.knownHostsFile` (a path, or a
404
+ * function returning one, exactly like the native pool) used to be stored
405
+ * and silently dropped — so a custom known_hosts never reached the CLI, and
406
+ * tests had no way to keep loopback keys out of the user's real file.
407
+ * Resolved per call so a function value is honored as late as the native
408
+ * backend resolves it.
409
+ */
410
+ _argConfig() {
411
+ const kh = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
412
+ return kh ? { ...this.config, knownHostsFile: kh } : this.config
413
+ }
414
+
415
+ /** The destination OpenSSH should connect to (an alias passes through). */
416
+ destination() {
417
+ // A bare alias resolves through ~/.ssh/config inside ssh itself, so it is
418
+ // handed over untouched; user/port ride on `-l`/`-p` when recorded.
419
+ return String(this.config.host || '')
420
+ }
421
+
422
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
423
+ const base = previous || this.config
424
+ const wanted = []
425
+ if (host !== undefined) wanted.push(['host', String(host)])
426
+ if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
427
+ if (username !== undefined) wanted.push(['username', String(username)])
428
+ if (password !== undefined && password !== null) wanted.push(['password', String(password)])
429
+ if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
430
+ if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
431
+ if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
432
+ if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
433
+ if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
434
+ if (proxy !== undefined) wanted.push(['proxy', proxy])
435
+ if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
436
+ // Issue #51: kept in step with the native pool's setTarget — the two
437
+ // backends share one contract (see test/issue51-openssh-backend.test.js), so
438
+ // a field one of them diffs must not be silently ignored by the other.
439
+ if (backend !== undefined) wanted.push(['backend', String(backend)])
440
+ if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
441
+ const differs = ([k, v]) => (k === 'proxy'
442
+ ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
443
+ : base[k] !== v)
444
+ if (wanted.some(differs)) {
445
+ for (const [k, v] of wanted) this.config[k] = v
446
+ this.platform = 'unknown'
447
+ this.gitBashPath = ''
448
+ this.shellMode = 'native'
449
+ this._detecting = null
450
+ this.close()
451
+ }
452
+ return this
453
+ }
454
+
455
+ /**
456
+ * The secret to offer askpass, resolved once per target.
457
+ *
458
+ * `connect()` no longer has to run first: `exec`/`sftp` call this themselves,
459
+ * so a pool that is only ever used for file listing still authenticates.
460
+ */
461
+ _resolveSecret() {
462
+ if (this._secret !== null) return Promise.resolve(this._secret)
463
+ if (this._secretPromise) return this._secretPromise
464
+ const epoch = this.epoch
465
+ this._secretPromise = (async () => {
466
+ let secret = this.config.password || ''
467
+ if (!secret && this.passwordResolver) {
468
+ try { secret = (await this.passwordResolver()) || '' } catch { /* fall through */ }
469
+ }
470
+ if (!secret) secret = this.config.passphrase || ''
471
+ // A target switch invalidates the answer; leave the memo for the new one.
472
+ if (this.epoch === epoch) this._secret = String(secret || '')
473
+ this._secretPromise = null
474
+ return String(secret || '')
475
+ })()
476
+ return this._secretPromise
477
+ }
478
+
479
+ /** Validate the environment; there is no socket to hold open. */
480
+ connect() {
481
+ if (this.client) return Promise.resolve(this.client)
482
+ if (this.connecting) return this.connecting
483
+ const epoch = this.epoch
484
+ const pending = this._doConnect(epoch)
485
+ this.connecting = pending
486
+ const clear = () => { if (this.epoch === epoch && this.connecting === pending) this.connecting = null }
487
+ pending.then(clear, clear)
488
+ return pending
489
+ }
490
+
491
+ async _doConnect(epoch) {
492
+ const isCurrent = () => this.epoch === epoch
493
+ const sshBinary = resolveSshBinary(this.config)
494
+ if (!this.config.host) throw new Error('no host configured')
495
+ if (!sshBinaryAvailable(sshBinary)) {
496
+ throw new Error(
497
+ `the system-OpenSSH backend needs a \`${sshBinary}\` executable on PATH`
498
+ + ' — install OpenSSH, or set the backend back to "native"',
499
+ )
500
+ }
501
+ const secret = await this._resolveSecret()
502
+ if (!isCurrent()) throw new Error('ssh target changed during connect')
503
+ // Native semantics are "connect() throws when the SSH connection fails", and
504
+ // a probe also gives the settings page a real latency number. The CLI has no
505
+ // socket to keep, so this is the only point where the target is proven.
506
+ const probe = await this._runSsh(['--', this.destination(), 'true'], { timeoutMs: this.config.connectTimeoutMs || 15000 })
507
+ if (!isCurrent()) throw new Error('ssh target changed during connect')
508
+ if (probe.code !== 0) {
509
+ throw new Error(`ssh failed (exit ${probe.code})${String(probe.stderr || '').trim() ? ': ' + String(probe.stderr).trim() : ''}`)
510
+ }
511
+ void secret
512
+ const conn = new OpenSshConnection(this, { sshBinary, secret })
513
+ this.client = conn
514
+ if (this.onReady) { try { this.onReady(conn) } catch {} }
515
+ return conn
516
+ }
517
+
518
+ /**
519
+ * Run one `ssh` invocation and collect its result.
520
+ *
521
+ * @param {string[]} extraArgs - arguments appended after the shared base.
522
+ * @param {object} [opts] - `timeoutMs`, `signal` (AbortSignal), `stdin`,
523
+ * `onData(channel, chunk)` — a streaming tap the native backend has had
524
+ * since issue #70. A background job's value is its LIVE output, which the
525
+ * job ring must receive as it arrives; a backend that only returns the
526
+ * accumulated text loses every byte the caller stopped waiting for
527
+ * (issue #80). Tap errors are not command errors, so the call is guarded.
528
+ * @returns {Promise<{code: number|null, signal: string|null, stdout: string, stderr: string}>}
529
+ */
530
+ async _runSsh(extraArgs, opts = {}) {
531
+ if (!this.config.host) throw new Error('no host configured')
532
+ const secret = await this._resolveSecret()
533
+ const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
534
+ const sshBinary = resolveSshBinary(this.config)
535
+ const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), ...extraArgs]
536
+ return new Promise((resolve) => {
537
+ let child
538
+ try {
539
+ child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
540
+ } catch (err) {
541
+ return resolve({ code: -1, signal: 'SPAWN_FAILED', stdout: '', stderr: String((err && err.message) || err) })
542
+ }
543
+ let stdout = ''
544
+ let stderr = ''
545
+ let settled = false
546
+ let exitCode = null
547
+ let exitSignal = null
548
+ const cap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
549
+ const onData = typeof opts.onData === 'function' ? opts.onData : null
550
+ const cleanupAbort = () => { if (opts.signal && onAbort) opts.signal.removeEventListener('abort', onAbort) }
551
+ let onAbort = null
552
+ const finish = () => {
553
+ if (settled) return
554
+ settled = true
555
+ clearTimeout(timer)
556
+ cleanupAbort()
557
+ resolve({
558
+ code: exitCode,
559
+ signal: exitSignal,
560
+ stdout: truncate(stdout, this.config.maxOutputChars),
561
+ stderr: truncate(stderr, this.config.maxOutputChars),
562
+ })
563
+ }
564
+ const killHard = () => { try { setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 800).unref?.() } catch {} }
565
+ const timer = setTimeout(() => {
566
+ if (settled) return
567
+ exitCode = -1
568
+ exitSignal = 'TIMEOUT'
569
+ try { child.kill() } catch {}
570
+ killHard()
571
+ finish()
572
+ }, timeoutMs)
573
+ if (opts.signal) {
574
+ if (opts.signal.aborted) {
575
+ try { child.kill() } catch {}
576
+ exitCode = -1; exitSignal = 'ABORTED'
577
+ return finish()
578
+ }
579
+ onAbort = () => {
580
+ if (settled) return
581
+ exitCode = -1; exitSignal = 'ABORTED'
582
+ try { child.kill() } catch {}
583
+ killHard()
584
+ finish()
585
+ }
586
+ opts.signal.addEventListener('abort', onAbort, { once: true })
587
+ }
588
+ // The tap fires even past the local cap, exactly like the native
589
+ // backend's: a promoted job's ring has its own bounds and must keep
590
+ // receiving output the truncated return value no longer holds.
591
+ child.stdout.on('data', (d) => {
592
+ if (stdout.length < cap) stdout += d
593
+ if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
594
+ })
595
+ child.stderr.on('data', (d) => {
596
+ if (stderr.length < cap) stderr += d
597
+ if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
598
+ })
599
+ child.on('error', (e) => {
600
+ if (settled) return
601
+ stderr += String((e && e.message) || e)
602
+ exitCode = -1
603
+ finish()
604
+ })
605
+ child.on('close', (code, sig) => {
606
+ if (settled) return
607
+ exitCode = code
608
+ exitSignal = sig || null
609
+ finish()
610
+ })
611
+ try { child.stdin.end(opts.stdin === undefined || opts.stdin === null ? undefined : String(opts.stdin)) } catch {}
612
+ })
613
+ }
614
+
615
+ /** Detect the remote platform + locate Git Bash once; cached per target. */
616
+ detect() {
617
+ if (this.platform !== 'unknown') return Promise.resolve()
618
+ if (this._detecting) return this._detecting
619
+ this._detecting = this._detect().finally(() => { this._detecting = null })
620
+ return this._detecting
621
+ }
622
+
623
+ async _detect() {
624
+ let res
625
+ try {
626
+ res = await this._runSsh(['--', this.destination(), 'cmd /c ver'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
627
+ } catch {
628
+ this.platform = 'unknown'
629
+ this.shellMode = 'native'
630
+ return
631
+ }
632
+ const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
633
+ if (res.code === 0 && /microsoft windows/i.test(out)) {
634
+ this.platform = 'windows'
635
+ await this._resolveGitBash()
636
+ return
637
+ }
638
+ try {
639
+ const u = await this._runSsh(['--', this.destination(), 'uname -s'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
640
+ if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
641
+ this.platform = 'windows'
642
+ await this._resolveGitBash()
643
+ return
644
+ }
645
+ } catch { /* inconclusive — fall through to posix */ }
646
+ this.platform = 'posix'
647
+ this.shellMode = 'native'
648
+ this.gitBashPath = ''
649
+ }
650
+
651
+ async _resolveGitBash() {
652
+ const cfg = String(this.config.shell || '').trim()
653
+ const exists = async (p) => {
654
+ const r = await this._runSsh(['--', this.destination(), `cmd /c if exist "${p}" (echo Y) else (echo N)`], { timeoutMs: 8000 })
655
+ return /Y/.test(String(r.stdout || ''))
656
+ }
657
+ if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
658
+ if (await exists(cfg)) { this.gitBashPath = cfg; this.shellMode = 'git-bash'; return }
659
+ }
660
+ if (cfg === 'native') { this.shellMode = 'native'; this.gitBashPath = ''; return }
661
+ try {
662
+ const r = await this._runSsh(['--', this.destination(), 'cmd /c where bash'], { timeoutMs: 8000 })
663
+ const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
664
+ if (first) { this.gitBashPath = first; this.shellMode = 'git-bash'; return }
665
+ } catch { /* fall through to the candidate list */ }
666
+ const candidates = [
667
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
668
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
669
+ ...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
670
+ ]
671
+ for (const c of candidates) {
672
+ if (await exists(c)) { this.gitBashPath = c; this.shellMode = 'git-bash'; return }
673
+ }
674
+ this.shellMode = 'git-bash'
675
+ this.gitBashPath = ''
676
+ }
677
+
678
+ /** Run one remote command (Windows + Git Bash is wrapped exactly as native). */
679
+ exec(command, timeoutMsOrOpts) {
680
+ const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
681
+ return this.detect().then(() => {
682
+ const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
683
+ if (this.platform === 'windows' && this.gitBashPath) {
684
+ // Pipe the script to `bash -s` over stdin: no quoting round-trip, so
685
+ // quotes/backslashes/newlines survive verbatim (same as the native pool).
686
+ return this._runSsh(['--', this.destination(), `"${this.gitBashPath}" -s`], { timeoutMs, signal: opts.signal, stdin: String(command), onData: opts.onData })
687
+ }
688
+ return this._runSsh(['--', this.destination(), String(command)], { timeoutMs, signal: opts.signal, onData: opts.onData })
689
+ })
690
+ }
691
+
692
+ /** Open a fresh `ssh -s <dest> sftp` session and its ssh2 SFTP engine. */
693
+ async _openSftpChannel() {
694
+ const Sftp = loadSftpEngine()
695
+ const secret = await this._resolveSecret()
696
+ const sshBinary = resolveSshBinary(this.config)
697
+ const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), '-s', this.destination(), 'sftp']
698
+ const child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
699
+
700
+ // ssh2's SFTP engine only reaches `_protocol` for channelData/channelClose,
701
+ // and reads `_remoteIdentRaw` to pick its packet sizes.
702
+ //
703
+ // ## Flow control, the subtle part
704
+ //
705
+ // Over real SSH the engine's send window is topped up by CHANNEL_WINDOW_ADJUST
706
+ // messages from the peer. On a raw `ssh -s sftp` pipe THERE ARE NO SUCH
707
+ // MESSAGES — the local ssh owns the SSH-layer window and the server's grants,
708
+ // and all we have is a byte pipe with OS-level backpressure. So the engine's
709
+ // window has to be mapped onto the pipe's writability:
710
+ //
711
+ // • a write the pipe accepts → more will fit;
712
+ // • the pipe fills (`stdin.write` returns false) → clamp the window so the
713
+ // engine buffers the remainder instead of queueing it all in memory;
714
+ // • the pipe drains → grant the window back and let the engine flush.
715
+ //
716
+ // The trap, and why the constants below are what they are: ssh2 arms its
717
+ // flush callback (`_waitWindow` / `_chunkcb = drainBuffer`) ONLY from inside
718
+ // its own subtraction, at the moment that subtraction takes the window to
719
+ // exactly 0. So the window must be handed over in EXACT MULTIPLES of
720
+ // packetSize and then left alone: the engine's own arithmetic then lands on 0
721
+ // during normal operation and arms the flush callback we depend on.
722
+ //
723
+ // Do NOT "help" by clamping the window to 0 from inside channelData when the
724
+ // pipe reports itself full. That sets the window to 0 AFTER the subtraction
725
+ // has already happened, so the callback is never armed and the buffered
726
+ // remainder is never flushed — the transfer deadlocks with a full window.
727
+ // (Measured: a 4 MB upload stopped dead after 32 KB, with `_buffer.length`
728
+ // stuck at 1 and no callback pending.) The pipe's own `drain` event is the
729
+ // only place the window is refilled.
730
+ //
731
+ // Also tempting-but-wrong: adding each received packet's length to the
732
+ // window. That happens to work for a DOWNLOAD (the peer's data is the
733
+ // window) and deadlocks an upload, because a write's acknowledgements are
734
+ // tiny status packets while each send consumes ~32 KB.
735
+ //
736
+ // See scripts/integration-openssh-large.mjs, which is what caught all of this.
737
+ const PACKET = 32768
738
+ const GRANT = PACKET * 8 // 256 KB per burst, and a multiple of packetSize
739
+ const chan = { sftp: null }
740
+ const fakeClient = {
741
+ _protocol: {
742
+ _remoteIdentRaw: Buffer.from('SSH-2.0-OpenSSH'),
743
+ // Hand the bytes to the pipe and nothing else. Backpressure is expressed
744
+ // through the engine's own window draining (see above); the OS pipe
745
+ // buffer plus Node's write buffer absorb the in-flight remainder.
746
+ channelData(_id, data) { try { child.stdin.write(data) } catch { /* close handles it */ } },
747
+ channelClose() { try { child.stdin.end() } catch {} },
748
+ },
749
+ }
750
+ const chanInfo = {
751
+ type: 'sftp',
752
+ incoming: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
753
+ outgoing: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
754
+ }
755
+ const sftp = new Sftp(fakeClient, chanInfo, {})
756
+ chan.sftp = sftp
757
+
758
+ // The pipe has room again: grant the window back and let the engine flush
759
+ // whatever it buffered while the pipe was full.
760
+ const onStdinDrain = () => {
761
+ if (!chan.sftp) return
762
+ chan.sftp.outgoing.window = GRANT
763
+ if (chan.sftp._waitWindow) {
764
+ chan.sftp._waitWindow = false
765
+ const cb = chan.sftp._chunkcb
766
+ chan.sftp._chunkcb = undefined
767
+ if (typeof cb === 'function') cb.call(chan.sftp)
768
+ }
769
+ }
770
+ child.stdin.on('drain', onStdinDrain)
771
+
772
+ return new Promise((resolve, reject) => {
773
+ let stderr = ''
774
+ let settled = false
775
+ const done = (err) => {
776
+ if (settled) return
777
+ settled = true
778
+ clearTimeout(timer)
779
+ if (err) { try { child.kill() } catch {} ; reject(err) } else resolve({ child, sftp })
780
+ }
781
+ const timer = setTimeout(() => {
782
+ done(new Error(`sftp session to ${this.destination()} timed out${stderr ? ': ' + stderr.trim() : ''}`))
783
+ }, this.config.commandTimeoutMs)
784
+ child.stderr.on('data', (d) => { stderr += d })
785
+ sftp.on('error', (e) => done(new Error('sftp session failed: ' + ((e && e.message) || e))))
786
+ sftp.on('ready', () => done(null))
787
+ child.on('error', (e) => done(e))
788
+ child.on('close', (code) => done(new Error(`ssh sftp exited (${code})${stderr ? ': ' + stderr.trim() : ''}`)))
789
+ // Inbound is simpler: the local ssh already did the SSH-level window
790
+ // accounting before handing us these bytes, so they just go straight in.
791
+ child.stdout.on('data', (d) => { sftp.push(d) })
792
+ child.stdout.on('end', () => { try { sftp.push(null) } catch { /* already gone */ } })
793
+ sftp._init()
794
+ })
795
+ }
796
+
797
+ /**
798
+ * A persistent SFTP session, kept across calls: the picker and the explorer
799
+ * issue one listing per interaction, and re-handshaking each time would be
800
+ * visibly slow. A session that dies is dropped so the next call reconnects.
801
+ */
802
+ sftp() {
803
+ if (this._sftpSession) return this._sftpSession
804
+ const epoch = this.epoch
805
+ const session = this._openSftpChannel().then(({ child, sftp }) => {
806
+ const drop = () => {
807
+ if (this._sftpSession === session) this._sftpSession = null
808
+ if (this._sftpChild === child) this._sftpChild = null
809
+ }
810
+ child.on('close', drop)
811
+ sftp.on('end', drop)
812
+ sftp.on('error', drop)
813
+ // A target switch during the handshake must not adopt the old host's
814
+ // session (the same generational guard connect() uses).
815
+ if (this.epoch !== epoch) {
816
+ try { child.kill() } catch {}
817
+ throw new Error('ssh target changed while opening sftp')
818
+ }
819
+ this._sftpChild = child
820
+ return buildSftpSurface(sftp, {
821
+ commandTimeoutMs: this.config.commandTimeoutMs,
822
+ onTimeout: () => {
823
+ // A timed-out SFTP session is dead: kill the child and drop the cached
824
+ // session so the next call re-handshakes instead of queueing behind the
825
+ // wedged one (issue #65).
826
+ try { child.kill() } catch { /* already gone */ }
827
+ drop()
828
+ },
829
+ })
830
+ })
831
+ // Never cache a rejected open: the next call must retry rather than get the
832
+ // same error back forever (a failed handshake is usually transient).
833
+ session.catch(() => { if (this._sftpSession === session) this._sftpSession = null })
834
+ this._sftpSession = session
835
+ return session
836
+ }
837
+
838
+ invalidate() {
839
+ this.epoch++
840
+ const client = this.client
841
+ this.client = null
842
+ this.connecting = null
843
+ this._secret = null
844
+ this._secretPromise = null
845
+ if (client) { try { client.close() } catch {} }
846
+ this._dropSftpSession()
847
+ if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
848
+ if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
849
+ }
850
+
851
+ _dropSftpSession() {
852
+ const session = this._sftpSession
853
+ this._sftpSession = null
854
+ if (this._sftpChild) {
855
+ try { this._sftpChild.kill() } catch {}
856
+ this._sftpChild = null
857
+ }
858
+ // Swallow the rejection: nobody awaits a session we are tearing down.
859
+ if (session && typeof session.catch === 'function') session.catch(() => {})
860
+ }
861
+
862
+ close() {
863
+ this.epoch++
864
+ const client = this.client
865
+ this.client = null
866
+ this.connecting = null
867
+ this._secret = null
868
+ this._secretPromise = null
869
+ if (client) { try { client.close() } catch {} }
870
+ this._dropSftpSession()
871
+ if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
872
+ if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
873
+ }
874
+ }
875
+
876
+ /** Pick the pool class for a config: 'openssh' backend, else the native pool. */
877
+ export function isSystemOpenSshBackend(config = {}) {
878
+ return String(config.backend || '').toLowerCase() === 'openssh'
879
+ }