mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
* Add first user prompt to AI Vault session history rows Re-parse transcripts on demand to extract and display the untruncated first user prompt for copy/reuse. List scans omit the body (payload/perf); UI loads it when session details expand. Grok sessions extract the typed ask from <user_query> envelope, skipping injected <user_info> bootstrap rows. Supports Claude, Codex, Grok, and OpenCode agents. * fix(ai-vault): split SessionTime out to pass max-lines lint AiVaultSessionDetails exceeded the 400-line oxlint limit after adding first-prompt UI; move SessionTime into its own module. * fix(ai-vault): handle corrupt transcripts and fix OpenCode prompt captur Corrupt transcripts now resolve null instead of rejecting the IPC call, matching behavior for other unavailable cases. OpenCode SQLite parsing now correctly captures all text parts from the earliest user message only, fixing truncation of large prompts and padding of small ones. Add stale-response guard in the UI to prevent late results from overwriting the current session when tabs switch. Consolidate text slicing via `sliceAtCodeUnitLimit` to avoid surrogate-pair splits across all callers. * test(ai-vault): add first-user-prompt UTF-16 safety tests Ensure truncation at safety limits doesn't split UTF-16 surrogate pairs, preventing corruption of astral characters in captured prompts. * fix(ai-vault): key first-prompt-card by session.id Remounting the card on session switches prevents late responses from a previous load from writing stale data into the component's refs. Also improves conversation-turn key stability. * fix(ai-vault): preserve first prompt after preview truncation * refactor(ai-vault): improve first user prompt capture robustness and per - Add 15s timeout to full-prompt load to prevent indefinite loading states - Extract seedFullFirstUserPrompt helper for reuse across parsers - Prevent AI-generated summaries from becoming the copyable first prompt - Fix truncation detection in OpenCode SQLite by probing for N+1 rows - Optimize text bounding to apply safety limit before toLowerCase - Gate synthetic OpenCode path detection on agent type, not just # presence - Add test coverage for remote execution host handling * Fix FirstPromptCard loading state stranded by stale promise reuse Clears loadPromiseRef during cleanup to prevent the dedupe handle from causing StrictMode remounts to await stale in-flight requests. Stops loading when session becomes non-loadable mid-request. Adds tests for StrictMode double-invoke resolution and main-process timeout scenarios. * refactor(ai-vault): split session parsers into modular files Split secondary-parsers into individual files per agent type (copilot, cursor, hermes, opencode) for improved modularity. Add test coverage for first-user-prompt envelope handling: unwrap user_query tags and reject bare user_info dumps. * fix(ci): clear max-lines and flaky portal readiness check Collapse an accidental multi-line regex wrap in ssh-connection-utils that pushed counted lines to 301. Harden the latched-readiness test's ready transition so CI load can re-observe attach after MutationObserver gaps. * fix(ssh): extract proxy command helpers to pass max-lines Move resolveEffectiveProxy/spawnProxyCommand out of ssh-connection-utils so oxfmt line wrapping cannot push that file over the 300-line lint cap. * capture first user prompt by ordering OpenCode messages by creation time - Add `readOpenCodeMessagesInOrder` to rebuild transcript by timestamp, handling corrupt/partial files gracefully instead of discarding sessions - Extract SSH proxy command tests to dedicated file; add backpressure handling and stderr draining to prevent proxy process stalls - On Windows, reject unsafe characters in ProxyCommand values instead of pretending to escape them; properly format cmd.exe invocation with verbatim arguments - Expand ProxyJump chains into -J plus final hop, mirroring OpenSSH behavior - Decouple portal readiness reapply budget from flip-count budget via explicit constant
780 lines
27 KiB
TypeScript
780 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 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',
|
|
...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()
|
|
})
|
|
})
|