From 73a35599d2c36091610c963648fd8ffc0f58aae6 Mon Sep 17 00:00:00 2001
From: Hare
Date: Tue, 8 Sep 2026 05:15:14 +0900
Subject: [PATCH] fix: complete configured Runtime onboarding
---
crates/workspace-server/src/server.rs | 64 +++++++++++++++++++
.../lib/workspace/api/runtime-management.ts | 31 ++++++++-
.../settings/runtimes/+page.svelte | 61 +++++++++++++++++-
.../tests/runtime-management.test.ts | 37 +++++++++++
4 files changed, 189 insertions(+), 4 deletions(-)
diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs
index 87ff4ed5..a8f5b3b5 100644
--- a/crates/workspace-server/src/server.rs
+++ b/crates/workspace-server/src/server.rs
@@ -13548,6 +13548,33 @@ async fn create_remote_runtime(
"an active Workspace signing identity is required before registering a Runtime",
));
}
+ let existing = api
+ .store
+ .get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
+ .await?;
+ if existing
+ .as_ref()
+ .is_some_and(|binding| binding.state == StoredRuntimeBindingState::Verified)
+ {
+ return Err(Error::RuntimeBindingConflict(
+ "a verified Runtime binding must be revoked before it can be replaced as configured"
+ .to_string(),
+ )
+ .into());
+ }
+ match api
+ .runtime
+ .unregister_if_idle(&runtime_id, api.config.max_records.min(200))
+ .map_err(|err| err.into_error())?
+ {
+ RuntimeRegistryUnregisterResult::Removed | RuntimeRegistryUnregisterResult::NotFound => {}
+ RuntimeRegistryUnregisterResult::BlockedByWorkers { worker_count, .. } => {
+ return Err(Error::RuntimeBindingConflict(format!(
+ "Runtime `{runtime_id}` still has {worker_count} active worker(s) and cannot become a configured-only binding"
+ ))
+ .into());
+ }
+ }
let now = Utc::now().to_rfc3339();
let record = WorkspaceRuntimeBinding {
workspace_id: api.config.workspace_id.clone(),
@@ -13575,6 +13602,12 @@ async fn create_remote_runtime(
.store
.put_workspace_runtime_binding_key(record, request.expected_revision, &actor.account_id)
.await?;
+ api.runtime_binding_expectations
+ .write()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .remove(&(api.config.workspace_id.clone(), runtime_id.clone()));
+ api.runtime_subscription_broker
+ .unregister_runtime(&runtime_id);
let resource = workspace_runtime_resources_response(&api, &api.config.workspace_id)
.await?
.items
@@ -19948,6 +19981,19 @@ mod tests {
api.signing_identities
.provision_existing(&api.config.workspace_id, &actor.account_id)
.unwrap();
+ api.runtime.register_or_replace(
+ RemoteWorkerRuntime::new(
+ RemoteRuntimeConfig::new(
+ "configured-runtime",
+ "Stale Runtime",
+ "https://8.8.8.8",
+ None,
+ ),
+ api.config.workspace_id.clone(),
+ "http://127.0.0.1:1".to_string(),
+ )
+ .unwrap(),
+ );
let runtime_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap();
let request = CreateRemoteRuntimeRequest {
public_bundle: workspace_api::RuntimePublicIdentityBundle {
@@ -19989,6 +20035,24 @@ mod tests {
assert!(binding.workspace_key_id.is_some());
assert_eq!(binding.workspace_key_generation, Some(1));
assert!(!created.runtime.worker_creation_available);
+ assert!(
+ api.runtime
+ .list_runtimes(100)
+ .items
+ .iter()
+ .all(|runtime| runtime.runtime_id != "configured-runtime"),
+ "configured binding must remove a stale active Runtime projection"
+ );
+ assert!(
+ !api.runtime_binding_expectations
+ .read()
+ .unwrap()
+ .contains_key(&(
+ api.config.workspace_id.clone(),
+ "configured-runtime".to_string(),
+ )),
+ "configured binding must not remain a control expectation"
+ );
let replacement_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap();
let generic_put = scoped_put_runtime_trust_key(
State(api.clone()),
diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts
index 8a086e4e..ced20e5a 100644
--- a/web/workspace/src/lib/workspace/api/runtime-management.ts
+++ b/web/workspace/src/lib/workspace/api/runtime-management.ts
@@ -1,6 +1,6 @@
import type {
- Diagnostic,
CreateRemoteRuntimeRequest,
+ Diagnostic,
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeIdentityAuthority,
@@ -334,13 +334,18 @@ function runtimeBinding(
authenticationMode === "legacy_server_issuer" &&
(workspaceKeyId != null || workspaceKeyGeneration != null)
) {
- return fail(path, "must not attach Workspace key metadata to legacy authority");
+ return fail(
+ path,
+ "must not attach Workspace key metadata to legacy authority",
+ );
}
return {
state: enumValue(item.state, `${path}.state`, BINDING_STATES),
authentication_mode: authenticationMode,
revision: safeRevision(item.revision, `${path}.revision`),
- ...(workspaceKeyId === undefined ? {} : { workspace_key_id: workspaceKeyId }),
+ ...(workspaceKeyId === undefined
+ ? {}
+ : { workspace_key_id: workspaceKeyId }),
...(workspaceKeyGeneration === undefined
? {}
: { workspace_key_generation: workspaceKeyGeneration }),
@@ -713,6 +718,26 @@ function requestErrorFrom(
): RuntimeTrustRequestError {
try {
const response = object(value, "Runtime trust error");
+ if ("details" in response) {
+ exactKeys(
+ response,
+ ["error", "details"],
+ [],
+ "Runtime trust error",
+ );
+ boundedString(
+ response.error,
+ "Runtime trust error.error",
+ LIMITS.idBytes,
+ );
+ return new RuntimeTrustRequestError(
+ boundedString(
+ response.details,
+ "Runtime trust error.details",
+ LIMITS.conflictMessageBytes,
+ ),
+ );
+ }
exactKeys(
response,
["error", "message", "diagnostics"],
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte
index 25e022e4..9053c242 100644
--- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte
+++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte
@@ -7,6 +7,7 @@
} from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
+ previewRuntimePublicKeyFingerprint,
RuntimeTrustRequestError,
} from '$lib/workspace/api/runtime-management';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
@@ -19,6 +20,8 @@
let runtimePublicBundle = $state('');
let displayName = $state('');
let endpoint = $state('');
+ let runtimeFingerprint = $state(null);
+ let fingerprintConfirmation = $state('');
let showAddRuntime = $state(false);
let busyRuntimeId = $state(null);
let requestError = $state(null);
@@ -82,12 +85,43 @@
: '';
}
+ async function copyWorkspaceBundle(): Promise {
+ requestError = null;
+ try {
+ await navigator.clipboard.writeText(workspacePublicBundle());
+ } catch {
+ requestError = 'Workspace public bundle could not be copied';
+ }
+ }
+
+ async function previewRuntimeFingerprint(): Promise {
+ requestError = null;
+ runtimeFingerprint = null;
+ fingerprintConfirmation = '';
+ busyRuntimeId = 'preview';
+ try {
+ const bundle = parseRuntimePublicBundle(runtimePublicBundle);
+ runtimeFingerprint = await previewRuntimePublicKeyFingerprint(bundle.public_key);
+ } catch (error) {
+ requestError = error instanceof Error ? error.message : String(error);
+ } finally {
+ busyRuntimeId = null;
+ }
+ }
+
async function addRuntime(event: SubmitEvent): Promise {
event.preventDefault();
requestError = 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');
+ }
await createRemoteRuntime(data.workspaceId, {
public_bundle: publicBundle,
display_name: displayName || null,
@@ -95,6 +129,8 @@
expected_revision: null,
});
runtimePublicBundle = '';
+ runtimeFingerprint = null;
+ fingerprintConfirmation = '';
displayName = '';
endpoint = '';
showAddRuntime = false;
@@ -149,12 +185,31 @@
Run yoi-runtime identity show --json on the Runtime host and paste the result.
+
+ {#if runtimeFingerprint}
+
+ {/if}
{workspacePublicBundle()}
+
yoi-runtime trust-workspace add --bundle workspace-public-bundle.json
Runtime registration remains configured until authenticated verification is completed.
@@ -183,7 +239,10 @@
{/if}
-
+
diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts
index 948346b5..11ab54f0 100644
--- a/web/workspace/tests/runtime-management.test.ts
+++ b/web/workspace/tests/runtime-management.test.ts
@@ -3,6 +3,7 @@ declare const Deno: {
};
import {
+ createRemoteRuntime,
parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail,
@@ -311,3 +312,39 @@ Deno.test("typed trust conflict is validated and preserves authoritative revisio
"request should serialize the generated bigint revision as a safe JSON integer",
);
});
+
+Deno.test("Runtime create surfaces bounded Settings error details", async () => {
+ const fetchImpl = (() =>
+ Promise.resolve(
+ new Response(
+ JSON.stringify({
+ error: "remote_runtime_endpoint_not_allowed",
+ details: "Runtime endpoint must use public https egress",
+ }),
+ { status: 400, headers: { "content-type": "application/json" } },
+ ),
+ )) as typeof fetch;
+
+ try {
+ await createRemoteRuntime(
+ "workspace-a",
+ {
+ public_bundle: {
+ identity_id: "runtime-a",
+ public_key: "yoi-ed25519-pub:v1:test",
+ },
+ display_name: null,
+ endpoint: "https://runtime.example",
+ expected_revision: null,
+ },
+ fetchImpl,
+ );
+ throw new Error("expected create to reject");
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ assert(
+ message === "Runtime endpoint must use public https egress",
+ `unexpected create error: ${message}`,
+ );
+ }
+});