mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* fix(ssh): fence stale kills and retired pane replay * fix(ssh): support cancellable interactive authentication * fix(ssh): await remote catalog before snapshot adoption * fix(pty): contain Windows ConPTY input failures * fix(power): avoid redundant macOS display blocking * perf(editor): narrow markdown override subscriptions * fix(quick-open): close directory handles after reads * refactor(linux): remove unused proc socket scanner * fix(usage): apply flat Sonnet 4.6 pricing * ci: prime Node next native test cache * docs(skills): resolve snapshot cleanup data path * fix(ssh): recover install locks after host reboot * test(ssh): recognize boot-aware install locks * test(ssh): prove previous-boot lock recovery live * test(wire): pin pre-metadata release coverage * fix(terminal): preserve remote tab ownership through recovery races * test(runtime): fence replaced terminal handles in agent guard * fix(ssh): preserve remote snapshot authority across polls * fix(pty): contain late ConPTY output EPIPE * test(pty): register Windows exit watcher before kill * fix: close SSH and tab readiness race gaps * fix(tabs): retain headless order and placeholder titles * fix(build): avoid parallel electron-vite config race * test(windows): avoid MSYS temp path rewriting * test(windows): avoid killing exited PTY * fix(pty): avoid late ConPTY input teardown race * fix(terminal): sync reconnect error ownership after commit * fix(runtime): use canonical worktree identity comparison * test(ssh): assert complete cold-hydration baseline * test(windows): invoke quoted retention fixture via PowerShell * test(windows): read ConPTY grid through mode con * fix(terminal): publish PTY replacements atomically * fix(terminal): infer stale identity on reattach * fix(terminal): fence stale pane PTY callbacks * fix(terminal): fence stale pane binds after rebind * fix(terminal): reject stale pane transport callbacks * fix(terminal): fence mirrored reattach spawn callbacks * fix(terminal): replace stale pane PTYs on remount * fix(ci): size the Windows launcher-compile test budget from measurement `native-smoke (windows-latest)` fails ~4.5% of runs on `preserves a multiline argument through the compiled remote launcher` with "Test timed out in 15000ms" — on unrelated PRs, for reasons that have nothing to do with them. Across 176 sampled attempts it is the only red that job produced, and it hit seven different PRs in two days: #16900, #16904, #16915, #16955 (twice), #16979, #17014, #17085. The test is six process creations: powershell.exe forks csc.exe, then the freshly compiled orca.exe forks node.exe, twice. Hosted Windows runners periodically slow process creation down, and this test amplifies that far harder than anything else in the job. Comparing the 80 attempts where it ran under 3s against the 12 where it ran over 12s, its own median goes 2198ms -> 15917ms (7.2x) while the same file's powershell-only test moves 556 -> 686ms (1.2x), the cmd.exe and Git Bash process tests in the neighbouring file move 1.4x, and the other 35 files put together move 1.5x. Measured across those 176 attempts: 1881ms to 35438ms, p50 4264ms, correlation +0.881 with the job's total Vitest duration. 8 of 176 (4.5%) exceeded the 15s cap; 2 of 176 (1.1%) also exceeded the shared 30s testTimeout, so deleting the override and inheriting the config is not enough on its own. 60s clears all 176 with 1.7x headroom on the worst. This is slow, not hung. Every body here is synchronous spawnSync, so Vitest cannot interrupt one — the timer fires only after the body returns and the reported duration is real elapsed time. That is why a failure reads `× ... 22464ms` under `Test timed out in 15000ms`. The work finished; the stopwatch was short. Seven reruns at one identical head measured 2053 / 4680 / 5551 / 8732 / 13506 / 14868 / 21937ms — the last of those would have been red on code that had not changed. The 15s came from #8897, which raised this test off Vitest's built-in 5s default because the job then ran bare `pnpm vitest run`. #8909 landed 3h27m later and pointed the job at config/vitest.config.ts, which is the real fix for that. The constant stayed behind and has been the binding budget ever since. * fix(terminal): fence stale remount reattach ownership * fix(terminal): reconcile mounted pane identity after replacement * fix(terminal): fence stale reattach fallback ownership * fix(terminal): fence deferred SSH reattach ownership * fix(terminal): fence stale split pane ownership callbacks * fix(terminal): keep stale spawns from consuming startup --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
794 lines
27 KiB
TypeScript
794 lines
27 KiB
TypeScript
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'
|
|
import { join } from 'node:path'
|
|
import { BaseAgent, utils, type ParsedKey } from 'ssh2'
|
|
|
|
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,
|
|
CONNECT_TIMEOUT_MS,
|
|
INITIAL_RETRY_ATTEMPTS,
|
|
INITIAL_RETRY_DELAY_MS,
|
|
RECONNECT_BACKOFF_MS
|
|
} from './ssh-connection-utils'
|
|
import { resolveEffectiveProxy } from './ssh-proxy-command'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
import type { SshResolvedConfig } from './ssh-config-parser'
|
|
|
|
// ── 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 true for the bounded SSH authentication watchdog', () => {
|
|
const timeout = Object.assign(new Error('Timed out while waiting for SSH authentication'), {
|
|
level: 'client-timeout'
|
|
})
|
|
|
|
expect(isTransientError(timeout)).toBe(true)
|
|
expect(isTransientError(new Error('Timed out while waiting for SSH authentication'))).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.each([
|
|
'Permission denied (publickey).',
|
|
'Permission denied (publickey,password).',
|
|
'Permission denied, please try again.'
|
|
])('detects OpenSSH credential rejection: %s', (message) => {
|
|
expect(isAuthError(new Error(message))).toBe(true)
|
|
})
|
|
|
|
it('returns false for transient errors', () => {
|
|
expect(isAuthError(new Error('connect ETIMEDOUT'))).toBe(false)
|
|
})
|
|
|
|
it('does not classify a local filesystem permission failure as authentication', () => {
|
|
expect(isAuthError(new Error('Permission denied (os error 13)'))).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',
|
|
userKnownHostsFiles: [],
|
|
globalKnownHostsFiles: [],
|
|
strictHostKeyChecking: 'ask',
|
|
hashKnownHosts: false,
|
|
updateHostKeys: '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()
|
|
})
|
|
})
|