From 149853bb0e6802392af71c6e007d68dce4a292e1 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 6 Oct 2026 03:37:07 +0900 Subject: [PATCH] feat: project attached checkouts with scoped native AI operations --- Cargo.lock | 1 + crates/fs-operation/src/checked.rs | 518 +++++++++ crates/fs-operation/src/glob.rs | 47 +- crates/fs-operation/src/lib.rs | 30 + crates/fs-operation/src/local.rs | 31 +- crates/fs-operation/src/operation.rs | 4 + crates/fs-operation/src/search.rs | 102 +- crates/fs-operation/src/traversal_tests.rs | 108 ++ crates/fs-operation/src/walk.rs | 226 ++++ crates/tools/README.md | 53 + crates/tools/src/checkout.rs | 252 ++++ crates/tools/src/checkout_tests.rs | 562 +++++++++ crates/tools/src/edit.rs | 112 +- crates/tools/src/error.rs | 19 +- crates/tools/src/file_target.rs | 186 +++ crates/tools/src/glob.rs | 89 +- crates/tools/src/grep.rs | 134 ++- crates/tools/src/lib.rs | 4 + crates/tools/src/read.rs | 175 +-- crates/tools/src/search_target.rs | 81 ++ crates/tools/src/tracker.rs | 19 + crates/tools/src/write.rs | 102 +- crates/workdir/CHECKOUT_PROVIDER.md | 143 +++ crates/workdir/src/checkout.rs | 1015 +++++++++++++++++ crates/workdir/src/checkout_search_tests.rs | 418 +++++++ crates/workdir/src/external.rs | 103 ++ crates/workdir/src/http.rs | 137 ++- crates/workdir/src/lib.rs | 36 + crates/workdir/src/local.rs | 535 ++++++++- crates/workdir/src/router.rs | 59 + crates/workdir/src/scope.rs | 256 +++++ crates/worker/Cargo.toml | 2 + crates/worker/src/checkout.rs | 764 +++++++++++++ crates/worker/src/checkout_http_tests.rs | 671 +++++++++++ crates/worker/src/checkout_race_tests.rs | 469 ++++++++ crates/worker/src/checkout_wip_tests.rs | 580 ++++++++++ crates/worker/src/controller.rs | 14 + .../src/feature/builtin/manage_workdir.rs | 146 +++ .../src/feature/builtin/manage_workdir/wip.rs | 31 +- crates/worker/src/lib.rs | 1 + crates/worker/src/wip.rs | 383 ++++++- crates/workspace-server/src/server.rs | 209 ++++ docs/design/checkout-wip-projection.md | 225 ++++ 43 files changed, 8706 insertions(+), 346 deletions(-) create mode 100644 crates/fs-operation/src/checked.rs create mode 100644 crates/fs-operation/src/traversal_tests.rs create mode 100644 crates/fs-operation/src/walk.rs create mode 100644 crates/tools/src/checkout.rs create mode 100644 crates/tools/src/checkout_tests.rs create mode 100644 crates/tools/src/file_target.rs create mode 100644 crates/tools/src/search_target.rs create mode 100644 crates/workdir/CHECKOUT_PROVIDER.md create mode 100644 crates/workdir/src/checkout.rs create mode 100644 crates/workdir/src/checkout_search_tests.rs create mode 100644 crates/worker/src/checkout.rs create mode 100644 crates/worker/src/checkout_http_tests.rs create mode 100644 crates/worker/src/checkout_race_tests.rs create mode 100644 crates/worker/src/checkout_wip_tests.rs create mode 100644 docs/design/checkout-wip-projection.md diff --git a/Cargo.lock b/Cargo.lock index e7470de00..43abfe0d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6416,6 +6416,7 @@ dependencies = [ "agen", "arc-swap", "async-trait", + "axum", "base64 0.22.1", "chrono", "clap", diff --git a/crates/fs-operation/src/checked.rs b/crates/fs-operation/src/checked.rs new file mode 100644 index 000000000..f27b251bb --- /dev/null +++ b/crates/fs-operation/src/checked.rs @@ -0,0 +1,518 @@ +//! Identity-fenced access adapter for the existing read/edit/write engine. +//! +//! Descriptor-rooted resolution is Linux-only and symlinks/mount crossings fail +//! closed. The final compare and atomic rename are NOT a kernel CAS against an +//! arbitrary external editor: external editors must cooperate with provider +//! serialization. A hostile rename in that last syscall gap is not excluded. +use crate::{AtomicWriteMode, FsAccessPolicy}; +use std::fs::{File, Metadata}; +use std::io::{Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Identity and state, deliberately not just a content digest. atime is omitted +/// so reading does not invalidate an observation. No host paths are encoded. +pub fn identity_validator(metadata: &Metadata) -> std::io::Result> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let mut bytes = b"yoi-checkout-v1".to_vec(); + for value in [ + metadata.dev(), + metadata.ino(), + metadata.len(), + metadata.mode() as u64, + metadata.mtime() as u64, + metadata.mtime_nsec() as u64, + metadata.ctime() as u64, + metadata.ctime_nsec() as u64, + ] { + bytes.extend_from_slice(&value.to_le_bytes()); + } + Ok(bytes) + } + #[cfg(not(unix))] + { + let _ = metadata; + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "identity validators unavailable", + )) + } +} + +/// One pinned target and its save parent. Retained through the shared operation. +pub struct CheckedTarget<'a> { + policy: &'a dyn FsAccessPolicy, + root: File, + relative: PathBuf, + logical: PathBuf, + file: File, + parent: Option, + validator: Vec, + effects_possible: AtomicBool, +} +impl<'a> CheckedTarget<'a> { + pub fn pin( + root: &File, + relative: &Path, + logical: &Path, + policy: &'a dyn FsAccessPolicy, + write: bool, + ) -> std::io::Result { + policy.check_cancelled()?; + if !(if write { + policy.is_writable_paths(logical, logical) + } else { + policy.is_readable_paths(logical, logical) + }) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "checkout target denied", + )); + } + let parent = if relative.as_os_str().is_empty() { + None + } else { + Some(crate::open_beneath_no_symlinks_at( + root, + relative.parent().unwrap_or(Path::new("")), + )?) + }; + let file = if let Some(parent) = &parent { + crate::open_beneath_no_symlinks_at(parent, Path::new(relative.file_name().unwrap()))? + } else { + crate::open_beneath_no_symlinks_at(root, Path::new(""))? + }; + let metadata = file.metadata()?; + if !metadata.is_file() && !metadata.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "checkout requires regular file or directory", + )); + } + let result = Self { + policy, + root: root.try_clone()?, + relative: relative.to_path_buf(), + logical: logical.to_path_buf(), + file, + parent, + validator: identity_validator(&metadata)?, + effects_possible: AtomicBool::new(false), + }; + result.confirm()?; + Ok(result) + } + pub fn validator(&self) -> &[u8] { + &self.validator + } + pub fn metadata(&self) -> std::io::Result { + self.file.metadata() + } + pub fn effects_possible(&self) -> bool { + self.effects_possible.load(Ordering::Acquire) + } + pub fn confirm(&self) -> std::io::Result<()> { + self.policy.check_cancelled()?; + if let Some(parent) = &self.parent { + let named_parent = crate::open_beneath_no_symlinks_at( + &self.root, + self.relative.parent().unwrap_or(Path::new("")), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let pinned_metadata = parent.metadata()?; + let named_metadata = named_parent.metadata()?; + if (pinned_metadata.dev(), pinned_metadata.ino()) + != (named_metadata.dev(), named_metadata.ino()) + { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "checkout parent identity changed", + )); + } + } + } + let named = crate::open_beneath_no_symlinks_at(&self.root, &self.relative)?; + if identity_validator(&named.metadata()?)? != self.validator + || identity_validator(&self.file.metadata()?)? != self.validator + { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "checkout observation changed", + )); + } + Ok(()) + } + /// Refresh a directory observation after Create, retaining the original + /// pinned target/root/parent handles. A replacement named directory cannot + /// supply the validator for effects performed in the original directory. + pub fn refresh_directory_observation(&mut self) -> std::io::Result<()> { + let metadata = self.file.metadata()?; + if !metadata.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Create target must be directory", + )); + } + self.validator = identity_validator(&metadata)?; + self.confirm() + } + + /// Adapter for no-clobber creation beneath a pinned directory. Both target + /// and destination authorization remain in the provider's normal policy. + pub fn create_access<'b>(&'b self, suffix: PathBuf, logical: PathBuf) -> CheckedCreate<'b, 'a> { + CheckedCreate { + target: self, + suffix, + logical, + } + } +} +impl FsAccessPolicy for CheckedTarget<'_> { + fn is_readable(&self, p: &Path) -> bool { + self.policy.is_readable(p) + } + fn is_writable(&self, p: &Path) -> bool { + self.policy.is_writable(p) + } + fn is_readable_paths(&self, p: &Path, r: &Path) -> bool { + p == self.logical && r == p && self.policy.is_readable_paths(p, r) + } + fn is_writable_paths(&self, p: &Path, r: &Path) -> bool { + p == self.logical && r == p && self.policy.is_writable_paths(p, r) + } + fn check_cancelled(&self) -> std::io::Result<()> { + self.policy.check_cancelled() + } + fn resolve_access_path(&self, p: &Path) -> std::io::Result { + Ok(p.to_path_buf()) + } + fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result { + self.file.metadata() + } + fn open_read_file(&self, _: &Path, _: &Path) -> std::io::Result { + self.confirm()?; + let mut file = self.file.try_clone()?; + file.seek(SeekFrom::Start(0))?; + Ok(file) + } + fn max_write_bytes(&self) -> Option { + Some( + self.policy + .max_write_bytes() + .unwrap_or(8 * 1024 * 1024) + .min(8 * 1024 * 1024), + ) + } + fn max_edit_replacements(&self) -> Option { + Some( + self.policy + .max_edit_replacements() + .unwrap_or(10_000) + .min(10_000), + ) + } + fn atomic_write_file( + &self, + _: &Path, + _: &Path, + content: &[u8], + mode: AtomicWriteMode, + ) -> std::io::Result<()> { + self.confirm()?; + let parent = self.parent.as_ref().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "cannot save root") + })?; + crate::atomic_write_beneath_no_symlinks_at( + parent, + Path::new(self.relative.file_name().unwrap()), + content, + mode, + || { + self.confirm()?; + // From here rename/encoding/post-observation failures have an unknown outcome. + self.effects_possible.store(true, Ordering::Release); + Ok(()) + }, + ) + } +} + +pub struct CheckedCreate<'b, 'a> { + target: &'b CheckedTarget<'a>, + suffix: PathBuf, + logical: PathBuf, +} +impl FsAccessPolicy for CheckedCreate<'_, '_> { + fn is_readable(&self, p: &Path) -> bool { + self.target.policy.is_readable(p) + } + fn is_writable(&self, p: &Path) -> bool { + p == self.logical && self.target.policy.is_writable_paths(p, p) + } + fn resolve_access_path(&self, p: &Path) -> std::io::Result { + Ok(p.to_path_buf()) + } + fn check_cancelled(&self) -> std::io::Result<()> { + self.target.policy.check_cancelled() + } + fn max_write_bytes(&self) -> Option { + self.target.max_write_bytes() + } + fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result { + match crate::open_beneath_no_symlinks_at(&self.target.file, &self.suffix) { + Ok(_) => Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "Create destination exists", + )), + Err(e) => Err(e), + } + } + fn atomic_write_file( + &self, + _: &Path, + _: &Path, + content: &[u8], + mode: AtomicWriteMode, + ) -> std::io::Result<()> { + self.target.confirm()?; + if mode != AtomicWriteMode::CreateNew || !self.target.file.metadata()?.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Create requires directory", + )); + } + // Creating parents must not turn a narrow destination grant into + // permission to populate ungranted intermediate directories. + let mut parent = self.target.logical.clone(); + let mut components = self.suffix.components().peekable(); + while let Some(component) = components.next() { + parent.push(component.as_os_str()); + if components.peek().is_some() + && !self.target.policy.is_writable_paths(&parent, &parent) + { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Create parent denied", + )); + } + } + // Nested mkdir may have effects before failure; never auto-retry. + self.target.effects_possible.store(true, Ordering::Release); + crate::atomic_write_beneath_no_symlinks_at( + &self.target.file, + &self.suffix, + content, + mode, + || self.target.policy.check_cancelled(), + ) + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use crate::{FsError, FsPath, ReadRequest, WriteRequest}; + use sha2::{Digest, Sha256}; + use std::sync::atomic::AtomicUsize; + struct Access { + path: PathBuf, + counter: AtomicUsize, + trigger: usize, + replacement: bool, + } + impl FsAccessPolicy for Access { + fn is_readable(&self, _: &Path) -> bool { + true + } + fn is_writable(&self, _: &Path) -> bool { + true + } + fn check_cancelled(&self) -> std::io::Result<()> { + if self.counter.fetch_add(1, Ordering::SeqCst) + 1 == self.trigger { + if self.replacement { + std::fs::rename(&self.path, self.path.with_extension("old"))?; + std::fs::write(&self.path, "replacement")?; + } else { + std::fs::write(&self.path, "updated")?; + } + } + Ok(()) + } + } + #[test] + fn checked_save_boundary_rejects_name_substitution_without_mutating_replacement() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("a"); + std::fs::write(&path, "original").unwrap(); + let policy = Access { + path: path.clone(), + counter: AtomicUsize::new(0), + trigger: 4, + replacement: true, + }; + let handle = crate::open_root_no_symlinks(root.path()).unwrap(); + let checked = CheckedTarget::pin(&handle, Path::new("a"), &path, &policy, true).unwrap(); + policy.counter.store(0, Ordering::SeqCst); + let error = crate::run_write( + root.path(), + WriteRequest { + path: FsPath::new("a").unwrap(), + content: b"worker".to_vec(), + expected_hash: Some(Sha256::digest(b"original").into()), + }, + &checked, + ) + .unwrap_err(); + assert!( + matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::WouldBlock) + ); + assert!(!checked.effects_possible()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement"); + assert_eq!( + std::fs::read_to_string(path.with_extension("old")).unwrap(), + "original" + ); + } + #[test] + fn checked_read_midread_name_replacement_never_pairs_old_bytes_with_new_validator() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("a"); + std::fs::write(&path, "original").unwrap(); + let policy = Access { + path: path.clone(), + counter: AtomicUsize::new(0), + trigger: 3, + replacement: true, + }; + let handle = crate::open_root_no_symlinks(root.path()).unwrap(); + let checked = CheckedTarget::pin(&handle, Path::new("a"), &path, &policy, false).unwrap(); + let validator = checked.validator().to_vec(); + policy.counter.store(0, Ordering::SeqCst); + let result = crate::run_read( + root.path(), + ReadRequest { + path: FsPath::new("a").unwrap(), + offset: 0, + limit: 20, + max_bytes: 1024, + }, + &checked, + ); + match result { + Err(FsError::Conflict(_)) => {} + Ok(read) => { + // Same pinned inode supplies bytes and hash. Execute's final + // named-target check must reject, not refresh to replacement. + assert_eq!(read.bytes, b"original"); + let expected_hash: crate::ContentHash = Sha256::digest(b"original").into(); + assert_eq!(read.content_hash, expected_hash); + assert_eq!( + checked.confirm().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + other => panic!("unexpected Read result: {other:?}"), + } + assert_eq!(checked.validator(), validator); + assert_ne!( + identity_validator(&std::fs::metadata(&path).unwrap()).unwrap(), + validator + ); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement"); + } + + #[test] + fn create_post_parent_observation_retains_bound_directory_and_rejects_substitution() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("d"); + std::fs::create_dir(&directory).unwrap(); + let policy = Access { + path: directory.clone(), + counter: AtomicUsize::new(0), + trigger: usize::MAX, + replacement: false, + }; + let handle = crate::open_root_no_symlinks(root.path()).unwrap(); + let mut checked = + CheckedTarget::pin(&handle, Path::new("d"), &directory, &policy, true).unwrap(); + let initial = checked.validator().to_vec(); + let create = checked.create_access(PathBuf::from("new"), directory.join("new")); + crate::run_write( + root.path(), + WriteRequest { + path: FsPath::new("d/new").unwrap(), + content: b"new".to_vec(), + expected_hash: None, + }, + &create, + ) + .unwrap(); + checked.refresh_directory_observation().unwrap(); + assert_ne!(initial, checked.validator()); + assert_eq!( + checked.validator(), + identity_validator(&std::fs::metadata(&directory).unwrap()).unwrap() + ); + std::fs::rename(&directory, root.path().join("old")).unwrap(); + std::fs::create_dir(&directory).unwrap(); + assert_eq!( + checked.refresh_directory_observation().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + assert!(checked.effects_possible()); + } + + #[test] + fn checked_parent_swap_is_rejected_even_with_a_hardlinked_same_inode_target() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("d"); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("a"); + std::fs::write(&path, "original").unwrap(); + let policy = Access { + path: path.clone(), + counter: AtomicUsize::new(0), + trigger: usize::MAX, + replacement: false, + }; + let handle = crate::open_root_no_symlinks(root.path()).unwrap(); + let checked = CheckedTarget::pin(&handle, Path::new("d/a"), &path, &policy, true).unwrap(); + std::fs::rename(&directory, root.path().join("old")).unwrap(); + std::fs::create_dir(&directory).unwrap(); + std::fs::hard_link(root.path().join("old/a"), &path).unwrap(); + assert_eq!( + checked.confirm().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + assert!(!checked.effects_possible()); + } + + #[test] + fn shared_text_read_rejects_mid_read_metadata_change() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("a"); + std::fs::write(&path, "original").unwrap(); + let policy = Access { + path, + counter: AtomicUsize::new(0), + trigger: 2, + replacement: false, + }; + let error = crate::run_read( + root.path(), + ReadRequest { + path: FsPath::new("a").unwrap(), + offset: 0, + limit: 20, + max_bytes: 1024, + }, + &policy, + ) + .unwrap_err(); + assert!(matches!(error, FsError::Conflict(_))); + } +} diff --git a/crates/fs-operation/src/glob.rs b/crates/fs-operation/src/glob.rs index 2ade239b6..91b561368 100644 --- a/crates/fs-operation/src/glob.rs +++ b/crates/fs-operation/src/glob.rs @@ -44,6 +44,10 @@ pub fn run_glob( .as_ref() .map(|traversal| traversal.path()) .unwrap_or(base); + let logical_walk_root = traversal + .as_ref() + .and_then(|t| t.logical_root()) + .unwrap_or(root); let mut walker = WalkBuilder::new(walker_root); walker.hidden(false).follow_links(false); if traversal.is_some() { @@ -56,22 +60,15 @@ pub fn run_glob( .git_exclude(false) .ignore(false) .parents(false); - let search_relative = base.strip_prefix(root).map_err(|_| { - FsError::InvalidArgument("glob base is outside its provider root".to_string()) - })?; - let search_relative = search_relative.to_path_buf(); - let filter_root = walker_root.to_path_buf(); - walker.filter_entry(move |entry| { - entry - .path() - .strip_prefix(&filter_root) - .is_ok_and(|relative| { - relative.starts_with(&search_relative) || search_relative.starts_with(relative) - }) - }); } let mut visited = 0_usize; - for entry in walker.build().flatten() { + let walk = if traversal.is_some() { + crate::walk::Walk::descriptor(logical_walk_root, access) + } else { + crate::walk::Walk::Plain(walker.build()) + }; + for entry in walk { + let entry = entry?; access.check_cancelled().map_err(|source| FsError::Io { path: PathBuf::from(request.path.as_str()), source, @@ -83,17 +80,10 @@ pub fn run_glob( crate::MAX_TRAVERSAL_ENTRIES ))); } - let walked_path = entry.path(); - let path = if traversal.is_some() { - let relative = walked_path.strip_prefix(walker_root).map_err(|_| { - FsError::InvalidArgument( - "descriptor traversal returned a path outside its root".to_string(), - ) - })?; - root.join(relative) - } else { - walked_path.to_path_buf() - }; + let path = entry.path; + if path.strip_prefix(root).unwrap_or(&path).to_str().is_none() { + continue; + } let resolved = match access.resolve_access_path(&path) { Ok(resolved) if access.is_readable_paths(&path, &resolved) => resolved, _ => continue, @@ -111,7 +101,12 @@ pub fn run_glob( let logical = path.strip_prefix(root).map_err(|_| { FsError::InvalidArgument("provider returned a path outside its root".to_string()) })?; - let result_path = FsPath::new(logical.to_string_lossy())?; + let Some(logical) = logical.to_str() else { + continue; + }; + let Ok(result_path) = FsPath::new(logical) else { + continue; + }; 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; diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs index 7fa897df8..41fda9d67 100644 --- a/crates/fs-operation/src/lib.rs +++ b/crates/fs-operation/src/lib.rs @@ -4,10 +4,15 @@ //! transport, or LLM Tool implementations. Paths are logical and root-relative; //! providers supply host roots and access policy. +mod checked; +pub use checked::{CheckedTarget, identity_validator}; mod glob; mod local; mod operation; mod search; +#[cfg(test)] +mod traversal_tests; +mod walk; use std::path::{Path, PathBuf}; @@ -272,6 +277,7 @@ pub fn atomic_write_beneath_no_symlinks_at( #[derive(Debug)] pub struct FsTraversalRoot { path: PathBuf, + logical_root: Option, _directory: std::fs::File, } @@ -279,20 +285,44 @@ impl FsTraversalRoot { pub fn new(path: PathBuf, directory: std::fs::File) -> Self { Self { path, + logical_root: None, _directory: directory, } } + /// Map walked paths to this logical anchor rather than the provider root. + pub fn new_at(path: PathBuf, directory: std::fs::File, logical_root: PathBuf) -> Self { + Self { + path, + logical_root: Some(logical_root), + _directory: directory, + } + } + pub fn logical_root(&self) -> Option<&Path> { + self.logical_root.as_deref() + } pub fn path(&self) -> &Path { &self.path } } +/// Owned predicate used before walker descent. Paths are provider-logical; +/// true directories require enumeration authority, other entries require read. +pub type FsTraversalFilter = std::sync::Arc bool + Send + Sync>; + /// 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; + fn can_enumerate_directory(&self, logical: &Path, resolved: &Path) -> bool { + self.is_readable_paths(logical, resolved) + } + /// Owned because the shared walker requires a 'static entry predicate. + fn traversal_filter(&self) -> Option { + None + } + /// Cooperatively stop provider-owned filesystem work. Implementations may /// use this for per-operation cancellation and deadlines. fn check_cancelled(&self) -> std::io::Result<()> { diff --git a/crates/fs-operation/src/local.rs b/crates/fs-operation/src/local.rs index 34f60b92d..e910e65b8 100644 --- a/crates/fs-operation/src/local.rs +++ b/crates/fs-operation/src/local.rs @@ -162,6 +162,19 @@ fn run_read_with_limits( } } + #[cfg(unix)] + if crate::identity_validator(&metadata).map_err(|e| map_io(&logical, e))? + != crate::identity_validator( + &reader + .get_ref() + .metadata() + .map_err(|e| map_io(&logical, e))?, + ) + .map_err(|e| map_io(&logical, e))? + { + return Err(FsError::Conflict(logical.as_str().to_string())); + } + 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 { @@ -473,6 +486,14 @@ pub fn run_list( if !metadata.is_dir() { return Err(FsError::NotDirectory(PathBuf::from(logical.as_str()))); } + if !access.can_enumerate_directory(&logical_base, &path) { + return Ok(ListResult { + entries: Vec::new(), + total_entries: 0, + total_bytes: 0, + truncated: false, + }); + } let mut entries = Vec::new(); let mut retained_path_bytes = 0_usize; let mut provider_truncated = false; @@ -490,6 +511,9 @@ pub fn run_list( ))); } let entry = entry.map_err(|error| map_io(&logical, error))?; + if entry.file_name().to_str().is_none() { + continue; + } let logical_absolute = logical_base.join(entry.file_name()); let resolved = match access.resolve_access_path(&logical_absolute) { Ok(resolved) => resolved, @@ -513,7 +537,12 @@ 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 Some(relative) = relative.to_str() else { + continue; + }; + let Ok(result_path) = FsPath::new(relative) else { + continue; + }; 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; diff --git a/crates/fs-operation/src/operation.rs b/crates/fs-operation/src/operation.rs index ddb309a8d..3d6e612f3 100644 --- a/crates/fs-operation/src/operation.rs +++ b/crates/fs-operation/src/operation.rs @@ -353,6 +353,10 @@ pub struct GrepRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct GrepResult { + /// Exact typed provider paths represented in this returned report, not all + /// candidate/matched source files. Older providers may omit this field. + #[serde(default)] + pub paths: Vec, /// Provider-rendered bounded grep report. Keeping rendering here avoids /// transferring candidate files across a remote provider boundary. pub output: String, diff --git a/crates/fs-operation/src/search.rs b/crates/fs-operation/src/search.rs index eb1129ea9..e40c13f63 100644 --- a/crates/fs-operation/src/search.rs +++ b/crates/fs-operation/src/search.rs @@ -79,6 +79,28 @@ fn reserve_report_bytes(report: &mut GrepReport, bytes: usize) -> bool { impl GrepReport { fn into_result(self, root: &Path) -> GrepResult { + // Build Object paths from retained provider identities, never by parsing + // rendered text. Offset, limit and truncation already selected this set. + let paths = match self.mode { + GrepOutputMode::FilesWithMatches => self + .files + .iter() + .filter_map(|path| logical_path(root, path)) + .collect(), + GrepOutputMode::Count => self + .counts + .iter() + .filter_map(|(path, _)| logical_path(root, path)) + .collect(), + GrepOutputMode::Content => self + .lines + .iter() + .map(|line| line.path.as_path()) + .collect::>() + .into_iter() + .filter_map(|path| logical_path(root, path)) + .collect(), + }; let (match_count, matched_files) = match self.mode { GrepOutputMode::FilesWithMatches => (self.files.len(), self.files.len()), GrepOutputMode::Count => ( @@ -116,6 +138,7 @@ impl GrepReport { } } GrepResult { + paths, output, match_count, matched_files, @@ -166,11 +189,15 @@ fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: b output } +fn logical_path(root: &Path, path: &Path) -> Option { + crate::FsPath::new_scoped(path.strip_prefix(root).unwrap_or(path).to_str()?).ok() +} + fn logical_display(root: &Path, path: &Path) -> String { - path.strip_prefix(root) - .unwrap_or(path) - .to_string_lossy() - .replace('\\', "/") + logical_path(root, path) + .expect("only representable provider paths enter a report") + .as_str() + .to_string() } const DEFAULT_HEAD_LIMIT: usize = 250; @@ -464,6 +491,9 @@ pub fn run_grep( retained_bytes: 0, truncated: false, }; + if head_limit == 0 { + return Ok(report.into_result(root)); + } let mut matching_files_seen = 0; let mut matches_seen = 0; @@ -474,6 +504,7 @@ pub fn run_grep( &mut searcher, &matcher, &base, + root, mode, &mut report, &mut matching_files_seen, @@ -494,6 +525,10 @@ pub fn run_grep( .as_ref() .map(|traversal| traversal.path()) .unwrap_or(&base); + let logical_walk_root = traversal + .as_ref() + .and_then(|t| t.logical_root()) + .unwrap_or(root); let mut walker = WalkBuilder::new(walker_root); if traversal.is_some() { walker @@ -506,19 +541,6 @@ pub fn run_grep( .ignore(false) .parents(false) .follow_links(false); - let search_relative = base.strip_prefix(root).map_err(|_| { - FsError::InvalidArgument("grep base is outside its provider root".to_string()) - })?; - let search_relative = search_relative.to_path_buf(); - let filter_root = walker_root.to_path_buf(); - walker.filter_entry(move |entry| { - entry - .path() - .strip_prefix(&filter_root) - .is_ok_and(|relative| { - relative.starts_with(&search_relative) || search_relative.starts_with(relative) - }) - }); } else { walker .hidden(true) @@ -563,7 +585,13 @@ pub fn run_grep( )?; } } - for entry in walker.build().flatten() { + let walk = if traversal.is_some() { + crate::walk::Walk::descriptor(logical_walk_root, access) + } else { + crate::walk::Walk::Plain(walker.build()) + }; + for entry in walk { + let entry = entry?; access .check_cancelled() .map_err(|error| FsError::io(&base, error))?; @@ -574,18 +602,11 @@ pub fn run_grep( crate::MAX_TRAVERSAL_ENTRIES ))); } - let walked_path = entry.path(); - let path = if traversal.is_some() { - let relative = walked_path.strip_prefix(walker_root).map_err(|_| { - FsError::InvalidArgument( - "descriptor traversal returned a path outside its root".to_string(), - ) - })?; - root.join(relative) - } else { - walked_path.to_path_buf() - }; - let file_type = entry.file_type(); + let path = entry.path; + if logical_path(root, &path).is_none() { + continue; + } + let file_type = entry.kind; if traversal.is_some() && file_type.as_ref().is_some_and(|kind| kind.is_dir()) { descriptor_ignores.load_directory(&path, &base, &mut source_bytes_remaining, access)?; continue; @@ -599,10 +620,12 @@ pub fn run_grep( "descriptor traversal returned a path outside its search base".to_string(), ) })?; - if relative - .components() - .any(|component| component.as_os_str().to_string_lossy().starts_with('.')) - || descriptor_ignores.is_ignored(root, &path) + if relative.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|name| name.starts_with('.')) + }) || descriptor_ignores.is_ignored(root, &path) || !direct_file_selected(&path, overrides.as_ref(), types.as_ref()) { continue; @@ -618,6 +641,7 @@ pub fn run_grep( &mut searcher, &matcher, &path, + root, mode, &mut report, &mut matching_files_seen, @@ -639,6 +663,7 @@ fn scan_path( searcher: &mut Searcher, matcher: &grep_regex::RegexMatcher, path: &Path, + root: &Path, mode: GrepOutputMode, report: &mut GrepReport, matching_files_seen: &mut usize, @@ -648,6 +673,9 @@ fn scan_path( source_bytes_remaining: &mut u64, access: &dyn FsAccessPolicy, ) -> Result { + if logical_path(root, path).is_none() { + return Ok(false); + } let resolved = access .resolve_access_path(path) .map_err(|error| FsError::io(path, error))?; @@ -678,7 +706,7 @@ fn scan_path( return Ok(false); } if *matching_files_seen >= offset { - if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(1)) { + if !reserve_report_bytes(report, path.as_os_str().len().saturating_add(1)) { return Ok(true); } report.files.push(path.to_path_buf()); @@ -695,7 +723,7 @@ fn scan_path( return Ok(false); } if *matching_files_seen >= offset { - if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(32)) { + if !reserve_report_bytes(report, path.as_os_str().len().saturating_add(32)) { return Ok(true); } report.counts.push((path.to_path_buf(), count)); @@ -780,7 +808,7 @@ impl ContentSink<'_> { fn reserve(&mut self, content_bytes: usize) -> bool { let retained = self .path - .to_string_lossy() + .as_os_str() .len() .saturating_add(content_bytes) .saturating_add(64); diff --git a/crates/fs-operation/src/traversal_tests.rs b/crates/fs-operation/src/traversal_tests.rs new file mode 100644 index 000000000..41cc8cddf --- /dev/null +++ b/crates/fs-operation/src/traversal_tests.rs @@ -0,0 +1,108 @@ +#![cfg(target_os = "linux")] +use crate::*; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +struct Filtered { + root: PathBuf, + seen: Arc>>, +} +impl FsAccessPolicy for Filtered { + fn is_readable(&self, path: &Path) -> bool { + path.starts_with(&self.root) && !path.starts_with(self.root.join("search/denied")) + } + fn is_writable(&self, _: &Path) -> bool { + false + } + fn open_traversal_root( + &self, + _: &Path, + resolved: &Path, + ) -> std::io::Result> { + use std::os::fd::AsRawFd; + let file = std::fs::File::open(resolved)?; + let walk = PathBuf::from(format!("/proc/self/fd/{}", file.as_raw_fd())); + Ok(Some(FsTraversalRoot::new_at( + walk, + file, + resolved.to_path_buf(), + ))) + } + fn traversal_filter(&self) -> Option { + let denied = self.root.join("search/denied"); + let seen = self.seen.clone(); + Some(Arc::new(move |path, _| { + seen.lock().unwrap().push(path.to_path_buf()); + !path.starts_with(&denied) + })) + } + fn open_read_file(&self, _: &Path, resolved: &Path) -> std::io::Result { + assert!( + !resolved.starts_with(self.root.join("search/denied")), + "denied source opened" + ); + std::fs::File::open(resolved) + } +} +#[test] +fn descriptor_search_anchor_and_predicate_prune_before_denied_directory_descent() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("search/denied/deep")).unwrap(); + std::fs::write(root.path().join("search/allowed.txt"), "needle allowed").unwrap(); + std::fs::write( + root.path().join("search/denied/deep/secret.txt"), + "needle secret", + ) + .unwrap(); + std::fs::write(root.path().join("outside.txt"), "needle outside").unwrap(); + let policy = Filtered { + root: root.path().to_path_buf(), + seen: Arc::new(Mutex::new(Vec::new())), + }; + let glob = run_glob( + root.path(), + &root.path().join("search"), + GlobRequest { + path: FsPath::new("search").unwrap(), + pattern: "**/*.txt".into(), + limit: 100, + }, + &policy, + ) + .unwrap(); + assert_eq!(glob.paths, vec![FsPath::new("search/allowed.txt").unwrap()]); + let grep = run_grep( + root.path(), + root.path().join("search"), + GrepRequest { + path: FsPath::new("search").unwrap(), + pattern: "needle".into(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: GrepOutputMode::Content, + offset: 0, + limit: 100, + }, + &policy, + ) + .unwrap(); + assert_eq!(grep.paths, vec![FsPath::new("search/allowed.txt").unwrap()]); + let seen = policy.seen.lock().unwrap(); + assert!( + seen.iter() + .any(|path| path == &root.path().join("search/denied")) + ); + assert!( + !seen + .iter() + .any(|path| path.starts_with(root.path().join("search/denied/deep"))) + ); + assert!( + !seen + .iter() + .any(|path| path == &root.path().join("outside.txt")) + ); +} diff --git a/crates/fs-operation/src/walk.rs b/crates/fs-operation/src/walk.rs new file mode 100644 index 000000000..3130327b6 --- /dev/null +++ b/crates/fs-operation/src/walk.rs @@ -0,0 +1,226 @@ +//! Bounded descriptor-provider walk shared by Glob and Grep. Unlike a +//! WalkBuilder predicate, this iterator counts even rejected entries and checks +//! cancellation while scanning them. Denied directories are never enumerated. +use crate::{FsAccessPolicy, FsError, FsTraversalFilter}; +use std::path::{Path, PathBuf}; + +pub(crate) struct Entry { + pub path: PathBuf, + pub kind: Option, +} +pub(crate) enum Walk<'a> { + Plain(ignore::Walk), + Descriptor(DescriptorWalk<'a>), +} +impl<'a> Walk<'a> { + pub fn descriptor(root: &Path, access: &'a dyn FsAccessPolicy) -> Self { + Self::Descriptor(DescriptorWalk { + access, + filter: access.traversal_filter(), + pending: Some((root.to_path_buf(), 0)), + root: Some(root.to_path_buf()), + stack: Vec::new(), + visited: 0, + failed: false, + }) + } +} +impl Iterator for Walk<'_> { + type Item = Result; + fn next(&mut self) -> Option { + match self { + Self::Plain(walk) => loop { + match walk.next()? { + Ok(entry) => { + return Some(Ok(Entry { + path: entry.path().to_path_buf(), + kind: entry.file_type(), + })); + } + Err(_) => continue, + } + }, + Self::Descriptor(walk) => walk.next(), + } + } +} +pub(crate) struct DescriptorWalk<'a> { + access: &'a dyn FsAccessPolicy, + filter: Option, + root: Option, + pending: Option<(PathBuf, usize)>, + stack: Vec<(PathBuf, usize, std::fs::ReadDir)>, + visited: usize, + failed: bool, +} +impl DescriptorWalk<'_> { + fn next_entry(&mut self) -> Result, FsError> { + if let Some(root) = self.root.take() { + self.access + .check_cancelled() + .map_err(|e| FsError::io(&root, e))?; + let resolved = self + .access + .resolve_access_path(&root) + .map_err(|e| FsError::io(&root, e))?; + let metadata = self + .access + .read_metadata(&root, &resolved) + .map_err(|e| FsError::io(&root, e))?; + return Ok(Some(Entry { + path: root, + kind: Some(metadata.file_type()), + })); + } + loop { + if let Some((path, depth)) = self.pending.take() { + self.access + .check_cancelled() + .map_err(|e| FsError::io(&path, e))?; + let selected = self + .filter + .as_ref() + .is_none_or(|filter| filter(&path, true)); + let resolved = self + .access + .resolve_access_path(&path) + .map_err(|e| FsError::io(&path, e))?; + if selected && self.access.can_enumerate_directory(&path, &resolved) { + if depth >= 128 { + return Err(FsError::InvalidArgument( + "descriptor traversal depth exceeds provider limit 128".into(), + )); + } + let entries = self + .access + .open_read_dir(&path, &resolved) + .map_err(|e| FsError::io(&path, e))?; + self.stack.push((path, depth, entries)); + } + } + let Some((parent, depth, entries)) = self.stack.last_mut() else { + return Ok(None); + }; + self.access + .check_cancelled() + .map_err(|e| FsError::io(&*parent, e))?; + let Some(entry) = entries.next() else { + self.stack.pop(); + continue; + }; + self.visited = self.visited.saturating_add(1); + if self.visited > crate::MAX_TRAVERSAL_ENTRIES { + return Err(FsError::InvalidArgument( + "descriptor traversal exceeds provider entry limit".into(), + )); + } + let entry = entry.map_err(|e| FsError::io(&*parent, e))?; + if entry.file_name().to_str().is_none() { + continue; + } + let path = parent.join(entry.file_name()); + let kind = entry.file_type().map_err(|e| FsError::io(&path, e))?; + let selected = self + .filter + .as_ref() + .is_none_or(|filter| filter(&path, kind.is_dir())); + if !selected { + continue; + } + let resolved = match self.access.resolve_access_path(&path) { + Ok(path) => path, + Err(_) => continue, + }; + if !self.access.is_readable_paths(&path, &resolved) { + continue; + } + if kind.is_dir() { + self.pending = Some((path.clone(), depth.saturating_add(1))); + } + return Ok(Some(Entry { + path, + kind: Some(kind), + })); + } + } +} +impl Iterator for DescriptorWalk<'_> { + type Item = Result; + fn next(&mut self) -> Option { + if self.failed { + return None; + } + match self.next_entry() { + Ok(entry) => entry.map(Ok), + Err(error) => { + self.failed = true; + Some(Err(error)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + struct RejectFiles { + calls: AtomicUsize, + cancel_at: usize, + } + impl FsAccessPolicy for RejectFiles { + fn is_readable(&self, _: &Path) -> bool { + true + } + fn is_writable(&self, _: &Path) -> bool { + false + } + fn traversal_filter(&self) -> Option { + Some(Arc::new(|_, directory| directory)) + } + fn check_cancelled(&self) -> std::io::Result<()> { + if self.calls.fetch_add(1, Ordering::Relaxed) >= self.cancel_at { + Err(std::io::Error::from(std::io::ErrorKind::Interrupted)) + } else { + Ok(()) + } + } + } + #[test] + fn descriptor_walk_counts_rejected_entries_toward_the_entry_budget() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("rejected"), "must not be opened").unwrap(); + let policy = RejectFiles { + calls: AtomicUsize::new(0), + cancel_at: usize::MAX, + }; + let Walk::Descriptor(mut walk) = Walk::descriptor(root.path(), &policy) else { + panic!() + }; + assert!(walk.next().unwrap().is_ok()); + walk.visited = crate::MAX_TRAVERSAL_ENTRIES; + assert!(matches!( + walk.next().unwrap(), + Err(FsError::InvalidArgument(_)) + )); + } + #[test] + fn descriptor_walk_checks_cancellation_while_scanning_only_rejected_entries() { + let root = tempfile::tempdir().unwrap(); + for index in 0..20 { + std::fs::write(root.path().join(index.to_string()), "must not be opened").unwrap(); + } + let policy = RejectFiles { + calls: AtomicUsize::new(0), + cancel_at: 4, + }; + let mut walk = Walk::descriptor(root.path(), &policy); + assert!(walk.next().unwrap().is_ok()); + assert!( + matches!(walk.next().unwrap(), Err(FsError::Io { source, .. }) if source.kind() == std::io::ErrorKind::Interrupted) + ); + } +} diff --git a/crates/tools/README.md b/crates/tools/README.md index 2503bec6b..d8f72b3fb 100644 --- a/crates/tools/README.md +++ b/crates/tools/README.md @@ -24,6 +24,59 @@ Does not own: A tool implementation must assume model input is untrusted. Permission policy, scope checks, output bounding, and redaction are part of the safety boundary, not optional UI behavior. +## Checkout-native processing + +`execute_checkout_tool` (also exported through `tools::checkout`) accepts the +original `WorkdirSessionRouter`, shared `Tracker`, route-bound alias/generation, +logical target and provider validator. It does not create an attachment or router. +The Host owns native publication and permission identity; provider scope and +capabilities remain authoritative. `Create` uses the `Write` capability/identity. + +| Operation | Arguments (only these keys accepted) | Target | +| --- | --- | --- | +| Read | `offset?`, `limit?` | Existing file; same numbered lines, defaults and byte bound as Tool Read | +| Edit | `old_string`, `new_string`, `replace_all?` | Existing file; prior read/hash and provider validator required | +| Write | `content` | Existing file only; prior read/hash and provider validator required | +| Create | `path`, `content` | Bound parent directory; relative destination strictly below it; create-new, missing parents allowed | +| Glob | `pattern`, `path?` | Bound directory; optional path relative beneath it | +| Grep | `pattern`, `path?`, `glob?`, `type?`, `case_insensitive?`, `-B?`, `-A?`, `-C?`, `multiline?`, `output_mode?`, `head_limit?`, `offset?` | Bound directory; same provider search/options/rendering as Tool Grep | + +Native arguments cannot override `file_path`, `target_workdir`, generation or +validator. Absolute/parent-traversing directory-relative paths are rejected. +Checkout file calls share Tool processing through an optional checked `FileTarget` +and use `checkout_execute`, never a separate Worker-side filesystem engine. +Read records the provider's full content hash **and total line count**, including +partial reads, in the original alias/generation namespace. Normal and native +observations/mutations interoperate. Only successful mutations update tracking. + +Glob/Grep share normal request defaults/options and rendering through `SearchTarget`. +Only native dispatch uses `CheckoutSearchRequest::new` and `checkout_search`. +The constructor's empty scope layers/root output are refined by provider wrappers; +Tools does not synthesize authority or parse/rebase rendered filenames. Ordinary +Tool dispatch remains `session.glob`/`session.grep`, with its existing error mapping. + +`CheckoutToolOutput.paths` contains typed checkout-root-relative search paths +(from `GlobResult.paths` / `GrepResult.paths`) and the created path for Create. +Rendered Grep text is never parsed to construct links. The optional validator is +the provider's returned observation validator, unchanged. Create's result +observation describes the bound parent target, while returned paths and tracker +updates describe the destination. Search checks the bound observation before and +after the bounded checkout provider search, but does **not** guarantee an atomic tree snapshot +or prevent external edits to descendants. File validator/hash checks are inside +the provider's checked execution boundary. Checked native provider conflicts and +native generation/search fences use `ToolError::StructuredConflict` with code +`checkout_stale`; ordinary Tool conflicts retain `ExecutionFailed`. Checked-only +known provider refusals use `checkout_denied` (scope/capability/read-only policy), +`checkout_invalid` (invalid arguments/paths/target kind/search syntax), or +`checkout_unavailable` (closed session/unavailable capacity). A disappeared bound +target is `checkout_stale`. These codes preserve known no-effect rejection for the +Host; ordinary Tool error mappings are unchanged. Ambiguous transport/IO and +operation failures remain `ExecutionFailed`, as does explicit provider +`OutcomeUnknown`; a failure after mutation must not +be automatically retried. A malformed post-mutation provider result is likewise +reported explicitly as outcome unknown. Explicit mismatched Read/Glob/Grep results +are readonly failures classified as `checkout_unavailable`, not stale or unknown. + ## See also - [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md) diff --git a/crates/tools/src/checkout.rs b/crates/tools/src/checkout.rs new file mode 100644 index 000000000..1bfcc254e --- /dev/null +++ b/crates/tools/src/checkout.rs @@ -0,0 +1,252 @@ +//! Native checkout entrypoint using the same processing as ordinary filesystem Tools. +//! +//! The Host binds alias, generation, path and validator from the checkout route; none +//! can be overridden by Operation arguments. File reads/mutations dispatch through +//! the provider's checked execution boundary. Search shares normal request and +//! rendering processing but dispatches through checkout_search so provider scope +//! layers and output rebasing are preserved; checks bracket the bound directory +//! observation, not an atomic snapshot of the searched tree. +use std::sync::Arc; + +use agen::tool::{ToolError, ToolExecutionContext, ToolOutput}; +use serde::de::DeserializeOwned; +use serde_json::Value; +use workdir::{WorkdirPath, WorkdirSessionCapability, WorkdirSessionRouter}; + +use crate::{ToolsError, Tracker, file_target::FileTarget}; + +pub struct CheckoutToolOutput { + pub output: ToolOutput, + pub paths: Vec, + pub validator: Option>, +} + +/// Execute an exact named native Operation on a route-bound checkout target. +/// `Create.path` and search `path` are relative to the bound directory; returned +/// paths are typed checkout-root-relative provider paths, never parsed output. +#[allow(clippy::too_many_arguments)] +pub async fn execute_checkout_tool( + router: Arc, + tracker: Tracker, + alias: &str, + generation: u64, + path: WorkdirPath, + validator: Vec, + tool_name: &str, + arguments: Value, + ctx: ToolExecutionContext, +) -> Result { + let (capability, allowed): (_, &[&str]) = match tool_name { + "Read" => (WorkdirSessionCapability::Read, &["offset", "limit"]), + "Edit" => ( + WorkdirSessionCapability::Edit, + &["old_string", "new_string", "replace_all"], + ), + "Write" => (WorkdirSessionCapability::Write, &["content"]), + "Create" => (WorkdirSessionCapability::Write, &["path", "content"]), + "Glob" => (WorkdirSessionCapability::Glob, &["pattern", "path"]), + "Grep" => ( + WorkdirSessionCapability::Grep, + &[ + "pattern", + "path", + "glob", + "type", + "case_insensitive", + "-B", + "-A", + "-C", + "multiline", + "output_mode", + "head_limit", + "offset", + ], + ), + _ => { + return Err(ToolError::InvalidArgument(format!( + "unknown checkout Operation: {tool_name}" + ))); + } + }; + let mut arguments = arguments + .as_object() + .cloned() + .ok_or_else(|| ToolError::InvalidArgument("checkout arguments must be an object".into()))?; + if let Some(key) = arguments + .keys() + .find(|key| !allowed.contains(&key.as_str())) + { + return Err(ToolError::InvalidArgument(format!( + "unknown or route override checkout argument: {key}" + ))); + } + // Do not rebuild a singleton router: this is the original router identity and + // tracker namespace shared with normal Tool calls and its detach/reuse fence. + let selected = crate::routing::resolve_session(&router, Some(alias), capability)?; + if selected.generation != generation { + return Err(stale_checkout( + "checkout attachment generation changed; resolve the checkout again", + )); + } + // Checkout paths are always logical and cannot use Tool-mode absolute scope. + let path = WorkdirPath::new(path.as_str()).map_err(ToolsError::from)?; + let tracker = tracker.scoped_attachment(&selected.alias, selected.generation); + let target = FileTarget { + session: selected.session.clone(), + path: path.clone(), + validator: Some(validator.clone()), + }; + match tool_name { + "Read" | "Edit" | "Write" => { + arguments.insert("file_path".into(), Value::String(path.to_string())); + let arguments = Value::Object(arguments); + match tool_name { + "Read" => { + crate::read::execute_read(target, tracker, decode(arguments, tool_name)?).await + } + "Edit" => { + crate::edit::execute_edit(target, tracker, decode(arguments, tool_name)?, ctx) + .await + } + _ => { + let params: crate::write::WriteParams = decode(arguments, tool_name)?; + crate::write::execute_write(target, tracker, params.content, None, ctx).await + } + } + } + "Create" => { + #[derive(serde::Deserialize)] + struct CreateParams { + path: String, + content: String, + } + let params: CreateParams = decode(Value::Object(arguments), tool_name)?; + let destination = relative_target(&path, ¶ms.path, true)?; + crate::write::execute_write(target, tracker, params.content, Some(destination), ctx) + .await + } + "Glob" | "Grep" => { + let before = selected + .session + .checkout_observe(path.clone()) + .await + .map_err(crate::file_target::checked_error)?; + if before.validator != validator { + return Err(stale_search()); + } + let mut result = if tool_name == "Glob" { + let params: crate::glob::GlobParams = decode(Value::Object(arguments), tool_name)?; + check_pattern(¶ms.pattern)?; + let search_path = + relative_target(&path, params.path.as_deref().unwrap_or("."), false)?; + crate::glob::execute_glob( + crate::search_target::SearchTarget::checkout(selected.session.clone()), + search_path, + params.pattern, + ) + .await? + } else { + let params: crate::grep::GrepParams = decode(Value::Object(arguments), tool_name)?; + if let Some(glob) = ¶ms.glob { + check_pattern(glob)?; + } + let search_path = + relative_target(&path, params.path.as_deref().unwrap_or("."), false)?; + crate::grep::execute_grep( + crate::search_target::SearchTarget::checkout(selected.session.clone()), + search_path, + params, + ) + .await? + }; + // Reject rather than emit a link/rendered path outside the binding. + // Provider scope is still authoritative for both traversal and content. + for result_path in &result.paths { + let logical = WorkdirPath::new(result_path.as_str()).map_err(ToolsError::from)?; + if !is_beneath(&path, &logical) { + return Err(crate::file_target::readonly_result_error( + "search paths escaped bound directory", + )); + } + } + let after = selected + .session + .checkout_observe(path) + .await + .map_err(crate::file_target::checked_error)?; + if after.validator != before.validator { + return Err(stale_search()); + } + result.validator = Some(after.validator); + Ok(result) + } + _ => unreachable!("Operation names checked above"), + } +} + +fn decode(arguments: Value, name: &str) -> Result { + serde_json::from_value(arguments) + .map_err(|error| ToolError::InvalidArgument(format!("invalid {name} input: {error}"))) +} + +fn stale_search() -> ToolError { + stale_checkout("checkout directory changed; resolve it again before searching") +} + +pub(crate) fn stale_checkout(message: impl Into) -> ToolError { + ToolError::StructuredConflict { + code: "checkout_stale".into(), + message: message.into(), + } +} + +fn is_beneath(base: &WorkdirPath, path: &WorkdirPath) -> bool { + base == &WorkdirPath::root() + || std::path::Path::new(path.as_str()).starts_with(std::path::Path::new(base.as_str())) +} + +fn relative_target( + base: &WorkdirPath, + relative: &str, + strict: bool, +) -> Result { + if relative.is_empty() + || relative.starts_with('/') + || relative.contains('\\') + || relative.split('/').any(|part| part == "..") + { + return Err(ToolError::InvalidArgument( + "path must be relative beneath the bound checkout directory, without parent traversal" + .into(), + )); + } + let relative = WorkdirPath::new(relative).map_err(ToolsError::from)?; + let joined = if relative == WorkdirPath::root() { + base.clone() + } else if base == &WorkdirPath::root() { + relative + } else { + WorkdirPath::new(&format!("{}/{}", base.as_str(), relative.as_str())) + .map_err(ToolsError::from)? + }; + if !is_beneath(base, &joined) || (strict && joined == *base) { + return Err(ToolError::InvalidArgument( + "destination must be strictly beneath the bound checkout directory".into(), + )); + } + Ok(joined) +} + +fn check_pattern(pattern: &str) -> Result<(), ToolError> { + // Patterns filter descendants, not an alternate filesystem starting point. + if pattern.starts_with('/') || pattern.split('/').any(|part| part == "..") { + return Err(ToolError::InvalidArgument( + "search glob must stay beneath the bound checkout directory".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +#[path = "checkout_tests.rs"] +mod tests; diff --git a/crates/tools/src/checkout_tests.rs b/crates/tools/src/checkout_tests.rs new file mode 100644 index 000000000..d11203ed3 --- /dev/null +++ b/crates/tools/src/checkout_tests.rs @@ -0,0 +1,562 @@ +use super::*; +use manifest::Scope; +use serde_json::json; +use tempfile::TempDir; +use workdir::{LocalWorkdirSession, WorkdirAttachmentAlias}; + +struct Fixture { + dir: TempDir, + router: Arc, + tracker: Tracker, +} + +impl Fixture { + fn new() -> Self { + let dir = TempDir::new().unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + let fixture = Self { + dir, + router, + tracker: Tracker::new(), + }; + fixture.attach("main"); + fixture + } + + fn attach(&self, alias: &str) { + self.router + .attach( + WorkdirAttachmentAlias::new(alias).unwrap(), + Arc::new(LocalWorkdirSession::new( + Scope::writable(self.dir.path()).unwrap(), + self.dir.path().to_path_buf(), + )), + ) + .unwrap(); + } + + async fn observation(&self, alias: &str, path: &str) -> (u64, Vec) { + let selected = self.router.resolve(Some(alias)).unwrap(); + let observation = selected + .session + .checkout_observe(WorkdirPath::new(path).unwrap()) + .await + .unwrap(); + (selected.generation, observation.validator) + } + + async fn native( + &self, + alias: &str, + path: &str, + name: &str, + arguments: Value, + ) -> Result { + let (generation, validator) = self.observation(alias, path).await; + execute_checkout_tool( + self.router.clone(), + self.tracker.clone(), + alias, + generation, + WorkdirPath::new(path).unwrap(), + validator, + name, + arguments, + Default::default(), + ) + .await + } + + async fn normal(&self, name: &str, arguments: Value) -> Result { + let definitions = crate::routed_builtin_tools( + self.router.clone(), + self.tracker.clone(), + Default::default(), + ); + for definition in definitions { + let (meta, tool) = definition(); + if meta.name == name { + return tool + .execute(&arguments.to_string(), Default::default()) + .await; + } + } + panic!("missing Tool {name}") + } +} + +fn assert_checkout_stale(error: ToolError) { + assert_checkout_code(error, "checkout_stale"); +} + +fn assert_checkout_code(error: ToolError, expected: &str) { + match error { + ToolError::StructuredConflict { code, .. } => assert_eq!(code, expected), + other => panic!("expected {expected} structured conflict, got {other:?}"), + } +} + +#[test] +fn checkout_known_refusals_have_typed_codes_without_changing_normal_tools() { + use workdir::{WorkdirError, WorkdirSessionCapability}; + let cases = [ + (WorkdirError::Denied("denied".into()), "checkout_denied"), + ( + WorkdirError::OutOfScope("outside".into()), + "checkout_denied", + ), + ( + WorkdirError::ReadOnly("read only".into()), + "checkout_denied", + ), + ( + WorkdirError::Unsupported(WorkdirSessionCapability::Write), + "checkout_denied", + ), + ( + WorkdirError::InvalidArgument("bad argument".into()), + "checkout_invalid", + ), + ( + WorkdirError::InvalidPath("bad path".into()), + "checkout_invalid", + ), + ( + WorkdirError::Unavailable("budget exhausted".into()), + "checkout_unavailable", + ), + (WorkdirError::NotFound("missing".into()), "checkout_stale"), + (WorkdirError::SessionClosed, "checkout_unavailable"), + ( + WorkdirError::UnsupportedOperation("unsupported".into()), + "checkout_denied", + ), + ( + WorkdirError::UnknownCommand("missing command".into()), + "checkout_invalid", + ), + ( + WorkdirError::RelativePath("relative".into()), + "checkout_invalid", + ), + ( + WorkdirError::IsDirectory("directory".into()), + "checkout_invalid", + ), + ( + WorkdirError::InvalidGlob("bad glob".into()), + "checkout_invalid", + ), + ( + WorkdirError::InvalidRegex("bad regex".into()), + "checkout_invalid", + ), + ]; + for (error, code) in cases { + assert_checkout_code(crate::file_target::checked_error(error), code); + } + assert!(matches!( + ToolError::from(ToolsError::from(WorkdirError::Unavailable( + "unavailable".into() + ))), + ToolError::ExecutionFailed(_) + )); + assert!(matches!( + ToolError::from(ToolsError::from(WorkdirError::Unsupported( + WorkdirSessionCapability::Write + ))), + ToolError::InvalidArgument(_) + )); +} + +#[test] +fn checkout_conflict_classification_preserves_normal_tools_and_unknown_outcomes() { + assert_checkout_stale(crate::file_target::checked_error( + workdir::WorkdirError::Conflict("a.txt".into()), + )); + assert_checkout_stale(stale_search()); + assert!(matches!( + ToolError::from(ToolsError::from(workdir::WorkdirError::Conflict( + "a.txt".into() + ))), + ToolError::ExecutionFailed(_) + )); + assert!(matches!( + crate::file_target::checked_error(workdir::WorkdirError::OutcomeUnknown( + "may have committed".into() + )), + ToolError::ExecutionFailed(_) + )); +} + +#[test] +fn checkout_ambiguous_transport_and_operation_failures_remain_unknown() { + use workdir::WorkdirError; + // Even refusal-looking text in an ambiguous transport error is not proof + // that the provider rejected the request before any effects. + for error in [ + WorkdirError::Transport("denied (ambiguous response)".into()), + WorkdirError::OperationFailed, + ] { + assert!(matches!( + crate::file_target::checked_error(error), + ToolError::ExecutionFailed(_) + )); + } +} + +#[test] +fn checkout_mismatched_readonly_results_are_unavailable_not_stale_or_unknown() { + use workdir::{CheckoutSearchResult, GlobResult, GrepResult}; + let glob = CheckoutSearchResult::Glob(GlobResult { + paths: Vec::new(), + truncated: false, + }); + let grep = CheckoutSearchResult::Grep(GrepResult { + output: String::new(), + match_count: 0, + matched_files: 0, + truncated: false, + paths: Vec::new(), + }); + assert_checkout_code( + crate::search_target::grep_result(glob).err().unwrap(), + "checkout_unavailable", + ); + assert_checkout_code( + crate::search_target::glob_result(grep).err().unwrap(), + "checkout_unavailable", + ); + assert_checkout_code( + crate::file_target::readonly_result_error("Read"), + "checkout_unavailable", + ); +} + +#[test] +fn checkout_relative_path_boundaries() { + let base = WorkdirPath::new("src").unwrap(); + assert_eq!( + relative_target(&base, "nested/a.txt", true) + .unwrap() + .as_str(), + "src/nested/a.txt" + ); + assert_eq!(relative_target(&base, ".", false).unwrap(), base); + assert_eq!( + relative_target(&WorkdirPath::root(), "a.txt", true) + .unwrap() + .as_str(), + "a.txt" + ); + for bad in [ + "", + "..", + "../outside", + "nested/../../outside", + "/absolute", + "a\\..\\outside", + ] { + assert!( + relative_target(&base, bad, false).is_err(), + "accepted {bad}" + ); + } + assert!(relative_target(&base, ".", true).is_err()); + assert!(!is_beneath( + &base, + &WorkdirPath::new("src-other/a.txt").unwrap() + )); + assert!(check_pattern("../*").is_err()); + assert!(check_pattern("/tmp/*").is_err()); + assert!(check_pattern("**/*.rs").is_ok()); +} + +#[tokio::test] +async fn checkout_read_and_normal_edit_share_hash_and_line_count() { + let fixture = Fixture::new(); + std::fs::write(fixture.dir.path().join("a.txt"), "alpha\nbeta\ngamma\n").unwrap(); + let read = fixture + .native("main", "a.txt", "Read", json!({"offset": 1, "limit": 1})) + .await + .unwrap(); + assert!(read.output.summary.contains("[2..2] of 3")); + assert_eq!(read.output.content.as_deref(), Some(" 2\tbeta\n")); + assert!(read.validator.is_some()); + let selected = fixture.router.resolve(Some("main")).unwrap(); + let tracker = fixture + .tracker + .scoped_attachment(&selected.alias, selected.generation); + assert_eq!( + tracker.observed_workdir_line_count(&WorkdirPath::new("a.txt").unwrap()), + Some(3) + ); + fixture.normal("Edit", json!({"target_workdir": "main", "file_path": "a.txt", "old_string": "beta", "new_string": "changed"})).await.unwrap(); + let write = fixture + .native("main", "a.txt", "Write", json!({"content": "new\n"})) + .await + .unwrap(); + assert!(write.output.summary.contains("Overwrote")); + assert_eq!(fixture.tracker.change_stat().deleted, 4); + let reread = fixture + .native("main", "a.txt", "Read", json!({})) + .await + .unwrap(); + assert_eq!(reread.output.content.as_deref(), Some(" 1\tnew\n")); +} + +#[tokio::test] +async fn checkout_normal_read_then_native_edit_uniqueness_and_replace_all() { + let fixture = Fixture::new(); + std::fs::write(fixture.dir.path().join("a.txt"), "x x x\n").unwrap(); + let before_read = fixture + .native( + "main", + "a.txt", + "Edit", + json!({"old_string": "x", "new_string": "y"}), + ) + .await + .err() + .unwrap(); + assert!(before_read.to_string().contains("has not been read")); + fixture + .normal( + "Read", + json!({"target_workdir": "main", "file_path": "a.txt"}), + ) + .await + .unwrap(); + let not_unique = fixture + .native( + "main", + "a.txt", + "Edit", + json!({"old_string": "x", "new_string": "y"}), + ) + .await + .err() + .unwrap(); + assert_checkout_code(not_unique, "checkout_invalid"); + assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default()); + let edit = fixture + .native( + "main", + "a.txt", + "Edit", + json!({"old_string": "x", "new_string": "y", "replace_all": true}), + ) + .await + .unwrap(); + assert!(edit.output.summary.contains("3 replacements")); + assert_eq!( + std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(), + "y y y\n" + ); + fixture.normal("Write", json!({"target_workdir": "main", "file_path": "a.txt", "content": "normal after native"})).await.unwrap(); +} + +#[tokio::test] +async fn checkout_alias_and_generation_fences() { + let fixture = Fixture::new(); + fixture.attach("other"); + std::fs::write(fixture.dir.path().join("a.txt"), "same\n").unwrap(); + fixture + .native("main", "a.txt", "Read", json!({})) + .await + .unwrap(); + let wrong_alias = fixture + .native("other", "a.txt", "Write", json!({"content": "not allowed"})) + .await + .err() + .unwrap(); + assert!(wrong_alias.to_string().contains("has not been read")); + let (old_generation, old_validator) = fixture.observation("main", "a.txt").await; + fixture + .router + .detach(&WorkdirAttachmentAlias::new("main").unwrap()) + .await + .unwrap(); + fixture.attach("main"); + let error = execute_checkout_tool( + fixture.router.clone(), + fixture.tracker.clone(), + "main", + old_generation, + WorkdirPath::new("a.txt").unwrap(), + old_validator, + "Read", + json!({}), + Default::default(), + ) + .await + .err() + .unwrap(); + assert!(error.to_string().contains("generation changed")); + assert_checkout_stale(error); + let fresh_generation_no_read = fixture + .native("main", "a.txt", "Write", json!({"content": "not allowed"})) + .await + .err() + .unwrap(); + assert!( + fresh_generation_no_read + .to_string() + .contains("has not been read") + ); +} + +#[tokio::test] +async fn checkout_stale_validator_and_hash_are_independent() { + let fixture = Fixture::new(); + std::fs::write(fixture.dir.path().join("a.txt"), "old\n").unwrap(); + fixture + .native("main", "a.txt", "Read", json!({})) + .await + .unwrap(); + let (generation, validator) = fixture.observation("main", "a.txt").await; + std::fs::write(fixture.dir.path().join("a.txt"), "externally changed\n").unwrap(); + let stale = execute_checkout_tool( + fixture.router.clone(), + fixture.tracker.clone(), + "main", + generation, + WorkdirPath::new("a.txt").unwrap(), + validator, + "Write", + json!({"content": "bad"}), + Default::default(), + ) + .await + .err() + .unwrap(); + assert_checkout_stale(stale); + let hash = fixture + .native("main", "a.txt", "Write", json!({"content": "bad"})) + .await + .err() + .unwrap(); + assert_checkout_stale(hash); + assert_eq!( + std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(), + "externally changed\n" + ); + assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default()); +} + +#[tokio::test] +async fn checkout_create_new_missing_parents_never_overwrites() { + let fixture = Fixture::new(); + let created = fixture + .native( + "main", + ".", + "Create", + json!({"path": "nested/deep/a.txt", "content": "new\n"}), + ) + .await + .unwrap(); + assert!(created.output.summary.contains("Created")); + assert_eq!( + created.paths, + vec![WorkdirPath::new("nested/deep/a.txt").unwrap()] + ); + let duplicate = fixture + .native( + "main", + ".", + "Create", + json!({"path": "nested/deep/a.txt", "content": "overwrite"}), + ) + .await; + assert!(duplicate.is_err()); + assert_eq!( + std::fs::read_to_string(fixture.dir.path().join("nested/deep/a.txt")).unwrap(), + "new\n" + ); + // Successful creation updates the same tracker as normal Tool Write. + fixture.normal("Write", json!({"target_workdir": "main", "file_path": "nested/deep/a.txt", "content": "changed"})).await.unwrap(); +} + +#[tokio::test] +async fn checkout_rejects_argument_overrides_and_escapes() { + let fixture = Fixture::new(); + std::fs::write(fixture.dir.path().join("a.txt"), "safe").unwrap(); + for (name, arguments) in [ + ("Read", json!({"file_path": "other"})), + ("Read", json!({"target_workdir": "other"})), + ("Write", json!({"content": "bad", "validator": []})), + ("Create", json!({"path": "../outside", "content": "bad"})), + ("Glob", json!({"pattern": "*", "path": "/absolute"})), + ("Grep", json!({"pattern": "safe", "path": "../outside"})), + ("Other", json!({})), + ] { + let path = if ["Create", "Glob", "Grep"].contains(&name) { + "." + } else { + "a.txt" + }; + let error = fixture + .native("main", path, name, arguments) + .await + .err() + .unwrap(); + assert!( + matches!(error, ToolError::InvalidArgument(_)), + "{name}: {error}" + ); + } +} + +#[tokio::test] +async fn checkout_search_typed_paths_and_normal_rendering() { + let fixture = Fixture::new(); + std::fs::create_dir_all(fixture.dir.path().join("src/nested")).unwrap(); + std::fs::write( + fixture.dir.path().join("src/nested/a.txt"), + "context\nhit\nafter\n", + ) + .unwrap(); + std::fs::write(fixture.dir.path().join("outside.txt"), "hit\n").unwrap(); + let glob = fixture + .native( + "main", + "src", + "Glob", + json!({"pattern": "**/*.txt", "path": "nested"}), + ) + .await + .unwrap(); + assert_eq!( + glob.paths, + vec![WorkdirPath::new("src/nested/a.txt").unwrap()] + ); + let normal = fixture + .normal( + "Glob", + json!({"target_workdir": "main", "pattern": "**/*.txt", "path": "src/nested"}), + ) + .await + .unwrap(); + assert_eq!(glob.output.summary, normal.summary); + assert_eq!(glob.output.content, normal.content); + for mode in ["files_with_matches", "content", "count"] { + let grep = fixture + .native( + "main", + "src", + "Grep", + json!({"pattern": "hit", "output_mode": mode, "-C": 1}), + ) + .await + .unwrap(); + assert_eq!( + grep.paths, + vec![WorkdirPath::new("src/nested/a.txt").unwrap()] + ); + let normal = fixture.normal("Grep", json!({"target_workdir": "main", "path": "src", "pattern": "hit", "output_mode": mode, "-C": 1})).await.unwrap(); + assert_eq!(grep.output.summary, normal.summary); + assert_eq!(grep.output.content, normal.content); + assert!(!grep.output.content.unwrap().contains("outside.txt")); + } +} diff --git a/crates/tools/src/edit.rs b/crates/tools/src/edit.rs index 4a22cb218..698b139b0 100644 --- a/crates/tools/src/edit.rs +++ b/crates/tools/src/edit.rs @@ -9,7 +9,7 @@ use serde::Deserialize; use crate::error::ToolsError; use crate::tracker::Tracker; -use workdir::{EditRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; +use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; const DESCRIPTION: &str = "Replace a substring in an existing file in the selected Workdir attachment. By default \ `old_string` must be unique in the file; set `replace_all: true` to replace \ @@ -56,56 +56,74 @@ impl Tool for EditTool { .tracker .scoped_attachment(&selected.alias, selected.generation); let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?; - tracing::debug!(path = %path, replace_all = params.replace_all, "Edit"); + Ok(execute_edit( + crate::file_target::FileTarget { + session: selected.session, + path, + validator: None, + }, + tracker, + params, + ctx, + ) + .await? + .output) + } +} - if params.old_string.is_empty() { - return Err(ToolError::InvalidArgument( - "old_string must not be empty".into(), - )); - } - if params.old_string == params.new_string { - return Err(ToolError::InvalidArgument( - "old_string and new_string are identical".into(), - )); - } - - let mutation_key = PathBuf::from(path.as_str()); - let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await; - let expected_hash = tracker.expected_workdir_hash(&path)?; - let result = selected - .session - .edit(EditRequest { - path: path.clone(), - old_string: params.old_string.clone(), - new_string: params.new_string.clone(), - replace_all: params.replace_all, - expected_hash, - }) - .await - .map_err(ToolsError::from)?; - let replacements = result.replacements; - tracker.record_workdir_edit( - &path, - result.content_hash, - replacements, - params.new_string.lines().count(), - params.old_string.lines().count(), - ); - - let summary = format!( - "Edited {} ({} replacement{})", - path, - replacements, - if replacements == 1 { "" } else { "s" } - ); - let preview = make_preview(¶ms.new_string, ¶ms.new_string); - - Ok(ToolOutput { +pub(crate) async fn execute_edit( + target: crate::file_target::FileTarget, + tracker: Tracker, + params: EditParams, + ctx: agen::tool::ToolExecutionContext, +) -> Result { + let path = &target.path; + tracing::debug!(path = %path, replace_all = params.replace_all, "Edit"); + if params.old_string.is_empty() { + return Err(ToolError::InvalidArgument( + "old_string must not be empty".into(), + )); + } + if params.old_string == params.new_string { + return Err(ToolError::InvalidArgument( + "old_string and new_string are identical".into(), + )); + } + let mutation_key = PathBuf::from(path.as_str()); + let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await; + let expected_hash = tracker.expected_workdir_hash(path)?; + let (result, validator) = target + .edit( + params.old_string.clone(), + params.new_string.clone(), + params.replace_all, + expected_hash, + ) + .await?; + let replacements = result.replacements; + tracker.record_workdir_edit( + path, + result.content_hash, + replacements, + params.new_string.lines().count(), + params.old_string.lines().count(), + ); + let summary = format!( + "Edited {} ({} replacement{})", + path, + replacements, + if replacements == 1 { "" } else { "s" } + ); + let preview = make_preview(¶ms.new_string, ¶ms.new_string); + Ok(crate::checkout::CheckoutToolOutput { + output: ToolOutput { summary, content: Some(preview), attachments: Vec::new(), - }) - } + }, + paths: Vec::new(), + validator, + }) } /// Build a small line-numbered snippet centered on the first occurrence of diff --git a/crates/tools/src/error.rs b/crates/tools/src/error.rs index 12b1103cb..dff6d1e08 100644 --- a/crates/tools/src/error.rs +++ b/crates/tools/src/error.rs @@ -44,7 +44,8 @@ impl From for ToolError { | workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::OperationFailed | workdir::WorkdirError::Transport(_) - | workdir::WorkdirError::Conflict(_), + | workdir::WorkdirError::Conflict(_) + | workdir::WorkdirError::OutcomeUnknown(_), ) => ToolError::ExecutionFailed(err.to_string()), ToolsError::FileSystem(_) | ToolsError::WorkdirSession(_) @@ -62,6 +63,22 @@ mod tests { use super::*; use workdir::http::{WorkdirTransportError, WorkdirTransportErrorCode}; + #[test] + fn checkout_outcome_unknown_remains_execution_failure() { + let error = ToolError::from(ToolsError::WorkdirSession( + workdir::WorkdirError::OutcomeUnknown( + "mutation may have committed; do not retry automatically".into(), + ), + )); + match error { + ToolError::ExecutionFailed(message) => { + assert!(message.contains("mutation may have committed")); + assert!(message.contains("do not retry automatically")); + } + other => panic!("outcome unknown must not become invalid input: {other:?}"), + } + } + #[test] fn local_workdir_content_conflict_is_retryable_execution_failure() { let error = ToolError::from(ToolsError::WorkdirSession( diff --git a/crates/tools/src/file_target.rs b/crates/tools/src/file_target.rs new file mode 100644 index 000000000..17397eea3 --- /dev/null +++ b/crates/tools/src/file_target.rs @@ -0,0 +1,186 @@ +//! Shared provider dispatch for Tool and checkout file processing. +use agen::tool::ToolError; +use workdir::{ + CheckoutOperation, CheckoutOutput, CheckoutRequest, ContentHash, EditRequest, EditResult, + ReadRequest, ReadResult, WorkdirPath, WorkdirSessionHandle, WriteRequest, WriteResult, +}; + +use crate::ToolsError; + +pub(crate) struct FileTarget { + pub session: WorkdirSessionHandle, + pub path: WorkdirPath, + pub validator: Option>, +} + +impl FileTarget { + async fn checked( + &self, + operation: CheckoutOperation, + ) -> Result<(CheckoutOutput, Option>), ToolError> { + let result = self + .session + .checkout_execute(CheckoutRequest { + target: self.path.clone(), + validator: self + .validator + .clone() + .expect("checked dispatch requires validator"), + operation, + }) + .await + .map_err(checked_error)?; + Ok((result.output, Some(result.observation.validator))) + } + + pub async fn read( + &self, + offset: usize, + limit: usize, + max_bytes: usize, + ) -> Result<(ReadResult, Option>), ToolError> { + if self.validator.is_some() { + let (output, validator) = self + .checked(CheckoutOperation::Read { + offset, + limit, + max_bytes, + }) + .await?; + match output { + CheckoutOutput::Read(result) => Ok((result, validator)), + _ => Err(readonly_result_error("Read")), + } + } else { + let result = self + .session + .read(ReadRequest { + path: self.path.clone(), + offset, + limit, + max_bytes, + }) + .await + .map_err(ToolsError::from)?; + Ok((result, None)) + } + } + + pub async fn edit( + &self, + old_string: String, + new_string: String, + replace_all: bool, + expected_hash: ContentHash, + ) -> Result<(EditResult, Option>), ToolError> { + if self.validator.is_some() { + let (output, validator) = self + .checked(CheckoutOperation::Edit { + old_string, + new_string, + replace_all, + expected_hash, + }) + .await?; + match output { + CheckoutOutput::Edit(result) => Ok((result, validator)), + _ => Err(unknown_result("Edit")), + } + } else { + let result = self + .session + .edit(EditRequest { + path: self.path.clone(), + old_string, + new_string, + replace_all, + expected_hash, + }) + .await + .map_err(ToolsError::from)?; + Ok((result, None)) + } + } + + pub async fn write( + &self, + content: Vec, + expected_hash: Option, + create_path: Option, + ) -> Result<(WriteResult, Option>), ToolError> { + if self.validator.is_some() { + let operation = match create_path { + Some(path) => CheckoutOperation::Create { path, content }, + None => CheckoutOperation::Write { + content, + expected_hash: expected_hash + .expect("existing checkout Write requires prior read"), + }, + }; + let (output, validator) = self.checked(operation).await?; + match output { + CheckoutOutput::Write(result) => Ok((result, validator)), + _ => Err(unknown_result("Write/Create")), + } + } else { + let result = self + .session + .write(WriteRequest { + path: self.path.clone(), + content, + expected_hash, + }) + .await + .map_err(ToolsError::from)?; + Ok((result, None)) + } + } +} + +pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError { + use workdir::WorkdirError; + let code = match &error { + WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale", + WorkdirError::Denied { .. } + | WorkdirError::OutOfScope { .. } + | WorkdirError::ReadOnly { .. } + | WorkdirError::Unsupported { .. } + | WorkdirError::UnsupportedOperation { .. } + | WorkdirError::SymlinkOutOfScope { .. } + | WorkdirError::SymlinkDirectoryNotTraversed { .. } => "checkout_denied", + WorkdirError::InvalidArgument { .. } + | WorkdirError::InvalidPath { .. } + | WorkdirError::UnknownCommand { .. } + | WorkdirError::RelativePath { .. } + | WorkdirError::BrokenSymlink { .. } + | WorkdirError::SymlinkTargetIsDirectory { .. } + | WorkdirError::IsDirectory { .. } + | WorkdirError::InvalidGlob { .. } + | WorkdirError::InvalidRegex { .. } => "checkout_invalid", + WorkdirError::Unavailable { .. } | WorkdirError::SessionClosed => "checkout_unavailable", + // IO/transport failures may arrive after effects have begun. Do not infer + // no-effects from their text or strip explicit OutcomeUnknown evidence. + WorkdirError::Io { .. } + | WorkdirError::Transport { .. } + | WorkdirError::OutcomeUnknown { .. } + | WorkdirError::OperationFailed => { + return ToolsError::from(error).into(); + } + }; + ToolError::StructuredConflict { + code: code.into(), + message: error.to_string(), + } +} + +pub(crate) fn readonly_result_error(operation: &str) -> ToolError { + checked_error(workdir::WorkdirError::Unavailable(format!( + "provider returned an invalid readonly checkout {operation} result" + ))) +} + +fn unknown_result(operation: &str) -> ToolError { + ToolError::ExecutionFailed(format!( + "Outcome unknown: provider returned wrong checkout {operation} result after mutation; do not automatically retry" + )) +} diff --git a/crates/tools/src/glob.rs b/crates/tools/src/glob.rs index 0c2914dfd..fe65fc36a 100644 --- a/crates/tools/src/glob.rs +++ b/crates/tools/src/glob.rs @@ -11,15 +11,15 @@ use crate::ToolsError; const RESULT_LIMIT: usize = 1000; #[derive(Debug, Deserialize, JsonSchema)] -struct GlobParams { +pub(crate) struct GlobParams { /// Worker-local alias of the Workdir attachment to use. #[serde(default)] - target_workdir: Option, + pub target_workdir: Option, /// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`. - pattern: String, + pub pattern: String, /// Logical Workdir-relative directory. Defaults to the Workdir root. #[serde(default)] - path: Option, + pub path: Option, } struct GlobTool { @@ -44,42 +44,57 @@ impl Tool for GlobTool { Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?, None => WorkdirPath::root(), }; - let pattern = params.pattern; - tracing::debug!(%pattern, %path, "Glob"); - let result = selected - .session - .glob(GlobRequest { - pattern: pattern.clone(), - path, - limit: RESULT_LIMIT, - }) - .await - .map_err(ToolsError::from)?; - let mut body = result - .paths - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); - if !body.is_empty() { - body.push('\n'); - } - let summary = if result.paths.is_empty() { - format!("No files found matching {pattern}") - } else if result.truncated { - format!( - "Found {}+ files matching {pattern} (truncated to {RESULT_LIMIT})", - result.paths.len() - ) - } else { - format!("Found {} file(s) matching {pattern}", result.paths.len()) - }; - Ok(ToolOutput { + Ok(execute_glob( + crate::search_target::SearchTarget::tool(selected.session), + path, + params.pattern, + ) + .await? + .output) + } +} + +pub(crate) async fn execute_glob( + target: crate::search_target::SearchTarget, + path: WorkdirPath, + pattern: String, +) -> Result { + tracing::debug!(%pattern, %path, "Glob"); + let result = target + .glob(GlobRequest { + pattern: pattern.clone(), + path, + limit: RESULT_LIMIT, + }) + .await?; + let mut body = result + .paths + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + if !body.is_empty() { + body.push('\n'); + } + let summary = if result.paths.is_empty() { + format!("No files found matching {pattern}") + } else if result.truncated { + format!( + "Found {}+ files matching {pattern} (truncated to {RESULT_LIMIT})", + result.paths.len() + ) + } else { + format!("Found {} file(s) matching {pattern}", result.paths.len()) + }; + Ok(crate::checkout::CheckoutToolOutput { + output: ToolOutput { summary, content: (!body.is_empty()).then_some(body), attachments: Vec::new(), - }) - } + }, + paths: result.paths, + validator: None, + }) } pub fn glob_tool(session: WorkdirSessionHandle) -> ToolDefinition { diff --git a/crates/tools/src/grep.rs b/crates/tools/src/grep.rs index 190b8590e..85257a8cf 100644 --- a/crates/tools/src/grep.rs +++ b/crates/tools/src/grep.rs @@ -22,16 +22,16 @@ enum OutputMode { } #[derive(Debug, Deserialize, JsonSchema)] -struct GrepParams { +pub(crate) struct GrepParams { /// Worker-local alias of the Workdir attachment to use. #[serde(default)] target_workdir: Option, pattern: String, /// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root. #[serde(default)] - path: Option, + pub path: Option, #[serde(default)] - glob: Option, + pub glob: Option, #[serde(default, rename = "type")] file_type: Option, #[serde(default)] @@ -70,69 +70,85 @@ impl Tool for GrepTool { params.target_workdir.as_deref(), workdir::WorkdirSessionCapability::Grep, )?; - let path = match params.path { - Some(path) => WorkdirPath::new_scoped(&path).map_err(ToolsError::from)?, + let path = match params.path.as_deref() { + Some(path) => WorkdirPath::new_scoped(path).map_err(ToolsError::from)?, None => WorkdirPath::root(), }; - let mode = match params.output_mode.unwrap_or_default() { - OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches, - OutputMode::Content => GrepOutputMode::Content, - OutputMode::Count => GrepOutputMode::Count, - }; - let (before_context, after_context) = params - .context - .map(|context| (context, context)) - .unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0))); - let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT); - let result = selected - .session - .grep(GrepRequest { - pattern: params.pattern, - path, - glob: params.glob, - file_type: params.file_type, - case_insensitive: params.case_insensitive, - before_context, - after_context, - multiline: params.multiline, - output_mode: mode, - limit: head_limit, - offset: params.offset.unwrap_or(0), - }) - .await - .map_err(ToolsError::from)?; + Ok(execute_grep( + crate::search_target::SearchTarget::tool(selected.session), + path, + params, + ) + .await? + .output) + } +} - let summary = if result.match_count == 0 { - match mode { - GrepOutputMode::Content => "No matches".to_owned(), - _ => "No files matched".to_owned(), +pub(crate) async fn execute_grep( + target: crate::search_target::SearchTarget, + path: WorkdirPath, + params: GrepParams, +) -> Result { + let mode = match params.output_mode.unwrap_or_default() { + OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches, + OutputMode::Content => GrepOutputMode::Content, + OutputMode::Count => GrepOutputMode::Count, + }; + let (before_context, after_context) = params + .context + .map(|context| (context, context)) + .unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0))); + let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT); + let result = target + .grep(GrepRequest { + pattern: params.pattern, + path, + glob: params.glob, + file_type: params.file_type, + case_insensitive: params.case_insensitive, + before_context, + after_context, + multiline: params.multiline, + output_mode: mode, + limit: head_limit, + offset: params.offset.unwrap_or(0), + }) + .await?; + + let summary = if result.match_count == 0 { + match mode { + GrepOutputMode::Content => "No matches".to_owned(), + _ => "No files matched".to_owned(), + } + } else { + match mode { + GrepOutputMode::FilesWithMatches => { + format!("Found matches in {} file(s)", result.matched_files) } - } else { - match mode { - GrepOutputMode::FilesWithMatches => { - format!("Found matches in {} file(s)", result.matched_files) - } - GrepOutputMode::Count => format!( - "Found matches in {} file(s), {} total line(s)", - result.matched_files, result.match_count - ), - GrepOutputMode::Content => format!( - "{} matching line(s) in {} file(s)", - result.match_count, result.matched_files - ), - } - }; - let summary = if result.truncated { - format!("{summary} (truncated at {head_limit})") - } else { - summary - }; - Ok(ToolOutput { + GrepOutputMode::Count => format!( + "Found matches in {} file(s), {} total line(s)", + result.matched_files, result.match_count + ), + GrepOutputMode::Content => format!( + "{} matching line(s) in {} file(s)", + result.match_count, result.matched_files + ), + } + }; + let summary = if result.truncated { + format!("{summary} (truncated at {head_limit})") + } else { + summary + }; + Ok(crate::checkout::CheckoutToolOutput { + output: ToolOutput { summary, content: (!result.output.is_empty()).then_some(result.output), attachments: Vec::new(), - }) - } + }, + paths: result.paths, + validator: None, + }) } pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 0aaabe8d6..790dabcf3 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -9,20 +9,24 @@ //! and command capability, while the Runtime process and OS user remain the //! trusted execution boundary. +pub mod checkout; pub mod error; pub mod tracker; mod bash; mod edit; +mod file_target; mod glob; mod grep; mod read; mod routing; +mod search_target; mod view_image; mod web; mod write; pub use bash::bash_tool; +pub use checkout::{CheckoutToolOutput, execute_checkout_tool}; pub use edit::edit_tool; pub use error::ToolsError; pub use glob::glob_tool; diff --git a/crates/tools/src/read.rs b/crates/tools/src/read.rs index 5a6800b02..a77be007b 100644 --- a/crates/tools/src/read.rs +++ b/crates/tools/src/read.rs @@ -8,7 +8,7 @@ use serde::Deserialize; use crate::error::ToolsError; use crate::tracker::Tracker; -use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; +use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; const DESCRIPTION: &str = "Read a text file from a Workdir attachment selected by its Worker-local alias. \ Supports offset/limit for large files. Returns line-numbered output (1-based). \ @@ -56,51 +56,59 @@ impl Tool for ReadTool { let tracker = self .tracker .scoped_attachment(&selected.alias, selected.generation); - let offset = params.offset.unwrap_or(0); - let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1); + Ok(execute_read( + crate::file_target::FileTarget { + session: selected.session, + path: WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?, + validator: None, + }, + tracker, + params, + ) + .await? + .output) + } +} - let path = WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?; - tracing::debug!(path = %path, offset, limit, "Read"); - - let result = selected - .session - .read(ReadRequest { - path: path.clone(), - offset, - limit, - max_bytes: PROVIDER_BYTE_LIMIT, - }) - .await - .map_err(ToolsError::from)?; - tracker.record_workdir_hash(&path, result.content_hash); - - let text = String::from_utf8_lossy(&result.bytes).into_owned(); - let rendered = render_provider_read( - &text, - result.start_line, - result.total_lines, - result.truncated, - ); - - let summary = if rendered.truncated { - format!( - "Read {} line(s) [{}..{}] of {} from {}", - rendered.line_count, - offset + 1, - offset + rendered.line_count, - rendered.total_lines, - path - ) - } else { - format!("Read {} line(s) from {}", rendered.line_count, path) - }; - - Ok(ToolOutput { +pub(crate) async fn execute_read( + target: crate::file_target::FileTarget, + tracker: Tracker, + params: ReadParams, +) -> Result { + let offset = params.offset.unwrap_or(0); + let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1); + let path = &target.path; + tracing::debug!(path = %path, offset, limit, "Read"); + let (result, validator) = target.read(offset, limit, PROVIDER_BYTE_LIMIT).await?; + tracker.record_workdir_observation(path, result.content_hash, result.total_lines); + let text = String::from_utf8_lossy(&result.bytes).into_owned(); + let rendered = render_provider_read( + &text, + result.start_line, + result.total_lines, + result.truncated, + ); + let summary = if rendered.truncated { + format!( + "Read {} line(s) [{}..{}] of {} from {}", + rendered.line_count, + offset.saturating_add(1), + offset.saturating_add(rendered.line_count), + rendered.total_lines, + path + ) + } else { + format!("Read {} line(s) from {}", rendered.line_count, path) + }; + Ok(crate::checkout::CheckoutToolOutput { + output: ToolOutput { summary, content: Some(rendered.body), attachments: Vec::new(), - }) - } + }, + paths: Vec::new(), + validator, + }) } struct Rendered { @@ -116,43 +124,36 @@ fn render_provider_read( total_lines: usize, truncated: bool, ) -> Rendered { - use std::fmt::Write as _; - let lines = text.lines().collect::>(); - let mut body = String::with_capacity(text.len().saturating_add(lines.len() * 8)); - for (index, line) in lines.iter().enumerate() { - let _ = writeln!(&mut body, "{:>6}\t{}", start_line + index + 1, line); + const MARKER: &str = "\n[truncated at rendered output byte limit]\n"; + // The numbered presentation is bounded too, not only provider source bytes. + // This fits the native wire limit even with JSON's worst-case escaping. + let budget = PROVIDER_BYTE_LIMIT - MARKER.len(); + let mut body = String::with_capacity(text.len().min(PROVIDER_BYTE_LIMIT)); + let mut line_count = 0; + let mut output_truncated = false; + for (index, line) in text.lines().enumerate() { + let numbered = format!("{:>6}\t{}\n", start_line + index + 1, line); + let remaining = budget.saturating_sub(body.len()); + if remaining > 0 { + line_count += 1; + } + if numbered.len() > remaining { + let mut end = remaining; + while !numbered.is_char_boundary(end) { + end -= 1; + } + body.push_str(&numbered[..end]); + body.push_str(MARKER); + output_truncated = true; + break; + } + body.push_str(&numbered); } - Rendered { - body, - line_count: lines.len(), - total_lines, - truncated: start_line > 0 || truncated, - } -} - -/// Format a slice of lines from `text` with `cat -n` style 1-based line -/// numbers. Pure function — no I/O, no history touching. -#[cfg(test)] -fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered { - let all_lines: Vec<&str> = text.lines().collect(); - let total_lines = all_lines.len(); - let start = offset.min(total_lines); - let end = start.saturating_add(limit).min(total_lines); - let slice = &all_lines[start..end]; - let line_count = slice.len(); - - use std::fmt::Write as _; - let mut body = String::with_capacity(text.len().saturating_add(line_count * 8)); - for (i, line) in slice.iter().enumerate() { - let lineno = start + i + 1; - let _ = writeln!(&mut body, "{:>6}\t{}", lineno, line); - } - Rendered { body, line_count, total_lines, - truncated: start > 0 || end < total_lines, + truncated: start_line > 0 || truncated || output_truncated, } } @@ -195,6 +196,32 @@ mod tests { (dir, session, Tracker::new()) } + #[test] + fn read_numbering_and_unicode_expansion_keep_rendered_output_bounded() { + for text in [ + "\n".repeat(PROVIDER_BYTE_LIMIT), + "界".repeat(PROVIDER_BYTE_LIMIT), + "\u{1}".repeat(PROVIDER_BYTE_LIMIT), + ] { + let rendered = render_provider_read(&text, 0, text.lines().count(), false); + assert!(rendered.body.len() <= PROVIDER_BYTE_LIMIT); + assert!(serde_json::to_vec(&rendered.body).unwrap().len() < 2 * 1024 * 1024); + } + let rendered = render_provider_read( + &"\n".repeat(PROVIDER_BYTE_LIMIT), + 0, + PROVIDER_BYTE_LIMIT, + false, + ); + assert!(rendered.truncated); + assert!(rendered.line_count < PROVIDER_BYTE_LIMIT); + assert!( + rendered + .body + .contains("truncated at rendered output byte limit") + ); + } + #[tokio::test] async fn read_tool_basic_records_history() { let (dir, fs, tracker) = setup(); diff --git a/crates/tools/src/search_target.rs b/crates/tools/src/search_target.rs new file mode 100644 index 000000000..59110e57d --- /dev/null +++ b/crates/tools/src/search_target.rs @@ -0,0 +1,81 @@ +//! Provider dispatch shared by native and ordinary search processing. +//! Request construction/defaults and rendering remain in Glob/Grep; this only +//! selects the provider surface and verifies the returned readonly result kind. +use agen::tool::ToolError; +use workdir::{ + CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult, GlobRequest, GlobResult, + GrepRequest, GrepResult, WorkdirSessionHandle, +}; + +use crate::{ToolsError, file_target::checked_error}; + +pub(crate) struct SearchTarget { + session: WorkdirSessionHandle, + checkout: bool, +} + +impl SearchTarget { + pub fn tool(session: WorkdirSessionHandle) -> Self { + Self { + session, + checkout: false, + } + } + + pub fn checkout(session: WorkdirSessionHandle) -> Self { + Self { + session, + checkout: true, + } + } + + pub async fn glob(&self, request: GlobRequest) -> Result { + if self.checkout { + let result = self + .session + .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob( + request, + ))) + .await + .map_err(checked_error)?; + glob_result(result) + } else { + self.session + .glob(request) + .await + .map_err(|error| ToolsError::from(error).into()) + } + } + + pub async fn grep(&self, request: GrepRequest) -> Result { + if self.checkout { + let result = self + .session + .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep( + request, + ))) + .await + .map_err(checked_error)?; + grep_result(result) + } else { + self.session + .grep(request) + .await + .map_err(|error| ToolsError::from(error).into()) + } + } +} + +pub(crate) fn glob_result(result: CheckoutSearchResult) -> Result { + match result { + CheckoutSearchResult::Glob(result) => Ok(result), + _ => Err(crate::file_target::readonly_result_error("Glob")), + } +} + +pub(crate) fn grep_result(result: CheckoutSearchResult) -> Result { + match result { + CheckoutSearchResult::Grep(result) => Ok(result), + _ => Err(crate::file_target::readonly_result_error("Grep")), + } +} diff --git a/crates/tools/src/tracker.rs b/crates/tools/src/tracker.rs index feaaf30ff..52cecabf9 100644 --- a/crates/tools/src/tracker.rs +++ b/crates/tools/src/tracker.rs @@ -256,6 +256,25 @@ impl Tracker { .copied() } + /// Record the full provider content hash and total line count, even for a sliced Read. + /// Keeping both under one lock prevents pairing counts from a different observation. + pub fn record_workdir_observation( + &self, + path: &workdir::WorkdirPath, + hash: workdir::ContentHash, + total_lines: usize, + ) { + let key = self.key(PathBuf::from(path.as_str())); + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.line_counts.insert(key.clone(), total_lines); + inner.hashes.insert(key.clone(), hash); + inner.recency.retain(|candidate| candidate != &key); + inner.recency.push_front(key); + if inner.recency.len() > RECENCY_CAPACITY { + inner.recency.pop_back(); + } + } + pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) { let key = self.key(PathBuf::from(path.as_str())); let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/tools/src/write.rs b/crates/tools/src/write.rs index 8479de806..858f91f44 100644 --- a/crates/tools/src/write.rs +++ b/crates/tools/src/write.rs @@ -9,10 +9,7 @@ use serde::Deserialize; use crate::error::ToolsError; use crate::tracker::Tracker; -use workdir::{ - StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter, - WriteRequest, -}; +use workdir::{StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; const DESCRIPTION: &str = "Create a new file or overwrite an existing one in the selected Workdir attachment. \ Missing parent directories within scope are created automatically. Existing files must have been read first \ @@ -53,50 +50,79 @@ impl Tool for WriteTool { .tracker .scoped_attachment(&selected.alias, selected.generation); let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?; - tracing::debug!(path = %path, bytes = params.content.len(), "Write"); + Ok(execute_write( + crate::file_target::FileTarget { + session: selected.session, + path, + validator: None, + }, + tracker, + params.content, + None, + ctx, + ) + .await? + .output) + } +} - let mutation_key = PathBuf::from(path.as_str()); - let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await; - let expected_hash = match selected +/// `create_path` is a checked parent-relative create-new target, never an overwrite. +pub(crate) async fn execute_write( + target: crate::file_target::FileTarget, + tracker: Tracker, + content: String, + create_path: Option, + ctx: agen::tool::ToolExecutionContext, +) -> Result { + let path = create_path.as_ref().unwrap_or(&target.path); + tracing::debug!(path = %path, bytes = content.len(), "Write"); + let mutation_key = PathBuf::from(path.as_str()); + let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await; + let expected_hash = if create_path.is_some() { + None + } else if target.validator.is_some() { + // Native Write is existing-file-only, and always requires a prior observation. + Some(tracker.expected_workdir_hash(path)?) + } else { + match target .session .stat(StatRequest { path: path.clone() }) .await { - Ok(_) => Some(tracker.expected_workdir_hash(&path)?), + Ok(_) => Some(tracker.expected_workdir_hash(path)?), Err(WorkdirError::NotFound(_)) => None, Err(error) => return Err(ToolsError::from(error).into()), - }; - - let old_line_count = tracker.observed_workdir_line_count(&path).unwrap_or(0); - let outcome = selected - .session - .write(WriteRequest { - path: path.clone(), - content: params.content.as_bytes().to_vec(), - expected_hash, - }) - .await - .map_err(ToolsError::from)?; - - tracker.record_change(params.content.lines().count(), old_line_count); - tracker.record_workdir_content(&path, params.content.as_bytes()); - - let summary = format!( - "{} {} ({} bytes)", - if outcome.created { - "Created" - } else { - "Overwrote" - }, - path, - outcome.bytes_written - ); - Ok(ToolOutput { + } + }; + let old_line_count = tracker.observed_workdir_line_count(path).unwrap_or(0); + let (outcome, validator) = target + .write( + content.as_bytes().to_vec(), + expected_hash, + create_path.clone(), + ) + .await?; + tracker.record_change(content.lines().count(), old_line_count); + tracker.record_workdir_content(path, content.as_bytes()); + let summary = format!( + "{} {} ({} bytes)", + if outcome.created { + "Created" + } else { + "Overwrote" + }, + path, + outcome.bytes_written + ); + Ok(crate::checkout::CheckoutToolOutput { + output: ToolOutput { summary, content: None, attachments: Vec::new(), - }) - } + }, + paths: create_path.into_iter().collect(), + validator, + }) } /// Factory for the `Write` tool bound to one compatibility session. diff --git a/crates/workdir/CHECKOUT_PROVIDER.md b/crates/workdir/CHECKOUT_PROVIDER.md new file mode 100644 index 000000000..8a5746d25 --- /dev/null +++ b/crates/workdir/CHECKOUT_PROVIDER.md @@ -0,0 +1,143 @@ +# Request-time checkout provider boundary + +`workdir::checkout` (also crate-root reexports) exposes `CheckoutObservation`, +`CheckoutRequest`, `CheckoutOperation`, `CheckoutOutput`, and `CheckoutResult`. +`WorkdirSession::checkout_observe(path)` defaults to unsupported; execution also +fails closed by default. Observation is metadata-only, with no recursive tree +walk. Directory discovery/search remains the ordinary bounded List/Glob/Grep +provider engine. GrepResult.paths carries exact typed provider paths appearing +in the returned report, respecting its offset/limit/truncation; filenames are +never parsed from rendered text. List/Glob/Grep skip unrepresentable non-UTF8 +paths, preserving valid Unicode exactly without normalization. Native callers supply their own attachment generation/namespace +fence; this provider validator is not a substitute for that fence. + +* Read: line `offset`, `limit`, `max_bytes`; same `ReadResult` (bytes, start line, + total lines, full-source content hash, truncation) as normal Tools. Formatting + line numbers and recording the returned hash in caller read-history are caller + responsibilities. The validator is **not** permission to bypass read-before-write. +* Write: replace existing file with `content` and required `expected_hash`. +* Edit: existing UTF-8 file, unique `old_string` unless `replace_all`, required + `expected_hash`; same match/replacement/result-size checks as Tools. +* Create: target must be an observed directory; `path` is **checkout-root + relative**, strictly below target, not target-relative. No existing file can + be overwritten. Intermediate directories and destination must be writable. +* Every result observation describes the bound request target. Create returns + the updated **parent directory** observation from the retained pinned target, + not the created destination. Read returns the read state's observation. + +## Shared implementation + +`fs_operation::CheckedTarget` is an `FsAccessPolicy` adapter around pinned target, +save-parent, and provider-root descriptors. It calls the ordinary +`run_read_bounded`, `run_edit`, and `run_write` functions, including full-source +hashing, UTF-8/string matching, bounds, and atomic temp-save primitive; there is +no second content-operation engine. Shared text reads now check descriptor +metadata before/after reading. Validator bytes encode device/inode/size/mode/ +mtime/ctime with nanoseconds, excluding atime and host paths; same-content inode +replacement is distinguishable. Successful native Read rechecks the named target +and pinned descriptor before returning hash and observation. + +Local sessions serialize checked observe/execute and ordinary trait Read/Write/ +Edit using one nonqueueing session lock. Expected validator comparison happens +inside that boundary. The adapter retains handles across reading and save, +rechecks named target + save-parent identity at save boundary, and writes a new +temporary inode then atomically renames it, isolating existing hardlink aliases. +External sessions retain the approved descriptor root after pathname rename or +replacement. Checkout resolution rejects symlinks, special files, mount crossings, +absolute/traversal paths and unavailable descriptor primitives, rather than +weakening confinement. This currently requires Linux `openat2` and `/proc/self/fd`. +Ordinary Tools preserve their existing explicit symlink-policy behavior. + +Scoped sessions use their existing scope mutex, current validity/source +capability checks, provider authorization and write leases; Create also checks +intermediate paths. Observation capabilities are attenuated per path and live +write leases, never broadened to the whole attachment. ReadOnly removes mutation +capabilities and rejects mutations. Routed operations use existing active-op +accounting and detach fencing. Remote HTTP forwards exact operation/result types +and validates returned operation/path pairing. External DTOs validate checkout +path/validator/content/result bounds. No provider endpoint or host root is added +to the observation. + +## Effects and limits + +Absent paths during checkout observation return `NotFound` (including a missing +ancestor or a non-directory ancestor); native discovery can return no route. +An execution against a previously observed target that disappeared instead +returns `Conflict`, consistently at pinning, shared-engine, and final-check +boundaries. Conflict means stale validator/hash/name or existing Create destination before +save effects. `WorkdirError::OutcomeUnknown` has a separate sanitized wire code +`outcome_unknown` (HTTP 500), never an automatic retry. Partial parent creation, +possible commit failure, or failed post-save observation is outcome unknown. +Remote lost/malformed/mismatched mutation responses are also outcome unknown. +Create failures conservatively report unknown once parent creation may begin, +even if a particular failure happened without a persisted effect. + +Bounds: path <=4096 UTF-8 bytes/128 components, validator <=256 bytes; Read source +64 MiB, retained response <=1 MiB (or stricter provider read limits), Write/Edit +preimage and result <=8 MiB (or stricter provider limit), Edit replacements +<=10,000 (or stricter provider limit). One checked operation per Local session, +with bounded retained handles and no waiting queue. Cancellation/deadline checks +use the existing provider guard, or a 30-second cooperative guard for Local +checkout operations. Blocking kernel/filesystem calls are not preempted; this +is **not** a hard wall-time guarantee for hung storage. Transport request-body +bounds before serde allocation remain the hosting transport's responsibility. + +### External-editor limitations (not a magic advisory-lock guarantee) + +No advisory `flock` is claimed to exclude arbitrary editors. Provider-session +serialization does not lock other sessions/processes. Metadata checks detect +concurrent reads/edits up to each check, but are not a transactional snapshot +against privileged actors forging metadata. Linux offers no atomic +compare-identity-and-replace-by-name primitive here: an uncooperative external +rename/change between the final validator check and rename can still race the +save. The pinned handles prevent following replacements/symlinks into arbitrary +objects, and deterministic substitution before the save boundary is rejected, +but the final syscall gap is an explicit limitation. Callers must not interpret +this as linearizability against all external filesystem mutations. Likewise, +post-save observation is the then-current named state, not an exclusion of an +external edit after save. Crash durability of parent directories is not claimed. + +Host generation reuse, native permission identity, read-history updates, model +Operation publication, transport hosting (Runtime/Backend/External), and durable +Ticket/MR review remain the parent integration's responsibilities. + +## Scoped checkout search boundary + +`CheckoutSearchRequest::new(CheckoutSearchOperation::{List, Glob, Grep}(request))` +uses the current session's logical root as its result frame. The typed result is +`CheckoutSearchResult::{List, Glob, Grep}`. The request's internal context fields +are `scope_layers: Vec>` and `output_root: WorkdirPath`. +Rules union within each layer; layers intersect with each other, the current +provider authority, and the output-root boundary. Wrappers contribute their own +scope; caller-supplied layers can only narrow, never replace it. Each wrapper +translates all incoming coordinates once and forwards the composed output root. +Provider-rendered Grep text and every typed result path are already relative to +that final root; wrappers must not prefix cwd onto returned paths again. + +Local/External use the same shared `run_list`, `run_glob`, `run_grep` engines. +`SearchAccess` gates metadata/content opens and enumeration. Shared List checks +`can_enumerate_directory`; Glob/Grep use the shared bounded descriptor walk and +owned `traversal_filter` policy to prune before descending/opening directories. +The walk anchors at the actual search directory, not at a broader provider +root. It counts rejected entries too, checks cancellation while scanning them, +and limits descent to 128 levels (bounded retained directory handles). Existing +provider-owned ignore loading, glob/type selection, grep context/offset/limit, +rendering, and source/result limits remain in their shared engines. Unreadable +ignore files are not opened to discover out-of-scope contents. + +Scoped ordinary trait List/Glob/Grep use this boundary as well, returning +session-logical coordinates. Unscoped normal Tools continue using their original +trait/provider behavior. A provider without checkout search support returns +UnsupportedOperation; there is no broad traversal fallback. Native/scoped +searches intentionally fail closed on symlink paths, including logical-policy +aliases which normal unscoped Tools may follow. A visible symlink may therefore +cause a native List failure rather than expanding authority. Descriptor support +is currently Linux-only. Search remains request-time traversal, not a snapshot +or transactional exclusion of external editors. + +HTTP operation/result variant: `CheckoutSearch`. External DTO validation rejects +absolute/out-of-root paths, empty scope layers, oversized paths/patterns/context/ +results and invalid result paths. Generic request ceilings are 16 layers and 256 +aggregate rules; External transport further limits aggregate rules to 64. Search +result limits follow the existing shared List/Glob/Grep wire bounds. Runtime and +Backend forwarding outside this crate remain parent integration work. diff --git a/crates/workdir/src/checkout.rs b/crates/workdir/src/checkout.rs new file mode 100644 index 000000000..f524ae61c --- /dev/null +++ b/crates/workdir/src/checkout.rs @@ -0,0 +1,1015 @@ +//! Request-time checkout contracts. Validators are opaque provider observations, +//! not content hashes. Callers must separately retain Read's content hash for +//! read-before-mutation. Create destinations are checkout-root relative. +use crate::{ + ContentHash, EditResult, EntryKind, ReadResult, WorkdirError, WorkdirPath, + WorkdirSessionCapabilities, WorkdirSessionCapability, WriteResult, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CheckoutObservation { + pub path: WorkdirPath, + pub kind: EntryKind, + pub size: u64, + pub validator: Vec, + pub capabilities: WorkdirSessionCapabilities, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CheckoutRequest { + pub target: WorkdirPath, + pub validator: Vec, + pub operation: CheckoutOperation, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum CheckoutOperation { + Read { + offset: usize, + limit: usize, + max_bytes: usize, + }, + Write { + content: Vec, + expected_hash: ContentHash, + }, + Edit { + old_string: String, + new_string: String, + replace_all: bool, + expected_hash: ContentHash, + }, + Create { + path: WorkdirPath, + content: Vec, + }, +} +impl CheckoutOperation { + pub fn capability(&self) -> WorkdirSessionCapability { + match self { + Self::Read { .. } => WorkdirSessionCapability::Read, + Self::Edit { .. } => WorkdirSessionCapability::Edit, + Self::Write { .. } | Self::Create { .. } => WorkdirSessionCapability::Write, + } + } +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "operation", + content = "result", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum CheckoutOutput { + Read(ReadResult), + Write(WriteResult), + Edit(EditResult), +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CheckoutResult { + /// Post-operation state of the bound request target. For Create this is the + /// parent directory, not the destination file (whose path the caller owns). + pub observation: CheckoutObservation, + pub output: CheckoutOutput, +} + +/// One provider-side traversal. Rules union within a layer and intersect across +/// layers, current provider authority and output_root. Wrappers add layers; +/// callers normally use new() and never supply provider coordinates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CheckoutSearchRequest { + pub operation: CheckoutSearchOperation, + pub scope_layers: Vec>, + pub output_root: WorkdirPath, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "operation", + content = "request", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum CheckoutSearchOperation { + List(crate::ListRequest), + Glob(crate::GlobRequest), + Grep(crate::GrepRequest), +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "operation", + content = "result", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum CheckoutSearchResult { + List(crate::ListResult), + Glob(crate::GlobResult), + Grep(crate::GrepResult), +} +impl CheckoutSearchOperation { + pub fn path(&self) -> &WorkdirPath { + match self { + Self::List(r) => &r.path, + Self::Glob(r) => &r.path, + Self::Grep(r) => &r.path, + } + } + pub fn path_mut(&mut self) -> &mut WorkdirPath { + match self { + Self::List(r) => &mut r.path, + Self::Glob(r) => &mut r.path, + Self::Grep(r) => &mut r.path, + } + } + pub fn capability(&self) -> WorkdirSessionCapability { + match self { + Self::List(_) => WorkdirSessionCapability::Read, + Self::Glob(_) => WorkdirSessionCapability::Glob, + Self::Grep(_) => WorkdirSessionCapability::Grep, + } + } +} +impl CheckoutSearchRequest { + pub fn new(operation: CheckoutSearchOperation) -> Self { + Self { + operation, + scope_layers: Vec::new(), + output_root: WorkdirPath::root(), + } + } + pub(crate) fn validate(&self) -> Result<(), WorkdirError> { + validate_path(&self.output_root)?; + validate_path(self.operation.path())?; + if !std::path::Path::new(self.operation.path().as_str()) + .starts_with(self.output_root.as_str()) + { + return Err(WorkdirError::Denied( + "checkout search must stay beneath its output root".into(), + )); + } + if self.scope_layers.len() > 16 + || self.scope_layers.iter().map(Vec::len).sum::() > 256 + || self.scope_layers.iter().any(Vec::is_empty) + { + return Err(WorkdirError::InvalidArgument( + "checkout search scope exceeds provider bounds".into(), + )); + } + for layer in &self.scope_layers { + for rule in layer { + validate_path(&rule.target)?; + } + } + let valid = match &self.operation { + CheckoutSearchOperation::List(r) => { + r.limit <= crate::external::MAX_EXTERNAL_RESULT_ITEMS + } + CheckoutSearchOperation::Glob(r) => { + r.limit <= crate::external::MAX_EXTERNAL_RESULT_ITEMS && r.pattern.len() <= 4096 + } + CheckoutSearchOperation::Grep(r) => { + r.limit <= crate::external::MAX_EXTERNAL_RESULT_ITEMS + && r.offset <= 1_000_000 + && r.pattern.len() <= 4096 + && r.glob.as_ref().is_none_or(|s| s.len() <= 4096) + && r.file_type.as_ref().is_none_or(|s| s.len() <= 128) + && r.before_context <= 100 + && r.after_context <= 100 + } + }; + if valid { + Ok(()) + } else { + Err(WorkdirError::InvalidArgument( + "checkout search exceeds provider bounds".into(), + )) + } + } +} + +pub(crate) fn validate_path(path: &WorkdirPath) -> Result<(), WorkdirError> { + WorkdirPath::new(path.as_str())?; + if path.as_str().len() > 4096 || std::path::Path::new(path.as_str()).components().count() > 128 + { + return Err(WorkdirError::InvalidArgument( + "checkout path exceeds provider bounds".into(), + )); + } + Ok(()) +} + +/// Check strict containment without converting a directory-relative path into +/// ambient authority. Absolute paths are forbidden even if otherwise scoped. +pub fn create_relative_path( + target: &WorkdirPath, + path: &WorkdirPath, +) -> Result { + validate_path(target)?; + validate_path(path)?; + let target = std::path::Path::new(target.as_str()); + let path = std::path::Path::new(path.as_str()); + if target.is_absolute() || path.is_absolute() { + return Err(WorkdirError::InvalidArgument( + "checkout paths must be root relative".into(), + )); + } + let relative = path.strip_prefix(target).map_err(|_| { + WorkdirError::InvalidArgument( + "Create destination must be strictly beneath its target".into(), + ) + })?; + if relative.as_os_str().is_empty() + || relative + .components() + .any(|c| !matches!(c, std::path::Component::Normal(_))) + { + return Err(WorkdirError::InvalidArgument( + "Create destination must be strictly beneath its target".into(), + )); + } + Ok(relative.to_path_buf()) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use crate::{LocalWorkdirSession, WorkdirSession}; + use std::sync::Arc; + fn path(p: &str) -> WorkdirPath { + WorkdirPath::new(p).unwrap() + } + fn session(root: &std::path::Path) -> LocalWorkdirSession { + LocalWorkdirSession::new(manifest::Scope::writable(root).unwrap(), root.to_path_buf()) + } + async fn checked_read( + session: &dyn WorkdirSession, + observation: CheckoutObservation, + ) -> CheckoutResult { + session + .checkout_execute(CheckoutRequest { + target: observation.path, + validator: observation.validator, + operation: CheckoutOperation::Read { + offset: 0, + limit: 20, + max_bytes: 1024, + }, + }) + .await + .unwrap() + } + fn hash(result: &CheckoutResult) -> ContentHash { + match &result.output { + CheckoutOutput::Read(r) => r.content_hash, + _ => panic!("not Read"), + } + } + #[tokio::test] + async fn checkout_read_edit_write_create_share_hash_and_string_contracts() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("a"), "alpha\nbeta\n").unwrap(); + let session = session(root.path()); + let read = checked_read(&session, session.checkout_observe(path("a")).await.unwrap()).await; + let edited = session + .checkout_execute(CheckoutRequest { + target: path("a"), + validator: read.observation.validator.clone(), + operation: CheckoutOperation::Edit { + old_string: "beta".into(), + new_string: "gamma".into(), + replace_all: false, + expected_hash: hash(&read), + }, + }) + .await + .unwrap(); + assert!(matches!( + edited.output, + CheckoutOutput::Edit(EditResult { + replacements: 1, + .. + }) + )); + assert_eq!( + std::fs::read_to_string(root.path().join("a")).unwrap(), + "alpha\ngamma\n" + ); + let read = checked_read(&session, edited.observation).await; + // A validator alone does not bypass read-history's expected content hash. + assert!(matches!( + session + .checkout_execute(CheckoutRequest { + target: path("a"), + validator: read.observation.validator.clone(), + operation: CheckoutOperation::Write { + content: b"bad".to_vec(), + expected_hash: [0; 32] + } + }) + .await, + Err(WorkdirError::Conflict(_)) + )); + let written = session + .checkout_execute(CheckoutRequest { + target: path("a"), + validator: read.observation.validator.clone(), + operation: CheckoutOperation::Write { + content: b"saved".to_vec(), + expected_hash: hash(&read), + }, + }) + .await + .unwrap(); + assert_ne!(written.observation.validator, read.observation.validator); + let directory = session.checkout_observe(WorkdirPath::root()).await.unwrap(); + let created = session + .checkout_execute(CheckoutRequest { + target: directory.path, + validator: directory.validator, + operation: CheckoutOperation::Create { + path: path("nested/new"), + content: b"new".to_vec(), + }, + }) + .await + .unwrap(); + assert_eq!(created.observation.path, WorkdirPath::root()); + assert_eq!(created.observation.kind, EntryKind::Directory); + assert_eq!( + created.observation, + session.checkout_observe(WorkdirPath::root()).await.unwrap() + ); + assert_eq!( + std::fs::read(root.path().join("nested/new")).unwrap(), + b"new" + ); + assert!(matches!( + created.output, + CheckoutOutput::Write(WriteResult { created: true, .. }) + )); + } + #[tokio::test] + async fn checkout_observe_missing_routes_are_not_found_but_deleted_execution_is_conflict() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file"), "original").unwrap(); + let local = session(root.path()); + let external = LocalWorkdirSession::external_read_write( + crate::Workdir::new("external-missing"), + root.path(), + crate::BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + let scoped = crate::WorkdirToolBroker::new(Arc::new(local.clone())); + for provider in [ + &local as &dyn WorkdirSession, + &external, + &*scoped.tool_session(), + ] { + for missing in ["missing", "missing-parent/child", "file/child"] { + let error = provider.checkout_observe(path(missing)).await.unwrap_err(); + assert!( + matches!(&error, WorkdirError::NotFound(logical) if logical == std::path::Path::new(missing)), + "{missing}: {error:?}" + ); + assert_eq!( + crate::http::WorkdirTransportError::from_workdir_error(&error).code, + crate::http::WorkdirTransportErrorCode::NotFound + ); + } + std::fs::write(root.path().join("observed"), "original").unwrap(); + let read = checked_read( + provider, + provider.checkout_observe(path("observed")).await.unwrap(), + ) + .await; + std::fs::remove_file(root.path().join("observed")).unwrap(); + for operation in [ + CheckoutOperation::Read { + offset: 0, + limit: 10, + max_bytes: 100, + }, + CheckoutOperation::Write { + content: b"new".to_vec(), + expected_hash: hash(&read), + }, + CheckoutOperation::Edit { + old_string: "original".into(), + new_string: "new".into(), + replace_all: false, + expected_hash: hash(&read), + }, + ] { + let error = provider + .checkout_execute(CheckoutRequest { + target: path("observed"), + validator: read.observation.validator.clone(), + operation, + }) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::Conflict(_)), "{error:?}"); + assert_eq!( + crate::http::WorkdirTransportError::from_workdir_error(&error).code, + crate::http::WorkdirTransportErrorCode::Conflict + ); + } + assert!(matches!( + provider.checkout_observe(path("observed")).await, + Err(WorkdirError::NotFound(_)) + )); + std::fs::create_dir(root.path().join("directory")).unwrap(); + let directory = provider.checkout_observe(path("directory")).await.unwrap(); + std::fs::remove_dir(root.path().join("directory")).unwrap(); + let error = provider + .checkout_execute(CheckoutRequest { + target: directory.path, + validator: directory.validator, + operation: CheckoutOperation::Create { + path: path("directory/new"), + content: b"new".to_vec(), + }, + }) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::Conflict(_))); + assert!(!root.path().join("directory").exists()); + // An observed parent replaced with a regular file makes the old + // descendant execution stale, while discovery reports no route. + std::fs::create_dir(root.path().join("parent")).unwrap(); + std::fs::write(root.path().join("parent/child"), "original").unwrap(); + let child = provider + .checkout_observe(path("parent/child")) + .await + .unwrap(); + std::fs::remove_dir_all(root.path().join("parent")).unwrap(); + std::fs::write(root.path().join("parent"), "replacement").unwrap(); + assert!(matches!( + provider.checkout_observe(path("parent/child")).await, + Err(WorkdirError::NotFound(_)) + )); + assert!(matches!( + provider + .checkout_execute(CheckoutRequest { + target: child.path, + validator: child.validator, + operation: CheckoutOperation::Read { + offset: 0, + limit: 10, + max_bytes: 100 + } + }) + .await, + Err(WorkdirError::Conflict(_)) + )); + std::fs::remove_file(root.path().join("parent")).unwrap(); + } + } + + #[tokio::test] + async fn checkout_inode_substitution_and_stale_content_are_conflicts() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("a"), "same").unwrap(); + let session = session(root.path()); + let original = + checked_read(&session, session.checkout_observe(path("a")).await.unwrap()).await; + std::fs::rename(root.path().join("a"), root.path().join("old")).unwrap(); + std::fs::write(root.path().join("a"), "same").unwrap(); + let new = session.checkout_observe(path("a")).await.unwrap(); + assert_ne!(original.observation.validator, new.validator); + assert!(matches!( + session + .checkout_execute(CheckoutRequest { + target: path("a"), + validator: original.observation.validator.clone(), + operation: CheckoutOperation::Write { + content: b"wrong".to_vec(), + expected_hash: hash(&original) + } + }) + .await, + Err(WorkdirError::Conflict(_)) + )); + assert_eq!( + std::fs::read_to_string(root.path().join("a")).unwrap(), + "same" + ); + std::fs::write(root.path().join("a"), "changed").unwrap(); + assert!(matches!( + session + .checkout_execute(CheckoutRequest { + target: path("a"), + validator: new.validator, + operation: CheckoutOperation::Read { + offset: 0, + limit: 20, + max_bytes: 100 + } + }) + .await, + Err(WorkdirError::Conflict(_)) + )); + } + #[tokio::test] + async fn checkout_hardlink_save_isolates_alias_and_rejects_symlinks_special_files() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("a"), "original").unwrap(); + std::fs::hard_link(root.path().join("a"), root.path().join("alias")).unwrap(); + std::os::unix::fs::symlink("a", root.path().join("link")).unwrap(); + let _socket = std::os::unix::net::UnixListener::bind(root.path().join("socket")).unwrap(); + let session = session(root.path()); + assert!(session.checkout_observe(path("link")).await.is_err()); + assert!(session.checkout_observe(path("socket")).await.is_err()); + let read = checked_read(&session, session.checkout_observe(path("a")).await.unwrap()).await; + session + .checkout_execute(CheckoutRequest { + target: path("a"), + validator: read.observation.validator.clone(), + operation: CheckoutOperation::Write { + content: b"saved".to_vec(), + expected_hash: hash(&read), + }, + }) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(root.path().join("alias")).unwrap(), + "original" + ); + } + #[tokio::test] + async fn checkout_create_is_strictly_beneath_directory_and_does_not_clobber() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("d")).unwrap(); + std::fs::write(root.path().join("d/existing"), "keep").unwrap(); + let session = session(root.path()); + for destination in ["sibling", "d", "directory/file"] { + let observation = session.checkout_observe(path("d")).await.unwrap(); + assert!( + session + .checkout_execute(CheckoutRequest { + target: path("d"), + validator: observation.validator, + operation: CheckoutOperation::Create { + path: path(destination), + content: vec![1] + } + }) + .await + .is_err() + ); + } + assert!(WorkdirPath::new("d/../sibling").is_err()); + assert!( + create_relative_path(&path("d"), &WorkdirPath::new_scoped("/d/new").unwrap()).is_err() + ); + let observation = session.checkout_observe(path("d")).await.unwrap(); + assert!( + session + .checkout_execute(CheckoutRequest { + target: path("d"), + validator: observation.validator, + operation: CheckoutOperation::Create { + path: path("d/existing"), + content: vec![1] + } + }) + .await + .is_err() + ); + assert_eq!( + std::fs::read_to_string(root.path().join("d/existing")).unwrap(), + "keep" + ); + let observation = session.checkout_observe(path("d")).await.unwrap(); + std::fs::write(root.path().join("d/concurrent"), "created externally").unwrap(); + assert!(matches!( + session + .checkout_execute(CheckoutRequest { + target: path("d"), + validator: observation.validator, + operation: CheckoutOperation::Create { + path: path("d/new"), + content: vec![1] + } + }) + .await, + Err(WorkdirError::Conflict(_)) + )); + assert!(!root.path().join("d/new").exists()); + } + #[tokio::test] + async fn checkout_external_approved_descriptor_survives_root_path_swap() { + let parent = tempfile::tempdir().unwrap(); + let selected = parent.path().join("selected"); + std::fs::create_dir(&selected).unwrap(); + std::fs::write(selected.join("a"), "approved").unwrap(); + let session = LocalWorkdirSession::external_read_write( + crate::Workdir::new("external"), + &selected, + crate::BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + let original = session.checkout_observe(path("a")).await.unwrap(); + let approved = parent.path().join("approved"); + std::fs::rename(&selected, &approved).unwrap(); + std::fs::create_dir(&selected).unwrap(); + std::fs::write(selected.join("a"), "replacement").unwrap(); + let read = checked_read(&session, original).await; + assert!(matches!(&read.output, CheckoutOutput::Read(r) if r.bytes == b"approved")); + session + .checkout_execute(CheckoutRequest { + target: path("a"), + validator: read.observation.validator.clone(), + operation: CheckoutOperation::Write { + content: b"saved".to_vec(), + expected_hash: hash(&read), + }, + }) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(approved.join("a")).unwrap(), + "saved" + ); + assert_eq!( + std::fs::read_to_string(selected.join("a")).unwrap(), + "replacement" + ); + } + #[tokio::test] + async fn checkout_scope_readonly_write_leases_and_revocation_attenuate_authority() { + use crate::{ + WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission as Permission, + WorkdirToolScopeRule, + }; + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("child")).unwrap(); + std::fs::write(root.path().join("child/a"), "child").unwrap(); + std::fs::write(root.path().join("outside"), "outside").unwrap(); + let source = Arc::new(session(root.path())); + let broker = WorkdirToolBroker::new(source.clone()); + let lease = broker + .scope(WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: path("child"), + permission: Permission::Write, + recursive: true, + symlink_policy: Default::default(), + }], + cwd: path("child"), + command: false, + }) + .await + .unwrap(); + let child = lease.tool_session(); + assert!(child.checkout_observe(path("outside")).await.is_err()); + let observation = child.checkout_observe(path("a")).await.unwrap(); + assert!( + observation + .capabilities + .supports(WorkdirSessionCapability::Write) + ); + let parent_observation = broker.checkout_observe(path("child/a")).await.unwrap(); + assert!( + !parent_observation + .capabilities + .supports(WorkdirSessionCapability::Write) + ); + let read = checked_read(&*child, observation).await; + assert!( + broker + .checkout_execute(CheckoutRequest { + target: path("child/a"), + validator: parent_observation.validator, + operation: CheckoutOperation::Write { + content: b"blocked".to_vec(), + expected_hash: hash(&read) + } + }) + .await + .is_err() + ); + let readonly = crate::ReadOnlyWorkdirSession::new(child.clone()); + assert!( + !readonly + .checkout_observe(path("a")) + .await + .unwrap() + .capabilities + .supports(WorkdirSessionCapability::Write) + ); + assert!( + readonly + .checkout_execute(CheckoutRequest { + target: path("a"), + validator: read.observation.validator.clone(), + operation: CheckoutOperation::Edit { + old_string: "child".into(), + new_string: "bad".into(), + replace_all: false, + expected_hash: hash(&read) + } + }) + .await + .is_err() + ); + let directory = child.checkout_observe(WorkdirPath::root()).await.unwrap(); + let created = child + .checkout_execute(CheckoutRequest { + target: WorkdirPath::root(), + validator: directory.validator, + operation: CheckoutOperation::Create { + path: path("fresh"), + content: b"fresh".to_vec(), + }, + }) + .await + .unwrap(); + assert_eq!(created.observation.path, WorkdirPath::root()); + assert_eq!(created.observation.kind, EntryKind::Directory); + assert_eq!( + created.observation, + child.checkout_observe(WorkdirPath::root()).await.unwrap() + ); + assert_eq!( + std::fs::read(root.path().join("child/fresh")).unwrap(), + b"fresh" + ); + lease.close().await.unwrap(); + assert!(matches!( + child + .checkout_execute(CheckoutRequest { + target: path("a"), + validator: read.observation.validator.clone(), + operation: CheckoutOperation::Read { + offset: 0, + limit: 10, + max_bytes: 100 + } + }) + .await, + Err(WorkdirError::SessionClosed) + )); + } + #[tokio::test] + async fn shared_search_skips_non_utf8_names_without_colliding_with_unicode_paths() { + use crate::{GlobRequest, GrepOutputMode, GrepRequest, ListRequest}; + use std::os::unix::ffi::OsStringExt; + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join(std::ffi::OsString::from_vec( + b"replacement\xff.txt".to_vec(), + )), + "needle invalid", + ) + .unwrap(); + std::fs::write(root.path().join("replacement\u{fffd}.txt"), "needle valid").unwrap(); + std::fs::write(root.path().join("é.txt"), "needle composed").unwrap(); + std::fs::write(root.path().join("e\u{301}.txt"), "needle decomposed").unwrap(); + let invalid_dir = root + .path() + .join(std::ffi::OsString::from_vec(b"directory\xff".to_vec())); + std::fs::create_dir(&invalid_dir).unwrap(); + std::fs::write(invalid_dir.join("child.txt"), "needle hidden invalid child").unwrap(); + std::fs::create_dir(root.path().join("directory\u{fffd}")).unwrap(); + std::fs::write( + root.path().join("directory\u{fffd}/child.txt"), + "needle visible valid child", + ) + .unwrap(); + let local = session(root.path()); + let external = LocalWorkdirSession::external_read_only( + crate::Workdir::new("external-search"), + root.path(), + crate::BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + for provider in [&local as &dyn WorkdirSession, &external] { + let listed = provider + .list(ListRequest { + path: WorkdirPath::root(), + limit: 100, + }) + .await + .unwrap(); + assert_eq!(listed.entries.len(), 4); + assert_eq!( + listed + .entries + .iter() + .filter(|e| e.path == path("replacement\u{fffd}.txt")) + .count(), + 1 + ); + let globbed = provider + .glob(GlobRequest { + path: WorkdirPath::root(), + pattern: "**/*.txt".into(), + limit: 100, + }) + .await + .unwrap(); + let mut expected = vec![ + path("directory\u{fffd}/child.txt"), + path("replacement\u{fffd}.txt"), + path("é.txt"), + path("e\u{301}.txt"), + ]; + expected.sort_by(|a, b| a.as_str().cmp(b.as_str())); + assert_eq!(globbed.paths, expected); + for mode in [ + GrepOutputMode::Content, + GrepOutputMode::FilesWithMatches, + GrepOutputMode::Count, + ] { + let grep = provider + .grep(GrepRequest { + path: WorkdirPath::root(), + pattern: "needle".into(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: mode, + offset: 0, + limit: 100, + }) + .await + .unwrap(); + let mut paths = grep.paths; + paths.sort_by(|a, b| a.as_str().cmp(b.as_str())); + assert_eq!(paths, expected); + assert_eq!(grep.matched_files, 4); + assert_eq!(grep.match_count, 4); + assert!(!grep.output.contains("needle invalid")); + assert!(!grep.output.contains("hidden invalid child")); + } + } + } + + #[tokio::test] + async fn shared_grep_typed_paths_follow_returned_modes_offset_limit_and_odd_names() { + use crate::{GrepOutputMode, GrepRequest}; + let root = tempfile::tempdir().unwrap(); + for name in ["a.txt", "b.txt", "c.txt", "line\nfile:1.txt"] { + std::fs::write(root.path().join(name), "needle").unwrap(); + } + let session = session(root.path()); + for mode in [ + GrepOutputMode::Content, + GrepOutputMode::FilesWithMatches, + GrepOutputMode::Count, + ] { + let request = GrepRequest { + path: WorkdirPath::root(), + pattern: "needle".into(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: mode, + offset: 1, + limit: 1, + }; + let result = session.grep(request.clone()).await.unwrap(); + assert_eq!(result.paths.len(), 1); + assert_eq!(result.matched_files, 1); + assert!(result.output.contains(result.paths[0].as_str())); + let empty = session + .grep(GrepRequest { + limit: 0, + ..request.clone() + }) + .await + .unwrap(); + assert!(empty.output.is_empty()); + assert!(empty.paths.is_empty()); + let direct = session + .grep(GrepRequest { + path: path("line\nfile:1.txt"), + offset: 0, + ..request + }) + .await + .unwrap(); + assert_eq!(direct.paths, vec![path("line\nfile:1.txt")]); + } + let legacy: crate::GrepResult = serde_json::from_value( + serde_json::json!({"output":"", "match_count":0,"matched_files":0,"truncated":false}), + ) + .unwrap(); + assert!(legacy.paths.is_empty()); + } + + #[tokio::test] + async fn checkout_partial_parent_creation_is_outcome_unknown() { + let root = tempfile::tempdir().unwrap(); + let session = session(root.path()); + let observation = session.checkout_observe(WorkdirPath::root()).await.unwrap(); + // The final name cannot be saved, but an earlier mkdir has effects. + let destination = path(&format!("made/{}", "x".repeat(300))); + let error = session + .checkout_execute(CheckoutRequest { + target: observation.path, + validator: observation.validator, + operation: CheckoutOperation::Create { + path: destination, + content: b"new".to_vec(), + }, + }) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::OutcomeUnknown(_))); + assert!(root.path().join("made").is_dir()); + } + + #[tokio::test] + async fn checkout_http_dispatch_and_external_dto_bounds() { + use crate::external::{ExternalWorkdirOperation, ExternalWorkdirOperationResult}; + use crate::http::{ + WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Result, + WorkdirTransportError, WorkdirTransportErrorCode, dispatch_workdir_session_operation, + }; + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("a"), "contents").unwrap(); + let session = session(root.path()); + let Result::CheckoutObserve(observation) = + dispatch_workdir_session_operation(&session, Op::CheckoutObserve(path("a"))) + .await + .unwrap() + else { + panic!() + }; + let request = CheckoutRequest { + target: path("a"), + validator: observation.validator, + operation: CheckoutOperation::Read { + offset: 0, + limit: 10, + max_bytes: 100, + }, + }; + assert!(ExternalWorkdirOperation::try_from(Op::CheckoutExecute(request.clone())).is_ok()); + let response = + dispatch_workdir_session_operation(&session, Op::CheckoutExecute(request.clone())) + .await + .unwrap(); + assert!(matches!(response, Result::CheckoutExecute(_))); + assert!(ExternalWorkdirOperationResult::try_from(response.clone()).is_ok()); + assert_eq!( + serde_json::from_value::(serde_json::to_value(response).unwrap()) + .unwrap() + .clone(), + dispatch_workdir_session_operation(&session, Op::CheckoutExecute(request.clone())) + .await + .unwrap() + ); + let mut oversized = request.clone(); + oversized.validator = vec![0; 257]; + assert!(ExternalWorkdirOperation::try_from(Op::CheckoutExecute(oversized)).is_err()); + let mut oversized = request; + oversized.operation = CheckoutOperation::Read { + offset: 0, + limit: 1, + max_bytes: 1024 * 1024 + 1, + }; + assert!(ExternalWorkdirOperation::try_from(Op::CheckoutExecute(oversized)).is_err()); + let grep = crate::GrepResult { + paths: vec![path("a")], + output: "a\n".into(), + match_count: 1, + matched_files: 1, + truncated: false, + }; + assert!(ExternalWorkdirOperationResult::try_from(Result::Grep(grep.clone())).is_ok()); + let invalid = crate::GrepResult { + paths: vec![WorkdirPath::new_scoped("/outside").unwrap()], + ..grep.clone() + }; + assert!(ExternalWorkdirOperationResult::try_from(Result::Grep(invalid)).is_err()); + let oversized = crate::GrepResult { + paths: vec![path("a"); crate::external::MAX_EXTERNAL_RESULT_ITEMS + 1], + ..grep + }; + assert!(ExternalWorkdirOperationResult::try_from(Result::Grep(oversized)).is_err()); + let unknown = WorkdirTransportError::from_workdir_error(&WorkdirError::OutcomeUnknown( + "/secret/host/path".into(), + )); + assert_eq!(unknown.code, WorkdirTransportErrorCode::OutcomeUnknown); + assert!(!unknown.message.contains("secret")); + assert!(matches!( + unknown.into_workdir_error(), + WorkdirError::OutcomeUnknown(_) + )); + } +} diff --git a/crates/workdir/src/checkout_search_tests.rs b/crates/workdir/src/checkout_search_tests.rs new file mode 100644 index 000000000..e41f78c2a --- /dev/null +++ b/crates/workdir/src/checkout_search_tests.rs @@ -0,0 +1,418 @@ +#![cfg(target_os = "linux")] +use crate::*; +use std::sync::Arc; +fn path(value: &str) -> WorkdirPath { + WorkdirPath::new(value).unwrap() +} +fn rule(value: &str, recursive: bool) -> WorkdirToolScopeRule { + WorkdirToolScopeRule { + target: path(value), + recursive, + permission: WorkdirToolScopePermission::Read, + symlink_policy: Default::default(), + } +} +fn scope(rules: Vec, cwd: &str) -> WorkdirToolScope { + WorkdirToolScope { + rules, + cwd: path(cwd), + command: false, + } +} +fn grep(mode: GrepOutputMode) -> GrepRequest { + GrepRequest { + path: WorkdirPath::root(), + pattern: "needle".into(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: mode, + offset: 0, + limit: 100, + } +} +fn fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(root.path().join("child/keep")).unwrap(); + std::fs::create_dir_all(root.path().join("child/denied/deep")).unwrap(); + std::fs::write(root.path().join("child/a.txt"), "needle allowed direct").unwrap(); + std::fs::write( + root.path().join("child/keep/yes.txt"), + "needle allowed subtree", + ) + .unwrap(); + std::fs::write( + root.path().join("child/denied/deep/secret.txt"), + "needle secret", + ) + .unwrap(); + // Opening this out-of-scope source would exhaust the shared grep source bound. + std::fs::File::create(root.path().join("child/denied/huge.txt")) + .unwrap() + .set_len(65 * 1024 * 1024) + .unwrap(); + std::fs::write(root.path().join("outside.txt"), "needle outside").unwrap(); + root +} +async fn verify_sparse(session: &dyn WorkdirSession) { + let listed = session + .list(ListRequest { + path: WorkdirPath::root(), + limit: 100, + }) + .await + .unwrap(); + assert_eq!( + listed + .entries + .iter() + .map(|entry| entry.path.clone()) + .collect::>(), + vec![path("denied"), path("keep"), path("a.txt")] + ); + let empty = session + .list(ListRequest { + path: path("denied"), + limit: 100, + }) + .await + .unwrap(); + assert!(empty.entries.is_empty()); + assert_eq!(empty.total_entries, 0); + let globbed = session + .glob(GlobRequest { + path: WorkdirPath::root(), + pattern: "**/*.txt".into(), + limit: 100, + }) + .await + .unwrap(); + assert_eq!(globbed.paths, vec![path("a.txt"), path("keep/yes.txt")]); + for mode in [ + GrepOutputMode::Content, + GrepOutputMode::Count, + GrepOutputMode::FilesWithMatches, + ] { + let result = session.grep(grep(mode)).await.unwrap(); + let mut paths = result.paths; + paths.sort_by(|a, b| a.as_str().cmp(b.as_str())); + assert_eq!(paths, vec![path("a.txt"), path("keep/yes.txt")]); + assert!(!result.output.contains("child/")); + assert!(!result.output.contains("secret")); + assert!(!result.output.contains("outside")); + assert_eq!(result.matched_files, 2); + } + let CheckoutSearchResult::List(native) = session + .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List( + ListRequest { + path: WorkdirPath::root(), + limit: 100, + }, + ))) + .await + .unwrap() + else { + panic!() + }; + assert_eq!(listed, native); +} +#[tokio::test] +async fn checkout_search_nested_sparse_nonrecursive_scopes_rebase_and_exclude_content() { + let root = fixture(); + let source = Arc::new(LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + )); + let parent = WorkdirToolBroker::new(source); + let first = parent + .scope(scope(vec![rule("child", true)], "child")) + .await + .unwrap(); + let nested = first + .scope(scope( + vec![rule("child", false), rule("child/keep", true)], + "child", + )) + .await + .unwrap(); + verify_sparse(&*nested.tool_session()).await; + // Nested incoming layers cannot replace the session's own sparse layer. + let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest { + path: WorkdirPath::root(), + pattern: "**/*.txt".into(), + limit: 100, + })); + request.scope_layers.push(vec![rule("", true)]); + let CheckoutSearchResult::Glob(result) = nested + .tool_session() + .checkout_search(request) + .await + .unwrap() + else { + panic!() + }; + assert_eq!(result.paths, vec![path("a.txt"), path("keep/yes.txt")]); + nested.close().await.unwrap(); + assert!(matches!( + nested + .tool_session() + .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List( + ListRequest { + path: WorkdirPath::root(), + limit: 100 + } + ))) + .await, + Err(WorkdirError::SessionClosed) + )); +} +#[tokio::test] +async fn checkout_search_composes_actual_nested_wrappers_and_output_root() { + let root = fixture(); + let parent = WorkdirToolBroker::new(Arc::new(LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + ))); + let first = parent + .scope(scope(vec![rule("child", true)], "child")) + .await + .unwrap(); + // Two broker wrappers, not only leases that share a source. + let stacked = WorkdirToolBroker::new(first.tool_session()); + let mut request = + CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(grep(GrepOutputMode::Content))); + *request.operation.path_mut() = path("keep"); + request.output_root = path("keep"); + request.scope_layers = vec![vec![rule("keep", false)]]; + let CheckoutSearchResult::Grep(result) = stacked.checkout_search(request).await.unwrap() else { + panic!() + }; + assert_eq!(result.paths, vec![path("yes.txt")]); + assert!(result.output.starts_with("yes.txt\n")); + // The outer context is relative to the inner session; no duplicated child/. + let CheckoutSearchResult::List(result) = stacked + .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List( + ListRequest { + path: path("keep"), + limit: 100, + }, + ))) + .await + .unwrap() + else { + panic!() + }; + assert_eq!(result.entries[0].path, path("keep/yes.txt")); + // A second actual scoped wrapper has its own cwd and nonrecursive layer. + // Logical rules avoid requesting resolved authorization from a wrapper + // that conservatively does not expose host-side resolution authority. + std::fs::create_dir(root.path().join("child/keep/deep")).unwrap(); + std::fs::write( + root.path().join("child/keep/deep/secret"), + "needle hidden nested", + ) + .unwrap(); + let mut own_rule = rule("keep", false); + own_rule.symlink_policy = manifest::SymlinkPolicy::Logical; + let second = stacked.scope(scope(vec![own_rule], "keep")).await.unwrap(); + let result = second + .tool_session() + .grep(grep(GrepOutputMode::Content)) + .await + .unwrap(); + assert_eq!(result.paths, vec![path("yes.txt")]); + assert!(result.output.starts_with("yes.txt\n")); + assert!(!result.output.contains("hidden nested")); + let entries = second + .tool_session() + .list(ListRequest { + path: WorkdirPath::root(), + limit: 100, + }) + .await + .unwrap() + .entries; + assert_eq!( + entries + .iter() + .map(|entry| entry.path.clone()) + .collect::>(), + vec![path("deep"), path("yes.txt")] + ); + assert!( + second + .tool_session() + .list(ListRequest { + path: path("deep"), + limit: 100 + }) + .await + .unwrap() + .entries + .is_empty() + ); +} +#[tokio::test] +async fn checkout_search_external_keeps_approved_root_and_sparse_authority() { + let root = fixture(); + let external = LocalWorkdirSession::external_read_only( + Workdir::new("external-search"), + root.path(), + BoundedReadLimits::EXTERNAL_DEFAULT, + ) + .unwrap(); + let broker = WorkdirToolBroker::new(Arc::new(external)); + let lease = broker + .scope(scope( + vec![rule("child", false), rule("child/keep", true)], + "child", + )) + .await + .unwrap(); + let parent = tempfile::tempdir().unwrap(); + let approved = parent.path().join("approved"); + std::fs::rename(root.path(), &approved).unwrap(); + std::fs::create_dir(root.path()).unwrap(); + std::fs::create_dir(root.path().join("child")).unwrap(); + std::fs::write( + root.path().join("child/replacement.txt"), + "needle replacement", + ) + .unwrap(); + verify_sparse(&*lease.tool_session()).await; +} +#[tokio::test] +async fn checkout_search_scope_layers_intersect_before_list_and_grep() { + let root = fixture(); + let source = LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + ); + let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest { + path: path("child"), + limit: 100, + })); + request.output_root = path("child"); + request.scope_layers = vec![ + vec![rule("child", false), rule("child/keep", true)], + vec![rule("child", false)], + ]; + let CheckoutSearchResult::List(result) = source.checkout_search(request.clone()).await.unwrap() + else { + panic!() + }; + assert_eq!(result.entries.len(), 3); + request.operation = CheckoutSearchOperation::Grep(GrepRequest { + path: path("child"), + ..grep(GrepOutputMode::Content) + }); + let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else { + panic!() + }; + assert_eq!(result.paths, vec![path("a.txt")]); + assert!(!result.output.contains("subtree")); +} +#[tokio::test] +async fn checkout_search_wire_dto_bounds_and_provider_capabilities() { + use crate::external::{ExternalWorkdirOperation, ExternalWorkdirOperationResult}; + use crate::http::{WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Output}; + let root = fixture(); + let source = LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + ); + let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest { + path: path("child/keep"), + limit: 100, + })); + let wire = Op::CheckoutSearch(request.clone()); + assert!(ExternalWorkdirOperation::try_from(wire.clone()).is_ok()); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&wire).unwrap()).unwrap(), + wire + ); + let result = crate::http::dispatch_workdir_session_operation(&source, wire) + .await + .unwrap(); + assert!(matches!( + &result, + Output::CheckoutSearch(CheckoutSearchResult::List(_)) + )); + assert!(ExternalWorkdirOperationResult::try_from(result.clone()).is_ok()); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&result).unwrap()).unwrap(), + result + ); + let mut bad = request.clone(); + bad.output_root = WorkdirPath::new_scoped("/host").unwrap(); + assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err()); + let mut bad = request.clone(); + bad.scope_layers = vec![vec![rule("", true)]; 17]; + assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err()); + let mut bad = request.clone(); + bad.scope_layers = vec![vec![rule("", true); 257]]; + assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err()); + let mut bad = request.clone(); + bad.scope_layers = vec![vec![rule("", true); 65]]; + assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err()); + let mut bad = request; + bad.scope_layers = vec![Vec::new()]; + assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err()); + let bad_result = Output::CheckoutSearch(CheckoutSearchResult::Glob(GlobResult { + paths: vec![WorkdirPath::new_scoped("/host").unwrap()], + truncated: false, + })); + assert!(ExternalWorkdirOperationResult::try_from(bad_result).is_err()); +} + +#[tokio::test] +async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_escape() { + let root = fixture(); + let current = manifest::Scope::from_config(&manifest::ScopeConfig { + allow: vec![manifest::ScopeRule { + target: root.path().join("child"), + permission: manifest::Permission::Read, + recursive: false, + symlink_policy: Default::default(), + }], + deny: Vec::new(), + }) + .unwrap(); + let source = LocalWorkdirSession::new(current, root.path().to_path_buf()); + let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest { + path: path("child"), + ..grep(GrepOutputMode::Content) + })); + request.output_root = path("child"); + // A broad caller layer cannot enlarge the provider's nonrecursive authority. + request.scope_layers = vec![vec![rule("", true)]]; + let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else { + panic!() + }; + assert_eq!(result.paths, vec![path("a.txt")]); + let CheckoutSearchResult::List(result) = source + .checkout_search(CheckoutSearchRequest { + operation: CheckoutSearchOperation::List(ListRequest { + path: path("child/denied"), + limit: 100, + }), + output_root: path("child"), + scope_layers: Vec::new(), + }) + .await + .unwrap() + else { + panic!() + }; + assert!(result.entries.is_empty()); + std::os::unix::fs::symlink("../outside.txt", root.path().join("child/link.txt")).unwrap(); + let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest { + path: path("child/link.txt"), + ..grep(GrepOutputMode::Content) + })); + request.output_root = path("child"); + assert!(source.checkout_search(request).await.is_err()); +} diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs index c94582867..1b37cda23 100644 --- a/crates/workdir/src/external.rs +++ b/crates/workdir/src/external.rs @@ -149,6 +149,56 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<() WorkdirSessionOperation::ScopeRulesOverlap(request) => { is_root_relative(&request.left.target) && is_root_relative(&request.right.target) } + WorkdirSessionOperation::CheckoutSearch(request) => { + request.validate().is_ok() + && request.scope_layers.iter().map(Vec::len).sum::() + <= MAX_EXTERNAL_SCOPE_RULES + && validate_external_operation(&match &request.operation { + crate::CheckoutSearchOperation::List(r) => { + WorkdirSessionOperation::List(r.clone()) + } + crate::CheckoutSearchOperation::Glob(r) => { + WorkdirSessionOperation::Glob(r.clone()) + } + crate::CheckoutSearchOperation::Grep(r) => { + WorkdirSessionOperation::Grep(r.clone()) + } + }) + .is_ok() + } + WorkdirSessionOperation::CheckoutObserve(path) => { + is_root_relative(path) && crate::checkout::validate_path(path).is_ok() + } + WorkdirSessionOperation::CheckoutExecute(request) => { + is_root_relative(&request.target) + && crate::checkout::validate_path(&request.target).is_ok() + && !request.validator.is_empty() + && request.validator.len() <= 256 + && match &request.operation { + crate::CheckoutOperation::Read { + limit, max_bytes, .. + } => { + *limit <= 1_000_000 + && *max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + } + crate::CheckoutOperation::Write { content, .. } => { + content.len() <= MAX_EXTERNAL_WRITE_BYTES + } + crate::CheckoutOperation::Edit { + old_string, + new_string, + .. + } => old_string + .len() + .checked_add(new_string.len()) + .is_some_and(|size| size <= MAX_EXTERNAL_WRITE_BYTES), + crate::CheckoutOperation::Create { path, content } => { + is_root_relative(path) + && crate::checkout::create_relative_path(&request.target, path).is_ok() + && content.len() <= MAX_EXTERNAL_WRITE_BYTES + } + } + } WorkdirSessionOperation::Stat(request) => is_root_relative(&request.path), WorkdirSessionOperation::Read(request) => { is_root_relative(&request.path) @@ -272,10 +322,59 @@ fn result_paths_fit<'a>(paths: impl IntoIterator) -> boo .is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES) } +fn valid_checkout_observation(observation: &crate::CheckoutObservation) -> bool { + is_root_relative(&observation.path) + && !observation.validator.is_empty() + && observation.validator.len() <= 256 + && matches!( + observation.kind, + crate::EntryKind::File | crate::EntryKind::Directory + ) + && (observation.kind == crate::EntryKind::Directory + || observation.size <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes) +} + fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<(), String> { let result_is_valid = match result { WorkdirSessionOperationResult::AuthorizeScope | WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true, + WorkdirSessionOperationResult::CheckoutSearch(result) => { + validate_external_result(&match result { + crate::CheckoutSearchResult::List(r) => { + WorkdirSessionOperationResult::List(r.clone()) + } + crate::CheckoutSearchResult::Glob(r) => { + WorkdirSessionOperationResult::Glob(r.clone()) + } + crate::CheckoutSearchResult::Grep(r) => { + WorkdirSessionOperationResult::Grep(r.clone()) + } + }) + .is_ok() + } + WorkdirSessionOperationResult::CheckoutObserve(observation) => { + valid_checkout_observation(observation) + } + WorkdirSessionOperationResult::CheckoutExecute(result) => { + valid_checkout_observation(&result.observation) + && match &result.output { + crate::CheckoutOutput::Read(read) => { + read.path == result.observation.path + && validate_external_result(&WorkdirSessionOperationResult::Read( + read.clone(), + )) + .is_ok() + } + crate::CheckoutOutput::Write(write) => validate_external_result( + &WorkdirSessionOperationResult::Write(write.clone()), + ) + .is_ok(), + crate::CheckoutOutput::Edit(edit) => { + validate_external_result(&WorkdirSessionOperationResult::Edit(edit.clone())) + .is_ok() + } + } + } WorkdirSessionOperationResult::Stat(result) => is_root_relative(&result.path), WorkdirSessionOperationResult::Read(result) => { is_root_relative(&result.path) @@ -307,6 +406,9 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<() result.output.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes && result.match_count <= MAX_EXTERNAL_RESULT_ITEMS && result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS + && result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS + && result.paths.iter().all(is_root_relative) + && result_paths_fit(&result.paths) } WorkdirSessionOperationResult::Write(result) => { result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES @@ -821,6 +923,7 @@ mod tests { assert!(ExternalWorkdirOperation::try_from(unbounded).is_err()); let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult { + paths: Vec::new(), output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1), match_count: 1, matched_files: 1, diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 4017056a0..1050ae3d9 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -64,6 +64,9 @@ pub struct OpenWorkdirSessionResponse { pub enum WorkdirSessionOperation { AuthorizeScope(WorkdirScopeAuthorizationRequest), ScopeRulesOverlap(WorkdirScopeOverlapRequest), + CheckoutSearch(crate::CheckoutSearchRequest), + CheckoutObserve(crate::WorkdirPath), + CheckoutExecute(crate::CheckoutRequest), Stat(StatRequest), Read(ReadRequest), ReadBytes(ReadBytesRequest), @@ -96,6 +99,9 @@ pub struct WorkdirSessionOperationRequest { pub enum WorkdirSessionOperationResult { AuthorizeScope, ScopeRulesOverlap { overlaps: bool }, + CheckoutSearch(crate::CheckoutSearchResult), + CheckoutObserve(crate::CheckoutObservation), + CheckoutExecute(crate::CheckoutResult), Stat(StatResult), Read(ReadResult), ReadBytes(ReadBytesResult), @@ -129,6 +135,18 @@ pub async fn dispatch_workdir_session_operation( .scope_rules_overlap(request) .await .map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }), + WorkdirSessionOperation::CheckoutSearch(request) => session + .checkout_search(request) + .await + .map(WorkdirSessionOperationResult::CheckoutSearch), + WorkdirSessionOperation::CheckoutObserve(path) => session + .checkout_observe(path) + .await + .map(WorkdirSessionOperationResult::CheckoutObserve), + WorkdirSessionOperation::CheckoutExecute(request) => session + .checkout_execute(request) + .await + .map(WorkdirSessionOperationResult::CheckoutExecute), WorkdirSessionOperation::Stat(request) => session .stat(request) .await @@ -186,6 +204,7 @@ pub async fn dispatch_workdir_session_operation( pub enum WorkdirTransportErrorCode { NotFound, Conflict, + OutcomeUnknown, Unsupported, InvalidRequest, Denied, @@ -208,6 +227,7 @@ impl WorkdirTransportErrorCode { match self { Self::NotFound => "not_found", Self::Conflict => "conflict", + Self::OutcomeUnknown => "outcome_unknown", Self::Unsupported => "unsupported", Self::InvalidRequest => "invalid_request", Self::Denied => "denied", @@ -239,7 +259,7 @@ impl WorkdirTransportErrorCode { | Self::IsDirectory | Self::SymlinkDirectoryNotTraversed => 400, Self::Unavailable => 503, - Self::Io | Self::Internal => 500, + Self::Io | Self::Internal | Self::OutcomeUnknown => 500, Self::Transport => 502, } } @@ -256,6 +276,10 @@ impl WorkdirTransportError { pub fn from_workdir_error(error: &WorkdirError) -> Self { use WorkdirTransportErrorCode as Code; let (code, message) = match error { + WorkdirError::OutcomeUnknown(_) => ( + Code::OutcomeUnknown, + "Workdir operation may have effects; inspect before retrying", + ), WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"), WorkdirError::Conflict(_) => ( Code::Conflict, @@ -330,6 +354,9 @@ impl WorkdirTransportError { match self.code { Code::NotFound => WorkdirError::NotFound("".into()), Code::Conflict => WorkdirError::Conflict(self.message), + Code::OutcomeUnknown => WorkdirError::OutcomeUnknown( + "Workdir operation may have effects; inspect before retrying".into(), + ), Code::Unsupported => WorkdirError::UnsupportedOperation(self.message), Code::InvalidRequest => WorkdirError::InvalidArgument(self.message), Code::Denied => WorkdirError::Denied(self.message), @@ -564,6 +591,114 @@ mod client { } } + async fn checkout_search( + &self, + request: crate::CheckoutSearchRequest, + ) -> Result { + request.validate()?; + let expected = request.operation.capability(); + match self + .operate(WorkdirSessionOperation::CheckoutSearch(request)) + .await? + { + WorkdirSessionOperationResult::CheckoutSearch(result) + if matches!( + (&result, expected), + ( + crate::CheckoutSearchResult::List(_), + crate::WorkdirSessionCapability::Read + ) | ( + crate::CheckoutSearchResult::Glob(_), + crate::WorkdirSessionCapability::Glob + ) | ( + crate::CheckoutSearchResult::Grep(_), + crate::WorkdirSessionCapability::Grep + ) + ) => + { + crate::external::ExternalWorkdirOperationResult::try_from( + WorkdirSessionOperationResult::CheckoutSearch(result.clone()), + ) + .map_err(|_| Self::mismatch("bounded checkout_search"))?; + Ok(result) + } + _ => Err(Self::mismatch("checkout_search")), + } + } + + async fn checkout_observe( + &self, + path: crate::WorkdirPath, + ) -> Result { + match self + .operate(WorkdirSessionOperation::CheckoutObserve(path.clone())) + .await? + { + WorkdirSessionOperationResult::CheckoutObserve(mut result) + if result.path == path + && !result.validator.is_empty() + && result.validator.len() <= 256 => + { + result.capabilities = result.capabilities.intersection(self.capabilities()); + Ok(result) + } + _ => Err(Self::mismatch("checkout_observe")), + } + } + async fn checkout_execute( + &self, + request: crate::CheckoutRequest, + ) -> Result { + let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read; + let expected_path = request.target.clone(); + let expected_output = request.operation.capability(); + match self + .operate(WorkdirSessionOperation::CheckoutExecute(request)) + .await + { + Ok(WorkdirSessionOperationResult::CheckoutExecute(mut result)) + if result.observation.path == expected_path + && !result.observation.validator.is_empty() + && result.observation.validator.len() <= 256 + && match (&result.output, expected_output) { + ( + crate::CheckoutOutput::Read(read), + crate::WorkdirSessionCapability::Read, + ) => { + read.path == expected_path + && read.bytes.len() + <= crate::BoundedReadLimits::EXTERNAL_DEFAULT + .max_response_bytes + } + ( + crate::CheckoutOutput::Write(_), + crate::WorkdirSessionCapability::Write, + ) + | ( + crate::CheckoutOutput::Edit(_), + crate::WorkdirSessionCapability::Edit, + ) => true, + _ => false, + } => + { + result.observation.capabilities = result + .observation + .capabilities + .intersection(self.capabilities()); + Ok(result) + } + Ok(_) if mutation => Err(WorkdirError::OutcomeUnknown( + "mismatched response after checkout mutation".into(), + )), + Ok(_) => Err(Self::mismatch("checkout_execute")), + Err(WorkdirError::Unavailable(_) | WorkdirError::Transport(_)) if mutation => { + Err(WorkdirError::OutcomeUnknown( + "checkout transport failed; effects may have occurred".into(), + )) + } + Err(error) => Err(error), + } + } async fn stat(&self, request: StatRequest) -> Result { match self.operate(WorkdirSessionOperation::Stat(request)).await? { WorkdirSessionOperationResult::Stat(result) => Ok(result), diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index a390b1084..29f265784 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -5,6 +5,11 @@ //! bound to one Worker. Tools consume sessions; they do not own Workdir //! materialization or cleanup. +pub mod checkout; +pub use checkout::{ + CheckoutObservation, CheckoutOperation, CheckoutOutput, CheckoutRequest, CheckoutResult, + CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult, +}; pub mod external; pub mod http; mod local; @@ -13,6 +18,8 @@ mod router; mod scope; pub mod workspace; +#[cfg(test)] +mod checkout_search_tests; #[cfg(test)] mod external_local_tests; @@ -219,6 +226,31 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync { )) } + async fn checkout_search( + &self, + _request: CheckoutSearchRequest, + ) -> Result { + Err(WorkdirError::UnsupportedOperation( + "scoped checkout traversal".into(), + )) + } + + async fn checkout_observe( + &self, + _path: WorkdirPath, + ) -> Result { + Err(WorkdirError::UnsupportedOperation( + "checkout observation".into(), + )) + } + async fn checkout_execute( + &self, + _request: CheckoutRequest, + ) -> Result { + Err(WorkdirError::UnsupportedOperation( + "checked checkout execution".into(), + )) + } async fn stat(&self, request: StatRequest) -> Result; async fn read(&self, request: ReadRequest) -> Result; async fn read_bytes( @@ -289,6 +321,10 @@ pub enum WorkdirError { #[error("{0}")] Conflict(String), + /// Effects may have happened. Do not automatically retry the operation. + #[error("Workdir operation outcome is unknown: {0}")] + OutcomeUnknown(String), + #[error("unknown Workdir session command: {0}")] UnknownCommand(String), diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 33850729b..417f6fe88 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -204,7 +204,7 @@ impl CommandTelemetry { } } -#[derive(Debug)] +#[derive(Debug, Clone)] struct ScopeAccess { scope: Arc, root: PathBuf, @@ -314,13 +314,12 @@ impl fs_operation::FsAccessPolicy for ScopeAccess { "path is not a directory", )); } - let root = self - .pinned_root - .as_ref() - .expect("confined traversal requires a pinned root") - .try_clone()?; - let path = PathBuf::from(format!("/proc/self/fd/{}", root.as_raw_fd())); - return Ok(Some(fs_operation::FsTraversalRoot::new(path, root))); + let path = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd())); + return Ok(Some(fs_operation::FsTraversalRoot::new_at( + path, + directory, + resolved.to_path_buf(), + ))); } #[cfg(not(target_os = "linux"))] Err(std::io::Error::new( @@ -443,6 +442,118 @@ impl fs_operation::FsAccessPolicy for ScopeAccess { } } +/// A read-only, conjunctively narrowed descriptor policy for native searches. +#[derive(Debug, Clone)] +struct SearchAccess { + inner: ScopeAccess, + layers: Vec>, + output_root: PathBuf, + source_allows: Arc>, +} +impl SearchAccess { + fn logical_path(&self, path: &Path) -> Option { + WorkdirPath::new(path.strip_prefix(&self.inner.root).ok()?.to_str()?).ok() + } + fn read_allowed(&self, logical: &Path, resolved: &Path) -> bool { + use fs_operation::FsAccessPolicy; + let Some(path) = self.logical_path(logical) else { + return false; + }; + logical.starts_with(&self.output_root) + && self.inner.is_readable_paths(logical, resolved) + && self.layers.iter().all(|layer| { + layer.iter().any(|rule| { + crate::scope::rule_allows_path(rule, &path, WorkdirToolScopePermission::Read) + }) + }) + } + fn enumeration_allowed(&self, logical: &Path, resolved: &Path) -> bool { + self.read_allowed(logical, resolved) + && self.source_allows.iter().any(|rule| { + if rule.recursive { + logical.starts_with(&rule.target) || rule.target.starts_with(logical) + } else { + logical == rule.target || rule.target.starts_with(logical) + } + }) + && self.layers.iter().all(|layer| { + layer.iter().any(|rule| { + let target = self.inner.root.join(rule.target.as_str()); + if rule.recursive { + logical.starts_with(&target) || target.starts_with(logical) + } else { + logical == target || target.starts_with(logical) + } + }) + }) + } + fn require_read(&self, logical: &Path, resolved: &Path) -> std::io::Result<()> { + if self.read_allowed(logical, resolved) { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "checkout search path denied", + )) + } + } +} +impl fs_operation::FsAccessPolicy for SearchAccess { + fn is_readable(&self, path: &Path) -> bool { + self.read_allowed(path, path) + } + fn is_writable(&self, _: &Path) -> bool { + false + } + fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool { + self.read_allowed(logical, resolved) + } + fn can_enumerate_directory(&self, logical: &Path, resolved: &Path) -> bool { + self.enumeration_allowed(logical, resolved) + } + fn traversal_filter(&self) -> Option { + let policy = self.clone(); + Some(Arc::new(move |path, directory| { + if directory { + policy.enumeration_allowed(path, path) + } else { + policy.read_allowed(path, path) + } + })) + } + fn check_cancelled(&self) -> std::io::Result<()> { + self.inner.check_cancelled() + } + fn resolve_access_path(&self, logical: &Path) -> std::io::Result { + self.inner.resolve_access_path(logical) + } + fn open_traversal_root( + &self, + logical: &Path, + resolved: &Path, + ) -> std::io::Result> { + self.require_read(logical, resolved)?; + self.inner.open_traversal_root(logical, resolved) + } + fn open_read_file(&self, logical: &Path, resolved: &Path) -> std::io::Result { + self.require_read(logical, resolved)?; + self.inner.open_read_file(logical, resolved) + } + fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result { + self.require_read(logical, resolved)?; + self.inner.read_metadata(logical, resolved) + } + fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result { + if !self.enumeration_allowed(logical, resolved) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "checkout directory enumeration denied", + )); + } + self.inner.open_read_dir(logical, resolved) + } +} + fn path_sets_overlap( left: &Path, left_recursive: bool, @@ -491,6 +602,7 @@ struct LocalWorkdirSessionInner { reject_symlinks: bool, closed: AtomicBool, close_lock: Mutex<()>, + checkout_lock: StdMutex<()>, next_command_id: AtomicU64, commands: Mutex>, command_telemetry: CommandTelemetry, @@ -911,6 +1023,7 @@ impl LocalWorkdirSession { reject_symlinks, closed: AtomicBool::new(false), close_lock: Mutex::new(()), + checkout_lock: StdMutex::new(()), next_command_id: AtomicU64::new(1), commands: Mutex::new(HashMap::new()), command_telemetry: CommandTelemetry::new(), @@ -1129,6 +1242,69 @@ impl LocalWorkdirSession { Ok(()) } + fn checkout_access(&self) -> ScopeAccess { + let guard = self + .operation_guard + .clone() + .unwrap_or_else(|| OperationGuard { + cancelled: Arc::new(AtomicBool::new(false)), + deadline: Instant::now() + Duration::from_secs(30), + }); + ScopeAccess::new( + self.inner.scope.snapshot(), + &self.inner.root, + self.inner.pinned_root.clone(), + self.inner.reject_symlinks, + Some(guard), + ) + } + + fn checkout_root(&self) -> Result, WorkdirError> { + match &self.inner.pinned_root { + Some(root) => Ok(root.clone()), + None => fs_operation::open_root_no_symlinks(&self.inner.root) + .map(Arc::new) + .map_err(|e| WorkdirError::io(Path::new(""), e)), + } + } + + fn checkout_observation( + &self, + path: WorkdirPath, + pinned: &fs_operation::CheckedTarget<'_>, + ) -> Result { + let metadata = pinned + .metadata() + .map_err(|e| WorkdirError::io(Path::new(path.as_str()), e))?; + if fs_operation::identity_validator(&metadata).map_err(|e| checkout_io(&path, e))? + != pinned.validator() + { + return Err(WorkdirError::Conflict( + "checkout changed during observation".into(), + )); + } + let access = self.scope_access(); + let absolute = self.resolve(&path); + let capabilities = if manifest::Scope::is_writable(&access.scope, &absolute) { + self.capabilities() + .intersection(WorkdirSessionCapabilities::READ_WRITE) + } else { + self.capabilities() + .intersection(WorkdirSessionCapabilities::READ_ONLY) + }; + Ok(crate::CheckoutObservation { + path, + kind: if metadata.is_dir() { + crate::EntryKind::Directory + } else { + crate::EntryKind::File + }, + size: metadata.len(), + validator: pinned.validator().to_vec(), + capabilities, + }) + } + fn scope_access(&self) -> ScopeAccess { ScopeAccess::new( self.inner.scope.snapshot(), @@ -1236,6 +1412,255 @@ impl WorkdirSession for LocalWorkdirSession { )) } + async fn checkout_search( + &self, + mut request: crate::CheckoutSearchRequest, + ) -> Result { + use crate::{CheckoutSearchOperation as Op, CheckoutSearchResult as Output}; + self.ensure_capability(request.operation.capability())?; + request.validate()?; + let _serial = self + .inner + .checkout_lock + .try_lock() + .map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?; + let provider_root = self.checkout_root()?; + let mut inner = self.checkout_access(); + inner.pinned_root = Some(provider_root); + inner.reject_symlinks = true; + let output_root = self.resolve(&request.output_root); + let logical = request.operation.path().clone(); + let relative = Path::new(logical.as_str()) + .strip_prefix(request.output_root.as_str()) + .map_err(|_| WorkdirError::Denied("checkout search escaped output root".into()))?; + *request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| { + WorkdirError::InvalidPath("checkout search path is not UTF8".into()) + })?)?; + let source_allows = Arc::new(inner.scope.allow_rules()); + let access = SearchAccess { + inner, + layers: request.scope_layers, + output_root: output_root.clone(), + source_allows, + }; + let result = match request.operation { + Op::List(r) => fs_operation::run_list(&output_root, r, &access).map(Output::List), + Op::Glob(r) => { + fs_operation::run_glob(&output_root, &self.resolve(&logical), r, &access) + .map(Output::Glob) + } + Op::Grep(r) => fs_operation::run_grep(&output_root, self.resolve(&logical), r, &access) + .map(Output::Grep), + }; + result + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) + } + + async fn checkout_observe( + &self, + path: WorkdirPath, + ) -> Result { + self.ensure_capability(WorkdirSessionCapability::Read)?; + crate::checkout::validate_path(&path)?; + let _serial = self + .inner + .checkout_lock + .try_lock() + .map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?; + let root = self.checkout_root().map_err(|error| match error { + WorkdirError::Io { source, .. } => checkout_observe_io(&path, source), + error => error, + })?; + let access = self.checkout_access(); + let pinned = fs_operation::CheckedTarget::pin( + &root, + Path::new(path.as_str()), + &self.resolve(&path), + &access, + false, + ) + .map_err(|e| checkout_observe_io(&path, e))?; + self.checkout_observation(path.clone(), &pinned) + .map_err(|error| match error { + WorkdirError::Io { source, .. } => checkout_observe_io(&path, source), + error => error, + }) + } + + async fn checkout_execute( + &self, + request: crate::CheckoutRequest, + ) -> Result { + use crate::{CheckoutOperation as Op, CheckoutOutput as Output}; + self.ensure_capability(request.operation.capability())?; + crate::checkout::validate_path(&request.target)?; + if request.validator.is_empty() || request.validator.len() > 256 { + return Err(WorkdirError::InvalidArgument( + "invalid checkout validator".into(), + )); + } + let _serial = self + .inner + .checkout_lock + .try_lock() + .map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?; + let path = request.target; + let root = self + .checkout_root() + .map_err(|error| checkout_execute_error(&path, error))?; + let access = self.checkout_access(); + let mut pinned = fs_operation::CheckedTarget::pin( + &root, + Path::new(path.as_str()), + &self.resolve(&path), + &access, + request.operation.capability() != WorkdirSessionCapability::Read, + ) + .map_err(|e| checkout_io(&path, e))?; + if pinned.validator() != request.validator { + return Err(WorkdirError::Conflict( + "checkout observation changed; observe and read again".into(), + )); + } + let is_create = matches!(&request.operation, Op::Create { .. }); + let output = match request.operation { + Op::Read { + offset, + limit, + max_bytes, + } => { + if limit > 1_000_000 + || max_bytes > BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + { + return Err(WorkdirError::InvalidArgument( + "checkout Read exceeds provider bounds".into(), + )); + } + fs_operation::run_read_bounded( + &self.inner.root, + ReadRequest { + path: path.clone(), + offset, + limit, + max_bytes, + }, + &pinned, + self.inner + .read_limits + .unwrap_or(BoundedReadLimits::EXTERNAL_DEFAULT), + ) + .map(Output::Read) + } + Op::Write { + content, + expected_hash, + } => fs_operation::run_write( + &self.inner.root, + WriteRequest { + path: path.clone(), + content, + expected_hash: Some(expected_hash), + }, + &pinned, + ) + .map(Output::Write), + Op::Edit { + old_string, + new_string, + replace_all, + expected_hash, + } => { + if old_string.len().saturating_add(new_string.len()) > 8 * 1024 * 1024 { + return Err(WorkdirError::InvalidArgument( + "checkout Edit exceeds provider bounds".into(), + )); + } + fs_operation::run_edit( + &self.inner.root, + EditRequest { + path: path.clone(), + old_string, + new_string, + replace_all, + expected_hash, + }, + &pinned, + ) + .map(Output::Edit) + } + Op::Create { + path: destination, + content, + } => { + let suffix = crate::checkout::create_relative_path(&path, &destination)?; + let create = pinned.create_access(suffix, self.resolve(&destination)); + fs_operation::run_write( + &self.inner.root, + WriteRequest { + path: destination, + content, + expected_hash: None, + }, + &create, + ) + .map(Output::Write) + } + }; + let output = output.map_err(|error| { + if pinned.effects_possible() { + WorkdirError::OutcomeUnknown( + "checkout save or parent creation may have effects; inspect before retry" + .into(), + ) + } else { + checkout_execute_error(&path, WorkdirError::from(error)) + } + })?; + if matches!(&output, Output::Read(_)) { + pinned.confirm().map_err(|e| checkout_io(&path, e))?; + return Ok(crate::CheckoutResult { + observation: self + .checkout_observation(path.clone(), &pinned) + .map_err(|error| checkout_execute_error(&path, error))?, + output, + }); + } + if is_create { + pinned.refresh_directory_observation().map_err(|_| { + WorkdirError::OutcomeUnknown( + "checkout Create may have effects but parent observation failed".into(), + ) + })?; + let observation = self.checkout_observation(path, &pinned).map_err(|_| { + WorkdirError::OutcomeUnknown( + "checkout Create may have effects but parent observation failed".into(), + ) + })?; + return Ok(crate::CheckoutResult { + observation, + output, + }); + } + let after = fs_operation::CheckedTarget::pin( + &root, + Path::new(path.as_str()), + &self.resolve(&path), + &access, + true, + ) + .map_err(|_| { + WorkdirError::OutcomeUnknown("checkout saved but post-save observation failed".into()) + })?; + let observation = self.checkout_observation(path, &after).map_err(|_| { + WorkdirError::OutcomeUnknown("checkout saved but post-save observation failed".into()) + })?; + Ok(crate::CheckoutResult { + observation, + output, + }) + } + async fn stat(&self, request: StatRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Read)?; let logical = request.path.clone(); @@ -1247,6 +1672,11 @@ impl WorkdirSession for LocalWorkdirSession { } async fn read(&self, request: ReadRequest) -> Result { + let _serial = self + .inner + .checkout_lock + .try_lock() + .map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?; self.ensure_capability(WorkdirSessionCapability::Read)?; let logical = request.path.clone(); self.validate_operation_path(&request.path)?; @@ -1277,6 +1707,11 @@ impl WorkdirSession for LocalWorkdirSession { } async fn write(&self, request: WriteRequest) -> Result { + let _serial = self + .inner + .checkout_lock + .try_lock() + .map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?; self.ensure_capability(WorkdirSessionCapability::Write)?; let logical = request.path.clone(); self.validate_operation_path(&request.path)?; @@ -1287,6 +1722,11 @@ impl WorkdirSession for LocalWorkdirSession { } async fn edit(&self, request: EditRequest) -> Result { + let _serial = self + .inner + .checkout_lock + .try_lock() + .map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?; self.ensure_capability(WorkdirSessionCapability::Edit)?; let logical = request.path.clone(); self.validate_operation_path(&request.path)?; @@ -1562,6 +2002,44 @@ fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> C } } +// Discovery of an absent route is not a stale-validator execution attempt. +fn checkout_observe_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError { + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) { + WorkdirError::NotFound(PathBuf::from(path.as_str())) + } else { + checkout_io(path, error) + } +} + +// All pre-effect execution stages preserve the same stale-name classification, +// including FsError::NotFound produced by the shared engine after pinning. +fn checkout_execute_error(path: &WorkdirPath, error: WorkdirError) -> WorkdirError { + match error { + WorkdirError::NotFound(_) => { + WorkdirError::Conflict("checkout observation changed; observe and read again".into()) + } + WorkdirError::Io { source, .. } => checkout_io(path, source), + error => sanitize_error(error, path), + } +} + +fn checkout_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError { + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::NotFound + | std::io::ErrorKind::NotADirectory + | std::io::ErrorKind::AlreadyExists + ) { + WorkdirError::Conflict("checkout observation changed; observe and read again".into()) + } else { + sanitize_error(WorkdirError::io(Path::new(path.as_str()), error), path) + } +} + fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError { let path = PathBuf::from(logical.as_str()); match error { @@ -2083,6 +2561,47 @@ mod tests { use std::fs; use tempfile::TempDir; + #[test] + fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() { + let path = WorkdirPath::new("missing").unwrap(); + for kind in [ + std::io::ErrorKind::NotFound, + std::io::ErrorKind::NotADirectory, + ] { + assert!( + matches!(checkout_observe_io(&path, std::io::Error::from(kind)), WorkdirError::NotFound(p) if p == Path::new("missing")) + ); + assert!(matches!( + checkout_io(&path, std::io::Error::from(kind)), + WorkdirError::Conflict(_) + )); + assert!(matches!( + checkout_execute_error( + &path, + WorkdirError::io(Path::new(""), std::io::Error::from(kind)) + ), + WorkdirError::Conflict(_) + )); + } + let shared_error = + WorkdirError::from(fs_operation::FsError::NotFound(PathBuf::from("missing"))); + assert!(matches!( + checkout_execute_error(&path, shared_error), + WorkdirError::Conflict(_) + )); + assert!(matches!( + checkout_observe_io(&path, std::io::Error::from(std::io::ErrorKind::WouldBlock)), + WorkdirError::Conflict(_) + )); + assert!(matches!( + checkout_execute_error( + &path, + WorkdirError::OutcomeUnknown("possible save effects".into()) + ), + WorkdirError::OutcomeUnknown(_) + )); + } + fn make_fs(dir: &TempDir) -> LocalWorkdirSession { LocalWorkdirSession::new( Scope::writable(dir.path()).unwrap(), diff --git a/crates/workdir/src/router.rs b/crates/workdir/src/router.rs index 270f0ec90..02e00004d 100644 --- a/crates/workdir/src/router.rs +++ b/crates/workdir/src/router.rs @@ -248,6 +248,29 @@ impl WorkdirSession for RoutedWorkdirSession { self.state.session.scope_rules_overlap(request).await } + async fn checkout_search( + &self, + request: crate::CheckoutSearchRequest, + ) -> Result { + let _active = self.state.enter()?; + self.state.session.checkout_search(request).await + } + + async fn checkout_observe( + &self, + path: crate::WorkdirPath, + ) -> Result { + let _active = self.state.enter()?; + self.state.session.checkout_observe(path).await + } + async fn checkout_execute( + &self, + request: crate::CheckoutRequest, + ) -> Result { + let _active = self.state.enter()?; + self.state.session.checkout_execute(request).await + } + async fn stat(&self, request: StatRequest) -> Result { let _active = self.state.enter()?; self.state.session.stat(request).await @@ -650,6 +673,42 @@ mod tests { )) } + #[tokio::test] + async fn checkout_routes_use_active_operation_detach_fence_and_old_handles_stay_closed() { + let root = TempDir::new().unwrap(); + std::fs::write(root.path().join("a"), "contents").unwrap(); + let router = WorkdirSessionRouter::new(); + let alias = WorkdirAttachmentAlias::new("checkout").unwrap(); + router.attach(alias.clone(), session("wd", &root)).unwrap(); + let old = router.resolve(Some("checkout")).unwrap().session; + old.checkout_observe(crate::WorkdirPath::new("a").unwrap()) + .await + .unwrap(); + let state = router.sessions.read().unwrap().get(&alias).unwrap().clone(); + let active = state.enter().unwrap(); + assert!(router.begin_detach(&alias).await.is_err()); + drop(active); + router.detach(&alias).await.unwrap(); + assert!( + old.checkout_observe(crate::WorkdirPath::new("a").unwrap()) + .await + .is_err() + ); + router.attach(alias, session("wd-new", &root)).unwrap(); + router + .resolve(Some("checkout")) + .unwrap() + .session + .checkout_observe(crate::WorkdirPath::new("a").unwrap()) + .await + .unwrap(); + assert!( + old.checkout_observe(crate::WorkdirPath::new("a").unwrap()) + .await + .is_err() + ); + } + #[tokio::test] async fn router_holds_multiple_aliases_without_implicit_primary() { let left = TempDir::new().unwrap(); diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index a58e0b01d..2f798d4b5 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -994,6 +994,138 @@ impl WorkdirSession for ScopedWorkdirSession { self.capabilities } + async fn checkout_search( + &self, + mut request: crate::CheckoutSearchRequest, + ) -> Result { + let _scope_guard = self.scope_lock.lock().await; + request.validate()?; + let path = self + .resolve_operation_path(request.operation.path(), WorkdirToolScopePermission::Read) + .await?; + self.ensure_read(&path, request.operation.capability())?; + *request.operation.path_mut() = path; + request.output_root = self.resolve_path(&request.output_root)?; + for layer in &mut request.scope_layers { + for rule in layer { + rule.target = self.resolve_path(&rule.target)?; + } + } + if let Some(rules) = &self.scope { + request.scope_layers.push(rules.clone()); + } + request.validate()?; + // Source results already use the composed output_root, not this source's + // cwd. Rewriting them here would duplicate cwd through nested wrappers. + let result = self.source.checkout_search(request).await?; + self.ensure_active()?; + Ok(result) + } + + async fn checkout_observe( + &self, + caller_path: crate::WorkdirPath, + ) -> Result { + let _scope_guard = self.scope_lock.lock().await; + let path = self + .resolve_operation_path(&caller_path, WorkdirToolScopePermission::Read) + .await?; + self.ensure_read(&path, WorkdirSessionCapability::Read)?; + let mut observation = self.source.checkout_observe(path.clone()).await?; + observation.capabilities = observation.capabilities.intersection(self.capabilities()); + // Capability bits must not advertise authority over a read-only path or + // a path whose write authority is currently leased to a child. + if self + .resolve_operation_path(&caller_path, WorkdirToolScopePermission::Write) + .await + .is_err() + || self + .ensure_write(&path, WorkdirSessionCapability::Write) + .is_err() + { + observation.capabilities = observation + .capabilities + .intersection(WorkdirSessionCapabilities::READ_ONLY); + } + observation.path = caller_path; + Ok(observation) + } + async fn checkout_execute( + &self, + mut request: crate::CheckoutRequest, + ) -> Result { + let _scope_guard = self.scope_lock.lock().await; + let caller_path = request.target.clone(); + let capability = request.operation.capability(); + let permission = if capability == WorkdirSessionCapability::Read { + WorkdirToolScopePermission::Read + } else { + WorkdirToolScopePermission::Write + }; + let path = self + .resolve_operation_path(&request.target, permission) + .await?; + if permission == WorkdirToolScopePermission::Read { + self.ensure_read(&path, capability)?; + } else { + self.ensure_write(&path, capability)?; + } + let result_path = caller_path; + if let crate::CheckoutOperation::Create { + path: destination, .. + } = &mut request.operation + { + let suffix = crate::checkout::create_relative_path(&request.target, destination)?; + let mut intermediate = std::path::PathBuf::from(request.target.as_str()); + let mut components = suffix.components().peekable(); + while let Some(component) = components.next() { + intermediate.push(component.as_os_str()); + if components.peek().is_some() { + let intermediate = + crate::WorkdirPath::new(intermediate.to_str().ok_or_else(|| { + WorkdirError::InvalidPath("non-UTF8 Create path".into()) + })?)?; + let resolved = self + .resolve_operation_path(&intermediate, WorkdirToolScopePermission::Write) + .await?; + self.ensure_write(&resolved, WorkdirSessionCapability::Write)?; + } + } + let resolved = self + .resolve_operation_path(destination, WorkdirToolScopePermission::Write) + .await?; + self.ensure_write(&resolved, WorkdirSessionCapability::Write)?; + *destination = resolved; + } + request.target = path; + let mut result = self.source.checkout_execute(request).await?; + result.observation.path = result_path.clone(); + result.observation.capabilities = result + .observation + .capabilities + .intersection(self.capabilities()); + if self + .resolve_operation_path(&result_path, WorkdirToolScopePermission::Write) + .await + .is_err() + || self + .ensure_write( + &self.resolve_path(&result_path)?, + WorkdirSessionCapability::Write, + ) + .is_err() + { + result.observation.capabilities = result + .observation + .capabilities + .intersection(WorkdirSessionCapabilities::READ_ONLY); + } + if let crate::CheckoutOutput::Read(read) = &mut result.output { + read.path = result_path; + } + Ok(result) + } + async fn stat(&self, mut request: StatRequest) -> Result { let path = self .resolve_operation_path(&request.path, WorkdirToolScopePermission::Read) @@ -1048,6 +1180,19 @@ impl WorkdirSession for ScopedWorkdirSession { } async fn list(&self, mut request: ListRequest) -> Result { + if self.scope.is_some() || !self.cwd.is_root() { + return match self + .checkout_search(crate::CheckoutSearchRequest::new( + crate::CheckoutSearchOperation::List(request), + )) + .await? + { + crate::CheckoutSearchResult::List(result) => Ok(result), + _ => Err(WorkdirError::Unavailable( + "mismatched checkout search result".into(), + )), + }; + } let path = self .resolve_operation_path(&request.path, WorkdirToolScopePermission::Read) .await?; @@ -1057,6 +1202,19 @@ impl WorkdirSession for ScopedWorkdirSession { } async fn glob(&self, mut request: GlobRequest) -> Result { + if self.scope.is_some() || !self.cwd.is_root() { + return match self + .checkout_search(crate::CheckoutSearchRequest::new( + crate::CheckoutSearchOperation::Glob(request), + )) + .await? + { + crate::CheckoutSearchResult::Glob(result) => Ok(result), + _ => Err(WorkdirError::Unavailable( + "mismatched checkout search result".into(), + )), + }; + } let path = self .resolve_operation_path(&request.path, WorkdirToolScopePermission::Read) .await?; @@ -1066,6 +1224,19 @@ impl WorkdirSession for ScopedWorkdirSession { } async fn grep(&self, mut request: GrepRequest) -> Result { + if self.scope.is_some() || !self.cwd.is_root() { + return match self + .checkout_search(crate::CheckoutSearchRequest::new( + crate::CheckoutSearchOperation::Grep(request), + )) + .await? + { + crate::CheckoutSearchResult::Grep(result) => Ok(result), + _ => Err(WorkdirError::Unavailable( + "mismatched checkout search result".into(), + )), + }; + } let path = self .resolve_operation_path(&request.path, WorkdirToolScopePermission::Read) .await?; @@ -1284,6 +1455,44 @@ impl WorkdirSession for ReadOnlyWorkdirSession { self.inner.scope_rules_overlap(request).await } + async fn checkout_search( + &self, + request: crate::CheckoutSearchRequest, + ) -> Result { + if !self + .capabilities() + .intersection(self.inner.capabilities()) + .supports(request.operation.capability()) + { + return Err(WorkdirError::Unsupported(request.operation.capability())); + } + self.inner.checkout_search(request).await + } + + async fn checkout_observe( + &self, + path: crate::WorkdirPath, + ) -> Result { + let mut observation = self.inner.checkout_observe(path).await?; + observation.capabilities = observation + .capabilities + .intersection(WorkdirSessionCapabilities::READ_ONLY); + Ok(observation) + } + async fn checkout_execute( + &self, + request: crate::CheckoutRequest, + ) -> Result { + if request.operation.capability() != WorkdirSessionCapability::Read { + return Err(WorkdirError::Unsupported(request.operation.capability())); + } + let mut result = self.inner.checkout_execute(request).await?; + result.observation.capabilities = result + .observation + .capabilities + .intersection(WorkdirSessionCapabilities::READ_ONLY); + Ok(result) + } async fn stat(&self, request: StatRequest) -> Result { self.inner.stat(request).await } @@ -1654,6 +1863,53 @@ mod tests { (WorkdirToolBroker::new(source), receiver, release) } + #[tokio::test] + async fn checkout_search_unsupported_source_never_falls_back_to_broad_traversal() { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("child")).unwrap(); + fs::write(root.path().join("child/a"), "needle").unwrap(); + let (parent, _, _) = blocking_session(root.path()); + // Unscoped normal Tools still use their established provider contract. + assert_eq!( + parent + .list(ListRequest { + path: fs_path("child"), + limit: 100 + }) + .await + .unwrap() + .entries + .len(), + 1 + ); + let child = parent + .scope(request("child", WorkdirToolScopePermission::Read)) + .await + .unwrap(); + assert!(matches!( + child + .tool_session() + .list(ListRequest { + path: FsPath::root(), + limit: 100 + }) + .await, + Err(WorkdirError::UnsupportedOperation(_)) + )); + assert!(matches!( + child + .tool_session() + .checkout_search(crate::CheckoutSearchRequest::new( + crate::CheckoutSearchOperation::List(ListRequest { + path: FsPath::root(), + limit: 100 + }) + )) + .await, + Err(WorkdirError::UnsupportedOperation(_)) + )); + } + fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope { WorkdirToolScope { rules: vec![WorkdirToolScopeRule { diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 84dad3c7d..597cf460c 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -55,6 +55,8 @@ wip-http = { workspace = true } wip-protocol = { workspace = true } [dev-dependencies] +axum = { workspace = true } +workdir = { workspace = true, features = ["http-client"] } dotenv = "0.15.0" futures = { workspace = true } serial_test = "3.4.0" diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs new file mode 100644 index 000000000..1602ce23b --- /dev/null +++ b/crates/worker/src/checkout.rs @@ -0,0 +1,764 @@ +//! Provider-owned request-time checkout projection. No filesystem path is opened +//! by the Worker: every observation and operation goes through the live router. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use agen::tool::ToolError; +use async_trait::async_trait; +use manifest::{ToolPermissionAction, ToolPermissionConfig}; +use serde_json::{Value as Json, json}; +use sha2::{Digest, Sha256}; +use wip_protocol::{ + Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, + ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value, +}; +use workdir::{ + CheckoutObservation, EntryKind, ListRequest, WorkdirPath, WorkdirSessionCapability as Cap, + WorkdirSessionRouter, +}; + +use crate::feature::builtin::manage_workdir::wip::{decode_identity, encode_identity}; +use crate::permission::permission_action_for; +use crate::wip::{ + WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, WipSubtreeMount, WipSubtreeProvider, + json_to_wip, wip_to_json, +}; + +const ROOT: &str = "/checkouts"; +const MAX_NODES: usize = 1024; +const DEADLINE: Duration = Duration::from_secs(30); + +/// Return a stable, injective attachment-local entrance, never a host path. +pub fn checkout_root(alias: &str) -> String { + format!("{ROOT}/{}", encode_identity(alias)) +} + +/// Register the native content namespace independently of inventory/lifecycle +/// Features. Its capabilities come from current attachments, not Feature flags. +pub fn mount_checkouts( + registry: &mut WipMountRegistry, + router: Arc, + tracker: tools::Tracker, + permissions: Option, +) -> Result<(), WipMountError> { + registry.allocate_namespace("checkout", "checkouts")?; + let provider = Arc::new(Provider { + router, + tracker, + permissions, + incarnation: uuid::Uuid::now_v7().to_string(), + permits: Arc::new(tokio::sync::Semaphore::new(16)), + deadline: DEADLINE, + }); + registry.mount(provider.collection())?; + registry.replace_compatibility_tools(ROOT, ["Read", "Edit", "Write", "Glob", "Grep"])?; + registry.mount_subtree(WipSubtreeMount { + root: ROOT.into(), + provider: Arc::new(provider), + }) +} + +struct Provider { + router: Arc, + tracker: tools::Tracker, + permissions: Option, + incarnation: String, + permits: Arc, + deadline: Duration, +} + +impl Provider { + fn allowed(&self, tool: &str, input: &Json) -> bool { + self.permissions + .as_ref() + .is_none_or(|p| permission_action_for(p, tool, input) == ToolPermissionAction::Allow) + } + fn may_allow(&self, tool: &str) -> bool { + let Some(p) = &self.permissions else { + return true; + }; + for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(tool)) { + if rule.action == ToolPermissionAction::Allow { + return true; + } + if rule.pattern == "*" { + return false; + } + } + p.default_action == ToolPermissionAction::Allow + } + fn parse(&self, path: &str) -> Option<(String, WorkdirPath)> { + wip_protocol::validate_path(path).ok()?; + let rest = path.strip_prefix("/checkouts/")?; + let (slug, tail) = rest.split_once('/').unwrap_or((rest, "")); + if tail.contains('\0') { + return None; + } + Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?)) + } + fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec { + let mut hash = Sha256::new(); + for value in [ + self.incarnation.as_bytes(), + alias.as_bytes(), + &generation.to_be_bytes(), + workdir.as_bytes(), + raw, + ] { + hash.update(value.len().to_be_bytes()); + hash.update(value); + } + hash.finalize().to_vec() + } + fn collection(self: &Arc) -> WipProjection { + let mut state = Sha256::new(); + state.update(&self.incarnation); + for alias in self.router.aliases() { + if let Ok(s) = self.router.resolve(Some(alias.as_str())) { + state.update(alias.as_str()); + state.update(s.generation.to_be_bytes()); + state.update(s.session.workdir().id().as_str()); + state.update(format!("{:?}", s.session.capabilities())); + } + } + self.project( + ROOT, + descriptor(vec![operation( + "list", + "List currently accessible checkouts", + vec![], + )]), + state.finalize().to_vec(), + "collection", + Arc::new(Collection { + provider: self.clone(), + }), + ) + } + fn project( + &self, + path: &str, + descriptor: InterfaceDescriptor, + validator: Vec, + kind: &str, + handler: Arc, + ) -> WipProjection { + let encoded: String = path.as_bytes().iter().map(|b| format!("{b:02x}")).collect(); + let interface = format!("yoi.checkout/{}/{kind}/v1/@/{encoded}", self.incarnation); + let interface_validator = Sha256::digest(format!("{descriptor:?}")).to_vec(); + WipProjection { + route: path.into(), + capability: format!("checkout:{kind}"), + kind: WipProjectionKind::Native, + object: Object { + name: path.rsplit('/').next().unwrap_or_default().into(), + description: Some(format!("Attached Workdir {kind}")), + interfaces: vec![interface.clone()], + r#ref: None, + validator: Some(validator), + }, + interface, + descriptor, + interface_validator: Some(interface_validator), + handler, + } + } + async fn node(self: &Arc, path: &str) -> Result, ProtocolError> { + if path == ROOT { + return Ok(Some(self.collection())); + } + let Some((alias, target)) = self.parse(path) else { + return Ok(None); + }; + let Ok(selected) = self.router.resolve(Some(&alias)) else { + return Ok(None); + }; + let observation = match selected.session.checkout_observe(target.clone()).await { + Ok(o) => o, + Err( + workdir::WorkdirError::Denied(_) + | workdir::WorkdirError::OutOfScope(_) + | workdir::WorkdirError::NotFound(_) + | workdir::WorkdirError::SessionClosed + | workdir::WorkdirError::UnsupportedOperation(_) + | workdir::WorkdirError::Unsupported(_), + ) => return Ok(None), + Err(_) => { + return Err(error( + ProtocolErrorCode::Internal, + "checkout observation unavailable", + )); + } + }; + if !matches!(observation.kind, EntryKind::File | EntryKind::Directory) { + return Ok(None); + } + let permission_input = json!({"target_workdir":alias, "file_path":target.as_str()}); + if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) { + return Ok(None); + } + let ops = self.operations(&observation); + if ops.is_empty() { + return Ok(None); + } + let kind = if observation.kind == EntryKind::File { + "file" + } else { + "directory" + }; + let workdir = selected.session.workdir().id().as_str().to_owned(); + let validator = self.wrap_validator( + &alias, + selected.generation, + &workdir, + &observation.validator, + ); + let handler = Arc::new(FileHandler { + provider: self.clone(), + alias, + generation: selected.generation, + workdir, + target, + observation, + }); + let family = format!("{kind}-g{}", selected.generation); + Ok(Some(self.project( + path, + descriptor(ops), + validator, + &family, + handler, + ))) + } + fn operations(&self, o: &CheckoutObservation) -> Vec { + let mut ops = Vec::new(); + let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name); + if o.kind == EntryKind::File { + if has(Cap::Read, "Read") { + ops.push(operation( + "read", + "Read text with line offsets and numbered output", + vec![ + parameter("offset", false, TypeExpr::Integer), + parameter("limit", false, TypeExpr::Integer), + ], + )); + } + if has(Cap::Edit, "Edit") { + ops.push(operation( + "edit", + "Replace a unique string (requires prior Read)", + vec![ + parameter("old_string", true, TypeExpr::String), + parameter("new_string", true, TypeExpr::String), + parameter("replace_all", false, TypeExpr::Boolean), + ], + )); + } + if has(Cap::Write, "Write") { + ops.push(operation( + "write", + "Overwrite this existing file (requires prior Read)", + vec![parameter("content", true, TypeExpr::String)], + )); + } + } else { + if has(Cap::Glob, "Glob") { + ops.push(operation( + "glob", + "Glob within this directory", + vec![ + parameter("pattern", true, TypeExpr::String), + parameter("path", false, TypeExpr::String), + ], + )); + } + if has(Cap::Grep, "Grep") { + ops.push(operation( + "grep", + "Search within this directory with the existing Grep contract", + grep_parameters(), + )); + } + if has(Cap::Write, "Write") { + ops.push(operation("create_file", "Create a new relative file, creating permitted missing parents; never overwrite", vec![parameter("path",true,TypeExpr::String),parameter("content",true,TypeExpr::String)])); + } + } + ops + } +} + +#[async_trait] +impl WipSubtreeProvider for Arc { + async fn projection(&self, path: &str) -> Result, ProtocolError> { + let _permit = tokio::time::timeout(self.deadline, self.permits.acquire()) + .await + .map_err(|_| { + error( + ProtocolErrorCode::ResourceLimitExceeded, + "checkout concurrency deadline", + ) + })? + .map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?; + tokio::time::timeout(self.deadline, self.node(path)) + .await + .map_err(|_| { + error( + ProtocolErrorCode::ResourceLimitExceeded, + "checkout observation deadline", + ) + })? + } + async fn children(&self, path: &str) -> Result, ProtocolError> { + let _permit = tokio::time::timeout(self.deadline, self.permits.acquire()) + .await + .map_err(|_| { + error( + ProtocolErrorCode::ResourceLimitExceeded, + "checkout capacity deadline", + ) + })? + .map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?; + tokio::time::timeout(self.deadline, self.children_inner(path)) + .await + .map_err(|_| { + error( + ProtocolErrorCode::ResourceLimitExceeded, + "checkout enumeration deadline", + ) + })? + } +} + +impl Provider { + async fn children_inner(self: &Arc, path: &str) -> Result, ProtocolError> { + if path == ROOT { + let before = self.collection().object.validator.unwrap(); + if self.router.aliases().len() > MAX_NODES { + return Err(error( + ProtocolErrorCode::ResourceLimitExceeded, + "too many checkouts", + )); + } + let mut paths = Vec::new(); + for alias in self.router.aliases() { + let p = checkout_root(alias.as_str()); + if self.node(&p).await?.is_some() { + paths.push(p); + } + } + if before != self.collection().object.validator.unwrap() { + return Err(error( + ProtocolErrorCode::ValidatorMismatch, + "attachment collection changed", + )); + } + return Ok(paths); + } + let Some((alias, target)) = self.parse(path) else { + return Err(error(ProtocolErrorCode::NotFound, "checkout not published")); + }; + let selected = self + .router + .resolve(Some(&alias)) + .map_err(|_| error(ProtocolErrorCode::NotFound, "attachment expired"))?; + let o = selected + .session + .checkout_observe(target.clone()) + .await + .map_err(|_| error(ProtocolErrorCode::NotFound, "checkout not published"))?; + if o.kind != EntryKind::Directory { + return Ok(Vec::new()); + } + if !self.allowed( + "Glob", + &json!({"target_workdir":alias,"path":target.as_str(),"pattern":"*"}), + ) && !self.allowed( + "Grep", + &json!({"target_workdir":alias,"path":target.as_str(),"pattern":""}), + ) { + return Ok(Vec::new()); + } + let listing = selected + .session + .checkout_search(workdir::CheckoutSearchRequest::new( + workdir::CheckoutSearchOperation::List(ListRequest { + path: target, + limit: MAX_NODES + 1, + }), + )) + .await + .map_err(|_| error(ProtocolErrorCode::Internal, "checkout listing unavailable"))?; + let workdir::CheckoutSearchResult::List(listing) = listing else { + return Err(error( + ProtocolErrorCode::Internal, + "mismatched checkout listing", + )); + }; + if listing.truncated || listing.entries.len() > MAX_NODES { + return Err(error( + ProtocolErrorCode::ResourceLimitExceeded, + "directory observation exceeds node bound", + )); + } + let mut paths = Vec::new(); + for entry in listing.entries { + let p = object_path(&alias, &entry.path); + if self.node(&p).await?.is_some() { + paths.push(p); + } + } + if !self + .router + .resolve(Some(&alias)) + .is_ok_and(|current| current.generation == selected.generation) + { + return Err(error( + ProtocolErrorCode::ValidatorMismatch, + "attachment changed", + )); + } + Ok(paths) + } +} + +struct Collection { + provider: Arc, +} +#[async_trait] +impl WipOperationHandler for Collection { + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + _context: WipCallContext, + ) -> Result { + if operation != "list" || !arguments.is_empty() { + return Err(protocol( + ProtocolErrorCode::InvalidArguments, + "list accepts no arguments", + )); + } + let before = self.provider.collection().object.validator.unwrap(); + let paths = Arc::new(self.provider.clone()) + .children(ROOT) + .await + .map_err(WipOperationError::Protocol)?; + let mut items = Vec::new(); + for path in paths { + let (alias, _) = self.provider.parse(&path).expect("generated checkout path"); + let s = self.provider.router.resolve(Some(&alias)).map_err(|_| { + protocol(ProtocolErrorCode::ValidatorMismatch, "attachment changed") + })?; + items.push(json!({"path":path,"slug":encode_identity(&alias),"alias":alias, + "working_directory_id":s.session.workdir().id().as_str(),"generation":s.generation})); + } + let value = json_to_wip(&json!({"items":items})) + .map_err(|_| protocol(ProtocolErrorCode::Internal, "invalid checkout result"))?; + let after = self.provider.collection().object.validator.unwrap(); + if before != after { + return Err(protocol( + ProtocolErrorCode::ValidatorMismatch, + "attachment collection changed", + )); + } + Ok(WipOperationOutput::native_with_validator(value, after)) + } +} + +struct FileHandler { + provider: Arc, + alias: String, + generation: u64, + workdir: String, + target: WorkdirPath, + observation: CheckoutObservation, +} +impl FileHandler { + fn check_connection(&self) -> Result<(), WipOperationError> { + if self + .provider + .router + .resolve(Some(&self.alias)) + .is_ok_and(|s| { + s.generation == self.generation && s.session.workdir().id().as_str() == self.workdir + }) + { + Ok(()) + } else { + Err(protocol( + ProtocolErrorCode::ValidatorMismatch, + "attachment changed while publishing result", + )) + } + } +} +#[async_trait] +impl WipOperationHandler for FileHandler { + async fn call( + &self, + operation: &str, + arguments: &BTreeMap, + context: WipCallContext, + ) -> Result { + let tool = match operation { + "read" => "Read", + "edit" => "Edit", + "write" => "Write", + "create_file" => "Create", + "glob" => "Glob", + "grep" => "Grep", + _ => { + return Err(protocol( + ProtocolErrorCode::OperationNotFound, + "unknown checkout operation", + )); + } + }; + let mut args = serde_json::Map::new(); + for (k, v) in arguments { + args.insert( + k.clone(), + wip_to_json(v).map_err(|_| { + protocol( + ProtocolErrorCode::InvalidArguments, + "invalid checkout argument", + ) + })?, + ); + } + let mut permission_input = args.clone(); + permission_input.insert("target_workdir".into(), json!(self.alias)); + if tool == "Glob" || tool == "Grep" { + let relative = args.get("path").and_then(Json::as_str).unwrap_or(""); + permission_input.insert("path".into(), json!(join_under(&self.target, relative)?)); + } else if tool == "Create" { + let relative = args.get("path").and_then(Json::as_str).ok_or_else(|| { + protocol(ProtocolErrorCode::InvalidArguments, "create requires path") + })?; + permission_input.remove("path"); + permission_input.insert( + "file_path".into(), + json!(join_under(&self.target, relative)?), + ); + } else { + permission_input.insert("file_path".into(), json!(self.target.as_str())); + } + if !self.provider.allowed( + if tool == "Create" { "Write" } else { tool }, + &Json::Object(permission_input), + ) { + return Err(protocol( + ProtocolErrorCode::PermissionDenied, + "file operation denied; approval-required policy fails closed", + )); + } + let _permit = tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire()) + .await + .map_err(|_| { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "checkout operation capacity deadline", + ) + })? + .map_err(|_| protocol(ProtocolErrorCode::Internal, "checkout unavailable"))?; + let deadline = tokio::time::Instant::now() + self.provider.deadline; + let result = tokio::time::timeout_at( + deadline, + tools::execute_checkout_tool( + self.provider.router.clone(), + self.provider.tracker.clone(), + &self.alias, + self.generation, + self.target.clone(), + self.observation.validator.clone(), + tool, + Json::Object(args), + context.execution, + ), + ) + .await + .map_err(|_| { + if matches!(tool, "Write" | "Edit" | "Create") { + WipOperationError::OutcomeUnknown("file operation deadline after dispatch".into()) + } else { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "file operation deadline", + ) + } + })? + .map_err(map_tool_error)?; + let links = tokio::time::timeout_at(deadline, async { + let mut links = Vec::new(); + self.check_connection()?; + for path in &result.paths { + let route = object_path(&self.alias, path); + // References are validated only against the captured live connection. + if self + .provider + .node(&route) + .await + .map_err(WipOperationError::Protocol)? + .is_some() + { + links.push(json!({"path":route})); + } + self.check_connection()?; + } + Ok::<_, WipOperationError>(links) + }) + .await + .map_err(|_| { + if matches!(tool, "Write" | "Edit" | "Create") { + WipOperationError::OutcomeUnknown("completed operation response deadline".into()) + } else { + protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "checkout result deadline", + ) + } + })? + .map_err(|e| { + if matches!(tool, "Write" | "Edit" | "Create") { + WipOperationError::OutcomeUnknown("completed operation response unavailable".into()) + } else { + e + } + })?; + let value = json_to_wip( + &json!({"summary":result.output.summary,"content":result.output.content,"items":links}), + ) + .map_err(|_| { + WipOperationError::OutcomeUnknown("invalid completed file operation result".into()) + })?; + if let Some(raw) = result.validator { + Ok(WipOperationOutput::native_with_validator( + value, + self.provider + .wrap_validator(&self.alias, self.generation, &self.workdir, &raw), + )) + } else { + Ok(WipOperationOutput::native(value)) + } + } +} + +fn object_path(alias: &str, path: &WorkdirPath) -> String { + if path.is_root() { + checkout_root(alias) + } else { + format!("{}/{}", checkout_root(alias), path.as_str()) + } +} +fn join_under(parent: &WorkdirPath, relative: &str) -> Result { + let child = WorkdirPath::new(relative).map_err(|_| { + protocol( + ProtocolErrorCode::InvalidArguments, + "path must stay relative to this directory", + ) + })?; + if relative.starts_with('/') { + return Err(protocol( + ProtocolErrorCode::InvalidArguments, + "absolute paths are not permitted", + )); + } + Ok(if parent.is_root() { + child.as_str().into() + } else if child.is_root() { + parent.as_str().into() + } else { + format!("{}/{}", parent.as_str(), child.as_str()) + }) +} +fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: name.into(), + required, + documentation: None, + r#type, + } +} +fn operation( + name: &str, + summary: &str, + parameters: Vec, +) -> OperationDeclaration { + OperationDeclaration { + name: name.into(), + documentation: Some(Documentation { + summary: summary.into(), + details: None, + }), + parameters, + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Json, + }, + } +} +fn descriptor(operations: Vec) -> InterfaceDescriptor { + InterfaceDescriptor{format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation{summary:"AI-oriented attached Workdir operations".into(),details:Some("The route binds attachment and target. Line offsets are zero-based; text output numbers lines from one. Read-before-write and provider validators are both required. Paths are Worldspace references, never host paths.".into())}),types:Vec::new(),operations} +} +fn grep_parameters() -> Vec { + let mut p = vec![parameter("pattern", true, TypeExpr::String)]; + for name in ["path", "glob", "type", "output_mode"] { + p.push(parameter(name, false, TypeExpr::String)); + } + for name in ["-A", "-B", "-C", "head_limit", "offset"] { + p.push(parameter(name, false, TypeExpr::Integer)); + } + for name in ["case_insensitive", "multiline"] { + p.push(parameter(name, false, TypeExpr::Boolean)); + } + p +} +fn error(code: ProtocolErrorCode, message: &str) -> ProtocolError { + ProtocolError { + code, + message: message.into(), + } +} +fn protocol(code: ProtocolErrorCode, message: &str) -> WipOperationError { + WipOperationError::Protocol(error(code, message)) +} +fn map_tool_error(e: ToolError) -> WipOperationError { + match e { + ToolError::InvalidArgument(_) => protocol( + ProtocolErrorCode::InvalidArguments, + "invalid file arguments or read-before-write requirement", + ), + ToolError::StructuredConflict { code, .. } => match code.as_str() { + "checkout_denied" => protocol( + ProtocolErrorCode::PermissionDenied, + "file operation refused before effects", + ), + "checkout_invalid" => protocol( + ProtocolErrorCode::InvalidArguments, + "invalid checkout operation or target", + ), + "checkout_unavailable" => protocol( + ProtocolErrorCode::ResourceLimitExceeded, + "checkout provider declined before effects", + ), + "checkout_stale" => protocol( + ProtocolErrorCode::ValidatorMismatch, + "file or attachment observation is stale; rediscover and reread", + ), + _ => WipOperationError::OutcomeUnknown("unclassified file operation failure".into()), + }, + ToolError::Cancelled(o) => WipOperationError::Cancelled(o), + ToolError::Interrupted(o) => WipOperationError::Interrupted(o), + _ => WipOperationError::OutcomeUnknown( + "file operation failed; inspect current state before retry".into(), + ), + } +} + +#[cfg(test)] +#[path = "checkout_race_tests.rs"] +mod race_tests; diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs new file mode 100644 index 000000000..169f7ee77 --- /dev/null +++ b/crates/worker/src/checkout_http_tests.rs @@ -0,0 +1,671 @@ +//! Real provider HTTP -> RemoteWorkdirSession -> router -> native WIP Client. +//! This deliberately does not exercise the production Backend grant network. +use super::*; +use axum::{ + Json as AxumJson, Router, + body::Body, + extract::{Path, State}, + http::{HeaderMap, StatusCode, Uri}, + response::{IntoResponse, Response as AxumResponse}, + routing::{delete, post}, +}; +use std::time::Duration; +use tempfile::TempDir; +use workdir::http::{ + OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, RemoteWorkdirSession, WorkdirSessionId, + WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirTransportError, + WorkdirTransportErrorCode, +}; +use workdir::{ + CheckoutOperation, CheckoutRequest, CheckoutSearchOperation, LocalWorkdirSession, + WorkdirAttachmentAlias, WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities, + WorkdirSessionHandle, WorkdirSessionRouter, dispatch_workdir_session_operation, +}; + +const TOKEN: &str = "checkout-http-test-private-bearer"; +const SESSION: &str = "http-test-session"; + +#[derive(Clone, Copy, Debug)] +enum WriteResponseFault { + Malformed, + LostBody, +} + +struct ProviderState { + session: WorkdirSessionHandle, + requests: Mutex>, + operations: Mutex>, + write_fault: Mutex>, +} + +impl ProviderState { + fn authorize(&self, headers: &HeaderMap, method: &str, uri: &Uri) -> Result<(), AxumResponse> { + self.requests + .lock() + .unwrap() + .push((method.into(), uri.path().into())); + let expected = format!("Bearer {TOKEN}"); + if headers.get("authorization").and_then(|h| h.to_str().ok()) != Some(expected.as_str()) { + return Err(( + StatusCode::UNAUTHORIZED, + AxumJson(WorkdirTransportError { + code: WorkdirTransportErrorCode::Denied, + message: "provider authorization denied".into(), + }), + ) + .into_response()); + } + Ok(()) + } + + fn execute_calls(&self) -> usize { + self.operations + .lock() + .unwrap() + .iter() + .filter(|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(_))) + .count() + } + + fn write_calls(&self) -> usize { + self.operations + .lock() + .unwrap() + .iter() + .filter(|op| { + matches!( + op, + WorkdirSessionOperation::CheckoutExecute(CheckoutRequest { + operation: CheckoutOperation::Write { .. }, + .. + }) + ) + }) + .count() + } +} + +fn provider_error(error: WorkdirError) -> AxumResponse { + let error = WorkdirTransportError::from_workdir_error(&error); + ( + StatusCode::from_u16(error.code.http_status()).unwrap(), + AxumJson(error), + ) + .into_response() +} + +async fn open_session( + State(state): State>, + Path(id): Path, + uri: Uri, + headers: HeaderMap, + AxumJson(_request): AxumJson, +) -> AxumResponse { + if let Err(response) = state.authorize(&headers, "POST", &uri) { + return response; + } + if id != state.session.workdir().id().as_str() { + return provider_error(WorkdirError::NotFound("unknown workdir".into())); + } + AxumJson(OpenWorkdirSessionResponse { + session_id: WorkdirSessionId::new(SESSION).unwrap(), + workdir_id: state.session.workdir().id().clone(), + capabilities: state.session.capabilities(), + }) + .into_response() +} + +async fn operate( + State(state): State>, + Path(id): Path, + uri: Uri, + headers: HeaderMap, + AxumJson(request): AxumJson, +) -> AxumResponse { + if let Err(response) = state.authorize(&headers, "POST", &uri) { + return response; + } + if id != SESSION { + return provider_error(WorkdirError::Unavailable("unknown session".into())); + } + let is_write = matches!( + &request.operation, + WorkdirSessionOperation::CheckoutExecute(CheckoutRequest { + operation: CheckoutOperation::Write { .. }, + .. + }) + ); + state + .operations + .lock() + .unwrap() + .push(request.operation.clone()); + let result = + dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await; + match result { + // Inject only AFTER the real LocalWorkdirSession successfully committed. + Ok(result) => { + let fault = if is_write { + state.write_fault.lock().unwrap().take() + } else { + None + }; + match fault { + Some(WriteResponseFault::Malformed) => ( + StatusCode::OK, + [("content-type", "application/json")], + "{broken-response", + ) + .into_response(), + Some(WriteResponseFault::LostBody) => { + let body = Body::from_stream(futures::stream::once(async { + Err::, _>(std::io::Error::other("response lost after commit")) + })); + AxumResponse::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(body) + .unwrap() + } + None => AxumJson(result).into_response(), + } + } + Err(error) => provider_error(error), + } +} + +async fn close_session( + State(state): State>, + Path(id): Path, + uri: Uri, + headers: HeaderMap, +) -> AxumResponse { + if let Err(response) = state.authorize(&headers, "DELETE", &uri) { + return response; + } + if id != SESSION { + return provider_error(WorkdirError::Unavailable("unknown session".into())); + } + match state.session.close().await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => provider_error(error), + } +} + +struct HttpFixture { + dir: TempDir, + state: Arc, + base_url: reqwest::Url, + server: tokio::task::JoinHandle<()>, +} + +impl Drop for HttpFixture { + fn drop(&mut self) { + self.server.abort(); + } +} + +impl HttpFixture { + async fn new(capabilities: WorkdirSessionCapabilities) -> Self { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("src/deep")).unwrap(); + std::fs::write( + dir.path().join("src/deep/a.txt"), + "first\nneedle needle\nlast\n", + ) + .unwrap(); + let session = Arc::new(LocalWorkdirSession::materialized( + dir.path().to_path_buf(), + dir.path().to_path_buf(), + manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()), + capabilities, + )); + let state = Arc::new(ProviderState { + session, + requests: Mutex::new(Vec::new()), + operations: Mutex::new(Vec::new()), + write_fault: Mutex::new(None), + }); + let app = Router::new() + .route( + "/provider/v1/working-directories/{id}/sessions", + post(open_session), + ) + .route( + "/provider/v1/workdir-sessions/{id}/operations", + post(operate), + ) + .route("/provider/v1/workdir-sessions/{id}", delete(close_session)) + .with_state(state.clone()); + // Binding before spawn makes readiness deterministic; no sleeps or polling. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}/provider/", listener.local_addr().unwrap()) + .parse() + .unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + Self { + dir, + state, + base_url, + server, + } + } + + async fn open(&self, token: &str) -> Result { + RemoteWorkdirSession::open( + reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(), + self.base_url.clone(), + token.to_owned(), + self.state.session.workdir().id().clone(), + OpenWorkdirSessionRequest::default(), + ) + .await + } + + fn public_output(&self, text: &str) { + assert!(!text.contains(TOKEN), "bearer token leaked"); + assert!( + !text.contains(self.dir.path().to_str().unwrap()), + "host path leaked" + ); + assert!( + !text.contains("/provider/"), + "transport prefix leaked into Worldspace" + ); + } + + fn assert_endpoints(&self) { + let open = format!( + "/provider/v1/working-directories/{}/sessions", + self.state.session.workdir().id() + ); + let operations = format!("/provider/v1/workdir-sessions/{SESSION}/operations"); + let close = format!("/provider/v1/workdir-sessions/{SESSION}"); + let requests = self.state.requests.lock().unwrap(); + assert!(requests.contains(&("POST".into(), open.clone()))); + assert!(requests.contains(&("POST".into(), operations.clone()))); + assert_eq!( + requests + .iter() + .filter(|(method, path)| method == "DELETE" && path == &close) + .count(), + 1 + ); + assert!(requests.iter().all(|(method, path)| (method == "POST" + && (path == &open || path == &operations)) + || (method == "DELETE" && path == &close))); + } +} + +fn native_runtime(remote: Arc) -> (WipRuntime, Arc) { + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), remote) + .unwrap(); + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None) + .unwrap(); + let runtime = WipRuntime::new( + WipHost::new(registry), + SecurityContext::new("http-checkout-worker"), + 0, + ) + .unwrap(); + (runtime, router) +} + +async fn observe_interface(runtime: &WipRuntime, path: &str) -> String { + runtime.discover(path.into(), 0, true).await.unwrap(); + // Select from the real Client's Known Space, not a direct handler invocation. + let interface = { + let state = runtime.state.lock().unwrap(); + state + .client + .known_space(&state.session) + .unwrap() + .into_iter() + .find(|o| o.path == path) + .unwrap() + .object + .unwrap() + .interfaces[0] + .clone() + }; + runtime.inspect(interface.clone(), true).await.unwrap(); + interface +} + +async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value { + let interface = observe_interface(runtime, path).await; + runtime + .call( + path.into(), + interface, + operation.into(), + args, + Default::default(), + ) + .await + .unwrap(); + let state = runtime.state.lock().unwrap(); + match &state + .client + .call_history(&state.session) + .unwrap() + .back() + .unwrap() + .outcome + { + CallOutcome::Success(response) => response.result.clone(), + outcome => panic!("expected native Client success, got {outcome:?}"), + } +} + +fn field<'a>(value: &'a Value, name: &str) -> &'a Value { + let Value::Record(fields) = value else { + panic!("expected typed Record") + }; + fields.get(name).unwrap() +} + +fn first_link(value: &Value) -> &str { + let Value::List(items) = field(value, "items") else { + panic!("expected typed link List") + }; + assert_eq!(items.len(), 1); + let Value::String(path) = field(&items[0], "path") else { + panic!("expected typed link path") + }; + path +} + +async fn watchdog(future: impl std::future::Future) { + tokio::time::timeout(Duration::from_secs(20), future) + .await + .expect("HTTP checkout test watchdog"); +} + +#[tokio::test] +async fn checkout_http_native_search_links_read_edit_write_create_and_close() { + watchdog(async { + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await; + let remote = Arc::new(fixture.open(TOKEN).await.unwrap()); + let (runtime, router) = native_runtime(remote); + let glob = native_call( + &runtime, + "/checkouts/main", + "glob", + json!({"pattern":"**/*.txt"}), + ) + .await; + let file = first_link(&glob).to_owned(); + assert_eq!(file, "/checkouts/main/src/deep/a.txt"); + let grep = native_call( + &runtime, + "/checkouts/main/src", + "grep", + json!({ + "pattern":"needle", "output_mode":"files_with_matches" + }), + ) + .await; + assert_eq!(first_link(&grep), file); + let read = native_call( + &runtime, + first_link(&grep), + "read", + json!({"offset":1,"limit":1}), + ) + .await; + let Value::String(content) = field(&read, "content") else { + panic!("expected line Read text") + }; + assert_eq!(content, " 2\tneedle needle\n"); + let edit = native_call( + &runtime, + &file, + "edit", + json!({ + "old_string":"needle", "new_string":"done", "replace_all":true + }), + ) + .await; + assert_eq!( + std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(), + "first\ndone done\nlast\n" + ); + let write = native_call(&runtime, &file, "write", json!({"content":"saved\n"})).await; + assert_eq!( + std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(), + "saved\n" + ); + let create = native_call( + &runtime, + "/checkouts/main/src", + "create_file", + json!({ + "path":"new/deeper.txt", "content":"created\n" + }), + ) + .await; + assert_eq!( + std::fs::read_to_string(fixture.dir.path().join("src/new/deeper.txt")).unwrap(), + "created\n" + ); + let created = native_call( + &runtime, + "/checkouts/main/src/new/deeper.txt", + "read", + json!({}), + ) + .await; + for value in [&glob, &grep, &read, &edit, &write, &create, &created] { + fixture.public_output(&serde_json::to_string(&wip_to_json(value).unwrap()).unwrap()); + } + let operations = fixture.state.operations.lock().unwrap(); + assert!( + operations + .iter() + .any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r) + if matches!(r.operation, CheckoutSearchOperation::Glob(_)))) + ); + assert!( + operations + .iter() + .any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r) + if matches!(r.operation, CheckoutSearchOperation::Grep(_)))) + ); + assert!(operations.iter().any( + |op| matches!(op, WorkdirSessionOperation::CheckoutExecute(r) + if matches!(r.operation, CheckoutOperation::Edit { .. })) + )); + assert!(operations.iter().any( + |op| matches!(op, WorkdirSessionOperation::CheckoutExecute(r) + if matches!(r.operation, CheckoutOperation::Create { .. })) + )); + drop(operations); + assert_eq!(fixture.state.write_calls(), 1); + router + .detach(&WorkdirAttachmentAlias::new("main").unwrap()) + .await + .unwrap(); + fixture.assert_endpoints(); + }) + .await; +} + +#[tokio::test] +async fn checkout_http_wrong_bearer_and_readonly_caps_fail_closed() { + watchdog(async { + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await; + let error = fixture.open("wrong-bearer").await.unwrap_err(); + assert!(matches!(error, WorkdirError::Denied(_))); + fixture.public_output(&error.to_string()); + assert!(fixture.state.operations.lock().unwrap().is_empty()); + let remote = Arc::new(fixture.open(TOKEN).await.unwrap()); + assert_eq!(remote.capabilities(), WorkdirSessionCapabilities::READ_ONLY); + let (runtime, router) = native_runtime(remote.clone()); + let file = "/checkouts/main/src/deep/a.txt"; + native_call(&runtime, file, "read", json!({})).await; + let interface = observe_interface(&runtime, file).await; + { + let state = runtime.state.lock().unwrap(); + let observed = state.client.interface(&state.session, &interface).unwrap(); + let descriptor = observed.descriptor.as_ref().unwrap(); + assert_eq!( + descriptor + .operations + .iter() + .map(|op| op.name.as_str()) + .collect::>(), + ["read"] + ); + } + let directory_interface = observe_interface(&runtime, "/checkouts/main").await; + let before = fixture.state.execute_calls(); + for (path, interface, operation, args) in [ + ( + file, + interface.clone(), + "write", + json!({"content":"forbidden"}), + ), + ( + file, + interface, + "edit", + json!({"old_string":"needle", "new_string":"forbidden"}), + ), + ( + "/checkouts/main", + directory_interface, + "create_file", + json!({"path":"forbidden.txt","content":"bad"}), + ), + ] { + let error = runtime + .call( + path.into(), + interface, + operation.into(), + args, + Default::default(), + ) + .await + .unwrap_err(); + fixture.public_output(&error.to_string()); + } + // Preflight may observe objects, but no rejected mutation reaches execution. + assert_eq!(fixture.state.execute_calls(), before); + // A caller bypassing WIP publication still cannot mutate at the provider. + let parent = remote.checkout_observe(WorkdirPath::root()).await.unwrap(); + let error = remote + .checkout_execute(CheckoutRequest { + target: parent.path, + validator: parent.validator, + operation: CheckoutOperation::Create { + path: WorkdirPath::new("forbidden.txt").unwrap(), + content: b"bad".to_vec(), + }, + }) + .await + .unwrap_err(); + assert!(matches!( + error, + WorkdirError::Unsupported(_) + | WorkdirError::UnsupportedOperation(_) + | WorkdirError::ReadOnly(_) + | WorkdirError::Denied(_) + )); + fixture.public_output(&error.to_string()); + assert_eq!( + std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(), + "first\nneedle needle\nlast\n" + ); + assert!(!fixture.dir.path().join("forbidden.txt").exists()); + router + .detach(&WorkdirAttachmentAlias::new("main").unwrap()) + .await + .unwrap(); + fixture.assert_endpoints(); + }) + .await; +} + +async fn committed_write_unknown(fault: WriteResponseFault) { + let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await; + let remote = Arc::new(fixture.open(TOKEN).await.unwrap()); + let (runtime, router) = native_runtime(remote); + let file = "/checkouts/main/src/deep/a.txt"; + native_call(&runtime, file, "read", json!({})).await; + let interface = observe_interface(&runtime, file).await; + *fixture.state.write_fault.lock().unwrap() = Some(fault); + let error = runtime + .call( + file.into(), + interface, + "write".into(), + json!({"content":"committed\n"}), + Default::default(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("outcome unknown")); + assert!(error.to_string().contains("do not retry automatically")); + fixture.public_output(&error.to_string()); + assert_eq!( + runtime.audit().last().unwrap().outcome, + WipAuditOutcome::OutcomeUnknown + ); + { + let state = runtime.state.lock().unwrap(); + assert!(matches!( + state + .client + .call_history(&state.session) + .unwrap() + .back() + .unwrap() + .outcome, + CallOutcome::Unknown { .. } + )); + } + assert_eq!( + std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(), + "committed\n" + ); + assert_eq!( + fixture.state.write_calls(), + 1, + "committed Write must not be automatically retried" + ); + // Explicit inspection of the effect is safe; it must not replay the mutation. + let read = native_call(&runtime, file, "read", json!({})).await; + fixture.public_output(&serde_json::to_string(&wip_to_json(&read).unwrap()).unwrap()); + let Value::String(content) = field(&read, "content") else { + panic!("expected Read text") + }; + assert!(content.contains("committed")); + assert_eq!(fixture.state.write_calls(), 1); + router + .detach(&WorkdirAttachmentAlias::new("main").unwrap()) + .await + .unwrap(); + fixture.assert_endpoints(); +} + +#[tokio::test] +async fn checkout_http_malformed_response_after_write_is_unknown_without_retry() { + watchdog(committed_write_unknown(WriteResponseFault::Malformed)).await; +} + +#[tokio::test] +async fn checkout_http_lost_response_after_write_is_unknown_without_retry() { + watchdog(committed_write_unknown(WriteResponseFault::LostBody)).await; +} diff --git a/crates/worker/src/checkout_race_tests.rs b/crates/worker/src/checkout_race_tests.rs new file mode 100644 index 000000000..b4e42397c --- /dev/null +++ b/crates/worker/src/checkout_race_tests.rs @@ -0,0 +1,469 @@ +//! Deterministic attachment races at provider observation boundaries. +//! +//! These tests deliberately avoid spawned tasks, sleeps, and detach attempts +//! inside a routed operation (which is busy and cannot be detached). Deadline +//! tests use the provider's configurable deadline and a permanently pending +//! observation, without Tokio's test-util clock or the production 30-second wait. +use super::*; + +use std::future::pending; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tempfile::TempDir; +use workdir::{ + CheckoutRequest, CheckoutResult, CommandHandle, CommandOutput, CommandOutputRequest, + CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, + GrepResult, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult, ReadRequest, + ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias, WorkdirError, + WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, + WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, +}; + +type ObserveHook = Box; + +/// Shared local provider with a one-shot, synchronous observation boundary. +/// Take the callback out of the lock before invoking it: it may call the router. +struct HookedSession { + local: LocalWorkdirSession, + observe_hook: Mutex>, + stall_path: Mutex>, + stalled_observations: AtomicUsize, +} + +impl std::fmt::Debug for HookedSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HookedSession") + .field("local", &self.local) + .field("stall_path", &self.stall_path) + .field("stalled_observations", &self.stalled_observations) + .finish_non_exhaustive() + } +} + +impl HookedSession { + fn new(dir: &TempDir) -> Arc { + Arc::new(Self { + local: LocalWorkdirSession::materialized( + dir.path().to_path_buf(), + dir.path().to_path_buf(), + manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()), + WorkdirSessionCapabilities::READ_WRITE, + ), + observe_hook: Mutex::new(None), + stall_path: Mutex::new(None), + stalled_observations: AtomicUsize::new(0), + }) + } + + fn on_next_observe(&self, hook: impl FnOnce(&WorkdirPath) + Send + 'static) { + let mut slot = self.observe_hook.lock().unwrap(); + assert!(slot.is_none(), "an observation hook is already armed"); + *slot = Some(Box::new(hook)); + } + + fn stall_observation(&self, path: Option) { + *self.stall_path.lock().unwrap() = path; + } +} + +#[async_trait] +impl WorkdirSession for HookedSession { + fn workdir(&self) -> &Workdir { + self.local.workdir() + } + + fn capabilities(&self) -> WorkdirSessionCapabilities { + self.local.capabilities() + } + + async fn authorize_scope_path( + &self, + request: WorkdirScopeAuthorizationRequest, + ) -> Result<(), WorkdirError> { + self.local.authorize_scope_path(request).await + } + + async fn scope_rules_overlap( + &self, + request: WorkdirScopeOverlapRequest, + ) -> Result { + self.local.scope_rules_overlap(request).await + } + + async fn checkout_observe( + &self, + path: WorkdirPath, + ) -> Result { + let hook = { self.observe_hook.lock().unwrap().take() }; + if let Some(hook) = hook { + hook(&path); + } + let should_stall = { + self.stall_path + .lock() + .unwrap() + .as_ref() + .is_some_and(|stall| stall.as_str() == path.as_str()) + }; + if should_stall { + self.stalled_observations.fetch_add(1, Ordering::SeqCst); + return pending().await; + } + self.local.checkout_observe(path).await + } + + async fn checkout_execute( + &self, + request: CheckoutRequest, + ) -> Result { + self.local.checkout_execute(request).await + } + + async fn checkout_search( + &self, + request: workdir::CheckoutSearchRequest, + ) -> Result { + self.local.checkout_search(request).await + } + + async fn stat(&self, request: StatRequest) -> Result { + self.local.stat(request).await + } + + async fn read(&self, request: ReadRequest) -> Result { + self.local.read(request).await + } + + async fn read_bytes(&self, request: ReadBytesRequest) -> Result { + self.local.read_bytes(request).await + } + + async fn write(&self, request: WriteRequest) -> Result { + self.local.write(request).await + } + + async fn edit(&self, request: EditRequest) -> Result { + self.local.edit(request).await + } + + async fn list(&self, request: ListRequest) -> Result { + self.local.list(request).await + } + + async fn glob(&self, request: GlobRequest) -> Result { + self.local.glob(request).await + } + + async fn grep(&self, request: GrepRequest) -> Result { + self.local.grep(request).await + } + + async fn start_command(&self, request: CommandRequest) -> Result { + self.local.start_command(request).await + } + + async fn command_status(&self, handle: CommandHandle) -> Result { + self.local.command_status(handle).await + } + + async fn command_output( + &self, + request: CommandOutputRequest, + ) -> Result { + self.local.command_output(request).await + } + + async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { + self.local.cancel_command(handle).await + } + + async fn close(&self) -> Result<(), WorkdirError> { + self.local.close().await + } +} + +fn provider(router: Arc) -> Arc { + Arc::new(Provider { + router, + tracker: tools::Tracker::new(), + permissions: None, + incarnation: "checkout-race-tests".into(), + permits: Arc::new(tokio::sync::Semaphore::new(16)), + deadline: DEADLINE, + }) +} + +fn deadline_provider(router: Arc) -> Arc { + let mut p = provider(router); + let settings = Arc::get_mut(&mut p).unwrap(); + // Allow ample time for the tiny local search before stalling post-processing. + // The outer test watchdog is intentionally much more generous than this. + settings.deadline = Duration::from_millis(100); + settings.permits = Arc::new(tokio::sync::Semaphore::new(1)); + p +} + +fn context() -> WipCallContext { + WipCallContext { + execution: Default::default(), + security_context: "checkout-race-tests".into(), + } +} + +fn assert_validator_mismatch(result: Result) { + match result { + Err(WipOperationError::Protocol(e)) => { + assert_eq!(e.code, ProtocolErrorCode::ValidatorMismatch); + } + Err(_) => panic!("expected ValidatorMismatch, received a non-protocol error"), + Ok(_) => panic!("a changed attachment snapshot must not be published"), + } +} + +#[tokio::test] +async fn collection_list_rejects_alias_attached_during_observation_and_retries_coherently() { + let main_dir = TempDir::new().unwrap(); + let extra_dir = TempDir::new().unwrap(); + let main = HookedSession::new(&main_dir); + let extra: WorkdirSessionHandle = HookedSession::new(&extra_dir); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), main.clone()) + .unwrap(); + let p = provider(router.clone()); + let before = p.collection().object.validator; + let hook_router = router.clone(); + main.on_next_observe(move |path| { + assert!(path.is_root(), "collection enumeration observes the root"); + hook_router + .attach(WorkdirAttachmentAlias::new("extra").unwrap(), extra) + .unwrap(); + }); + + let collection = Collection { + provider: p.clone(), + }; + assert_validator_mismatch(collection.call("list", &BTreeMap::new(), context()).await); + assert_ne!(before, p.collection().object.validator); + assert!(router.resolve(Some("extra")).is_ok(), "the hook must run"); + + // The callback was consumed. A fresh snapshot includes both aliases, with + // identity metadata from exactly the router state being enumerated. + let output = match collection.call("list", &BTreeMap::new(), context()).await { + Ok(output) => output, + Err(_) => panic!("the stable retry must succeed"), + }; + let value = wip_to_json(&output.value).unwrap(); + let items = value["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); + for alias in ["extra", "main"] { + let item = items + .iter() + .find(|item| item["alias"] == json!(alias)) + .expect("each attached alias must appear in the stable snapshot"); + let selected = router.resolve(Some(alias)).unwrap(); + assert_eq!(item["alias"], json!(alias)); + assert_eq!(item["path"], json!(checkout_root(alias))); + assert_eq!(item["slug"], json!(encode_identity(alias))); + assert_eq!(item["generation"], json!(selected.generation)); + assert_eq!( + item["working_directory_id"], + json!(selected.session.workdir().id().as_str()) + ); + } +} + +#[tokio::test] +async fn root_enumeration_rejects_alias_attached_during_observation() { + let main_dir = TempDir::new().unwrap(); + let extra_dir = TempDir::new().unwrap(); + let main = HookedSession::new(&main_dir); + let extra: WorkdirSessionHandle = HookedSession::new(&extra_dir); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), main.clone()) + .unwrap(); + let hook_router = router.clone(); + main.on_next_observe(move |_| { + hook_router + .attach(WorkdirAttachmentAlias::new("extra").unwrap(), extra) + .unwrap(); + }); + let subtree = Arc::new(provider(router.clone())); + + match subtree.children(ROOT).await { + Err(e) => assert_eq!(e.code, ProtocolErrorCode::ValidatorMismatch), + Ok(_) => panic!("enumeration must reject a mixed attachment snapshot"), + } + assert!(router.resolve(Some("extra")).is_ok(), "the hook must run"); + let mut paths = subtree.children(ROOT).await.unwrap(); + paths.sort(); + assert_eq!(paths, vec![checkout_root("extra"), checkout_root("main")]); +} + +#[tokio::test] +async fn file_handler_connection_fences_detach_and_same_session_alias_reuse() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("file.txt"), "unchanged\n").unwrap(); + let session: WorkdirSessionHandle = HookedSession::new(&dir); + let router = Arc::new(WorkdirSessionRouter::new()); + let alias = WorkdirAttachmentAlias::new("main").unwrap(); + router.attach(alias.clone(), session.clone()).unwrap(); + let selected = router.resolve(Some("main")).unwrap(); + let target = WorkdirPath::new("file.txt").unwrap(); + let observation = selected + .session + .checkout_observe(target.clone()) + .await + .unwrap(); + let mut handler = FileHandler { + provider: provider(router.clone()), + alias: "main".into(), + generation: selected.generation, + workdir: selected.session.workdir().id().as_str().into(), + target, + observation, + }; + drop(selected); + assert!(handler.check_connection().is_ok()); + + // No routed operation is active here. Reuse the exact same provider identity + // to ensure generation, not changed workdir ID/content, causes rejection. + // Detach may close the local session; no subsequent filesystem operation is + // made on it. This test only exercises the router's connection identity. + router.detach(&alias).await.unwrap(); + assert_validator_mismatch(handler.check_connection()); + router.attach(alias, session).unwrap(); + let replacement = router.resolve(Some("main")).unwrap(); + assert_ne!(replacement.generation, handler.generation); + assert_eq!(replacement.session.workdir().id().as_str(), handler.workdir); + assert_validator_mismatch(handler.check_connection()); + + // Positive control: the new generation is accepted with the same identity. + handler.generation = replacement.generation; + assert!(handler.check_connection().is_ok()); +} + +fn assert_deadline(result: Result, message: &str) { + match result { + Err(WipOperationError::Protocol(e)) => { + assert_eq!(e.code, ProtocolErrorCode::ResourceLimitExceeded); + assert_eq!(e.message, message); + } + Err(_) => panic!("expected a protocol deadline error"), + Ok(_) => panic!("a stalled provider observation must time out"), + } +} + +fn assert_permit_released(p: &Provider) { + assert_eq!(p.permits.available_permits(), 1); + let permit = p + .permits + .try_acquire() + .expect("the timed-out operation must release its permit"); + drop(permit); +} + +#[tokio::test] +async fn collection_list_and_root_enumeration_bound_stalled_observation_and_release_permit() { + let dir = TempDir::new().unwrap(); + let session = HookedSession::new(&dir); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session.clone(), + ) + .unwrap(); + session.stall_observation(Some(WorkdirPath::new("").unwrap())); + let p = deadline_provider(router); + let collection = Collection { + provider: p.clone(), + }; + + // The outer watchdog prevents a missing production timeout from hanging the + // suite. The exact protocol message proves the inner enumeration timed out. + let result = tokio::time::timeout( + Duration::from_secs(2), + collection.call("list", &BTreeMap::new(), context()), + ) + .await + .expect("Collection.list must be bounded by the provider deadline"); + assert_deadline(result, "checkout enumeration deadline"); + assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1); + assert_permit_released(&p); + + // Exercise the subtree entry point independently with the stall still armed. + let subtree = Arc::new(p.clone()); + let result = tokio::time::timeout(Duration::from_secs(2), subtree.children(ROOT)) + .await + .expect("root enumeration must be bounded by the provider deadline") + .map_err(WipOperationError::Protocol); + assert_deadline(result, "checkout enumeration deadline"); + assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 2); + assert_permit_released(&p); + + // Removing the stall makes the same provider usable immediately, without + // resetting its semaphore or waiting for any background task to finish. + session.stall_observation(None); + let paths = tokio::time::timeout(Duration::from_secs(2), subtree.children(ROOT)) + .await + .expect("enumeration must remain usable after cancellation") + .unwrap(); + assert_eq!(paths, vec![checkout_root("main")]); + assert_permit_released(&p); +} + +#[tokio::test] +async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_permit() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap(); + let session = HookedSession::new(&dir); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session.clone(), + ) + .unwrap(); + let p = deadline_provider(router); + let root = checkout_root("main"); + let projection = p.node(&root).await.unwrap().unwrap(); + + // Directory observations and the normal local grep/checkout execution can + // complete. Only publishing the typed search result path gets stuck. + session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap())); + let arguments = BTreeMap::from([ + ("pattern".into(), Value::String("needle".into())), + ( + "output_mode".into(), + Value::String("files_with_matches".into()), + ), + ]); + let result = tokio::time::timeout( + Duration::from_secs(2), + projection.handler.call("grep", &arguments, context()), + ) + .await + .expect("post-search link observations must share the operation deadline"); + assert_deadline(result, "checkout result deadline"); + assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1); + assert_permit_released(&p); + + // A successful retry both checks cancellation cleanup and proves that the + // delegated search produces the typed path consumed by post-processing. + session.stall_observation(None); + let result = tokio::time::timeout( + Duration::from_secs(2), + projection.handler.call("grep", &arguments, context()), + ) + .await + .expect("grep must remain usable after cancelling result publication"); + let output = match result { + Ok(output) => output, + Err(_) => panic!("grep must succeed once the link observation is unstalled"), + }; + let value = wip_to_json(&output.value).unwrap(); + assert_eq!(value["items"], json!([{"path": format!("{root}/a.txt")}])); + assert_permit_released(&p); +} diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs new file mode 100644 index 000000000..b081df1cf --- /dev/null +++ b/crates/worker/src/checkout_wip_tests.rs @@ -0,0 +1,580 @@ +//! Exercises native checkout interactions through the real WIP Client/HTTP codec. +use super::*; +use tempfile::TempDir; +use workdir::{ + LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionCapabilities, WorkdirSessionHandle, + WorkdirSessionRouter, +}; + +fn session(dir: &TempDir, read_only: bool) -> WorkdirSessionHandle { + let caps = if read_only { + WorkdirSessionCapabilities::READ_ONLY + } else { + WorkdirSessionCapabilities::READ_WRITE + }; + Arc::new(LocalWorkdirSession::materialized( + dir.path().to_path_buf(), + dir.path().to_path_buf(), + manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()), + caps, + )) +} +fn runtime( + router: Arc, + permissions: Option, +) -> WipRuntime { + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), permissions) + .unwrap(); + WipRuntime::new( + WipHost::new(registry), + SecurityContext::new("checkout-test-worker"), + 0, + ) + .unwrap() +} +async fn interface(r: &WipRuntime, path: &str) -> String { + r.discover(path.into(), 0, true).await.unwrap(); + let p = r.host.projection_live(path).await.unwrap().unwrap(); + r.inspect(p.interface.clone(), true).await.unwrap(); + p.interface +} +async fn call( + r: &WipRuntime, + path: &str, + operation: &str, + args: Json, +) -> Result { + let i = interface(r, path).await; + r.call(path.into(), i, operation.into(), args, Default::default()) + .await +} + +#[tokio::test] +async fn checkout_wip_native_roundtrip_search_read_edit_write_create() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("src/deep")).unwrap(); + std::fs::write( + dir.path().join("src/deep/a.txt"), + "first\nneedle needle\nlast\n", + ) + .unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, false), + ) + .unwrap(); + let r = runtime(router, None); + let root = r.host.observe_live("/", 2).await.unwrap(); + assert_eq!(root.children.unwrap()[0].object.name, "checkouts"); + let file = "/checkouts/main/src/deep/a.txt"; + let glob = call(&r, "/checkouts/main", "glob", json!({"pattern":"**/*.txt"})) + .await + .unwrap(); + assert!(glob.content.unwrap().contains(file)); + let grep = call( + &r, + "/checkouts/main/src", + "grep", + json!({"pattern":"needle","output_mode":"files_with_matches"}), + ) + .await + .unwrap(); + let grep_result: Json = serde_json::from_str(grep.content.as_deref().unwrap()).unwrap(); + assert_eq!(grep_result["items"][0]["path"], file); + let followed = grep_result["items"][0]["path"].as_str().unwrap(); + assert_eq!(followed, file); + assert!( + call( + &r, + file, + "edit", + json!({"old_string":"needle","new_string":"done"}) + ) + .await + .is_err() + ); + let read = call(&r, file, "read", json!({"offset":1,"limit":1})) + .await + .unwrap(); + assert!(read.content.unwrap().contains("needle needle")); + assert!( + call( + &r, + file, + "edit", + json!({"old_string":"needle","new_string":"done"}) + ) + .await + .is_err() + ); + call( + &r, + file, + "edit", + json!({"old_string":"needle","new_string":"done","replace_all":true}), + ) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("src/deep/a.txt")).unwrap(), + "first\ndone done\nlast\n" + ); + call(&r, file, "write", json!({"content":"saved\n"})) + .await + .unwrap(); + let reread = call(&r, file, "read", json!({})).await.unwrap(); + assert!(reread.content.unwrap().contains("saved")); + call( + &r, + "/checkouts/main/src", + "create_file", + json!({"path":"new/deeper.txt","content":"created\n"}), + ) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("src/new/deeper.txt")).unwrap(), + "created\n" + ); + assert!( + call( + &r, + "/checkouts/main/src", + "create_file", + json!({"path":"new/deeper.txt","content":"overwrite"}) + ) + .await + .is_err() + ); + call(&r, "/checkouts/main/src/new/deeper.txt", "read", json!({})) + .await + .unwrap(); +} + +#[tokio::test] +async fn checkout_wip_multiple_aliases_read_only_and_escape_rejection() { + let a = TempDir::new().unwrap(); + let b = TempDir::new().unwrap(); + std::fs::write(a.path().join("a.txt"), "A").unwrap(); + std::fs::write(b.path().join("b.txt"), "B").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("a").unwrap(), + session(&a, false), + ) + .unwrap(); + router + .attach(WorkdirAttachmentAlias::new("b").unwrap(), session(&b, true)) + .unwrap(); + let r = runtime(router, None); + let observed = r.host.observe_live("/checkouts", 2).await.unwrap(); + assert_eq!(observed.children.unwrap().len(), 2); + let bfile = r + .host + .projection_live("/checkouts/b/b.txt") + .await + .unwrap() + .unwrap(); + assert_eq!( + bfile + .descriptor + .operations + .iter() + .map(|o| o.name.as_str()) + .collect::>(), + vec!["read"] + ); + assert!( + r.host + .projection_live("/checkouts/b/a.txt") + .await + .unwrap() + .is_none() + ); + assert!( + r.host + .projection_live("/checkouts/a/../b/b.txt") + .await + .is_err() + ); + assert!( + call( + &r, + "/checkouts/a", + "glob", + json!({"pattern":"*","path":"../b"}) + ) + .await + .is_err() + ); + assert!( + call( + &r, + "/checkouts/a", + "create_file", + json!({"path":"/outside.txt","content":"bad"}) + ) + .await + .is_err() + ); + assert!( + call( + &r, + "/checkouts/a/a.txt", + "read", + json!({"target_workdir":"b"}) + ) + .await + .is_err() + ); + assert!( + call( + &r, + "/checkouts/a/a.txt", + "read", + json!({"file_path":"b.txt"}) + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() { + let a = TempDir::new().unwrap(); + let b = TempDir::new().unwrap(); + std::fs::write(a.path().join("file.txt"), "same").unwrap(); + std::fs::write(b.path().join("file.txt"), "same").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + let alias = WorkdirAttachmentAlias::new("main").unwrap(); + router.attach(alias.clone(), session(&a, false)).unwrap(); + let r = runtime(router.clone(), None); + let path = "/checkouts/main/file.txt"; + call(&r, path, "read", json!({})).await.unwrap(); + let old = r.host.projection_live(path).await.unwrap().unwrap(); + router.detach(&alias).await.unwrap(); + assert!(r.host.projection_live(path).await.unwrap().is_none()); + assert!(r.host.fetch_interface_live(&old.interface).await.is_err()); + router.attach(alias, session(&b, false)).unwrap(); + let new = r.host.projection_live(path).await.unwrap().unwrap(); + assert_ne!(old.interface, new.interface); + assert_ne!(old.object.validator, new.object.validator); + assert!(r.host.fetch_interface_live(&old.interface).await.is_err()); + assert!( + call( + &r, + path, + "write", + json!({"content":"must read new attachment"}) + ) + .await + .is_err() + ); + let restored = runtime(router, None); + let next = restored.host.projection_live(path).await.unwrap().unwrap(); + assert_ne!(new.interface, next.interface); + assert_ne!(new.object.validator, next.object.validator); + assert!( + restored + .host + .fetch_interface_live(&new.interface) + .await + .is_err() + ); + assert_eq!( + std::fs::read_to_string(b.path().join("file.txt")).unwrap(), + "same" + ); +} + +#[tokio::test] +async fn checkout_wip_provider_validator_rejects_same_content_inode_substitution() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("file.txt"), "same").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, false), + ) + .unwrap(); + let r = runtime(router, None); + let path = "/checkouts/main/file.txt"; + call(&r, path, "read", json!({})).await.unwrap(); + let p = r.host.projection_live(path).await.unwrap().unwrap(); + std::fs::write(dir.path().join("replacement"), "same").unwrap(); + std::fs::rename(dir.path().join("replacement"), dir.path().join("file.txt")).unwrap(); + // Call the captured handler directly: provider must reject even if Host validation was earlier. + let out = p + .handler + .call( + "write", + &BTreeMap::from([("content".into(), Value::String("bad".into()))]), + WipCallContext { + execution: Default::default(), + security_context: "checkout-test-worker".into(), + }, + ) + .await; + assert!(matches!( + out, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::ValidatorMismatch, + .. + })) + )); + assert_eq!( + std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), + "same" + ); +} + +#[tokio::test] +async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_replaced() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("file.txt"), "secret").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, false), + ) + .unwrap(); + let permissions = ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: Vec::new(), + }; + let r = runtime(router.clone(), Some(permissions)); + assert!( + r.host + .projection_live("/checkouts/main/file.txt") + .await + .unwrap() + .is_none() + ); + assert!( + r.host + .observe_live("/checkouts", 1) + .await + .unwrap() + .children + .unwrap() + .is_empty() + ); + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None) + .unwrap(); + for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) { + let (meta, tool) = def(); + let p = compatibility_projection(meta, tool, None).unwrap(); + registry.mount(p).unwrap(); + } + for tool in ["Read", "Edit", "Write", "Glob", "Grep"] { + assert!(!registry.routes().any(|p| p == format!("/tools/{tool}"))); + } + assert!(registry.routes().any(|p| p == "/tools/Bash")); +} + +#[tokio::test] +async fn checkout_wip_bounded_observation_and_deep_subtree_collision() { + let dir = TempDir::new().unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, false), + ) + .unwrap(); + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap(); + let placeholder = registry.mounts["/checkouts"].resolved(); + let mut collision = placeholder.clone(); + collision.route = "/checkouts/main/a".into(); + collision.object.name = "a".into(); + assert!(matches!( + registry.mount(collision), + Err(WipMountError::RouteCollision { .. }) + )); + let host = WipHost::new(registry); + assert_eq!( + host.observe_live("/checkouts", 33).await.unwrap_err().code, + ProtocolErrorCode::ResourceLimitExceeded + ); +} + +#[tokio::test] +async fn checkout_wip_post_operation_validators_support_cached_chains() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("file.txt"), "old\n").unwrap(); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach( + WorkdirAttachmentAlias::new("main").unwrap(), + session(&dir, false), + ) + .unwrap(); + let r = runtime(router, None); + let path = "/checkouts/main/file.txt"; + let i = interface(&r, path).await; + // No rediscovery or Interface refresh between any of these operations. + for (op, args) in [ + ("read", json!({})), + ("edit", json!({"old_string":"old","new_string":"edited"})), + ("write", json!({"content":"saved\n"})), + ("read", json!({})), + ] { + r.call(path.into(), i.clone(), op.into(), args, Default::default()) + .await + .unwrap(); + } + assert_eq!( + std::fs::read_to_string(dir.path().join("file.txt")).unwrap(), + "saved\n" + ); + let parent = "/checkouts/main"; + let i = interface(&r, parent).await; + for name in ["one.txt", "nested/two.txt"] { + r.call( + parent.into(), + i.clone(), + "create_file".into(), + json!({"path":name,"content":"new"}), + Default::default(), + ) + .await + .unwrap(); + } + assert_eq!( + std::fs::read_to_string(dir.path().join("nested/two.txt")).unwrap(), + "new" + ); +} + +#[tokio::test] +async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() { + use workdir::{ + WorkdirPath, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission as Permission, + WorkdirToolScopeRule, + }; + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join("child")).unwrap(); + std::fs::write(dir.path().join("child/a.txt"), "visible needle").unwrap(); + std::fs::write(dir.path().join("private-sibling-name.txt"), "hidden needle").unwrap(); + let broker = Arc::new(WorkdirToolBroker::new(session(&dir, false))); + let lease = broker + .scope(WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::new("child").unwrap(), + permission: Permission::Write, + recursive: true, + symlink_policy: Default::default(), + }], + cwd: WorkdirPath::new("child").unwrap(), + command: false, + }) + .await + .unwrap(); + let parent_router = Arc::new(WorkdirSessionRouter::new()); + parent_router + .attach( + WorkdirAttachmentAlias::new("parent").unwrap(), + broker.tool_session(), + ) + .unwrap(); + let parent = runtime(parent_router, None); + let parent_file = parent + .host + .projection_live("/checkouts/parent/child/a.txt") + .await + .unwrap() + .unwrap(); + assert!( + !parent_file + .descriptor + .operations + .iter() + .any(|op| op.name == "write" || op.name == "edit") + ); + let child_router = Arc::new(WorkdirSessionRouter::new()); + child_router + .attach( + WorkdirAttachmentAlias::new("child").unwrap(), + lease.tool_session(), + ) + .unwrap(); + let child = runtime(child_router, None); + let observed = child + .host + .observe_live("/checkouts/child", 2) + .await + .unwrap(); + assert_eq!(observed.children.unwrap().len(), 1); + for (op, args) in [ + ("glob", json!({"pattern":"**/*"})), + ( + "grep", + json!({"pattern":"needle","output_mode":"files_with_matches"}), + ), + ] { + let output = call(&child, "/checkouts/child", op, args) + .await + .unwrap() + .content + .unwrap(); + assert!(output.contains("/checkouts/child/a.txt")); + assert!(!output.contains("private-sibling-name")); + assert!(!output.contains("hidden needle")); + } + assert!( + child + .host + .projection_live("/checkouts/child/private-sibling-name.txt") + .await + .unwrap() + .is_none() + ); + let file = "/checkouts/child/a.txt"; + let i = interface(&child, file).await; + child + .call( + file.into(), + i.clone(), + "read".into(), + json!({}), + Default::default(), + ) + .await + .unwrap(); + child + .call( + file.into(), + i.clone(), + "write".into(), + json!({"content":"changed"}), + Default::default(), + ) + .await + .unwrap(); + lease.close().await.unwrap(); + assert!(child.host.projection_live(file).await.unwrap().is_none()); + assert!( + child + .call( + file.into(), + i, + "write".into(), + json!({"content":"after revoke"}), + Default::default() + ) + .await + .is_err() + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("child/a.txt")).unwrap(), + "changed" + ); +} diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 7d74415d8..2f922592a 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1597,6 +1597,20 @@ where let host_worker_observation_provider = worker.worker_observation_provider(); let wip_permissions = worker.manifest().permissions.clone(); let mut wip_mount_registry = crate::wip::WipMountRegistry::new(); + if wip_mode && !backend_job_profile { + crate::checkout::mount_checkouts( + &mut wip_mount_registry, + worker.workdir_sessions(), + tracker.clone(), + wip_permissions.clone(), + ) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("mount checkout WIP projection: {error}"), + ) + })?; + } if wip_mode && let Some(feature) = &workdir_wip_feature { crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( &mut wip_mount_registry, diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 537739187..28867c0ca 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -353,6 +353,102 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { } } + async fn checkout_observe( + &self, + path: workdir::WorkdirPath, + ) -> Result { + match self.operate(WorkdirSessionOperation::CheckoutObserve(path.clone()))? { + WorkdirSessionOperationResult::CheckoutObserve(mut result) + if result.path == path + && !result.validator.is_empty() + && result.validator.len() <= 256 => + { + result.capabilities = result.capabilities.intersection(self.capabilities); + Ok(result) + } + _ => Err(Self::mismatch("checkout_observe")), + } + } + + async fn checkout_execute( + &self, + request: workdir::CheckoutRequest, + ) -> Result { + let cap = request.operation.capability(); + if !self.capabilities.supports(cap) { + return Err(WorkdirError::Unsupported(cap)); + } + let target = request.target.clone(); + let mutation = cap != workdir::WorkdirSessionCapability::Read; + match self.operate(WorkdirSessionOperation::CheckoutExecute(request)) { + Ok(WorkdirSessionOperationResult::CheckoutExecute(mut result)) + if result.observation.path == target + && !result.observation.validator.is_empty() + && result.observation.validator.len() <= 256 + && matches!( + (&result.output, cap), + ( + workdir::CheckoutOutput::Read(_), + workdir::WorkdirSessionCapability::Read + ) | ( + workdir::CheckoutOutput::Write(_), + workdir::WorkdirSessionCapability::Write + ) | ( + workdir::CheckoutOutput::Edit(_), + workdir::WorkdirSessionCapability::Edit + ) + ) => + { + result.observation.capabilities = result + .observation + .capabilities + .intersection(self.capabilities); + Ok(result) + } + Ok(_) if mutation => Err(WorkdirError::OutcomeUnknown( + "mismatched checkout mutation response".into(), + )), + Ok(_) => Err(Self::mismatch("checkout_execute")), + Err(WorkdirError::Transport(_) | WorkdirError::Unavailable(_)) if mutation => { + Err(WorkdirError::OutcomeUnknown( + "checkout mutation response lost; inspect effects before retry".into(), + )) + } + Err(error) => Err(error), + } + } + + async fn checkout_search( + &self, + request: workdir::CheckoutSearchRequest, + ) -> Result { + use workdir::{ + CheckoutSearchOperation as Op, CheckoutSearchResult as SearchResult, + WorkdirSessionCapability as Cap, + }; + let cap = match &request.operation { + Op::List(_) => Cap::Read, + Op::Glob(_) => Cap::Glob, + Op::Grep(_) => Cap::Grep, + }; + if !self.capabilities.supports(cap) { + return Err(WorkdirError::Unsupported(cap)); + } + match self.operate(WorkdirSessionOperation::CheckoutSearch(request))? { + WorkdirSessionOperationResult::CheckoutSearch(result) + if matches!( + (&result, cap), + (SearchResult::List(_), Cap::Read) + | (SearchResult::Glob(_), Cap::Glob) + | (SearchResult::Grep(_), Cap::Grep) + ) => + { + Ok(result) + } + _ => Err(Self::mismatch("checkout_search")), + } + } + async fn stat(&self, request: StatRequest) -> Result { match self.operate(WorkdirSessionOperation::Stat(request))? { WorkdirSessionOperationResult::Stat(result) => Ok(result), @@ -1414,6 +1510,56 @@ mod tests { assert!(body.get("session_id").is_none()); } + #[tokio::test] + async fn attached_checkout_mutations_preserve_known_rejection_and_lost_outcomes() { + let client = Arc::new(RecordingWorkspaceClient::new(vec![ + response(json!({"operation":"write","result":{"bytes_written":3,"created":false}})), + WorkspaceResponse { + status: 200, + body: "invalid payload".into(), + }, + error_response( + 409, + workdir::http::WorkdirTransportErrorCode::Conflict, + "stale", + ), + ])); + let session = WorkspaceAttachedWorkdirSession::handle(client.clone(), "checkout"); + let request = workdir::CheckoutRequest { + target: workdir::WorkdirPath::new("file.txt").unwrap(), + validator: vec![1], + operation: workdir::CheckoutOperation::Write { + content: b"new".to_vec(), + expected_hash: [0; 32], + }, + }; + for _ in 0..2 { + assert!(matches!( + session.checkout_execute(request.clone()).await, + Err(WorkdirError::OutcomeUnknown(_)) + )); + } + assert!(matches!( + session.checkout_execute(request.clone()).await, + Err(WorkdirError::Conflict(_)) + )); + // Missing response simulates the Workspace client losing the provider result. + assert!(matches!( + session.checkout_execute(request).await, + Err(WorkdirError::OutcomeUnknown(_)) + )); + let calls = client.requests(); + assert_eq!(calls.len(), 4); + for call in calls { + let body: serde_json::Value = + serde_json::from_str(call.body.as_deref().unwrap()).unwrap(); + assert_eq!(body["target_workdir"], "checkout"); + assert_eq!(body["operation"]["operation"], "checkout_execute"); + assert_eq!(body["operation"]["request"]["target"], "file.txt"); + assert!(body.get("runtime_id").is_none()); + } + } + #[tokio::test] async fn attached_session_does_not_send_worker_local_bash_spill_path() { let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({ diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip.rs b/crates/worker/src/feature/builtin/manage_workdir/wip.rs index 0d3439578..c1a745f79 100644 --- a/crates/worker/src/feature/builtin/manage_workdir/wip.rs +++ b/crates/worker/src/feature/builtin/manage_workdir/wip.rs @@ -344,7 +344,21 @@ impl Provider { if let Some(id) = raw.get("working_directory_id").and_then(Json::as_str) { result.insert("workdir_path".into(), json!(item_path(Domain::Workdir, id))); } - // Content projection belongs to T-699/T-695. No unconditional FS entrance. + if let Some(alias) = raw.get("alias").and_then(Json::as_str) + && let Ok(selected) = self.backend.session_router.resolve(Some(alias)) + && raw.get("working_directory_id").and_then(Json::as_str) + == Some(selected.session.workdir().id().as_str()) + && selected + .session + .capabilities() + .supports(workdir::WorkdirSessionCapability::Read) + { + result.insert( + "checkout_path".into(), + json!(crate::checkout::checkout_root(alias)), + ); + result.insert("checkout_slug".into(), json!(encode_identity(alias))); + } } result.insert("path".into(), json!(item_path(domain, id))); Ok(Json::Object(result)) @@ -735,6 +749,17 @@ impl WipOperationHandler for ItemHandler { let mut result = raw; result["workdir_path"] = json!(item_path(Domain::Workdir, &self.id)); result["attachments_path"] = json!(ATTACHMENTS); + if let Some(alias) = input.get("alias").and_then(Json::as_str) + && self + .provider + .backend + .session_router + .resolve(Some(alias)) + .is_ok() + { + result["checkout_path"] = json!(crate::checkout::checkout_root(alias)); + result["checkout_slug"] = json!(encode_identity(alias)); + } // Backend attachment list supplies the durable lifetime identity. result } else { @@ -866,7 +891,7 @@ fn page_limit(input: &Json) -> Result { // Readable ASCII identities remain unchanged. Every other UTF-8 identity uses a // canonical ~hex segment; the escape marker is itself always escaped. This is // injective, does not exclude existing keys, and never decodes a second route. -fn encode_identity(id: &str) -> String { +pub(crate) fn encode_identity(id: &str) -> String { if !id.is_empty() && id .bytes() @@ -883,7 +908,7 @@ fn encode_identity(id: &str) -> String { ) } } -fn decode_identity(segment: &str) -> Option { +pub(crate) fn decode_identity(segment: &str) -> Option { let id = if let Some(hex) = segment.strip_prefix('~') { if hex.len() % 2 != 0 || hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return None; diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 49c277eb9..66160db84 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -1,4 +1,5 @@ pub mod bootstrap; +pub mod checkout; pub mod compact; pub mod controller; pub mod discovery; diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 46c06c438..31b2b8c38 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -42,6 +42,13 @@ use wip_protocol::{ use crate::permission::permission_action_for; +#[cfg(test)] +#[path = "checkout_http_tests.rs"] +mod checkout_http_tests; +#[cfg(test)] +#[path = "checkout_wip_tests.rs"] +mod checkout_wip_tests; + const WIP_ENDPOINT: &str = "https://worker.wip.invalid/v1/"; const WIP_ROOT: &str = "/"; const WIP_TOOLS_ROOT: &str = "/tools"; @@ -92,6 +99,8 @@ pub struct WipCallContext { pub struct WipOperationOutput { pub value: Value, tool_output: Option, + // Exact post-operation provider state; never substitute a later path stat. + validator: Option>, } impl WipOperationOutput { @@ -99,6 +108,15 @@ impl WipOperationOutput { Self { value, tool_output: None, + validator: None, + } + } + + pub fn native_with_validator(value: Value, validator: Vec) -> Self { + Self { + value, + tool_output: None, + validator: Some(validator), } } @@ -106,6 +124,7 @@ impl WipOperationOutput { Self { value, tool_output: Some(output), + validator: None, } } } @@ -218,6 +237,19 @@ pub struct WipDynamicMount { pub resolver: Arc, } +/// Explicit arbitrary-depth request-time subtree provider. Unlike item families, +/// this resolves both files and directories and enumerates authorized children. +#[async_trait] +pub trait WipSubtreeProvider: Send + Sync { + async fn projection(&self, path: &str) -> Result, ProtocolError>; + async fn children(&self, path: &str) -> Result, ProtocolError>; +} + +pub struct WipSubtreeMount { + pub root: String, + pub provider: Arc, +} + pub trait WipDynamicOperationResolver: Send + Sync { fn handler(&self, item_reference: &str) -> Arc; } @@ -327,6 +359,7 @@ impl WipOperationHandler for OperationDispatchHandler { pub struct WipMountRegistry { mounts: BTreeMap, dynamic_mounts: Vec, + subtree_mounts: Vec, dynamic_operation_contributions: BTreeMap>, namespaces: BTreeMap, replaced_compatibility_capabilities: BTreeMap, @@ -395,6 +428,11 @@ impl WipMountRegistry { projection: WipProjection, ) -> Result { validate_projection(&projection)?; + if projection.kind == WipProjectionKind::Compatibility + && self.replaces_compatibility(&projection.capability) + { + return Ok(WipMountDisposition::NativeAlreadySelected); + } if projection.kind == WipProjectionKind::Compatibility && !is_tool_route(&projection.route) { return Err(WipMountError::InvalidProjection { @@ -424,6 +462,15 @@ impl WipMountRegistry { }); } } + if self.subtree_mounts.iter().any(|mount| { + projection.route == mount.root + || projection.route.starts_with(&format!("{}/", mount.root)) + }) { + return Err(WipMountError::RouteCollision { + route: projection.route, + existing: "request-time subtree".into(), + }); + } if self.dynamic_mounts.iter().any(|dynamic| { projection .route @@ -571,6 +618,16 @@ impl WipMountRegistry { message: error.to_string(), } })?; + if self.subtree_mounts.iter().any(|m| { + m.root == mount.collection_route + || m.root.starts_with(&format!("{}/", mount.collection_route)) + || mount.collection_route.starts_with(&format!("{}/", m.root)) + }) { + return Err(WipMountError::RouteCollision { + route: mount.collection_route, + existing: "request-time subtree".into(), + }); + } let Some(collection) = self.mounts.get(&mount.collection_route) else { return Err(WipMountError::InvalidProjection { route: mount.collection_route, @@ -635,6 +692,42 @@ impl WipMountRegistry { Ok(()) } + /// Delegate an entire descendant namespace to one request-time provider. + /// The root Object remains registry-owned; nested/static/item collisions fail. + pub fn mount_subtree(&mut self, mount: WipSubtreeMount) -> Result<(), WipMountError> { + let Some(root) = self.mounts.get(&mount.root) else { + return Err(WipMountError::OperationTargetNotFound { route: mount.root }); + }; + if root.projection.kind != WipProjectionKind::Native { + return Err(WipMountError::InvalidProjection { + route: mount.root, + message: "subtree requires a native root Object".into(), + }); + } + let overlaps = |path: &str| { + path == mount.root + || path.starts_with(&format!("{}/", mount.root)) + || mount.root.starts_with(&format!("{path}/")) + }; + if self.subtree_mounts.iter().any(|m| overlaps(&m.root)) + || self + .dynamic_mounts + .iter() + .any(|m| overlaps(&m.collection_route)) + || self + .mounts + .keys() + .any(|p| p != &mount.root && p.starts_with(&format!("{}/", mount.root))) + { + return Err(WipMountError::RouteCollision { + route: mount.root, + existing: "subtree/static/item provider".into(), + }); + } + self.subtree_mounts.push(mount); + Ok(()) + } + /// Add operations to every item resolved by one existing dynamic Object /// family. The family keeps exactly one Object resolver; each Feature only /// supplies handlers for its own disjoint operation names. @@ -793,6 +886,10 @@ impl WipMountRegistry { } } for capability in capabilities { + self.mounts.retain(|_, mounted| { + mounted.projection.kind != WipProjectionKind::Compatibility + || mounted.projection.capability != capability + }); self.replaced_compatibility_capabilities .insert(capability, owner_route.to_string()); } @@ -964,6 +1061,163 @@ impl WipHost { } } + fn subtree(&self, path: &str) -> Option<&WipSubtreeMount> { + self.registry + .subtree_mounts + .iter() + .find(|m| path == m.root || path.starts_with(&format!("{}/", m.root))) + } + + async fn projection_live(&self, path: &str) -> Result, ProtocolError> { + wip_protocol::validate_path(path).map_err(|_| unpublished_path_error(path))?; + if let Some(mount) = self.subtree(path) { + let Some(projection) = mount.provider.projection(path).await? else { + return Ok(None); + }; + validate_projection(&projection).map_err(|_| { + protocol_error( + ProtocolErrorCode::Internal, + "subtree returned an invalid Object", + ) + })?; + let owner = &self.registry.mounts[&mount.root].projection.capability; + if projection.route != path + || projection.kind != WipProjectionKind::Native + || projection.capability.split(':').next() != owner.split(':').next() + { + return Err(protocol_error( + ProtocolErrorCode::Internal, + "subtree changed route or ownership", + )); + } + return Ok(Some(projection)); + } + Ok(self.projection(path)) + } + + async fn descriptor_live( + &self, + reference: &str, + ) -> Result>)>, ProtocolError> { + if let Some((_, encoded)) = reference.rsplit_once("/@/") + && let Some(path) = decode_contextual_path(encoded) + && self.subtree(&path).is_some() + { + return Ok(self + .projection_live(&path) + .await? + .filter(|p| p.interface == reference) + .map(|p| (p.descriptor, p.interface_validator))); + } + // Never expose the static registration placeholder descriptor of a subtree. + if self + .registry + .subtree_mounts + .iter() + .any(|m| self.registry.mounts[&m.root].projection.interface == reference) + { + return Ok(None); + } + Ok(self.descriptor(reference)) + } + + async fn fetch_interface_live( + &self, + reference: &str, + ) -> Result { + let (descriptor, validator) = self.descriptor_live(reference).await?.ok_or_else(|| { + protocol_error( + ProtocolErrorCode::InterfaceNotFound, + "interface is not published", + ) + })?; + Ok(FetchInterfaceResponse { + interface: reference.into(), + descriptor, + validator, + }) + } + + async fn observe_live( + &self, + path: &str, + depth: u32, + ) -> Result { + if depth > 32 { + return Err(protocol_error( + ProtocolErrorCode::ResourceLimitExceeded, + "observation depth exceeds 32", + )); + } + let mut remaining = 1024usize; + tokio::time::timeout( + std::time::Duration::from_secs(30), + self.observe_live_node(path, depth, &mut remaining), + ) + .await + .map_err(|_| { + protocol_error( + ProtocolErrorCode::ResourceLimitExceeded, + "observation deadline exceeded", + ) + })? + } + + fn observe_live_node<'a>( + &'a self, + path: &'a str, + depth: u32, + remaining: &'a mut usize, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async move { + if *remaining == 0 { + return Err(protocol_error( + ProtocolErrorCode::ResourceLimitExceeded, + "observation exceeds 1024 nodes", + )); + } + *remaining -= 1; + let object = if self.subtree(path).is_some() { + self.projection_live(path).await?.map(|p| p.object) + } else { + self.object_at(path) + } + .ok_or_else(|| unpublished_path_error(path))?; + let children = if depth == 0 { + None + } else { + let paths = if let Some(mount) = self.subtree(path) { + mount.provider.children(path).await? + } else { + self.children(path) + }; + let mut values = Vec::new(); + let mut seen = BTreeSet::new(); + for child in paths { + let prefix = if path == "/" { + "/".to_string() + } else { + format!("{path}/") + }; + let suffix = child.strip_prefix(&prefix).ok_or_else(|| { + protocol_error(ProtocolErrorCode::Internal, "subtree child outside parent") + })?; + if suffix.is_empty() || suffix.contains('/') || !seen.insert(child.clone()) { + return Err(protocol_error( + ProtocolErrorCode::Internal, + "invalid/duplicate subtree child", + )); + } + values.push(self.observe_live_node(&child, depth - 1, remaining).await?); + } + Some(values) + }; + Ok(ObjectObservation { object, children }) + }) + } + fn descriptor(&self, reference: &str) -> Option<(InterfaceDescriptor, Option>)> { for (path, mounted) in &self.registry.mounts { if mounted.projection.interface == reference { @@ -1168,6 +1422,7 @@ impl WipHost { children.into_iter().collect() } + #[cfg(test)] fn observe(&self, path: &str, depth: u32) -> Result { let object = self .object_at(path) @@ -1184,6 +1439,7 @@ impl WipHost { Ok(ObjectObservation { object, children }) } + #[cfg(test)] fn fetch_interface(&self, reference: &str) -> Result { let (descriptor, validator) = self.descriptor(reference).ok_or_else(|| { protocol_error( @@ -1204,9 +1460,13 @@ impl WipHost { request: CallOperationRequest, context: WipCallContext, ) -> Result { - let projection = self.projection(&request.target.path).ok_or_else(|| { - WipOperationError::Protocol(unpublished_path_error(&request.target.path)) - })?; + let projection = self + .projection_live(&request.target.path) + .await + .map_err(WipOperationError::Protocol)? + .ok_or_else(|| { + WipOperationError::Protocol(unpublished_path_error(&request.target.path)) + })?; self.call_projection(projection, request, context).await } @@ -1716,7 +1976,12 @@ impl WipRuntime { arguments: Json, execution: ToolExecutionContext, ) -> Result { - let arguments = match self.host.projection(&path) { + let arguments = match self + .host + .projection_live(&path) + .await + .map_err(|e| ToolError::InvalidArgument(e.message))? + { Some(projection) if projection.kind == WipProjectionKind::Compatibility => { BTreeMap::from([( "input".to_string(), @@ -1847,7 +2112,11 @@ impl WipRuntime { Route::Observe => { let request_value = decode_observe_request(request.body(), self.wire_limits) .map_err(|error| ToolError::Internal(error.to_string()))?; - match self.host.observe(&request_value.path, request_value.depth) { + match self + .host + .observe_live(&request_value.path, request_value.depth) + .await + { Ok(value) => encode_observe_response(&request_value, &value, self.wire_limits), Err(error) => encode_protocol_error_response( ProtocolInteraction::Observe, @@ -1861,7 +2130,11 @@ impl WipRuntime { let request_value = decode_fetch_interface_request(request.body(), self.wire_limits) .map_err(|error| ToolError::Internal(error.to_string()))?; - match self.host.fetch_interface(&request_value.interface) { + match self + .host + .fetch_interface_live(&request_value.interface) + .await + { Ok(value) => { encode_fetch_interface_response(&request_value, &value, self.wire_limits) } @@ -1895,7 +2168,9 @@ impl WipRuntime { .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; let descriptor = self .host - .descriptor(&metadata.interface.reference) + .descriptor_live(&metadata.interface.reference) + .await + .map_err(|e| ToolError::InvalidArgument(e.message))? .map(|(descriptor, _)| descriptor.clone()) .ok_or_else(|| ToolError::InvalidArgument("interface is not published".into()))?; let request_value = metadata @@ -1905,7 +2180,12 @@ impl WipRuntime { execution, security_context: self.security_context.as_str().to_string(), }; - let result = match self.host.projection(&request_value.target.path) { + let result = match self + .host + .projection_live(&request_value.target.path) + .await + .map_err(|e| ToolError::InvalidArgument(e.message))? + { Some(projection) => { // Pin the exact resolved instance used for dispatch. A dynamic // resolver may return a fresh stateful handler on each lookup. @@ -1934,14 +2214,37 @@ impl WipRuntime { &descriptor, &CallOperationResponse { result: output.value, - validator: self - .host - .projection(&request_value.target.path) - .and_then(|projection| projection.object.validator.clone()), + validator: match output.validator { + Some(validator) => Some(validator), + None if self.host.subtree(&request_value.target.path).is_some() => None, + None => self + .host + .projection(&request_value.target.path) + .and_then(|projection| projection.object.validator.clone()), + }, }, self.wire_limits, - ) - .map_err(|error| ToolError::Internal(error.to_string()))?; + ); + let response = match response { + Ok(response) => response, + Err(_) => { + // Dispatch has completed: losing its result is not a pre-effect refusal. + let message = "completed operation result could not be encoded; inspect effects before retry"; + let response = encode_protocol_error_response( + ProtocolInteraction::CallOperation, + &protocol_error(ProtocolErrorCode::OperationOutcomeUnknown, message), + self.wire_limits, + ) + .map_err(|error| ToolError::Internal(error.to_string()))?; + return Ok(( + response, + Some(Err(ToolError::ExecutionFailed(format!( + "WIP operation outcome unknown; do not retry automatically: {message}" + )))), + WipAuditOutcome::OutcomeUnknown, + )); + } + }; Ok(( response, output.tool_output.map(Ok), @@ -2800,6 +3103,58 @@ mod tests { )); } + #[tokio::test] + async fn completed_native_result_encoding_failure_is_unknown_and_not_retried() { + struct OversizedResult(Arc); + #[async_trait] + impl WipOperationHandler for OversizedResult { + async fn call( + &self, + _: &str, + _: &BTreeMap, + _: WipCallContext, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(WipOperationOutput::native(Value::String( + "x".repeat(3 * 1024 * 1024), + ))) + } + } + let calls = Arc::new(AtomicUsize::new(0)); + let mut p = contribution_projection(Arc::new(OversizedResult(calls.clone()))); + p.descriptor.operations[0].returns.r#type = TypeExpr::String; + p.interface_validator = Some(descriptor_validator(&p.descriptor)); + let mut registry = WipMountRegistry::new(); + registry.allocate_namespace("asset", "assets").unwrap(); + registry.mount(p).unwrap(); + let runtime = WipRuntime::new( + WipHost::new(registry), + SecurityContext::new("encoding-test"), + 0, + ) + .unwrap(); + runtime + .discover("/assets/A-1".into(), 0, true) + .await + .unwrap(); + runtime + .inspect("test.asset/item/v1".into(), true) + .await + .unwrap(); + let error = runtime + .call( + "/assets/A-1".into(), + "test.asset/item/v1".into(), + "read".into(), + Json::Object(Default::default()), + Default::default(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("outcome unknown"), "{error}"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + struct ContributionHandler { calls: Arc, allowed: Arc, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 96ad98499..007d8b95f 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1077,6 +1077,9 @@ fn external_workdir_operation_kind(operation: &WorkdirSessionOperation) -> &'sta match operation { WorkdirSessionOperation::AuthorizeScope(_) => "authorize_scope", WorkdirSessionOperation::ScopeRulesOverlap(_) => "scope_rules_overlap", + WorkdirSessionOperation::CheckoutObserve(_) => "checkout_observe", + WorkdirSessionOperation::CheckoutExecute(_) => "checkout_execute", + WorkdirSessionOperation::CheckoutSearch(_) => "checkout_search", WorkdirSessionOperation::Stat(_) => "stat", WorkdirSessionOperation::Read(_) => "read", WorkdirSessionOperation::ReadBytes(_) => "read_bytes", @@ -1127,6 +1130,107 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { _ => Err(Self::mismatch("scope_rules_overlap")), } } + async fn checkout_observe( + &self, + path: workdir::WorkdirPath, + ) -> std::result::Result { + self.ensure_capability(workdir::WorkdirSessionCapability::Read)?; + match self + .operate(WorkdirSessionOperation::CheckoutObserve(path.clone())) + .await? + { + WorkdirSessionOperationResult::CheckoutObserve(mut value) + if value.path == path + && !value.validator.is_empty() + && value.validator.len() <= 256 => + { + value.capabilities = value.capabilities.intersection(self.capabilities()); + Ok(value) + } + _ => Err(Self::mismatch("checkout_observe")), + } + } + async fn checkout_execute( + &self, + request: workdir::CheckoutRequest, + ) -> std::result::Result { + let cap = request.operation.capability(); + self.ensure_capability(cap)?; + let target = request.target.clone(); + let mutation = cap != workdir::WorkdirSessionCapability::Read; + match self + .operate(WorkdirSessionOperation::CheckoutExecute(request)) + .await + { + Ok(WorkdirSessionOperationResult::CheckoutExecute(mut value)) + if value.observation.path == target + && !value.observation.validator.is_empty() + && value.observation.validator.len() <= 256 + && matches!( + (&value.output, cap), + ( + workdir::CheckoutOutput::Read(_), + workdir::WorkdirSessionCapability::Read + ) | ( + workdir::CheckoutOutput::Write(_), + workdir::WorkdirSessionCapability::Write + ) | ( + workdir::CheckoutOutput::Edit(_), + workdir::WorkdirSessionCapability::Edit + ) + ) => + { + value.observation.capabilities = value + .observation + .capabilities + .intersection(self.capabilities()); + Ok(value) + } + Ok(_) if mutation => Err(workdir::WorkdirError::OutcomeUnknown( + "mismatched checkout mutation response".into(), + )), + Ok(_) => Err(Self::mismatch("checkout_execute")), + Err(workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Transport(_)) + if mutation => + { + Err(workdir::WorkdirError::OutcomeUnknown( + "checkout provider response lost; inspect effects before retry".into(), + )) + } + Err(error) => Err(error), + } + } + async fn checkout_search( + &self, + request: workdir::CheckoutSearchRequest, + ) -> std::result::Result { + use workdir::{ + CheckoutSearchOperation as Op, CheckoutSearchResult as Result, + WorkdirSessionCapability as Cap, + }; + let cap = match &request.operation { + Op::List(_) => Cap::Read, + Op::Glob(_) => Cap::Glob, + Op::Grep(_) => Cap::Grep, + }; + self.ensure_capability(cap)?; + match self + .operate(WorkdirSessionOperation::CheckoutSearch(request)) + .await? + { + WorkdirSessionOperationResult::CheckoutSearch(value) + if matches!( + (&value, cap), + (Result::List(_), Cap::Read) + | (Result::Glob(_), Cap::Glob) + | (Result::Grep(_), Cap::Grep) + ) => + { + Ok(value) + } + _ => Err(Self::mismatch("checkout_search")), + } + } async fn stat( &self, request: workdir::StatRequest, @@ -16917,6 +17021,9 @@ async fn scoped_execute_current_worker_workdir_operation( } operation @ (WorkdirSessionOperation::AuthorizeScope(_) | WorkdirSessionOperation::ScopeRulesOverlap(_) + | WorkdirSessionOperation::CheckoutObserve(_) + | WorkdirSessionOperation::CheckoutExecute(_) + | WorkdirSessionOperation::CheckoutSearch(_) | WorkdirSessionOperation::Stat(_) | WorkdirSessionOperation::Read(_) | WorkdirSessionOperation::ReadBytes(_) @@ -16978,6 +17085,18 @@ async fn execute_workdir_session_operation( .scope_rules_overlap(request) .await .map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }), + WorkdirSessionOperation::CheckoutObserve(path) => session + .checkout_observe(path) + .await + .map(WorkdirSessionOperationResult::CheckoutObserve), + WorkdirSessionOperation::CheckoutExecute(request) => session + .checkout_execute(request) + .await + .map(WorkdirSessionOperationResult::CheckoutExecute), + WorkdirSessionOperation::CheckoutSearch(request) => session + .checkout_search(request) + .await + .map(WorkdirSessionOperationResult::CheckoutSearch), WorkdirSessionOperation::Stat(request) => session .stat(request) .await @@ -32999,6 +33118,96 @@ mod tests { assert!(commands.try_recv().is_err()); } + #[tokio::test] + async fn checkout_external_provider_typed_boundary_and_unknown_response() { + let (sender, mut commands) = tokio::sync::mpsc::channel(1); + let connection = Arc::new(ExternalProviderConnection { + grant_id: "checkout-grant".into(), + workdir_id: "checkout-external".into(), + provider_instance_id: "checkout-provider".into(), + generation: 7, + expires_at: None, + capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE, + admission: Arc::new(tokio::sync::Semaphore::new(16)), + shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + sender, + }); + let session = Arc::new(ExternalProviderWorkdirSession::new(connection, None)); + let path = workdir::WorkdirPath::new("file.txt").unwrap(); + let observe = { + let session = session.clone(); + let path = path.clone(); + tokio::spawn(async move { session.checkout_observe(path).await }) + }; + let Some(ExternalProviderCommand::Operation { + operation, + response, + .. + }) = commands.recv().await + else { + panic!("expected operation") + }; + assert!(matches!( + operation, + WorkdirSessionOperation::CheckoutObserve(_) + )); + response + .send(Ok(WorkdirSessionOperationResult::CheckoutObserve( + workdir::CheckoutObservation { + path: path.clone(), + kind: workdir::EntryKind::File, + size: 4, + validator: vec![1], + capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE, + }, + ))) + .unwrap(); + let observed = observe.await.unwrap().unwrap(); + for lose_response in [false, true] { + let request = workdir::CheckoutRequest { + target: path.clone(), + validator: observed.validator.clone(), + operation: workdir::CheckoutOperation::Write { + content: b"next".to_vec(), + expected_hash: [0; 32], + }, + }; + let write = { + let session = session.clone(); + tokio::spawn(async move { session.checkout_execute(request).await }) + }; + let Some(ExternalProviderCommand::Operation { + operation, + response, + .. + }) = commands.recv().await + else { + panic!("expected operation") + }; + assert!(matches!( + operation, + WorkdirSessionOperation::CheckoutExecute(_) + )); + if lose_response { + drop(response); + } else { + // A valid ordinary result is still the wrong subject/operation for this checked call. + response + .send(Ok(WorkdirSessionOperationResult::Write( + workdir::WriteResult { + bytes_written: 4, + created: false, + }, + ))) + .unwrap(); + } + assert!(matches!( + write.await.unwrap(), + Err(workdir::WorkdirError::OutcomeUnknown(_)) + )); + } + } + #[tokio::test] async fn external_operation_cancel_retries_without_blocking_a_saturated_provider_queue() { let (sender, mut commands) = tokio::sync::mpsc::channel(1); diff --git a/docs/design/checkout-wip-projection.md b/docs/design/checkout-wip-projection.md new file mode 100644 index 000000000..2a1a548bf --- /dev/null +++ b/docs/design/checkout-wip-projection.md @@ -0,0 +1,225 @@ +# Attached checkout WIP projection + +T-699 publishes the contents of **current Worker attachments**, independently of +Repository/Workdir inventory and management. This is a Yoi implementation of +request-time projection, **not a dependency on `wip-fs-server`**. The upstream +read-only source/specification informed the design; its byte `read` and +truncate/write `replace` contracts are not substituted for Yoi's text/edit/save +contracts. No file-tree store, synchronization, or per-file resident registration +is created. + +```text +/ +├── repositories inventory/reference +├── workdirs identity/lifecycle +├── workdir-attachments Backend connection lifetimes +└── checkouts current accessible content entrances; list + └── directory; glob, grep, create_file + ├── README.md file; read, edit, write + └── src directory; glob, grep, create_file +``` + +Operations are Interface declarations, not children or `/features/...` routes. +File tools are native when the normal Worker WIP surface is installed. Bash stays +in compatibility mode. Ordinary Tools mode retains Read/Edit/Write/Glob/Grep. +Feature activation never grants READ, WRITE, EDIT, search, COMMAND, or delegation. + +## Placement and ownership + +`worker::checkout` owns Worldspace binding, contextual Interface declarations, +permission input reconstruction and safe output. It never opens a host pathname. +It resolves the live `WorkdirSessionRouter` on each request. The existing +`WorkdirSession` provider executes `checkout_observe` and `checkout_execute`; +Runtime HTTP and External transports carry typed operation/result envelopes at +their existing authorization, connection-generation and cancellation boundaries. +The Workspace-attached session forwards through the existing Backend broker. +Endpoint paths, credentials and materialized roots do not enter WIP descriptors, +Objects, arguments or results. + +The common Host registry has an **explicit asynchronous subtree provider** +contract, separate from the existing one-segment dynamic item families. It +validates every resolved projection's path/owner/descriptor and every enumerated +child's immediate-parent relationship. Static and dynamic/subtree overlaps are +rejected. Observe resolves only the requested depth; successful observations are +complete through that depth, not partial trees masked as success. Directory +listing and native search use the shared List/Glob/Grep engines through typed +`CheckoutSearchRequest` forwarding. Scoped sessions append intersecting rule +layers, translate their cwd, and require provider-side visibility checks before +entry descent/content reads. Results retain session-logical coordinates, so a +SubWorker cwd does not duplicate a path prefix in its Object links. Unsupported +providers fail closed rather than falling back to a broader source search. +Direct resolution also uses current provider authority, not possession of a +cached Interface. + +## Names and connection isolation + +Aliases use T-697's canonical encoding: nonempty ASCII letters/digits/`-`/`_` are +unchanged; every other UTF-8 alias is `~` plus lowercase UTF-8 hex, with `~` itself +encoded. This is injective, not display-name slugification, Unicode normalization, +URL decoding or case folding. Only canonical spellings are accepted. + +`checkouts.list` returns alias, slug, Workdir ID, Worker-local attachment generation +and `path`. Attachment inventory retains its Backend connection lifetime ID and +can return `checkout_path`/`checkout_slug` for the matching live router attachment. +Attach results point to the attachment catalog and checkout entrance. Workdir +existence alone does not expose its content. Following a reference still requires +current scope/capability and an accessible filesystem projection. + +Objects have no shared inode `ref`: hard-linked names and different attachments +must not be mistaken for one path-independent representation. Interface references +are opaque, path-qualified and include a fresh projection incarnation and live +attachment generation. Validators combine that namespace with the provider's +opaque identity/state validator. Restoring a Worker creates a fresh incarnation; +detach/alias reuse changes the router generation. An old Interface, validator or +read-history entry cannot retarget a new connection. No separate connection ledger +is added. The existing router's active-operation exclusion and begin/finish/cancel +detach paths remain authoritative; detach does not remove the Workdir/files. +Collection enumeration rejects connection-set changes between sampling and +publication instead of pairing old items with a new validator. Search-link +publication checks the captured attachment generation, not merely a reused +alias; response-side provider awaits are covered by the operation deadline. + +## AI Operation mapping + +All operations bind their attachment and target from the WIP route. There is no +`target_workdir`, Workdir ID, host path or replacement `file_path` argument. +Arguments not declared by the descriptor are rejected before dispatch. Paths +below are relative to the **bound directory**, not the Worldspace or endpoint. + +| Object | Operation | Arguments | Result | +| --- | --- | --- | --- | +| `/checkouts` | `list` | none | bounded `items` containing alias/slug/Workdir/generation/path | +| directory | `glob` | `pattern`, optional `path` | normal bounded Glob text/summary plus same-checkout Object links | +| directory | `grep` | `pattern`; optional `path`, `glob`, `type`, `case_insensitive`, `-A`, `-B`, `-C`, `multiline`, `output_mode`, `head_limit`, `offset` | normal Grep grouped/context/count text and typed provider search targets mapped to Object links | +| file | `read` | optional line `offset`, `limit` | line-numbered text and normal Read summary | +| file | `edit` | `old_string`, `new_string`, optional `replace_all` | replacement count/summary and preview | +| file | `write` | `content` | existing-file save summary; never creation | +| directory | `create_file` | `path`, `content` | create-new summary and created-file link; permitted missing parents are created | + +Native operation results use a JSON record with `summary`, nullable `content` and +`items` (`path` is an absolute WIP Object navigation coordinate, not an OS path). +Search links are constructed from **typed provider paths**, not by parsing Grep's +rendered filenames/line delimiters. Provider scope remains authoritative for +search output as well as links. Glob/Grep share normal provider ignore/pattern, +context/output mode, offset and bounded result behavior. Search does not claim an +atomic snapshot of every descendant: it fences the bound directory before/after +search and each provider read/search retains its own consistency guarantees. + +Read offsets are zero-based **lines**, limit defaults to 2000, displayed numbers +are one-based. The shared Read retains at most 256 KiB of source text and bounds +its numbered presentation to 256 KiB, with an explicit truncation marker when +numbering/Unicode expansion exceeds that budget. No byte-range +operation is published by this native surface. Partial Read records the provider's +full bounded-source hash and total line count, not the hash of only its displayed +slice. Native and ordinary Tool calls use the same original alias/generation +tracker, mutation coordinator, rendering and parameter validation. + +Edit requires a nonempty, different `old_string`/`new_string`; `old_string` must +occur exactly once unless `replace_all` is true. Existing Write/Edit require a +prior successful Read through the same attachment. The read-history hash check +and the provider identity validator are **both** required: a same-content inode +replacement cannot be blessed merely by matching a hash. Failed/conflicting Read +must not record history; successful mutations update hash/change statistics only +after provider success. Create is strictly beneath the bound parent, creates only +an absent file, and must not overwrite an existing destination or symlink. + +Manifest permission identities remain Read/Edit/Write/Glob/Grep; create_file uses +Write. The Host reconstructs the same route-bound input for policy evaluation; +provider read/write scope, capabilities and delegated leases are checked again. +`ask` fails closed. Native replacement claims remove `/tools/Read`, `/tools/Edit`, +`/tools/Write`, `/tools/Glob`, `/tools/Grep`, not unrelated Tools. + +## Consistency, safety and resource policy + +The provider resolves/pins the entry/parent, compares its opaque validator and +executes shared `fs-operation` processing within the same checked boundary. The +validator represents identity and protected metadata, independently of the Read +content hash. Read checks state across its stream and returns a validator for +that same state. The WIP response uses the **execution result's validator**, not a +later independent path stat that could bless a replacement after execution. + +The native checked filesystem projection deliberately rejects symlink traversal +and unsupported/special entries. It does not silently broaden existing resolved +or logical scope. Ordinary Tools retain their existing symlink-policy behavior. +External sessions retain the root descriptor pinned **before approval/grant**; +renaming/replacing its ambient pathname must not change the approved root. +Absolute paths, traversal, alternate alias arguments and special-file mutation +are not native capabilities. UTF-8 names are byte-for-byte; non-UTF-8 names must +not be lossy-normalized into another Object name. Root/directory permissions are +not inferred from catalog visibility or from a descendant pathname supplied by +the model. + +Saves retain Yoi's temporary-file publication rather than in-place truncation. +Thus saving one hard-link pathname replaces that name rather than changing all +aliases of the old inode. Checked preconditions detect known changes before +commit. Advisory locks cannot exclude arbitrary noncooperating external editors; +read-time/commit-time identity checks do not create a filesystem-wide transaction. +The supported race guarantees and remaining external namespace race window are +specified by the provider implementation and tests, not by a claim that a pinned +fd makes any rename-based update universally atomic. + +A guaranteed pre-effect rejection is distinct from **OutcomeUnknown** after +mutation/parent creation may have started, commit succeeds but state cannot be +returned, response validation/encoding fails, or a transport/deadline loses the +result. Never automatically retry uncertain mutations. Inspect current state and +rediscover/reread. Do not report partially created parents as unchanged. + +Host observations are bounded to depth 32 and 1024 nodes with a 30-second total +cooperative deadline. The checkout adapter permits at most 16 concurrent requests +and bounds admission waiting and the complete operation (including result-link +publication) to 30 seconds each; provider path/depth, source/response, +mutation/search and transport bounds apply too. No initial context expands an +entire tree. Resource-limit errors do not turn incomplete observation into a +successful truncated tree; bounded search results may explicitly report +truncation under the existing search contract. + +## Typical sequence + +1. Discover `/checkouts` with depth 1; inspect its Interface and call `list`. +2. Discover an entrance or known deep directory, inspect it, call `glob`/`grep`. +3. Follow a returned file `path`, discover/inspect, call line `read`. +4. Call `edit` or `write`; Client-managed validators protect the exact observed + object while the shared tracker enforces the prior Read. +5. Rediscover after external changes; a stale rejection is not permission to skip + Read. ReRead after successful save to verify text. +6. Inspect a directory and call `create_file` with a descendant relative path, + then discover/Read the returned new file. +7. Use `/workdir-attachments/` for detach. Old checkout + observations and history cannot operate a newly attached alias. + +Workspace configuration (T-695) remains a separate logical provider; it is not +replaced by raw Linux filesystem writes. Bash native conversion, broad POSIX +operations, lifecycle duplication, upstream changes and deployment/restarts are +outside this feature. + +## Reproducible validation matrix + +Run from the main repository, on Linux with `openat2` and `/proc/self/fd`: + +```sh +cargo check --workspace --all-targets +cargo test -p fs-operation -p workdir -p tools -p worker +cargo test -p worker-runtime -p yoi-workspace-server +cargo fmt --all -- --check +git diff --check +``` + +| Boundary | Focused regression locations | +| --- | --- | +| Deep mounts, multiple aliases, route-only arguments, exact permission identities, no duplicate compatibility file Tools | `worker/src/checkout_wip_tests.rs` and existing `wip.rs` registry/controller tests | +| Native search → numbered Read → unique/all Edit → Write → Create; returned validator cached chains | `checkout_wip_tests.rs`, `tools/src/checkout_tests.rs` | +| SubWorker write leases, sparse/nonrecursive/nested scopes, denied basename/content pruning, result-coordinate rebasing, revocation | `workdir/src/checkout_search_tests.rs`, `checkout.rs`, native scoped-child test | +| Approved External root replacement; same-content inode/name/parent substitution; hard-link save isolation; symlinks/special files; Unicode/non-UTF8 paths | `workdir/src/checkout.rs`, `fs-operation/src/checked.rs` | +| Mid-read updates; independent hash and validator fences; parent partial creation and post-save state failure | `checked.rs`, `checkout.rs`, Tools tests | +| Detach/alias reuse/restore references and read history; collection/search response generation fences | `checkout_wip_tests.rs`, `checkout_race_tests.rs`, existing router/scope/catalog lifecycle tests | +| Traversal depth/entry/cancellation bounds; stalled observation/link publication; numbered output expansion; post-dispatch encoding loss | `fs-operation/src/traversal_tests.rs`, `checkout_race_tests.rs`, `tools/src/read.rs`, `wip.rs` | +| Real prefixed HTTP provider → Remote session → router → native WIP; auth, read-only, lost/malformed post-commit response without retry | `worker/src/checkout_http_tests.rs` | +| Workspace proxy/External typed pairing and unknown-outcome propagation; Backend/Runtime Workspace, grant and generation isolation | checkout additions in `manage_workdir.rs` and Backend `server.rs`, plus full existing host/provider suites | +| Ordinary Tools text/hash/edit/write/search and provider symlink policy | full Tools, fs-operation and Workdir suites | + +The HTTP fixture tests actual network/codec boundaries but is not a production +Backend grant-network end-to-end deployment. Production routing/authorization is +covered separately by the existing host suites. Fault tests are deterministic +boundary simulations, not power-loss tests. Metadata/pinned-handle tests do not +close the external-editor final-check/rename window documented above. No live +Worker or deployed environment needs updating/restarting to execute this matrix.