diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 13d7c330..a4e3850d 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -1597,11 +1597,51 @@ pub enum WorkspaceRuntimeAuthenticationMode { WorkspaceIdentity, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeConnectionDisplayState { + Configured, + Verified, + Unavailable, + Revoked, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RuntimeVerificationOutcome { + Verified, + ChallengeIssued, + VerificationFailed, + ConnectivityFailed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RuntimeVerificationEvidenceSummary { + pub verified_at: Option, + pub last_checked_at: String, + pub last_outcome: RuntimeVerificationOutcome, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub binding_revision: u64, + pub workspace_key_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub workspace_identity_revision: u64, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub workspace_trust_generation: u64, + pub runtime_public_key_fingerprint: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub runtime_identity_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WorkspaceRuntimeBindingSummary { pub state: WorkspaceRuntimeBindingState, + pub connection_state: RuntimeConnectionDisplayState, pub authentication_mode: WorkspaceRuntimeAuthenticationMode, #[cfg_attr(feature = "typescript", ts(type = "number"))] pub revision: u64, @@ -1610,6 +1650,8 @@ pub struct WorkspaceRuntimeBindingSummary { #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(type = "number | null"))] pub workspace_key_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verification: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1794,6 +1836,10 @@ pub enum RuntimeConnectionTestFailureKind { pub struct RuntimeConnectionTestResponse { pub workspace_id: String, pub runtime_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub binding_revision: u64, + pub connection_state: RuntimeConnectionDisplayState, + pub verification: Option, pub checked_at: String, pub status: RuntimeConnectionTestStatus, pub failure_kind: Option, @@ -2977,6 +3023,9 @@ pub fn catalog_typescript() -> String { RuntimeSourceSummary::decl(&config), RuntimeSummary::decl(&config), WorkspaceRuntimeBindingState::decl(&config), + RuntimeConnectionDisplayState::decl(&config), + RuntimeVerificationOutcome::decl(&config), + RuntimeVerificationEvidenceSummary::decl(&config), WorkspaceRuntimeAuthenticationMode::decl(&config), WorkspaceRuntimeBindingSummary::decl(&config), RuntimeManagementSummary::decl(&config), @@ -3842,6 +3891,9 @@ mod tests { let compatible = serde_json::json!({ "workspace_id": "workspace-test", "runtime_id": "runtime-test", + "binding_revision": 3, + "connection_state": "verified", + "verification": null, "checked_at": "2026-09-01T12:00:00Z", "status": "compatible", "failure_kind": null, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a99d465e..d1c74fb9 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -85,13 +85,13 @@ use workspace_api::{ PutRuntimeTrustKeyRequest, RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest, - RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, - RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry, - RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyRevealResponse, - RuntimeTrustKeyState, RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, UpdateWorkspaceMetadataRequest, WhoamiResponse, - WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, - WorkerLaunchWorkerSummary, + RuntimeConnectionDisplayState, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, + RuntimeConnectionTestStatus, RuntimeManagementSummary, RuntimeTrustAuditAction, + RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse, + RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus, + TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, + UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse, + WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, @@ -12054,16 +12054,24 @@ async fn scoped_delete_remote_runtime( async fn scoped_test_runtime_connection( State(api): State, AxumPath(path): AxumPath, + Extension(actor): Extension, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; + require_workspace_owner( + &api, + &path.workspace_id, + &actor, + "Runtime connection verification", + ) + .await?; let binding = api .store .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) .await? .ok_or_else(|| Error::UnknownRuntime(path.runtime_id.clone()))?; - if binding.state != StoredRuntimeBindingState::Verified { + if binding.state == StoredRuntimeBindingState::Revoked || binding.revoked_at.is_some() { return Err(Error::RuntimeBindingConflict(format!( - "Runtime `{}` is not an authenticated verified connection candidate", + "Runtime `{}` binding is revoked", path.runtime_id )) .into()); @@ -13930,16 +13938,18 @@ async fn test_runtime_connection( .get_workspace_runtime_verification(api.workspace_id(), &runtime_id) .await { - evidence.state = "failed".to_string(); + if evidence.state != "verified" { + evidence.state = "failed".to_string(); + evidence.verified_at = None; + } evidence.last_outcome = "verification_failed".to_string(); - evidence.verified_at = None; evidence.checked_at = Utc::now().to_rfc3339(); let _ = api .store .record_workspace_runtime_verification_attempt(&evidence) .await; } - return Ok(Json(runtime_connection_test_failure( + let mut result = runtime_connection_test_failure( api.workspace_id(), &runtime_id, Utc::now().to_rfc3339(), @@ -13950,7 +13960,10 @@ async fn test_runtime_connection( "error", message, ), - ))); + ); + result.binding_revision = binding.binding_revision; + result.connection_state = RuntimeConnectionDisplayState::Unavailable; + return Ok(Json(result)); } } } @@ -13966,12 +13979,41 @@ async fn test_runtime_connection( message: "Runtime connection test could not be completed".to_string(), })?; - Ok(Json(runtime_connection_test_response( - api.workspace_id(), - &runtime_id, - checked_at, - ping, - ))) + if ping.is_err() + && binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity + && let Some(mut evidence) = api + .store + .get_workspace_runtime_verification(api.workspace_id(), &runtime_id) + .await? + { + evidence.last_outcome = "connectivity_failed".to_string(); + evidence.checked_at = Utc::now().to_rfc3339(); + api.store + .record_workspace_runtime_verification_attempt(&evidence) + .await?; + } + let current_binding = api + .store + .get_workspace_runtime_binding(api.workspace_id(), &runtime_id) + .await? + .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + let verification = api + .store + .get_workspace_runtime_verification(api.workspace_id(), &runtime_id) + .await?; + let summary = runtime_binding_summary(¤t_binding, verification.as_ref()); + let mut result = + runtime_connection_test_response(api.workspace_id(), &runtime_id, checked_at, ping); + result.binding_revision = current_binding.binding_revision; + result.connection_state = if result.status == RuntimeConnectionTestStatus::Compatible { + summary.connection_state + } else if summary.connection_state == RuntimeConnectionDisplayState::Revoked { + RuntimeConnectionDisplayState::Revoked + } else { + RuntimeConnectionDisplayState::Unavailable + }; + result.verification = summary.verification; + Ok(Json(result)) } async fn get_worker_launch_options( @@ -16070,6 +16112,16 @@ async fn workspace_runtime_resources_response( .store .list_workspace_runtime_bindings(workspace_id, true) .await?; + let mut verifications = HashMap::new(); + for binding in &bindings { + if let Some(verification) = api + .store + .get_workspace_runtime_verification(workspace_id, &binding.runtime_id) + .await? + { + verifications.insert(binding.runtime_id.clone(), verification); + } + } let mut items = runtimes .items .into_iter() @@ -16091,7 +16143,9 @@ async fn workspace_runtime_resources_response( endpoint_configured: binding .is_some_and(|binding| !binding.base_url.trim().is_empty()), token_ref_configured: false, - binding: binding.map(runtime_binding_summary), + binding: binding.map(|binding| { + runtime_binding_summary(binding, verifications.get(&binding.runtime_id)) + }), }, } }) @@ -16137,7 +16191,10 @@ async fn workspace_runtime_resources_response( removable: true, endpoint_configured: !binding.base_url.trim().is_empty(), token_ref_configured: false, - binding: Some(runtime_binding_summary(&binding)), + binding: Some(runtime_binding_summary( + binding, + verifications.get(&binding.runtime_id), + )), }, }); } @@ -16151,13 +16208,38 @@ async fn workspace_runtime_resources_response( }) } -fn runtime_binding_summary(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeBindingSummary { +fn runtime_binding_summary( + binding: &WorkspaceRuntimeBinding, + verification: Option<&crate::store::WorkspaceRuntimeVerificationEvidence>, +) -> WorkspaceRuntimeBindingSummary { + let valid_verification = verification.filter(|verification| { + verification.state == "verified" + && verification.binding_revision == binding.binding_revision + && verification.runtime_public_key_fingerprint == binding.public_key_fingerprint + && verification.workspace_key_id + == binding.workspace_key_id.as_deref().unwrap_or_default() + }); + let connection_state = match binding.state { + StoredRuntimeBindingState::Revoked => RuntimeConnectionDisplayState::Revoked, + _ if verification.is_some_and(|verification| verification.last_outcome != "verified") => { + RuntimeConnectionDisplayState::Unavailable + } + StoredRuntimeBindingState::Verified + if binding.authentication_mode + == StoredRuntimeAuthenticationMode::LegacyServerIssuer + || valid_verification.is_some() => + { + RuntimeConnectionDisplayState::Verified + } + _ => RuntimeConnectionDisplayState::Configured, + }; WorkspaceRuntimeBindingSummary { state: match binding.state { StoredRuntimeBindingState::Configured => WorkspaceRuntimeBindingState::Configured, StoredRuntimeBindingState::Verified => WorkspaceRuntimeBindingState::Verified, StoredRuntimeBindingState::Revoked => WorkspaceRuntimeBindingState::Revoked, }, + connection_state, authentication_mode: match binding.authentication_mode { StoredRuntimeAuthenticationMode::LegacyServerIssuer => { WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer @@ -16169,9 +16251,33 @@ fn runtime_binding_summary(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntim revision: binding.binding_revision, workspace_key_id: binding.workspace_key_id.clone(), workspace_key_generation: binding.workspace_key_generation, + verification: verification.and_then(runtime_verification_summary), } } +fn runtime_verification_summary( + verification: &crate::store::WorkspaceRuntimeVerificationEvidence, +) -> Option { + let last_outcome = match verification.last_outcome.as_str() { + "verified" => workspace_api::RuntimeVerificationOutcome::Verified, + "challenge_issued" => workspace_api::RuntimeVerificationOutcome::ChallengeIssued, + "verification_failed" => workspace_api::RuntimeVerificationOutcome::VerificationFailed, + "connectivity_failed" => workspace_api::RuntimeVerificationOutcome::ConnectivityFailed, + _ => return None, + }; + Some(workspace_api::RuntimeVerificationEvidenceSummary { + verified_at: verification.verified_at.clone(), + last_checked_at: verification.checked_at.clone(), + last_outcome, + binding_revision: verification.binding_revision, + workspace_key_id: verification.workspace_key_id.clone(), + workspace_identity_revision: verification.workspace_identity_revision, + workspace_trust_generation: verification.workspace_trust_generation, + runtime_public_key_fingerprint: verification.runtime_public_key_fingerprint.clone(), + runtime_identity_revision: verification.runtime_identity_revision, + }) +} + async fn workspace_runtime_detail( api: &WorkspaceApi, workspace_id: &str, @@ -16213,7 +16319,7 @@ async fn workspace_runtime_detail( removable: false, endpoint_configured: !binding.base_url.trim().is_empty(), token_ref_configured: false, - binding: Some(runtime_binding_summary(&binding)), + binding: Some(runtime_binding_summary(&binding, None)), }, }); } @@ -16463,6 +16569,9 @@ fn runtime_connection_test_response( Ok(ping) => RuntimeConnectionTestResponse { workspace_id: workspace_id.to_string(), runtime_id: runtime_id.to_string(), + binding_revision: 0, + connection_state: RuntimeConnectionDisplayState::Configured, + verification: None, checked_at, status: RuntimeConnectionTestStatus::Compatible, failure_kind: None, @@ -16512,6 +16621,9 @@ fn runtime_connection_test_failure( RuntimeConnectionTestResponse { workspace_id: workspace_id.to_string(), runtime_id: runtime_id.to_string(), + binding_revision: 0, + connection_state: RuntimeConnectionDisplayState::Unavailable, + verification: None, checked_at, status: RuntimeConnectionTestStatus::Failed, failure_kind: Some(failure_kind), @@ -20336,18 +20448,20 @@ mod tests { generic_put.into_response().status(), StatusCode::BAD_REQUEST ); - let configured_test = scoped_test_runtime_connection( + let Json(configured_test) = scoped_test_runtime_connection( State(api.clone()), AxumPath(ScopedRuntimePath { workspace_id: api.config.workspace_id.clone(), runtime_id: "configured-runtime".to_string(), }), + Extension(actor.clone()), ) .await - .unwrap_err(); + .unwrap(); + assert_eq!(configured_test.status, RuntimeConnectionTestStatus::Failed); assert_eq!( - configured_test.into_response().status(), - StatusCode::CONFLICT + configured_test.connection_state, + RuntimeConnectionDisplayState::Unavailable ); let (status, Json(replayed)) = create_remote_runtime( @@ -27991,7 +28105,9 @@ mod tests { ) -> serde_json::Value { let (endpoint, _server) = runtime_ping_stub(status, body).await; let dir = tempfile::tempdir().unwrap(); - let app = test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint).await; + let app = test_app_with_remote_runtime(dir.path(), "probe-runtime", endpoint) + .await + .layer(Extension(test_owner_actor())); post_json( app, &format!("/api/w/{TEST_WORKSPACE_ID}/runtimes/probe-runtime/connection-tests"), @@ -28000,6 +28116,25 @@ mod tests { .await } + #[tokio::test] + async fn runtime_connection_verification_requires_workspace_owner() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let mut actor = test_owner_actor(); + actor.account_id = "account-other".to_string(); + let error = scoped_test_runtime_connection( + State(api), + AxumPath(ScopedRuntimePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + runtime_id: "probe-runtime".to_string(), + }), + Extension(actor), + ) + .await + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN); + } + #[tokio::test(flavor = "multi_thread")] async fn runtime_connection_test_reports_exact_compatible_protocol() { let response = run_runtime_connection_test( @@ -28012,6 +28147,9 @@ mod tests { .await; assert_eq!(response["status"], "compatible"); + assert_eq!(response["binding_revision"], 1); + assert_eq!(response["connection_state"], "verified"); + assert_eq!(response["verification"], serde_json::Value::Null); assert_eq!(response["failure_kind"], serde_json::Value::Null); assert_eq!( response["expected_protocol_version"], @@ -30413,8 +30551,14 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK, "{uri}"); + let status = response.status(); let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{uri}: {}", + String::from_utf8_lossy(&bytes) + ); serde_json::from_slice(&bytes).unwrap() } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 3e4fe69b..a4662c06 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -2234,6 +2234,7 @@ impl SqliteWorkspaceStore { return Ok(false); }; Ok(evidence.state == "verified" + && evidence.last_outcome == "verified" && evidence.verified_at.is_some() && evidence.binding_revision == binding.binding_revision && evidence.workspace_key_id == binding.workspace_key_id.as_deref().unwrap_or_default() @@ -2251,18 +2252,22 @@ impl SqliteWorkspaceStore { validate_identifier("workspace_id", workspace_id)?; validate_identifier("runtime_id", runtime_id)?; self.with_conn(|conn| { - conn.query_row( - r#"SELECT workspace_id, runtime_id, binding_revision, workspace_key_id, + let evidence = conn + .query_row( + r#"SELECT workspace_id, runtime_id, binding_revision, workspace_key_id, workspace_identity_revision, workspace_trust_generation, runtime_public_key_fingerprint, runtime_identity_revision, challenge_id, state, last_outcome, verified_at, checked_at FROM workspace_runtime_verifications WHERE workspace_id = ?1 AND runtime_id = ?2"#, - params![workspace_id, runtime_id], - read_workspace_runtime_verification, - ) - .optional() - .map_err(Error::from) + params![workspace_id, runtime_id], + read_workspace_runtime_verification, + ) + .optional()?; + if let Some(evidence) = &evidence { + validate_workspace_runtime_verification(evidence)?; + } + Ok(evidence) }) } @@ -2287,9 +2292,29 @@ impl SqliteWorkspaceStore { runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint, runtime_identity_revision = excluded.runtime_identity_revision, challenge_id = excluded.challenge_id, - state = excluded.state, + state = CASE + WHEN workspace_runtime_verifications.state = 'verified' + AND workspace_runtime_verifications.binding_revision = excluded.binding_revision + AND workspace_runtime_verifications.workspace_key_id = excluded.workspace_key_id + AND workspace_runtime_verifications.workspace_identity_revision = excluded.workspace_identity_revision + AND workspace_runtime_verifications.workspace_trust_generation = excluded.workspace_trust_generation + AND workspace_runtime_verifications.runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint + AND workspace_runtime_verifications.runtime_identity_revision = excluded.runtime_identity_revision + THEN workspace_runtime_verifications.state + ELSE excluded.state + END, last_outcome = excluded.last_outcome, - verified_at = excluded.verified_at, + verified_at = CASE + WHEN workspace_runtime_verifications.state = 'verified' + AND workspace_runtime_verifications.binding_revision = excluded.binding_revision + AND workspace_runtime_verifications.workspace_key_id = excluded.workspace_key_id + AND workspace_runtime_verifications.workspace_identity_revision = excluded.workspace_identity_revision + AND workspace_runtime_verifications.workspace_trust_generation = excluded.workspace_trust_generation + AND workspace_runtime_verifications.runtime_public_key_fingerprint = excluded.runtime_public_key_fingerprint + AND workspace_runtime_verifications.runtime_identity_revision = excluded.runtime_identity_revision + THEN workspace_runtime_verifications.verified_at + ELSE excluded.verified_at + END, checked_at = excluded.checked_at"#, params![ evidence.workspace_id, @@ -6725,7 +6750,14 @@ fn validate_workspace_runtime_verification( &evidence.runtime_public_key_fingerprint, )?; validate_non_empty("verification state", &evidence.state)?; - validate_non_empty("verification outcome", &evidence.last_outcome)?; + if !matches!( + evidence.last_outcome.as_str(), + "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed" + ) { + return Err(Error::InvalidInput( + "Runtime verification outcome is invalid".to_string(), + )); + } validate_non_empty("checked_at", &evidence.checked_at)?; Ok(()) } @@ -9829,6 +9861,31 @@ mod tests { .complete_workspace_runtime_verification(&evidence) .unwrap(); assert_eq!(verified.state, WorkspaceRuntimeBindingState::Verified); + let pending_retry = WorkspaceRuntimeVerificationEvidence { + state: "pending".to_string(), + last_outcome: "challenge_issued".to_string(), + verified_at: None, + checked_at: "3".to_string(), + ..evidence.clone() + }; + store + .record_workspace_runtime_verification_attempt(&pending_retry) + .unwrap(); + let retained = store + .get_workspace_runtime_verification("workspace-a", "runtime-a") + .unwrap() + .unwrap(); + assert_eq!(retained.state, "verified"); + assert_eq!(retained.verified_at.as_deref(), Some("2")); + assert_eq!(retained.last_outcome, "challenge_issued"); + assert!( + !store + .workspace_runtime_verification_matches(&verified, 1, 1) + .unwrap() + ); + store + .complete_workspace_runtime_verification(&evidence) + .unwrap(); drop(store); let reopened = SqliteWorkspaceStore::open(&path).unwrap(); diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 2cd5f0a9..c60131bf 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -349,16 +349,42 @@ export type WorkspaceRuntimeBindingState = | "verified" | "revoked"; +export type RuntimeConnectionDisplayState = + | "configured" + | "verified" + | "unavailable" + | "revoked"; + +export type RuntimeVerificationOutcome = + | "verified" + | "challenge_issued" + | "verification_failed" + | "connectivity_failed"; + +export type RuntimeVerificationEvidenceSummary = { + verified_at: string | null; + last_checked_at: string; + last_outcome: RuntimeVerificationOutcome; + binding_revision: number; + workspace_key_id: string; + workspace_identity_revision: number; + workspace_trust_generation: number; + runtime_public_key_fingerprint: string; + runtime_identity_revision: number; +}; + export type WorkspaceRuntimeAuthenticationMode = | "legacy_server_issuer" | "workspace_identity"; export type WorkspaceRuntimeBindingSummary = { state: WorkspaceRuntimeBindingState; + connection_state: RuntimeConnectionDisplayState; authentication_mode: WorkspaceRuntimeAuthenticationMode; revision: number; workspace_key_id?: string | null; workspace_key_generation?: number | null; + verification?: RuntimeVerificationEvidenceSummary | null; }; export type RuntimeManagementSummary = { @@ -464,6 +490,9 @@ export type RuntimeConnectionTestFailureKind = export type RuntimeConnectionTestResponse = { workspace_id: string; runtime_id: string; + binding_revision: number; + connection_state: RuntimeConnectionDisplayState; + verification: RuntimeVerificationEvidenceSummary | null; checked_at: string; status: RuntimeConnectionTestStatus; failure_kind: RuntimeConnectionTestFailureKind | null; diff --git a/web/workspace/src/lib/workspace/api/runtime-connection.ts b/web/workspace/src/lib/workspace/api/runtime-connection.ts index 6eb58965..05ec74ce 100644 --- a/web/workspace/src/lib/workspace/api/runtime-connection.ts +++ b/web/workspace/src/lib/workspace/api/runtime-connection.ts @@ -2,11 +2,15 @@ import type { Diagnostic, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, + RuntimeVerificationEvidenceSummary, } from "$lib/generated/workspace-api"; const RESPONSE_KEYS = [ "workspace_id", "runtime_id", + "binding_revision", + "connection_state", + "verification", "checked_at", "status", "failure_kind", @@ -103,9 +107,28 @@ export function parseRuntimeConnectionTestResponse( ) { return null; } + const bindingRevision = value.binding_revision; + const connectionState = parseConnectionState(value.connection_state); + const verification = parseVerificationEvidence(value.verification); + if ( + !isSafeRevision(bindingRevision) || + connectionState === null || + (value.verification !== null && verification === null) || + (verification !== null && + verification.binding_revision !== bindingRevision) || + (connectionState === "verified" && value.status !== "compatible") || + (connectionState === "verified" && verification !== null && + (verification.last_outcome !== "verified" || + verification.verified_at === null)) + ) { + return null; + } return { workspace_id: value.workspace_id, runtime_id: value.runtime_id, + binding_revision: bindingRevision, + connection_state: connectionState, + verification, checked_at: value.checked_at, status: value.status, failure_kind: failureKind as RuntimeConnectionTestFailureKind | null, @@ -115,6 +138,67 @@ export function parseRuntimeConnectionTestResponse( }; } +function parseConnectionState( + value: unknown, +): "configured" | "verified" | "unavailable" | "revoked" | null { + return value === "configured" || value === "verified" || + value === "unavailable" || value === "revoked" + ? value + : null; +} + +function isSafeRevision(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function parseVerificationEvidence( + value: unknown, +): RuntimeVerificationEvidenceSummary | null { + if (value === null) return null; + const keys = [ + "verified_at", + "last_checked_at", + "last_outcome", + "binding_revision", + "workspace_key_id", + "workspace_identity_revision", + "workspace_trust_generation", + "runtime_public_key_fingerprint", + "runtime_identity_revision", + ] as const; + if (!isRecord(value) || !hasExactKeys(value, keys)) return null; + if ( + (value.verified_at !== null && + (!isBoundedString(value.verified_at, 128) || + Number.isNaN(Date.parse(value.verified_at)))) || + !isBoundedString(value.last_checked_at, 128) || + Number.isNaN(Date.parse(value.last_checked_at)) || + (value.last_outcome !== "verified" && + value.last_outcome !== "challenge_issued" && + value.last_outcome !== "verification_failed" && + value.last_outcome !== "connectivity_failed") || + !isSafeRevision(value.binding_revision) || + !isBoundedString(value.workspace_key_id, 128) || + !isSafeRevision(value.workspace_identity_revision) || + !isSafeRevision(value.workspace_trust_generation) || + !isBoundedString(value.runtime_public_key_fingerprint, 128) || + !isSafeRevision(value.runtime_identity_revision) + ) { + return null; + } + return { + verified_at: value.verified_at, + last_checked_at: value.last_checked_at, + last_outcome: value.last_outcome, + binding_revision: value.binding_revision, + workspace_key_id: value.workspace_key_id, + workspace_identity_revision: value.workspace_identity_revision, + workspace_trust_generation: value.workspace_trust_generation, + runtime_public_key_fingerprint: value.runtime_public_key_fingerprint, + runtime_identity_revision: value.runtime_identity_revision, + }; +} + export async function testRuntimeConnection( workspaceId: string, runtimeId: string, diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index ced20e5a..4417e037 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -3,6 +3,7 @@ import type { Diagnostic, PutRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest, + RuntimeConnectionDisplayState, RuntimeIdentityAuthority, RuntimeManagementSummary, RuntimeSourceKind, @@ -15,6 +16,7 @@ import type { RuntimeTrustKeyRevealResponse, RuntimeTrustKeyState, RuntimeTrustKeyStatus, + RuntimeVerificationEvidenceSummary, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBindingState, WorkspaceRuntimeBindingSummary, @@ -76,6 +78,13 @@ const BINDING_STATES = new Set([ "verified", "revoked", ]); +const CONNECTION_STATES = new Set([ + "configured", + "verified", + "unavailable", + "revoked", +]); + const AUTHENTICATION_MODES = new Set([ "legacy_server_issuer", "workspace_identity", @@ -299,6 +308,79 @@ function runtimeSource(value: unknown, path: string): RuntimeSourceSummary { }; } +function runtimeVerification( + value: unknown, + path: string, +): RuntimeVerificationEvidenceSummary { + const item = object(value, path); + exactKeys( + item, + [ + "verified_at", + "last_checked_at", + "last_outcome", + "binding_revision", + "workspace_key_id", + "workspace_identity_revision", + "workspace_trust_generation", + "runtime_public_key_fingerprint", + "runtime_identity_revision", + ], + [], + path, + ); + const verifiedAt = item.verified_at === null + ? null + : boundedString(item.verified_at, `${path}.verified_at`, 128); + const lastOutcome = enumValue( + item.last_outcome, + `${path}.last_outcome`, + new Set( + [ + "verified", + "challenge_issued", + "verification_failed", + "connectivity_failed", + ] as const, + ), + ); + return { + verified_at: verifiedAt, + last_checked_at: boundedString( + item.last_checked_at, + `${path}.last_checked_at`, + 128, + ), + last_outcome: lastOutcome, + binding_revision: safeRevision( + item.binding_revision, + `${path}.binding_revision`, + ), + workspace_key_id: boundedString( + item.workspace_key_id, + `${path}.workspace_key_id`, + LIMITS.idBytes, + ), + workspace_identity_revision: safeRevision( + item.workspace_identity_revision, + `${path}.workspace_identity_revision`, + ), + workspace_trust_generation: safeRevision( + item.workspace_trust_generation, + `${path}.workspace_trust_generation`, + ), + runtime_public_key_fingerprint: boundedString( + item.runtime_public_key_fingerprint, + `${path}.runtime_public_key_fingerprint`, + LIMITS.fingerprintBytes, + ), + runtime_identity_revision: safeRevision( + item.runtime_identity_revision, + `${path}.runtime_identity_revision`, + ), + }; +} + function runtimeBinding( value: unknown, path: string, @@ -306,8 +388,8 @@ function runtimeBinding( const item = object(value, path); exactKeys( item, - ["state", "authentication_mode", "revision"], - ["workspace_key_id", "workspace_key_generation"], + ["state", "connection_state", "authentication_mode", "revision"], + ["workspace_key_id", "workspace_key_generation", "verification"], path, ); const authenticationMode = enumValue( @@ -339,16 +421,45 @@ function runtimeBinding( "must not attach Workspace key metadata to legacy authority", ); } + const state = enumValue(item.state, `${path}.state`, BINDING_STATES); + const connectionState = enumValue( + item.connection_state, + `${path}.connection_state`, + CONNECTION_STATES, + ); + const revision = safeRevision(item.revision, `${path}.revision`); + const verification = item.verification === undefined + ? undefined + : runtimeVerification(item.verification, `${path}.verification`); + if ( + verification !== undefined && verification.binding_revision !== revision + ) { + return fail(path, "verification must match the current binding revision"); + } + if ( + authenticationMode === "workspace_identity" && + connectionState === "verified" && + (verification === undefined || + verification.verified_at === null || + verification.last_outcome !== "verified") + ) { + return fail( + path, + "verified Workspace identity binding requires verification evidence", + ); + } return { - state: enumValue(item.state, `${path}.state`, BINDING_STATES), + state, + connection_state: connectionState, authentication_mode: authenticationMode, - revision: safeRevision(item.revision, `${path}.revision`), + revision, ...(workspaceKeyId === undefined ? {} : { workspace_key_id: workspaceKeyId }), ...(workspaceKeyGeneration === undefined ? {} : { workspace_key_generation: workspaceKeyGeneration }), + ...(verification === undefined ? {} : { verification }), }; } 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 9053c242..49929bfd 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/+page.svelte @@ -26,6 +26,7 @@ let busyRuntimeId = $state(null); let requestError = $state(null); let testResults = $state>({}); + let connectionTestGeneration = 0; function runtimePlatform(runtime: WorkspaceRuntimeResource): string { return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown'; @@ -33,7 +34,9 @@ function connectionTestSummary(result: RuntimeConnectionTestResponse): string { if (result.status === 'compatible') { - return `Compatible · protocol v${result.actual_protocol_version}`; + return result.connection_state === 'verified' + ? `Verified · protocol v${result.actual_protocol_version}` + : `Compatible · ${result.connection_state} · protocol v${result.actual_protocol_version}`; } switch (result.failure_kind) { case 'authentication': return 'Authentication failed'; @@ -144,16 +147,40 @@ } } + function currentTestResult( + runtime: WorkspaceRuntimeResource, + ): RuntimeConnectionTestResponse | undefined { + const result = testResults[runtime.runtime_id]; + return result?.binding_revision === runtime.management?.binding?.revision + ? result + : undefined; + } + async function testRuntime(runtime: WorkspaceRuntimeResource): Promise { + const bindingRevision = runtime.management?.binding?.revision; + if (typeof bindingRevision !== 'number') return; + const generation = ++connectionTestGeneration; requestError = null; busyRuntimeId = runtime.runtime_id; try { const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id); + if ( + generation !== connectionTestGeneration || + result.binding_revision !== bindingRevision + ) { + await invalidateAll(); + return; + } testResults = { ...testResults, [runtime.runtime_id]: result }; + await invalidateAll(); } catch (error) { - requestError = error instanceof Error ? error.message : String(error); + if (generation === connectionTestGeneration) { + requestError = error instanceof Error ? error.message : String(error); + } } finally { - busyRuntimeId = null; + if (generation === connectionTestGeneration) { + busyRuntimeId = null; + } } } @@ -287,7 +314,7 @@ {runtime.kind} - {runtime.management?.binding?.state ?? runtime.status} + {runtime.management?.binding?.connection_state ?? runtime.status} {runtimePlatform(runtime)} {managementLabel(runtime)} @@ -298,7 +325,7 @@
- {#if runtime.management?.config_managed && runtime.management.binding?.state === 'verified'} + {#if runtime.management?.config_managed && runtime.management.binding?.connection_state !== 'revoked'}