feat: project attached checkouts with scoped native AI operations

This commit is contained in:
2026-10-06 03:37:07 +09:00
parent 3deef49897
commit 149853bb0e
43 changed files with 8706 additions and 346 deletions
Generated
+1
View File
@@ -6416,6 +6416,7 @@ dependencies = [
"agen",
"arc-swap",
"async-trait",
"axum",
"base64 0.22.1",
"chrono",
"clap",
+518
View File
@@ -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(_)));
}
}
+21 -26
View File
@@ -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;
+30
View File
@@ -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<()> {
+30 -1
View File
@@ -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;
+4
View File
@@ -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,
+65 -37
View File
@@ -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);
+108
View File
@@ -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"))
);
}
+226
View File
@@ -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)
);
}
}
+53
View File
@@ -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)
+252
View File
@@ -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, &params.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(&params.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) = &params.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;
+562
View File
@@ -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
View File
@@ -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(&params.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(&params.new_string, &params.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(&params.new_string, &params.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
+18 -1
View File
@@ -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(
+186
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+4
View File
@@ -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
View File
@@ -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(&params.file_path).map_err(ToolsError::from)?,
validator: None,
},
tracker,
params,
)
.await?
.output)
}
}
let path = WorkdirPath::new_scoped(&params.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();
+81
View File
@@ -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")),
}
}
+19
View File
@@ -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
View File
@@ -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(&params.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.
+143
View File
@@ -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
+418
View File
@@ -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());
}
+103
View File
@@ -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
View File
@@ -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),
+36
View File
@@ -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
View File
@@ -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(),
+59
View File
@@ -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();
+256
View File
@@ -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 {
+2
View File
@@ -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"
+764
View File
@@ -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;
+671
View File
@@ -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;
}
+469
View File
@@ -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);
}
+580
View File
@@ -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"
);
}
+14
View File
@@ -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
View File
@@ -1,4 +1,5 @@
pub mod bootstrap;
pub mod checkout;
pub mod compact;
pub mod controller;
pub mod discovery;
+369 -14
View File
@@ -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>,
+209
View File
@@ -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);
+225
View File
@@ -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.