The zsh login-shell legs in remote-lifecycle.test.ts and ssh-connection.test.ts silently returned when zsh was missing, and the js-tests runner image ships no zsh, so the #111949 coverage never ran on CI and a wrapper regression stayed green. - js-tests.yml: install zsh on the Linux runner before the checks. - Both legs now report vitest skips ('zsh not installed') instead of passing; the ssh-connection leg is its own test so the skip is visible. - Docs: note the zsh degraded mode (no process-group kill for a hung probe's grandchildren) in the SSH connection guide.
1166 lines
41 KiB
TypeScript
1166 lines
41 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 } from 'vitest'
|
||
|
||
import {
|
||
baseSshOptions,
|
||
buildControlArgs,
|
||
buildExecArgs,
|
||
buildInteractiveSshArgs,
|
||
buildMasterArgs,
|
||
classifySshError,
|
||
controlSocketPath,
|
||
createSshProbeConnection,
|
||
forwardSpec,
|
||
hostArgs,
|
||
redactSecrets,
|
||
REMOTE_PROBE_TIMEOUT_SECS,
|
||
runSsh,
|
||
SSH_ERROR,
|
||
SshConnection,
|
||
sshErrorMessage,
|
||
stopTunnelChild,
|
||
target,
|
||
validateSshTarget,
|
||
withRemoteTimeout
|
||
} from './ssh-connection'
|
||
|
||
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(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('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.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)
|
||
})
|
||
|
||
test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => {
|
||
const conn = { user: 'me', host: 'box', port: 22 }
|
||
assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/)
|
||
assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/)
|
||
})
|
||
|
||
// A fake child process that emits a scripted result on next tick.
|
||
function fakeChild({ code = 0, 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) {
|
||
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', code)
|
||
})
|
||
|
||
return child
|
||
}
|
||
|
||
// 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, 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, 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, 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, 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, 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('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, 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'))
|
||
})
|
||
|
||
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, 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('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('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,
|
||
controlDir: '/tmp/d',
|
||
ownershipId: 'installation',
|
||
scope: 'first'
|
||
}
|
||
)
|
||
|
||
const second = new SshConnection(
|
||
{ host: 'box', user: 'me' },
|
||
{
|
||
spawnFn: secondSpawn,
|
||
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, controlDir: '/tmp/d' })
|
||
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(wrapped.includes('sleep 15'), 'watchdog duration honored')
|
||
assert.ok(wrapped.includes('kill -9'), 'watchdog kills the hung child remotely')
|
||
assert.ok(!/(^|[ ;(])timeout[ ;]/.test(wrapped), 'no GNU timeout dependency')
|
||
assert.ok(wrapped.endsWith('exit $__htrc'), 'inner exit code propagated')
|
||
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')
|
||
}
|
||
})
|