Files
orca/src/main/ssh/ssh-config-parser.test.ts
T
Jinjing 637c7e94c9 Add SSH config host picker to add-host dialog (#12334)
* feat(ssh): add SSH config host picker for add-host form

Users can now click 'Fill from ~/.ssh/config…' to browse available SSH
config hosts in a picker, select one, and have the form automatically
prefill with resolved connection details (hostname, port, username, auth).

Previously, an 'import' button provided bulk sync on this form—confusing
and unhelpful when everything was already synced. That action is now
available as a secondary 'Add all' option in the picker.

* fix(ssh): import filter preservation and label fallback

- Reuse search loader on import completion to preserve active filter inside generation guard
- Fall back to hostname when manual host has no label, not empty string
- Make alias duplicate detection case-insensitive to match config picker behavior
- Validate host availability when restoring project group selection
- Add aria-selected attribute to picker options for accessibility

* fix(ssh): harden config picker import, alias folding, and host targeting

Review findings on the ~/.ssh/config picker + bulk add:

- Guard config-host resolution with a generation counter so a late resolve
  cannot overwrite a later pick or a form the user backed out of; freeze the
  other rows while a pick resolves.
- Stop "Add all N" from re-adopting deleted hosts — it now imports without
  reAdopt, matching the new-host count it advertises. Settings → Import keeps
  the explicit re-adopt path.
- Fold SSH aliases through a shared normalizeSshConfigAlias for import
  ownership, delete tombstones, reclaim, picker search, and the save-time
  duplicate check, which now occupies configHost *and* label like the picker.
- Persist GSSAPIAuthentication only when a parsed Host entry asks for it, not
  when `ssh -G` merely echoes the /etc/ssh system default.
- Fail closed with unavailable/setup-not-found when an explicit
  projectHostSetupId names a non-actionable host instead of silently creating
  the workspace on a sibling host.
- Cache the parsed config for the picker session (refresh on open/retry) so
  filter keystrokes no longer reparse and Include-expand the file, keep the
  filter usable during loads, add a Retry on load errors, explain an empty
  Identity file after a config fill, and drop the always-false aria-selected.

* refactor(ssh): centralize host result limit and extract folder group val

Move SSH_CONFIG_HOST_RESULT_LIMIT to shared types so the renderer's limit message
cannot drift from the host's query limit. Extract findActionableFolderProjectGroup
to avoid repeating the folder-host-availability check across the composer hook.

* fix(ssh): pass -F to ssh -G when HOME differs from passwd home

In E2E tests and sandboxes, isolated HOME can differ from the system
passwd home. OpenSSH resolves the default config via getpwuid (passwd),
while Node's loadUserSshConfig uses os.homedir() (HOME-aware). Pass -F
to explicitly specify the config path when they diverge, so ssh -G and
the picker resolve the same file.

* fix(ssh): verify config host exists before resolving with ssh -G

When a user edits ~/.ssh/config and removes a host, the import picker
should not fall back to ssh -G's echoed response (which treats any alias
as valid). Check the reloaded config file before resolving.

- Force reload config on each resolve to catch user edits post-open
- Reject aliases not in the current config before calling ssh -G
- Add test for deleted alias edge case
- Fix workspace-target fallback to honor explicit host selection

* fix(ssh): let tombstoned aliases be re-picked in the config picker

Allow users to reclaim a deleted SSH host by re-picking it from ~/.ssh/config. Tombstoned aliases now appear in the picker with a "Removed from Orca" badge and remain pickable, but don't count toward "Add all" operations — ensuring passive import never resurrects a deleted alias while still giving the user a recovery path.
2026-08-03 17:32:13 -07:00

686 lines
20 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { parseSshConfig, sshConfigHostsToTargets, parseSshGOutput } from './ssh-config-parser'
import { searchSshConfigHosts } from './ssh-config-host-picker'
const sshConfigHostsToSummaries = (
hosts: Parameters<typeof searchSshConfigHosts>[0],
targets: Parameters<typeof searchSshConfigHosts>[1]
) => searchSshConfigHosts(hosts, targets).hosts
vi.mock('os', () => ({
homedir: () => '/home/testuser'
}))
const LARGE_HOST_ALIAS_COUNT = 150_000
const TEST_HOME = '/home/testuser'
function testHomePath(...parts: string[]): string {
return join(TEST_HOME, ...parts)
}
function buildHostAliases(count: number): string {
const aliases: string[] = []
for (let index = 0; index < count; index += 1) {
aliases.push(`generated-${index}`)
}
return aliases.join(' ')
}
describe('parseSshConfig', () => {
it('parses a basic host block', () => {
const config = `
Host myserver
HostName 192.168.1.100
User deploy
Port 2222
`
const hosts = parseSshConfig(config)
expect(hosts).toHaveLength(1)
expect(hosts[0]).toEqual({
host: 'myserver',
hostname: '192.168.1.100',
user: 'deploy',
port: 2222
})
})
it('parses multiple host blocks', () => {
const config = `
Host staging
HostName staging.example.com
User admin
Host production
HostName prod.example.com
User deploy
Port 2222
`
const hosts = parseSshConfig(config)
expect(hosts).toHaveLength(2)
expect(hosts[0].host).toBe('staging')
expect(hosts[1].host).toBe('production')
expect(hosts[1].port).toBe(2222)
})
it('skips wildcard-only Host entries', () => {
const config = `
Host *
ServerAliveInterval 60
Host myserver
HostName 10.0.0.1
`
const hosts = parseSshConfig(config)
expect(hosts).toHaveLength(1)
expect(hosts[0].host).toBe('myserver')
})
it('skips Host entries with only pattern characters', () => {
const config = `
Host *.example.com
User admin
Host dev
HostName dev.example.com
`
const hosts = parseSshConfig(config)
expect(hosts).toHaveLength(1)
expect(hosts[0].host).toBe('dev')
})
it('parses IdentityFile with ~ expansion', () => {
const config = `
Host myserver
HostName example.com
IdentityFile ~/.ssh/id_ed25519
`
const hosts = parseSshConfig(config)
expect(hosts[0].identityFile).toBe(testHomePath('.ssh', 'id_ed25519'))
})
it('parses Windows-style IdentityFile with ~ expansion', () => {
const config = `
Host myserver
HostName example.com
IdentityFile ~\\.ssh\\id_ed25519
`
const hosts = parseSshConfig(config)
expect(hosts[0].identityFile).toBe(testHomePath('.ssh', 'id_ed25519'))
})
it('parses quoted scalar values like OpenSSH', () => {
const config = `
Host quoted
HostName "localhost" # local test
User "deploy" # deploy user
Port "2202" # ssh port
IdentityFile "~/.ssh/id with space" # private key
IdentityAgent "~/.1password/agent sock" # agent socket
IdentitiesOnly "yes" # limit keys
ProxyJump "bastion" # jump host
`
const hosts = parseSshConfig(config)
expect(hosts[0]).toEqual({
host: 'quoted',
hostname: 'localhost',
user: 'deploy',
port: 2202,
identityFile: testHomePath('.ssh', 'id with space'),
identityAgent: testHomePath('.1password', 'agent sock'),
identitiesOnly: true,
proxyJump: 'bastion'
})
})
it('parses equals-form scalar directives', () => {
const config = `
Host eq
HostName=eq.example.com
User=deploy
Port=2202
`
const hosts = parseSshConfig(config)
expect(hosts[0]).toEqual({
host: 'eq',
hostname: 'eq.example.com',
user: 'deploy',
port: 2202
})
})
it('parses IdentityAgent with ~ expansion', () => {
const config = `
Host myserver
HostName example.com
IdentityAgent ~/.1password/agent.sock
`
const hosts = parseSshConfig(config)
expect(hosts[0].identityAgent).toBe(testHomePath('.1password', 'agent.sock'))
})
it('parses IdentitiesOnly', () => {
const config = `
Host myserver
HostName example.com
IdentitiesOnly yes
`
const hosts = parseSshConfig(config)
expect(hosts[0].identitiesOnly).toBe(true)
})
it('parses GSSAPIAuthentication', () => {
const config = `
Host krb-host
HostName krb.example.com
GSSAPIAuthentication yes
Host plain-host
HostName plain.example.com
GSSAPIAuthentication no
Host silent-host
HostName silent.example.com
`
const hosts = parseSshConfig(config)
expect(hosts[0].gssapiAuthentication).toBe(true)
expect(hosts[1].gssapiAuthentication).toBe(false)
expect(hosts[2].gssapiAuthentication).toBeUndefined()
})
it('keeps the first GSSAPIAuthentication value like OpenSSH', () => {
const hosts = parseSshConfig(`
Host enabled
GSSAPIAuthentication yes
GSSAPIAuthentication no
Host disabled
GSSAPIAuthentication no
GSSAPIAuthentication yes
`)
expect(hosts[0].gssapiAuthentication).toBe(true)
expect(hosts[1].gssapiAuthentication).toBe(false)
})
it('keeps the first value for repeated single-valued options like OpenSSH', () => {
const hosts = parseSshConfig(`
Host repeated
HostName first.example.com
HostName second.example.com
User first-user
User second-user
Port 2201
Port 2202
IdentityAgent ~/.ssh/first-agent.sock
IdentityAgent ~/.ssh/second-agent.sock
IdentitiesOnly yes
IdentitiesOnly no
ProxyCommand ssh -W %h:%p first-bastion
ProxyCommand ssh -W %h:%p second-bastion
ProxyUseFdpass yes
ProxyUseFdpass no
ProxyJump first-jump
ProxyJump second-jump
Host repeated-disabled
IdentitiesOnly no
IdentitiesOnly yes
ProxyUseFdpass no
ProxyUseFdpass yes
`)
expect(hosts[0]).toEqual({
host: 'repeated',
hostname: 'first.example.com',
user: 'first-user',
port: 2201,
identityAgent: testHomePath('.ssh', 'first-agent.sock'),
identitiesOnly: true,
proxyCommand: 'ssh -W %h:%p first-bastion',
proxyUseFdpass: true,
proxyJump: 'first-jump'
})
expect(hosts[1]).toEqual({
host: 'repeated-disabled',
identitiesOnly: false,
proxyUseFdpass: false
})
})
it('parses ProxyCommand, ProxyUseFdpass, and ProxyJump', () => {
const config = `
Host internal
HostName 10.0.0.5
ProxyCommand ssh -W %h:%p bastion
ProxyUseFdpass yes
ProxyJump bastion.example.com
`
const hosts = parseSshConfig(config)
expect(hosts[0].proxyCommand).toBe('ssh -W %h:%p bastion')
expect(hosts[0].proxyUseFdpass).toBe(true)
expect(hosts[0].proxyJump).toBe('bastion.example.com')
})
it('preserves ProxyCommand as the rest of the line', () => {
const config = `
Host internal
ProxyCommand sh -c "nc %h %p" # shell comment
`
const hosts = parseSshConfig(config)
expect(hosts[0].proxyCommand).toBe('sh -c "nc %h %p" # shell comment')
})
it('ignores comments and blank lines', () => {
const config = `
# This is a comment
Host myserver
# Another comment
HostName example.com
User admin
`
const hosts = parseSshConfig(config)
expect(hosts).toHaveLength(1)
expect(hosts[0].user).toBe('admin')
})
it('handles case-insensitive keywords', () => {
const config = `
Host myserver
hostname EXAMPLE.COM
user Admin
port 3022
`
const hosts = parseSshConfig(config)
expect(hosts[0].hostname).toBe('EXAMPLE.COM')
expect(hosts[0].user).toBe('Admin')
expect(hosts[0].port).toBe(3022)
})
it('stops current block on Match directive', () => {
const config = `
Host myserver
HostName example.com
Match host *.internal
User internal-admin
Host other
HostName other.com
`
const hosts = parseSshConfig(config)
expect(hosts).toHaveLength(2)
expect(hosts[0].host).toBe('myserver')
expect(hosts[1].host).toBe('other')
})
it('returns empty array for empty input', () => {
expect(parseSshConfig('')).toEqual([])
})
it('creates one parsed host per concrete alias on a multi-pattern Host line', () => {
const config = `
Host staging stage *.example.com
HostName staging.example.com
`
const hosts = parseSshConfig(config)
expect(hosts).toEqual([
{ host: 'staging', hostname: 'staging.example.com' },
{ host: 'stage', hostname: 'staging.example.com' }
])
})
it('parses large concrete alias lists on one Host line', () => {
const config = [
`Host ${buildHostAliases(LARGE_HOST_ALIAS_COUNT)}`,
' HostName generated.example.com',
'Host after',
' HostName after.example.com'
].join('\n')
const hosts = parseSshConfig(config)
expect(hosts).toHaveLength(LARGE_HOST_ALIAS_COUNT + 1)
expect(hosts[0]).toEqual({
host: 'generated-0',
hostname: 'generated.example.com'
})
expect(hosts[LARGE_HOST_ALIAS_COUNT - 1]).toEqual({
host: `generated-${LARGE_HOST_ALIAS_COUNT - 1}`,
hostname: 'generated.example.com'
})
expect(hosts.at(-1)).toEqual({
host: 'after',
hostname: 'after.example.com'
})
})
it('applies identity agent settings to every concrete alias on a multi-pattern Host line', () => {
const config = `
Host staging stage
IdentityAgent ~/.1password/agent.sock
IdentitiesOnly yes
`
const hosts = parseSshConfig(config)
expect(hosts).toEqual([
{
host: 'staging',
identityAgent: testHomePath('.1password', 'agent.sock'),
identitiesOnly: true
},
{
host: 'stage',
identityAgent: testHomePath('.1password', 'agent.sock'),
identitiesOnly: true
}
])
})
it('defaults port to 22 for invalid port values', () => {
const config = `
Host myserver
Port notanumber
`
const hosts = parseSshConfig(config)
expect(hosts[0].port).toBe(22)
})
})
describe('sshConfigHostsToTargets', () => {
it('converts hosts to SshTarget objects', () => {
const hosts = [{ host: 'myserver', hostname: '10.0.0.1', port: 22, user: 'deploy' }]
const targets = sshConfigHostsToTargets(hosts, new Set())
expect(targets).toHaveLength(1)
expect(targets[0]).toMatchObject({
label: 'myserver',
host: '10.0.0.1',
port: 22,
username: 'deploy'
})
expect(targets[0].id).toMatch(/^ssh-/)
})
it('uses host alias as hostname when HostName is missing', () => {
const hosts = [{ host: 'myserver' }]
const targets = sshConfigHostsToTargets(hosts, new Set())
expect(targets[0].host).toBe('myserver')
})
it('skips hosts that are already imported', () => {
const hosts = [
{ host: 'existing', hostname: '10.0.0.1' },
{ host: 'new-host', hostname: '10.0.0.2' }
]
const targets = sshConfigHostsToTargets(hosts, new Set(['existing']))
expect(targets).toHaveLength(1)
expect(targets[0].label).toBe('new-host')
})
it('defaults username to empty string when not specified', () => {
const hosts = [{ host: 'nouser', hostname: '10.0.0.1' }]
const targets = sshConfigHostsToTargets(hosts, new Set())
expect(targets[0].username).toBe('')
})
it('carries through identityFile, identityAgent, identitiesOnly, proxyCommand, and jumpHost', () => {
const hosts = [
{
host: 'internal',
hostname: '10.0.0.5',
identityFile: '/home/user/.ssh/id_rsa',
identityAgent: '/home/user/.1password/agent.sock',
identitiesOnly: true,
proxyCommand: 'ssh -W %h:%p bastion',
proxyUseFdpass: true,
proxyJump: 'bastion.example.com'
}
]
const targets = sshConfigHostsToTargets(hosts, new Set())
expect(targets[0].identityFile).toBe('/home/user/.ssh/id_rsa')
expect(targets[0].identityAgent).toBe('/home/user/.1password/agent.sock')
expect(targets[0].identitiesOnly).toBe(true)
expect(targets[0].proxyCommand).toBe('ssh -W %h:%p bastion')
expect(targets[0].jumpHost).toBe('bastion.example.com')
})
it('carries through gssapiAuthentication', () => {
const hosts = [{ host: 'krb-host', hostname: 'krb.example.com', gssapiAuthentication: true }]
const targets = sshConfigHostsToTargets(hosts, new Set())
expect(targets[0].gssapiAuthentication).toBe(true)
})
it('imports duplicate aliases only once and keeps the first concrete host', () => {
const hosts = [
{ host: 'dup', hostname: 'first.example.com', user: 'first' },
{ host: 'dup', hostname: 'second.example.com', user: 'second' }
]
const targets = sshConfigHostsToTargets(hosts, new Set())
expect(targets).toHaveLength(1)
expect(targets[0]).toMatchObject({
label: 'dup',
host: 'first.example.com',
username: 'first'
})
})
})
describe('sshConfigHostsToSummaries', () => {
it('maps hosts for the picker and flags aliases already in Orca', () => {
const hosts = [
{
host: 'staging',
hostname: 'staging.internal',
user: 'ubuntu',
port: 22,
identityFile: '/home/me/.ssh/staging'
},
{ host: 'prod', hostname: 'prod.example', user: 'ops', port: 2222, proxyJump: 'bastion' }
]
const summaries = sshConfigHostsToSummaries(hosts, [
{ label: 'staging', configHost: 'staging' }
])
expect(summaries).toEqual([
{
alias: 'staging',
hostname: 'staging.internal',
port: 22,
username: 'ubuntu',
identityFile: '/home/me/.ssh/staging',
alreadyInOrca: true
},
{
alias: 'prod',
hostname: 'prod.example',
port: 2222,
username: 'ops',
jumpHost: 'bastion',
alreadyInOrca: false
}
])
})
it('de-dupes aliases and defaults missing fields', () => {
const hosts = [
{ host: 'box' },
{ host: 'box', hostname: 'second.example' },
{ host: 'other', user: 'me' }
]
const summaries = sshConfigHostsToSummaries(hosts, [])
expect(summaries).toHaveLength(2)
expect(summaries[0]).toEqual({
alias: 'box',
hostname: 'box',
port: 22,
username: '',
alreadyInOrca: false
})
expect(summaries[1].alias).toBe('other')
})
})
// ── parseSshGOutput ──────────────────────────────────────────────────
describe('parseSshGOutput', () => {
it('parses hostname, user, port from ssh -G output', () => {
const output = [
'hostname 192.168.1.100',
'user deploy',
'port 2222',
'identityfile /home/testuser/.ssh/id_ed25519',
'forwardagent no'
].join('\n')
const result = parseSshGOutput(output)
expect(result.hostname).toBe('192.168.1.100')
expect(result.user).toBe('deploy')
expect(result.port).toBe(2222)
expect(result.identityFile).toEqual(['/home/testuser/.ssh/id_ed25519'])
expect(result.forwardAgent).toBe(false)
})
it('collects multiple identity files', () => {
const output = [
'hostname example.com',
'identityfile ~/.ssh/id_ed25519',
'identityfile ~/.ssh/id_rsa',
'port 22'
].join('\n')
const result = parseSshGOutput(output)
expect(result.identityFile).toEqual([
testHomePath('.ssh', 'id_ed25519'),
testHomePath('.ssh', 'id_rsa')
])
})
it('parses forwardagent yes', () => {
const output = 'hostname example.com\nforwardagent yes\nport 22'
const result = parseSshGOutput(output)
expect(result.forwardAgent).toBe(true)
})
it('defaults port to 22 when missing', () => {
const output = 'hostname example.com'
const result = parseSshGOutput(output)
expect(result.port).toBe(22)
})
it('returns empty hostname when missing', () => {
const output = 'user admin\nport 22'
const result = parseSshGOutput(output)
expect(result.hostname).toBe('')
})
it('returns undefined user when missing', () => {
const output = 'hostname example.com\nport 22'
const result = parseSshGOutput(output)
expect(result.user).toBeUndefined()
})
it('handles empty output', () => {
const result = parseSshGOutput('')
expect(result.hostname).toBe('')
expect(result.port).toBe(22)
expect(result.identityFile).toEqual([])
})
it('skips lines without spaces', () => {
const output = 'hostname example.com\nbadline\nport 22'
const result = parseSshGOutput(output)
expect(result.hostname).toBe('example.com')
expect(result.port).toBe(22)
})
it('parses proxycommand and filters "none"', () => {
const output = 'hostname example.com\nproxycommand ssh -W %h:%p bastion\nport 22'
const result = parseSshGOutput(output)
expect(result.proxyCommand).toBe('ssh -W %h:%p bastion')
const noneOutput = 'hostname example.com\nproxycommand none\nport 22'
const noneResult = parseSshGOutput(noneOutput)
expect(noneResult.proxyCommand).toBeUndefined()
})
it('parses proxyusefdpass yes', () => {
const output = 'hostname example.com\nproxyusefdpass yes\nport 22'
const result = parseSshGOutput(output)
expect(result.proxyUseFdpass).toBe(true)
const noneOutput = 'hostname example.com\nproxyusefdpass no\nport 22'
const noneResult = parseSshGOutput(noneOutput)
expect(noneResult.proxyUseFdpass).toBe(false)
})
it('parses proxyjump and filters "none"', () => {
const output = 'hostname example.com\nproxyjump bastion.example.com\nport 22'
const result = parseSshGOutput(output)
expect(result.proxyJump).toBe('bastion.example.com')
const noneOutput = 'hostname example.com\nproxyjump none\nport 22'
const noneResult = parseSshGOutput(noneOutput)
expect(noneResult.proxyJump).toBeUndefined()
})
it('handles ~ expansion in identity file paths', () => {
const output = 'hostname example.com\nidentityfile ~/custom_key\nport 22'
const result = parseSshGOutput(output)
expect(result.identityFile).toEqual([testHomePath('custom_key')])
})
it('handles Windows-style ~ expansion in identity file paths', () => {
const output = 'hostname example.com\nidentityfile ~\\.ssh\\custom_key\nport 22'
const result = parseSshGOutput(output)
expect(result.identityFile).toEqual([testHomePath('.ssh', 'custom_key')])
})
it('parses identityagent with ~ expansion', () => {
const output = 'hostname example.com\nidentityagent ~/.1password/agent.sock\nport 22'
const result = parseSshGOutput(output)
expect(result.identityAgent).toBe(testHomePath('.1password', 'agent.sock'))
})
it('preserves identityagent none so auth can disable agent fallback', () => {
const output = 'hostname example.com\nidentityagent none\nport 22'
const result = parseSshGOutput(output)
expect(result.identityAgent).toBe('none')
})
it('parses identitiesonly yes', () => {
const output = 'hostname example.com\nidentitiesonly yes\nport 22'
const result = parseSshGOutput(output)
expect(result.identitiesOnly).toBe(true)
})
it('parses gssapiauthentication', () => {
const output = 'hostname example.com\ngssapiauthentication yes\nport 22'
const result = parseSshGOutput(output)
expect(result.gssapiAuthentication).toBe(true)
const offResult = parseSshGOutput('hostname example.com\ngssapiauthentication no\nport 22')
expect(offResult.gssapiAuthentication).toBe(false)
})
it('parses controlmaster options and filters controlpath none', () => {
const output = [
'hostname example.com',
'controlmaster auto',
'controlpath ~/.ssh/cm/%r@%h:%p',
'controlpersist 10m',
'port 22'
].join('\n')
const result = parseSshGOutput(output)
expect(result.controlMaster).toBe('auto')
expect(result.controlPath).toBe(testHomePath('.ssh', 'cm', '%r@%h:%p'))
expect(result.controlPersist).toBe('10m')
const noneResult = parseSshGOutput(
'hostname example.com\ncontrolmaster no\ncontrolpath none\ncontrolpersist no\nport 22'
)
expect(noneResult.controlMaster).toBe('no')
expect(noneResult.controlPath).toBeUndefined()
expect(noneResult.controlPersist).toBe('no')
})
})