All ssh spawns (the -G config probe, the ssh-config:resolve IPC, SSH terminals, and SshConnection) now go through one pure resolver: desktop.ssh_path in config.yaml, then System32 OpenSSH, then Git for Windows' usr\bin\ssh.exe, then bare ssh. Non-Windows keeps bare ssh. A failed local ssh -G probe is now tagged terminal: boot latches on the failure overlay (naming the client and the ssh_path setting) instead of re-driving the same probe every ~2s. Fixes #103288
1628 lines
56 KiB
TypeScript
1628 lines
56 KiB
TypeScript
import assert from 'node:assert/strict'
|
||
import { execFile } from 'node:child_process'
|
||
import { EventEmitter } from 'node:events'
|
||
import fs from 'node:fs'
|
||
import os from 'node:os'
|
||
import path from 'node:path'
|
||
import { promisify } from 'node:util'
|
||
|
||
import { test, vi } from 'vitest'
|
||
|
||
import {
|
||
baseSshOptions,
|
||
buildControlArgs,
|
||
buildExecArgs,
|
||
buildInteractiveSshArgs,
|
||
buildMasterArgs,
|
||
classifySshError,
|
||
CONTROL_FORWARD_KEEPALIVE_MS,
|
||
CONTROL_PERSIST_SECONDS,
|
||
controlSocketPath,
|
||
createSshProbeConnection,
|
||
forwardSpec,
|
||
hostArgs,
|
||
redactSecrets,
|
||
REMOTE_PROBE_TIMEOUT_SECS,
|
||
runSsh,
|
||
SSH_ERROR,
|
||
SshConnection,
|
||
stopTunnelChild,
|
||
target,
|
||
validateSshTarget,
|
||
withRemoteTimeout
|
||
} from './ssh-connection'
|
||
import { createControlMasterHolders } from './ssh-control-master-holders'
|
||
|
||
const execFileAsync = promisify(execFile)
|
||
|
||
test('redactSecrets scrubs the spawn-time session token env var', () => {
|
||
const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard'
|
||
const out = redactSecrets(line)
|
||
assert.ok(!out.includes('abc123deadbeef'))
|
||
assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=<redacted>/)
|
||
// non-secret env vars are preserved
|
||
assert.match(out, /HERMES_DESKTOP=1/)
|
||
})
|
||
|
||
test('redactSecrets scrubs ?token= and ?ticket= URL params', () => {
|
||
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=<redacted>/)
|
||
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=<redacted>/)
|
||
assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=<redacted>/)
|
||
assert.ok(!redactSecrets('?token=supersecret').includes('supersecret'))
|
||
})
|
||
|
||
test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => {
|
||
assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer <redacted>/)
|
||
assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999'))
|
||
assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?<redacted>/)
|
||
assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888'))
|
||
})
|
||
|
||
test('redactSecrets handles null/undefined and non-secret text untouched', () => {
|
||
assert.equal(redactSecrets(null), '')
|
||
assert.equal(redactSecrets(undefined), '')
|
||
assert.equal(redactSecrets('uname -s -m'), 'uname -s -m')
|
||
})
|
||
|
||
test('redactSecrets masks a credential glued into an ssh target string', () => {
|
||
// Real incident: password typed into the host field surfaced verbatim in
|
||
// desktop.log and a public debug share.
|
||
const line = 'connecting (no-mux) to root@100.84.204.123:Luisclawy2026:22'
|
||
const out = redactSecrets(line)
|
||
assert.ok(!out.includes('Luisclawy2026'), 'credential must be masked')
|
||
assert.match(out, /root@100\.84\.204\.123:<redacted>:22/)
|
||
|
||
// Comma-mangled IP variant from the same incident.
|
||
const mangled = redactSecrets('connecting (no-mux) to root@100,84,204,123:Luisclawy2026:22')
|
||
assert.ok(!mangled.includes('Luisclawy2026'))
|
||
})
|
||
|
||
test('redactSecrets leaves legitimate ssh target logging untouched', () => {
|
||
assert.equal(
|
||
redactSecrets('connecting (no-mux) to root@100.84.204.123:22'),
|
||
'connecting (no-mux) to root@100.84.204.123:22'
|
||
)
|
||
assert.equal(
|
||
redactSecrets('opening control master to alice@box.example.com:2222'),
|
||
'opening control master to alice@box.example.com:2222'
|
||
)
|
||
})
|
||
|
||
test('controlSocketPath is stable, short, and host-distinct', () => {
|
||
const a = controlSocketPath('me', 'box1', 22, '/tmp/d')
|
||
const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d')
|
||
const b = controlSocketPath('me', 'box2', 22, '/tmp/d')
|
||
assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)')
|
||
assert.notEqual(a, b, 'different host → different socket')
|
||
// 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit
|
||
assert.match(path.basename(a), /^[0-9a-f]{16}\.sock$/)
|
||
})
|
||
|
||
test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => {
|
||
// OpenSSH binds a temporary listener at `<ControlPath>.<16 random chars>` (a
|
||
// 17-byte suffix) while opening the master. The macOS regression was the
|
||
// default base under os.tmpdir() (/var/folders/.../T/) pushing it over 104.
|
||
const p = controlSocketPath('hermes', 'remote-build-server', 22) // no baseDir → default
|
||
const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix
|
||
assert.ok(
|
||
worstCase.length <= 104,
|
||
`default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})`
|
||
)
|
||
// And it must NOT live under the deeply-nested macOS per-user temp dir.
|
||
assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS')
|
||
})
|
||
|
||
test.runIf(process.platform !== 'win32')(
|
||
'deep HOME uses a private short directory and binds a real listener',
|
||
async () => {
|
||
const previousHome = process.env.HOME
|
||
process.env.HOME = path.join(os.tmpdir(), 'deep-home-' + 'x'.repeat(110))
|
||
const net = await import('node:net')
|
||
const listener = net.createServer()
|
||
|
||
try {
|
||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true })
|
||
assert.equal(conn.controlPath, controlSocketPath('me', 'box', 22))
|
||
assert.ok(!conn.controlPath.startsWith(process.env.HOME))
|
||
await conn.open()
|
||
const dir = path.dirname(conn.controlPath)
|
||
const st = fs.lstatSync(dir)
|
||
assert.ok(st.isDirectory() && !st.isSymbolicLink())
|
||
assert.equal(st.uid, process.getuid())
|
||
assert.equal(st.mode & 0o777, 0o700)
|
||
const temporaryPath = `${conn.controlPath}.0123456789abcdef`
|
||
assert.ok(Buffer.byteLength(temporaryPath) <= 104)
|
||
await new Promise<void>((resolve, reject) => {
|
||
listener.once('error', reject)
|
||
listener.listen(temporaryPath, resolve)
|
||
})
|
||
} finally {
|
||
if (previousHome === undefined) {
|
||
delete process.env.HOME
|
||
} else {
|
||
process.env.HOME = previousHome
|
||
}
|
||
|
||
if (listener.listening) {
|
||
await new Promise<void>((resolve, reject) => listener.close(error => (error ? reject(error) : resolve())))
|
||
}
|
||
}
|
||
}
|
||
)
|
||
|
||
test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => {
|
||
const opts = baseSshOptions('/tmp/x.sock', 15000)
|
||
const joined = opts.join(' ')
|
||
assert.match(joined, /ControlPath=\/tmp\/x\.sock/)
|
||
assert.match(joined, /ControlMaster=auto/)
|
||
assert.match(joined, /ControlPersist=\d+/)
|
||
assert.match(joined, /BatchMode=yes/)
|
||
assert.match(joined, /StrictHostKeyChecking=accept-new/)
|
||
assert.match(joined, /ExitOnForwardFailure=yes/)
|
||
assert.match(joined, /ConnectTimeout=15/)
|
||
assert.match(joined, /ServerAliveInterval=15/)
|
||
assert.match(joined, /ServerAliveCountMax=3/)
|
||
assert.match(joined, /TCPKeepAlive=yes/)
|
||
assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking')
|
||
})
|
||
|
||
test('hostArgs adds -p only for non-default port and -i only with a key', () => {
|
||
assert.deepEqual(hostArgs({ port: 22 }), [])
|
||
assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222'])
|
||
assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k'])
|
||
assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k'])
|
||
})
|
||
|
||
test('target builds user@host or bare host', () => {
|
||
assert.equal(target('me', 'box'), 'me@box')
|
||
assert.equal(target('', 'box'), 'box')
|
||
})
|
||
|
||
test('buildExecArgs ends with host then the remote command', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||
const args = buildExecArgs(conn, 'command -v hermes', 15000)
|
||
assert.equal(args[args.length - 1], 'command -v hermes')
|
||
assert.equal(args[args.length - 2], 'me@box')
|
||
assert.ok(args.includes('BatchMode=yes'))
|
||
})
|
||
|
||
test('buildControlArgs places -O <op> first and never appends a remote command', () => {
|
||
const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' }
|
||
const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000)
|
||
assert.equal(args[0], '-O')
|
||
assert.equal(args[1], 'forward')
|
||
assert.ok(args.includes('-L'))
|
||
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
|
||
assert.equal(args[args.length - 1], 'me@box')
|
||
})
|
||
|
||
test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||
const args = buildMasterArgs(conn, 15000)
|
||
assert.ok(args.includes('-M'))
|
||
assert.ok(args.includes('-N'))
|
||
assert.ok(args.includes('-f'))
|
||
})
|
||
|
||
test('forwardSpec binds the local end to 127.0.0.1 only', () => {
|
||
assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000')
|
||
assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:'))
|
||
assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0'))
|
||
})
|
||
|
||
test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||
const args = buildInteractiveSshArgs(conn, '', 15000)
|
||
assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal')
|
||
assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)')
|
||
assert.equal(args[args.length - 2], 'me@box')
|
||
assert.equal(args[args.length - 1], 'exec "$SHELL" -l')
|
||
})
|
||
|
||
test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||
const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000)
|
||
const remoteCmd = args[args.length - 1]
|
||
assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/)
|
||
})
|
||
|
||
test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||
const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000)
|
||
// the embedded quote must be escaped, not break out of the quoting
|
||
assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'"))
|
||
assert.ok(args[args.length - 1].includes('exec "$SHELL" -l'))
|
||
})
|
||
|
||
test('classifySshError detects a changed host key (fail-closed)', () => {
|
||
assert.equal(
|
||
classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'),
|
||
SSH_ERROR.HOST_KEY_CHANGED
|
||
)
|
||
assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED)
|
||
assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED)
|
||
})
|
||
|
||
test('classifySshError detects auth failure', () => {
|
||
assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED)
|
||
assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED)
|
||
})
|
||
|
||
test('classifySshError detects unreachable', () => {
|
||
assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE)
|
||
assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE)
|
||
})
|
||
|
||
// A fake child process that emits a scripted result on next tick.
|
||
// Node's `close` is `(code, signal)`: a signal death has `code === null`.
|
||
function fakeChild({ code = 0, signal = null, stdout = '', stderr = '', errorEvent = null, hang = false }: any = {}) {
|
||
const child: any = new EventEmitter()
|
||
child.stdout = new EventEmitter()
|
||
child.stderr = new EventEmitter()
|
||
|
||
child.kill = () => {
|
||
child._killed = true
|
||
}
|
||
|
||
if (hang) {
|
||
process.nextTick(() => {
|
||
if (stdout) {
|
||
child.stdout.emit('data', Buffer.from(stdout))
|
||
}
|
||
|
||
if (stderr) {
|
||
child.stderr.emit('data', Buffer.from(stderr))
|
||
}
|
||
})
|
||
|
||
return child // never emits close → drives the timeout path
|
||
}
|
||
|
||
process.nextTick(() => {
|
||
if (errorEvent) {
|
||
child.emit('error', errorEvent)
|
||
|
||
return
|
||
}
|
||
|
||
if (stdout) {
|
||
child.stdout.emit('data', Buffer.from(stdout))
|
||
}
|
||
|
||
if (stderr) {
|
||
child.stderr.emit('data', Buffer.from(stderr))
|
||
}
|
||
|
||
child.emit('close', signal ? null : code, signal)
|
||
})
|
||
|
||
return child
|
||
}
|
||
|
||
function assertSignalDeathNotUnreachable(err, signal) {
|
||
assert.notEqual(err.kind, SSH_ERROR.UNREACHABLE)
|
||
assert.equal(err.signal, signal)
|
||
assert.doesNotMatch(err.message, /Could not reach/)
|
||
assert.match(err.message, new RegExp(signal))
|
||
|
||
return true
|
||
}
|
||
|
||
// Build a spawnFn that returns scripted children per ssh invocation, recording
|
||
// the args it was called with.
|
||
function scriptedSpawn(scripts) {
|
||
const calls: any[] = []
|
||
let i = 0
|
||
|
||
const fn: any = (_cmd, args) => {
|
||
calls.push(args)
|
||
const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)]
|
||
i += 1
|
||
|
||
return fakeChild(script || {})
|
||
}
|
||
|
||
fn.calls = calls
|
||
|
||
return fn
|
||
}
|
||
|
||
test('open() establishes the master when not already alive', async () => {
|
||
// `-O check` fails first (not alive) → master opens (code 0). Track which
|
||
// ssh ops ran rather than re-probing with the same always-failing check.
|
||
const ops: string[] = []
|
||
|
||
const spawnFn = scriptedSpawn(args => {
|
||
ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other')
|
||
|
||
if (args.includes('check')) {
|
||
return { code: 255, stderr: 'no control path' }
|
||
}
|
||
|
||
return { code: 0 }
|
||
})
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: '/tmp/d' })
|
||
await conn.open()
|
||
assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')
|
||
})
|
||
|
||
test('open() abort kills an in-flight SSH child instead of waiting for timeout', async () => {
|
||
const child = fakeChild({ hang: true })
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn: () => child, controlDir: '/tmp/d', connectTimeoutMs: 1000 }
|
||
)
|
||
|
||
const controller = new AbortController()
|
||
const opening = conn.open({ signal: controller.signal })
|
||
|
||
await Promise.resolve()
|
||
controller.abort()
|
||
|
||
await assert.rejects(opening, (error: any) => error.kind === 'superseded')
|
||
assert.equal(child._killed, true)
|
||
})
|
||
|
||
test('open() is a no-op when the master is already alive and execs verify', async () => {
|
||
const ops: string[] = []
|
||
|
||
const spawnFn = scriptedSpawn(args => {
|
||
ops.push(args.includes('check') ? 'check' : args.includes('exit 0') ? 'verify' : 'master')
|
||
|
||
return { code: 0 } // check succeeds → alive; verify exec succeeds → trusted
|
||
})
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: '/tmp/d' })
|
||
await conn.open()
|
||
assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening')
|
||
})
|
||
|
||
test('open() evicts a wedged master (check passes, exec hangs) and dials fresh', async () => {
|
||
// The macOS mode-switch wedge: ControlPersist master answers -O check but
|
||
// every exec through it hangs. open() must verify, evict (-O exit), and
|
||
// establish a fresh master instead of trusting the corpse.
|
||
const ops: string[] = []
|
||
|
||
const spawnFn = scriptedSpawn(args => {
|
||
if (args.includes('check')) {
|
||
ops.push('check')
|
||
|
||
return { code: 0 }
|
||
}
|
||
|
||
if (args.includes('exit 0')) {
|
||
ops.push('verify')
|
||
|
||
return { hang: true }
|
||
}
|
||
|
||
if (args.includes('-O')) {
|
||
ops.push('evict')
|
||
|
||
return { code: 0 }
|
||
}
|
||
|
||
ops.push('master')
|
||
|
||
return { code: 0 }
|
||
})
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn, mux: true, controlDir: '/tmp/d', connectTimeoutMs: 50 }
|
||
)
|
||
|
||
await conn.open()
|
||
assert.deepEqual(
|
||
ops,
|
||
['check', 'verify', 'evict', 'master'],
|
||
'wedged master: verified, evicted, then a fresh master is dialed'
|
||
)
|
||
})
|
||
|
||
test('close() removes the control socket when -O exit fails', async () => {
|
||
const dir = path.join(os.tmpdir(), `hermes-ssh-close-${process.pid}-${Date.now()}`)
|
||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||
|
||
const spawnFn = scriptedSpawn(args => {
|
||
if (args.includes('check')) {
|
||
return { code: 255 }
|
||
} // not alive → open dials master
|
||
|
||
if (args.includes('-M')) {
|
||
return { code: 0 }
|
||
}
|
||
|
||
return { code: 255, stderr: 'mux: master gone' } // -O exit fails
|
||
})
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: dir })
|
||
await conn.open()
|
||
fs.writeFileSync(conn.controlPath, '') // simulate the lingering socket file
|
||
await conn.close()
|
||
assert.ok(!fs.existsSync(conn.controlPath), 'failed -O exit drops the socket so the next open dials fresh')
|
||
fs.rmSync(dir, { recursive: true, force: true })
|
||
})
|
||
|
||
test('open() creates the control-socket directory if it does not exist', async () => {
|
||
const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`)
|
||
assert.ok(!fs.existsSync(dir), 'precondition: control dir absent')
|
||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: dir })
|
||
|
||
try {
|
||
await conn.open()
|
||
assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh')
|
||
} finally {
|
||
try {
|
||
fs.rmSync(dir, { recursive: true, force: true })
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
})
|
||
|
||
test('open() surfaces a classified auth error', async () => {
|
||
const spawnFn = scriptedSpawn(args => {
|
||
if (args.includes('check')) {
|
||
return { code: 255 }
|
||
}
|
||
|
||
return { code: 255, stderr: 'Permission denied (publickey).' }
|
||
})
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||
await assert.rejects(
|
||
() => conn.open(),
|
||
(err: any) => {
|
||
assert.equal(err.kind, SSH_ERROR.AUTH_FAILED)
|
||
assert.match(err.message, /ssh-agent|ssh-add/)
|
||
|
||
return true
|
||
}
|
||
)
|
||
})
|
||
|
||
test('open() turns a Tailscale browser-check timeout into safe interactive-auth guidance', async () => {
|
||
const checkUrl = 'https://login.tailscale.com/a/example-one-time-check'
|
||
|
||
const spawnFn = scriptedSpawn(args => {
|
||
if (args.includes('check')) {
|
||
return { code: 255 }
|
||
}
|
||
|
||
return {
|
||
hang: true,
|
||
stderr: `# Tailscale SSH requires an additional check.\n# To authenticate, visit: ${checkUrl}\n`
|
||
}
|
||
})
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 20 })
|
||
|
||
await assert.rejects(
|
||
() => conn.open(),
|
||
(err: any) => {
|
||
assert.equal(err.kind, SSH_ERROR.INTERACTIVE_AUTH)
|
||
assert.match(err.message, /Tailscale SSH requires an interactive browser check/)
|
||
assert.match(err.message, /ssh me@box true/)
|
||
assert.doesNotMatch(err.message, /login\.tailscale\.com|example-one-time-check/)
|
||
|
||
return true
|
||
}
|
||
)
|
||
})
|
||
|
||
test('exec() returns stdout on success and rejects (classified) on failure', async () => {
|
||
const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }])
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' })
|
||
assert.equal((await conn.exec('uname -s')).trim(), 'Linux')
|
||
|
||
const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }])
|
||
const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' })
|
||
await assert.rejects(
|
||
() => conn2.exec('uname -s'),
|
||
(err: any) => {
|
||
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
|
||
|
||
return true
|
||
}
|
||
)
|
||
})
|
||
|
||
test('exec() treats a hung ssh as a timeout (half-open connection)', async () => {
|
||
const spawnFn = scriptedSpawn([{ hang: true }])
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||
await assert.rejects(
|
||
() => conn.exec('uname -s', { timeoutMs: 30 }),
|
||
(err: any) => {
|
||
assert.equal(err.kind, SSH_ERROR.TIMEOUT)
|
||
|
||
return true
|
||
}
|
||
)
|
||
})
|
||
|
||
test('forward() issues -O forward with a loopback-bound -L spec', async () => {
|
||
const spawnFn = scriptedSpawn([{ code: 0 }])
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: '/tmp/d' })
|
||
await conn.forward(5000, 6000)
|
||
const args = spawnFn.calls[0]
|
||
assert.equal(args[0], '-O')
|
||
assert.equal(args[1], 'forward')
|
||
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
|
||
await conn.cancelForward(5000, 6000)
|
||
})
|
||
|
||
test('mux forward keeps the ControlPersist master alive until the final forward is cancelled', async () => {
|
||
vi.useFakeTimers()
|
||
|
||
try {
|
||
const spawnFn = scriptedSpawn({ code: 0 })
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||
|
||
await conn.forward(5000, 6000)
|
||
await conn.forward(5001, 6001)
|
||
assert.ok(CONTROL_FORWARD_KEEPALIVE_MS < CONTROL_PERSIST_SECONDS * 1_000)
|
||
await vi.advanceTimersByTimeAsync(CONTROL_FORWARD_KEEPALIVE_MS)
|
||
|
||
const checks = () => spawnFn.calls.filter(args => args[0] === '-O' && args[1] === 'check').length
|
||
assert.equal(checks(), 1, 'a tracked forward refreshes the ControlPersist timer')
|
||
|
||
await conn.cancelForward(5000, 6000)
|
||
await vi.advanceTimersByTimeAsync(CONTROL_FORWARD_KEEPALIVE_MS)
|
||
assert.equal(checks(), 2, 'cancelling one of several forwards keeps the refresh active')
|
||
|
||
await conn.cancelForward(5001, 6001)
|
||
await vi.advanceTimersByTimeAsync(CONTROL_FORWARD_KEEPALIVE_MS * 2)
|
||
assert.equal(checks(), 2, 'cancelling the final forward stops the refresh timer')
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
test('lifecycle logging passes through redaction', async () => {
|
||
const logs: string[] = []
|
||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn, mux: true, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }
|
||
)
|
||
|
||
await conn.open()
|
||
|
||
// none of the emitted log lines may carry a raw token-shaped secret
|
||
for (const line of logs) {
|
||
assert.ok(!/token=[^<]/.test(line))
|
||
}
|
||
|
||
assert.ok(logs.some(l => l.includes('[ssh]')))
|
||
})
|
||
|
||
test('no-mux: ssh args carry no ControlMaster/ControlPath options', async () => {
|
||
const spawnFn = scriptedSpawn({ code: 0 })
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||
await conn.open()
|
||
|
||
for (const args of spawnFn.calls) {
|
||
assert.ok(!args.some(a => /ControlMaster|ControlPath|ControlPersist/.test(a)), `mux option leaked: ${args}`)
|
||
}
|
||
})
|
||
|
||
test('no-mux: open() verifies auth with a one-shot exec, no -M master', async () => {
|
||
const spawnFn = scriptedSpawn({ code: 0 })
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||
await conn.open()
|
||
assert.ok(!spawnFn.calls.some(args => args.includes('-M')), 'no master should be spawned')
|
||
assert.ok(
|
||
spawnFn.calls.some(args => args[args.length - 1] === 'exit 0'),
|
||
'liveness/openness via one-shot exec'
|
||
)
|
||
})
|
||
|
||
test('SSH probe never creates or closes a ControlMaster', async () => {
|
||
const spawnFn = scriptedSpawn({ code: 0 })
|
||
const conn = createSshProbeConnection({ host: 'box', user: 'me' }, { spawnFn })
|
||
await conn.open()
|
||
await conn.close()
|
||
const args = spawnFn.calls.flat()
|
||
assert.ok(!args.includes('-M'))
|
||
assert.ok(!args.includes('-O'))
|
||
assert.ok(!args.some(value => /Control(?:Master|Path|Persist)/.test(value)))
|
||
})
|
||
|
||
test('no-mux: open() classifies auth failure', async () => {
|
||
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }])
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||
await assert.rejects(conn.open(), (err: any) => err.kind === 'auth-failed')
|
||
})
|
||
|
||
test('open() records what the failed ssh did in the desktop log (#80836)', async () => {
|
||
// The renderer only shows friendly copy for the kind, so the log is the one
|
||
// place a connect that dies right after TCP setup can be diagnosed from.
|
||
for (const mux of [false, true]) {
|
||
const logs: string[] = []
|
||
|
||
const spawnFn = scriptedSpawn(args =>
|
||
args.includes('check') ? { code: 255, stderr: 'no control path' } : { signal: 'SIGTERM', stderr: '' }
|
||
)
|
||
|
||
const controlDir = path.join(os.tmpdir(), `hermes-ssh-connect-log-${process.pid}-${Date.now()}`)
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn, mux, controlDir, rememberLog: line => logs.push(line) }
|
||
)
|
||
|
||
await assert.rejects(conn.open())
|
||
fs.rmSync(controlDir, { recursive: true, force: true })
|
||
assert.ok(
|
||
logs.some(line =>
|
||
/connect to me@box:22 failed \(kind=unknown, exit=null, signal=SIGTERM\): \(empty\)/.test(line)
|
||
),
|
||
`mux=${mux}: ${logs.join(' | ')}`
|
||
)
|
||
}
|
||
|
||
const logs: string[] = []
|
||
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }])
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn, mux: false, rememberLog: line => logs.push(line) }
|
||
)
|
||
|
||
await assert.rejects(conn.open())
|
||
assert.ok(
|
||
logs.some(line => /failed \(kind=auth-failed, exit=255, signal=none\): me@box: Permission denied/.test(line))
|
||
)
|
||
})
|
||
|
||
test('runSsh keeps Node close signal on the result', async () => {
|
||
const spawnFn = () => fakeChild({ signal: 'SIGTERM', stderr: '' })
|
||
const result: any = await runSsh(['box'], { timeoutMs: 5000, spawnFn })
|
||
|
||
assert.equal(result.code, null)
|
||
assert.equal(result.signal, 'SIGTERM')
|
||
assert.equal(result.stderr, '')
|
||
})
|
||
|
||
test('no-mux open() does not classify a signal death with empty stderr as unreachable', async () => {
|
||
const spawnFn = scriptedSpawn([{ signal: 'SIGTERM', stderr: '' }])
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||
|
||
await assert.rejects(
|
||
() => conn.open(),
|
||
(err: any) => assertSignalDeathNotUnreachable(err, 'SIGTERM')
|
||
)
|
||
})
|
||
|
||
test('mux open() does not classify a signal-killed master with empty stderr as unreachable', async () => {
|
||
const spawnFn = scriptedSpawn(args => {
|
||
if (args.includes('check')) {
|
||
return { code: 255, stderr: 'no control path' }
|
||
}
|
||
|
||
return { signal: 'SIGHUP', stderr: '' }
|
||
})
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||
|
||
await assert.rejects(
|
||
() => conn.open(),
|
||
(err: any) => assertSignalDeathNotUnreachable(err, 'SIGHUP')
|
||
)
|
||
})
|
||
|
||
test('exec() does not classify a signal death with empty stderr as unreachable', async () => {
|
||
const spawnFn = scriptedSpawn([{ signal: 'SIGKILL', stderr: '' }])
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||
|
||
await assert.rejects(
|
||
() => conn.exec('uname -s'),
|
||
(err: any) => assertSignalDeathNotUnreachable(err, 'SIGKILL')
|
||
)
|
||
})
|
||
|
||
test('forward() does not classify a signal death with empty stderr as unreachable', async () => {
|
||
const spawnFn = scriptedSpawn([{ signal: 'SIGPIPE', stderr: '' }])
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||
|
||
await assert.rejects(
|
||
() => conn.forward(5000, 6000),
|
||
(err: any) => assertSignalDeathNotUnreachable(err, 'SIGPIPE')
|
||
)
|
||
})
|
||
|
||
test('close() does not report a signal-killed -O exit with empty stderr as unreachable', async () => {
|
||
const logs: string[] = []
|
||
|
||
const spawnFn = scriptedSpawn(args => {
|
||
if (args.includes('check')) {
|
||
return { code: 255 }
|
||
}
|
||
|
||
if (args.includes('-M')) {
|
||
return { code: 0 }
|
||
}
|
||
|
||
return { signal: 'SIGINT', stderr: '' }
|
||
})
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{
|
||
spawnFn,
|
||
controlDir: '/tmp/d',
|
||
controlMasterHolders: createControlMasterHolders(),
|
||
rememberLog: line => logs.push(line)
|
||
}
|
||
)
|
||
|
||
await conn.open()
|
||
await conn.close()
|
||
|
||
const logged = logs.join('\n')
|
||
assert.match(logged, /SIGINT/)
|
||
assert.doesNotMatch(logged, /Could not reach/)
|
||
})
|
||
|
||
test('no-mux open() still classifies a normal non-zero exit with empty stderr as unreachable', async () => {
|
||
const spawnFn = scriptedSpawn([{ code: 255, stderr: '' }])
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||
|
||
await assert.rejects(
|
||
() => conn.open(),
|
||
(err: any) => {
|
||
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
|
||
assert.equal(err.signal, undefined)
|
||
|
||
return true
|
||
}
|
||
)
|
||
})
|
||
|
||
test('a signal death that already printed an unreachable ssh error stays unreachable', async () => {
|
||
const spawnFn = scriptedSpawn([
|
||
{
|
||
signal: 'SIGTERM',
|
||
stderr: 'ssh: connect to host box port 22: Connection refused'
|
||
}
|
||
])
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||
|
||
await assert.rejects(
|
||
() => conn.open(),
|
||
(err: any) => {
|
||
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
|
||
assert.equal(err.signal, 'SIGTERM')
|
||
|
||
return true
|
||
}
|
||
)
|
||
})
|
||
|
||
test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it', async () => {
|
||
// Real listener stands in for the tunnel's local end so waitForLocalPort sees it.
|
||
const net = await import('node:net')
|
||
const srv = net.createServer()
|
||
await new Promise<void>(r => srv.listen(0, '127.0.0.1', () => r()))
|
||
const localPort = (srv.address() as any).port
|
||
const tunnels: any[] = []
|
||
|
||
const spawnFn: any = (_cmd, args) => {
|
||
const child: any = new EventEmitter()
|
||
child.stderr = new EventEmitter()
|
||
child.exitCode = null
|
||
|
||
child.kill = () => {
|
||
child._killed = true
|
||
child.exitCode = 0
|
||
process.nextTick(() => child.emit('exit', 0))
|
||
|
||
return true
|
||
}
|
||
|
||
if (args.includes('-N')) {
|
||
tunnels.push({ args, child })
|
||
process.nextTick(() =>
|
||
child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))
|
||
)
|
||
} else {
|
||
process.nextTick(() => child.emit('close', 0))
|
||
}
|
||
|
||
if (!args.includes('-N')) {
|
||
child.stdout = new EventEmitter()
|
||
process.nextTick(() => child.emit('close', 0))
|
||
}
|
||
|
||
return child
|
||
}
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false })
|
||
await conn.forward(localPort, 9119)
|
||
assert.equal(tunnels.length, 1, 'one persistent tunnel child')
|
||
assert.ok(tunnels[0].args.includes('-L'), 'tunnel child carries -L spec')
|
||
assert.ok(!tunnels[0].args.some(a => /ControlPath/.test(a)))
|
||
|
||
await conn.cancelForward(localPort, 9119)
|
||
assert.ok(tunnels[0].child._killed, 'cancelForward kills the tunnel child')
|
||
|
||
conn._opened = true
|
||
await conn.close() // no-mux close never runs ssh -O exit; must not throw
|
||
srv.close()
|
||
})
|
||
|
||
test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', async () => {
|
||
const spawnFn: any = (_cmd, args) => {
|
||
const child: any = new EventEmitter()
|
||
child.stderr = new EventEmitter()
|
||
child.exitCode = null
|
||
|
||
child.kill = () => {}
|
||
|
||
if (args.includes('-N')) {
|
||
process.nextTick(() => {
|
||
child.stderr.emit('data', Buffer.from('Permission denied (publickey).'))
|
||
child.exitCode = 255
|
||
})
|
||
}
|
||
|
||
return child
|
||
}
|
||
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false, forwardTimeoutMs: 2000 })
|
||
await assert.rejects(conn.forward(1, 9119), (err: any) => err.kind === 'auth-failed')
|
||
})
|
||
|
||
test('no-mux: an unrelated listener cannot mask a delayed bind failure', async () => {
|
||
const net = await import('node:net')
|
||
const srv = net.createServer()
|
||
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
|
||
const localPort = (srv.address() as any).port
|
||
|
||
const spawnFn: any = (_cmd, args) => {
|
||
const child: any = new EventEmitter()
|
||
child.stderr = new EventEmitter()
|
||
child.exitCode = null
|
||
|
||
child.kill = () => {}
|
||
|
||
if (args.includes('-N')) {
|
||
setTimeout(() => {
|
||
child.stderr.emit('data', Buffer.from(`bind [127.0.0.1]:${localPort}: Address already in use`))
|
||
child.exitCode = 255
|
||
child.emit('exit', 255)
|
||
}, 20)
|
||
}
|
||
|
||
return child
|
||
}
|
||
|
||
const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false, forwardTimeoutMs: 1000 })
|
||
await assert.rejects(conn.forward(localPort, 9119), /address already in use/i)
|
||
srv.close()
|
||
})
|
||
|
||
test('no-mux: tunnel death after readiness triggers a bounded restart, then unhealthy', async () => {
|
||
const net = await import('node:net')
|
||
const srv = net.createServer()
|
||
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
|
||
const localPort = (srv.address() as any).port
|
||
const tunnels: any[] = []
|
||
|
||
const spawnFn: any = (_cmd, args) => {
|
||
const child: any = new EventEmitter()
|
||
child.stdout = new EventEmitter()
|
||
child.stderr = new EventEmitter()
|
||
child.exitCode = null
|
||
|
||
child.kill = () => {
|
||
child.exitCode = 0
|
||
process.nextTick(() => child.emit('exit', 0))
|
||
|
||
return true
|
||
}
|
||
|
||
if (args.includes('-N')) {
|
||
tunnels.push(child)
|
||
process.nextTick(() =>
|
||
child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))
|
||
)
|
||
} else {
|
||
process.nextTick(() => child.emit('close', 0))
|
||
}
|
||
|
||
return child
|
||
}
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box' },
|
||
{ spawnFn, mux: false, tunnelRestartLimit: 1, tunnelRestartDelayMs: 5 }
|
||
)
|
||
|
||
await conn.open()
|
||
await conn.forward(localPort, 9119)
|
||
assert.equal(tunnels.length, 1)
|
||
|
||
// First death after readiness: a restart is pending, so the connection is
|
||
// NOT reported dead — the exact flap that used to cascade into a SIGTERM of
|
||
// a healthy backend (#96266).
|
||
tunnels[0].exitCode = 255
|
||
tunnels[0].emit('exit', 255)
|
||
assert.equal(await conn.isAlive(), true, 'flap within the restart budget must not poison isAlive')
|
||
|
||
// The restart spawns a replacement -N child.
|
||
await new Promise(resolve => setTimeout(resolve, 30))
|
||
assert.equal(tunnels.length, 2, 'a replacement tunnel child is spawned')
|
||
assert.equal(await conn.isAlive(), true)
|
||
|
||
// Second death exhausts the budget (limit 1): now the connection is dead.
|
||
tunnels[1].exitCode = 255
|
||
tunnels[1].emit('exit', 255)
|
||
await new Promise(resolve => setTimeout(resolve, 30))
|
||
assert.equal(tunnels.length, 2, 'no restart past the budget')
|
||
assert.equal(await conn.isAlive(), false, 'exhausted budget reports the connection dead')
|
||
srv.close()
|
||
})
|
||
|
||
test('no-mux: cancelForward during a pending tunnel restart cancels the restart', async () => {
|
||
const net = await import('node:net')
|
||
const srv = net.createServer()
|
||
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
|
||
const localPort = (srv.address() as any).port
|
||
const tunnels: any[] = []
|
||
|
||
const spawnFn: any = (_cmd, args) => {
|
||
const child: any = new EventEmitter()
|
||
child.stdout = new EventEmitter()
|
||
child.stderr = new EventEmitter()
|
||
child.exitCode = null
|
||
|
||
child.kill = () => {
|
||
child.exitCode = 0
|
||
process.nextTick(() => child.emit('exit', 0))
|
||
|
||
return true
|
||
}
|
||
|
||
if (args.includes('-N')) {
|
||
tunnels.push(child)
|
||
process.nextTick(() =>
|
||
child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))
|
||
)
|
||
} else {
|
||
process.nextTick(() => child.emit('close', 0))
|
||
}
|
||
|
||
return child
|
||
}
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box' },
|
||
{ spawnFn, mux: false, tunnelRestartLimit: 5, tunnelRestartDelayMs: 20 }
|
||
)
|
||
|
||
await conn.forward(localPort, 9119)
|
||
tunnels[0].exitCode = 255
|
||
tunnels[0].emit('exit', 255)
|
||
|
||
// Deliberate teardown while the restart timer is pending.
|
||
await conn.cancelForward(localPort, 9119)
|
||
await new Promise(resolve => setTimeout(resolve, 60))
|
||
assert.equal(tunnels.length, 1, 'cancelled forward must not restart its tunnel')
|
||
srv.close()
|
||
})
|
||
|
||
test('no-mux: close() during a pending tunnel restart cancels the restart', async () => {
|
||
const net = await import('node:net')
|
||
const srv = net.createServer()
|
||
await new Promise<void>(resolve => srv.listen(0, '127.0.0.1', resolve))
|
||
const localPort = (srv.address() as any).port
|
||
const tunnels: any[] = []
|
||
|
||
const spawnFn: any = (_cmd, args) => {
|
||
const child: any = new EventEmitter()
|
||
child.stdout = new EventEmitter()
|
||
child.stderr = new EventEmitter()
|
||
child.exitCode = null
|
||
|
||
child.kill = () => {
|
||
child.exitCode = 0
|
||
process.nextTick(() => child.emit('exit', 0))
|
||
|
||
return true
|
||
}
|
||
|
||
if (args.includes('-N')) {
|
||
tunnels.push(child)
|
||
process.nextTick(() =>
|
||
child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`))
|
||
)
|
||
} else {
|
||
process.nextTick(() => child.emit('close', 0))
|
||
}
|
||
|
||
return child
|
||
}
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box' },
|
||
{ spawnFn, mux: false, tunnelRestartLimit: 5, tunnelRestartDelayMs: 20 }
|
||
)
|
||
|
||
await conn.open()
|
||
await conn.forward(localPort, 9119)
|
||
tunnels[0].exitCode = 255
|
||
tunnels[0].emit('exit', 255)
|
||
|
||
await conn.close()
|
||
await new Promise(resolve => setTimeout(resolve, 60))
|
||
assert.equal(tunnels.length, 1, 'closed connection must not restart its tunnels')
|
||
srv.close()
|
||
})
|
||
|
||
test('validateSshTarget rejects a host starting with a dash (option injection)', () => {
|
||
assert.throws(() => validateSshTarget('-oProxyCommand=evil', '', 22), /unsafe/i)
|
||
assert.throws(() => validateSshTarget('--version', '', 22), /unsafe/i)
|
||
})
|
||
|
||
test('validateSshTarget rejects control characters in host', () => {
|
||
assert.throws(() => validateSshTarget('host\x00evil', '', 22), /unsafe/i)
|
||
assert.throws(() => validateSshTarget('host\nnewline', '', 22), /unsafe/i)
|
||
assert.throws(() => validateSshTarget('host\ttab', '', 22), /unsafe/i)
|
||
})
|
||
|
||
test('validateSshTarget rejects control characters in user', () => {
|
||
assert.throws(() => validateSshTarget('box', 'me\x00root', 22), /unsafe/i)
|
||
assert.throws(() => validateSshTarget('box', '-oForward=yes', 22), /unsafe/i)
|
||
})
|
||
|
||
test('validateSshTarget rejects ports outside 1-65535', () => {
|
||
assert.throws(() => validateSshTarget('box', '', 0), /port/i)
|
||
assert.throws(() => validateSshTarget('box', '', 65536), /port/i)
|
||
assert.throws(() => validateSshTarget('box', '', -1), /port/i)
|
||
assert.throws(() => validateSshTarget('box', '', NaN), /port/i)
|
||
})
|
||
|
||
test('validateSshTarget rejects commas in host (mistyped IP)', () => {
|
||
assert.throws(() => validateSshTarget('100,84,204,123', 'root', 22), /commas/i)
|
||
})
|
||
|
||
test('validateSshTarget rejects whitespace in host (pasted ssh command)', () => {
|
||
assert.throws(() => validateSshTarget('ssh root@box', '', 22), /whitespace/i)
|
||
assert.throws(() => validateSshTarget('box extra', '', 22), /whitespace/i)
|
||
})
|
||
|
||
test('validateSshTarget rejects a password glued into the host field', () => {
|
||
// Real incident shape: user typed host:PASSWORD (port parsed off upstream).
|
||
assert.throws(() => validateSshTarget('100.84.204.123:hunter2', 'root', 22), /password|":" segment/i)
|
||
|
||
// The thrown message must not echo the credential verbatim.
|
||
try {
|
||
validateSshTarget('100.84.204.123:hunter2', 'root', 22)
|
||
assert.fail('expected throw')
|
||
} catch (err) {
|
||
assert.ok(!String(err.message).includes('hunter2'), 'error message must not leak the credential')
|
||
}
|
||
})
|
||
|
||
test('validateSshTarget rejects garbage hostnames', () => {
|
||
assert.throws(() => validateSshTarget('host!bad', '', 22), /not a valid hostname/i)
|
||
assert.throws(() => validateSshTarget('host/path', '', 22), /not a valid hostname/i)
|
||
})
|
||
|
||
test('validateSshTarget rejects @ or whitespace in user', () => {
|
||
assert.throws(() => validateSshTarget('box', 'root@extra', 22), /user/i)
|
||
assert.throws(() => validateSshTarget('box', 'ro ot', 22), /user/i)
|
||
})
|
||
|
||
test('validateSshTarget still accepts bare IPv6 hosts', () => {
|
||
assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22))
|
||
assert.doesNotThrow(() => validateSshTarget('fe80::1%eth0', '', 22))
|
||
assert.doesNotThrow(() => validateSshTarget('2001:db8::42', '', 22))
|
||
})
|
||
|
||
test('validateSshTarget accepts valid targets', () => {
|
||
assert.doesNotThrow(() => validateSshTarget('my-host.example.com', 'alice', 22))
|
||
assert.doesNotThrow(() => validateSshTarget('192.168.1.1', '', 2222))
|
||
assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22))
|
||
})
|
||
|
||
test('SshConnection constructor rejects hostile host/user/port', () => {
|
||
assert.throws(() => new SshConnection({ host: '-oProxyCommand=evil' }), /unsafe/i)
|
||
assert.throws(() => new SshConnection({ host: 'box', user: '-oForward' }), /unsafe/i)
|
||
assert.throws(() => new SshConnection({ host: 'box', port: 99999 }), /port/i)
|
||
})
|
||
|
||
test('buildExecArgs inserts -- before the destination', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||
const args = buildExecArgs(conn, 'uname -s', 15000)
|
||
const ddIdx = args.indexOf('--')
|
||
assert.ok(ddIdx >= 0, 'must contain --')
|
||
assert.equal(args[ddIdx + 1], 'me@box', '-- immediately precedes the destination')
|
||
assert.equal(args[ddIdx + 2], 'uname -s', 'remote command follows destination')
|
||
})
|
||
|
||
test('buildMasterArgs inserts -- before the destination', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||
const args = buildMasterArgs(conn, 15000)
|
||
const ddIdx = args.indexOf('--')
|
||
assert.ok(ddIdx >= 0, 'must contain --')
|
||
assert.equal(args[ddIdx + 1], 'me@box')
|
||
})
|
||
|
||
test('buildControlArgs inserts -- before the destination', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||
const args = buildControlArgs(conn, 'check', [], 15000)
|
||
const ddIdx = args.indexOf('--')
|
||
assert.ok(ddIdx >= 0, 'must contain --')
|
||
assert.equal(args[ddIdx + 1], 'me@box')
|
||
})
|
||
|
||
test('buildInteractiveSshArgs inserts -- before the destination', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||
const args = buildInteractiveSshArgs(conn, '', 15000)
|
||
const ddIdx = args.indexOf('--')
|
||
assert.ok(ddIdx >= 0, 'must contain --')
|
||
assert.equal(args[ddIdx + 1], 'me@box')
|
||
})
|
||
|
||
test('hostArgs rejects a keyPath with control characters', () => {
|
||
assert.throws(() => hostArgs({ keyPath: '/tmp/key\x00inject' }), /unsafe/i)
|
||
})
|
||
|
||
test('hostArgs rejects a keyPath starting with a dash', () => {
|
||
assert.throws(() => hostArgs({ keyPath: '-oProxyCommand=evil' }), /unsafe/i)
|
||
})
|
||
|
||
test('hostArgs accepts valid key paths', () => {
|
||
assert.deepEqual(hostArgs({ keyPath: '/home/user/.ssh/id_ed25519' }), ['-i', '/home/user/.ssh/id_ed25519'])
|
||
assert.deepEqual(hostArgs({ keyPath: '~/.ssh/id_rsa' }), ['-i', '~/.ssh/id_rsa'])
|
||
})
|
||
|
||
test('runSsh delivers stdinData to the child and does not log it', async () => {
|
||
let stdinWritten = ''
|
||
|
||
const spawnFn: any = (_cmd, _args, opts) => {
|
||
const child: any = new EventEmitter()
|
||
child.stdout = new EventEmitter()
|
||
child.stderr = new EventEmitter()
|
||
|
||
child.kill = () => {}
|
||
child.stdin = {
|
||
end(data) {
|
||
stdinWritten = String(data)
|
||
}
|
||
}
|
||
assert.equal(opts.stdio[0], 'pipe', 'stdin must be pipe when stdinData is provided')
|
||
process.nextTick(() => child.emit('close', 0))
|
||
|
||
return child
|
||
}
|
||
|
||
await runSsh(['host', 'cat'], { timeoutMs: 5000, spawnFn, stdinData: 'secret-token-value' })
|
||
assert.equal(stdinWritten, 'secret-token-value', 'stdinData must be written to child.stdin')
|
||
})
|
||
|
||
test.runIf(process.platform !== 'win32')('open() rejects a control-dir that is a symlink', async () => {
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))
|
||
const real = path.join(tmp, 'real')
|
||
const link = path.join(tmp, 'link')
|
||
fs.mkdirSync(real, { mode: 0o700 })
|
||
fs.symlinkSync(real, link)
|
||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: link })
|
||
await assert.rejects(conn.open(), /symlink|unsafe/i)
|
||
fs.rmSync(tmp, { recursive: true, force: true })
|
||
})
|
||
|
||
test('open() enforces 0700 on an existing control dir with lax permissions', async () => {
|
||
if (process.platform === 'win32') {
|
||
return
|
||
}
|
||
|
||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))
|
||
const dir = path.join(tmp, 'ctrl')
|
||
fs.mkdirSync(dir, { mode: 0o755 })
|
||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
|
||
await conn.open()
|
||
const stat = fs.statSync(dir)
|
||
assert.equal(stat.mode & 0o777, 0o700, 'control dir must be tightened to 0700')
|
||
fs.rmSync(tmp, { recursive: true, force: true })
|
||
})
|
||
|
||
test('control socket identity separates installation scope and key identity', () => {
|
||
const base = controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||
ownershipId: 'installation-a',
|
||
scope: 'primary',
|
||
keyPath: '/keys/id'
|
||
})
|
||
|
||
assert.equal(
|
||
base,
|
||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||
ownershipId: 'installation-a',
|
||
scope: 'primary',
|
||
keyPath: '/keys/./id'
|
||
})
|
||
)
|
||
assert.notEqual(
|
||
base,
|
||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||
ownershipId: 'installation-a',
|
||
scope: 'worker',
|
||
keyPath: '/keys/id'
|
||
})
|
||
)
|
||
assert.notEqual(
|
||
base,
|
||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||
ownershipId: 'installation-b',
|
||
scope: 'primary',
|
||
keyPath: '/keys/id'
|
||
})
|
||
)
|
||
assert.notEqual(
|
||
base,
|
||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||
ownershipId: 'installation-a',
|
||
scope: 'primary',
|
||
keyPath: '/keys/other'
|
||
})
|
||
)
|
||
assert.notEqual(
|
||
base,
|
||
controlSocketPath('me', 'box', 22, '/tmp/d', {
|
||
ownershipId: 'installation-a',
|
||
scope: 'primary',
|
||
keyPath: '/keys/id',
|
||
effectiveConfigFingerprint: 'changed-config'
|
||
})
|
||
)
|
||
})
|
||
|
||
test('closing one scope addresses only that scope control master', async () => {
|
||
const firstSpawn = scriptedSpawn({ code: 0 })
|
||
const secondSpawn = scriptedSpawn({ code: 0 })
|
||
|
||
const first = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{
|
||
spawnFn: firstSpawn,
|
||
mux: true,
|
||
controlDir: '/tmp/d',
|
||
ownershipId: 'installation',
|
||
scope: 'first'
|
||
}
|
||
)
|
||
|
||
const second = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{
|
||
spawnFn: secondSpawn,
|
||
mux: true,
|
||
controlDir: '/tmp/d',
|
||
ownershipId: 'installation',
|
||
scope: 'second'
|
||
}
|
||
)
|
||
|
||
first._opened = true
|
||
second._opened = true
|
||
await first.close()
|
||
assert.notEqual(first.controlPath, second.controlPath)
|
||
assert.ok(firstSpawn.calls[0].includes(`ControlPath=${first.controlPath}`))
|
||
assert.ok(!firstSpawn.calls[0].includes(`ControlPath=${second.controlPath}`))
|
||
assert.equal(second._opened, true)
|
||
})
|
||
|
||
test('failed ControlMaster close disowns the master instead of retrying it', async () => {
|
||
// Old contract kept _opened=true for a retry — which left wedged ControlPersist
|
||
// masters trusted and reattachable (the macOS mode-switch livelock). New
|
||
// contract: a master that refuses -O exit is disowned — socket dropped,
|
||
// connection marked closed — so the next open dials fresh.
|
||
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }])
|
||
|
||
const conn = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn, mux: true, controlDir: '/tmp/d', controlMasterHolders: createControlMasterHolders() }
|
||
)
|
||
|
||
conn._opened = true
|
||
await conn.close()
|
||
assert.equal(conn._opened, false)
|
||
assert.equal(spawnFn.calls.length, 1)
|
||
})
|
||
|
||
test('stopTunnelChild waits for process exit', async () => {
|
||
const child: any = new EventEmitter()
|
||
child.exitCode = null
|
||
|
||
child.kill = () => {
|
||
process.nextTick(() => {
|
||
child.exitCode = 0
|
||
child.emit('exit', 0)
|
||
})
|
||
|
||
return true
|
||
}
|
||
|
||
let stopped = false
|
||
|
||
const stopping = stopTunnelChild(child).then(() => {
|
||
stopped = true
|
||
})
|
||
|
||
assert.equal(stopped, false)
|
||
await stopping
|
||
assert.equal(stopped, true)
|
||
})
|
||
|
||
test.skipIf(process.platform === 'win32')(
|
||
'withRemoteTimeout runs a healthy probe under a zsh login shell (#111949)',
|
||
async t => {
|
||
// SSH runs the remote command through the account's login shell. In
|
||
// non-interactive zsh, a bare `set -m` is fatal, so the wrapper must still
|
||
// run a healthy probe rather than reporting the remote as unsupported.
|
||
const zsh = await execFileAsync('sh', ['-c', 'command -v zsh || true']).then(r => r.stdout.trim())
|
||
|
||
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
|
||
// skip, not a pass, or a wrapper regression stays green unnoticed.
|
||
if (!zsh) {
|
||
t.skip('zsh not installed')
|
||
|
||
return
|
||
}
|
||
|
||
const { stdout: zshStdout } = await execFileAsync(zsh, ['-fc', withRemoteTimeout('echo zsh-ok', 5)])
|
||
|
||
assert.equal(zshStdout, 'zsh-ok\n')
|
||
}
|
||
)
|
||
|
||
test('withRemoteTimeout kills a hung probe remotely instead of orphaning it (#110478)', async () => {
|
||
if (process.platform === 'win32') {
|
||
return
|
||
}
|
||
|
||
// Shape: POSIX watchdog — macOS remotes have no GNU `timeout`.
|
||
const wrapped = withRemoteTimeout('hermes --version 2>&1', 15)
|
||
|
||
assert.ok(!/(^|[ ;(])timeout[ ;]/.test(wrapped), 'no GNU timeout dependency')
|
||
assert.ok(
|
||
withRemoteTimeout('true').includes(`sleep ${REMOTE_PROBE_TIMEOUT_SECS}`),
|
||
'defaults to REMOTE_PROBE_TIMEOUT_SECS'
|
||
)
|
||
assert.ok(REMOTE_PROBE_TIMEOUT_SECS * 1000 < 20_000, 'remote watchdog fires before the local exec timeout')
|
||
|
||
// Behavior through a real POSIX shell: healthy output passes through …
|
||
const healthyStart = Date.now()
|
||
const { stdout } = await execFileAsync('sh', ['-c', withRemoteTimeout('echo hello', 5)])
|
||
const healthyElapsed = Date.now() - healthyStart
|
||
|
||
assert.equal(stdout, 'hello\n')
|
||
// … and returns promptly: the watchdog's orphaned `sleep` must not hold the
|
||
// session pipes open until the full timeout on the healthy path.
|
||
assert.ok(healthyElapsed < 4000, `healthy probe returned fast (took ${healthyElapsed}ms)`)
|
||
|
||
// … a hung command is killed promptly with a non-zero exit … The duration
|
||
// is unique to this run so the orphan sweep below cannot match an unrelated
|
||
// `sleep` on a busy host.
|
||
const hungSecs = 30_000 + (process.pid % 10_000)
|
||
const start = Date.now()
|
||
|
||
const err: any = await execFileAsync('sh', ['-c', withRemoteTimeout(`sleep ${hungSecs}`, 1)]).then(
|
||
() => null,
|
||
e => e
|
||
)
|
||
|
||
const elapsed = Date.now() - start
|
||
|
||
assert.ok(err && err.code !== 0, 'hung command must exit non-zero')
|
||
assert.ok(elapsed < 15000, `watchdog fired promptly instead of waiting ${hungSecs}s (took ${elapsed}ms)`)
|
||
|
||
// … and no orphan is left behind.
|
||
const { stdout: strays } = await execFileAsync('sh', ['-c', `ps -eo args | grep "[s]leep ${hungSecs}$" || true`])
|
||
|
||
assert.equal(strays.trim(), '', 'killed probe left no orphan process')
|
||
|
||
// … including the grandchild of a launcher that runs the CLI without exec
|
||
// (the broken-launcher class of #110478). Needs a shell with job control
|
||
// off a tty; bash has it, dash does not.
|
||
const bash = await execFileAsync('sh', ['-c', 'command -v bash || true']).then(r => r.stdout.trim())
|
||
|
||
if (bash) {
|
||
const grandSecs = hungSecs + 1
|
||
const launcher = `sh -c 'sleep ${grandSecs}; echo done'`
|
||
|
||
const err2: any = await execFileAsync(bash, ['-c', withRemoteTimeout(launcher, 1)]).then(
|
||
() => null,
|
||
e => e
|
||
)
|
||
|
||
assert.ok(err2 && err2.code !== 0, 'hung launcher must exit non-zero')
|
||
|
||
const { stdout: grandStrays } = await execFileAsync('sh', [
|
||
'-c',
|
||
`ps -eo args | grep "[s]leep ${grandSecs}$" || true`
|
||
])
|
||
|
||
assert.equal(grandStrays.trim(), '', 'watchdog killed the launcher’s grandchild too')
|
||
}
|
||
})
|
||
|
||
// #97264: every attempt for one scope/host/identity hashes to the same
|
||
// ControlPath, so a stale attempt's `-O exit` used to kill the master its
|
||
// successor had attached to (the live backend's forward died ~40s after
|
||
// "ready"). Script the master as shared state so each op reflects it.
|
||
function sharedMasterSpawn() {
|
||
const state = { alive: false, exits: 0 }
|
||
|
||
const spawnFn = scriptedSpawn(args => {
|
||
if (args.includes('check')) {
|
||
return state.alive ? { code: 0 } : { code: 255, stderr: 'no control master' }
|
||
}
|
||
|
||
if (args.includes('-M')) {
|
||
state.alive = true
|
||
|
||
return { code: 0 }
|
||
}
|
||
|
||
if (args.includes('-O') && args.includes('exit')) {
|
||
state.exits += 1
|
||
state.alive = false
|
||
|
||
return { code: 0 }
|
||
}
|
||
|
||
return { code: 0 } // `exit 0` verify exec through the live master
|
||
})
|
||
|
||
return { spawnFn, state }
|
||
}
|
||
|
||
test('a stale attempt closing late leaves the master its successor attached to (#97264)', async () => {
|
||
const { spawnFn, state } = sharedMasterSpawn()
|
||
const controlMasterHolders = createControlMasterHolders()
|
||
const opts = { spawnFn, mux: true, controlDir: '/tmp/d', controlMasterHolders }
|
||
|
||
const stale = new SshConnection({ host: 'box', user: 'me' }, opts)
|
||
await stale.open()
|
||
|
||
const winner = new SshConnection({ host: 'box', user: 'me' }, opts)
|
||
await winner.open()
|
||
assert.equal(winner.controlPath, stale.controlPath, 'precondition: both attempts share one ControlPath')
|
||
|
||
await stale.close()
|
||
assert.equal(state.exits, 0, 'the stale attempt must not run -O exit on the shared socket')
|
||
assert.equal(await winner.isAlive(), true, 'the winning attempt keeps its master')
|
||
|
||
await winner.close()
|
||
assert.equal(state.exits, 1, 'the last holder still tears the master down')
|
||
assert.equal(state.alive, false)
|
||
})
|
||
|
||
test('a failed newer attempt does not kill the master an older live connection holds (#97264)', async () => {
|
||
const { spawnFn, state } = sharedMasterSpawn()
|
||
const controlMasterHolders = createControlMasterHolders()
|
||
const opts = { spawnFn, mux: true, controlDir: '/tmp/d', controlMasterHolders }
|
||
|
||
const live = new SshConnection({ host: 'box', user: 'me' }, opts)
|
||
await live.open()
|
||
const failed = new SshConnection({ host: 'box', user: 'me' }, opts)
|
||
await failed.open()
|
||
|
||
await failed.close()
|
||
assert.equal(state.exits, 0)
|
||
assert.equal(await live.isAlive(), true)
|
||
})
|
||
|
||
test('a stale close while a successor is still dialing does not exit the master (#97264)', async () => {
|
||
const controlMasterHolders = createControlMasterHolders()
|
||
const exits: string[][] = []
|
||
|
||
let releaseMaster: () => void = () => {}
|
||
|
||
const spawnFn: any = (_cmd, args) => {
|
||
if (args.includes('-O') && args.includes('exit')) {
|
||
exits.push(args)
|
||
}
|
||
|
||
if (args.includes('check')) {
|
||
return fakeChild({ code: 255 })
|
||
}
|
||
|
||
if (args.includes('-M')) {
|
||
const child = fakeChild({ hang: true })
|
||
releaseMaster = () => child.emit('close', 0, null)
|
||
|
||
return child
|
||
}
|
||
|
||
return fakeChild({ code: 0 })
|
||
}
|
||
|
||
const opts = { spawnFn, mux: true, controlDir: '/tmp/d', controlMasterHolders, connectTimeoutMs: 5000 }
|
||
const staleSpawn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||
|
||
const stale = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn: staleSpawn, mux: true, controlDir: '/tmp/d', controlMasterHolders }
|
||
)
|
||
|
||
await stale.open()
|
||
|
||
const successor = new SshConnection({ host: 'box', user: 'me' }, opts)
|
||
const dialing = successor.open()
|
||
await new Promise(resolve => setImmediate(resolve))
|
||
|
||
await stale.close()
|
||
assert.ok(
|
||
!staleSpawn.calls.some(args => args.includes('-O') && args.includes('exit')),
|
||
'the stale attempt must not exit a master a dialing successor is attaching to'
|
||
)
|
||
|
||
releaseMaster()
|
||
await dialing
|
||
assert.equal(controlMasterHolders.count(successor.controlPath), 1)
|
||
})
|
||
|
||
test('a failed open releases its claim so the remaining holder can still close the master', async () => {
|
||
const controlMasterHolders = createControlMasterHolders()
|
||
const { spawnFn, state } = sharedMasterSpawn()
|
||
|
||
const live = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn, mux: true, controlDir: '/tmp/d', controlMasterHolders }
|
||
)
|
||
|
||
await live.open()
|
||
|
||
const authFail = scriptedSpawn(args =>
|
||
args.includes('check') ? { code: 255 } : { code: 255, stderr: 'Permission denied (publickey).' }
|
||
)
|
||
|
||
const broken = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn: authFail, mux: true, controlDir: '/tmp/d', controlMasterHolders }
|
||
)
|
||
|
||
await assert.rejects(() => broken.open())
|
||
await live.close()
|
||
assert.equal(state.exits, 1, 'the failed opener left no phantom claim behind')
|
||
})
|
||
|
||
test('#103288: every spawn uses the injected sshBinary; the default stays bare ssh', async () => {
|
||
const commands: string[] = []
|
||
|
||
const spawnFn: any = (cmd: string) => {
|
||
commands.push(cmd)
|
||
|
||
return fakeChild({ code: 0 })
|
||
}
|
||
|
||
const custom = createSshProbeConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{ spawnFn, sshBinary: 'C:\\Program Files\\Git\\usr\\bin\\ssh.exe' }
|
||
)
|
||
|
||
await custom.open()
|
||
await custom.exec('true')
|
||
assert.ok(commands.length >= 2, 'open + exec both spawned')
|
||
assert.deepEqual([...new Set(commands)], ['C:\\Program Files\\Git\\usr\\bin\\ssh.exe'])
|
||
|
||
commands.length = 0
|
||
const plain = createSshProbeConnection({ host: 'box', user: 'me' }, { spawnFn })
|
||
await plain.open()
|
||
assert.deepEqual([...new Set(commands)], ['ssh'])
|
||
})
|