diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 0c1d6aac..470fd5a0 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -1839,6 +1839,15 @@ pub struct CreateRemoteRuntimeRequest { pub expected_revision: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct UpdateRemoteRuntimeRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub endpoint: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] @@ -3098,6 +3107,7 @@ pub fn catalog_typescript() -> String { RuntimeTrustConflictResponse::decl(&config), RuntimePublicIdentityBundle::decl(&config), CreateRemoteRuntimeRequest::decl(&config), + UpdateRemoteRuntimeRequest::decl(&config), RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestResponse::decl(&config), @@ -3617,6 +3627,33 @@ mod tests { } } + #[test] + fn remote_runtime_metadata_update_cannot_carry_public_key_authority() { + let request = UpdateRemoteRuntimeRequest { + display_name: Some("Runtime A".to_string()), + endpoint: "https://runtime.example.test".to_string(), + }; + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({ + "display_name": "Runtime A", + "endpoint": "https://runtime.example.test", + }) + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "display_name": "Runtime A", + "endpoint": "https://runtime.example.test", + "public_bundle": { + "identity_id": "runtime-a", + "public_key": "yoi-ed25519-pub:v1:not-accepted", + }, + })) + .is_err(), + "metadata updates must reject public key fields" + ); + } + #[test] fn worker_launch_optional_omission_and_request_shape_are_stable() { assert_eq!( diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 78240f19..820ef071 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -93,9 +93,9 @@ use workspace_api::{ RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, UpdateWorkspaceMetadataRequest, WhoamiResponse, - WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, - WorkerLaunchWorkerSummary, + TICKET_RELATIONS_QUERY_PATH, UpdateRemoteRuntimeRequest, UpdateWorkspaceMetadataRequest, + WhoamiResponse, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, + WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, @@ -3420,7 +3420,9 @@ fn build_inner_router(api: WorkspaceApi) -> Router { ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}", - get(scoped_get_runtime_detail).delete(scoped_delete_remote_runtime), + get(scoped_get_runtime_detail) + .post(scoped_update_remote_runtime) + .delete(scoped_delete_remote_runtime), ) .route( "/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key", @@ -12075,6 +12077,60 @@ async fn scoped_get_runtime_detail( )) } +async fn scoped_update_remote_runtime( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "manage Workspace Runtimes", + ) + .await?; + if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID { + return Err(settings_bad_request( + "embedded_runtime_not_config_managed", + "the embedded Runtime is built in and cannot be edited", + )); + } + let endpoint = + validate_runtime_metadata(request.display_name.as_deref(), &request.endpoint).await?; + let display_name = request + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&path.runtime_id); + let binding = api + .store + .update_workspace_runtime_binding_metadata( + &path.workspace_id, + &path.runtime_id, + display_name, + endpoint.as_str().trim_end_matches('/'), + &Utc::now().to_rfc3339(), + ) + .await?; + let activate_runtime = binding.state == StoredRuntimeBindingState::Verified; + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + (path.workspace_id.clone(), path.runtime_id.clone()), + binding.clone(), + ); + api.runtime_subscription_broker + .unregister_runtime(&path.runtime_id); + api.register_workspace_runtime_binding(binding, activate_runtime)?; + Ok(Json( + workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?, + )) +} + async fn scoped_reveal_runtime_trust_key( State(api): State, AxumPath(path): AxumPath, @@ -16593,7 +16649,11 @@ async fn validate_runtime_connection_request( "Runtime public bundle must contain a public key", )); } - let endpoint = Url::parse(request.endpoint.trim()).map_err(|_| { + validate_runtime_metadata(request.display_name.as_deref(), &request.endpoint).await +} + +async fn validate_runtime_metadata(display_name: Option<&str>, endpoint: &str) -> ApiResult { + let endpoint = Url::parse(endpoint.trim()).map_err(|_| { settings_bad_request( "invalid_remote_runtime_endpoint", "endpoint must be an absolute HTTP or HTTPS URL", @@ -16656,11 +16716,7 @@ async fn validate_runtime_connection_request( )); } } - if request - .display_name - .as_deref() - .is_some_and(|value| value.is_empty() || value.chars().any(char::is_control)) - { + if display_name.is_some_and(|value| value.is_empty() || value.chars().any(char::is_control)) { return Err(settings_bad_request( "invalid_remote_runtime_display_name", "display_name must be non-empty when supplied and cannot contain control characters", @@ -20869,6 +20925,124 @@ mod tests { ); } + #[tokio::test] + async fn verified_remote_runtime_metadata_update_preserves_public_key_and_verification() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let actor = test_owner_actor(); + let workspace_identity = api + .signing_identities + .provision_existing(&api.config.workspace_id, &actor.account_id) + .unwrap(); + let runtime_identity = RuntimeIdentityMaterial::generate("verified-runtime").unwrap(); + api.store + .upsert_workspace_runtime_binding_record( + WorkspaceRuntimeBinding { + workspace_id: api.config.workspace_id.clone(), + runtime_id: "verified-runtime".to_string(), + display_name: "Old label".to_string(), + base_url: "https://8.8.8.8".to_string(), + public_key: runtime_identity.public_key.clone(), + public_key_fingerprint: String::new(), + binding_revision: 1, + state: StoredRuntimeBindingState::Configured, + authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some(workspace_identity.key_id.clone()), + workspace_key_generation: Some(workspace_identity.revision), + created_at: "1".to_string(), + updated_at: "1".to_string(), + revoked_at: None, + }, + false, + ) + .await + .unwrap(); + let configured = api + .store + .get_workspace_runtime_binding(&api.config.workspace_id, "verified-runtime") + .await + .unwrap() + .unwrap(); + let evidence = crate::store::WorkspaceRuntimeVerificationEvidence { + workspace_id: configured.workspace_id.clone(), + runtime_id: configured.runtime_id.clone(), + binding_revision: configured.binding_revision, + workspace_key_id: workspace_identity.key_id, + workspace_identity_revision: workspace_identity.revision, + workspace_trust_generation: workspace_identity.revision, + runtime_public_key_fingerprint: configured.public_key_fingerprint.clone(), + runtime_identity_revision: 1, + challenge_id: "verified-metadata-update".to_string(), + state: "verified".to_string(), + last_outcome: "verified".to_string(), + verified_at: Some("2".to_string()), + checked_at: "2".to_string(), + }; + api.store + .record_workspace_runtime_verification_attempt(&evidence) + .await + .unwrap(); + api.store + .complete_workspace_runtime_verification(&evidence) + .await + .unwrap(); + + let Json(updated) = scoped_update_remote_runtime( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: api.config.workspace_id.clone(), + runtime_id: "verified-runtime".to_string(), + }), + Extension(actor.clone()), + Json(UpdateRemoteRuntimeRequest { + display_name: Some("New label".to_string()), + endpoint: "https://8.8.4.4".to_string(), + }), + ) + .await + .unwrap(); + + assert_eq!(updated.runtime.runtime.label, "New label"); + assert_eq!(updated.endpoint.as_deref(), Some("https://8.8.4.4")); + let binding = updated.runtime.management.binding.unwrap(); + assert_eq!(binding.state, WorkspaceRuntimeBindingState::Verified); + assert_eq!(binding.revision, 1); + assert_eq!(binding.verification.unwrap().binding_revision, 1); + assert_eq!( + updated.trust_key.fingerprint.as_deref(), + Some(configured.public_key_fingerprint.as_str()) + ); + let stored = api + .store + .get_workspace_runtime_binding(&api.config.workspace_id, "verified-runtime") + .await + .unwrap() + .unwrap(); + assert_eq!(stored.public_key, runtime_identity.public_key); + assert_eq!( + stored.public_key_fingerprint, + configured.public_key_fingerprint + ); + + let replacement = RuntimeIdentityMaterial::generate("verified-runtime").unwrap(); + let error = create_remote_runtime( + State(api), + Extension(actor), + Json(CreateRemoteRuntimeRequest { + public_bundle: workspace_api::RuntimePublicIdentityBundle { + identity_id: "verified-runtime".to_string(), + public_key: replacement.public_key, + }, + display_name: Some("New label".to_string()), + endpoint: "https://8.8.4.4".to_string(), + expected_revision: Some(1), + }), + ) + .await + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::CONFLICT); + } + #[tokio::test] async fn remote_runtime_registration_is_workspace_scoped_revisioned_and_configured() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b7dc34e0..76e283cb 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -832,6 +832,14 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { expected_revision: Option, actor_account_id: &str, ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)>; + async fn update_workspace_runtime_binding_metadata( + &self, + workspace_id: &str, + runtime_id: &str, + display_name: &str, + base_url: &str, + updated_at: &str, + ) -> Result; async fn revoke_workspace_runtime_binding_key( &self, workspace_id: &str, @@ -2277,6 +2285,59 @@ impl SqliteWorkspaceStore { }) } + pub fn update_workspace_runtime_binding_metadata( + &self, + workspace_id: &str, + runtime_id: &str, + display_name: &str, + base_url: &str, + updated_at: &str, + ) -> Result { + validate_identifier("workspace_id", workspace_id)?; + validate_identifier("runtime_id", runtime_id)?; + validate_non_empty("runtime display_name", display_name)?; + validate_runtime_base_url(base_url)?; + validate_non_empty("updated_at", updated_at)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = tx + .query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id], + read_workspace_runtime_binding, + ) + .optional()? + .ok_or_else(|| Error::RuntimeBindingNotFound { + runtime_id: runtime_id.to_string(), + })?; + if existing.display_name == display_name && existing.base_url == base_url { + tx.commit()?; + return Ok(existing); + } + tx.execute( + r#"UPDATE workspace_runtime_bindings + SET display_name = ?3, base_url = ?4, updated_at = ?5 + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id, display_name, base_url, updated_at], + )?; + let updated = tx.query_row( + r#"SELECT workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, binding_revision, state, authentication_mode, + workspace_key_id, workspace_key_generation, created_at, updated_at, revoked_at + FROM workspace_runtime_bindings + WHERE workspace_id = ?1 AND runtime_id = ?2"#, + params![workspace_id, runtime_id], + read_workspace_runtime_binding, + )?; + tx.commit()?; + Ok(updated) + }) + } + pub fn revoke_workspace_runtime_binding_key( &self, workspace_id: &str, @@ -3505,6 +3566,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) } + async fn update_workspace_runtime_binding_metadata( + &self, + workspace_id: &str, + runtime_id: &str, + display_name: &str, + base_url: &str, + updated_at: &str, + ) -> Result { + SqliteWorkspaceStore::update_workspace_runtime_binding_metadata( + self, + workspace_id, + runtime_id, + display_name, + base_url, + updated_at, + ) + } + async fn revoke_workspace_runtime_binding_key( &self, workspace_id: &str, diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index b4939ea9..4ab513c3 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -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 = diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index 1ef10295..6ed93eb8 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -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 { + 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, diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index 03b4c3ad..1a2f6593 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -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); diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index 7888d3ff..a06ce45b 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -10,6 +10,7 @@ previewRuntimePublicKeyFingerprint, revealRuntimeTrustKey, revokeRuntimeTrustKey, + updateRemoteRuntime, RuntimeTrustConflictError, RuntimeTrustRouteFence, RuntimeTrustRequestError, @@ -23,8 +24,11 @@ let showPublicKey = $state(false); let revealedPublicKey = $state(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(null); let deleteRuntimeError = $state(null); let requestError = $state(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 { + 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 { 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)}
@@ -373,6 +424,50 @@
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in} +
+

Runtime settings

+ {#if editingMetadata} +
+ + + + +
+ + +
+
+ {:else} +
+ +
+ {/if} +
+

Workspace trust

@@ -395,7 +490,16 @@ {/if} {/if} -
+ {#if verifiedTrust} +
+ Runtime public key +

+ This verified key is read-only. Revoke Workspace trust and register the Runtime again to use a different public key. +

+ {trust.fingerprint} +
+ {:else} +