feat: complete Workspace Runtime management flow

This commit is contained in:
2026-09-09 09:38:26 +09:00
parent d0999326bd
commit 3df611636b
6 changed files with 312 additions and 119 deletions
@@ -919,6 +919,30 @@ export async function createRemoteRuntime(
return runtime;
}
export async function deleteRemoteRuntime(
workspaceId: string,
runtimeId: string,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}`,
),
{ method: "DELETE" },
);
if (response.ok) return;
let payload: unknown;
try {
payload = await readBoundedJson(response);
} catch {
throw new RuntimeTrustRequestError(
`Runtime registration delete failed (${response.status})`,
);
}
throw requestErrorFrom(payload, response.status);
}
export async function revealRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
@@ -678,7 +678,7 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
"Runtimes should be admin Settings navigation, not primary workspace sidebar navigation",
);
assert(
runtimesPage.includes("Add remote Runtime") &&
runtimesPage.includes("Connect a remote Runtime") &&
runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("settings-runtime-table") &&
runtimesPage.includes("testRuntimeConnection") &&
@@ -11,6 +11,7 @@
RuntimeTrustRequestError,
} from '$lib/workspace/api/runtime-management';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
import { provisionWorkspaceSigningIdentity } from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types';
const runtimeBundlePlaceholder =
@@ -21,10 +22,10 @@
let displayName = $state('');
let endpoint = $state('');
let runtimeFingerprint = $state<string | null>(null);
let fingerprintConfirmation = $state('');
let showAddRuntime = $state(false);
let busyRuntimeId = $state<string | null>(null);
let requestError = $state<string | null>(null);
let requestNotice = $state<string | null>(null);
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
let connectionTestGeneration = 0;
@@ -88,6 +89,25 @@
: '';
}
function workspaceBundleFilename(): string {
return `workspace-${data.workspaceId}-public-bundle.json`;
}
async function provisionSigningIdentity(): Promise<void> {
requestError = null;
requestNotice = null;
busyRuntimeId = 'provision-workspace-identity';
try {
await provisionWorkspaceSigningIdentity(data.workspaceId);
await invalidateAll();
requestNotice = 'Workspace identity provisioned. Copy its public bundle to the Runtime host.';
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function copyWorkspaceBundle(): Promise<void> {
requestError = null;
try {
@@ -100,7 +120,6 @@
async function previewRuntimeFingerprint(): Promise<void> {
requestError = null;
runtimeFingerprint = null;
fingerprintConfirmation = '';
busyRuntimeId = 'preview';
try {
const bundle = parseRuntimePublicBundle(runtimePublicBundle);
@@ -115,15 +134,13 @@
async function addRuntime(event: SubmitEvent): Promise<void> {
event.preventDefault();
requestError = null;
requestNotice = null;
busyRuntimeId = 'create';
try {
const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key);
if (
runtimeFingerprint !== currentFingerprint ||
fingerprintConfirmation.trim() !== currentFingerprint
) {
throw new Error('Preview and confirm the exact Runtime public key fingerprint before registration');
if (runtimeFingerprint !== currentFingerprint) {
throw new Error('Preview the Runtime public key fingerprint before registration');
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: publicBundle,
@@ -133,10 +150,10 @@
});
runtimePublicBundle = '';
runtimeFingerprint = null;
fingerprintConfirmation = '';
displayName = '';
endpoint = '';
showAddRuntime = false;
requestNotice = 'Runtime registered for this Workspace. Run Test to complete authenticated verification.';
await invalidateAll();
} catch (error) {
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
@@ -205,16 +222,63 @@
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
<form class="settings-runtime-form" onsubmit={addRuntime}>
<h2>Add remote Runtime</h2>
<header>
<h2>Connect a remote Runtime</h2>
<p>
This creates a binding for this Workspace. The Runtime can remain connected to other Workspaces;
their trust entries are not replaced.
</p>
</header>
<section class="settings-runtime-trust-instructions" aria-labelledby="workspace-to-runtime-heading">
<h3 id="workspace-to-runtime-heading">1. Trust this Workspace on the Runtime</h3>
<p>
Each Workspace has its own signing identity. Add this Workspace public bundle to the same store used
when starting the Runtime.
</p>
{#if data.signingIdentityError}
<p class="section-state error">{data.signingIdentityError}</p>
{:else if data.signingIdentity?.identity.state === 'pending_provisioning'}
<p>This Workspace does not have an active signing identity yet.</p>
<button
type="button"
disabled={busyRuntimeId !== null}
onclick={() => void provisionSigningIdentity()}
>
{busyRuntimeId === 'provision-workspace-identity' ? 'Provisioning…' : 'Provision Workspace identity'}
</button>
{:else if data.signingIdentity?.public_bundle}
<p>
Save the bundle as <code>{workspaceBundleFilename()}</code> on the Runtime host. It contains no
private key material.
</p>
<pre>{workspacePublicBundle()}</pre>
<button type="button" disabled={busyRuntimeId !== null} onclick={copyWorkspaceBundle}>
Copy Workspace public bundle
</button>
<pre>yoi-runtime trust-workspace add --bundle {workspaceBundleFilename()}</pre>
<small>
Pass the same <code>--fs-root</code> and <code>--fs-runtime-dir</code> options used by the Runtime
service. Existing Workspace trust entries are preserved.
</small>
{:else}
<p class="section-state">Loading Workspace public identity…</p>
{/if}
</section>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-to-workspace-heading">
<h3 id="runtime-to-workspace-heading">2. Verify the Runtime identity</h3>
<p>
On the Runtime host, run <code>yoi-runtime identity show --json</code> with the same Runtime storage
options, then paste the public bundle below.
</p>
<div class="settings-form-grid">
<label class="settings-form-wide">
Runtime public bundle
<small>Run <code>yoi-runtime identity show --json</code> on the Runtime host and paste the result.</small>
<textarea
bind:value={runtimePublicBundle}
oninput={() => {
runtimeFingerprint = null;
fingerprintConfirmation = '';
}}
required
rows="5"
@@ -226,17 +290,19 @@
</button>
</label>
{#if runtimeFingerprint}
<label>
Runtime key fingerprint
<code>{runtimeFingerprint}</code>
<input
bind:value={fingerprintConfirmation}
required
autocomplete="off"
placeholder="Enter the fingerprint exactly"
/>
</label>
<dl class="runtime-facts">
<div>
<dt>Runtime fingerprint</dt>
<dd><code>{runtimeFingerprint}</code></dd>
</div>
</dl>
{/if}
</div>
</section>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-connection-heading">
<h3 id="runtime-connection-heading">3. Register the connection</h3>
<div class="settings-form-grid">
<label>
Display name
<input bind:value={displayName} autocomplete="off" />
@@ -246,30 +312,17 @@
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
</label>
</div>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-trust-heading">
<h3 id="runtime-trust-heading">Trust this Workspace on the Runtime</h3>
{#if data.signingIdentityError}
<p class="section-state error">{data.signingIdentityError}</p>
{:else if data.signingIdentity?.public_bundle}
<p>
Save this public bundle as <code>workspace-public-bundle.json</code> on the Runtime host.
It contains no private key material.
Registration stores this Workspace-scoped binding. After it appears in the list, run
<strong>Test</strong> to complete authenticated verification.
</p>
<pre>{workspacePublicBundle()}</pre>
<button type="button" onclick={copyWorkspaceBundle}>Copy Workspace public bundle</button>
<pre>yoi-runtime trust-workspace add --bundle workspace-public-bundle.json</pre>
<p>
Runtime registration remains <code>configured</code> until authenticated verification is completed.
</p>
{:else}
<p class="section-state">Loading Workspace public identity…</p>
{/if}
</section>
<div class="settings-action-row">
<button
type="submit"
disabled={busyRuntimeId !== null || !runtimeFingerprint || fingerprintConfirmation.trim() !== runtimeFingerprint}
>Add Runtime</button>
disabled={busyRuntimeId !== null || !data.signingIdentity?.public_bundle || !runtimeFingerprint}
>Register Runtime</button>
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
Cancel
</button>
@@ -280,6 +333,9 @@
{#if requestError}
<p class="section-state error">{requestError}</p>
{/if}
{#if requestNotice}
<p class="section-state">{requestNotice}</p>
{/if}
{#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p>
@@ -1,11 +1,12 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { goto, invalidateAll } from '$app/navigation';
import type {
RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyStatus,
} from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
deleteRemoteRuntime,
previewRuntimePublicKeyFingerprint,
revealRuntimeTrustKey,
revokeRuntimeTrustKey,
@@ -22,10 +23,10 @@
let showPublicKey = $state(false);
let revealedPublicKey = $state<string | null>(null);
let publicKey = $state('');
let fingerprintConfirmation = $state('');
let revokeFingerprintConfirmation = $state('');
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | null>(null);
let deleteRuntimeConfirmation = $state('');
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
let fieldError = $state<string | null>(null);
let deleteRuntimeError = $state<string | null>(null);
let requestError = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let replacementFingerprint = $state<string | null>(null);
@@ -42,10 +43,10 @@
showPublicKey = false;
revealedPublicKey = null;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
deleteRuntimeConfirmation = '';
busyAction = null;
fieldError = null;
deleteRuntimeError = null;
requestError = null;
successMessage = null;
replacementFingerprint = null;
@@ -132,16 +133,6 @@
const trust = data.runtimeDetail.trust_key;
const action = trustAction(trust.status);
if (action !== 'create') {
if (!trust.fingerprint) {
requestError = 'The authoritative fingerprint is unavailable. Reload before changing trust.';
return;
}
if (fingerprintConfirmation.trim() !== trust.fingerprint) {
fieldError = 'Enter the current fingerprint exactly to confirm this change.';
return;
}
}
const operation = routeFence.capture(data.runtimeId);
busyAction = 'save';
@@ -163,8 +154,6 @@
});
if (!isCurrentRoute(operation)) return;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = action === 'create'
@@ -175,7 +164,6 @@
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
fingerprintConfirmation = '';
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
@@ -196,11 +184,8 @@
requestError = 'Only active Workspace trust can be revoked.';
return;
}
if (
!trust.fingerprint ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
) {
fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.';
if (!trust.fingerprint) {
requestError = 'The authoritative fingerprint is unavailable. Reload before revoking trust.';
return;
}
@@ -219,12 +204,10 @@
operation.runtimeId,
request,
trust.fingerprint,
revokeFingerprintConfirmation,
trust.fingerprint,
);
if (!isCurrentRoute(operation)) return;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = 'Workspace trust was revoked.';
@@ -242,6 +225,48 @@
}
}
async function deleteRegistration(): Promise<void> {
if (busyAction !== null || !data.runtimeDetail) return;
const runtime = data.runtimeDetail.runtime;
if (runtime.management.built_in) return;
if (deleteRuntimeConfirmation.trim() !== data.runtimeId) {
deleteRuntimeError = 'Enter the Runtime ID exactly to confirm deletion.';
return;
}
const operation = routeFence.capture(data.runtimeId);
busyAction = 'delete';
deleteRuntimeError = null;
try {
if (data.runtimeDetail.trust_key.status !== 'revoked') {
const trust = data.runtimeDetail.trust_key;
if (trust.revision == null || !trust.fingerprint) {
throw new Error('Runtime trust revision and fingerprint are required before deletion.');
}
await revokeRuntimeTrustKey(
data.workspaceId,
operation.runtimeId,
{ expected_revision: trust.revision },
trust.fingerprint,
trust.fingerprint,
);
if (!isCurrentRoute(operation)) return;
}
await deleteRemoteRuntime(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
replaceState: true,
});
} catch (error) {
if (!isCurrentRoute(operation)) return;
deleteRuntimeError = error instanceof Error
? error.message
: 'Runtime registration deletion failed.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function togglePublicKeyReveal(): Promise<void> {
if (showPublicKey) {
showPublicKey = false;
@@ -397,18 +422,6 @@
<p class="field-error">{replacementFingerprintError}</p>
{/if}
{#if currentAction !== 'create'}
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
<input
id="runtime-fingerprint-confirmation"
bind:value={fingerprintConfirmation}
autocomplete="off"
spellcheck="false"
placeholder={trust.fingerprint ?? ''}
/>
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly.</small>
{/if}
{#if fieldError}
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
{/if}
@@ -423,25 +436,11 @@
<div>
<strong>Revoke Workspace trust</strong>
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
<label>
Confirm current fingerprint
<input
bind:value={revokeFingerprintConfirmation}
autocomplete="off"
spellcheck="false"
disabled={trust.status !== 'active' || busyAction !== null}
/>
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly before revocation.</small>
</label>
</div>
<button
type="button"
class="danger"
disabled={
busyAction !== null ||
trust.status !== 'active' ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
}
disabled={busyAction !== null || trust.status !== 'active'}
onclick={revokeTrust}
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
</div>
@@ -480,5 +479,45 @@
</div>
{/if}
</section>
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
<section class="runtime-detail-section runtime-danger-zone" aria-labelledby="runtime-delete-heading">
<h2 id="runtime-delete-heading">Delete Runtime registration</h2>
<p>
Remove this Runtime binding from the current Workspace. This does not stop the Runtime process,
delete its Workers or Workdirs, or revoke this Workspace on the Runtime host.
</p>
{#if trust.status !== 'revoked'}
<p class="section-state warning">
Deletion will revoke this Workspace trust first. Stop or move active Workers before continuing.
</p>
{/if}
<label for="runtime-delete-confirmation">Confirm Runtime ID</label>
<input
id="runtime-delete-confirmation"
bind:value={deleteRuntimeConfirmation}
autocomplete="off"
spellcheck="false"
disabled={busyAction !== null}
placeholder={data.runtimeId}
/>
<small>Enter <code>{data.runtimeId}</code> exactly.</small>
{#if deleteRuntimeError}
<p class="section-state error" role="alert">{deleteRuntimeError}</p>
{/if}
<div class="settings-action-row">
<button
type="button"
class="danger"
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
onclick={deleteRegistration}
>{busyAction === 'delete'
? 'Deleting…'
: trust.status === 'revoked'
? 'Delete registration'
: 'Revoke trust and delete registration'}</button>
</div>
</section>
{/if}
{/if}
</section>
@@ -44,6 +44,48 @@ Deno.test("Runtime Settings routes validate unknown JSON through the shared Runt
}
});
Deno.test("Runtime registration presents the complete multi-Workspace trust sequence", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte",
import.meta.url,
),
);
for (
const token of [
"1. Trust this Workspace on the Runtime",
"Provision Workspace identity",
"provisionWorkspaceSigningIdentity(data.workspaceId)",
"Existing Workspace trust entries are preserved.",
"--fs-root",
"--fs-runtime-dir",
"2. Verify the Runtime identity",
"yoi-runtime identity show --json",
"3. Register the connection",
"Register Runtime",
"Run Test to complete authenticated verification.",
]
) {
assert(
page.includes(token),
`Runtime registration should include ${token}`,
);
}
assert(
page.includes(
"data.signingIdentity?.identity.state === 'pending_provisioning'",
) &&
page.includes("Loading Workspace public identity…"),
"pending identity must have a dedicated provisioning state before loading fallback",
);
assert(
!page.includes("fingerprintConfirmation") &&
!page.includes("Confirm Runtime fingerprint"),
"Runtime registration must not require retyping a fingerprint",
);
});
Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
const page = await Deno.readTextFile(
new URL(
@@ -111,22 +153,26 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"Create Workspace trust",
"Replace trusted key",
"Reactivate with this key",
"Confirm current fingerprint",
"Revoke Workspace trust",
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
"await revokeRuntimeTrustKey(",
"deleteRemoteRuntime(data.workspaceId, operation.runtimeId)",
"Revoke trust and delete registration",
"trust.status !== 'revoked'",
"deleteRuntimeConfirmation.trim() !== data.runtimeId",
"Delete Runtime registration",
"Delete registration",
"This does not stop the Runtime process",
"RuntimeTrustConflictError",
"RuntimeTrustRouteFence",
"routeFence.enter(data.runtimeId)",
"showPublicKey = false",
"revealedPublicKey = null",
"publicKey = ''",
"fingerprintConfirmation = ''",
"revokeFingerprintConfirmation = ''",
"requestError = null",
"successMessage = null",
"isCurrentRoute(operation)",
"revealRuntimeTrustKey",
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
"await reloadAuthority()",
"busyAction !== null",
"Workdirs",
@@ -135,6 +181,15 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
) {
assert(page.includes(token), `Runtime detail should include ${token}`);
}
for (
const token of [
"fingerprintConfirmation",
"revokeFingerprintConfirmation",
"Confirm current fingerprint",
]
) {
assert(!page.includes(token), `Runtime detail must not require ${token}`);
}
});
Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
@@ -4,6 +4,7 @@ declare const Deno: {
import {
createRemoteRuntime,
deleteRemoteRuntime,
parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail,
@@ -272,6 +273,24 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => {
assert(requests === 0, "mismatched fingerprint sent a revoke request");
});
Deno.test("Runtime registration delete uses the Workspace-scoped resource route", async () => {
let requestedUrl = "";
let requestedMethod = "";
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
requestedUrl = String(input);
requestedMethod = init?.method ?? "GET";
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
await deleteRemoteRuntime("workspace a", "runtime/a", fetchImpl);
assert(
requestedUrl === "/api/w/workspace%20a/runtimes/runtime%2Fa",
`unexpected delete URL: ${requestedUrl}`,
);
assert(requestedMethod === "DELETE", "Runtime delete must use DELETE");
});
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
const fence = new RuntimeTrustRouteFence();
fence.enter("runtime-a");