From 5e9f7a7dc3d1169118412376f16b72850aab63f2 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 6 Sep 2026 02:17:26 +0900 Subject: [PATCH] feat: add versioned runtime connection ping --- crates/worker-runtime/src/http_server.rs | 137 ++- crates/workspace-api/src/lib.rs | 62 +- crates/workspace-server/src/hosts.rs | 224 ++++- crates/workspace-server/src/server.rs | 873 +++++++----------- web/workspace/deno.json | 2 +- .../src/lib/generated/workspace-api.ts | 24 + .../lib/workspace/api/runtime-connection.ts | 142 +++ .../console/worker-console.ui.test.ts | 11 +- .../src/lib/workspace/styles/settings.css | 3 + .../settings/runtimes/+page.svelte | 56 +- .../tests/runtime-connection.test.ts | 103 +++ 11 files changed, 1053 insertions(+), 584 deletions(-) create mode 100644 web/workspace/src/lib/workspace/api/runtime-connection.ts create mode 100644 web/workspace/tests/runtime-connection.test.ts diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 2217e973..d1e93598 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -33,7 +33,7 @@ use axum::extract::rejection::{JsonRejection, QueryRejection}; #[cfg(feature = "ws-server")] use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{DefaultBodyLimit, Extension, Path, Query, State}; -use axum::http::{Method, Request, StatusCode, header}; +use axum::http::{HeaderMap, Method, Request, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post}; @@ -66,6 +66,11 @@ use workdir::{ }; const DEFAULT_RUNTIME_HTTP_PORT: u16 = 38800; +pub const RUNTIME_HTTP_PROTOCOL_MIN_VERSION: u32 = 1; +pub const RUNTIME_HTTP_PROTOCOL_MAX_VERSION: u32 = 1; +pub const RUNTIME_HTTP_PROTOCOL_VERSION: u32 = RUNTIME_HTTP_PROTOCOL_MAX_VERSION; +pub const RUNTIME_PING_PERMISSION: &str = "runtime:ping"; +pub const RUNTIME_WORKSPACE_SCOPE_HEADER: &str = "x-yoi-workspace-id"; fn default_runtime_http_bind_addr() -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], DEFAULT_RUNTIME_HTTP_PORT)) @@ -187,6 +192,7 @@ fn runtime_http_router_with_optional_auth( }; let router = Router::new() + .route("/v1/ping", get(get_runtime_ping)) .route("/v1/runtime", get(get_runtime)) .route( "/v1/config-bundles", @@ -340,6 +346,14 @@ enum RuntimeHttpWorkerStatusFilter { Stopped, } +/// `GET /v1/ping` response. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeHttpPingResponse { + pub runtime_id: String, + pub protocol_version: u32, +} + /// `GET /v1/workers` response. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeHttpWorkersResponse { @@ -461,6 +475,48 @@ struct RuntimeWorkerEventsWsQuery { type RestResult = Result, RuntimeHttpRestError>; +async fn get_runtime_ping( + State(state): State, + Extension(auth): Extension, + headers: HeaderMap, +) -> RestResult { + let requested_workspace_id = headers + .get(RUNTIME_WORKSPACE_SCOPE_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + RuntimeHttpRestError::new( + StatusCode::FORBIDDEN, + "runtime_ping_workspace_scope_required", + "Runtime ping requires the target Workspace scope", + ) + })?; + if requested_workspace_id != auth.workspace_id { + return Err(RuntimeHttpRestError::new( + StatusCode::FORBIDDEN, + "runtime_ping_workspace_scope_mismatch", + "Runtime ping Workspace scope does not match the authenticated capability", + )); + } + let runtime_id = state + .auth + .as_ref() + .map(|config| config.runtime_id.trim()) + .filter(|runtime_id| !runtime_id.is_empty()) + .ok_or_else(|| { + RuntimeHttpRestError::new( + StatusCode::SERVICE_UNAVAILABLE, + "runtime_ping_identity_unavailable", + "Runtime ping identity is not configured", + ) + })?; + Ok(Json(RuntimeHttpPingResponse { + runtime_id: runtime_id.to_string(), + protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION, + })) +} + async fn get_runtime( State(state): State, ) -> RestResult { @@ -1767,6 +1823,9 @@ fn auth_workspace_scope( } fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static str> { + if path == "/v1/ping" && *method == Method::GET { + return Some(RUNTIME_PING_PERMISSION); + } if path == "/v1/runtime" { return None; } @@ -2084,6 +2143,82 @@ mod tests { WorkdirPath, WorkdirSessionCapabilities, }; + #[tokio::test] + async fn ping_requires_scoped_permission_and_returns_versioned_identity() { + let runtime = Runtime::new_memory(); + let (auth, signer) = auth_config_and_signer(); + let app = runtime_http_router_with_auth(runtime, None, auth); + let token = + token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]); + let request = Request::builder() + .method(Method::GET) + .uri("/v1/ping") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a") + .body(Body::empty()) + .unwrap(); + + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + RuntimeHttpPingResponse { + runtime_id: "runtime-test".to_string(), + protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION, + } + ); + + let wrong_scope_token = + token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]); + let wrong_scope_request = Request::builder() + .method(Method::GET) + .uri("/v1/ping") + .header(header::AUTHORIZATION, format!("Bearer {wrong_scope_token}")) + .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-b") + .body(Body::empty()) + .unwrap(); + assert_eq!( + app.oneshot(wrong_scope_request).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn ping_rejects_token_without_ping_permission() { + let runtime = Runtime::new_memory(); + let (auth, signer) = auth_config_and_signer(); + let app = runtime_http_router_with_auth(runtime, None, auth); + let missing_credential = Request::builder() + .method(Method::GET) + .uri("/v1/ping") + .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a") + .body(Body::empty()) + .unwrap(); + assert_eq!( + app.clone() + .oneshot(missing_credential) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + + let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]); + let request = Request::builder() + .method(Method::GET) + .uri("/v1/ping") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a") + .body(Body::empty()) + .unwrap(); + + assert_eq!( + app.oneshot(request).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + } + #[test] fn attachment_routes_require_worker_input_permission() { assert_eq!( diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index b77db77a..8c8dd902 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -1205,16 +1205,39 @@ pub struct CreateRemoteRuntimeRequest { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeConnectionTestStatus { + Compatible, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeConnectionTestFailureKind { + Authentication, + Authorization, + NetworkUnreachable, + Timeout, + TlsOrTransport, + MalformedResponse, + ProtocolVersionMismatch, + RuntimeIdentityMismatch, + Configuration, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct RuntimeConnectionTestResponse { pub workspace_id: String, pub runtime_id: String, pub checked_at: String, - pub state: String, - pub protocol_version: Option, - pub compatibility_basis: String, - #[serde(default)] - pub capabilities: Vec, - pub health_result: String, + pub status: RuntimeConnectionTestStatus, + pub failure_kind: Option, + pub expected_protocol_version: u32, + pub actual_protocol_version: Option, #[serde(default)] pub diagnostics: Vec, } @@ -2371,6 +2394,9 @@ pub fn catalog_typescript() -> String { RepositoryListResponse::decl(&config), RepositoryDetailResponse::decl(&config), RepositoryLogResponse::decl(&config), + RuntimeConnectionTestStatus::decl(&config), + RuntimeConnectionTestFailureKind::decl(&config), + RuntimeConnectionTestResponse::decl(&config), ] .map(|declaration| format!("export {declaration}")); @@ -3060,6 +3086,27 @@ mod tests { assert!(serde_json::from_value::(stale).is_err()); } + #[test] + fn runtime_connection_test_response_is_closed_and_typed() { + let compatible = serde_json::json!({ + "workspace_id": "workspace-test", + "runtime_id": "runtime-test", + "checked_at": "2026-09-01T12:00:00Z", + "status": "compatible", + "failure_kind": null, + "expected_protocol_version": 1, + "actual_protocol_version": 1, + "diagnostics": [] + }); + let parsed: RuntimeConnectionTestResponse = + serde_json::from_value(compatible.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), compatible); + + let mut unknown = compatible; + unknown["capabilities"] = serde_json::json!(["shell"]); + assert!(serde_json::from_value::(unknown).is_err()); + } + #[cfg(feature = "typescript")] #[test] fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() { @@ -3080,6 +3127,9 @@ mod tests { assert!(output.contains( "export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\"" )); + assert!(output.contains("export type RuntimeConnectionTestResponse =")); + assert!(output.contains("status: RuntimeConnectionTestStatus")); + assert!(output.contains("failure_kind: RuntimeConnectionTestFailureKind | null")); assert!(!output.contains("repository_key: string, display_name")); } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 7f530a8d..3a9720f7 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -9,7 +9,9 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ + error::Error as _, future::Future, + io::Read as _, path::PathBuf, pin::Pin, sync::{Arc, RwLock}, @@ -38,13 +40,15 @@ use worker_runtime::error::RuntimeError as EmbeddedRuntimeError; use worker_runtime::execution::WorkerExecutionRunState; use worker_runtime::fs_store::FsRuntimeStoreOptions; use worker_runtime::http_server::{ + RUNTIME_PING_PERMISSION, RUNTIME_WORKSPACE_SCOPE_HEADER, RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest, - RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse, - RuntimeHttpUploadedFileDeleteResponse, RuntimeHttpUploadedFileResponse, - RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse, - RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse, - RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse, - RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse, + RuntimeHttpErrorResponse, RuntimeHttpPingResponse, RuntimeHttpRepositoryAccessResponse, + RuntimeHttpSummaryResponse, RuntimeHttpUploadedFileDeleteResponse, + RuntimeHttpUploadedFileResponse, RuntimeHttpWorkerCompletionsRequest, + RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse, + RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest, + RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, + RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse, RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse, RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse, }; @@ -64,6 +68,7 @@ pub(crate) const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime"; const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host"; const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host"; const MAX_DIAGNOSTICS: usize = 16; +const MAX_RUNTIME_PING_RESPONSE_BYTES: usize = 8 * 1024; const MAX_HOST_SCAN: usize = 256; const MAX_IDENTIFIER_LEN: usize = 120; const ID_DIGEST_HEX_LEN: usize = 16; @@ -760,11 +765,50 @@ fn default_worker_input_kind() -> WorkerInputKind { WorkerInputKind::User } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimePingFailureKind { + Authentication, + Authorization, + NetworkUnreachable, + Timeout, + TlsOrTransport, + MalformedResponse, + Configuration, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimePingFailure { + pub kind: RuntimePingFailureKind, + pub diagnostic: RuntimeDiagnostic, +} + +impl RuntimePingFailure { + fn new( + kind: RuntimePingFailureKind, + code: impl Into, + message: impl Into, + ) -> Self { + Self { + kind, + diagnostic: diagnostic(code, DiagnosticSeverity::Error, message.into()), + } + } +} + pub trait WorkspaceWorkerRuntime: Send + Sync { fn runtime_id(&self) -> &str; fn runtime_summary(&self, limit: usize) -> RuntimeSummary; + fn ping(&self) -> Result { + Err(RuntimePingFailure::new( + RuntimePingFailureKind::Unsupported, + "runtime_ping_unsupported", + "Runtime connection testing is unavailable for this Runtime provider", + )) + } + fn list_hosts(&self, limit: usize) -> RuntimeList; fn list_workers(&self, limit: usize) -> RuntimeList; @@ -1791,6 +1835,17 @@ impl RuntimeRegistry { }) } + pub fn ping(&self, runtime_id: &str) -> Result { + let runtime = self.runtime(runtime_id).map_err(|_| { + RuntimePingFailure::new( + RuntimePingFailureKind::Configuration, + "runtime_ping_registration_unavailable", + "Registered Runtime binding is unavailable", + ) + })?; + runtime.ping() + } + fn runtimes_snapshot(&self) -> Vec> { self.runtimes .read() @@ -2901,6 +2956,49 @@ pub struct RemoteWorkerRuntime { async_http: AsyncHttpClient, } +fn remote_runtime_ping_transport_failure(error: reqwest::Error) -> RuntimePingFailure { + if error.is_timeout() { + return RuntimePingFailure::new( + RuntimePingFailureKind::Timeout, + "runtime_ping_timeout", + "Runtime ping timed out", + ); + } + let mut source = error.source(); + let mut tls_error = false; + while let Some(current) = source { + let message = current.to_string().to_ascii_lowercase(); + if message.contains("tls") + || message.contains("certificate") + || message.contains("unknownissuer") + || message.contains("handshake") + { + tls_error = true; + break; + } + source = current.source(); + } + if tls_error { + return RuntimePingFailure::new( + RuntimePingFailureKind::TlsOrTransport, + "runtime_ping_tls_failed", + "Runtime TLS connection failed", + ); + } + if error.is_connect() { + return RuntimePingFailure::new( + RuntimePingFailureKind::NetworkUnreachable, + "runtime_ping_network_unreachable", + "Runtime could not be reached", + ); + } + RuntimePingFailure::new( + RuntimePingFailureKind::TlsOrTransport, + "runtime_ping_transport_failed", + "Runtime ping transport failed", + ) +} + fn all_remote_runtime_permissions() -> Vec { [ "workers:list", @@ -3049,14 +3147,18 @@ impl RemoteWorkerRuntime { self.send_json(path, self.http.delete(self.endpoint(path))) } - fn runtime_capability_token(&self, path: &str) -> Option { + fn runtime_capability_token_with_permissions( + &self, + path: &str, + permissions: Vec, + ) -> Option { let auth = self.auth.as_ref()?; let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key); let claims = capability_claims( &auth.server_id, &self.runtime_id, &self.workspace_id, - all_remote_runtime_permissions(), + permissions, 300, ) .map_err(|error| { @@ -3079,6 +3181,82 @@ impl RemoteWorkerRuntime { .ok() } + fn runtime_capability_token(&self, path: &str) -> Option { + self.runtime_capability_token_with_permissions(path, all_remote_runtime_permissions()) + } + + fn ping_http(&self) -> Result { + const PATH: &str = "/v1/ping"; + let workspace_id = self.workspace_id.clone(); + let bearer_token = self.bearer_token.clone(); + let capability_token = self.runtime_capability_token_with_permissions( + PATH, + vec![RUNTIME_PING_PERMISSION.to_string()], + ); + let request = self + .http + .get(self.endpoint(PATH)) + .header(RUNTIME_WORKSPACE_SCOPE_HEADER, &workspace_id); + run_blocking_http(move || { + let request = match capability_token.as_deref().or(bearer_token.as_deref()) { + Some(token) => request.header(AUTHORIZATION, format!("Bearer {token}")), + None => request, + }; + let response = request + .send() + .map_err(remote_runtime_ping_transport_failure)?; + match response.status() { + StatusCode::UNAUTHORIZED => { + return Err(RuntimePingFailure::new( + RuntimePingFailureKind::Authentication, + "runtime_ping_authentication_failed", + "Runtime rejected the connection-test credential", + )); + } + StatusCode::FORBIDDEN => { + return Err(RuntimePingFailure::new( + RuntimePingFailureKind::Authorization, + "runtime_ping_authorization_failed", + "Runtime rejected the connection-test scope or permission", + )); + } + status if !status.is_success() => { + return Err(RuntimePingFailure::new( + RuntimePingFailureKind::TlsOrTransport, + "runtime_ping_http_failed", + "Runtime ping returned an unsuccessful HTTP response", + )); + } + _ => {} + } + let mut body = Vec::new(); + response + .take((MAX_RUNTIME_PING_RESPONSE_BYTES + 1) as u64) + .read_to_end(&mut body) + .map_err(|_| { + RuntimePingFailure::new( + RuntimePingFailureKind::TlsOrTransport, + "runtime_ping_response_read_failed", + "Runtime ping response could not be read", + ) + })?; + if body.len() > MAX_RUNTIME_PING_RESPONSE_BYTES { + return Err(RuntimePingFailure::new( + RuntimePingFailureKind::MalformedResponse, + "runtime_ping_response_too_large", + "Runtime ping response exceeded the allowed size", + )); + } + serde_json::from_slice::(&body).map_err(|_| { + RuntimePingFailure::new( + RuntimePingFailureKind::MalformedResponse, + "runtime_ping_malformed_response", + "Runtime ping returned an unrecognized response", + ) + }) + }) + } + fn send_json(&self, path: &str, request: RequestBuilder) -> Result where T: DeserializeOwned + Send + 'static, @@ -3266,6 +3444,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } } + fn ping(&self) -> Result { + self.ping_http() + } + fn list_hosts(&self, limit: usize) -> RuntimeList { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); @@ -4562,7 +4744,7 @@ mod tests { use super::*; use serde_json::json; use std::collections::HashMap; - use std::io::{Read as _, Write as _}; + use std::io::Write as _; use std::net::TcpListener; use std::sync::{Arc, Mutex}; use std::thread; @@ -5707,6 +5889,30 @@ mod tests { assert_eq!(runtime.runtime_id(), "remote:async-init"); } + #[test] + fn remote_runtime_ping_classifies_unreachable_without_endpoint_leak() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let runtime = RemoteWorkerRuntime::new( + RemoteRuntimeConfig::new( + "remote:unreachable", + "Remote Unreachable", + endpoint.clone(), + Some("secret-token".to_string()), + ), + "workspace-test".to_string(), + "http://127.0.0.1:8787".to_string(), + ) + .unwrap(); + + let failure = runtime.ping().unwrap_err(); + assert_eq!(failure.kind, RuntimePingFailureKind::NetworkUnreachable); + assert_eq!(failure.diagnostic.code, "runtime_ping_network_unreachable"); + assert!(!failure.diagnostic.message.contains(&endpoint)); + assert!(!format!("{failure:?}").contains("secret-token")); + } + #[test] fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() { let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3599f26d..7c6fb8e8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -53,6 +53,10 @@ use workdir::workspace::{ }; use workdir::{CommandHandle, WorkdirSessionHandle}; use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef}; +use worker_runtime::http_server::{ + RUNTIME_HTTP_PROTOCOL_MAX_VERSION, RUNTIME_HTTP_PROTOCOL_MIN_VERSION, + RUNTIME_HTTP_PROTOCOL_VERSION, +}; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use workspace_api::{ @@ -72,7 +76,8 @@ use workspace_api::{ PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor, - RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary, + RotateRepositorySshCredentialRequest, RuntimeConnectionTestFailureKind, + RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, @@ -107,14 +112,14 @@ use crate::config_source::ConfigCommitRequest; use crate::hosts::{ ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID, EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, - RuntimeDiagnostic, RuntimeRegistry, RuntimeRegistryError, RuntimeRegistryUnregisterResult, - TicketWorkerRole, WorkerCapabilitySummary, WorkerCompletionsRequest, WorkerCompletionsResult, - WorkerControlOperation, WorkerCreateBinding, WorkerImplementationSummary, WorkerInputKind, - WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest, WorkerLifecycleResult, - WorkerOperationState, WorkerRestoreResult, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, - WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, - WorkerTicketAssignmentRequest, WorkerWorkspaceSummary, worker_spawn_create_fingerprint, - workspace_worker_summary, + RuntimeDiagnostic, RuntimePingFailureKind, RuntimeRegistry, RuntimeRegistryError, + RuntimeRegistryUnregisterResult, TicketWorkerRole, WorkerCapabilitySummary, + WorkerCompletionsRequest, WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding, + WorkerImplementationSummary, WorkerInputKind, WorkerInputRequest, WorkerInputResult, + WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState, WorkerRestoreResult, + WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, + WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest, + WorkerWorkspaceSummary, worker_spawn_create_fingerprint, workspace_worker_summary, }; use crate::identity::WorkspaceIdentity; use crate::memory_backend::execute_memory_backend_operation_with_authority; @@ -164,11 +169,7 @@ use worker_runtime::catalog::{ WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef, }; use worker_runtime::config_bundle::ConfigBundle; -use worker_runtime::http_server::{ - MAX_WORKER_FILE_UPLOAD_BYTES, RuntimeHttpConfigBundleAvailabilityResponse, - RuntimeHttpConfigBundlesResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, - RuntimeHttpWorkersResponse, -}; +use worker_runtime::http_server::MAX_WORKER_FILE_UPLOAD_BYTES; use worker_runtime::identity::{RuntimeWorkerRef, WorkerId}; const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; @@ -12518,14 +12519,39 @@ async fn test_runtime_connection( State(api): State, AxumPath(runtime_id): AxumPath, ) -> ApiResult> { + let runtime_id = runtime_id.trim().to_string(); + if runtime_id.is_empty() { + return Err(Error::InvalidRuntimeIdentifier { + kind: "runtime".to_string(), + value: runtime_id, + } + .into()); + } let runtime_config = load_backend_runtimes_config_for_settings(&api)?; - let remote = runtime_config + runtime_config .runtimes .remote .iter() .find(|remote| remote.id == runtime_id) .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; - Ok(Json(test_remote_runtime_config(&api, remote).await)) + + let checked_at = Utc::now().to_rfc3339(); + let runtime = api.runtime.clone(); + let ping_runtime_id = runtime_id.clone(); + let ping = tokio::task::spawn_blocking(move || runtime.ping(&ping_runtime_id)) + .await + .map_err(|_| Error::RuntimeOperationFailed { + runtime_id: runtime_id.clone(), + code: "runtime_connection_test_unavailable".to_string(), + message: "Runtime connection test could not be completed".to_string(), + })?; + + Ok(Json(runtime_connection_test_response( + api.workspace_id(), + &runtime_id, + checked_at, + ping, + ))) } async fn get_worker_launch_options( @@ -14581,438 +14607,111 @@ fn remote_runtime_config_from_file( }) } -async fn test_remote_runtime_config( - api: &WorkspaceApi, - remote: &RemoteRuntimeConfigFile, -) -> RuntimeConnectionTestResponse { - let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - if remote - .token_ref - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - { - return RuntimeConnectionTestResponse { - workspace_id: api.config.workspace_id.clone(), - runtime_id: remote.id.clone(), - checked_at, - state: "rejected".to_string(), - protocol_version: None, - compatibility_basis: "not_checked_token_ref_unsupported".to_string(), - capabilities: Vec::new(), - health_result: "not_checked".to_string(), - diagnostics: vec![settings_diagnostic( - "remote_runtime_token_ref_unsupported", - DiagnosticSeverity::Error, - "Remote Runtime test cannot use token_ref in v0; no token or secret value was exposed to the Browser.", - ) - .into()], - }; - } - - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - { - Ok(client) => client, - Err(_) => { - return remote_runtime_test_failed( - api, - remote, - checked_at, - "remote_runtime_test_client_unavailable", - "Remote Runtime test client could not be initialized.", - ); - } - }; - - let mut observation = RuntimeCompatibilityObservation::default(); - let summary_url = match remote_probe_url(remote, "/v1/runtime") { - Ok(url) => url, - Err(diagnostic) => { - return remote_runtime_test_failed( - api, - remote, - checked_at, - diagnostic.code, - diagnostic.message, - ); - } - }; - - let summary_payload = - match probe_remote_json(&client, summary_url, "runtime.summary", "Runtime summary").await { - Ok(payload) => payload, - Err(diagnostic) => { - return remote_runtime_test_failed( - api, - remote, - checked_at, - diagnostic.code, - diagnostic.message, - ); - } - }; - let protocol_version = summary_payload - .get("protocol_version") - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned); - let summary = match serde_json::from_value::(summary_payload) { - Ok(summary) => summary, - Err(_) => { - return remote_runtime_test_failed( - api, - remote, - checked_at, - "remote_runtime_malformed_summary", - "Remote Runtime summary responded, but the payload was not recognized.", - ); - } - }; - observation.available( - "runtime.summary", - "Connected: /v1/runtime responded with a recognized worker-runtime summary.", - ); - - let workers_url = match remote_probe_url(remote, "/v1/workers") { - Ok(url) => url, - Err(diagnostic) => { - observation.incompatible("workers.list", diagnostic); - String::new() - } - }; - let workers = if workers_url.is_empty() { - None - } else { - match probe_remote_json(&client, workers_url, "workers.list", "Worker list").await { - Ok(payload) => match serde_json::from_value::(payload) { - Ok(workers) => { - observation.available( - "workers.list", - "Verified: /v1/workers responded with a recognized worker list.", - ); - Some(workers) - } - Err(_) => { - observation.incompatible( - "workers.list", - settings_diagnostic( - "remote_runtime_workers_malformed", - DiagnosticSeverity::Error, - "Remote Runtime worker list responded, but the payload was not recognized.", - ), - ); - None - } - }, - Err(diagnostic) => { - observation.incompatible("workers.list", diagnostic); - None - } - } - }; - - if let Some(worker) = workers.as_ref().and_then(|workers| workers.workers.first()) { - let path = format!( - "/v1/workers/{}", - encode_path_segment(&worker.worker_id.to_string()) - ); - match remote_probe_url(remote, &path) { - Ok(url) => match probe_remote_json(&client, url, "workers.detail", "Worker detail").await { - Ok(payload) => match serde_json::from_value::(payload) { - Ok(_) => observation.available( - "workers.detail", - "Verified: worker detail responded for an existing worker reported by the remote Runtime.", - ), - Err(_) => observation.incompatible( - "workers.detail", - settings_diagnostic( - "remote_runtime_worker_detail_malformed", - DiagnosticSeverity::Error, - "Remote Runtime worker detail responded, but the payload was not recognized.", - ), - ), - }, - Err(diagnostic) => observation.incompatible("workers.detail", diagnostic), - }, - Err(diagnostic) => observation.incompatible("workers.detail", diagnostic), - } - } else { - observation.unknown( - "workers.detail", - "No connection problem found. Worker detail was not checked because the remote Runtime reported no workers during the lightweight probe.", - ); - } - - observation.available( - "workers.events_ws.construct", - "Verified: worker event websocket URL can be constructed from the configured HTTP(S) Runtime endpoint. The lightweight test does not open a websocket stream.", - ); - - let bundles_url = match remote_probe_url(remote, "/v1/config-bundles") { - Ok(url) => url, - Err(diagnostic) => { - observation.incompatible("config_bundles.list", diagnostic); - String::new() - } - }; - let bundles = if bundles_url.is_empty() { - None - } else { - match probe_remote_json( - &client, - bundles_url, - "config_bundles.list", - "Config-bundle list", - ) - .await - { - Ok(payload) => { - match serde_json::from_value::(payload) { - Ok(bundles) => { - observation.available( - "config_bundles.list", - "Verified: /v1/config-bundles responded with a recognized config-bundle list.", - ); - Some(bundles) - } - Err(_) => { - observation.incompatible( - "config_bundles.list", - settings_diagnostic( - "remote_runtime_config_bundles_malformed", - DiagnosticSeverity::Error, - "Remote Runtime config-bundle list responded, but the payload was not recognized.", - ), - ); - None - } - } - } - Err(diagnostic) => { - observation.incompatible("config_bundles.list", diagnostic); - None - } - } - }; - - if let Some(bundle) = bundles.as_ref().and_then(|bundles| bundles.bundles.first()) { - let path = format!( - "/v1/config-bundles/{}/availability?digest={}", - encode_path_segment(&bundle.id), - encode_path_segment(&bundle.digest) - ); - match remote_probe_url(remote, &path) { - Ok(url) => match probe_remote_json( - &client, - url, - "config_bundles.availability", - "Config-bundle availability", - ) - .await - { - Ok(payload) => { - match serde_json::from_value::(payload) - { - Ok(_) => observation.available( - "config_bundles.availability", - "Verified: config-bundle availability was confirmed for an advertised bundle.", - ), - Err(_) => observation.incompatible( - "config_bundles.availability", - settings_diagnostic( - "remote_runtime_config_bundle_availability_malformed", - DiagnosticSeverity::Error, - "Remote Runtime config-bundle availability responded, but the payload was not recognized.", - ), - ), - } - } - Err(diagnostic) => { - observation.incompatible("config_bundles.availability", diagnostic) - } - }, - Err(diagnostic) => observation.incompatible("config_bundles.availability", diagnostic), - } - } else { - observation.unknown( - "config_bundles.availability", - "No connection problem found. Config-bundle availability was not checked because the remote Runtime advertised no bundles during the lightweight probe.", - ); - } - - if summary.runtime.worker_creation_available { - observation.available( - "workers.spawn", - "Verified: /v1/runtime reports worker creation is enabled by a Runtime execution backend. The lightweight test does not create a worker.", - ); - } else { - observation.incompatible( - "workers.spawn", - settings_diagnostic( - "remote_runtime_worker_creation_unavailable", - DiagnosticSeverity::Error, - "Connected to the Runtime, but worker creation is unavailable because this Runtime process has no execution backend attached.", - ), - ); - } - observation.unknown( - "workers.input_dispatch", - "No connection problem found. Worker input dispatch was not checked because this lightweight test does not send model-visible input as a side effect.", - ); - observation.unknown( - "config_bundles.sync", - "No connection problem found. Config-bundle sync was not checked because this lightweight test does not upload bundles as a side effect.", - ); - - RuntimeConnectionTestResponse { - workspace_id: api.config.workspace_id.clone(), - runtime_id: remote.id.clone(), - checked_at, - state: observation.state().to_string(), - protocol_version, - compatibility_basis: "Connected to /v1/runtime and verified non-side-effecting worker-runtime HTTP endpoints. No incompatible operation was found; warning items below are unproven optional or side-effecting checks, not connection failures.".to_string(), - capabilities: observation.capabilities, - health_result: format!( - "connected=true; runtime_status={:?}; available={}; incompatible={}; warnings={}", - summary.runtime.status, - observation.available_count, - observation.incompatible_count, - observation.unknown_count - ), - diagnostics: observation - .diagnostics - .into_iter() - .map(Into::into) - .collect(), - } -} - -fn remote_runtime_test_failed( - api: &WorkspaceApi, - remote: &RemoteRuntimeConfigFile, +fn runtime_connection_test_response( + workspace_id: &str, + runtime_id: &str, checked_at: String, - code: impl Into, - message: impl Into, + ping: std::result::Result< + worker_runtime::http_server::RuntimeHttpPingResponse, + crate::hosts::RuntimePingFailure, + >, +) -> RuntimeConnectionTestResponse { + match ping { + Ok(ping) if ping.runtime_id != runtime_id => runtime_connection_test_failure( + workspace_id, + runtime_id, + checked_at, + RuntimeConnectionTestFailureKind::RuntimeIdentityMismatch, + None, + RuntimeDiagnostic::new( + "runtime_ping_identity_mismatch", + "error", + "Runtime ping identity does not match the registered Runtime", + ), + ), + Ok(ping) + if !(RUNTIME_HTTP_PROTOCOL_MIN_VERSION..=RUNTIME_HTTP_PROTOCOL_MAX_VERSION) + .contains(&ping.protocol_version) => + { + let code = if ping.protocol_version > RUNTIME_HTTP_PROTOCOL_MAX_VERSION { + "runtime_ping_protocol_newer" + } else { + "runtime_ping_protocol_older" + }; + runtime_connection_test_failure( + workspace_id, + runtime_id, + checked_at, + RuntimeConnectionTestFailureKind::ProtocolVersionMismatch, + Some(ping.protocol_version), + RuntimeDiagnostic::new( + code, + "error", + "Runtime protocol version is incompatible with this Server", + ), + ) + } + Ok(ping) => RuntimeConnectionTestResponse { + workspace_id: workspace_id.to_string(), + runtime_id: runtime_id.to_string(), + checked_at, + status: RuntimeConnectionTestStatus::Compatible, + failure_kind: None, + expected_protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION, + actual_protocol_version: Some(ping.protocol_version), + diagnostics: Vec::new(), + }, + Err(failure) => runtime_connection_test_failure( + workspace_id, + runtime_id, + checked_at, + match failure.kind { + RuntimePingFailureKind::Authentication => { + RuntimeConnectionTestFailureKind::Authentication + } + RuntimePingFailureKind::Authorization => { + RuntimeConnectionTestFailureKind::Authorization + } + RuntimePingFailureKind::NetworkUnreachable => { + RuntimeConnectionTestFailureKind::NetworkUnreachable + } + RuntimePingFailureKind::Timeout => RuntimeConnectionTestFailureKind::Timeout, + RuntimePingFailureKind::TlsOrTransport => { + RuntimeConnectionTestFailureKind::TlsOrTransport + } + RuntimePingFailureKind::MalformedResponse => { + RuntimeConnectionTestFailureKind::MalformedResponse + } + RuntimePingFailureKind::Configuration | RuntimePingFailureKind::Unsupported => { + RuntimeConnectionTestFailureKind::Configuration + } + }, + None, + failure.diagnostic, + ), + } +} + +fn runtime_connection_test_failure( + workspace_id: &str, + runtime_id: &str, + checked_at: String, + failure_kind: RuntimeConnectionTestFailureKind, + actual_protocol_version: Option, + diagnostic: RuntimeDiagnostic, ) -> RuntimeConnectionTestResponse { RuntimeConnectionTestResponse { - workspace_id: api.config.workspace_id.clone(), - runtime_id: remote.id.clone(), + workspace_id: workspace_id.to_string(), + runtime_id: runtime_id.to_string(), checked_at, - state: "failed".to_string(), - protocol_version: None, - compatibility_basis: "worker-runtime lightweight HTTP compatibility probes".to_string(), - capabilities: Vec::new(), - health_result: "failed".to_string(), - diagnostics: vec![settings_diagnostic(code, DiagnosticSeverity::Error, message).into()], + status: RuntimeConnectionTestStatus::Failed, + failure_kind: Some(failure_kind), + expected_protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION, + actual_protocol_version, + diagnostics: vec![diagnostic.into()], } } -#[derive(Default)] -struct RuntimeCompatibilityObservation { - capabilities: Vec, - diagnostics: Vec, - available_count: usize, - incompatible_count: usize, - unknown_count: usize, -} - -impl RuntimeCompatibilityObservation { - fn available(&mut self, operation: &str, message: impl Into) { - self.available_count += 1; - self.capabilities.push(format!("{operation}:available")); - self.diagnostics.push(settings_diagnostic( - format!("{operation}.available"), - DiagnosticSeverity::Info, - message, - )); - } - - fn unknown(&mut self, operation: &str, message: impl Into) { - self.unknown_count += 1; - self.capabilities.push(format!("{operation}:unknown")); - self.diagnostics.push(settings_diagnostic( - format!("{operation}.unknown"), - DiagnosticSeverity::Warning, - message, - )); - } - - fn incompatible(&mut self, operation: &str, diagnostic: RuntimeDiagnostic) { - self.incompatible_count += 1; - self.capabilities.push(format!("{operation}:incompatible")); - self.diagnostics.push(diagnostic); - } - - fn state(&self) -> &'static str { - if self.incompatible_count > 0 { - "incompatible" - } else { - "compatible" - } - } -} - -fn remote_probe_url( - remote: &RemoteRuntimeConfigFile, - path: &str, -) -> std::result::Result { - let endpoint = remote.endpoint.trim(); - if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) { - return Err(settings_diagnostic( - "remote_runtime_endpoint_invalid", - DiagnosticSeverity::Error, - "Configured remote Runtime endpoint is not an absolute HTTP(S) URL.", - )); - } - Ok(format!("{}{}", endpoint.trim_end_matches('/'), path)) -} - -async fn probe_remote_json( - client: &reqwest::Client, - url: String, - operation: &'static str, - label: &'static str, -) -> std::result::Result { - let response = client.get(url).send().await.map_err(|error| { - let (code, message) = if error.is_timeout() { - ( - format!("{operation}.timeout"), - format!("Remote Runtime probe for {label} timed out."), - ) - } else if error.is_connect() { - ( - format!("{operation}.connect_failed"), - format!("Remote Runtime probe for {label} could not connect."), - ) - } else { - ( - format!("{operation}.request_failed"), - format!("Remote Runtime probe for {label} failed before a response was received."), - ) - }; - settings_diagnostic(code, DiagnosticSeverity::Error, message) - })?; - - if !response.status().is_success() { - return Err(settings_diagnostic( - format!("{operation}.http_status"), - DiagnosticSeverity::Error, - format!( - "Remote Runtime probe for {label} returned HTTP status {}.", - response.status().as_u16() - ), - )); - } - - response.json::().await.map_err(|_| { - settings_diagnostic( - format!("{operation}.malformed_json"), - DiagnosticSeverity::Error, - format!("Remote Runtime probe for {label} returned an unrecognized JSON payload."), - ) - }) -} - fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult { let runtimes = api .runtime @@ -24337,6 +24036,71 @@ mod tests { axum::serve(listener, proxy).await } + async fn runtime_ping_stub( + status: StatusCode, + body: serde_json::Value, + ) -> (String, tokio::task::JoinHandle<()>) { + async fn ping( + State((status, body)): State<(StatusCode, serde_json::Value)>, + headers: HeaderMap, + ) -> (StatusCode, Json) { + assert_eq!( + headers + .get(worker_runtime::http_server::RUNTIME_WORKSPACE_SCOPE_HEADER) + .and_then(|value| value.to_str().ok()), + Some(TEST_WORKSPACE_ID) + ); + assert!( + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("Bearer ")) + ); + (status, Json(body)) + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ping stub"); + let base_url = format!("http://{}", listener.local_addr().expect("ping stub addr")); + let app = Router::new() + .route("/v1/ping", axum::routing::get(ping)) + .with_state((status, body)); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve ping stub"); + }); + (base_url, server) + } + + async fn test_app_with_remote_runtime( + workspace_root: impl Into, + runtime_id: &str, + endpoint: String, + ) -> Router { + let api = test_api(workspace_root).await; + api.runtime.register_or_replace( + RemoteWorkerRuntime::new( + RemoteRuntimeConfig { + runtime_id: runtime_id.to_string(), + workspace_id: Some(TEST_WORKSPACE_ID.to_string()), + display_name: "Probe Runtime".to_string(), + base_url: endpoint, + bearer_token: Some("test-connection-token".to_string()), + auth: None, + cached_worker_creation_available: true, + cached_os: "linux".to_string(), + cached_arch: "x86_64".to_string(), + cached_status: "active".to_string(), + timeout: std::time::Duration::from_secs(2), + }, + TEST_WORKSPACE_ID.to_string(), + "http://127.0.0.1:1".to_string(), + ) + .unwrap(), + ); + build_inner_router(api) + } + async fn test_app(workspace_root: impl Into) -> Router { build_inner_router(test_api(workspace_root).await) } @@ -25575,124 +25339,157 @@ mod tests { assert_eq!(persisted.runtimes.remote.len(), 1); } - #[tokio::test(flavor = "multi_thread")] - async fn runtime_connection_test_reports_compatible_with_unknown_warnings_without_endpoint_leak() - { - let (runtime, _worker_ref) = runtime_with_worker(); - let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let runtime_addr = runtime_listener.local_addr().unwrap(); - tokio::spawn({ - let runtime = runtime.clone(); - async move { - serve_runtime_http_with_injected_test_auth(runtime, runtime_listener) - .await - .unwrap() - } - }); - - let dir = tempfile::tempdir().unwrap(); - let endpoint = format!("http://{runtime_addr}"); + fn write_test_remote_runtime(root: &std::path::Path, runtime_id: &str, endpoint: String) { BackendRuntimesConfigFile { runtimes: WorkspaceBackendRuntimesConfig { remote: vec![RemoteRuntimeConfigFile { - id: "probe-runtime".to_string(), - endpoint: endpoint.clone(), + id: runtime_id.to_string(), + endpoint, display_name: Some("Probe Runtime".to_string()), token_ref: None, }], }, } - .write_to_path(dir.path().join(".test-config/runtimes.toml")) + .write_to_path(root.join(".test-config/runtimes.toml")) .unwrap(); - let app = test_app(dir.path()).await; + } - let response = post_json( + async fn run_runtime_connection_test( + body: serde_json::Value, + status: StatusCode, + ) -> serde_json::Value { + let (endpoint, _server) = runtime_ping_stub(status, body).await; + let dir = tempfile::tempdir().unwrap(); + write_test_remote_runtime(dir.path(), "probe-runtime", endpoint.clone()); + let app = test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint).await; + post_json( app, &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"), serde_json::json!({}), ) - .await; - assert_eq!(response["state"], "compatible"); - let capabilities = response["capabilities"].as_array().unwrap(); - assert!( - capabilities - .iter() - .any(|value| value == "runtime.summary:available") - ); - assert!( - capabilities - .iter() - .any(|value| value == "workers.list:available") - ); - assert!( - capabilities - .iter() - .any(|value| value == "workers.spawn:available") - ); - assert!( - response["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| { diagnostic["code"] == "workers.spawn.available" }) - ); - let projected = serde_json::to_string(&response).unwrap(); - assert!(!projected.contains(&endpoint)); - assert!(!projected.contains(&runtime_addr.to_string())); - assert_eq!(response["protocol_version"], serde_json::Value::Null); + .await } #[tokio::test(flavor = "multi_thread")] - async fn runtime_connection_test_marks_missing_execution_backend_incompatible() { - let runtime = - worker_runtime::Runtime::with_options(worker_runtime::RuntimeOptions::default()); - let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let runtime_addr = runtime_listener.local_addr().unwrap(); - tokio::spawn(async move { - serve_runtime_http_with_injected_test_auth(runtime, runtime_listener) - .await - .unwrap() - }); - - let dir = tempfile::tempdir().unwrap(); - let endpoint = format!("http://{runtime_addr}"); - BackendRuntimesConfigFile { - runtimes: WorkspaceBackendRuntimesConfig { - remote: vec![RemoteRuntimeConfigFile { - id: "control-only-runtime".to_string(), - display_name: Some("Control-only Runtime".to_string()), - endpoint, - token_ref: None, - }], - }, - } - .write_to_path(dir.path().join(".test-config/runtimes.toml")) - .unwrap(); - let app = test_app(dir.path()).await; - - let response = post_json( - app, - &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/control-only-runtime/connection-tests"), - serde_json::json!({}), + async fn runtime_connection_test_reports_exact_compatible_protocol() { + let response = run_runtime_connection_test( + serde_json::json!({ + "runtime_id": "probe-runtime", + "protocol_version": RUNTIME_HTTP_PROTOCOL_VERSION, + }), + StatusCode::OK, ) .await; - assert_eq!(response["state"], "incompatible"); - assert!( - response["capabilities"] - .as_array() - .unwrap() - .iter() - .any(|value| { value == "workers.spawn:incompatible" }) + + assert_eq!(response["status"], "compatible"); + assert_eq!(response["failure_kind"], serde_json::Value::Null); + assert_eq!( + response["expected_protocol_version"], + RUNTIME_HTTP_PROTOCOL_VERSION ); - assert!( - response["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|diagnostic| { - diagnostic["code"] == "remote_runtime_worker_creation_unavailable" - }) + assert_eq!( + response["actual_protocol_version"], + RUNTIME_HTTP_PROTOCOL_VERSION ); + assert_eq!(response["diagnostics"], serde_json::json!([])); + let projected = serde_json::to_string(&response).unwrap(); + assert!(!projected.contains("Bearer")); + assert!(!projected.contains("public_key")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn runtime_connection_test_rejects_newer_protocol() { + let newer = RUNTIME_HTTP_PROTOCOL_MAX_VERSION + 1; + let response = run_runtime_connection_test( + serde_json::json!({ + "runtime_id": "probe-runtime", + "protocol_version": newer, + }), + StatusCode::OK, + ) + .await; + + assert_eq!(response["status"], "failed"); + assert_eq!(response["failure_kind"], "protocol_version_mismatch"); + assert_eq!(response["actual_protocol_version"], newer); + assert_eq!( + response["diagnostics"][0]["code"], + "runtime_ping_protocol_newer" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn runtime_connection_test_rejects_older_protocol() { + let older = RUNTIME_HTTP_PROTOCOL_MIN_VERSION.saturating_sub(1); + let response = run_runtime_connection_test( + serde_json::json!({ + "runtime_id": "probe-runtime", + "protocol_version": older, + }), + StatusCode::OK, + ) + .await; + + assert_eq!(response["status"], "failed"); + assert_eq!(response["failure_kind"], "protocol_version_mismatch"); + assert_eq!(response["actual_protocol_version"], older); + assert_eq!( + response["diagnostics"][0]["code"], + "runtime_ping_protocol_older" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn runtime_connection_test_classifies_authentication_failure() { + let response = run_runtime_connection_test( + serde_json::json!({"error": "credential details must not escape"}), + StatusCode::UNAUTHORIZED, + ) + .await; + + assert_eq!(response["status"], "failed"); + assert_eq!(response["failure_kind"], "authentication"); + assert_eq!(response["actual_protocol_version"], serde_json::Value::Null); + let projected = serde_json::to_string(&response).unwrap(); + assert!(!projected.contains("credential details")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn runtime_connection_test_rejects_malformed_ping_response() { + let response = run_runtime_connection_test( + serde_json::json!({ + "runtime_id": "probe-runtime", + "protocol_version": "not-a-number", + "unexpected": true, + }), + StatusCode::OK, + ) + .await; + + assert_eq!(response["status"], "failed"); + assert_eq!(response["failure_kind"], "malformed_response"); + assert_eq!( + response["diagnostics"][0]["code"], + "runtime_ping_malformed_response" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn runtime_connection_test_rejects_runtime_identity_mismatch() { + let response = run_runtime_connection_test( + serde_json::json!({ + "runtime_id": "different-runtime", + "protocol_version": RUNTIME_HTTP_PROTOCOL_VERSION, + }), + StatusCode::OK, + ) + .await; + + assert_eq!(response["status"], "failed"); + assert_eq!(response["failure_kind"], "runtime_identity_mismatch"); + assert_eq!(response["actual_protocol_version"], serde_json::Value::Null); + let projected = serde_json::to_string(&response).unwrap(); + assert!(!projected.contains("different-runtime")); } #[tokio::test] diff --git a/web/workspace/deno.json b/web/workspace/deno.json index a8b34b18..cf49316b 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 36c6c324..5d96d4ea 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -220,3 +220,27 @@ export type RepositoryLogResponse = { items: Array; diagnostics: Array; }; + +export type RuntimeConnectionTestStatus = "compatible" | "failed"; + +export type RuntimeConnectionTestFailureKind = + | "authentication" + | "authorization" + | "network_unreachable" + | "timeout" + | "tls_or_transport" + | "malformed_response" + | "protocol_version_mismatch" + | "runtime_identity_mismatch" + | "configuration"; + +export type RuntimeConnectionTestResponse = { + workspace_id: string; + runtime_id: string; + checked_at: string; + status: RuntimeConnectionTestStatus; + failure_kind: RuntimeConnectionTestFailureKind | null; + expected_protocol_version: number; + actual_protocol_version: number | null; + diagnostics: Array; +}; diff --git a/web/workspace/src/lib/workspace/api/runtime-connection.ts b/web/workspace/src/lib/workspace/api/runtime-connection.ts new file mode 100644 index 00000000..6eb58965 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/runtime-connection.ts @@ -0,0 +1,142 @@ +import type { + Diagnostic, + RuntimeConnectionTestFailureKind, + RuntimeConnectionTestResponse, +} from "$lib/generated/workspace-api"; + +const RESPONSE_KEYS = [ + "workspace_id", + "runtime_id", + "checked_at", + "status", + "failure_kind", + "expected_protocol_version", + "actual_protocol_version", + "diagnostics", +] as const; +const DIAGNOSTIC_KEYS = ["code", "severity", "message"] as const; +const FAILURE_KINDS = new Set([ + "authentication", + "authorization", + "network_unreachable", + "timeout", + "tls_or_transport", + "malformed_response", + "protocol_version_mismatch", + "runtime_identity_mismatch", + "configuration", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactKeys( + record: Record, + expected: readonly string[], +): boolean { + const actual = Object.keys(record).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && + actual.every((key, index) => key === wanted[index]); +} + +function isBoundedString(value: unknown, max = 1024): value is string { + return typeof value === "string" && value.length > 0 && value.length <= max; +} + +function isProtocolVersion(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function parseDiagnostic(value: unknown): Diagnostic | null { + if (!isRecord(value) || !hasExactKeys(value, DIAGNOSTIC_KEYS)) return null; + if (!isBoundedString(value.code, 128) || !isBoundedString(value.message)) { + return null; + } + if ( + value.severity !== "info" && value.severity !== "warning" && + value.severity !== "error" + ) { + return null; + } + return { + code: value.code, + severity: value.severity, + message: value.message, + }; +} + +export function parseRuntimeConnectionTestResponse( + value: unknown, +): RuntimeConnectionTestResponse | null { + if (!isRecord(value) || !hasExactKeys(value, RESPONSE_KEYS)) return null; + if ( + !isBoundedString(value.workspace_id, 256) || + !isBoundedString(value.runtime_id, 256) || + !isBoundedString(value.checked_at, 128) || + Number.isNaN(Date.parse(value.checked_at)) || + (value.status !== "compatible" && value.status !== "failed") || + !isProtocolVersion(value.expected_protocol_version) || + (value.actual_protocol_version !== null && + !isProtocolVersion(value.actual_protocol_version)) || + !Array.isArray(value.diagnostics) || + value.diagnostics.length > 16 + ) { + return null; + } + const failureKind = value.failure_kind; + if ( + failureKind !== null && + !FAILURE_KINDS.has(failureKind as RuntimeConnectionTestFailureKind) + ) { + return null; + } + const diagnostics = value.diagnostics.map(parseDiagnostic); + if (diagnostics.some((diagnostic) => diagnostic === null)) return null; + if ( + (value.status === "compatible" && + (failureKind !== null || + value.actual_protocol_version !== value.expected_protocol_version || + diagnostics.length !== 0)) || + (value.status === "failed" && failureKind === null) + ) { + return null; + } + return { + workspace_id: value.workspace_id, + runtime_id: value.runtime_id, + checked_at: value.checked_at, + status: value.status, + failure_kind: failureKind as RuntimeConnectionTestFailureKind | null, + expected_protocol_version: value.expected_protocol_version, + actual_protocol_version: value.actual_protocol_version, + diagnostics: diagnostics as Diagnostic[], + }; +} + +export async function testRuntimeConnection( + workspaceId: string, + runtimeId: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchImpl( + `/api/w/${encodeURIComponent(workspaceId)}/runtimes/${ + encodeURIComponent(runtimeId) + }/connection-tests`, + { method: "POST" }, + ); + if (!response.ok) { + throw new Error(`Connection test failed (${response.status})`); + } + const parsed = parseRuntimeConnectionTestResponse(await response.json()); + if (!parsed) { + throw new Error("Connection test returned an invalid response"); + } + if (parsed.workspace_id !== workspaceId || parsed.runtime_id !== runtimeId) { + throw new Error( + "Connection test response did not match the selected Runtime", + ); + } + return parsed; +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index d9e8650c..e7effcb7 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -655,6 +655,9 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async import.meta.url, ), ); + const runtimeConnectionApi = await Deno.readTextFile( + new URL("../api/runtime-connection.ts", import.meta.url), + ); const workdirsPage = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte", @@ -678,9 +681,11 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async runtimesPage.includes("Add remote Runtime") && runtimesPage.includes("Open workdirs") && runtimesPage.includes("settings-runtime-table") && - runtimesPage.includes( - "/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests", - ) && + runtimesPage.includes("testRuntimeConnection") && + runtimesPage.includes("data.workspaceId") && + runtimesPage.includes("runtime.runtime_id") && + runtimeConnectionApi.includes("/runtimes/${") && + runtimeConnectionApi.includes("}/connection-tests") && runtimesPage.includes( "/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs", ), diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index 0a1d1b82..ded7886b 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -339,6 +339,9 @@ background: rgba(255, 255, 255, 0.04); padding: 0.75rem; } + .settings-test-result.failed { + border-inline-start: 3px solid var(--danger); + } .settings-page { display: grid; gap: var(--space-5); 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 f1e3e25d..4e9380b0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte @@ -1,20 +1,11 @@