From ffb2a34ae5fc17b5f18109b9205a83b3378c6166 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 26 Aug 2026 14:39:37 +0900 Subject: [PATCH] fix: enforce repository access and cache boundaries --- crates/worker-runtime/src/catalog.rs | 7 + crates/worker-runtime/src/execution.rs | 22 +- crates/worker-runtime/src/http_server.rs | 41 +- crates/worker-runtime/src/runtime.rs | 21 +- crates/worker-runtime/src/worker_backend.rs | 15 +- .../worker-runtime/src/working_directory.rs | 369 +++++++++++++++--- crates/workspace-server/src/hosts.rs | 54 ++- crates/workspace-server/src/server.rs | 74 ++++ 8 files changed, 533 insertions(+), 70 deletions(-) diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index fd0b8a7e..da18810e 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -129,6 +129,7 @@ pub struct RepositorySshMaterializationAccess { pub host_trust_id: String, pub host_trust_revision: u64, pub access: workspace_api::RepositoryAccessMode, + pub expires_at_epoch_seconds: u64, pub private_key: SensitiveString, pub known_hosts_entry: SensitiveString, } @@ -146,6 +147,12 @@ pub struct RepositoryMaterializationContext { pub ssh: Option, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkingDirectoryRepositoryAccessRequest { + pub working_directory_id: String, + pub materialization: RepositoryMaterializationContext, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkingDirectoryRequest { pub repository: WorkingDirectoryRepository, diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index 642e23e5..c37f9792 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -1,4 +1,6 @@ -use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus}; +use crate::catalog::{ + WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus, +}; use crate::config_bundle::ConfigBundle; use crate::error::RuntimeError; use crate::identity::WorkerRef; @@ -319,6 +321,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static { )) } + fn authorize_working_directory_repository_access( + &self, + _request: &WorkingDirectoryRepositoryAccessRequest, + ) -> Result<(), WorkingDirectoryDiagnostic> { + Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_repository_access_unsupported", + "Worker execution backend does not support Repository access authorization", + )) + } + fn list_working_directories(&self) -> Vec { Vec::new() } @@ -454,6 +466,14 @@ impl WorkerExecutionBackendRef { self.backend.create_working_directory(request) } + pub(crate) fn authorize_working_directory_repository_access( + &self, + request: &WorkingDirectoryRepositoryAccessRequest, + ) -> Result<(), WorkingDirectoryDiagnostic> { + self.backend + .authorize_working_directory_repository_access(request) + } + pub(crate) fn list_working_directories(&self) -> Vec { self.backend.list_working_directories() } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 0ed02914..4c179663 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -12,7 +12,8 @@ use crate::auth::{ }; use crate::catalog::{ ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary, - WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef, + WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus, + WorkspaceApiRef, }; use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary}; use crate::error::RuntimeError; @@ -203,6 +204,10 @@ fn runtime_http_router_with_optional_auth( "/v1/working-directories", get(list_working_directories).post(create_working_directory), ) + .route( + "/v1/working-directories/repository-access", + post(authorize_working_directory_repository_access), + ) .route( "/v1/working-directories/{working_directory_id}/sessions", post(open_workdir_session), @@ -335,6 +340,11 @@ pub struct RuntimeHttpWorkingDirectoriesResponse { pub working_directories: Vec, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeHttpRepositoryAccessResponse { + pub authorized: bool, +} + /// Working directory response used by create/detail/delete endpoints. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RuntimeHttpWorkingDirectoryResponse { @@ -513,6 +523,28 @@ async fn list_workers( Ok(Json(RuntimeHttpWorkersResponse { workers })) } +async fn authorize_working_directory_repository_access( + State(state): State, + Extension(auth): Extension, + body: Result, JsonRejection>, +) -> RestResult { + let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?; + if request.materialization.workspace_id != auth.workspace_id { + return Err(RuntimeHttpRestError::new( + StatusCode::FORBIDDEN, + "working_directory_materialization_workspace_mismatch", + "Repository access authority does not match the authenticated Workspace", + )); + } + state + .runtime + .authorize_working_directory_repository_access(request) + .map_err(RuntimeHttpRestError::runtime)?; + Ok(Json(RuntimeHttpRepositoryAccessResponse { + authorized: true, + })) +} + async fn list_working_directories( State(state): State, ) -> RestResult { @@ -1569,6 +1601,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s if path == "/v1/workers" && *method == Method::POST { return Some("workers:create"); } + if path == "/v1/working-directories/repository-access" && *method == Method::POST { + return Some("workdirs:operate"); + } if path.starts_with("/v1/workdir-sessions") || (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions")) { @@ -2230,6 +2265,10 @@ mod tests { #[test] fn workdir_routes_require_dedicated_operation_permission() { + assert_eq!( + required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",), + Some("workdirs:operate") + ); assert_eq!( required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"), Some("workdirs:operate") diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 97ad176c..588f3338 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1,6 +1,6 @@ use crate::catalog::{ ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck, - WorkerStatus, WorkerSummary, WorkingDirectoryRequest, + WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef, }; use crate::config_bundle::{ @@ -366,6 +366,25 @@ impl Runtime { .map_err(RuntimeError::from) } + pub fn authorize_working_directory_repository_access( + &self, + request: WorkingDirectoryRepositoryAccessRequest, + ) -> Result<(), RuntimeError> { + let backend = { + let state = self.lock()?; + state.ensure_running()?; + state.execution_backend.clone().ok_or_else(|| { + RuntimeError::ExecutionBackendUnavailable { + message: "working directory Repository access requires an execution backend" + .to_string(), + } + })? + }; + backend + .authorize_working_directory_repository_access(&request) + .map_err(RuntimeError::from) + } + /// List Runtime-owned working directories through the attached execution backend. pub fn list_working_directories( &self, diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 3f334145..fe245c6f 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -20,7 +20,7 @@ use crate::auth::{ }; use crate::catalog::{ CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, - WorkingDirectoryRequest, WorkingDirectoryStatus, + WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus, }; use crate::execution::{ WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, @@ -1590,6 +1590,19 @@ where Ok(materializer.create(request)?.status()) } + fn authorize_working_directory_repository_access( + &self, + request: &WorkingDirectoryRepositoryAccessRequest, + ) -> Result<(), WorkingDirectoryDiagnostic> { + let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| { + WorkingDirectoryDiagnostic::rejected( + "working_directory_materializer_unavailable", + "working directory Repository access requested, but no materializer is configured for this runtime backend", + ) + })?; + materializer.authorize_repository_access(request) + } + fn list_working_directories(&self) -> Vec { self.working_directory_materializer .as_ref() diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 7d2d7f44..f2407a6e 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -1,7 +1,7 @@ use crate::catalog::{ MaterializerKind, RepositorySshMaterializationAccess, WorkingDirectoryCleanupTarget, - WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryStatusKind, - WorkingDirectorySummary, + WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus, + WorkingDirectoryStatusKind, WorkingDirectorySummary, }; use crate::identity::WorkerRef; use serde::{Deserialize, Serialize}; @@ -48,6 +48,8 @@ pub struct WorkingDirectoryEvidence { pub credential_revision: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub host_trust_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transport_warning: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -174,6 +176,11 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static { request: &WorkingDirectoryRequest, ) -> Result; + fn authorize_repository_access( + &self, + request: &WorkingDirectoryRepositoryAccessRequest, + ) -> Result<(), WorkingDirectoryDiagnostic>; + fn bind_working_directory( &self, working_directory_id: &str, @@ -379,8 +386,7 @@ impl RuntimeGitCacheMaterializer { "Runtime Repository access state is unavailable", ) })? - .get(working_directory_id) - .cloned(); + .remove(working_directory_id); let Some(access) = access else { if binding .working_directory @@ -395,11 +401,26 @@ impl RuntimeGitCacheMaterializer { } return Ok(binding); }; + validate_ssh_materialization_access(&access)?; let agent = Arc::new(RepositorySshAgent::start( &self.runtime_root, working_directory_id, &access, )?); + let weak_agent = Arc::downgrade(&agent); + let expires_at = access.expires_at_epoch_seconds; + std::thread::spawn(move || { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if expires_at > now { + std::thread::sleep(Duration::from_secs(expires_at - now)); + } + if let Some(agent) = weak_agent.upgrade() { + agent.stop(); + } + }); binding.command_environment.insert( "SSH_AUTH_SOCK".to_string(), agent.socket.to_string_lossy().to_string(), @@ -436,7 +457,9 @@ impl RuntimeGitCacheMaterializer { } if matches!( request.repository.source.kind, - workspace_api::RepositorySourceKind::Https | workspace_api::RepositorySourceKind::Ssh + workspace_api::RepositorySourceKind::Https + | workspace_api::RepositorySourceKind::Http + | workspace_api::RepositorySourceKind::Ssh ) { validate_remote_source_uri(request)?; let materialization = request.materialization.as_ref().ok_or_else(|| { @@ -460,7 +483,8 @@ impl RuntimeGitCacheMaterializer { match request.repository.source.kind { workspace_api::RepositorySourceKind::LocalPath | workspace_api::RepositorySourceKind::File - | workspace_api::RepositorySourceKind::Https => {} + | workspace_api::RepositorySourceKind::Https + | workspace_api::RepositorySourceKind::Http => {} workspace_api::RepositorySourceKind::Ssh => { let ssh = request .materialization @@ -474,12 +498,6 @@ impl RuntimeGitCacheMaterializer { })?; validate_ssh_materialization_access(ssh)?; } - workspace_api::RepositorySourceKind::Http => { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_insecure_repository_transport_rejected", - "plain HTTP Repository materialization is rejected", - )); - } workspace_api::RepositorySourceKind::Invalid => { return Err(WorkingDirectoryDiagnostic::new( "working_directory_repository_source_invalid", @@ -539,12 +557,7 @@ impl RuntimeGitCacheMaterializer { "Runtime Repository cache identity does not match the requested source", )); } - let mut command = repository_git_command(request, access.as_ref()); - command - .arg("--git-dir") - .arg(&cache_path) - .args(["remote", "update", "--prune"]); - run_repository_git(command, "working_directory_repository_fetch_failed")?; + fetch_repository_cache(request, access.as_ref(), &cache_path)?; } else { let staging = cache_path.with_extension(format!( "staging-{}", @@ -553,15 +566,42 @@ impl RuntimeGitCacheMaterializer { if staging.exists() { let _ = fs::remove_dir_all(&staging); } - let mut command = repository_git_command(request, access.as_ref()); - command.args(["clone", "--mirror"]); - if request.repository.source.kind == workspace_api::RepositorySourceKind::LocalPath { - command.arg("--no-local"); - } - command.arg(&request.repository.source.uri).arg(&staging); - if let Err(error) = - run_repository_git(command, "working_directory_repository_fetch_failed") - { + fs::create_dir_all(&staging).map_err(|_| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_cache_create_failed", + "Runtime Repository cache could not be created; backend-private path details were omitted", + ) + })?; + let mut init = isolated_git_command(); + init.args(["init", "--bare"]).arg(&staging); + let mut add_origin = repository_git_command(request, access.as_ref()); + add_origin + .arg("--git-dir") + .arg(&staging) + .args(["remote", "add", "origin"]) + .arg(&request.repository.source.uri); + let mut configure_fetch = isolated_git_command(); + configure_fetch.arg("--git-dir").arg(&staging).args([ + "config", + "remote.origin.fetch", + "+refs/heads/*:refs/remotes/origin/*", + ]); + let initialized = + run_repository_git(init, "working_directory_repository_cache_create_failed") + .and_then(|_| { + run_repository_git( + add_origin, + "working_directory_repository_cache_create_failed", + ) + }) + .and_then(|_| { + run_repository_git( + configure_fetch, + "working_directory_repository_cache_create_failed", + ) + }) + .and_then(|_| fetch_repository_cache(request, access.as_ref(), &staging)); + if let Err(error) = initialized { let _ = fs::remove_dir_all(&staging); return Err(error); } @@ -590,9 +630,7 @@ impl RuntimeGitCacheMaterializer { validate_working_directory_id(&working_directory_id)?; let repository_cache = self.ensure_repository_cache(request)?; let selector = request.repository.selector.as_deref().unwrap_or("HEAD"); - let commit_spec = format!("{selector}^{{commit}}"); - let resolved_commit = - git_dir_stdout(&repository_cache, ["rev-parse", commit_spec.as_str()])?; + let resolved_commit = resolve_cached_commit(&repository_cache, selector)?; let tree_spec = format!("{resolved_commit}^{{tree}}"); let resolved_tree = git_dir_stdout(&repository_cache, ["rev-parse", tree_spec.as_str()]) .ok() @@ -682,6 +720,8 @@ impl RuntimeGitCacheMaterializer { host_trust_revision: context .and_then(|value| value.ssh.as_ref()) .map(|value| value.host_trust_revision), + transport_warning: repository_transport_warning(request.repository.source.kind) + .map(str::to_string), }, cleanup_target: WorkingDirectoryCleanupTarget { kind: "runtime_git_cache_worktree".to_string(), @@ -704,24 +744,6 @@ impl RuntimeGitCacheMaterializer { let _ = fs::remove_dir_all(&working_directory_root); return Err(error); } - if let Some(ssh) = request - .materialization - .as_ref() - .and_then(|materialization| materialization.ssh.clone()) - { - let mut repository_access = match self.repository_access.lock() { - Ok(repository_access) => repository_access, - Err(_) => { - remove_cached_worktree(&repository_cache, &worktree_root); - let _ = fs::remove_dir_all(&working_directory_root); - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_repository_access_unavailable", - "Runtime Repository access state is unavailable", - )); - } - }; - repository_access.insert(binding.working_directory.id.clone(), ssh); - } Ok(binding) } } @@ -747,6 +769,67 @@ impl WorkingDirectoryMaterializer for RuntimeGitCacheMaterializer { self.materialize_with_working_directory_id(working_directory_id, request) } + fn authorize_repository_access( + &self, + request: &WorkingDirectoryRepositoryAccessRequest, + ) -> Result<(), WorkingDirectoryDiagnostic> { + validate_working_directory_id(&request.working_directory_id)?; + let ssh = request.materialization.ssh.as_ref().ok_or_else(|| { + WorkingDirectoryDiagnostic::new( + "working_directory_remote_repository_access_required", + "SSH Repository access authority is required", + ) + })?; + validate_ssh_materialization_access(ssh)?; + let mut binding = self.read_binding(&request.working_directory_id)?; + if binding + .working_directory + .evidence + .credential_revision + .is_none() + { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_not_applicable", + "Workdir is not backed by an SSH Repository", + )); + } + 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.host_trust_revision = Some(ssh.host_trust_revision); + self.write_record(&binding)?; + let working_directory_id = request.working_directory_id.clone(); + let credential_revision = ssh.credential_revision; + let expires_at = ssh.expires_at_epoch_seconds; + self.repository_access + .lock() + .map_err(|_| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_unavailable", + "Runtime Repository access state is unavailable", + ) + })? + .insert(working_directory_id.clone(), ssh.clone()); + let repository_access = self.repository_access.clone(); + std::thread::spawn(move || { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if expires_at > now { + 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.remove(&working_directory_id); + } + }); + Ok(()) + } + fn bind_working_directory( &self, working_directory_id: &str, @@ -1021,10 +1104,8 @@ impl RepositorySshAgent { } Ok(agent) } -} -impl Drop for RepositorySshAgent { - fn drop(&mut self) { + fn stop(&self) { if let Ok(mut child) = self.child.lock() && let Some(mut child) = child.take() { @@ -1035,9 +1116,16 @@ impl Drop for RepositorySshAgent { } } +impl Drop for RepositorySshAgent { + fn drop(&mut self) { + self.stop(); + } +} + struct RepositoryCommandAccess { root: PathBuf, ssh_command: PathBuf, + agent: RepositorySshAgent, } impl RepositoryCommandAccess { @@ -1045,6 +1133,9 @@ impl RepositoryCommandAccess { runtime_root: &Path, request: &WorkingDirectoryRequest, ) -> Result, WorkingDirectoryDiagnostic> { + if request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh { + return Ok(None); + } let Some(ssh) = request .materialization .as_ref() @@ -1069,19 +1160,21 @@ impl RepositoryCommandAccess { ) })?; set_directory_owner_only(&root)?; - let private_key = root.join("identity"); let known_hosts = root.join("known_hosts"); let ssh_command = root.join("ssh-command"); - write_owner_only(&private_key, ssh.private_key.expose().as_bytes())?; write_owner_only(&known_hosts, ssh.known_hosts_entry.expose().as_bytes())?; let script = format!( - "#!/bin/sh\nexec ssh -F /dev/null -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile={} -i {} \"$@\"\n", + "#!/bin/sh\nexec ssh -F /dev/null -o BatchMode=yes -o IdentitiesOnly=no -o IdentityFile=/dev/null -o StrictHostKeyChecking=yes -o UserKnownHostsFile={} \"$@\"\n", shell_quote_path(&known_hosts)?, - shell_quote_path(&private_key)?, ); write_owner_only(&ssh_command, script.as_bytes())?; set_file_owner_executable(&ssh_command)?; - Ok(Some(Self { root, ssh_command })) + let agent = RepositorySshAgent::start(runtime_root, operation_id, ssh)?; + Ok(Some(Self { + root, + ssh_command, + agent, + })) } } @@ -1094,6 +1187,16 @@ impl Drop for RepositoryCommandAccess { fn validate_ssh_materialization_access( access: &RepositorySshMaterializationAccess, ) -> Result<(), WorkingDirectoryDiagnostic> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if access.expires_at_epoch_seconds <= now { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_expired", + "operation-scoped SSH credential and host-trust authority has expired", + )); + } if access.credential_id.trim().is_empty() || access.credential_revision == 0 || access.host_trust_id.trim().is_empty() @@ -1109,6 +1212,10 @@ fn validate_ssh_materialization_access( Ok(()) } +fn repository_transport_warning(kind: workspace_api::RepositorySourceKind) -> Option<&'static str> { + (kind == workspace_api::RepositorySourceKind::Http).then_some("plain_http_transport") +} + fn validate_remote_source_uri( request: &WorkingDirectoryRequest, ) -> Result<(), WorkingDirectoryDiagnostic> { @@ -1120,6 +1227,7 @@ fn validate_remote_source_uri( })?; 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(()), }; @@ -1128,7 +1236,7 @@ fn validate_remote_source_uri( || url.password().is_some() || !url.query().is_none() || !url.fragment().is_none() - || (expected_scheme == "https" && !url.username().is_empty()) + || (matches!(expected_scheme, "https" | "http") && !url.username().is_empty()) { return Err(WorkingDirectoryDiagnostic::new( "working_directory_repository_source_invalid", @@ -1223,7 +1331,9 @@ fn repository_git_command( }; command.args(["-c", &format!("protocol.file.allow={file_policy}")]); if let Some(access) = access { - command.env("GIT_SSH_COMMAND", &access.ssh_command); + command + .env("GIT_SSH_COMMAND", &access.ssh_command) + .env("SSH_AUTH_SOCK", &access.agent.socket); } command } @@ -1273,6 +1383,57 @@ fn run_repository_git( } } +fn resolve_cached_commit( + repository_cache: &Path, + selector: &str, +) -> Result { + let mut candidates = Vec::new(); + if selector == "HEAD" { + candidates.push("FETCH_HEAD".to_string()); + } else if let Some(branch) = selector.strip_prefix("refs/heads/") { + candidates.push(format!("refs/remotes/origin/{branch}")); + } else if selector.starts_with("refs/") || selector.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + candidates.push(selector.to_string()); + } else { + candidates.push(format!("refs/remotes/origin/{selector}")); + candidates.push(selector.to_string()); + } + for candidate in candidates { + let spec = format!("{candidate}^{{commit}}"); + if let Ok(commit) = git_dir_stdout(repository_cache, ["rev-parse", spec.as_str()]) + && !commit.is_empty() + { + return Ok(commit); + } + } + Err(WorkingDirectoryDiagnostic::new( + "working_directory_repository_selector_unresolved", + "configured Repository selector could not be resolved to a commit", + )) +} + +fn fetch_repository_cache( + request: &WorkingDirectoryRequest, + access: Option<&RepositoryCommandAccess>, + repository_cache: &Path, +) -> Result<(), WorkingDirectoryDiagnostic> { + let mut refs = repository_git_command(request, access); + refs.arg("--git-dir").arg(repository_cache).args([ + "fetch", + "--prune", + "--tags", + "origin", + "+refs/heads/*:refs/remotes/origin/*", + ]); + let mut head = repository_git_command(request, access); + head.arg("--git-dir") + .arg(repository_cache) + .args(["fetch", "--no-tags", "origin", "HEAD"]); + run_repository_git(refs, "working_directory_repository_fetch_failed")?; + run_repository_git(head, "working_directory_repository_fetch_failed") +} + fn validate_repository_cache_limits( repository_cache: &Path, ) -> Result<(), WorkingDirectoryDiagnostic> { @@ -1693,6 +1854,21 @@ mod tests { local.working_directory.evidence.repository_cache_key, second.working_directory.evidence.repository_cache_key ); + assert_eq!( + git_dir_stdout( + local.source_repository_path(), + ["config", "--get-all", "remote.origin.fetch"], + ) + .unwrap(), + "+refs/heads/*:refs/remotes/origin/*" + ); + assert!( + git_dir_stdout( + local.source_repository_path(), + ["config", "--get", "remote.origin.mirror"], + ) + .is_err() + ); assert_eq!( fs::read_dir(runtime_root.path().join(REPOSITORY_CACHE_DIR)) .unwrap() @@ -1754,6 +1930,7 @@ mod tests { host_trust_id: "trust-1".to_string(), host_trust_revision: 4, access: workspace_api::RepositoryAccessMode::ReadOnly, + expires_at_epoch_seconds: u64::MAX, private_key: crate::catalog::SensitiveString::new("PRIVATE KEY secret bytes"), known_hosts_entry: crate::catalog::SensitiveString::new("host key secret bytes"), }; @@ -1790,6 +1967,7 @@ mod tests { host_trust_id: "trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadWrite, + expires_at_epoch_seconds: u64::MAX, private_key: crate::catalog::SensitiveString::new( fs::read_to_string(&key_path).unwrap(), ), @@ -1798,10 +1976,35 @@ mod tests { ), }), }); + let mut ssh_operation = request.clone(); + ssh_operation.repository.source = workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::Ssh, + uri: "ssh://git@example.test/repo.git".to_string(), + }; + let command_access = RepositoryCommandAccess::prepare(runtime_root.path(), &ssh_operation) + .unwrap() + .unwrap(); + assert!(!command_access.root.join("identity").exists()); + assert!(command_access.agent.socket.exists()); + let operation_socket = command_access.agent.socket.clone(); + drop(command_access); + assert!(!operation_socket.exists()); + let created = materializer.create(&request).unwrap(); let id = created.working_directory.id; assert_eq!(materializer.list_working_directories().unwrap().len(), 1); - assert!(!runtime_root.path().join(".repository-agents").exists()); + assert_eq!( + fs::read_dir(runtime_root.path().join(".repository-agents")) + .map(|entries| entries.count()) + .unwrap_or_default(), + 0 + ); + materializer + .authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest { + working_directory_id: id.clone(), + materialization: request.materialization.clone().unwrap(), + }) + .unwrap(); let binding = materializer.bind_working_directory(&id, None).unwrap(); let socket = PathBuf::from(binding.command_environment()["SSH_AUTH_SOCK"].clone()); assert!(socket.exists()); @@ -1811,6 +2014,40 @@ mod tests { ); drop(binding); assert!(!socket.exists()); + assert_eq!( + materializer + .bind_working_directory(&id, None) + .unwrap_err() + .code, + "working_directory_remote_repository_access_required" + ); + let mut rotated = request.materialization.clone().unwrap(); + rotated.operation_id = "operation-agent-rotated".to_string(); + rotated.ssh.as_mut().unwrap().credential_revision = 2; + materializer + .authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest { + working_directory_id: id.clone(), + materialization: rotated, + }) + .unwrap(); + let rebound = materializer.bind_working_directory(&id, None).unwrap(); + assert_eq!( + rebound.working_directory.evidence.credential_revision, + Some(2) + ); + drop(rebound); + let mut expired = request.materialization.clone().unwrap(); + expired.ssh.as_mut().unwrap().expires_at_epoch_seconds = 1; + assert_eq!( + materializer + .authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest { + working_directory_id: id.clone(), + materialization: expired, + }) + .unwrap_err() + .code, + "working_directory_repository_access_expired" + ); let restored = RuntimeGitCacheMaterializer::new(runtime_root.path()); assert_eq!( restored.bind_working_directory(&id, None).unwrap_err().code, @@ -1837,6 +2074,7 @@ mod tests { host_trust_id: "trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadOnly, + expires_at_epoch_seconds: u64::MAX, private_key: crate::catalog::SensitiveString::new("PRIVATE KEY placeholder"), known_hosts_entry: crate::catalog::SensitiveString::new( "example.test ssh-ed25519 placeholder", @@ -1902,6 +2140,18 @@ mod tests { "working_directory_repository_source_invalid" ); + let mut http = request(repo.path()); + http.repository.source = workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::Http, + uri: "http://example.test/repo.git".to_string(), + }; + http.materialization = Some(context(None)); + RuntimeGitCacheMaterializer::validate_request(&http).unwrap(); + assert_eq!( + repository_transport_warning(http.repository.source.kind), + Some("plain_http_transport") + ); + let mut ssh = request(repo.path()); ssh.repository.source = workspace_api::RepositorySource { kind: workspace_api::RepositorySourceKind::Ssh, @@ -1914,6 +2164,7 @@ mod tests { host_trust_id: "trust-1".to_string(), host_trust_revision: 1, access: workspace_api::RepositoryAccessMode::ReadOnly, + expires_at_epoch_seconds: u64::MAX, 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-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 87cf4261..4c67e799 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -25,8 +25,9 @@ use worker_runtime::auth::{CapabilityTokenSigner, capability_claims}; use worker_runtime::catalog::{ ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail, - WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest, - WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef, + WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, + WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus, + WorkingDirectorySummary, WorkspaceApiRef, }; use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary}; #[cfg(test)] @@ -39,11 +40,11 @@ use worker_runtime::execution::WorkerExecutionRunState; use worker_runtime::fs_store::FsRuntimeStoreOptions; use worker_runtime::http_server::{ RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest, - RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest, - RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse, - RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest, - RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, - RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse, + RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse, + RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse, + RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse, + RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse, + RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse, RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse, RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse, }; @@ -854,6 +855,16 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { } } + fn authorize_working_directory_repository_access( + &self, + _request: WorkingDirectoryRepositoryAccessRequest, + ) -> std::result::Result<(), Error> { + Err(Error::InvalidInput( + "Runtime does not support working directory Repository access authorization" + .to_string(), + )) + } + fn list_working_directories(&self) -> RuntimeList { RuntimeList::new(Vec::new(), Vec::new()) } @@ -1427,6 +1438,23 @@ impl RuntimeRegistry { Ok(runtime.create_working_directory(request)) } + pub fn authorize_working_directory_repository_access( + &self, + runtime_id: &str, + request: WorkingDirectoryRepositoryAccessRequest, + ) -> Result<(), RuntimeRegistryError> { + validate_backend_identifier("runtime_id", runtime_id)?; + validate_backend_identifier("working_directory_id", &request.working_directory_id)?; + let runtime = self.runtime(runtime_id)?; + runtime + .authorize_working_directory_repository_access(request) + .map_err(|error| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "working_directory_repository_access_failed".to_string(), + message: error.to_string(), + }) + } + pub fn list_working_directories( &self, runtime_id: &str, @@ -3205,6 +3233,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } } + fn authorize_working_directory_repository_access( + &self, + request: WorkingDirectoryRepositoryAccessRequest, + ) -> std::result::Result<(), Error> { + self.post_json::<_, RuntimeHttpRepositoryAccessResponse>( + "/v1/working-directories/repository-access", + &request, + ) + .map(|_| ()) + .map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message)) + } + fn list_working_directories(&self) -> RuntimeList { match self.get_json::("/v1/working-directories") { Ok(response) => RuntimeList::new(response.working_directories, Vec::new()), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index fbc7df02..660b9245 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1437,6 +1437,18 @@ impl WorkspaceApi { self.validate_worker_spawn_repository_scope(&request)?; let workspace_api = self.workspace_api_ref(runtime_id); request.resolved_workspace_api = Some(workspace_api.clone()); + if let Some(working_directory) = request.resolved_working_directory.as_ref() + && let Some(access) = repository_access_request_for_workdir( + self, + runtime_id, + &working_directory.working_directory_id, + &format!("worker-spawn:{}", WorkerId::now_v7()), + )? + { + self.runtime + .authorize_working_directory_repository_access(runtime_id, access) + .map_err(RuntimeRegistryError::into_error)?; + } let attachment_reservation = request .resolved_working_directory @@ -1652,6 +1664,22 @@ impl WorkspaceApi { &self, worker: &RuntimeWorkerRef, ) -> ApiResult { + if let Some(link) = self + .store + .list_worker_workdir_links(&self.config.workspace_id, worker)? + .into_iter() + .find(|link| link.unlinked_at.is_none()) + && let Some(access) = repository_access_request_for_workdir( + self, + &worker.runtime_id, + &link.workdir_id, + &format!("worker-restore:{}", WorkerId::now_v7()), + )? + { + self.runtime + .authorize_working_directory_repository_access(&worker.runtime_id, access) + .map_err(RuntimeRegistryError::into_error)?; + } let binding = self .runtime .replace_worker_workspace_api(worker, self.workspace_api_ref(&worker.runtime_id)) @@ -13967,6 +13995,11 @@ fn authorize_repository_materialization( host_trust_id: lease.host_trust_id, host_trust_revision: lease.host_trust_revision, access: binding.access, + expires_at_epoch_seconds: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_add(300), private_key: SensitiveString::new(lease.private_key.as_str()), known_hosts_entry: SensitiveString::new(lease.known_hosts_entry), }) @@ -13985,6 +14018,47 @@ fn authorize_repository_materialization( Ok(()) } +fn repository_access_request_for_workdir( + api: &WorkspaceApi, + runtime_id: &str, + working_directory_id: &str, + operation_id: &str, +) -> ApiResult> { + let record = api + .config_store + .get_workdir_registry(&api.config.workspace_id, working_directory_id)? + .ok_or_else(|| { + settings_bad_request( + "working_directory_not_found", + "Working directory is not registered in this Workspace", + ) + })?; + if record.runtime_id != runtime_id { + return Err(settings_bad_request( + "working_directory_runtime_mismatch", + "Working directory is owned by a different Runtime", + )); + } + let repository = api.require_configured_workspace_repository(&record.repository_id)?; + if repository.source.kind != workspace_api::RepositorySourceKind::Ssh { + return Ok(None); + } + let projection = active_repository_access_projection(api, &api.config.workspace_id)?; + let mut request = working_directory_request_from_repository(&repository, None); + authorize_repository_materialization(api, runtime_id, operation_id, &projection, &mut request)?; + Ok(Some( + worker_runtime::catalog::WorkingDirectoryRepositoryAccessRequest { + working_directory_id: working_directory_id.to_string(), + materialization: request.materialization.ok_or_else(|| { + settings_bad_request( + "working_directory_remote_repository_access_required", + "SSH Repository access authority is unavailable", + ) + })?, + }, + )) +} + fn working_directory_request_for_browser( api: &WorkspaceApi, request: BrowserWorkingDirectoryCreateRequest,