Merge commit '5e9f7a7dc3d1169118412376f16b72850aab63f2' into work/T-593-workspace-runtime-bindings

# Conflicts:
#	crates/workspace-server/src/server.rs
This commit is contained in:
2026-09-06 03:46:09 +09:00
11 changed files with 1067 additions and 589 deletions
+136 -1
View File
@@ -33,7 +33,7 @@ use axum::extract::rejection::{JsonRejection, QueryRejection};
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{DefaultBodyLimit, Extension, Path, Query, State}; 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::middleware::{self, Next};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post}; use axum::routing::{delete, get, post};
@@ -66,6 +66,11 @@ use workdir::{
}; };
const DEFAULT_RUNTIME_HTTP_PORT: u16 = 38800; 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 { fn default_runtime_http_bind_addr() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], DEFAULT_RUNTIME_HTTP_PORT)) 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() let router = Router::new()
.route("/v1/ping", get(get_runtime_ping))
.route("/v1/runtime", get(get_runtime)) .route("/v1/runtime", get(get_runtime))
.route( .route(
"/v1/config-bundles", "/v1/config-bundles",
@@ -340,6 +346,14 @@ enum RuntimeHttpWorkerStatusFilter {
Stopped, 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. /// `GET /v1/workers` response.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkersResponse { pub struct RuntimeHttpWorkersResponse {
@@ -461,6 +475,48 @@ struct RuntimeWorkerEventsWsQuery {
type RestResult<T> = Result<Json<T>, RuntimeHttpRestError>; type RestResult<T> = Result<Json<T>, RuntimeHttpRestError>;
async fn get_runtime_ping(
State(state): State<RuntimeHttpState>,
Extension(auth): Extension<RuntimeAuthContext>,
headers: HeaderMap,
) -> RestResult<RuntimeHttpPingResponse> {
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( async fn get_runtime(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
) -> RestResult<RuntimeHttpSummaryResponse> { ) -> RestResult<RuntimeHttpSummaryResponse> {
@@ -1767,6 +1823,9 @@ fn auth_workspace_scope(
} }
fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static str> { 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" { if path == "/v1/runtime" {
return None; return None;
} }
@@ -2084,6 +2143,82 @@ mod tests {
WorkdirPath, WorkdirSessionCapabilities, 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::<RuntimeHttpPingResponse>(&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] #[test]
fn attachment_routes_require_worker_input_permission() { fn attachment_routes_require_worker_input_permission() {
assert_eq!( assert_eq!(
+56 -6
View File
@@ -1205,16 +1205,39 @@ pub struct CreateRemoteRuntimeRequest {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[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 struct RuntimeConnectionTestResponse {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String, pub runtime_id: String,
pub checked_at: String, pub checked_at: String,
pub state: String, pub status: RuntimeConnectionTestStatus,
pub protocol_version: Option<String>, pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
pub compatibility_basis: String, pub expected_protocol_version: u32,
#[serde(default)] pub actual_protocol_version: Option<u32>,
pub capabilities: Vec<String>,
pub health_result: String,
#[serde(default)] #[serde(default)]
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
@@ -2371,6 +2394,9 @@ pub fn catalog_typescript() -> String {
RepositoryListResponse::decl(&config), RepositoryListResponse::decl(&config),
RepositoryDetailResponse::decl(&config), RepositoryDetailResponse::decl(&config),
RepositoryLogResponse::decl(&config), RepositoryLogResponse::decl(&config),
RuntimeConnectionTestStatus::decl(&config),
RuntimeConnectionTestFailureKind::decl(&config),
RuntimeConnectionTestResponse::decl(&config),
] ]
.map(|declaration| format!("export {declaration}")); .map(|declaration| format!("export {declaration}"));
@@ -3060,6 +3086,27 @@ mod tests {
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err()); assert!(serde_json::from_value::<RepositoryListResponse>(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::<RuntimeConnectionTestResponse>(unknown).is_err());
}
#[cfg(feature = "typescript")] #[cfg(feature = "typescript")]
#[test] #[test]
fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() { fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() {
@@ -3080,6 +3127,9 @@ mod tests {
assert!(output.contains( assert!(output.contains(
"export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\"" "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")); assert!(!output.contains("repository_key: string, display_name"));
} }
+215 -9
View File
@@ -9,7 +9,9 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{ use std::{
error::Error as _,
future::Future, future::Future,
io::Read as _,
path::PathBuf, path::PathBuf,
pin::Pin, pin::Pin,
sync::{Arc, RwLock}, sync::{Arc, RwLock},
@@ -38,13 +40,15 @@ use worker_runtime::error::RuntimeError as EmbeddedRuntimeError;
use worker_runtime::execution::WorkerExecutionRunState; use worker_runtime::execution::WorkerExecutionRunState;
use worker_runtime::fs_store::FsRuntimeStoreOptions; use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{ use worker_runtime::http_server::{
RUNTIME_PING_PERMISSION, RUNTIME_WORKSPACE_SCOPE_HEADER,
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest, RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse, RuntimeHttpErrorResponse, RuntimeHttpPingResponse, RuntimeHttpRepositoryAccessResponse,
RuntimeHttpUploadedFileDeleteResponse, RuntimeHttpUploadedFileResponse, RuntimeHttpSummaryResponse, RuntimeHttpUploadedFileDeleteResponse,
RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse, RuntimeHttpUploadedFileResponse, RuntimeHttpWorkerCompletionsRequest,
RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse, RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse, RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse, RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
}; };
@@ -64,6 +68,7 @@ pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host"; const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host"; const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host";
const MAX_DIAGNOSTICS: usize = 16; const MAX_DIAGNOSTICS: usize = 16;
const MAX_RUNTIME_PING_RESPONSE_BYTES: usize = 8 * 1024;
const MAX_HOST_SCAN: usize = 256; const MAX_HOST_SCAN: usize = 256;
const MAX_IDENTIFIER_LEN: usize = 120; const MAX_IDENTIFIER_LEN: usize = 120;
const ID_DIGEST_HEX_LEN: usize = 16; const ID_DIGEST_HEX_LEN: usize = 16;
@@ -760,11 +765,50 @@ fn default_worker_input_kind() -> WorkerInputKind {
WorkerInputKind::User 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<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
diagnostic: diagnostic(code, DiagnosticSeverity::Error, message.into()),
}
}
}
pub trait WorkspaceWorkerRuntime: Send + Sync { pub trait WorkspaceWorkerRuntime: Send + Sync {
fn runtime_id(&self) -> &str; fn runtime_id(&self) -> &str;
fn runtime_summary(&self, limit: usize) -> RuntimeSummary; fn runtime_summary(&self, limit: usize) -> RuntimeSummary;
fn ping(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
Err(RuntimePingFailure::new(
RuntimePingFailureKind::Unsupported,
"runtime_ping_unsupported",
"Runtime connection testing is unavailable for this Runtime provider",
))
}
fn list_hosts(&self, limit: usize) -> RuntimeList<HostSummary>; fn list_hosts(&self, limit: usize) -> RuntimeList<HostSummary>;
fn list_workers(&self, limit: usize) -> RuntimeList<WorkerSummary>; fn list_workers(&self, limit: usize) -> RuntimeList<WorkerSummary>;
@@ -1811,6 +1855,17 @@ impl RuntimeRegistry {
}) })
} }
pub fn ping(&self, runtime_id: &str) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
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<Arc<dyn WorkspaceWorkerRuntime>> { fn runtimes_snapshot(&self) -> Vec<Arc<dyn WorkspaceWorkerRuntime>> {
self.runtimes self.runtimes
.read() .read()
@@ -2927,6 +2982,49 @@ pub struct RemoteWorkerRuntime {
async_http: AsyncHttpClient, 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<String> { fn all_remote_runtime_permissions() -> Vec<String> {
[ [
"workers:list", "workers:list",
@@ -3075,14 +3173,18 @@ impl RemoteWorkerRuntime {
self.send_json(path, self.http.delete(self.endpoint(path))) self.send_json(path, self.http.delete(self.endpoint(path)))
} }
fn runtime_capability_token(&self, path: &str) -> Option<String> { fn runtime_capability_token_with_permissions(
&self,
path: &str,
permissions: Vec<String>,
) -> Option<String> {
let auth = self.auth.as_ref()?; let auth = self.auth.as_ref()?;
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key); let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key);
let claims = capability_claims( let claims = capability_claims(
&auth.server_id, &auth.server_id,
&self.runtime_id, &self.runtime_id,
&self.workspace_id, &self.workspace_id,
all_remote_runtime_permissions(), permissions,
300, 300,
) )
.map_err(|error| { .map_err(|error| {
@@ -3105,6 +3207,82 @@ impl RemoteWorkerRuntime {
.ok() .ok()
} }
fn runtime_capability_token(&self, path: &str) -> Option<String> {
self.runtime_capability_token_with_permissions(path, all_remote_runtime_permissions())
}
fn ping_http(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
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::<RuntimeHttpPingResponse>(&body).map_err(|_| {
RuntimePingFailure::new(
RuntimePingFailureKind::MalformedResponse,
"runtime_ping_malformed_response",
"Runtime ping returned an unrecognized response",
)
})
})
}
fn send_json<T>(&self, path: &str, request: RequestBuilder) -> Result<T, RuntimeDiagnostic> fn send_json<T>(&self, path: &str, request: RequestBuilder) -> Result<T, RuntimeDiagnostic>
where where
T: DeserializeOwned + Send + 'static, T: DeserializeOwned + Send + 'static,
@@ -3292,6 +3470,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} }
} }
fn ping(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
self.ping_http()
}
fn list_hosts(&self, limit: usize) -> RuntimeList<HostSummary> { fn list_hosts(&self, limit: usize) -> RuntimeList<HostSummary> {
if limit == 0 { if limit == 0 {
return RuntimeList::new(Vec::new(), Vec::new()); return RuntimeList::new(Vec::new(), Vec::new());
@@ -4588,7 +4770,7 @@ mod tests {
use super::*; use super::*;
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Read as _, Write as _}; use std::io::Write as _;
use std::net::TcpListener; use std::net::TcpListener;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
@@ -5791,6 +5973,30 @@ mod tests {
assert_eq!(runtime.runtime_id(), "remote:async-init"); 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] #[test]
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() { fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string(); let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
+349 -543
View File
@@ -53,6 +53,10 @@ use workdir::workspace::{
}; };
use workdir::{CommandHandle, WorkdirSessionHandle}; use workdir::{CommandHandle, WorkdirSessionHandle};
use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef}; 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::resource::{BackendResourceError, BackendResourceFetchRequest};
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use workspace_api::{ use workspace_api::{
@@ -72,7 +76,8 @@ use workspace_api::{
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest, PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse, RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor,
RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary, RotateRepositorySshCredentialRequest, RuntimeConnectionTestFailureKind,
RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, RuntimeManagementSummary,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse, UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse,
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
@@ -106,14 +111,14 @@ use crate::config_source::ConfigCommitRequest;
use crate::hosts::{ use crate::hosts::{
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID, ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime, EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime,
RuntimeDiagnostic, RuntimeRegistry, RuntimeRegistryError, RuntimeRegistryUnregisterResult, RuntimeDiagnostic, RuntimePingFailureKind, RuntimeRegistry, RuntimeRegistryError,
TicketWorkerRole, WorkerCapabilitySummary, WorkerCompletionsRequest, WorkerCompletionsResult, RuntimeRegistryUnregisterResult, TicketWorkerRole, WorkerCapabilitySummary,
WorkerControlOperation, WorkerCreateBinding, WorkerImplementationSummary, WorkerInputKind, WorkerCompletionsRequest, WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding,
WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest, WorkerLifecycleResult, WorkerImplementationSummary, WorkerInputKind, WorkerInputRequest, WorkerInputResult,
WorkerOperationState, WorkerRestoreResult, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState, WorkerRestoreResult,
WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
WorkerTicketAssignmentRequest, WorkerWorkspaceSummary, worker_spawn_create_fingerprint, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest,
workspace_worker_summary, WorkerWorkspaceSummary, worker_spawn_create_fingerprint, workspace_worker_summary,
}; };
use crate::identity::WorkspaceIdentity; use crate::identity::WorkspaceIdentity;
use crate::memory_backend::execute_memory_backend_operation_with_authority; use crate::memory_backend::execute_memory_backend_operation_with_authority;
@@ -148,7 +153,7 @@ use crate::store::{
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord, RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord, TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
WorkspaceResourceKind, WorkspaceRuntimeBinding, WorkspaceResourceKind,
}; };
use crate::workdir_removal::{ use crate::workdir_removal::{
WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation, WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation,
@@ -163,11 +168,7 @@ use worker_runtime::catalog::{
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef, WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
}; };
use worker_runtime::config_bundle::ConfigBundle; use worker_runtime::config_bundle::ConfigBundle;
use worker_runtime::http_server::{ use worker_runtime::http_server::MAX_WORKER_FILE_UPLOAD_BYTES;
MAX_WORKER_FILE_UPLOAD_BYTES, RuntimeHttpConfigBundleAvailabilityResponse,
RuntimeHttpConfigBundlesResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse,
RuntimeHttpWorkersResponse,
};
use worker_runtime::identity::{RuntimeWorkerRef, WorkerId}; use worker_runtime::identity::{RuntimeWorkerRef, WorkerId};
const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime";
@@ -12513,13 +12514,37 @@ async fn test_runtime_connection(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>, AxumPath(runtime_id): AxumPath<String>,
) -> ApiResult<Json<RuntimeConnectionTestResponse>> { ) -> ApiResult<Json<RuntimeConnectionTestResponse>> {
let binding = api let runtime_id = runtime_id.trim().to_string();
.store if runtime_id.is_empty() {
.get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id) return Err(Error::InvalidRuntimeIdentifier {
kind: "runtime".to_string(),
value: runtime_id,
}
.into());
}
api.store
.get_workspace_runtime_binding(api.workspace_id(), &runtime_id)
.await? .await?
.filter(|binding| binding.revoked_at.is_none()) .filter(|binding| binding.revoked_at.is_none())
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
Ok(Json(test_remote_runtime_binding(&api, &binding).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( async fn get_worker_launch_options(
@@ -14520,415 +14545,111 @@ fn remote_runtime_config_from_binding(
Ok(remote) Ok(remote)
} }
async fn test_remote_runtime_binding( fn runtime_connection_test_response(
api: &WorkspaceApi, workspace_id: &str,
remote: &WorkspaceRuntimeBinding, runtime_id: &str,
) -> RuntimeConnectionTestResponse {
let checked_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
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::<RuntimeHttpSummaryResponse>(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::<RuntimeHttpWorkersResponse>(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::<RuntimeHttpWorkerResponse>(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::<RuntimeHttpConfigBundlesResponse>(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::<RuntimeHttpConfigBundleAvailabilityResponse>(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.runtime_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: &WorkspaceRuntimeBinding,
checked_at: String, checked_at: String,
code: impl Into<String>, ping: std::result::Result<
message: impl Into<String>, 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<u32>,
diagnostic: RuntimeDiagnostic,
) -> RuntimeConnectionTestResponse { ) -> RuntimeConnectionTestResponse {
RuntimeConnectionTestResponse { RuntimeConnectionTestResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: workspace_id.to_string(),
runtime_id: remote.runtime_id.clone(), runtime_id: runtime_id.to_string(),
checked_at, checked_at,
state: "failed".to_string(), status: RuntimeConnectionTestStatus::Failed,
protocol_version: None, failure_kind: Some(failure_kind),
compatibility_basis: "worker-runtime lightweight HTTP compatibility probes".to_string(), expected_protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION,
capabilities: Vec::new(), actual_protocol_version,
health_result: "failed".to_string(), diagnostics: vec![diagnostic.into()],
diagnostics: vec![settings_diagnostic(code, DiagnosticSeverity::Error, message).into()],
} }
} }
#[derive(Default)]
struct RuntimeCompatibilityObservation {
capabilities: Vec<String>,
diagnostics: Vec<RuntimeDiagnostic>,
available_count: usize,
incompatible_count: usize,
unknown_count: usize,
}
impl RuntimeCompatibilityObservation {
fn available(&mut self, operation: &str, message: impl Into<String>) {
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<String>) {
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: &WorkspaceRuntimeBinding,
path: &str,
) -> std::result::Result<String, RuntimeDiagnostic> {
let endpoint = remote.base_url.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<serde_json::Value, RuntimeDiagnostic> {
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::<serde_json::Value>().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<WorkerLaunchOptionsResponse> { fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult<WorkerLaunchOptionsResponse> {
let runtimes = api let runtimes = api
.runtime .runtime
@@ -24314,6 +24035,90 @@ mod tests {
axum::serve(listener, proxy).await 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<serde_json::Value>) {
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<PathBuf>,
runtime_id: &str,
endpoint: String,
) -> Router {
let api = test_api(workspace_root).await;
api.store
.upsert_workspace_runtime_binding_record(
WorkspaceRuntimeBinding {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: runtime_id.to_string(),
display_name: "Probe Runtime".to_string(),
base_url: endpoint.clone(),
public_key: RuntimeIdentityMaterial::generate(runtime_id)
.unwrap()
.public_key,
public_key_fingerprint: String::new(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
revoked_at: None,
},
false,
)
.await
.unwrap();
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<PathBuf>) -> Router { async fn test_app(workspace_root: impl Into<PathBuf>) -> Router {
build_inner_router(test_api(workspace_root).await) build_inner_router(test_api(workspace_root).await)
} }
@@ -25599,140 +25404,141 @@ mod tests {
); );
} }
#[tokio::test(flavor = "multi_thread")] async fn run_runtime_connection_test(
async fn runtime_connection_test_reports_compatible_with_unknown_warnings_without_endpoint_leak() body: serde_json::Value,
{ status: StatusCode,
let (runtime, _worker_ref) = runtime_with_worker(); ) -> serde_json::Value {
let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let (endpoint, _server) = runtime_ping_stub(status, body).await;
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 dir = tempfile::tempdir().unwrap();
let endpoint = format!("http://{runtime_addr}"); let app = test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint).await;
let api = test_api(dir.path()).await; post_json(
api.store
.upsert_workspace_runtime_binding_record(
WorkspaceRuntimeBinding {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "probe-runtime".to_string(),
display_name: "Probe Runtime".to_string(),
base_url: endpoint.clone(),
public_key: RuntimeIdentityMaterial::generate("probe-runtime")
.unwrap()
.public_key,
public_key_fingerprint: String::new(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
revoked_at: None,
},
false,
)
.await
.unwrap();
let app = build_inner_router(api);
let response = post_json(
app, app,
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"), &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"),
serde_json::json!({}), serde_json::json!({}),
) )
.await; .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);
} }
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn runtime_connection_test_marks_missing_execution_backend_incompatible() { async fn runtime_connection_test_reports_exact_compatible_protocol() {
let runtime = let response = run_runtime_connection_test(
worker_runtime::Runtime::with_options(worker_runtime::RuntimeOptions::default()); serde_json::json!({
let runtime_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); "runtime_id": "probe-runtime",
let runtime_addr = runtime_listener.local_addr().unwrap(); "protocol_version": RUNTIME_HTTP_PROTOCOL_VERSION,
tokio::spawn(async move { }),
serve_runtime_http_with_injected_test_auth(runtime, runtime_listener) StatusCode::OK,
.await
.unwrap()
});
let dir = tempfile::tempdir().unwrap();
let endpoint = format!("http://{runtime_addr}");
let api = test_api(dir.path()).await;
api.store
.upsert_workspace_runtime_binding_record(
WorkspaceRuntimeBinding {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "control-only-runtime".to_string(),
display_name: "Control-only Runtime".to_string(),
base_url: endpoint,
public_key: RuntimeIdentityMaterial::generate("control-only-runtime")
.unwrap()
.public_key,
public_key_fingerprint: String::new(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
revoked_at: None,
},
false,
)
.await
.unwrap();
let app = build_inner_router(api);
let response = post_json(
app,
&format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/control-only-runtime/connection-tests"),
serde_json::json!({}),
) )
.await; .await;
assert_eq!(response["state"], "incompatible");
assert!( assert_eq!(response["status"], "compatible");
response["capabilities"] assert_eq!(response["failure_kind"], serde_json::Value::Null);
.as_array() assert_eq!(
.unwrap() response["expected_protocol_version"],
.iter() RUNTIME_HTTP_PROTOCOL_VERSION
.any(|value| { value == "workers.spawn:incompatible" })
); );
assert!( assert_eq!(
response["diagnostics"] response["actual_protocol_version"],
.as_array() RUNTIME_HTTP_PROTOCOL_VERSION
.unwrap()
.iter()
.any(|diagnostic| {
diagnostic["code"] == "remote_runtime_worker_creation_unavailable"
})
); );
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] #[tokio::test]
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "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", "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", "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", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -220,3 +220,27 @@ export type RepositoryLogResponse = {
items: Array<GitCommitSummary>; items: Array<GitCommitSummary>;
diagnostics: Array<Diagnostic>; diagnostics: Array<Diagnostic>;
}; };
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<Diagnostic>;
};
@@ -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<RuntimeConnectionTestFailureKind>([
"authentication",
"authorization",
"network_unreachable",
"timeout",
"tls_or_transport",
"malformed_response",
"protocol_version_mismatch",
"runtime_identity_mismatch",
"configuration",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasExactKeys(
record: Record<string, unknown>,
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<RuntimeConnectionTestResponse> {
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;
}
@@ -655,6 +655,9 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
import.meta.url, import.meta.url,
), ),
); );
const runtimeConnectionApi = await Deno.readTextFile(
new URL("../api/runtime-connection.ts", import.meta.url),
);
const workdirsPage = await Deno.readTextFile( const workdirsPage = await Deno.readTextFile(
new URL( new URL(
"./../../../routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte", "./../../../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("Add remote Runtime") &&
runtimesPage.includes("Open workdirs") && runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("settings-runtime-table") && runtimesPage.includes("settings-runtime-table") &&
runtimesPage.includes( runtimesPage.includes("testRuntimeConnection") &&
"/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests", runtimesPage.includes("data.workspaceId") &&
) && runtimesPage.includes("runtime.runtime_id") &&
runtimeConnectionApi.includes("/runtimes/${") &&
runtimeConnectionApi.includes("}/connection-tests") &&
runtimesPage.includes( runtimesPage.includes(
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs", "/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}/workdirs",
), ),
@@ -339,6 +339,9 @@
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
padding: 0.75rem; padding: 0.75rem;
} }
.settings-test-result.failed {
border-inline-start: 3px solid var(--danger);
}
.settings-page { .settings-page {
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-5);
@@ -1,20 +1,11 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import type { RuntimeConnectionTestResponse } from '$lib/generated/workspace-api';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
import { workspaceApiPath } from '$lib/workspace/api/http'; import { workspaceApiPath } from '$lib/workspace/api/http';
import type { Diagnostic, Runtime } from '$lib/workspace/sidebar/types'; import type { Runtime } from '$lib/workspace/sidebar/types';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
type ConnectionTest = {
runtime_id: string;
checked_at: string;
state: string;
protocol_version?: string | null;
compatibility_basis: string;
capabilities: string[];
health_result: string;
diagnostics: Diagnostic[];
};
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let runtimeId = $state(''); let runtimeId = $state('');
let displayName = $state(''); let displayName = $state('');
@@ -22,12 +13,31 @@
let showAddRuntime = $state(false); let showAddRuntime = $state(false);
let busyRuntimeId = $state<string | null>(null); let busyRuntimeId = $state<string | null>(null);
let requestError = $state<string | null>(null); let requestError = $state<string | null>(null);
let testResults = $state<Record<string, ConnectionTest>>({}); let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
function runtimePlatform(runtime: Runtime): string { function runtimePlatform(runtime: Runtime): string {
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown'; return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
} }
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
if (result.status === 'compatible') {
return `Compatible · protocol v${result.actual_protocol_version}`;
}
switch (result.failure_kind) {
case 'authentication': return 'Authentication failed';
case 'authorization': return 'Permission or Workspace scope rejected';
case 'network_unreachable': return 'Runtime unreachable';
case 'timeout': return 'Connection timed out';
case 'tls_or_transport': return 'TLS or transport failed';
case 'malformed_response': return 'Runtime returned an invalid ping response';
case 'protocol_version_mismatch':
return `Incompatible protocol · expected v${result.expected_protocol_version}, received v${result.actual_protocol_version ?? 'unknown'}`;
case 'runtime_identity_mismatch': return 'Runtime identity mismatch';
case 'configuration': return 'Runtime connection test is not configured';
default: return 'Connection test failed';
}
}
function managementLabel(runtime: Runtime): string { function managementLabel(runtime: Runtime): string {
if (runtime.management?.built_in) return 'Built-in'; if (runtime.management?.built_in) return 'Built-in';
if (runtime.management?.config_managed) return 'Managed remote'; if (runtime.management?.config_managed) return 'Managed remote';
@@ -92,15 +102,7 @@
requestError = null; requestError = null;
busyRuntimeId = runtime.runtime_id; busyRuntimeId = runtime.runtime_id;
try { try {
const response = await fetch( const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
workspaceApiPath(
data.workspaceId,
`/runtimes/${encodeURIComponent(runtime.runtime_id)}/connection-tests`,
),
{ method: 'POST' },
);
if (!response.ok) throw new Error(await responseError(response));
const result = await response.json() as ConnectionTest;
testResults = { ...testResults, [runtime.runtime_id]: result }; testResults = { ...testResults, [runtime.runtime_id]: result };
} catch (error) { } catch (error) {
requestError = error instanceof Error ? error.message : String(error); requestError = error instanceof Error ? error.message : String(error);
@@ -229,10 +231,12 @@
{/if} {/if}
{#if testResults[runtime.runtime_id]} {#if testResults[runtime.runtime_id]}
{@const result = testResults[runtime.runtime_id]} {@const result = testResults[runtime.runtime_id]}
<div class="settings-test-result"> <div class:failed={result.status === 'failed'} class="settings-test-result">
<strong>Connection test: {result.state}</strong> <strong>Connection test: {connectionTestSummary(result)}</strong>
<span>{result.health_result}</span> {#if result.diagnostics[0]}
<small>{result.compatibility_basis} · {result.checked_at}</small> <span>{result.diagnostics[0].message}</span>
{/if}
<small>Checked {new Date(result.checked_at).toLocaleString()}</small>
</div> </div>
{/if} {/if}
</td> </td>
@@ -0,0 +1,103 @@
declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import {
parseRuntimeConnectionTestResponse,
testRuntimeConnection,
} from "../src/lib/workspace/api/runtime-connection.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
);
}
}
function compatibleResponse(): Record<string, unknown> {
return {
workspace_id: "workspace-a",
runtime_id: "runtime-a",
checked_at: "2026-09-01T12:00:00Z",
status: "compatible",
failure_kind: null,
expected_protocol_version: 1,
actual_protocol_version: 1,
diagnostics: [],
};
}
Deno.test("runtime connection response accepts the exact compatible contract", () => {
assertEquals(
parseRuntimeConnectionTestResponse(compatibleResponse()),
compatibleResponse(),
);
});
Deno.test("runtime connection response rejects unknown fields and incoherent compatibility", () => {
assertEquals(
parseRuntimeConnectionTestResponse({
...compatibleResponse(),
capabilities: ["shell"],
}),
null,
);
assertEquals(
parseRuntimeConnectionTestResponse({
...compatibleResponse(),
actual_protocol_version: 2,
}),
null,
);
assertEquals(
parseRuntimeConnectionTestResponse({
...compatibleResponse(),
failure_kind: "timeout",
}),
null,
);
});
Deno.test("runtime connection response rejects unknown failure kinds and unbounded diagnostics", () => {
const failed = {
...compatibleResponse(),
status: "failed",
failure_kind: "future_failure",
actual_protocol_version: null,
diagnostics: [],
};
assertEquals(parseRuntimeConnectionTestResponse(failed), null);
assertEquals(
parseRuntimeConnectionTestResponse({
...failed,
failure_kind: "timeout",
diagnostics: Array.from({ length: 17 }, () => ({
code: "timeout",
severity: "error",
message: "Timed out",
})),
}),
null,
);
});
Deno.test("runtime connection request rejects a mismatched response identity", async () => {
const fetchImpl = (() =>
Promise.resolve(
new Response(
JSON.stringify({ ...compatibleResponse(), runtime_id: "runtime-b" }),
{ status: 200, headers: { "content-type": "application/json" } },
),
)) as typeof fetch;
let message = "";
try {
await testRuntimeConnection("workspace-a", "runtime-a", fetchImpl);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
assertEquals(
message,
"Connection test response did not match the selected Runtime",
);
});