feat: project attached checkouts with scoped native AI operations
This commit is contained in:
Generated
+1
@@ -6416,6 +6416,7 @@ dependencies = [
|
|||||||
"agen",
|
"agen",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
//! Identity-fenced access adapter for the existing read/edit/write engine.
|
||||||
|
//!
|
||||||
|
//! Descriptor-rooted resolution is Linux-only and symlinks/mount crossings fail
|
||||||
|
//! closed. The final compare and atomic rename are NOT a kernel CAS against an
|
||||||
|
//! arbitrary external editor: external editors must cooperate with provider
|
||||||
|
//! serialization. A hostile rename in that last syscall gap is not excluded.
|
||||||
|
use crate::{AtomicWriteMode, FsAccessPolicy};
|
||||||
|
use std::fs::{File, Metadata};
|
||||||
|
use std::io::{Seek, SeekFrom};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
/// Identity and state, deliberately not just a content digest. atime is omitted
|
||||||
|
/// so reading does not invalidate an observation. No host paths are encoded.
|
||||||
|
pub fn identity_validator(metadata: &Metadata) -> std::io::Result<Vec<u8>> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let mut bytes = b"yoi-checkout-v1".to_vec();
|
||||||
|
for value in [
|
||||||
|
metadata.dev(),
|
||||||
|
metadata.ino(),
|
||||||
|
metadata.len(),
|
||||||
|
metadata.mode() as u64,
|
||||||
|
metadata.mtime() as u64,
|
||||||
|
metadata.mtime_nsec() as u64,
|
||||||
|
metadata.ctime() as u64,
|
||||||
|
metadata.ctime_nsec() as u64,
|
||||||
|
] {
|
||||||
|
bytes.extend_from_slice(&value.to_le_bytes());
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
let _ = metadata;
|
||||||
|
Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"identity validators unavailable",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pinned target and its save parent. Retained through the shared operation.
|
||||||
|
pub struct CheckedTarget<'a> {
|
||||||
|
policy: &'a dyn FsAccessPolicy,
|
||||||
|
root: File,
|
||||||
|
relative: PathBuf,
|
||||||
|
logical: PathBuf,
|
||||||
|
file: File,
|
||||||
|
parent: Option<File>,
|
||||||
|
validator: Vec<u8>,
|
||||||
|
effects_possible: AtomicBool,
|
||||||
|
}
|
||||||
|
impl<'a> CheckedTarget<'a> {
|
||||||
|
pub fn pin(
|
||||||
|
root: &File,
|
||||||
|
relative: &Path,
|
||||||
|
logical: &Path,
|
||||||
|
policy: &'a dyn FsAccessPolicy,
|
||||||
|
write: bool,
|
||||||
|
) -> std::io::Result<Self> {
|
||||||
|
policy.check_cancelled()?;
|
||||||
|
if !(if write {
|
||||||
|
policy.is_writable_paths(logical, logical)
|
||||||
|
} else {
|
||||||
|
policy.is_readable_paths(logical, logical)
|
||||||
|
}) {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
"checkout target denied",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let parent = if relative.as_os_str().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(crate::open_beneath_no_symlinks_at(
|
||||||
|
root,
|
||||||
|
relative.parent().unwrap_or(Path::new("")),
|
||||||
|
)?)
|
||||||
|
};
|
||||||
|
let file = if let Some(parent) = &parent {
|
||||||
|
crate::open_beneath_no_symlinks_at(parent, Path::new(relative.file_name().unwrap()))?
|
||||||
|
} else {
|
||||||
|
crate::open_beneath_no_symlinks_at(root, Path::new(""))?
|
||||||
|
};
|
||||||
|
let metadata = file.metadata()?;
|
||||||
|
if !metadata.is_file() && !metadata.is_dir() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"checkout requires regular file or directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let result = Self {
|
||||||
|
policy,
|
||||||
|
root: root.try_clone()?,
|
||||||
|
relative: relative.to_path_buf(),
|
||||||
|
logical: logical.to_path_buf(),
|
||||||
|
file,
|
||||||
|
parent,
|
||||||
|
validator: identity_validator(&metadata)?,
|
||||||
|
effects_possible: AtomicBool::new(false),
|
||||||
|
};
|
||||||
|
result.confirm()?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
pub fn validator(&self) -> &[u8] {
|
||||||
|
&self.validator
|
||||||
|
}
|
||||||
|
pub fn metadata(&self) -> std::io::Result<Metadata> {
|
||||||
|
self.file.metadata()
|
||||||
|
}
|
||||||
|
pub fn effects_possible(&self) -> bool {
|
||||||
|
self.effects_possible.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
pub fn confirm(&self) -> std::io::Result<()> {
|
||||||
|
self.policy.check_cancelled()?;
|
||||||
|
if let Some(parent) = &self.parent {
|
||||||
|
let named_parent = crate::open_beneath_no_symlinks_at(
|
||||||
|
&self.root,
|
||||||
|
self.relative.parent().unwrap_or(Path::new("")),
|
||||||
|
)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
let pinned_metadata = parent.metadata()?;
|
||||||
|
let named_metadata = named_parent.metadata()?;
|
||||||
|
if (pinned_metadata.dev(), pinned_metadata.ino())
|
||||||
|
!= (named_metadata.dev(), named_metadata.ino())
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::WouldBlock,
|
||||||
|
"checkout parent identity changed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let named = crate::open_beneath_no_symlinks_at(&self.root, &self.relative)?;
|
||||||
|
if identity_validator(&named.metadata()?)? != self.validator
|
||||||
|
|| identity_validator(&self.file.metadata()?)? != self.validator
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::WouldBlock,
|
||||||
|
"checkout observation changed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// Refresh a directory observation after Create, retaining the original
|
||||||
|
/// pinned target/root/parent handles. A replacement named directory cannot
|
||||||
|
/// supply the validator for effects performed in the original directory.
|
||||||
|
pub fn refresh_directory_observation(&mut self) -> std::io::Result<()> {
|
||||||
|
let metadata = self.file.metadata()?;
|
||||||
|
if !metadata.is_dir() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"Create target must be directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.validator = identity_validator(&metadata)?;
|
||||||
|
self.confirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adapter for no-clobber creation beneath a pinned directory. Both target
|
||||||
|
/// and destination authorization remain in the provider's normal policy.
|
||||||
|
pub fn create_access<'b>(&'b self, suffix: PathBuf, logical: PathBuf) -> CheckedCreate<'b, 'a> {
|
||||||
|
CheckedCreate {
|
||||||
|
target: self,
|
||||||
|
suffix,
|
||||||
|
logical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FsAccessPolicy for CheckedTarget<'_> {
|
||||||
|
fn is_readable(&self, p: &Path) -> bool {
|
||||||
|
self.policy.is_readable(p)
|
||||||
|
}
|
||||||
|
fn is_writable(&self, p: &Path) -> bool {
|
||||||
|
self.policy.is_writable(p)
|
||||||
|
}
|
||||||
|
fn is_readable_paths(&self, p: &Path, r: &Path) -> bool {
|
||||||
|
p == self.logical && r == p && self.policy.is_readable_paths(p, r)
|
||||||
|
}
|
||||||
|
fn is_writable_paths(&self, p: &Path, r: &Path) -> bool {
|
||||||
|
p == self.logical && r == p && self.policy.is_writable_paths(p, r)
|
||||||
|
}
|
||||||
|
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||||
|
self.policy.check_cancelled()
|
||||||
|
}
|
||||||
|
fn resolve_access_path(&self, p: &Path) -> std::io::Result<PathBuf> {
|
||||||
|
Ok(p.to_path_buf())
|
||||||
|
}
|
||||||
|
fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result<Metadata> {
|
||||||
|
self.file.metadata()
|
||||||
|
}
|
||||||
|
fn open_read_file(&self, _: &Path, _: &Path) -> std::io::Result<File> {
|
||||||
|
self.confirm()?;
|
||||||
|
let mut file = self.file.try_clone()?;
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
fn max_write_bytes(&self) -> Option<usize> {
|
||||||
|
Some(
|
||||||
|
self.policy
|
||||||
|
.max_write_bytes()
|
||||||
|
.unwrap_or(8 * 1024 * 1024)
|
||||||
|
.min(8 * 1024 * 1024),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn max_edit_replacements(&self) -> Option<usize> {
|
||||||
|
Some(
|
||||||
|
self.policy
|
||||||
|
.max_edit_replacements()
|
||||||
|
.unwrap_or(10_000)
|
||||||
|
.min(10_000),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn atomic_write_file(
|
||||||
|
&self,
|
||||||
|
_: &Path,
|
||||||
|
_: &Path,
|
||||||
|
content: &[u8],
|
||||||
|
mode: AtomicWriteMode,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
self.confirm()?;
|
||||||
|
let parent = self.parent.as_ref().ok_or_else(|| {
|
||||||
|
std::io::Error::new(std::io::ErrorKind::InvalidInput, "cannot save root")
|
||||||
|
})?;
|
||||||
|
crate::atomic_write_beneath_no_symlinks_at(
|
||||||
|
parent,
|
||||||
|
Path::new(self.relative.file_name().unwrap()),
|
||||||
|
content,
|
||||||
|
mode,
|
||||||
|
|| {
|
||||||
|
self.confirm()?;
|
||||||
|
// From here rename/encoding/post-observation failures have an unknown outcome.
|
||||||
|
self.effects_possible.store(true, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CheckedCreate<'b, 'a> {
|
||||||
|
target: &'b CheckedTarget<'a>,
|
||||||
|
suffix: PathBuf,
|
||||||
|
logical: PathBuf,
|
||||||
|
}
|
||||||
|
impl FsAccessPolicy for CheckedCreate<'_, '_> {
|
||||||
|
fn is_readable(&self, p: &Path) -> bool {
|
||||||
|
self.target.policy.is_readable(p)
|
||||||
|
}
|
||||||
|
fn is_writable(&self, p: &Path) -> bool {
|
||||||
|
p == self.logical && self.target.policy.is_writable_paths(p, p)
|
||||||
|
}
|
||||||
|
fn resolve_access_path(&self, p: &Path) -> std::io::Result<PathBuf> {
|
||||||
|
Ok(p.to_path_buf())
|
||||||
|
}
|
||||||
|
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||||
|
self.target.policy.check_cancelled()
|
||||||
|
}
|
||||||
|
fn max_write_bytes(&self) -> Option<usize> {
|
||||||
|
self.target.max_write_bytes()
|
||||||
|
}
|
||||||
|
fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result<Metadata> {
|
||||||
|
match crate::open_beneath_no_symlinks_at(&self.target.file, &self.suffix) {
|
||||||
|
Ok(_) => Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::AlreadyExists,
|
||||||
|
"Create destination exists",
|
||||||
|
)),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn atomic_write_file(
|
||||||
|
&self,
|
||||||
|
_: &Path,
|
||||||
|
_: &Path,
|
||||||
|
content: &[u8],
|
||||||
|
mode: AtomicWriteMode,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
self.target.confirm()?;
|
||||||
|
if mode != AtomicWriteMode::CreateNew || !self.target.file.metadata()?.is_dir() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"Create requires directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Creating parents must not turn a narrow destination grant into
|
||||||
|
// permission to populate ungranted intermediate directories.
|
||||||
|
let mut parent = self.target.logical.clone();
|
||||||
|
let mut components = self.suffix.components().peekable();
|
||||||
|
while let Some(component) = components.next() {
|
||||||
|
parent.push(component.as_os_str());
|
||||||
|
if components.peek().is_some()
|
||||||
|
&& !self.target.policy.is_writable_paths(&parent, &parent)
|
||||||
|
{
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::PermissionDenied,
|
||||||
|
"Create parent denied",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nested mkdir may have effects before failure; never auto-retry.
|
||||||
|
self.target.effects_possible.store(true, Ordering::Release);
|
||||||
|
crate::atomic_write_beneath_no_symlinks_at(
|
||||||
|
&self.target.file,
|
||||||
|
&self.suffix,
|
||||||
|
content,
|
||||||
|
mode,
|
||||||
|
|| self.target.policy.check_cancelled(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{FsError, FsPath, ReadRequest, WriteRequest};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
struct Access {
|
||||||
|
path: PathBuf,
|
||||||
|
counter: AtomicUsize,
|
||||||
|
trigger: usize,
|
||||||
|
replacement: bool,
|
||||||
|
}
|
||||||
|
impl FsAccessPolicy for Access {
|
||||||
|
fn is_readable(&self, _: &Path) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn is_writable(&self, _: &Path) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||||
|
if self.counter.fetch_add(1, Ordering::SeqCst) + 1 == self.trigger {
|
||||||
|
if self.replacement {
|
||||||
|
std::fs::rename(&self.path, self.path.with_extension("old"))?;
|
||||||
|
std::fs::write(&self.path, "replacement")?;
|
||||||
|
} else {
|
||||||
|
std::fs::write(&self.path, "updated")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checked_save_boundary_rejects_name_substitution_without_mutating_replacement() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let path = root.path().join("a");
|
||||||
|
std::fs::write(&path, "original").unwrap();
|
||||||
|
let policy = Access {
|
||||||
|
path: path.clone(),
|
||||||
|
counter: AtomicUsize::new(0),
|
||||||
|
trigger: 4,
|
||||||
|
replacement: true,
|
||||||
|
};
|
||||||
|
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
|
||||||
|
let checked = CheckedTarget::pin(&handle, Path::new("a"), &path, &policy, true).unwrap();
|
||||||
|
policy.counter.store(0, Ordering::SeqCst);
|
||||||
|
let error = crate::run_write(
|
||||||
|
root.path(),
|
||||||
|
WriteRequest {
|
||||||
|
path: FsPath::new("a").unwrap(),
|
||||||
|
content: b"worker".to_vec(),
|
||||||
|
expected_hash: Some(Sha256::digest(b"original").into()),
|
||||||
|
},
|
||||||
|
&checked,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::WouldBlock)
|
||||||
|
);
|
||||||
|
assert!(!checked.effects_possible());
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(path.with_extension("old")).unwrap(),
|
||||||
|
"original"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn checked_read_midread_name_replacement_never_pairs_old_bytes_with_new_validator() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let path = root.path().join("a");
|
||||||
|
std::fs::write(&path, "original").unwrap();
|
||||||
|
let policy = Access {
|
||||||
|
path: path.clone(),
|
||||||
|
counter: AtomicUsize::new(0),
|
||||||
|
trigger: 3,
|
||||||
|
replacement: true,
|
||||||
|
};
|
||||||
|
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
|
||||||
|
let checked = CheckedTarget::pin(&handle, Path::new("a"), &path, &policy, false).unwrap();
|
||||||
|
let validator = checked.validator().to_vec();
|
||||||
|
policy.counter.store(0, Ordering::SeqCst);
|
||||||
|
let result = crate::run_read(
|
||||||
|
root.path(),
|
||||||
|
ReadRequest {
|
||||||
|
path: FsPath::new("a").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
max_bytes: 1024,
|
||||||
|
},
|
||||||
|
&checked,
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Err(FsError::Conflict(_)) => {}
|
||||||
|
Ok(read) => {
|
||||||
|
// Same pinned inode supplies bytes and hash. Execute's final
|
||||||
|
// named-target check must reject, not refresh to replacement.
|
||||||
|
assert_eq!(read.bytes, b"original");
|
||||||
|
let expected_hash: crate::ContentHash = Sha256::digest(b"original").into();
|
||||||
|
assert_eq!(read.content_hash, expected_hash);
|
||||||
|
assert_eq!(
|
||||||
|
checked.confirm().unwrap_err().kind(),
|
||||||
|
std::io::ErrorKind::WouldBlock
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected Read result: {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(checked.validator(), validator);
|
||||||
|
assert_ne!(
|
||||||
|
identity_validator(&std::fs::metadata(&path).unwrap()).unwrap(),
|
||||||
|
validator
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_post_parent_observation_retains_bound_directory_and_rejects_substitution() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let directory = root.path().join("d");
|
||||||
|
std::fs::create_dir(&directory).unwrap();
|
||||||
|
let policy = Access {
|
||||||
|
path: directory.clone(),
|
||||||
|
counter: AtomicUsize::new(0),
|
||||||
|
trigger: usize::MAX,
|
||||||
|
replacement: false,
|
||||||
|
};
|
||||||
|
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
|
||||||
|
let mut checked =
|
||||||
|
CheckedTarget::pin(&handle, Path::new("d"), &directory, &policy, true).unwrap();
|
||||||
|
let initial = checked.validator().to_vec();
|
||||||
|
let create = checked.create_access(PathBuf::from("new"), directory.join("new"));
|
||||||
|
crate::run_write(
|
||||||
|
root.path(),
|
||||||
|
WriteRequest {
|
||||||
|
path: FsPath::new("d/new").unwrap(),
|
||||||
|
content: b"new".to_vec(),
|
||||||
|
expected_hash: None,
|
||||||
|
},
|
||||||
|
&create,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
checked.refresh_directory_observation().unwrap();
|
||||||
|
assert_ne!(initial, checked.validator());
|
||||||
|
assert_eq!(
|
||||||
|
checked.validator(),
|
||||||
|
identity_validator(&std::fs::metadata(&directory).unwrap()).unwrap()
|
||||||
|
);
|
||||||
|
std::fs::rename(&directory, root.path().join("old")).unwrap();
|
||||||
|
std::fs::create_dir(&directory).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
checked.refresh_directory_observation().unwrap_err().kind(),
|
||||||
|
std::io::ErrorKind::WouldBlock
|
||||||
|
);
|
||||||
|
assert!(checked.effects_possible());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checked_parent_swap_is_rejected_even_with_a_hardlinked_same_inode_target() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let directory = root.path().join("d");
|
||||||
|
std::fs::create_dir(&directory).unwrap();
|
||||||
|
let path = directory.join("a");
|
||||||
|
std::fs::write(&path, "original").unwrap();
|
||||||
|
let policy = Access {
|
||||||
|
path: path.clone(),
|
||||||
|
counter: AtomicUsize::new(0),
|
||||||
|
trigger: usize::MAX,
|
||||||
|
replacement: false,
|
||||||
|
};
|
||||||
|
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
|
||||||
|
let checked = CheckedTarget::pin(&handle, Path::new("d/a"), &path, &policy, true).unwrap();
|
||||||
|
std::fs::rename(&directory, root.path().join("old")).unwrap();
|
||||||
|
std::fs::create_dir(&directory).unwrap();
|
||||||
|
std::fs::hard_link(root.path().join("old/a"), &path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
checked.confirm().unwrap_err().kind(),
|
||||||
|
std::io::ErrorKind::WouldBlock
|
||||||
|
);
|
||||||
|
assert!(!checked.effects_possible());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_text_read_rejects_mid_read_metadata_change() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let path = root.path().join("a");
|
||||||
|
std::fs::write(&path, "original").unwrap();
|
||||||
|
let policy = Access {
|
||||||
|
path,
|
||||||
|
counter: AtomicUsize::new(0),
|
||||||
|
trigger: 2,
|
||||||
|
replacement: false,
|
||||||
|
};
|
||||||
|
let error = crate::run_read(
|
||||||
|
root.path(),
|
||||||
|
ReadRequest {
|
||||||
|
path: FsPath::new("a").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 20,
|
||||||
|
max_bytes: 1024,
|
||||||
|
},
|
||||||
|
&policy,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, FsError::Conflict(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ pub fn run_glob(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|traversal| traversal.path())
|
.map(|traversal| traversal.path())
|
||||||
.unwrap_or(base);
|
.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);
|
let mut walker = WalkBuilder::new(walker_root);
|
||||||
walker.hidden(false).follow_links(false);
|
walker.hidden(false).follow_links(false);
|
||||||
if traversal.is_some() {
|
if traversal.is_some() {
|
||||||
@@ -56,22 +60,15 @@ pub fn run_glob(
|
|||||||
.git_exclude(false)
|
.git_exclude(false)
|
||||||
.ignore(false)
|
.ignore(false)
|
||||||
.parents(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;
|
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 {
|
access.check_cancelled().map_err(|source| FsError::Io {
|
||||||
path: PathBuf::from(request.path.as_str()),
|
path: PathBuf::from(request.path.as_str()),
|
||||||
source,
|
source,
|
||||||
@@ -83,17 +80,10 @@ pub fn run_glob(
|
|||||||
crate::MAX_TRAVERSAL_ENTRIES
|
crate::MAX_TRAVERSAL_ENTRIES
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let walked_path = entry.path();
|
let path = entry.path;
|
||||||
let path = if traversal.is_some() {
|
if path.strip_prefix(root).unwrap_or(&path).to_str().is_none() {
|
||||||
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
|
continue;
|
||||||
FsError::InvalidArgument(
|
}
|
||||||
"descriptor traversal returned a path outside its root".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
root.join(relative)
|
|
||||||
} else {
|
|
||||||
walked_path.to_path_buf()
|
|
||||||
};
|
|
||||||
let resolved = match access.resolve_access_path(&path) {
|
let resolved = match access.resolve_access_path(&path) {
|
||||||
Ok(resolved) if access.is_readable_paths(&path, &resolved) => resolved,
|
Ok(resolved) if access.is_readable_paths(&path, &resolved) => resolved,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@@ -111,7 +101,12 @@ pub fn run_glob(
|
|||||||
let logical = path.strip_prefix(root).map_err(|_| {
|
let logical = path.strip_prefix(root).map_err(|_| {
|
||||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
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);
|
let retained = result_path.as_str().len().saturating_add(16);
|
||||||
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
||||||
provider_truncated = true;
|
provider_truncated = true;
|
||||||
|
|||||||
@@ -4,10 +4,15 @@
|
|||||||
//! transport, or LLM Tool implementations. Paths are logical and root-relative;
|
//! transport, or LLM Tool implementations. Paths are logical and root-relative;
|
||||||
//! providers supply host roots and access policy.
|
//! providers supply host roots and access policy.
|
||||||
|
|
||||||
|
mod checked;
|
||||||
|
pub use checked::{CheckedTarget, identity_validator};
|
||||||
mod glob;
|
mod glob;
|
||||||
mod local;
|
mod local;
|
||||||
mod operation;
|
mod operation;
|
||||||
mod search;
|
mod search;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod traversal_tests;
|
||||||
|
mod walk;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -272,6 +277,7 @@ pub fn atomic_write_beneath_no_symlinks_at(
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct FsTraversalRoot {
|
pub struct FsTraversalRoot {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
logical_root: Option<PathBuf>,
|
||||||
_directory: std::fs::File,
|
_directory: std::fs::File,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,20 +285,44 @@ impl FsTraversalRoot {
|
|||||||
pub fn new(path: PathBuf, directory: std::fs::File) -> Self {
|
pub fn new(path: PathBuf, directory: std::fs::File) -> Self {
|
||||||
Self {
|
Self {
|
||||||
path,
|
path,
|
||||||
|
logical_root: None,
|
||||||
_directory: directory,
|
_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 {
|
pub fn path(&self) -> &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.
|
/// Provider-owned access policy used by local filesystem operations.
|
||||||
pub trait FsAccessPolicy: Send + Sync {
|
pub trait FsAccessPolicy: Send + Sync {
|
||||||
fn is_readable(&self, path: &Path) -> bool;
|
fn is_readable(&self, path: &Path) -> bool;
|
||||||
fn is_writable(&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
|
/// Cooperatively stop provider-owned filesystem work. Implementations may
|
||||||
/// use this for per-operation cancellation and deadlines.
|
/// use this for per-operation cancellation and deadlines.
|
||||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||||
|
|||||||
@@ -162,6 +162,19 @@ fn run_read_with_limits(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
if crate::identity_validator(&metadata).map_err(|e| map_io(&logical, e))?
|
||||||
|
!= crate::identity_validator(
|
||||||
|
&reader
|
||||||
|
.get_ref()
|
||||||
|
.metadata()
|
||||||
|
.map_err(|e| map_io(&logical, e))?,
|
||||||
|
)
|
||||||
|
.map_err(|e| map_io(&logical, e))?
|
||||||
|
{
|
||||||
|
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let total_lines =
|
let total_lines =
|
||||||
current_line.saturating_add(usize::from(last_byte.is_some_and(|byte| byte != b'\n')));
|
current_line.saturating_add(usize::from(last_byte.is_some_and(|byte| byte != b'\n')));
|
||||||
if request.offset > total_lines && request.offset != 0 {
|
if request.offset > total_lines && request.offset != 0 {
|
||||||
@@ -473,6 +486,14 @@ pub fn run_list(
|
|||||||
if !metadata.is_dir() {
|
if !metadata.is_dir() {
|
||||||
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
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 entries = Vec::new();
|
||||||
let mut retained_path_bytes = 0_usize;
|
let mut retained_path_bytes = 0_usize;
|
||||||
let mut provider_truncated = false;
|
let mut provider_truncated = false;
|
||||||
@@ -490,6 +511,9 @@ pub fn run_list(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
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 logical_absolute = logical_base.join(entry.file_name());
|
||||||
let resolved = match access.resolve_access_path(&logical_absolute) {
|
let resolved = match access.resolve_access_path(&logical_absolute) {
|
||||||
Ok(resolved) => resolved,
|
Ok(resolved) => resolved,
|
||||||
@@ -513,7 +537,12 @@ pub fn run_list(
|
|||||||
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
|
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
|
||||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
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);
|
let retained = result_path.as_str().len().saturating_add(64);
|
||||||
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
||||||
provider_truncated = true;
|
provider_truncated = true;
|
||||||
|
|||||||
@@ -353,6 +353,10 @@ pub struct GrepRequest {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct GrepResult {
|
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
|
/// Provider-rendered bounded grep report. Keeping rendering here avoids
|
||||||
/// transferring candidate files across a remote provider boundary.
|
/// transferring candidate files across a remote provider boundary.
|
||||||
pub output: String,
|
pub output: String,
|
||||||
|
|||||||
@@ -79,6 +79,28 @@ fn reserve_report_bytes(report: &mut GrepReport, bytes: usize) -> bool {
|
|||||||
|
|
||||||
impl GrepReport {
|
impl GrepReport {
|
||||||
fn into_result(self, root: &Path) -> GrepResult {
|
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 {
|
let (match_count, matched_files) = match self.mode {
|
||||||
GrepOutputMode::FilesWithMatches => (self.files.len(), self.files.len()),
|
GrepOutputMode::FilesWithMatches => (self.files.len(), self.files.len()),
|
||||||
GrepOutputMode::Count => (
|
GrepOutputMode::Count => (
|
||||||
@@ -116,6 +138,7 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepResult {
|
GrepResult {
|
||||||
|
paths,
|
||||||
output,
|
output,
|
||||||
match_count,
|
match_count,
|
||||||
matched_files,
|
matched_files,
|
||||||
@@ -166,11 +189,15 @@ fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: b
|
|||||||
output
|
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 {
|
fn logical_display(root: &Path, path: &Path) -> String {
|
||||||
path.strip_prefix(root)
|
logical_path(root, path)
|
||||||
.unwrap_or(path)
|
.expect("only representable provider paths enter a report")
|
||||||
.to_string_lossy()
|
.as_str()
|
||||||
.replace('\\', "/")
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HEAD_LIMIT: usize = 250;
|
const DEFAULT_HEAD_LIMIT: usize = 250;
|
||||||
@@ -464,6 +491,9 @@ pub fn run_grep(
|
|||||||
retained_bytes: 0,
|
retained_bytes: 0,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
};
|
};
|
||||||
|
if head_limit == 0 {
|
||||||
|
return Ok(report.into_result(root));
|
||||||
|
}
|
||||||
let mut matching_files_seen = 0;
|
let mut matching_files_seen = 0;
|
||||||
let mut matches_seen = 0;
|
let mut matches_seen = 0;
|
||||||
|
|
||||||
@@ -474,6 +504,7 @@ pub fn run_grep(
|
|||||||
&mut searcher,
|
&mut searcher,
|
||||||
&matcher,
|
&matcher,
|
||||||
&base,
|
&base,
|
||||||
|
root,
|
||||||
mode,
|
mode,
|
||||||
&mut report,
|
&mut report,
|
||||||
&mut matching_files_seen,
|
&mut matching_files_seen,
|
||||||
@@ -494,6 +525,10 @@ pub fn run_grep(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|traversal| traversal.path())
|
.map(|traversal| traversal.path())
|
||||||
.unwrap_or(&base);
|
.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);
|
let mut walker = WalkBuilder::new(walker_root);
|
||||||
if traversal.is_some() {
|
if traversal.is_some() {
|
||||||
walker
|
walker
|
||||||
@@ -506,19 +541,6 @@ pub fn run_grep(
|
|||||||
.ignore(false)
|
.ignore(false)
|
||||||
.parents(false)
|
.parents(false)
|
||||||
.follow_links(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 {
|
} else {
|
||||||
walker
|
walker
|
||||||
.hidden(true)
|
.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
|
access
|
||||||
.check_cancelled()
|
.check_cancelled()
|
||||||
.map_err(|error| FsError::io(&base, error))?;
|
.map_err(|error| FsError::io(&base, error))?;
|
||||||
@@ -574,18 +602,11 @@ pub fn run_grep(
|
|||||||
crate::MAX_TRAVERSAL_ENTRIES
|
crate::MAX_TRAVERSAL_ENTRIES
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let walked_path = entry.path();
|
let path = entry.path;
|
||||||
let path = if traversal.is_some() {
|
if logical_path(root, &path).is_none() {
|
||||||
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
|
continue;
|
||||||
FsError::InvalidArgument(
|
}
|
||||||
"descriptor traversal returned a path outside its root".to_string(),
|
let file_type = entry.kind;
|
||||||
)
|
|
||||||
})?;
|
|
||||||
root.join(relative)
|
|
||||||
} else {
|
|
||||||
walked_path.to_path_buf()
|
|
||||||
};
|
|
||||||
let file_type = entry.file_type();
|
|
||||||
if traversal.is_some() && file_type.as_ref().is_some_and(|kind| kind.is_dir()) {
|
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)?;
|
descriptor_ignores.load_directory(&path, &base, &mut source_bytes_remaining, access)?;
|
||||||
continue;
|
continue;
|
||||||
@@ -599,10 +620,12 @@ pub fn run_grep(
|
|||||||
"descriptor traversal returned a path outside its search base".to_string(),
|
"descriptor traversal returned a path outside its search base".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if relative
|
if relative.components().any(|component| {
|
||||||
.components()
|
component
|
||||||
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
|
.as_os_str()
|
||||||
|| descriptor_ignores.is_ignored(root, &path)
|
.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())
|
|| !direct_file_selected(&path, overrides.as_ref(), types.as_ref())
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -618,6 +641,7 @@ pub fn run_grep(
|
|||||||
&mut searcher,
|
&mut searcher,
|
||||||
&matcher,
|
&matcher,
|
||||||
&path,
|
&path,
|
||||||
|
root,
|
||||||
mode,
|
mode,
|
||||||
&mut report,
|
&mut report,
|
||||||
&mut matching_files_seen,
|
&mut matching_files_seen,
|
||||||
@@ -639,6 +663,7 @@ fn scan_path(
|
|||||||
searcher: &mut Searcher,
|
searcher: &mut Searcher,
|
||||||
matcher: &grep_regex::RegexMatcher,
|
matcher: &grep_regex::RegexMatcher,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
|
root: &Path,
|
||||||
mode: GrepOutputMode,
|
mode: GrepOutputMode,
|
||||||
report: &mut GrepReport,
|
report: &mut GrepReport,
|
||||||
matching_files_seen: &mut usize,
|
matching_files_seen: &mut usize,
|
||||||
@@ -648,6 +673,9 @@ fn scan_path(
|
|||||||
source_bytes_remaining: &mut u64,
|
source_bytes_remaining: &mut u64,
|
||||||
access: &dyn FsAccessPolicy,
|
access: &dyn FsAccessPolicy,
|
||||||
) -> Result<bool, FsError> {
|
) -> Result<bool, FsError> {
|
||||||
|
if logical_path(root, path).is_none() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
let resolved = access
|
let resolved = access
|
||||||
.resolve_access_path(path)
|
.resolve_access_path(path)
|
||||||
.map_err(|error| FsError::io(path, error))?;
|
.map_err(|error| FsError::io(path, error))?;
|
||||||
@@ -678,7 +706,7 @@ fn scan_path(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
if *matching_files_seen >= offset {
|
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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
report.files.push(path.to_path_buf());
|
report.files.push(path.to_path_buf());
|
||||||
@@ -695,7 +723,7 @@ fn scan_path(
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
if *matching_files_seen >= offset {
|
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);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
report.counts.push((path.to_path_buf(), count));
|
report.counts.push((path.to_path_buf(), count));
|
||||||
@@ -780,7 +808,7 @@ impl ContentSink<'_> {
|
|||||||
fn reserve(&mut self, content_bytes: usize) -> bool {
|
fn reserve(&mut self, content_bytes: usize) -> bool {
|
||||||
let retained = self
|
let retained = self
|
||||||
.path
|
.path
|
||||||
.to_string_lossy()
|
.as_os_str()
|
||||||
.len()
|
.len()
|
||||||
.saturating_add(content_bytes)
|
.saturating_add(content_bytes)
|
||||||
.saturating_add(64);
|
.saturating_add(64);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
use crate::*;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
struct Filtered {
|
||||||
|
root: PathBuf,
|
||||||
|
seen: Arc<Mutex<Vec<PathBuf>>>,
|
||||||
|
}
|
||||||
|
impl FsAccessPolicy for Filtered {
|
||||||
|
fn is_readable(&self, path: &Path) -> bool {
|
||||||
|
path.starts_with(&self.root) && !path.starts_with(self.root.join("search/denied"))
|
||||||
|
}
|
||||||
|
fn is_writable(&self, _: &Path) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn open_traversal_root(
|
||||||
|
&self,
|
||||||
|
_: &Path,
|
||||||
|
resolved: &Path,
|
||||||
|
) -> std::io::Result<Option<FsTraversalRoot>> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let file = std::fs::File::open(resolved)?;
|
||||||
|
let walk = PathBuf::from(format!("/proc/self/fd/{}", file.as_raw_fd()));
|
||||||
|
Ok(Some(FsTraversalRoot::new_at(
|
||||||
|
walk,
|
||||||
|
file,
|
||||||
|
resolved.to_path_buf(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
fn traversal_filter(&self) -> Option<FsTraversalFilter> {
|
||||||
|
let denied = self.root.join("search/denied");
|
||||||
|
let seen = self.seen.clone();
|
||||||
|
Some(Arc::new(move |path, _| {
|
||||||
|
seen.lock().unwrap().push(path.to_path_buf());
|
||||||
|
!path.starts_with(&denied)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
fn open_read_file(&self, _: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
|
||||||
|
assert!(
|
||||||
|
!resolved.starts_with(self.root.join("search/denied")),
|
||||||
|
"denied source opened"
|
||||||
|
);
|
||||||
|
std::fs::File::open(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn descriptor_search_anchor_and_predicate_prune_before_denied_directory_descent() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(root.path().join("search/denied/deep")).unwrap();
|
||||||
|
std::fs::write(root.path().join("search/allowed.txt"), "needle allowed").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.path().join("search/denied/deep/secret.txt"),
|
||||||
|
"needle secret",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(root.path().join("outside.txt"), "needle outside").unwrap();
|
||||||
|
let policy = Filtered {
|
||||||
|
root: root.path().to_path_buf(),
|
||||||
|
seen: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
};
|
||||||
|
let glob = run_glob(
|
||||||
|
root.path(),
|
||||||
|
&root.path().join("search"),
|
||||||
|
GlobRequest {
|
||||||
|
path: FsPath::new("search").unwrap(),
|
||||||
|
pattern: "**/*.txt".into(),
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
&policy,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(glob.paths, vec![FsPath::new("search/allowed.txt").unwrap()]);
|
||||||
|
let grep = run_grep(
|
||||||
|
root.path(),
|
||||||
|
root.path().join("search"),
|
||||||
|
GrepRequest {
|
||||||
|
path: FsPath::new("search").unwrap(),
|
||||||
|
pattern: "needle".into(),
|
||||||
|
glob: None,
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: GrepOutputMode::Content,
|
||||||
|
offset: 0,
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
&policy,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(grep.paths, vec![FsPath::new("search/allowed.txt").unwrap()]);
|
||||||
|
let seen = policy.seen.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
seen.iter()
|
||||||
|
.any(|path| path == &root.path().join("search/denied"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!seen
|
||||||
|
.iter()
|
||||||
|
.any(|path| path.starts_with(root.path().join("search/denied/deep")))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!seen
|
||||||
|
.iter()
|
||||||
|
.any(|path| path == &root.path().join("outside.txt"))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
//! Bounded descriptor-provider walk shared by Glob and Grep. Unlike a
|
||||||
|
//! WalkBuilder predicate, this iterator counts even rejected entries and checks
|
||||||
|
//! cancellation while scanning them. Denied directories are never enumerated.
|
||||||
|
use crate::{FsAccessPolicy, FsError, FsTraversalFilter};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub(crate) struct Entry {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub kind: Option<std::fs::FileType>,
|
||||||
|
}
|
||||||
|
pub(crate) enum Walk<'a> {
|
||||||
|
Plain(ignore::Walk),
|
||||||
|
Descriptor(DescriptorWalk<'a>),
|
||||||
|
}
|
||||||
|
impl<'a> Walk<'a> {
|
||||||
|
pub fn descriptor(root: &Path, access: &'a dyn FsAccessPolicy) -> Self {
|
||||||
|
Self::Descriptor(DescriptorWalk {
|
||||||
|
access,
|
||||||
|
filter: access.traversal_filter(),
|
||||||
|
pending: Some((root.to_path_buf(), 0)),
|
||||||
|
root: Some(root.to_path_buf()),
|
||||||
|
stack: Vec::new(),
|
||||||
|
visited: 0,
|
||||||
|
failed: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Iterator for Walk<'_> {
|
||||||
|
type Item = Result<Entry, FsError>;
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
match self {
|
||||||
|
Self::Plain(walk) => loop {
|
||||||
|
match walk.next()? {
|
||||||
|
Ok(entry) => {
|
||||||
|
return Some(Ok(Entry {
|
||||||
|
path: entry.path().to_path_buf(),
|
||||||
|
kind: entry.file_type(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(_) => continue,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Self::Descriptor(walk) => walk.next(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) struct DescriptorWalk<'a> {
|
||||||
|
access: &'a dyn FsAccessPolicy,
|
||||||
|
filter: Option<FsTraversalFilter>,
|
||||||
|
root: Option<PathBuf>,
|
||||||
|
pending: Option<(PathBuf, usize)>,
|
||||||
|
stack: Vec<(PathBuf, usize, std::fs::ReadDir)>,
|
||||||
|
visited: usize,
|
||||||
|
failed: bool,
|
||||||
|
}
|
||||||
|
impl DescriptorWalk<'_> {
|
||||||
|
fn next_entry(&mut self) -> Result<Option<Entry>, FsError> {
|
||||||
|
if let Some(root) = self.root.take() {
|
||||||
|
self.access
|
||||||
|
.check_cancelled()
|
||||||
|
.map_err(|e| FsError::io(&root, e))?;
|
||||||
|
let resolved = self
|
||||||
|
.access
|
||||||
|
.resolve_access_path(&root)
|
||||||
|
.map_err(|e| FsError::io(&root, e))?;
|
||||||
|
let metadata = self
|
||||||
|
.access
|
||||||
|
.read_metadata(&root, &resolved)
|
||||||
|
.map_err(|e| FsError::io(&root, e))?;
|
||||||
|
return Ok(Some(Entry {
|
||||||
|
path: root,
|
||||||
|
kind: Some(metadata.file_type()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
if let Some((path, depth)) = self.pending.take() {
|
||||||
|
self.access
|
||||||
|
.check_cancelled()
|
||||||
|
.map_err(|e| FsError::io(&path, e))?;
|
||||||
|
let selected = self
|
||||||
|
.filter
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|filter| filter(&path, true));
|
||||||
|
let resolved = self
|
||||||
|
.access
|
||||||
|
.resolve_access_path(&path)
|
||||||
|
.map_err(|e| FsError::io(&path, e))?;
|
||||||
|
if selected && self.access.can_enumerate_directory(&path, &resolved) {
|
||||||
|
if depth >= 128 {
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"descriptor traversal depth exceeds provider limit 128".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let entries = self
|
||||||
|
.access
|
||||||
|
.open_read_dir(&path, &resolved)
|
||||||
|
.map_err(|e| FsError::io(&path, e))?;
|
||||||
|
self.stack.push((path, depth, entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some((parent, depth, entries)) = self.stack.last_mut() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
self.access
|
||||||
|
.check_cancelled()
|
||||||
|
.map_err(|e| FsError::io(&*parent, e))?;
|
||||||
|
let Some(entry) = entries.next() else {
|
||||||
|
self.stack.pop();
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.visited = self.visited.saturating_add(1);
|
||||||
|
if self.visited > crate::MAX_TRAVERSAL_ENTRIES {
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"descriptor traversal exceeds provider entry limit".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let entry = entry.map_err(|e| FsError::io(&*parent, e))?;
|
||||||
|
if entry.file_name().to_str().is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = parent.join(entry.file_name());
|
||||||
|
let kind = entry.file_type().map_err(|e| FsError::io(&path, e))?;
|
||||||
|
let selected = self
|
||||||
|
.filter
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|filter| filter(&path, kind.is_dir()));
|
||||||
|
if !selected {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let resolved = match self.access.resolve_access_path(&path) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !self.access.is_readable_paths(&path, &resolved) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if kind.is_dir() {
|
||||||
|
self.pending = Some((path.clone(), depth.saturating_add(1)));
|
||||||
|
}
|
||||||
|
return Ok(Some(Entry {
|
||||||
|
path,
|
||||||
|
kind: Some(kind),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Iterator for DescriptorWalk<'_> {
|
||||||
|
type Item = Result<Entry, FsError>;
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
if self.failed {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match self.next_entry() {
|
||||||
|
Ok(entry) => entry.map(Ok),
|
||||||
|
Err(error) => {
|
||||||
|
self.failed = true;
|
||||||
|
Some(Err(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
struct RejectFiles {
|
||||||
|
calls: AtomicUsize,
|
||||||
|
cancel_at: usize,
|
||||||
|
}
|
||||||
|
impl FsAccessPolicy for RejectFiles {
|
||||||
|
fn is_readable(&self, _: &Path) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn is_writable(&self, _: &Path) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn traversal_filter(&self) -> Option<FsTraversalFilter> {
|
||||||
|
Some(Arc::new(|_, directory| directory))
|
||||||
|
}
|
||||||
|
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||||
|
if self.calls.fetch_add(1, Ordering::Relaxed) >= self.cancel_at {
|
||||||
|
Err(std::io::Error::from(std::io::ErrorKind::Interrupted))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn descriptor_walk_counts_rejected_entries_toward_the_entry_budget() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(root.path().join("rejected"), "must not be opened").unwrap();
|
||||||
|
let policy = RejectFiles {
|
||||||
|
calls: AtomicUsize::new(0),
|
||||||
|
cancel_at: usize::MAX,
|
||||||
|
};
|
||||||
|
let Walk::Descriptor(mut walk) = Walk::descriptor(root.path(), &policy) else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert!(walk.next().unwrap().is_ok());
|
||||||
|
walk.visited = crate::MAX_TRAVERSAL_ENTRIES;
|
||||||
|
assert!(matches!(
|
||||||
|
walk.next().unwrap(),
|
||||||
|
Err(FsError::InvalidArgument(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn descriptor_walk_checks_cancellation_while_scanning_only_rejected_entries() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
for index in 0..20 {
|
||||||
|
std::fs::write(root.path().join(index.to_string()), "must not be opened").unwrap();
|
||||||
|
}
|
||||||
|
let policy = RejectFiles {
|
||||||
|
calls: AtomicUsize::new(0),
|
||||||
|
cancel_at: 4,
|
||||||
|
};
|
||||||
|
let mut walk = Walk::descriptor(root.path(), &policy);
|
||||||
|
assert!(walk.next().unwrap().is_ok());
|
||||||
|
assert!(
|
||||||
|
matches!(walk.next().unwrap(), Err(FsError::Io { source, .. }) if source.kind() == std::io::ErrorKind::Interrupted)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,59 @@ Does not own:
|
|||||||
|
|
||||||
A tool implementation must assume model input is untrusted. Permission policy, scope checks, output bounding, and redaction are part of the safety boundary, not optional UI behavior.
|
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
|
## See also
|
||||||
|
|
||||||
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
//! Native checkout entrypoint using the same processing as ordinary filesystem Tools.
|
||||||
|
//!
|
||||||
|
//! The Host binds alias, generation, path and validator from the checkout route; none
|
||||||
|
//! can be overridden by Operation arguments. File reads/mutations dispatch through
|
||||||
|
//! the provider's checked execution boundary. Search shares normal request and
|
||||||
|
//! rendering processing but dispatches through checkout_search so provider scope
|
||||||
|
//! layers and output rebasing are preserved; checks bracket the bound directory
|
||||||
|
//! observation, not an atomic snapshot of the searched tree.
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use agen::tool::{ToolError, ToolExecutionContext, ToolOutput};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde_json::Value;
|
||||||
|
use workdir::{WorkdirPath, WorkdirSessionCapability, WorkdirSessionRouter};
|
||||||
|
|
||||||
|
use crate::{ToolsError, Tracker, file_target::FileTarget};
|
||||||
|
|
||||||
|
pub struct CheckoutToolOutput {
|
||||||
|
pub output: ToolOutput,
|
||||||
|
pub paths: Vec<WorkdirPath>,
|
||||||
|
pub validator: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute an exact named native Operation on a route-bound checkout target.
|
||||||
|
/// `Create.path` and search `path` are relative to the bound directory; returned
|
||||||
|
/// paths are typed checkout-root-relative provider paths, never parsed output.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn execute_checkout_tool(
|
||||||
|
router: Arc<WorkdirSessionRouter>,
|
||||||
|
tracker: Tracker,
|
||||||
|
alias: &str,
|
||||||
|
generation: u64,
|
||||||
|
path: WorkdirPath,
|
||||||
|
validator: Vec<u8>,
|
||||||
|
tool_name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
ctx: ToolExecutionContext,
|
||||||
|
) -> Result<CheckoutToolOutput, ToolError> {
|
||||||
|
let (capability, allowed): (_, &[&str]) = match tool_name {
|
||||||
|
"Read" => (WorkdirSessionCapability::Read, &["offset", "limit"]),
|
||||||
|
"Edit" => (
|
||||||
|
WorkdirSessionCapability::Edit,
|
||||||
|
&["old_string", "new_string", "replace_all"],
|
||||||
|
),
|
||||||
|
"Write" => (WorkdirSessionCapability::Write, &["content"]),
|
||||||
|
"Create" => (WorkdirSessionCapability::Write, &["path", "content"]),
|
||||||
|
"Glob" => (WorkdirSessionCapability::Glob, &["pattern", "path"]),
|
||||||
|
"Grep" => (
|
||||||
|
WorkdirSessionCapability::Grep,
|
||||||
|
&[
|
||||||
|
"pattern",
|
||||||
|
"path",
|
||||||
|
"glob",
|
||||||
|
"type",
|
||||||
|
"case_insensitive",
|
||||||
|
"-B",
|
||||||
|
"-A",
|
||||||
|
"-C",
|
||||||
|
"multiline",
|
||||||
|
"output_mode",
|
||||||
|
"head_limit",
|
||||||
|
"offset",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
return Err(ToolError::InvalidArgument(format!(
|
||||||
|
"unknown checkout Operation: {tool_name}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut arguments = arguments
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ToolError::InvalidArgument("checkout arguments must be an object".into()))?;
|
||||||
|
if let Some(key) = arguments
|
||||||
|
.keys()
|
||||||
|
.find(|key| !allowed.contains(&key.as_str()))
|
||||||
|
{
|
||||||
|
return Err(ToolError::InvalidArgument(format!(
|
||||||
|
"unknown or route override checkout argument: {key}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// Do not rebuild a singleton router: this is the original router identity and
|
||||||
|
// tracker namespace shared with normal Tool calls and its detach/reuse fence.
|
||||||
|
let selected = crate::routing::resolve_session(&router, Some(alias), capability)?;
|
||||||
|
if selected.generation != generation {
|
||||||
|
return Err(stale_checkout(
|
||||||
|
"checkout attachment generation changed; resolve the checkout again",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Checkout paths are always logical and cannot use Tool-mode absolute scope.
|
||||||
|
let path = WorkdirPath::new(path.as_str()).map_err(ToolsError::from)?;
|
||||||
|
let tracker = tracker.scoped_attachment(&selected.alias, selected.generation);
|
||||||
|
let target = FileTarget {
|
||||||
|
session: selected.session.clone(),
|
||||||
|
path: path.clone(),
|
||||||
|
validator: Some(validator.clone()),
|
||||||
|
};
|
||||||
|
match tool_name {
|
||||||
|
"Read" | "Edit" | "Write" => {
|
||||||
|
arguments.insert("file_path".into(), Value::String(path.to_string()));
|
||||||
|
let arguments = Value::Object(arguments);
|
||||||
|
match tool_name {
|
||||||
|
"Read" => {
|
||||||
|
crate::read::execute_read(target, tracker, decode(arguments, tool_name)?).await
|
||||||
|
}
|
||||||
|
"Edit" => {
|
||||||
|
crate::edit::execute_edit(target, tracker, decode(arguments, tool_name)?, ctx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let params: crate::write::WriteParams = decode(arguments, tool_name)?;
|
||||||
|
crate::write::execute_write(target, tracker, params.content, None, ctx).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Create" => {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct CreateParams {
|
||||||
|
path: String,
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
let params: CreateParams = decode(Value::Object(arguments), tool_name)?;
|
||||||
|
let destination = relative_target(&path, ¶ms.path, true)?;
|
||||||
|
crate::write::execute_write(target, tracker, params.content, Some(destination), ctx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"Glob" | "Grep" => {
|
||||||
|
let before = selected
|
||||||
|
.session
|
||||||
|
.checkout_observe(path.clone())
|
||||||
|
.await
|
||||||
|
.map_err(crate::file_target::checked_error)?;
|
||||||
|
if before.validator != validator {
|
||||||
|
return Err(stale_search());
|
||||||
|
}
|
||||||
|
let mut result = if tool_name == "Glob" {
|
||||||
|
let params: crate::glob::GlobParams = decode(Value::Object(arguments), tool_name)?;
|
||||||
|
check_pattern(¶ms.pattern)?;
|
||||||
|
let search_path =
|
||||||
|
relative_target(&path, params.path.as_deref().unwrap_or("."), false)?;
|
||||||
|
crate::glob::execute_glob(
|
||||||
|
crate::search_target::SearchTarget::checkout(selected.session.clone()),
|
||||||
|
search_path,
|
||||||
|
params.pattern,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
let params: crate::grep::GrepParams = decode(Value::Object(arguments), tool_name)?;
|
||||||
|
if let Some(glob) = ¶ms.glob {
|
||||||
|
check_pattern(glob)?;
|
||||||
|
}
|
||||||
|
let search_path =
|
||||||
|
relative_target(&path, params.path.as_deref().unwrap_or("."), false)?;
|
||||||
|
crate::grep::execute_grep(
|
||||||
|
crate::search_target::SearchTarget::checkout(selected.session.clone()),
|
||||||
|
search_path,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
// Reject rather than emit a link/rendered path outside the binding.
|
||||||
|
// Provider scope is still authoritative for both traversal and content.
|
||||||
|
for result_path in &result.paths {
|
||||||
|
let logical = WorkdirPath::new(result_path.as_str()).map_err(ToolsError::from)?;
|
||||||
|
if !is_beneath(&path, &logical) {
|
||||||
|
return Err(crate::file_target::readonly_result_error(
|
||||||
|
"search paths escaped bound directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let after = selected
|
||||||
|
.session
|
||||||
|
.checkout_observe(path)
|
||||||
|
.await
|
||||||
|
.map_err(crate::file_target::checked_error)?;
|
||||||
|
if after.validator != before.validator {
|
||||||
|
return Err(stale_search());
|
||||||
|
}
|
||||||
|
result.validator = Some(after.validator);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
_ => unreachable!("Operation names checked above"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode<T: DeserializeOwned>(arguments: Value, name: &str) -> Result<T, ToolError> {
|
||||||
|
serde_json::from_value(arguments)
|
||||||
|
.map_err(|error| ToolError::InvalidArgument(format!("invalid {name} input: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stale_search() -> ToolError {
|
||||||
|
stale_checkout("checkout directory changed; resolve it again before searching")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stale_checkout(message: impl Into<String>) -> ToolError {
|
||||||
|
ToolError::StructuredConflict {
|
||||||
|
code: "checkout_stale".into(),
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_beneath(base: &WorkdirPath, path: &WorkdirPath) -> bool {
|
||||||
|
base == &WorkdirPath::root()
|
||||||
|
|| std::path::Path::new(path.as_str()).starts_with(std::path::Path::new(base.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relative_target(
|
||||||
|
base: &WorkdirPath,
|
||||||
|
relative: &str,
|
||||||
|
strict: bool,
|
||||||
|
) -> Result<WorkdirPath, ToolError> {
|
||||||
|
if relative.is_empty()
|
||||||
|
|| relative.starts_with('/')
|
||||||
|
|| relative.contains('\\')
|
||||||
|
|| relative.split('/').any(|part| part == "..")
|
||||||
|
{
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"path must be relative beneath the bound checkout directory, without parent traversal"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let relative = WorkdirPath::new(relative).map_err(ToolsError::from)?;
|
||||||
|
let joined = if relative == WorkdirPath::root() {
|
||||||
|
base.clone()
|
||||||
|
} else if base == &WorkdirPath::root() {
|
||||||
|
relative
|
||||||
|
} else {
|
||||||
|
WorkdirPath::new(&format!("{}/{}", base.as_str(), relative.as_str()))
|
||||||
|
.map_err(ToolsError::from)?
|
||||||
|
};
|
||||||
|
if !is_beneath(base, &joined) || (strict && joined == *base) {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"destination must be strictly beneath the bound checkout directory".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(joined)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_pattern(pattern: &str) -> Result<(), ToolError> {
|
||||||
|
// Patterns filter descendants, not an alternate filesystem starting point.
|
||||||
|
if pattern.starts_with('/') || pattern.split('/').any(|part| part == "..") {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"search glob must stay beneath the bound checkout directory".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "checkout_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
use super::*;
|
||||||
|
use manifest::Scope;
|
||||||
|
use serde_json::json;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use workdir::{LocalWorkdirSession, WorkdirAttachmentAlias};
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
dir: TempDir,
|
||||||
|
router: Arc<WorkdirSessionRouter>,
|
||||||
|
tracker: Tracker,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
let fixture = Self {
|
||||||
|
dir,
|
||||||
|
router,
|
||||||
|
tracker: Tracker::new(),
|
||||||
|
};
|
||||||
|
fixture.attach("main");
|
||||||
|
fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach(&self, alias: &str) {
|
||||||
|
self.router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new(alias).unwrap(),
|
||||||
|
Arc::new(LocalWorkdirSession::new(
|
||||||
|
Scope::writable(self.dir.path()).unwrap(),
|
||||||
|
self.dir.path().to_path_buf(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn observation(&self, alias: &str, path: &str) -> (u64, Vec<u8>) {
|
||||||
|
let selected = self.router.resolve(Some(alias)).unwrap();
|
||||||
|
let observation = selected
|
||||||
|
.session
|
||||||
|
.checkout_observe(WorkdirPath::new(path).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(selected.generation, observation.validator)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn native(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
path: &str,
|
||||||
|
name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
) -> Result<CheckoutToolOutput, ToolError> {
|
||||||
|
let (generation, validator) = self.observation(alias, path).await;
|
||||||
|
execute_checkout_tool(
|
||||||
|
self.router.clone(),
|
||||||
|
self.tracker.clone(),
|
||||||
|
alias,
|
||||||
|
generation,
|
||||||
|
WorkdirPath::new(path).unwrap(),
|
||||||
|
validator,
|
||||||
|
name,
|
||||||
|
arguments,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn normal(&self, name: &str, arguments: Value) -> Result<ToolOutput, ToolError> {
|
||||||
|
let definitions = crate::routed_builtin_tools(
|
||||||
|
self.router.clone(),
|
||||||
|
self.tracker.clone(),
|
||||||
|
Default::default(),
|
||||||
|
);
|
||||||
|
for definition in definitions {
|
||||||
|
let (meta, tool) = definition();
|
||||||
|
if meta.name == name {
|
||||||
|
return tool
|
||||||
|
.execute(&arguments.to_string(), Default::default())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("missing Tool {name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_checkout_stale(error: ToolError) {
|
||||||
|
assert_checkout_code(error, "checkout_stale");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_checkout_code(error: ToolError, expected: &str) {
|
||||||
|
match error {
|
||||||
|
ToolError::StructuredConflict { code, .. } => assert_eq!(code, expected),
|
||||||
|
other => panic!("expected {expected} structured conflict, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkout_known_refusals_have_typed_codes_without_changing_normal_tools() {
|
||||||
|
use workdir::{WorkdirError, WorkdirSessionCapability};
|
||||||
|
let cases = [
|
||||||
|
(WorkdirError::Denied("denied".into()), "checkout_denied"),
|
||||||
|
(
|
||||||
|
WorkdirError::OutOfScope("outside".into()),
|
||||||
|
"checkout_denied",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::ReadOnly("read only".into()),
|
||||||
|
"checkout_denied",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::Unsupported(WorkdirSessionCapability::Write),
|
||||||
|
"checkout_denied",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::InvalidArgument("bad argument".into()),
|
||||||
|
"checkout_invalid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::InvalidPath("bad path".into()),
|
||||||
|
"checkout_invalid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::Unavailable("budget exhausted".into()),
|
||||||
|
"checkout_unavailable",
|
||||||
|
),
|
||||||
|
(WorkdirError::NotFound("missing".into()), "checkout_stale"),
|
||||||
|
(WorkdirError::SessionClosed, "checkout_unavailable"),
|
||||||
|
(
|
||||||
|
WorkdirError::UnsupportedOperation("unsupported".into()),
|
||||||
|
"checkout_denied",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::UnknownCommand("missing command".into()),
|
||||||
|
"checkout_invalid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::RelativePath("relative".into()),
|
||||||
|
"checkout_invalid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::IsDirectory("directory".into()),
|
||||||
|
"checkout_invalid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::InvalidGlob("bad glob".into()),
|
||||||
|
"checkout_invalid",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WorkdirError::InvalidRegex("bad regex".into()),
|
||||||
|
"checkout_invalid",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (error, code) in cases {
|
||||||
|
assert_checkout_code(crate::file_target::checked_error(error), code);
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
ToolError::from(ToolsError::from(WorkdirError::Unavailable(
|
||||||
|
"unavailable".into()
|
||||||
|
))),
|
||||||
|
ToolError::ExecutionFailed(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
ToolError::from(ToolsError::from(WorkdirError::Unsupported(
|
||||||
|
WorkdirSessionCapability::Write
|
||||||
|
))),
|
||||||
|
ToolError::InvalidArgument(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkout_conflict_classification_preserves_normal_tools_and_unknown_outcomes() {
|
||||||
|
assert_checkout_stale(crate::file_target::checked_error(
|
||||||
|
workdir::WorkdirError::Conflict("a.txt".into()),
|
||||||
|
));
|
||||||
|
assert_checkout_stale(stale_search());
|
||||||
|
assert!(matches!(
|
||||||
|
ToolError::from(ToolsError::from(workdir::WorkdirError::Conflict(
|
||||||
|
"a.txt".into()
|
||||||
|
))),
|
||||||
|
ToolError::ExecutionFailed(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
crate::file_target::checked_error(workdir::WorkdirError::OutcomeUnknown(
|
||||||
|
"may have committed".into()
|
||||||
|
)),
|
||||||
|
ToolError::ExecutionFailed(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkout_ambiguous_transport_and_operation_failures_remain_unknown() {
|
||||||
|
use workdir::WorkdirError;
|
||||||
|
// Even refusal-looking text in an ambiguous transport error is not proof
|
||||||
|
// that the provider rejected the request before any effects.
|
||||||
|
for error in [
|
||||||
|
WorkdirError::Transport("denied (ambiguous response)".into()),
|
||||||
|
WorkdirError::OperationFailed,
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
crate::file_target::checked_error(error),
|
||||||
|
ToolError::ExecutionFailed(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkout_mismatched_readonly_results_are_unavailable_not_stale_or_unknown() {
|
||||||
|
use workdir::{CheckoutSearchResult, GlobResult, GrepResult};
|
||||||
|
let glob = CheckoutSearchResult::Glob(GlobResult {
|
||||||
|
paths: Vec::new(),
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
let grep = CheckoutSearchResult::Grep(GrepResult {
|
||||||
|
output: String::new(),
|
||||||
|
match_count: 0,
|
||||||
|
matched_files: 0,
|
||||||
|
truncated: false,
|
||||||
|
paths: Vec::new(),
|
||||||
|
});
|
||||||
|
assert_checkout_code(
|
||||||
|
crate::search_target::grep_result(glob).err().unwrap(),
|
||||||
|
"checkout_unavailable",
|
||||||
|
);
|
||||||
|
assert_checkout_code(
|
||||||
|
crate::search_target::glob_result(grep).err().unwrap(),
|
||||||
|
"checkout_unavailable",
|
||||||
|
);
|
||||||
|
assert_checkout_code(
|
||||||
|
crate::file_target::readonly_result_error("Read"),
|
||||||
|
"checkout_unavailable",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checkout_relative_path_boundaries() {
|
||||||
|
let base = WorkdirPath::new("src").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
relative_target(&base, "nested/a.txt", true)
|
||||||
|
.unwrap()
|
||||||
|
.as_str(),
|
||||||
|
"src/nested/a.txt"
|
||||||
|
);
|
||||||
|
assert_eq!(relative_target(&base, ".", false).unwrap(), base);
|
||||||
|
assert_eq!(
|
||||||
|
relative_target(&WorkdirPath::root(), "a.txt", true)
|
||||||
|
.unwrap()
|
||||||
|
.as_str(),
|
||||||
|
"a.txt"
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
"",
|
||||||
|
"..",
|
||||||
|
"../outside",
|
||||||
|
"nested/../../outside",
|
||||||
|
"/absolute",
|
||||||
|
"a\\..\\outside",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
relative_target(&base, bad, false).is_err(),
|
||||||
|
"accepted {bad}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(relative_target(&base, ".", true).is_err());
|
||||||
|
assert!(!is_beneath(
|
||||||
|
&base,
|
||||||
|
&WorkdirPath::new("src-other/a.txt").unwrap()
|
||||||
|
));
|
||||||
|
assert!(check_pattern("../*").is_err());
|
||||||
|
assert!(check_pattern("/tmp/*").is_err());
|
||||||
|
assert!(check_pattern("**/*.rs").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_read_and_normal_edit_share_hash_and_line_count() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
std::fs::write(fixture.dir.path().join("a.txt"), "alpha\nbeta\ngamma\n").unwrap();
|
||||||
|
let read = fixture
|
||||||
|
.native("main", "a.txt", "Read", json!({"offset": 1, "limit": 1}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(read.output.summary.contains("[2..2] of 3"));
|
||||||
|
assert_eq!(read.output.content.as_deref(), Some(" 2\tbeta\n"));
|
||||||
|
assert!(read.validator.is_some());
|
||||||
|
let selected = fixture.router.resolve(Some("main")).unwrap();
|
||||||
|
let tracker = fixture
|
||||||
|
.tracker
|
||||||
|
.scoped_attachment(&selected.alias, selected.generation);
|
||||||
|
assert_eq!(
|
||||||
|
tracker.observed_workdir_line_count(&WorkdirPath::new("a.txt").unwrap()),
|
||||||
|
Some(3)
|
||||||
|
);
|
||||||
|
fixture.normal("Edit", json!({"target_workdir": "main", "file_path": "a.txt", "old_string": "beta", "new_string": "changed"})).await.unwrap();
|
||||||
|
let write = fixture
|
||||||
|
.native("main", "a.txt", "Write", json!({"content": "new\n"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(write.output.summary.contains("Overwrote"));
|
||||||
|
assert_eq!(fixture.tracker.change_stat().deleted, 4);
|
||||||
|
let reread = fixture
|
||||||
|
.native("main", "a.txt", "Read", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(reread.output.content.as_deref(), Some(" 1\tnew\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_normal_read_then_native_edit_uniqueness_and_replace_all() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
std::fs::write(fixture.dir.path().join("a.txt"), "x x x\n").unwrap();
|
||||||
|
let before_read = fixture
|
||||||
|
.native(
|
||||||
|
"main",
|
||||||
|
"a.txt",
|
||||||
|
"Edit",
|
||||||
|
json!({"old_string": "x", "new_string": "y"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(before_read.to_string().contains("has not been read"));
|
||||||
|
fixture
|
||||||
|
.normal(
|
||||||
|
"Read",
|
||||||
|
json!({"target_workdir": "main", "file_path": "a.txt"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let not_unique = fixture
|
||||||
|
.native(
|
||||||
|
"main",
|
||||||
|
"a.txt",
|
||||||
|
"Edit",
|
||||||
|
json!({"old_string": "x", "new_string": "y"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_checkout_code(not_unique, "checkout_invalid");
|
||||||
|
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
|
||||||
|
let edit = fixture
|
||||||
|
.native(
|
||||||
|
"main",
|
||||||
|
"a.txt",
|
||||||
|
"Edit",
|
||||||
|
json!({"old_string": "x", "new_string": "y", "replace_all": true}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(edit.output.summary.contains("3 replacements"));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(),
|
||||||
|
"y y y\n"
|
||||||
|
);
|
||||||
|
fixture.normal("Write", json!({"target_workdir": "main", "file_path": "a.txt", "content": "normal after native"})).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_alias_and_generation_fences() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
fixture.attach("other");
|
||||||
|
std::fs::write(fixture.dir.path().join("a.txt"), "same\n").unwrap();
|
||||||
|
fixture
|
||||||
|
.native("main", "a.txt", "Read", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let wrong_alias = fixture
|
||||||
|
.native("other", "a.txt", "Write", json!({"content": "not allowed"}))
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(wrong_alias.to_string().contains("has not been read"));
|
||||||
|
let (old_generation, old_validator) = fixture.observation("main", "a.txt").await;
|
||||||
|
fixture
|
||||||
|
.router
|
||||||
|
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fixture.attach("main");
|
||||||
|
let error = execute_checkout_tool(
|
||||||
|
fixture.router.clone(),
|
||||||
|
fixture.tracker.clone(),
|
||||||
|
"main",
|
||||||
|
old_generation,
|
||||||
|
WorkdirPath::new("a.txt").unwrap(),
|
||||||
|
old_validator,
|
||||||
|
"Read",
|
||||||
|
json!({}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(error.to_string().contains("generation changed"));
|
||||||
|
assert_checkout_stale(error);
|
||||||
|
let fresh_generation_no_read = fixture
|
||||||
|
.native("main", "a.txt", "Write", json!({"content": "not allowed"}))
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
fresh_generation_no_read
|
||||||
|
.to_string()
|
||||||
|
.contains("has not been read")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_stale_validator_and_hash_are_independent() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
std::fs::write(fixture.dir.path().join("a.txt"), "old\n").unwrap();
|
||||||
|
fixture
|
||||||
|
.native("main", "a.txt", "Read", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (generation, validator) = fixture.observation("main", "a.txt").await;
|
||||||
|
std::fs::write(fixture.dir.path().join("a.txt"), "externally changed\n").unwrap();
|
||||||
|
let stale = execute_checkout_tool(
|
||||||
|
fixture.router.clone(),
|
||||||
|
fixture.tracker.clone(),
|
||||||
|
"main",
|
||||||
|
generation,
|
||||||
|
WorkdirPath::new("a.txt").unwrap(),
|
||||||
|
validator,
|
||||||
|
"Write",
|
||||||
|
json!({"content": "bad"}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_checkout_stale(stale);
|
||||||
|
let hash = fixture
|
||||||
|
.native("main", "a.txt", "Write", json!({"content": "bad"}))
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_checkout_stale(hash);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(),
|
||||||
|
"externally changed\n"
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_create_new_missing_parents_never_overwrites() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let created = fixture
|
||||||
|
.native(
|
||||||
|
"main",
|
||||||
|
".",
|
||||||
|
"Create",
|
||||||
|
json!({"path": "nested/deep/a.txt", "content": "new\n"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(created.output.summary.contains("Created"));
|
||||||
|
assert_eq!(
|
||||||
|
created.paths,
|
||||||
|
vec![WorkdirPath::new("nested/deep/a.txt").unwrap()]
|
||||||
|
);
|
||||||
|
let duplicate = fixture
|
||||||
|
.native(
|
||||||
|
"main",
|
||||||
|
".",
|
||||||
|
"Create",
|
||||||
|
json!({"path": "nested/deep/a.txt", "content": "overwrite"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(duplicate.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(fixture.dir.path().join("nested/deep/a.txt")).unwrap(),
|
||||||
|
"new\n"
|
||||||
|
);
|
||||||
|
// Successful creation updates the same tracker as normal Tool Write.
|
||||||
|
fixture.normal("Write", json!({"target_workdir": "main", "file_path": "nested/deep/a.txt", "content": "changed"})).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_rejects_argument_overrides_and_escapes() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
std::fs::write(fixture.dir.path().join("a.txt"), "safe").unwrap();
|
||||||
|
for (name, arguments) in [
|
||||||
|
("Read", json!({"file_path": "other"})),
|
||||||
|
("Read", json!({"target_workdir": "other"})),
|
||||||
|
("Write", json!({"content": "bad", "validator": []})),
|
||||||
|
("Create", json!({"path": "../outside", "content": "bad"})),
|
||||||
|
("Glob", json!({"pattern": "*", "path": "/absolute"})),
|
||||||
|
("Grep", json!({"pattern": "safe", "path": "../outside"})),
|
||||||
|
("Other", json!({})),
|
||||||
|
] {
|
||||||
|
let path = if ["Create", "Glob", "Grep"].contains(&name) {
|
||||||
|
"."
|
||||||
|
} else {
|
||||||
|
"a.txt"
|
||||||
|
};
|
||||||
|
let error = fixture
|
||||||
|
.native("main", path, name, arguments)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(error, ToolError::InvalidArgument(_)),
|
||||||
|
"{name}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_search_typed_paths_and_normal_rendering() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
std::fs::create_dir_all(fixture.dir.path().join("src/nested")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
fixture.dir.path().join("src/nested/a.txt"),
|
||||||
|
"context\nhit\nafter\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(fixture.dir.path().join("outside.txt"), "hit\n").unwrap();
|
||||||
|
let glob = fixture
|
||||||
|
.native(
|
||||||
|
"main",
|
||||||
|
"src",
|
||||||
|
"Glob",
|
||||||
|
json!({"pattern": "**/*.txt", "path": "nested"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
glob.paths,
|
||||||
|
vec![WorkdirPath::new("src/nested/a.txt").unwrap()]
|
||||||
|
);
|
||||||
|
let normal = fixture
|
||||||
|
.normal(
|
||||||
|
"Glob",
|
||||||
|
json!({"target_workdir": "main", "pattern": "**/*.txt", "path": "src/nested"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(glob.output.summary, normal.summary);
|
||||||
|
assert_eq!(glob.output.content, normal.content);
|
||||||
|
for mode in ["files_with_matches", "content", "count"] {
|
||||||
|
let grep = fixture
|
||||||
|
.native(
|
||||||
|
"main",
|
||||||
|
"src",
|
||||||
|
"Grep",
|
||||||
|
json!({"pattern": "hit", "output_mode": mode, "-C": 1}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
grep.paths,
|
||||||
|
vec![WorkdirPath::new("src/nested/a.txt").unwrap()]
|
||||||
|
);
|
||||||
|
let normal = fixture.normal("Grep", json!({"target_workdir": "main", "path": "src", "pattern": "hit", "output_mode": mode, "-C": 1})).await.unwrap();
|
||||||
|
assert_eq!(grep.output.summary, normal.summary);
|
||||||
|
assert_eq!(grep.output.content, normal.content);
|
||||||
|
assert!(!grep.output.content.unwrap().contains("outside.txt"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-19
@@ -9,7 +9,7 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::error::ToolsError;
|
use crate::error::ToolsError;
|
||||||
use crate::tracker::Tracker;
|
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 \
|
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 \
|
`old_string` must be unique in the file; set `replace_all: true` to replace \
|
||||||
@@ -56,8 +56,29 @@ impl Tool for EditTool {
|
|||||||
.tracker
|
.tracker
|
||||||
.scoped_attachment(&selected.alias, selected.generation);
|
.scoped_attachment(&selected.alias, selected.generation);
|
||||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||||
tracing::debug!(path = %path, replace_all = params.replace_all, "Edit");
|
Ok(execute_edit(
|
||||||
|
crate::file_target::FileTarget {
|
||||||
|
session: selected.session,
|
||||||
|
path,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
tracker,
|
||||||
|
params,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
if params.old_string.is_empty() {
|
||||||
return Err(ToolError::InvalidArgument(
|
return Err(ToolError::InvalidArgument(
|
||||||
"old_string must not be empty".into(),
|
"old_string must not be empty".into(),
|
||||||
@@ -68,30 +89,25 @@ impl Tool for EditTool {
|
|||||||
"old_string and new_string are identical".into(),
|
"old_string and new_string are identical".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mutation_key = PathBuf::from(path.as_str());
|
let mutation_key = PathBuf::from(path.as_str());
|
||||||
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||||
let expected_hash = tracker.expected_workdir_hash(&path)?;
|
let expected_hash = tracker.expected_workdir_hash(path)?;
|
||||||
let result = selected
|
let (result, validator) = target
|
||||||
.session
|
.edit(
|
||||||
.edit(EditRequest {
|
params.old_string.clone(),
|
||||||
path: path.clone(),
|
params.new_string.clone(),
|
||||||
old_string: params.old_string.clone(),
|
params.replace_all,
|
||||||
new_string: params.new_string.clone(),
|
|
||||||
replace_all: params.replace_all,
|
|
||||||
expected_hash,
|
expected_hash,
|
||||||
})
|
)
|
||||||
.await
|
.await?;
|
||||||
.map_err(ToolsError::from)?;
|
|
||||||
let replacements = result.replacements;
|
let replacements = result.replacements;
|
||||||
tracker.record_workdir_edit(
|
tracker.record_workdir_edit(
|
||||||
&path,
|
path,
|
||||||
result.content_hash,
|
result.content_hash,
|
||||||
replacements,
|
replacements,
|
||||||
params.new_string.lines().count(),
|
params.new_string.lines().count(),
|
||||||
params.old_string.lines().count(),
|
params.old_string.lines().count(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let summary = format!(
|
let summary = format!(
|
||||||
"Edited {} ({} replacement{})",
|
"Edited {} ({} replacement{})",
|
||||||
path,
|
path,
|
||||||
@@ -99,14 +115,16 @@ impl Tool for EditTool {
|
|||||||
if replacements == 1 { "" } else { "s" }
|
if replacements == 1 { "" } else { "s" }
|
||||||
);
|
);
|
||||||
let preview = make_preview(¶ms.new_string, ¶ms.new_string);
|
let preview = make_preview(¶ms.new_string, ¶ms.new_string);
|
||||||
|
Ok(crate::checkout::CheckoutToolOutput {
|
||||||
Ok(ToolOutput {
|
output: ToolOutput {
|
||||||
summary,
|
summary,
|
||||||
content: Some(preview),
|
content: Some(preview),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
paths: Vec::new(),
|
||||||
|
validator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a small line-numbered snippet centered on the first occurrence of
|
/// Build a small line-numbered snippet centered on the first occurrence of
|
||||||
/// `needle` in `text`. Shows ±3 surrounding lines.
|
/// `needle` in `text`. Shows ±3 surrounding lines.
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ impl From<ToolsError> for ToolError {
|
|||||||
| workdir::WorkdirError::Unavailable(_)
|
| workdir::WorkdirError::Unavailable(_)
|
||||||
| workdir::WorkdirError::OperationFailed
|
| workdir::WorkdirError::OperationFailed
|
||||||
| workdir::WorkdirError::Transport(_)
|
| workdir::WorkdirError::Transport(_)
|
||||||
| workdir::WorkdirError::Conflict(_),
|
| workdir::WorkdirError::Conflict(_)
|
||||||
|
| workdir::WorkdirError::OutcomeUnknown(_),
|
||||||
) => ToolError::ExecutionFailed(err.to_string()),
|
) => ToolError::ExecutionFailed(err.to_string()),
|
||||||
ToolsError::FileSystem(_)
|
ToolsError::FileSystem(_)
|
||||||
| ToolsError::WorkdirSession(_)
|
| ToolsError::WorkdirSession(_)
|
||||||
@@ -62,6 +63,22 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use workdir::http::{WorkdirTransportError, WorkdirTransportErrorCode};
|
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]
|
#[test]
|
||||||
fn local_workdir_content_conflict_is_retryable_execution_failure() {
|
fn local_workdir_content_conflict_is_retryable_execution_failure() {
|
||||||
let error = ToolError::from(ToolsError::WorkdirSession(
|
let error = ToolError::from(ToolsError::WorkdirSession(
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
//! Shared provider dispatch for Tool and checkout file processing.
|
||||||
|
use agen::tool::ToolError;
|
||||||
|
use workdir::{
|
||||||
|
CheckoutOperation, CheckoutOutput, CheckoutRequest, ContentHash, EditRequest, EditResult,
|
||||||
|
ReadRequest, ReadResult, WorkdirPath, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::ToolsError;
|
||||||
|
|
||||||
|
pub(crate) struct FileTarget {
|
||||||
|
pub session: WorkdirSessionHandle,
|
||||||
|
pub path: WorkdirPath,
|
||||||
|
pub validator: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileTarget {
|
||||||
|
async fn checked(
|
||||||
|
&self,
|
||||||
|
operation: CheckoutOperation,
|
||||||
|
) -> Result<(CheckoutOutput, Option<Vec<u8>>), ToolError> {
|
||||||
|
let result = self
|
||||||
|
.session
|
||||||
|
.checkout_execute(CheckoutRequest {
|
||||||
|
target: self.path.clone(),
|
||||||
|
validator: self
|
||||||
|
.validator
|
||||||
|
.clone()
|
||||||
|
.expect("checked dispatch requires validator"),
|
||||||
|
operation,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(checked_error)?;
|
||||||
|
Ok((result.output, Some(result.observation.validator)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read(
|
||||||
|
&self,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
max_bytes: usize,
|
||||||
|
) -> Result<(ReadResult, Option<Vec<u8>>), ToolError> {
|
||||||
|
if self.validator.is_some() {
|
||||||
|
let (output, validator) = self
|
||||||
|
.checked(CheckoutOperation::Read {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
max_bytes,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
match output {
|
||||||
|
CheckoutOutput::Read(result) => Ok((result, validator)),
|
||||||
|
_ => Err(readonly_result_error("Read")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let result = self
|
||||||
|
.session
|
||||||
|
.read(ReadRequest {
|
||||||
|
path: self.path.clone(),
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
max_bytes,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ToolsError::from)?;
|
||||||
|
Ok((result, None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit(
|
||||||
|
&self,
|
||||||
|
old_string: String,
|
||||||
|
new_string: String,
|
||||||
|
replace_all: bool,
|
||||||
|
expected_hash: ContentHash,
|
||||||
|
) -> Result<(EditResult, Option<Vec<u8>>), ToolError> {
|
||||||
|
if self.validator.is_some() {
|
||||||
|
let (output, validator) = self
|
||||||
|
.checked(CheckoutOperation::Edit {
|
||||||
|
old_string,
|
||||||
|
new_string,
|
||||||
|
replace_all,
|
||||||
|
expected_hash,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
match output {
|
||||||
|
CheckoutOutput::Edit(result) => Ok((result, validator)),
|
||||||
|
_ => Err(unknown_result("Edit")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let result = self
|
||||||
|
.session
|
||||||
|
.edit(EditRequest {
|
||||||
|
path: self.path.clone(),
|
||||||
|
old_string,
|
||||||
|
new_string,
|
||||||
|
replace_all,
|
||||||
|
expected_hash,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ToolsError::from)?;
|
||||||
|
Ok((result, None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write(
|
||||||
|
&self,
|
||||||
|
content: Vec<u8>,
|
||||||
|
expected_hash: Option<ContentHash>,
|
||||||
|
create_path: Option<WorkdirPath>,
|
||||||
|
) -> Result<(WriteResult, Option<Vec<u8>>), ToolError> {
|
||||||
|
if self.validator.is_some() {
|
||||||
|
let operation = match create_path {
|
||||||
|
Some(path) => CheckoutOperation::Create { path, content },
|
||||||
|
None => CheckoutOperation::Write {
|
||||||
|
content,
|
||||||
|
expected_hash: expected_hash
|
||||||
|
.expect("existing checkout Write requires prior read"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let (output, validator) = self.checked(operation).await?;
|
||||||
|
match output {
|
||||||
|
CheckoutOutput::Write(result) => Ok((result, validator)),
|
||||||
|
_ => Err(unknown_result("Write/Create")),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let result = self
|
||||||
|
.session
|
||||||
|
.write(WriteRequest {
|
||||||
|
path: self.path.clone(),
|
||||||
|
content,
|
||||||
|
expected_hash,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ToolsError::from)?;
|
||||||
|
Ok((result, None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
|
||||||
|
use workdir::WorkdirError;
|
||||||
|
let code = match &error {
|
||||||
|
WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale",
|
||||||
|
WorkdirError::Denied { .. }
|
||||||
|
| WorkdirError::OutOfScope { .. }
|
||||||
|
| WorkdirError::ReadOnly { .. }
|
||||||
|
| WorkdirError::Unsupported { .. }
|
||||||
|
| WorkdirError::UnsupportedOperation { .. }
|
||||||
|
| WorkdirError::SymlinkOutOfScope { .. }
|
||||||
|
| WorkdirError::SymlinkDirectoryNotTraversed { .. } => "checkout_denied",
|
||||||
|
WorkdirError::InvalidArgument { .. }
|
||||||
|
| WorkdirError::InvalidPath { .. }
|
||||||
|
| WorkdirError::UnknownCommand { .. }
|
||||||
|
| WorkdirError::RelativePath { .. }
|
||||||
|
| WorkdirError::BrokenSymlink { .. }
|
||||||
|
| WorkdirError::SymlinkTargetIsDirectory { .. }
|
||||||
|
| WorkdirError::IsDirectory { .. }
|
||||||
|
| WorkdirError::InvalidGlob { .. }
|
||||||
|
| WorkdirError::InvalidRegex { .. } => "checkout_invalid",
|
||||||
|
WorkdirError::Unavailable { .. } | WorkdirError::SessionClosed => "checkout_unavailable",
|
||||||
|
// IO/transport failures may arrive after effects have begun. Do not infer
|
||||||
|
// no-effects from their text or strip explicit OutcomeUnknown evidence.
|
||||||
|
WorkdirError::Io { .. }
|
||||||
|
| WorkdirError::Transport { .. }
|
||||||
|
| WorkdirError::OutcomeUnknown { .. }
|
||||||
|
| WorkdirError::OperationFailed => {
|
||||||
|
return ToolsError::from(error).into();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ToolError::StructuredConflict {
|
||||||
|
code: code.into(),
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn readonly_result_error(operation: &str) -> ToolError {
|
||||||
|
checked_error(workdir::WorkdirError::Unavailable(format!(
|
||||||
|
"provider returned an invalid readonly checkout {operation} result"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unknown_result(operation: &str) -> ToolError {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"Outcome unknown: provider returned wrong checkout {operation} result after mutation; do not automatically retry"
|
||||||
|
))
|
||||||
|
}
|
||||||
+26
-11
@@ -11,15 +11,15 @@ use crate::ToolsError;
|
|||||||
const RESULT_LIMIT: usize = 1000;
|
const RESULT_LIMIT: usize = 1000;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct GlobParams {
|
pub(crate) struct GlobParams {
|
||||||
/// Worker-local alias of the Workdir attachment to use.
|
/// Worker-local alias of the Workdir attachment to use.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
target_workdir: Option<String>,
|
pub target_workdir: Option<String>,
|
||||||
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
|
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
|
||||||
pattern: String,
|
pub pattern: String,
|
||||||
/// Logical Workdir-relative directory. Defaults to the Workdir root.
|
/// Logical Workdir-relative directory. Defaults to the Workdir root.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
path: Option<String>,
|
pub path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobTool {
|
struct GlobTool {
|
||||||
@@ -44,17 +44,29 @@ impl Tool for GlobTool {
|
|||||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
||||||
None => WorkdirPath::root(),
|
None => WorkdirPath::root(),
|
||||||
};
|
};
|
||||||
let pattern = params.pattern;
|
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");
|
tracing::debug!(%pattern, %path, "Glob");
|
||||||
let result = selected
|
let result = target
|
||||||
.session
|
|
||||||
.glob(GlobRequest {
|
.glob(GlobRequest {
|
||||||
pattern: pattern.clone(),
|
pattern: pattern.clone(),
|
||||||
path,
|
path,
|
||||||
limit: RESULT_LIMIT,
|
limit: RESULT_LIMIT,
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
.map_err(ToolsError::from)?;
|
|
||||||
let mut body = result
|
let mut body = result
|
||||||
.paths
|
.paths
|
||||||
.iter()
|
.iter()
|
||||||
@@ -74,13 +86,16 @@ impl Tool for GlobTool {
|
|||||||
} else {
|
} else {
|
||||||
format!("Found {} file(s) matching {pattern}", result.paths.len())
|
format!("Found {} file(s) matching {pattern}", result.paths.len())
|
||||||
};
|
};
|
||||||
Ok(ToolOutput {
|
Ok(crate::checkout::CheckoutToolOutput {
|
||||||
|
output: ToolOutput {
|
||||||
summary,
|
summary,
|
||||||
content: (!body.is_empty()).then_some(body),
|
content: (!body.is_empty()).then_some(body),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
paths: result.paths,
|
||||||
|
validator: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn glob_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
pub fn glob_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||||
routed_glob_tool(crate::routing::singleton_router(session))
|
routed_glob_tool(crate::routing::singleton_router(session))
|
||||||
|
|||||||
+27
-11
@@ -22,16 +22,16 @@ enum OutputMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct GrepParams {
|
pub(crate) struct GrepParams {
|
||||||
/// Worker-local alias of the Workdir attachment to use.
|
/// Worker-local alias of the Workdir attachment to use.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
target_workdir: Option<String>,
|
target_workdir: Option<String>,
|
||||||
pattern: String,
|
pattern: String,
|
||||||
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
|
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
path: Option<String>,
|
pub path: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
glob: Option<String>,
|
pub glob: Option<String>,
|
||||||
#[serde(default, rename = "type")]
|
#[serde(default, rename = "type")]
|
||||||
file_type: Option<String>,
|
file_type: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -70,10 +70,25 @@ impl Tool for GrepTool {
|
|||||||
params.target_workdir.as_deref(),
|
params.target_workdir.as_deref(),
|
||||||
workdir::WorkdirSessionCapability::Grep,
|
workdir::WorkdirSessionCapability::Grep,
|
||||||
)?;
|
)?;
|
||||||
let path = match params.path {
|
let path = match params.path.as_deref() {
|
||||||
Some(path) => WorkdirPath::new_scoped(&path).map_err(ToolsError::from)?,
|
Some(path) => WorkdirPath::new_scoped(path).map_err(ToolsError::from)?,
|
||||||
None => WorkdirPath::root(),
|
None => WorkdirPath::root(),
|
||||||
};
|
};
|
||||||
|
Ok(execute_grep(
|
||||||
|
crate::search_target::SearchTarget::tool(selected.session),
|
||||||
|
path,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
let mode = match params.output_mode.unwrap_or_default() {
|
||||||
OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches,
|
OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches,
|
||||||
OutputMode::Content => GrepOutputMode::Content,
|
OutputMode::Content => GrepOutputMode::Content,
|
||||||
@@ -84,8 +99,7 @@ impl Tool for GrepTool {
|
|||||||
.map(|context| (context, context))
|
.map(|context| (context, context))
|
||||||
.unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0)));
|
.unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0)));
|
||||||
let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||||
let result = selected
|
let result = target
|
||||||
.session
|
|
||||||
.grep(GrepRequest {
|
.grep(GrepRequest {
|
||||||
pattern: params.pattern,
|
pattern: params.pattern,
|
||||||
path,
|
path,
|
||||||
@@ -99,8 +113,7 @@ impl Tool for GrepTool {
|
|||||||
limit: head_limit,
|
limit: head_limit,
|
||||||
offset: params.offset.unwrap_or(0),
|
offset: params.offset.unwrap_or(0),
|
||||||
})
|
})
|
||||||
.await
|
.await?;
|
||||||
.map_err(ToolsError::from)?;
|
|
||||||
|
|
||||||
let summary = if result.match_count == 0 {
|
let summary = if result.match_count == 0 {
|
||||||
match mode {
|
match mode {
|
||||||
@@ -127,13 +140,16 @@ impl Tool for GrepTool {
|
|||||||
} else {
|
} else {
|
||||||
summary
|
summary
|
||||||
};
|
};
|
||||||
Ok(ToolOutput {
|
Ok(crate::checkout::CheckoutToolOutput {
|
||||||
|
output: ToolOutput {
|
||||||
summary,
|
summary,
|
||||||
content: (!result.output.is_empty()).then_some(result.output),
|
content: (!result.output.is_empty()).then_some(result.output),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
paths: result.paths,
|
||||||
|
validator: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||||
routed_grep_tool(crate::routing::singleton_router(session))
|
routed_grep_tool(crate::routing::singleton_router(session))
|
||||||
|
|||||||
@@ -9,20 +9,24 @@
|
|||||||
//! and command capability, while the Runtime process and OS user remain the
|
//! and command capability, while the Runtime process and OS user remain the
|
||||||
//! trusted execution boundary.
|
//! trusted execution boundary.
|
||||||
|
|
||||||
|
pub mod checkout;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod tracker;
|
pub mod tracker;
|
||||||
|
|
||||||
mod bash;
|
mod bash;
|
||||||
mod edit;
|
mod edit;
|
||||||
|
mod file_target;
|
||||||
mod glob;
|
mod glob;
|
||||||
mod grep;
|
mod grep;
|
||||||
mod read;
|
mod read;
|
||||||
mod routing;
|
mod routing;
|
||||||
|
mod search_target;
|
||||||
mod view_image;
|
mod view_image;
|
||||||
mod web;
|
mod web;
|
||||||
mod write;
|
mod write;
|
||||||
|
|
||||||
pub use bash::bash_tool;
|
pub use bash::bash_tool;
|
||||||
|
pub use checkout::{CheckoutToolOutput, execute_checkout_tool};
|
||||||
pub use edit::edit_tool;
|
pub use edit::edit_tool;
|
||||||
pub use error::ToolsError;
|
pub use error::ToolsError;
|
||||||
pub use glob::glob_tool;
|
pub use glob::glob_tool;
|
||||||
|
|||||||
+78
-51
@@ -8,7 +8,7 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::error::ToolsError;
|
use crate::error::ToolsError;
|
||||||
use crate::tracker::Tracker;
|
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. \
|
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). \
|
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
||||||
@@ -56,24 +56,31 @@ impl Tool for ReadTool {
|
|||||||
let tracker = self
|
let tracker = self
|
||||||
.tracker
|
.tracker
|
||||||
.scoped_attachment(&selected.alias, selected.generation);
|
.scoped_attachment(&selected.alias, selected.generation);
|
||||||
|
Ok(execute_read(
|
||||||
|
crate::file_target::FileTarget {
|
||||||
|
session: selected.session,
|
||||||
|
path: WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
tracker,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 offset = params.offset.unwrap_or(0);
|
||||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||||
|
let path = &target.path;
|
||||||
let path = WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?;
|
|
||||||
tracing::debug!(path = %path, offset, limit, "Read");
|
tracing::debug!(path = %path, offset, limit, "Read");
|
||||||
|
let (result, validator) = target.read(offset, limit, PROVIDER_BYTE_LIMIT).await?;
|
||||||
let result = selected
|
tracker.record_workdir_observation(path, result.content_hash, result.total_lines);
|
||||||
.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 text = String::from_utf8_lossy(&result.bytes).into_owned();
|
||||||
let rendered = render_provider_read(
|
let rendered = render_provider_read(
|
||||||
&text,
|
&text,
|
||||||
@@ -81,27 +88,28 @@ impl Tool for ReadTool {
|
|||||||
result.total_lines,
|
result.total_lines,
|
||||||
result.truncated,
|
result.truncated,
|
||||||
);
|
);
|
||||||
|
|
||||||
let summary = if rendered.truncated {
|
let summary = if rendered.truncated {
|
||||||
format!(
|
format!(
|
||||||
"Read {} line(s) [{}..{}] of {} from {}",
|
"Read {} line(s) [{}..{}] of {} from {}",
|
||||||
rendered.line_count,
|
rendered.line_count,
|
||||||
offset + 1,
|
offset.saturating_add(1),
|
||||||
offset + rendered.line_count,
|
offset.saturating_add(rendered.line_count),
|
||||||
rendered.total_lines,
|
rendered.total_lines,
|
||||||
path
|
path
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("Read {} line(s) from {}", rendered.line_count, path)
|
format!("Read {} line(s) from {}", rendered.line_count, path)
|
||||||
};
|
};
|
||||||
|
Ok(crate::checkout::CheckoutToolOutput {
|
||||||
Ok(ToolOutput {
|
output: ToolOutput {
|
||||||
summary,
|
summary,
|
||||||
content: Some(rendered.body),
|
content: Some(rendered.body),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
paths: Vec::new(),
|
||||||
|
validator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
struct Rendered {
|
struct Rendered {
|
||||||
body: String,
|
body: String,
|
||||||
@@ -116,43 +124,36 @@ fn render_provider_read(
|
|||||||
total_lines: usize,
|
total_lines: usize,
|
||||||
truncated: bool,
|
truncated: bool,
|
||||||
) -> Rendered {
|
) -> Rendered {
|
||||||
use std::fmt::Write as _;
|
const MARKER: &str = "\n[truncated at rendered output byte limit]\n";
|
||||||
let lines = text.lines().collect::<Vec<_>>();
|
// The numbered presentation is bounded too, not only provider source bytes.
|
||||||
let mut body = String::with_capacity(text.len().saturating_add(lines.len() * 8));
|
// This fits the native wire limit even with JSON's worst-case escaping.
|
||||||
for (index, line) in lines.iter().enumerate() {
|
let budget = PROVIDER_BYTE_LIMIT - MARKER.len();
|
||||||
let _ = writeln!(&mut body, "{:>6}\t{}", start_line + index + 1, line);
|
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;
|
||||||
}
|
}
|
||||||
Rendered {
|
if numbered.len() > remaining {
|
||||||
body,
|
let mut end = remaining;
|
||||||
line_count: lines.len(),
|
while !numbered.is_char_boundary(end) {
|
||||||
total_lines,
|
end -= 1;
|
||||||
truncated: start_line > 0 || truncated,
|
|
||||||
}
|
}
|
||||||
|
body.push_str(&numbered[..end]);
|
||||||
|
body.push_str(MARKER);
|
||||||
|
output_truncated = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
body.push_str(&numbered);
|
||||||
/// 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 {
|
Rendered {
|
||||||
body,
|
body,
|
||||||
line_count,
|
line_count,
|
||||||
total_lines,
|
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())
|
(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]
|
#[tokio::test]
|
||||||
async fn read_tool_basic_records_history() {
|
async fn read_tool_basic_records_history() {
|
||||||
let (dir, fs, tracker) = setup();
|
let (dir, fs, tracker) = setup();
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! Provider dispatch shared by native and ordinary search processing.
|
||||||
|
//! Request construction/defaults and rendering remain in Glob/Grep; this only
|
||||||
|
//! selects the provider surface and verifies the returned readonly result kind.
|
||||||
|
use agen::tool::ToolError;
|
||||||
|
use workdir::{
|
||||||
|
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult, GlobRequest, GlobResult,
|
||||||
|
GrepRequest, GrepResult, WorkdirSessionHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{ToolsError, file_target::checked_error};
|
||||||
|
|
||||||
|
pub(crate) struct SearchTarget {
|
||||||
|
session: WorkdirSessionHandle,
|
||||||
|
checkout: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchTarget {
|
||||||
|
pub fn tool(session: WorkdirSessionHandle) -> Self {
|
||||||
|
Self {
|
||||||
|
session,
|
||||||
|
checkout: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkout(session: WorkdirSessionHandle) -> Self {
|
||||||
|
Self {
|
||||||
|
session,
|
||||||
|
checkout: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn glob(&self, request: GlobRequest) -> Result<GlobResult, ToolError> {
|
||||||
|
if self.checkout {
|
||||||
|
let result = self
|
||||||
|
.session
|
||||||
|
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
|
||||||
|
request,
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.map_err(checked_error)?;
|
||||||
|
glob_result(result)
|
||||||
|
} else {
|
||||||
|
self.session
|
||||||
|
.glob(request)
|
||||||
|
.await
|
||||||
|
.map_err(|error| ToolsError::from(error).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn grep(&self, request: GrepRequest) -> Result<GrepResult, ToolError> {
|
||||||
|
if self.checkout {
|
||||||
|
let result = self
|
||||||
|
.session
|
||||||
|
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
|
||||||
|
request,
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.map_err(checked_error)?;
|
||||||
|
grep_result(result)
|
||||||
|
} else {
|
||||||
|
self.session
|
||||||
|
.grep(request)
|
||||||
|
.await
|
||||||
|
.map_err(|error| ToolsError::from(error).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn glob_result(result: CheckoutSearchResult) -> Result<GlobResult, ToolError> {
|
||||||
|
match result {
|
||||||
|
CheckoutSearchResult::Glob(result) => Ok(result),
|
||||||
|
_ => Err(crate::file_target::readonly_result_error("Glob")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn grep_result(result: CheckoutSearchResult) -> Result<GrepResult, ToolError> {
|
||||||
|
match result {
|
||||||
|
CheckoutSearchResult::Grep(result) => Ok(result),
|
||||||
|
_ => Err(crate::file_target::readonly_result_error("Grep")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -256,6 +256,25 @@ impl Tracker {
|
|||||||
.copied()
|
.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) {
|
pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) {
|
||||||
let key = self.key(PathBuf::from(path.as_str()));
|
let key = self.key(PathBuf::from(path.as_str()));
|
||||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
|||||||
+49
-23
@@ -9,10 +9,7 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::error::ToolsError;
|
use crate::error::ToolsError;
|
||||||
use crate::tracker::Tracker;
|
use crate::tracker::Tracker;
|
||||||
use workdir::{
|
use workdir::{StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||||
StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter,
|
|
||||||
WriteRequest,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DESCRIPTION: &str = "Create a new file or overwrite an existing one in the selected Workdir attachment. \
|
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 \
|
Missing parent directories within scope are created automatically. Existing files must have been read first \
|
||||||
@@ -53,34 +50,60 @@ impl Tool for WriteTool {
|
|||||||
.tracker
|
.tracker
|
||||||
.scoped_attachment(&selected.alias, selected.generation);
|
.scoped_attachment(&selected.alias, selected.generation);
|
||||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||||
tracing::debug!(path = %path, bytes = params.content.len(), "Write");
|
Ok(execute_write(
|
||||||
|
crate::file_target::FileTarget {
|
||||||
|
session: selected.session,
|
||||||
|
path,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
tracker,
|
||||||
|
params.content,
|
||||||
|
None,
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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_key = PathBuf::from(path.as_str());
|
||||||
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||||
let expected_hash = match selected
|
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
|
.session
|
||||||
.stat(StatRequest { path: path.clone() })
|
.stat(StatRequest { path: path.clone() })
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => Some(tracker.expected_workdir_hash(&path)?),
|
Ok(_) => Some(tracker.expected_workdir_hash(path)?),
|
||||||
Err(WorkdirError::NotFound(_)) => None,
|
Err(WorkdirError::NotFound(_)) => None,
|
||||||
Err(error) => return Err(ToolsError::from(error).into()),
|
Err(error) => return Err(ToolsError::from(error).into()),
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
let old_line_count = tracker.observed_workdir_line_count(path).unwrap_or(0);
|
||||||
let old_line_count = tracker.observed_workdir_line_count(&path).unwrap_or(0);
|
let (outcome, validator) = target
|
||||||
let outcome = selected
|
.write(
|
||||||
.session
|
content.as_bytes().to_vec(),
|
||||||
.write(WriteRequest {
|
|
||||||
path: path.clone(),
|
|
||||||
content: params.content.as_bytes().to_vec(),
|
|
||||||
expected_hash,
|
expected_hash,
|
||||||
})
|
create_path.clone(),
|
||||||
.await
|
)
|
||||||
.map_err(ToolsError::from)?;
|
.await?;
|
||||||
|
tracker.record_change(content.lines().count(), old_line_count);
|
||||||
tracker.record_change(params.content.lines().count(), old_line_count);
|
tracker.record_workdir_content(path, content.as_bytes());
|
||||||
tracker.record_workdir_content(&path, params.content.as_bytes());
|
|
||||||
|
|
||||||
let summary = format!(
|
let summary = format!(
|
||||||
"{} {} ({} bytes)",
|
"{} {} ({} bytes)",
|
||||||
if outcome.created {
|
if outcome.created {
|
||||||
@@ -91,13 +114,16 @@ impl Tool for WriteTool {
|
|||||||
path,
|
path,
|
||||||
outcome.bytes_written
|
outcome.bytes_written
|
||||||
);
|
);
|
||||||
Ok(ToolOutput {
|
Ok(crate::checkout::CheckoutToolOutput {
|
||||||
|
output: ToolOutput {
|
||||||
summary,
|
summary,
|
||||||
content: None,
|
content: None,
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
paths: create_path.into_iter().collect(),
|
||||||
|
validator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Factory for the `Write` tool bound to one compatibility session.
|
/// Factory for the `Write` tool bound to one compatibility session.
|
||||||
pub fn write_tool(session: WorkdirSessionHandle, tracker: Tracker) -> ToolDefinition {
|
pub fn write_tool(session: WorkdirSessionHandle, tracker: Tracker) -> ToolDefinition {
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Request-time checkout provider boundary
|
||||||
|
|
||||||
|
`workdir::checkout` (also crate-root reexports) exposes `CheckoutObservation`,
|
||||||
|
`CheckoutRequest`, `CheckoutOperation`, `CheckoutOutput`, and `CheckoutResult`.
|
||||||
|
`WorkdirSession::checkout_observe(path)` defaults to unsupported; execution also
|
||||||
|
fails closed by default. Observation is metadata-only, with no recursive tree
|
||||||
|
walk. Directory discovery/search remains the ordinary bounded List/Glob/Grep
|
||||||
|
provider engine. GrepResult.paths carries exact typed provider paths appearing
|
||||||
|
in the returned report, respecting its offset/limit/truncation; filenames are
|
||||||
|
never parsed from rendered text. List/Glob/Grep skip unrepresentable non-UTF8
|
||||||
|
paths, preserving valid Unicode exactly without normalization. Native callers supply their own attachment generation/namespace
|
||||||
|
fence; this provider validator is not a substitute for that fence.
|
||||||
|
|
||||||
|
* Read: line `offset`, `limit`, `max_bytes`; same `ReadResult` (bytes, start line,
|
||||||
|
total lines, full-source content hash, truncation) as normal Tools. Formatting
|
||||||
|
line numbers and recording the returned hash in caller read-history are caller
|
||||||
|
responsibilities. The validator is **not** permission to bypass read-before-write.
|
||||||
|
* Write: replace existing file with `content` and required `expected_hash`.
|
||||||
|
* Edit: existing UTF-8 file, unique `old_string` unless `replace_all`, required
|
||||||
|
`expected_hash`; same match/replacement/result-size checks as Tools.
|
||||||
|
* Create: target must be an observed directory; `path` is **checkout-root
|
||||||
|
relative**, strictly below target, not target-relative. No existing file can
|
||||||
|
be overwritten. Intermediate directories and destination must be writable.
|
||||||
|
* Every result observation describes the bound request target. Create returns
|
||||||
|
the updated **parent directory** observation from the retained pinned target,
|
||||||
|
not the created destination. Read returns the read state's observation.
|
||||||
|
|
||||||
|
## Shared implementation
|
||||||
|
|
||||||
|
`fs_operation::CheckedTarget` is an `FsAccessPolicy` adapter around pinned target,
|
||||||
|
save-parent, and provider-root descriptors. It calls the ordinary
|
||||||
|
`run_read_bounded`, `run_edit`, and `run_write` functions, including full-source
|
||||||
|
hashing, UTF-8/string matching, bounds, and atomic temp-save primitive; there is
|
||||||
|
no second content-operation engine. Shared text reads now check descriptor
|
||||||
|
metadata before/after reading. Validator bytes encode device/inode/size/mode/
|
||||||
|
mtime/ctime with nanoseconds, excluding atime and host paths; same-content inode
|
||||||
|
replacement is distinguishable. Successful native Read rechecks the named target
|
||||||
|
and pinned descriptor before returning hash and observation.
|
||||||
|
|
||||||
|
Local sessions serialize checked observe/execute and ordinary trait Read/Write/
|
||||||
|
Edit using one nonqueueing session lock. Expected validator comparison happens
|
||||||
|
inside that boundary. The adapter retains handles across reading and save,
|
||||||
|
rechecks named target + save-parent identity at save boundary, and writes a new
|
||||||
|
temporary inode then atomically renames it, isolating existing hardlink aliases.
|
||||||
|
External sessions retain the approved descriptor root after pathname rename or
|
||||||
|
replacement. Checkout resolution rejects symlinks, special files, mount crossings,
|
||||||
|
absolute/traversal paths and unavailable descriptor primitives, rather than
|
||||||
|
weakening confinement. This currently requires Linux `openat2` and `/proc/self/fd`.
|
||||||
|
Ordinary Tools preserve their existing explicit symlink-policy behavior.
|
||||||
|
|
||||||
|
Scoped sessions use their existing scope mutex, current validity/source
|
||||||
|
capability checks, provider authorization and write leases; Create also checks
|
||||||
|
intermediate paths. Observation capabilities are attenuated per path and live
|
||||||
|
write leases, never broadened to the whole attachment. ReadOnly removes mutation
|
||||||
|
capabilities and rejects mutations. Routed operations use existing active-op
|
||||||
|
accounting and detach fencing. Remote HTTP forwards exact operation/result types
|
||||||
|
and validates returned operation/path pairing. External DTOs validate checkout
|
||||||
|
path/validator/content/result bounds. No provider endpoint or host root is added
|
||||||
|
to the observation.
|
||||||
|
|
||||||
|
## Effects and limits
|
||||||
|
|
||||||
|
Absent paths during checkout observation return `NotFound` (including a missing
|
||||||
|
ancestor or a non-directory ancestor); native discovery can return no route.
|
||||||
|
An execution against a previously observed target that disappeared instead
|
||||||
|
returns `Conflict`, consistently at pinning, shared-engine, and final-check
|
||||||
|
boundaries. Conflict means stale validator/hash/name or existing Create destination before
|
||||||
|
save effects. `WorkdirError::OutcomeUnknown` has a separate sanitized wire code
|
||||||
|
`outcome_unknown` (HTTP 500), never an automatic retry. Partial parent creation,
|
||||||
|
possible commit failure, or failed post-save observation is outcome unknown.
|
||||||
|
Remote lost/malformed/mismatched mutation responses are also outcome unknown.
|
||||||
|
Create failures conservatively report unknown once parent creation may begin,
|
||||||
|
even if a particular failure happened without a persisted effect.
|
||||||
|
|
||||||
|
Bounds: path <=4096 UTF-8 bytes/128 components, validator <=256 bytes; Read source
|
||||||
|
64 MiB, retained response <=1 MiB (or stricter provider read limits), Write/Edit
|
||||||
|
preimage and result <=8 MiB (or stricter provider limit), Edit replacements
|
||||||
|
<=10,000 (or stricter provider limit). One checked operation per Local session,
|
||||||
|
with bounded retained handles and no waiting queue. Cancellation/deadline checks
|
||||||
|
use the existing provider guard, or a 30-second cooperative guard for Local
|
||||||
|
checkout operations. Blocking kernel/filesystem calls are not preempted; this
|
||||||
|
is **not** a hard wall-time guarantee for hung storage. Transport request-body
|
||||||
|
bounds before serde allocation remain the hosting transport's responsibility.
|
||||||
|
|
||||||
|
### External-editor limitations (not a magic advisory-lock guarantee)
|
||||||
|
|
||||||
|
No advisory `flock` is claimed to exclude arbitrary editors. Provider-session
|
||||||
|
serialization does not lock other sessions/processes. Metadata checks detect
|
||||||
|
concurrent reads/edits up to each check, but are not a transactional snapshot
|
||||||
|
against privileged actors forging metadata. Linux offers no atomic
|
||||||
|
compare-identity-and-replace-by-name primitive here: an uncooperative external
|
||||||
|
rename/change between the final validator check and rename can still race the
|
||||||
|
save. The pinned handles prevent following replacements/symlinks into arbitrary
|
||||||
|
objects, and deterministic substitution before the save boundary is rejected,
|
||||||
|
but the final syscall gap is an explicit limitation. Callers must not interpret
|
||||||
|
this as linearizability against all external filesystem mutations. Likewise,
|
||||||
|
post-save observation is the then-current named state, not an exclusion of an
|
||||||
|
external edit after save. Crash durability of parent directories is not claimed.
|
||||||
|
|
||||||
|
Host generation reuse, native permission identity, read-history updates, model
|
||||||
|
Operation publication, transport hosting (Runtime/Backend/External), and durable
|
||||||
|
Ticket/MR review remain the parent integration's responsibilities.
|
||||||
|
|
||||||
|
## Scoped checkout search boundary
|
||||||
|
|
||||||
|
`CheckoutSearchRequest::new(CheckoutSearchOperation::{List, Glob, Grep}(request))`
|
||||||
|
uses the current session's logical root as its result frame. The typed result is
|
||||||
|
`CheckoutSearchResult::{List, Glob, Grep}`. The request's internal context fields
|
||||||
|
are `scope_layers: Vec<Vec<WorkdirToolScopeRule>>` and `output_root: WorkdirPath`.
|
||||||
|
Rules union within each layer; layers intersect with each other, the current
|
||||||
|
provider authority, and the output-root boundary. Wrappers contribute their own
|
||||||
|
scope; caller-supplied layers can only narrow, never replace it. Each wrapper
|
||||||
|
translates all incoming coordinates once and forwards the composed output root.
|
||||||
|
Provider-rendered Grep text and every typed result path are already relative to
|
||||||
|
that final root; wrappers must not prefix cwd onto returned paths again.
|
||||||
|
|
||||||
|
Local/External use the same shared `run_list`, `run_glob`, `run_grep` engines.
|
||||||
|
`SearchAccess` gates metadata/content opens and enumeration. Shared List checks
|
||||||
|
`can_enumerate_directory`; Glob/Grep use the shared bounded descriptor walk and
|
||||||
|
owned `traversal_filter` policy to prune before descending/opening directories.
|
||||||
|
The walk anchors at the actual search directory, not at a broader provider
|
||||||
|
root. It counts rejected entries too, checks cancellation while scanning them,
|
||||||
|
and limits descent to 128 levels (bounded retained directory handles). Existing
|
||||||
|
provider-owned ignore loading, glob/type selection, grep context/offset/limit,
|
||||||
|
rendering, and source/result limits remain in their shared engines. Unreadable
|
||||||
|
ignore files are not opened to discover out-of-scope contents.
|
||||||
|
|
||||||
|
Scoped ordinary trait List/Glob/Grep use this boundary as well, returning
|
||||||
|
session-logical coordinates. Unscoped normal Tools continue using their original
|
||||||
|
trait/provider behavior. A provider without checkout search support returns
|
||||||
|
UnsupportedOperation; there is no broad traversal fallback. Native/scoped
|
||||||
|
searches intentionally fail closed on symlink paths, including logical-policy
|
||||||
|
aliases which normal unscoped Tools may follow. A visible symlink may therefore
|
||||||
|
cause a native List failure rather than expanding authority. Descriptor support
|
||||||
|
is currently Linux-only. Search remains request-time traversal, not a snapshot
|
||||||
|
or transactional exclusion of external editors.
|
||||||
|
|
||||||
|
HTTP operation/result variant: `CheckoutSearch`. External DTO validation rejects
|
||||||
|
absolute/out-of-root paths, empty scope layers, oversized paths/patterns/context/
|
||||||
|
results and invalid result paths. Generic request ceilings are 16 layers and 256
|
||||||
|
aggregate rules; External transport further limits aggregate rules to 64. Search
|
||||||
|
result limits follow the existing shared List/Glob/Grep wire bounds. Runtime and
|
||||||
|
Backend forwarding outside this crate remain parent integration work.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
|||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
use crate::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
fn path(value: &str) -> WorkdirPath {
|
||||||
|
WorkdirPath::new(value).unwrap()
|
||||||
|
}
|
||||||
|
fn rule(value: &str, recursive: bool) -> WorkdirToolScopeRule {
|
||||||
|
WorkdirToolScopeRule {
|
||||||
|
target: path(value),
|
||||||
|
recursive,
|
||||||
|
permission: WorkdirToolScopePermission::Read,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn scope(rules: Vec<WorkdirToolScopeRule>, cwd: &str) -> WorkdirToolScope {
|
||||||
|
WorkdirToolScope {
|
||||||
|
rules,
|
||||||
|
cwd: path(cwd),
|
||||||
|
command: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn grep(mode: GrepOutputMode) -> GrepRequest {
|
||||||
|
GrepRequest {
|
||||||
|
path: WorkdirPath::root(),
|
||||||
|
pattern: "needle".into(),
|
||||||
|
glob: None,
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: mode,
|
||||||
|
offset: 0,
|
||||||
|
limit: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn fixture() -> tempfile::TempDir {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(root.path().join("child/keep")).unwrap();
|
||||||
|
std::fs::create_dir_all(root.path().join("child/denied/deep")).unwrap();
|
||||||
|
std::fs::write(root.path().join("child/a.txt"), "needle allowed direct").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.path().join("child/keep/yes.txt"),
|
||||||
|
"needle allowed subtree",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.path().join("child/denied/deep/secret.txt"),
|
||||||
|
"needle secret",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Opening this out-of-scope source would exhaust the shared grep source bound.
|
||||||
|
std::fs::File::create(root.path().join("child/denied/huge.txt"))
|
||||||
|
.unwrap()
|
||||||
|
.set_len(65 * 1024 * 1024)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(root.path().join("outside.txt"), "needle outside").unwrap();
|
||||||
|
root
|
||||||
|
}
|
||||||
|
async fn verify_sparse(session: &dyn WorkdirSession) {
|
||||||
|
let listed = session
|
||||||
|
.list(ListRequest {
|
||||||
|
path: WorkdirPath::root(),
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
listed
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.path.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![path("denied"), path("keep"), path("a.txt")]
|
||||||
|
);
|
||||||
|
let empty = session
|
||||||
|
.list(ListRequest {
|
||||||
|
path: path("denied"),
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(empty.entries.is_empty());
|
||||||
|
assert_eq!(empty.total_entries, 0);
|
||||||
|
let globbed = session
|
||||||
|
.glob(GlobRequest {
|
||||||
|
path: WorkdirPath::root(),
|
||||||
|
pattern: "**/*.txt".into(),
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(globbed.paths, vec![path("a.txt"), path("keep/yes.txt")]);
|
||||||
|
for mode in [
|
||||||
|
GrepOutputMode::Content,
|
||||||
|
GrepOutputMode::Count,
|
||||||
|
GrepOutputMode::FilesWithMatches,
|
||||||
|
] {
|
||||||
|
let result = session.grep(grep(mode)).await.unwrap();
|
||||||
|
let mut paths = result.paths;
|
||||||
|
paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));
|
||||||
|
assert_eq!(paths, vec![path("a.txt"), path("keep/yes.txt")]);
|
||||||
|
assert!(!result.output.contains("child/"));
|
||||||
|
assert!(!result.output.contains("secret"));
|
||||||
|
assert!(!result.output.contains("outside"));
|
||||||
|
assert_eq!(result.matched_files, 2);
|
||||||
|
}
|
||||||
|
let CheckoutSearchResult::List(native) = session
|
||||||
|
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||||
|
ListRequest {
|
||||||
|
path: WorkdirPath::root(),
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(listed, native);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_search_nested_sparse_nonrecursive_scopes_rebase_and_exclude_content() {
|
||||||
|
let root = fixture();
|
||||||
|
let source = Arc::new(LocalWorkdirSession::new(
|
||||||
|
manifest::Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
));
|
||||||
|
let parent = WorkdirToolBroker::new(source);
|
||||||
|
let first = parent
|
||||||
|
.scope(scope(vec![rule("child", true)], "child"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let nested = first
|
||||||
|
.scope(scope(
|
||||||
|
vec![rule("child", false), rule("child/keep", true)],
|
||||||
|
"child",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
verify_sparse(&*nested.tool_session()).await;
|
||||||
|
// Nested incoming layers cannot replace the session's own sparse layer.
|
||||||
|
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest {
|
||||||
|
path: WorkdirPath::root(),
|
||||||
|
pattern: "**/*.txt".into(),
|
||||||
|
limit: 100,
|
||||||
|
}));
|
||||||
|
request.scope_layers.push(vec![rule("", true)]);
|
||||||
|
let CheckoutSearchResult::Glob(result) = nested
|
||||||
|
.tool_session()
|
||||||
|
.checkout_search(request)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(result.paths, vec![path("a.txt"), path("keep/yes.txt")]);
|
||||||
|
nested.close().await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
nested
|
||||||
|
.tool_session()
|
||||||
|
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||||
|
ListRequest {
|
||||||
|
path: WorkdirPath::root(),
|
||||||
|
limit: 100
|
||||||
|
}
|
||||||
|
)))
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::SessionClosed)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
||||||
|
let root = fixture();
|
||||||
|
let parent = WorkdirToolBroker::new(Arc::new(LocalWorkdirSession::new(
|
||||||
|
manifest::Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
)));
|
||||||
|
let first = parent
|
||||||
|
.scope(scope(vec![rule("child", true)], "child"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Two broker wrappers, not only leases that share a source.
|
||||||
|
let stacked = WorkdirToolBroker::new(first.tool_session());
|
||||||
|
let mut request =
|
||||||
|
CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(grep(GrepOutputMode::Content)));
|
||||||
|
*request.operation.path_mut() = path("keep");
|
||||||
|
request.output_root = path("keep");
|
||||||
|
request.scope_layers = vec![vec![rule("keep", false)]];
|
||||||
|
let CheckoutSearchResult::Grep(result) = stacked.checkout_search(request).await.unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(result.paths, vec![path("yes.txt")]);
|
||||||
|
assert!(result.output.starts_with("yes.txt\n"));
|
||||||
|
// The outer context is relative to the inner session; no duplicated child/.
|
||||||
|
let CheckoutSearchResult::List(result) = stacked
|
||||||
|
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||||
|
ListRequest {
|
||||||
|
path: path("keep"),
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(result.entries[0].path, path("keep/yes.txt"));
|
||||||
|
// A second actual scoped wrapper has its own cwd and nonrecursive layer.
|
||||||
|
// Logical rules avoid requesting resolved authorization from a wrapper
|
||||||
|
// that conservatively does not expose host-side resolution authority.
|
||||||
|
std::fs::create_dir(root.path().join("child/keep/deep")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.path().join("child/keep/deep/secret"),
|
||||||
|
"needle hidden nested",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut own_rule = rule("keep", false);
|
||||||
|
own_rule.symlink_policy = manifest::SymlinkPolicy::Logical;
|
||||||
|
let second = stacked.scope(scope(vec![own_rule], "keep")).await.unwrap();
|
||||||
|
let result = second
|
||||||
|
.tool_session()
|
||||||
|
.grep(grep(GrepOutputMode::Content))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.paths, vec![path("yes.txt")]);
|
||||||
|
assert!(result.output.starts_with("yes.txt\n"));
|
||||||
|
assert!(!result.output.contains("hidden nested"));
|
||||||
|
let entries = second
|
||||||
|
.tool_session()
|
||||||
|
.list(ListRequest {
|
||||||
|
path: WorkdirPath::root(),
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.entries;
|
||||||
|
assert_eq!(
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.path.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![path("deep"), path("yes.txt")]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
second
|
||||||
|
.tool_session()
|
||||||
|
.list(ListRequest {
|
||||||
|
path: path("deep"),
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_search_external_keeps_approved_root_and_sparse_authority() {
|
||||||
|
let root = fixture();
|
||||||
|
let external = LocalWorkdirSession::external_read_only(
|
||||||
|
Workdir::new("external-search"),
|
||||||
|
root.path(),
|
||||||
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let broker = WorkdirToolBroker::new(Arc::new(external));
|
||||||
|
let lease = broker
|
||||||
|
.scope(scope(
|
||||||
|
vec![rule("child", false), rule("child/keep", true)],
|
||||||
|
"child",
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let parent = tempfile::tempdir().unwrap();
|
||||||
|
let approved = parent.path().join("approved");
|
||||||
|
std::fs::rename(root.path(), &approved).unwrap();
|
||||||
|
std::fs::create_dir(root.path()).unwrap();
|
||||||
|
std::fs::create_dir(root.path().join("child")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
root.path().join("child/replacement.txt"),
|
||||||
|
"needle replacement",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
verify_sparse(&*lease.tool_session()).await;
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_search_scope_layers_intersect_before_list_and_grep() {
|
||||||
|
let root = fixture();
|
||||||
|
let source = LocalWorkdirSession::new(
|
||||||
|
manifest::Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
);
|
||||||
|
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||||
|
path: path("child"),
|
||||||
|
limit: 100,
|
||||||
|
}));
|
||||||
|
request.output_root = path("child");
|
||||||
|
request.scope_layers = vec![
|
||||||
|
vec![rule("child", false), rule("child/keep", true)],
|
||||||
|
vec![rule("child", false)],
|
||||||
|
];
|
||||||
|
let CheckoutSearchResult::List(result) = source.checkout_search(request.clone()).await.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(result.entries.len(), 3);
|
||||||
|
request.operation = CheckoutSearchOperation::Grep(GrepRequest {
|
||||||
|
path: path("child"),
|
||||||
|
..grep(GrepOutputMode::Content)
|
||||||
|
});
|
||||||
|
let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(result.paths, vec![path("a.txt")]);
|
||||||
|
assert!(!result.output.contains("subtree"));
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_search_wire_dto_bounds_and_provider_capabilities() {
|
||||||
|
use crate::external::{ExternalWorkdirOperation, ExternalWorkdirOperationResult};
|
||||||
|
use crate::http::{WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Output};
|
||||||
|
let root = fixture();
|
||||||
|
let source = LocalWorkdirSession::new(
|
||||||
|
manifest::Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
);
|
||||||
|
let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||||
|
path: path("child/keep"),
|
||||||
|
limit: 100,
|
||||||
|
}));
|
||||||
|
let wire = Op::CheckoutSearch(request.clone());
|
||||||
|
assert!(ExternalWorkdirOperation::try_from(wire.clone()).is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<Op>(serde_json::to_value(&wire).unwrap()).unwrap(),
|
||||||
|
wire
|
||||||
|
);
|
||||||
|
let result = crate::http::dispatch_workdir_session_operation(&source, wire)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
&result,
|
||||||
|
Output::CheckoutSearch(CheckoutSearchResult::List(_))
|
||||||
|
));
|
||||||
|
assert!(ExternalWorkdirOperationResult::try_from(result.clone()).is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<Output>(serde_json::to_value(&result).unwrap()).unwrap(),
|
||||||
|
result
|
||||||
|
);
|
||||||
|
let mut bad = request.clone();
|
||||||
|
bad.output_root = WorkdirPath::new_scoped("/host").unwrap();
|
||||||
|
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||||
|
let mut bad = request.clone();
|
||||||
|
bad.scope_layers = vec![vec![rule("", true)]; 17];
|
||||||
|
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||||
|
let mut bad = request.clone();
|
||||||
|
bad.scope_layers = vec![vec![rule("", true); 257]];
|
||||||
|
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||||
|
let mut bad = request.clone();
|
||||||
|
bad.scope_layers = vec![vec![rule("", true); 65]];
|
||||||
|
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||||
|
let mut bad = request;
|
||||||
|
bad.scope_layers = vec![Vec::new()];
|
||||||
|
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
|
||||||
|
let bad_result = Output::CheckoutSearch(CheckoutSearchResult::Glob(GlobResult {
|
||||||
|
paths: vec![WorkdirPath::new_scoped("/host").unwrap()],
|
||||||
|
truncated: false,
|
||||||
|
}));
|
||||||
|
assert!(ExternalWorkdirOperationResult::try_from(bad_result).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_escape() {
|
||||||
|
let root = fixture();
|
||||||
|
let current = manifest::Scope::from_config(&manifest::ScopeConfig {
|
||||||
|
allow: vec![manifest::ScopeRule {
|
||||||
|
target: root.path().join("child"),
|
||||||
|
permission: manifest::Permission::Read,
|
||||||
|
recursive: false,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
|
}],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let source = LocalWorkdirSession::new(current, root.path().to_path_buf());
|
||||||
|
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest {
|
||||||
|
path: path("child"),
|
||||||
|
..grep(GrepOutputMode::Content)
|
||||||
|
}));
|
||||||
|
request.output_root = path("child");
|
||||||
|
// A broad caller layer cannot enlarge the provider's nonrecursive authority.
|
||||||
|
request.scope_layers = vec![vec![rule("", true)]];
|
||||||
|
let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(result.paths, vec![path("a.txt")]);
|
||||||
|
let CheckoutSearchResult::List(result) = source
|
||||||
|
.checkout_search(CheckoutSearchRequest {
|
||||||
|
operation: CheckoutSearchOperation::List(ListRequest {
|
||||||
|
path: path("child/denied"),
|
||||||
|
limit: 100,
|
||||||
|
}),
|
||||||
|
output_root: path("child"),
|
||||||
|
scope_layers: Vec::new(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert!(result.entries.is_empty());
|
||||||
|
std::os::unix::fs::symlink("../outside.txt", root.path().join("child/link.txt")).unwrap();
|
||||||
|
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest {
|
||||||
|
path: path("child/link.txt"),
|
||||||
|
..grep(GrepOutputMode::Content)
|
||||||
|
}));
|
||||||
|
request.output_root = path("child");
|
||||||
|
assert!(source.checkout_search(request).await.is_err());
|
||||||
|
}
|
||||||
@@ -149,6 +149,56 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
|||||||
WorkdirSessionOperation::ScopeRulesOverlap(request) => {
|
WorkdirSessionOperation::ScopeRulesOverlap(request) => {
|
||||||
is_root_relative(&request.left.target) && is_root_relative(&request.right.target)
|
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::Stat(request) => is_root_relative(&request.path),
|
||||||
WorkdirSessionOperation::Read(request) => {
|
WorkdirSessionOperation::Read(request) => {
|
||||||
is_root_relative(&request.path)
|
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)
|
.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> {
|
fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||||
let result_is_valid = match result {
|
let result_is_valid = match result {
|
||||||
WorkdirSessionOperationResult::AuthorizeScope
|
WorkdirSessionOperationResult::AuthorizeScope
|
||||||
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
|
| 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::Stat(result) => is_root_relative(&result.path),
|
||||||
WorkdirSessionOperationResult::Read(result) => {
|
WorkdirSessionOperationResult::Read(result) => {
|
||||||
is_root_relative(&result.path)
|
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.output.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||||
&& result.match_count <= MAX_EXTERNAL_RESULT_ITEMS
|
&& result.match_count <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
&& result.matched_files <= 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) => {
|
WorkdirSessionOperationResult::Write(result) => {
|
||||||
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
|
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
|
||||||
@@ -821,6 +923,7 @@ mod tests {
|
|||||||
assert!(ExternalWorkdirOperation::try_from(unbounded).is_err());
|
assert!(ExternalWorkdirOperation::try_from(unbounded).is_err());
|
||||||
|
|
||||||
let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult {
|
let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult {
|
||||||
|
paths: Vec::new(),
|
||||||
output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1),
|
output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1),
|
||||||
match_count: 1,
|
match_count: 1,
|
||||||
matched_files: 1,
|
matched_files: 1,
|
||||||
|
|||||||
+136
-1
@@ -64,6 +64,9 @@ pub struct OpenWorkdirSessionResponse {
|
|||||||
pub enum WorkdirSessionOperation {
|
pub enum WorkdirSessionOperation {
|
||||||
AuthorizeScope(WorkdirScopeAuthorizationRequest),
|
AuthorizeScope(WorkdirScopeAuthorizationRequest),
|
||||||
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
||||||
|
CheckoutSearch(crate::CheckoutSearchRequest),
|
||||||
|
CheckoutObserve(crate::WorkdirPath),
|
||||||
|
CheckoutExecute(crate::CheckoutRequest),
|
||||||
Stat(StatRequest),
|
Stat(StatRequest),
|
||||||
Read(ReadRequest),
|
Read(ReadRequest),
|
||||||
ReadBytes(ReadBytesRequest),
|
ReadBytes(ReadBytesRequest),
|
||||||
@@ -96,6 +99,9 @@ pub struct WorkdirSessionOperationRequest {
|
|||||||
pub enum WorkdirSessionOperationResult {
|
pub enum WorkdirSessionOperationResult {
|
||||||
AuthorizeScope,
|
AuthorizeScope,
|
||||||
ScopeRulesOverlap { overlaps: bool },
|
ScopeRulesOverlap { overlaps: bool },
|
||||||
|
CheckoutSearch(crate::CheckoutSearchResult),
|
||||||
|
CheckoutObserve(crate::CheckoutObservation),
|
||||||
|
CheckoutExecute(crate::CheckoutResult),
|
||||||
Stat(StatResult),
|
Stat(StatResult),
|
||||||
Read(ReadResult),
|
Read(ReadResult),
|
||||||
ReadBytes(ReadBytesResult),
|
ReadBytes(ReadBytesResult),
|
||||||
@@ -129,6 +135,18 @@ pub async fn dispatch_workdir_session_operation(
|
|||||||
.scope_rules_overlap(request)
|
.scope_rules_overlap(request)
|
||||||
.await
|
.await
|
||||||
.map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }),
|
.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
|
WorkdirSessionOperation::Stat(request) => session
|
||||||
.stat(request)
|
.stat(request)
|
||||||
.await
|
.await
|
||||||
@@ -186,6 +204,7 @@ pub async fn dispatch_workdir_session_operation(
|
|||||||
pub enum WorkdirTransportErrorCode {
|
pub enum WorkdirTransportErrorCode {
|
||||||
NotFound,
|
NotFound,
|
||||||
Conflict,
|
Conflict,
|
||||||
|
OutcomeUnknown,
|
||||||
Unsupported,
|
Unsupported,
|
||||||
InvalidRequest,
|
InvalidRequest,
|
||||||
Denied,
|
Denied,
|
||||||
@@ -208,6 +227,7 @@ impl WorkdirTransportErrorCode {
|
|||||||
match self {
|
match self {
|
||||||
Self::NotFound => "not_found",
|
Self::NotFound => "not_found",
|
||||||
Self::Conflict => "conflict",
|
Self::Conflict => "conflict",
|
||||||
|
Self::OutcomeUnknown => "outcome_unknown",
|
||||||
Self::Unsupported => "unsupported",
|
Self::Unsupported => "unsupported",
|
||||||
Self::InvalidRequest => "invalid_request",
|
Self::InvalidRequest => "invalid_request",
|
||||||
Self::Denied => "denied",
|
Self::Denied => "denied",
|
||||||
@@ -239,7 +259,7 @@ impl WorkdirTransportErrorCode {
|
|||||||
| Self::IsDirectory
|
| Self::IsDirectory
|
||||||
| Self::SymlinkDirectoryNotTraversed => 400,
|
| Self::SymlinkDirectoryNotTraversed => 400,
|
||||||
Self::Unavailable => 503,
|
Self::Unavailable => 503,
|
||||||
Self::Io | Self::Internal => 500,
|
Self::Io | Self::Internal | Self::OutcomeUnknown => 500,
|
||||||
Self::Transport => 502,
|
Self::Transport => 502,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +276,10 @@ impl WorkdirTransportError {
|
|||||||
pub fn from_workdir_error(error: &WorkdirError) -> Self {
|
pub fn from_workdir_error(error: &WorkdirError) -> Self {
|
||||||
use WorkdirTransportErrorCode as Code;
|
use WorkdirTransportErrorCode as Code;
|
||||||
let (code, message) = match error {
|
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::NotFound(_) => (Code::NotFound, "Workdir path was not found"),
|
||||||
WorkdirError::Conflict(_) => (
|
WorkdirError::Conflict(_) => (
|
||||||
Code::Conflict,
|
Code::Conflict,
|
||||||
@@ -330,6 +354,9 @@ impl WorkdirTransportError {
|
|||||||
match self.code {
|
match self.code {
|
||||||
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
|
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
|
||||||
Code::Conflict => WorkdirError::Conflict(self.message),
|
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::Unsupported => WorkdirError::UnsupportedOperation(self.message),
|
||||||
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
|
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
|
||||||
Code::Denied => WorkdirError::Denied(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> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
||||||
//! materialization or cleanup.
|
//! materialization or cleanup.
|
||||||
|
|
||||||
|
pub mod checkout;
|
||||||
|
pub use checkout::{
|
||||||
|
CheckoutObservation, CheckoutOperation, CheckoutOutput, CheckoutRequest, CheckoutResult,
|
||||||
|
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult,
|
||||||
|
};
|
||||||
pub mod external;
|
pub mod external;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
mod local;
|
mod local;
|
||||||
@@ -13,6 +18,8 @@ mod router;
|
|||||||
mod scope;
|
mod scope;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod checkout_search_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod external_local_tests;
|
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 stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
||||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||||
async fn read_bytes(
|
async fn read_bytes(
|
||||||
@@ -289,6 +321,10 @@ pub enum WorkdirError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Conflict(String),
|
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}")]
|
#[error("unknown Workdir session command: {0}")]
|
||||||
UnknownCommand(String),
|
UnknownCommand(String),
|
||||||
|
|
||||||
|
|||||||
+527
-8
@@ -204,7 +204,7 @@ impl CommandTelemetry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
struct ScopeAccess {
|
struct ScopeAccess {
|
||||||
scope: Arc<Scope>,
|
scope: Arc<Scope>,
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
@@ -314,13 +314,12 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
|||||||
"path is not a directory",
|
"path is not a directory",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let root = self
|
let path = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
|
||||||
.pinned_root
|
return Ok(Some(fs_operation::FsTraversalRoot::new_at(
|
||||||
.as_ref()
|
path,
|
||||||
.expect("confined traversal requires a pinned root")
|
directory,
|
||||||
.try_clone()?;
|
resolved.to_path_buf(),
|
||||||
let path = PathBuf::from(format!("/proc/self/fd/{}", root.as_raw_fd()));
|
)));
|
||||||
return Ok(Some(fs_operation::FsTraversalRoot::new(path, root)));
|
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
Err(std::io::Error::new(
|
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(
|
fn path_sets_overlap(
|
||||||
left: &Path,
|
left: &Path,
|
||||||
left_recursive: bool,
|
left_recursive: bool,
|
||||||
@@ -491,6 +602,7 @@ struct LocalWorkdirSessionInner {
|
|||||||
reject_symlinks: bool,
|
reject_symlinks: bool,
|
||||||
closed: AtomicBool,
|
closed: AtomicBool,
|
||||||
close_lock: Mutex<()>,
|
close_lock: Mutex<()>,
|
||||||
|
checkout_lock: StdMutex<()>,
|
||||||
next_command_id: AtomicU64,
|
next_command_id: AtomicU64,
|
||||||
commands: Mutex<HashMap<String, LocalCommand>>,
|
commands: Mutex<HashMap<String, LocalCommand>>,
|
||||||
command_telemetry: CommandTelemetry,
|
command_telemetry: CommandTelemetry,
|
||||||
@@ -911,6 +1023,7 @@ impl LocalWorkdirSession {
|
|||||||
reject_symlinks,
|
reject_symlinks,
|
||||||
closed: AtomicBool::new(false),
|
closed: AtomicBool::new(false),
|
||||||
close_lock: Mutex::new(()),
|
close_lock: Mutex::new(()),
|
||||||
|
checkout_lock: StdMutex::new(()),
|
||||||
next_command_id: AtomicU64::new(1),
|
next_command_id: AtomicU64::new(1),
|
||||||
commands: Mutex::new(HashMap::new()),
|
commands: Mutex::new(HashMap::new()),
|
||||||
command_telemetry: CommandTelemetry::new(),
|
command_telemetry: CommandTelemetry::new(),
|
||||||
@@ -1129,6 +1242,69 @@ impl LocalWorkdirSession {
|
|||||||
Ok(())
|
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 {
|
fn scope_access(&self) -> ScopeAccess {
|
||||||
ScopeAccess::new(
|
ScopeAccess::new(
|
||||||
self.inner.scope.snapshot(),
|
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> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
@@ -1247,6 +1672,11 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
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)?;
|
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
self.validate_operation_path(&request.path)?;
|
self.validate_operation_path(&request.path)?;
|
||||||
@@ -1277,6 +1707,11 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
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)?;
|
self.ensure_capability(WorkdirSessionCapability::Write)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
self.validate_operation_path(&request.path)?;
|
self.validate_operation_path(&request.path)?;
|
||||||
@@ -1287,6 +1722,11 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
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)?;
|
self.ensure_capability(WorkdirSessionCapability::Edit)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
self.validate_operation_path(&request.path)?;
|
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 {
|
fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
||||||
let path = PathBuf::from(logical.as_str());
|
let path = PathBuf::from(logical.as_str());
|
||||||
match error {
|
match error {
|
||||||
@@ -2083,6 +2561,47 @@ mod tests {
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
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 {
|
fn make_fs(dir: &TempDir) -> LocalWorkdirSession {
|
||||||
LocalWorkdirSession::new(
|
LocalWorkdirSession::new(
|
||||||
Scope::writable(dir.path()).unwrap(),
|
Scope::writable(dir.path()).unwrap(),
|
||||||
|
|||||||
@@ -248,6 +248,29 @@ impl WorkdirSession for RoutedWorkdirSession {
|
|||||||
self.state.session.scope_rules_overlap(request).await
|
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> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
let _active = self.state.enter()?;
|
let _active = self.state.enter()?;
|
||||||
self.state.session.stat(request).await
|
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]
|
#[tokio::test]
|
||||||
async fn router_holds_multiple_aliases_without_implicit_primary() {
|
async fn router_holds_multiple_aliases_without_implicit_primary() {
|
||||||
let left = TempDir::new().unwrap();
|
let left = TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -994,6 +994,138 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
self.capabilities
|
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> {
|
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
let path = self
|
let path = self
|
||||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
.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> {
|
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
|
let path = self
|
||||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1057,6 +1202,19 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
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
|
let path = self
|
||||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1066,6 +1224,19 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
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
|
let path = self
|
||||||
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1284,6 +1455,44 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
|||||||
self.inner.scope_rules_overlap(request).await
|
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> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
self.inner.stat(request).await
|
self.inner.stat(request).await
|
||||||
}
|
}
|
||||||
@@ -1654,6 +1863,53 @@ mod tests {
|
|||||||
(WorkdirToolBroker::new(source), receiver, release)
|
(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 {
|
fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope {
|
||||||
WorkdirToolScope {
|
WorkdirToolScope {
|
||||||
rules: vec![WorkdirToolScopeRule {
|
rules: vec![WorkdirToolScopeRule {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ wip-http = { workspace = true }
|
|||||||
wip-protocol = { workspace = true }
|
wip-protocol = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
axum = { workspace = true }
|
||||||
|
workdir = { workspace = true, features = ["http-client"] }
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
serial_test = "3.4.0"
|
serial_test = "3.4.0"
|
||||||
|
|||||||
@@ -0,0 +1,764 @@
|
|||||||
|
//! Provider-owned request-time checkout projection. No filesystem path is opened
|
||||||
|
//! by the Worker: every observation and operation goes through the live router.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use agen::tool::ToolError;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||||
|
use serde_json::{Value as Json, json};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use wip_protocol::{
|
||||||
|
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
|
||||||
|
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
||||||
|
};
|
||||||
|
use workdir::{
|
||||||
|
CheckoutObservation, EntryKind, ListRequest, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||||
|
WorkdirSessionRouter,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::feature::builtin::manage_workdir::wip::{decode_identity, encode_identity};
|
||||||
|
use crate::permission::permission_action_for;
|
||||||
|
use crate::wip::{
|
||||||
|
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||||
|
WipOperationOutput, WipProjection, WipProjectionKind, WipSubtreeMount, WipSubtreeProvider,
|
||||||
|
json_to_wip, wip_to_json,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROOT: &str = "/checkouts";
|
||||||
|
const MAX_NODES: usize = 1024;
|
||||||
|
const DEADLINE: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Return a stable, injective attachment-local entrance, never a host path.
|
||||||
|
pub fn checkout_root(alias: &str) -> String {
|
||||||
|
format!("{ROOT}/{}", encode_identity(alias))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the native content namespace independently of inventory/lifecycle
|
||||||
|
/// Features. Its capabilities come from current attachments, not Feature flags.
|
||||||
|
pub fn mount_checkouts(
|
||||||
|
registry: &mut WipMountRegistry,
|
||||||
|
router: Arc<WorkdirSessionRouter>,
|
||||||
|
tracker: tools::Tracker,
|
||||||
|
permissions: Option<ToolPermissionConfig>,
|
||||||
|
) -> Result<(), WipMountError> {
|
||||||
|
registry.allocate_namespace("checkout", "checkouts")?;
|
||||||
|
let provider = Arc::new(Provider {
|
||||||
|
router,
|
||||||
|
tracker,
|
||||||
|
permissions,
|
||||||
|
incarnation: uuid::Uuid::now_v7().to_string(),
|
||||||
|
permits: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||||
|
deadline: DEADLINE,
|
||||||
|
});
|
||||||
|
registry.mount(provider.collection())?;
|
||||||
|
registry.replace_compatibility_tools(ROOT, ["Read", "Edit", "Write", "Glob", "Grep"])?;
|
||||||
|
registry.mount_subtree(WipSubtreeMount {
|
||||||
|
root: ROOT.into(),
|
||||||
|
provider: Arc::new(provider),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Provider {
|
||||||
|
router: Arc<WorkdirSessionRouter>,
|
||||||
|
tracker: tools::Tracker,
|
||||||
|
permissions: Option<ToolPermissionConfig>,
|
||||||
|
incarnation: String,
|
||||||
|
permits: Arc<tokio::sync::Semaphore>,
|
||||||
|
deadline: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provider {
|
||||||
|
fn allowed(&self, tool: &str, input: &Json) -> bool {
|
||||||
|
self.permissions
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|p| permission_action_for(p, tool, input) == ToolPermissionAction::Allow)
|
||||||
|
}
|
||||||
|
fn may_allow(&self, tool: &str) -> bool {
|
||||||
|
let Some(p) = &self.permissions else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(tool)) {
|
||||||
|
if rule.action == ToolPermissionAction::Allow {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if rule.pattern == "*" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.default_action == ToolPermissionAction::Allow
|
||||||
|
}
|
||||||
|
fn parse(&self, path: &str) -> Option<(String, WorkdirPath)> {
|
||||||
|
wip_protocol::validate_path(path).ok()?;
|
||||||
|
let rest = path.strip_prefix("/checkouts/")?;
|
||||||
|
let (slug, tail) = rest.split_once('/').unwrap_or((rest, ""));
|
||||||
|
if tail.contains('\0') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?))
|
||||||
|
}
|
||||||
|
fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec<u8> {
|
||||||
|
let mut hash = Sha256::new();
|
||||||
|
for value in [
|
||||||
|
self.incarnation.as_bytes(),
|
||||||
|
alias.as_bytes(),
|
||||||
|
&generation.to_be_bytes(),
|
||||||
|
workdir.as_bytes(),
|
||||||
|
raw,
|
||||||
|
] {
|
||||||
|
hash.update(value.len().to_be_bytes());
|
||||||
|
hash.update(value);
|
||||||
|
}
|
||||||
|
hash.finalize().to_vec()
|
||||||
|
}
|
||||||
|
fn collection(self: &Arc<Self>) -> WipProjection {
|
||||||
|
let mut state = Sha256::new();
|
||||||
|
state.update(&self.incarnation);
|
||||||
|
for alias in self.router.aliases() {
|
||||||
|
if let Ok(s) = self.router.resolve(Some(alias.as_str())) {
|
||||||
|
state.update(alias.as_str());
|
||||||
|
state.update(s.generation.to_be_bytes());
|
||||||
|
state.update(s.session.workdir().id().as_str());
|
||||||
|
state.update(format!("{:?}", s.session.capabilities()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.project(
|
||||||
|
ROOT,
|
||||||
|
descriptor(vec![operation(
|
||||||
|
"list",
|
||||||
|
"List currently accessible checkouts",
|
||||||
|
vec![],
|
||||||
|
)]),
|
||||||
|
state.finalize().to_vec(),
|
||||||
|
"collection",
|
||||||
|
Arc::new(Collection {
|
||||||
|
provider: self.clone(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn project(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
descriptor: InterfaceDescriptor,
|
||||||
|
validator: Vec<u8>,
|
||||||
|
kind: &str,
|
||||||
|
handler: Arc<dyn WipOperationHandler>,
|
||||||
|
) -> WipProjection {
|
||||||
|
let encoded: String = path.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
let interface = format!("yoi.checkout/{}/{kind}/v1/@/{encoded}", self.incarnation);
|
||||||
|
let interface_validator = Sha256::digest(format!("{descriptor:?}")).to_vec();
|
||||||
|
WipProjection {
|
||||||
|
route: path.into(),
|
||||||
|
capability: format!("checkout:{kind}"),
|
||||||
|
kind: WipProjectionKind::Native,
|
||||||
|
object: Object {
|
||||||
|
name: path.rsplit('/').next().unwrap_or_default().into(),
|
||||||
|
description: Some(format!("Attached Workdir {kind}")),
|
||||||
|
interfaces: vec![interface.clone()],
|
||||||
|
r#ref: None,
|
||||||
|
validator: Some(validator),
|
||||||
|
},
|
||||||
|
interface,
|
||||||
|
descriptor,
|
||||||
|
interface_validator: Some(interface_validator),
|
||||||
|
handler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn node(self: &Arc<Self>, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||||
|
if path == ROOT {
|
||||||
|
return Ok(Some(self.collection()));
|
||||||
|
}
|
||||||
|
let Some((alias, target)) = self.parse(path) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Ok(selected) = self.router.resolve(Some(&alias)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let observation = match selected.session.checkout_observe(target.clone()).await {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(
|
||||||
|
workdir::WorkdirError::Denied(_)
|
||||||
|
| workdir::WorkdirError::OutOfScope(_)
|
||||||
|
| workdir::WorkdirError::NotFound(_)
|
||||||
|
| workdir::WorkdirError::SessionClosed
|
||||||
|
| workdir::WorkdirError::UnsupportedOperation(_)
|
||||||
|
| workdir::WorkdirError::Unsupported(_),
|
||||||
|
) => return Ok(None),
|
||||||
|
Err(_) => {
|
||||||
|
return Err(error(
|
||||||
|
ProtocolErrorCode::Internal,
|
||||||
|
"checkout observation unavailable",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !matches!(observation.kind, EntryKind::File | EntryKind::Directory) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let permission_input = json!({"target_workdir":alias, "file_path":target.as_str()});
|
||||||
|
if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let ops = self.operations(&observation);
|
||||||
|
if ops.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let kind = if observation.kind == EntryKind::File {
|
||||||
|
"file"
|
||||||
|
} else {
|
||||||
|
"directory"
|
||||||
|
};
|
||||||
|
let workdir = selected.session.workdir().id().as_str().to_owned();
|
||||||
|
let validator = self.wrap_validator(
|
||||||
|
&alias,
|
||||||
|
selected.generation,
|
||||||
|
&workdir,
|
||||||
|
&observation.validator,
|
||||||
|
);
|
||||||
|
let handler = Arc::new(FileHandler {
|
||||||
|
provider: self.clone(),
|
||||||
|
alias,
|
||||||
|
generation: selected.generation,
|
||||||
|
workdir,
|
||||||
|
target,
|
||||||
|
observation,
|
||||||
|
});
|
||||||
|
let family = format!("{kind}-g{}", selected.generation);
|
||||||
|
Ok(Some(self.project(
|
||||||
|
path,
|
||||||
|
descriptor(ops),
|
||||||
|
validator,
|
||||||
|
&family,
|
||||||
|
handler,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
|
||||||
|
let mut ops = Vec::new();
|
||||||
|
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
|
||||||
|
if o.kind == EntryKind::File {
|
||||||
|
if has(Cap::Read, "Read") {
|
||||||
|
ops.push(operation(
|
||||||
|
"read",
|
||||||
|
"Read text with line offsets and numbered output",
|
||||||
|
vec![
|
||||||
|
parameter("offset", false, TypeExpr::Integer),
|
||||||
|
parameter("limit", false, TypeExpr::Integer),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if has(Cap::Edit, "Edit") {
|
||||||
|
ops.push(operation(
|
||||||
|
"edit",
|
||||||
|
"Replace a unique string (requires prior Read)",
|
||||||
|
vec![
|
||||||
|
parameter("old_string", true, TypeExpr::String),
|
||||||
|
parameter("new_string", true, TypeExpr::String),
|
||||||
|
parameter("replace_all", false, TypeExpr::Boolean),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if has(Cap::Write, "Write") {
|
||||||
|
ops.push(operation(
|
||||||
|
"write",
|
||||||
|
"Overwrite this existing file (requires prior Read)",
|
||||||
|
vec![parameter("content", true, TypeExpr::String)],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if has(Cap::Glob, "Glob") {
|
||||||
|
ops.push(operation(
|
||||||
|
"glob",
|
||||||
|
"Glob within this directory",
|
||||||
|
vec![
|
||||||
|
parameter("pattern", true, TypeExpr::String),
|
||||||
|
parameter("path", false, TypeExpr::String),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if has(Cap::Grep, "Grep") {
|
||||||
|
ops.push(operation(
|
||||||
|
"grep",
|
||||||
|
"Search within this directory with the existing Grep contract",
|
||||||
|
grep_parameters(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if has(Cap::Write, "Write") {
|
||||||
|
ops.push(operation("create_file", "Create a new relative file, creating permitted missing parents; never overwrite", vec![parameter("path",true,TypeExpr::String),parameter("content",true,TypeExpr::String)]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ops
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WipSubtreeProvider for Arc<Provider> {
|
||||||
|
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||||
|
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
error(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"checkout concurrency deadline",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
||||||
|
tokio::time::timeout(self.deadline, self.node(path))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
error(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"checkout observation deadline",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||||
|
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
error(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"checkout capacity deadline",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
||||||
|
tokio::time::timeout(self.deadline, self.children_inner(path))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
error(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"checkout enumeration deadline",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Provider {
|
||||||
|
async fn children_inner(self: &Arc<Self>, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||||
|
if path == ROOT {
|
||||||
|
let before = self.collection().object.validator.unwrap();
|
||||||
|
if self.router.aliases().len() > MAX_NODES {
|
||||||
|
return Err(error(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"too many checkouts",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
for alias in self.router.aliases() {
|
||||||
|
let p = checkout_root(alias.as_str());
|
||||||
|
if self.node(&p).await?.is_some() {
|
||||||
|
paths.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if before != self.collection().object.validator.unwrap() {
|
||||||
|
return Err(error(
|
||||||
|
ProtocolErrorCode::ValidatorMismatch,
|
||||||
|
"attachment collection changed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(paths);
|
||||||
|
}
|
||||||
|
let Some((alias, target)) = self.parse(path) else {
|
||||||
|
return Err(error(ProtocolErrorCode::NotFound, "checkout not published"));
|
||||||
|
};
|
||||||
|
let selected = self
|
||||||
|
.router
|
||||||
|
.resolve(Some(&alias))
|
||||||
|
.map_err(|_| error(ProtocolErrorCode::NotFound, "attachment expired"))?;
|
||||||
|
let o = selected
|
||||||
|
.session
|
||||||
|
.checkout_observe(target.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|_| error(ProtocolErrorCode::NotFound, "checkout not published"))?;
|
||||||
|
if o.kind != EntryKind::Directory {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if !self.allowed(
|
||||||
|
"Glob",
|
||||||
|
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":"*"}),
|
||||||
|
) && !self.allowed(
|
||||||
|
"Grep",
|
||||||
|
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":""}),
|
||||||
|
) {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let listing = selected
|
||||||
|
.session
|
||||||
|
.checkout_search(workdir::CheckoutSearchRequest::new(
|
||||||
|
workdir::CheckoutSearchOperation::List(ListRequest {
|
||||||
|
path: target,
|
||||||
|
limit: MAX_NODES + 1,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout listing unavailable"))?;
|
||||||
|
let workdir::CheckoutSearchResult::List(listing) = listing else {
|
||||||
|
return Err(error(
|
||||||
|
ProtocolErrorCode::Internal,
|
||||||
|
"mismatched checkout listing",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if listing.truncated || listing.entries.len() > MAX_NODES {
|
||||||
|
return Err(error(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"directory observation exceeds node bound",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
for entry in listing.entries {
|
||||||
|
let p = object_path(&alias, &entry.path);
|
||||||
|
if self.node(&p).await?.is_some() {
|
||||||
|
paths.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self
|
||||||
|
.router
|
||||||
|
.resolve(Some(&alias))
|
||||||
|
.is_ok_and(|current| current.generation == selected.generation)
|
||||||
|
{
|
||||||
|
return Err(error(
|
||||||
|
ProtocolErrorCode::ValidatorMismatch,
|
||||||
|
"attachment changed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Collection {
|
||||||
|
provider: Arc<Provider>,
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl WipOperationHandler for Collection {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
operation: &str,
|
||||||
|
arguments: &BTreeMap<String, Value>,
|
||||||
|
_context: WipCallContext,
|
||||||
|
) -> Result<WipOperationOutput, WipOperationError> {
|
||||||
|
if operation != "list" || !arguments.is_empty() {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"list accepts no arguments",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let before = self.provider.collection().object.validator.unwrap();
|
||||||
|
let paths = Arc::new(self.provider.clone())
|
||||||
|
.children(ROOT)
|
||||||
|
.await
|
||||||
|
.map_err(WipOperationError::Protocol)?;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
let (alias, _) = self.provider.parse(&path).expect("generated checkout path");
|
||||||
|
let s = self.provider.router.resolve(Some(&alias)).map_err(|_| {
|
||||||
|
protocol(ProtocolErrorCode::ValidatorMismatch, "attachment changed")
|
||||||
|
})?;
|
||||||
|
items.push(json!({"path":path,"slug":encode_identity(&alias),"alias":alias,
|
||||||
|
"working_directory_id":s.session.workdir().id().as_str(),"generation":s.generation}));
|
||||||
|
}
|
||||||
|
let value = json_to_wip(&json!({"items":items}))
|
||||||
|
.map_err(|_| protocol(ProtocolErrorCode::Internal, "invalid checkout result"))?;
|
||||||
|
let after = self.provider.collection().object.validator.unwrap();
|
||||||
|
if before != after {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::ValidatorMismatch,
|
||||||
|
"attachment collection changed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(WipOperationOutput::native_with_validator(value, after))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FileHandler {
|
||||||
|
provider: Arc<Provider>,
|
||||||
|
alias: String,
|
||||||
|
generation: u64,
|
||||||
|
workdir: String,
|
||||||
|
target: WorkdirPath,
|
||||||
|
observation: CheckoutObservation,
|
||||||
|
}
|
||||||
|
impl FileHandler {
|
||||||
|
fn check_connection(&self) -> Result<(), WipOperationError> {
|
||||||
|
if self
|
||||||
|
.provider
|
||||||
|
.router
|
||||||
|
.resolve(Some(&self.alias))
|
||||||
|
.is_ok_and(|s| {
|
||||||
|
s.generation == self.generation && s.session.workdir().id().as_str() == self.workdir
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(protocol(
|
||||||
|
ProtocolErrorCode::ValidatorMismatch,
|
||||||
|
"attachment changed while publishing result",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl WipOperationHandler for FileHandler {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
operation: &str,
|
||||||
|
arguments: &BTreeMap<String, Value>,
|
||||||
|
context: WipCallContext,
|
||||||
|
) -> Result<WipOperationOutput, WipOperationError> {
|
||||||
|
let tool = match operation {
|
||||||
|
"read" => "Read",
|
||||||
|
"edit" => "Edit",
|
||||||
|
"write" => "Write",
|
||||||
|
"create_file" => "Create",
|
||||||
|
"glob" => "Glob",
|
||||||
|
"grep" => "Grep",
|
||||||
|
_ => {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::OperationNotFound,
|
||||||
|
"unknown checkout operation",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut args = serde_json::Map::new();
|
||||||
|
for (k, v) in arguments {
|
||||||
|
args.insert(
|
||||||
|
k.clone(),
|
||||||
|
wip_to_json(v).map_err(|_| {
|
||||||
|
protocol(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"invalid checkout argument",
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut permission_input = args.clone();
|
||||||
|
permission_input.insert("target_workdir".into(), json!(self.alias));
|
||||||
|
if tool == "Glob" || tool == "Grep" {
|
||||||
|
let relative = args.get("path").and_then(Json::as_str).unwrap_or("");
|
||||||
|
permission_input.insert("path".into(), json!(join_under(&self.target, relative)?));
|
||||||
|
} else if tool == "Create" {
|
||||||
|
let relative = args.get("path").and_then(Json::as_str).ok_or_else(|| {
|
||||||
|
protocol(ProtocolErrorCode::InvalidArguments, "create requires path")
|
||||||
|
})?;
|
||||||
|
permission_input.remove("path");
|
||||||
|
permission_input.insert(
|
||||||
|
"file_path".into(),
|
||||||
|
json!(join_under(&self.target, relative)?),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
permission_input.insert("file_path".into(), json!(self.target.as_str()));
|
||||||
|
}
|
||||||
|
if !self.provider.allowed(
|
||||||
|
if tool == "Create" { "Write" } else { tool },
|
||||||
|
&Json::Object(permission_input),
|
||||||
|
) {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::PermissionDenied,
|
||||||
|
"file operation denied; approval-required policy fails closed",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let _permit = tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
protocol(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"checkout operation capacity deadline",
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|_| protocol(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
||||||
|
let deadline = tokio::time::Instant::now() + self.provider.deadline;
|
||||||
|
let result = tokio::time::timeout_at(
|
||||||
|
deadline,
|
||||||
|
tools::execute_checkout_tool(
|
||||||
|
self.provider.router.clone(),
|
||||||
|
self.provider.tracker.clone(),
|
||||||
|
&self.alias,
|
||||||
|
self.generation,
|
||||||
|
self.target.clone(),
|
||||||
|
self.observation.validator.clone(),
|
||||||
|
tool,
|
||||||
|
Json::Object(args),
|
||||||
|
context.execution,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||||
|
WipOperationError::OutcomeUnknown("file operation deadline after dispatch".into())
|
||||||
|
} else {
|
||||||
|
protocol(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"file operation deadline",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
.map_err(map_tool_error)?;
|
||||||
|
let links = tokio::time::timeout_at(deadline, async {
|
||||||
|
let mut links = Vec::new();
|
||||||
|
self.check_connection()?;
|
||||||
|
for path in &result.paths {
|
||||||
|
let route = object_path(&self.alias, path);
|
||||||
|
// References are validated only against the captured live connection.
|
||||||
|
if self
|
||||||
|
.provider
|
||||||
|
.node(&route)
|
||||||
|
.await
|
||||||
|
.map_err(WipOperationError::Protocol)?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
links.push(json!({"path":route}));
|
||||||
|
}
|
||||||
|
self.check_connection()?;
|
||||||
|
}
|
||||||
|
Ok::<_, WipOperationError>(links)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||||
|
WipOperationError::OutcomeUnknown("completed operation response deadline".into())
|
||||||
|
} else {
|
||||||
|
protocol(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"checkout result deadline",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
.map_err(|e| {
|
||||||
|
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||||
|
WipOperationError::OutcomeUnknown("completed operation response unavailable".into())
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let value = json_to_wip(
|
||||||
|
&json!({"summary":result.output.summary,"content":result.output.content,"items":links}),
|
||||||
|
)
|
||||||
|
.map_err(|_| {
|
||||||
|
WipOperationError::OutcomeUnknown("invalid completed file operation result".into())
|
||||||
|
})?;
|
||||||
|
if let Some(raw) = result.validator {
|
||||||
|
Ok(WipOperationOutput::native_with_validator(
|
||||||
|
value,
|
||||||
|
self.provider
|
||||||
|
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(WipOperationOutput::native(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object_path(alias: &str, path: &WorkdirPath) -> String {
|
||||||
|
if path.is_root() {
|
||||||
|
checkout_root(alias)
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", checkout_root(alias), path.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn join_under(parent: &WorkdirPath, relative: &str) -> Result<String, WipOperationError> {
|
||||||
|
let child = WorkdirPath::new(relative).map_err(|_| {
|
||||||
|
protocol(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"path must stay relative to this directory",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if relative.starts_with('/') {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"absolute paths are not permitted",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(if parent.is_root() {
|
||||||
|
child.as_str().into()
|
||||||
|
} else if child.is_root() {
|
||||||
|
parent.as_str().into()
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", parent.as_str(), child.as_str())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
|
||||||
|
ParameterDeclaration {
|
||||||
|
name: name.into(),
|
||||||
|
required,
|
||||||
|
documentation: None,
|
||||||
|
r#type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn operation(
|
||||||
|
name: &str,
|
||||||
|
summary: &str,
|
||||||
|
parameters: Vec<ParameterDeclaration>,
|
||||||
|
) -> OperationDeclaration {
|
||||||
|
OperationDeclaration {
|
||||||
|
name: name.into(),
|
||||||
|
documentation: Some(Documentation {
|
||||||
|
summary: summary.into(),
|
||||||
|
details: None,
|
||||||
|
}),
|
||||||
|
parameters,
|
||||||
|
returns: ReturnDeclaration {
|
||||||
|
documentation: None,
|
||||||
|
r#type: TypeExpr::Json,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn descriptor(operations: Vec<OperationDeclaration>) -> InterfaceDescriptor {
|
||||||
|
InterfaceDescriptor{format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation{summary:"AI-oriented attached Workdir operations".into(),details:Some("The route binds attachment and target. Line offsets are zero-based; text output numbers lines from one. Read-before-write and provider validators are both required. Paths are Worldspace references, never host paths.".into())}),types:Vec::new(),operations}
|
||||||
|
}
|
||||||
|
fn grep_parameters() -> Vec<ParameterDeclaration> {
|
||||||
|
let mut p = vec![parameter("pattern", true, TypeExpr::String)];
|
||||||
|
for name in ["path", "glob", "type", "output_mode"] {
|
||||||
|
p.push(parameter(name, false, TypeExpr::String));
|
||||||
|
}
|
||||||
|
for name in ["-A", "-B", "-C", "head_limit", "offset"] {
|
||||||
|
p.push(parameter(name, false, TypeExpr::Integer));
|
||||||
|
}
|
||||||
|
for name in ["case_insensitive", "multiline"] {
|
||||||
|
p.push(parameter(name, false, TypeExpr::Boolean));
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
fn error(code: ProtocolErrorCode, message: &str) -> ProtocolError {
|
||||||
|
ProtocolError {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn protocol(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||||
|
WipOperationError::Protocol(error(code, message))
|
||||||
|
}
|
||||||
|
fn map_tool_error(e: ToolError) -> WipOperationError {
|
||||||
|
match e {
|
||||||
|
ToolError::InvalidArgument(_) => protocol(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"invalid file arguments or read-before-write requirement",
|
||||||
|
),
|
||||||
|
ToolError::StructuredConflict { code, .. } => match code.as_str() {
|
||||||
|
"checkout_denied" => protocol(
|
||||||
|
ProtocolErrorCode::PermissionDenied,
|
||||||
|
"file operation refused before effects",
|
||||||
|
),
|
||||||
|
"checkout_invalid" => protocol(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"invalid checkout operation or target",
|
||||||
|
),
|
||||||
|
"checkout_unavailable" => protocol(
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded,
|
||||||
|
"checkout provider declined before effects",
|
||||||
|
),
|
||||||
|
"checkout_stale" => protocol(
|
||||||
|
ProtocolErrorCode::ValidatorMismatch,
|
||||||
|
"file or attachment observation is stale; rediscover and reread",
|
||||||
|
),
|
||||||
|
_ => WipOperationError::OutcomeUnknown("unclassified file operation failure".into()),
|
||||||
|
},
|
||||||
|
ToolError::Cancelled(o) => WipOperationError::Cancelled(o),
|
||||||
|
ToolError::Interrupted(o) => WipOperationError::Interrupted(o),
|
||||||
|
_ => WipOperationError::OutcomeUnknown(
|
||||||
|
"file operation failed; inspect current state before retry".into(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "checkout_race_tests.rs"]
|
||||||
|
mod race_tests;
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
//! Real provider HTTP -> RemoteWorkdirSession -> router -> native WIP Client.
|
||||||
|
//! This deliberately does not exercise the production Backend grant network.
|
||||||
|
use super::*;
|
||||||
|
use axum::{
|
||||||
|
Json as AxumJson, Router,
|
||||||
|
body::Body,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{HeaderMap, StatusCode, Uri},
|
||||||
|
response::{IntoResponse, Response as AxumResponse},
|
||||||
|
routing::{delete, post},
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use workdir::http::{
|
||||||
|
OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, RemoteWorkdirSession, WorkdirSessionId,
|
||||||
|
WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirTransportError,
|
||||||
|
WorkdirTransportErrorCode,
|
||||||
|
};
|
||||||
|
use workdir::{
|
||||||
|
CheckoutOperation, CheckoutRequest, CheckoutSearchOperation, LocalWorkdirSession,
|
||||||
|
WorkdirAttachmentAlias, WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
|
||||||
|
WorkdirSessionHandle, WorkdirSessionRouter, dispatch_workdir_session_operation,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKEN: &str = "checkout-http-test-private-bearer";
|
||||||
|
const SESSION: &str = "http-test-session";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum WriteResponseFault {
|
||||||
|
Malformed,
|
||||||
|
LostBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProviderState {
|
||||||
|
session: WorkdirSessionHandle,
|
||||||
|
requests: Mutex<Vec<(String, String)>>,
|
||||||
|
operations: Mutex<Vec<WorkdirSessionOperation>>,
|
||||||
|
write_fault: Mutex<Option<WriteResponseFault>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderState {
|
||||||
|
fn authorize(&self, headers: &HeaderMap, method: &str, uri: &Uri) -> Result<(), AxumResponse> {
|
||||||
|
self.requests
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((method.into(), uri.path().into()));
|
||||||
|
let expected = format!("Bearer {TOKEN}");
|
||||||
|
if headers.get("authorization").and_then(|h| h.to_str().ok()) != Some(expected.as_str()) {
|
||||||
|
return Err((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
AxumJson(WorkdirTransportError {
|
||||||
|
code: WorkdirTransportErrorCode::Denied,
|
||||||
|
message: "provider authorization denied".into(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_calls(&self) -> usize {
|
||||||
|
self.operations
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(_)))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_calls(&self) -> usize {
|
||||||
|
self.operations
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|op| {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
|
||||||
|
operation: CheckoutOperation::Write { .. },
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_error(error: WorkdirError) -> AxumResponse {
|
||||||
|
let error = WorkdirTransportError::from_workdir_error(&error);
|
||||||
|
(
|
||||||
|
StatusCode::from_u16(error.code.http_status()).unwrap(),
|
||||||
|
AxumJson(error),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open_session(
|
||||||
|
State(state): State<Arc<ProviderState>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
AxumJson(_request): AxumJson<OpenWorkdirSessionRequest>,
|
||||||
|
) -> AxumResponse {
|
||||||
|
if let Err(response) = state.authorize(&headers, "POST", &uri) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if id != state.session.workdir().id().as_str() {
|
||||||
|
return provider_error(WorkdirError::NotFound("unknown workdir".into()));
|
||||||
|
}
|
||||||
|
AxumJson(OpenWorkdirSessionResponse {
|
||||||
|
session_id: WorkdirSessionId::new(SESSION).unwrap(),
|
||||||
|
workdir_id: state.session.workdir().id().clone(),
|
||||||
|
capabilities: state.session.capabilities(),
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn operate(
|
||||||
|
State(state): State<Arc<ProviderState>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
AxumJson(request): AxumJson<WorkdirSessionOperationRequest>,
|
||||||
|
) -> AxumResponse {
|
||||||
|
if let Err(response) = state.authorize(&headers, "POST", &uri) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if id != SESSION {
|
||||||
|
return provider_error(WorkdirError::Unavailable("unknown session".into()));
|
||||||
|
}
|
||||||
|
let is_write = matches!(
|
||||||
|
&request.operation,
|
||||||
|
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
|
||||||
|
operation: CheckoutOperation::Write { .. },
|
||||||
|
..
|
||||||
|
})
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.operations
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(request.operation.clone());
|
||||||
|
let result =
|
||||||
|
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
|
||||||
|
match result {
|
||||||
|
// Inject only AFTER the real LocalWorkdirSession successfully committed.
|
||||||
|
Ok(result) => {
|
||||||
|
let fault = if is_write {
|
||||||
|
state.write_fault.lock().unwrap().take()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
match fault {
|
||||||
|
Some(WriteResponseFault::Malformed) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[("content-type", "application/json")],
|
||||||
|
"{broken-response",
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Some(WriteResponseFault::LostBody) => {
|
||||||
|
let body = Body::from_stream(futures::stream::once(async {
|
||||||
|
Err::<Vec<u8>, _>(std::io::Error::other("response lost after commit"))
|
||||||
|
}));
|
||||||
|
AxumResponse::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
None => AxumJson(result).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => provider_error(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close_session(
|
||||||
|
State(state): State<Arc<ProviderState>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
uri: Uri,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> AxumResponse {
|
||||||
|
if let Err(response) = state.authorize(&headers, "DELETE", &uri) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if id != SESSION {
|
||||||
|
return provider_error(WorkdirError::Unavailable("unknown session".into()));
|
||||||
|
}
|
||||||
|
match state.session.close().await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(error) => provider_error(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HttpFixture {
|
||||||
|
dir: TempDir,
|
||||||
|
state: Arc<ProviderState>,
|
||||||
|
base_url: reqwest::Url,
|
||||||
|
server: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for HttpFixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.server.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpFixture {
|
||||||
|
async fn new(capabilities: WorkdirSessionCapabilities) -> Self {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("src/deep")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("src/deep/a.txt"),
|
||||||
|
"first\nneedle needle\nlast\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let session = Arc::new(LocalWorkdirSession::materialized(
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
||||||
|
capabilities,
|
||||||
|
));
|
||||||
|
let state = Arc::new(ProviderState {
|
||||||
|
session,
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
operations: Mutex::new(Vec::new()),
|
||||||
|
write_fault: Mutex::new(None),
|
||||||
|
});
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/provider/v1/working-directories/{id}/sessions",
|
||||||
|
post(open_session),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/provider/v1/workdir-sessions/{id}/operations",
|
||||||
|
post(operate),
|
||||||
|
)
|
||||||
|
.route("/provider/v1/workdir-sessions/{id}", delete(close_session))
|
||||||
|
.with_state(state.clone());
|
||||||
|
// Binding before spawn makes readiness deterministic; no sleeps or polling.
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let base_url = format!("http://{}/provider/", listener.local_addr().unwrap())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
state,
|
||||||
|
base_url,
|
||||||
|
server,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn open(&self, token: &str) -> Result<RemoteWorkdirSession, WorkdirError> {
|
||||||
|
RemoteWorkdirSession::open(
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
self.base_url.clone(),
|
||||||
|
token.to_owned(),
|
||||||
|
self.state.session.workdir().id().clone(),
|
||||||
|
OpenWorkdirSessionRequest::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public_output(&self, text: &str) {
|
||||||
|
assert!(!text.contains(TOKEN), "bearer token leaked");
|
||||||
|
assert!(
|
||||||
|
!text.contains(self.dir.path().to_str().unwrap()),
|
||||||
|
"host path leaked"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("/provider/"),
|
||||||
|
"transport prefix leaked into Worldspace"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_endpoints(&self) {
|
||||||
|
let open = format!(
|
||||||
|
"/provider/v1/working-directories/{}/sessions",
|
||||||
|
self.state.session.workdir().id()
|
||||||
|
);
|
||||||
|
let operations = format!("/provider/v1/workdir-sessions/{SESSION}/operations");
|
||||||
|
let close = format!("/provider/v1/workdir-sessions/{SESSION}");
|
||||||
|
let requests = self.state.requests.lock().unwrap();
|
||||||
|
assert!(requests.contains(&("POST".into(), open.clone())));
|
||||||
|
assert!(requests.contains(&("POST".into(), operations.clone())));
|
||||||
|
assert_eq!(
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.filter(|(method, path)| method == "DELETE" && path == &close)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert!(requests.iter().all(|(method, path)| (method == "POST"
|
||||||
|
&& (path == &open || path == &operations))
|
||||||
|
|| (method == "DELETE" && path == &close)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn native_runtime(remote: Arc<RemoteWorkdirSession>) -> (WipRuntime, Arc<WorkdirSessionRouter>) {
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote)
|
||||||
|
.unwrap();
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
||||||
|
.unwrap();
|
||||||
|
let runtime = WipRuntime::new(
|
||||||
|
WipHost::new(registry),
|
||||||
|
SecurityContext::new("http-checkout-worker"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(runtime, router)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn observe_interface(runtime: &WipRuntime, path: &str) -> String {
|
||||||
|
runtime.discover(path.into(), 0, true).await.unwrap();
|
||||||
|
// Select from the real Client's Known Space, not a direct handler invocation.
|
||||||
|
let interface = {
|
||||||
|
let state = runtime.state.lock().unwrap();
|
||||||
|
state
|
||||||
|
.client
|
||||||
|
.known_space(&state.session)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|o| o.path == path)
|
||||||
|
.unwrap()
|
||||||
|
.object
|
||||||
|
.unwrap()
|
||||||
|
.interfaces[0]
|
||||||
|
.clone()
|
||||||
|
};
|
||||||
|
runtime.inspect(interface.clone(), true).await.unwrap();
|
||||||
|
interface
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value {
|
||||||
|
let interface = observe_interface(runtime, path).await;
|
||||||
|
runtime
|
||||||
|
.call(
|
||||||
|
path.into(),
|
||||||
|
interface,
|
||||||
|
operation.into(),
|
||||||
|
args,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let state = runtime.state.lock().unwrap();
|
||||||
|
match &state
|
||||||
|
.client
|
||||||
|
.call_history(&state.session)
|
||||||
|
.unwrap()
|
||||||
|
.back()
|
||||||
|
.unwrap()
|
||||||
|
.outcome
|
||||||
|
{
|
||||||
|
CallOutcome::Success(response) => response.result.clone(),
|
||||||
|
outcome => panic!("expected native Client success, got {outcome:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field<'a>(value: &'a Value, name: &str) -> &'a Value {
|
||||||
|
let Value::Record(fields) = value else {
|
||||||
|
panic!("expected typed Record")
|
||||||
|
};
|
||||||
|
fields.get(name).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_link(value: &Value) -> &str {
|
||||||
|
let Value::List(items) = field(value, "items") else {
|
||||||
|
panic!("expected typed link List")
|
||||||
|
};
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
let Value::String(path) = field(&items[0], "path") else {
|
||||||
|
panic!("expected typed link path")
|
||||||
|
};
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn watchdog(future: impl std::future::Future<Output = ()>) {
|
||||||
|
tokio::time::timeout(Duration::from_secs(20), future)
|
||||||
|
.await
|
||||||
|
.expect("HTTP checkout test watchdog");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_http_native_search_links_read_edit_write_create_and_close() {
|
||||||
|
watchdog(async {
|
||||||
|
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
|
||||||
|
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
|
||||||
|
let (runtime, router) = native_runtime(remote);
|
||||||
|
let glob = native_call(
|
||||||
|
&runtime,
|
||||||
|
"/checkouts/main",
|
||||||
|
"glob",
|
||||||
|
json!({"pattern":"**/*.txt"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let file = first_link(&glob).to_owned();
|
||||||
|
assert_eq!(file, "/checkouts/main/src/deep/a.txt");
|
||||||
|
let grep = native_call(
|
||||||
|
&runtime,
|
||||||
|
"/checkouts/main/src",
|
||||||
|
"grep",
|
||||||
|
json!({
|
||||||
|
"pattern":"needle", "output_mode":"files_with_matches"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(first_link(&grep), file);
|
||||||
|
let read = native_call(
|
||||||
|
&runtime,
|
||||||
|
first_link(&grep),
|
||||||
|
"read",
|
||||||
|
json!({"offset":1,"limit":1}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let Value::String(content) = field(&read, "content") else {
|
||||||
|
panic!("expected line Read text")
|
||||||
|
};
|
||||||
|
assert_eq!(content, " 2\tneedle needle\n");
|
||||||
|
let edit = native_call(
|
||||||
|
&runtime,
|
||||||
|
&file,
|
||||||
|
"edit",
|
||||||
|
json!({
|
||||||
|
"old_string":"needle", "new_string":"done", "replace_all":true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
|
||||||
|
"first\ndone done\nlast\n"
|
||||||
|
);
|
||||||
|
let write = native_call(&runtime, &file, "write", json!({"content":"saved\n"})).await;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
|
||||||
|
"saved\n"
|
||||||
|
);
|
||||||
|
let create = native_call(
|
||||||
|
&runtime,
|
||||||
|
"/checkouts/main/src",
|
||||||
|
"create_file",
|
||||||
|
json!({
|
||||||
|
"path":"new/deeper.txt", "content":"created\n"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(fixture.dir.path().join("src/new/deeper.txt")).unwrap(),
|
||||||
|
"created\n"
|
||||||
|
);
|
||||||
|
let created = native_call(
|
||||||
|
&runtime,
|
||||||
|
"/checkouts/main/src/new/deeper.txt",
|
||||||
|
"read",
|
||||||
|
json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
for value in [&glob, &grep, &read, &edit, &write, &create, &created] {
|
||||||
|
fixture.public_output(&serde_json::to_string(&wip_to_json(value).unwrap()).unwrap());
|
||||||
|
}
|
||||||
|
let operations = fixture.state.operations.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
operations
|
||||||
|
.iter()
|
||||||
|
.any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r)
|
||||||
|
if matches!(r.operation, CheckoutSearchOperation::Glob(_))))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
operations
|
||||||
|
.iter()
|
||||||
|
.any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r)
|
||||||
|
if matches!(r.operation, CheckoutSearchOperation::Grep(_))))
|
||||||
|
);
|
||||||
|
assert!(operations.iter().any(
|
||||||
|
|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(r)
|
||||||
|
if matches!(r.operation, CheckoutOperation::Edit { .. }))
|
||||||
|
));
|
||||||
|
assert!(operations.iter().any(
|
||||||
|
|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(r)
|
||||||
|
if matches!(r.operation, CheckoutOperation::Create { .. }))
|
||||||
|
));
|
||||||
|
drop(operations);
|
||||||
|
assert_eq!(fixture.state.write_calls(), 1);
|
||||||
|
router
|
||||||
|
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fixture.assert_endpoints();
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_http_wrong_bearer_and_readonly_caps_fail_closed() {
|
||||||
|
watchdog(async {
|
||||||
|
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
|
||||||
|
let error = fixture.open("wrong-bearer").await.unwrap_err();
|
||||||
|
assert!(matches!(error, WorkdirError::Denied(_)));
|
||||||
|
fixture.public_output(&error.to_string());
|
||||||
|
assert!(fixture.state.operations.lock().unwrap().is_empty());
|
||||||
|
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
|
||||||
|
assert_eq!(remote.capabilities(), WorkdirSessionCapabilities::READ_ONLY);
|
||||||
|
let (runtime, router) = native_runtime(remote.clone());
|
||||||
|
let file = "/checkouts/main/src/deep/a.txt";
|
||||||
|
native_call(&runtime, file, "read", json!({})).await;
|
||||||
|
let interface = observe_interface(&runtime, file).await;
|
||||||
|
{
|
||||||
|
let state = runtime.state.lock().unwrap();
|
||||||
|
let observed = state.client.interface(&state.session, &interface).unwrap();
|
||||||
|
let descriptor = observed.descriptor.as_ref().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
descriptor
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.map(|op| op.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["read"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let directory_interface = observe_interface(&runtime, "/checkouts/main").await;
|
||||||
|
let before = fixture.state.execute_calls();
|
||||||
|
for (path, interface, operation, args) in [
|
||||||
|
(
|
||||||
|
file,
|
||||||
|
interface.clone(),
|
||||||
|
"write",
|
||||||
|
json!({"content":"forbidden"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
file,
|
||||||
|
interface,
|
||||||
|
"edit",
|
||||||
|
json!({"old_string":"needle", "new_string":"forbidden"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/checkouts/main",
|
||||||
|
directory_interface,
|
||||||
|
"create_file",
|
||||||
|
json!({"path":"forbidden.txt","content":"bad"}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let error = runtime
|
||||||
|
.call(
|
||||||
|
path.into(),
|
||||||
|
interface,
|
||||||
|
operation.into(),
|
||||||
|
args,
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
fixture.public_output(&error.to_string());
|
||||||
|
}
|
||||||
|
// Preflight may observe objects, but no rejected mutation reaches execution.
|
||||||
|
assert_eq!(fixture.state.execute_calls(), before);
|
||||||
|
// A caller bypassing WIP publication still cannot mutate at the provider.
|
||||||
|
let parent = remote.checkout_observe(WorkdirPath::root()).await.unwrap();
|
||||||
|
let error = remote
|
||||||
|
.checkout_execute(CheckoutRequest {
|
||||||
|
target: parent.path,
|
||||||
|
validator: parent.validator,
|
||||||
|
operation: CheckoutOperation::Create {
|
||||||
|
path: WorkdirPath::new("forbidden.txt").unwrap(),
|
||||||
|
content: b"bad".to_vec(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
WorkdirError::Unsupported(_)
|
||||||
|
| WorkdirError::UnsupportedOperation(_)
|
||||||
|
| WorkdirError::ReadOnly(_)
|
||||||
|
| WorkdirError::Denied(_)
|
||||||
|
));
|
||||||
|
fixture.public_output(&error.to_string());
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
|
||||||
|
"first\nneedle needle\nlast\n"
|
||||||
|
);
|
||||||
|
assert!(!fixture.dir.path().join("forbidden.txt").exists());
|
||||||
|
router
|
||||||
|
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fixture.assert_endpoints();
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn committed_write_unknown(fault: WriteResponseFault) {
|
||||||
|
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
|
||||||
|
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
|
||||||
|
let (runtime, router) = native_runtime(remote);
|
||||||
|
let file = "/checkouts/main/src/deep/a.txt";
|
||||||
|
native_call(&runtime, file, "read", json!({})).await;
|
||||||
|
let interface = observe_interface(&runtime, file).await;
|
||||||
|
*fixture.state.write_fault.lock().unwrap() = Some(fault);
|
||||||
|
let error = runtime
|
||||||
|
.call(
|
||||||
|
file.into(),
|
||||||
|
interface,
|
||||||
|
"write".into(),
|
||||||
|
json!({"content":"committed\n"}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("outcome unknown"));
|
||||||
|
assert!(error.to_string().contains("do not retry automatically"));
|
||||||
|
fixture.public_output(&error.to_string());
|
||||||
|
assert_eq!(
|
||||||
|
runtime.audit().last().unwrap().outcome,
|
||||||
|
WipAuditOutcome::OutcomeUnknown
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let state = runtime.state.lock().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
state
|
||||||
|
.client
|
||||||
|
.call_history(&state.session)
|
||||||
|
.unwrap()
|
||||||
|
.back()
|
||||||
|
.unwrap()
|
||||||
|
.outcome,
|
||||||
|
CallOutcome::Unknown { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
|
||||||
|
"committed\n"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.state.write_calls(),
|
||||||
|
1,
|
||||||
|
"committed Write must not be automatically retried"
|
||||||
|
);
|
||||||
|
// Explicit inspection of the effect is safe; it must not replay the mutation.
|
||||||
|
let read = native_call(&runtime, file, "read", json!({})).await;
|
||||||
|
fixture.public_output(&serde_json::to_string(&wip_to_json(&read).unwrap()).unwrap());
|
||||||
|
let Value::String(content) = field(&read, "content") else {
|
||||||
|
panic!("expected Read text")
|
||||||
|
};
|
||||||
|
assert!(content.contains("committed"));
|
||||||
|
assert_eq!(fixture.state.write_calls(), 1);
|
||||||
|
router
|
||||||
|
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fixture.assert_endpoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_http_malformed_response_after_write_is_unknown_without_retry() {
|
||||||
|
watchdog(committed_write_unknown(WriteResponseFault::Malformed)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_http_lost_response_after_write_is_unknown_without_retry() {
|
||||||
|
watchdog(committed_write_unknown(WriteResponseFault::LostBody)).await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
//! Deterministic attachment races at provider observation boundaries.
|
||||||
|
//!
|
||||||
|
//! These tests deliberately avoid spawned tasks, sleeps, and detach attempts
|
||||||
|
//! inside a routed operation (which is busy and cannot be detached). Deadline
|
||||||
|
//! tests use the provider's configurable deadline and a permanently pending
|
||||||
|
//! observation, without Tokio's test-util clock or the production 30-second wait.
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::future::pending;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use workdir::{
|
||||||
|
CheckoutRequest, CheckoutResult, CommandHandle, CommandOutput, CommandOutputRequest,
|
||||||
|
CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
|
||||||
|
GrepResult, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult, ReadRequest,
|
||||||
|
ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias, WorkdirError,
|
||||||
|
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||||
|
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ObserveHook = Box<dyn FnOnce(&WorkdirPath) + Send>;
|
||||||
|
|
||||||
|
/// Shared local provider with a one-shot, synchronous observation boundary.
|
||||||
|
/// Take the callback out of the lock before invoking it: it may call the router.
|
||||||
|
struct HookedSession {
|
||||||
|
local: LocalWorkdirSession,
|
||||||
|
observe_hook: Mutex<Option<ObserveHook>>,
|
||||||
|
stall_path: Mutex<Option<WorkdirPath>>,
|
||||||
|
stalled_observations: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for HookedSession {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("HookedSession")
|
||||||
|
.field("local", &self.local)
|
||||||
|
.field("stall_path", &self.stall_path)
|
||||||
|
.field("stalled_observations", &self.stalled_observations)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HookedSession {
|
||||||
|
fn new(dir: &TempDir) -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
local: LocalWorkdirSession::materialized(
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
||||||
|
WorkdirSessionCapabilities::READ_WRITE,
|
||||||
|
),
|
||||||
|
observe_hook: Mutex::new(None),
|
||||||
|
stall_path: Mutex::new(None),
|
||||||
|
stalled_observations: AtomicUsize::new(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_next_observe(&self, hook: impl FnOnce(&WorkdirPath) + Send + 'static) {
|
||||||
|
let mut slot = self.observe_hook.lock().unwrap();
|
||||||
|
assert!(slot.is_none(), "an observation hook is already armed");
|
||||||
|
*slot = Some(Box::new(hook));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stall_observation(&self, path: Option<WorkdirPath>) {
|
||||||
|
*self.stall_path.lock().unwrap() = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkdirSession for HookedSession {
|
||||||
|
fn workdir(&self) -> &Workdir {
|
||||||
|
self.local.workdir()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||||
|
self.local.capabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorize_scope_path(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
|
) -> Result<(), WorkdirError> {
|
||||||
|
self.local.authorize_scope_path(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scope_rules_overlap(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeOverlapRequest,
|
||||||
|
) -> Result<bool, WorkdirError> {
|
||||||
|
self.local.scope_rules_overlap(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn checkout_observe(
|
||||||
|
&self,
|
||||||
|
path: WorkdirPath,
|
||||||
|
) -> Result<CheckoutObservation, WorkdirError> {
|
||||||
|
let hook = { self.observe_hook.lock().unwrap().take() };
|
||||||
|
if let Some(hook) = hook {
|
||||||
|
hook(&path);
|
||||||
|
}
|
||||||
|
let should_stall = {
|
||||||
|
self.stall_path
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|stall| stall.as_str() == path.as_str())
|
||||||
|
};
|
||||||
|
if should_stall {
|
||||||
|
self.stalled_observations.fetch_add(1, Ordering::SeqCst);
|
||||||
|
return pending().await;
|
||||||
|
}
|
||||||
|
self.local.checkout_observe(path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn checkout_execute(
|
||||||
|
&self,
|
||||||
|
request: CheckoutRequest,
|
||||||
|
) -> Result<CheckoutResult, WorkdirError> {
|
||||||
|
self.local.checkout_execute(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn checkout_search(
|
||||||
|
&self,
|
||||||
|
request: workdir::CheckoutSearchRequest,
|
||||||
|
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
|
||||||
|
self.local.checkout_search(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
|
self.local.stat(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||||
|
self.local.read(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
|
||||||
|
self.local.read_bytes(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||||
|
self.local.write(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||||
|
self.local.edit(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||||
|
self.local.list(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||||
|
self.local.glob(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||||
|
self.local.grep(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||||
|
self.local.start_command(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||||
|
self.local.command_status(handle).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_output(
|
||||||
|
&self,
|
||||||
|
request: CommandOutputRequest,
|
||||||
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
|
self.local.command_output(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||||
|
self.local.cancel_command(handle).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&self) -> Result<(), WorkdirError> {
|
||||||
|
self.local.close().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider(router: Arc<WorkdirSessionRouter>) -> Arc<Provider> {
|
||||||
|
Arc::new(Provider {
|
||||||
|
router,
|
||||||
|
tracker: tools::Tracker::new(),
|
||||||
|
permissions: None,
|
||||||
|
incarnation: "checkout-race-tests".into(),
|
||||||
|
permits: Arc::new(tokio::sync::Semaphore::new(16)),
|
||||||
|
deadline: DEADLINE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deadline_provider(router: Arc<WorkdirSessionRouter>) -> Arc<Provider> {
|
||||||
|
let mut p = provider(router);
|
||||||
|
let settings = Arc::get_mut(&mut p).unwrap();
|
||||||
|
// Allow ample time for the tiny local search before stalling post-processing.
|
||||||
|
// The outer test watchdog is intentionally much more generous than this.
|
||||||
|
settings.deadline = Duration::from_millis(100);
|
||||||
|
settings.permits = Arc::new(tokio::sync::Semaphore::new(1));
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn context() -> WipCallContext {
|
||||||
|
WipCallContext {
|
||||||
|
execution: Default::default(),
|
||||||
|
security_context: "checkout-race-tests".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_validator_mismatch<T>(result: Result<T, WipOperationError>) {
|
||||||
|
match result {
|
||||||
|
Err(WipOperationError::Protocol(e)) => {
|
||||||
|
assert_eq!(e.code, ProtocolErrorCode::ValidatorMismatch);
|
||||||
|
}
|
||||||
|
Err(_) => panic!("expected ValidatorMismatch, received a non-protocol error"),
|
||||||
|
Ok(_) => panic!("a changed attachment snapshot must not be published"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collection_list_rejects_alias_attached_during_observation_and_retries_coherently() {
|
||||||
|
let main_dir = TempDir::new().unwrap();
|
||||||
|
let extra_dir = TempDir::new().unwrap();
|
||||||
|
let main = HookedSession::new(&main_dir);
|
||||||
|
let extra: WorkdirSessionHandle = HookedSession::new(&extra_dir);
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(WorkdirAttachmentAlias::new("main").unwrap(), main.clone())
|
||||||
|
.unwrap();
|
||||||
|
let p = provider(router.clone());
|
||||||
|
let before = p.collection().object.validator;
|
||||||
|
let hook_router = router.clone();
|
||||||
|
main.on_next_observe(move |path| {
|
||||||
|
assert!(path.is_root(), "collection enumeration observes the root");
|
||||||
|
hook_router
|
||||||
|
.attach(WorkdirAttachmentAlias::new("extra").unwrap(), extra)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let collection = Collection {
|
||||||
|
provider: p.clone(),
|
||||||
|
};
|
||||||
|
assert_validator_mismatch(collection.call("list", &BTreeMap::new(), context()).await);
|
||||||
|
assert_ne!(before, p.collection().object.validator);
|
||||||
|
assert!(router.resolve(Some("extra")).is_ok(), "the hook must run");
|
||||||
|
|
||||||
|
// The callback was consumed. A fresh snapshot includes both aliases, with
|
||||||
|
// identity metadata from exactly the router state being enumerated.
|
||||||
|
let output = match collection.call("list", &BTreeMap::new(), context()).await {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(_) => panic!("the stable retry must succeed"),
|
||||||
|
};
|
||||||
|
let value = wip_to_json(&output.value).unwrap();
|
||||||
|
let items = value["items"].as_array().unwrap();
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
for alias in ["extra", "main"] {
|
||||||
|
let item = items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item["alias"] == json!(alias))
|
||||||
|
.expect("each attached alias must appear in the stable snapshot");
|
||||||
|
let selected = router.resolve(Some(alias)).unwrap();
|
||||||
|
assert_eq!(item["alias"], json!(alias));
|
||||||
|
assert_eq!(item["path"], json!(checkout_root(alias)));
|
||||||
|
assert_eq!(item["slug"], json!(encode_identity(alias)));
|
||||||
|
assert_eq!(item["generation"], json!(selected.generation));
|
||||||
|
assert_eq!(
|
||||||
|
item["working_directory_id"],
|
||||||
|
json!(selected.session.workdir().id().as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn root_enumeration_rejects_alias_attached_during_observation() {
|
||||||
|
let main_dir = TempDir::new().unwrap();
|
||||||
|
let extra_dir = TempDir::new().unwrap();
|
||||||
|
let main = HookedSession::new(&main_dir);
|
||||||
|
let extra: WorkdirSessionHandle = HookedSession::new(&extra_dir);
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(WorkdirAttachmentAlias::new("main").unwrap(), main.clone())
|
||||||
|
.unwrap();
|
||||||
|
let hook_router = router.clone();
|
||||||
|
main.on_next_observe(move |_| {
|
||||||
|
hook_router
|
||||||
|
.attach(WorkdirAttachmentAlias::new("extra").unwrap(), extra)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let subtree = Arc::new(provider(router.clone()));
|
||||||
|
|
||||||
|
match subtree.children(ROOT).await {
|
||||||
|
Err(e) => assert_eq!(e.code, ProtocolErrorCode::ValidatorMismatch),
|
||||||
|
Ok(_) => panic!("enumeration must reject a mixed attachment snapshot"),
|
||||||
|
}
|
||||||
|
assert!(router.resolve(Some("extra")).is_ok(), "the hook must run");
|
||||||
|
let mut paths = subtree.children(ROOT).await.unwrap();
|
||||||
|
paths.sort();
|
||||||
|
assert_eq!(paths, vec![checkout_root("extra"), checkout_root("main")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn file_handler_connection_fences_detach_and_same_session_alias_reuse() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("file.txt"), "unchanged\n").unwrap();
|
||||||
|
let session: WorkdirSessionHandle = HookedSession::new(&dir);
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
let alias = WorkdirAttachmentAlias::new("main").unwrap();
|
||||||
|
router.attach(alias.clone(), session.clone()).unwrap();
|
||||||
|
let selected = router.resolve(Some("main")).unwrap();
|
||||||
|
let target = WorkdirPath::new("file.txt").unwrap();
|
||||||
|
let observation = selected
|
||||||
|
.session
|
||||||
|
.checkout_observe(target.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut handler = FileHandler {
|
||||||
|
provider: provider(router.clone()),
|
||||||
|
alias: "main".into(),
|
||||||
|
generation: selected.generation,
|
||||||
|
workdir: selected.session.workdir().id().as_str().into(),
|
||||||
|
target,
|
||||||
|
observation,
|
||||||
|
};
|
||||||
|
drop(selected);
|
||||||
|
assert!(handler.check_connection().is_ok());
|
||||||
|
|
||||||
|
// No routed operation is active here. Reuse the exact same provider identity
|
||||||
|
// to ensure generation, not changed workdir ID/content, causes rejection.
|
||||||
|
// Detach may close the local session; no subsequent filesystem operation is
|
||||||
|
// made on it. This test only exercises the router's connection identity.
|
||||||
|
router.detach(&alias).await.unwrap();
|
||||||
|
assert_validator_mismatch(handler.check_connection());
|
||||||
|
router.attach(alias, session).unwrap();
|
||||||
|
let replacement = router.resolve(Some("main")).unwrap();
|
||||||
|
assert_ne!(replacement.generation, handler.generation);
|
||||||
|
assert_eq!(replacement.session.workdir().id().as_str(), handler.workdir);
|
||||||
|
assert_validator_mismatch(handler.check_connection());
|
||||||
|
|
||||||
|
// Positive control: the new generation is accepted with the same identity.
|
||||||
|
handler.generation = replacement.generation;
|
||||||
|
assert!(handler.check_connection().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_deadline<T>(result: Result<T, WipOperationError>, message: &str) {
|
||||||
|
match result {
|
||||||
|
Err(WipOperationError::Protocol(e)) => {
|
||||||
|
assert_eq!(e.code, ProtocolErrorCode::ResourceLimitExceeded);
|
||||||
|
assert_eq!(e.message, message);
|
||||||
|
}
|
||||||
|
Err(_) => panic!("expected a protocol deadline error"),
|
||||||
|
Ok(_) => panic!("a stalled provider observation must time out"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_permit_released(p: &Provider) {
|
||||||
|
assert_eq!(p.permits.available_permits(), 1);
|
||||||
|
let permit = p
|
||||||
|
.permits
|
||||||
|
.try_acquire()
|
||||||
|
.expect("the timed-out operation must release its permit");
|
||||||
|
drop(permit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collection_list_and_root_enumeration_bound_stalled_observation_and_release_permit() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let session = HookedSession::new(&dir);
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
session.stall_observation(Some(WorkdirPath::new("").unwrap()));
|
||||||
|
let p = deadline_provider(router);
|
||||||
|
let collection = Collection {
|
||||||
|
provider: p.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The outer watchdog prevents a missing production timeout from hanging the
|
||||||
|
// suite. The exact protocol message proves the inner enumeration timed out.
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
collection.call("list", &BTreeMap::new(), context()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Collection.list must be bounded by the provider deadline");
|
||||||
|
assert_deadline(result, "checkout enumeration deadline");
|
||||||
|
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1);
|
||||||
|
assert_permit_released(&p);
|
||||||
|
|
||||||
|
// Exercise the subtree entry point independently with the stall still armed.
|
||||||
|
let subtree = Arc::new(p.clone());
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(2), subtree.children(ROOT))
|
||||||
|
.await
|
||||||
|
.expect("root enumeration must be bounded by the provider deadline")
|
||||||
|
.map_err(WipOperationError::Protocol);
|
||||||
|
assert_deadline(result, "checkout enumeration deadline");
|
||||||
|
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 2);
|
||||||
|
assert_permit_released(&p);
|
||||||
|
|
||||||
|
// Removing the stall makes the same provider usable immediately, without
|
||||||
|
// resetting its semaphore or waiting for any background task to finish.
|
||||||
|
session.stall_observation(None);
|
||||||
|
let paths = tokio::time::timeout(Duration::from_secs(2), subtree.children(ROOT))
|
||||||
|
.await
|
||||||
|
.expect("enumeration must remain usable after cancellation")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(paths, vec![checkout_root("main")]);
|
||||||
|
assert_permit_released(&p);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_permit() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap();
|
||||||
|
let session = HookedSession::new(&dir);
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let p = deadline_provider(router);
|
||||||
|
let root = checkout_root("main");
|
||||||
|
let projection = p.node(&root).await.unwrap().unwrap();
|
||||||
|
|
||||||
|
// Directory observations and the normal local grep/checkout execution can
|
||||||
|
// complete. Only publishing the typed search result path gets stuck.
|
||||||
|
session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap()));
|
||||||
|
let arguments = BTreeMap::from([
|
||||||
|
("pattern".into(), Value::String("needle".into())),
|
||||||
|
(
|
||||||
|
"output_mode".into(),
|
||||||
|
Value::String("files_with_matches".into()),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
projection.handler.call("grep", &arguments, context()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("post-search link observations must share the operation deadline");
|
||||||
|
assert_deadline(result, "checkout result deadline");
|
||||||
|
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1);
|
||||||
|
assert_permit_released(&p);
|
||||||
|
|
||||||
|
// A successful retry both checks cancellation cleanup and proves that the
|
||||||
|
// delegated search produces the typed path consumed by post-processing.
|
||||||
|
session.stall_observation(None);
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
projection.handler.call("grep", &arguments, context()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("grep must remain usable after cancelling result publication");
|
||||||
|
let output = match result {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(_) => panic!("grep must succeed once the link observation is unstalled"),
|
||||||
|
};
|
||||||
|
let value = wip_to_json(&output.value).unwrap();
|
||||||
|
assert_eq!(value["items"], json!([{"path": format!("{root}/a.txt")}]));
|
||||||
|
assert_permit_released(&p);
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
//! Exercises native checkout interactions through the real WIP Client/HTTP codec.
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use workdir::{
|
||||||
|
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionCapabilities, WorkdirSessionHandle,
|
||||||
|
WorkdirSessionRouter,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn session(dir: &TempDir, read_only: bool) -> WorkdirSessionHandle {
|
||||||
|
let caps = if read_only {
|
||||||
|
WorkdirSessionCapabilities::READ_ONLY
|
||||||
|
} else {
|
||||||
|
WorkdirSessionCapabilities::READ_WRITE
|
||||||
|
};
|
||||||
|
Arc::new(LocalWorkdirSession::materialized(
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
|
||||||
|
caps,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
fn runtime(
|
||||||
|
router: Arc<WorkdirSessionRouter>,
|
||||||
|
permissions: Option<ToolPermissionConfig>,
|
||||||
|
) -> WipRuntime {
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), permissions)
|
||||||
|
.unwrap();
|
||||||
|
WipRuntime::new(
|
||||||
|
WipHost::new(registry),
|
||||||
|
SecurityContext::new("checkout-test-worker"),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
async fn interface(r: &WipRuntime, path: &str) -> String {
|
||||||
|
r.discover(path.into(), 0, true).await.unwrap();
|
||||||
|
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||||
|
r.inspect(p.interface.clone(), true).await.unwrap();
|
||||||
|
p.interface
|
||||||
|
}
|
||||||
|
async fn call(
|
||||||
|
r: &WipRuntime,
|
||||||
|
path: &str,
|
||||||
|
operation: &str,
|
||||||
|
args: Json,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let i = interface(r, path).await;
|
||||||
|
r.call(path.into(), i, operation.into(), args, Default::default())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_wip_native_roundtrip_search_read_edit_write_create() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("src/deep")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("src/deep/a.txt"),
|
||||||
|
"first\nneedle needle\nlast\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session(&dir, false),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = runtime(router, None);
|
||||||
|
let root = r.host.observe_live("/", 2).await.unwrap();
|
||||||
|
assert_eq!(root.children.unwrap()[0].object.name, "checkouts");
|
||||||
|
let file = "/checkouts/main/src/deep/a.txt";
|
||||||
|
let glob = call(&r, "/checkouts/main", "glob", json!({"pattern":"**/*.txt"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(glob.content.unwrap().contains(file));
|
||||||
|
let grep = call(
|
||||||
|
&r,
|
||||||
|
"/checkouts/main/src",
|
||||||
|
"grep",
|
||||||
|
json!({"pattern":"needle","output_mode":"files_with_matches"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let grep_result: Json = serde_json::from_str(grep.content.as_deref().unwrap()).unwrap();
|
||||||
|
assert_eq!(grep_result["items"][0]["path"], file);
|
||||||
|
let followed = grep_result["items"][0]["path"].as_str().unwrap();
|
||||||
|
assert_eq!(followed, file);
|
||||||
|
assert!(
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
file,
|
||||||
|
"edit",
|
||||||
|
json!({"old_string":"needle","new_string":"done"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let read = call(&r, file, "read", json!({"offset":1,"limit":1}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(read.content.unwrap().contains("needle needle"));
|
||||||
|
assert!(
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
file,
|
||||||
|
"edit",
|
||||||
|
json!({"old_string":"needle","new_string":"done"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
file,
|
||||||
|
"edit",
|
||||||
|
json!({"old_string":"needle","new_string":"done","replace_all":true}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(dir.path().join("src/deep/a.txt")).unwrap(),
|
||||||
|
"first\ndone done\nlast\n"
|
||||||
|
);
|
||||||
|
call(&r, file, "write", json!({"content":"saved\n"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let reread = call(&r, file, "read", json!({})).await.unwrap();
|
||||||
|
assert!(reread.content.unwrap().contains("saved"));
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
"/checkouts/main/src",
|
||||||
|
"create_file",
|
||||||
|
json!({"path":"new/deeper.txt","content":"created\n"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(dir.path().join("src/new/deeper.txt")).unwrap(),
|
||||||
|
"created\n"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
"/checkouts/main/src",
|
||||||
|
"create_file",
|
||||||
|
json!({"path":"new/deeper.txt","content":"overwrite"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
call(&r, "/checkouts/main/src/new/deeper.txt", "read", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_wip_multiple_aliases_read_only_and_escape_rejection() {
|
||||||
|
let a = TempDir::new().unwrap();
|
||||||
|
let b = TempDir::new().unwrap();
|
||||||
|
std::fs::write(a.path().join("a.txt"), "A").unwrap();
|
||||||
|
std::fs::write(b.path().join("b.txt"), "B").unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("a").unwrap(),
|
||||||
|
session(&a, false),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
router
|
||||||
|
.attach(WorkdirAttachmentAlias::new("b").unwrap(), session(&b, true))
|
||||||
|
.unwrap();
|
||||||
|
let r = runtime(router, None);
|
||||||
|
let observed = r.host.observe_live("/checkouts", 2).await.unwrap();
|
||||||
|
assert_eq!(observed.children.unwrap().len(), 2);
|
||||||
|
let bfile = r
|
||||||
|
.host
|
||||||
|
.projection_live("/checkouts/b/b.txt")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
bfile
|
||||||
|
.descriptor
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.map(|o| o.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["read"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
r.host
|
||||||
|
.projection_live("/checkouts/b/a.txt")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
r.host
|
||||||
|
.projection_live("/checkouts/a/../b/b.txt")
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
"/checkouts/a",
|
||||||
|
"glob",
|
||||||
|
json!({"pattern":"*","path":"../b"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
"/checkouts/a",
|
||||||
|
"create_file",
|
||||||
|
json!({"path":"/outside.txt","content":"bad"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
"/checkouts/a/a.txt",
|
||||||
|
"read",
|
||||||
|
json!({"target_workdir":"b"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
"/checkouts/a/a.txt",
|
||||||
|
"read",
|
||||||
|
json!({"file_path":"b.txt"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
|
||||||
|
let a = TempDir::new().unwrap();
|
||||||
|
let b = TempDir::new().unwrap();
|
||||||
|
std::fs::write(a.path().join("file.txt"), "same").unwrap();
|
||||||
|
std::fs::write(b.path().join("file.txt"), "same").unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
let alias = WorkdirAttachmentAlias::new("main").unwrap();
|
||||||
|
router.attach(alias.clone(), session(&a, false)).unwrap();
|
||||||
|
let r = runtime(router.clone(), None);
|
||||||
|
let path = "/checkouts/main/file.txt";
|
||||||
|
call(&r, path, "read", json!({})).await.unwrap();
|
||||||
|
let old = r.host.projection_live(path).await.unwrap().unwrap();
|
||||||
|
router.detach(&alias).await.unwrap();
|
||||||
|
assert!(r.host.projection_live(path).await.unwrap().is_none());
|
||||||
|
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
|
||||||
|
router.attach(alias, session(&b, false)).unwrap();
|
||||||
|
let new = r.host.projection_live(path).await.unwrap().unwrap();
|
||||||
|
assert_ne!(old.interface, new.interface);
|
||||||
|
assert_ne!(old.object.validator, new.object.validator);
|
||||||
|
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
|
||||||
|
assert!(
|
||||||
|
call(
|
||||||
|
&r,
|
||||||
|
path,
|
||||||
|
"write",
|
||||||
|
json!({"content":"must read new attachment"})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let restored = runtime(router, None);
|
||||||
|
let next = restored.host.projection_live(path).await.unwrap().unwrap();
|
||||||
|
assert_ne!(new.interface, next.interface);
|
||||||
|
assert_ne!(new.object.validator, next.object.validator);
|
||||||
|
assert!(
|
||||||
|
restored
|
||||||
|
.host
|
||||||
|
.fetch_interface_live(&new.interface)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(b.path().join("file.txt")).unwrap(),
|
||||||
|
"same"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_wip_provider_validator_rejects_same_content_inode_substitution() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("file.txt"), "same").unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session(&dir, false),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = runtime(router, None);
|
||||||
|
let path = "/checkouts/main/file.txt";
|
||||||
|
call(&r, path, "read", json!({})).await.unwrap();
|
||||||
|
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||||
|
std::fs::write(dir.path().join("replacement"), "same").unwrap();
|
||||||
|
std::fs::rename(dir.path().join("replacement"), dir.path().join("file.txt")).unwrap();
|
||||||
|
// Call the captured handler directly: provider must reject even if Host validation was earlier.
|
||||||
|
let out = p
|
||||||
|
.handler
|
||||||
|
.call(
|
||||||
|
"write",
|
||||||
|
&BTreeMap::from([("content".into(), Value::String("bad".into()))]),
|
||||||
|
WipCallContext {
|
||||||
|
execution: Default::default(),
|
||||||
|
security_context: "checkout-test-worker".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(
|
||||||
|
out,
|
||||||
|
Err(WipOperationError::Protocol(ProtocolError {
|
||||||
|
code: ProtocolErrorCode::ValidatorMismatch,
|
||||||
|
..
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
|
||||||
|
"same"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_replaced() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("file.txt"), "secret").unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session(&dir, false),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let permissions = ToolPermissionConfig {
|
||||||
|
default_action: ToolPermissionAction::Deny,
|
||||||
|
rules: Vec::new(),
|
||||||
|
};
|
||||||
|
let r = runtime(router.clone(), Some(permissions));
|
||||||
|
assert!(
|
||||||
|
r.host
|
||||||
|
.projection_live("/checkouts/main/file.txt")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
r.host
|
||||||
|
.observe_live("/checkouts", 1)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.children
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
||||||
|
.unwrap();
|
||||||
|
for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) {
|
||||||
|
let (meta, tool) = def();
|
||||||
|
let p = compatibility_projection(meta, tool, None).unwrap();
|
||||||
|
registry.mount(p).unwrap();
|
||||||
|
}
|
||||||
|
for tool in ["Read", "Edit", "Write", "Glob", "Grep"] {
|
||||||
|
assert!(!registry.routes().any(|p| p == format!("/tools/{tool}")));
|
||||||
|
}
|
||||||
|
assert!(registry.routes().any(|p| p == "/tools/Bash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_wip_bounded_observation_and_deep_subtree_collision() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session(&dir, false),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut registry = WipMountRegistry::new();
|
||||||
|
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap();
|
||||||
|
let placeholder = registry.mounts["/checkouts"].resolved();
|
||||||
|
let mut collision = placeholder.clone();
|
||||||
|
collision.route = "/checkouts/main/a".into();
|
||||||
|
collision.object.name = "a".into();
|
||||||
|
assert!(matches!(
|
||||||
|
registry.mount(collision),
|
||||||
|
Err(WipMountError::RouteCollision { .. })
|
||||||
|
));
|
||||||
|
let host = WipHost::new(registry);
|
||||||
|
assert_eq!(
|
||||||
|
host.observe_live("/checkouts", 33).await.unwrap_err().code,
|
||||||
|
ProtocolErrorCode::ResourceLimitExceeded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_wip_post_operation_validators_support_cached_chains() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("file.txt"), "old\n").unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session(&dir, false),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = runtime(router, None);
|
||||||
|
let path = "/checkouts/main/file.txt";
|
||||||
|
let i = interface(&r, path).await;
|
||||||
|
// No rediscovery or Interface refresh between any of these operations.
|
||||||
|
for (op, args) in [
|
||||||
|
("read", json!({})),
|
||||||
|
("edit", json!({"old_string":"old","new_string":"edited"})),
|
||||||
|
("write", json!({"content":"saved\n"})),
|
||||||
|
("read", json!({})),
|
||||||
|
] {
|
||||||
|
r.call(path.into(), i.clone(), op.into(), args, Default::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
|
||||||
|
"saved\n"
|
||||||
|
);
|
||||||
|
let parent = "/checkouts/main";
|
||||||
|
let i = interface(&r, parent).await;
|
||||||
|
for name in ["one.txt", "nested/two.txt"] {
|
||||||
|
r.call(
|
||||||
|
parent.into(),
|
||||||
|
i.clone(),
|
||||||
|
"create_file".into(),
|
||||||
|
json!({"path":name,"content":"new"}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(dir.path().join("nested/two.txt")).unwrap(),
|
||||||
|
"new"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||||
|
use workdir::{
|
||||||
|
WorkdirPath, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission as Permission,
|
||||||
|
WorkdirToolScopeRule,
|
||||||
|
};
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir(dir.path().join("child")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("child/a.txt"), "visible needle").unwrap();
|
||||||
|
std::fs::write(dir.path().join("private-sibling-name.txt"), "hidden needle").unwrap();
|
||||||
|
let broker = Arc::new(WorkdirToolBroker::new(session(&dir, false)));
|
||||||
|
let lease = broker
|
||||||
|
.scope(WorkdirToolScope {
|
||||||
|
rules: vec![WorkdirToolScopeRule {
|
||||||
|
target: WorkdirPath::new("child").unwrap(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
|
}],
|
||||||
|
cwd: WorkdirPath::new("child").unwrap(),
|
||||||
|
command: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let parent_router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
parent_router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("parent").unwrap(),
|
||||||
|
broker.tool_session(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let parent = runtime(parent_router, None);
|
||||||
|
let parent_file = parent
|
||||||
|
.host
|
||||||
|
.projection_live("/checkouts/parent/child/a.txt")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!parent_file
|
||||||
|
.descriptor
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.any(|op| op.name == "write" || op.name == "edit")
|
||||||
|
);
|
||||||
|
let child_router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
child_router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("child").unwrap(),
|
||||||
|
lease.tool_session(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let child = runtime(child_router, None);
|
||||||
|
let observed = child
|
||||||
|
.host
|
||||||
|
.observe_live("/checkouts/child", 2)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(observed.children.unwrap().len(), 1);
|
||||||
|
for (op, args) in [
|
||||||
|
("glob", json!({"pattern":"**/*"})),
|
||||||
|
(
|
||||||
|
"grep",
|
||||||
|
json!({"pattern":"needle","output_mode":"files_with_matches"}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let output = call(&child, "/checkouts/child", op, args)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.content
|
||||||
|
.unwrap();
|
||||||
|
assert!(output.contains("/checkouts/child/a.txt"));
|
||||||
|
assert!(!output.contains("private-sibling-name"));
|
||||||
|
assert!(!output.contains("hidden needle"));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
child
|
||||||
|
.host
|
||||||
|
.projection_live("/checkouts/child/private-sibling-name.txt")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let file = "/checkouts/child/a.txt";
|
||||||
|
let i = interface(&child, file).await;
|
||||||
|
child
|
||||||
|
.call(
|
||||||
|
file.into(),
|
||||||
|
i.clone(),
|
||||||
|
"read".into(),
|
||||||
|
json!({}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
child
|
||||||
|
.call(
|
||||||
|
file.into(),
|
||||||
|
i.clone(),
|
||||||
|
"write".into(),
|
||||||
|
json!({"content":"changed"}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
lease.close().await.unwrap();
|
||||||
|
assert!(child.host.projection_live(file).await.unwrap().is_none());
|
||||||
|
assert!(
|
||||||
|
child
|
||||||
|
.call(
|
||||||
|
file.into(),
|
||||||
|
i,
|
||||||
|
"write".into(),
|
||||||
|
json!({"content":"after revoke"}),
|
||||||
|
Default::default()
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(dir.path().join("child/a.txt")).unwrap(),
|
||||||
|
"changed"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1597,6 +1597,20 @@ where
|
|||||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||||
let wip_permissions = worker.manifest().permissions.clone();
|
let wip_permissions = worker.manifest().permissions.clone();
|
||||||
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
|
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 {
|
if wip_mode && let Some(feature) = &workdir_wip_feature {
|
||||||
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
||||||
&mut wip_mount_registry,
|
&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> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
match self.operate(WorkdirSessionOperation::Stat(request))? {
|
match self.operate(WorkdirSessionOperation::Stat(request))? {
|
||||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||||
@@ -1414,6 +1510,56 @@ mod tests {
|
|||||||
assert!(body.get("session_id").is_none());
|
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]
|
#[tokio::test]
|
||||||
async fn attached_session_does_not_send_worker_local_bash_spill_path() {
|
async fn attached_session_does_not_send_worker_local_bash_spill_path() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
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) {
|
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)));
|
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)));
|
result.insert("path".into(), json!(item_path(domain, id)));
|
||||||
Ok(Json::Object(result))
|
Ok(Json::Object(result))
|
||||||
@@ -735,6 +749,17 @@ impl WipOperationHandler for ItemHandler {
|
|||||||
let mut result = raw;
|
let mut result = raw;
|
||||||
result["workdir_path"] = json!(item_path(Domain::Workdir, &self.id));
|
result["workdir_path"] = json!(item_path(Domain::Workdir, &self.id));
|
||||||
result["attachments_path"] = json!(ATTACHMENTS);
|
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.
|
// Backend attachment list supplies the durable lifetime identity.
|
||||||
result
|
result
|
||||||
} else {
|
} 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
|
// Readable ASCII identities remain unchanged. Every other UTF-8 identity uses a
|
||||||
// canonical ~hex segment; the escape marker is itself always escaped. This is
|
// canonical ~hex segment; the escape marker is itself always escaped. This is
|
||||||
// injective, does not exclude existing keys, and never decodes a second route.
|
// 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()
|
if !id.is_empty()
|
||||||
&& id
|
&& id
|
||||||
.bytes()
|
.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('~') {
|
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()) {
|
if hex.len() % 2 != 0 || hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod bootstrap;
|
pub mod bootstrap;
|
||||||
|
pub mod checkout;
|
||||||
pub mod compact;
|
pub mod compact;
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
|
|||||||
+362
-7
@@ -42,6 +42,13 @@ use wip_protocol::{
|
|||||||
|
|
||||||
use crate::permission::permission_action_for;
|
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_ENDPOINT: &str = "https://worker.wip.invalid/v1/";
|
||||||
const WIP_ROOT: &str = "/";
|
const WIP_ROOT: &str = "/";
|
||||||
const WIP_TOOLS_ROOT: &str = "/tools";
|
const WIP_TOOLS_ROOT: &str = "/tools";
|
||||||
@@ -92,6 +99,8 @@ pub struct WipCallContext {
|
|||||||
pub struct WipOperationOutput {
|
pub struct WipOperationOutput {
|
||||||
pub value: Value,
|
pub value: Value,
|
||||||
tool_output: Option<ToolOutput>,
|
tool_output: Option<ToolOutput>,
|
||||||
|
// Exact post-operation provider state; never substitute a later path stat.
|
||||||
|
validator: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WipOperationOutput {
|
impl WipOperationOutput {
|
||||||
@@ -99,6 +108,15 @@ impl WipOperationOutput {
|
|||||||
Self {
|
Self {
|
||||||
value,
|
value,
|
||||||
tool_output: None,
|
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 {
|
Self {
|
||||||
value,
|
value,
|
||||||
tool_output: Some(output),
|
tool_output: Some(output),
|
||||||
|
validator: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +237,19 @@ pub struct WipDynamicMount {
|
|||||||
pub resolver: Arc<dyn WipDynamicItemResolver>,
|
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 {
|
pub trait WipDynamicOperationResolver: Send + Sync {
|
||||||
fn handler(&self, item_reference: &str) -> Arc<dyn WipOperationHandler>;
|
fn handler(&self, item_reference: &str) -> Arc<dyn WipOperationHandler>;
|
||||||
}
|
}
|
||||||
@@ -327,6 +359,7 @@ impl WipOperationHandler for OperationDispatchHandler {
|
|||||||
pub struct WipMountRegistry {
|
pub struct WipMountRegistry {
|
||||||
mounts: BTreeMap<String, MountedProjection>,
|
mounts: BTreeMap<String, MountedProjection>,
|
||||||
dynamic_mounts: Vec<WipDynamicMount>,
|
dynamic_mounts: Vec<WipDynamicMount>,
|
||||||
|
subtree_mounts: Vec<WipSubtreeMount>,
|
||||||
dynamic_operation_contributions: BTreeMap<String, Vec<MountedDynamicOperationContribution>>,
|
dynamic_operation_contributions: BTreeMap<String, Vec<MountedDynamicOperationContribution>>,
|
||||||
namespaces: BTreeMap<String, String>,
|
namespaces: BTreeMap<String, String>,
|
||||||
replaced_compatibility_capabilities: BTreeMap<String, String>,
|
replaced_compatibility_capabilities: BTreeMap<String, String>,
|
||||||
@@ -395,6 +428,11 @@ impl WipMountRegistry {
|
|||||||
projection: WipProjection,
|
projection: WipProjection,
|
||||||
) -> Result<WipMountDisposition, WipMountError> {
|
) -> Result<WipMountDisposition, WipMountError> {
|
||||||
validate_projection(&projection)?;
|
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)
|
if projection.kind == WipProjectionKind::Compatibility && !is_tool_route(&projection.route)
|
||||||
{
|
{
|
||||||
return Err(WipMountError::InvalidProjection {
|
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| {
|
if self.dynamic_mounts.iter().any(|dynamic| {
|
||||||
projection
|
projection
|
||||||
.route
|
.route
|
||||||
@@ -571,6 +618,16 @@ impl WipMountRegistry {
|
|||||||
message: error.to_string(),
|
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 {
|
let Some(collection) = self.mounts.get(&mount.collection_route) else {
|
||||||
return Err(WipMountError::InvalidProjection {
|
return Err(WipMountError::InvalidProjection {
|
||||||
route: mount.collection_route,
|
route: mount.collection_route,
|
||||||
@@ -635,6 +692,42 @@ impl WipMountRegistry {
|
|||||||
Ok(())
|
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
|
/// Add operations to every item resolved by one existing dynamic Object
|
||||||
/// family. The family keeps exactly one Object resolver; each Feature only
|
/// family. The family keeps exactly one Object resolver; each Feature only
|
||||||
/// supplies handlers for its own disjoint operation names.
|
/// supplies handlers for its own disjoint operation names.
|
||||||
@@ -793,6 +886,10 @@ impl WipMountRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for capability in capabilities {
|
for capability in capabilities {
|
||||||
|
self.mounts.retain(|_, mounted| {
|
||||||
|
mounted.projection.kind != WipProjectionKind::Compatibility
|
||||||
|
|| mounted.projection.capability != capability
|
||||||
|
});
|
||||||
self.replaced_compatibility_capabilities
|
self.replaced_compatibility_capabilities
|
||||||
.insert(capability, owner_route.to_string());
|
.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>>)> {
|
fn descriptor(&self, reference: &str) -> Option<(InterfaceDescriptor, Option<Vec<u8>>)> {
|
||||||
for (path, mounted) in &self.registry.mounts {
|
for (path, mounted) in &self.registry.mounts {
|
||||||
if mounted.projection.interface == reference {
|
if mounted.projection.interface == reference {
|
||||||
@@ -1168,6 +1422,7 @@ impl WipHost {
|
|||||||
children.into_iter().collect()
|
children.into_iter().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn observe(&self, path: &str, depth: u32) -> Result<ObjectObservation, ProtocolError> {
|
fn observe(&self, path: &str, depth: u32) -> Result<ObjectObservation, ProtocolError> {
|
||||||
let object = self
|
let object = self
|
||||||
.object_at(path)
|
.object_at(path)
|
||||||
@@ -1184,6 +1439,7 @@ impl WipHost {
|
|||||||
Ok(ObjectObservation { object, children })
|
Ok(ObjectObservation { object, children })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
fn fetch_interface(&self, reference: &str) -> Result<FetchInterfaceResponse, ProtocolError> {
|
fn fetch_interface(&self, reference: &str) -> Result<FetchInterfaceResponse, ProtocolError> {
|
||||||
let (descriptor, validator) = self.descriptor(reference).ok_or_else(|| {
|
let (descriptor, validator) = self.descriptor(reference).ok_or_else(|| {
|
||||||
protocol_error(
|
protocol_error(
|
||||||
@@ -1204,7 +1460,11 @@ impl WipHost {
|
|||||||
request: CallOperationRequest,
|
request: CallOperationRequest,
|
||||||
context: WipCallContext,
|
context: WipCallContext,
|
||||||
) -> Result<WipOperationOutput, WipOperationError> {
|
) -> Result<WipOperationOutput, WipOperationError> {
|
||||||
let projection = self.projection(&request.target.path).ok_or_else(|| {
|
let projection = self
|
||||||
|
.projection_live(&request.target.path)
|
||||||
|
.await
|
||||||
|
.map_err(WipOperationError::Protocol)?
|
||||||
|
.ok_or_else(|| {
|
||||||
WipOperationError::Protocol(unpublished_path_error(&request.target.path))
|
WipOperationError::Protocol(unpublished_path_error(&request.target.path))
|
||||||
})?;
|
})?;
|
||||||
self.call_projection(projection, request, context).await
|
self.call_projection(projection, request, context).await
|
||||||
@@ -1716,7 +1976,12 @@ impl WipRuntime {
|
|||||||
arguments: Json,
|
arguments: Json,
|
||||||
execution: ToolExecutionContext,
|
execution: ToolExecutionContext,
|
||||||
) -> Result<ToolOutput, ToolError> {
|
) -> 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 => {
|
Some(projection) if projection.kind == WipProjectionKind::Compatibility => {
|
||||||
BTreeMap::from([(
|
BTreeMap::from([(
|
||||||
"input".to_string(),
|
"input".to_string(),
|
||||||
@@ -1847,7 +2112,11 @@ impl WipRuntime {
|
|||||||
Route::Observe => {
|
Route::Observe => {
|
||||||
let request_value = decode_observe_request(request.body(), self.wire_limits)
|
let request_value = decode_observe_request(request.body(), self.wire_limits)
|
||||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
.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),
|
Ok(value) => encode_observe_response(&request_value, &value, self.wire_limits),
|
||||||
Err(error) => encode_protocol_error_response(
|
Err(error) => encode_protocol_error_response(
|
||||||
ProtocolInteraction::Observe,
|
ProtocolInteraction::Observe,
|
||||||
@@ -1861,7 +2130,11 @@ impl WipRuntime {
|
|||||||
let request_value =
|
let request_value =
|
||||||
decode_fetch_interface_request(request.body(), self.wire_limits)
|
decode_fetch_interface_request(request.body(), self.wire_limits)
|
||||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
.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) => {
|
Ok(value) => {
|
||||||
encode_fetch_interface_response(&request_value, &value, self.wire_limits)
|
encode_fetch_interface_response(&request_value, &value, self.wire_limits)
|
||||||
}
|
}
|
||||||
@@ -1895,7 +2168,9 @@ impl WipRuntime {
|
|||||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||||
let descriptor = self
|
let descriptor = self
|
||||||
.host
|
.host
|
||||||
.descriptor(&metadata.interface.reference)
|
.descriptor_live(&metadata.interface.reference)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ToolError::InvalidArgument(e.message))?
|
||||||
.map(|(descriptor, _)| descriptor.clone())
|
.map(|(descriptor, _)| descriptor.clone())
|
||||||
.ok_or_else(|| ToolError::InvalidArgument("interface is not published".into()))?;
|
.ok_or_else(|| ToolError::InvalidArgument("interface is not published".into()))?;
|
||||||
let request_value = metadata
|
let request_value = metadata
|
||||||
@@ -1905,7 +2180,12 @@ impl WipRuntime {
|
|||||||
execution,
|
execution,
|
||||||
security_context: self.security_context.as_str().to_string(),
|
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) => {
|
Some(projection) => {
|
||||||
// Pin the exact resolved instance used for dispatch. A dynamic
|
// Pin the exact resolved instance used for dispatch. A dynamic
|
||||||
// resolver may return a fresh stateful handler on each lookup.
|
// resolver may return a fresh stateful handler on each lookup.
|
||||||
@@ -1934,14 +2214,37 @@ impl WipRuntime {
|
|||||||
&descriptor,
|
&descriptor,
|
||||||
&CallOperationResponse {
|
&CallOperationResponse {
|
||||||
result: output.value,
|
result: output.value,
|
||||||
validator: self
|
validator: match output.validator {
|
||||||
|
Some(validator) => Some(validator),
|
||||||
|
None if self.host.subtree(&request_value.target.path).is_some() => None,
|
||||||
|
None => self
|
||||||
.host
|
.host
|
||||||
.projection(&request_value.target.path)
|
.projection(&request_value.target.path)
|
||||||
.and_then(|projection| projection.object.validator.clone()),
|
.and_then(|projection| projection.object.validator.clone()),
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
self.wire_limits,
|
||||||
|
);
|
||||||
|
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,
|
self.wire_limits,
|
||||||
)
|
)
|
||||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
.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((
|
Ok((
|
||||||
response,
|
response,
|
||||||
output.tool_output.map(Ok),
|
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 {
|
struct ContributionHandler {
|
||||||
calls: Arc<AtomicUsize>,
|
calls: Arc<AtomicUsize>,
|
||||||
allowed: Arc<AtomicBool>,
|
allowed: Arc<AtomicBool>,
|
||||||
|
|||||||
@@ -1077,6 +1077,9 @@ fn external_workdir_operation_kind(operation: &WorkdirSessionOperation) -> &'sta
|
|||||||
match operation {
|
match operation {
|
||||||
WorkdirSessionOperation::AuthorizeScope(_) => "authorize_scope",
|
WorkdirSessionOperation::AuthorizeScope(_) => "authorize_scope",
|
||||||
WorkdirSessionOperation::ScopeRulesOverlap(_) => "scope_rules_overlap",
|
WorkdirSessionOperation::ScopeRulesOverlap(_) => "scope_rules_overlap",
|
||||||
|
WorkdirSessionOperation::CheckoutObserve(_) => "checkout_observe",
|
||||||
|
WorkdirSessionOperation::CheckoutExecute(_) => "checkout_execute",
|
||||||
|
WorkdirSessionOperation::CheckoutSearch(_) => "checkout_search",
|
||||||
WorkdirSessionOperation::Stat(_) => "stat",
|
WorkdirSessionOperation::Stat(_) => "stat",
|
||||||
WorkdirSessionOperation::Read(_) => "read",
|
WorkdirSessionOperation::Read(_) => "read",
|
||||||
WorkdirSessionOperation::ReadBytes(_) => "read_bytes",
|
WorkdirSessionOperation::ReadBytes(_) => "read_bytes",
|
||||||
@@ -1127,6 +1130,107 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
|||||||
_ => Err(Self::mismatch("scope_rules_overlap")),
|
_ => 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(
|
async fn stat(
|
||||||
&self,
|
&self,
|
||||||
request: workdir::StatRequest,
|
request: workdir::StatRequest,
|
||||||
@@ -16917,6 +17021,9 @@ async fn scoped_execute_current_worker_workdir_operation(
|
|||||||
}
|
}
|
||||||
operation @ (WorkdirSessionOperation::AuthorizeScope(_)
|
operation @ (WorkdirSessionOperation::AuthorizeScope(_)
|
||||||
| WorkdirSessionOperation::ScopeRulesOverlap(_)
|
| WorkdirSessionOperation::ScopeRulesOverlap(_)
|
||||||
|
| WorkdirSessionOperation::CheckoutObserve(_)
|
||||||
|
| WorkdirSessionOperation::CheckoutExecute(_)
|
||||||
|
| WorkdirSessionOperation::CheckoutSearch(_)
|
||||||
| WorkdirSessionOperation::Stat(_)
|
| WorkdirSessionOperation::Stat(_)
|
||||||
| WorkdirSessionOperation::Read(_)
|
| WorkdirSessionOperation::Read(_)
|
||||||
| WorkdirSessionOperation::ReadBytes(_)
|
| WorkdirSessionOperation::ReadBytes(_)
|
||||||
@@ -16978,6 +17085,18 @@ async fn execute_workdir_session_operation(
|
|||||||
.scope_rules_overlap(request)
|
.scope_rules_overlap(request)
|
||||||
.await
|
.await
|
||||||
.map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }),
|
.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
|
WorkdirSessionOperation::Stat(request) => session
|
||||||
.stat(request)
|
.stat(request)
|
||||||
.await
|
.await
|
||||||
@@ -32999,6 +33118,96 @@ mod tests {
|
|||||||
assert!(commands.try_recv().is_err());
|
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]
|
#[tokio::test]
|
||||||
async fn external_operation_cancel_retries_without_blocking_a_saturated_provider_queue() {
|
async fn external_operation_cancel_retries_without_blocking_a_saturated_provider_queue() {
|
||||||
let (sender, mut commands) = tokio::sync::mpsc::channel(1);
|
let (sender, mut commands) = tokio::sync::mpsc::channel(1);
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Attached checkout WIP projection
|
||||||
|
|
||||||
|
T-699 publishes the contents of **current Worker attachments**, independently of
|
||||||
|
Repository/Workdir inventory and management. This is a Yoi implementation of
|
||||||
|
request-time projection, **not a dependency on `wip-fs-server`**. The upstream
|
||||||
|
read-only source/specification informed the design; its byte `read` and
|
||||||
|
truncate/write `replace` contracts are not substituted for Yoi's text/edit/save
|
||||||
|
contracts. No file-tree store, synchronization, or per-file resident registration
|
||||||
|
is created.
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── repositories inventory/reference
|
||||||
|
├── workdirs identity/lifecycle
|
||||||
|
├── workdir-attachments Backend connection lifetimes
|
||||||
|
└── checkouts current accessible content entrances; list
|
||||||
|
└── <encoded alias> directory; glob, grep, create_file
|
||||||
|
├── README.md file; read, edit, write
|
||||||
|
└── src directory; glob, grep, create_file
|
||||||
|
```
|
||||||
|
|
||||||
|
Operations are Interface declarations, not children or `/features/...` routes.
|
||||||
|
File tools are native when the normal Worker WIP surface is installed. Bash stays
|
||||||
|
in compatibility mode. Ordinary Tools mode retains Read/Edit/Write/Glob/Grep.
|
||||||
|
Feature activation never grants READ, WRITE, EDIT, search, COMMAND, or delegation.
|
||||||
|
|
||||||
|
## Placement and ownership
|
||||||
|
|
||||||
|
`worker::checkout` owns Worldspace binding, contextual Interface declarations,
|
||||||
|
permission input reconstruction and safe output. It never opens a host pathname.
|
||||||
|
It resolves the live `WorkdirSessionRouter` on each request. The existing
|
||||||
|
`WorkdirSession` provider executes `checkout_observe` and `checkout_execute`;
|
||||||
|
Runtime HTTP and External transports carry typed operation/result envelopes at
|
||||||
|
their existing authorization, connection-generation and cancellation boundaries.
|
||||||
|
The Workspace-attached session forwards through the existing Backend broker.
|
||||||
|
Endpoint paths, credentials and materialized roots do not enter WIP descriptors,
|
||||||
|
Objects, arguments or results.
|
||||||
|
|
||||||
|
The common Host registry has an **explicit asynchronous subtree provider**
|
||||||
|
contract, separate from the existing one-segment dynamic item families. It
|
||||||
|
validates every resolved projection's path/owner/descriptor and every enumerated
|
||||||
|
child's immediate-parent relationship. Static and dynamic/subtree overlaps are
|
||||||
|
rejected. Observe resolves only the requested depth; successful observations are
|
||||||
|
complete through that depth, not partial trees masked as success. Directory
|
||||||
|
listing and native search use the shared List/Glob/Grep engines through typed
|
||||||
|
`CheckoutSearchRequest` forwarding. Scoped sessions append intersecting rule
|
||||||
|
layers, translate their cwd, and require provider-side visibility checks before
|
||||||
|
entry descent/content reads. Results retain session-logical coordinates, so a
|
||||||
|
SubWorker cwd does not duplicate a path prefix in its Object links. Unsupported
|
||||||
|
providers fail closed rather than falling back to a broader source search.
|
||||||
|
Direct resolution also uses current provider authority, not possession of a
|
||||||
|
cached Interface.
|
||||||
|
|
||||||
|
## Names and connection isolation
|
||||||
|
|
||||||
|
Aliases use T-697's canonical encoding: nonempty ASCII letters/digits/`-`/`_` are
|
||||||
|
unchanged; every other UTF-8 alias is `~` plus lowercase UTF-8 hex, with `~` itself
|
||||||
|
encoded. This is injective, not display-name slugification, Unicode normalization,
|
||||||
|
URL decoding or case folding. Only canonical spellings are accepted.
|
||||||
|
|
||||||
|
`checkouts.list` returns alias, slug, Workdir ID, Worker-local attachment generation
|
||||||
|
and `path`. Attachment inventory retains its Backend connection lifetime ID and
|
||||||
|
can return `checkout_path`/`checkout_slug` for the matching live router attachment.
|
||||||
|
Attach results point to the attachment catalog and checkout entrance. Workdir
|
||||||
|
existence alone does not expose its content. Following a reference still requires
|
||||||
|
current scope/capability and an accessible filesystem projection.
|
||||||
|
|
||||||
|
Objects have no shared inode `ref`: hard-linked names and different attachments
|
||||||
|
must not be mistaken for one path-independent representation. Interface references
|
||||||
|
are opaque, path-qualified and include a fresh projection incarnation and live
|
||||||
|
attachment generation. Validators combine that namespace with the provider's
|
||||||
|
opaque identity/state validator. Restoring a Worker creates a fresh incarnation;
|
||||||
|
detach/alias reuse changes the router generation. An old Interface, validator or
|
||||||
|
read-history entry cannot retarget a new connection. No separate connection ledger
|
||||||
|
is added. The existing router's active-operation exclusion and begin/finish/cancel
|
||||||
|
detach paths remain authoritative; detach does not remove the Workdir/files.
|
||||||
|
Collection enumeration rejects connection-set changes between sampling and
|
||||||
|
publication instead of pairing old items with a new validator. Search-link
|
||||||
|
publication checks the captured attachment generation, not merely a reused
|
||||||
|
alias; response-side provider awaits are covered by the operation deadline.
|
||||||
|
|
||||||
|
## AI Operation mapping
|
||||||
|
|
||||||
|
All operations bind their attachment and target from the WIP route. There is no
|
||||||
|
`target_workdir`, Workdir ID, host path or replacement `file_path` argument.
|
||||||
|
Arguments not declared by the descriptor are rejected before dispatch. Paths
|
||||||
|
below are relative to the **bound directory**, not the Worldspace or endpoint.
|
||||||
|
|
||||||
|
| Object | Operation | Arguments | Result |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/checkouts` | `list` | none | bounded `items` containing alias/slug/Workdir/generation/path |
|
||||||
|
| directory | `glob` | `pattern`, optional `path` | normal bounded Glob text/summary plus same-checkout Object links |
|
||||||
|
| directory | `grep` | `pattern`; optional `path`, `glob`, `type`, `case_insensitive`, `-A`, `-B`, `-C`, `multiline`, `output_mode`, `head_limit`, `offset` | normal Grep grouped/context/count text and typed provider search targets mapped to Object links |
|
||||||
|
| file | `read` | optional line `offset`, `limit` | line-numbered text and normal Read summary |
|
||||||
|
| file | `edit` | `old_string`, `new_string`, optional `replace_all` | replacement count/summary and preview |
|
||||||
|
| file | `write` | `content` | existing-file save summary; never creation |
|
||||||
|
| directory | `create_file` | `path`, `content` | create-new summary and created-file link; permitted missing parents are created |
|
||||||
|
|
||||||
|
Native operation results use a JSON record with `summary`, nullable `content` and
|
||||||
|
`items` (`path` is an absolute WIP Object navigation coordinate, not an OS path).
|
||||||
|
Search links are constructed from **typed provider paths**, not by parsing Grep's
|
||||||
|
rendered filenames/line delimiters. Provider scope remains authoritative for
|
||||||
|
search output as well as links. Glob/Grep share normal provider ignore/pattern,
|
||||||
|
context/output mode, offset and bounded result behavior. Search does not claim an
|
||||||
|
atomic snapshot of every descendant: it fences the bound directory before/after
|
||||||
|
search and each provider read/search retains its own consistency guarantees.
|
||||||
|
|
||||||
|
Read offsets are zero-based **lines**, limit defaults to 2000, displayed numbers
|
||||||
|
are one-based. The shared Read retains at most 256 KiB of source text and bounds
|
||||||
|
its numbered presentation to 256 KiB, with an explicit truncation marker when
|
||||||
|
numbering/Unicode expansion exceeds that budget. No byte-range
|
||||||
|
operation is published by this native surface. Partial Read records the provider's
|
||||||
|
full bounded-source hash and total line count, not the hash of only its displayed
|
||||||
|
slice. Native and ordinary Tool calls use the same original alias/generation
|
||||||
|
tracker, mutation coordinator, rendering and parameter validation.
|
||||||
|
|
||||||
|
Edit requires a nonempty, different `old_string`/`new_string`; `old_string` must
|
||||||
|
occur exactly once unless `replace_all` is true. Existing Write/Edit require a
|
||||||
|
prior successful Read through the same attachment. The read-history hash check
|
||||||
|
and the provider identity validator are **both** required: a same-content inode
|
||||||
|
replacement cannot be blessed merely by matching a hash. Failed/conflicting Read
|
||||||
|
must not record history; successful mutations update hash/change statistics only
|
||||||
|
after provider success. Create is strictly beneath the bound parent, creates only
|
||||||
|
an absent file, and must not overwrite an existing destination or symlink.
|
||||||
|
|
||||||
|
Manifest permission identities remain Read/Edit/Write/Glob/Grep; create_file uses
|
||||||
|
Write. The Host reconstructs the same route-bound input for policy evaluation;
|
||||||
|
provider read/write scope, capabilities and delegated leases are checked again.
|
||||||
|
`ask` fails closed. Native replacement claims remove `/tools/Read`, `/tools/Edit`,
|
||||||
|
`/tools/Write`, `/tools/Glob`, `/tools/Grep`, not unrelated Tools.
|
||||||
|
|
||||||
|
## Consistency, safety and resource policy
|
||||||
|
|
||||||
|
The provider resolves/pins the entry/parent, compares its opaque validator and
|
||||||
|
executes shared `fs-operation` processing within the same checked boundary. The
|
||||||
|
validator represents identity and protected metadata, independently of the Read
|
||||||
|
content hash. Read checks state across its stream and returns a validator for
|
||||||
|
that same state. The WIP response uses the **execution result's validator**, not a
|
||||||
|
later independent path stat that could bless a replacement after execution.
|
||||||
|
|
||||||
|
The native checked filesystem projection deliberately rejects symlink traversal
|
||||||
|
and unsupported/special entries. It does not silently broaden existing resolved
|
||||||
|
or logical scope. Ordinary Tools retain their existing symlink-policy behavior.
|
||||||
|
External sessions retain the root descriptor pinned **before approval/grant**;
|
||||||
|
renaming/replacing its ambient pathname must not change the approved root.
|
||||||
|
Absolute paths, traversal, alternate alias arguments and special-file mutation
|
||||||
|
are not native capabilities. UTF-8 names are byte-for-byte; non-UTF-8 names must
|
||||||
|
not be lossy-normalized into another Object name. Root/directory permissions are
|
||||||
|
not inferred from catalog visibility or from a descendant pathname supplied by
|
||||||
|
the model.
|
||||||
|
|
||||||
|
Saves retain Yoi's temporary-file publication rather than in-place truncation.
|
||||||
|
Thus saving one hard-link pathname replaces that name rather than changing all
|
||||||
|
aliases of the old inode. Checked preconditions detect known changes before
|
||||||
|
commit. Advisory locks cannot exclude arbitrary noncooperating external editors;
|
||||||
|
read-time/commit-time identity checks do not create a filesystem-wide transaction.
|
||||||
|
The supported race guarantees and remaining external namespace race window are
|
||||||
|
specified by the provider implementation and tests, not by a claim that a pinned
|
||||||
|
fd makes any rename-based update universally atomic.
|
||||||
|
|
||||||
|
A guaranteed pre-effect rejection is distinct from **OutcomeUnknown** after
|
||||||
|
mutation/parent creation may have started, commit succeeds but state cannot be
|
||||||
|
returned, response validation/encoding fails, or a transport/deadline loses the
|
||||||
|
result. Never automatically retry uncertain mutations. Inspect current state and
|
||||||
|
rediscover/reread. Do not report partially created parents as unchanged.
|
||||||
|
|
||||||
|
Host observations are bounded to depth 32 and 1024 nodes with a 30-second total
|
||||||
|
cooperative deadline. The checkout adapter permits at most 16 concurrent requests
|
||||||
|
and bounds admission waiting and the complete operation (including result-link
|
||||||
|
publication) to 30 seconds each; provider path/depth, source/response,
|
||||||
|
mutation/search and transport bounds apply too. No initial context expands an
|
||||||
|
entire tree. Resource-limit errors do not turn incomplete observation into a
|
||||||
|
successful truncated tree; bounded search results may explicitly report
|
||||||
|
truncation under the existing search contract.
|
||||||
|
|
||||||
|
## Typical sequence
|
||||||
|
|
||||||
|
1. Discover `/checkouts` with depth 1; inspect its Interface and call `list`.
|
||||||
|
2. Discover an entrance or known deep directory, inspect it, call `glob`/`grep`.
|
||||||
|
3. Follow a returned file `path`, discover/inspect, call line `read`.
|
||||||
|
4. Call `edit` or `write`; Client-managed validators protect the exact observed
|
||||||
|
object while the shared tracker enforces the prior Read.
|
||||||
|
5. Rediscover after external changes; a stale rejection is not permission to skip
|
||||||
|
Read. ReRead after successful save to verify text.
|
||||||
|
6. Inspect a directory and call `create_file` with a descendant relative path,
|
||||||
|
then discover/Read the returned new file.
|
||||||
|
7. Use `/workdir-attachments/<connection lifetime>` for detach. Old checkout
|
||||||
|
observations and history cannot operate a newly attached alias.
|
||||||
|
|
||||||
|
Workspace configuration (T-695) remains a separate logical provider; it is not
|
||||||
|
replaced by raw Linux filesystem writes. Bash native conversion, broad POSIX
|
||||||
|
operations, lifecycle duplication, upstream changes and deployment/restarts are
|
||||||
|
outside this feature.
|
||||||
|
|
||||||
|
## Reproducible validation matrix
|
||||||
|
|
||||||
|
Run from the main repository, on Linux with `openat2` and `/proc/self/fd`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo check --workspace --all-targets
|
||||||
|
cargo test -p fs-operation -p workdir -p tools -p worker
|
||||||
|
cargo test -p worker-runtime -p yoi-workspace-server
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
| Boundary | Focused regression locations |
|
||||||
|
| --- | --- |
|
||||||
|
| Deep mounts, multiple aliases, route-only arguments, exact permission identities, no duplicate compatibility file Tools | `worker/src/checkout_wip_tests.rs` and existing `wip.rs` registry/controller tests |
|
||||||
|
| Native search → numbered Read → unique/all Edit → Write → Create; returned validator cached chains | `checkout_wip_tests.rs`, `tools/src/checkout_tests.rs` |
|
||||||
|
| SubWorker write leases, sparse/nonrecursive/nested scopes, denied basename/content pruning, result-coordinate rebasing, revocation | `workdir/src/checkout_search_tests.rs`, `checkout.rs`, native scoped-child test |
|
||||||
|
| Approved External root replacement; same-content inode/name/parent substitution; hard-link save isolation; symlinks/special files; Unicode/non-UTF8 paths | `workdir/src/checkout.rs`, `fs-operation/src/checked.rs` |
|
||||||
|
| Mid-read updates; independent hash and validator fences; parent partial creation and post-save state failure | `checked.rs`, `checkout.rs`, Tools tests |
|
||||||
|
| Detach/alias reuse/restore references and read history; collection/search response generation fences | `checkout_wip_tests.rs`, `checkout_race_tests.rs`, existing router/scope/catalog lifecycle tests |
|
||||||
|
| Traversal depth/entry/cancellation bounds; stalled observation/link publication; numbered output expansion; post-dispatch encoding loss | `fs-operation/src/traversal_tests.rs`, `checkout_race_tests.rs`, `tools/src/read.rs`, `wip.rs` |
|
||||||
|
| Real prefixed HTTP provider → Remote session → router → native WIP; auth, read-only, lost/malformed post-commit response without retry | `worker/src/checkout_http_tests.rs` |
|
||||||
|
| Workspace proxy/External typed pairing and unknown-outcome propagation; Backend/Runtime Workspace, grant and generation isolation | checkout additions in `manage_workdir.rs` and Backend `server.rs`, plus full existing host/provider suites |
|
||||||
|
| Ordinary Tools text/hash/edit/write/search and provider symlink policy | full Tools, fs-operation and Workdir suites |
|
||||||
|
|
||||||
|
The HTTP fixture tests actual network/codec boundaries but is not a production
|
||||||
|
Backend grant-network end-to-end deployment. Production routing/authorization is
|
||||||
|
covered separately by the existing host suites. Fault tests are deterministic
|
||||||
|
boundary simulations, not power-loss tests. Metadata/pinned-handle tests do not
|
||||||
|
close the external-editor final-check/rename window documented above. No live
|
||||||
|
Worker or deployed environment needs updating/restarting to execute this matrix.
|
||||||
Reference in New Issue
Block a user