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; 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( export async function revealRuntimeTrustKey(
workspaceId: string, workspaceId: string,
runtimeId: 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", "Runtimes should be admin Settings navigation, not primary workspace sidebar navigation",
); );
assert( assert(
runtimesPage.includes("Add remote Runtime") && runtimesPage.includes("Connect a remote Runtime") &&
runtimesPage.includes("Open workdirs") && runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("settings-runtime-table") && runtimesPage.includes("settings-runtime-table") &&
runtimesPage.includes("testRuntimeConnection") && runtimesPage.includes("testRuntimeConnection") &&
@@ -11,6 +11,7 @@
RuntimeTrustRequestError, RuntimeTrustRequestError,
} from '$lib/workspace/api/runtime-management'; } from '$lib/workspace/api/runtime-management';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection'; import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
import { provisionWorkspaceSigningIdentity } from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
const runtimeBundlePlaceholder = const runtimeBundlePlaceholder =
@@ -21,10 +22,10 @@
let displayName = $state(''); let displayName = $state('');
let endpoint = $state(''); let endpoint = $state('');
let runtimeFingerprint = $state<string | null>(null); let runtimeFingerprint = $state<string | null>(null);
let fingerprintConfirmation = $state('');
let showAddRuntime = $state(false); let showAddRuntime = $state(false);
let busyRuntimeId = $state<string | null>(null); let busyRuntimeId = $state<string | null>(null);
let requestError = $state<string | null>(null); let requestError = $state<string | null>(null);
let requestNotice = $state<string | null>(null);
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({}); let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
let connectionTestGeneration = 0; 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> { async function copyWorkspaceBundle(): Promise<void> {
requestError = null; requestError = null;
try { try {
@@ -100,7 +120,6 @@
async function previewRuntimeFingerprint(): Promise<void> { async function previewRuntimeFingerprint(): Promise<void> {
requestError = null; requestError = null;
runtimeFingerprint = null; runtimeFingerprint = null;
fingerprintConfirmation = '';
busyRuntimeId = 'preview'; busyRuntimeId = 'preview';
try { try {
const bundle = parseRuntimePublicBundle(runtimePublicBundle); const bundle = parseRuntimePublicBundle(runtimePublicBundle);
@@ -115,15 +134,13 @@
async function addRuntime(event: SubmitEvent): Promise<void> { async function addRuntime(event: SubmitEvent): Promise<void> {
event.preventDefault(); event.preventDefault();
requestError = null; requestError = null;
requestNotice = null;
busyRuntimeId = 'create'; busyRuntimeId = 'create';
try { try {
const publicBundle = parseRuntimePublicBundle(runtimePublicBundle); const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key); const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key);
if ( if (runtimeFingerprint !== currentFingerprint) {
runtimeFingerprint !== currentFingerprint || throw new Error('Preview the Runtime public key fingerprint before registration');
fingerprintConfirmation.trim() !== currentFingerprint
) {
throw new Error('Preview and confirm the exact Runtime public key fingerprint before registration');
} }
await createRemoteRuntime(data.workspaceId, { await createRemoteRuntime(data.workspaceId, {
public_bundle: publicBundle, public_bundle: publicBundle,
@@ -133,10 +150,10 @@
}); });
runtimePublicBundle = ''; runtimePublicBundle = '';
runtimeFingerprint = null; runtimeFingerprint = null;
fingerprintConfirmation = '';
displayName = ''; displayName = '';
endpoint = ''; endpoint = '';
showAddRuntime = false; showAddRuntime = false;
requestNotice = 'Runtime registered for this Workspace. Run Test to complete authenticated verification.';
await invalidateAll(); await invalidateAll();
} catch (error) { } catch (error) {
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
@@ -205,16 +222,63 @@
{#if showAddRuntime && data.workspace.permissions.manage_runtimes} {#if showAddRuntime && data.workspace.permissions.manage_runtimes}
<form class="settings-runtime-form" onsubmit={addRuntime}> <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"> <div class="settings-form-grid">
<label class="settings-form-wide"> <label class="settings-form-wide">
Runtime public bundle Runtime public bundle
<small>Run <code>yoi-runtime identity show --json</code> on the Runtime host and paste the result.</small>
<textarea <textarea
bind:value={runtimePublicBundle} bind:value={runtimePublicBundle}
oninput={() => { oninput={() => {
runtimeFingerprint = null; runtimeFingerprint = null;
fingerprintConfirmation = '';
}} }}
required required
rows="5" rows="5"
@@ -226,17 +290,19 @@
</button> </button>
</label> </label>
{#if runtimeFingerprint} {#if runtimeFingerprint}
<label> <dl class="runtime-facts">
Runtime key fingerprint <div>
<code>{runtimeFingerprint}</code> <dt>Runtime fingerprint</dt>
<input <dd><code>{runtimeFingerprint}</code></dd>
bind:value={fingerprintConfirmation} </div>
required </dl>
autocomplete="off"
placeholder="Enter the fingerprint exactly"
/>
</label>
{/if} {/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> <label>
Display name Display name
<input bind:value={displayName} autocomplete="off" /> <input bind:value={displayName} autocomplete="off" />
@@ -246,30 +312,17 @@
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" /> <input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
</label> </label>
</div> </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> <p>
Save this public bundle as <code>workspace-public-bundle.json</code> on the Runtime host. Registration stores this Workspace-scoped binding. After it appears in the list, run
It contains no private key material. <strong>Test</strong> to complete authenticated verification.
</p> </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> </section>
<div class="settings-action-row"> <div class="settings-action-row">
<button <button
type="submit" type="submit"
disabled={busyRuntimeId !== null || !runtimeFingerprint || fingerprintConfirmation.trim() !== runtimeFingerprint} disabled={busyRuntimeId !== null || !data.signingIdentity?.public_bundle || !runtimeFingerprint}
>Add Runtime</button> >Register Runtime</button>
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}> <button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
Cancel Cancel
</button> </button>
@@ -280,6 +333,9 @@
{#if requestError} {#if requestError}
<p class="section-state error">{requestError}</p> <p class="section-state error">{requestError}</p>
{/if} {/if}
{#if requestNotice}
<p class="section-state">{requestNotice}</p>
{/if}
{#if data.runtimesError} {#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p> <p class="section-state error">{data.runtimesError}</p>
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import type { import type {
RevokeRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyStatus, RuntimeTrustKeyStatus,
} from '$lib/generated/workspace-api'; } from '$lib/generated/workspace-api';
import { import {
createRemoteRuntime, createRemoteRuntime,
deleteRemoteRuntime,
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
revealRuntimeTrustKey, revealRuntimeTrustKey,
revokeRuntimeTrustKey, revokeRuntimeTrustKey,
@@ -22,10 +23,10 @@
let showPublicKey = $state(false); let showPublicKey = $state(false);
let revealedPublicKey = $state<string | null>(null); let revealedPublicKey = $state<string | null>(null);
let publicKey = $state(''); let publicKey = $state('');
let fingerprintConfirmation = $state(''); let deleteRuntimeConfirmation = $state('');
let revokeFingerprintConfirmation = $state(''); let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | null>(null);
let fieldError = $state<string | null>(null); let fieldError = $state<string | null>(null);
let deleteRuntimeError = $state<string | null>(null);
let requestError = $state<string | null>(null); let requestError = $state<string | null>(null);
let successMessage = $state<string | null>(null); let successMessage = $state<string | null>(null);
let replacementFingerprint = $state<string | null>(null); let replacementFingerprint = $state<string | null>(null);
@@ -42,10 +43,10 @@
showPublicKey = false; showPublicKey = false;
revealedPublicKey = null; revealedPublicKey = null;
publicKey = ''; publicKey = '';
fingerprintConfirmation = ''; deleteRuntimeConfirmation = '';
revokeFingerprintConfirmation = '';
busyAction = null; busyAction = null;
fieldError = null; fieldError = null;
deleteRuntimeError = null;
requestError = null; requestError = null;
successMessage = null; successMessage = null;
replacementFingerprint = null; replacementFingerprint = null;
@@ -132,16 +133,6 @@
const trust = data.runtimeDetail.trust_key; const trust = data.runtimeDetail.trust_key;
const action = trustAction(trust.status); 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); const operation = routeFence.capture(data.runtimeId);
busyAction = 'save'; busyAction = 'save';
@@ -163,8 +154,6 @@
}); });
if (!isCurrentRoute(operation)) return; if (!isCurrentRoute(operation)) return;
publicKey = ''; publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false; showPublicKey = false;
revealedPublicKey = null; revealedPublicKey = null;
successMessage = action === 'create' successMessage = action === 'create'
@@ -175,7 +164,6 @@
await reloadAuthority(); await reloadAuthority();
} catch (error) { } catch (error) {
if (!isCurrentRoute(operation)) return; if (!isCurrentRoute(operation)) return;
fingerprintConfirmation = '';
if (error instanceof RuntimeTrustConflictError) { if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`; requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority(); await reloadAuthority();
@@ -196,11 +184,8 @@
requestError = 'Only active Workspace trust can be revoked.'; requestError = 'Only active Workspace trust can be revoked.';
return; return;
} }
if ( if (!trust.fingerprint) {
!trust.fingerprint || requestError = 'The authoritative fingerprint is unavailable. Reload before revoking trust.';
revokeFingerprintConfirmation.trim() !== trust.fingerprint
) {
fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.';
return; return;
} }
@@ -219,12 +204,10 @@
operation.runtimeId, operation.runtimeId,
request, request,
trust.fingerprint, trust.fingerprint,
revokeFingerprintConfirmation, trust.fingerprint,
); );
if (!isCurrentRoute(operation)) return; if (!isCurrentRoute(operation)) return;
publicKey = ''; publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false; showPublicKey = false;
revealedPublicKey = null; revealedPublicKey = null;
successMessage = 'Workspace trust was revoked.'; 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> { async function togglePublicKeyReveal(): Promise<void> {
if (showPublicKey) { if (showPublicKey) {
showPublicKey = false; showPublicKey = false;
@@ -397,18 +422,6 @@
<p class="field-error">{replacementFingerprintError}</p> <p class="field-error">{replacementFingerprintError}</p>
{/if} {/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} {#if fieldError}
<p id="runtime-public-key-error" class="field-error">{fieldError}</p> <p id="runtime-public-key-error" class="field-error">{fieldError}</p>
{/if} {/if}
@@ -423,25 +436,11 @@
<div> <div>
<strong>Revoke Workspace trust</strong> <strong>Revoke Workspace trust</strong>
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p> <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> </div>
<button <button
type="button" type="button"
class="danger" class="danger"
disabled={ disabled={busyAction !== null || trust.status !== 'active'}
busyAction !== null ||
trust.status !== 'active' ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
}
onclick={revokeTrust} onclick={revokeTrust}
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button> >{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
</div> </div>
@@ -480,5 +479,45 @@
</div> </div>
{/if} {/if}
</section> </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} {/if}
</section> </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 () => { Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
const page = await Deno.readTextFile( const page = await Deno.readTextFile(
new URL( new URL(
@@ -111,22 +153,26 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"Create Workspace trust", "Create Workspace trust",
"Replace trusted key", "Replace trusted key",
"Reactivate with this key", "Reactivate with this key",
"Confirm current fingerprint",
"Revoke Workspace trust", "Revoke Workspace trust",
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.", "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", "RuntimeTrustConflictError",
"RuntimeTrustRouteFence", "RuntimeTrustRouteFence",
"routeFence.enter(data.runtimeId)", "routeFence.enter(data.runtimeId)",
"showPublicKey = false", "showPublicKey = false",
"revealedPublicKey = null", "revealedPublicKey = null",
"publicKey = ''", "publicKey = ''",
"fingerprintConfirmation = ''",
"revokeFingerprintConfirmation = ''",
"requestError = null", "requestError = null",
"successMessage = null", "successMessage = null",
"isCurrentRoute(operation)", "isCurrentRoute(operation)",
"revealRuntimeTrustKey", "revealRuntimeTrustKey",
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
"await reloadAuthority()", "await reloadAuthority()",
"busyAction !== null", "busyAction !== null",
"Workdirs", "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}`); 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 () => { Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
@@ -4,6 +4,7 @@ declare const Deno: {
import { import {
createRemoteRuntime, createRemoteRuntime,
deleteRemoteRuntime,
parseRuntimeTrustConflict, parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse, parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail, parseWorkspaceRuntimeDetail,
@@ -272,6 +273,24 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => {
assert(requests === 0, "mismatched fingerprint sent a revoke request"); 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 () => { Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
const fence = new RuntimeTrustRouteFence(); const fence = new RuntimeTrustRouteFence();
fence.enter("runtime-a"); fence.enter("runtime-a");