Files
orca/src/main/ssh/ssh-connection-store.test.ts
T
0302ae86b8 feat(ssh): support Kerberos/GSSAPI hosts via the system OpenSSH transport (#7507)
* feat(ssh): support Kerberos/GSSAPI hosts via the system OpenSSH transport

ssh2 has no gssapi-with-mic support, and adding it would mean forking its
protocol layer plus packaging the kerberos native module for three
platforms. Instead, route GSSAPI hosts through the existing system-OpenSSH
transport, which delegates Kerberos (tickets, SSPI on Windows) to the
platform ssh binary.

Two tiers, because RHEL-family distros enable GSSAPIAuthentication
globally in /etc/ssh/ssh_config and ssh -G therefore reports it for every
host:

- Targets whose ~/.ssh/config Host block explicitly sets
  GSSAPIAuthentication yes (imported as target.gssapiAuthentication) try
  system ssh first, falling through to ssh2 so key auth and credential
  prompts still work when no ticket is available.
- When ssh2 exhausts key/agent auth and the ssh -G-resolved config
  enables GSSAPI, retry over system ssh before prompting for credentials,
  so Kerberos-only hosts on distro-default configs connect without a
  password prompt. Hosts where keys work never leave the ssh2 path.

Manual targets flagged for GSSAPI pass -o GSSAPIAuthentication=yes
explicitly since they bypass ssh_config. Both tiers work headless (no
credential callbacks required).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ssh): harden GSSAPI transport selection (review fixes for PR #7507)

Review fixes on top of the Kerberos/GSSAPI feature branch (s546126/kerberos-ssh):

- HIGH: reset useSystemSshTransport on the ssh2 fall-through. doSystemSshProbe
  sets the flag before spawnSystemSshCommand, which throws synchronously when no
  system ssh binary is on PATH (outside the probe try/catch). The proactive
  fall-through previously reset only 2 of 3 transport fields, so exec/sftp kept
  routing through the failed transport - breaking GSSAPI on Windows-with-Git-ssh
  and headless Linux.
- MEDIUM: throw a cancellation error (not the stale ssh2 authError) when a
  disconnect supersedes the reactive probe mid-flight, and guard connect()'s
  catch on disposed, so a deliberate disconnect is not overwritten with
  auth-failed.
- MEDIUM: skip the encrypted-key passphrase prompt when the GSSAPI fallback
  applies, so a Kerberos ticket is tried before prompting; the general prompt
  still fires if the probe fails.

Adds 3 mutation-verified regression tests and hardens two existing tests to
assert the probe actually ran. Not connected to any PR remote.

Co-authored-by: Orca <help@stably.ai>

* fix(ssh): isolate GSSAPI system transport

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: s546126 <268420947+s546126@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-15 01:58:46 -07:00

663 lines
21 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SshConnectionStore } from './ssh-connection-store'
import type { RemovedSshTargetTombstone, SshTarget } from '../../shared/ssh-types'
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
loadUserSshConfigMock: vi.fn(),
sshConfigHostsToTargetsMock: vi.fn()
}))
vi.mock('./ssh-config-parser', () => ({
loadUserSshConfig: loadUserSshConfigMock,
sshConfigHostsToTargets: sshConfigHostsToTargetsMock
}))
function createMockStore() {
const targets: SshTarget[] = []
let deletedAliases: string[] = []
const removedTombstones: RemovedSshTargetTombstone[] = []
const reassignments: { oldTargetId: string; newTargetId: string }[] = []
return {
getSshTargets: vi.fn(() => [...targets]),
getSshTarget: vi.fn((id: string) => targets.find((t) => t.id === id)),
addSshTarget: vi.fn((target: SshTarget) => targets.push(target)),
updateSshTarget: vi.fn((id: string, updates: Partial<Omit<SshTarget, 'id'>>) => {
const target = targets.find((t) => t.id === id)
if (!target) {
return null
}
Object.assign(target, updates)
return { ...target }
}),
removeSshTarget: vi.fn((id: string) => {
const idx = targets.findIndex((t) => t.id === id)
if (idx !== -1) {
targets.splice(idx, 1)
}
}),
getDeletedSshConfigAliases: vi.fn(() => [...deletedAliases]),
addDeletedSshConfigAlias: vi.fn((alias: string) => {
if (!deletedAliases.includes(alias)) {
deletedAliases.push(alias)
}
}),
removeDeletedSshConfigAlias: vi.fn((alias: string) => {
deletedAliases = deletedAliases.filter((entry) => entry !== alias)
}),
clearDeletedSshConfigAliases: vi.fn(() => {
deletedAliases = []
}),
removedTombstones,
reassignments,
getRemovedSshTargetTombstones: vi.fn(() => [...removedTombstones]),
addRemovedSshTargetTombstone: vi.fn((tombstone: RemovedSshTargetTombstone) => {
const filtered = removedTombstones.filter((t) => t.oldTargetId !== tombstone.oldTargetId)
removedTombstones.length = 0
removedTombstones.push(...filtered, tombstone)
}),
removeRemovedSshTargetTombstone: vi.fn((oldTargetId: string) => {
const kept = removedTombstones.filter((t) => t.oldTargetId !== oldTargetId)
removedTombstones.length = 0
removedTombstones.push(...kept)
}),
reassignSshTargetId: vi.fn((oldTargetId: string, newTargetId: string) => {
reassignments.push({ oldTargetId, newTargetId })
// Pretend one repo referenced the old id.
return ['repo-1']
})
}
}
describe('SshConnectionStore', () => {
let mockStore: ReturnType<typeof createMockStore>
let sshStore: SshConnectionStore
beforeEach(() => {
mockStore = createMockStore()
sshStore = new SshConnectionStore(mockStore as never)
loadUserSshConfigMock.mockReset()
sshConfigHostsToTargetsMock.mockReset()
})
it('listTargets delegates to store', () => {
sshStore.listTargets()
expect(mockStore.getSshTargets).toHaveBeenCalled()
})
it('getTarget delegates to store', () => {
sshStore.getTarget('test-id')
expect(mockStore.getSshTarget).toHaveBeenCalledWith('test-id')
})
it('addTarget generates an id and persists', () => {
const target = sshStore.addTarget({
label: 'My Server',
host: 'example.com',
port: 22,
username: 'deploy'
})
expect(target.id).toMatch(/^ssh-/)
expect(target.label).toBe('My Server')
expect(mockStore.addSshTarget).toHaveBeenCalledWith(target)
})
it('addTarget stamps source as manual by default', () => {
const target = sshStore.addTarget({
label: 'My Server',
host: 'example.com',
port: 22,
username: 'deploy'
})
expect(target.source).toBe('manual')
})
it('addTarget preserves an explicitly provided source', () => {
const target = sshStore.addTarget({
label: 'My Server',
host: 'example.com',
port: 22,
username: 'deploy',
source: 'ssh-config'
})
expect(target.source).toBe('ssh-config')
})
it('hides runtime-owned targets from normal target lists', () => {
const userTarget = sshStore.addTarget({
label: 'My Server',
host: 'example.com',
port: 22,
username: 'deploy'
})
sshStore.upsertRuntimeOwnedTarget('runtime-1', {
label: 'Sandbox',
host: 'sandbox.example.com',
port: 22,
username: 'root'
})
expect(sshStore.listTargets()).toEqual([userTarget])
})
it('updateTarget delegates to store', () => {
const original: SshTarget = {
id: 'ssh-1',
label: 'Old Name',
host: 'example.com',
port: 22,
username: 'user'
}
mockStore.addSshTarget(original)
const result = sshStore.updateTarget('ssh-1', { label: 'New Name' })
expect(result).toBeTruthy()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith('ssh-1', { label: 'New Name' })
})
it('removeTarget delegates to store', () => {
sshStore.removeTarget('ssh-1')
expect(mockStore.removeSshTarget).toHaveBeenCalledWith('ssh-1')
})
describe('importFromSshConfig', () => {
function candidate(overrides: Partial<SshTarget> & { configHost: string }): SshTarget {
return {
id: `tmp-${overrides.configHost}`,
label: overrides.configHost,
host: `${overrides.configHost}.example.com`,
port: 22,
username: '',
...overrides
}
}
it('inserts a new config host stamped as ssh-config', () => {
loadUserSshConfigMock.mockReturnValue([{ host: 'staging' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'staging', host: 'staging.example.com' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.addSshTarget).toHaveBeenCalledWith(
expect.objectContaining({ configHost: 'staging', source: 'ssh-config' })
)
expect(result).toHaveLength(1)
expect(result[0].source).toBe('ssh-config')
})
it('asks the parser for all hosts — reconciliation happens in the store', () => {
loadUserSshConfigMock.mockReturnValue([{ host: 'a' }])
sshConfigHostsToTargetsMock.mockReturnValue([])
sshStore.importFromSshConfig()
expect(sshConfigHostsToTargetsMock).toHaveBeenCalledWith([{ host: 'a' }], new Set())
})
// PRIMARY regression (#4684 item #1): a rotated port must take effect on
// re-import instead of silently keeping the stale value.
it('updates an existing config-sourced target when the port changed', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'cluster',
configHost: 'cluster',
host: '10.0.0.5',
port: 2200,
username: 'dev',
source: 'ssh-config'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'cluster', host: '10.0.0.5', port: 2222, username: 'dev' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({ port: 2222, source: 'ssh-config' })
)
// Only the seed insert — no duplicate target created.
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1)
expect(result).toHaveLength(1)
expect(result[0].port).toBe(2222)
})
it('refreshes host, username, and jump host on sync, not just port', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'box',
configHost: 'box',
host: 'old.example.com',
port: 22,
username: 'old',
source: 'ssh-config'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'box' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({
configHost: 'box',
host: 'new.example.com',
port: 2200,
username: 'newuser',
jumpHost: 'bastion'
})
])
sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({
host: 'new.example.com',
port: 2200,
username: 'newuser',
jumpHost: 'bastion'
})
)
})
it('refreshes gssapiAuthentication on sync', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'krb-box',
configHost: 'krb-box',
host: 'krb.example.com',
port: 22,
username: 'dev',
source: 'ssh-config'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'krb-box' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({
configHost: 'krb-box',
host: 'krb.example.com',
username: 'dev',
gssapiAuthentication: true
})
])
sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith(
'ssh-1',
expect.objectContaining({ gssapiAuthentication: true })
)
})
it('never overwrites a manual target that owns the alias', () => {
mockStore.addSshTarget({
id: 'ssh-m',
label: 'cluster',
configHost: 'cluster',
host: 'manual.example.com',
port: 22,
username: 'me',
source: 'manual'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'cluster', host: '10.0.0.9', port: 2222, username: 'dev' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).not.toHaveBeenCalled()
// Only the manual seed insert — the config alias is not duplicated.
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1)
expect(result).toEqual([])
})
it('adopts a legacy unsourced target into config-sync', () => {
mockStore.addSshTarget({
id: 'ssh-legacy',
label: 'cluster',
configHost: 'cluster',
host: '10.0.0.5',
port: 2200,
username: 'dev'
// no source — predates the field
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'cluster', host: '10.0.0.5', port: 2222, username: 'dev' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).toHaveBeenCalledWith(
'ssh-legacy',
expect.objectContaining({ port: 2222, source: 'ssh-config' })
)
expect(result[0].source).toBe('ssh-config')
})
it('does not overwrite a legacy unsourced manual target with the same alias', () => {
mockStore.addSshTarget({
id: 'ssh-legacy-manual',
label: 'cluster',
configHost: 'cluster',
host: 'cluster',
port: 2200,
username: 'me'
// no source — predates the field, but does not look like a config import
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'cluster', host: '10.0.0.5', port: 2222, username: 'dev' })
])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).not.toHaveBeenCalled()
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1)
expect(result).toEqual([])
})
it('does not rewrite an unchanged config-sourced target', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'cluster',
configHost: 'cluster',
host: 'cluster.example.com',
port: 22,
username: '',
source: 'ssh-config'
})
loadUserSshConfigMock.mockReturnValue([{ host: 'cluster' }])
// Candidate is identical to the persisted target (same default fields).
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'cluster' })])
const result = sshStore.importFromSshConfig()
expect(mockStore.updateSshTarget).not.toHaveBeenCalled()
expect(result).toEqual([])
})
it('returns empty array when nothing changed', () => {
loadUserSshConfigMock.mockReturnValue([])
sshConfigHostsToTargetsMock.mockReturnValue([])
const result = sshStore.importFromSshConfig()
expect(result).toEqual([])
})
})
describe('deleted config host tombstones', () => {
function candidate(overrides: Partial<SshTarget> & { configHost: string }): SshTarget {
return {
id: `tmp-${overrides.configHost}`,
label: overrides.configHost,
host: `${overrides.configHost}.example.com`,
port: 22,
username: '',
...overrides
}
}
// PRIMARY regression: deleting a config-sourced host must not let the next
// ~/.ssh/config sync resurrect it.
it('tombstones a deleted config-sourced host so re-import skips it', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'mini',
configHost: 'mini',
host: 'mini.example.com',
port: 22,
username: 'ping',
source: 'ssh-config'
})
sshStore.removeTarget('ssh-1')
expect(mockStore.addDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
const result = sshStore.importFromSshConfig()
expect(mockStore.addSshTarget).toHaveBeenCalledTimes(1) // only the seed insert
expect(result).toEqual([])
})
it('does not tombstone a manual target on delete', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'mini',
configHost: 'mini',
host: 'mini.example.com',
port: 22,
username: 'ping',
source: 'manual'
})
sshStore.removeTarget('ssh-1')
expect(mockStore.addDeletedSshConfigAlias).not.toHaveBeenCalled()
})
it('re-adding a deleted host reclaims its alias so sync stops suppressing it', () => {
mockStore.addDeletedSshConfigAlias('mini')
sshStore.addTarget({
label: 'mini',
configHost: 'mini',
host: '10.0.0.2',
port: 22,
username: 'ping'
})
expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
// Alias reclaimed, but it is now a manual target — still not re-inserted.
const result = sshStore.importFromSshConfig()
expect(result).toEqual([])
})
it('editing a target reclaims its alias from the deleted set', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'mini',
configHost: 'mini',
host: 'mini.example.com',
port: 22,
username: 'ping',
source: 'ssh-config'
})
mockStore.addDeletedSshConfigAlias('mini')
sshStore.updateTarget('ssh-1', { port: 2222, source: 'manual' })
expect(mockStore.removeDeletedSshConfigAlias).toHaveBeenCalledWith('mini')
})
it('reAdopt clears all tombstones and re-imports the deleted host', () => {
mockStore.addDeletedSshConfigAlias('mini')
loadUserSshConfigMock.mockReturnValue([{ host: 'mini' }])
sshConfigHostsToTargetsMock.mockReturnValue([candidate({ configHost: 'mini' })])
const result = sshStore.importFromSshConfig({ reAdopt: true })
expect(mockStore.clearDeletedSshConfigAliases).toHaveBeenCalled()
expect(mockStore.addSshTarget).toHaveBeenCalledWith(
expect.objectContaining({ configHost: 'mini', source: 'ssh-config' })
)
expect(result).toHaveLength(1)
})
it('reports every exact repo migration from a multi-host re-import', () => {
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old-a',
configHost: 'host-a',
host: 'host-a.example.com',
port: 22,
username: '',
label: 'host-a',
removedAt: 1
})
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old-b',
configHost: 'host-b',
host: 'host-b.example.com',
port: 22,
username: '',
label: 'host-b',
removedAt: 1
})
loadUserSshConfigMock.mockReturnValue([{ host: 'host-a' }, { host: 'host-b' }])
sshConfigHostsToTargetsMock.mockReturnValue([
candidate({ configHost: 'host-a' }),
candidate({ configHost: 'host-b' })
])
sshStore.importFromSshConfig({ reAdopt: true })
expect(sshStore.lastRepoReadoptions).toEqual([
{ oldTargetId: 'ssh-old-a', newTargetId: 'tmp-host-a', repoIds: ['repo-1'] },
{ oldTargetId: 'ssh-old-b', newTargetId: 'tmp-host-b', repoIds: ['repo-1'] }
])
})
})
describe('re-adoption of orphaned workspaces', () => {
it('records a tombstone when removing any user-facing target', () => {
mockStore.addSshTarget({
id: 'ssh-1',
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim',
source: 'manual'
})
sshStore.removeTarget('ssh-1')
expect(mockStore.addRemovedSshTargetTombstone).toHaveBeenCalledWith(
expect.objectContaining({
oldTargetId: 'ssh-1',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
)
})
it('does not tombstone runtime-owned targets', () => {
mockStore.addSshTarget({
id: 'runtime-ssh-abc',
label: 'VM',
host: 'vm.example.com',
port: 22,
username: 'tim',
owner: { type: 'on-demand-runtime', runtimeId: 'abc' }
})
sshStore.removeTarget('runtime-ssh-abc')
expect(mockStore.addRemovedSshTargetTombstone).not.toHaveBeenCalled()
})
it('re-adopts orphaned repos when the same host is re-added', () => {
// Simulate a prior removal by seeding a matching tombstone.
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old',
host: 'dev.example.com',
port: 22,
username: 'tim',
label: 'Dev',
removedAt: 1
})
sshStore.addTarget({
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
expect(mockStore.reassignSshTargetId).toHaveBeenCalledTimes(1)
const [oldId, newId] = mockStore.reassignSshTargetId.mock.calls[0]
expect(oldId).toBe('ssh-old')
expect(newId).toMatch(/^ssh-/)
expect(sshStore.lastRepoReadoptions).toEqual([
{ oldTargetId: 'ssh-old', newTargetId: newId, repoIds: ['repo-1'] }
])
})
// Why: drive the real remove→re-add path so the tombstone carries the
// defaulted configHost (host) that buildRemovedSshTargetTombstone produces,
// rather than a hand-seeded tombstone without one.
it('re-adopts through an actual removeTarget then re-add of the same host', () => {
const added = sshStore.addTarget({
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
sshStore.removeTarget(added.id)
// The tombstone was built from the real target (configHost defaulted to host).
const tombstones = mockStore.getRemovedSshTargetTombstones()
expect(tombstones).toHaveLength(1)
expect(tombstones[0]).toMatchObject({ oldTargetId: added.id, configHost: 'dev.example.com' })
mockStore.reassignSshTargetId.mockClear()
const readded = sshStore.addTarget({
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
expect(mockStore.reassignSshTargetId).toHaveBeenCalledWith(added.id, readded.id)
expect(sshStore.lastRepoReadoptions).toEqual([
{ oldTargetId: added.id, newTargetId: readded.id, repoIds: ['repo-1'] }
])
})
// A different account on the SAME host must NOT re-adopt, even though both
// manual adds default configHost to the shared hostname.
it('does not re-adopt a different account on the same host', () => {
const alice = sshStore.addTarget({
label: 'alice',
host: 'dev.example.com',
port: 22,
username: 'alice'
})
sshStore.removeTarget(alice.id)
mockStore.reassignSshTargetId.mockClear()
sshStore.addTarget({
label: 'bob',
host: 'dev.example.com',
port: 2222,
username: 'bob'
})
expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled()
})
it('does not re-adopt when the re-added host identity differs', () => {
mockStore.addRemovedSshTargetTombstone({
oldTargetId: 'ssh-old',
host: 'other.example.com',
port: 22,
username: 'root',
label: 'Other',
removedAt: 1
})
sshStore.addTarget({
label: 'Dev',
host: 'dev.example.com',
port: 22,
username: 'tim'
})
expect(mockStore.reassignSshTargetId).not.toHaveBeenCalled()
expect(sshStore.lastRepoReadoptions).toEqual([])
})
})
})