From 18fd6a1f5ea655ddc8077c37174936bbc7d32411 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 9 Sep 2026 00:26:04 +0900 Subject: [PATCH] fix: restore remote Runtime management contracts --- crates/worker-runtime/src/execution.rs | 3 + crates/worker-runtime/src/http_server.rs | 28 +- crates/worker-runtime/src/runtime.rs | 58 ++- crates/worker-runtime/src/worker_backend.rs | 5 + crates/workspace-server/src/hosts.rs | 43 ++ crates/workspace-server/src/main.rs | 34 +- .../src/runtime_subscription_tests.rs | 28 +- crates/workspace-server/src/server.rs | 169 +++++--- crates/workspace-server/src/store.rs | 393 +++++++++++++++-- .../lib/workspace/api/runtime-management.ts | 8 +- .../src/lib/workspace/settings/model.test.ts | 8 +- .../src/lib/workspace/settings/model.ts | 2 +- .../src/lib/workspace/settings/profile-api.ts | 10 +- .../workspace/sidebar/SettingsSidebar.svelte | 10 +- .../src/lib/workspace/sidebar/worker-state.ts | 7 +- .../sidebar/worker-subscription.test.ts | 4 + .../w/[workspaceId]/settings/+page.svelte | 395 ++++++++++++++++++ .../[workspaceId]/settings/runtimes/+page.ts | 2 +- .../settings/workspace/+page.svelte | 395 ------------------ web/workspace/tests/profile-api.test.ts | 37 +- .../tests/runtime-management-source.test.ts | 5 + .../tests/runtime-management.test.ts | 27 ++ web/workspace/tests/workspace-model.test.ts | 2 +- 23 files changed, 1144 insertions(+), 529 deletions(-) delete mode 100644 web/workspace/src/routes/w/[workspaceId]/settings/workspace/+page.svelte diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index 1c18670a..91dda346 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -287,6 +287,7 @@ pub enum WorkspaceConfigFetchResult { pub enum WorkerExecutionSpawnResult { Connected { handle: WorkerExecutionHandle, + worker_state: protocol::WorkerStateSnapshot, working_directory: Option, }, Rejected(WorkerExecutionResult), @@ -296,10 +297,12 @@ pub enum WorkerExecutionSpawnResult { impl WorkerExecutionSpawnResult { pub fn connected( handle: WorkerExecutionHandle, + worker_state: protocol::WorkerStateSnapshot, working_directory: Option, ) -> Self { Self::Connected { handle, + worker_state, working_directory, } } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index bed1b816..1c79f815 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -2992,6 +2992,10 @@ mod tests { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { WorkerExecutionSpawnResult::Connected { handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), + worker_state: protocol::WorkerStateSnapshot { + execution_generation: request.run_generation, + ..protocol::WorkerStatus::Idle.into() + }, working_directory: request .working_directory .as_ref() @@ -3005,6 +3009,10 @@ mod tests { ) -> WorkerExecutionSpawnResult { WorkerExecutionSpawnResult::Connected { handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), + worker_state: protocol::WorkerStateSnapshot { + execution_generation: request.run_generation, + ..protocol::WorkerStatus::Idle.into() + }, working_directory: request.previous_working_directory, } } @@ -3313,6 +3321,10 @@ mod ws_tests { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { WorkerExecutionSpawnResult::Connected { handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), + worker_state: protocol::WorkerStateSnapshot { + execution_generation: request.run_generation, + ..protocol::WorkerStatus::Idle.into() + }, working_directory: request .working_directory .as_ref() @@ -3575,11 +3587,23 @@ mod ws_tests { worker_ref.worker_id.to_string() ); + let running_snapshot = |worker_ref: &WorkerRef| { + let mut snapshot = runtime + .worker_detail(worker_ref) + .unwrap() + .worker_state + .expect("connected test Worker must expose its initial state"); + snapshot.revision += 1; + snapshot.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( + protocol::WorkerRunState::Running, + )); + snapshot + }; runtime .observe_worker_event( &other.worker_ref, protocol::Event::WorkerState { - snapshot: protocol::WorkerStatus::Running.into(), + snapshot: running_snapshot(&other.worker_ref), }, ) .unwrap(); @@ -3587,7 +3611,7 @@ mod ws_tests { .observe_worker_event( &worker_ref, protocol::Event::WorkerState { - snapshot: protocol::WorkerStatus::Running.into(), + snapshot: running_snapshot(&worker_ref), }, ) .unwrap(); diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 77d72b11..ff6d8f5b 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -890,11 +890,12 @@ impl Runtime { }; let spawn_result = backend.spawn_worker(spawn_request); - let (handle, working_directory) = match spawn_result { + let (handle, initial_worker_state, working_directory) = match spawn_result { WorkerExecutionSpawnResult::Connected { handle, + worker_state, working_directory, - } => (handle, working_directory), + } => (handle, worker_state, working_directory), WorkerExecutionSpawnResult::Rejected(result) | WorkerExecutionSpawnResult::Errored(result) => { self.rollback_failed_create(&worker_ref)?; @@ -950,6 +951,7 @@ impl Runtime { let detail = match self.commit_created_worker( &worker_ref, handle.clone(), + initial_worker_state.clone(), working_directory, dispatch_result, ) { @@ -968,6 +970,7 @@ impl Runtime { match self.commit_created_worker( &worker_ref, handle.clone(), + initial_worker_state, working_directory, WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn), ) { @@ -1260,11 +1263,13 @@ impl Runtime { match backend.restore_worker(request) { WorkerExecutionSpawnResult::Connected { handle, + worker_state, working_directory, } => { self.commit_restored_worker_execution( worker_ref, handle, + worker_state, WorkerStatus::Idle, working_directory, )?; @@ -1656,6 +1661,7 @@ impl Runtime { &self, worker_ref: &WorkerRef, handle: WorkerExecutionHandle, + initial_worker_state: protocol::WorkerStateSnapshot, working_directory: Option, result: WorkerExecutionResult, ) -> Result { @@ -1665,7 +1671,7 @@ impl Runtime { worker.execution_handle = Some(handle); worker.execution_bound = true; worker.status = WorkerStatus::Idle; - worker.worker_state = None; + let _ = worker.apply_worker_state(&initial_worker_state); if let Some(snapshot) = result.worker_state.as_ref() { let _ = worker.apply_worker_state(snapshot); } @@ -2192,10 +2198,12 @@ impl Runtime { match backend.restore_worker(request) { WorkerExecutionSpawnResult::Connected { handle, + worker_state, working_directory, } => self.commit_restored_worker_execution( &candidate.worker_ref, handle, + worker_state, WorkerStatus::Idle, working_directory, )?, @@ -2213,6 +2221,7 @@ impl Runtime { &self, worker_ref: &WorkerRef, handle: WorkerExecutionHandle, + worker_state: protocol::WorkerStateSnapshot, status: WorkerStatus, working_directory: Option, ) -> Result<(), RuntimeError> { @@ -2223,6 +2232,7 @@ impl Runtime { worker.execution_handle = Some(handle); worker.execution_bound = true; worker.status = status; + let _ = worker.apply_worker_state(&worker_state); worker.restore_intent = restore_intent_for_status(worker.status); worker.working_directory = working_directory; } @@ -4325,6 +4335,10 @@ mod tests { .insert(request.worker_ref.worker_id.clone(), request.context); WorkerExecutionSpawnResult::Connected { handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), + worker_state: protocol::WorkerStateSnapshot { + execution_generation: request.run_generation, + ..protocol::WorkerStatus::Idle.into() + }, working_directory: request .working_directory .as_ref() @@ -4354,6 +4368,10 @@ mod tests { .insert(request.worker_ref.worker_id.clone(), request.context); WorkerExecutionSpawnResult::Connected { handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), + worker_state: protocol::WorkerStateSnapshot { + execution_generation: request.run_generation, + ..protocol::WorkerStatus::Idle.into() + }, working_directory: request .working_directory .as_ref() @@ -5135,7 +5153,27 @@ mod tests { let detail = runtime.create_worker(request).unwrap(); assert_eq!(detail.status, WorkerStatus::Idle); - assert_eq!(detail.worker_state, None); + assert_eq!( + detail.worker_state.as_ref().map(|snapshot| &snapshot.state), + Some(&protocol::WorkerState::Idle) + ); + } + + #[test] + fn restored_worker_exposes_the_backend_initial_state_snapshot() { + let (runtime, _) = runtime_and_backend(); + let created = runtime + .create_worker(task_request("restore initial state")) + .unwrap(); + runtime.stop_worker(&created.worker_ref, None).unwrap(); + + let restored = runtime.restore_worker(&created.worker_ref).unwrap(); + + let worker_state = restored + .worker_state + .expect("restored Worker must expose its initial state"); + assert_eq!(worker_state.execution_generation, 2); + assert_eq!(worker_state.state, protocol::WorkerState::Idle); } #[test] @@ -5414,6 +5452,10 @@ mod tests { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { WorkerExecutionSpawnResult::Connected { handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), + worker_state: protocol::WorkerStateSnapshot { + execution_generation: request.run_generation, + ..protocol::WorkerStatus::Idle.into() + }, working_directory: request .working_directory .as_ref() @@ -5513,7 +5555,13 @@ mod tests { assert_eq!(*backend.run_generations.lock().unwrap(), vec![1, 2]); let restored = runtime.worker_detail(&detail.worker_ref).unwrap(); assert_eq!(restored.status, WorkerStatus::Idle); - assert_eq!(restored.worker_state, None); + assert_eq!( + restored + .worker_state + .as_ref() + .map(|snapshot| (snapshot.execution_generation, &snapshot.state)), + Some((2, &protocol::WorkerState::Idle)) + ); } #[test] diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index a413c45b..5e6b010e 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -1548,6 +1548,10 @@ where )); } }; + let connected_worker_state = worker_state + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); workers.insert( worker_ref.clone(), RuntimeWorkerExecution { @@ -1560,6 +1564,7 @@ where WorkerExecutionSpawnResult::Connected { handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()), + worker_state: connected_worker_state, working_directory: working_directory.map(|binding| binding.status()), } } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 6f128302..cd0778c9 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -3277,6 +3277,28 @@ pub(crate) fn resolve_strict_remote_runtime_endpoint( Ok((host, addresses)) } +/// Returns whether `endpoint` is a credential-free HTTP(S) origin on a literal +/// IPv4 or IPv6 loopback address. +pub fn is_loopback_runtime_origin(endpoint: &str) -> bool { + let Ok(endpoint) = reqwest::Url::parse(endpoint.trim()) else { + return false; + }; + if !matches!(endpoint.scheme(), "http" | "https") + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || endpoint.path() != "/" + { + return false; + } + endpoint + .host_str() + .map(|host| host.trim_start_matches('[').trim_end_matches(']')) + .and_then(|host| host.parse::().ok()) + .is_some_and(|address| address.is_loopback()) +} + pub(crate) fn is_disallowed_remote_runtime_address(address: IpAddr) -> bool { match address { IpAddr::V4(address) => { @@ -5325,6 +5347,23 @@ mod tests { .unwrap(); } + #[test] + fn loopback_runtime_origin_accepts_only_literal_clean_http_or_https_origins() { + assert!(is_loopback_runtime_origin("http://127.0.0.1:8788")); + assert!(is_loopback_runtime_origin("https://127.0.0.1")); + assert!(is_loopback_runtime_origin("http://[::1]:8788")); + for endpoint in [ + "http://localhost:8788", + "http://10.0.0.1:8788", + "http://127.0.0.1:8788/path", + "http://127.0.0.1:8788/?query=1", + "http://user@127.0.0.1:8788", + "ftp://127.0.0.1:8788", + ] { + assert!(!is_loopback_runtime_origin(endpoint), "{endpoint}"); + } + } + #[test] fn remote_worker_create_timeout_covers_runtime_phase_budgets() { assert!(REMOTE_WORKER_CREATE_TIMEOUT > Duration::from_secs(60 + 10 + 5)); @@ -5712,6 +5751,10 @@ mod tests { request.worker_ref, self.backend_id(), ), + worker_state: protocol::WorkerStateSnapshot { + execution_generation: request.run_generation, + ..protocol::WorkerStatus::Idle.into() + }, working_directory: request .working_directory .as_ref() diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index b19426da..1ceb38a5 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -5,7 +5,9 @@ use std::sync::Arc; use chrono::Utc; use tokio::net::TcpListener; -use yoi_workspace_server::hosts::{EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig}; +use yoi_workspace_server::hosts::{ + EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin, +}; use yoi_workspace_server::store::{ SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState, @@ -165,6 +167,7 @@ fn remote_runtime_config_from_binding( binding.workspace_id, binding.runtime_id ))); } + let strict_public_egress = !is_loopback_runtime_origin(&binding.base_url); Ok(Some( RemoteRuntimeConfig::new( binding.runtime_id, @@ -173,7 +176,7 @@ fn remote_runtime_config_from_binding( None, ) .with_workspace_id(binding.workspace_id) - .with_strict_public_egress(true), + .with_strict_public_egress(strict_public_egress), )) } @@ -621,6 +624,33 @@ mod tests { ); } + #[test] + fn runtime_startup_uses_non_strict_transport_for_literal_loopback_origin() { + let binding = WorkspaceRuntimeBinding { + workspace_id: "workspace-a".to_owned(), + runtime_id: "arcadia".to_owned(), + display_name: "Arcadia".to_owned(), + base_url: "http://127.0.0.1:8788".to_owned(), + public_key: "unused".to_owned(), + public_key_fingerprint: "unused".to_owned(), + binding_revision: 1, + state: WorkspaceRuntimeBindingState::Verified, + authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some("WK-test".to_owned()), + workspace_key_generation: Some(1), + created_at: "2026-09-01T00:00:00Z".to_owned(), + updated_at: "2026-09-01T00:00:00Z".to_owned(), + revoked_at: None, + }; + + let config = remote_runtime_config_from_binding(binding) + .unwrap() + .expect("remote Runtime config"); + + assert_eq!(config.base_url, "http://127.0.0.1:8788"); + assert!(!config.strict_public_egress); + } + #[test] fn parse_cli_rejects_removed_server_global_runtime_trust_commands() { for command in ["identity", "trust-runtime"] { diff --git a/crates/workspace-server/src/runtime_subscription_tests.rs b/crates/workspace-server/src/runtime_subscription_tests.rs index 7e3798e8..ee58f3d0 100644 --- a/crates/workspace-server/src/runtime_subscription_tests.rs +++ b/crates/workspace-server/src/runtime_subscription_tests.rs @@ -19,6 +19,10 @@ impl WorkerExecutionBackend for TestExecutionBackend { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { WorkerExecutionSpawnResult::connected( WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), + protocol::WorkerStateSnapshot { + execution_generation: request.run_generation, + ..protocol::WorkerStatus::Idle.into() + }, None, ) } @@ -174,12 +178,18 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() { .await; assert_eq!(status.desired_selectors, 1); + let mut running = worker + .worker_state + .clone() + .expect("connected test Worker must expose its initial state"); + running.revision += 1; + running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( + protocol::WorkerRunState::Running, + )); runtime .observe_worker_event( &worker.worker_ref, - protocol::Event::WorkerState { - snapshot: protocol::WorkerStatus::Running.into(), - }, + protocol::Event::WorkerState { snapshot: running }, ) .unwrap(); for subscription in [&mut first, &mut second] { @@ -334,12 +344,18 @@ async fn embedded_runtime_uses_in_process_subscription_source() { workers[0].runtime_id.as_deref(), Some("embedded-worker-runtime") ); + let mut running = worker + .worker_state + .clone() + .expect("connected test Worker must expose its initial state"); + running.revision += 1; + running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run( + protocol::WorkerRunState::Running, + )); runtime .observe_worker_event( &worker.worker_ref, - protocol::Event::WorkerState { - snapshot: protocol::WorkerStatus::Running.into(), - }, + protocol::Event::WorkerState { snapshot: running }, ) .unwrap(); assert!(matches!(next_event(&mut subscription).await, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index c5933742..45a53df0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -135,7 +135,7 @@ use crate::hosts::{ WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest, WorkerWorkspaceSummary, WorkspaceRuntimeAuthorization, is_disallowed_remote_runtime_address, - worker_spawn_create_fingerprint, workspace_worker_summary, + is_loopback_runtime_origin, worker_spawn_create_fingerprint, workspace_worker_summary, }; use crate::identity::WorkspaceIdentity; use crate::memory_backend::execute_memory_backend_operation_with_authority; @@ -3014,15 +3014,15 @@ fn build_inner_router(api: WorkspaceApi) -> Router { .route("/api/workspace", get(get_workspace)) .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) .route( - "/api/w/{workspace_id}/settings/workspace", + "/api/w/{workspace_id}/settings", get(scoped_get_workspace_settings).put(scoped_update_workspace_settings), ) .route( - "/api/w/{workspace_id}/settings/workspace/signing-identity", + "/api/w/{workspace_id}/settings/signing-identity", get(scoped_get_workspace_signing_identity), ) .route( - "/api/w/{workspace_id}/settings/workspace/signing-identity/provision", + "/api/w/{workspace_id}/settings/signing-identity/provision", post(scoped_provision_workspace_signing_identity), ) .route( @@ -16312,61 +16312,65 @@ async fn validate_runtime_connection_request( let endpoint = Url::parse(request.endpoint.trim()).map_err(|_| { settings_bad_request( "invalid_remote_runtime_endpoint", - "endpoint must be an absolute https URL", + "endpoint must be an absolute HTTP or HTTPS URL", ) })?; - if endpoint.scheme() != "https" - || endpoint.host_str().is_none() + let local_loopback = is_loopback_runtime_origin(endpoint.as_str()); + if endpoint.host_str().is_none() || !endpoint.username().is_empty() || endpoint.password().is_some() || endpoint.query().is_some() || endpoint.fragment().is_some() + || endpoint.path() != "/" + || (!local_loopback && endpoint.scheme() != "https") { return Err(settings_bad_request( "remote_runtime_endpoint_not_allowed", - "Runtime endpoint must be an https origin without credentials, query, or fragment", + "Runtime endpoint must be a public HTTPS origin or a loopback HTTP origin without credentials, path, query, or fragment", )); } - let host = endpoint.host_str().expect("checked above"); - if host.eq_ignore_ascii_case("localhost") - || host.ends_with(".localhost") - || host - .parse::() - .is_ok_and(is_disallowed_remote_runtime_address) - { - return Err(settings_bad_request( - "remote_runtime_endpoint_not_allowed", - "Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address", - )); - } - let port = endpoint.port_or_known_default().unwrap_or(443); - let addresses = tokio::time::timeout( - std::time::Duration::from_secs(3), - tokio::net::lookup_host((host, port)), - ) - .await - .map_err(|_| { - settings_bad_request( - "remote_runtime_endpoint_dns_timeout", - "Runtime endpoint DNS resolution timed out", + if !local_loopback { + let host = endpoint.host_str().expect("checked above"); + if host.eq_ignore_ascii_case("localhost") + || host.ends_with(".localhost") + || host + .parse::() + .is_ok_and(is_disallowed_remote_runtime_address) + { + return Err(settings_bad_request( + "remote_runtime_endpoint_not_allowed", + "Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address", + )); + } + let port = endpoint.port_or_known_default().unwrap_or(443); + let addresses = tokio::time::timeout( + std::time::Duration::from_secs(3), + tokio::net::lookup_host((host, port)), ) - })? - .map_err(|_| { - settings_bad_request( - "remote_runtime_endpoint_dns_failed", - "Runtime endpoint DNS resolution failed", - ) - })? - .collect::>(); - if addresses.is_empty() - || addresses - .iter() - .any(|address| is_disallowed_remote_runtime_address(address.ip())) - { - return Err(settings_bad_request( - "remote_runtime_endpoint_not_allowed", - "Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address", - )); + .await + .map_err(|_| { + settings_bad_request( + "remote_runtime_endpoint_dns_timeout", + "Runtime endpoint DNS resolution timed out", + ) + })? + .map_err(|_| { + settings_bad_request( + "remote_runtime_endpoint_dns_failed", + "Runtime endpoint DNS resolution failed", + ) + })? + .collect::>(); + if addresses.is_empty() + || addresses + .iter() + .any(|address| is_disallowed_remote_runtime_address(address.ip())) + { + return Err(settings_bad_request( + "remote_runtime_endpoint_not_allowed", + "Runtime endpoint resolves to a loopback, private, link-local, metadata, or otherwise non-public address", + )); + } } if request .display_name @@ -16412,7 +16416,8 @@ fn remote_runtime_config_from_binding( ) .with_workspace_id(binding.workspace_id.clone()) .with_strict_public_egress( - binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity, + binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity + && !is_loopback_runtime_origin(&binding.base_url), ); Ok(remote) } @@ -20505,15 +20510,29 @@ mod tests { }; assert!(validate_runtime_connection_request(&ok).await.is_ok()); - let bad_endpoint = CreateRemoteRuntimeRequest { - endpoint: "http://169.254.169.254/latest/meta-data".to_string(), - ..ok + let loopback = CreateRemoteRuntimeRequest { + endpoint: "http://127.0.0.1:8788".to_string(), + ..ok.clone() }; - assert!( - validate_runtime_connection_request(&bad_endpoint) - .await - .is_err() - ); + assert!(validate_runtime_connection_request(&loopback).await.is_ok()); + + for endpoint in [ + "http://10.0.0.1:8788", + "http://localhost:8788", + "http://127.0.0.1:8788/runtime", + "http://169.254.169.254/latest/meta-data", + ] { + let bad_endpoint = CreateRemoteRuntimeRequest { + endpoint: endpoint.to_string(), + ..ok.clone() + }; + assert!( + validate_runtime_connection_request(&bad_endpoint) + .await + .is_err(), + "{endpoint}" + ); + } } #[test] @@ -20683,6 +20702,10 @@ mod tests { request.worker_ref, self.backend_id(), ), + worker_state: protocol::WorkerStateSnapshot { + execution_generation: request.run_generation, + ..protocol::WorkerStatus::Idle.into() + }, working_directory, } } @@ -21480,7 +21503,7 @@ mod tests { assert!(String::from_utf8_lossy(&listed_body).contains("documentation")); let identity_uri = format!( - "/api/w/{}/settings/workspace/signing-identity", + "/api/w/{}/settings/signing-identity", workspace.workspace.workspace_id ); let identity_response = app @@ -21511,6 +21534,33 @@ mod tests { let identity_text = String::from_utf8(identity_body.to_vec()).unwrap(); assert!(!identity_text.contains("private_key")); assert!(!identity_text.contains("private_material_ref")); + for legacy_uri in [ + format!( + "/api/w/{}/settings/workspace", + workspace.workspace.workspace_id + ), + format!( + "/api/w/{}/settings/workspace/signing-identity", + workspace.workspace.workspace_id + ), + ] { + let legacy_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri(legacy_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(legacy_response.status(), StatusCode::NOT_FOUND); + } let identity_non_owner = app .clone() .oneshot( @@ -21582,10 +21632,7 @@ mod tests { pending_identity.key_id ); - let settings_uri = format!( - "/api/w/{}/settings/workspace", - workspace.workspace.workspace_id - ); + let settings_uri = format!("/api/w/{}/settings", workspace.workspace.workspace_id); let csrf_rejected = app .clone() .oneshot( diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 84e6d870..ab5c537d 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -18,7 +18,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 56; +const LATEST_SCHEMA_VERSION: i64 = 57; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -28,6 +28,8 @@ const WORKSPACE_RUNTIME_BINDING_STATE_MIGRATION_NAME: &str = "Workspace Runtime binding state and identity mode"; const WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME: &str = "Workspace-signed Runtime verification evidence"; +const LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME: &str = + "convert legacy Server-issued Runtime bindings to Workspace identity"; const MIGRATIONS: &[Migration] = &[ Migration { @@ -60,6 +62,11 @@ const MIGRATIONS: &[Migration] = &[ name: WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME, apply: migrate_workspace_runtime_verification_v55_to_v56, }, + Migration { + version: 57, + name: LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME, + apply: migrate_legacy_external_runtime_bindings_v56_to_v57, + }, ]; #[derive(Clone, Copy)] @@ -6970,7 +6977,13 @@ fn normalize_workspace_runtime_binding_key(record: &mut WorkspaceRuntimeBinding) WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer => { if record.workspace_key_id.is_some() || record.workspace_key_generation.is_some() { return Err(Error::InvalidInput( - "legacy Server issuer Runtime bindings must not carry Workspace key metadata" + "legacy Runtime bindings must not carry Workspace key metadata".into(), + )); + } + #[cfg(not(test))] + if record.runtime_id != crate::hosts::EMBEDDED_RUNTIME_ID { + return Err(Error::InvalidInput( + "legacy Server issuer authentication is reserved for the embedded Runtime" .into(), )); } @@ -7789,14 +7802,11 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<() )); } all_workspace_ids.clone() + } else if let Some(workspace_id) = workspace_id.filter(|value| !value.trim().is_empty()) + { + vec![workspace_id] } else { - vec![workspace_id - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - Error::Store(format!( - "Runtime `{runtime_id}` has no persisted Workspace ownership; refusing to guess during schema-50 migration" - )) - })?] + continue; }; let (public_key, fingerprint) = normalize_runtime_public_key(&public_key)?; for workspace_id in workspace_ids { @@ -7871,11 +7881,9 @@ fn migrate_workspace_runtime_bindings_v50_to_v51(conn: &Connection) -> Result<() })?; for row in rows { let (runtime_id, jti, expires_at, consumed_at) = row?; - let workspace_ids = runtime_workspaces.get(runtime_id.as_str()).ok_or_else(|| { - Error::Store(format!( - "consumed Worker mutation proof for Runtime `{runtime_id}` has no provable Workspace binding" - )) - })?; + let Some(workspace_ids) = runtime_workspaces.get(runtime_id.as_str()) else { + continue; + }; for workspace_id in workspace_ids { consumed_jtis.push(( (*workspace_id).to_string(), @@ -8307,6 +8315,93 @@ fn migrate_workspace_runtime_verification_v55_to_v56(conn: &Connection) -> Resul Ok(()) } +fn migrate_legacy_external_runtime_bindings_v56_to_v57(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 56 { + return Err(Error::Store(format!( + "expected schema version 56 before {LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let missing_workspace_identity_count = tx.query_row( + "SELECT COUNT(*) + FROM workspace_runtime_bindings binding + LEFT JOIN workspace_signing_identities identity + ON identity.workspace_id = binding.workspace_id + WHERE binding.authentication_mode = 'legacy_server_issuer' + AND binding.runtime_id <> ?1 + AND identity.workspace_id IS NULL", + params![crate::hosts::EMBEDDED_RUNTIME_ID], + |row| row.get::<_, i64>(0), + )?; + if missing_workspace_identity_count != 0 { + return Err(Error::Store( + "legacy external Runtime binding migration requires Workspace signing identity metadata" + .to_string(), + )); + } + tx.execute( + "DELETE FROM workspace_runtime_verifications + WHERE EXISTS ( + SELECT 1 FROM workspace_runtime_bindings binding + WHERE binding.workspace_id = workspace_runtime_verifications.workspace_id + AND binding.runtime_id = workspace_runtime_verifications.runtime_id + AND binding.authentication_mode = 'legacy_server_issuer' + AND binding.runtime_id <> ?1 + )", + params![crate::hosts::EMBEDDED_RUNTIME_ID], + )?; + tx.execute( + "UPDATE workspace_runtime_bindings + SET state = CASE WHEN state = 'verified' THEN 'configured' ELSE state END, + authentication_mode = 'workspace_identity', + workspace_key_id = ( + SELECT identity.key_id FROM workspace_signing_identities identity + WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id + ), + workspace_key_generation = ( + SELECT identity.revision FROM workspace_signing_identities identity + WHERE identity.workspace_id = workspace_runtime_bindings.workspace_id + ) + WHERE authentication_mode = 'legacy_server_issuer' + AND runtime_id <> ?1", + params![crate::hosts::EMBEDDED_RUNTIME_ID], + )?; + let remaining_external_legacy_bindings = tx.query_row( + "SELECT COUNT(*) FROM workspace_runtime_bindings + WHERE authentication_mode = 'legacy_server_issuer' + AND runtime_id <> ?1", + params![crate::hosts::EMBEDDED_RUNTIME_ID], + |row| row.get::<_, i64>(0), + )?; + if remaining_external_legacy_bindings != 0 { + return Err(Error::Store( + "legacy Server-issued external Runtime bindings remain after schema-57 migration" + .to_string(), + )); + } + verify_workspace_runtime_binding_schema(&tx)?; + verify_workspace_runtime_verification_schema(&tx)?; + let foreign_key_violations = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?; + if foreign_key_violations != 0 { + return Err(Error::Store(format!( + "schema-57 Runtime binding migration left {foreign_key_violations} foreign-key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![ + 57_i64, + LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME + ], + )?; + tx.commit()?; + Ok(()) +} + fn verify_workspace_runtime_verification_schema(conn: &Connection) -> Result<()> { let actual = table_columns(conn, "workspace_runtime_verifications")? .into_iter() @@ -8496,6 +8591,8 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { "SELECT COUNT(*) FROM workspace_runtime_bindings WHERE state NOT IN ('configured', 'verified', 'revoked') OR authentication_mode NOT IN ('legacy_server_issuer', 'workspace_identity') + OR (authentication_mode = 'legacy_server_issuer' AND + (workspace_key_id IS NOT NULL OR workspace_key_generation IS NOT NULL)) OR (authentication_mode = 'workspace_identity' AND (workspace_key_id IS NULL OR workspace_key_generation IS NULL)) OR (state = 'revoked' AND revoked_at IS NULL) @@ -8633,6 +8730,22 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> { } fn verify_canonical_workspace_runtime_binding(binding: WorkspaceRuntimeBinding) -> Result<()> { + if binding.authentication_mode == WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer { + if binding.workspace_key_id.is_some() || binding.workspace_key_generation.is_some() { + return Err(Error::Store(format!( + "legacy Runtime binding `{}/{}` carries Workspace key metadata", + binding.workspace_id, binding.runtime_id + ))); + } + let (canonical_key, fingerprint) = normalize_runtime_public_key(&binding.public_key)?; + if canonical_key != binding.public_key || fingerprint != binding.public_key_fingerprint { + return Err(Error::Store(format!( + "Runtime binding `{}/{}` has non-canonical trust content", + binding.workspace_id, binding.runtime_id + ))); + } + return Ok(()); + } let mut normalized = binding.clone(); normalize_workspace_runtime_binding_key(&mut normalized)?; if normalized.public_key != binding.public_key @@ -9427,6 +9540,10 @@ mod tests { version: 56, name: WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 57, + name: LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME.to_string(), + }, ] ); @@ -9457,6 +9574,10 @@ mod tests { 56, WORKSPACE_RUNTIME_VERIFICATION_MIGRATION_NAME.to_string(), ), + ( + 57, + LEGACY_EXTERNAL_RUNTIME_BINDING_CUTOVER_MIGRATION_NAME.to_string(), + ), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -9476,17 +9597,18 @@ mod tests { [], |row| row.get::<_, String>(0), )?, - "verified:legacy_server_issuer" + "configured:workspace_identity" ); - assert!( - conn.execute( - "UPDATE workspace_runtime_bindings - SET state='configured', authentication_mode='workspace_identity' - WHERE workspace_id='workspace-a' AND runtime_id='shared'", + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_runtime_bindings + WHERE workspace_id='workspace-a' AND runtime_id='shared' + AND workspace_key_id IS NOT NULL + AND workspace_key_generation IS NOT NULL", [], - ) - .is_err(), - "migrated schema must reject Workspace identity mode without key metadata" + |row| row.get::<_, i64>(0), + )?, + 1 ); assert_eq!( conn.query_row( @@ -9526,7 +9648,7 @@ mod tests { .iter() .map(|migration| migration.version) .collect::>(), - vec![52, 53, 54, 55, 56] + vec![52, 53, 54, 55, 56, 57] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -9534,7 +9656,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 7); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 8); } #[test] @@ -9594,23 +9716,47 @@ mod tests { } #[test] - fn schema_v50_dry_run_rejects_unscoped_external_runtime_without_mutating_source() { + fn schema_v50_discards_unscoped_external_runtime_without_guessing_ownership() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); prepare_schema_v50(&path, None); - let error = SqliteWorkspaceStore::migration_plan(&path).unwrap_err(); - assert!( - error - .to_string() - .contains("has no persisted Workspace ownership; refusing to guess"), - "{error}" - ); + let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); + assert_eq!(plan.current_schema_version, 50); + assert_eq!(plan.target_schema_version, LATEST_SCHEMA_VERSION); let unchanged = Connection::open(&path).unwrap(); assert_eq!(current_schema_version(&unchanged).unwrap(), 50); assert!(table_exists(&unchanged, "trusted_runtime_records").unwrap()); assert!(!table_exists(&unchanged, "workspace_runtime_bindings").unwrap()); + drop(unchanged); + + SqliteWorkspaceStore::migrate_database(&path).unwrap(); + let migrated = Connection::open(&path).unwrap(); + assert_eq!( + current_schema_version(&migrated).unwrap(), + LATEST_SCHEMA_VERSION + ); + assert_eq!( + migrated + .query_row( + "SELECT COUNT(*) FROM workspace_runtime_bindings WHERE runtime_id = 'shared'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + assert_eq!( + migrated + .query_row( + "SELECT COUNT(*) FROM worker_mutation_source_proof_jtis WHERE runtime_id = 'shared'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); } #[test] @@ -9803,9 +9949,9 @@ mod tests { public_key_fingerprint: String::new(), binding_revision: 1, state: WorkspaceRuntimeBindingState::Verified, - authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, - workspace_key_id: None, - workspace_key_generation: None, + authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + workspace_key_id: Some(format!("WK-{}", &workspace_id["workspace-".len()..])), + workspace_key_generation: Some(1), created_at: "1".to_string(), updated_at: "1".to_string(), revoked_at: None, @@ -10082,6 +10228,171 @@ mod tests { assert!(table_exists(&migrated, "workspace_runtime_verifications").unwrap()); } + #[test] + fn schema_v56_converts_legacy_external_binding_and_preserves_embedded() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + prepare_schema_v50(&path, Some("workspace-a")); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + for migration in MIGRATIONS + .iter() + .filter(|migration| migration.version > 50 && migration.version <= 56) + { + (migration.apply)(&conn).unwrap(); + } + assert_eq!(current_schema_version(&conn).unwrap(), 56); + let external_identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("legacy-external").unwrap(); + let embedded_identity = worker_runtime::auth::RuntimeIdentityMaterial::generate( + crate::hosts::EMBEDDED_RUNTIME_ID, + ) + .unwrap(); + let (_, external_fingerprint) = + normalize_runtime_public_key(&external_identity.public_key).unwrap(); + let (_, embedded_fingerprint) = + normalize_runtime_public_key(&embedded_identity.public_key).unwrap(); + conn.execute( + r#"INSERT INTO workspace_runtime_bindings( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, created_at, updated_at, revoked_at, state, + authentication_mode, workspace_key_id, workspace_key_generation, + binding_revision + ) VALUES ( + 'workspace-a', 'legacy-external', 'Legacy External', 'https://runtime.test', + ?1, ?2, '1', '1', NULL, 'verified', 'legacy_server_issuer', NULL, NULL, 1 + )"#, + params![external_identity.public_key, external_fingerprint], + ) + .unwrap(); + conn.execute( + r#"INSERT INTO workspace_runtime_bindings( + workspace_id, runtime_id, display_name, base_url, public_key, + public_key_fingerprint, created_at, updated_at, revoked_at, state, + authentication_mode, workspace_key_id, workspace_key_generation, + binding_revision + ) VALUES ( + 'workspace-a', 'embedded-worker-runtime', 'Embedded', 'embedded://runtime', + ?1, ?2, '1', '1', NULL, 'verified', 'legacy_server_issuer', NULL, NULL, 1 + )"#, + params![embedded_identity.public_key, embedded_fingerprint], + ) + .unwrap(); + conn.execute_batch( + r#" + INSERT INTO workspace_runtime_binding_audit( + workspace_id, runtime_id, actor_account_id, action, + old_fingerprint, new_fingerprint, binding_revision, at + ) VALUES + ('workspace-a', 'legacy-external', 'owner', 'created', NULL, 'legacy', 1, '1'), + ('workspace-a', 'embedded-worker-runtime', 'owner', 'created', NULL, 'embedded', 1, '1'); + INSERT INTO worker_mutation_source_proof_jtis( + workspace_id, runtime_id, jti, expires_at, consumed_at + ) VALUES + ('workspace-a', 'legacy-external', 'legacy-jti', 10, '1'), + ('workspace-a', 'embedded-worker-runtime', 'embedded-jti', 10, '1'); + INSERT INTO workspace_runtime_verifications( + workspace_id, runtime_id, binding_revision, workspace_key_id, + workspace_identity_revision, workspace_trust_generation, + runtime_public_key_fingerprint, runtime_identity_revision, + challenge_id, state, last_outcome, verified_at, checked_at + ) VALUES ( + 'workspace-a', 'legacy-external', 1, 'legacy-workspace-key', + 1, 1, 'legacy-runtime-key', 1, + 'legacy-challenge', 'verified', 'legacy', '1', '1' + ); + "#, + ) + .unwrap(); + + migrate_legacy_external_runtime_bindings_v56_to_v57(&conn).unwrap(); + + assert_eq!(current_schema_version(&conn).unwrap(), 57); + let workspace_identity: (String, i64) = conn + .query_row( + "SELECT key_id, revision FROM workspace_signing_identities WHERE workspace_id = 'workspace-a'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + let external_binding: (String, String, String, i64) = conn + .query_row( + "SELECT state, authentication_mode, workspace_key_id, workspace_key_generation + FROM workspace_runtime_bindings WHERE runtime_id = 'legacy-external'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!( + external_binding, + ( + "configured".to_string(), + "workspace_identity".to_string(), + workspace_identity.0, + workspace_identity.1, + ) + ); + let embedded_binding: (String, String, Option, Option) = conn + .query_row( + "SELECT state, authentication_mode, workspace_key_id, workspace_key_generation + FROM workspace_runtime_bindings WHERE runtime_id = 'embedded-worker-runtime'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!( + embedded_binding, + ( + "verified".to_string(), + "legacy_server_issuer".to_string(), + None, + None, + ) + ); + for table in [ + "workspace_runtime_binding_audit", + "worker_mutation_source_proof_jtis", + ] { + for runtime_id in ["legacy-external", "embedded-worker-runtime"] { + let count: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM {table} WHERE runtime_id = ?1"), + params![runtime_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "{table}:{runtime_id}"); + } + } + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_runtime_verifications WHERE runtime_id = 'legacy-external'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM workspace_runtime_bindings + WHERE authentication_mode = 'legacy_server_issuer' + AND runtime_id <> 'embedded-worker-runtime'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + } + #[test] fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() { let store = SqliteWorkspaceStore::in_memory().unwrap(); @@ -11401,14 +11712,18 @@ INSERT INTO worker_registry ( fn server_refuses_a_database_from_a_newer_schema_generation() { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); + let future_version = LATEST_SCHEMA_VERSION + 1; conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (57, 'future')", - [], + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, 'future')", + params![future_version], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 57 is newer"), "{error}"); + assert!( + error.contains(&format!("schema version {future_version} is newer")), + "{error}" + ); assert!(error.contains("refusing to serve"), "{error}"); } diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index 24cf94a5..90773aa6 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -377,6 +377,7 @@ function runtimeVerification( function runtimeBinding( value: unknown, path: string, + requiresWorkspaceIdentity: boolean, ): WorkspaceRuntimeBindingSummary { const item = object(value, path); exactKeys( @@ -396,6 +397,7 @@ function runtimeBinding( ); const state = enumValue(item.state, `${path}.state`, BINDING_STATES); if ( + requiresWorkspaceIdentity && state !== "revoked" && (workspaceKeyId == null || workspaceKeyGeneration == null) ) { @@ -416,6 +418,7 @@ function runtimeBinding( return fail(path, "verification must match the current binding revision"); } if ( + requiresWorkspaceIdentity && connectionState === "verified" && (verification === undefined || verification.verified_at === null || @@ -457,11 +460,12 @@ function runtimeManagement( ["binding"], path, ); + const builtIn = boolean(item.built_in, `${path}.built_in`); const binding = item.binding == null ? undefined - : runtimeBinding(item.binding, `${path}.binding`); + : runtimeBinding(item.binding, `${path}.binding`, !builtIn); return { - built_in: boolean(item.built_in, `${path}.built_in`), + built_in: builtIn, config_managed: boolean(item.config_managed, `${path}.config_managed`), removable: boolean(item.removable, `${path}.removable`), endpoint_configured: boolean( diff --git a/web/workspace/src/lib/workspace/settings/model.test.ts b/web/workspace/src/lib/workspace/settings/model.test.ts index 645fc09b..43a26ae7 100644 --- a/web/workspace/src/lib/workspace/settings/model.test.ts +++ b/web/workspace/src/lib/workspace/settings/model.test.ts @@ -24,6 +24,10 @@ Deno.test("settings section navigation stays under the settings route", () => { settingsSectionHref("configuration-sources") === "/settings/configuration", "shared configuration editor route should stay canonical", ); + assert( + settingsSectionHref("workspace-identity") === "/settings", + "Workspace identity should use the settings root without a redundant workspace segment", + ); for (const section of SETTINGS_SECTIONS) { const href = settingsSectionHref(section.id); @@ -54,7 +58,9 @@ Deno.test("settings shell advertises scoped account authority", () => { }); Deno.test("Repository settings expose the canonical list and Add route", () => { - const section = SETTINGS_SECTIONS.find((entry) => entry.id === "repositories"); + const section = SETTINGS_SECTIONS.find((entry) => + entry.id === "repositories" + ); assert(section?.status === "editable", "Repositories should be editable"); assert( settingsSectionHref("repositories") === "/settings/repositories", diff --git a/web/workspace/src/lib/workspace/settings/model.ts b/web/workspace/src/lib/workspace/settings/model.ts index 59f69f3c..49c9d071 100644 --- a/web/workspace/src/lib/workspace/settings/model.ts +++ b/web/workspace/src/lib/workspace/settings/model.ts @@ -134,7 +134,7 @@ export function settingsSectionHref(id: SettingsSectionId): string { case "profile-sources": return `${SETTINGS_ROUTE}/profiles`; case "workspace-identity": - return `${SETTINGS_ROUTE}/workspace`; + return SETTINGS_ROUTE; } } diff --git a/web/workspace/src/lib/workspace/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index 51cbfc45..57881a3d 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -519,7 +519,7 @@ export async function fetchWorkspaceMetadata( workspaceId: string, ): Promise { return await parseResponse( - await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`), + await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings`), parseWorkspaceMetadataSettingsResponse, ); } @@ -530,7 +530,7 @@ export async function updateWorkspaceMetadata( ): Promise { return await parseResponse( await fetch( - `/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, + `/api/w/${encodeURIComponent(workspaceId)}/settings`, { method: "PUT", headers: { "content-type": "application/json" }, @@ -546,9 +546,7 @@ export async function fetchWorkspaceSigningIdentity( ): Promise { return await parseResponse( await fetch( - `/api/w/${ - encodeURIComponent(workspaceId) - }/settings/workspace/signing-identity`, + `/api/w/${encodeURIComponent(workspaceId)}/settings/signing-identity`, ), parseWorkspaceSigningIdentityResponse, ); @@ -561,7 +559,7 @@ export async function provisionWorkspaceSigningIdentity( await fetch( `/api/w/${ encodeURIComponent(workspaceId) - }/settings/workspace/signing-identity/provision`, + }/settings/signing-identity/provision`, { method: "POST" }, ), parseWorkspaceSigningIdentityResponse, diff --git a/web/workspace/src/lib/workspace/sidebar/SettingsSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/SettingsSidebar.svelte index f5f164de..38527a40 100644 --- a/web/workspace/src/lib/workspace/sidebar/SettingsSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/SettingsSidebar.svelte @@ -1,6 +1,7 @@ @@ -32,10 +34,10 @@ {#each SETTINGS_SECTIONS as section} {@const href = sectionHref(settingsSectionHref(section.id))} {section.label} diff --git a/web/workspace/src/lib/workspace/sidebar/worker-state.ts b/web/workspace/src/lib/workspace/sidebar/worker-state.ts index 0f45e9f3..fa7b4484 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-state.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-state.ts @@ -5,7 +5,12 @@ export function liveWorkerState(worker: { worker_state?: WorkerStateSnapshot | null; }): string { const state = worker.worker_state?.state; - if (!state) return worker.state === "stopped" ? "stopped" : "unknown"; + if (!state) { + if (worker.state === "missing" || worker.state === "stopped") { + return worker.state; + } + return "unknown"; + } if (state.kind === "idle") return "idle"; if (state.state.kind === "maintenance") return "running"; return state.state.state === "paused" ? "paused" : "running"; diff --git a/web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts index 84e81598..1c43e43e 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts @@ -46,6 +46,10 @@ Deno.test('Worker list state uses the authoritative live snapshot separately fro const unavailable = worker('runtime-a', 'worker-2', 1); assertEquals(liveWorkerState(unavailable), 'unknown'); + assertEquals( + liveWorkerState({ ...unavailable, state: 'missing' }), + 'missing', + ); unavailable.state = 'stopped'; assertEquals(liveWorkerState(unavailable), 'stopped'); }); diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte index e69de29b..0dfd5a68 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte @@ -0,0 +1,395 @@ + + + + Workspace settings · Yoi Workspace + + +
+
+
+

editable

+

Workspace Identity

+
+ Backend scoped +
+ + {#if loading} +

Loading workspace settings…

+ {:else} +
{ event.preventDefault(); void submitWorkspaceName(); }}> + +

Workspace id: {workspaceMetadata?.workspace_id ?? workspaceId}

+ +
+ +
+
+
Source
+
{workspaceMetadata?.source ?? 'unknown'}
+
+
+
Revision
+
{workspaceMetadata?.revision ?? 'unknown'}
+
+
+ {/if} + + {#if message} +

{message}

+ {/if} + +
+ +{#if data.workspace?.permissions.delete_workspace} +
+
+
+

Workspace public identity

+

Use this public bundle when connecting a Runtime to this Workspace.

+
+ {#if signingIdentity?.public_bundle} + + {/if} +
+ {#if identityError} +

{identityError}

+ {/if} + {#if identityLoading} +

Loading identity…

+ {:else if signingIdentity?.identity.state === 'pending_provisioning'} +

This existing Workspace needs one explicit signing identity provisioning operation.

+ + {:else if signingIdentity?.public_bundle} + + + {/if} +
+ +
+
+

Danger zone

+

Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.

+
+ +
+{/if} + +{#if deletionOpen} + +{/if} + + diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts index 37ed3880..84270689 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.ts @@ -19,7 +19,7 @@ export const load: PageLoad = async ({ fetch, params }) => { const signingIdentity = await loadJson( fetch, - workspaceApiPath(params.workspaceId, "/signing-identity"), + workspaceApiPath(params.workspaceId, "/settings/signing-identity"), undefined, (value) => { const response = parseWorkspaceSigningIdentityResponse(value); diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/workspace/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/workspace/+page.svelte deleted file mode 100644 index 0dfd5a68..00000000 --- a/web/workspace/src/routes/w/[workspaceId]/settings/workspace/+page.svelte +++ /dev/null @@ -1,395 +0,0 @@ - - - - Workspace settings · Yoi Workspace - - -
-
-
-

editable

-

Workspace Identity

-
- Backend scoped -
- - {#if loading} -

Loading workspace settings…

- {:else} -
{ event.preventDefault(); void submitWorkspaceName(); }}> - -

Workspace id: {workspaceMetadata?.workspace_id ?? workspaceId}

- -
- -
-
-
Source
-
{workspaceMetadata?.source ?? 'unknown'}
-
-
-
Revision
-
{workspaceMetadata?.revision ?? 'unknown'}
-
-
- {/if} - - {#if message} -

{message}

- {/if} - -
- -{#if data.workspace?.permissions.delete_workspace} -
-
-
-

Workspace public identity

-

Use this public bundle when connecting a Runtime to this Workspace.

-
- {#if signingIdentity?.public_bundle} - - {/if} -
- {#if identityError} -

{identityError}

- {/if} - {#if identityLoading} -

Loading identity…

- {:else if signingIdentity?.identity.state === 'pending_provisioning'} -

This existing Workspace needs one explicit signing identity provisioning operation.

- - {:else if signingIdentity?.public_bundle} - - - {/if} -
- -
-
-

Danger zone

-

Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.

-
- -
-{/if} - -{#if deletionOpen} - -{/if} - - diff --git a/web/workspace/tests/profile-api.test.ts b/web/workspace/tests/profile-api.test.ts index 5f15dbe0..980bd69d 100644 --- a/web/workspace/tests/profile-api.test.ts +++ b/web/workspace/tests/profile-api.test.ts @@ -26,10 +26,12 @@ function assertThrows( import { fetchProfileSettings, fetchWorkspaceMetadata, + fetchWorkspaceSigningIdentity, parseProfileSettingsResponse, parseWorkspaceMetadataSettingsResponse, parseWorkspaceSigningIdentityResponse, ProfileApiError, + provisionWorkspaceSigningIdentity, updateWorkspaceMetadata, } from "../src/lib/workspace/settings/profile-api.ts"; @@ -127,8 +129,8 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => { "workspace 1", ); assertEquals(requests.map((request) => request.url), [ - "/api/w/workspace%201/settings/workspace", - "/api/w/workspace%201/settings/workspace", + "/api/w/workspace%201/settings", + "/api/w/workspace%201/settings", ]); assertEquals(requests[1].init?.method, "PUT"); assertEquals( @@ -140,6 +142,37 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => { } }); +Deno.test("Workspace signing identity requests use flat settings routes", async () => { + const originalFetch = globalThis.fetch; + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (input: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(input), init }); + return Promise.resolve(Response.json({ + identity: { + workspace_id: "workspace 1", + key_id: "workspace-signing-key", + algorithm: "ed25519", + revision: 1, + state: "pending_provisioning", + created_at: "2026-01-01T00:00:00Z", + }, + })); + }; + + try { + await fetchWorkspaceSigningIdentity("workspace 1"); + await provisionWorkspaceSigningIdentity("workspace 1"); + assertEquals(requests.map((request) => request.url), [ + "/api/w/workspace%201/settings/signing-identity", + "/api/w/workspace%201/settings/signing-identity/provision", + ]); + assertEquals(requests[0].init, undefined); + assertEquals(requests[1].init?.method, "POST"); + } finally { + globalThis.fetch = originalFetch; + } +}); + Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid provenance fields", () => { const missing = profileSettingsFixture(); delete missing.profiles; diff --git a/web/workspace/tests/runtime-management-source.test.ts b/web/workspace/tests/runtime-management-source.test.ts index 1aaee2c1..09be4efe 100644 --- a/web/workspace/tests/runtime-management-source.test.ts +++ b/web/workspace/tests/runtime-management-source.test.ts @@ -27,6 +27,11 @@ Deno.test("Runtime Settings routes validate unknown JSON through the shared Runt listLoader.includes("parseWorkspaceRuntimeList(value)"), "Runtime list loader should validate unknown JSON", ); + assert( + listLoader.includes('"/settings/signing-identity"') && + !listLoader.includes("/settings/workspace"), + "Runtime list loader should use the canonical Workspace signing identity route", + ); assert( detailLoader.includes("parseWorkspaceRuntimeDetail(value)"), "Runtime detail loader should validate unknown JSON", diff --git a/web/workspace/tests/runtime-management.test.ts b/web/workspace/tests/runtime-management.test.ts index d8c79793..a7707967 100644 --- a/web/workspace/tests/runtime-management.test.ts +++ b/web/workspace/tests/runtime-management.test.ts @@ -129,6 +129,33 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes", ); }); +Deno.test("Runtime list parser accepts the built-in Runtime's internal binding", () => { + const embedded = runtime(); + embedded.runtime_id = "embedded"; + embedded.label = "Embedded Runtime"; + embedded.kind = "embedded"; + embedded.management.built_in = true; + embedded.management.endpoint_configured = false; + const binding = embedded.management.binding as Partial< + typeof embedded.management.binding + >; + delete binding.workspace_key_id; + delete binding.workspace_key_generation; + delete binding.verification; + + const list = parseWorkspaceRuntimeList({ + workspace_id: "workspace-a", + limit: 200, + items: [embedded], + source: "workspace-control-plane", + diagnostics: [], + }); + assert( + list.items[0]?.management.binding?.connection_state === "verified", + "built-in Runtime binding was not preserved", + ); +}); + Deno.test("Runtime management parser rejects Workspace identity bindings without key metadata", () => { const payload = detail(); const binding = payload.runtime.management.binding as Partial< diff --git a/web/workspace/tests/workspace-model.test.ts b/web/workspace/tests/workspace-model.test.ts index 223d4b17..4daa10ec 100644 --- a/web/workspace/tests/workspace-model.test.ts +++ b/web/workspace/tests/workspace-model.test.ts @@ -190,7 +190,7 @@ Deno.test("Workspace deletion DTOs fail closed and preserve durable operation st Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => { const source = await Deno.readTextFile( new URL( - "../src/routes/w/[workspaceId]/settings/workspace/+page.svelte", + "../src/routes/w/[workspaceId]/settings/+page.svelte", import.meta.url, ), );