diff --git a/web/workspace/src/lib/workspace/repositories/ssh-connection.ts b/web/workspace/src/lib/workspace/repositories/ssh-connection.ts index c49f8a47..d76e374f 100644 --- a/web/workspace/src/lib/workspace/repositories/ssh-connection.ts +++ b/web/workspace/src/lib/workspace/repositories/ssh-connection.ts @@ -1,5 +1,65 @@ import type { WorkspaceRuntimeResource } from "$lib/generated/workspace-api"; +export type RepositorySshProbeSelection = Readonly<{ + changed: boolean; + runtimeId: string; + probe: T | null; + selectedHostKey: string; +}>; + +export function changeRepositorySshProbeRuntime( + currentRuntimeId: string, + nextRuntimeId: string, + probe: T | null, + selectedHostKey: string, +): RepositorySshProbeSelection { + if (currentRuntimeId === nextRuntimeId) { + return { + changed: false, + runtimeId: currentRuntimeId, + probe, + selectedHostKey, + }; + } + return { + changed: true, + runtimeId: nextRuntimeId, + probe: null, + selectedHostKey: "", + }; +} + +export type RepositorySshProbeOperation = Readonly<{ + runtimeId: string; + generation: number; +}>; + +export class RepositorySshProbeFence { + #runtimeId: string | null = null; + #generation = 0; + + enter(runtimeId: string): number { + if (this.#runtimeId !== runtimeId) { + this.#runtimeId = runtimeId; + this.#generation += 1; + } + return this.#generation; + } + + capture(runtimeId: string): RepositorySshProbeOperation { + return { runtimeId, generation: this.enter(runtimeId) }; + } + + isCurrent( + operation: RepositorySshProbeOperation, + runtimeId: string, + ): boolean { + return operation.runtimeId === runtimeId && + operation.generation === this.#generation && + this.#runtimeId === runtimeId; + } +} + export function repositorySshProbeRuntimes( runtimes: readonly WorkspaceRuntimeResource[], ): WorkspaceRuntimeResource[] { diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte index 717062c8..215e879a 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte @@ -7,7 +7,11 @@ import { parseRepositorySshHostTrust } from '$lib/workspace/api/repository-access'; import { formatDate, workspaceApiPath } from '$lib/workspace/api/http'; import { parseRepositorySshConnectionProbeResponse } from '$lib/workspace/api/workspace-model'; - import { repositorySshProbeRuntimes } from '$lib/workspace/repositories/ssh-connection'; + import { + changeRepositorySshProbeRuntime, + RepositorySshProbeFence, + repositorySshProbeRuntimes + } from '$lib/workspace/repositories/ssh-connection'; import type { PageProps } from './$types'; let { data }: PageProps = $props(); @@ -16,11 +20,28 @@ let selectedHostKey = $state(''); let pending = $state(false); let connectionMessage = $state(null); + const probeFence = new RepositorySshProbeFence(); const probeRuntimes = $derived(data.runtimes ? repositorySshProbeRuntimes(data.runtimes.items) : []); + function selectRuntime(runtimeId: string) { + const selection = changeRepositorySshProbeRuntime( + selectedRuntimeId, + runtimeId, + probe, + selectedHostKey + ); + if (!selection.changed) return; + selectedRuntimeId = selection.runtimeId; + probe = selection.probe; + selectedHostKey = selection.selectedHostKey; + probeFence.enter(runtimeId); + connectionMessage = null; + pending = false; + } + $effect(() => { if (!selectedRuntimeId) { - selectedRuntimeId = probeRuntimes[0]?.runtime_id ?? ''; + selectRuntime(probeRuntimes[0]?.runtime_id ?? ''); } }); @@ -45,42 +66,52 @@ } async function runConnectionTest() { + const operation = probeFence.capture(selectedRuntimeId); pending = true; connectionMessage = null; probe = null; selectedHostKey = ''; try { - const body: RepositorySshConnectionProbeRequest = { runtime_id: selectedRuntimeId }; - probe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body)); + const body: RepositorySshConnectionProbeRequest = { runtime_id: operation.runtimeId }; + const nextProbe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body)); + if (!probeFence.isCurrent(operation, selectedRuntimeId)) return; + if (nextProbe.runtime_id !== operation.runtimeId) { + throw new Error('SSH connection test returned a different Runtime'); + } + probe = nextProbe; selectedHostKey = probe.candidates[0]?.host_key ?? ''; connectionMessage = probe.trust_state === 'verified' ? 'The observed SSH host key matches the Workspace trust record.' : 'Review the observed fingerprint before trusting this SSH host.'; } catch (error) { + if (!probeFence.isCurrent(operation, selectedRuntimeId)) return; connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed'; } finally { - pending = false; + if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false; } } async function confirmHostTrust() { - if (!probe || !selectedHostKey) return; + if (!probe || !selectedHostKey || probe.runtime_id !== selectedRuntimeId) return; + const operation = probeFence.capture(selectedRuntimeId); pending = true; connectionMessage = null; try { const body: ConfirmRepositorySshHostTrustRequest = { operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`, - runtime_id: probe.runtime_id, + runtime_id: operation.runtimeId, host_key: selectedHostKey, expected_host_trust_revision: probe.expected_host_trust_revision }; parseRepositorySshHostTrust(await requestConnectionTest('PUT', body)); + if (!probeFence.isCurrent(operation, selectedRuntimeId)) return; probe = { ...probe, trust_state: 'verified' }; connectionMessage = 'SSH host trust saved. Future connections must present this key.'; } catch (error) { + if (!probeFence.isCurrent(operation, selectedRuntimeId)) return; connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust'; } finally { - pending = false; + if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false; } } @@ -167,7 +198,7 @@ {:else if data.runtimes}