fix(ssh): pick up ~/.ssh/config changes at launch and when the hosts menu opens (#29451)

* fix(ssh): pick up ~/.ssh/config changes at launch and when the hosts menu opens

* fix(ssh): retry publishing imported hosts after a failed listing
This commit is contained in:
Neil
2026-10-15 23:24:59 -07:00
committed by GitHub
parent cfca22070e
commit 3a18a7c679
7 changed files with 172 additions and 7 deletions
@@ -3,6 +3,7 @@
import { act, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resetSshConfigPassiveSyncForTests } from '../../lib/ssh-config-passive-sync'
import type { SshTarget } from '../../../../shared/ssh-types'
import { setRendererUiLanguage } from '@/i18n/i18n'
import { SshPane } from './SshPane'
@@ -54,6 +55,7 @@ afterEach(async () => {
container = null
root = null
Reflect.deleteProperty(window, 'api')
resetSshConfigPassiveSyncForTests()
vi.clearAllMocks()
await setRendererUiLanguage('en')
})
@@ -19,6 +19,7 @@ import { getAllWorktreesFromState } from '@/store/selectors'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
import { useSshAddTargetIntent } from './use-ssh-add-target-intent'
import { syncSshConfigTargetsPassively } from '../../lib/ssh-config-passive-sync'
export { getSshPaneSearchEntries } from './ssh-search'
type SshPaneProps = { addTargetIntentSignal?: number }
@@ -76,12 +77,7 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem
// and newly added hosts appear without a manual Import click. Best-effort —
// a sync failure must not block listing the already-known targets.
void (async () => {
try {
const result = await window.api.ssh.importConfig()
useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions)
} catch {
// Surfaced on demand via the explicit Import button; ignore here.
}
await syncSshConfigTargetsPassively()
if (abortController.signal.aborted) {
return
}
@@ -23,6 +23,7 @@ import { isListedRemoteServer } from '../../../../shared/runtime-environments'
import { RuntimeHostStatusRow } from './RuntimeHostStatusRow'
import { connectedHostCountLabel, connectingHostsLabel } from './ssh-status-segment-copy'
import { SshTargetStatusRow } from './SshTargetStatusRow'
import { syncSshConfigTargetsPassively } from '../../lib/ssh-config-passive-sync'
import { connectRuntimeHostAndReloadProjects } from './runtime-environment-explicit-connect'
import {
overallDotColor,
@@ -159,6 +160,7 @@ export function SshStatusSegment({
onOpenChange={(open) => {
if (open) {
void hydrateRuntimeEnvironmentStatuses()
void syncSshConfigTargetsPassively()
recordFeatureInteraction('ssh')
}
}}
@@ -6,8 +6,13 @@ import {
} from '@/store/slices/runtime-environment-ssh'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge'
import { resetSshConfigPassiveSyncForTests } from '../../lib/ssh-config-passive-sync'
type SshApiStub = {
importConfig?: () => Promise<{
targets: { id: string; label: string }[]
repoReadoptions: never[]
}>
listTargets: () => Promise<{ id: string; label: string }[]>
listRemovedTargetLabels: () => Promise<Record<string, string>>
getState: (args: { targetId: string }) => Promise<SshConnectionState | null>
@@ -53,6 +58,7 @@ describe('direct SSH hydration target metadata', () => {
const unsubs: (() => void)[] = []
beforeEach(() => {
resetSshConfigPassiveSyncForTests()
onStateChangedHandler = null
useAppStore.setState({
sshTargetLabels: new Map(),
@@ -70,6 +76,32 @@ describe('direct SSH hydration target metadata', () => {
vi.restoreAllMocks()
})
it('syncs ~/.ssh/config after the saved hosts load, so a newly added host shows up', async () => {
const importConfig = vi
.fn<NonNullable<SshApiStub['importConfig']>>()
.mockResolvedValue({ targets: [{ id: 'ssh-new', label: 'new-box' }], repoReadoptions: [] })
const listTargets = vi
.fn<SshApiStub['listTargets']>()
.mockResolvedValueOnce([{ id: 'ssh-old', label: 'old-box' }])
.mockResolvedValue([
{ id: 'ssh-old', label: 'old-box' },
{ id: 'ssh-new', label: 'new-box' }
])
stubWindow({
importConfig,
listTargets,
listRemovedTargetLabels: () => Promise.resolve({}),
getState: () => Promise.resolve(null)
})
registerDirectSshStateIpcBridge(unsubs)
await settle()
await settle()
expect(importConfig).toHaveBeenCalledWith()
expect(useAppStore.getState().sshTargetLabels.get('ssh-new')).toBe('new-box')
})
it('names a ghost host once its tombstone label lands', async () => {
let resolveRemovedLabels!: (labels: Record<string, string>) => void
stubWindow({
@@ -7,6 +7,7 @@ import {
type DirectSshConnectedStateOrigin
} from '../direct-ssh-state-routing'
import { hydrateDirectSshInitialState } from './direct-ssh-initial-state-hydration'
import { syncSshConfigTargetsPassively } from '../../lib/ssh-config-passive-sync'
import { applySshManagedServerTransition } from './ssh-managed-server-state-effects'
type AuthorityDeadline = { timer: ReturnType<typeof setTimeout>; settle: () => void }
@@ -56,7 +57,12 @@ export function registerDirectSshStateIpcBridge(unsubs: (() => void)[]): () => v
) => void
void hydrateDirectSshInitialState(isStopped, sshStateWatermarkByTargetId, (targetId, state) =>
applySshConnectionStateChange(targetId, state, 'initial-hydration')
)
).then(() => {
// Why: after the saved list paints, so a slow config parse never delays the first host list.
if (!isStopped()) {
void syncSshConfigTargetsPassively()
}
})
unsubs.push(
window.api.ssh.onCredentialRequest((data) => {
useAppStore.getState().enqueueSshCredentialRequest(data)
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '../store'
import {
resetSshConfigPassiveSyncForTests,
syncSshConfigTargetsPassively
} from './ssh-config-passive-sync'
const importConfig = vi.fn()
const listTargets = vi.fn()
beforeEach(() => {
vi.useFakeTimers()
resetSshConfigPassiveSyncForTests()
useAppStore.setState(useAppStore.getInitialState(), true)
importConfig.mockReset().mockResolvedValue({ targets: [], repoReadoptions: [] })
listTargets.mockReset().mockResolvedValue([])
vi.stubGlobal('window', { api: { ssh: { importConfig, listTargets } } })
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('syncSshConfigTargetsPassively', () => {
it('imports without re-adopting deleted hosts and publishes newly added ones', async () => {
importConfig.mockResolvedValue({
targets: [{ id: 'ssh-new', label: 'new-box' }],
repoReadoptions: []
})
listTargets.mockResolvedValue([
{ id: 'ssh-old', label: 'old-box' },
{ id: 'ssh-new', label: 'new-box' }
])
await syncSshConfigTargetsPassively()
expect(importConfig).toHaveBeenCalledWith()
expect(useAppStore.getState().sshTargetLabels.get('ssh-new')).toBe('new-box')
})
it('skips the listing when the config added or changed nothing', async () => {
await syncSshConfigTargetsPassively()
expect(importConfig).toHaveBeenCalledTimes(1)
expect(listTargets).not.toHaveBeenCalled()
})
it('shares one sync between callers and waits out the interval before the next', async () => {
await Promise.all([syncSshConfigTargetsPassively(), syncSshConfigTargetsPassively()])
await syncSshConfigTargetsPassively()
expect(importConfig).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(10_000)
await syncSshConfigTargetsPassively()
expect(importConfig).toHaveBeenCalledTimes(2)
})
it('retries the listing when the import already saved hosts but listing them failed', async () => {
importConfig.mockResolvedValueOnce({
targets: [{ id: 'ssh-new', label: 'new-box' }],
repoReadoptions: []
})
listTargets.mockRejectedValueOnce(new Error('ipc busy'))
await syncSshConfigTargetsPassively()
listTargets.mockResolvedValue([{ id: 'ssh-new', label: 'new-box' }])
vi.advanceTimersByTime(10_000)
await syncSshConfigTargetsPassively()
expect(useAppStore.getState().sshTargetLabels.get('ssh-new')).toBe('new-box')
})
it('swallows a failed import so callers still list known hosts', async () => {
importConfig.mockRejectedValue(new Error('bad config'))
await expect(syncSshConfigTargetsPassively()).resolves.toBeUndefined()
})
})
@@ -0,0 +1,48 @@
import { useAppStore } from '../store'
// Why: launch, the hosts menu and Settings → SSH can all ask within a second; one sync covers them.
const SSH_CONFIG_SYNC_MIN_INTERVAL_MS = 10_000
let inFlight: Promise<void> | null = null
let lastStartedAt = Number.NEGATIVE_INFINITY
// An import persists hosts before they are listed; a failed listing must be retried next time.
let metadataRefreshPending = false
/**
* Non-destructive ~/.ssh/config sync: no reAdopt, so hosts the user deleted stay deleted. Settings →
* SSH used to be its only trigger, which left the status-bar hosts menu stale for everyone else.
*/
export function syncSshConfigTargetsPassively(): Promise<void> {
if (inFlight) {
return inFlight
}
const now = Date.now()
if (now - lastStartedAt < SSH_CONFIG_SYNC_MIN_INTERVAL_MS) {
return Promise.resolve()
}
lastStartedAt = now
inFlight = (async () => {
try {
const result = await window.api.ssh.importConfig()
const store = useAppStore.getState()
store.recordSshRepoReadoptions(result.repoReadoptions)
// Unchanged config is the common case; skip the extra listing and store write.
if (result.targets.length > 0 || metadataRefreshPending) {
metadataRefreshPending = true
useAppStore.getState().setSshTargetsMetadata(await window.api.ssh.listTargets())
metadataRefreshPending = false
}
} catch {
// Best-effort; the explicit Import button surfaces failures.
} finally {
inFlight = null
}
})()
return inFlight
}
export function resetSshConfigPassiveSyncForTests(): void {
inFlight = null
lastStartedAt = Number.NEGATIVE_INFINITY
metadataRefreshPending = false
}