fix: fence stale repository SSH probes

This commit is contained in:
2026-09-13 00:27:32 +09:00
3 changed files with 146 additions and 10 deletions
@@ -1,5 +1,65 @@
import type { WorkspaceRuntimeResource } from "$lib/generated/workspace-api"; import type { WorkspaceRuntimeResource } from "$lib/generated/workspace-api";
export type RepositorySshProbeSelection<T> = Readonly<{
changed: boolean;
runtimeId: string;
probe: T | null;
selectedHostKey: string;
}>;
export function changeRepositorySshProbeRuntime<T>(
currentRuntimeId: string,
nextRuntimeId: string,
probe: T | null,
selectedHostKey: string,
): RepositorySshProbeSelection<T> {
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( export function repositorySshProbeRuntimes(
runtimes: readonly WorkspaceRuntimeResource[], runtimes: readonly WorkspaceRuntimeResource[],
): WorkspaceRuntimeResource[] { ): WorkspaceRuntimeResource[] {
@@ -7,7 +7,11 @@
import { parseRepositorySshHostTrust } from '$lib/workspace/api/repository-access'; import { parseRepositorySshHostTrust } from '$lib/workspace/api/repository-access';
import { formatDate, workspaceApiPath } from '$lib/workspace/api/http'; import { formatDate, workspaceApiPath } from '$lib/workspace/api/http';
import { parseRepositorySshConnectionProbeResponse } from '$lib/workspace/api/workspace-model'; 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'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
@@ -16,11 +20,28 @@
let selectedHostKey = $state(''); let selectedHostKey = $state('');
let pending = $state(false); let pending = $state(false);
let connectionMessage = $state<string | null>(null); let connectionMessage = $state<string | null>(null);
const probeFence = new RepositorySshProbeFence();
const probeRuntimes = $derived(data.runtimes ? repositorySshProbeRuntimes(data.runtimes.items) : []); 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(() => { $effect(() => {
if (!selectedRuntimeId) { if (!selectedRuntimeId) {
selectedRuntimeId = probeRuntimes[0]?.runtime_id ?? ''; selectRuntime(probeRuntimes[0]?.runtime_id ?? '');
} }
}); });
@@ -45,42 +66,52 @@
} }
async function runConnectionTest() { async function runConnectionTest() {
const operation = probeFence.capture(selectedRuntimeId);
pending = true; pending = true;
connectionMessage = null; connectionMessage = null;
probe = null; probe = null;
selectedHostKey = ''; selectedHostKey = '';
try { try {
const body: RepositorySshConnectionProbeRequest = { runtime_id: selectedRuntimeId }; const body: RepositorySshConnectionProbeRequest = { runtime_id: operation.runtimeId };
probe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body)); 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 ?? ''; selectedHostKey = probe.candidates[0]?.host_key ?? '';
connectionMessage = probe.trust_state === 'verified' connectionMessage = probe.trust_state === 'verified'
? 'The observed SSH host key matches the Workspace trust record.' ? 'The observed SSH host key matches the Workspace trust record.'
: 'Review the observed fingerprint before trusting this SSH host.'; : 'Review the observed fingerprint before trusting this SSH host.';
} catch (error) { } catch (error) {
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed'; connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed';
} finally { } finally {
pending = false; if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false;
} }
} }
async function confirmHostTrust() { async function confirmHostTrust() {
if (!probe || !selectedHostKey) return; if (!probe || !selectedHostKey || probe.runtime_id !== selectedRuntimeId) return;
const operation = probeFence.capture(selectedRuntimeId);
pending = true; pending = true;
connectionMessage = null; connectionMessage = null;
try { try {
const body: ConfirmRepositorySshHostTrustRequest = { const body: ConfirmRepositorySshHostTrustRequest = {
operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`, operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`,
runtime_id: probe.runtime_id, runtime_id: operation.runtimeId,
host_key: selectedHostKey, host_key: selectedHostKey,
expected_host_trust_revision: probe.expected_host_trust_revision expected_host_trust_revision: probe.expected_host_trust_revision
}; };
parseRepositorySshHostTrust(await requestConnectionTest('PUT', body)); parseRepositorySshHostTrust(await requestConnectionTest('PUT', body));
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
probe = { ...probe, trust_state: 'verified' }; probe = { ...probe, trust_state: 'verified' };
connectionMessage = 'SSH host trust saved. Future connections must present this key.'; connectionMessage = 'SSH host trust saved. Future connections must present this key.';
} catch (error) { } catch (error) {
if (!probeFence.isCurrent(operation, selectedRuntimeId)) return;
connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust'; connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust';
} finally { } finally {
pending = false; if (probeFence.isCurrent(operation, selectedRuntimeId)) pending = false;
} }
} }
</script> </script>
@@ -167,7 +198,7 @@
{:else if data.runtimes} {:else if data.runtimes}
<label> <label>
<span>Runtime</span> <span>Runtime</span>
<select bind:value={selectedRuntimeId} disabled={pending}> <select value={selectedRuntimeId} onchange={(event) => selectRuntime(event.currentTarget.value)}>
{#each probeRuntimes as runtime} {#each probeRuntimes as runtime}
<option value={runtime.runtime_id}>{runtime.label} · {runtime.runtime_id}</option> <option value={runtime.runtime_id}>{runtime.label} · {runtime.runtime_id}</option>
{/each} {/each}
@@ -1,7 +1,11 @@
import { assert, assertEquals } from "jsr:@std/assert"; import { assert, assertEquals } from "jsr:@std/assert";
import type { WorkspaceRuntimeResource } from "../src/lib/generated/workspace-api.ts"; import type { WorkspaceRuntimeResource } from "../src/lib/generated/workspace-api.ts";
import { parseRepositorySshConnectionProbeResponse } from "../src/lib/workspace/api/workspace-model.ts"; import { parseRepositorySshConnectionProbeResponse } from "../src/lib/workspace/api/workspace-model.ts";
import { repositorySshProbeRuntimes } from "../src/lib/workspace/repositories/ssh-connection.ts"; import {
changeRepositorySshProbeRuntime,
RepositorySshProbeFence,
repositorySshProbeRuntimes,
} from "../src/lib/workspace/repositories/ssh-connection.ts";
const root = new URL("../", import.meta.url); const root = new URL("../", import.meta.url);
const pageSource = await Deno.readTextFile( const pageSource = await Deno.readTextFile(
@@ -84,6 +88,47 @@ Deno.test("Repository SSH probe offers configured remote Runtimes regardless of
); );
}); });
Deno.test("changing the SSH probe Runtime clears the prior result and confirmation key", () => {
const runtimeAProbe = {
runtime_id: "runtime-a",
candidates: [{ host_key: "ssh-ed25519 runtime-a" }],
};
assertEquals(
changeRepositorySshProbeRuntime(
"runtime-a",
"runtime-b",
runtimeAProbe,
"ssh-ed25519 runtime-a",
),
{
changed: true,
runtimeId: "runtime-b",
probe: null,
selectedHostKey: "",
},
);
});
Deno.test("a delayed SSH probe response cannot apply after the Runtime changes", async () => {
const fence = new RepositorySshProbeFence();
const operation = fence.capture("runtime-a");
let renderedRuntimeId: string | null = null;
let resolveProbe!: (runtimeId: string) => void;
const delayedProbe = new Promise<string>((resolve) => {
resolveProbe = resolve;
}).then((runtimeId) => {
if (fence.isCurrent(operation, "runtime-b")) {
renderedRuntimeId = runtimeId;
}
});
fence.enter("runtime-b");
resolveProbe("runtime-a");
await delayedProbe;
assertEquals(renderedRuntimeId, null);
});
Deno.test("Repository SSH connection test requires an explicit host-key confirmation", () => { Deno.test("Repository SSH connection test requires an explicit host-key confirmation", () => {
for ( for (
const token of [ const token of [