diff --git a/Cargo.lock b/Cargo.lock index 3e63f2c58..0e9528999 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1446,6 +1446,7 @@ dependencies = [ "grep-regex", "grep-searcher", "ignore", + "libc", "serde", "serde_json", "sha2 0.10.9", @@ -6258,9 +6259,11 @@ version = "0.1.0" dependencies = [ "chrono", "client", + "futures", "manifest", "memory", "project-record", + "reqwest", "serde", "serde_json", "serde_yaml", @@ -6270,9 +6273,11 @@ dependencies = [ "tempfile", "ticket", "tokio", + "tokio-tungstenite 0.29.0", "toml", "tui", "uuid", + "workdir", "worker", ] diff --git a/crates/client/src/backend_api.rs b/crates/client/src/backend_api.rs index 5154a13f6..125812660 100644 --- a/crates/client/src/backend_api.rs +++ b/crates/client/src/backend_api.rs @@ -7,6 +7,7 @@ use std::fmt; use std::fs::{self, OpenOptions}; use std::io::Write as _; use std::path::{Path, PathBuf}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; const TOKEN_FILE_NAME: &str = "backend-tokens.json"; const MAX_REDIRECTS: usize = 10; @@ -173,6 +174,37 @@ impl BackendApiClient { .bearer_auth(&self.access_token.0)) } + pub fn websocket_request( + &self, + path_and_query: &str, + ) -> Result, BackendApiClientError> { + let mut url = self.origin.url(path_and_query)?; + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + _ => unreachable!("Backend origin validates HTTP schemes"), + }; + url.set_scheme(scheme).map_err(|()| { + BackendApiClientError::InvalidRequestPath( + "Backend WebSocket URL scheme could not be constructed".to_string(), + ) + })?; + let mut request = url.as_str().into_client_request().map_err(|error| { + BackendApiClientError::InvalidRequestPath(format!( + "Backend WebSocket request is invalid: {error}" + )) + })?; + request.headers_mut().insert( + tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, + self.authorization_header_value().parse().map_err(|error| { + BackendApiClientError::InvalidRequestPath(format!( + "Backend authorization header is invalid: {error}" + )) + })?, + ); + Ok(request) + } + pub fn blocking_request( &self, method: Method, @@ -743,6 +775,33 @@ mod tests { fs::remove_file(path).unwrap(); } + #[test] + fn websocket_requests_are_same_origin_and_use_the_stored_bearer() { + let client = BackendApiClient::from_access_token_for_test( + "https://backend.example:8443", + "websocket-secret", + ) + .unwrap(); + let request = client + .websocket_request("/api/w/workspace/external-workdir-grants/grant/provider") + .unwrap(); + assert_eq!(request.uri().scheme_str(), Some("wss")); + assert_eq!(request.uri().host(), Some("backend.example")); + assert_eq!(request.uri().port_u16(), Some(8443)); + assert_eq!( + request + .headers() + .get(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION) + .unwrap(), + "Bearer websocket-secret" + ); + assert!( + client + .websocket_request("//other.example/provider") + .is_err() + ); + } + #[test] fn client_debug_and_errors_never_include_token_value() { let client = BackendApiClient::from_access_token_for_test( diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index a3e78e3dc..515b27c73 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -21,6 +21,7 @@ pub use server_api::{ WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse, WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse, + WorkingDirectorySource as BackendWorkingDirectorySource, WorkingDirectorySummary as BackendWorkingDirectorySummary, }; use std::fmt; @@ -1018,7 +1019,7 @@ mod tests { "alias": "checkout", "working_directory": { "working_directory_id": "wd-1", - "repository_key": "main", + "source": {"kind": "repository", "repository_key": "main"}, "materializer_kind": "runtime_git_clone", "status": "active", "occupied_by": { @@ -1035,7 +1036,12 @@ mod tests { let attachment = worker.workdir_attachments.into_iter().next().unwrap(); assert_eq!(attachment.alias, "checkout"); let workdir = attachment.working_directory; - assert_eq!(workdir.repository_key, "main"); + assert_eq!( + workdir.source, + BackendWorkingDirectorySource::Repository { + repository_key: "main".to_string(), + } + ); let occupied_by = workdir.occupied_by.expect("occupied Workdir"); assert_eq!(occupied_by.runtime_id, "arcadia"); assert_eq!(occupied_by.worker_id, "worker-opaque-64"); diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 554f88c0d..4627b87ff 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -28,9 +28,10 @@ pub use backend_runtime::{ BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption, BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary, - BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime, - create_backend_worker, get_backend_worker_launch_options, list_backend_stopped_workers, - list_backend_workers, observe_backend_worker_session, restore_backend_worker, + BackendWorkerWorkspaceSummary, BackendWorkingDirectorySource, BackendWorkingDirectorySummary, + connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options, + list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session, + restore_backend_worker, }; pub use backend_workspace::{ BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, diff --git a/crates/fs-operation/Cargo.toml b/crates/fs-operation/Cargo.toml index 51181b75c..f61b8be76 100644 --- a/crates/fs-operation/Cargo.toml +++ b/crates/fs-operation/Cargo.toml @@ -10,6 +10,7 @@ grep-matcher = "0.1.8" grep-regex = "0.1.14" grep-searcher = "0.1.16" ignore = "0.4.25" +libc = "0.2" serde = { workspace = true, features = ["derive"] } sha2 = "0.10.9" tempfile.workspace = true diff --git a/crates/fs-operation/src/glob.rs b/crates/fs-operation/src/glob.rs index 47af5862f..821c3afd3 100644 --- a/crates/fs-operation/src/glob.rs +++ b/crates/fs-operation/src/glob.rs @@ -26,15 +26,31 @@ pub fn run_glob( .map_err(|error| FsError::InvalidGlob(error.to_string()))? .compile_matcher(); let mut matches = Vec::new(); + let mut retained_path_bytes = 0_usize; + let mut provider_truncated = false; let mut walker = WalkBuilder::new(base); walker.hidden(false).follow_links(false); + let mut visited = 0_usize; for entry in walker.build().flatten() { + visited = visited.saturating_add(1); + if visited > crate::MAX_TRAVERSAL_ENTRIES { + return Err(FsError::InvalidArgument(format!( + "glob traversal exceeds provider limit {}", + crate::MAX_TRAVERSAL_ENTRIES + ))); + } let path = entry.path(); let readable = resolve_access_path(path) .is_ok_and(|resolved| access.is_readable_paths(path, &resolved)); if !path.is_file() || !readable { continue; } + let Ok(file) = access.open_read_file(path, path) else { + continue; + }; + if !file.metadata().is_ok_and(|metadata| metadata.is_file()) { + continue; + } let relative = path.strip_prefix(base).unwrap_or(path); if !matcher.is_match(relative) { continue; @@ -42,10 +58,17 @@ pub fn run_glob( let logical = path.strip_prefix(root).map_err(|_| { FsError::InvalidArgument("provider returned a path outside its root".to_string()) })?; - matches.push(FsPath::new(logical.to_string_lossy())?); + let result_path = FsPath::new(logical.to_string_lossy())?; + let retained = result_path.as_str().len().saturating_add(16); + if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES { + provider_truncated = true; + break; + } + retained_path_bytes = retained_path_bytes.saturating_add(retained); + matches.push(result_path); } matches.sort_by(|left, right| left.as_str().cmp(right.as_str())); - let truncated = matches.len() > request.limit; + let truncated = provider_truncated || matches.len() > request.limit; matches.truncate(request.limit); Ok(GlobResult { paths: matches, diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs index 5e58f5a2f..7228ccc20 100644 --- a/crates/fs-operation/src/lib.rs +++ b/crates/fs-operation/src/lib.rs @@ -14,15 +14,167 @@ use std::path::{Path, PathBuf}; use thiserror::Error; pub use glob::run_glob; -pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write}; +pub use local::{ + resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_stat, run_write, +}; pub use operation::*; pub use search::run_grep; +/// Provider-side ceiling for directory/tree traversal, independent of result truncation. +pub const MAX_TRAVERSAL_ENTRIES: usize = 100_000; +/// Provider-side ceiling for aggregate retained result paths per operation. +pub const MAX_RESULT_PATH_BYTES: usize = 1024 * 1024; +/// Provider-side ceiling for aggregate grep source bytes per operation. +pub const MAX_GREP_SOURCE_BYTES: u64 = 64 * 1024 * 1024; + +/// Open `path` beneath `root` without following any symbolic link. Linux uses +/// `openat2` so resolution and open are one kernel-enforced operation. Other +/// platforms fail closed rather than silently weakening an External grant. +#[cfg(target_os = "linux")] +pub fn open_root_no_symlinks(root: &Path) -> std::io::Result { + use std::ffi::CString; + use std::os::fd::{FromRawFd, RawFd}; + use std::os::unix::ffi::OsStrExt; + + let root = CString::new(root.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "root contains NUL"))?; + // SAFETY: `root` is a valid C string and the returned descriptor is checked + // before ownership is transferred to File. + let fd: RawFd = unsafe { + libc::open( + root.as_ptr(), + libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: fd is a new owned descriptor returned by open. + Ok(unsafe { std::fs::File::from_raw_fd(fd) }) +} + +#[cfg(not(target_os = "linux"))] +pub fn open_root_no_symlinks(_root: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "root-confined no-symlink open is unavailable on this platform", + )) +} + +#[cfg(target_os = "linux")] +pub fn open_beneath_no_symlinks_at( + root: &std::fs::File, + relative: &Path, +) -> std::io::Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd, RawFd}; + use std::os::unix::ffi::OsStrExt; + + #[repr(C)] + struct OpenHow { + flags: u64, + mode: u64, + resolve: u64, + } + const RESOLVE_NO_XDEV: u64 = 0x01; + const RESOLVE_NO_SYMLINKS: u64 = 0x04; + const RESOLVE_BENEATH: u64 = 0x08; + + if relative.is_absolute() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "path is outside provider root", + )); + } + let relative = if relative.as_os_str().is_empty() { + CString::new(".").expect("static path") + } else { + CString::new(relative.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL") + })? + }; + let how = OpenHow { + flags: (libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK) as u64, + mode: 0, + resolve: RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_XDEV, + }; + // SAFETY: `how` and the path remain alive for the syscall duration, and the + // borrowed root descriptor remains valid for the call. + let fd = unsafe { + libc::syscall( + libc::SYS_openat2, + root.as_raw_fd(), + relative.as_ptr(), + &how as *const OpenHow, + std::mem::size_of::(), + ) as RawFd + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: fd is a new owned descriptor returned by openat2. + Ok(unsafe { std::fs::File::from_raw_fd(fd) }) +} + +#[cfg(not(target_os = "linux"))] +pub fn open_beneath_no_symlinks_at( + _root: &std::fs::File, + _relative: &Path, +) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "root-confined no-symlink open is unavailable on this platform", + )) +} + +#[cfg(target_os = "linux")] +pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result { + let relative = path.strip_prefix(root).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "path is outside provider root", + ) + })?; + let root = open_root_no_symlinks(root)?; + open_beneath_no_symlinks_at(&root, relative) +} + +#[cfg(not(target_os = "linux"))] +pub fn open_beneath_no_symlinks(_root: &Path, _path: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "root-confined no-symlink open is unavailable on this platform", + )) +} + /// Provider-owned access policy used by local filesystem operations. pub trait FsAccessPolicy: Send + Sync { fn is_readable(&self, path: &Path) -> bool; fn is_writable(&self, path: &Path) -> bool; + /// Open an already-authorized readable file. Capability providers override + /// this to bind path resolution and open into one root-confined operation. + fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result { + std::fs::File::open(resolved) + } + + /// Obtain metadata for an already-authorized path. Capability providers + /// override this to prevent a path swap between authorization and stat. + fn read_metadata( + &self, + logical: &Path, + _resolved: &Path, + ) -> std::io::Result { + std::fs::symlink_metadata(logical) + } + + /// Open an already-authorized directory for bounded enumeration. + /// Capability providers override this to bind traversal to a confined + /// directory descriptor rather than reopening a mutable path. + fn open_read_dir(&self, _logical: &Path, resolved: &Path) -> std::io::Result { + std::fs::read_dir(resolved) + } + /// Authorize both the Workdir-visible path and its provider-resolved /// target. Implementations that do not distinguish symbolic-link identity /// retain resolved-target semantics through the defaults. @@ -201,6 +353,165 @@ mod tests { assert!(serde_json::from_str::(r#""../secret""#).is_err()); } + #[cfg(target_os = "linux")] + #[test] + fn root_confined_open_rejects_a_symlink_swap_between_authorization_and_open() { + use std::os::unix::fs::symlink; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct SwapAccess { + root: PathBuf, + selected: PathBuf, + outside: PathBuf, + swapped: AtomicBool, + } + impl FsAccessPolicy for SwapAccess { + fn is_readable(&self, path: &Path) -> bool { + path.starts_with(&self.root) + } + fn is_writable(&self, _path: &Path) -> bool { + false + } + fn is_readable_paths(&self, _logical: &Path, _resolved: &Path) -> bool { + if !self.swapped.swap(true, Ordering::SeqCst) { + std::fs::remove_file(&self.selected).unwrap(); + symlink(&self.outside, &self.selected).unwrap(); + } + true + } + fn open_read_file( + &self, + _logical: &Path, + resolved: &Path, + ) -> std::io::Result { + open_beneath_no_symlinks(&self.root, resolved) + } + } + + let root_dir = tempfile::tempdir().unwrap(); + let outside_dir = tempfile::tempdir().unwrap(); + let root = root_dir.path().canonicalize().unwrap(); + let outside = outside_dir.path().join("secret.txt"); + let selected = root.join("selected.txt"); + std::fs::write(&selected, "safe").unwrap(); + std::fs::write(&outside, "secret").unwrap(); + let access = SwapAccess { + root: root.clone(), + selected, + outside, + swapped: AtomicBool::new(false), + }; + + let error = run_read_bounded( + &root, + ReadRequest { + path: FsPath::new("selected.txt").unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }, + &access, + BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .expect_err("openat2 must reject the swapped symbolic link"); + assert!(matches!(error, FsError::Io { .. })); + } + + #[cfg(target_os = "linux")] + #[test] + fn root_confined_stat_and_list_reject_swaps_after_authorization() { + use std::os::unix::fs::symlink; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct SwapAccess { + root: PathBuf, + selected: PathBuf, + outside: PathBuf, + directory: bool, + swapped: AtomicBool, + } + impl FsAccessPolicy for SwapAccess { + fn is_readable(&self, path: &Path) -> bool { + path.starts_with(&self.root) + } + fn is_writable(&self, _path: &Path) -> bool { + false + } + fn is_readable_paths(&self, logical: &Path, _resolved: &Path) -> bool { + if logical == self.selected && !self.swapped.swap(true, Ordering::SeqCst) { + if self.directory { + std::fs::remove_dir(&self.selected).unwrap(); + } else { + std::fs::remove_file(&self.selected).unwrap(); + } + symlink(&self.outside, &self.selected).unwrap(); + } + true + } + fn read_metadata( + &self, + _logical: &Path, + resolved: &Path, + ) -> std::io::Result { + open_beneath_no_symlinks(&self.root, resolved)?.metadata() + } + fn open_read_dir( + &self, + _logical: &Path, + resolved: &Path, + ) -> std::io::Result { + use std::os::fd::AsRawFd; + let directory = open_beneath_no_symlinks(&self.root, resolved)?; + std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd())) + } + } + + let root_dir = tempfile::tempdir().unwrap(); + let outside_dir = tempfile::tempdir().unwrap(); + let root = root_dir.path().canonicalize().unwrap(); + let selected_file = root.join("selected.txt"); + let outside_file = outside_dir.path().join("secret.txt"); + std::fs::write(&selected_file, "safe").unwrap(); + std::fs::write(&outside_file, "secret").unwrap(); + let stat_error = run_stat( + &root, + StatRequest { + path: FsPath::new("selected.txt").unwrap(), + }, + &SwapAccess { + root: root.clone(), + selected: selected_file, + outside: outside_file, + directory: false, + swapped: AtomicBool::new(false), + }, + ) + .expect_err("confined stat must reject a swapped symlink"); + assert!(matches!(stat_error, FsError::Io { .. })); + + let selected_dir = root.join("selected-dir"); + let outside_content = outside_dir.path().join("outside-dir"); + std::fs::create_dir(&selected_dir).unwrap(); + std::fs::create_dir(&outside_content).unwrap(); + std::fs::write(outside_content.join("secret.txt"), "secret").unwrap(); + let list_error = run_list( + &root, + ListRequest { + path: FsPath::new("selected-dir").unwrap(), + limit: 10, + }, + &SwapAccess { + root: root.clone(), + selected: selected_dir, + outside: outside_content, + directory: true, + swapped: AtomicBool::new(false), + }, + ) + .expect_err("confined list must reject a swapped symlink"); + assert!(matches!(list_error, FsError::Io { .. })); + } + #[test] fn direct_operations_cover_stat_read_write_edit_and_list() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/fs-operation/src/local.rs b/crates/fs-operation/src/local.rs index e22b98235..3e7f085a3 100644 --- a/crates/fs-operation/src/local.rs +++ b/crates/fs-operation/src/local.rs @@ -1,16 +1,18 @@ use std::ffi::OsString; use std::fs; -use std::io::Write; +use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; use crate::{ - ContentHash, EditRequest, EditResult, EntryKind, FsAccessPolicy, FsError, FsPath, ListEntry, - ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, - WriteResult, direct_symlink, + BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsAccessPolicy, FsError, + FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, + WriteRequest, WriteResult, direct_symlink, }; +const READ_BUFFER_BYTES: usize = 16 * 1024; + /// Execute stat while keeping host paths inside the provider boundary. pub fn run_stat( root: &Path, @@ -23,7 +25,9 @@ pub fn run_stat( if !access.is_readable_paths(&path, &resolved) { return Err(FsError::OutOfScope(PathBuf::from(logical.as_str()))); } - let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?; + let metadata = access + .read_metadata(&path, &resolved) + .map_err(|error| map_io(&logical, error))?; let kind = if metadata.file_type().is_symlink() { EntryKind::Symlink } else if metadata.is_file() { @@ -44,20 +48,110 @@ pub fn run_read( root: &Path, request: ReadRequest, access: &dyn FsAccessPolicy, +) -> Result { + run_read_with_limits(root, request, access, None) +} + +/// Execute a read while enforcing provider-owned source and response bounds. +/// +/// The source is streamed through a fixed-size buffer. Files larger than the +/// configured source bound fail before their content is read, and growth after +/// the metadata check is fenced while streaming. +pub fn run_read_bounded( + root: &Path, + request: ReadRequest, + access: &dyn FsAccessPolicy, + limits: BoundedReadLimits, +) -> Result { + run_read_with_limits(root, request, access, Some(limits)) +} + +fn run_read_with_limits( + root: &Path, + request: ReadRequest, + access: &dyn FsAccessPolicy, + limits: Option, ) -> Result { let logical = request.path; let path = resolve(root, &logical)?; - let path = require_access(&path, &logical, access, false, false)?; - let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?; + let target = require_access(&path, &logical, access, false, false)?; + let file = access + .open_read_file(&path, &target) + .map_err(|error| map_io(&logical, error))?; + let metadata = file.metadata().map_err(|error| map_io(&logical, error))?; if metadata.is_dir() { return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); } - let bytes = fs::read(&path).map_err(|error| map_io(&logical, error))?; - let content_hash = hash_bytes(&bytes); - let lines = bytes - .split_inclusive(|byte| *byte == b'\n') - .collect::>(); - let total_lines = lines.len(); + if !metadata.is_file() { + return Err(FsError::InvalidArgument( + "read source must be a regular file".to_string(), + )); + } + if let Some(limits) = limits + && metadata.len() > limits.max_source_bytes + { + return Err(FsError::InvalidArgument(format!( + "read source size {} exceeds provider limit {}", + metadata.len(), + limits.max_source_bytes + ))); + } + + let response_limit = limits + .map(|limits| request.max_bytes.min(limits.max_response_bytes)) + .unwrap_or(request.max_bytes); + let retained_limit = response_limit.saturating_add(4); + let selected_end = request.offset.saturating_add(request.limit); + let mut selected = Vec::with_capacity(retained_limit.min(READ_BUFFER_BYTES)); + let mut selected_bytes_seen = 0usize; + let mut current_line = 0usize; + let mut source_bytes_seen = 0u64; + let mut last_byte = None; + let mut content_hasher = Sha256::new(); + let mut reader = BufReader::with_capacity(READ_BUFFER_BYTES, file); + let mut buffer = [0u8; READ_BUFFER_BYTES]; + loop { + let read_limit = limits + .map(|limits| { + limits + .max_source_bytes + .saturating_sub(source_bytes_seen) + .saturating_add(1) + .min(READ_BUFFER_BYTES as u64) as usize + }) + .unwrap_or(READ_BUFFER_BYTES); + let read = reader + .read(&mut buffer[..read_limit]) + .map_err(|error| map_io(&logical, error))?; + if read == 0 { + break; + } + source_bytes_seen = source_bytes_seen.saturating_add(read as u64); + if let Some(limits) = limits + && source_bytes_seen > limits.max_source_bytes + { + return Err(FsError::InvalidArgument(format!( + "read source exceeds provider limit {}", + limits.max_source_bytes + ))); + } + content_hasher.update(&buffer[..read]); + for byte in &buffer[..read] { + if current_line >= request.offset && current_line < selected_end { + selected_bytes_seen = selected_bytes_seen.saturating_add(1); + if selected.len() < retained_limit { + selected.push(*byte); + } + } + if *byte == b'\n' { + current_line = current_line.saturating_add(1); + } + last_byte = Some(*byte); + } + } + + let total_lines = + current_line.saturating_add(usize::from(last_byte.is_some_and(|byte| byte != b'\n'))); if request.offset > total_lines && request.offset != 0 { return Err(FsError::InvalidArgument(format!( "offset {} exceeds file length {total_lines}", @@ -68,16 +162,14 @@ pub fn run_read( .offset .saturating_add(request.limit) .min(total_lines); - let mut selected = lines[request.offset.min(total_lines)..end] - .iter() - .flat_map(|line| line.iter().copied()) - .collect::>(); - let byte_truncated = selected.len() > request.max_bytes; + let byte_truncated = selected_bytes_seen > response_limit; if byte_truncated { - let mut byte_end = request.max_bytes; - if let Ok(text) = std::str::from_utf8(&selected) { - while byte_end > 0 && !text.is_char_boundary(byte_end) { - byte_end -= 1; + let mut byte_end = response_limit.min(selected.len()); + while byte_end > 0 { + match std::str::from_utf8(&selected[..byte_end]) { + Ok(_) => break, + Err(error) if error.error_len().is_none() => byte_end -= 1, + Err(_) => break, } } selected.truncate(byte_end); @@ -87,7 +179,7 @@ pub fn run_read( bytes: selected, start_line: request.offset, total_lines, - content_hash, + content_hash: content_hasher.finalize().into(), truncated: end < total_lines || byte_truncated, }) } @@ -173,35 +265,39 @@ pub fn run_list( let path = resolve(root, &logical)?; let logical_base = path.clone(); let path = require_access(&path, &logical, access, false, true)?; - let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?; + let metadata = access + .open_read_file(&logical_base, &path) + .and_then(|directory| directory.metadata()) + .map_err(|error| map_io(&logical, error))?; if !metadata.is_dir() { return Err(FsError::NotDirectory(PathBuf::from(logical.as_str()))); } let mut entries = Vec::new(); - let read_dir = fs::read_dir(&path).map_err(|error| map_io(&logical, error))?; - for entry in read_dir { + let mut retained_path_bytes = 0_usize; + let mut provider_truncated = false; + let read_dir = access + .open_read_dir(&logical_base, &path) + .map_err(|error| map_io(&logical, error))?; + for (entry_index, entry) in read_dir.enumerate() { + if entry_index >= crate::MAX_TRAVERSAL_ENTRIES { + return Err(FsError::InvalidArgument(format!( + "directory traversal exceeds provider limit {}", + crate::MAX_TRAVERSAL_ENTRIES + ))); + } let entry = entry.map_err(|error| map_io(&logical, error))?; - let absolute = entry.path(); - let relative_to_base = absolute.strip_prefix(&path).map_err(|_| { - FsError::InvalidArgument("provider returned a path outside its list base".to_string()) - })?; - let logical_absolute = logical_base.join(relative_to_base); - let resolved = match resolve_access_path(&absolute) { + let logical_absolute = logical_base.join(entry.file_name()); + let resolved = match resolve_access_path(&logical_absolute) { Ok(resolved) => resolved, Err(_) => continue, }; if !access.is_readable_paths(&logical_absolute, &resolved) { continue; } - let link_metadata = - fs::symlink_metadata(&absolute).map_err(|error| map_io(&logical, error))?; - let is_symlink = link_metadata.file_type().is_symlink(); - let metadata = if is_symlink { - link_metadata - } else { - entry.metadata().map_err(|error| map_io(&logical, error))? - }; - let kind = if is_symlink { + let metadata = access + .read_metadata(&logical_absolute, &resolved) + .map_err(|error| map_io(&logical, error))?; + let kind = if metadata.file_type().is_symlink() { EntryKind::Symlink } else if metadata.is_file() { EntryKind::File @@ -213,8 +309,15 @@ pub fn run_list( let relative = logical_absolute.strip_prefix(root).map_err(|_| { FsError::InvalidArgument("provider returned a path outside its root".to_string()) })?; + let result_path = FsPath::new(relative.to_string_lossy())?; + let retained = result_path.as_str().len().saturating_add(64); + if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES { + provider_truncated = true; + break; + } + retained_path_bytes = retained_path_bytes.saturating_add(retained); entries.push(ListEntry { - path: FsPath::new(relative.to_string_lossy())?, + path: result_path, kind, size: metadata.len(), }); @@ -228,7 +331,7 @@ pub fn run_list( }); let total_entries = entries.len(); let total_bytes = entries.iter().map(|entry| entry.size).sum(); - let truncated = entries.len() > request.limit; + let truncated = provider_truncated || entries.len() > request.limit; entries.truncate(request.limit); Ok(ListResult { entries, @@ -366,3 +469,71 @@ fn map_io(logical: &FsPath, error: std::io::Error) -> FsError { }, } } + +#[cfg(test)] +mod bounded_read_tests { + use std::path::{Path, PathBuf}; + + use super::*; + + struct RootAccess(PathBuf); + + impl FsAccessPolicy for RootAccess { + fn is_readable(&self, path: &Path) -> bool { + path.starts_with(&self.0) + } + + fn is_writable(&self, _path: &Path) -> bool { + false + } + } + + #[test] + fn bounded_read_rejects_an_oversized_source_before_loading_content() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let source = root.join("large.log"); + let file = fs::File::create(&source).unwrap(); + file.set_len(4097).unwrap(); + + let error = run_read_bounded( + &root, + ReadRequest { + path: FsPath::new("large.log").unwrap(), + offset: 0, + limit: 10, + max_bytes: usize::MAX, + }, + &RootAccess(root.clone()), + BoundedReadLimits::new(4096, 1024).unwrap(), + ) + .unwrap_err(); + + assert!(matches!(error, FsError::InvalidArgument(_))); + assert!(error.to_string().contains("exceeds provider limit 4096")); + } + + #[test] + fn bounded_read_caps_retained_response_independently_of_request() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + fs::write(root.join("events.log"), "alpha\nbeta\ngamma\n").unwrap(); + + let result = run_read_bounded( + &root, + ReadRequest { + path: FsPath::new("events.log").unwrap(), + offset: 0, + limit: 10, + max_bytes: usize::MAX, + }, + &RootAccess(root.clone()), + BoundedReadLimits::new(1024, 5).unwrap(), + ) + .unwrap(); + + assert_eq!(result.bytes, b"alpha"); + assert_eq!(result.total_lines, 3); + assert!(result.truncated); + } +} diff --git a/crates/fs-operation/src/operation.rs b/crates/fs-operation/src/operation.rs index a883b8ee4..4d74184b9 100644 --- a/crates/fs-operation/src/operation.rs +++ b/crates/fs-operation/src/operation.rs @@ -5,6 +5,71 @@ use serde::{Deserialize, Serialize}; use crate::FsError; +/// Absolute provider-side bounds for one filesystem read. +/// +/// `max_source_bytes` limits how much of the source the provider will inspect +/// while computing line and content metadata. `max_response_bytes` limits the +/// bytes retained for the response independently of the caller's requested +/// `ReadRequest::max_bytes`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct BoundedReadLimits { + pub max_source_bytes: u64, + pub max_response_bytes: usize, +} + +impl BoundedReadLimits { + /// Conservative defaults for a client-hosted, read-only provider. + pub const EXTERNAL_DEFAULT: Self = Self { + max_source_bytes: 64 * 1024 * 1024, + max_response_bytes: 1024 * 1024, + }; + + pub fn new(max_source_bytes: u64, max_response_bytes: usize) -> Result { + let limits = Self { + max_source_bytes, + max_response_bytes, + }; + limits.validate()?; + Ok(limits) + } + + pub fn validate(self) -> Result<(), FsError> { + if self.max_source_bytes == 0 { + return Err(FsError::InvalidArgument( + "max_source_bytes must be greater than zero".to_string(), + )); + } + if self.max_response_bytes == 0 { + return Err(FsError::InvalidArgument( + "max_response_bytes must be greater than zero".to_string(), + )); + } + if self.max_response_bytes as u128 > self.max_source_bytes as u128 { + return Err(FsError::InvalidArgument( + "max_response_bytes must not exceed max_source_bytes".to_string(), + )); + } + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BoundedReadLimitsWire { + max_source_bytes: u64, + max_response_bytes: usize, +} + +impl<'de> Deserialize<'de> for BoundedReadLimits { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = BoundedReadLimitsWire::deserialize(deserializer)?; + Self::new(wire.max_source_bytes, wire.max_response_bytes).map_err(serde::de::Error::custom) + } +} + /// Scope-checked filesystem path. Relative paths resolve below the bound /// Workdir root; absolute paths require an explicit matching scope rule. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] @@ -89,11 +154,13 @@ impl fmt::Display for FsPath { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct StatRequest { pub path: FsPath, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct StatResult { pub path: FsPath, pub kind: EntryKind, @@ -112,6 +179,7 @@ pub enum EntryKind { pub type ContentHash = [u8; 32]; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ReadRequest { pub path: FsPath, pub offset: usize, @@ -120,6 +188,7 @@ pub struct ReadRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ReadResult { pub path: FsPath, pub bytes: Vec, @@ -130,6 +199,7 @@ pub struct ReadResult { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct WriteRequest { pub path: FsPath, pub content: Vec, @@ -143,6 +213,7 @@ pub struct WriteResult { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct EditRequest { pub path: FsPath, pub old_string: String, @@ -152,6 +223,7 @@ pub struct EditRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct EditResult { pub replacements: usize, pub bytes_written: usize, @@ -159,12 +231,14 @@ pub struct EditResult { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ListRequest { pub path: FsPath, pub limit: usize, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ListEntry { pub path: FsPath, pub kind: EntryKind, @@ -172,6 +246,7 @@ pub struct ListEntry { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ListResult { pub entries: Vec, pub total_entries: usize, @@ -180,6 +255,7 @@ pub struct ListResult { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GlobRequest { pub pattern: String, pub path: FsPath, @@ -187,6 +263,7 @@ pub struct GlobRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GlobResult { pub paths: Vec, pub truncated: bool, @@ -207,6 +284,7 @@ impl Default for GrepOutputMode { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GrepRequest { pub pattern: String, pub path: FsPath, @@ -222,6 +300,7 @@ pub struct GrepRequest { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct GrepResult { /// Provider-rendered bounded grep report. Keeping rendering here avoids /// transferring candidate files across a remote provider boundary. diff --git a/crates/fs-operation/src/search.rs b/crates/fs-operation/src/search.rs index 57f107f47..dbf001228 100644 --- a/crates/fs-operation/src/search.rs +++ b/crates/fs-operation/src/search.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::fmt::Write as _; +use std::io::Read; use std::path::{Path, PathBuf}; use crate::FsAccessPolicy; @@ -14,6 +15,35 @@ use crate::{ FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path, }; +struct SourceBoundedReader<'a, R> { + inner: R, + remaining: &'a mut u64, +} + +impl Read for SourceBoundedReader<'_, R> { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if buffer.is_empty() { + return Ok(0); + } + if *self.remaining == 0 { + let mut probe = [0_u8; 1]; + return match self.inner.read(&mut probe)? { + 0 => Ok(0), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "grep source exceeded provider byte limit", + )), + }; + } + let limit = usize::try_from(*self.remaining) + .unwrap_or(usize::MAX) + .min(buffer.len()); + let read = self.inner.read(&mut buffer[..limit])?; + *self.remaining = (*self.remaining).saturating_sub(read as u64); + Ok(read) + } +} + struct ContentLine { path: PathBuf, line_number: Option, @@ -27,9 +57,25 @@ struct GrepReport { files: Vec, counts: Vec<(PathBuf, usize)>, lines: Vec, + retained_bytes: usize, truncated: bool, } +const MAX_GREP_RESULT_RETAINED_BYTES: usize = 512 * 1024; + +fn reserve_report_bytes(report: &mut GrepReport, bytes: usize) -> bool { + let Some(next) = report.retained_bytes.checked_add(bytes) else { + report.truncated = true; + return false; + }; + if next > MAX_GREP_RESULT_RETAINED_BYTES { + report.truncated = true; + return false; + } + report.retained_bytes = next; + true +} + impl GrepReport { fn into_result(self, root: &Path) -> GrepResult { let (match_count, matched_files) = match self.mode { @@ -281,12 +327,14 @@ pub fn run_grep( files: Vec::new(), counts: Vec::new(), lines: Vec::new(), + retained_bytes: 0, truncated: false, }; let mut matching_files_seen = 0; let mut matches_seen = 0; if base_meta.is_file() { + let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES; if direct_file_selected(&base, overrides.as_ref(), types.as_ref()) { scan_path( &mut searcher, @@ -298,6 +346,8 @@ pub fn run_grep( &mut matches_seen, offset, head_limit, + &mut source_bytes_remaining, + access, )?; } return Ok(report.into_result(root)); @@ -319,7 +369,16 @@ pub fn run_grep( walker.overrides(overrides); } + let mut visited = 0_usize; + let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES; for entry in walker.build().flatten() { + visited = visited.saturating_add(1); + if visited > crate::MAX_TRAVERSAL_ENTRIES { + return Err(FsError::InvalidArgument(format!( + "grep traversal exceeds provider limit {}", + crate::MAX_TRAVERSAL_ENTRIES + ))); + } if !entry .file_type() .map(|kind| kind.is_file()) @@ -343,6 +402,8 @@ pub fn run_grep( &mut matches_seen, offset, head_limit, + &mut source_bytes_remaining, + access, )? { break; } @@ -362,13 +423,38 @@ fn scan_path( matches_seen: &mut usize, offset: usize, head_limit: usize, + source_bytes_remaining: &mut u64, + access: &dyn FsAccessPolicy, ) -> Result { + let reader = access + .open_read_file(path, path) + .map_err(|error| FsError::io(path, error))?; + let metadata = reader + .metadata() + .map_err(|error| FsError::io(path, error))?; + if !metadata.is_file() { + return Err(FsError::InvalidArgument( + "grep source must be a regular file".to_string(), + )); + } + if metadata.len() > *source_bytes_remaining { + return Err(FsError::InvalidArgument( + "grep sources exceed provider byte limit".to_string(), + )); + } + let mut reader = SourceBoundedReader { + inner: reader, + remaining: source_bytes_remaining, + }; match mode { GrepOutputMode::FilesWithMatches => { - if !scan_any_match(searcher, matcher, path)? { + if !scan_any_match(searcher, matcher, &mut reader, path)? { return Ok(false); } if *matching_files_seen >= offset { + if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(1)) { + return Ok(true); + } report.files.push(path.to_path_buf()); if report.files.len() >= head_limit { report.truncated = true; @@ -378,11 +464,14 @@ fn scan_path( *matching_files_seen += 1; } GrepOutputMode::Count => { - let count = scan_count(searcher, matcher, path)?; + let count = scan_count(searcher, matcher, &mut reader, path)?; if count == 0 { return Ok(false); } if *matching_files_seen >= offset { + if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(32)) { + return Ok(true); + } report.counts.push((path.to_path_buf(), count)); if report.counts.len() >= head_limit { report.truncated = true; @@ -396,13 +485,18 @@ fn scan_path( let mut sink = ContentSink { path: path.to_path_buf(), lines: &mut report.lines, + retained_bytes: &mut report.retained_bytes, + truncated: &mut report.truncated, matches_seen, offset, head_limit, }; searcher - .search_path(matcher, path, &mut sink) + .search_reader(matcher, &mut reader, &mut sink) .map_err(|error| FsError::io(path, error))?; + if report.truncated { + return Ok(true); + } if *matches_seen >= offset.saturating_add(head_limit) && *matches_seen > before_count { report.truncated = true; return Ok(true); @@ -415,6 +509,7 @@ fn scan_path( fn scan_any_match( searcher: &mut Searcher, matcher: &grep_regex::RegexMatcher, + reader: &mut dyn Read, path: &Path, ) -> Result { let mut hit = false; @@ -423,7 +518,7 @@ fn scan_any_match( Ok(false) // stop searching this file immediately }); searcher - .search_path(matcher, path, sink) + .search_reader(matcher, reader, sink) .map_err(|e| FsError::io(path, e))?; Ok(hit) } @@ -431,6 +526,7 @@ fn scan_any_match( fn scan_count( searcher: &mut Searcher, matcher: &grep_regex::RegexMatcher, + reader: &mut dyn Read, path: &Path, ) -> Result { let mut count = 0usize; @@ -439,7 +535,7 @@ fn scan_count( Ok(true) }); searcher - .search_path(matcher, path, sink) + .search_reader(matcher, reader, sink) .map_err(|e| FsError::io(path, e))?; Ok(count) } @@ -447,11 +543,34 @@ fn scan_count( struct ContentSink<'a> { path: PathBuf, lines: &'a mut Vec, + retained_bytes: &'a mut usize, + truncated: &'a mut bool, matches_seen: &'a mut usize, offset: usize, head_limit: usize, } +impl ContentSink<'_> { + fn reserve(&mut self, content_bytes: usize) -> bool { + let retained = self + .path + .to_string_lossy() + .len() + .saturating_add(content_bytes) + .saturating_add(64); + let Some(next) = (*self.retained_bytes).checked_add(retained) else { + *self.truncated = true; + return false; + }; + if next > MAX_GREP_RESULT_RETAINED_BYTES { + *self.truncated = true; + return false; + } + *self.retained_bytes = next; + true + } +} + impl Sink for ContentSink<'_> { type Error = std::io::Error; @@ -468,6 +587,9 @@ impl Sink for ContentSink<'_> { return Ok(false); } + if !self.reserve(mat.bytes().len()) { + return Ok(false); + } let text = String::from_utf8_lossy(mat.bytes()) .trim_end_matches('\n') .trim_end_matches('\r') @@ -493,6 +615,9 @@ impl Sink for ContentSink<'_> { if seen >= self.offset.saturating_add(self.head_limit) { return Ok(false); } + if !self.reserve(ctx.bytes().len()) { + return Ok(false); + } let text = String::from_utf8_lossy(ctx.bytes()) .trim_end_matches('\n') .trim_end_matches('\r') @@ -506,3 +631,31 @@ impl Sink for ContentSink<'_> { Ok(true) } } + +#[cfg(test)] +mod bounded_reader_tests { + use std::io::{Cursor, Read as _}; + + use super::SourceBoundedReader; + + #[test] + fn source_budget_is_shared_across_candidate_files() { + let mut remaining = 3_u64; + let mut first = SourceBoundedReader { + inner: Cursor::new(b"ab"), + remaining: &mut remaining, + }; + let mut output = Vec::new(); + first.read_to_end(&mut output).unwrap(); + drop(first); + assert_eq!(remaining, 1); + + let mut second = SourceBoundedReader { + inner: Cursor::new(b"cd"), + remaining: &mut remaining, + }; + let error = second.read_to_end(&mut output).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert_eq!(output, b"abc"); + } +} diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index b4f583756..9520f37ec 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -352,6 +352,12 @@ pub struct LogicalWorkdirAttachment { /// Stable Worker-local routing key. This is not a Workdir id or display name. pub alias: workdir::WorkdirAttachmentAlias, pub working_directory_id: String, + #[serde(default = "default_workdir_session_capabilities")] + pub capabilities: workdir::WorkdirSessionCapabilities, +} + +fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities { + workdir::WorkdirSessionCapabilities::ALL } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 62f51a1c3..26e94a7f3 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1482,6 +1482,7 @@ pub struct Diagnostic { pub enum WorkingDirectoryMaterializerKind { #[default] RuntimeGitClone, + ClientHostedExternal, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -1612,6 +1613,15 @@ pub struct RuntimeWorkingDirectorySummary { pub occupied_by: Option, } +/// Public Workdir source identity. Provider routing details and host paths are intentionally absent. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum WorkingDirectorySource { + Repository { repository_key: String }, + ExternalGrant { grant_id: String }, +} + /// Public, provider-neutral Workdir inventory projection. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -1622,7 +1632,7 @@ pub struct WorkingDirectorySummary { /// Optional human-facing label, never a routing key. #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, - pub repository_key: String, + pub source: WorkingDirectorySource, #[serde(default, skip_serializing_if = "Option::is_none")] pub creation_selector: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1662,6 +1672,9 @@ impl From workdir::workspace::WorkingDirectoryMaterializerKind::RuntimeGitClone => { Self::RuntimeGitClone } + workdir::workspace::WorkingDirectoryMaterializerKind::ClientHostedExternal => { + Self::ClientHostedExternal + } } } } @@ -1738,7 +1751,14 @@ impl From for WorkingDirectorySumma Self { working_directory_id: value.working_directory_id, display_name: value.display_name, - repository_key: value.repository_key, + source: match value.source { + workdir::workspace::WorkingDirectorySource::Repository { repository_key } => { + WorkingDirectorySource::Repository { repository_key } + } + workdir::workspace::WorkingDirectorySource::ExternalGrant { grant_id } => { + WorkingDirectorySource::ExternalGrant { grant_id } + } + }, creation_selector: value.creation_selector, creation_ref: value.creation_ref, creation_tree: value.creation_tree, @@ -1755,6 +1775,31 @@ impl From for WorkingDirectorySumma } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ExternalWorkdirGrantCreateRequest { + pub provider_instance_id: String, + pub display_name: String, + pub ttl_seconds: u64, + pub read_only: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ExternalWorkdirGrantResponse { + pub grant_id: String, + pub workspace_id: String, + pub working_directory_id: String, + pub provider_instance_id: String, + pub display_name: String, + pub permissions: String, + pub expires_at: String, + pub generation: u64, + pub status: String, +} + /// Browser/Rust-client Workdir materialization request. /// /// `runtime_id = None` requests Workspace default Runtime resolution and @@ -1791,7 +1836,8 @@ pub struct WorkingDirectoryListResponse { #[serde(deny_unknown_fields)] pub struct WorkingDirectoryDetailResponse { pub workspace_id: String, - pub runtime_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, pub item: WorkingDirectorySummary, pub diagnostics: Vec, } @@ -3653,6 +3699,7 @@ pub fn workdir_api_typescript() -> String { WorkingDirectoryStatusKind::decl(&config), WorkingDirectoryCleanupTarget::decl(&config), WorkingDirectoryOccupancy::decl(&config), + WorkingDirectorySource::decl(&config), WorkingDirectorySummary::decl(&config), WorkingDirectoryCreateRequest::decl(&config), WorkingDirectoryListResponse::decl(&config), @@ -3683,6 +3730,7 @@ pub fn worker_launch_api_typescript() -> String { RuntimeWorkingDirectoryCleanupTarget::decl(&config), RuntimeWorkingDirectorySummary::decl(&config), WorkingDirectoryOccupancy::decl(&config), + WorkingDirectorySource::decl(&config), WorkingDirectorySummary::decl(&config), WorkerWorkspaceSummary::decl(&config), WorkerImplementationSummary::decl(&config), @@ -5123,7 +5171,9 @@ mod tests { let value = serde_json::to_value(WorkingDirectorySummary { working_directory_id: "workdir-1".into(), display_name: None, - repository_key: "main".into(), + source: WorkingDirectorySource::Repository { + repository_key: "main".into(), + }, creation_selector: None, creation_ref: None, creation_tree: None, diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index 6f94ffc29..547089b19 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -3,8 +3,9 @@ use std::io; use std::time::Duration; use client::{ - BackendRuntimeListTarget, BackendWorkerSummary, WorkerSessionAvailability, - list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session, + BackendRuntimeListTarget, BackendWorkerSummary, BackendWorkingDirectorySource, + WorkerSessionAvailability, list_backend_stopped_workers, list_backend_workers, + observe_backend_worker_session, }; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::Frame; @@ -416,10 +417,11 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String { } let attachment = &worker.workdir_attachments[0]; let directory = &attachment.working_directory; - let label = directory - .display_name - .as_deref() - .unwrap_or(&directory.repository_key); + let source_label = match &directory.source { + BackendWorkingDirectorySource::Repository { repository_key } => repository_key.as_str(), + BackendWorkingDirectorySource::ExternalGrant { .. } => "external", + }; + let label = directory.display_name.as_deref().unwrap_or(source_label); format!( "wd:{}:{}・{}", attachment.alias, label, directory.working_directory_id @@ -547,7 +549,7 @@ mod tests { "working_directory": { "working_directory_id": "001a06a9f0202000000", "display_name": "Checkout", - "repository_key": "main", + "source": {"kind": "repository", "repository_key": "main"}, "materializer_kind": "runtime_git_clone", "status": "active", "cleanliness": "clean" @@ -591,7 +593,7 @@ mod tests { "alias": "checkout", "working_directory": { "working_directory_id": "workdir-1", - "repository_key": "main", + "source": {"kind": "repository", "repository_key": "main"}, "materializer_kind": "runtime_git_clone", "status": "active" } diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs new file mode 100644 index 000000000..5bfca2214 --- /dev/null +++ b/crates/workdir/src/external.rs @@ -0,0 +1,599 @@ +//! Versioned websocket DTOs for a client-hosted External Workdir provider. +//! +//! Authentication belongs to the websocket upgrade and Backend grant +//! authority. These frames intentionally contain only opaque identities, +//! logical Workdir operations, generation fences, and bounded control data. +//! They must never carry a provider host path, credential, or tunnel token. + +use std::path::{Component, Path}; + +use serde::{Deserialize, Serialize}; + +use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError}; +use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities}; + +pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 1; +/// Maximum serialized Backend-to-provider command frame size. +pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024; +/// Maximum serialized provider-to-Backend result frame size. Read bytes are +/// represented as a JSON byte array, so this must exceed the semantic payload +/// bound while remaining an explicit transport allocation ceiling. +pub const MAX_EXTERNAL_PROVIDER_FRAME_BYTES: usize = 8 * 1024 * 1024; +const MAX_PROTOCOL_ID_BYTES: usize = 128; +const MAX_EXTERNAL_PATH_BYTES: usize = 4 * 1024; +const MAX_EXTERNAL_PATTERN_BYTES: usize = 4 * 1024; +const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000; +const MAX_EXTERNAL_GREP_CONTEXT: usize = 100; +const MAX_EXTERNAL_SCOPE_RULES: usize = 64; + +/// Fail-closed wire version for the External Workdir provider protocol. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct ExternalWorkdirProtocolVersion(u16); + +impl ExternalWorkdirProtocolVersion { + pub const CURRENT: Self = Self(EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION); + + pub const fn as_u16(self) -> u16 { + self.0 + } +} + +impl<'de> Deserialize<'de> for ExternalWorkdirProtocolVersion { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let version = u16::deserialize(deserializer)?; + if version == EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION { + Ok(Self::CURRENT) + } else { + Err(serde::de::Error::custom(format!( + "unsupported External Workdir provider protocol version {version}" + ))) + } + } +} + +macro_rules! protocol_id { + ($name:ident, $label:literal) => { + #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_PROTOCOL_ID_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(concat!($label, " is invalid").to_string()); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } + } + }; +} + +protocol_id!(ExternalProviderInstanceId, "provider instance id"); +protocol_id!(ExternalWorkdirGrantId, "External Workdir grant id"); +protocol_id!(ExternalWorkdirOperationId, "External Workdir operation id"); + +/// First provider frame on a newly authenticated outbound websocket. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExternalWorkdirProviderRegistration { + pub provider_instance_id: ExternalProviderInstanceId, + pub grant_id: ExternalWorkdirGrantId, + pub workdir_id: WorkdirId, + /// Monotonic grant connection generation. Reconnect advances it and fences + /// every frame from an older connection. + pub generation: u64, + pub capabilities: WorkdirSessionCapabilities, + pub read_limits: BoundedReadLimits, +} + +fn is_root_relative(path: &WorkdirPath) -> bool { + let path_value = path.as_str(); + let path = Path::new(path_value); + path_value.len() <= MAX_EXTERNAL_PATH_BYTES + && !path.is_absolute() + && !path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) +} + +fn validate_read_only_operation(operation: &WorkdirSessionOperation) -> Result<(), String> { + let path_is_valid = match operation { + WorkdirSessionOperation::AuthorizeScope(request) => { + is_root_relative(&request.path) + && request.rules.len() <= MAX_EXTERNAL_SCOPE_RULES + && request + .rules + .iter() + .all(|rule| is_root_relative(&rule.target)) + } + WorkdirSessionOperation::ScopeRulesOverlap(request) => { + is_root_relative(&request.left.target) && is_root_relative(&request.right.target) + } + WorkdirSessionOperation::Stat(request) => is_root_relative(&request.path), + WorkdirSessionOperation::Read(request) => { + is_root_relative(&request.path) + && request.limit <= 1_000_000 + && request.max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + } + WorkdirSessionOperation::List(request) => { + is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS + } + WorkdirSessionOperation::Glob(request) => { + is_root_relative(&request.path) + && request.pattern.len() <= MAX_EXTERNAL_PATTERN_BYTES + && request.limit <= MAX_EXTERNAL_RESULT_ITEMS + } + WorkdirSessionOperation::Grep(request) => { + is_root_relative(&request.path) + && request.pattern.len() <= MAX_EXTERNAL_PATTERN_BYTES + && request + .glob + .as_ref() + .is_none_or(|glob| glob.len() <= MAX_EXTERNAL_PATTERN_BYTES) + && request + .file_type + .as_ref() + .is_none_or(|file_type| file_type.len() <= 128) + && request.before_context <= MAX_EXTERNAL_GREP_CONTEXT + && request.after_context <= MAX_EXTERNAL_GREP_CONTEXT + && request.limit <= MAX_EXTERNAL_RESULT_ITEMS + && request.offset <= 1_000_000 + } + WorkdirSessionOperation::Write(_) + | WorkdirSessionOperation::Edit(_) + | WorkdirSessionOperation::CommandStart(_) + | WorkdirSessionOperation::CommandStatus(_) + | WorkdirSessionOperation::CommandOutput(_) + | WorkdirSessionOperation::CommandCancel(_) => { + return Err("operation is not available to a read-only External Workdir".to_string()); + } + }; + if path_is_valid { + Ok(()) + } else { + Err("External Workdir operation paths must be root-relative".to_string()) + } +} + +/// Validated read-only subset of the shared Workdir operation contract. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct ExternalWorkdirOperation(WorkdirSessionOperation); + +impl ExternalWorkdirOperation { + pub fn into_inner(self) -> WorkdirSessionOperation { + self.0 + } + + pub fn as_inner(&self) -> &WorkdirSessionOperation { + &self.0 + } +} + +impl TryFrom for ExternalWorkdirOperation { + type Error = String; + + fn try_from(operation: WorkdirSessionOperation) -> Result { + validate_read_only_operation(&operation)?; + Ok(Self(operation)) + } +} + +impl<'de> Deserialize<'de> for ExternalWorkdirOperation { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let operation = WorkdirSessionOperation::deserialize(deserializer)?; + Self::try_from(operation).map_err(serde::de::Error::custom) + } +} + +fn result_paths_fit<'a>(paths: impl IntoIterator) -> bool { + paths + .into_iter() + .try_fold(0_usize, |retained, path| { + retained.checked_add(path.as_str().len().saturating_add(64)) + }) + .is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES) +} + +fn validate_read_only_result(result: &WorkdirSessionOperationResult) -> Result<(), String> { + let result_is_valid = match result { + WorkdirSessionOperationResult::AuthorizeScope + | WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true, + WorkdirSessionOperationResult::Stat(result) => is_root_relative(&result.path), + WorkdirSessionOperationResult::Read(result) => { + is_root_relative(&result.path) + && result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + } + WorkdirSessionOperationResult::List(result) => { + result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS + && result + .entries + .iter() + .all(|entry| is_root_relative(&entry.path)) + && result_paths_fit(result.entries.iter().map(|entry| &entry.path)) + } + WorkdirSessionOperationResult::Glob(result) => { + result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS + && result.paths.iter().all(is_root_relative) + && result_paths_fit(&result.paths) + } + WorkdirSessionOperationResult::Grep(result) => { + result.output.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + && result.match_count <= MAX_EXTERNAL_RESULT_ITEMS + && result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS + } + WorkdirSessionOperationResult::Write(_) + | WorkdirSessionOperationResult::Edit(_) + | WorkdirSessionOperationResult::CommandStart(_) + | WorkdirSessionOperationResult::CommandStatus(_) + | WorkdirSessionOperationResult::CommandOutput(_) + | WorkdirSessionOperationResult::CommandCancel => { + return Err( + "operation result is not available to a read-only External Workdir".to_string(), + ); + } + }; + if result_is_valid { + Ok(()) + } else { + Err("External Workdir result exceeds path or response bounds".to_string()) + } +} + +/// Validated read-only subset of the shared Workdir operation result contract. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct ExternalWorkdirOperationResult(WorkdirSessionOperationResult); + +impl ExternalWorkdirOperationResult { + pub fn into_inner(self) -> WorkdirSessionOperationResult { + self.0 + } + + pub fn as_inner(&self) -> &WorkdirSessionOperationResult { + &self.0 + } +} + +impl TryFrom for ExternalWorkdirOperationResult { + type Error = String; + + fn try_from(result: WorkdirSessionOperationResult) -> Result { + validate_read_only_result(&result)?; + Ok(Self(result)) + } +} + +impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let result = WorkdirSessionOperationResult::deserialize(deserializer)?; + Self::try_from(result).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)] +pub enum ExternalWorkdirOperationOutcome { + Completed { + result: ExternalWorkdirOperationResult, + }, + Failed { + error: WorkdirTransportError, + }, + Cancelled, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalWorkdirRevokeReason { + UserRequested, + Expired, + AuthorityWithdrawn, +} + +/// Frames sent by the authenticated Backend to the outbound provider. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ExternalWorkdirServerMessage { + Registered { + generation: u64, + heartbeat_interval_ms: u64, + max_in_flight_operations: usize, + }, + Operation { + generation: u64, + operation_id: ExternalWorkdirOperationId, + operation: ExternalWorkdirOperation, + }, + Cancel { + generation: u64, + operation_id: ExternalWorkdirOperationId, + }, + Revoke { + generation: u64, + reason: ExternalWorkdirRevokeReason, + }, + Heartbeat { + generation: u64, + sequence: u64, + }, +} + +/// Frames sent by the client-hosted provider over its outbound connection. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ExternalWorkdirProviderMessage { + Register { + registration: ExternalWorkdirProviderRegistration, + }, + OperationResult { + generation: u64, + operation_id: ExternalWorkdirOperationId, + outcome: ExternalWorkdirOperationOutcome, + }, + Heartbeat { + generation: u64, + sequence: u64, + }, + RevokeAcknowledged { + generation: u64, + }, +} + +/// Versioned server-to-provider websocket envelope. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExternalWorkdirServerFrame { + pub version: ExternalWorkdirProtocolVersion, + pub message: ExternalWorkdirServerMessage, +} + +impl ExternalWorkdirServerFrame { + pub fn current(message: ExternalWorkdirServerMessage) -> Self { + Self { + version: ExternalWorkdirProtocolVersion::CURRENT, + message, + } + } +} + +/// Versioned provider-to-server websocket envelope. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExternalWorkdirProviderFrame { + pub version: ExternalWorkdirProtocolVersion, + pub message: ExternalWorkdirProviderMessage, +} + +impl ExternalWorkdirProviderFrame { + pub fn current(message: ExternalWorkdirProviderMessage) -> Self { + Self { + version: ExternalWorkdirProtocolVersion::CURRENT, + message, + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::*; + use crate::{CommandRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath}; + + fn assert_no_provider_authority(value: &Value) { + match value { + Value::Object(object) => { + for (key, value) in object { + assert!( + ![ + "host_path", + "root", + "credential", + "credentials", + "token", + "access_token", + "bearer_token", + "endpoint", + "url", + ] + .contains(&key.as_str()), + "provider authority field leaked into protocol: {key}" + ); + assert_no_provider_authority(value); + } + } + Value::Array(values) => values.iter().for_each(assert_no_provider_authority), + Value::String(value) => { + assert!(!value.contains("/home/operator/private")); + assert!(!value.contains("provider-secret")); + } + _ => {} + } + } + + #[test] + fn protocol_is_versioned_correlated_and_contains_no_provider_authority() { + let workdir = Workdir::new("working-directory-1"); + let registration = + ExternalWorkdirProviderFrame::current(ExternalWorkdirProviderMessage::Register { + registration: ExternalWorkdirProviderRegistration { + provider_instance_id: ExternalProviderInstanceId::new("provider-1").unwrap(), + grant_id: ExternalWorkdirGrantId::new("grant-1").unwrap(), + workdir_id: workdir.id().clone(), + generation: 7, + capabilities: WorkdirSessionCapabilities::READ_ONLY, + read_limits: BoundedReadLimits::new(4096, 1024).unwrap(), + }, + }); + let operation = + ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Operation { + generation: 7, + operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(), + operation: WorkdirSessionOperation::Read(ReadRequest { + path: WorkdirPath::new("sessions/events.jsonl").unwrap(), + offset: 0, + limit: 20, + max_bytes: 1024, + }) + .try_into() + .unwrap(), + }); + + for value in [ + serde_json::to_value(registration).unwrap(), + serde_json::to_value(operation).unwrap(), + serde_json::to_value(ExternalWorkdirServerFrame::current( + ExternalWorkdirServerMessage::Cancel { + generation: 7, + operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(), + }, + )) + .unwrap(), + serde_json::to_value(ExternalWorkdirServerFrame::current( + ExternalWorkdirServerMessage::Revoke { + generation: 7, + reason: ExternalWorkdirRevokeReason::Expired, + }, + )) + .unwrap(), + serde_json::to_value(ExternalWorkdirProviderFrame::current( + ExternalWorkdirProviderMessage::Heartbeat { + generation: 7, + sequence: 3, + }, + )) + .unwrap(), + ] { + assert_eq!( + value.get("version").and_then(Value::as_u64), + Some(EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION.into()) + ); + assert_no_provider_authority(&value); + } + } + + #[test] + fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() { + let future = r#"{ + "version": 2, + "message": {"kind": "heartbeat", "generation": 1, "sequence": 1} + }"#; + assert!(serde_json::from_str::(future).is_err()); + + let invalid_limits = r#"{ + "max_source_bytes": 1024, + "max_response_bytes": 2048 + }"#; + assert!(serde_json::from_str::(invalid_limits).is_err()); + } + + #[test] + fn read_only_protocol_rejects_host_paths_and_command_payloads() { + let absolute = WorkdirSessionOperation::Read(ReadRequest { + path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(), + offset: 0, + limit: 1, + max_bytes: 1024, + }); + assert!(ExternalWorkdirOperation::try_from(absolute).is_err()); + + let command = WorkdirSessionOperation::CommandStart(CommandRequest { + command: "cat /home/operator/private/session.log".to_string(), + timeout_secs: 1, + output_limit: 1024, + cwd: None, + spill_dir: Some("/home/operator/private/output".into()), + tool_call_id: None, + }); + assert!(ExternalWorkdirOperation::try_from(command).is_err()); + } + + #[test] + fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() { + let unknown = r#"{ + "version": 1, + "message": { + "kind": "heartbeat", + "generation": 1, + "sequence": 1, + "credential": "must-not-be-ignored" + } + }"#; + assert!(serde_json::from_str::(unknown).is_err()); + + let mut nested = serde_json::to_value(ExternalWorkdirServerFrame::current( + ExternalWorkdirServerMessage::Operation { + generation: 1, + operation_id: ExternalWorkdirOperationId::new("operation-1").unwrap(), + operation: WorkdirSessionOperation::Read(ReadRequest { + path: WorkdirPath::new("events.jsonl").unwrap(), + offset: 0, + limit: 1, + max_bytes: 1024, + }) + .try_into() + .unwrap(), + }, + )) + .unwrap(); + nested["message"]["operation"]["request"]["credential"] = + Value::String("must-not-be-ignored".to_string()); + assert!(serde_json::from_value::(nested).is_err()); + + let unbounded = WorkdirSessionOperation::List(ListRequest { + path: WorkdirPath::root(), + limit: MAX_EXTERNAL_RESULT_ITEMS + 1, + }); + assert!(ExternalWorkdirOperation::try_from(unbounded).is_err()); + + let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult { + output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1), + match_count: 1, + matched_files: 1, + truncated: true, + }); + assert!(ExternalWorkdirOperationResult::try_from(oversized_result).is_err()); + } + + #[test] + fn protocol_ids_are_bounded_and_control_free() { + assert!(ExternalWorkdirOperationId::new("").is_err()); + assert!(ExternalWorkdirOperationId::new(" operation-1").is_err()); + assert!(ExternalWorkdirOperationId::new("operation\n1").is_err()); + assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err()); + assert!(ExternalWorkdirOperationId::new("operation-1").is_ok()); + } +} diff --git a/crates/workdir/src/external_local_tests.rs b/crates/workdir/src/external_local_tests.rs new file mode 100644 index 000000000..915ac10b8 --- /dev/null +++ b/crates/workdir/src/external_local_tests.rs @@ -0,0 +1,257 @@ +use crate::http::{ + WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation, +}; +use crate::{ + BoundedReadLimits, CommandRequest, EditRequest, LocalWorkdirSession, ReadRequest, Workdir, + WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities, + WorkdirSessionCapability, WriteRequest, +}; + +fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession { + LocalWorkdirSession::external_read_only(Workdir::new("external-workdir-1"), root.path(), limits) + .unwrap() +} + +#[tokio::test] +async fn external_local_provider_is_strictly_read_only() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("item.txt"), "original").unwrap(); + let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap()); + + assert_eq!( + session.capabilities(), + WorkdirSessionCapabilities::READ_ONLY + ); + + let write = WorkdirSession::write( + &session, + WriteRequest { + path: WorkdirPath::new("item.txt").unwrap(), + content: b"changed".to_vec(), + expected_hash: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!( + write, + WorkdirError::Unsupported(WorkdirSessionCapability::Write) + )); + + let edit = WorkdirSession::edit( + &session, + EditRequest { + path: WorkdirPath::new("item.txt").unwrap(), + old_string: "original".to_string(), + new_string: "changed".to_string(), + replace_all: false, + expected_hash: [0; 32], + }, + ) + .await + .unwrap_err(); + assert!(matches!( + edit, + WorkdirError::Unsupported(WorkdirSessionCapability::Edit) + )); + + let command = WorkdirSession::start_command( + &session, + CommandRequest { + command: "echo changed > item.txt".to_string(), + timeout_secs: 1, + output_limit: 1024, + cwd: None, + spill_dir: None, + tool_call_id: None, + }, + ) + .await + .unwrap_err(); + assert!(matches!( + command, + WorkdirError::Unsupported(WorkdirSessionCapability::Command) + )); + assert_eq!( + std::fs::read_to_string(root.path().join("item.txt")).unwrap(), + "original" + ); +} + +#[tokio::test] +async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("item.txt"), "visible").unwrap(); + let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap()); + + let result = dispatch_workdir_session_operation( + &session, + WorkdirSessionOperation::Read(ReadRequest { + path: WorkdirPath::new("item.txt").unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }), + ) + .await + .unwrap(); + assert!(matches!( + result, + WorkdirSessionOperationResult::Read(ref result) if result.bytes == b"visible" + )); + + let error = dispatch_workdir_session_operation( + &session, + WorkdirSessionOperation::Write(WriteRequest { + path: WorkdirPath::new("item.txt").unwrap(), + content: b"blocked".to_vec(), + expected_hash: None, + }), + ) + .await + .unwrap_err(); + assert!(matches!( + error, + WorkdirError::Unsupported(WorkdirSessionCapability::Write) + )); +} + +#[tokio::test] +async fn external_local_provider_rejects_absolute_and_parent_paths() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap()); + + assert!(WorkdirPath::new("../outside.txt").is_err()); + let absolute = WorkdirPath::new_scoped(outside.path().to_string_lossy()).unwrap(); + let error = WorkdirSession::read( + &session, + ReadRequest { + path: absolute, + offset: 0, + limit: 10, + max_bytes: 1024, + }, + ) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::InvalidPath(_))); +} + +#[tokio::test] +async fn external_local_provider_enforces_source_and_response_bounds() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("large.log"), vec![b'x'; 2048]).unwrap(); + std::fs::write(root.path().join("small.log"), b"0123456789\n").unwrap(); + let session = external_session(&root, BoundedReadLimits::new(1024, 4).unwrap()); + + let error = WorkdirSession::read( + &session, + ReadRequest { + path: WorkdirPath::new("large.log").unwrap(), + offset: 0, + limit: 10, + max_bytes: usize::MAX, + }, + ) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::InvalidArgument(_))); + + let bounded = WorkdirSession::read( + &session, + ReadRequest { + path: WorkdirPath::new("small.log").unwrap(), + offset: 0, + limit: 10, + max_bytes: usize::MAX, + }, + ) + .await + .unwrap(); + assert_eq!(bounded.bytes, b"0123"); + assert!(bounded.truncated); +} + +#[cfg(unix)] +#[tokio::test] +async fn external_local_provider_rejects_symlink_roots_and_traversal() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), "secret").unwrap(); + symlink( + outside.path().join("secret.txt"), + root.path().join("outside.txt"), + ) + .unwrap(); + std::fs::write(root.path().join("inside.txt"), "inside").unwrap(); + symlink( + root.path().join("inside.txt"), + root.path().join("inside-link.txt"), + ) + .unwrap(); + let session = external_session(&root, BoundedReadLimits::new(4096, 1024).unwrap()); + + for path in ["outside.txt", "inside-link.txt"] { + let error = WorkdirSession::read( + &session, + ReadRequest { + path: WorkdirPath::new(path).unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }, + ) + .await + .unwrap_err(); + assert!(matches!( + error, + WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_) + )); + } + + let parent = tempfile::tempdir().unwrap(); + let root_link = parent.path().join("shared-root"); + symlink(root.path(), &root_link).unwrap(); + let error = LocalWorkdirSession::external_read_only( + Workdir::new("external-workdir-2"), + &root_link, + BoundedReadLimits::new(4096, 1024).unwrap(), + ) + .unwrap_err(); + assert!(matches!(error, WorkdirError::Denied(_))); +} + +#[cfg(target_os = "linux")] +#[tokio::test] +async fn external_local_provider_pins_the_approved_root_across_directory_replacement() { + let parent = tempfile::tempdir().unwrap(); + let approved = parent.path().join("shared"); + std::fs::create_dir(&approved).unwrap(); + std::fs::write(approved.join("item.txt"), "approved").unwrap(); + let session = LocalWorkdirSession::external_read_only( + Workdir::new("external-workdir-pinned-root"), + &approved, + BoundedReadLimits::new(4096, 1024).unwrap(), + ) + .unwrap(); + + std::fs::rename(&approved, parent.path().join("approved-original")).unwrap(); + std::fs::create_dir(&approved).unwrap(); + std::fs::write(approved.join("item.txt"), "replacement").unwrap(); + + let read = WorkdirSession::read( + &session, + ReadRequest { + path: WorkdirPath::new("item.txt").unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }, + ) + .await + .unwrap(); + assert_eq!(read.bytes, b"approved"); +} diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 5a9c8c821..00fa2330f 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -11,8 +11,8 @@ use crate::{ CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId, - WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities, - WriteRequest, WriteResult, + WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, + WorkdirSessionCapabilities, WriteRequest, WriteResult, }; /// Opaque Runtime-owned identifier for one ephemeral Workdir session. @@ -54,7 +54,12 @@ pub struct OpenWorkdirSessionResponse { /// One provider-side Workdir operation. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "operation", content = "request", rename_all = "snake_case")] +#[serde( + tag = "operation", + content = "request", + rename_all = "snake_case", + deny_unknown_fields +)] pub enum WorkdirSessionOperation { AuthorizeScope(WorkdirScopeAuthorizationRequest), ScopeRulesOverlap(WorkdirScopeOverlapRequest), @@ -80,7 +85,12 @@ pub struct WorkdirSessionOperationRequest { /// Typed result paired with [`WorkdirSessionOperation`]. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "operation", content = "result", rename_all = "snake_case")] +#[serde( + tag = "operation", + content = "result", + rename_all = "snake_case", + deny_unknown_fields +)] pub enum WorkdirSessionOperationResult { AuthorizeScope, ScopeRulesOverlap { overlaps: bool }, @@ -97,6 +107,72 @@ pub enum WorkdirSessionOperationResult { CommandCancel, } +/// Dispatch one typed operation against any provider-neutral Workdir session. +/// +/// Runtime HTTP handlers, outbound External providers, and Backend brokers can +/// share this operation/result pairing while keeping transport authorization, +/// generation fencing, cancellation timeouts, and session lookup at their own +/// boundaries. +pub async fn dispatch_workdir_session_operation( + session: &dyn WorkdirSession, + operation: WorkdirSessionOperation, +) -> Result { + match operation { + WorkdirSessionOperation::AuthorizeScope(request) => session + .authorize_scope_path(request) + .await + .map(|()| WorkdirSessionOperationResult::AuthorizeScope), + WorkdirSessionOperation::ScopeRulesOverlap(request) => session + .scope_rules_overlap(request) + .await + .map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }), + WorkdirSessionOperation::Stat(request) => session + .stat(request) + .await + .map(WorkdirSessionOperationResult::Stat), + WorkdirSessionOperation::Read(request) => session + .read(request) + .await + .map(WorkdirSessionOperationResult::Read), + WorkdirSessionOperation::Write(request) => session + .write(request) + .await + .map(WorkdirSessionOperationResult::Write), + WorkdirSessionOperation::Edit(request) => session + .edit(request) + .await + .map(WorkdirSessionOperationResult::Edit), + WorkdirSessionOperation::List(request) => session + .list(request) + .await + .map(WorkdirSessionOperationResult::List), + WorkdirSessionOperation::Glob(request) => session + .glob(request) + .await + .map(WorkdirSessionOperationResult::Glob), + WorkdirSessionOperation::Grep(request) => session + .grep(request) + .await + .map(WorkdirSessionOperationResult::Grep), + WorkdirSessionOperation::CommandStart(request) => session + .start_command(request) + .await + .map(WorkdirSessionOperationResult::CommandStart), + WorkdirSessionOperation::CommandStatus(handle) => session + .command_status(handle) + .await + .map(WorkdirSessionOperationResult::CommandStatus), + WorkdirSessionOperation::CommandOutput(request) => session + .command_output(request) + .await + .map(WorkdirSessionOperationResult::CommandOutput), + WorkdirSessionOperation::CommandCancel(handle) => session + .cancel_command(handle) + .await + .map(|()| WorkdirSessionOperationResult::CommandCancel), + } +} + /// Stable, host-path-free error code crossing the Runtime boundary. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index ba1070541..fbaeecdfb 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -5,6 +5,7 @@ //! bound to one Worker. Tools consume sessions; they do not own Workdir //! materialization or cleanup. +pub mod external; pub mod http; mod local; mod operation; @@ -12,6 +13,9 @@ mod router; mod scope; pub mod workspace; +#[cfg(test)] +mod external_local_tests; + use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -20,10 +24,11 @@ use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; pub use fs_operation::{ - ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest, - GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult, - ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, + BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, + GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, + ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, }; +pub use http::dispatch_workdir_session_operation; pub use local::{ LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink, }; diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 982ad4462..76afab701 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -18,17 +18,17 @@ use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; -use manifest::{Permission, Scope, SharedScope, SymlinkPolicy}; +use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy}; use sha2::{Digest, Sha256}; use tokio::process::Command; use tokio::sync::{Mutex, broadcast, watch}; use tokio::task::JoinHandle; use crate::{ - CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, - CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult, - GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest, - ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, + BoundedReadLimits, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, + CommandRequest, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, + EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, + ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest, WriteResult, @@ -203,26 +203,112 @@ impl CommandTelemetry { } #[derive(Debug)] -struct ScopeAccess(Arc); +struct ScopeAccess { + scope: Arc, + root: PathBuf, + pinned_root: Option>, + reject_symlinks: bool, +} + +impl ScopeAccess { + fn new( + scope: Arc, + root: &Path, + pinned_root: Option>, + reject_symlinks: bool, + ) -> Self { + Self { + scope, + root: root.to_path_buf(), + pinned_root, + reject_symlinks, + } + } + + fn open_confined(&self, path: &Path) -> std::io::Result { + let root = self.pinned_root.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Unsupported, + "External Workdir root handle is unavailable", + ) + })?; + let relative = path.strip_prefix(&self.root).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "path is outside provider root", + ) + })?; + fs_operation::open_beneath_no_symlinks_at(root, relative) + } + + fn path_is_confined(&self, logical: &Path, resolved: &Path) -> bool { + !self.reject_symlinks + || (logical.starts_with(&self.root) + && resolved.starts_with(&self.root) + && fs_operation::first_symlink(logical).is_none()) + } +} impl fs_operation::FsAccessPolicy for ScopeAccess { fn is_readable(&self, path: &Path) -> bool { - self.0.is_readable(path) + self.scope.is_readable(path) } fn is_writable(&self, path: &Path) -> bool { - self.0.is_writable(path) + self.scope.is_writable(path) + } + + fn open_read_file(&self, _logical: &Path, resolved: &Path) -> std::io::Result { + if self.reject_symlinks { + self.open_confined(resolved) + } else { + std::fs::File::open(resolved) + } + } + + fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result { + if self.reject_symlinks { + self.open_confined(resolved)?.metadata() + } else { + std::fs::symlink_metadata(logical) + } + } + + fn open_read_dir(&self, _logical: &Path, resolved: &Path) -> std::io::Result { + if !self.reject_symlinks { + return std::fs::read_dir(resolved); + } + #[cfg(target_os = "linux")] + { + use std::os::fd::AsRawFd; + + let directory = self.open_confined(resolved)?; + if !directory.metadata()?.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotADirectory, + "path is not a directory", + )); + } + return std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd())); + } + #[cfg(not(target_os = "linux"))] + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "root-confined directory enumeration is unavailable on this platform", + )) } fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool { - matches!( - self.0.permission_at_paths(logical, resolved), - Some(Permission::Read | Permission::Write) - ) + self.path_is_confined(logical, resolved) + && matches!( + self.scope.permission_at_paths(logical, resolved), + Some(Permission::Read | Permission::Write) + ) } fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool { - self.0.permission_at_paths(logical, resolved) == Some(Permission::Write) + self.path_is_confined(logical, resolved) + && self.scope.permission_at_paths(logical, resolved) == Some(Permission::Write) } } @@ -265,9 +351,12 @@ fn rule_targets( struct LocalWorkdirSessionInner { workdir: Workdir, root: PathBuf, + pinned_root: Option>, scope: SharedScope, cwd: PathBuf, capabilities: WorkdirSessionCapabilities, + read_limits: Option, + reject_symlinks: bool, closed: AtomicBool, close_lock: Mutex<()>, next_command_id: AtomicU64, @@ -388,14 +477,113 @@ impl LocalWorkdirSession { capabilities: WorkdirSessionCapabilities, command_environment: BTreeMap, resources: Vec>, + ) -> Self { + Self::materialized_bound_with_policy( + workdir, + root, + cwd, + scope, + capabilities, + command_environment, + resources, + None, + None, + false, + ) + } + + /// Construct a strictly read-only provider session for an + /// authority-assigned Workdir and an operator-selected local directory. + /// + /// The selected directory is canonicalized and fixed for the lifetime of + /// the session. A symlink selected as the root is rejected, and all + /// operation paths are confined below the canonical root without following + /// symbolic links. Read source and response sizes are provider-bounded. + pub fn external_read_only( + workdir: Workdir, + directory: impl AsRef, + read_limits: BoundedReadLimits, + ) -> Result { + read_limits.validate().map_err(WorkdirError::from)?; + let directory = directory.as_ref(); + if !directory.is_absolute() { + return Err(WorkdirError::InvalidArgument( + "External Workdir directory must be absolute".to_string(), + )); + } + let selected_metadata = std::fs::symlink_metadata(directory) + .map_err(|error| WorkdirError::io(Path::new(""), error))?; + if selected_metadata.file_type().is_symlink() { + return Err(WorkdirError::Denied( + "External Workdir root must not be a symbolic link".to_string(), + )); + } + let root = directory + .canonicalize() + .map_err(|error| WorkdirError::io(Path::new(""), error))?; + if !root + .metadata() + .map_err(|error| WorkdirError::io(Path::new(""), error))? + .is_dir() + { + return Err(WorkdirError::InvalidArgument( + "External Workdir root must be a directory".to_string(), + )); + } + let pinned_root = Arc::new( + fs_operation::open_root_no_symlinks(&root) + .map_err(|error| WorkdirError::io(Path::new(""), error))?, + ); + let scope = Scope::from_config(&ScopeConfig { + allow: vec![ScopeRule { + target: root.clone(), + permission: Permission::Read, + recursive: true, + symlink_policy: SymlinkPolicy::Resolved, + }], + deny: Vec::new(), + }) + .map_err(|_| { + WorkdirError::InvalidArgument( + "External Workdir read scope could not be established".to_string(), + ) + })?; + Ok(Self::materialized_bound_with_policy( + workdir, + root.clone(), + root, + SharedScope::new(scope), + WorkdirSessionCapabilities::READ_ONLY, + BTreeMap::new(), + Vec::new(), + Some(pinned_root), + Some(read_limits), + true, + )) + } + + fn materialized_bound_with_policy( + workdir: Workdir, + root: PathBuf, + cwd: PathBuf, + scope: SharedScope, + capabilities: WorkdirSessionCapabilities, + command_environment: BTreeMap, + resources: Vec>, + pinned_root: Option>, + read_limits: Option, + reject_symlinks: bool, ) -> Self { Self { inner: Arc::new(LocalWorkdirSessionInner { workdir, root, + pinned_root, scope, cwd, capabilities, + read_limits, + reject_symlinks, closed: AtomicBool::new(false), close_lock: Mutex::new(()), next_command_id: AtomicU64::new(1), @@ -592,6 +780,37 @@ impl LocalWorkdirSession { self.inner.root.join(path.as_str()) } } + + fn validate_operation_path(&self, path: &WorkdirPath) -> Result<(), WorkdirError> { + if !self.inner.reject_symlinks { + return Ok(()); + } + let path = Path::new(path.as_str()); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + return Err(WorkdirError::InvalidPath( + "External Workdir paths must be root-relative".to_string(), + )); + } + Ok(()) + } + + fn scope_access(&self) -> ScopeAccess { + ScopeAccess::new( + self.inner.scope.snapshot(), + &self.inner.root, + self.inner.pinned_root.clone(), + self.inner.reject_symlinks, + ) + } } #[async_trait] @@ -609,7 +828,13 @@ impl WorkdirSession for LocalWorkdirSession { request: WorkdirScopeAuthorizationRequest, ) -> Result<(), WorkdirError> { self.ensure_open()?; + self.validate_operation_path(&request.path)?; let logical = self.inner.root.join(request.path.as_str()); + if self.inner.reject_symlinks && fs_operation::first_symlink(&logical).is_some() { + return Err(WorkdirError::Denied( + "External Workdir scope cannot traverse symbolic links".to_string(), + )); + } let resolved = fs_operation::resolve_access_path(&logical) .map_err(|error| WorkdirError::io(&logical, error))?; let parent_permission = self @@ -687,7 +912,8 @@ impl WorkdirSession for LocalWorkdirSession { async fn stat(&self, request: StatRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Read)?; let logical = request.path.clone(); - let access = ScopeAccess(self.inner.scope.snapshot()); + self.validate_operation_path(&request.path)?; + let access = self.scope_access(); fs_operation::run_stat(&self.inner.root, request, &access) .map_err(WorkdirError::from) .map_err(|error| sanitize_error(error, &logical)) @@ -696,16 +922,23 @@ impl WorkdirSession for LocalWorkdirSession { async fn read(&self, request: ReadRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Read)?; let logical = request.path.clone(); - let access = ScopeAccess(self.inner.scope.snapshot()); - fs_operation::run_read(&self.inner.root, request, &access) - .map_err(WorkdirError::from) - .map_err(|error| sanitize_error(error, &logical)) + self.validate_operation_path(&request.path)?; + let access = self.scope_access(); + match self.inner.read_limits { + Some(limits) => { + fs_operation::run_read_bounded(&self.inner.root, request, &access, limits) + } + None => fs_operation::run_read(&self.inner.root, request, &access), + } + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) } async fn write(&self, request: WriteRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Write)?; let logical = request.path.clone(); - let access = ScopeAccess(self.inner.scope.snapshot()); + self.validate_operation_path(&request.path)?; + let access = self.scope_access(); fs_operation::run_write(&self.inner.root, request, &access) .map_err(WorkdirError::from) .map_err(|error| sanitize_error(error, &logical)) @@ -714,7 +947,8 @@ impl WorkdirSession for LocalWorkdirSession { async fn edit(&self, request: EditRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Edit)?; let logical = request.path.clone(); - let access = ScopeAccess(self.inner.scope.snapshot()); + self.validate_operation_path(&request.path)?; + let access = self.scope_access(); fs_operation::run_edit(&self.inner.root, request, &access) .map_err(WorkdirError::from) .map_err(|error| sanitize_error(error, &logical)) @@ -723,7 +957,8 @@ impl WorkdirSession for LocalWorkdirSession { async fn list(&self, request: ListRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Read)?; let logical = request.path.clone(); - let access = ScopeAccess(self.inner.scope.snapshot()); + self.validate_operation_path(&request.path)?; + let access = self.scope_access(); fs_operation::run_list(&self.inner.root, request, &access) .map_err(WorkdirError::from) .map_err(|error| sanitize_error(error, &logical)) @@ -733,7 +968,8 @@ impl WorkdirSession for LocalWorkdirSession { self.ensure_capability(WorkdirSessionCapability::Glob)?; let logical = request.path.clone(); let base = self.resolve(&request.path); - let access = ScopeAccess(self.inner.scope.snapshot()); + self.validate_operation_path(&request.path)?; + let access = self.scope_access(); fs_operation::run_glob(&self.inner.root, &base, request, &access) .map_err(WorkdirError::from) .map_err(|error| sanitize_error(error, &logical)) @@ -743,7 +979,8 @@ impl WorkdirSession for LocalWorkdirSession { self.ensure_capability(WorkdirSessionCapability::Grep)?; let base = self.resolve(&request.path); let logical = request.path.clone(); - let access = ScopeAccess(self.inner.scope.snapshot()); + self.validate_operation_path(&request.path)?; + let access = self.scope_access(); fs_operation::run_grep(&self.inner.root, base, request, &access) .map_err(WorkdirError::from) .map_err(|error| sanitize_error(error, &logical)) diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index 3bec80329..c945accd4 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; pub enum WorkingDirectoryMaterializerKind { #[default] RuntimeGitClone, + ClientHostedExternal, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -98,6 +99,13 @@ pub struct RuntimeWorkingDirectorySummary { pub occupied_by: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum WorkingDirectorySource { + Repository { repository_key: String }, + ExternalGrant { grant_id: String }, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkingDirectorySummary { @@ -105,7 +113,7 @@ pub struct WorkingDirectorySummary { /// Optional human-facing label. Never used for attachment routing. #[serde(default, skip_serializing_if = "Option::is_none")] pub display_name: Option, - pub repository_key: String, + pub source: WorkingDirectorySource, #[serde(default, skip_serializing_if = "Option::is_none")] pub creation_selector: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -215,7 +223,9 @@ mod tests { let summary = WorkingDirectorySummary { working_directory_id: "wd-1".to_string(), display_name: Some("Checkout".to_string()), - repository_key: "repo-1".to_string(), + source: WorkingDirectorySource::Repository { + repository_key: "repo-1".to_string(), + }, creation_selector: None, creation_ref: None, creation_tree: None, diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 35f9a0a71..4970b1a84 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -204,6 +204,12 @@ pub struct RepositoryRefObservation { pub struct LogicalWorkdirAttachment { pub alias: workdir::WorkdirAttachmentAlias, pub working_directory_id: String, + #[serde(default = "default_workdir_session_capabilities")] + pub capabilities: workdir::WorkdirSessionCapabilities, +} + +fn default_workdir_session_capabilities() -> workdir::WorkdirSessionCapabilities { + workdir::WorkdirSessionCapabilities::ALL } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index 44ee398b3..ffb129bfa 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -2409,6 +2409,7 @@ mod tests { [LogicalWorkdirAttachment { alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(), working_directory_id: "remote-workdir".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, }] ); } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index baa1e857b..87c61f140 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -4293,6 +4293,7 @@ fn logical_workdir_attachments_from_statuses( .summary .working_directory_id .clone(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, }) .collect() } @@ -6228,6 +6229,7 @@ mod tests { let attachment = LogicalWorkdirAttachment { alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(), working_directory_id: "workdir-on-another-runtime".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::ALL, }; let detail = runtime diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 68194f025..0cd3ad5de 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -817,10 +817,11 @@ fn restored_workdir_router( router .attach( attachment.alias.clone(), - WorkspaceAttachedWorkdirSession::handle_for_workdir( + WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities( workspace_client.clone(), attachment.alias.as_str(), &attachment.working_directory_id, + attachment.capabilities, ), ) .map_err(|error| { @@ -2971,6 +2972,7 @@ mod tests { &[LogicalWorkdirAttachment { alias: alias.clone(), working_directory_id: "remote-workdir".to_string(), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }], scope, client, @@ -2979,6 +2981,10 @@ mod tests { let session = router.session(&alias).unwrap(); assert_eq!(session.workdir().id().as_str(), "remote-workdir"); + assert_eq!( + session.capabilities(), + workdir::WorkdirSessionCapabilities::READ_ONLY + ); } #[test] diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 2df835b34..0a25d8fc9 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -215,6 +215,7 @@ pub struct WorkspaceAttachedWorkdirSession { client: Arc, target_workdir: String, workdir: Workdir, + capabilities: WorkdirSessionCapabilities, } impl WorkspaceAttachedWorkdirSession { @@ -229,11 +230,26 @@ impl WorkspaceAttachedWorkdirSession { client: Arc, target_workdir: impl Into, workdir_id: impl Into, + ) -> WorkdirSessionHandle { + Self::handle_for_workdir_with_capabilities( + client, + target_workdir, + workdir_id, + WorkdirSessionCapabilities::ALL, + ) + } + + pub fn handle_for_workdir_with_capabilities( + client: Arc, + target_workdir: impl Into, + workdir_id: impl Into, + capabilities: WorkdirSessionCapabilities, ) -> WorkdirSessionHandle { Arc::new(Self { client, target_workdir: target_workdir.into(), workdir: Workdir::new(workdir_id), + capabilities, }) } @@ -304,7 +320,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { } fn capabilities(&self) -> WorkdirSessionCapabilities { - WorkdirSessionCapabilities::ALL + self.capabilities } async fn authorize_scope_path( @@ -522,10 +538,11 @@ impl WorkspaceHttpWorkdirBackend { let response = self.attach_response(alias, working_directory_id)?; if let Err(error) = self.session_router.attach( alias_key.clone(), - WorkspaceAttachedWorkdirSession::handle_for_workdir( + WorkspaceAttachedWorkdirSession::handle_for_workdir_with_capabilities( self.client.clone(), alias, working_directory_id, + response.capabilities, ), ) { let _ = self.detach(alias); @@ -924,6 +941,7 @@ struct WorkdirAttachmentResponse { workspace_id: String, alias: String, working_directory_id: String, + capabilities: WorkdirSessionCapabilities, attached: bool, } @@ -1014,7 +1032,7 @@ mod tests { fn workdir_json(id: &str) -> serde_json::Value { json!({ "working_directory_id": id, - "repository_key": "main", + "source": {"kind": "repository", "repository_key": "main"}, "creation_selector": "refs/heads/main", "creation_ref": "0123456789abcdef", "materializer_kind": "runtime_git_clone", @@ -1195,12 +1213,14 @@ mod tests { "workspace_id": "workspace/test", "alias": "checkout", "working_directory_id": "wd-created", + "capabilities": {"bits": 63}, "attached": true })), response(json!({ "workspace_id": "workspace/test", "alias": "checkout", "working_directory_id": "wd-created", + "capabilities": {"bits": 63}, "attached": false })), response(json!({ @@ -1510,6 +1530,7 @@ mod tests { "workspace_id": "workspace/test", "alias": "checkout", "working_directory_id": "wd-attached", + "capabilities": {"bits": 63}, "attached": false }))])); let router = Arc::new(workdir::WorkdirSessionRouter::new()); @@ -1582,6 +1603,7 @@ mod tests { "workspace_id": "workspace/test", "alias": "checkout", "working_directory_id": "wd-attached", + "capabilities": {"bits": 63}, "attached": true })), response(json!({ @@ -1596,6 +1618,7 @@ mod tests { "workspace_id": "workspace/test", "alias": "checkout", "working_directory_id": "wd-attached", + "capabilities": {"bits": 63}, "attached": false })), ])); @@ -1640,12 +1663,40 @@ mod tests { ); } + #[test] + fn attach_propagates_provider_read_only_capabilities_to_the_alias_session() { + let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({ + "workspace_id": "workspace/test", + "alias": "external", + "working_directory_id": "external-workdir", + "capabilities": {"bits": 25}, + "attached": true + }))])); + let backend = WorkspaceHttpWorkdirBackend::new(client); + backend + .attach(WorkdirAttachInput { + alias: "external".to_string(), + working_directory_id: "external-workdir".to_string(), + }) + .unwrap(); + + let session = backend + .session_router + .session(&workdir::WorkdirAttachmentAlias::new("external").unwrap()) + .unwrap(); + assert_eq!( + session.capabilities(), + WorkdirSessionCapabilities::READ_ONLY + ); + } + #[tokio::test] async fn successful_attach_reopens_internal_subworker_admission() { let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({ "workspace_id": "workspace/test", "alias": "checkout", "working_directory_id": "wd-attached", + "capabilities": {"bits": 63}, "attached": true }))])); let reopen_calls = Arc::new(AtomicUsize::new(0)); diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 2b0a096a8..a1aa5dd23 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -668,8 +668,10 @@ CREATE TABLE "workdir_registry" ( workspace_id TEXT NOT NULL, workdir_id TEXT NOT NULL, display_name TEXT, - runtime_id TEXT NOT NULL, - repository_id TEXT NOT NULL, + source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant')), + runtime_id TEXT, + repository_id TEXT, + external_grant_id TEXT, creation_selector TEXT, creation_ref TEXT, materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), @@ -679,9 +681,34 @@ CREATE TABLE "workdir_registry" ( current_selector TEXT, current_ref TEXT, creation_tree TEXT, current_tree TEXT, observed_at_epoch_seconds INTEGER, PRIMARY KEY (workspace_id, workdir_id), + CHECK ( + (source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL) + OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL) + ), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, repository_id) - REFERENCES "repositories"(workspace_id, repository_id) + REFERENCES "repositories"(workspace_id, repository_id), + FOREIGN KEY (workspace_id, external_grant_id) + REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE external_workdir_grants ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + provider_instance_id TEXT NOT NULL, + display_name TEXT NOT NULL, + permissions TEXT NOT NULL CHECK (permissions = 'read_only'), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, workdir_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED ); CREATE TABLE workdir_removal_operations ( workspace_id TEXT NOT NULL, @@ -1049,6 +1076,8 @@ CREATE INDEX idx_typed_tickets_workspace_updated ON typed_tickets(workspace_id, updated_at DESC, ticket_id); CREATE INDEX idx_workdir_registry_workspace_updated ON workdir_registry(workspace_id, updated_at DESC); +CREATE INDEX idx_external_workdir_grants_status_expiry + ON external_workdir_grants(workspace_id, status, expires_at); CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending ON workdir_removal_operations(workspace_id, workdir_id) WHERE state = 'pending'; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 206ddc7b4..420153b3e 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -4,6 +4,7 @@ use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; +use async_trait::async_trait; use axum::body::Bytes; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State}; @@ -36,17 +37,17 @@ use server_api::{ DeleteRepositorySshHostTrustRequest, DeviceAccessTokenType, DeviceLoginApprovalStatus, DeviceLoginApproveRequest, DeviceLoginApproveResponse, DeviceLoginPollRequest, DeviceLoginPollResponse, DeviceLoginPollStatus, DeviceLoginStartRequest, - DeviceLoginStartResponse, GenerateRepositorySshCredentialRequest, LogoutResponse, LogoutStatus, - MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, - ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, - PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse, - PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest, - PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest, - RemoveRuntimeRequest, RepositoryAccessProjection, RepositoryDetailResponse, - RepositoryListResponse, RepositoryLogResponse, RepositorySshConnectionProbeRequest, - RepositorySshConnectionProbeResponse, RepositorySshConnectionTrustState, - RepositorySshCredential, RepositorySshHostKeyCandidate, RepositorySshHostTrust, - RepositorySshPublicKey, RequestActor, RevokeRuntimeTrustKeyRequest, + DeviceLoginStartResponse, ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse, + GenerateRepositorySshCredentialRequest, LogoutResponse, LogoutStatus, MemoryDocumentResponse, + MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveEditRequest, + ObjectiveLinkTicketRequest, ObjectiveStateRequest, PasskeyLoginCompleteRequest, + PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest, + PasskeyRegistrationOptionsRequest, PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, + PutRepositorySshHostTrustRequest, RemoveRuntimeRequest, RepositoryAccessProjection, + RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse, + RepositorySshConnectionProbeRequest, RepositorySshConnectionProbeResponse, + RepositorySshConnectionTrustState, RepositorySshCredential, RepositorySshHostKeyCandidate, + RepositorySshHostTrust, RepositorySshPublicKey, RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest, RuntimeConnectionDisplayState, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, RuntimeManagementSummary, RuntimeRemovalOperationResponse, @@ -93,12 +94,18 @@ use uuid::Uuid; use webauthn_rs::prelude::{ Passkey, PasskeyAuthentication, PasskeyRegistration, Webauthn, WebauthnBuilder, }; +use workdir::external::{ + ExternalProviderInstanceId, ExternalWorkdirOperation, ExternalWorkdirOperationId, + ExternalWorkdirOperationOutcome, ExternalWorkdirProviderFrame, ExternalWorkdirProviderMessage, + ExternalWorkdirServerFrame, ExternalWorkdirServerMessage, +}; use workdir::http::{ WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError, }; use workdir::workspace::{ MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy, - WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionOperationRequest, + WorkingDirectorySource, WorkingDirectoryStatusKind, WorkingDirectorySummary, + WorkspaceWorkdirSessionOperationRequest, }; use workdir::{CommandHandle, WorkdirSessionHandle}; use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef}; @@ -170,11 +177,12 @@ use crate::runtime_subscription::RuntimeSubscriptionBroker; use crate::skills; use crate::store::{ AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, - DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryInsertOutcome, - RepositoryRecord, RuntimeRemovalOperation, RuntimeRemovalOperationState, - TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord, - TicketRoleAssignmentRecord, UserRecord, WorkdirCreateCredentialCandidate, - WorkdirCreateCredentialCandidateRole, WorkdirCreateOperationRecord, WorkdirRegistryRecord, + DeviceLoginFlowRecord, ExternalWorkdirGrantRecord, FlowSourceRecord, PasskeyCredentialRecord, + RepositoryInsertOutcome, RepositoryRecord, RuntimeRemovalOperation, + RuntimeRemovalOperationState, TicketAssignmentPrincipal, TicketAssignmentRole, + TicketCoderAssignmentRecord, TicketRoleAssignmentRecord, UserRecord, + WorkdirCreateCredentialCandidate, WorkdirCreateCredentialCandidateRole, + WorkdirCreateOperationRecord, WorkdirRegistryRecord, WorkdirRegistrySource, WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind, WorkspaceRuntimeAuthenticationMode as StoredRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord, WorkspaceRuntimeBindingMutation, @@ -663,6 +671,414 @@ impl Drop for WorkerProjectionShutdownGuard { } } +#[derive(Debug)] +enum ExternalProviderCommand { + Operation { + operation_id: ExternalWorkdirOperationId, + operation: WorkdirSessionOperation, + response: tokio::sync::oneshot::Sender< + std::result::Result, + >, + }, + Cancel { + operation_id: ExternalWorkdirOperationId, + }, + Revoke, +} + +#[derive(Debug)] +struct ExternalOperationCancelGuard { + sender: tokio::sync::mpsc::Sender, + operation_id: Option, +} + +impl ExternalOperationCancelGuard { + fn disarm(&mut self) { + self.operation_id = None; + } +} + +impl Drop for ExternalOperationCancelGuard { + fn drop(&mut self) { + if let Some(operation_id) = self.operation_id.take() { + let _ = self + .sender + .try_send(ExternalProviderCommand::Cancel { operation_id }); + } + } +} + +#[derive(Debug, Clone)] +struct ExternalProviderConnection { + grant_id: String, + workdir_id: String, + provider_instance_id: String, + generation: u64, + expires_at: chrono::DateTime, + capabilities: workdir::WorkdirSessionCapabilities, + admission: Arc, + sender: tokio::sync::mpsc::Sender, +} + +#[derive(Debug, Default)] +struct ExternalProviderRegistry { + connections: HashMap>, +} + +impl ExternalProviderRegistry { + fn connected( + &mut self, + connection: Arc, + ) -> Option> { + if let Some(existing) = self.connections.get(&connection.grant_id) { + return Some(existing.clone()); + } + self.connections + .insert(connection.grant_id.clone(), connection); + None + } + + fn disconnect( + &mut self, + grant_id: &str, + generation: u64, + ) -> Option> { + if self + .connections + .get(grant_id) + .is_some_and(|connection| connection.generation == generation) + { + self.connections.remove(grant_id) + } else { + None + } + } + + fn connection(&self, grant_id: &str) -> Option> { + self.connections.get(grant_id).cloned() + } +} + +#[derive(Clone)] +struct ExternalProviderAuditContext { + store: Arc, + workspace_id: String, + actor_key: String, + attachment_alias: String, +} + +struct ExternalProviderWorkdirSession { + workdir: workdir::Workdir, + connection: Arc, + audit: Option, + closed: std::sync::atomic::AtomicBool, +} + +impl std::fmt::Debug for ExternalProviderWorkdirSession { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExternalProviderWorkdirSession") + .field("workdir", &self.workdir) + .field("connection", &self.connection) + .field("audited", &self.audit.is_some()) + .finish_non_exhaustive() + } +} + +impl ExternalProviderWorkdirSession { + fn new( + connection: Arc, + audit: Option, + ) -> Self { + Self { + workdir: workdir::Workdir::new(&connection.workdir_id), + connection, + audit, + closed: std::sync::atomic::AtomicBool::new(false), + } + } + + fn audit_operation( + &self, + operation_id: &ExternalWorkdirOperationId, + operation_kind: &str, + outcome: &str, + ) -> std::result::Result<(), workdir::WorkdirError> { + let Some(audit) = &self.audit else { + return Ok(()); + }; + audit + .store + .record_external_workdir_audit( + &audit.workspace_id, + "worker", + &audit.actor_key, + "external_workdir_operation", + &self.connection.grant_id, + Some(operation_id.as_str()), + outcome, + &format!( + "workdir_id={} attachment_alias={} operation_kind={} generation={}", + self.connection.workdir_id, + audit.attachment_alias, + operation_kind, + self.connection.generation + ), + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) + .map_err(|error| { + workdir::WorkdirError::Unavailable(format!( + "External Workdir operation audit failed: {error}" + )) + }) + } + + async fn operate( + &self, + operation: WorkdirSessionOperation, + ) -> std::result::Result { + let operation_kind = external_workdir_operation_kind(&operation); + if self.closed.load(Ordering::Acquire) { + return Err(workdir::WorkdirError::SessionClosed); + } + if Utc::now() >= self.connection.expires_at { + return Err(workdir::WorkdirError::Unavailable( + "External Workdir grant has expired".to_string(), + )); + } + let _admission = self + .connection + .admission + .clone() + .acquire_owned() + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir provider admission is closed".to_string(), + ) + })?; + if Utc::now() >= self.connection.expires_at { + return Err(workdir::WorkdirError::Unavailable( + "External Workdir grant has expired".to_string(), + )); + } + let operation_id = ExternalWorkdirOperationId::new(format!("op-{}", Uuid::now_v7())) + .map_err(workdir::WorkdirError::InvalidArgument)?; + self.audit_operation(&operation_id, operation_kind, "requested")?; + let (response, receive) = tokio::sync::oneshot::channel(); + let mut cancel_guard = ExternalOperationCancelGuard { + sender: self.connection.sender.clone(), + operation_id: Some(operation_id.clone()), + }; + if self + .connection + .sender + .send(ExternalProviderCommand::Operation { + operation_id: operation_id.clone(), + operation, + response, + }) + .await + .is_err() + { + self.audit_operation(&operation_id, operation_kind, "provider_unavailable")?; + return Err(workdir::WorkdirError::Unavailable( + "External Workdir provider disconnected".to_string(), + )); + } + let result = match tokio::time::timeout(std::time::Duration::from_secs(30), receive).await { + Ok(result) => { + cancel_guard.disarm(); + match result { + Ok(result) => result.map_err(WorkdirTransportError::into_workdir_error), + Err(_) => Err(workdir::WorkdirError::Unavailable( + "External Workdir provider disconnected".to_string(), + )), + } + } + Err(_) => { + if let Some(cancel_operation_id) = cancel_guard.operation_id.take() { + let _ = cancel_guard + .sender + .send(ExternalProviderCommand::Cancel { + operation_id: cancel_operation_id, + }) + .await; + } + Err(workdir::WorkdirError::Unavailable( + "External Workdir provider operation timed out".to_string(), + )) + } + }; + self.audit_operation( + &operation_id, + operation_kind, + if result.is_ok() { + "completed" + } else { + "failed" + }, + )?; + result + } + + fn mismatch(expected: &str) -> workdir::WorkdirError { + workdir::WorkdirError::Unavailable(format!( + "External Workdir provider returned a mismatched result; expected {expected}" + )) + } +} + +fn external_workdir_operation_kind(operation: &WorkdirSessionOperation) -> &'static str { + match operation { + WorkdirSessionOperation::AuthorizeScope(_) => "authorize_scope", + WorkdirSessionOperation::ScopeRulesOverlap(_) => "scope_rules_overlap", + WorkdirSessionOperation::Stat(_) => "stat", + WorkdirSessionOperation::Read(_) => "read", + WorkdirSessionOperation::Write(_) => "write", + WorkdirSessionOperation::Edit(_) => "edit", + WorkdirSessionOperation::List(_) => "list", + WorkdirSessionOperation::Glob(_) => "glob", + WorkdirSessionOperation::Grep(_) => "grep", + WorkdirSessionOperation::CommandStart(_) => "command_start", + WorkdirSessionOperation::CommandStatus(_) => "command_status", + WorkdirSessionOperation::CommandOutput(_) => "command_output", + WorkdirSessionOperation::CommandCancel(_) => "command_cancel", + } +} + +#[async_trait] +impl workdir::WorkdirSession for ExternalProviderWorkdirSession { + fn workdir(&self) -> &workdir::Workdir { + &self.workdir + } + fn capabilities(&self) -> workdir::WorkdirSessionCapabilities { + self.connection.capabilities + } + + async fn authorize_scope_path( + &self, + request: workdir::WorkdirScopeAuthorizationRequest, + ) -> std::result::Result<(), workdir::WorkdirError> { + match self + .operate(WorkdirSessionOperation::AuthorizeScope(request)) + .await? + { + WorkdirSessionOperationResult::AuthorizeScope => Ok(()), + _ => Err(Self::mismatch("authorize_scope")), + } + } + async fn scope_rules_overlap( + &self, + request: workdir::WorkdirScopeOverlapRequest, + ) -> std::result::Result { + match self + .operate(WorkdirSessionOperation::ScopeRulesOverlap(request)) + .await? + { + WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps), + _ => Err(Self::mismatch("scope_rules_overlap")), + } + } + async fn stat( + &self, + request: workdir::StatRequest, + ) -> std::result::Result { + match self.operate(WorkdirSessionOperation::Stat(request)).await? { + WorkdirSessionOperationResult::Stat(value) => Ok(value), + _ => Err(Self::mismatch("stat")), + } + } + async fn read( + &self, + request: workdir::ReadRequest, + ) -> std::result::Result { + match self.operate(WorkdirSessionOperation::Read(request)).await? { + WorkdirSessionOperationResult::Read(value) => Ok(value), + _ => Err(Self::mismatch("read")), + } + } + async fn write( + &self, + _request: workdir::WriteRequest, + ) -> std::result::Result { + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Write, + )) + } + async fn edit( + &self, + _request: workdir::EditRequest, + ) -> std::result::Result { + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Edit, + )) + } + async fn list( + &self, + request: workdir::ListRequest, + ) -> std::result::Result { + match self.operate(WorkdirSessionOperation::List(request)).await? { + WorkdirSessionOperationResult::List(value) => Ok(value), + _ => Err(Self::mismatch("list")), + } + } + async fn glob( + &self, + request: workdir::GlobRequest, + ) -> std::result::Result { + match self.operate(WorkdirSessionOperation::Glob(request)).await? { + WorkdirSessionOperationResult::Glob(value) => Ok(value), + _ => Err(Self::mismatch("glob")), + } + } + async fn grep( + &self, + request: workdir::GrepRequest, + ) -> std::result::Result { + match self.operate(WorkdirSessionOperation::Grep(request)).await? { + WorkdirSessionOperationResult::Grep(value) => Ok(value), + _ => Err(Self::mismatch("grep")), + } + } + async fn start_command( + &self, + _request: workdir::CommandRequest, + ) -> std::result::Result { + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Command, + )) + } + async fn command_status( + &self, + _handle: workdir::CommandHandle, + ) -> std::result::Result { + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Command, + )) + } + async fn command_output( + &self, + _request: workdir::CommandOutputRequest, + ) -> std::result::Result { + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Command, + )) + } + async fn cancel_command( + &self, + _handle: workdir::CommandHandle, + ) -> std::result::Result<(), workdir::WorkdirError> { + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Command, + )) + } + async fn close(&self) -> std::result::Result<(), workdir::WorkdirError> { + self.closed.store(true, Ordering::Release); + Ok(()) + } +} + #[derive(Clone)] pub struct WorkspaceApi { pub(crate) config: ServerConfig, @@ -684,6 +1100,7 @@ pub struct WorkspaceApi { pub(crate) worker_projection: Arc, resource_broker: BackendResourceBroker, workdir_sessions: Arc>, + external_workdir_providers: Arc>, workdir_session_locks: Arc>>>>, worker_remove_locks: Arc>>>>, workdir_remove_locks: Arc>>>>, @@ -2621,6 +3038,7 @@ impl WorkspaceApi { worker_projection, resource_broker, workdir_sessions: Arc::new(Mutex::new(WorkdirSessionRegistry::default())), + external_workdir_providers: Arc::new(Mutex::new(ExternalProviderRegistry::default())), workdir_session_locks: Arc::new(Mutex::new(HashMap::new())), worker_remove_locks: Arc::new(Mutex::new(HashMap::new())), workdir_remove_locks: Arc::new(Mutex::new(HashMap::new())), @@ -2633,6 +3051,15 @@ impl WorkspaceApi { .install_executor(Arc::new(WorkerRemovalService::new(&api))) .map_err(|message| Error::Config(message.to_string()))?; } + let expired_external_workdirs = api.store.reconcile_external_workdir_grants_after_restart( + &api.config.workspace_id, + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + )?; + for workdir_id in expired_external_workdirs { + release_external_workdir_attachments(&api, &workdir_id) + .await + .map_err(|error| error.error)?; + } recover_workdir_removals(&api)?; recover_runtime_removals(&api).await; Ok(api) @@ -3041,16 +3468,61 @@ impl WorkspaceApi { .into_iter() .filter(|link| link.unlinked_at.is_none()) { - let workdir_runtime_id = registered_workdir_runtime_id(self, &link.workdir_id)?; - if let Some(access) = repository_access_request_for_workdir( - self, - &workdir_runtime_id, - &link.workdir_id, - &format!("worker-restore:{}", WorkerId::now_v7()), - )? { - self.runtime - .authorize_working_directory_repository_access(&workdir_runtime_id, access) - .map_err(RuntimeRegistryError::into_error)?; + let record = self + .store + .get_workdir_registry(&self.config.workspace_id, &link.workdir_id)? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "working_directory_not_found".to_string(), + message: format!("Unknown Workdir `{}`", link.workdir_id), + })?; + match record.source { + WorkdirRegistrySource::Repository { runtime_id, .. } => { + if let Some(access) = repository_access_request_for_workdir( + self, + &runtime_id, + &link.workdir_id, + &format!("worker-restore:{}", WorkerId::now_v7()), + )? { + self.runtime + .authorize_working_directory_repository_access(&runtime_id, access) + .map_err(RuntimeRegistryError::into_error)?; + } + } + WorkdirRegistrySource::ExternalGrant { grant_id } => { + let grant = self + .store + .get_external_workdir_grant(&self.config.workspace_id, &grant_id)? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "external-workdir-provider".to_string(), + code: "external_workdir_unavailable".to_string(), + message: "External Workdir grant is unavailable".to_string(), + })?; + let grant_expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) + .map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now()) + .unwrap_or(true); + let connection = self + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .connection(&grant_id); + if grant.status != "online" + || grant_expired + || !connection.is_some_and(|connection| { + connection.generation == grant.generation + && connection.provider_instance_id == grant.provider_instance_id + && connection.workdir_id == link.workdir_id + && connection.expires_at > Utc::now() + }) + { + return Err(Error::RuntimeOperationFailed { + runtime_id: "external-workdir-provider".to_string(), + code: "external_workdir_unavailable".to_string(), + message: "External Workdir provider is offline".to_string(), + } + .into()); + } + } } } let binding = self @@ -3144,8 +3616,10 @@ impl WorkspaceApi { claim.working_directory_id ))) })?; - self.require_workspace_repository(&workdir.repository_id)?; - selected_repositories.push((workdir.repository_id, workdir.creation_selector)); + if let WorkdirRegistrySource::Repository { repository_id, .. } = workdir.source { + self.require_workspace_repository(&repository_id)?; + selected_repositories.push((repository_id, workdir.creation_selector)); + } } if let WorkerSpawnIntent::TicketRole { ticket_id, .. } = &request.intent { @@ -3753,6 +4227,19 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/workers/self/workdir-session/operations", post(scoped_execute_current_worker_workdir_operation), ) + .route( + "/api/w/{workspace_id}/external-workdir-grants", + post(scoped_create_external_workdir_grant), + ) + .route( + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}", + get(scoped_get_external_workdir_grant) + .delete(scoped_revoke_external_workdir_grant), + ) + .route( + "/api/w/{workspace_id}/external-workdir-grants/{grant_id}/provider", + get(scoped_external_workdir_provider_ws), + ) .route( "/api/w/{workspace_id}/working-directories", get(scoped_list_working_directories).post(scoped_create_working_directory), @@ -4361,6 +4848,7 @@ struct CurrentWorkerWorkdirAttachmentResponse { workspace_id: String, alias: String, working_directory_id: String, + capabilities: workdir::WorkdirSessionCapabilities, attached: bool, } @@ -8485,8 +8973,120 @@ async fn open_current_worker_workdir_session_locked( link.workdir_id ), })?; + if let WorkdirRegistrySource::ExternalGrant { grant_id } = &workdir.source { + close_current_worker_attachment_session_locked(api, worker).await?; + let grant = api + .store + .get_external_workdir_grant(&api.config.workspace_id, grant_id)? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "external_workdir_grant_not_found".to_string(), + message: "External Workdir grant is unavailable".to_string(), + })?; + let expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) + .map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now()) + .unwrap_or(true); + if expired { + let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); + api.store.update_external_workdir_grant_state( + &api.config.workspace_id, + grant_id, + &grant.provider_instance_id, + grant.generation, + "expired", + &updated_at, + )?; + let connection = { + api.external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .disconnect(grant_id, grant.generation) + }; + if let Some(connection) = connection { + let _ = connection + .sender + .send(ExternalProviderCommand::Revoke) + .await; + } + close_current_worker_command_sessions_for_alias_locked(api, worker, &link.alias) + .await?; + api.store.detach_worker_workdir( + &api.config.workspace_id, + worker, + Some(&link.workdir_id), + &updated_at, + )?; + sync_runtime_worker_workdir_attachments(api, worker).map_err(|error| error.error)?; + api.worker_projection.refresh(worker)?; + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "external_workdir_provider_expired".to_string(), + message: "External Workdir grant has expired".to_string(), + }); + } + if grant.status != "online" { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: format!("external_workdir_provider_{}", grant.status), + message: "External Workdir provider is not online".to_string(), + }); + } + let connection = api + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .connection(grant_id) + .filter(|connection| { + connection.generation == grant.generation + && connection.provider_instance_id == grant.provider_instance_id + && connection.workdir_id == workdir.workdir_id + }) + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "external_workdir_provider_offline".to_string(), + message: "External Workdir provider connection is unavailable".to_string(), + })?; + let session: WorkdirSessionHandle = Arc::new(ExternalProviderWorkdirSession::new( + connection, + Some(ExternalProviderAuditContext { + store: api.store.clone(), + workspace_id: api.config.workspace_id.clone(), + actor_key: format!("{}:{}", worker.runtime_id, worker.worker_id), + attachment_alias: link.alias.clone(), + }), + )); + let replaced = api + .workdir_sessions + .lock() + .expect("Workdir session registry lock poisoned") + .insert_attachment(worker.clone(), session.clone()); + if let Some(replaced) = replaced { + replaced + .close() + .await + .map_err(|error| Error::RuntimeOperationFailed { + runtime_id: worker.runtime_id.clone(), + code: "duplicate_workdir_session_close_failed".to_string(), + message: error.to_string(), + })?; + } + return Ok(session); + } + let (runtime_id, repository_id) = match &workdir.source { + WorkdirRegistrySource::Repository { + runtime_id, + repository_id, + } => (runtime_id.as_str(), repository_id.as_str()), + WorkdirRegistrySource::ExternalGrant { .. } => { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "external_workdir_provider_unavailable".to_string(), + message: "External Workdir provider is not connected".to_string(), + }); + } + }; let repository = api - .require_configured_workspace_repository(&workdir.repository_id) + .require_configured_workspace_repository(repository_id) .map_err(|error| error.error)?; let repository_requires_access = repository.source.kind == server_api::RepositorySourceKind::Ssh; @@ -8494,7 +9094,7 @@ async fn open_current_worker_workdir_session_locked( close_current_worker_attachment_session_locked(api, worker).await?; let access = repository_access_request_for_workdir( api, - &workdir.runtime_id, + runtime_id, &workdir.workdir_id, &format!( "workdir-session:{}:{}:{}", @@ -8503,12 +9103,12 @@ async fn open_current_worker_workdir_session_locked( ) .map_err(|error| error.error)? .ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: workdir.runtime_id.clone(), + runtime_id: runtime_id.to_string(), code: "working_directory_remote_repository_access_required".to_string(), message: "current Repository SSH access authority is unavailable".to_string(), })?; api.runtime - .authorize_working_directory_repository_access(&workdir.runtime_id, access) + .authorize_working_directory_repository_access(runtime_id, access) .map_err(RuntimeRegistryError::into_error)?; } else { // The registry is an operation-session cache, not routing authority. @@ -8516,10 +9116,10 @@ async fn open_current_worker_workdir_session_locked( // attachment so multiple aliases can never share one provider namespace. close_current_worker_attachment_session_locked(api, worker).await?; } - let owner_worker_id = runtime_local_owner_worker_id(worker, &workdir.runtime_id); + let owner_worker_id = runtime_local_owner_worker_id(worker, runtime_id); let session = api .runtime - .open_workdir_session(&workdir.runtime_id, &workdir.workdir_id, owner_worker_id) + .open_workdir_session(runtime_id, &workdir.workdir_id, owner_worker_id) .await .map_err(|error| error.into_error())?; let replaced = api @@ -8602,10 +9202,28 @@ fn sync_runtime_worker_workdir_attachments( .into_iter() .filter(|link| link.unlinked_at.is_none()) .map(|link| { + let workdir = api + .store + .get_workdir_registry(&api.config.workspace_id, &link.workdir_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Worker attachment references unknown Workdir {}", + link.workdir_id + )) + })?; + let capabilities = match workdir.source { + WorkdirRegistrySource::Repository { .. } => { + workdir::WorkdirSessionCapabilities::ALL + } + WorkdirRegistrySource::ExternalGrant { .. } => { + workdir::WorkdirSessionCapabilities::READ_ONLY + } + }; Ok(LogicalWorkdirAttachment { alias: workdir::WorkdirAttachmentAlias::new(link.alias) .map_err(|error| Error::InvalidInput(error.to_string()))?, working_directory_id: link.workdir_id, + capabilities, }) }) .collect::>>()?; @@ -8669,13 +9287,49 @@ async fn scoped_attach_current_worker_workdir( code: "working_directory_not_found".to_string(), message: format!("unknown Workdir `{workdir_id}`"), })?; - let status = runtime_workdir_summary_from_record(&workdir).status; - if status != WorkingDirectoryStatusKind::Active { + if workdir.materialization_status != "present" { return Err(Error::WorkdirAttachmentConflict(format!( - "Workdir `{workdir_id}` is not active ({status})" + "Workdir `{workdir_id}` is not active ({})", + workdir.materialization_status )) .into()); } + let capabilities = match &workdir.source { + WorkdirRegistrySource::Repository { .. } => workdir::WorkdirSessionCapabilities::ALL, + WorkdirRegistrySource::ExternalGrant { grant_id } => { + let grant = api + .store + .get_external_workdir_grant(&api.config.workspace_id, grant_id)? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "external-workdir-provider".to_string(), + code: "external_workdir_unavailable".to_string(), + message: "External Workdir grant is unavailable".to_string(), + })?; + let grant_expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) + .map(|expires_at| expires_at.with_timezone(&Utc) <= Utc::now()) + .unwrap_or(true); + let online = api + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .connection(grant_id) + .is_some_and(|connection| { + connection.generation == grant.generation + && connection.provider_instance_id == grant.provider_instance_id + && connection.workdir_id == workdir.workdir_id + && connection.expires_at > Utc::now() + }); + if grant.status != "online" || grant_expired || !online { + return Err(Error::RuntimeOperationFailed { + runtime_id: "external-workdir-provider".to_string(), + code: "external_workdir_unavailable".to_string(), + message: "External Workdir provider is offline or expired".to_string(), + } + .into()); + } + workdir::WorkdirSessionCapabilities::READ_ONLY + } + }; let session_lock = current_worker_session_lock(&api, &worker); let _session_guard = session_lock.lock().await; if !api @@ -8711,6 +9365,7 @@ async fn scoped_attach_current_worker_workdir( workspace_id: api.config.workspace_id.clone(), alias: alias.to_string(), working_directory_id: workdir_id.to_string(), + capabilities, attached: true, })); } @@ -8769,6 +9424,7 @@ async fn scoped_attach_current_worker_workdir( workspace_id: api.config.workspace_id.clone(), alias: alias.to_string(), working_directory_id: workdir_id.to_string(), + capabilities, attached: true, })) } @@ -8841,6 +9497,7 @@ async fn scoped_detach_current_worker_workdir( workspace_id: api.config.workspace_id.clone(), alias: alias.to_string(), working_directory_id: link.workdir_id, + capabilities: workdir::WorkdirSessionCapabilities::EMPTY, attached: false, })) } @@ -11264,6 +11921,610 @@ async fn scoped_cleanup_runtime_working_directory( .map_err(ApiError::from) } +async fn scoped_create_external_workdir_grant( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { + validate_workspace_scope(&api, &path.workspace_id)?; + let display_name = request.display_name.trim(); + if display_name.is_empty() + || display_name.len() > 100 + || display_name.chars().any(char::is_control) + { + return Err(Error::InvalidInput( + "External Workdir display name must be between 1 and 100 bytes and contain no control characters".to_string(), + ).into()); + } + if !request.read_only { + return Err(Error::InvalidInput( + "External Workdir grants currently require read_only=true".to_string(), + ) + .into()); + } + if !(60..=86_400).contains(&request.ttl_seconds) { + return Err(Error::InvalidInput( + "External Workdir ttl_seconds must be between 60 and 86400".to_string(), + ) + .into()); + } + ExternalProviderInstanceId::new(request.provider_instance_id.clone()) + .map_err(Error::InvalidInput)?; + let now = Utc::now(); + let created_at = now.to_rfc3339_opts(SecondsFormat::Nanos, true); + let expires_at = (now + Duration::seconds(request.ttl_seconds as i64)) + .to_rfc3339_opts(SecondsFormat::Nanos, true); + let grant_id = format!("ewg-{}", Uuid::now_v7()); + let workdir_id = format!("external-{}", Uuid::now_v7()); + let grant = ExternalWorkdirGrantRecord { + grant_id: grant_id.clone(), + workspace_id: api.config.workspace_id.clone(), + workdir_id: workdir_id.clone(), + provider_instance_id: request.provider_instance_id, + display_name: display_name.to_string(), + permissions: "read_only".to_string(), + created_by: actor.account_id, + created_at: created_at.clone(), + expires_at: expires_at.clone(), + generation: 1, + status: "pending".to_string(), + updated_at: created_at.clone(), + }; + let workdir = WorkdirRegistryRecord { + workspace_id: api.config.workspace_id.clone(), + workdir_id: workdir_id.clone(), + display_name: Some(display_name.to_string()), + source: WorkdirRegistrySource::ExternalGrant { + grant_id: grant_id.clone(), + }, + 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: "pending".to_string(), + cleanliness: "unknown".to_string(), + created_at, + updated_at: grant.updated_at.clone(), + }; + api.store.create_external_workdir_grant(&grant, &workdir)?; + Ok(( + StatusCode::CREATED, + Json(external_workdir_grant_response(&grant)), + )) +} + +fn external_workdir_grant_response( + grant: &ExternalWorkdirGrantRecord, +) -> ExternalWorkdirGrantResponse { + ExternalWorkdirGrantResponse { + grant_id: grant.grant_id.clone(), + workspace_id: grant.workspace_id.clone(), + working_directory_id: grant.workdir_id.clone(), + provider_instance_id: grant.provider_instance_id.clone(), + display_name: grant.display_name.clone(), + permissions: grant.permissions.clone(), + expires_at: grant.expires_at.clone(), + generation: grant.generation, + status: grant.status.clone(), + } +} + +async fn scoped_external_workdir_provider_ws( + State(api): State, + Extension(actor): Extension, + AxumPath((workspace_id, grant_id)): AxumPath<(String, String)>, + ws: WebSocketUpgrade, +) -> ApiResult { + validate_workspace_scope(&api, &workspace_id)?; + let grant = api + .store + .get_external_workdir_grant(&workspace_id, &grant_id)? + .ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?; + if grant.created_by != actor.account_id { + return Err(Error::WorkspacePermissionDenied( + "External Workdir provider connection must use the grant creator's authenticated client".to_string(), + ).into()); + } + let now = Utc::now(); + let expired = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) + .map(|expires_at| expires_at.with_timezone(&Utc) <= now) + .unwrap_or(true); + if expired { + api.store.update_external_workdir_grant_state( + &workspace_id, + &grant_id, + &grant.provider_instance_id, + grant.generation, + "expired", + &now.to_rfc3339_opts(SecondsFormat::Nanos, true), + )?; + release_external_workdir_attachments(&api, &grant.workdir_id).await?; + return Err(Error::InvalidInput("External Workdir grant has expired".to_string()).into()); + } + Ok(ws + .max_message_size(workdir::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES) + .max_frame_size(workdir::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES) + .on_upgrade(move |socket| serve_external_workdir_provider(api, grant, socket))) +} + +async fn send_external_workdir_server_frame( + socket: &mut WebSocket, + frame: &ExternalWorkdirServerFrame, +) -> bool { + let Ok(text) = serde_json::to_string(frame) else { + return false; + }; + matches!( + tokio::time::timeout( + std::time::Duration::from_secs(5), + socket.send(WsMessage::Text(text.into())), + ) + .await, + Ok(Ok(())) + ) +} + +async fn serve_external_workdir_provider( + api: WorkspaceApi, + initial_grant: ExternalWorkdirGrantRecord, + mut socket: WebSocket, +) { + let registration = + match tokio::time::timeout(std::time::Duration::from_secs(10), socket.next()).await { + Ok(Some(Ok(WsMessage::Text(text)))) if text.len() <= 64 * 1024 => { + serde_json::from_str::(&text).ok() + } + _ => None, + }; + let Some(ExternalWorkdirProviderFrame { + message: ExternalWorkdirProviderMessage::Register { registration }, + .. + }) = registration + else { + let _ = socket.close().await; + return; + }; + if registration.grant_id.as_str() != initial_grant.grant_id + || registration.workdir_id.as_str() != initial_grant.workdir_id + || registration.provider_instance_id.as_str() != initial_grant.provider_instance_id + || registration.capabilities != workdir::WorkdirSessionCapabilities::READ_ONLY + || registration.read_limits != workdir::BoundedReadLimits::EXTERNAL_DEFAULT + || registration.generation == 0 + { + let _ = socket.close().await; + return; + } + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); + if !api + .store + .activate_external_workdir_grant( + &initial_grant.workspace_id, + &initial_grant.grant_id, + &initial_grant.provider_instance_id, + registration.generation, + &now, + ) + .unwrap_or(false) + { + let _ = socket.close().await; + return; + } + if let Err(error) = api.store.record_external_workdir_audit( + &initial_grant.workspace_id, + "account", + &initial_grant.created_by, + "external_workdir_provider_connected", + &initial_grant.grant_id, + None, + "online", + &format!( + "workdir_id={} generation={}", + initial_grant.workdir_id, registration.generation + ), + &now, + ) { + let _ = api.store.update_external_workdir_grant_state( + &initial_grant.workspace_id, + &initial_grant.grant_id, + &initial_grant.provider_instance_id, + registration.generation, + "offline", + &now, + ); + tracing::error!( + grant_id = %initial_grant.grant_id, + generation = registration.generation, + %error, + "failed to audit External Workdir provider activation" + ); + let _ = socket.close().await; + return; + } + let expires_at = match chrono::DateTime::parse_from_rfc3339(&initial_grant.expires_at) { + Ok(value) => value.with_timezone(&Utc), + Err(_) => { + let _ = socket.close().await; + return; + } + }; + let (sender, mut commands) = tokio::sync::mpsc::channel(32); + let connection = Arc::new(ExternalProviderConnection { + grant_id: initial_grant.grant_id.clone(), + workdir_id: initial_grant.workdir_id.clone(), + provider_instance_id: initial_grant.provider_instance_id.clone(), + generation: registration.generation, + expires_at, + capabilities: registration.capabilities, + admission: Arc::new(tokio::sync::Semaphore::new(16)), + sender, + }); + if api + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .connected(connection.clone()) + .is_some() + { + let _ = socket.close().await; + return; + } + let registered = + ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Registered { + generation: connection.generation, + heartbeat_interval_ms: 10_000, + max_in_flight_operations: 16, + }); + if !send_external_workdir_server_frame(&mut socket, ®istered).await { + let _ = api + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .disconnect(&connection.grant_id, connection.generation); + if let Err(error) = api.store.update_external_workdir_grant_state( + &initial_grant.workspace_id, + &initial_grant.grant_id, + &connection.provider_instance_id, + connection.generation, + "offline", + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) { + tracing::error!( + grant_id = %initial_grant.grant_id, + generation = connection.generation, + %error, + "failed to persist External Workdir provider registration disconnect" + ); + } + return; + } + + let mut pending = HashMap::< + String, + tokio::sync::oneshot::Sender< + std::result::Result, + >, + >::new(); + let expiry = tokio::time::sleep_until( + tokio::time::Instant::now() + (expires_at - Utc::now()).to_std().unwrap_or_default(), + ); + tokio::pin!(expiry); + let mut heartbeat = tokio::time::interval(std::time::Duration::from_secs(10)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut heartbeat_sequence = 0_u64; + let mut last_heartbeat_ack = tokio::time::Instant::now(); + let terminal_status = loop { + tokio::select! { + _ = heartbeat.tick() => { + if last_heartbeat_ack.elapsed() > std::time::Duration::from_secs(30) { + break "offline"; + } + heartbeat_sequence = heartbeat_sequence.saturating_add(1); + let frame = ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Heartbeat { + generation: connection.generation, + sequence: heartbeat_sequence, + }); + if !send_external_workdir_server_frame(&mut socket, &frame).await { + break "offline"; + } + } + _ = &mut expiry => { + let frame = ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Revoke { + generation: connection.generation, + reason: workdir::external::ExternalWorkdirRevokeReason::Expired, + }); + let _ = send_external_workdir_server_frame(&mut socket, &frame).await; + break "expired"; + } + command = commands.recv() => match command { + Some(ExternalProviderCommand::Operation { operation_id, operation, response }) => { + if pending.len() >= 16 { + let _ = response.send(Err(WorkdirTransportError { + code: workdir::http::WorkdirTransportErrorCode::Unavailable, + message: "External Workdir provider is at its in-flight operation limit".to_string(), + })); + continue; + } + let id = operation_id.as_str().to_string(); + let operation = match ExternalWorkdirOperation::try_from(operation) { + Ok(operation) => operation, + Err(message) => { + let _ = response.send(Err(WorkdirTransportError { + code: workdir::http::WorkdirTransportErrorCode::Unsupported, + message, + })); + continue; + } + }; + let frame = ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Operation { + generation: connection.generation, + operation_id, + operation, + }); + pending.insert(id.clone(), response); + if !send_external_workdir_server_frame(&mut socket, &frame).await { + pending.remove(&id); + break "offline"; + } + } + Some(ExternalProviderCommand::Cancel { operation_id }) => { + pending.remove(operation_id.as_str()); + let frame = ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Cancel { + generation: connection.generation, + operation_id, + }); + if !send_external_workdir_server_frame(&mut socket, &frame).await { + break "offline"; + } + } + Some(ExternalProviderCommand::Revoke) => { + let frame = ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Revoke { + generation: connection.generation, + reason: workdir::external::ExternalWorkdirRevokeReason::UserRequested, + }); + let _ = send_external_workdir_server_frame(&mut socket, &frame).await; + break "revoked"; + } + None => break "offline", + }, + incoming = socket.next() => match incoming { + Some(Ok(WsMessage::Text(text))) + if text.len() <= workdir::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES => { + let Ok(frame) = serde_json::from_str::(&text) else { break "offline"; }; + match frame.message { + ExternalWorkdirProviderMessage::OperationResult { generation, operation_id, outcome } + if generation == connection.generation => { + if let Some(response) = pending.remove(operation_id.as_str()) { + let result = match outcome { + ExternalWorkdirOperationOutcome::Completed { result } => Ok(result.into_inner()), + ExternalWorkdirOperationOutcome::Failed { error } => Err(error), + ExternalWorkdirOperationOutcome::Cancelled => Err(WorkdirTransportError { + code: workdir::http::WorkdirTransportErrorCode::Unavailable, + message: "External Workdir operation was cancelled".to_string(), + }), + }; + let _ = response.send(result); + } + } + ExternalWorkdirProviderMessage::Heartbeat { generation, sequence } + if generation == connection.generation && sequence == heartbeat_sequence => { + last_heartbeat_ack = tokio::time::Instant::now(); + } + ExternalWorkdirProviderMessage::RevokeAcknowledged { generation } + if generation == connection.generation => break "revoked", + _ => break "offline", + } + } + Some(Ok(WsMessage::Ping(bytes))) => { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(5), + socket.send(WsMessage::Pong(bytes)), + ) + .await; + } + Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break "offline", + _ => {} + } + } + }; + pending.clear(); + let _ = api + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .disconnect(&connection.grant_id, connection.generation); + let state_updated = match api.store.update_external_workdir_grant_state( + &initial_grant.workspace_id, + &initial_grant.grant_id, + &connection.provider_instance_id, + connection.generation, + terminal_status, + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) { + Ok(updated) => updated, + Err(error) => { + tracing::error!( + grant_id = %initial_grant.grant_id, + generation = connection.generation, + status = terminal_status, + %error, + "failed to persist External Workdir provider terminal state" + ); + false + } + }; + if state_updated + && let Err(error) = api.store.record_external_workdir_audit( + &initial_grant.workspace_id, + "account", + &initial_grant.created_by, + "external_workdir_provider_disconnected", + &initial_grant.grant_id, + None, + terminal_status, + &format!( + "workdir_id={} generation={}", + initial_grant.workdir_id, connection.generation + ), + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) + { + tracing::error!( + grant_id = %initial_grant.grant_id, + generation = connection.generation, + status = terminal_status, + %error, + "failed to audit External Workdir provider terminal state" + ); + } + if state_updated + && matches!(terminal_status, "expired" | "revoked") + && let Err(error) = + release_external_workdir_attachments(&api, &initial_grant.workdir_id).await + { + tracing::error!( + grant_id = %initial_grant.grant_id, + workdir_id = %initial_grant.workdir_id, + error = ?error, + "External Workdir terminal attachment cleanup requires retry" + ); + } +} + +async fn scoped_get_external_workdir_grant( + State(api): State, + Extension(actor): Extension, + AxumPath((workspace_id, grant_id)): AxumPath<(String, String)>, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + let mut grant = api + .store + .get_external_workdir_grant(&workspace_id, &grant_id)? + .ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?; + if grant.created_by != actor.account_id { + return Err(Error::WorkspacePermissionDenied( + "External Workdir grant inspection requires the grant creator".to_string(), + ) + .into()); + } + let now = Utc::now(); + let expired = !matches!(grant.status.as_str(), "revoked" | "expired") + && chrono::DateTime::parse_from_rfc3339(&grant.expires_at) + .map(|expires_at| expires_at.with_timezone(&Utc) <= now) + .unwrap_or(true); + if expired { + let updated_at = now.to_rfc3339_opts(SecondsFormat::Nanos, true); + if api.store.update_external_workdir_grant_state( + &workspace_id, + &grant_id, + &grant.provider_instance_id, + grant.generation, + "expired", + &updated_at, + )? { + grant.status = "expired".to_string(); + grant.updated_at = updated_at; + } + } + // Terminal cleanup is retryable: a prior expiry/revoke may have committed + // its fence before Runtime/session projection completed. + if matches!(grant.status.as_str(), "expired" | "revoked") { + release_external_workdir_attachments(&api, &grant.workdir_id).await?; + } + Ok(Json(external_workdir_grant_response(&grant))) +} + +async fn scoped_revoke_external_workdir_grant( + State(api): State, + Extension(actor): Extension, + AxumPath((workspace_id, grant_id)): AxumPath<(String, String)>, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + let mut grant = api + .store + .get_external_workdir_grant(&workspace_id, &grant_id)? + .ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?; + if grant.created_by != actor.account_id { + return Err(Error::WorkspacePermissionDenied( + "External Workdir revoke requires the grant creator".to_string(), + ) + .into()); + } + let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); + if !matches!(grant.status.as_str(), "revoked" | "expired") { + api.store.update_external_workdir_grant_state( + &workspace_id, + &grant_id, + &grant.provider_instance_id, + grant.generation, + "revoked", + &updated_at, + )?; + let connection = { + api.external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .disconnect(&grant_id, grant.generation) + }; + if let Some(connection) = connection { + let _ = connection + .sender + .send(ExternalProviderCommand::Revoke) + .await; + } + grant.status = "revoked".to_string(); + grant.updated_at = updated_at; + api.store.record_external_workdir_audit( + &workspace_id, + "account", + &actor.account_id, + "external_workdir_grant_revoked", + &grant_id, + None, + "revoked", + &format!( + "workdir_id={} generation={}", + grant.workdir_id, grant.generation + ), + &grant.updated_at, + )?; + } + // Cleanup is deliberately retried for terminal grants. A prior revoke may + // have committed its fence before a Runtime/session projection failure. + release_external_workdir_attachments(&api, &grant.workdir_id).await?; + Ok(Json(external_workdir_grant_response(&grant))) +} + +async fn release_external_workdir_attachments( + api: &WorkspaceApi, + workdir_id: &str, +) -> ApiResult<()> { + let links = api + .store + .list_workdir_worker_links(&api.config.workspace_id, workdir_id)?; + for link in links.into_iter().filter(|link| link.unlinked_at.is_none()) { + let lock = current_worker_session_lock(api, &link.worker); + let _guard = lock.lock().await; + close_current_worker_attachment_session_locked(api, &link.worker).await?; + close_current_worker_command_sessions_for_alias_locked(api, &link.worker, &link.alias) + .await?; + api.store.detach_worker_workdir( + &api.config.workspace_id, + &link.worker, + Some(workdir_id), + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + )?; + sync_runtime_worker_workdir_attachments(api, &link.worker)?; + api.worker_projection + .refresh(&link.worker) + .map_err(ApiError::from)?; + } + Ok(()) +} + async fn scoped_list_working_directories( State(api): State, AxumPath(path): AxumPath, @@ -11291,8 +12552,25 @@ async fn scoped_working_directory_detail( AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let runtime_id = registered_workdir_runtime_id(&api, &path.working_directory_id)?; - working_directory_detail_for_runtime(api, &runtime_id, &path.working_directory_id) + let record = api + .store + .get_workdir_registry(&api.config.workspace_id, &path.working_directory_id)? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "working_directory_not_found".to_string(), + message: format!("Unknown Workdir `{}`", path.working_directory_id), + })?; + if let Some(runtime_id) = record.source.runtime_id().map(str::to_string) { + working_directory_detail_for_runtime(api, &runtime_id, &path.working_directory_id) + } else { + let item = projected_workdir_summary_from_record(&api, &record)?; + Ok(Json(BrowserWorkingDirectoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: None, + item: item.into(), + diagnostics: Vec::new(), + })) + } } async fn scoped_cleanup_working_directory( @@ -11328,7 +12606,7 @@ fn registered_workdir_runtime_id( ) -> ApiResult { api.store .get_workdir_registry(&api.config.workspace_id, working_directory_id)? - .map(|record| record.runtime_id) + .and_then(|record| record.source.runtime_id().map(str::to_string)) .ok_or_else(|| { ApiError::with_diagnostics( Error::RuntimeOperationFailed { @@ -11576,7 +12854,9 @@ async fn create_workspace_working_directory( StatusCode::OK, Json(BrowserWorkingDirectoryCreateResponse { workspace_id: response.workspace_id, - runtime_id: response.runtime_id, + runtime_id: response + .runtime_id + .expect("Repository Workdir detail has Runtime"), item: response.item, diagnostics: response.diagnostics, }), @@ -11796,7 +13076,7 @@ fn working_directory_detail_for_runtime( Vec::new(), ) })?; - if existing.runtime_id != runtime_id { + if existing.source.runtime_id() != Some(runtime_id) { return Err(ApiError::from(Error::WorkspacePermissionDenied( "Workdir does not belong to the requested Runtime".to_string(), ))); @@ -11812,14 +13092,14 @@ fn working_directory_detail_for_runtime( let summary = projected_workdir_summary_from_record(&api, &record)?; return Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), - runtime_id: runtime_id.to_string(), + runtime_id: Some(runtime_id.to_string()), item: summary.into(), diagnostics: working_directory_diagnostics(result.diagnostics), })); } Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), - runtime_id: runtime_id.to_string(), + runtime_id: Some(runtime_id.to_string()), item: projected_workdir_summary_from_record(&api, &existing)?.into(), diagnostics: working_directory_diagnostics(result.diagnostics), })) @@ -12366,10 +13646,17 @@ fn build_runtime_cleanup_plan( } let mut workdir_candidates = Vec::new(); - for record in workdir_records - .iter() - .filter(|record| record.runtime_id == runtime_id) - { + for record in &workdir_records { + let WorkdirRegistrySource::Repository { + runtime_id: record_runtime_id, + repository_id, + } = &record.source + else { + continue; + }; + if record_runtime_id != runtime_id { + continue; + } let links = api .store .list_workdir_worker_links(&api.config.workspace_id, record.workdir_id.as_str())?; @@ -12416,14 +13703,14 @@ fn build_runtime_cleanup_plan( }; let repository_key = api .store - .get_repository(&api.config.workspace_id, &record.repository_id)? + .get_repository(&api.config.workspace_id, repository_id)? .map(|repository| repository.repository_key) - .ok_or_else(|| Error::UnknownRepository(record.repository_id.clone()))?; + .ok_or_else(|| Error::UnknownRepository(repository_id.clone()))?; workdir_candidates.push(CleanupWorkdirCandidate { target_id: format!("workdir:{}", record.workdir_id), action, workdir_id: record.workdir_id.clone(), - runtime_id: record.runtime_id.clone(), + runtime_id: record_runtime_id.clone(), repository_key, reason: if blocking_reason.is_some() { "Workdir cleanup is blocked until linked Worker state is safe".to_string() @@ -17716,7 +19003,7 @@ fn runtime_working_directory_summaries( .list_workdir_registry(&api.config.workspace_id, 200)?; let items = records .iter() - .filter(|record| record.runtime_id == runtime_id) + .filter(|record| record.source.runtime_id() == Some(runtime_id)) .map(|record| projected_workdir_summary_from_record(api, record)) .collect::>>() .map_err(ApiError::from)?; @@ -17998,8 +19285,10 @@ fn upsert_pending_backend_workdir( workspace_id: api.config.workspace_id.clone(), workdir_id: workdir_id.clone(), display_name: request.display_name.clone(), - runtime_id: runtime_id.to_string(), - repository_id: request.repository.id.clone(), + source: WorkdirRegistrySource::Repository { + runtime_id: runtime_id.to_string(), + repository_id: request.repository.id.clone(), + }, creation_selector: request .repository .selector @@ -18033,7 +19322,7 @@ fn reconcile_runtime_workdir_observations( else { continue; }; - if existing.runtime_id != runtime_id { + if existing.source.runtime_id() != Some(runtime_id) { continue; } observed.insert(status.summary.working_directory_id.clone()); @@ -18061,7 +19350,9 @@ fn sync_runtime_workdir_observations( .store .list_workdir_registry(&api.config.workspace_id, 500)? .into_iter() - .filter(|record| record.runtime_id == runtime_id && !observed.contains(&record.workdir_id)) + .filter(|record| { + record.source.runtime_id() == Some(runtime_id) && !observed.contains(&record.workdir_id) + }) { match api .runtime @@ -18192,8 +19483,10 @@ fn workdir_record_from_summary( workspace_id: api.config.workspace_id.clone(), workdir_id: summary.working_directory_id.clone(), display_name: summary.display_name.clone(), - runtime_id: runtime_id.to_string(), - repository_id: summary.repository_id.clone(), + source: WorkdirRegistrySource::Repository { + runtime_id: runtime_id.to_string(), + repository_id: summary.repository_id.clone(), + }, creation_selector: summary.creation_selector.clone(), creation_ref: summary.creation_ref.clone(), creation_tree: summary.creation_tree.clone(), @@ -18228,8 +19521,11 @@ fn preserve_workdir_identity_for_corrupted_summary( let Some(existing) = existing else { return; }; - if record.repository_id == "unknown" { - record.repository_id = existing.repository_id.clone(); + if matches!( + &record.source, + WorkdirRegistrySource::Repository { repository_id, .. } if repository_id == "unknown" + ) { + record.source = existing.source.clone(); } if record.creation_selector.is_none() { record.creation_selector = existing.creation_selector.clone(); @@ -18264,10 +19560,15 @@ fn runtime_workdir_summary_from_record( "not_found" | "missing" => WorkingDirectoryStatusKind::NotFound, _ => WorkingDirectoryStatusKind::Unknown, }; + let repository_id = record + .source + .repository_id() + .expect("Runtime Workdir projection requires Repository source") + .to_string(); worker_runtime::catalog::WorkingDirectorySummary { working_directory_id: record.workdir_id.clone(), display_name: record.display_name.clone(), - repository_id: record.repository_id.clone(), + repository_id: repository_id.clone(), creation_selector: record.creation_selector.clone(), creation_ref: record.creation_ref.clone(), creation_tree: record.creation_tree.clone(), @@ -18279,7 +19580,7 @@ fn runtime_workdir_summary_from_record( cleanup_target: Some(worker_runtime::catalog::WorkingDirectoryCleanupTarget { kind: "runtime_git_clone".to_string(), working_directory_id: record.workdir_id.clone(), - repository_id: record.repository_id.clone(), + repository_id, }), status, cleanliness: Some(record.cleanliness.clone()), @@ -18289,7 +19590,7 @@ fn runtime_workdir_summary_from_record( fn workdir_summary_from_record( record: &WorkdirRegistryRecord, - repository_key: &str, + repository_key: Option<&str>, ) -> WorkingDirectorySummary { let status = match record.materialization_status.as_str() { "present" => WorkingDirectoryStatusKind::Active, @@ -18299,10 +19600,34 @@ fn workdir_summary_from_record( "unknown" => WorkingDirectoryStatusKind::Unknown, _ => WorkingDirectoryStatusKind::Unknown, }; + let (source, materializer_kind, cleanup_target) = match &record.source { + WorkdirRegistrySource::Repository { .. } => { + let repository_key = + repository_key.expect("Repository Workdir projection requires key"); + ( + WorkingDirectorySource::Repository { + repository_key: repository_key.to_string(), + }, + MaterializerKind::RuntimeGitClone, + Some(WorkingDirectoryCleanupTarget { + kind: "runtime_git_clone".to_string(), + working_directory_id: record.workdir_id.clone(), + repository_key: repository_key.to_string(), + }), + ) + } + WorkdirRegistrySource::ExternalGrant { grant_id } => ( + WorkingDirectorySource::ExternalGrant { + grant_id: grant_id.clone(), + }, + MaterializerKind::ClientHostedExternal, + None, + ), + }; WorkingDirectorySummary { working_directory_id: record.workdir_id.clone(), display_name: record.display_name.clone(), - repository_key: repository_key.to_string(), + source, creation_selector: record.creation_selector.clone(), creation_ref: record.creation_ref.clone(), creation_tree: record.creation_tree.clone(), @@ -18310,12 +19635,8 @@ fn workdir_summary_from_record( current_ref: record.current_ref.clone(), current_tree: record.current_tree.clone(), observed_at_epoch_seconds: record.observed_at_epoch_seconds, - materializer_kind: MaterializerKind::RuntimeGitClone, - cleanup_target: Some(WorkingDirectoryCleanupTarget { - kind: "runtime_git_clone".to_string(), - working_directory_id: record.workdir_id.clone(), - repository_key: repository_key.to_string(), - }), + materializer_kind, + cleanup_target, status, cleanliness: Some(record.cleanliness.clone()), occupied_by: None, @@ -18356,11 +19677,16 @@ fn projected_workdir_summary_from_record( api: &WorkspaceApi, record: &WorkdirRegistryRecord, ) -> Result { - let repository = api - .store - .get_repository(&record.workspace_id, &record.repository_id)? - .ok_or_else(|| Error::UnknownRepository(record.repository_id.clone()))?; - let mut summary = workdir_summary_from_record(record, &repository.repository_key); + let repository_key = match &record.source { + WorkdirRegistrySource::Repository { repository_id, .. } => Some( + api.store + .get_repository(&record.workspace_id, repository_id)? + .ok_or_else(|| Error::UnknownRepository(repository_id.clone()))? + .repository_key, + ), + WorkdirRegistrySource::ExternalGrant { .. } => None, + }; + let mut summary = workdir_summary_from_record(record, repository_key.as_deref()); apply_workdir_occupancy_projection(api, &mut summary)?; Ok(summary) } @@ -18907,13 +20233,23 @@ fn repository_access_request_for_workdir( "Working directory is not registered in this Workspace", ) })?; - if record.runtime_id != runtime_id { + let WorkdirRegistrySource::Repository { + runtime_id: record_runtime_id, + repository_id, + } = &record.source + else { + return Err(settings_bad_request( + "working_directory_provider_mismatch", + "External working directories do not require Repository access", + )); + }; + 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)?; + let repository = api.require_configured_workspace_repository(repository_id)?; if repository.source.kind != server_api::RepositorySourceKind::Ssh { return Ok(None); } @@ -19516,6 +20852,7 @@ mod tests { use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use tower::ServiceExt; + use workdir::WorkdirSession; use worker_runtime::auth::{ RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION, decode_worker_mutation_source_claims, @@ -19536,6 +20873,348 @@ mod tests { SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding, }; + #[tokio::test] + async fn external_provider_session_reuses_operation_contract_and_read_only_capabilities() { + let (sender, mut commands) = tokio::sync::mpsc::channel(1); + let connection = Arc::new(ExternalProviderConnection { + grant_id: "grant-a".to_string(), + workdir_id: "external-a".to_string(), + provider_instance_id: "provider-a".to_string(), + generation: 4, + expires_at: Utc::now() + Duration::minutes(1), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + admission: Arc::new(tokio::sync::Semaphore::new(16)), + sender, + }); + let session = Arc::new(ExternalProviderWorkdirSession::new(connection, None)); + assert_eq!( + session.capabilities(), + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .read(workdir::ReadRequest { + path: workdir::WorkdirPath::new("events.jsonl").unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }) + .await + }) + }; + let Some(ExternalProviderCommand::Operation { + operation, + response, + .. + }) = commands.recv().await + else { + panic!("expected provider operation"); + }; + assert!(matches!(operation, WorkdirSessionOperation::Read(_))); + response + .send(Err(WorkdirTransportError { + code: workdir::http::WorkdirTransportErrorCode::NotFound, + message: "logical file was not found".to_string(), + })) + .unwrap(); + assert!(matches!( + reader.await.unwrap(), + Err(workdir::WorkdirError::NotFound(_)) + )); + assert!(matches!( + session + .write(workdir::WriteRequest { + path: workdir::WorkdirPath::new("events.jsonl").unwrap(), + content: Vec::new(), + expected_hash: None, + }) + .await, + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Write + )) + )); + } + + #[tokio::test] + async fn external_provider_websocket_reconnects_with_fresh_generation_and_revokes() { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let api = test_api(dir.path()).await; + let Json(created_worker) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: "External analysis Worker".to_string(), + profile: Some("builtin:coder".to_string()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + let worker = RuntimeWorkerRef::new(&created_worker.runtime_id, &created_worker.worker_id); + let (_, Json(grant)) = scoped_create_external_workdir_grant( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(test_browser_request_actor()), + Json(ExternalWorkdirGrantCreateRequest { + provider_instance_id: "provider-a".to_string(), + display_name: "Session logs".to_string(), + ttl_seconds: 600, + read_only: true, + }), + ) + .await + .unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = build_inner_router(api.clone()).layer(Extension(test_browser_request_actor())); + let server = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let url = format!( + "ws://{address}/api/w/{TEST_WORKSPACE_ID}/external-workdir-grants/{}/provider", + grant.grant_id + ); + + let register = |generation| { + ExternalWorkdirProviderFrame::current(ExternalWorkdirProviderMessage::Register { + registration: workdir::external::ExternalWorkdirProviderRegistration { + provider_instance_id: ExternalProviderInstanceId::new("provider-a").unwrap(), + grant_id: workdir::external::ExternalWorkdirGrantId::new( + grant.grant_id.clone(), + ) + .unwrap(), + workdir_id: workdir::Workdir::new(&grant.working_directory_id) + .id() + .clone(), + generation, + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + read_limits: workdir::BoundedReadLimits::EXTERNAL_DEFAULT, + }, + }) + }; + + let (mut first, _) = connect_async(&url).await.unwrap(); + first + .send(Message::Text( + serde_json::to_string(®ister(1)).unwrap().into(), + )) + .await + .unwrap(); + let registered = first.next().await.unwrap().unwrap().into_text().unwrap(); + assert!(matches!( + serde_json::from_str::(®istered) + .unwrap() + .message, + ExternalWorkdirServerMessage::Registered { generation: 1, .. } + )); + first.close(None).await.unwrap(); + for _ in 0..50 { + if api + .store + .get_external_workdir_grant(TEST_WORKSPACE_ID, &grant.grant_id) + .unwrap() + .is_some_and(|stored| stored.status == "offline") + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + let (mut second, _) = connect_async(&url).await.unwrap(); + second + .send(Message::Text( + serde_json::to_string(®ister(2)).unwrap().into(), + )) + .await + .unwrap(); + let registered = second.next().await.unwrap().unwrap().into_text().unwrap(); + assert!(matches!( + serde_json::from_str::(®istered) + .unwrap() + .message, + ExternalWorkdirServerMessage::Registered { generation: 2, .. } + )); + + api.store + .upsert_workdir_registry(&WorkdirRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + workdir_id: "analysis-repository".to_string(), + display_name: Some("Analysis repository".to_string()), + source: WorkdirRegistrySource::Repository { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + repository_id: test_repository_id(&api), + }, + creation_selector: Some("HEAD".to_string()), + creation_ref: None, + creation_tree: None, + current_selector: Some("HEAD".to_string()), + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materialization_status: "present".to_string(), + cleanliness: "clean".to_string(), + created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + }) + .unwrap(); + let repository_link = WorkerWorkdirLinkRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: worker.clone(), + workdir_id: "analysis-repository".to_string(), + alias: "repository".to_string(), + linked_at: TEST_CREATED_AT.to_string(), + unlinked_at: None, + }; + let external_link = WorkerWorkdirLinkRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: worker.clone(), + workdir_id: grant.working_directory_id.clone(), + alias: "session-logs".to_string(), + linked_at: TEST_CREATED_AT.to_string(), + unlinked_at: None, + }; + api.store.attach_worker_workdir(&repository_link).unwrap(); + api.store.attach_worker_workdir(&external_link).unwrap(); + sync_runtime_worker_workdir_attachments(&api, &worker).unwrap(); + + let provider_root = tempfile::tempdir().unwrap(); + fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap(); + let local_session = workdir::LocalWorkdirSession::external_read_only( + workdir::Workdir::new(&grant.working_directory_id), + provider_root.path(), + workdir::BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + let session_lock = current_worker_session_lock(&api, &worker); + let session_guard = session_lock.lock().await; + let broker_session = + open_current_worker_workdir_session_locked(&api, &worker, &external_link) + .await + .unwrap(); + assert_eq!( + broker_session.capabilities(), + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + drop(session_guard); + let read = { + let broker_session = broker_session.clone(); + tokio::spawn(async move { + broker_session + .read(workdir::ReadRequest { + path: workdir::WorkdirPath::new("events.jsonl").unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, + }) + .await + }) + }; + let (operation_id, operation) = loop { + let text = second.next().await.unwrap().unwrap().into_text().unwrap(); + let frame = serde_json::from_str::(&text).unwrap(); + if let ExternalWorkdirServerMessage::Operation { + generation: 2, + operation_id, + operation, + } = frame.message + { + break (operation_id, operation); + } + }; + let result = workdir::http::dispatch_workdir_session_operation( + &local_session, + operation.into_inner(), + ) + .await + .unwrap() + .try_into() + .unwrap(); + second + .send(Message::Text( + serde_json::to_string(&ExternalWorkdirProviderFrame::current( + ExternalWorkdirProviderMessage::OperationResult { + generation: 2, + operation_id, + outcome: ExternalWorkdirOperationOutcome::Completed { result }, + }, + )) + .unwrap() + .into(), + )) + .await + .unwrap(); + assert_eq!(read.await.unwrap().unwrap().bytes, b"one\ntwo\n"); + + let Json(revoked) = scoped_revoke_external_workdir_grant( + State(api.clone()), + Extension(test_browser_request_actor()), + AxumPath((TEST_WORKSPACE_ID.to_string(), grant.grant_id.clone())), + ) + .await + .unwrap(); + assert_eq!(revoked.status, "revoked"); + let active_links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap() + .into_iter() + .filter(|link| link.unlinked_at.is_none()) + .collect::>(); + assert_eq!(active_links.len(), 1); + assert_eq!(active_links[0].alias, "repository"); + assert_eq!(active_links[0].workdir_id, "analysis-repository"); + let revoke = loop { + let text = second.next().await.unwrap().unwrap().into_text().unwrap(); + let frame = serde_json::from_str::(&text).unwrap(); + if matches!(frame.message, ExternalWorkdirServerMessage::Revoke { .. }) { + break frame; + } + }; + assert!(matches!( + revoke.message, + ExternalWorkdirServerMessage::Revoke { generation: 2, .. } + )); + server.abort(); + } + + #[test] + fn external_provider_registry_does_not_replace_a_live_generation() { + let (sender, _) = tokio::sync::mpsc::channel(1); + let original = Arc::new(ExternalProviderConnection { + grant_id: "grant-a".to_string(), + workdir_id: "external-a".to_string(), + provider_instance_id: "provider-a".to_string(), + generation: 2, + expires_at: Utc::now() + Duration::minutes(1), + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + admission: Arc::new(tokio::sync::Semaphore::new(16)), + sender, + }); + let (sender, _) = tokio::sync::mpsc::channel(1); + let replacement = Arc::new(ExternalProviderConnection { + generation: 3, + sender, + ..(*original).clone() + }); + let mut registry = ExternalProviderRegistry::default(); + assert!(registry.connected(original.clone()).is_none()); + assert!(Arc::ptr_eq( + ®istry.connected(replacement).unwrap(), + &original + )); + assert_eq!(registry.connection("grant-a").unwrap().generation, 2); + } + #[derive(Clone)] struct TestBearerAuthorizer(&'static str); @@ -20824,8 +22503,10 @@ mod tests { workspace_id: "workspace-1".to_string(), workdir_id: "0000019a00000000000".to_string(), display_name: None, - runtime_id: "embedded".to_string(), - repository_id: "repo".to_string(), + source: WorkdirRegistrySource::Repository { + runtime_id: "embedded".to_string(), + repository_id: "repo".to_string(), + }, creation_selector: Some("develop".to_string()), creation_ref: Some("abcdef".to_string()), creation_tree: Some("tree-creation".to_string()), @@ -21705,8 +23386,10 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), workdir_id: workdir_id.to_string(), display_name: Some("Cross-runtime checkout".to_string()), - runtime_id: "arcadia".to_string(), - repository_id: test_repository_id(&api), + source: WorkdirRegistrySource::Repository { + runtime_id: "arcadia".to_string(), + repository_id: test_repository_id(&api), + }, creation_selector: Some("HEAD".to_string()), creation_ref: None, creation_tree: None, @@ -21998,8 +23681,10 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), workdir_id: workdir_id.clone(), display_name: None, - runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), - repository_id: repository_id.clone(), + source: WorkdirRegistrySource::Repository { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.to_string(), + repository_id: repository_id.clone(), + }, creation_selector: Some("develop".to_string()), creation_ref: None, creation_tree: None, @@ -22375,8 +24060,10 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), workdir_id: workdir_id.clone(), display_name: None, - runtime_id: STRICT_REMOTE_RUNTIME_ID.to_string(), - repository_id: repository_id.clone(), + source: WorkdirRegistrySource::Repository { + runtime_id: STRICT_REMOTE_RUNTIME_ID.to_string(), + repository_id: repository_id.clone(), + }, creation_selector: Some("develop".to_string()), creation_ref: None, creation_tree: None, @@ -22908,8 +24595,10 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), workdir_id: "managed".to_string(), display_name: Some("Managed checkout".to_string()), - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - repository_id: "repo".to_string(), + source: WorkdirRegistrySource::Repository { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + repository_id: "repo".to_string(), + }, creation_selector: None, creation_ref: None, creation_tree: None, @@ -22928,8 +24617,10 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), workdir_id: "runtime-direct".to_string(), display_name: None, - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - repository_id: "repo".to_string(), + source: WorkdirRegistrySource::Repository { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + repository_id: "repo".to_string(), + }, creation_selector: None, creation_ref: None, creation_tree: None, @@ -23003,8 +24694,10 @@ mod tests { workspace_id: "workspace-1".to_string(), workdir_id: "runtime-direct".to_string(), display_name: None, - runtime_id: "embedded".to_string(), - repository_id: "repo".to_string(), + source: WorkdirRegistrySource::Repository { + runtime_id: "embedded".to_string(), + repository_id: "repo".to_string(), + }, creation_selector: None, creation_ref: None, creation_tree: None, @@ -23018,7 +24711,7 @@ mod tests { updated_at: "2".to_string(), }; - let projected = workdir_summary_from_record(&workdir, "main"); + let projected = workdir_summary_from_record(&workdir, Some("main")); assert_eq!(projected.status, WorkingDirectoryStatusKind::Active); let serialized = serde_json::to_string(&projected).unwrap(); @@ -23868,8 +25561,10 @@ mod tests { workspace_id: "other-workspace".to_string(), workdir_id: "foreign-workdir".to_string(), display_name: None, - runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), - repository_id: "foreign-repository".to_string(), + source: WorkdirRegistrySource::Repository { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + repository_id: "foreign-repository".to_string(), + }, creation_selector: None, creation_ref: None, creation_tree: None, @@ -29225,8 +30920,10 @@ mod tests { workspace_id: api.config.workspace_id.clone(), workdir_id: workdir_id.to_string(), display_name: None, - runtime_id: runtime_id.to_string(), - repository_id: "repo-test".to_string(), + source: WorkdirRegistrySource::Repository { + runtime_id: runtime_id.to_string(), + repository_id: "repo-test".to_string(), + }, creation_selector: Some("HEAD".to_string()), creation_ref: None, creation_tree: None, @@ -33584,8 +35281,10 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), workdir_id: workdir_id.to_string(), display_name: None, - runtime_id: "external-workdir-runtime".to_string(), - repository_id, + source: WorkdirRegistrySource::Repository { + runtime_id: "external-workdir-runtime".to_string(), + repository_id, + }, creation_selector: None, creation_ref: None, creation_tree: None, @@ -33978,8 +35677,10 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), workdir_id: workdir_id.to_string(), display_name: None, - runtime_id: "arcadia".to_string(), - repository_id: test_repository_id(&api), + source: WorkdirRegistrySource::Repository { + runtime_id: "arcadia".to_string(), + repository_id: test_repository_id(&api), + }, creation_selector: Some("HEAD".to_string()), creation_ref: None, creation_tree: None, @@ -34582,7 +36283,9 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); WorkingDirectorySummary { working_directory_id: "wd-1".to_string(), display_name: Some("Checkout".to_string()), - repository_key: "main".to_string(), + source: WorkingDirectorySource::Repository { + repository_key: "main".to_string(), + }, creation_selector: None, creation_ref: None, creation_tree: None, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 2fdc2f986..56a5b2446 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 63; +const LATEST_SCHEMA_VERSION: i64 = 64; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -43,6 +43,7 @@ const WORKER_REGISTRY_PROJECTION_MIGRATION_NAME: &str = "durable Runtime-backed Worker registry projection"; const MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME: &str = "multiple Worker Workdir attachments and Workdir display names"; +const EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME: &str = "typed client-hosted External Workdir grants"; const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS worker_registry_observations ( workspace_id TEXT NOT NULL, @@ -153,6 +154,11 @@ const MIGRATIONS: &[Migration] = &[ name: MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME, apply: migrate_multi_workdir_attachments_v62_to_v63, }, + Migration { + version: 64, + name: EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME, + apply: migrate_external_workdir_grants_v63_to_v64, + }, ]; #[derive(Clone, Copy)] @@ -784,13 +790,47 @@ pub struct WorkdirCreateOperationRecord { pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkdirRegistrySource { + Repository { + runtime_id: String, + repository_id: String, + }, + ExternalGrant { + grant_id: String, + }, +} + +impl WorkdirRegistrySource { + pub fn runtime_id(&self) -> Option<&str> { + match self { + Self::Repository { runtime_id, .. } => Some(runtime_id), + Self::ExternalGrant { .. } => None, + } + } + + pub fn repository_id(&self) -> Option<&str> { + match self { + Self::Repository { repository_id, .. } => Some(repository_id), + Self::ExternalGrant { .. } => None, + } + } + + pub fn external_grant_id(&self) -> Option<&str> { + match self { + Self::Repository { .. } => None, + Self::ExternalGrant { grant_id } => Some(grant_id), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkdirRegistryRecord { pub workspace_id: String, pub workdir_id: String, pub display_name: Option, - pub runtime_id: String, - pub repository_id: String, + pub source: WorkdirRegistrySource, pub creation_selector: Option, pub creation_ref: Option, pub creation_tree: Option, @@ -804,6 +844,22 @@ pub struct WorkdirRegistryRecord { pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ExternalWorkdirGrantRecord { + pub grant_id: String, + pub workspace_id: String, + pub workdir_id: String, + pub provider_instance_id: String, + pub display_name: String, + pub permissions: String, + pub created_by: String, + pub created_at: String, + pub expires_at: String, + pub generation: u64, + pub status: String, + pub updated_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkerWorkdirLinkRecord { pub workspace_id: String, @@ -1578,6 +1634,52 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ) -> Result>; fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result; + fn create_external_workdir_grant( + &self, + grant: &ExternalWorkdirGrantRecord, + workdir: &WorkdirRegistryRecord, + ) -> Result<()>; + fn get_external_workdir_grant( + &self, + workspace_id: &str, + grant_id: &str, + ) -> Result>; + fn activate_external_workdir_grant( + &self, + workspace_id: &str, + grant_id: &str, + provider_instance_id: &str, + generation: u64, + updated_at: &str, + ) -> Result; + fn update_external_workdir_grant_state( + &self, + workspace_id: &str, + grant_id: &str, + provider_instance_id: &str, + generation: u64, + status: &str, + updated_at: &str, + ) -> Result; + fn reconcile_external_workdir_grants_after_restart( + &self, + workspace_id: &str, + now: &str, + ) -> Result>; + #[allow(clippy::too_many_arguments)] + fn record_external_workdir_audit( + &self, + workspace_id: &str, + actor_kind: &str, + actor_key: &str, + action: &str, + grant_id: &str, + operation_id: Option<&str>, + outcome: &str, + summary: &str, + created_at: &str, + ) -> Result<()>; + fn reserve_worker_workdir_attachment( &self, workspace_id: &str, @@ -7334,15 +7436,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { conn.execute( r#"INSERT INTO workdir_registry ( - workspace_id, workdir_id, display_name, runtime_id, repository_id, - creation_selector, creation_ref, creation_tree, + workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, + external_grant_id, creation_selector, creation_ref, creation_tree, current_selector, current_ref, current_tree, observed_at_epoch_seconds, materialization_status, cleanliness, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET display_name = excluded.display_name, + source_kind = excluded.source_kind, runtime_id = excluded.runtime_id, repository_id = excluded.repository_id, + external_grant_id = excluded.external_grant_id, creation_selector = excluded.creation_selector, creation_ref = excluded.creation_ref, creation_tree = excluded.creation_tree, @@ -7357,8 +7461,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.workspace_id, record.workdir_id, record.display_name, - record.runtime_id, - record.repository_id, + match &record.source { WorkdirRegistrySource::Repository { .. } => "repository", WorkdirRegistrySource::ExternalGrant { .. } => "external_grant" }, + record.source.runtime_id(), + record.source.repository_id(), + record.source.external_grant_id(), record.creation_selector, record.creation_ref, record.creation_tree, @@ -7442,6 +7548,282 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn create_external_workdir_grant( + &self, + grant: &ExternalWorkdirGrantRecord, + workdir: &WorkdirRegistryRecord, + ) -> Result<()> { + if workdir.workspace_id != grant.workspace_id + || workdir.workdir_id != grant.workdir_id + || workdir.source.external_grant_id() != Some(grant.grant_id.as_str()) + { + return Err(Error::Store( + "External Workdir grant and registry identity do not match".to_string(), + )); + } + self.with_conn(|conn| { + let tx = rusqlite::Transaction::new_unchecked( + conn, + rusqlite::TransactionBehavior::Immediate, + )?; + tx.execute( + r#"INSERT INTO external_workdir_grants ( + grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)"#, + params![ + grant.grant_id, + grant.workspace_id, + grant.workdir_id, + grant.provider_instance_id, + grant.display_name, + grant.permissions, + grant.created_by, + grant.created_at, + grant.expires_at, + grant.generation as i64, + grant.status, + grant.updated_at, + ], + )?; + tx.execute( + r#"INSERT INTO workdir_registry ( + workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, + external_grant_id, creation_selector, creation_ref, creation_tree, + current_selector, current_ref, current_tree, observed_at_epoch_seconds, + materialization_status, cleanliness, created_at, updated_at + ) VALUES (?1, ?2, ?3, 'external_grant', NULL, NULL, ?4, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?5, 'unknown', ?6, ?7)"#, + params![ + workdir.workspace_id, + workdir.workdir_id, + workdir.display_name, + grant.grant_id, + workdir.materialization_status, + workdir.created_at, + workdir.updated_at, + ], + )?; + tx.execute( + r#"INSERT INTO audit_events ( + workspace_id, audit_event_id, created_at, actor_kind, actor_key, + actor_display, actor_source_kind, actor_source_key, action, + target_kind, target_id, outcome, request_id, summary + ) VALUES (?1, ?2, ?3, 'account', ?4, ?4, NULL, NULL, + 'external_workdir_grant_created', 'external_workdir_grant', + ?5, 'completed', NULL, ?6)"#, + params![ + grant.workspace_id, + format!("audit-{}", Uuid::now_v7()), + grant.created_at, + grant.created_by, + grant.grant_id, + format!( + "workdir_id={} permission={} generation={}", + grant.workdir_id, grant.permissions, grant.generation + ), + ], + )?; + tx.commit()?; + Ok(()) + }) + } + + fn get_external_workdir_grant( + &self, + workspace_id: &str, + grant_id: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + r#"SELECT grant_id, workspace_id, workdir_id, provider_instance_id, display_name, + permissions, created_by, created_at, expires_at, generation, status, updated_at + FROM external_workdir_grants WHERE workspace_id = ?1 AND grant_id = ?2"#, + params![workspace_id, grant_id], + |row| { + Ok(ExternalWorkdirGrantRecord { + grant_id: row.get(0)?, + workspace_id: row.get(1)?, + workdir_id: row.get(2)?, + provider_instance_id: row.get(3)?, + display_name: row.get(4)?, + permissions: row.get(5)?, + created_by: row.get(6)?, + created_at: row.get(7)?, + expires_at: row.get(8)?, + generation: row.get::<_, i64>(9)? as u64, + status: row.get(10)?, + updated_at: row.get(11)?, + }) + }, + ) + .optional() + .map_err(Error::from) + }) + } + + fn activate_external_workdir_grant( + &self, + workspace_id: &str, + grant_id: &str, + provider_instance_id: &str, + generation: u64, + updated_at: &str, + ) -> Result { + self.with_conn(|conn| { + let changed = conn.execute( + r#"UPDATE external_workdir_grants + SET generation = ?4, status = 'online', updated_at = ?5 + WHERE workspace_id = ?1 AND grant_id = ?2 AND provider_instance_id = ?3 + AND ((status = 'pending' AND generation = ?4) + OR (status = 'offline' AND generation + 1 = ?4)) + AND expires_at > ?5"#, + params![ + workspace_id, + grant_id, + provider_instance_id, + generation as i64, + updated_at, + ], + )?; + if changed > 0 { + conn.execute( + "UPDATE workdir_registry SET materialization_status = 'present', updated_at = ?3 WHERE workspace_id = ?1 AND external_grant_id = ?2", + params![workspace_id, grant_id, updated_at], + )?; + } + Ok(changed > 0) + }) + } + + fn update_external_workdir_grant_state( + &self, + workspace_id: &str, + grant_id: &str, + provider_instance_id: &str, + generation: u64, + status: &str, + updated_at: &str, + ) -> Result { + self.with_conn(|conn| { + let changed = conn.execute( + r#"UPDATE external_workdir_grants SET status = ?5, updated_at = ?6 + WHERE workspace_id = ?1 AND grant_id = ?2 AND provider_instance_id = ?3 + AND generation = ?4 AND status NOT IN ('revoked', 'expired')"#, + params![ + workspace_id, + grant_id, + provider_instance_id, + generation as i64, + status, + updated_at, + ], + )?; + if changed > 0 { + conn.execute( + "UPDATE workdir_registry SET materialization_status = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND external_grant_id = ?2", + params![ + workspace_id, + grant_id, + if status == "online" { "present" } else { "unknown" }, + updated_at, + ], + )?; + } + Ok(changed > 0) + }) + } + + fn reconcile_external_workdir_grants_after_restart( + &self, + workspace_id: &str, + now: &str, + ) -> Result> { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "UPDATE external_workdir_grants SET status = 'expired', updated_at = ?2 + WHERE workspace_id = ?1 AND status IN ('pending', 'online', 'offline') + AND expires_at <= ?2", + params![workspace_id, now], + )?; + tx.execute( + "UPDATE external_workdir_grants SET status = 'offline', updated_at = ?2 + WHERE workspace_id = ?1 AND status = 'online' AND expires_at > ?2", + params![workspace_id, now], + )?; + tx.execute( + "UPDATE workdir_registry SET materialization_status = 'unknown', updated_at = ?2 + WHERE workspace_id = ?1 AND source_kind = 'external_grant' + AND external_grant_id IN ( + SELECT grant_id FROM external_workdir_grants + WHERE workspace_id = ?1 AND status != 'online' + )", + params![workspace_id, now], + )?; + let cleanup = { + let mut statement = tx.prepare( + "SELECT DISTINCT grant.workdir_id + FROM external_workdir_grants grant + JOIN worker_workdir_links link + ON link.workspace_id = grant.workspace_id + AND link.workdir_id = grant.workdir_id + AND link.unlinked_at IS NULL + WHERE grant.workspace_id = ?1 + AND grant.status IN ('revoked', 'expired') + ORDER BY grant.workdir_id", + )?; + statement + .query_map(params![workspace_id], |row| row.get::<_, String>(0))? + .collect::>>()? + }; + tx.commit()?; + Ok(cleanup) + }) + } + + #[allow(clippy::too_many_arguments)] + fn record_external_workdir_audit( + &self, + workspace_id: &str, + actor_kind: &str, + actor_key: &str, + action: &str, + grant_id: &str, + operation_id: Option<&str>, + outcome: &str, + summary: &str, + created_at: &str, + ) -> Result<()> { + if summary.len() > 500 || summary.chars().any(char::is_control) { + return Err(Error::InvalidInput( + "External Workdir audit summary is invalid".to_string(), + )); + } + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO audit_events ( + workspace_id, audit_event_id, created_at, actor_kind, actor_key, + actor_display, actor_source_kind, actor_source_key, action, + target_kind, target_id, outcome, request_id, summary + ) VALUES (?1, ?2, ?3, ?4, ?5, ?5, NULL, NULL, ?6, + 'external_workdir_grant', ?7, ?8, ?9, ?10)"#, + params![ + workspace_id, + format!("audit-{}", Uuid::now_v7()), + created_at, + actor_kind, + actor_key, + action, + grant_id, + outcome, + operation_id, + summary, + ], + )?; + Ok(()) + }) + } + fn reserve_worker_workdir_attachment( &self, workspace_id: &str, @@ -9356,7 +9738,7 @@ fn require_expected_ticket_assignment( fn workdir_registry_select_sql(where_clause: &str) -> String { format!( - "SELECT workspace_id, workdir_id, display_name, runtime_id, repository_id, \ + "SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, \ creation_selector, creation_ref, creation_tree, \ current_selector, current_ref, current_tree, observed_at_epoch_seconds, \ materialization_status, cleanliness, created_at, updated_at \ @@ -9367,23 +9749,60 @@ fn workdir_registry_select_sql(where_clause: &str) -> String { fn read_workdir_registry_record( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { + let source_kind = row.get::<_, String>(3)?; + let runtime_id = row.get::<_, Option>(4)?; + let repository_id = row.get::<_, Option>(5)?; + let external_grant_id = row.get::<_, Option>(6)?; + let source = match source_kind.as_str() { + "repository" => WorkdirRegistrySource::Repository { + runtime_id: runtime_id.ok_or_else(|| { + rusqlite::Error::InvalidColumnType( + 4, + "runtime_id".to_string(), + rusqlite::types::Type::Null, + ) + })?, + repository_id: repository_id.ok_or_else(|| { + rusqlite::Error::InvalidColumnType( + 5, + "repository_id".to_string(), + rusqlite::types::Type::Null, + ) + })?, + }, + "external_grant" => WorkdirRegistrySource::ExternalGrant { + grant_id: external_grant_id.ok_or_else(|| { + rusqlite::Error::InvalidColumnType( + 6, + "external_grant_id".to_string(), + rusqlite::types::Type::Null, + ) + })?, + }, + _ => { + return Err(rusqlite::Error::InvalidColumnType( + 3, + "source_kind".to_string(), + rusqlite::types::Type::Text, + )); + } + }; Ok(WorkdirRegistryRecord { workspace_id: row.get(0)?, workdir_id: row.get(1)?, display_name: row.get(2)?, - runtime_id: row.get(3)?, - repository_id: row.get(4)?, - creation_selector: row.get(5)?, - creation_ref: row.get(6)?, - creation_tree: row.get(7)?, - current_selector: row.get(8)?, - current_ref: row.get(9)?, - current_tree: row.get(10)?, - observed_at_epoch_seconds: row.get::<_, Option>(11)?.map(|value| value as u64), - materialization_status: row.get(12)?, - cleanliness: row.get(13)?, - created_at: row.get(14)?, - updated_at: row.get(15)?, + source, + creation_selector: row.get(7)?, + creation_ref: row.get(8)?, + creation_tree: row.get(9)?, + current_selector: row.get(10)?, + current_ref: row.get(11)?, + current_tree: row.get(12)?, + observed_at_epoch_seconds: row.get::<_, Option>(13)?.map(|value| value as u64), + materialization_status: row.get(14)?, + cleanliness: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, }) } @@ -10651,6 +11070,162 @@ fn migrate_multi_workdir_attachments_v62_to_v63(conn: &Connection) -> Result<()> Ok(()) } +fn migrate_external_workdir_grants_v63_to_v64(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + // Migration-chain tests and repaired databases may carry the canonical v64 + // shape while the durable history still stops at v63. In that case only + // advance the marker; rebuilding would discard External grant rows and + // collide with the already-created table. + if column_exists(&tx, "workdir_registry", "source_kind")? + && table_exists(&tx, "external_workdir_grants")? + { + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![64, EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME], + )?; + tx.commit()?; + return Ok(()); + } + tx.execute_batch( + r#" + PRAGMA defer_foreign_keys = ON; + DROP TRIGGER IF EXISTS workdir_registry_insert_blocked_by_runtime_removal; + DROP TRIGGER IF EXISTS workdir_registry_update_blocked_by_runtime_removal; + CREATE TEMP TABLE worker_workdir_attachment_reservations_v64 AS + SELECT workspace_id, workdir_id, reservation_id, reserved_at + FROM worker_workdir_attachment_reservations; + CREATE TEMP TABLE worker_workdir_links_v64 AS + SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + FROM worker_workdir_links; + DROP TABLE worker_workdir_attachment_reservations; + DROP TABLE worker_workdir_links; + CREATE TABLE external_workdir_grants ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + provider_instance_id TEXT NOT NULL, + display_name TEXT NOT NULL, + permissions TEXT NOT NULL CHECK (permissions = 'read_only'), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, workdir_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) REFERENCES workdir_registry_v64(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED + ); + CREATE TABLE workdir_registry_v64 ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + display_name TEXT, + source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant')), + runtime_id TEXT, + repository_id TEXT, + external_grant_id TEXT, + creation_selector TEXT, + creation_ref TEXT, + creation_tree TEXT, + current_selector TEXT, + current_ref TEXT, + current_tree TEXT, + observed_at_epoch_seconds INTEGER, + materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), + cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id), + CHECK ( + (source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL) + OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) REFERENCES repositories(workspace_id, repository_id), + FOREIGN KEY (workspace_id, external_grant_id) REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED + ); + INSERT INTO workdir_registry_v64 ( + workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, + external_grant_id, creation_selector, creation_ref, creation_tree, current_selector, + current_ref, current_tree, observed_at_epoch_seconds, materialization_status, + cleanliness, created_at, updated_at + ) SELECT workspace_id, workdir_id, display_name, 'repository', runtime_id, repository_id, + NULL, creation_selector, creation_ref, creation_tree, current_selector, + current_ref, current_tree, observed_at_epoch_seconds, materialization_status, + cleanliness, created_at, updated_at + FROM workdir_registry; + DROP TABLE workdir_registry; + ALTER TABLE workdir_registry_v64 RENAME TO workdir_registry; + CREATE TABLE worker_workdir_attachment_reservations ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + reservation_id TEXT NOT NULL, + reserved_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id), + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_attachment_reservations + (workspace_id, workdir_id, reservation_id, reserved_at) + SELECT workspace_id, workdir_id, reservation_id, reserved_at + FROM worker_workdir_attachment_reservations_v64; + CREATE TABLE worker_workdir_links ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO worker_workdir_links + (workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at) + SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at + FROM worker_workdir_links_v64; + DROP TABLE worker_workdir_attachment_reservations_v64; + DROP TABLE worker_workdir_links_v64; + CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL; + CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL; + CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); + CREATE INDEX idx_workdir_registry_workspace_updated + ON workdir_registry(workspace_id, updated_at DESC); + CREATE INDEX idx_external_workdir_grants_status_expiry + ON external_workdir_grants(workspace_id, status, expires_at); + CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal + BEFORE INSERT ON workdir_registry FOR EACH ROW + WHEN NEW.runtime_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal + BEFORE UPDATE ON workdir_registry FOR EACH ROW + WHEN NEW.runtime_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') + ) + BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![64, EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { conn.execute_batch(include_str!("latest_schema.sql"))?; Ok(()) @@ -10716,7 +11291,7 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + )) + })? + .collect::>>()?) + }) + .unwrap(); + assert_eq!(audit.len(), 2); + assert!( + audit + .iter() + .any(|entry| entry.0 == "external_workdir_grant_created") + ); + assert!(audit.iter().any(|entry| { + entry.0 == "external_workdir_operation" + && entry.1 == "worker" + && entry.2 == "runtime-a:worker-a" + && entry.3.as_deref() == Some("operation-a") + && !entry.4.contains("/home/") + })); + + let persisted = store + .get_workdir_registry("workspace-a", "external-a") + .unwrap() + .unwrap(); + assert_eq!(persisted.source, workdir.source); + assert_eq!(persisted.source.runtime_id(), None); + assert_eq!(persisted.source.repository_id(), None); + assert!( + store + .activate_external_workdir_grant( + "workspace-a", + "grant-a", + "provider-a", + 1, + "2026-01-01T00:00:01Z", + ) + .unwrap() + ); + assert!( + !store + .activate_external_workdir_grant( + "workspace-a", + "grant-a", + "provider-a", + 1, + "2026-01-01T00:00:02Z", + ) + .unwrap() + ); + assert!( + store + .reconcile_external_workdir_grants_after_restart( + "workspace-a", + "2026-01-01T00:00:03Z", + ) + .unwrap() + .is_empty() + ); + assert!( + !store + .activate_external_workdir_grant( + "workspace-a", + "grant-a", + "provider-a", + 1, + "2026-01-01T00:00:04Z", + ) + .unwrap() + ); + assert!( + store + .activate_external_workdir_grant( + "workspace-a", + "grant-a", + "provider-a", + 2, + "2026-01-01T00:00:05Z", + ) + .unwrap() + ); + assert!( + !store + .update_external_workdir_grant_state( + "workspace-a", + "grant-a", + "provider-a", + 1, + "offline", + "2026-01-01T00:00:06Z", + ) + .unwrap() + ); + assert!( + store + .update_external_workdir_grant_state( + "workspace-a", + "grant-a", + "provider-a", + 2, + "revoked", + "2026-01-01T00:00:07Z", + ) + .unwrap() + ); + assert!( + !store + .activate_external_workdir_grant( + "workspace-a", + "grant-a", + "provider-a", + 3, + "2026-01-01T00:00:08Z", + ) + .unwrap() + ); + } + + #[test] + fn external_workdir_migration_preserves_existing_attachment_rows() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch( + r#" + CREATE TABLE workspaces (workspace_id TEXT PRIMARY KEY); + CREATE TABLE repositories ( + workspace_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, repository_id) + ); + CREATE TABLE worker_registry ( + workspace_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, worker_id) + ); + CREATE TABLE runtime_removal_operations ( + runtime_id TEXT NOT NULL, + state TEXT NOT NULL + ); + CREATE TABLE workdir_registry ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + display_name TEXT, + runtime_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + creation_selector TEXT, + creation_ref TEXT, + creation_tree TEXT, + current_selector TEXT, + current_ref TEXT, + current_tree TEXT, + observed_at_epoch_seconds INTEGER, + materialization_status TEXT NOT NULL, + cleanliness TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id) + ); + CREATE TABLE worker_workdir_attachment_reservations ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + reservation_id TEXT NOT NULL, + reserved_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id), + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + CREATE TABLE worker_workdir_links ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); + INSERT INTO workspaces VALUES ('workspace-a'); + INSERT INTO repositories VALUES ('workspace-a', 'repository-a'); + INSERT INTO worker_registry VALUES ('workspace-a', 'worker-a'); + INSERT INTO workdir_registry VALUES ( + 'workspace-a', 'workdir-a', 'Checkout', 'runtime-a', 'repository-a', + 'develop', 'abc', 'tree-a', 'develop', 'abc', 'tree-a', 1, + 'present', 'clean', '1', '1' + ); + INSERT INTO worker_workdir_attachment_reservations + VALUES ('workspace-a', 'workdir-a', 'reservation-a', '1'); + INSERT INTO worker_workdir_links + VALUES ('workspace-a', 'runtime-a', 'worker-a', 'workdir-a', 'repo', '1', NULL); + INSERT INTO __yoi_schema_migrations(version, name) + VALUES (63, 'workspace schema baseline'); + "#, + ) + .unwrap(); + + migrate_external_workdir_grants_v63_to_v64(&conn).unwrap(); + + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM worker_workdir_attachment_reservations", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM worker_workdir_links", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row( + "SELECT source_kind || ':' || runtime_id || ':' || repository_id FROM workdir_registry", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "repository:runtime-a:repository-a" + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0 + ); + } + fn runtime_removal_test_store() -> (tempfile::TempDir, SqliteWorkspaceStore) { let temp = tempfile::tempdir().unwrap(); let store = SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap(); @@ -11383,10 +12277,10 @@ mod tests { 'local', '/repo', 1, 'source-a', 'unverified'\ ); \ INSERT INTO workdir_registry(\ - workspace_id, workdir_id, runtime_id, repository_id, materialization_status, \ + workspace_id, workdir_id, source_kind, runtime_id, repository_id, materialization_status, \ cleanliness, created_at, updated_at\ ) VALUES (\ - 'workspace-a', 'workdir-a', 'runtime-a', 'repository-a', 'present', \ + 'workspace-a', 'workdir-a', 'repository', 'runtime-a', 'repository-a', 'present', \ 'clean', '1', '1'\ );", )?; @@ -12030,6 +12924,10 @@ mod tests { version: 63, name: MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 64, + name: EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME.to_string(), + }, ] ); @@ -12082,6 +12980,7 @@ mod tests { ), (62, WORKER_REGISTRY_PROJECTION_MIGRATION_NAME.to_string()), (63, MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME.to_string()), + (64, EXTERNAL_WORKDIR_GRANTS_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -12152,7 +13051,7 @@ mod tests { .iter() .map(|migration| migration.version) .collect::>(), - vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63] + vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -12160,7 +13059,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 14); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 15); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); } @@ -14876,8 +15775,10 @@ INSERT INTO worker_registry ( workspace_id: "local-dev".to_string(), workdir_id: "0000019a00000000001".to_string(), display_name: Some("Checkout".to_string()), - runtime_id: "embedded".to_string(), - repository_id: "repo".to_string(), + source: WorkdirRegistrySource::Repository { + runtime_id: "embedded".to_string(), + repository_id: "repo".to_string(), + }, creation_selector: Some("develop".to_string()), creation_ref: Some("abcdef".to_string()), creation_tree: Some("tree-creation".to_string()), @@ -14895,8 +15796,10 @@ INSERT INTO worker_registry ( workspace_id: "local-dev".to_string(), workdir_id: "runtime-direct".to_string(), display_name: None, - runtime_id: "embedded".to_string(), - repository_id: "repo".to_string(), + source: WorkdirRegistrySource::Repository { + runtime_id: "embedded".to_string(), + repository_id: "repo".to_string(), + }, creation_selector: Some("feature".to_string()), creation_ref: Some("123456".to_string()), creation_tree: None, diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 3fccda15c..5044d8045 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -11,7 +11,7 @@ use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::store::WorkdirRegistryRecord; +use crate::store::{WorkdirRegistryRecord, WorkdirRegistrySource}; use crate::{Error, Result, SqliteWorkspaceStore}; const MAX_REASON_BYTES: usize = 500; @@ -95,8 +95,7 @@ pub fn workdir_materialization_fingerprint(record: &WorkdirRegistryRecord) -> St let bytes = serde_json::to_vec(&serde_json::json!([ record.workspace_id, record.workdir_id, - record.runtime_id, - record.repository_id, + record.source, record.creation_selector, record.creation_ref, record.creation_tree, @@ -116,21 +115,29 @@ pub fn workdir_removal_intent( let fingerprint_bytes = serde_json::to_vec(&serde_json::json!([ record.workspace_id, record.workdir_id, - record.runtime_id, - record.repository_id, + record.source, materialization_fingerprint, source_actor, reason, ])) .map_err(|error| Error::Store(format!("Workdir removal fingerprint failed: {error}")))?; let request_fingerprint = hex_sha256(&fingerprint_bytes); + let WorkdirRegistrySource::Repository { + runtime_id, + repository_id, + } = &record.source + else { + return Err(Error::InvalidInput( + "External Workdirs must be revoked through their grant authority".to_string(), + )); + }; Ok(WorkdirRemovalIntent { operation_id: format!("wdr_{}", &request_fingerprint[..32]), request_fingerprint, workspace_id: record.workspace_id.clone(), working_directory_id: record.workdir_id.clone(), - runtime_id: record.runtime_id.clone(), - repository_id: record.repository_id.clone(), + runtime_id: runtime_id.clone(), + repository_id: repository_id.clone(), materialization_fingerprint, source_actor: source_actor.to_string(), reason: reason.to_string(), @@ -682,8 +689,7 @@ fn require_matching_materialization( ) -> Result<()> { if intent.workspace_id != record.workspace_id || intent.working_directory_id != record.workdir_id - || intent.runtime_id != record.runtime_id - || intent.repository_id != record.repository_id + || !matches!(&record.source, WorkdirRegistrySource::Repository { runtime_id, repository_id } if runtime_id == &intent.runtime_id && repository_id == &intent.repository_id) || intent.materialization_fingerprint != workdir_materialization_fingerprint(record) { return Err(Error::WorkdirAttachmentConflict( @@ -699,8 +705,7 @@ fn require_operation_materialization( ) -> Result<()> { if operation.workspace_id != record.workspace_id || operation.working_directory_id != record.workdir_id - || operation.runtime_id != record.runtime_id - || operation.repository_id != record.repository_id + || !matches!(&record.source, WorkdirRegistrySource::Repository { runtime_id, repository_id } if runtime_id == &operation.runtime_id && repository_id == &operation.repository_id) || operation.materialization_fingerprint != workdir_materialization_fingerprint(record) { return Err(Error::WorkdirAttachmentConflict( @@ -716,7 +721,7 @@ fn load_workdir_record( workdir_id: &str, ) -> Result> { conn.query_row( - r#"SELECT workspace_id, workdir_id, display_name, runtime_id, repository_id, + r#"SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, creation_selector, creation_ref, creation_tree, current_selector, current_ref, current_tree, observed_at_epoch_seconds, materialization_status, cleanliness, created_at, updated_at @@ -727,19 +732,30 @@ fn load_workdir_record( workspace_id: row.get(0)?, workdir_id: row.get(1)?, display_name: row.get(2)?, - runtime_id: row.get(3)?, - repository_id: row.get(4)?, - creation_selector: row.get(5)?, - creation_ref: row.get(6)?, - creation_tree: row.get(7)?, - current_selector: row.get(8)?, - current_ref: row.get(9)?, - current_tree: row.get(10)?, - observed_at_epoch_seconds: row.get::<_, Option>(11)?.map(|value| value as u64), - materialization_status: row.get(12)?, - cleanliness: row.get(13)?, - created_at: row.get(14)?, - updated_at: row.get(15)?, + source: match row.get::<_, String>(3)?.as_str() { + "repository" => WorkdirRegistrySource::Repository { + runtime_id: row.get(4)?, + repository_id: row.get(5)?, + }, + _ => { + return Err(rusqlite::Error::InvalidColumnType( + 3, + "source_kind".to_string(), + rusqlite::types::Type::Text, + )); + } + }, + creation_selector: row.get(6)?, + creation_ref: row.get(7)?, + creation_tree: row.get(8)?, + current_selector: row.get(9)?, + current_ref: row.get(10)?, + current_tree: row.get(11)?, + observed_at_epoch_seconds: row.get::<_, Option>(12)?.map(|value| value as u64), + materialization_status: row.get(13)?, + cleanliness: row.get(14)?, + created_at: row.get(15)?, + updated_at: row.get(16)?, }) }, ) @@ -936,8 +952,10 @@ mod tests { workspace_id: "workspace-a".to_string(), workdir_id: "workdir-a".to_string(), display_name: None, - runtime_id: "runtime-a".to_string(), - repository_id: "repository-a".to_string(), + source: WorkdirRegistrySource::Repository { + runtime_id: "runtime-a".to_string(), + repository_id: "repository-a".to_string(), + }, creation_selector: Some("refs/heads/develop".to_string()), creation_ref: Some("abc".to_string()), creation_tree: Some("tree-a".to_string()), diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index e4c5f5b54..e09c9355a 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -18,6 +18,7 @@ use crate::{Error, Result}; const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "artifacts", "audit_events", + "external_workdir_grants", "flow_source_revisions", "flow_sources", "memory_staging_records", diff --git a/crates/workspace-server/src/workspace_subscription.rs b/crates/workspace-server/src/workspace_subscription.rs index dac5df2a2..f6c5d158f 100644 --- a/crates/workspace-server/src/workspace_subscription.rs +++ b/crates/workspace-server/src/workspace_subscription.rs @@ -455,7 +455,7 @@ fn project_working_directory( attachments.push( protocol::subscription::SubscriptionWorkerWorkdirAttachment { alias: link.alias, - repository_id: Some(workdir.repository_id), + repository_id: workdir.source.repository_id().map(str::to_string), repository_key: None, working_directory_id, }, diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index c959c4e70..77877261c 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -10,12 +10,16 @@ e2e-test = ["tui/e2e-test"] [dependencies] project-record = { workspace = true } +reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } client = { workspace = true } memory = { workspace = true } +futures = { workspace = true } manifest = { workspace = true } worker = { workspace = true } server-api = { workspace = true } +workdir = { workspace = true } +tokio-tungstenite = { workspace = true } session-store = { workspace = true } session-analytics = { workspace = true } ticket = { workspace = true } @@ -23,7 +27,7 @@ tui = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_yaml = "0.9.34" -tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal"] } toml = { workspace = true } uuid = { workspace = true } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 9ac56b9ec..6cc675518 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -5,6 +5,7 @@ mod objective_cli; mod plugin_cli; mod session_cli; mod ticket_cli; +mod workdir_share; mod worker_cleanup_cli; mod workspace_bootstrap; @@ -53,6 +54,8 @@ enum Mode { no_wait: bool, }, Init(InitOptions), + WorkdirHelp, + WorkdirShare(workdir_share::WorkdirShareOptions), WorkerRuntime(Vec), Keys, SetupModel, @@ -133,6 +136,17 @@ async fn run(mode: Mode) -> ExitCode { ExitCode::FAILURE } }, + Mode::WorkdirHelp => { + print_workdir_help(); + ExitCode::SUCCESS + } + Mode::WorkdirShare(options) => match workdir_share::run(options).await { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("yoi workdir share: {error}"); + ExitCode::FAILURE + } + }, Mode::MemoryLint(options) => match memory_lint::run(&options) { Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Failed) => ExitCode::FAILURE, @@ -420,6 +434,9 @@ fn parse_args_slice_with_connection_resolver( "--help" | "-h" => return Ok(Mode::Help), "resume" => return parse_resume_args(&args[1..], &target_selection, connection_resolver), "workers" => return parse_workers_args(&args[1..], &target_selection, connection_resolver), + "workdir" => { + return parse_workdir_args(&args[1..], &target_selection); + } "worker" => { if let Some(cli) = worker_cleanup_cli::parse_worker_management_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))? @@ -611,6 +628,142 @@ fn parse_args_slice_with_connection_resolver( parse_console_options(args, &target_selection, connection_resolver) } +fn parse_workdir_args( + args: &[String], + target_selection: &TargetSelection, +) -> Result { + if target_selection.explicit_local { + return Err(ParseError( + "yoi workdir share requires a Backend target and cannot use --local".to_string(), + )); + } + let Some((subcommand, args)) = args.split_first() else { + return Ok(Mode::WorkdirHelp); + }; + if matches!(subcommand.as_str(), "--help" | "-h") { + return Ok(Mode::WorkdirHelp); + } + if subcommand != "share" { + return Err(ParseError(format!( + "unknown yoi workdir subcommand `{subcommand}`" + ))); + } + let mut path = None; + let mut workspace_id = target_selection.workspace_id.clone(); + let mut backend_url = target_selection.backend_url.clone(); + let mut display_name = None; + let mut ttl = None; + let mut read_only = false; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace-id" => { + if workspace_id.is_some() { + return Err(ParseError( + "--workspace-id may only be provided once".to_string(), + )); + } + workspace_id = + Some(required_option_value(args, index, "--workspace-id")?.to_string()); + index += 2; + } + "--backend" => { + if backend_url.is_some() { + return Err(ParseError( + "--backend may only be provided once".to_string(), + )); + } + backend_url = Some(required_option_value(args, index, "--backend")?.to_string()); + index += 2; + } + "--display-name" => { + if display_name.is_some() { + return Err(ParseError( + "--display-name may only be provided once".to_string(), + )); + } + display_name = + Some(required_option_value(args, index, "--display-name")?.to_string()); + index += 2; + } + "--ttl" => { + if ttl.is_some() { + return Err(ParseError("--ttl may only be provided once".to_string())); + } + ttl = Some(parse_share_ttl(required_option_value( + args, index, "--ttl", + )?)?); + index += 2; + } + "--read-only" => { + if read_only { + return Err(ParseError( + "--read-only may only be provided once".to_string(), + )); + } + read_only = true; + index += 1; + } + "--help" | "-h" => return Ok(Mode::WorkdirHelp), + option if option.starts_with('-') => { + return Err(ParseError(format!( + "unknown yoi workdir share option `{option}`" + ))); + } + value => { + if path.is_some() { + return Err(ParseError( + "yoi workdir share accepts exactly one directory path".to_string(), + )); + } + path = Some(PathBuf::from(value)); + index += 1; + } + } + } + if !read_only { + return Err(ParseError( + "yoi workdir share requires --read-only".to_string(), + )); + } + let workspace_id = workspace_id + .ok_or_else(|| ParseError("yoi workdir share requires --workspace-id".to_string()))?; + let backend_url = resolve_backend_url(backend_url, Some(&workspace_id))?; + Ok(Mode::WorkdirShare(workdir_share::WorkdirShareOptions { + path: path + .ok_or_else(|| ParseError("yoi workdir share requires a directory path".to_string()))?, + workspace_id, + backend_url, + display_name: display_name + .ok_or_else(|| ParseError("yoi workdir share requires --display-name".to_string()))?, + ttl: ttl.ok_or_else(|| ParseError("yoi workdir share requires --ttl".to_string()))?, + })) +} + +fn parse_share_ttl(value: &str) -> Result { + let (number, multiplier) = if let Some(value) = value.strip_suffix('h') { + (value, 60 * 60) + } else if let Some(value) = value.strip_suffix('m') { + (value, 60) + } else if let Some(value) = value.strip_suffix('s') { + (value, 1) + } else { + return Err(ParseError( + "--ttl must use an h, m, or s suffix (for example 1h)".to_string(), + )); + }; + let amount = number.parse::().map_err(|_| { + ParseError("--ttl must contain a positive integer and h, m, or s suffix".to_string()) + })?; + let seconds = amount + .checked_mul(multiplier) + .ok_or_else(|| ParseError("--ttl is too large".to_string()))?; + if !(60..=86_400).contains(&seconds) { + return Err(ParseError("--ttl must be between 60s and 24h".to_string())); + } + Ok(Duration::from_secs(seconds)) +} + fn parse_init_args( args: &[String], explicit_backend_url: Option, @@ -1734,6 +1887,7 @@ Usage: yoi --backend [--workspace-id ] panel yoi [--backend ] init --display-name --repository-key [--repository ] [--default-ref ] yoi [--backend ] login [--no-wait] + yoi [--backend ] workdir share --workspace-id --read-only --ttl --display-name yoi [OPTIONS] Target selection: @@ -1759,6 +1913,7 @@ Console options: Host commands: yoi init Register the current Git repository as a new Backend Workspace. + yoi workdir share Share a temporary read-only client-hosted External Workdir. keys Manage local model/API keys setup-model Configure a local model provider worker [WORKER_OPTIONS] Run the direct Worker process entrypoint @@ -1777,6 +1932,12 @@ Options: -h, --help Print help "#; +fn print_workdir_help() { + println!( + "yoi workdir share\n\nUsage:\n yoi [--backend ] workdir share --workspace-id --read-only --ttl --display-name \n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nNo host path is sent to the Backend. TTL accepts s, m, or h suffixes from 60s through 24h.\nCtrl-C explicitly revokes the grant before exit.\n" + ); +} + fn print_help() { println!("{TOP_LEVEL_HELP}"); } @@ -1834,6 +1995,81 @@ fn print_memory_lint_help() { mod tests { use super::*; + #[test] + fn workdir_share_parses_explicit_backend_workspace_and_bounded_ttl() { + let args = vec![ + "share".to_string(), + "./session-log".to_string(), + "--read-only".to_string(), + "--ttl".to_string(), + "90m".to_string(), + "--display-name".to_string(), + "session-analysis".to_string(), + ]; + let target = TargetSelection { + backend_url: Some("https://backend.example".to_string()), + workspace_id: Some("workspace-a".to_string()), + ..TargetSelection::default() + }; + let Mode::WorkdirShare(options) = parse_workdir_args(&args, &target).unwrap() else { + panic!("expected Workdir share mode"); + }; + assert_eq!(options.path, PathBuf::from("./session-log")); + assert_eq!(options.workspace_id, "workspace-a"); + assert_eq!(options.backend_url, "https://backend.example"); + assert_eq!(options.display_name, "session-analysis"); + assert_eq!(options.ttl, Duration::from_secs(5_400)); + } + + #[test] + fn workdir_share_fails_closed_without_explicit_read_only_workspace_or_valid_ttl() { + let base = vec![ + "share".to_string(), + ".".to_string(), + "--ttl".to_string(), + "1h".to_string(), + "--display-name".to_string(), + "logs".to_string(), + ]; + let target = TargetSelection { + backend_url: Some("https://backend.example".to_string()), + workspace_id: Some("workspace-a".to_string()), + ..TargetSelection::default() + }; + assert!( + parse_workdir_args(&base, &target) + .err() + .unwrap() + .0 + .contains("--read-only") + ); + + let mut invalid_ttl = base.clone(); + invalid_ttl.push("--read-only".to_string()); + invalid_ttl[3] = "25h".to_string(); + assert!( + parse_workdir_args(&invalid_ttl, &target) + .err() + .unwrap() + .0 + .contains("24h") + ); + + let missing_workspace = TargetSelection { + backend_url: Some("https://backend.example".to_string()), + ..TargetSelection::default() + }; + let mut valid = base; + valid.push("--read-only".to_string()); + assert!( + parse_workdir_args(&valid, &missing_workspace) + .err() + .unwrap() + .0 + .contains("--workspace-id") + ); + } + #[test] fn cli_parses_connection_before_starting_tokio_runtime() { let mut parsed = false; diff --git a/crates/yoi/src/workdir_share.rs b/crates/yoi/src/workdir_share.rs new file mode 100644 index 000000000..a6e3d5bec --- /dev/null +++ b/crates/yoi/src/workdir_share.rs @@ -0,0 +1,564 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use chrono::Utc; +use client::BackendApiClient; +use futures::{SinkExt, StreamExt}; +use server_api::{ExternalWorkdirGrantCreateRequest, ExternalWorkdirGrantResponse}; +use tokio_tungstenite::connect_async_with_config; +use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig}; +use workdir::external::{ + ExternalProviderInstanceId, ExternalWorkdirGrantId, ExternalWorkdirOperationId, + ExternalWorkdirOperationOutcome, ExternalWorkdirProviderFrame, ExternalWorkdirProviderMessage, + ExternalWorkdirProviderRegistration, ExternalWorkdirServerFrame, ExternalWorkdirServerMessage, +}; +use workdir::http::{WorkdirTransportError, dispatch_workdir_session_operation}; +use workdir::{ + BoundedReadLimits, LocalWorkdirSession, Workdir, WorkdirSession, WorkdirSessionCapabilities, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkdirShareOptions { + pub path: PathBuf, + pub workspace_id: String, + pub backend_url: String, + pub display_name: String, + pub ttl: Duration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProviderEnd { + Disconnected, + Revoked, + Interrupted, +} + +struct OperationCompletion { + operation_id: ExternalWorkdirOperationId, + outcome: ExternalWorkdirOperationOutcome, +} + +struct OperationTask { + cancelled: Arc, +} + +impl OperationTask { + fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } + + fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } +} + +impl Drop for OperationTask { + fn drop(&mut self) { + self.cancelled.store(true, Ordering::Release); + } +} + +pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { + let metadata = std::fs::symlink_metadata(&options.path) + .map_err(|error| format!("selected path is unavailable: {error}"))?; + if metadata.file_type().is_symlink() { + return Err("selected External Workdir root must not be a symbolic link".to_string()); + } + let canonical = options + .path + .canonicalize() + .map_err(|error| format!("failed to canonicalize selected directory: {error}"))?; + if !canonical.is_dir() { + return Err("selected External Workdir path must be a directory".to_string()); + } + + let client = BackendApiClient::from_stored_token(&options.backend_url) + .map_err(|error| error.to_string())?; + let provider_instance_id = format!("cli-{}", uuid::Uuid::now_v7()); + let create_path = format!( + "/api/w/{}/external-workdir-grants", + encode_path_segment(&options.workspace_id) + ); + let response = client + .request(reqwest::Method::POST, &create_path) + .map_err(|error| error.to_string())? + .timeout(Duration::from_secs(10)) + .json(&ExternalWorkdirGrantCreateRequest { + provider_instance_id: provider_instance_id.clone(), + display_name: options.display_name.clone(), + ttl_seconds: options.ttl.as_secs(), + read_only: true, + }) + .send() + .await + .map_err(|error| format!("External Workdir grant request failed: {error}"))?; + let response = client + .require_success(response) + .await + .map_err(|error| error.to_string())?; + let grant = response + .json::() + .await + .map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}"))?; + + let limits = BoundedReadLimits::EXTERNAL_DEFAULT; + let session = LocalWorkdirSession::external_read_only( + Workdir::new(&grant.working_directory_id), + &canonical, + limits, + ) + .map_err(|error| error.to_string())?; + + println!("External Workdir grant: {}", grant.grant_id); + println!("Logical Workdir: {}", grant.working_directory_id); + println!("Display name: {}", grant.display_name); + println!("Permission: {}", grant.permissions); + println!("Expires at: {}", grant.expires_at); + + let expires_at = chrono::DateTime::parse_from_rfc3339(&grant.expires_at) + .map_err(|_| "Backend returned an invalid External Workdir expiry".to_string())? + .with_timezone(&Utc); + let mut generation = grant.generation; + loop { + let outcome = serve_provider_connection( + &client, + &options, + &grant, + &provider_instance_id, + &session, + generation, + ) + .await; + let outcome = match outcome { + Ok(outcome) => outcome, + Err(connection_error) => { + if Utc::now() >= expires_at { + return Err(format!( + "External Workdir grant expired after provider connection failure: {connection_error}" + )); + } + let refresh = fetch_grant(&client, &options.workspace_id, &grant.grant_id); + let refreshed = tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; + revoke(&client, &options.workspace_id, &grant.grant_id).await?; + println!("External Workdir grant revoked."); + return Ok(()); + } + refreshed = refresh => refreshed, + }; + let current = match refreshed { + Ok(current) => current, + Err(refresh_error) => { + eprintln!( + "Connection refresh failed; retrying: {connection_error}; {refresh_error}" + ); + if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id) + .await? + { + println!("External Workdir grant revoked."); + return Ok(()); + } + continue; + } + }; + match current.status.as_str() { + "pending" => generation = current.generation, + "offline" => { + generation = current.generation.checked_add(1).ok_or_else(|| { + "External Workdir provider generation overflow".to_string() + })?; + } + "online" => { + // The Backend may still be observing the old socket close. + // Wait for its durable offline fence before reconnecting. + } + "revoked" | "expired" => { + println!("External Workdir grant is no longer active."); + return Ok(()); + } + status => { + return Err(format!( + "Backend returned unknown External Workdir grant status `{status}`" + )); + } + } + println!("Connection: reconnecting"); + if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? { + println!("External Workdir grant revoked."); + return Ok(()); + } + continue; + } + }; + match outcome { + ProviderEnd::Interrupted => { + println!("External Workdir grant revoked."); + return Ok(()); + } + ProviderEnd::Revoked => { + println!("External Workdir grant is no longer active."); + return Ok(()); + } + ProviderEnd::Disconnected => { + if Utc::now() >= expires_at { + return Err("External Workdir grant expired while disconnected".to_string()); + } + generation = generation + .checked_add(1) + .ok_or_else(|| "External Workdir provider generation overflow".to_string())?; + println!("Connection: reconnecting"); + if wait_to_reconnect(&client, &options.workspace_id, &grant.grant_id).await? { + println!("External Workdir grant revoked."); + return Ok(()); + } + } + } + } +} + +async fn serve_provider_connection( + client: &BackendApiClient, + options: &WorkdirShareOptions, + grant: &ExternalWorkdirGrantResponse, + provider_instance_id: &str, + session: &LocalWorkdirSession, + generation: u64, +) -> Result { + let provider_path = format!( + "/api/w/{}/external-workdir-grants/{}/provider", + encode_path_segment(&options.workspace_id), + encode_path_segment(&grant.grant_id) + ); + let request = client + .websocket_request(&provider_path) + .map_err(|error| error.to_string())?; + let websocket_config = WebSocketConfig::default() + .max_message_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES)) + .max_frame_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES)); + let connection = connect_async_with_config(request, Some(websocket_config), false); + let (mut socket, _) = tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; + revoke(client, &options.workspace_id, &grant.grant_id).await?; + return Ok(ProviderEnd::Interrupted); + } + connection = connection => connection + .map_err(|error| format!("External Workdir provider connection failed: {error}"))?, + }; + let registration = + ExternalWorkdirProviderFrame::current(ExternalWorkdirProviderMessage::Register { + registration: ExternalWorkdirProviderRegistration { + provider_instance_id: ExternalProviderInstanceId::new(provider_instance_id) + .map_err(|error| error.to_string())?, + grant_id: ExternalWorkdirGrantId::new(grant.grant_id.clone()) + .map_err(|error| error.to_string())?, + workdir_id: session.workdir().id().clone(), + generation, + capabilities: WorkdirSessionCapabilities::READ_ONLY, + read_limits: BoundedReadLimits::EXTERNAL_DEFAULT, + }, + }); + send_provider_frame(&mut socket, ®istration).await?; + let registered = tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; + revoke(client, &options.workspace_id, &grant.grant_id).await?; + return Ok(ProviderEnd::Interrupted); + } + registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => { + registered + .map_err(|_| "Backend timed out External Workdir provider registration".to_string())? + .ok_or_else(|| "Backend closed the External Workdir provider connection".to_string())? + .map_err(|error| format!("External Workdir provider registration failed: {error}"))? + } + }; + let Message::Text(registered) = registered else { + return Err("Backend returned an invalid External Workdir registration frame".to_string()); + }; + let frame = serde_json::from_str::(®istered) + .map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?; + if !matches!( + frame.message, + ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. } + if registered_generation == generation + ) { + return Err("Backend rejected the External Workdir provider registration".to_string()); + } + println!("Connection: online (generation {generation})"); + + let (completion_sender, mut completions) = + tokio::sync::mpsc::channel::(16); + let mut operations = HashMap::::new(); + loop { + tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; + abort_operations(&mut operations); + revoke(client, &options.workspace_id, &grant.grant_id).await?; + return Ok(ProviderEnd::Interrupted); + } + completion = completions.recv() => { + let Some(completion) = completion else { + abort_operations(&mut operations); + return Err("External Workdir operation executor closed".to_string()); + }; + let Some(operation) = operations.remove(completion.operation_id.as_str()) else { + continue; + }; + if operation.is_cancelled() { + continue; + } + let result = ExternalWorkdirProviderFrame::current( + ExternalWorkdirProviderMessage::OperationResult { + generation, + operation_id: completion.operation_id, + outcome: completion.outcome, + }, + ); + if send_provider_frame(&mut socket, &result).await.is_err() { + abort_operations(&mut operations); + return Ok(ProviderEnd::Disconnected); + } + } + incoming = socket.next() => { + let Some(incoming) = incoming else { + abort_operations(&mut operations); + return Ok(ProviderEnd::Disconnected); + }; + let incoming = match incoming { + Ok(incoming) => incoming, + Err(_) => { + abort_operations(&mut operations); + return Ok(ProviderEnd::Disconnected); + } + }; + match incoming { + Message::Text(text) => { + let frame = serde_json::from_str::(&text) + .map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?; + match frame.message { + ExternalWorkdirServerMessage::Operation { + generation: frame_generation, + operation_id, + operation, + } if frame_generation == generation => { + if operations.len() >= 16 || operations.contains_key(operation_id.as_str()) { + abort_operations(&mut operations); + return Err("Backend exceeded External Workdir operation bounds".to_string()); + } + let key = operation_id.as_str().to_string(); + let completion_sender = completion_sender.clone(); + let session = session.clone(); + let task_operation_id = operation_id.clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let operation = operation.into_inner(); + std::thread::Builder::new() + .name("external-workdir-operation".to_string()) + .spawn(move || { + let outcome = futures::executor::block_on(execute_operation(&session, operation)); + let _ = completion_sender.blocking_send(OperationCompletion { + operation_id: task_operation_id, + outcome, + }); + }) + .map_err(|error| format!("failed to start External Workdir operation: {error}"))?; + operations.insert(key, OperationTask { cancelled }); + } + ExternalWorkdirServerMessage::Heartbeat { + generation: frame_generation, + sequence, + } if frame_generation == generation => { + let heartbeat = ExternalWorkdirProviderFrame::current( + ExternalWorkdirProviderMessage::Heartbeat { generation, sequence }, + ); + send_provider_frame(&mut socket, &heartbeat).await?; + } + ExternalWorkdirServerMessage::Cancel { + generation: frame_generation, + operation_id, + } if frame_generation == generation => { + if let Some(operation) = operations.get(operation_id.as_str()) { + operation.cancel(); + let cancelled = ExternalWorkdirProviderFrame::current( + ExternalWorkdirProviderMessage::OperationResult { + generation, + operation_id, + outcome: ExternalWorkdirOperationOutcome::Cancelled, + }, + ); + send_provider_frame(&mut socket, &cancelled).await?; + } + } + ExternalWorkdirServerMessage::Revoke { + generation: frame_generation, + .. + } if frame_generation == generation => { + abort_operations(&mut operations); + let acknowledged = ExternalWorkdirProviderFrame::current( + ExternalWorkdirProviderMessage::RevokeAcknowledged { generation }, + ); + let _ = send_provider_frame(&mut socket, &acknowledged).await; + return Ok(ProviderEnd::Revoked); + } + _ => { + abort_operations(&mut operations); + return Err("stale or unexpected External Workdir provider frame".to_string()); + } + } + } + Message::Ping(bytes) => { + let pong = tokio::time::timeout( + Duration::from_secs(5), + socket.send(Message::Pong(bytes)), + ) + .await; + if !matches!(pong, Ok(Ok(()))) { + abort_operations(&mut operations); + return Ok(ProviderEnd::Disconnected); + } + } + Message::Close(_) => { + abort_operations(&mut operations); + return Ok(ProviderEnd::Disconnected); + } + _ => {} + } + } + } + } +} + +async fn execute_operation( + session: &LocalWorkdirSession, + operation: workdir::http::WorkdirSessionOperation, +) -> ExternalWorkdirOperationOutcome { + match dispatch_workdir_session_operation(session, operation).await { + Ok(result) => match result.try_into() { + Ok(result) => ExternalWorkdirOperationOutcome::Completed { result }, + Err(message) => ExternalWorkdirOperationOutcome::Failed { + error: WorkdirTransportError { + code: workdir::http::WorkdirTransportErrorCode::Unsupported, + message, + }, + }, + }, + Err(error) => ExternalWorkdirOperationOutcome::Failed { + error: WorkdirTransportError::from_workdir_error(&error), + }, + } +} + +fn abort_operations(operations: &mut HashMap) { + for (_, operation) in operations.drain() { + operation.cancel(); + } +} + +async fn send_provider_frame( + socket: &mut tokio_tungstenite::WebSocketStream, + frame: &ExternalWorkdirProviderFrame, +) -> Result<(), String> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let message = Message::Text( + serde_json::to_string(frame) + .map_err(|error| error.to_string())? + .into(), + ); + tokio::time::timeout(Duration::from_secs(5), socket.send(message)) + .await + .map_err(|_| "timed out sending External Workdir provider frame".to_string())? + .map_err(|error| format!("failed to send External Workdir provider frame: {error}")) +} + +async fn wait_to_reconnect( + client: &BackendApiClient, + workspace_id: &str, + grant_id: &str, +) -> Result { + tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; + revoke(client, workspace_id, grant_id).await?; + Ok(true) + } + _ = tokio::time::sleep(Duration::from_millis(250)) => Ok(false), + } +} + +async fn fetch_grant( + client: &BackendApiClient, + workspace_id: &str, + grant_id: &str, +) -> Result { + let path = format!( + "/api/w/{}/external-workdir-grants/{}", + encode_path_segment(workspace_id), + encode_path_segment(grant_id) + ); + let response = client + .request(reqwest::Method::GET, &path) + .map_err(|error| error.to_string())? + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|error| format!("External Workdir grant refresh failed: {error}"))?; + client + .require_success(response) + .await + .map_err(|error| error.to_string())? + .json::() + .await + .map_err(|error| format!("Backend returned an invalid External Workdir grant: {error}")) +} + +async fn revoke( + client: &BackendApiClient, + workspace_id: &str, + grant_id: &str, +) -> Result<(), String> { + let path = format!( + "/api/w/{}/external-workdir-grants/{}", + encode_path_segment(workspace_id), + encode_path_segment(grant_id) + ); + let response = client + .request(reqwest::Method::DELETE, &path) + .map_err(|error| error.to_string())? + .timeout(Duration::from_secs(10)) + .send() + .await + .map_err(|error| format!("External Workdir revoke request failed: {error}"))?; + client.require_success(response).await.map_err(|error| { + format!("External Workdir revoke was not confirmed by the Backend: {error}") + })?; + Ok(()) +} + +fn encode_path_segment(value: &str) -> String { + value + .bytes() + .map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (byte as char).to_string() + } + _ => format!("%{byte:02X}"), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_segments_are_encoded_without_exposing_structure() { + assert_eq!(encode_path_segment("workspace/a b"), "workspace%2Fa%20b"); + assert_eq!(encode_path_segment("safe-_.~"), "safe-_.~"); + } +} diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index b4701eca3..385624fec 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -5,7 +5,7 @@ export type DiagnosticSeverity = "info" | "warning" | "error"; export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; -export type WorkingDirectoryMaterializerKind = "runtime_git_clone"; +export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; @@ -13,11 +13,13 @@ export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, }; +export type WorkingDirectorySource = { "kind": "repository", repository_key: string, } | { "kind": "external_grant", grant_id: string, }; + export type WorkingDirectorySummary = { working_directory_id: string, /** * Optional human-facing label, never a routing key. */ -display_name?: string | null, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; +display_name?: string | null, source: WorkingDirectorySource, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, /** @@ -27,6 +29,6 @@ display_name?: string | null, repository_key: string, selector?: string | null, export type WorkingDirectoryListResponse = { workspace_id: string, items: Array, diagnostics: Array, }; -export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; +export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id?: string | null, item: WorkingDirectorySummary, diagnostics: Array, }; export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 022413353..ab24375d9 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -7,7 +7,7 @@ export type DiagnosticSeverity = "info" | "warning" | "error"; export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; -export type WorkingDirectoryMaterializerKind = "runtime_git_clone"; +export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hosted_external"; export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; @@ -23,11 +23,13 @@ display_name?: string | null, repository_id: string, creation_selector?: string export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, }; +export type WorkingDirectorySource = { "kind": "repository", repository_key: string, } | { "kind": "external_grant", grant_id: string, }; + export type WorkingDirectorySummary = { working_directory_id: string, /** * Optional human-facing label, never a routing key. */ -display_name?: string | null, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; +display_name?: string | null, source: WorkingDirectorySource, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; export type WorkerWorkspaceSummary = { visibility: string, identity: string, workspace_id?: string | null, }; diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts index 5cd6dbaa3..870c425fd 100644 --- a/web/workspace/src/lib/workspace/api/workdirs.ts +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -6,13 +6,14 @@ import type { WorkingDirectoryDetailResponse, WorkingDirectoryListResponse, WorkingDirectoryOccupancy, + WorkingDirectorySource, WorkingDirectorySummary, } from "../../generated/workdir-api"; const SUMMARY_KEYS = new Set([ "working_directory_id", "display_name", - "repository_key", + "source", "creation_selector", "creation_ref", "creation_tree", @@ -64,13 +65,30 @@ export function parseWorkingDirectoryListResponse( export function parseWorkingDirectoryDetailResponse( value: unknown, ): WorkingDirectoryDetailResponse { - return parseDetailLike(value, "Workdir detail response"); + const record = parseDetailRecord(value, "Workdir detail response"); + const response: WorkingDirectoryDetailResponse = { + workspace_id: stringField(record, "workspace_id"), + item: parseWorkingDirectorySummary(record.item), + diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic), + }; + if (record.runtime_id !== undefined) { + response.runtime_id = record.runtime_id === null + ? null + : stringField(record, "runtime_id"); + } + return response; } export function parseWorkingDirectoryCreateResponse( value: unknown, ): WorkingDirectoryCreateResponse { - return parseDetailLike(value, "Workdir create response"); + const record = parseDetailRecord(value, "Workdir create response"); + return { + workspace_id: stringField(record, "workspace_id"), + runtime_id: stringField(record, "runtime_id"), + item: parseWorkingDirectorySummary(record.item), + diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic), + }; } export function validateWorkingDirectoryCreateRequest( @@ -91,21 +109,15 @@ export function validateWorkingDirectoryCreateRequest( return request; } -function parseDetailLike( +function parseDetailRecord( value: unknown, label: string, -): WorkingDirectoryDetailResponse { - const record = exactRecord( +): Record { + return exactRecord( value, new Set(["workspace_id", "runtime_id", "item", "diagnostics"]), label, ); - return { - workspace_id: stringField(record, "workspace_id"), - runtime_id: stringField(record, "runtime_id"), - item: parseWorkingDirectorySummary(record.item), - diagnostics: arrayField(record, "diagnostics").map(parseDiagnostic), - }; } export function parseWorkingDirectorySummary( @@ -114,9 +126,10 @@ export function parseWorkingDirectorySummary( const record = exactRecord(value, SUMMARY_KEYS, "Workdir summary"); const summary: WorkingDirectorySummary = { working_directory_id: stringField(record, "working_directory_id"), - repository_key: stringField(record, "repository_key"), + source: parseWorkingDirectorySource(record.source), materializer_kind: enumField(record, "materializer_kind", [ "runtime_git_clone", + "client_hosted_external", ]), status: enumField(record, "status", [ "active", @@ -160,6 +173,36 @@ export function parseWorkingDirectorySummary( return summary; } +function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource { + const source = exactRecord( + value, + new Set(["kind", "repository_key", "grant_id"]), + "Workdir source", + ); + const kind = stringField(source, "kind"); + if (kind === "repository") { + if (source.grant_id !== undefined) { + throw new Error("Repository Workdir source must not contain grant_id"); + } + return { + kind, + repository_key: stringField(source, "repository_key"), + }; + } + if (kind === "external_grant") { + if (source.repository_key !== undefined) { + throw new Error( + "External Workdir source must not contain repository_key", + ); + } + return { + kind, + grant_id: stringField(source, "grant_id"), + }; + } + throw new Error("Workdir source.kind has an unsupported value"); +} + function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget { const record = exactRecord( value, diff --git a/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts b/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts index 4e2d084cc..d26299d57 100644 --- a/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts +++ b/web/workspace/src/lib/workspace/settings/workdir-revision.test.ts @@ -17,7 +17,7 @@ function workdir( ): WorkingDirectorySummary { return { working_directory_id: "workdir-1", - repository_key: "repository-1", + source: { kind: "repository", repository_key: "repository-1" }, current_selector, current_ref, materializer_kind: "runtime_git_clone", diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index b1abd3d67..9fea0695a 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -28,6 +28,33 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published defaults and initi assertEquals(form.working_directory_selector, "HEAD"); }); +Deno.test("defaultWorkerLaunchForm preserves an available read-only External Workdir", () => { + const external = { + working_directory_id: "external-1", + display_name: "Session analysis", + source: { kind: "external_grant" as const, grant_id: "grant-1" }, + materializer_kind: "client_hosted_external" as const, + status: "active" as const, + cleanliness: "unknown", + }; + const form = defaultWorkerLaunchForm( + { ...options, working_directories: [external] }, + emptyForm({ + workdir_attachments: [{ + alias: "sessions", + working_directory_id: "external-1", + relative_cwd: "", + }], + }), + ); + + assertEquals(form.workdir_attachments, [{ + alias: "sessions", + working_directory_id: "external-1", + relative_cwd: "", + }]); +}); + Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => { const reviewerOptions = { ...options, @@ -87,7 +114,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => { ...options.working_directories[0], working_directory_id: "ticket-workdir", - repository_key: "ticket-repo", + source: { kind: "repository", repository_key: "ticket-repo" }, creation_selector: "work/ticket", }, ], @@ -318,7 +345,7 @@ const options: WorkerLaunchOptionsResponse = { working_directories: [ { working_directory_id: "wd-1-repo", - repository_key: "repo", + source: { kind: "repository", repository_key: "repo" }, creation_selector: "HEAD", creation_ref: "0123456789abcdef", current_selector: null, diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index 834651337..23e6fac7a 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -40,7 +40,8 @@ export function defaultWorkerLaunchForm( const availableWorkingDirectories = options?.working_directories.filter((directory) => directory.status === "active" && - directory.cleanliness === "clean" && + (directory.source.kind === "external_grant" || + directory.cleanliness === "clean") && directory.occupied_by == null ) ?? []; const selectedRuntime = current.runtime_id @@ -54,13 +55,17 @@ export function defaultWorkerLaunchForm( ? undefined : availableWorkingDirectories.find((directory) => Boolean(current.working_directory_repository_key) && - directory.repository_key === current.working_directory_repository_key && + directory.source.kind === "repository" && + directory.source.repository_key === + current.working_directory_repository_key && (!current.working_directory_selector || (directory.current_selector ?? directory.creation_selector) === current.working_directory_selector) ) ?? availableWorkingDirectories.find((directory) => Boolean(current.working_directory_repository_key) && - directory.repository_key === current.working_directory_repository_key + directory.source.kind === "repository" && + directory.source.repository_key === + current.working_directory_repository_key ) ?? (current.working_directory_repository_key ? undefined : availableWorkingDirectories[0]); diff --git a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts index 263e1ef07..31e320ce2 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.test.ts @@ -25,7 +25,7 @@ function workdir( ): WorkingDirectorySummary { return { working_directory_id: "workdir-1234567890abcdef", - repository_key: "main", + source: { kind: "repository", repository_key: "main" }, materializer_kind: "runtime_git_clone", status: "active", ...overrides, @@ -39,7 +39,9 @@ function attachment( const workingDirectory = workdir(overrides); return { alias, - repository_key: workingDirectory.repository_key, + repository_key: workingDirectory.source.kind === "repository" + ? workingDirectory.source.repository_key + : null, working_directory_id: workingDirectory.working_directory_id, working_directory: workingDirectory, }; @@ -105,13 +107,30 @@ Deno.test("Worker sidebar Workdir label has an explicit unavailable-projection f assertEquals(meta.text, "main:unknown@workdir-1234"); }); +Deno.test("Worker sidebar Workdir label uses an External Workdir display name without fake repository metadata", () => { + const meta = sidebarWorkdirMeta([ + attachment("sessions", { + display_name: "Session analysis", + source: { kind: "external_grant", grant_id: "grant-1" }, + materializer_kind: "client_hosted_external", + cleanliness: "unknown", + }), + ]); + + assertEquals(meta.text, "Session analysis"); + assert( + meta.details.includes("sessions — Session analysis"), + "alias should remain visible", + ); +}); + Deno.test("Worker sidebar Workdir metadata preserves long names for truncated display details", () => { const repository = "repository-with-a-name-that-exceeds-the-sidebar-width"; const branch = "feature/workdir-label-with-a-name-that-also-exceeds-the-sidebar-width"; const meta = sidebarWorkdirMeta([ attachment("long-checkout", { - repository_key: repository, + source: { kind: "repository", repository_key: repository }, current_selector: branch, }), ]); @@ -128,7 +147,7 @@ Deno.test("Worker sidebar Workdir metadata keeps multiple attachments distinguis attachment("frontend", { current_selector: "develop" }), attachment("backend", { working_directory_id: "workdir-fedcba0987654321", - repository_key: "server", + source: { kind: "repository", repository_key: "server" }, current_selector: "release", }), ]); diff --git a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.ts b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.ts index 7451eb818..8a4ffa37c 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-workdir-meta.ts @@ -27,9 +27,13 @@ export function sidebarWorkdirMeta( function attachmentLabel(attachment: SidebarWorkdirAttachment): string { const workdir = attachment.working_directory; - const repository = clean(workdir?.repository_key) ?? - clean(attachment.repository_key) ?? "unknown-repo"; - return `${repository}:${revisionLabel(attachment)}`; + if (workdir?.source.kind === "external_grant") { + return clean(workdir.display_name) ?? "external"; + } + const repository = workdir?.source.kind === "repository" + ? clean(workdir.source.repository_key) + : clean(attachment.repository_key); + return `${repository ?? "unknown-repo"}:${revisionLabel(attachment)}`; } function revisionLabel(attachment: SidebarWorkdirAttachment): string { diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte index eb2e90b09..74b732fda 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/workdirs/+page.svelte @@ -24,9 +24,15 @@ workdirs = data.workdirs?.items ?? []; }); + function repositoryKey(workdir: WorkingDirectorySummary): string | null { + return workdir.source.kind === 'repository' ? workdir.source.repository_key : null; + } + function repositoryProvider(workdir: WorkingDirectorySummary): string | null { - return data.repositories?.items.find((repository) => repository.repository_key === workdir.repository_key) - ?.provider ?? null; + const key = repositoryKey(workdir); + return key + ? data.repositories?.items.find((repository) => repository.repository_key === key)?.provider ?? null + : null; } function currentRevision(workdir: WorkingDirectorySummary): string { @@ -142,7 +148,7 @@ {workdir.display_name ?? '—'} {workdir.working_directory_id} - {workdir.repository_key} + {repositoryKey(workdir) ?? 'External'} {currentRevision(workdir)} {workdir.status} {workdir.cleanliness ?? 'unknown'} diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte index eb6e1a684..0ab6e7fc0 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/+page.svelte @@ -148,9 +148,13 @@ const attachments = worker.workdir_attachments ?? []; if (attachments.length === 0) return '—'; return attachments.map(({ alias, working_directory: directory }) => { - const provider = data.repositories?.items.find((repository) => repository.repository_key === directory.repository_key) - ?.provider; - const label = directory.display_name ?? directory.repository_key; + const repositoryKey = directory.source.kind === 'repository' + ? directory.source.repository_key + : null; + const provider = repositoryKey + ? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider + : null; + const label = directory.display_name ?? repositoryKey ?? 'External Workdir'; return `${alias}: ${label} · ${formatCurrentWorkdirRevision(directory, provider)}`; }).join(', '); } diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index fba5c5c15..134894910 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -36,9 +36,13 @@ }; function workdirOptionLabel(directory: WorkingDirectorySummary): string { - const provider = data.repositories?.items.find((repository) => repository.repository_key === directory.repository_key) - ?.provider; - const label = directory.display_name ?? directory.repository_key; + const repositoryKey = directory.source.kind === 'repository' + ? directory.source.repository_key + : null; + const provider = repositoryKey + ? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider + : null; + const label = directory.display_name ?? repositoryKey ?? 'External Workdir'; return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`; } @@ -83,7 +87,7 @@ ? [] : (options?.working_directories ?? []).filter((directory) => directory.status === 'active' && - directory.cleanliness === 'clean' && + (directory.source.kind === 'external_grant' || directory.cleanliness === 'clean') && directory.occupied_by == null ), ); diff --git a/web/workspace/tests/workdir-api.test.ts b/web/workspace/tests/workdir-api.test.ts index e93ea2c3d..f4c125484 100644 --- a/web/workspace/tests/workdir-api.test.ts +++ b/web/workspace/tests/workdir-api.test.ts @@ -4,6 +4,7 @@ declare const Deno: { import { parseWorkingDirectoryCreateResponse, + parseWorkingDirectoryDetailResponse, parseWorkingDirectoryListResponse, validateWorkingDirectoryCreateRequest, } from "../src/lib/workspace/api/workdirs.ts"; @@ -11,7 +12,7 @@ import { const summary = { working_directory_id: "workdir-1", display_name: "Checkout", - repository_key: "main", + source: { kind: "repository", repository_key: "main" }, materializer_kind: "runtime_git_clone", status: "active", occupied_by: { @@ -46,6 +47,27 @@ Deno.test("Workdir REST validation accepts the generated list and create contrac } }); +Deno.test("Workdir REST validation accepts External grant sources without Runtime identity", () => { + const detail = parseWorkingDirectoryDetailResponse({ + workspace_id: "workspace-a", + item: { + working_directory_id: "external-1", + display_name: "Session analysis", + source: { kind: "external_grant", grant_id: "grant-1" }, + materializer_kind: "client_hosted_external", + status: "active", + cleanliness: "unknown", + }, + diagnostics: [], + }); + if (detail.runtime_id !== undefined) { + throw new Error("External Workdir acquired a fake Runtime identity"); + } + if (detail.item.source.kind !== "external_grant") { + throw new Error("External Workdir source was not preserved"); + } +}); + Deno.test("Workdir REST validation rejects stale response JSON", () => { let rejected = false; try {