mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user