diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index 72220dee..a838b48a 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -39,7 +39,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature ring.workspace = true tar.workspace = true thiserror = { workspace = true } -tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } +tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] } tracing.workspace = true tracing-subscriber.workspace = true toml.workspace = true diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 3caedcb3..ee2cb058 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -119,9 +119,16 @@ impl std::fmt::Debug for SensitiveString { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct RepositorySshMaterializationAccess { +pub struct RepositorySshCredentialCandidate { pub credential_id: String, pub credential_revision: u64, + #[serde(skip, default)] + pub private_key: SensitiveString, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepositorySshMaterializationAccess { + pub credential_candidates: Vec, pub host_trust_id: String, pub host_trust_revision: u64, pub access: workspace_api::RepositoryAccessMode, @@ -131,8 +138,6 @@ pub struct RepositorySshMaterializationAccess { pub repository_uri: String, pub secret_resource: crate::resource::BackendResourceHandle, #[serde(skip, default)] - pub private_key: SensitiveString, - #[serde(skip, default)] pub known_hosts_entry: SensitiveString, } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 1c79f815..b63378d9 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -24,6 +24,11 @@ use crate::retention::{ }; #[cfg(feature = "ws-server")] use crate::runtime::RuntimeSubscriptionRecvError; +use crate::ssh_host_key_probe::{ + SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SSH_KEYSCAN_TIMEOUT, + SshHostKeyProbeError, SshHostKeyProbeRequest, SshHostKeyProbeResponse, + probe_ssh_host_keys_with_program, +}; use crate::workspace_issuer::{ RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_ACK_PATH, WORKSPACE_VERIFICATION_CHALLENGE_PATH, WORKSPACE_VERIFICATION_OPERATION, @@ -58,7 +63,6 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fmt; use std::net::SocketAddr; -#[cfg(feature = "fs-store")] use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::net::TcpListener; @@ -181,12 +185,27 @@ fn runtime_http_router_with_optional_auth( runtime: Runtime, local_token: Option, workspace_auth: Option, +) -> Router { + runtime_http_router_with_auth_and_ssh_keyscan_program( + runtime, + local_token, + workspace_auth, + PathBuf::from("ssh-keyscan"), + ) +} + +fn runtime_http_router_with_auth_and_ssh_keyscan_program( + runtime: Runtime, + local_token: Option, + workspace_auth: Option, + ssh_keyscan_program: PathBuf, ) -> Router { let state = RuntimeHttpState { runtime, local_token: local_token.map(Arc::::from), workspace_auth: workspace_auth.map(Arc::new), workdir_sessions: Arc::new(Mutex::new(HashMap::new())), + ssh_keyscan_program: Arc::new(ssh_keyscan_program), }; let router = Router::new() @@ -220,6 +239,10 @@ fn runtime_http_router_with_optional_auth( "/v1/working-directories/repository-access", post(authorize_working_directory_repository_access), ) + .route( + SSH_HOST_KEY_PROBE_PATH, + post(probe_repository_ssh_host_keys), + ) .route("/v1/repository-refs/observe", post(observe_repository_ref)) .route( "/v1/working-directories/{working_directory_id}/sessions", @@ -293,6 +316,7 @@ struct RuntimeHttpState { local_token: Option>, workspace_auth: Option>, workdir_sessions: Arc>>, + ssh_keyscan_program: Arc, } #[derive(Clone, Debug)] @@ -811,6 +835,45 @@ async fn authorize_working_directory_repository_access( })) } +async fn probe_repository_ssh_host_keys( + State(state): State, + Extension(_auth): Extension, + body: Result, JsonRejection>, +) -> RestResult { + let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?; + let response = probe_ssh_host_keys_with_program( + &request, + state.ssh_keyscan_program.as_path(), + SSH_KEYSCAN_TIMEOUT, + ) + .await + .map_err(|error| match error { + SshHostKeyProbeError::InvalidHostname | SshHostKeyProbeError::InvalidPort => { + RuntimeHttpRestError::new( + StatusCode::BAD_REQUEST, + "ssh_host_key_probe_invalid_request", + error.to_string(), + ) + } + SshHostKeyProbeError::Unavailable => RuntimeHttpRestError::new( + StatusCode::SERVICE_UNAVAILABLE, + "ssh_host_key_probe_unavailable", + error.to_string(), + ), + SshHostKeyProbeError::Timeout => RuntimeHttpRestError::new( + StatusCode::GATEWAY_TIMEOUT, + "ssh_host_key_probe_timeout", + error.to_string(), + ), + SshHostKeyProbeError::Failed { .. } => RuntimeHttpRestError::new( + StatusCode::BAD_GATEWAY, + "ssh_host_key_probe_failed", + error.to_string(), + ), + })?; + Ok(Json(response)) +} + async fn observe_repository_ref( State(state): State, Extension(auth): Extension, @@ -2174,10 +2237,11 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s return Some("workers:create"); } if (path == "/v1/working-directories/repository-access" - || path == "/v1/repository-refs/observe") + || path == "/v1/repository-refs/observe" + || path == SSH_HOST_KEY_PROBE_PATH) && *method == Method::POST { - return Some("workdirs:operate"); + return Some(SSH_HOST_KEY_PROBE_OPERATION); } if path.starts_with("/v1/workdir-sessions") || (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions")) @@ -2848,6 +2912,10 @@ mod tests { required_runtime_permission(&Method::POST, "/v1/repository-refs/observe"), Some("workdirs:operate") ); + assert_eq!( + required_runtime_permission(&Method::POST, SSH_HOST_KEY_PROBE_PATH), + Some(SSH_HOST_KEY_PROBE_OPERATION) + ); assert_eq!( required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"), Some("workdirs:operate") @@ -2890,6 +2958,7 @@ mod tests { session: session.clone(), }, )]))), + ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")), }; let auth = RuntimeAuthContext { server_id: "server-a".to_string(), @@ -3245,6 +3314,112 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[test] + fn ssh_probe_uses_workdir_operation_capability() { + assert_eq!( + required_runtime_permission(&Method::POST, SSH_HOST_KEY_PROBE_PATH), + Some(SSH_HOST_KEY_PROBE_OPERATION) + ); + assert_eq!( + workspace_runtime_operation(&Method::POST, SSH_HOST_KEY_PROBE_PATH), + SSH_HOST_KEY_PROBE_OPERATION + ); + } + + #[tokio::test] + async fn ssh_host_key_probe_rejects_invalid_hostname_before_execution() { + let token = "local-token"; + let app = runtime_http_router_with_auth_and_ssh_keyscan_program( + Runtime::new_memory(), + Some(token.to_string()), + None, + PathBuf::from("/definitely/missing/ssh-keyscan"), + ); + let response = authed_json_request( + app, + Method::POST, + SSH_HOST_KEY_PROBE_PATH, + token, + &SshHostKeyProbeRequest { + hostname: "-oProxyCommand=malicious".to_string(), + port: 22, + }, + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let error: RuntimeHttpErrorResponse = read_json(response).await; + assert_eq!(error.error.code, "ssh_host_key_probe_invalid_request"); + } + + #[cfg(unix)] + #[tokio::test] + async fn authenticated_ssh_host_key_probe_returns_deduplicated_candidates() { + use base64::Engine as _; + use std::os::unix::fs::PermissionsExt as _; + + let mut blob = Vec::new(); + blob.extend_from_slice(&11_u32.to_be_bytes()); + blob.extend_from_slice(b"ssh-ed25519"); + blob.extend_from_slice(&32_u32.to_be_bytes()); + blob.extend_from_slice(&[9_u8; 32]); + let key = base64::engine::general_purpose::STANDARD.encode(blob); + let temp = tempfile::tempdir().unwrap(); + let program = temp.path().join("ssh-keyscan"); + let recorded_arguments = temp.path().join("arguments"); + std::fs::write( + &program, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\nprintf 'private diagnostic' >&2\nprintf '%s\\n' 'example.test ssh-ed25519 {key}' '[example.test]:2222 ssh-ed25519 {key}'\n", + recorded_arguments.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let token = "local-token"; + let app = runtime_http_router_with_auth_and_ssh_keyscan_program( + Runtime::new_memory(), + Some(token.to_string()), + None, + program, + ); + let body = SshHostKeyProbeRequest { + hostname: "example.test".to_string(), + port: 2222, + }; + + let unauthenticated = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/repositories/ssh/probe") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let response = + authed_json_request(app, Method::POST, SSH_HOST_KEY_PROBE_PATH, token, &body).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + std::fs::read_to_string(recorded_arguments).unwrap(), + "-T\n5\n-p\n2222\n-t\ned25519\nexample.test\n" + ); + let response: SshHostKeyProbeResponse = read_json(response).await; + assert_eq!(response.candidates.len(), 1); + assert_eq!(response.candidates[0].algorithm, "ssh-ed25519"); + assert_eq!( + response.candidates[0].public_key, + format!("ssh-ed25519 {key}") + ); + assert!(response.candidates[0].fingerprint.starts_with("SHA256:")); + } + #[tokio::test] async fn runtime_errors_use_typed_rest_error_shape() { let token = "local-token"; diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index 8bf7c342..bd4ed723 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -25,6 +25,7 @@ pub mod resource; #[cfg(feature = "fs-store")] pub mod retention; mod runtime; +pub mod ssh_host_key_probe; pub mod worker_backend; pub mod worker_source; pub mod working_directory; diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index 71e867f6..4ea001a1 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -30,8 +30,8 @@ use worker_runtime::workspace_issuer::{ FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority, MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord, - add_workspace_issuer_trust, replace_workspace_issuer_trust, revoke_workspace_issuer_trust, - validate_workspace_issuer_trust_records, + WorkspaceIssuerTrustState, add_workspace_issuer_trust, replace_workspace_issuer_trust, + revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records, }; use worker_runtime::{Runtime, RuntimeOptions}; @@ -236,6 +236,33 @@ fn build_runtime(config: &ProcessConfig) -> Result { factory = factory.with_resource_client(client.clone()); backend_resource_client = Some(client); } + let mut workspace_backend_resource_clients: Vec<( + String, + Arc, + )> = Vec::new(); + if config.backend_resource_endpoint.is_none() + && let Some(identity) = runtime_auth.identity.as_ref() + { + for workspace_issuer in runtime_auth + .workspace_issuers + .iter() + .filter(|issuer| issuer.state == WorkspaceIssuerTrustState::Active) + { + let endpoint = workspace_backend_resource_endpoint( + &workspace_issuer.backend_url, + &workspace_issuer.workspace_id, + ); + let client = Arc::new( + worker_runtime::resource::HttpBackendResourceClient::new( + endpoint, + config.backend_resource_token.clone(), + ) + .with_runtime_request_source(identity, workspace_issuer.backend_url.clone()), + ); + workspace_backend_resource_clients + .push((workspace_issuer.workspace_id.clone(), client)); + } + } let backend = Arc::new( WorkerRuntimeExecutionBackend::new(factory) .map_err(ProcessError::WorkerAdapter)? @@ -267,14 +294,31 @@ fn build_runtime(config: &ProcessConfig) -> Result { )); } }; + if let Some(identity) = runtime_auth.identity.as_ref() { + runtime + .bind_runtime_identity(&identity.identity_id) + .map_err(ProcessError::Runtime)?; + } if let Some(client) = backend_resource_client { runtime .install_backend_resource_client(client) .map_err(ProcessError::Runtime)?; } + for (workspace_id, client) in workspace_backend_resource_clients { + runtime + .install_workspace_backend_resource_client(workspace_id, client) + .map_err(ProcessError::Runtime)?; + } Ok(runtime) } +fn workspace_backend_resource_endpoint(backend_url: &str, workspace_id: &str) -> String { + format!( + "{}/api/runtime/v1/workspaces/{workspace_id}/resources/fetch", + backend_url.trim_end_matches('/'), + ) +} + fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions { RuntimeOptions { display_name: config.display_name.clone(), @@ -1521,6 +1565,42 @@ mod tests { assert_eq!(error, "unknown auth command `trust-server`"); } + #[test] + fn runtime_startup_binds_auth_identity_before_resource_use() { + let temp = tempfile::tempdir().unwrap(); + let mut config = ProcessConfig { + fs_root: Some(temp.path().to_path_buf()), + ..ProcessConfig::default().unwrap() + }; + config.http.store = RuntimeHttpStoreSelection::Memory; + let identity = RuntimeIdentityMaterial::generate("runtime-startup").unwrap(); + write_runtime_auth_file( + &runtime_auth_path(&config), + &RuntimeAuthFile { + identity: Some(identity), + workspace_issuers: Vec::new(), + }, + ) + .unwrap(); + + let runtime = build_runtime(&config).unwrap(); + + runtime.bind_runtime_identity("runtime-startup").unwrap(); + assert!(runtime.bind_runtime_identity("other-runtime").is_err()); + } + + #[test] + fn workspace_resource_endpoints_are_derived_per_issuer() { + assert_eq!( + workspace_backend_resource_endpoint("https://backend.example/", "workspace-a"), + "https://backend.example/api/runtime/v1/workspaces/workspace-a/resources/fetch" + ); + assert_ne!( + workspace_backend_resource_endpoint("https://backend.example", "workspace-a"), + workspace_backend_resource_endpoint("https://backend.example", "workspace-b") + ); + } + #[test] fn no_store_disables_runtime_catalog_persistence() { let config = parse_args(["--no-store"]).unwrap().unwrap(); diff --git a/crates/worker-runtime/src/resource.rs b/crates/worker-runtime/src/resource.rs index 46dd6a10..4f1c9487 100644 --- a/crates/worker-runtime/src/resource.rs +++ b/crates/worker-runtime/src/resource.rs @@ -13,16 +13,41 @@ pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str = "application/vnd.yoi.repository-ssh-access+json"; pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024; pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024; +pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(15); + +#[derive(Clone, Serialize, Deserialize)] +pub struct RepositorySshAccessSecretCandidate { + pub credential_id: String, + pub credential_revision: u64, + pub private_key: String, +} + +impl Drop for RepositorySshAccessSecretCandidate { + fn drop(&mut self) { + zeroize::Zeroize::zeroize(&mut self.private_key); + } +} + +impl std::fmt::Debug for RepositorySshAccessSecretCandidate { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RepositorySshAccessSecretCandidate") + .field("credential_id", &self.credential_id) + .field("credential_revision", &self.credential_revision) + .field("private_key", &"[REDACTED]") + .finish() + } +} #[derive(Clone, Serialize, Deserialize)] pub struct RepositorySshAccessSecret { - pub private_key: String, + pub credential_candidates: Vec, pub known_hosts_entry: String, } impl Drop for RepositorySshAccessSecret { fn drop(&mut self) { - zeroize::Zeroize::zeroize(&mut self.private_key); zeroize::Zeroize::zeroize(&mut self.known_hosts_entry); } } @@ -31,7 +56,7 @@ impl std::fmt::Debug for RepositorySshAccessSecret { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("RepositorySshAccessSecret") - .field("private_key", &"[REDACTED]") + .field("credential_candidates", &self.credential_candidates) .field("known_hosts_entry", &"[REDACTED]") .finish() } @@ -142,6 +167,8 @@ pub enum BackendResourceError { Oversized { max_bytes: u64, actual_bytes: u64 }, #[error("backend resource content type mismatch: expected {expected}, got {actual}")] ContentTypeMismatch { expected: String, actual: String }, + #[error("backend resource fetch timed out")] + Timeout, #[error("backend resource transport failed: {message}")] Transport { message: String }, #[error("backend resource response is invalid: {message}")] @@ -163,6 +190,7 @@ pub struct HttpBackendResourceClient { bearer_token: Option, request_source_signer: Option, request_source_audience: Option, + request_timeout: std::time::Duration, client: reqwest::Client, } @@ -174,10 +202,16 @@ impl HttpBackendResourceClient { bearer_token, request_source_signer: None, request_source_audience: None, + request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT, client: reqwest::Client::new(), } } + pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self { + self.request_timeout = timeout; + self + } + pub fn with_runtime_request_source( mut self, identity: &RuntimeIdentityMaterial, @@ -209,6 +243,7 @@ impl BackendResourceClient for HttpBackendResourceClient { let mut builder = self .client .post(endpoint.clone()) + .timeout(self.request_timeout) .header(reqwest::header::CONTENT_TYPE, "application/json") .body(body.clone()); if let Some(signer) = self.request_source_signer.as_ref() { @@ -239,12 +274,15 @@ impl BackendResourceClient for HttpBackendResourceClient { } else { builder }; - let response = builder - .send() - .await - .map_err(|err| BackendResourceError::Transport { - message: err.to_string(), - })?; + let response = builder.send().await.map_err(|error| { + if error.is_timeout() { + BackendResourceError::Timeout + } else { + BackendResourceError::Transport { + message: error.to_string(), + } + } + })?; if response.status().is_success() { response .json::() @@ -382,6 +420,54 @@ mod tests { } } + #[cfg(feature = "http-server")] + #[tokio::test] + async fn http_backend_resource_fetch_has_a_bounded_timeout() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + futures::future::pending::<()>().await; + drop(stream); + }); + let base_url = format!("http://{address}"); + let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + let handle = handle_for(b"archive-bytes"); + let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None) + .with_request_timeout(std::time::Duration::from_millis(25)) + .with_runtime_request_source(&identity, base_url); + + let error = client + .fetch_resource(BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + handle, + runtime_id: "runtime-test".to_string(), + worker_id: None, + }) + .await + .unwrap_err(); + + server.abort(); + assert_eq!(error, BackendResourceError::Timeout); + } + + #[test] + fn repository_ssh_access_secret_debug_redacts_all_secret_values() { + let secret = RepositorySshAccessSecret { + credential_candidates: vec![RepositorySshAccessSecretCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 2, + private_key: "PRIVATE KEY secret bytes".to_string(), + }], + known_hosts_entry: "host key secret bytes".to_string(), + }; + + let debug = format!("{secret:?}"); + assert!(debug.contains("credential-1")); + assert!(!debug.contains("secret bytes")); + assert_eq!(debug.matches("[REDACTED]").count(), 2); + } + #[test] fn response_verification_detects_digest_mismatch() { let bytes = b"archive-bytes"; diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index ff6d8f5b..281a614e 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -189,6 +189,23 @@ impl Runtime { Ok(()) } + pub fn install_workspace_backend_resource_client( + &self, + workspace_id: impl Into, + client: Arc, + ) -> Result<(), RuntimeError> { + let workspace_id = workspace_id.into(); + if workspace_id.trim().is_empty() { + return Err(RuntimeError::InvalidRequest( + "Backend resource client Workspace id is empty".to_string(), + )); + } + self.lock()? + .workspace_backend_resource_clients + .insert(workspace_id, BackendResourceClientRef(client)); + Ok(()) + } + /// Create or restore a filesystem-backed Runtime. /// /// The store is scoped by `options.root`; if the directory already exists, @@ -439,26 +456,51 @@ impl Runtime { &self, ssh: &mut crate::catalog::RepositorySshMaterializationAccess, ) -> Result<(), RuntimeError> { - if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() { + if ssh.credential_candidates.is_empty() { + return Err(RuntimeError::InvalidRequest( + "Repository SSH access requires at least one credential candidate".to_string(), + )); + } + if ssh + .credential_candidates + .iter() + .all(|candidate| !candidate.private_key.expose().is_empty()) + && !ssh.known_hosts_entry.expose().is_empty() + { return Ok(()); } let (client, runtime_id) = { let state = self.lock()?; - let client = state.backend_resource_client.clone().ok_or_else(|| { - RuntimeError::InvalidRequest( - "Backend Repository access resource client is unavailable".to_string(), - ) - })?; + let client = state + .workspace_backend_resource_clients + .get(&ssh.secret_resource.workspace_id) + .cloned() + .or_else(|| state.backend_resource_client.clone()) + .ok_or_else(|| { + RuntimeError::InvalidRequest(format!( + "Backend Repository access resource client is unavailable for Workspace `{}`", + ssh.secret_resource.workspace_id + )) + })?; let runtime_id = state.runtime_identity.clone().ok_or_else(|| { RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string()) })?; (client, runtime_id) }; + tracing::info!( + target: "yoi::repository_access", + event = "repository_access_resource_fetch_started", + workspace_id = %ssh.secret_resource.workspace_id, + resource_id = %ssh.secret_resource.resource_id, + runtime_id = %runtime_id, + credential_candidate_count = ssh.credential_candidates.len(), + "fetching Repository SSH access resource from Workspace Backend" + ); let mut response = client .0 .fetch_resource(BackendResourceFetchRequest { handle: ssh.secret_resource.clone(), - runtime_id, + runtime_id: runtime_id.clone(), worker_id: None, audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(), }) @@ -481,10 +523,40 @@ impl Runtime { "Backend Repository SSH access resource payload was invalid".to_string(), ) })?; - ssh.private_key = - crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key)); + if secret.credential_candidates.len() != ssh.credential_candidates.len() + || secret + .credential_candidates + .iter() + .zip(&ssh.credential_candidates) + .any(|(secret, metadata)| { + secret.credential_id != metadata.credential_id + || secret.credential_revision != metadata.credential_revision + }) + { + return Err(RuntimeError::InvalidRequest( + "Backend Repository SSH access resource credential metadata was invalid" + .to_string(), + )); + } + for (candidate, secret) in ssh + .credential_candidates + .iter_mut() + .zip(&mut secret.credential_candidates) + { + candidate.private_key = + crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key)); + } ssh.known_hosts_entry = crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry)); + tracing::info!( + target: "yoi::repository_access", + event = "repository_access_resource_fetch_succeeded", + workspace_id = %ssh.secret_resource.workspace_id, + resource_id = %ssh.secret_resource.resource_id, + runtime_id = %runtime_id, + credential_candidate_count = ssh.credential_candidates.len(), + "fetched Repository SSH access resource from Workspace Backend" + ); Ok(()) } @@ -492,10 +564,22 @@ impl Runtime { &self, mut request: WorkingDirectoryRepositoryAccessRequest, ) -> Result<(), RuntimeError> { + let materialization_runtime_id = request.materialization.runtime_id.clone(); let ssh = request.materialization.ssh.as_mut().ok_or_else(|| { RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string()) })?; - self.resolve_repository_access_resource(ssh).await?; + if let Err(error) = self.resolve_repository_access_resource(ssh).await { + tracing::warn!( + target: "yoi::repository_access", + event = "repository_access_resource_fetch_failed", + workspace_id = %ssh.secret_resource.workspace_id, + resource_id = %ssh.secret_resource.resource_id, + runtime_id = %materialization_runtime_id, + error = %error, + "failed to fetch Repository SSH access resource from Workspace Backend" + ); + return Err(error); + } self.authorize_working_directory_repository_access(request) } @@ -2428,6 +2512,7 @@ struct RuntimeState { status: RuntimeStatus, execution_backend: Option, backend_resource_client: Option, + workspace_backend_resource_clients: BTreeMap, #[cfg(feature = "fs-store")] next_diagnostic_id: u64, workers: BTreeMap, @@ -2458,6 +2543,7 @@ impl RuntimeState { status: RuntimeStatus::Running, execution_backend: None, backend_resource_client: None, + workspace_backend_resource_clients: BTreeMap::new(), #[cfg(feature = "fs-store")] next_diagnostic_id: 1, workers: BTreeMap::new(), @@ -2489,6 +2575,7 @@ impl RuntimeState { status: RuntimeStatus::Running, execution_backend: None, backend_resource_client: None, + workspace_backend_resource_clients: BTreeMap::new(), #[cfg(feature = "fs-store")] next_diagnostic_id: 1, workers: BTreeMap::new(), @@ -2552,6 +2639,7 @@ impl RuntimeState { status: persisted.status, execution_backend: None, backend_resource_client: None, + workspace_backend_resource_clients: BTreeMap::new(), next_diagnostic_id, workers, config_bundles: BTreeMap::new(), @@ -3344,6 +3432,10 @@ fn repository_resource_error(error: BackendResourceError) -> RuntimeError { "repository_access_credential_unavailable", "Repository access credential lease is unavailable or already consumed", ), + BackendResourceError::Timeout => ( + "repository_access_resource_fetch_timeout", + "Timed out while fetching Repository SSH access from Workspace Backend", + ), BackendResourceError::Transport { .. } => ( "repository_access_provider_unavailable", "Repository access credential provider is unavailable", @@ -3615,8 +3707,9 @@ mod tests { use super::*; use crate::catalog::{ ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext, - RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim, - WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef, + RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString, + WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest, + WorkspaceApiRef, }; use crate::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration, @@ -3670,6 +3763,10 @@ mod tests { BackendResourceError::MissingResource, "repository_access_credential_unavailable", ), + ( + BackendResourceError::Timeout, + "repository_access_resource_fetch_timeout", + ), ( BackendResourceError::Unauthorized { message: "denied".to_string(), @@ -3933,8 +4030,11 @@ mod tests { config_projection_digest: "sha256:projection".to_string(), cache_generation: 0, ssh: Some(RepositorySshMaterializationAccess { - credential_id: "credential-1".to_string(), - credential_revision: 1, + credential_candidates: vec![RepositorySshCredentialCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: SensitiveString::new("private-key-bytes"), + }], host_trust_id: "host-trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadOnly, @@ -3943,7 +4043,6 @@ mod tests { repository_source_fingerprint: "sha256:source".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(), secret_resource: repository_resource_handle(), - private_key: SensitiveString::new("private-key-bytes"), known_hosts_entry: SensitiveString::new("known-hosts-entry"), }), }), @@ -4003,20 +4102,35 @@ mod tests { runtime.bind_runtime_identity("runtime-1").unwrap(); let handle = repository_resource_handle(); runtime - .install_backend_resource_client(Arc::new(TestRepositoryResourceClient { - response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse { - kind: crate::resource::BackendResourceKind::RepositorySshAccess, - resource_id: handle.resource_id.clone(), - digest: handle.digest.clone(), - content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), - bytes: serde_json::to_vec(&RepositorySshAccessSecret { - private_key: "private-key-bytes".to_string(), - known_hosts_entry: "known-hosts-entry".to_string(), - }) - .unwrap(), - audit_correlation_id: handle.audit_correlation_id.clone(), - })), - })) + .install_workspace_backend_resource_client( + "workspace-1", + Arc::new(TestRepositoryResourceClient { + response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse { + kind: crate::resource::BackendResourceKind::RepositorySshAccess, + resource_id: handle.resource_id.clone(), + digest: handle.digest.clone(), + content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE + .to_string(), + bytes: serde_json::to_vec(&RepositorySshAccessSecret { + credential_candidates: vec![ + crate::resource::RepositorySshAccessSecretCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: "private-key-bytes-1".to_string(), + }, + crate::resource::RepositorySshAccessSecretCandidate { + credential_id: "credential-2".to_string(), + credential_revision: 3, + private_key: "private-key-bytes-2".to_string(), + }, + ], + known_hosts_entry: "known-hosts-entry".to_string(), + }) + .unwrap(), + audit_correlation_id: handle.audit_correlation_id.clone(), + })), + }), + ) .unwrap(); let request = WorkingDirectoryRepositoryAccessRequest { working_directory_id: "working-directory-1".to_string(), @@ -4028,8 +4142,18 @@ mod tests { config_projection_digest: "sha256:projection".to_string(), cache_generation: 0, ssh: Some(RepositorySshMaterializationAccess { - credential_id: "credential-1".to_string(), - credential_revision: 1, + credential_candidates: vec![ + RepositorySshCredentialCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: SensitiveString::default(), + }, + RepositorySshCredentialCandidate { + credential_id: "credential-2".to_string(), + credential_revision: 3, + private_key: SensitiveString::default(), + }, + ], host_trust_id: "host-trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadOnly, @@ -4038,7 +4162,6 @@ mod tests { repository_source_fingerprint: "sha256:source".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(), secret_resource: handle, - private_key: SensitiveString::default(), known_hosts_entry: SensitiveString::default(), }), }, @@ -4059,7 +4182,23 @@ mod tests { let accesses = backend.repository_accesses.lock().unwrap(); assert_eq!(accesses.len(), 1); let access = accesses[0].materialization.ssh.as_ref().unwrap(); - assert_eq!(access.private_key.expose(), "private-key-bytes"); + assert_eq!(access.credential_candidates.len(), 2); + assert_eq!( + access.credential_candidates[0].credential_id, + "credential-1" + ); + assert_eq!( + access.credential_candidates[1].credential_id, + "credential-2" + ); + assert_eq!( + access.credential_candidates[0].private_key.expose(), + "private-key-bytes-1" + ); + assert_eq!( + access.credential_candidates[1].private_key.expose(), + "private-key-bytes-2" + ); assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry"); } @@ -4072,20 +4211,35 @@ mod tests { runtime.bind_runtime_identity("runtime-1").unwrap(); let handle = repository_resource_handle(); runtime - .install_backend_resource_client(Arc::new(TestRepositoryResourceClient { - response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse { - kind: crate::resource::BackendResourceKind::RepositorySshAccess, - resource_id: handle.resource_id.clone(), - digest: handle.digest.clone(), - content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), - bytes: serde_json::to_vec(&RepositorySshAccessSecret { - private_key: "create-private-key-bytes".to_string(), - known_hosts_entry: "create-known-hosts-entry".to_string(), - }) - .unwrap(), - audit_correlation_id: handle.audit_correlation_id.clone(), - })), - })) + .install_workspace_backend_resource_client( + "workspace-1", + Arc::new(TestRepositoryResourceClient { + response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse { + kind: crate::resource::BackendResourceKind::RepositorySshAccess, + resource_id: handle.resource_id.clone(), + digest: handle.digest.clone(), + content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE + .to_string(), + bytes: serde_json::to_vec(&RepositorySshAccessSecret { + credential_candidates: vec![ + crate::resource::RepositorySshAccessSecretCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: "create-private-key-bytes-1".to_string(), + }, + crate::resource::RepositorySshAccessSecretCandidate { + credential_id: "credential-2".to_string(), + credential_revision: 3, + private_key: "create-private-key-bytes-2".to_string(), + }, + ], + known_hosts_entry: "create-known-hosts-entry".to_string(), + }) + .unwrap(), + audit_correlation_id: handle.audit_correlation_id.clone(), + })), + }), + ) .unwrap(); let request = WorkingDirectoryRequest { repository: WorkingDirectoryRepository { @@ -4109,8 +4263,18 @@ mod tests { config_projection_digest: "sha256:projection".to_string(), cache_generation: 0, ssh: Some(RepositorySshMaterializationAccess { - credential_id: "credential-1".to_string(), - credential_revision: 1, + credential_candidates: vec![ + RepositorySshCredentialCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: SensitiveString::default(), + }, + RepositorySshCredentialCandidate { + credential_id: "credential-2".to_string(), + credential_revision: 3, + private_key: SensitiveString::default(), + }, + ], host_trust_id: "host-trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadOnly, @@ -4119,7 +4283,6 @@ mod tests { repository_source_fingerprint: "sha256:source".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(), secret_resource: handle, - private_key: SensitiveString::default(), known_hosts_entry: SensitiveString::default(), }), }), @@ -4138,7 +4301,14 @@ mod tests { .as_ref() .and_then(|materialization| materialization.ssh.as_ref()) .unwrap(); - assert_eq!(access.private_key.expose(), "create-private-key-bytes"); + assert_eq!( + access.credential_candidates[0].private_key.expose(), + "create-private-key-bytes-1" + ); + assert_eq!( + access.credential_candidates[1].private_key.expose(), + "create-private-key-bytes-2" + ); assert_eq!( access.known_hosts_entry.expose(), "create-known-hosts-entry" diff --git a/crates/worker-runtime/src/ssh_host_key_probe.rs b/crates/worker-runtime/src/ssh_host_key_probe.rs new file mode 100644 index 00000000..9675aa68 --- /dev/null +++ b/crates/worker-runtime/src/ssh_host_key_probe.rs @@ -0,0 +1,368 @@ +//! Side-effect-free SSH host key discovery for Repository trust enrollment. +//! +//! Probing only observes public host keys. It does not persist trust, use clone +//! credentials, or authenticate to the target host. + +use base64::Engine as _; +use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::net::IpAddr; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; +use tokio::process::Command; + +pub const SSH_HOST_KEY_PROBE_PATH: &str = "/v1/repositories/ssh/probe"; +pub const SSH_HOST_KEY_PROBE_OPERATION: &str = "workdirs:operate"; +pub(crate) const SSH_KEYSCAN_TIMEOUT: Duration = Duration::from_secs(10); +const SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS: &str = "5"; +const MAX_SSH_KEYSCAN_OUTPUT_BYTES: usize = 64 * 1024; +const MAX_PROBE_CANDIDATES: usize = 32; +const MAX_DIAGNOSTIC_BYTES: usize = 256; + +/// `POST /v1/repositories/ssh/probe` request. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SshHostKeyProbeRequest { + pub hostname: String, + pub port: u16, +} + +/// One public host key observed by an SSH host key probe. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SshHostKeyCandidate { + /// Canonical OpenSSH public key text (`algorithm base64-key`), without a host prefix. + pub public_key: String, + /// OpenSSH public key algorithm name. + pub algorithm: String, + /// OpenSSH SHA-256 fingerprint (`SHA256:base64-digest`). + pub fingerprint: String, +} + +/// `POST /v1/repositories/ssh/probe` response. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SshHostKeyProbeResponse { + pub candidates: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum SshHostKeyProbeError { + #[error("SSH host key probe hostname is invalid")] + InvalidHostname, + #[error("SSH host key probe port must be greater than zero")] + InvalidPort, + #[error("SSH host key probe executable is unavailable")] + Unavailable, + #[error("SSH host key probe timed out")] + Timeout, + #[error("SSH host key probe failed: {diagnostic}")] + Failed { diagnostic: String }, +} + +/// Observe the target's public Ed25519 host keys without persisting trust or using credentials. +pub async fn probe_ssh_host_keys( + request: &SshHostKeyProbeRequest, +) -> Result { + probe_ssh_host_keys_with_program(request, Path::new("ssh-keyscan"), SSH_KEYSCAN_TIMEOUT).await +} + +pub(crate) async fn probe_ssh_host_keys_with_program( + request: &SshHostKeyProbeRequest, + program: &Path, + timeout: Duration, +) -> Result { + validate_request(request)?; + + let mut command = Command::new(program); + command + .args(["-T", SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS]) + .arg("-p") + .arg(request.port.to_string()) + .args(["-t", "ed25519"]) + .arg(&request.hostname) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + // ssh-keyscan diagnostics are intentionally not returned or retained: they may contain + // environment-specific details and are not needed for the public error contract. + .stderr(Stdio::null()) + .kill_on_drop(true); + + let output = tokio::time::timeout(timeout, command.output()) + .await + .map_err(|_| SshHostKeyProbeError::Timeout)? + .map_err(|_| SshHostKeyProbeError::Unavailable)?; + + if !output.status.success() { + return Err(SshHostKeyProbeError::Failed { + diagnostic: bounded_diagnostic(format!( + "ssh-keyscan exited unsuccessfully ({})", + output.status + )), + }); + } + if output.stdout.len() > MAX_SSH_KEYSCAN_OUTPUT_BYTES { + return Err(SshHostKeyProbeError::Failed { + diagnostic: "ssh-keyscan output exceeded the probe limit".to_string(), + }); + } + + let candidates = parse_ssh_keyscan_output(&output.stdout); + if candidates.is_empty() { + return Err(SshHostKeyProbeError::Failed { + diagnostic: "ssh-keyscan returned no valid ssh-ed25519 host keys".to_string(), + }); + } + Ok(SshHostKeyProbeResponse { candidates }) +} + +fn validate_request(request: &SshHostKeyProbeRequest) -> Result<(), SshHostKeyProbeError> { + if request.port == 0 { + return Err(SshHostKeyProbeError::InvalidPort); + } + validate_hostname(&request.hostname) +} + +fn validate_hostname(hostname: &str) -> Result<(), SshHostKeyProbeError> { + if hostname.is_empty() + || hostname.len() > 253 + || !hostname.is_ascii() + || hostname.bytes().any(|byte| byte.is_ascii_whitespace()) + || hostname.starts_with('-') + { + return Err(SshHostKeyProbeError::InvalidHostname); + } + if hostname.parse::().is_ok() { + return Ok(()); + } + + let hostname = hostname.strip_suffix('.').unwrap_or(hostname); + if hostname.is_empty() + || hostname.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err(SshHostKeyProbeError::InvalidHostname); + } + Ok(()) +} + +fn parse_ssh_keyscan_output(output: &[u8]) -> Vec { + let mut seen = BTreeSet::new(); + let mut candidates = Vec::new(); + for line in output.split(|byte| *byte == b'\n') { + let Ok(line) = std::str::from_utf8(line) else { + continue; + }; + let mut fields = line.split_ascii_whitespace(); + let (Some(_host), Some(algorithm), Some(encoded_key)) = + (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + if line.trim_start().starts_with('#') || algorithm != "ssh-ed25519" { + continue; + } + let Ok(key_blob) = STANDARD.decode(encoded_key) else { + continue; + }; + if !is_ed25519_public_key_blob(&key_blob) { + continue; + } + + let canonical_key = STANDARD.encode(&key_blob); + if !seen.insert(canonical_key.clone()) { + continue; + } + let public_key = format!("{algorithm} {canonical_key}"); + candidates.push(SshHostKeyCandidate { + algorithm: algorithm.to_string(), + fingerprint: format!( + "SHA256:{}", + STANDARD_NO_PAD.encode(Sha256::digest(&key_blob)) + ), + public_key, + }); + if candidates.len() == MAX_PROBE_CANDIDATES { + break; + } + } + candidates +} + +fn is_ed25519_public_key_blob(blob: &[u8]) -> bool { + let Some((algorithm, rest)) = take_ssh_string(blob) else { + return false; + }; + let Some((public_key, rest)) = take_ssh_string(rest) else { + return false; + }; + algorithm == b"ssh-ed25519" && public_key.len() == 32 && rest.is_empty() +} + +fn take_ssh_string(input: &[u8]) -> Option<(&[u8], &[u8])> { + let length = u32::from_be_bytes(input.get(..4)?.try_into().ok()?) as usize; + let value = input.get(4..4usize.checked_add(length)?)?; + let rest = input.get(4usize.checked_add(length)?..)?; + Some((value, rest)) +} + +fn bounded_diagnostic(mut diagnostic: String) -> String { + if diagnostic.len() <= MAX_DIAGNOSTIC_BYTES { + return diagnostic; + } + let mut end = MAX_DIAGNOSTIC_BYTES; + while !diagnostic.is_char_boundary(end) { + end -= 1; + } + diagnostic.truncate(end); + diagnostic +} + +#[cfg(test)] +mod tests { + use super::*; + + fn encoded_ed25519_key(seed: u8) -> String { + let mut blob = Vec::new(); + blob.extend_from_slice(&("ssh-ed25519".len() as u32).to_be_bytes()); + blob.extend_from_slice(b"ssh-ed25519"); + blob.extend_from_slice(&32_u32.to_be_bytes()); + blob.extend_from_slice(&[seed; 32]); + STANDARD.encode(blob) + } + + #[test] + fn hostname_validation_rejects_option_injection_and_ambiguous_text() { + for hostname in [ + "", + "-example.test", + "--help", + "example.test other.test", + "example.test\nother.test", + "example_test", + ".example.test", + "example..test", + "example.test:22", + "[::1]", + "éxample.test", + ] { + assert_eq!( + validate_hostname(hostname), + Err(SshHostKeyProbeError::InvalidHostname), + "{hostname:?} must be rejected" + ); + } + for hostname in [ + "localhost", + "example.test", + "example.test.", + "127.0.0.1", + "::1", + ] { + validate_hostname(hostname).unwrap(); + } + } + + #[test] + fn request_validation_rejects_zero_port() { + assert_eq!( + validate_request(&SshHostKeyProbeRequest { + hostname: "example.test".to_string(), + port: 0, + }), + Err(SshHostKeyProbeError::InvalidPort) + ); + } + + #[test] + fn parser_accepts_only_valid_ed25519_keys_and_deduplicates() { + let key = encoded_ed25519_key(7); + let other_key = encoded_ed25519_key(8); + let output = format!( + "# comment\nexample.test ssh-rsa AAAA\nexample.test ssh-ed25519 invalid!\nexample.test ssh-ed25519 {key}\n[example.test]:2222 ssh-ed25519 {key}\nexample.test ssh-ed25519 {other_key}\n" + ); + + let candidates = parse_ssh_keyscan_output(output.as_bytes()); + + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].algorithm, "ssh-ed25519"); + assert_eq!(candidates[0].public_key, format!("ssh-ed25519 {key}")); + let decoded = STANDARD.decode(key).unwrap(); + assert_eq!( + candidates[0].fingerprint, + format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(decoded))) + ); + } + + #[test] + fn parser_rejects_base64_that_is_not_an_ed25519_wire_key() { + let output = format!("example.test ssh-ed25519 {}\n", STANDARD.encode([1_u8; 32])); + assert!(parse_ssh_keyscan_output(output.as_bytes()).is_empty()); + } + + #[cfg(unix)] + #[tokio::test] + async fn unsuccessful_command_does_not_return_stderr() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = tempfile::tempdir().unwrap(); + let program = temp.path().join("ssh-keyscan"); + std::fs::write( + &program, + "#!/bin/sh\nprintf 'secret from stderr' >&2\nexit 7\n", + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + let error = probe_ssh_host_keys_with_program( + &SshHostKeyProbeRequest { + hostname: "example.test".to_string(), + port: 22, + }, + &program, + Duration::from_secs(1), + ) + .await + .unwrap_err(); + + let diagnostic = error.to_string(); + assert!(matches!(error, SshHostKeyProbeError::Failed { .. })); + assert!(!diagnostic.contains("secret")); + assert!(diagnostic.len() <= MAX_DIAGNOSTIC_BYTES + "SSH host key probe failed: ".len()); + } + + #[cfg(unix)] + #[tokio::test] + async fn command_execution_times_out_without_returning_process_diagnostics() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = tempfile::tempdir().unwrap(); + let program = temp.path().join("ssh-keyscan"); + std::fs::write( + &program, + "#!/bin/sh\nprintf 'secret from stderr' >&2\nsleep 2\n", + ) + .unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap(); + let request = SshHostKeyProbeRequest { + hostname: "example.test".to_string(), + port: 22, + }; + + let error = probe_ssh_host_keys_with_program(&request, &program, Duration::from_millis(20)) + .await + .unwrap_err(); + + assert_eq!(error, SshHostKeyProbeError::Timeout); + assert!(!error.to_string().contains("secret")); + } +} diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 2e6cbeb6..b1a42e9a 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -27,6 +27,9 @@ const MATERIALIZATION_RECORD: &str = "materialization.json"; const REPOSITORY_CACHE_DIR: &str = ".repository-cache"; const REPOSITORY_ACCESS_DIR: &str = ".repository-access"; const REPOSITORY_COMMAND_TIMEOUT: Duration = Duration::from_secs(300); +const REPOSITORY_SSH_CONNECT_TIMEOUT_SECONDS: &str = "10"; +const REPOSITORY_SSH_SERVER_ALIVE_INTERVAL_SECONDS: &str = "15"; +const REPOSITORY_SSH_SERVER_ALIVE_COUNT_MAX: &str = "2"; const REPOSITORY_MAX_OBJECTS: u64 = 5_000_000; const REPOSITORY_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024; static NEXT_WORKING_DIRECTORY_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -408,7 +411,16 @@ impl RuntimeGitCacheMaterializer { ) -> Result<(), WorkingDirectoryDiagnostic> { validate_ssh_materialization_access(ssh)?; let working_directory_id = working_directory_id.to_string(); - let credential_revision = ssh.credential_revision; + let credential_candidates = ssh + .credential_candidates + .iter() + .map(|candidate| { + ( + candidate.credential_id.clone(), + candidate.credential_revision, + ) + }) + .collect::>(); let expires_at = ssh.expires_at_epoch_seconds; self.repository_access .lock() @@ -429,9 +441,18 @@ impl RuntimeGitCacheMaterializer { std::thread::sleep(Duration::from_secs(expires_at - now)); } if let Ok(mut access) = repository_access.lock() - && access - .get(&working_directory_id) - .is_some_and(|access| access.credential_revision == credential_revision) + && access.get(&working_directory_id).is_some_and(|access| { + access + .credential_candidates + .iter() + .map(|candidate| { + ( + candidate.credential_id.clone(), + candidate.credential_revision, + ) + }) + .eq(credential_candidates.iter().cloned()) + }) { access.remove(&working_directory_id); } @@ -837,7 +858,8 @@ impl RuntimeGitCacheMaterializer { operation_id: context.map(|value| value.operation_id.clone()), credential_revision: context .and_then(|value| value.ssh.as_ref()) - .map(|value| value.credential_revision), + .and_then(|value| value.credential_candidates.first()) + .map(|candidate| candidate.credential_revision), host_trust_revision: context .and_then(|value| value.ssh.as_ref()) .map(|value| value.host_trust_revision), @@ -943,7 +965,12 @@ impl WorkingDirectoryMaterializer for RuntimeGitCacheMaterializer { )?; binding.working_directory.evidence.operation_id = Some(request.materialization.operation_id.clone()); - binding.working_directory.evidence.credential_revision = Some(ssh.credential_revision); + binding.working_directory.evidence.credential_revision = Some( + ssh.credential_candidates + .first() + .expect("validated SSH credential candidate") + .credential_revision, + ); binding.working_directory.evidence.host_trust_revision = Some(ssh.host_trust_revision); self.write_record(&binding)?; self.cache_repository_access(&request.working_directory_id, ssh) @@ -1263,35 +1290,37 @@ impl RepositorySshAgent { socket, child: Mutex::new(Some(child)), }; - let mut add = match Command::new("ssh-add") - .arg("-") - .env("SSH_AUTH_SOCK", &agent.socket) - .env("SSH_ASKPASS", "/bin/false") - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - { - Ok(add) => add, - Err(_) => { + for candidate in &access.credential_candidates { + let mut add = match Command::new("ssh-add") + .arg("-") + .env("SSH_AUTH_SOCK", &agent.socket) + .env("SSH_ASKPASS", "/bin/false") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(add) => add, + Err(_) => { + drop(agent); + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_repository_agent_unavailable", + "Runtime-managed Repository SSH agent is unavailable", + )); + } + }; + let write_result = add.stdin.as_mut().map_or_else( + || Err(std::io::Error::other("ssh-add stdin unavailable")), + |stdin| stdin.write_all(candidate.private_key.expose().as_bytes()), + ); + let status = add.wait(); + if write_result.is_err() || !matches!(status, Ok(status) if status.success()) { drop(agent); return Err(WorkingDirectoryDiagnostic::new( - "working_directory_repository_agent_unavailable", - "Runtime-managed Repository SSH agent is unavailable", + "working_directory_repository_agent_failed", + "Runtime-managed Repository SSH agent rejected credential material", )); } - }; - let write_result = add.stdin.as_mut().map_or_else( - || Err(std::io::Error::other("ssh-add stdin unavailable")), - |stdin| stdin.write_all(access.private_key.expose().as_bytes()), - ); - let status = add.wait(); - if write_result.is_err() || !matches!(status, Ok(status) if status.success()) { - drop(agent); - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_repository_agent_failed", - "Runtime-managed Repository SSH agent rejected credential material", - )); } Ok(agent) } @@ -1505,6 +1534,14 @@ fn run_brokered_repository_ssh( .take() .expect("complete broker request status"); let status = if !validate_repository_ssh_args(&args, policy) { + tracing::warn!( + target: "yoi::repository_access", + event = "repository_ssh_broker_request_rejected", + host = %policy.host, + port = policy.port.unwrap_or(22), + argument_count = args.len(), + "Repository SSH broker rejected command arguments" + ); let _ = stderr_stream.write_all(b"Repository SSH operation denied\n"); let _ = stderr_stream.shutdown(Shutdown::Write); let _ = data.shutdown(Shutdown::Both); @@ -1514,10 +1551,30 @@ fn run_brokered_repository_ssh( let _ = data.shutdown(Shutdown::Both); 0 } else { + tracing::info!( + target: "yoi::repository_access", + event = "repository_ssh_process_started", + host = %policy.host, + port = policy.port.unwrap_or(22), + "starting brokered Repository SSH process" + ); let mut command = Command::new("ssh"); command .args(["-F", "/dev/null"]) .args(["-o", "BatchMode=yes"]) + .args([ + "-o", + &format!("ConnectTimeout={REPOSITORY_SSH_CONNECT_TIMEOUT_SECONDS}"), + ]) + .args(["-o", "ConnectionAttempts=1"]) + .args([ + "-o", + &format!("ServerAliveInterval={REPOSITORY_SSH_SERVER_ALIVE_INTERVAL_SECONDS}"), + ]) + .args([ + "-o", + &format!("ServerAliveCountMax={REPOSITORY_SSH_SERVER_ALIVE_COUNT_MAX}"), + ]) .args(["-o", "IdentitiesOnly=no"]) .args(["-o", "IdentityFile=/dev/null"]) .args(["-o", "IdentityAgent=SSH_AUTH_SOCK"]) @@ -1539,11 +1596,11 @@ fn run_brokered_repository_ssh( let mut child_stdin = child.stdin.take().expect("piped ssh stdin"); let mut input = data.try_clone().expect("clone broker data stream"); let input_thread = std::thread::spawn(move || { - let _ = std::io::copy(&mut input, &mut child_stdin); + let _ = copy_streaming(&mut input, &mut child_stdin, "git_to_ssh"); }); let mut child_stdout = child.stdout.take().expect("piped ssh stdout"); let output_thread = std::thread::spawn(move || { - let _ = std::io::copy(&mut child_stdout, &mut data); + let _ = copy_streaming(&mut child_stdout, &mut data, "ssh_to_git"); let _ = data.shutdown(Shutdown::Write); }); let mut child_stderr = child.stderr.take().expect("piped ssh stderr"); @@ -1556,6 +1613,14 @@ fn run_brokered_repository_ssh( .ok() .and_then(|status| status.code()) .unwrap_or(1); + tracing::info!( + target: "yoi::repository_access", + event = "repository_ssh_process_completed", + host = %policy.host, + port = policy.port.unwrap_or(22), + exit_status = status, + "brokered Repository SSH process completed" + ); let _ = input_thread.join(); let _ = output_thread.join(); let _ = error_thread.join(); @@ -1568,6 +1633,72 @@ fn run_brokered_repository_ssh( let _ = status_stream.shutdown(Shutdown::Write); } +#[derive(Clone, Debug)] +struct ParsedRepositorySshSource { + host: String, + username: Option, + port: Option, + repository_path: String, +} + +fn parse_repository_ssh_source(value: &str) -> Option { + if let Ok(uri) = url::Url::parse(value) { + if uri.scheme() != "ssh" + || uri.password().is_some() + || uri.query().is_some() + || uri.fragment().is_some() + { + return None; + } + let host = uri.host_str()?.to_string(); + let username = (!uri.username().is_empty()).then(|| uri.username().to_string()); + if username + .as_deref() + .is_some_and(|username| !is_safe_ssh_destination(username)) + || uri.path().is_empty() + || uri.path() == "/" + || !is_safe_repository_path(uri.path()) + { + return None; + } + return Some(ParsedRepositorySshSource { + host, + username, + port: uri.port(), + repository_path: uri.path().to_string(), + }); + } + + if value.contains("://") + || value.contains('?') + || value.contains('#') + || value.chars().any(char::is_whitespace) + { + return None; + } + let (identity, repository_path) = value.split_once(':')?; + let (username, host) = identity.split_once('@')?; + if username.is_empty() + || host.is_empty() + || repository_path.is_empty() + || username.contains('@') + || username.contains(':') + || host.contains('@') + || repository_path.starts_with('-') + || !is_safe_ssh_destination(username) + || !is_safe_ssh_destination(host) + || !is_safe_repository_path(repository_path) + { + return None; + } + Some(ParsedRepositorySshSource { + host: host.to_ascii_lowercase(), + username: Some(username.to_string()), + port: None, + repository_path: repository_path.to_string(), + }) +} + #[derive(Clone, Debug)] struct RepositorySshCommandPolicy { host: String, @@ -1581,48 +1712,17 @@ impl RepositorySshCommandPolicy { fn from_access( access: &RepositorySshMaterializationAccess, ) -> Result { - let uri = url::Url::parse(&access.repository_uri).map_err(|_| { + let source = parse_repository_ssh_source(&access.repository_uri).ok_or_else(|| { WorkingDirectoryDiagnostic::new( "working_directory_repository_access_binding_mismatch", "Repository SSH access URI is invalid", ) })?; - if uri.scheme() != "ssh" - || uri.password().is_some() - || uri.query().is_some() - || uri.fragment().is_some() - { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_repository_access_binding_mismatch", - "Repository SSH access URI is not an authorized SSH endpoint", - )); - } - let host = uri - .host_str() - .filter(|host| !host.is_empty()) - .ok_or_else(|| { - WorkingDirectoryDiagnostic::new( - "working_directory_repository_access_binding_mismatch", - "Repository SSH access host is missing", - ) - })?; - let username = (!uri.username().is_empty()).then(|| uri.username().to_string()); - if username - .as_deref() - .is_some_and(|username| !is_safe_ssh_destination(username)) - || uri.path().is_empty() - || !is_safe_repository_path(uri.path()) - { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_repository_access_binding_mismatch", - "Repository SSH access endpoint or path is invalid", - )); - } Ok(Self { - host: host.to_string(), - username, - port: uri.port(), - repository_path: uri.path().to_string(), + host: source.host, + username: source.username, + port: source.port, + repository_path: source.repository_path, access: access.access, }) } @@ -1784,7 +1884,7 @@ pub fn run_repository_ssh_client(arguments: &[String]) -> Result { request_id: request_id.clone(), }, )?; - let mut status_stream = connect_repository_ssh_broker_channel( + let status_stream = connect_repository_ssh_broker_channel( socket, &RepositorySshBrokerHeader::Status { request_id }, )?; @@ -1792,15 +1892,67 @@ pub fn run_repository_ssh_client(arguments: &[String]) -> Result { .try_clone() .map_err(|_| "Repository SSH broker input failed".to_string())?; let input_thread = std::thread::spawn(move || { - let _ = std::io::copy(&mut std::io::stdin(), &mut input); + let _ = copy_streaming(&mut std::io::stdin(), &mut input, "git_to_broker"); let _ = input.shutdown(Shutdown::Write); }); let error_thread = std::thread::spawn(move || { let _ = std::io::copy(&mut stderr_stream, &mut std::io::stderr()); }); - std::io::copy(&mut data, &mut std::io::stdout()) + let mut stdout = std::io::stdout(); + copy_streaming(&mut data, &mut stdout, "broker_to_git") .map_err(|_| "Repository SSH broker output failed".to_string())?; - let _ = input_thread.join(); + complete_repository_ssh_client(data, input_thread, error_thread, status_stream) +} + +fn copy_streaming( + reader: &mut R, + writer: &mut W, + direction: &'static str, +) -> std::io::Result { + let mut copied = 0u64; + let mut buffer = [0u8; 16 * 1024]; + loop { + let read = match reader.read(&mut buffer) { + Ok(0) => { + tracing::info!( + target: "yoi::repository_access", + event = "repository_ssh_stream_completed", + direction, + bytes = copied, + "Repository SSH byte stream completed" + ); + return Ok(copied); + } + Ok(read) => read, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + }; + if copied == 0 { + tracing::info!( + target: "yoi::repository_access", + event = "repository_ssh_stream_first_chunk", + direction, + bytes = read, + "Repository SSH byte stream received its first chunk" + ); + } + writer.write_all(&buffer[..read])?; + writer.flush()?; + copied = copied.saturating_add(read as u64); + } +} + +fn complete_repository_ssh_client( + data: UnixStream, + input_thread: std::thread::JoinHandle<()>, + error_thread: std::thread::JoinHandle<()>, + mut status_stream: UnixStream, +) -> Result { + // The parent Git process keeps this client's stdin open until the client exits. Do not join + // the stdin pump here: that would wait for Git while Git waits for the broker status. Closing + // the data channel is enough to release the broker-side stdin pump. + let _ = data.shutdown(Shutdown::Both); + drop(input_thread); let _ = error_thread.join(); let mut status = String::new(); status_stream @@ -1826,6 +1978,27 @@ fn connect_repository_ssh_broker_channel( Ok(stream) } +fn repository_command_access_root( + runtime_root: &Path, + operation_id: &str, + repository_id: &str, +) -> PathBuf { + let mut digest = Sha256::new(); + digest.update(operation_id.as_bytes()); + digest.update([0]); + digest.update(repository_id.as_bytes()); + digest.update([0]); + digest.update(next_working_directory_id("access").as_bytes()); + let access_id = digest + .finalize() + .iter() + .take(8) + .map(|byte| format!("{byte:02x}")) + .collect::(); + // Keep operation-scoped Unix socket paths below sockaddr_un::sun_path on deep runtime roots. + runtime_root.join(REPOSITORY_ACCESS_DIR).join(access_id) +} + #[derive(Debug)] struct RepositoryCommandAccess { root: PathBuf, @@ -1854,7 +2027,7 @@ impl RepositoryCommandAccess { .as_ref() .map(|materialization| materialization.operation_id.as_str()) .unwrap_or("operation"); - Ok(Some(Self::prepare_ssh( + Ok(Some(Self::prepare_materialization_ssh( runtime_root, operation_id, &request.repository.id, @@ -1862,26 +2035,61 @@ impl RepositoryCommandAccess { )?)) } + fn prepare_materialization_ssh( + runtime_root: &Path, + operation_id: &str, + repository_id: &str, + ssh: &RepositorySshMaterializationAccess, + ) -> Result { + let root = repository_command_access_root(runtime_root, operation_id, repository_id); + fs::create_dir_all(&root).map_err(|_| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_setup_failed", + "operation-scoped Repository access could not be prepared", + ) + })?; + set_directory_owner_only(&root)?; + let known_hosts = root.join("known_hosts"); + let ssh_command = root.join("ssh-command"); + write_owner_only(&known_hosts, ssh.known_hosts_entry.expose().as_bytes())?; + let agent = Arc::new(RepositorySshAgent::start(runtime_root, operation_id, ssh)?); + let policy = RepositorySshCommandPolicy::from_access(ssh)?; + let destination = match policy.username.as_deref() { + Some(username) => format!("{username}@{}", policy.host), + None => policy.host.clone(), + }; + let port = policy + .port + .map(|port| format!("-p {} ", shell_quote(&port.to_string()))) + .unwrap_or_default(); + let remote_command = format!("git-upload-pack {}", shell_quote(&policy.repository_path)); + let script = format!( + "#!/bin/sh\nfor arg in \"$@\"; do [ \"$arg\" = -G ] && exit 0; done\nexec ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout={} -o ConnectionAttempts=1 -o ServerAliveInterval={} -o ServerAliveCountMax={} -o IdentitiesOnly=no -o IdentityFile=/dev/null -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=yes -o UserKnownHostsFile={} -o ClearAllForwardings=yes -o PermitLocalCommand=no {}-- {} {}\n", + REPOSITORY_SSH_CONNECT_TIMEOUT_SECONDS, + REPOSITORY_SSH_SERVER_ALIVE_INTERVAL_SECONDS, + REPOSITORY_SSH_SERVER_ALIVE_COUNT_MAX, + shell_quote_path(&known_hosts)?, + port, + shell_quote(&destination), + shell_quote(&remote_command), + ); + write_owner_only(&ssh_command, script.as_bytes())?; + set_file_owner_executable(&ssh_command)?; + Ok(Self { + root, + ssh_command, + agent, + ssh_broker: None, + }) + } + fn prepare_ssh( runtime_root: &Path, operation_id: &str, repository_id: &str, ssh: &RepositorySshMaterializationAccess, ) -> Result { - let mut digest = Sha256::new(); - digest.update(operation_id.as_bytes()); - digest.update([0]); - digest.update(repository_id.as_bytes()); - digest.update([0]); - digest.update(next_working_directory_id("access").as_bytes()); - let access_id = digest - .finalize() - .iter() - .take(8) - .map(|byte| format!("{byte:02x}")) - .collect::(); - // Keep operation-scoped Unix socket paths below sockaddr_un::sun_path on deep runtime roots. - let root = runtime_root.join(REPOSITORY_ACCESS_DIR).join(access_id); + let root = repository_command_access_root(runtime_root, operation_id, repository_id); fs::create_dir_all(&root).map_err(|_| { WorkingDirectoryDiagnostic::new( "working_directory_repository_access_setup_failed", @@ -1968,11 +2176,14 @@ fn validate_ssh_materialization_access( "operation-scoped SSH credential and host-trust authority has expired", )); } - if access.credential_id.trim().is_empty() - || access.credential_revision == 0 + if access.credential_candidates.is_empty() + || access.credential_candidates.iter().any(|candidate| { + candidate.credential_id.trim().is_empty() + || candidate.credential_revision == 0 + || !candidate.private_key.expose().contains("PRIVATE KEY") + }) || access.host_trust_id.trim().is_empty() || access.host_trust_revision == 0 - || !access.private_key.expose().contains("PRIVATE KEY") || access.known_hosts_entry.expose().trim().is_empty() { return Err(WorkingDirectoryDiagnostic::new( @@ -1990,31 +2201,14 @@ fn repository_transport_warning(kind: workspace_api::RepositorySourceKind) -> Op fn validate_remote_source_uri( request: &WorkingDirectoryRequest, ) -> Result<(), WorkingDirectoryDiagnostic> { - let url = url::Url::parse(&request.repository.source.uri).map_err(|_| { - WorkingDirectoryDiagnostic::new( - "working_directory_repository_source_invalid", - "remote Repository source URI is invalid", - ) - })?; - let expected_scheme = match request.repository.source.kind { - workspace_api::RepositorySourceKind::Https => "https", - workspace_api::RepositorySourceKind::Http => "http", - workspace_api::RepositorySourceKind::Ssh => "ssh", - _ => return Ok(()), - }; - if url.scheme() != expected_scheme - || url.host_str().is_none() - || url.password().is_some() - || !url.query().is_none() - || !url.fragment().is_none() - || (matches!(expected_scheme, "https" | "http") && !url.username().is_empty()) - { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_repository_source_invalid", - "remote Repository source URI is invalid or contains forbidden credentials", - )); - } - if expected_scheme == "ssh" { + if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh { + let source = + parse_repository_ssh_source(&request.repository.source.uri).ok_or_else(|| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_source_invalid", + "remote Repository source URI is invalid", + ) + })?; let access = request .materialization .as_ref() @@ -2025,11 +2219,10 @@ fn validate_remote_source_uri( "SSH Repository materialization requires operation-scoped credential and host-trust authority", ) })?; - let host = url.host_str().unwrap_or_default(); - let known_host = if url.port().unwrap_or(22) == 22 { - format!("{host} ") + let known_host = if source.port.unwrap_or(22) == 22 { + format!("{} ", source.host) } else { - format!("[{host}]:{} ", url.port().unwrap_or(22)) + format!("[{}]:{} ", source.host, source.port.unwrap_or(22)) }; if !access.known_hosts_entry.expose().starts_with(&known_host) { return Err(WorkingDirectoryDiagnostic::new( @@ -2037,6 +2230,31 @@ fn validate_remote_source_uri( "SSH Repository source does not match the operation-scoped host-trust authority", )); } + return Ok(()); + } + + let url = url::Url::parse(&request.repository.source.uri).map_err(|_| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_source_invalid", + "remote Repository source URI is invalid", + ) + })?; + let expected_scheme = match request.repository.source.kind { + workspace_api::RepositorySourceKind::Https => "https", + workspace_api::RepositorySourceKind::Http => "http", + _ => return Ok(()), + }; + if url.scheme() != expected_scheme + || url.host_str().is_none() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !url.username().is_empty() + { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_repository_source_invalid", + "remote Repository source URI is invalid or contains forbidden credentials", + )); } Ok(()) } @@ -2253,10 +2471,17 @@ fn run_repository_git( mut command: Command, code: &'static str, ) -> Result<(), WorkingDirectoryDiagnostic> { + tracing::info!( + target: "yoi::repository_access", + event = "repository_git_operation_started", + stage = code, + "starting Git Repository operation" + ); + let started = Instant::now(); let mut child = command .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::piped()) .spawn() .map_err(|_| { WorkingDirectoryDiagnostic::new( @@ -2264,7 +2489,8 @@ fn run_repository_git( "Git command could not be executed; backend-private path details were omitted", ) })?; - let started = Instant::now(); + let mut stderr = child.stderr.take().expect("piped Git stderr"); + let stderr_reader = std::thread::spawn(move || read_bounded_command_output(&mut stderr)); let status = loop { if let Some(status) = child.try_wait().map_err(|_| { WorkingDirectoryDiagnostic::new( @@ -2277,6 +2503,13 @@ fn run_repository_git( if started.elapsed() >= REPOSITORY_COMMAND_TIMEOUT { let _ = child.kill(); let _ = child.wait(); + tracing::warn!( + target: "yoi::repository_access", + event = "repository_git_operation_timed_out", + stage = code, + elapsed_ms = started.elapsed().as_millis() as u64, + "Git Repository operation exceeded the Runtime time limit" + ); return Err(WorkingDirectoryDiagnostic::new( "working_directory_repository_timeout", "Git Repository operation exceeded the Runtime time limit", @@ -2284,16 +2517,78 @@ fn run_repository_git( } std::thread::sleep(Duration::from_millis(25)); }; + let stderr = stderr_reader.join().unwrap_or_default(); if status.success() { + tracing::info!( + target: "yoi::repository_access", + event = "repository_git_operation_succeeded", + stage = code, + elapsed_ms = started.elapsed().as_millis() as u64, + "Git Repository operation succeeded" + ); Ok(()) } else { - Err(WorkingDirectoryDiagnostic::new( - code, - "Git Repository operation failed; credentials and backend-private path details were omitted", - )) + let diagnostic = repository_git_failure_diagnostic(code, &stderr); + tracing::warn!( + target: "yoi::repository_access", + event = "repository_git_operation_failed", + stage = code, + diagnostic_code = %diagnostic.code, + elapsed_ms = started.elapsed().as_millis() as u64, + exit_status = %status, + "Git Repository operation failed" + ); + Err(diagnostic) } } +fn repository_git_failure_diagnostic( + default_code: &'static str, + stderr: &[u8], +) -> WorkingDirectoryDiagnostic { + let stderr = String::from_utf8_lossy(stderr).to_ascii_lowercase(); + let (code, message) = if stderr.contains("repository ssh operation denied") { + ( + "working_directory_repository_access_denied", + "Git SSH command was rejected by the operation-scoped Repository access policy", + ) + } else if stderr.contains("host key verification failed") + || stderr.contains("no ed25519 host key is known") + { + ( + "working_directory_repository_host_trust_failed", + "Git SSH host key verification failed", + ) + } else if stderr.contains("permission denied") || stderr.contains("publickey") { + ( + "working_directory_repository_authentication_failed", + "Git SSH authentication failed for every operation-scoped credential candidate", + ) + } else if stderr.contains("connection timed out") + || stderr.contains("connection refused") + || stderr.contains("network is unreachable") + || stderr.contains("no route to host") + || stderr.contains("could not resolve hostname") + || stderr.contains("name or service not known") + { + ( + "working_directory_repository_connection_failed", + "Git SSH connection to the Repository host failed", + ) + } else if stderr.contains("repository not found") { + ( + "working_directory_repository_not_found", + "Git Repository was not found or is not accessible", + ) + } else { + ( + default_code, + "Git Repository operation failed; credentials and backend-private path details were omitted", + ) + }; + WorkingDirectoryDiagnostic::new(code, message) +} + fn resolve_cached_commit( repository_cache: &Path, selector: &str, @@ -2507,9 +2802,12 @@ fn set_file_owner_executable(path: &Path) -> Result<(), WorkingDirectoryDiagnost Ok(()) } +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + fn shell_quote_path(path: &Path) -> Result { - let value = path_str(path)?; - Ok(format!("'{}'", value.replace('\'', "'\\''"))) + Ok(shell_quote(&path_str(path)?)) } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -2673,6 +2971,25 @@ mod tests { } } + fn repository_ssh_access() -> crate::catalog::RepositorySshMaterializationAccess { + crate::catalog::RepositorySshMaterializationAccess { + credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 2, + private_key: crate::catalog::SensitiveString::new("PRIVATE KEY secret bytes"), + }], + host_trust_id: "trust-1".to_string(), + host_trust_revision: 4, + access: workspace_api::RepositoryAccessMode::ReadOnly, + expires_at_epoch_seconds: u64::MAX, + repository_id: "repo-main".to_string(), + repository_source_fingerprint: "sha256:test".to_string(), + repository_uri: "ssh://git@example.test/repo.git".to_string(), + secret_resource: repository_resource_handle(), + known_hosts_entry: crate::catalog::SensitiveString::new("host key secret bytes"), + } + } + fn git(path: &Path, args: &[&str]) { let status = Command::new("git") .arg("-C") @@ -3054,24 +3371,48 @@ mod tests { #[test] fn sensitive_repository_access_debug_output_is_redacted() { - let access = crate::catalog::RepositorySshMaterializationAccess { - credential_id: "credential-1".to_string(), - credential_revision: 2, - host_trust_id: "trust-1".to_string(), - host_trust_revision: 4, - access: workspace_api::RepositoryAccessMode::ReadOnly, - expires_at_epoch_seconds: u64::MAX, - repository_id: "repo-main".to_string(), - repository_source_fingerprint: "sha256:test".to_string(), - repository_uri: "ssh://git@example.test/repo.git".to_string(), - secret_resource: repository_resource_handle(), - private_key: crate::catalog::SensitiveString::new("PRIVATE KEY secret bytes"), - known_hosts_entry: crate::catalog::SensitiveString::new("host key secret bytes"), - }; + let access = repository_ssh_access(); let debug = format!("{access:?}"); assert!(!debug.contains("secret bytes")); assert!(debug.contains("[REDACTED]")); + + let serialized = serde_json::to_value(&access).unwrap(); + assert_eq!( + serialized["credential_candidates"][0]["credential_id"], + "credential-1" + ); + assert_eq!( + serialized["credential_candidates"][0]["credential_revision"], + 2 + ); + assert!( + serialized["credential_candidates"][0] + .get("private_key") + .is_none() + ); + assert!(serialized.get("known_hosts_entry").is_none()); + let decoded: crate::catalog::RepositorySshMaterializationAccess = + serde_json::from_value(serialized).unwrap(); + assert!( + decoded.credential_candidates[0] + .private_key + .expose() + .is_empty() + ); + assert!(decoded.known_hosts_entry.expose().is_empty()); + } + + #[test] + fn repository_ssh_access_rejects_empty_credential_candidates() { + let mut access = repository_ssh_access(); + access.credential_candidates.clear(); + + let error = validate_ssh_materialization_access(&access).unwrap_err(); + assert_eq!( + error.code, + "working_directory_remote_repository_access_invalid" + ); } #[test] @@ -3080,12 +3421,15 @@ mod tests { let runtime_root = tempfile::tempdir().unwrap(); let key_root = tempfile::tempdir().unwrap(); let key_path = key_root.path().join("id_ed25519"); - let status = Command::new("ssh-keygen") - .args(["-q", "-t", "ed25519", "-N", "", "-f"]) - .arg(&key_path) - .status() - .unwrap(); - assert!(status.success()); + let fallback_key_path = key_root.path().join("id_ed25519_fallback"); + for path in [&key_path, &fallback_key_path] { + let status = Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-f"]) + .arg(path) + .status() + .unwrap(); + assert!(status.success()); + } let materializer = RuntimeGitCacheMaterializer::new(runtime_root.path()); let mut request = request(repo.path()); request.materialization = Some(crate::catalog::RepositoryMaterializationContext { @@ -3096,8 +3440,22 @@ mod tests { config_projection_digest: "sha256:projection".to_string(), cache_generation: 0, ssh: Some(crate::catalog::RepositorySshMaterializationAccess { - credential_id: "credential-1".to_string(), - credential_revision: 1, + credential_candidates: vec![ + crate::catalog::RepositorySshCredentialCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: crate::catalog::SensitiveString::new( + fs::read_to_string(&key_path).unwrap(), + ), + }, + crate::catalog::RepositorySshCredentialCandidate { + credential_id: "credential-2".to_string(), + credential_revision: 3, + private_key: crate::catalog::SensitiveString::new( + fs::read_to_string(&fallback_key_path).unwrap(), + ), + }, + ], host_trust_id: "trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadWrite, @@ -3106,9 +3464,6 @@ mod tests { repository_source_fingerprint: "sha256:test".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(), secret_resource: repository_resource_handle(), - private_key: crate::catalog::SensitiveString::new( - fs::read_to_string(&key_path).unwrap(), - ), known_hosts_entry: crate::catalog::SensitiveString::new( "example.test ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample", ), @@ -3123,7 +3478,58 @@ mod tests { .unwrap() .unwrap(); assert!(!command_access.root.join("identity").exists()); + assert!(command_access.ssh_broker.is_none()); + let materialization_ssh_command = fs::read_to_string(&command_access.ssh_command).unwrap(); + assert!(materialization_ssh_command.contains("exec ssh -F /dev/null")); + assert!(materialization_ssh_command.contains("git-upload-pack")); + assert!(!materialization_ssh_command.contains("__repository-ssh")); + assert!(!materialization_ssh_command.contains("PRIVATE KEY")); + let fake_bin = command_access.root.join("fake-bin"); + fs::create_dir(&fake_bin).unwrap(); + let fake_ssh = fake_bin.join("ssh"); + write_owner_only( + &fake_ssh, + b"#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$CAPTURE\"\n", + ) + .unwrap(); + set_file_owner_executable(&fake_ssh).unwrap(); + let captured = command_access.root.join("captured-ssh-args"); + let wrapper_status = Command::new(&command_access.ssh_command) + .args(["attacker@example.invalid", "arbitrary-command"]) + .env("PATH", &fake_bin) + .env("CAPTURE", &captured) + .status() + .unwrap(); + assert!(wrapper_status.success()); + let captured = fs::read_to_string(captured).unwrap(); + assert!(captured.contains("git@example.test")); + assert!(captured.contains("git-upload-pack '/repo.git'")); + assert!(!captured.contains("attacker@example.invalid")); + assert!(!captured.contains("arbitrary-command")); assert!(command_access.agent.socket.exists()); + let identities = Command::new("ssh-add") + .arg("-L") + .env("SSH_AUTH_SOCK", &command_access.agent.socket) + .output() + .unwrap(); + assert!(identities.status.success()); + let identity_blobs = String::from_utf8(identities.stdout) + .unwrap() + .lines() + .map(|line| line.split_whitespace().nth(1).unwrap().to_string()) + .collect::>(); + let expected_blobs = [&key_path, &fallback_key_path] + .into_iter() + .map(|path| { + fs::read_to_string(path.with_extension("pub")) + .unwrap() + .split_whitespace() + .nth(1) + .unwrap() + .to_string() + }) + .collect::>(); + assert_eq!(identity_blobs, expected_blobs); let operation_socket = command_access.agent.socket.clone(); drop(command_access); assert!(!operation_socket.exists()); @@ -3238,7 +3644,7 @@ mod tests { ); let mut rotated = initial_materialization.clone(); rotated.operation_id = "operation-agent-rotated".to_string(); - rotated.ssh.as_mut().unwrap().credential_revision = 2; + rotated.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 2; rotated.ssh.as_mut().unwrap().access = workspace_api::RepositoryAccessMode::ReadOnly; materializer .authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest { @@ -3360,7 +3766,7 @@ mod tests { let mut read_write = rotated; read_write.operation_id = "operation-agent-read-write".to_string(); - read_write.ssh.as_mut().unwrap().credential_revision = 3; + read_write.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 3; read_write.ssh.as_mut().unwrap().access = workspace_api::RepositoryAccessMode::ReadWrite; let read_write_command_policy = RepositorySshCommandPolicy::from_access(read_write.ssh.as_ref().unwrap()).unwrap(); @@ -3432,8 +3838,11 @@ mod tests { config_projection_digest: "sha256:projection".to_string(), cache_generation: 0, ssh: Some(crate::catalog::RepositorySshMaterializationAccess { - credential_id: "credential-1".to_string(), - credential_revision: 1, + credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"), + }], host_trust_id: "trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadOnly, @@ -3442,7 +3851,6 @@ mod tests { repository_source_fingerprint: "sha256:test".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(), secret_resource: repository_resource_handle(), - private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"), known_hosts_entry: crate::catalog::SensitiveString::new( "example.test ssh-ed25519 placeholder", ), @@ -3478,6 +3886,150 @@ mod tests { } } + #[test] + fn repository_ssh_output_flushes_every_protocol_chunk() { + struct ChunkReader { + chunks: std::collections::VecDeque<&'static [u8]>, + } + impl Read for ChunkReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let Some(chunk) = self.chunks.pop_front() else { + return Ok(0); + }; + buffer[..chunk.len()].copy_from_slice(chunk); + Ok(chunk.len()) + } + } + #[derive(Default)] + struct FlushRecorder { + pending: Vec, + flushed: Vec>, + } + impl Write for FlushRecorder { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.pending.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.flushed.push(std::mem::take(&mut self.pending)); + Ok(()) + } + } + + let mut reader = ChunkReader { + chunks: std::collections::VecDeque::from([&b"0008"[..], &b"NAK\n"[..]]), + }; + let mut writer = FlushRecorder::default(); + assert_eq!( + copy_streaming(&mut reader, &mut writer, "test_protocol").unwrap(), + 8 + ); + assert_eq!(writer.flushed, [b"0008".to_vec(), b"NAK\n".to_vec()]); + } + + #[test] + fn repository_git_stderr_is_classified_without_exposing_raw_output() { + let cases: &[(&[u8], &str)] = &[ + ( + b"git@example.test: Permission denied (publickey).\n", + "working_directory_repository_authentication_failed", + ), + ( + b"Host key verification failed.\n", + "working_directory_repository_host_trust_failed", + ), + ( + b"ssh: connect to host example.test port 22: Connection timed out\n", + "working_directory_repository_connection_failed", + ), + ( + b"Repository SSH operation denied\n", + "working_directory_repository_access_denied", + ), + ]; + for (stderr, expected_code) in cases { + let diagnostic = repository_git_failure_diagnostic( + "working_directory_repository_fetch_failed", + stderr, + ); + assert_eq!(&diagnostic.code, expected_code); + assert!(!diagnostic.message.contains("example.test")); + } + } + + #[test] + fn repository_ssh_client_completion_does_not_wait_for_parent_stdin() { + let (data, _broker_data) = UnixStream::pair().unwrap(); + let (status, mut broker_status) = UnixStream::pair().unwrap(); + broker_status.write_all(b"0\n").unwrap(); + broker_status.shutdown(Shutdown::Write).unwrap(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let input_thread = std::thread::spawn(move || { + release_rx.recv().unwrap(); + finished_tx.send(()).unwrap(); + }); + let error_thread = std::thread::spawn(|| {}); + + assert_eq!( + complete_repository_ssh_client(data, input_thread, error_thread, status).unwrap(), + 0 + ); + release_tx.send(()).unwrap(); + finished_rx + .recv_timeout(Duration::from_secs(1)) + .expect("detached stdin pump exits after its input closes"); + } + + #[test] + fn scp_like_ssh_source_is_validated_and_authorized() { + let uri = "git@example.test:team/repo.git"; + let source = parse_repository_ssh_source(uri).expect("SCP-like source parses"); + assert_eq!(source.host, "example.test"); + assert_eq!(source.username.as_deref(), Some("git")); + assert_eq!(source.port, None); + assert_eq!(source.repository_path, "team/repo.git"); + + let mut access = repository_ssh_access(); + access.repository_uri = uri.to_string(); + access.known_hosts_entry = + crate::catalog::SensitiveString::new("example.test ssh-ed25519 placeholder"); + let policy = RepositorySshCommandPolicy::from_access(&access).unwrap(); + assert_eq!(policy.host, "example.test"); + assert_eq!(policy.username.as_deref(), Some("git")); + assert_eq!(policy.repository_path, "team/repo.git"); + + let mut request = request(Path::new(".")); + request.repository.source = workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::Ssh, + uri: uri.to_string(), + }; + request.materialization = Some(crate::catalog::RepositoryMaterializationContext { + workspace_id: "workspace-1".to_string(), + runtime_id: "runtime-1".to_string(), + operation_id: "operation-1".to_string(), + config_revision: 1, + config_projection_digest: "sha256:projection".to_string(), + cache_generation: 0, + ssh: Some(access), + }); + validate_remote_source_uri(&request).unwrap(); + + for invalid in [ + "git@example.test:", + "@example.test:team/repo.git", + "git@:team/repo.git", + "git@example.test:-upload-pack", + "git@example.test:team/repo.git?ref=main", + ] { + assert!( + parse_repository_ssh_source(invalid).is_none(), + "invalid SCP-like source was accepted: {invalid}" + ); + } + } + #[test] fn remote_source_rejects_uri_credentials_and_mismatched_host_trust() { let repo = create_clean_repo(); @@ -3526,8 +4078,11 @@ mod tests { }; ssh.materialization = Some(context(Some( crate::catalog::RepositorySshMaterializationAccess { - credential_id: "credential-1".to_string(), - credential_revision: 1, + credential_candidates: vec![crate::catalog::RepositorySshCredentialCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"), + }], host_trust_id: "trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadOnly, @@ -3536,7 +4091,6 @@ mod tests { repository_source_fingerprint: "sha256:test".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(), secret_resource: repository_resource_handle(), - private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"), known_hosts_entry: crate::catalog::SensitiveString::new( "other.test ssh-ed25519 placeholder", ), diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 3dd32212..6608584f 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -1152,6 +1152,58 @@ pub struct RepositoryDetailResponse { pub source: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositorySshConnectionProbeRequest { + pub runtime_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositorySshHostKeyCandidate { + pub algorithm: String, + pub host_key: String, + pub fingerprint: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum RepositorySshConnectionTrustState { + Untrusted, + Verified, + Changed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositorySshConnectionProbeResponse { + pub workspace_id: String, + pub repository_key: String, + pub runtime_id: String, + pub hostname: String, + pub port: u16, + pub trust_state: RepositorySshConnectionTrustState, + pub host_trust_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub expected_host_trust_revision: Option, + pub candidates: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ConfirmRepositorySshHostTrustRequest { + pub operation_id: String, + pub runtime_id: String, + pub host_key: String, + #[cfg_attr(feature = "typescript", ts(type = "number | null"))] + pub expected_host_trust_revision: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -2455,6 +2507,27 @@ pub struct CreateRepositorySshCredentialRequest { pub passphrase: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct GenerateRepositorySshCredentialRequest { + pub operation_id: String, + pub credential_id: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RepositorySshPublicKey { + pub credential_id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub current_revision: u64, + pub public_key_algorithm: String, + pub public_key_fingerprint: String, + pub public_key: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -2997,6 +3070,11 @@ pub fn catalog_typescript() -> String { GitCommitSummary::decl(&config), RepositoryListResponse::decl(&config), RepositoryDetailResponse::decl(&config), + RepositorySshConnectionProbeRequest::decl(&config), + RepositorySshHostKeyCandidate::decl(&config), + RepositorySshConnectionTrustState::decl(&config), + RepositorySshConnectionProbeResponse::decl(&config), + ConfirmRepositorySshHostTrustRequest::decl(&config), RepositoryLogResponse::decl(&config), RuntimeSourceKind::decl(&config), RuntimeSourceStatus::decl(&config), @@ -3041,6 +3119,8 @@ pub fn repository_access_api_typescript() -> String { let declarations = [ RepositorySshCredential::decl(&config), CreateRepositorySshCredentialRequest::decl(&config), + GenerateRepositorySshCredentialRequest::decl(&config), + RepositorySshPublicKey::decl(&config), RotateRepositorySshCredentialRequest::decl(&config), DeleteRepositorySshCredentialRequest::decl(&config), RepositorySshHostTrust::decl(&config), diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index cd0778c9..c7c29822 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -67,6 +67,10 @@ use worker_runtime::profile_archive::ProfileSourceArchive; use worker_runtime::retention::{ WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory, }; +use worker_runtime::ssh_host_key_probe::{ + SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SshHostKeyProbeRequest, + SshHostKeyProbeResponse, +}; use worker_runtime::workspace_issuer::{ WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt, @@ -82,6 +86,9 @@ const MAX_REMOTE_RUNTIME_RESPONSE_BYTES: usize = 16 * 1024 * 1024; // Runtime creation can spend up to 60s bootstrapping; durable Submit // acceptance is acknowledged before the potentially long run preparation. const REMOTE_WORKER_CREATE_TIMEOUT: Duration = Duration::from_secs(80); +// Repository materialization can spend up to 300s in Git. Keep the HTTP +// caller alive long enough for Runtime to return its bounded result. +const REMOTE_WORKING_DIRECTORY_CREATE_TIMEOUT: Duration = Duration::from_secs(330); const MAX_HOST_SCAN: usize = 256; const MAX_IDENTIFIER_LEN: usize = 120; const ID_DIGEST_HEX_LEN: usize = 16; @@ -826,6 +833,17 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { )) } + fn probe_ssh_host_keys( + &self, + _request: SshHostKeyProbeRequest, + ) -> std::result::Result { + Err(Error::RuntimeOperationFailed { + runtime_id: self.runtime_id().to_string(), + code: "ssh_host_key_probe_unsupported".to_string(), + message: "Runtime does not support SSH host key probing".to_string(), + }) + } + fn activate_workspace_authorization(&self, _binding: crate::store::WorkspaceRuntimeBinding) {} fn send_workspace_verification_challenge( @@ -1569,6 +1587,31 @@ impl RuntimeRegistry { }) } + pub fn probe_ssh_host_keys( + &self, + runtime_id: &str, + request: SshHostKeyProbeRequest, + ) -> Result { + validate_backend_identifier("runtime_id", runtime_id)?; + let runtime = self.runtime(runtime_id)?; + runtime + .probe_ssh_host_keys(request) + .map_err(|error| match error { + Error::RuntimeOperationFailed { code, message, .. } => { + RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code, + message, + } + } + other => RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "ssh_host_key_probe_failed".to_string(), + message: other.to_string(), + }, + }) + } + pub fn observe_repository_ref( &self, runtime_id: &str, @@ -3404,10 +3447,11 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s return "workers:create"; } if (path == "/v1/working-directories/repository-access" - || path == "/v1/repository-refs/observe") + || path == "/v1/repository-refs/observe" + || path == SSH_HOST_KEY_PROBE_PATH) && method == "POST" { - return "workdirs:operate"; + return SSH_HOST_KEY_PROBE_OPERATION; } if path.starts_with("/v1/workdir-sessions") || (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions")) @@ -3631,6 +3675,19 @@ impl RemoteWorkerRuntime { } fn post_json(&self, path: &str, body: &B) -> Result + where + B: Serialize + ?Sized, + T: DeserializeOwned + Send + 'static, + { + self.post_json_with_timeout(path, body, None) + } + + fn post_json_with_timeout( + &self, + path: &str, + body: &B, + timeout: Option, + ) -> Result where B: Serialize + ?Sized, T: DeserializeOwned + Send + 'static, @@ -3642,15 +3699,15 @@ impl RemoteWorkerRuntime { error.to_string(), ) })?; - self.send_json( - path, - "POST", - &body, - self.http - .post(self.endpoint(path)) - .header(CONTENT_TYPE, "application/json") - .body(body.clone()), - ) + let mut request = self + .http + .post(self.endpoint(path)) + .header(CONTENT_TYPE, "application/json") + .body(body.clone()); + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + self.send_json(path, "POST", &body, request) } fn post_bytes(&self, path: &str, body: &[u8]) -> Result @@ -4140,9 +4197,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { &self, request: WorkingDirectoryRequest, ) -> RuntimeWorkingDirectoryResult { - match self.post_json::<_, RuntimeHttpWorkingDirectoryResponse>( + match self.post_json_with_timeout::<_, RuntimeHttpWorkingDirectoryResponse>( "/v1/working-directories", &request, + Some(REMOTE_WORKING_DIRECTORY_CREATE_TIMEOUT), ) { Ok(response) => RuntimeWorkingDirectoryResult { state: WorkerOperationState::Accepted, @@ -4169,6 +4227,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { .map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message)) } + fn probe_ssh_host_keys( + &self, + request: SshHostKeyProbeRequest, + ) -> std::result::Result { + self.post_json::<_, SshHostKeyProbeResponse>(SSH_HOST_KEY_PROBE_PATH, &request) + .map_err(|diagnostic| Error::RuntimeOperationFailed { + runtime_id: self.runtime_id.clone(), + code: diagnostic.code, + message: diagnostic.message, + }) + } + fn observe_repository_ref( &self, request: RepositoryRefObservationRequest, @@ -5369,6 +5439,15 @@ mod tests { assert!(REMOTE_WORKER_CREATE_TIMEOUT > Duration::from_secs(60 + 10 + 5)); } + #[test] + fn remote_working_directory_create_timeout_covers_git_budget() { + assert!(REMOTE_WORKING_DIRECTORY_CREATE_TIMEOUT > Duration::from_secs(300)); + assert!( + REMOTE_WORKING_DIRECTORY_CREATE_TIMEOUT + > worker_runtime::resource::DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT + ); + } + fn test_create_binding() -> WorkerCreateBinding { WorkerCreateBinding { worker_id: EmbeddedWorkerId::now_v7(), @@ -6593,6 +6672,10 @@ mod tests { workspace_runtime_operation("GET", &format!("/v1/workers/{worker_id}/protocol/ws")), "workers:protocol" ); + assert_eq!( + workspace_runtime_operation("POST", SSH_HOST_KEY_PROBE_PATH), + SSH_HOST_KEY_PROBE_OPERATION + ); } #[test] diff --git a/crates/workspace-server/src/repository_access.rs b/crates/workspace-server/src/repository_access.rs index 9f8fe10f..3433a8f5 100644 --- a/crates/workspace-server/src/repository_access.rs +++ b/crates/workspace-server/src/repository_access.rs @@ -7,16 +7,19 @@ use std::sync::Arc; use chrono::{SecondsFormat, Utc}; use config_source::ConfigSchemaContribution; use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; +use ring::hmac; use ring::rand::{SecureRandom, SystemRandom}; use rusqlite::{OptionalExtension, TransactionBehavior, params}; use serde::Deserialize; use sha2::{Digest, Sha256}; +use ssh_key::private::Ed25519Keypair; use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey}; use workspace_api::{ CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest, - DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode, - RepositoryAccessProjection, RepositorySshAccessBinding, RepositorySshCredential, - RepositorySshHostTrust, RotateRepositorySshCredentialRequest, + DeleteRepositorySshHostTrustRequest, GenerateRepositorySshCredentialRequest, + PutRepositorySshHostTrustRequest, RepositoryAccessMode, RepositoryAccessProjection, + RepositorySshAccessBinding, RepositorySshCredential, RepositorySshHostTrust, + RepositorySshPublicKey, RotateRepositorySshCredentialRequest, }; use crate::config_source::{ @@ -42,6 +45,9 @@ const MAX_NAME_BYTES: usize = 200; const MAX_IDENTIFIER_BYTES: usize = 128; const MASTER_KEY_BYTES: usize = 32; const NONCE_BYTES: usize = 12; +pub const WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID: &str = "workspace-default"; +const WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID: &str = "workspace-default-repository-ssh-v1"; +const WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME: &str = "Workspace default SSH key"; #[derive(Debug, Default)] pub struct RepositoryAccessConfigSchemaProvider; @@ -130,6 +136,43 @@ pub fn project_repository_access_state( ) } +pub(crate) fn repository_ssh_endpoint( + repository_key: &str, + repository_uri: &str, +) -> Result> { + if !repository_uri.contains("://") { + if let Some((identity, path)) = repository_uri.split_once(':') + && !path.is_empty() + && let Some((_, hostname)) = identity.rsplit_once('@') + && !hostname.is_empty() + { + return Ok(Some((hostname.to_ascii_lowercase(), 22))); + } + } + let parsed = url::Url::parse(repository_uri).map_err(|error| { + Error::InvalidInput(format!( + "Repository `{repository_key}` has invalid SSH URI: {error}" + )) + })?; + if parsed.scheme() != "ssh" { + return Ok(None); + } + if parsed.username().is_empty() || parsed.password().is_some() { + return Err(Error::InvalidInput(format!( + "Repository `{repository_key}` must use ssh://user@host[:port]/path without embedded credentials" + ))); + } + let hostname = parsed.host_str().ok_or_else(|| { + Error::InvalidInput(format!( + "Repository `{repository_key}` SSH URI has no hostname" + )) + })?; + Ok(Some(( + hostname.to_ascii_lowercase(), + parsed.port().unwrap_or(22), + ))) +} + fn project_repository_access_evaluation( store: &dyn ControlPlaneStore, secrets: &RepositorySecretService, @@ -145,6 +188,9 @@ fn project_repository_access_evaluation( .map_err(|error| { Error::InvalidInput(format!("invalid Repository access config: {error}")) })?; + if !config.repository_access.is_empty() { + secrets.ensure_workspace_default_credential(workspace_id)?; + } let mut bindings = Vec::with_capacity(config.repository_access.len()); for (repository_key, access) in config.repository_access { workspace_api::validate_repository_key(&repository_key) @@ -181,22 +227,14 @@ fn project_repository_access_evaluation( access.ssh.host_trust )) })?; - let uri = url::Url::parse(&repository.source.uri).map_err(|_| { - Error::InvalidInput(format!( - "Repository `{repository_key}` has an invalid SSH URI" - )) - })?; - if uri.scheme() != "ssh" || uri.username().is_empty() || uri.password().is_some() { - return Err(Error::InvalidInput(format!( - "Repository `{repository_key}` must use ssh://user@host[:port]/path without credentials" - ))); - } - let hostname = uri.host_str().ok_or_else(|| { - Error::InvalidInput(format!( - "Repository `{repository_key}` SSH URI has no hostname" - )) - })?; - let port = uri.port().unwrap_or(22); + let (hostname, port) = + repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else( + || { + Error::InvalidInput(format!( + "Repository `{repository_key}` must use an SSH source" + )) + }, + )?; if hostname != host_trust.hostname || port != host_trust.port { return Err(Error::InvalidInput(format!( "Repository `{repository_key}` SSH host does not match host trust `{}`", @@ -245,6 +283,152 @@ impl RepositorySecretService { }) } + fn generated_ed25519_private_key( + &self, + workspace_id: &str, + operation_id: &str, + credential_id: &str, + intent: &str, + ) -> Result { + let master_key = self.master_key.as_ref().ok_or_else(|| { + Error::Store("Repository secret encryption authority is unavailable".to_string()) + })?; + let key = hmac::Key::new(hmac::HMAC_SHA256, master_key.as_slice()); + let context = format!( + "yoi/repository-ssh-key/v1\0{workspace_id}\0{operation_id}\0{credential_id}\0{intent}" + ); + let seed = hmac::sign(&key, context.as_bytes()); + PrivateKey::from(Ed25519Keypair::from_seed( + seed.as_ref().try_into().map_err(|_| { + Error::Store("generated SSH Ed25519 seed had an invalid length".to_string()) + })?, + )) + .to_openssh(LineEnding::LF) + .map(|key| key.to_string()) + .map_err(|err| Error::Store(format!("failed to encode generated SSH key: {err}"))) + } + + pub fn generate_credential( + &self, + workspace_id: &str, + request: GenerateRepositorySshCredentialRequest, + actor_account_id: &str, + ) -> Result { + let operation_id = validate_identifier("operation_id", &request.operation_id)?; + let credential_id = validate_identifier("credential_id", &request.credential_id)?; + let name = normalize_name(&request.name)?; + let private_key = self.generated_ed25519_private_key( + workspace_id, + &operation_id, + &credential_id, + &format!("create\0{name}"), + )?; + self.create_credential( + workspace_id, + CreateRepositorySshCredentialRequest { + operation_id, + credential_id, + name, + private_key, + passphrase: None, + }, + actor_account_id, + ) + } + + pub fn ensure_workspace_default_credential( + &self, + workspace_id: &str, + ) -> Result { + if let Some(credential) = self.store.with_conn(|conn| { + read_credential( + conn, + workspace_id, + WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID, + ) + })? { + return Ok(credential); + } + self.generate_credential( + workspace_id, + GenerateRepositorySshCredentialRequest { + operation_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID.to_string(), + credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(), + name: WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME.to_string(), + }, + "workspace-system", + ) + } + + pub fn credential_public_key( + &self, + workspace_id: &str, + credential_id: &str, + ) -> Result> { + let credential_id = validate_identifier("credential_id", credential_id)?; + let Some((credential, private_secret, passphrase_secret)) = + self.store.with_conn(|conn| { + let Some(credential) = read_credential(conn, workspace_id, &credential_id)? else { + return Ok(None); + }; + let private_secret = read_sealed_secret( + conn, + workspace_id, + &credential_id, + credential.current_revision, + "private_key", + )? + .ok_or_else(|| Error::Store("credential private key is missing".to_string()))?; + let passphrase_secret = read_sealed_secret( + conn, + workspace_id, + &credential_id, + credential.current_revision, + "passphrase", + )?; + Ok(Some((credential, private_secret, passphrase_secret))) + })? + else { + return Ok(None); + }; + let private_key = zeroize::Zeroizing::new(self.unseal( + workspace_id, + &credential_id, + credential.current_revision, + "private_key", + private_secret, + )?); + let passphrase = passphrase_secret + .map(|secret| { + self.unseal( + workspace_id, + &credential_id, + credential.current_revision, + "passphrase", + secret, + ) + .map(zeroize::Zeroizing::new) + }) + .transpose()?; + let private_key = std::str::from_utf8(private_key.as_slice()) + .map_err(|_| Error::Store("credential private key is not UTF-8".to_string()))?; + let passphrase = passphrase + .as_deref() + .map(|value| std::str::from_utf8(value.as_slice())) + .transpose() + .map_err(|_| Error::Store("credential passphrase is not UTF-8".to_string()))?; + let parsed = parse_private_key(private_key, passphrase).map_err(|err| { + Error::Store(format!("stored credential private key is invalid: {err}")) + })?; + Ok(Some(RepositorySshPublicKey { + credential_id, + current_revision: credential.current_revision, + public_key_algorithm: parsed.algorithm, + public_key_fingerprint: parsed.fingerprint, + public_key: parsed.public_key, + })) + } + pub fn create_credential( &self, workspace_id: &str, @@ -384,6 +568,11 @@ impl RepositorySecretService { actor_account_id: &str, ) -> Result { let credential_id = validate_identifier("credential_id", credential_id)?; + if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID { + return Err(Error::WorkspaceConfigConflict( + "Workspace default SSH credential is immutable".to_string(), + )); + } let operation_id = validate_identifier("operation_id", &request.operation_id)?; let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?; let next_revision = request @@ -529,6 +718,11 @@ impl RepositorySecretService { projection: &RepositoryAccessProjection, ) -> Result<()> { let credential_id = validate_identifier("credential_id", credential_id)?; + if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID { + return Err(Error::WorkspaceConfigConflict( + "Workspace default SSH credential is immutable".to_string(), + )); + } let operation_id = validate_identifier("operation_id", &request.operation_id)?; let references = credential_references(projection, &credential_id); if !references.is_empty() { @@ -839,6 +1033,73 @@ impl RepositorySecretService { }) } + pub fn host_trusts_for_endpoint( + &self, + workspace_id: &str, + hostname: &str, + port: u16, + ) -> Result> { + self.store.with_conn(|conn| { + let mut statement = conn.prepare( + r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm, + host_key, fingerprint, current_revision, created_at, updated_at + FROM repository_ssh_host_trusts + WHERE workspace_id = ?1 AND lower(hostname) = lower(?2) AND port = ?3 + ORDER BY host_trust_id"#, + )?; + statement + .query_map( + params![workspace_id, hostname, i64::from(port)], + read_host_trust_row, + )? + .collect::, _>>() + .map_err(Error::from) + }) + } + + pub fn automatic_host_trust_id(hostname: &str, port: u16) -> String { + let normalized = hostname + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .take(96) + .collect::(); + format!("tofu-{normalized}-{port}") + } + + pub fn default_ssh_binding_for_repository( + &self, + workspace_id: &str, + repository_key: &str, + repository_uri: &str, + ) -> Result> { + let Some((hostname, port)) = repository_ssh_endpoint(repository_key, repository_uri)? + else { + return Ok(None); + }; + let matches = self.host_trusts_for_endpoint(workspace_id, &hostname, port)?; + let Some(host_trust) = matches.first() else { + return Ok(None); + }; + if matches.len() > 1 { + return Err(Error::InvalidInput(format!( + "Repository `{repository_key}` matches multiple SSH host trusts for {hostname}:{port}; configure an explicit Repository access binding" + ))); + } + self.ensure_workspace_default_credential(workspace_id)?; + Ok(Some(RepositorySshAccessBinding { + repository_key: repository_key.to_string(), + credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(), + host_trust_id: host_trust.host_trust_id.clone(), + access: RepositoryAccessMode::ReadOnly, + })) + } + pub fn lease_ssh_materialization_access( &self, workspace_id: &str, @@ -1056,6 +1317,7 @@ impl RepositorySecretService { struct ParsedKey { algorithm: String, fingerprint: String, + public_key: String, } fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result { @@ -1092,6 +1354,9 @@ fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result bool { + if matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) { + return false; + } + // Fetching a one-time Runtime resource consumes only an in-memory broker handle. It is a + // callback required to finish an already-gated mutation, not a Workspace state mutation. + path != format!("/api/runtime/v1/workspaces/{workspace_id}/resources/fetch") +} + async fn dispatch_workspace_request( State(api): State, mut request: Request, @@ -1867,10 +1884,8 @@ async fn dispatch_workspace_request( let path = request.uri().path().to_owned(); let workspace_id = scoped_workspace_id(&path); let _mutation_guard = if let Some(workspace_id) = workspace_id - && !matches!( - *request.method(), - Method::GET | Method::HEAD | Method::OPTIONS - ) { + && workspace_request_requires_mutation_lock(request.method(), &path, workspace_id) + { Some(api.mutation_lock(workspace_id).await.lock_owned().await) } else { None @@ -3053,11 +3068,19 @@ fn build_inner_router(api: WorkspaceApi) -> Router { get(scoped_list_repository_ssh_credentials) .post(scoped_create_repository_ssh_credential), ) + .route( + "/api/w/{workspace_id}/settings/repository-access/credentials/generate", + post(scoped_generate_repository_ssh_credential), + ) .route( "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}", get(scoped_get_repository_ssh_credential) .delete(scoped_delete_repository_ssh_credential), ) + .route( + "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/public-key", + get(scoped_get_repository_ssh_public_key), + ) .route( "/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/rotate", post(scoped_rotate_repository_ssh_credential), @@ -3342,6 +3365,11 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/repositories/{repository_key}", get(scoped_repository_detail), ) + .route( + "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", + post(scoped_probe_repository_ssh_connection) + .put(scoped_confirm_repository_ssh_host_trust), + ) .route("/api/repositories/{repository_key}/log", get(repository_log)) .route( "/api/w/{workspace_id}/repositories/{repository_key}/log", @@ -4449,6 +4477,8 @@ async fn scoped_list_repository_ssh_credentials( Extension(actor): Extension, ) -> ApiResult>> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + api.repository_secrets + .ensure_workspace_default_credential(&path.workspace_id)?; let projection = active_repository_access_projection(&api, &path.workspace_id)?; Ok(Json( api.repository_secrets @@ -4472,6 +4502,19 @@ async fn scoped_get_repository_ssh_credential( Ok(Json(credential)) } +async fn scoped_get_repository_ssh_public_key( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, +) -> ApiResult> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + let public_key = api + .repository_secrets + .credential_public_key(&path.workspace_id, &path.credential_id)? + .ok_or_else(|| Error::InvalidRecordId(path.credential_id.clone()))?; + Ok(Json(public_key)) +} + async fn scoped_create_repository_ssh_credential( State(api): State, AxumPath(path): AxumPath, @@ -4479,12 +4522,43 @@ async fn scoped_create_repository_ssh_credential( Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + if request.credential_id + == crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID + { + return Err(Error::InvalidInput( + "Workspace default SSH credential id is reserved".to_string(), + ) + .into()); + } let credential = api.repository_secrets .create_credential(&path.workspace_id, request, &actor.account_id)?; Ok((StatusCode::CREATED, Json(credential))) } +async fn scoped_generate_repository_ssh_credential( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + if request.credential_id + == crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID + { + return Err(Error::InvalidInput( + "Workspace default SSH credential id is reserved".to_string(), + ) + .into()); + } + let credential = api.repository_secrets.generate_credential( + &path.workspace_id, + request, + &actor.account_id, + )?; + Ok((StatusCode::CREATED, Json(credential))) +} + async fn scoped_rotate_repository_ssh_credential( State(api): State, AxumPath(path): AxumPath, @@ -9270,6 +9344,148 @@ async fn scoped_repository_detail( repository_detail(State(api), AxumPath(path.repository_key)).await } +async fn scoped_probe_repository_ssh_connection( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + require_active_workspace_runtime_binding(&api, &request.runtime_id).await?; + probe_repository_ssh_connection(&api, &path, &request.runtime_id).map(Json) +} + +async fn scoped_confirm_repository_ssh_host_trust( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult> { + require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; + require_active_workspace_runtime_binding(&api, &request.runtime_id).await?; + let probe = probe_repository_ssh_connection(&api, &path, &request.runtime_id)?; + let candidate = probe + .candidates + .iter() + .find(|candidate| candidate.host_key == request.host_key) + .ok_or_else(|| { + settings_bad_request( + "repository_ssh_host_key_not_observed", + "Selected SSH host key was not presented by the target Runtime", + ) + })?; + if request.expected_host_trust_revision != probe.expected_host_trust_revision { + return Err(ApiError::from(Error::WorkspaceConfigConflict( + "SSH host trust revision changed after the connection test".to_string(), + ))); + } + let host_trust = api.repository_secrets.put_host_trust( + &path.workspace_id, + PutRepositorySshHostTrustRequest { + operation_id: request.operation_id, + host_trust_id: probe.host_trust_id, + hostname: probe.hostname, + port: probe.port, + host_key: candidate.host_key.clone(), + expected_revision: probe.expected_host_trust_revision, + }, + &actor.account_id, + )?; + Ok(Json(host_trust)) +} + +fn repository_ssh_connection_trust_state( + candidates: &[RepositorySshHostKeyCandidate], + existing_fingerprint: Option<&str>, +) -> RepositorySshConnectionTrustState { + match existing_fingerprint { + None => RepositorySshConnectionTrustState::Untrusted, + Some(fingerprint) + if candidates + .iter() + .any(|candidate| candidate.fingerprint == fingerprint) => + { + RepositorySshConnectionTrustState::Verified + } + Some(_) => RepositorySshConnectionTrustState::Changed, + } +} + +fn probe_repository_ssh_connection( + api: &WorkspaceApi, + path: &ScopedRepositoryPath, + runtime_id: &str, +) -> ApiResult { + validate_workspace_scope(api, &path.workspace_id)?; + let repository = api.require_configured_workspace_repository_by_key(&path.repository_key)?; + let (hostname, port) = crate::repository_access::repository_ssh_endpoint( + &path.repository_key, + &repository.source.uri, + )? + .ok_or_else(|| { + settings_bad_request( + "repository_ssh_connection_test_not_applicable", + "Repository does not use an SSH source", + ) + })?; + let observed = api + .runtime + .probe_ssh_host_keys( + runtime_id, + worker_runtime::ssh_host_key_probe::SshHostKeyProbeRequest { + hostname: hostname.clone(), + port, + }, + ) + .map_err(|error| error.into_error())?; + let candidates = observed + .candidates + .into_iter() + .map(|candidate| RepositorySshHostKeyCandidate { + algorithm: candidate.algorithm, + host_key: candidate.public_key, + fingerprint: candidate.fingerprint, + }) + .collect::>(); + if candidates.is_empty() { + return Err(settings_bad_request( + "repository_ssh_host_key_not_observed", + "Target Runtime did not observe an SSH Ed25519 host key", + )); + } + let matches = + api.repository_secrets + .host_trusts_for_endpoint(&path.workspace_id, &hostname, port)?; + if matches.len() > 1 { + return Err(ApiError::from(Error::WorkspaceConfigConflict( + "Multiple SSH host trust records match this Repository endpoint".to_string(), + ))); + } + let existing = matches.first(); + let trust_state = repository_ssh_connection_trust_state( + &candidates, + existing.map(|host_trust| host_trust.fingerprint.as_str()), + ); + Ok(RepositorySshConnectionProbeResponse { + workspace_id: path.workspace_id.clone(), + repository_key: path.repository_key.clone(), + runtime_id: runtime_id.to_string(), + hostname: hostname.clone(), + port, + trust_state, + host_trust_id: existing.map_or_else( + || { + crate::repository_access::RepositorySecretService::automatic_host_trust_id( + &hostname, port, + ) + }, + |host_trust| host_trust.host_trust_id.clone(), + ), + expected_host_trust_revision: existing.map(|host_trust| host_trust.current_revision), + candidates, + }) +} + async fn scoped_repository_log( State(api): State, AxumPath(path): AxumPath, @@ -10921,12 +11137,31 @@ fn working_directory_detail_for_runtime( runtime_id: &str, working_directory_id: &str, ) -> ApiResult> { + let existing = api + .store + .get_workdir_registry(&api.config.workspace_id, working_directory_id)? + .ok_or_else(|| { + ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "working_directory_not_found".to_string(), + message: format!("Unknown Workdir `{working_directory_id}`"), + }, + Vec::new(), + ) + })?; + if existing.runtime_id != runtime_id { + return Err(ApiError::from(Error::WorkspacePermissionDenied( + "Workdir does not belong to the requested Runtime".to_string(), + ))); + } let result = api .runtime .working_directory(runtime_id, working_directory_id) .map_err(|err| err.into_error())?; if let Some(working_directory) = result.working_directory { - let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary); + let mut record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary); + preserve_workdir_identity_for_corrupted_summary(&mut record, Some(&existing)); api.store.upsert_workdir_registry(&record)?; let summary = projected_workdir_summary_from_record(&api, &record)?; return Ok(Json(BrowserWorkingDirectoryDetailResponse { @@ -10936,25 +11171,12 @@ fn working_directory_detail_for_runtime( diagnostics: working_directory_diagnostics(result.diagnostics), })); } - if let Some(record) = api - .store - .get_workdir_registry(&api.config.workspace_id, working_directory_id)? - { - return Ok(Json(BrowserWorkingDirectoryDetailResponse { - workspace_id: api.config.workspace_id.clone(), - runtime_id: runtime_id.to_string(), - item: projected_workdir_summary_from_record(&api, &record)?, - diagnostics: working_directory_diagnostics(result.diagnostics), - })); - } - Err(ApiError::with_diagnostics( - Error::RuntimeOperationFailed { - runtime_id: runtime_id.to_string(), - code: "workspace_working_directory_lookup_failed".to_string(), - message: "Runtime did not return working directory".to_string(), - }, - result.diagnostics, - )) + Ok(Json(BrowserWorkingDirectoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: runtime_id.to_string(), + item: projected_workdir_summary_from_record(&api, &existing)?, + diagnostics: working_directory_diagnostics(result.diagnostics), + })) } fn workdir_removal_response( @@ -14555,6 +14777,7 @@ fn backend_resource_error_status(error: &BackendResourceError) -> StatusCode { | BackendResourceError::Oversized { .. } | BackendResourceError::ContentTypeMismatch { .. } | BackendResourceError::InvalidResponse { .. } => StatusCode::BAD_REQUEST, + BackendResourceError::Timeout => StatusCode::GATEWAY_TIMEOUT, BackendResourceError::Transport { .. } => StatusCode::BAD_GATEWAY, } } @@ -16904,6 +17127,35 @@ fn upsert_pending_backend_workdir( Ok(workdir_id) } +fn reconcile_runtime_workdir_observations( + api: &WorkspaceApi, + runtime_id: &str, + items: &[worker_runtime::catalog::WorkingDirectoryStatus], +) -> ApiResult> { + let mut observed = std::collections::BTreeSet::new(); + for status in items { + let Some(existing) = api.store.get_workdir_registry( + &api.config.workspace_id, + &status.summary.working_directory_id, + )? + else { + continue; + }; + if existing.runtime_id != runtime_id { + continue; + } + observed.insert(status.summary.working_directory_id.clone()); + if status.summary.status == WorkingDirectoryStatusKind::NotFound { + persist_workdir_not_found(api, existing)?; + continue; + } + let mut record = workdir_record_from_summary(api, runtime_id, &status.summary); + preserve_workdir_identity_for_corrupted_summary(&mut record, Some(&existing)); + api.store.upsert_workdir_registry(&record)?; + } + Ok(observed) +} + fn sync_runtime_workdir_observations( api: &WorkspaceApi, runtime_id: &str, @@ -16912,26 +17164,7 @@ fn sync_runtime_workdir_observations( .runtime .list_working_directories(runtime_id) .map_err(|err| err.into_error())?; - let mut observed = std::collections::BTreeSet::new(); - for status in &response.items { - observed.insert(status.summary.working_directory_id.clone()); - if status.summary.status == WorkingDirectoryStatusKind::NotFound { - if let Some(record) = api.store.get_workdir_registry( - &api.config.workspace_id, - &status.summary.working_directory_id, - )? { - persist_workdir_not_found(api, record)?; - } - continue; - } - let existing = api.store.get_workdir_registry( - &api.config.workspace_id, - &status.summary.working_directory_id, - )?; - let mut record = workdir_record_from_summary(api, runtime_id, &status.summary); - preserve_workdir_identity_for_corrupted_summary(&mut record, existing.as_ref()); - api.store.upsert_workdir_registry(&record)?; - } + let observed = reconcile_runtime_workdir_observations(api, runtime_id, &response.items)?; for mut record in api .store .list_workdir_registry(&api.config.workspace_id, 500)? @@ -17295,6 +17528,30 @@ fn validate_working_directory_claim_for_browser( Ok(()) } +fn repository_ssh_lease_candidates( + api: &WorkspaceApi, + primary: crate::repository_access::LeasedRepositorySshAccess, +) -> ApiResult> { + let workspace_default = api + .repository_secrets + .ensure_workspace_default_credential(&api.config.workspace_id)?; + if primary.credential_id + == crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID + { + return Ok(vec![primary]); + } + let workspace_default = api + .repository_secrets + .lease_ssh_materialization_access_revision( + &api.config.workspace_id, + crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID, + workspace_default.current_revision, + &primary.host_trust_id, + primary.host_trust_revision, + )?; + Ok(vec![primary, workspace_default]) +} + fn authorize_repository_materialization_operation( api: &WorkspaceApi, operation: &WorkdirCreateOperationRecord, @@ -17324,6 +17581,16 @@ fn authorize_repository_materialization_operation( host_trust_id, host_trust_revision, )?; + let leases = repository_ssh_lease_candidates(api, lease)?; + let primary_lease = leases.first().ok_or_else(|| { + settings_bad_request( + "working_directory_repository_access_invalid", + "Repository SSH access has no credential candidates", + ) + })?; + let primary_host_trust_id = primary_lease.host_trust_id.clone(); + let primary_host_trust_revision = primary_lease.host_trust_revision; + let known_hosts_entry = primary_lease.known_hosts_entry.clone(); let access = match access_mode { "read_only" => workspace_api::RepositoryAccessMode::ReadOnly, "read_write" => workspace_api::RepositoryAccessMode::ReadWrite, @@ -17342,13 +17609,30 @@ fn authorize_repository_materialization_operation( &operation.resolved_runtime_id, format!("repository-ssh-access:{}", operation.operation_id), format!( - "credential:{}:host-trust:{}", - lease.credential_revision, lease.host_trust_revision + "credentials:{}:host-trust:{}", + leases + .iter() + .map(|lease| format!( + "{}:{}", + lease.credential_id, lease.credential_revision + )) + .collect::>() + .join(","), + primary_host_trust_revision ), i64::try_from(expires_at_epoch_seconds).unwrap_or(i64::MAX), worker_runtime::resource::RepositorySshAccessSecret { - private_key: lease.private_key.as_str().to_string(), - known_hosts_entry: lease.known_hosts_entry.clone(), + credential_candidates: leases + .iter() + .map(|lease| { + worker_runtime::resource::RepositorySshAccessSecretCandidate { + credential_id: lease.credential_id.clone(), + credential_revision: lease.credential_revision, + private_key: lease.private_key.as_str().to_string(), + } + }) + .collect(), + known_hosts_entry: known_hosts_entry.clone(), }, ) .map_err(|_| { @@ -17365,17 +17649,22 @@ fn authorize_repository_materialization_operation( config_projection_digest: operation.config_projection_digest.clone(), cache_generation: operation.cache_generation, ssh: Some(RepositorySshMaterializationAccess { - credential_id: lease.credential_id, - credential_revision: lease.credential_revision, - host_trust_id: lease.host_trust_id, - host_trust_revision: lease.host_trust_revision, + credential_candidates: leases + .into_iter() + .map(|lease| RepositorySshCredentialCandidate { + credential_id: lease.credential_id, + credential_revision: lease.credential_revision, + private_key: SensitiveString::default(), + }) + .collect(), + host_trust_id: primary_host_trust_id, + host_trust_revision: primary_host_trust_revision, access, expires_at_epoch_seconds, repository_id: request.repository.id.clone(), repository_source_fingerprint: request.repository.source_fingerprint.clone(), repository_uri: request.repository.source.uri.clone(), secret_resource, - private_key: SensitiveString::default(), known_hosts_entry: SensitiveString::default(), }), } @@ -17408,12 +17697,18 @@ fn authorize_repository_materialization_operation( "SSH Repository access authority is unavailable", ) })?; + let primary_credential = ssh.credential_candidates.first().ok_or_else(|| { + settings_bad_request( + "working_directory_repository_access_invalid", + "Repository SSH access has no credential candidates", + ) + })?; api.config_store.bind_workdir_create_repository_access( &api.config.workspace_id, &operation.operation_id, request_fingerprint, - &ssh.credential_id, - ssh.credential_revision, + &primary_credential.credential_id, + primary_credential.credential_revision, &ssh.host_trust_id, ssh.host_trust_revision, match ssh.access { @@ -17473,19 +17768,40 @@ fn authorize_repository_materialization( .map(|repository| repository.repository_key.as_str()) .ok_or_else(|| Error::UnknownRepository(request.repository.id.clone()))?; let ssh = if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh { - let binding = projection + let binding = match projection .bindings .iter() .find(|binding| binding.repository_key == repository_key) - .ok_or_else(|| { - settings_bad_request( - "working_directory_remote_repository_access_required", - "SSH Repository has no active Workspace credential and host-trust binding", - ) - })?; + .cloned() + { + Some(binding) => binding, + None => api + .repository_secrets + .default_ssh_binding_for_repository( + &api.config.workspace_id, + repository_key, + &request.repository.source.uri, + )? + .ok_or_else(|| { + settings_bad_request( + "working_directory_remote_repository_host_trust_required", + "SSH Repository has no pinned host trust matching its URI", + ) + })?, + }; let lease = api .repository_secrets - .lease_ssh_materialization_access(&api.config.workspace_id, binding)?; + .lease_ssh_materialization_access(&api.config.workspace_id, &binding)?; + let leases = repository_ssh_lease_candidates(api, lease)?; + let primary_lease = leases.first().ok_or_else(|| { + settings_bad_request( + "working_directory_repository_access_invalid", + "Repository SSH access has no credential candidates", + ) + })?; + let primary_host_trust_id = primary_lease.host_trust_id.clone(); + let primary_host_trust_revision = primary_lease.host_trust_revision; + let known_hosts_entry = primary_lease.known_hosts_entry.clone(); let expires_at_epoch_seconds = repository_access_expiry(); let secret_resource = api .resource_broker @@ -17494,13 +17810,30 @@ fn authorize_repository_materialization( runtime_id, format!("repository-ssh-access:{operation_id}"), format!( - "credential:{}:host-trust:{}", - lease.credential_revision, lease.host_trust_revision + "credentials:{}:host-trust:{}", + leases + .iter() + .map(|lease| format!( + "{}:{}", + lease.credential_id, lease.credential_revision + )) + .collect::>() + .join(","), + primary_host_trust_revision ), i64::try_from(expires_at_epoch_seconds).unwrap_or(i64::MAX), worker_runtime::resource::RepositorySshAccessSecret { - private_key: lease.private_key.as_str().to_string(), - known_hosts_entry: lease.known_hosts_entry.clone(), + credential_candidates: leases + .iter() + .map( + |lease| worker_runtime::resource::RepositorySshAccessSecretCandidate { + credential_id: lease.credential_id.clone(), + credential_revision: lease.credential_revision, + private_key: lease.private_key.as_str().to_string(), + }, + ) + .collect(), + known_hosts_entry: known_hosts_entry.clone(), }, ) .map_err(|_| { @@ -17510,17 +17843,22 @@ fn authorize_repository_materialization( ) })?; Some(RepositorySshMaterializationAccess { - credential_id: lease.credential_id, - credential_revision: lease.credential_revision, - host_trust_id: lease.host_trust_id, - host_trust_revision: lease.host_trust_revision, + credential_candidates: leases + .into_iter() + .map(|lease| RepositorySshCredentialCandidate { + credential_id: lease.credential_id, + credential_revision: lease.credential_revision, + private_key: SensitiveString::default(), + }) + .collect(), + host_trust_id: primary_host_trust_id, + host_trust_revision: primary_host_trust_revision, access: binding.access, expires_at_epoch_seconds, repository_id: request.repository.id.clone(), repository_source_fingerprint: request.repository.source_fingerprint.clone(), repository_uri: request.repository.source.uri.clone(), secret_resource, - private_key: SensitiveString::default(), known_hosts_entry: SensitiveString::default(), }) } else { @@ -18151,6 +18489,14 @@ mod tests { SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding, }; + #[test] + fn backend_resource_timeout_maps_to_gateway_timeout() { + assert_eq!( + backend_resource_error_status(&BackendResourceError::Timeout), + StatusCode::GATEWAY_TIMEOUT + ); + } + #[test] fn browser_worker_console_href_uses_logical_worker_route() { let href = browser_worker_console_href("workspace/one", "W-7"); @@ -18158,6 +18504,25 @@ mod tests { assert!(!href.contains("/runtimes/")); } + #[test] + fn runtime_resource_fetch_bypasses_workspace_mutation_lock() { + assert!(!workspace_request_requires_mutation_lock( + &Method::POST, + "/api/runtime/v1/workspaces/workspace-a/resources/fetch", + "workspace-a" + )); + assert!(workspace_request_requires_mutation_lock( + &Method::POST, + "/api/w/workspace-a/working-directories", + "workspace-a" + )); + assert!(!workspace_request_requires_mutation_lock( + &Method::GET, + "/api/w/workspace-a/working-directories", + "workspace-a" + )); + } + #[tokio::test] async fn workspace_mutation_gate_serializes_deletion_with_active_mutations() { let locks = Arc::new(AsyncMutex::new(HashMap::new())); @@ -19161,6 +19526,106 @@ mod tests { ); } + #[test] + fn repository_ssh_probe_distinguishes_untrusted_verified_and_changed_keys() { + let candidates = vec![RepositorySshHostKeyCandidate { + algorithm: "ssh-ed25519".to_string(), + host_key: "ssh-ed25519 AAAA".to_string(), + fingerprint: "SHA256:observed".to_string(), + }]; + + assert_eq!( + repository_ssh_connection_trust_state(&candidates, None), + RepositorySshConnectionTrustState::Untrusted + ); + assert_eq!( + repository_ssh_connection_trust_state(&candidates, Some("SHA256:observed")), + RepositorySshConnectionTrustState::Verified + ); + assert_eq!( + repository_ssh_connection_trust_state(&candidates, Some("SHA256:old")), + RepositorySshConnectionTrustState::Changed + ); + } + + #[tokio::test] + async fn repository_ssh_clone_leases_specific_then_workspace_default_credentials() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let specific = api + .repository_secrets + .generate_credential( + &api.config.workspace_id, + GenerateRepositorySshCredentialRequest { + operation_id: "generate-repository-specific".to_string(), + credential_id: "repository-specific".to_string(), + name: "Repository specific".to_string(), + }, + "owner-account", + ) + .unwrap(); + let host_public_key = api + .repository_secrets + .credential_public_key(&api.config.workspace_id, &specific.credential_id) + .unwrap() + .unwrap() + .public_key; + let host_trust = api + .repository_secrets + .put_host_trust( + &api.config.workspace_id, + PutRepositorySshHostTrustRequest { + operation_id: "trust-example-host".to_string(), + host_trust_id: "example-host".to_string(), + hostname: "example.test".to_string(), + port: 22, + host_key: host_public_key, + expected_revision: None, + }, + "owner-account", + ) + .unwrap(); + let binding = workspace_api::RepositorySshAccessBinding { + repository_key: "test-repository".to_string(), + credential_id: specific.credential_id.clone(), + host_trust_id: host_trust.host_trust_id, + access: workspace_api::RepositoryAccessMode::ReadOnly, + }; + let primary = api + .repository_secrets + .lease_ssh_materialization_access(&api.config.workspace_id, &binding) + .unwrap(); + + let candidates = repository_ssh_lease_candidates(&api, primary).unwrap(); + + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].credential_id, "repository-specific"); + assert_eq!( + candidates[1].credential_id, + crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID + ); + assert_ne!( + candidates[0].private_key.as_str(), + candidates[1].private_key.as_str() + ); + assert_eq!( + candidates[0].known_hosts_entry, + candidates[1].known_hosts_entry + ); + + let default_binding = workspace_api::RepositorySshAccessBinding { + credential_id: crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID + .to_string(), + ..binding + }; + let primary_default = api + .repository_secrets + .lease_ssh_materialization_access(&api.config.workspace_id, &default_binding) + .unwrap(); + let default_only = repository_ssh_lease_candidates(&api, primary_default).unwrap(); + assert_eq!(default_only.len(), 1); + } + #[tokio::test] async fn repository_bound_ticket_flow_and_workdir_launches_fail_closed_across_workspaces() { let dir = tempfile::tempdir().unwrap(); @@ -19270,7 +19735,13 @@ mod tests { "1", i64::MAX, worker_runtime::resource::RepositorySshAccessSecret { - private_key: "private-key-bytes".to_string(), + credential_candidates: vec![ + worker_runtime::resource::RepositorySshAccessSecretCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: "private-key-bytes".to_string(), + }, + ], known_hosts_entry: "known-hosts-entry".to_string(), }, ) @@ -19290,8 +19761,13 @@ mod tests { cache_generation: 0, ssh: Some( worker_runtime::catalog::RepositorySshMaterializationAccess { - credential_id: "credential-1".to_string(), - credential_revision: 1, + credential_candidates: vec![ + worker_runtime::catalog::RepositorySshCredentialCandidate { + credential_id: "credential-1".to_string(), + credential_revision: 1, + private_key: worker_runtime::catalog::SensitiveString::default(), + }, + ], host_trust_id: "host-trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadOnly, @@ -19303,7 +19779,6 @@ mod tests { .clone(), repository_uri: working_directory.repository.source.uri.clone(), secret_resource, - private_key: worker_runtime::catalog::SensitiveString::default(), known_hosts_entry: worker_runtime::catalog::SensitiveString::default(), }, ), @@ -21007,6 +21482,60 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + #[tokio::test] + async fn runtime_workdir_inventory_does_not_adopt_unknown_workspace_rows() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let foreign = WorkdirRegistryRecord { + workspace_id: "other-workspace".to_string(), + workdir_id: "foreign-workdir".to_string(), + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + repository_id: "foreign-repository".to_string(), + creation_selector: None, + creation_ref: None, + creation_tree: None, + current_selector: None, + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materialization_status: "present".to_string(), + cleanliness: "clean".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }; + let items = [worker_runtime::catalog::WorkingDirectoryStatus { + summary: runtime_workdir_summary_from_record(&foreign), + }]; + + let observed = + reconcile_runtime_workdir_observations(&api, EMBEDDED_WORKER_RUNTIME_ID, &items) + .unwrap(); + + assert!(observed.is_empty()); + assert!( + api.store + .get_workdir_registry(TEST_WORKSPACE_ID, "foreign-workdir") + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn runtime_workdir_detail_rejects_unknown_workspace_row() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + + let response = working_directory_detail_for_runtime( + api, + EMBEDDED_WORKER_RUNTIME_ID, + "foreign-workdir", + ) + .unwrap_err() + .into_response(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + #[test] fn runtime_binding_summary_omits_stale_verification_revision() { let binding = WorkspaceRuntimeBinding { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 4fda14c1..0887fc85 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.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 tests/runtime-management.test.ts tests/runtime-management-source.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 src/lib/workspace/console/composer-delivery.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 tests/runtime-management.test.ts tests/runtime-management-source.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/repository-ssh-connection-ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/generated/repository-access-api.ts b/web/workspace/src/lib/generated/repository-access-api.ts index 8adeda20..958c76d7 100644 --- a/web/workspace/src/lib/generated/repository-access-api.ts +++ b/web/workspace/src/lib/generated/repository-access-api.ts @@ -22,6 +22,20 @@ export type CreateRepositorySshCredentialRequest = { passphrase: string | null; }; +export type GenerateRepositorySshCredentialRequest = { + operation_id: string; + credential_id: string; + name: string; +}; + +export type RepositorySshPublicKey = { + credential_id: string; + current_revision: number; + public_key_algorithm: string; + public_key_fingerprint: string; + public_key: string; +}; + export type RotateRepositorySshCredentialRequest = { operation_id: string; expected_revision: number; diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts index 9688deab..b4939ea9 100644 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ b/web/workspace/src/lib/generated/workspace-api.ts @@ -307,6 +307,38 @@ export type RepositoryDetailResponse = { source: string; }; +export type RepositorySshConnectionProbeRequest = { runtime_id: string }; + +export type RepositorySshHostKeyCandidate = { + algorithm: string; + host_key: string; + fingerprint: string; +}; + +export type RepositorySshConnectionTrustState = + | "untrusted" + | "verified" + | "changed"; + +export type RepositorySshConnectionProbeResponse = { + workspace_id: string; + repository_key: string; + runtime_id: string; + hostname: string; + port: number; + trust_state: RepositorySshConnectionTrustState; + host_trust_id: string; + expected_host_trust_revision: number | null; + candidates: Array; +}; + +export type ConfirmRepositorySshHostTrustRequest = { + operation_id: string; + runtime_id: string; + host_key: string; + expected_host_trust_revision: number | null; +}; + export type RepositoryLogResponse = { workspace_id: string; repository_key: string; diff --git a/web/workspace/src/lib/workspace/api/repository-access.ts b/web/workspace/src/lib/workspace/api/repository-access.ts index cad14248..6d78ed45 100644 --- a/web/workspace/src/lib/workspace/api/repository-access.ts +++ b/web/workspace/src/lib/workspace/api/repository-access.ts @@ -2,6 +2,7 @@ import type { RepositoryAccessProjection, RepositorySshCredential, RepositorySshHostTrust, + RepositorySshPublicKey, } from "../../generated/repository-access-api.ts"; export class RepositoryAccessSchemaError extends Error { @@ -50,6 +51,25 @@ export function parseRepositorySshCredential( return record as RepositorySshCredential; } +export function parseRepositorySshPublicKey( + value: unknown, + path = "public_key", +): RepositorySshPublicKey { + const record = readRecord(value, path, [ + "credential_id", + "current_revision", + "public_key_algorithm", + "public_key_fingerprint", + "public_key", + ]); + readString(record, "credential_id", path); + readRevision(record, "current_revision", path); + readString(record, "public_key_algorithm", path); + readString(record, "public_key_fingerprint", path); + readString(record, "public_key", path); + return record as RepositorySshPublicKey; +} + export function parseRepositorySshHostTrusts( value: unknown, ): RepositorySshHostTrust[] { diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index 2a62b3bb..b14b056f 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -10,6 +10,9 @@ import type { RepositoryLogResponse, RepositorySource, RepositorySourceKind, + RepositorySshConnectionProbeResponse, + RepositorySshConnectionTrustState, + RepositorySshHostKeyCandidate, RepositorySummary, WorkspaceAuthConfig, WorkspaceCatalogListResponse, @@ -35,6 +38,8 @@ export type { RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse, + RepositorySshConnectionProbeResponse, + RepositorySshHostKeyCandidate, RepositorySummary, WorkspaceCatalogListResponse, WorkspaceCreateResponse, @@ -583,6 +588,98 @@ export function parseRepositoryDetailResponse( }; } +const SSH_CONNECTION_TRUST_STATES = new Set([ + "untrusted", + "verified", + "changed", +]); + +function repositorySshHostKeyCandidate( + value: unknown, + path: string, +): RepositorySshHostKeyCandidate { + const candidate = object(value, path); + exactKeys(candidate, ["algorithm", "host_key", "fingerprint"], path); + return { + algorithm: string(candidate.algorithm, `${path}.algorithm`), + host_key: string(candidate.host_key, `${path}.host_key`), + fingerprint: string(candidate.fingerprint, `${path}.fingerprint`), + }; +} + +export function parseRepositorySshConnectionProbeResponse( + value: unknown, +): RepositorySshConnectionProbeResponse { + const response = object(value, "repository SSH connection probe response"); + exactKeys( + response, + [ + "workspace_id", + "repository_key", + "runtime_id", + "hostname", + "port", + "trust_state", + "host_trust_id", + "expected_host_trust_revision", + "candidates", + ], + "repository SSH connection probe response", + ); + const trustState = string( + response.trust_state, + "repository SSH connection probe response.trust_state", + ) as RepositorySshConnectionTrustState; + if (!SSH_CONNECTION_TRUST_STATES.has(trustState)) { + throw new Error( + "repository SSH connection probe response.trust_state is invalid", + ); + } + return { + workspace_id: string( + response.workspace_id, + "repository SSH connection probe response.workspace_id", + ), + repository_key: string( + response.repository_key, + "repository SSH connection probe response.repository_key", + ), + runtime_id: string( + response.runtime_id, + "repository SSH connection probe response.runtime_id", + ), + hostname: string( + response.hostname, + "repository SSH connection probe response.hostname", + ), + port: integer( + response.port, + "repository SSH connection probe response.port", + ), + trust_state: trustState, + host_trust_id: string( + response.host_trust_id, + "repository SSH connection probe response.host_trust_id", + ), + expected_host_trust_revision: response.expected_host_trust_revision === null + ? null + : integer( + response.expected_host_trust_revision, + "repository SSH connection probe response.expected_host_trust_revision", + ), + candidates: array( + response.candidates, + "repository SSH connection probe response.candidates", + ).map( + (candidate, index) => + repositorySshHostKeyCandidate( + candidate, + `repository SSH connection probe response.candidates[${index}]`, + ), + ), + }; +} + const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES = 128; const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128; const WORKSPACE_DELETION_MAX_BLOCKERS = 1024; diff --git a/web/workspace/src/lib/workspace/repositories/ssh-connection.ts b/web/workspace/src/lib/workspace/repositories/ssh-connection.ts new file mode 100644 index 00000000..c49f8a47 --- /dev/null +++ b/web/workspace/src/lib/workspace/repositories/ssh-connection.ts @@ -0,0 +1,13 @@ +import type { WorkspaceRuntimeResource } from "$lib/generated/workspace-api"; + +export function repositorySshProbeRuntimes( + runtimes: readonly WorkspaceRuntimeResource[], +): WorkspaceRuntimeResource[] { + return runtimes.filter((runtime) => + runtime.kind === "remote_worker_runtime" && + runtime.management.endpoint_configured && + runtime.management.binding !== undefined && + runtime.management.binding !== null && + runtime.management.binding.state !== "revoked" + ); +} diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte index b64e7c15..717062c8 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte @@ -1,8 +1,88 @@ @@ -78,6 +158,47 @@ {/if} +{#if data.repository?.item.source.kind === 'ssh'} +
+

SSH connection test

+

Observe the SSH host key from the same Runtime that will clone this Repository. Nothing is trusted until you confirm a fingerprint below.

+ {#if data.runtimesError} +

{data.runtimesError}

+ {:else if data.runtimes} + + {#if probeRuntimes.length === 0} +

No configured remote Runtime is available for this connection test.

+ {/if} + + {/if} + + {#if probe} +

{probe.hostname}:{probe.port} · {probe.trust_state}

+ {#each probe.candidates as candidate} + + {/each} + {#if probe.trust_state !== 'verified'} + + {/if} + {/if} + {#if connectionMessage}

{connectionMessage}

{/if} +
+{/if} +

Recent commits

{#if data.repositoryLog} diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts index ba4e8053..d307a617 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts @@ -1,4 +1,5 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; +import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management"; import { parseRepositoryDetailResponse, parseRepositoryLogResponse, @@ -8,7 +9,7 @@ import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; const repositoryKey = params.repositoryKey; - const [repositoryResult, logResult] = await Promise.all([ + const [repositoryResult, logResult, runtimesResult] = await Promise.all([ loadJson( fetch, workspaceApiPath( @@ -23,6 +24,10 @@ export const load: PageLoad = async ({ fetch, params }) => { `/repositories/${encodeURIComponent(repositoryKey)}/log`, ), ), + loadJson( + fetch, + workspaceApiPath(workspaceId, "/runtimes"), + ), ]); let repository = null; @@ -49,11 +54,25 @@ export const load: PageLoad = async ({ fetch, params }) => { } } + let runtimes = null; + let runtimesError = runtimesResult.error; + if (runtimesResult.data !== null) { + try { + runtimes = parseWorkspaceRuntimeList(runtimesResult.data); + } catch (cause) { + runtimesError = cause instanceof Error + ? cause.message + : "invalid Runtime summary response"; + } + } + return { repositoryKey, repository, repositoryError, repositoryLog: log, repositoryLogError: logError, + runtimes, + runtimesError, }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte index b5f5ef29..86787388 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -4,24 +4,33 @@ CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, + GenerateRepositorySshCredentialRequest, PutRepositorySshHostTrustRequest, RepositorySshCredential, RepositorySshHostTrust, + RepositorySshPublicKey, RotateRepositorySshCredentialRequest, } from '$lib/generated/repository-access-api'; import { parseRepositorySshCredential, parseRepositorySshHostTrust, + parseRepositorySshPublicKey, } from '$lib/workspace/api/repository-access'; import type { PageProps } from './$types'; let { data }: PageProps = $props(); let credentials = $state(untrack(() => data.credentials)); + let publicKeys = $state>( + Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key])) + ); let hostTrusts = $state(untrack(() => data.hostTrusts)); const accessProjection = untrack(() => data.accessProjection); let message = $state(null); let pending = $state(false); + let copiedCredentialId = $state(null); + let generateCredentialId = $state(''); + let generateCredentialName = $state(''); let credentialId = $state(''); let credentialName = $state(''); let privateKey = $state(''); @@ -37,6 +46,7 @@ let hostExpectedRevision = $state(null); const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`); + const workspaceDefaultCredentialId = 'workspace-default'; function operationId(prefix: string): string { return `${prefix}-${crypto.randomUUID()}`; @@ -71,6 +81,55 @@ return parse(payload); } + async function loadPublicKey(credentialId: string): Promise { + const response = await fetch( + `${base}/credentials/${encodeURIComponent(credentialId)}/public-key`, + { headers: { accept: 'application/json' } } + ); + const body = await response.json(); + if (!response.ok) { + throw new Error(`Repository Access request failed with status ${response.status}.`); + } + return parseRepositorySshPublicKey(body); + } + + async function generateCredential() { + pending = true; + message = null; + try { + const body: GenerateRepositorySshCredentialRequest = { + operation_id: operationId('credential-generate'), + credential_id: generateCredentialId, + name: generateCredentialName + }; + const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential); + const publicKey = await loadPublicKey(created.credential_id); + credentials = [...credentials.filter((item) => item.credential_id !== created.credential_id), created]; + publicKeys = { ...publicKeys, [created.credential_id]: publicKey }; + generateCredentialId = ''; + generateCredentialName = ''; + message = `Generated SSH credential ${created.credential_id}`; + } catch (error) { + message = error instanceof Error ? error.message : 'Failed to generate SSH credential'; + } finally { + pending = false; + } + } + + async function copyPublicKey(credentialId: string) { + const publicKey = publicKeys[credentialId]?.public_key; + if (!publicKey) return; + try { + await navigator.clipboard.writeText(publicKey); + copiedCredentialId = credentialId; + window.setTimeout(() => { + if (copiedCredentialId === credentialId) copiedCredentialId = null; + }, 1500); + } catch { + message = 'Failed to copy the public key'; + } + } + async function createCredential() { pending = true; message = null; @@ -88,7 +147,9 @@ body, parseRepositorySshCredential ); + const publicKey = await loadPublicKey(created.credential_id); credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id)); + publicKeys = { ...publicKeys, [created.credential_id]: publicKey }; credentialId = ''; credentialName = ''; message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`; @@ -117,7 +178,9 @@ body, parseRepositorySshCredential ); + const publicKey = await loadPublicKey(rotated.credential_id); credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry); + publicKeys = { ...publicKeys, [rotated.credential_id]: publicKey }; rotateCredentialId = null; message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`; } catch (error) { @@ -145,6 +208,9 @@ null ); credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id); + const remainingPublicKeys = { ...publicKeys }; + delete remainingPublicKeys[credential.credential_id]; + publicKeys = remainingPublicKeys; message = `Credential ${credential.credential_id} deleted.`; } catch (error) { message = error instanceof Error ? error.message : 'Credential deletion failed'; @@ -227,7 +293,7 @@

owner only

Repository Access

encrypted -

Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.

+

The Workspace default SSH key is generated separately from the Runtime authentication identity and is always offered during SSH clone. Without an explicit Repository binding, a unique pinned host trust matching the Repository URI is used with this default key. A binding can add one dedicated credential; OpenSSH receives both candidates and tries them through one operation-scoped agent. Private keys and passphrases remain write-only.

{#if message}

{message}

{/if}
@@ -237,7 +303,7 @@ {#each accessProjection.bindings as binding (binding.repository_key)}
{binding.repository_key} -

{binding.access} · credential {binding.credential_id} · host trust {binding.host_trust_id}

+

{binding.access} · additional credential {binding.credential_id} · always includes {workspaceDefaultCredentialId} · host trust {binding.host_trust_id}

{/each}
@@ -248,12 +314,19 @@ {#each credentials as credential (credential.credential_id)}
{credential.name} {credential.credential_id} + {#if credential.credential_id === workspaceDefaultCredentialId}Workspace default{/if}

{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}

-

References: {credential.referenced_repositories.join(', ') || 'none'}

-
- - -
+ {#if publicKeys[credential.credential_id]} + + + {/if} +

References: {credential.credential_id === workspaceDefaultCredentialId ? 'all SSH repository operations' : credential.referenced_repositories.join(', ') || 'none'}

+ {#if credential.credential_id !== workspaceDefaultCredentialId} +
+ + +
+ {/if} {#if rotateCredentialId === credential.credential_id}
{ event.preventDefault(); void rotateCredential(credential); }}> @@ -264,8 +337,16 @@
{/each} + { event.preventDefault(); void generateCredential(); }}> +

Generate Repository SSH credential

+

Create an additional Ed25519 key for a Repository binding. The Workspace default SSH key is already generated automatically and is included separately.

+ + + + +
{ event.preventDefault(); void createCredential(); }}> -

Add SSH credential

+

Import existing SSH credential

diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts index 92ac47e6..dc1905d2 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts @@ -3,6 +3,7 @@ import { parseRepositoryAccessProjection, parseRepositorySshCredentials, parseRepositorySshHostTrusts, + parseRepositorySshPublicKey, } from "$lib/workspace/api/repository-access"; import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader"; import type { PageLoad } from "./$types"; @@ -27,9 +28,25 @@ export const load: PageLoad = async ({ fetch, params }) => { ), ]); + const publicKeys = await Promise.all( + credentials.map((credential) => + loadRepositoryAccessJson( + fetch, + workspaceApiPath( + workspaceId, + `/settings/repository-access/credentials/${ + encodeURIComponent(credential.credential_id) + }/public-key`, + ), + parseRepositorySshPublicKey, + ) + ), + ); + return { workspaceId, credentials, + publicKeys, hostTrusts, accessProjection, }; diff --git a/web/workspace/test/repository-access/api.test.ts b/web/workspace/test/repository-access/api.test.ts index ab30d94c..c18aecfc 100644 --- a/web/workspace/test/repository-access/api.test.ts +++ b/web/workspace/test/repository-access/api.test.ts @@ -2,6 +2,7 @@ import { parseRepositoryAccessProjection, parseRepositorySshCredentials, parseRepositorySshHostTrusts, + parseRepositorySshPublicKey, RepositoryAccessSchemaError, } from "../../src/lib/workspace/api/repository-access.ts"; @@ -59,6 +60,22 @@ const hostTrust = { Deno.test("Repository Access parsers accept generated response contracts", () => { assertEquals(parseRepositorySshCredentials([credential]), [credential]); + assertEquals( + parseRepositorySshPublicKey({ + credential_id: "deploy-key", + current_revision: 2, + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:credential", + public_key: "ssh-ed25519 AAAA", + }), + { + credential_id: "deploy-key", + current_revision: 2, + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:credential", + public_key: "ssh-ed25519 AAAA", + }, + ); assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]); assertEquals( parseRepositoryAccessProjection({ diff --git a/web/workspace/test/repository-access/ui.test.ts b/web/workspace/test/repository-access/ui.test.ts index b47b88dd..ea0cbe9e 100644 --- a/web/workspace/test/repository-access/ui.test.ts +++ b/web/workspace/test/repository-access/ui.test.ts @@ -28,6 +28,7 @@ test("Repository Access Web code consumes workspace-api generated DTOs", () => { assert( loaderSource.includes("parseRepositorySshCredentials") && loaderSource.includes("parseRepositorySshHostTrusts") && + loaderSource.includes("parseRepositorySshPublicKey") && loaderSource.includes("parseRepositoryAccessProjection"), "loader should validate unknown JSON before exposing generated DTOs to Svelte", ); @@ -66,6 +67,29 @@ test("Repository Access renders the shared access projection fields", () => { } }); +test("Repository Access generates and copies selectable public keys", () => { + for ( + const token of [ + "/credentials/generate", + "/public-key", + "Generate Repository SSH credential", + "navigator.clipboard.writeText", + "publicKeys[credential.credential_id]", + "workspace-default", + "always offered during SSH clone", + ] + ) { + assert( + source.includes(token), + `missing generated public key flow ${token}`, + ); + } + assert( + source.includes("binding.credential_id"), + "Repository bindings should identify the selected credential", + ); +}); + test("Repository credential submissions clear write-only fields in finally blocks", () => { const createStart = source.indexOf("async function createCredential()"); const rotateStart = source.indexOf("async function rotateCredential("); diff --git a/web/workspace/test/repository-ssh-connection-ui.test.ts b/web/workspace/test/repository-ssh-connection-ui.test.ts new file mode 100644 index 00000000..af5c7d7a --- /dev/null +++ b/web/workspace/test/repository-ssh-connection-ui.test.ts @@ -0,0 +1,106 @@ +import { assert, assertEquals } from "jsr:@std/assert"; +import type { WorkspaceRuntimeResource } from "../src/lib/generated/workspace-api.ts"; +import { parseRepositorySshConnectionProbeResponse } from "../src/lib/workspace/api/workspace-model.ts"; +import { repositorySshProbeRuntimes } from "../src/lib/workspace/repositories/ssh-connection.ts"; + +const root = new URL("../", import.meta.url); +const pageSource = await Deno.readTextFile( + new URL( + "./src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte", + root, + ), +); +const loaderSource = await Deno.readTextFile( + new URL( + "./src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts", + root, + ), +); + +Deno.test("Repository SSH probe parser preserves the confirmation contract", () => { + const response = { + workspace_id: "workspace-a", + repository_key: "main", + runtime_id: "runtime-a", + hostname: "example.test", + port: 22, + trust_state: "untrusted" as const, + host_trust_id: "tofu-example.test-22", + expected_host_trust_revision: null, + candidates: [ + { + algorithm: "ssh-ed25519", + host_key: "ssh-ed25519 AAAA", + fingerprint: "SHA256:host", + }, + ], + }; + + assertEquals(parseRepositorySshConnectionProbeResponse(response), response); +}); + +Deno.test("Repository SSH probe offers configured remote Runtimes regardless of worker-style status", () => { + const configured = { + runtime_id: "arcadia", + label: "Arcadia", + kind: "remote_worker_runtime", + status: "idle", + diagnostics: [], + management: { + endpoint_configured: true, + endpoint_display: "https://arcadia.example", + binding: { + state: "verified", + }, + }, + } as unknown as WorkspaceRuntimeResource; + const embedded = { + ...configured, + runtime_id: "embedded-worker-runtime", + kind: "embedded_worker_runtime", + } as unknown as WorkspaceRuntimeResource; + const revoked = { + ...configured, + runtime_id: "revoked", + management: { + ...configured.management, + binding: { state: "revoked" }, + }, + } as unknown as WorkspaceRuntimeResource; + const unbound = { + ...configured, + runtime_id: "unbound", + management: { + ...configured.management, + binding: undefined, + }, + } as unknown as WorkspaceRuntimeResource; + + assertEquals( + repositorySshProbeRuntimes([embedded, configured, revoked, unbound]).map(( + runtime, + ) => runtime.runtime_id), + ["arcadia"], + ); +}); + +Deno.test("Repository SSH connection test requires an explicit host-key confirmation", () => { + for ( + const token of [ + "Check SSH connection", + "selectedRuntimeId", + "candidate.fingerprint", + "Confirm and trust selected host key", + "expected_host_trust_revision", + "requestConnectionTest('POST'", + "requestConnectionTest('PUT'", + ] + ) { + assert(pageSource.includes(token), `missing SSH connection flow ${token}`); + } +}); + +Deno.test("Repository detail loads configured Workspace Runtimes for the connection test", () => { + assert(loaderSource.includes('workspaceApiPath(workspaceId, "/runtimes")')); + assert(loaderSource.includes("parseWorkspaceRuntimeList")); +});