orca/src/main/ssh/ssh-connection-utils.test.ts

826 lines
28 KiB
TypeScript

import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'
import { join } from 'node:path'
import { BaseAgent, utils, type ParsedKey } from 'ssh2'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
vi.mock('os', () => ({
homedir: () => '/home/testuser'
}))
const mockExistsSync = vi.fn().mockReturnValue(false)
const mockReadFileSync = vi.fn()
const TEST_HOME = '/home/testuser'
function testHomePath(...parts: string[]): string {
return join(TEST_HOME, ...parts)
}
vi.mock('fs', () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readFileSync: (...args: unknown[]) => mockReadFileSync(...args)
}))
import {
isTransientError,
isSystemSshFallbackError,
isGssapiSystemSshFallbackCandidate,
isAuthError,
isAgentFallbackError,
sleep,
shellEscape,
findDefaultKeyFile,
buildConnectConfig,
resolveAgentSocket,
resolveEffectiveProxy,
CONNECT_TIMEOUT_MS,
INITIAL_RETRY_ATTEMPTS,
INITIAL_RETRY_DELAY_MS,
RECONNECT_BACKOFF_MS,
spawnProxyCommand
} from './ssh-connection-utils'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
type MockProxyProcess = EventEmitter & {
stdin: EventEmitter & { write: ReturnType<typeof vi.fn> }
stdout: EventEmitter
stderr: EventEmitter
}
function createMockProxyProcess(): MockProxyProcess {
const proc = new EventEmitter() as MockProxyProcess
proc.stdin = Object.assign(new EventEmitter(), {
write: vi.fn((_chunk, cb?: (error?: Error | null) => void) => cb?.())
})
proc.stdout = new EventEmitter()
proc.stderr = new EventEmitter()
return proc
}
// ── Constants ────────────────────────────────────────────────────────
describe('SSH connection constants', () => {
it('CONNECT_TIMEOUT_MS is 30 seconds (matches VS Code)', () => {
expect(CONNECT_TIMEOUT_MS).toBe(30_000)
})
it('INITIAL_RETRY_ATTEMPTS is 5', () => {
expect(INITIAL_RETRY_ATTEMPTS).toBe(5)
})
it('INITIAL_RETRY_DELAY_MS is 2 seconds', () => {
expect(INITIAL_RETRY_DELAY_MS).toBe(2000)
})
it('RECONNECT_BACKOFF_MS has 9 entries', () => {
expect(RECONNECT_BACKOFF_MS).toHaveLength(9)
})
})
// ── isTransientError ─────────────────────────────────────────────────
describe('isTransientError', () => {
it('returns true for ETIMEDOUT code', () => {
const err = new Error('timed out') as NodeJS.ErrnoException
err.code = 'ETIMEDOUT'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ECONNREFUSED code', () => {
const err = new Error('refused') as NodeJS.ErrnoException
err.code = 'ECONNREFUSED'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ECONNRESET code', () => {
const err = new Error('reset') as NodeJS.ErrnoException
err.code = 'ECONNRESET'
expect(isTransientError(err)).toBe(true)
})
it('returns true for EHOSTUNREACH code', () => {
const err = new Error('host unreachable') as NodeJS.ErrnoException
err.code = 'EHOSTUNREACH'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ENETUNREACH code', () => {
const err = new Error('net unreachable') as NodeJS.ErrnoException
err.code = 'ENETUNREACH'
expect(isTransientError(err)).toBe(true)
})
it('returns true for EAI_AGAIN code', () => {
const err = new Error('dns') as NodeJS.ErrnoException
err.code = 'EAI_AGAIN'
expect(isTransientError(err)).toBe(true)
})
it('returns true for ETIMEDOUT in message (no code)', () => {
expect(isTransientError(new Error('connect ETIMEDOUT 1.2.3.4:22'))).toBe(true)
})
it('returns true for ECONNREFUSED in message', () => {
expect(isTransientError(new Error('connect ECONNREFUSED 1.2.3.4:22'))).toBe(true)
})
it('returns true for ECONNRESET in message', () => {
expect(isTransientError(new Error('read ECONNRESET'))).toBe(true)
})
it('returns false for auth errors', () => {
expect(isTransientError(new Error('All configured authentication methods failed'))).toBe(false)
})
it('returns false for generic errors', () => {
expect(isTransientError(new Error('something went wrong'))).toBe(false)
})
})
// ── isSystemSshFallbackError ─────────────────────────────────────────
describe('isSystemSshFallbackError', () => {
it('returns true for local reachability errors that system ssh may bypass', () => {
const hostErr = new Error('host unreachable') as NodeJS.ErrnoException
hostErr.code = 'EHOSTUNREACH'
const netErr = new Error('net unreachable') as NodeJS.ErrnoException
netErr.code = 'ENETUNREACH'
expect(isSystemSshFallbackError(hostErr)).toBe(true)
expect(isSystemSshFallbackError(netErr)).toBe(true)
})
it('returns false for transient errors that should keep the normal retry path', () => {
const refused = new Error('refused') as NodeJS.ErrnoException
refused.code = 'ECONNREFUSED'
expect(isSystemSshFallbackError(refused)).toBe(false)
expect(isSystemSshFallbackError(new Error('connect ETIMEDOUT 1.2.3.4:22'))).toBe(false)
})
})
// ── isGssapiSystemSshFallbackCandidate ───────────────────────────────
describe('isGssapiSystemSshFallbackCandidate', () => {
const authErr = new Error('All configured authentication methods failed')
it('returns true for auth failures when resolved config enables GSSAPI', () => {
expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: true })).toBe(
true
)
})
it('returns true for passphrase failures so Kerberos SSO runs before prompting', () => {
const passphraseErr = new Error('Encrypted private OpenSSH key detected, but no passphrase')
expect(
isGssapiSystemSshFallbackCandidate(passphraseErr, {}, { gssapiAuthentication: true })
).toBe(true)
})
it('returns false when the target already tried system ssh proactively', () => {
expect(
isGssapiSystemSshFallbackCandidate(
authErr,
{ gssapiAuthentication: true },
{ gssapiAuthentication: true }
)
).toBe(false)
})
it('returns false without GSSAPI in the resolved config', () => {
expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: false })).toBe(
false
)
expect(isGssapiSystemSshFallbackCandidate(authErr, {}, null)).toBe(false)
})
it('returns false for network errors so retry semantics stay unchanged', () => {
const netErr = new Error('connect ETIMEDOUT 1.2.3.4:22')
expect(isGssapiSystemSshFallbackCandidate(netErr, {}, { gssapiAuthentication: true })).toBe(
false
)
})
})
// ── isAuthError ──────────────────────────────────────────────────────
describe('isAuthError', () => {
it('returns true for "All configured authentication methods failed"', () => {
expect(isAuthError(new Error('All configured authentication methods failed'))).toBe(true)
})
it('returns true for "Authentication failed"', () => {
expect(isAuthError(new Error('Authentication failed'))).toBe(true)
})
it('returns true for client-authentication level', () => {
const err = new Error('auth') as Error & { level: string }
err.level = 'client-authentication'
expect(isAuthError(err)).toBe(true)
})
it('returns true for server auth-attempt exhaustion', () => {
expect(isAuthError(new Error('Received disconnect: Too many authentication failures'))).toBe(
true
)
})
it('returns false for transient errors', () => {
expect(isAuthError(new Error('connect ETIMEDOUT'))).toBe(false)
})
})
// ── isAgentFallbackError ────────────────────────────────────────────
describe('isAgentFallbackError', () => {
it('returns true for ssh2 agent-level failures', () => {
const err = new Error('Failed to connect to agent') as Error & { level: string }
err.level = 'agent'
expect(isAgentFallbackError(err)).toBe(true)
})
it('returns true when agent auth exhausts the server auth attempt limit', () => {
expect(
isAgentFallbackError(new Error('Received disconnect: Too many authentication failures'))
).toBe(true)
})
it('keeps unrelated transport errors out of agent fallback handling', () => {
expect(isAgentFallbackError(new Error('connect ECONNRESET'))).toBe(false)
})
})
// ── sleep ────────────────────────────────────────────────────────────
describe('sleep', () => {
it('resolves after the given delay', async () => {
const start = Date.now()
await sleep(50)
expect(Date.now() - start).toBeGreaterThanOrEqual(40)
})
})
// ── shellEscape ──────────────────────────────────────────────────────
describe('shellEscape', () => {
it('wraps string in single quotes', () => {
expect(shellEscape('hello')).toBe("'hello'")
})
it('escapes embedded single quotes', () => {
expect(shellEscape("it's")).toBe("'it'\\''s'")
})
it('handles empty string', () => {
expect(shellEscape('')).toBe("''")
})
it('handles special characters', () => {
expect(shellEscape('foo bar; rm -rf /')).toBe("'foo bar; rm -rf /'")
})
})
// ── findDefaultKeyFile ───────────────────────────────────────────────
describe('findDefaultKeyFile', () => {
beforeEach(() => {
mockExistsSync.mockReset()
mockExistsSync.mockReturnValue(false)
mockReadFileSync.mockReset()
})
it('returns undefined when no default keys exist', () => {
expect(findDefaultKeyFile()).toBeUndefined()
})
it('returns the first existing key file', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return path === testHomePath('.ssh', 'id_ed25519')
})
mockReadFileSync.mockReturnValue(Buffer.from('key-contents'))
const result = findDefaultKeyFile()
expect(result).toBeDefined()
expect(result!.path).toBe('~/.ssh/id_ed25519')
expect(result!.contents).toEqual(Buffer.from('key-contents'))
})
it('probes regular and FIDO2 keys in stable default order', () => {
const checkedPaths: string[] = []
mockExistsSync.mockImplementation((path: unknown) => {
checkedPaths.push(String(path))
return false
})
findDefaultKeyFile()
expect(checkedPaths).toEqual([
testHomePath('.ssh', 'id_ed25519'),
testHomePath('.ssh', 'id_rsa'),
testHomePath('.ssh', 'id_ecdsa'),
testHomePath('.ssh', 'id_dsa'),
testHomePath('.ssh', 'id_xmss')
])
})
it('keeps a regular default ahead of a malformed FIDO2 default', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return (
path === testHomePath('.ssh', 'id_rsa') || path === testHomePath('.ssh', 'id_ed25519_sk')
)
})
mockReadFileSync.mockImplementation((path: unknown) => {
if (String(path) === testHomePath('.ssh', 'id_ed25519_sk')) {
throw new Error('malformed FIDO2 key')
}
return Buffer.from('rsa-key')
})
expect(findDefaultKeyFile()).toEqual({
path: '~/.ssh/id_rsa',
contents: Buffer.from('rsa-key')
})
})
it('leaves FIDO2 defaults out of the ssh2 private-key fallback', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return path === testHomePath('.ssh', 'id_ed25519_sk')
})
expect(findDefaultKeyFile()).toBeUndefined()
expect(mockReadFileSync).not.toHaveBeenCalled()
})
it('skips unreadable key files and tries next', () => {
mockExistsSync.mockImplementation((path: unknown) => {
return path === testHomePath('.ssh', 'id_ed25519') || path === testHomePath('.ssh', 'id_rsa')
})
mockReadFileSync.mockImplementation((path: unknown) => {
if (String(path) === testHomePath('.ssh', 'id_ed25519')) {
throw new Error('permission denied')
}
return Buffer.from('rsa-key')
})
const result = findDefaultKeyFile()
expect(result).toBeDefined()
expect(result!.path).toBe('~/.ssh/id_rsa')
})
})
// ── buildConnectConfig ──────────────────────────────────────────────
function makeTarget(overrides?: Partial<SshTarget>): SshTarget {
return {
id: 'test-1',
label: 'myhost',
host: 'example.com',
port: 22,
username: 'deploy',
...overrides
}
}
function makeResolved(overrides?: Partial<SshResolvedConfig>): SshResolvedConfig {
return {
hostname: '10.0.0.1',
port: 22,
identityFile: [],
forwardAgent: false,
identitiesOnly: false,
proxyUseFdpass: false,
controlMaster: 'no',
controlPersist: 'no',
...overrides
}
}
describe('buildConnectConfig', () => {
const originalEnv = process.env.SSH_AUTH_SOCK
beforeEach(() => {
mockExistsSync.mockReset()
mockExistsSync.mockReturnValue(false)
mockReadFileSync.mockReset()
process.env.SSH_AUTH_SOCK = '/tmp/agent.sock'
})
afterEach(() => {
vi.restoreAllMocks()
if (originalEnv !== undefined) {
process.env.SSH_AUTH_SOCK = originalEnv
} else {
delete process.env.SSH_AUTH_SOCK
}
})
it('uses target host/port/username', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.host).toBe('example.com')
expect(config.port).toBe(22)
expect(config.username).toBe('deploy')
})
it('falls back to resolved config when target fields are empty', () => {
const config = buildConnectConfig(
makeTarget({ host: '', port: 0, username: '' }),
makeResolved({ hostname: '10.0.0.1', port: 2222, user: 'admin' })
)
expect(config.host).toBe('10.0.0.1')
expect(config.port).toBe(2222)
expect(config.username).toBe('admin')
})
it('uses ssh -G HostName when a config-host target still points at its alias', () => {
const config = buildConnectConfig(
makeTarget({ label: 'workbox', configHost: 'workbox', host: 'workbox' }),
makeResolved({ hostname: 'workbox.internal' })
)
expect(config.host).toBe('workbox.internal')
})
it('uses ssh -G Port when a config-host target still has the default port', () => {
const config = buildConnectConfig(
makeTarget({ configHost: 'workbox', host: 'workbox', port: 22 }),
makeResolved({ port: 2202 })
)
expect(config.port).toBe(2202)
})
it('keeps explicit non-default target ports ahead of ssh -G Port', () => {
const config = buildConnectConfig(
makeTarget({ configHost: 'workbox', host: 'workbox', port: 2022 }),
makeResolved({ port: 2202 })
)
expect(config.port).toBe(2022)
})
it('uses fresh OpenSSH endpoint authority for imported config targets', () => {
const config = buildConnectConfig(
makeTarget({
source: 'ssh-config',
configHost: 'workbox',
host: 'stale.example.com',
port: 2022,
username: 'stale-user'
}),
makeResolved({
hostname: 'current.example.com',
port: 2202,
user: 'current-user'
})
)
expect(config.host).toBe('current.example.com')
expect(config.port).toBe(2202)
expect(config.username).toBe('current-user')
})
it('keeps imported endpoint fields as the fallback when ssh -G is unavailable', () => {
const config = buildConnectConfig(
makeTarget({
source: 'ssh-config',
configHost: 'workbox',
host: 'fallback.example.com',
port: 2022,
username: 'fallback-user'
}),
null
)
expect(config.host).toBe('fallback.example.com')
expect(config.port).toBe(2022)
expect(config.username).toBe('fallback-user')
})
it('sets readyTimeout to CONNECT_TIMEOUT_MS', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.readyTimeout).toBe(30_000)
})
it('sets keepaliveInterval to 15s', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.keepaliveInterval).toBe(15_000)
})
it('uses agent auth when no explicit key and SSH_AUTH_SOCK is set', () => {
const config = buildConnectConfig(makeTarget(), null)
expect(config.agent).toBe('/tmp/agent.sock')
})
it('enables agent forwarding when OpenSSH config requests it and an agent is available', () => {
const config = buildConnectConfig(makeTarget(), makeResolved({ forwardAgent: true }))
expect(config.agent).toBe('/tmp/agent.sock')
expect(config.agentForward).toBe(true)
})
it('does not enable agent forwarding without a usable agent', () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
delete process.env.SSH_AUTH_SOCK
try {
const config = buildConnectConfig(makeTarget(), makeResolved({ forwardAgent: true }))
expect(config.agent).toBeUndefined()
expect(config.agentForward).toBeUndefined()
} finally {
platformSpy.mockRestore()
}
})
it('uses configured IdentityAgent before SSH_AUTH_SOCK', () => {
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityAgent: '/tmp/one-password.sock' })
)
expect(config.agent).toBe('/tmp/one-password.sock')
})
it('prefers ssh -G resolved IdentityAgent for config-host targets', () => {
const config = buildConnectConfig(
makeTarget({ configHost: 'work', identityAgent: '%d/.1password/agent.sock' }),
makeResolved({ identityAgent: testHomePath('.1password', 'agent.sock') })
)
expect(config.agent).toBe(testHomePath('.1password', 'agent.sock'))
})
it('allows IdentityAgent none to disable agent auth', () => {
const config = buildConnectConfig(makeTarget(), makeResolved({ identityAgent: 'none' }))
expect(config.agent).toBeUndefined()
})
it('resolves IdentityAgent SSH_AUTH_SOCK from the environment', () => {
expect(resolveAgentSocket(makeTarget(), makeResolved({ identityAgent: 'SSH_AUTH_SOCK' }))).toBe(
'/tmp/agent.sock'
)
expect(
resolveAgentSocket(makeTarget(), makeResolved({ identityAgent: '$SSH_AUTH_SOCK' }))
).toBe('/tmp/agent.sock')
})
it('uses the Windows OpenSSH agent pipe when no environment socket is available on Windows', () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
delete process.env.SSH_AUTH_SOCK
try {
expect(resolveAgentSocket(makeTarget(), null)).toBe('\\\\.\\pipe\\openssh-ssh-agent')
} finally {
platformSpy.mockRestore()
}
})
it('wraps agent auth with IdentityFile filtering when IdentitiesOnly is enabled', () => {
mockReadFileSync.mockImplementation((path: unknown) => {
if (String(path) === '/home/user/.ssh/work_key.pub') {
return 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILI4wa2zRZoB26D015dsafYmu3jDCI7rh26bFXZrUiAp test-key'
}
throw new Error('unexpected read')
})
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityFile: ['/home/user/.ssh/work_key'], identitiesOnly: true })
)
expect(config.agent).toMatchObject({ kind: 'identity-filtered-agent' })
expect(config.agent).toBeInstanceOf(BaseAgent)
expect(config.privateKey).toBeUndefined()
expect(mockReadFileSync).toHaveBeenCalledWith('/home/user/.ssh/work_key.pub')
})
it('does not offer broad agent auth when IdentitiesOnly keys cannot be parsed', () => {
mockReadFileSync.mockReturnValue(Buffer.from('not-a-key'))
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityFile: ['/home/user/.ssh/work_key'], identitiesOnly: true })
)
expect(config.agent).toBeUndefined()
expect(config.privateKey).toEqual(Buffer.from('not-a-key'))
})
it('includes unencrypted target.identityFile auth when an agent is available', () => {
vi.spyOn(utils, 'parseKey').mockReturnValue({
isPrivateKey: () => true
} as ParsedKey)
mockReadFileSync.mockReturnValue(Buffer.from('key'))
const config = buildConnectConfig(makeTarget({ identityFile: '/home/user/.ssh/custom' }), null)
expect(config.agent).toBe('/tmp/agent.sock')
expect(config.privateKey).toEqual(Buffer.from('key'))
expect(mockReadFileSync).toHaveBeenCalledWith('/home/user/.ssh/custom')
})
it('defers encrypted target.identityFile auth when an agent is available', () => {
vi.spyOn(utils, 'parseKey').mockReturnValue(
new Error('Encrypted private OpenSSH key detected, but no passphrase given')
)
mockReadFileSync.mockReturnValue(Buffer.from('encrypted-key'))
const config = buildConnectConfig(makeTarget({ identityFile: '/home/user/.ssh/custom' }), null)
expect(config.agent).toBe('/tmp/agent.sock')
expect(config.privateKey).toBeUndefined()
expect(mockReadFileSync).toHaveBeenCalledWith('/home/user/.ssh/custom')
})
it('uses keyFile auth when target.identityFile is set and no agent is available', () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
delete process.env.SSH_AUTH_SOCK
mockReadFileSync.mockReturnValue(Buffer.from('key'))
try {
const config = buildConnectConfig(
makeTarget({ identityFile: '/home/user/.ssh/custom' }),
null
)
expect(config.privateKey).toEqual(Buffer.from('key'))
expect(config.agent).toBeUndefined()
} finally {
platformSpy.mockRestore()
}
})
it('uses fresh OpenSSH IdentityFile authority for imported config targets', () => {
mockReadFileSync.mockImplementation((path: unknown) => Buffer.from(String(path)))
const config = buildConnectConfig(
makeTarget({
source: 'ssh-config',
configHost: 'workbox',
identityFile: '/home/user/.ssh/stale'
}),
makeResolved({ identityFile: ['/home/user/.ssh/current'] }),
{ includeAgent: false, includePrivateKey: true }
)
expect(config.privateKey).toEqual(Buffer.from('/home/user/.ssh/current'))
expect(mockReadFileSync).toHaveBeenCalledWith('/home/user/.ssh/current')
})
it('expands Windows-style target.identityFile before reading private key', () => {
mockReadFileSync.mockReturnValue(Buffer.from('key'))
const config = buildConnectConfig(makeTarget({ identityFile: '~\\.ssh\\custom' }), null, {
includeAgent: false,
includePrivateKey: true
})
expect(config.privateKey).toEqual(Buffer.from('key'))
expect(mockReadFileSync).toHaveBeenCalledWith(testHomePath('.ssh', 'custom'))
})
it('includes unencrypted resolved identityFile auth when an agent is available', () => {
vi.spyOn(utils, 'parseKey').mockReturnValue({
isPrivateKey: () => true
} as ParsedKey)
mockReadFileSync.mockReturnValue(Buffer.from('custom-key'))
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityFile: ['/home/user/.ssh/work_key'] })
)
expect(config.agent).toBe('/tmp/agent.sock')
expect(config.privateKey).toEqual(Buffer.from('custom-key'))
})
it('uses agent auth without probing when resolved identityFile is a default path (expanded)', () => {
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityFile: [testHomePath('.ssh', 'id_ed25519')] })
)
expect(config.agent).toBe('/tmp/agent.sock')
expect(config.privateKey).toBeUndefined()
expect(mockReadFileSync).not.toHaveBeenCalled()
})
it('does not probe default key files before agent auth', () => {
mockExistsSync.mockImplementation(
(p: unknown) => String(p) === testHomePath('.ssh', 'id_ed25519')
)
const config = buildConnectConfig(makeTarget(), null)
expect(config.agent).toBe('/tmp/agent.sock')
expect(config.privateKey).toBeUndefined()
expect(mockExistsSync).not.toHaveBeenCalled()
})
it('provides fallback key when no agent is available', () => {
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
delete process.env.SSH_AUTH_SOCK
mockExistsSync.mockImplementation(
(p: unknown) => String(p) === testHomePath('.ssh', 'id_ed25519')
)
mockReadFileSync.mockReturnValue(Buffer.from('fallback'))
try {
const config = buildConnectConfig(makeTarget(), null)
expect(config.agent).toBeUndefined()
expect(config.privateKey).toEqual(Buffer.from('fallback'))
} finally {
platformSpy.mockRestore()
}
})
it('can force private key inclusion for the post-agent fallback path', () => {
mockReadFileSync.mockReturnValue(Buffer.from('key'))
const config = buildConnectConfig(
makeTarget({ identityFile: '/home/user/.ssh/custom' }),
null,
{ includeAgent: false, includePrivateKey: true }
)
expect(config.agent).toBeUndefined()
expect(config.privateKey).toEqual(Buffer.from('key'))
})
})
// ── resolveEffectiveProxy ───────────────────────────────────────────
describe('resolveEffectiveProxy', () => {
it('returns target.proxyCommand first', () => {
const target = { ...makeTarget(), proxyCommand: 'cloudflared access ssh --hostname %h' }
const resolved = makeResolved({ proxyCommand: 'other' })
expect(resolveEffectiveProxy(target, resolved)).toEqual({
kind: 'proxy-command',
command: 'cloudflared access ssh --hostname %h'
})
})
it('uses fresh OpenSSH proxy authority for imported config targets', () => {
const target = {
...makeTarget(),
source: 'ssh-config' as const,
configHost: 'workbox',
proxyCommand: 'ssh -W %h:%p stale-bastion'
}
expect(resolveEffectiveProxy(target, makeResolved())).toBeUndefined()
expect(
resolveEffectiveProxy(target, makeResolved({ proxyCommand: 'ssh -W %h:%p current-bastion' }))
).toEqual({
kind: 'proxy-command',
command: 'ssh -W %h:%p current-bastion'
})
})
it('falls back to resolved proxyCommand', () => {
expect(
resolveEffectiveProxy(makeTarget(), makeResolved({ proxyCommand: 'ssh -W %h:%p gw' }))
).toEqual({
kind: 'proxy-command',
command: 'ssh -W %h:%p gw'
})
})
it('returns structured jump-host config for target.jumpHost', () => {
const target = { ...makeTarget(), jumpHost: 'bastion.example.com' }
expect(resolveEffectiveProxy(target, null)).toEqual({
kind: 'jump-host',
jumpHost: 'bastion.example.com'
})
})
it('returns structured jump-host config for resolved proxyJump', () => {
expect(resolveEffectiveProxy(makeTarget(), makeResolved({ proxyJump: 'jump.host' }))).toEqual({
kind: 'jump-host',
jumpHost: 'jump.host'
})
})
it('returns undefined when no proxy is configured', () => {
expect(resolveEffectiveProxy(makeTarget(), null)).toBeUndefined()
})
})
// ── spawnProxyCommand ───────────────────────────────────────────────
describe('spawnProxyCommand', () => {
beforeEach(() => {
spawnMock.mockReset()
})
it('removes proxy process listeners when the socket is destroyed', () => {
const proc = createMockProxyProcess()
spawnMock.mockReturnValue(proc)
const { sock } = spawnProxyCommand(
{ kind: 'jump-host', jumpHost: 'bastion.example.com' },
'target.example.com',
22,
'deploy'
)
expect(proc.stdout.listenerCount('data')).toBe(1)
expect(proc.stdout.listenerCount('end')).toBe(1)
expect(proc.stdin.listenerCount('error')).toBe(1)
expect(proc.listenerCount('error')).toBe(1)
sock.destroy()
expect(proc.stdout.listenerCount('data')).toBe(0)
expect(proc.stdout.listenerCount('end')).toBe(0)
expect(proc.stdin.listenerCount('error')).toBe(0)
expect(proc.listenerCount('error')).toBe(0)
})
})