feat: project attached checkouts with scoped native AI operations
This commit is contained in:
Generated
+1
@@ -6416,6 +6416,7 @@ dependencies = [
|
||||
"agen",
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
#[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<File>,
|
||||
validator: Vec<u8>,
|
||||
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<Self> {
|
||||
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<Metadata> {
|
||||
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<PathBuf> {
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result<Metadata> {
|
||||
self.file.metadata()
|
||||
}
|
||||
fn open_read_file(&self, _: &Path, _: &Path) -> std::io::Result<File> {
|
||||
self.confirm()?;
|
||||
let mut file = self.file.try_clone()?;
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
Ok(file)
|
||||
}
|
||||
fn max_write_bytes(&self) -> Option<usize> {
|
||||
Some(
|
||||
self.policy
|
||||
.max_write_bytes()
|
||||
.unwrap_or(8 * 1024 * 1024)
|
||||
.min(8 * 1024 * 1024),
|
||||
)
|
||||
}
|
||||
fn max_edit_replacements(&self) -> Option<usize> {
|
||||
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<PathBuf> {
|
||||
Ok(p.to_path_buf())
|
||||
}
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
self.target.policy.check_cancelled()
|
||||
}
|
||||
fn max_write_bytes(&self) -> Option<usize> {
|
||||
self.target.max_write_bytes()
|
||||
}
|
||||
fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result<Metadata> {
|
||||
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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
_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<dyn Fn(&Path, bool) -> 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<FsTraversalFilter> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Cooperatively stop provider-owned filesystem work. Implementations may
|
||||
/// use this for per-operation cancellation and deadlines.
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<FsPath>,
|
||||
/// Provider-rendered bounded grep report. Keeping rendering here avoids
|
||||
/// transferring candidate files across a remote provider boundary.
|
||||
pub output: String,
|
||||
|
||||
@@ -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::<std::collections::BTreeSet<_>>()
|
||||
.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> {
|
||||
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<bool, FsError> {
|
||||
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);
|
||||
|
||||
@@ -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<Mutex<Vec<PathBuf>>>,
|
||||
}
|
||||
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<Option<FsTraversalRoot>> {
|
||||
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<FsTraversalFilter> {
|
||||
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<std::fs::File> {
|
||||
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"))
|
||||
);
|
||||
}
|
||||
@@ -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<std::fs::FileType>,
|
||||
}
|
||||
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<Entry, FsError>;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
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<FsTraversalFilter>,
|
||||
root: Option<PathBuf>,
|
||||
pending: Option<(PathBuf, usize)>,
|
||||
stack: Vec<(PathBuf, usize, std::fs::ReadDir)>,
|
||||
visited: usize,
|
||||
failed: bool,
|
||||
}
|
||||
impl DescriptorWalk<'_> {
|
||||
fn next_entry(&mut self) -> Result<Option<Entry>, 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<Entry, FsError>;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
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<FsTraversalFilter> {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<WorkdirPath>,
|
||||
pub validator: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 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<WorkdirSessionRouter>,
|
||||
tracker: Tracker,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
path: WorkdirPath,
|
||||
validator: Vec<u8>,
|
||||
tool_name: &str,
|
||||
arguments: Value,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<CheckoutToolOutput, ToolError> {
|
||||
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<T: DeserializeOwned>(arguments: Value, name: &str) -> Result<T, ToolError> {
|
||||
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<String>) -> 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<WorkdirPath, ToolError> {
|
||||
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;
|
||||
@@ -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<WorkdirSessionRouter>,
|
||||
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<u8>) {
|
||||
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<CheckoutToolOutput, ToolError> {
|
||||
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<ToolOutput, ToolError> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
+65
-47
@@ -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<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
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
|
||||
|
||||
@@ -44,7 +44,8 @@ impl From<ToolsError> 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(
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FileTarget {
|
||||
async fn checked(
|
||||
&self,
|
||||
operation: CheckoutOperation,
|
||||
) -> Result<(CheckoutOutput, Option<Vec<u8>>), 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<Vec<u8>>), 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<Vec<u8>>), 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<u8>,
|
||||
expected_hash: Option<ContentHash>,
|
||||
create_path: Option<WorkdirPath>,
|
||||
) -> Result<(WriteResult, Option<Vec<u8>>), 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"
|
||||
))
|
||||
}
|
||||
+52
-37
@@ -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<String>,
|
||||
pub target_workdir: Option<String>,
|
||||
/// 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<String>,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>()
|
||||
.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<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
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::<Vec<_>>()
|
||||
.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 {
|
||||
|
||||
+75
-59
@@ -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<String>,
|
||||
pattern: String,
|
||||
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
pub path: Option<String>,
|
||||
#[serde(default)]
|
||||
glob: Option<String>,
|
||||
pub glob: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
file_type: Option<String>,
|
||||
#[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<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+101
-74
@@ -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<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
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::<Vec<_>>();
|
||||
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();
|
||||
|
||||
@@ -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<GlobResult, ToolError> {
|
||||
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<GrepResult, ToolError> {
|
||||
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<GlobResult, ToolError> {
|
||||
match result {
|
||||
CheckoutSearchResult::Glob(result) => Ok(result),
|
||||
_ => Err(crate::file_target::readonly_result_error("Glob")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn grep_result(result: CheckoutSearchResult) -> Result<GrepResult, ToolError> {
|
||||
match result {
|
||||
CheckoutSearchResult::Grep(result) => Ok(result),
|
||||
_ => Err(crate::file_target::readonly_result_error("Grep")),
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
+64
-38
@@ -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<WorkdirPath>,
|
||||
ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
|
||||
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.
|
||||
|
||||
@@ -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<Vec<WorkdirToolScopeRule>>` 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<WorkdirToolScopeRule>, 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<_>>(),
|
||||
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<_>>(),
|
||||
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::<Op>(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::<Output>(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());
|
||||
}
|
||||
@@ -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::<usize>()
|
||||
<= 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<Item = &'a WorkdirPath>) -> 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,
|
||||
|
||||
+136
-1
@@ -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("<remote>".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<crate::CheckoutSearchResult, WorkdirError> {
|
||||
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<crate::CheckoutObservation, WorkdirError> {
|
||||
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<crate::CheckoutResult, WorkdirError> {
|
||||
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<StatResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||
|
||||
@@ -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<CheckoutSearchResult, WorkdirError> {
|
||||
Err(WorkdirError::UnsupportedOperation(
|
||||
"scoped checkout traversal".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
_path: WorkdirPath,
|
||||
) -> Result<CheckoutObservation, WorkdirError> {
|
||||
Err(WorkdirError::UnsupportedOperation(
|
||||
"checkout observation".into(),
|
||||
))
|
||||
}
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
_request: CheckoutRequest,
|
||||
) -> Result<CheckoutResult, WorkdirError> {
|
||||
Err(WorkdirError::UnsupportedOperation(
|
||||
"checked checkout execution".into(),
|
||||
))
|
||||
}
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||
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),
|
||||
|
||||
|
||||
+527
-8
@@ -204,7 +204,7 @@ impl CommandTelemetry {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct ScopeAccess {
|
||||
scope: Arc<Scope>,
|
||||
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<Vec<crate::WorkdirToolScopeRule>>,
|
||||
output_root: PathBuf,
|
||||
source_allows: Arc<Vec<manifest::ScopeRule>>,
|
||||
}
|
||||
impl SearchAccess {
|
||||
fn logical_path(&self, path: &Path) -> Option<WorkdirPath> {
|
||||
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<fs_operation::FsTraversalFilter> {
|
||||
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<PathBuf> {
|
||||
self.inner.resolve_access_path(logical)
|
||||
}
|
||||
fn open_traversal_root(
|
||||
&self,
|
||||
logical: &Path,
|
||||
resolved: &Path,
|
||||
) -> std::io::Result<Option<fs_operation::FsTraversalRoot>> {
|
||||
self.require_read(logical, resolved)?;
|
||||
self.inner.open_traversal_root(logical, resolved)
|
||||
}
|
||||
fn open_read_file(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
||||
self.require_read(logical, resolved)?;
|
||||
self.inner.open_read_file(logical, resolved)
|
||||
}
|
||||
fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
|
||||
self.require_read(logical, resolved)?;
|
||||
self.inner.read_metadata(logical, resolved)
|
||||
}
|
||||
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
|
||||
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<HashMap<String, LocalCommand>>,
|
||||
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<Arc<std::fs::File>, 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("<checkout-root>"), e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn checkout_observation(
|
||||
&self,
|
||||
path: WorkdirPath,
|
||||
pinned: &fs_operation::CheckedTarget<'_>,
|
||||
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
||||
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<crate::CheckoutSearchResult, WorkdirError> {
|
||||
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<crate::CheckoutObservation, WorkdirError> {
|
||||
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<crate::CheckoutResult, WorkdirError> {
|
||||
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<StatResult, WorkdirError> {
|
||||
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<ReadResult, WorkdirError> {
|
||||
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<WriteResult, WorkdirError> {
|
||||
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<EditResult, WorkdirError> {
|
||||
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("<provider-root>"), 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(),
|
||||
|
||||
@@ -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<crate::CheckoutSearchResult, WorkdirError> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.checkout_search(request).await
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: crate::WorkdirPath,
|
||||
) -> Result<crate::CheckoutObservation, WorkdirError> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.checkout_observe(path).await
|
||||
}
|
||||
async fn checkout_execute(
|
||||
&self,
|
||||
request: crate::CheckoutRequest,
|
||||
) -> Result<crate::CheckoutResult, WorkdirError> {
|
||||
let _active = self.state.enter()?;
|
||||
self.state.session.checkout_execute(request).await
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
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();
|
||||
|
||||
@@ -994,6 +994,138 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
self.capabilities
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
mut request: crate::CheckoutSearchRequest,
|
||||
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
|
||||
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<crate::CheckoutObservation, WorkdirError> {
|
||||
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<crate::CheckoutResult, WorkdirError> {
|
||||
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<StatResult, WorkdirError> {
|
||||
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<ListResult, WorkdirError> {
|
||||
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<GlobResult, WorkdirError> {
|
||||
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<GrepResult, WorkdirError> {
|
||||
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<crate::CheckoutSearchResult, WorkdirError> {
|
||||
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<crate::CheckoutObservation, WorkdirError> {
|
||||
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<crate::CheckoutResult, WorkdirError> {
|
||||
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<StatResult, WorkdirError> {
|
||||
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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<WorkdirSessionRouter>,
|
||||
tracker: tools::Tracker,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
) -> 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<WorkdirSessionRouter>,
|
||||
tracker: tools::Tracker,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
incarnation: String,
|
||||
permits: Arc<tokio::sync::Semaphore>,
|
||||
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<u8> {
|
||||
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<Self>) -> 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<u8>,
|
||||
kind: &str,
|
||||
handler: Arc<dyn WipOperationHandler>,
|
||||
) -> 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<Self>, path: &str) -> Result<Option<WipProjection>, 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<OperationDeclaration> {
|
||||
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<Provider> {
|
||||
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, 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<Vec<String>, 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<Self>, path: &str) -> Result<Vec<String>, 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<Provider>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for Collection {
|
||||
async fn call(
|
||||
&self,
|
||||
operation: &str,
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
_context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
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<Provider>,
|
||||
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<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
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<String, WipOperationError> {
|
||||
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<ParameterDeclaration>,
|
||||
) -> 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<OperationDeclaration>) -> 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<ParameterDeclaration> {
|
||||
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;
|
||||
@@ -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<Vec<(String, String)>>,
|
||||
operations: Mutex<Vec<WorkdirSessionOperation>>,
|
||||
write_fault: Mutex<Option<WriteResponseFault>>,
|
||||
}
|
||||
|
||||
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<Arc<ProviderState>>,
|
||||
Path(id): Path<String>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
AxumJson(_request): AxumJson<OpenWorkdirSessionRequest>,
|
||||
) -> 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<Arc<ProviderState>>,
|
||||
Path(id): Path<String>,
|
||||
uri: Uri,
|
||||
headers: HeaderMap,
|
||||
AxumJson(request): AxumJson<WorkdirSessionOperationRequest>,
|
||||
) -> 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::<Vec<u8>, _>(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<Arc<ProviderState>>,
|
||||
Path(id): Path<String>,
|
||||
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<ProviderState>,
|
||||
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, WorkdirError> {
|
||||
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<RemoteWorkdirSession>) -> (WipRuntime, Arc<WorkdirSessionRouter>) {
|
||||
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<Output = ()>) {
|
||||
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::<Vec<_>>(),
|
||||
["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;
|
||||
}
|
||||
@@ -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<dyn FnOnce(&WorkdirPath) + Send>;
|
||||
|
||||
/// 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<Option<ObserveHook>>,
|
||||
stall_path: Mutex<Option<WorkdirPath>>,
|
||||
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<Self> {
|
||||
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<WorkdirPath>) {
|
||||
*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<bool, WorkdirError> {
|
||||
self.local.scope_rules_overlap(request).await
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: WorkdirPath,
|
||||
) -> Result<CheckoutObservation, WorkdirError> {
|
||||
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<CheckoutResult, WorkdirError> {
|
||||
self.local.checkout_execute(request).await
|
||||
}
|
||||
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
request: workdir::CheckoutSearchRequest,
|
||||
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
|
||||
self.local.checkout_search(request).await
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
self.local.stat(request).await
|
||||
}
|
||||
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
self.local.read(request).await
|
||||
}
|
||||
|
||||
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
|
||||
self.local.read_bytes(request).await
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
self.local.write(request).await
|
||||
}
|
||||
|
||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
self.local.edit(request).await
|
||||
}
|
||||
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
self.local.list(request).await
|
||||
}
|
||||
|
||||
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
self.local.glob(request).await
|
||||
}
|
||||
|
||||
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
self.local.grep(request).await
|
||||
}
|
||||
|
||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||
self.local.start_command(request).await
|
||||
}
|
||||
|
||||
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||
self.local.command_status(handle).await
|
||||
}
|
||||
|
||||
async fn command_output(
|
||||
&self,
|
||||
request: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
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<WorkdirSessionRouter>) -> Arc<Provider> {
|
||||
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<WorkdirSessionRouter>) -> Arc<Provider> {
|
||||
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<T>(result: Result<T, WipOperationError>) {
|
||||
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<T>(result: Result<T, WipOperationError>, 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);
|
||||
}
|
||||
@@ -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<WorkdirSessionRouter>,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
) -> 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<ToolOutput, ToolError> {
|
||||
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<_>>(),
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -353,6 +353,102 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: workdir::WorkdirPath,
|
||||
) -> Result<workdir::CheckoutObservation, WorkdirError> {
|
||||
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<workdir::CheckoutResult, WorkdirError> {
|
||||
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<workdir::CheckoutSearchResult, WorkdirError> {
|
||||
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<StatResult, WorkdirError> {
|
||||
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!({
|
||||
|
||||
@@ -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<usize, WipOperationError> {
|
||||
// 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<String> {
|
||||
pub(crate) fn decode_identity(segment: &str) -> Option<String> {
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod bootstrap;
|
||||
pub mod checkout;
|
||||
pub mod compact;
|
||||
pub mod controller;
|
||||
pub mod discovery;
|
||||
|
||||
+369
-14
@@ -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<ToolOutput>,
|
||||
// Exact post-operation provider state; never substitute a later path stat.
|
||||
validator: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl WipOperationOutput {
|
||||
@@ -99,6 +108,15 @@ impl WipOperationOutput {
|
||||
Self {
|
||||
value,
|
||||
tool_output: None,
|
||||
validator: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn native_with_validator(value: Value, validator: Vec<u8>) -> 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<dyn WipDynamicItemResolver>,
|
||||
}
|
||||
|
||||
/// 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<Option<WipProjection>, ProtocolError>;
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError>;
|
||||
}
|
||||
|
||||
pub struct WipSubtreeMount {
|
||||
pub root: String,
|
||||
pub provider: Arc<dyn WipSubtreeProvider>,
|
||||
}
|
||||
|
||||
pub trait WipDynamicOperationResolver: Send + Sync {
|
||||
fn handler(&self, item_reference: &str) -> Arc<dyn WipOperationHandler>;
|
||||
}
|
||||
@@ -327,6 +359,7 @@ impl WipOperationHandler for OperationDispatchHandler {
|
||||
pub struct WipMountRegistry {
|
||||
mounts: BTreeMap<String, MountedProjection>,
|
||||
dynamic_mounts: Vec<WipDynamicMount>,
|
||||
subtree_mounts: Vec<WipSubtreeMount>,
|
||||
dynamic_operation_contributions: BTreeMap<String, Vec<MountedDynamicOperationContribution>>,
|
||||
namespaces: BTreeMap<String, String>,
|
||||
replaced_compatibility_capabilities: BTreeMap<String, String>,
|
||||
@@ -395,6 +428,11 @@ impl WipMountRegistry {
|
||||
projection: WipProjection,
|
||||
) -> Result<WipMountDisposition, WipMountError> {
|
||||
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<Option<WipProjection>, 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<Option<(InterfaceDescriptor, Option<Vec<u8>>)>, 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<FetchInterfaceResponse, ProtocolError> {
|
||||
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<ObjectObservation, ProtocolError> {
|
||||
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<dyn std::future::Future<Output = Result<ObjectObservation, ProtocolError>> + 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<Vec<u8>>)> {
|
||||
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<ObjectObservation, ProtocolError> {
|
||||
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<FetchInterfaceResponse, ProtocolError> {
|
||||
let (descriptor, validator) = self.descriptor(reference).ok_or_else(|| {
|
||||
protocol_error(
|
||||
@@ -1204,9 +1460,13 @@ impl WipHost {
|
||||
request: CallOperationRequest,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
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<ToolOutput, ToolError> {
|
||||
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<AtomicUsize>);
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for OversizedResult {
|
||||
async fn call(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
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<AtomicUsize>,
|
||||
allowed: Arc<AtomicBool>,
|
||||
|
||||
@@ -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<workdir::CheckoutObservation, workdir::WorkdirError> {
|
||||
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<workdir::CheckoutResult, workdir::WorkdirError> {
|
||||
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<workdir::CheckoutSearchResult, workdir::WorkdirError> {
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
└── <encoded alias> 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/<connection lifetime>` 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.
|
||||
Reference in New Issue
Block a user