fix: lock verified Runtime public keys in settings

This commit is contained in:
2026-09-13 00:23:25 +09:00
parent 33a2b5d702
commit 2528312142
9 changed files with 515 additions and 15 deletions
@@ -496,6 +496,11 @@ export type CreateRemoteRuntimeRequest = {
expected_revision?: number | null;
};
export type UpdateRemoteRuntimeRequest = {
display_name?: string | null;
endpoint: string;
};
export type RuntimeConnectionTestStatus = "compatible" | "failed";
export type RuntimeConnectionTestFailureKind =
@@ -16,6 +16,7 @@ import type {
RuntimeTrustKeyState,
RuntimeTrustKeyStatus,
RuntimeVerificationEvidenceSummary,
UpdateRemoteRuntimeRequest,
WorkspaceRuntimeBindingState,
WorkspaceRuntimeBindingSummary,
WorkspaceRuntimeDetail,
@@ -919,6 +920,26 @@ export async function createRemoteRuntime(
return runtime;
}
export async function updateRemoteRuntime(
workspaceId: string,
runtimeId: string,
request: UpdateRemoteRuntimeRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}`,
),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
return finishMutation(response, workspaceId, runtimeId);
}
export async function deleteRemoteRuntime(
workspaceId: string,
runtimeId: string,
@@ -445,6 +445,20 @@
overflow-wrap: anywhere;
}
.runtime-public-key-readonly {
display: grid;
gap: var(--space-1);
max-width: 56rem;
}
.runtime-public-key-readonly p {
color: var(--text-muted);
}
.runtime-public-key-readonly code {
overflow-wrap: anywhere;
}
.runtime-trust-form {
display: grid;
gap: var(--space-2);
@@ -10,6 +10,7 @@
previewRuntimePublicKeyFingerprint,
revealRuntimeTrustKey,
revokeRuntimeTrustKey,
updateRemoteRuntime,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
RuntimeTrustRequestError,
@@ -23,8 +24,11 @@
let showPublicKey = $state(false);
let revealedPublicKey = $state<string | null>(null);
let publicKey = $state('');
let displayName = $state('');
let endpoint = $state('');
let editingMetadata = $state(false);
let deleteRuntimeConfirmation = $state('');
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
let busyAction = $state<'metadata' | 'trust' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
let fieldError = $state<string | null>(null);
let deleteRuntimeError = $state<string | null>(null);
let requestError = $state<string | null>(null);
@@ -43,6 +47,9 @@
showPublicKey = false;
revealedPublicKey = null;
publicKey = '';
displayName = data.runtimeDetail?.runtime.label ?? '';
endpoint = data.runtimeDetail?.endpoint ?? '';
editingMetadata = false;
deleteRuntimeConfirmation = '';
busyAction = null;
fieldError = null;
@@ -105,6 +112,49 @@
return routeFence.isCurrent(operation, data.runtimeId);
}
function cancelRuntimeMetadataEdit(): void {
displayName = data.runtimeDetail?.runtime.label ?? '';
endpoint = data.runtimeDetail?.endpoint ?? '';
editingMetadata = false;
requestError = null;
}
async function saveRuntimeMetadata(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busyAction !== null || !data.runtimeDetail) return;
requestError = null;
successMessage = null;
const normalizedDisplayName = displayName.trim();
const normalizedEndpoint = endpoint.trim();
if (!normalizedDisplayName) {
requestError = 'Enter a Runtime label.';
return;
}
if (!normalizedEndpoint) {
requestError = 'Enter the Runtime endpoint.';
return;
}
const operation = routeFence.capture(data.runtimeId);
busyAction = 'metadata';
try {
await updateRemoteRuntime(data.workspaceId, operation.runtimeId, {
display_name: normalizedDisplayName,
endpoint: normalizedEndpoint,
});
if (!isCurrentRoute(operation)) return;
successMessage = 'Runtime settings were updated.';
editingMetadata = false;
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
requestError = error instanceof Error ? error.message : 'Runtime settings update failed.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function saveTrustKey(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busyAction !== null || !data.runtimeDetail) return;
@@ -135,7 +185,7 @@
const action = trustAction(trust.status);
const operation = routeFence.capture(data.runtimeId);
busyAction = 'save';
busyAction = 'trust';
try {
const binding = data.runtimeDetail.runtime.management.binding;
if (!binding || !data.runtimeDetail.endpoint) {
@@ -339,6 +389,7 @@
{@const detail = data.runtimeDetail}
{@const runtime = detail.runtime}
{@const trust = detail.trust_key}
{@const verifiedTrust = runtime.management.binding?.state === 'verified'}
{@const currentAction = trustAction(trust.status)}
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
@@ -373,6 +424,50 @@
</section>
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
<section class="runtime-detail-section" aria-labelledby="runtime-settings-heading">
<h2 id="runtime-settings-heading">Runtime settings</h2>
{#if editingMetadata}
<form class="runtime-trust-form" onsubmit={saveRuntimeMetadata}>
<label for="runtime-display-name-input">Label</label>
<input
id="runtime-display-name-input"
bind:value={displayName}
autocomplete="off"
disabled={busyAction !== null}
/>
<label for="runtime-endpoint-input">Endpoint</label>
<input
id="runtime-endpoint-input"
bind:value={endpoint}
inputmode="url"
autocomplete="url"
spellcheck="false"
disabled={busyAction !== null}
/>
<div class="settings-action-row">
<button type="submit" disabled={busyAction !== null}>
{busyAction === 'metadata' ? 'Saving…' : 'Save Runtime settings'}
</button>
<button
class="settings-secondary-action"
type="button"
onclick={cancelRuntimeMetadataEdit}
disabled={busyAction !== null}
>Cancel</button>
</div>
</form>
{:else}
<div class="settings-action-row">
<button
class="settings-secondary-action"
type="button"
onclick={() => (editingMetadata = true)}
disabled={busyAction !== null}
>Edit Runtime</button>
</div>
{/if}
</section>
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
<h2 id="runtime-trust-heading">Workspace trust</h2>
@@ -395,7 +490,16 @@
{/if}
{/if}
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
{#if verifiedTrust}
<div class="runtime-public-key-readonly">
<strong>Runtime public key</strong>
<p>
This verified key is read-only. Revoke Workspace trust and register the Runtime again to use a different public key.
</p>
<code>{trust.fingerprint}</code>
</div>
{:else}
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
<label for="runtime-public-key-input">Runtime public key</label>
<textarea
id="runtime-public-key-input"
@@ -427,10 +531,11 @@
{/if}
<div class="settings-action-row">
<button type="submit" disabled={busyAction !== null}>
{busyAction === 'save' ? 'Saving…' : actionLabel(currentAction)}
{busyAction === 'trust' ? 'Saving…' : actionLabel(currentAction)}
</button>
</div>
</form>
</form>
{/if}
<div class="runtime-revoke-row">
<div>
@@ -129,8 +129,29 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
const reveal = page.indexOf("Reveal public key");
const verifiedKey = page.indexOf("{#if verifiedTrust}");
const mutation = page.indexOf('id="runtime-public-key-input"');
const metadataStart = page.indexOf("async function saveRuntimeMetadata");
const trustStart = page.indexOf("async function saveTrustKey");
const metadataMutation = page.slice(metadataStart, trustStart);
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
assert(
verifiedKey >= 0 && verifiedKey < mutation &&
page.slice(verifiedKey, mutation).includes("{:else}"),
"verified Runtime public key must be read-only while unverified trust keeps key input",
);
assert(
metadataMutation.includes("updateRemoteRuntime(") &&
metadataMutation.includes("display_name: normalizedDisplayName") &&
metadataMutation.includes("endpoint: normalizedEndpoint"),
"mutable Runtime settings should use the metadata-only update request",
);
assert(
!metadataMutation.includes("publicKey") &&
!metadataMutation.includes("public_bundle") &&
!metadataMutation.includes("public_key"),
"verified Runtime metadata updates must not carry public key authority",
);
assert(
page.includes("Current fingerprint"),
"current fingerprint must be explicit",
@@ -168,6 +189,10 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"routeFence.enter(data.runtimeId)",
"showPublicKey = false",
"revealedPublicKey = null",
"saveRuntimeMetadata",
"Runtime settings",
"Edit Runtime",
"This verified key is read-only",
"publicKey = ''",
"requestError = null",
"successMessage = null",
@@ -13,6 +13,7 @@ import {
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
updateRemoteRuntime,
} from "../src/lib/workspace/api/runtime-management.ts";
function assert(condition: unknown, message: string): asserts condition {
@@ -273,6 +274,45 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => {
assert(requests === 0, "mismatched fingerprint sent a revoke request");
});
Deno.test("Runtime metadata update never sends public key authority", async () => {
let requestedUrl = "";
let requestedMethod = "";
let requestedBody: unknown = null;
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
requestedUrl = String(input);
requestedMethod = init?.method ?? "GET";
requestedBody = JSON.parse(String(init?.body));
return Promise.resolve(Response.json(detail()));
}) as typeof fetch;
await updateRemoteRuntime(
"workspace-a",
"arcadia",
{
display_name: "Updated Runtime",
endpoint: "https://runtime.example.test/v2",
},
fetchImpl,
);
assert(
requestedUrl === "/api/w/workspace-a/runtimes/arcadia",
`unexpected update URL: ${requestedUrl}`,
);
assert(requestedMethod === "POST", "Runtime update must use POST");
assert(
JSON.stringify(requestedBody) ===
JSON.stringify({
display_name: "Updated Runtime",
endpoint: "https://runtime.example.test/v2",
}),
`unexpected update body: ${JSON.stringify(requestedBody)}`,
);
const body = requestedBody as Record<string, unknown>;
assert(!("public_bundle" in body), "metadata update sent public_bundle");
assert(!("public_key" in body), "metadata update sent public_key");
});
Deno.test("Runtime registration delete uses the Workspace-scoped resource route", async () => {
let requestedUrl = "";
let requestedMethod = "";