feat: limit checkout indexing to entrances and expose bounded typed discovery
Content directories always have no indexable children; direct authorized paths remain resolvable. Add List using provider Read/enumeration authority and separate List permission, dirs-first lexical live continuation, bounded retention/path bytes, scoped result coordinates and External DTO validation. List/Glob/Grep return Descriptor-typed entries without eager acquisition. Bind content Interfaces coherently to the live entrance scope. Validation: root cargo check; FS 58 unit+6 pagination, Workdir 125+5 (default and http-client), Tools 84 unit+39 integration+1 doctest, Worker 901 unit+110 integration, Server 691 unit+11 CLI, Server API 83 tests; exact crates.io WIP 0.2.0 resolution; format/diff checks. Focused model/local/remote/provider/race/scope checks passed. Commands and semantics in docs/development/wip-integration-validation.md. Publication/review paused: continuation ShowTicket returned HTTP 401. No Backend bypass, source push, MR approval, target integration or live environment update is claimed.
This commit is contained in:
@@ -941,6 +941,7 @@ mod tests {
|
|||||||
let list_error = run_list(
|
let list_error = run_list(
|
||||||
&root,
|
&root,
|
||||||
ListRequest {
|
ListRequest {
|
||||||
|
after: None,
|
||||||
path: FsPath::new("selected-dir").unwrap(),
|
path: FsPath::new("selected-dir").unwrap(),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
},
|
},
|
||||||
@@ -1008,6 +1009,7 @@ mod tests {
|
|||||||
let listed = run_list(
|
let listed = run_list(
|
||||||
&root,
|
&root,
|
||||||
ListRequest {
|
ListRequest {
|
||||||
|
after: None,
|
||||||
path: FsPath::new("notes").unwrap(),
|
path: FsPath::new("notes").unwrap(),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::BinaryHeap;
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufReader, Read};
|
use std::io::{BufReader, Read};
|
||||||
@@ -7,9 +9,9 @@ use sha2::{Digest, Sha256};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind,
|
AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind,
|
||||||
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest,
|
FsAccessPolicy, FsError, FsPath, ListCursor, ListEntry, ListRequest, ListResult,
|
||||||
ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult,
|
||||||
direct_symlink,
|
WriteRequest, WriteResult, direct_symlink,
|
||||||
};
|
};
|
||||||
|
|
||||||
const READ_BUFFER_BYTES: usize = 16 * 1024;
|
const READ_BUFFER_BYTES: usize = 16 * 1024;
|
||||||
@@ -437,12 +439,55 @@ pub fn run_edit(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_order(
|
||||||
|
left_kind: EntryKind,
|
||||||
|
left: &FsPath,
|
||||||
|
right_kind: EntryKind,
|
||||||
|
right: &FsPath,
|
||||||
|
) -> Ordering {
|
||||||
|
(left_kind != EntryKind::Directory)
|
||||||
|
.cmp(&(right_kind != EntryKind::Directory))
|
||||||
|
.then_with(|| left.as_str().cmp(right.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RetainedListEntry(ListEntry);
|
||||||
|
|
||||||
|
impl PartialEq for RetainedListEntry {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.cmp(other) == Ordering::Equal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Eq for RetainedListEntry {}
|
||||||
|
impl Ord for RetainedListEntry {
|
||||||
|
fn cmp(&self, other: &Self) -> Ordering {
|
||||||
|
list_order(self.0.kind, &self.0.path, other.0.kind, &other.0.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl PartialOrd for RetainedListEntry {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_entry_bytes(entry: &ListEntry) -> usize {
|
||||||
|
entry.path.as_str().len().saturating_add(64)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run_list(
|
pub fn run_list(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
request: ListRequest,
|
request: ListRequest,
|
||||||
access: &dyn FsAccessPolicy,
|
access: &dyn FsAccessPolicy,
|
||||||
) -> Result<ListResult, FsError> {
|
) -> Result<ListResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
|
if let Some(after) = &request.after {
|
||||||
|
if after.path.is_root()
|
||||||
|
|| Path::new(after.path.as_str()).parent() != Some(Path::new(logical.as_str()))
|
||||||
|
{
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"List cursor must name a direct child of the listed directory".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
access
|
access
|
||||||
.check_cancelled()
|
.check_cancelled()
|
||||||
.map_err(|error| map_io(&logical, error))?;
|
.map_err(|error| map_io(&logical, error))?;
|
||||||
@@ -462,11 +507,15 @@ pub fn run_list(
|
|||||||
total_entries: 0,
|
total_entries: 0,
|
||||||
total_bytes: 0,
|
total_bytes: 0,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
next_after: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut entries = Vec::new();
|
let mut entries: BinaryHeap<RetainedListEntry> = BinaryHeap::new();
|
||||||
|
let mut retained_limit = request.limit.saturating_add(1);
|
||||||
let mut retained_path_bytes = 0_usize;
|
let mut retained_path_bytes = 0_usize;
|
||||||
let mut provider_truncated = false;
|
let mut total_entries = 0usize;
|
||||||
|
let mut total_bytes = 0u64;
|
||||||
|
let mut remaining_entries = 0usize;
|
||||||
let read_dir = access
|
let read_dir = access
|
||||||
.open_read_dir(&logical_base, &path)
|
.open_read_dir(&logical_base, &path)
|
||||||
.map_err(|error| map_io(&logical, error))?;
|
.map_err(|error| map_io(&logical, error))?;
|
||||||
@@ -513,34 +562,77 @@ pub fn run_list(
|
|||||||
let Ok(result_path) = FsPath::new(relative) else {
|
let Ok(result_path) = FsPath::new(relative) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let retained = result_path.as_str().len().saturating_add(64);
|
total_entries += 1;
|
||||||
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
total_bytes = total_bytes.saturating_add(metadata.len());
|
||||||
provider_truncated = true;
|
if request.after.as_ref().is_some_and(|after| {
|
||||||
break;
|
list_order(kind, &result_path, after.kind, &after.path) != Ordering::Greater
|
||||||
|
}) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
retained_path_bytes = retained_path_bytes.saturating_add(retained);
|
remaining_entries += 1;
|
||||||
entries.push(ListEntry {
|
let candidate = RetainedListEntry(ListEntry {
|
||||||
path: result_path,
|
path: result_path,
|
||||||
kind,
|
kind,
|
||||||
size: metadata.len(),
|
size: metadata.len(),
|
||||||
});
|
});
|
||||||
|
if entries.len() == retained_limit {
|
||||||
|
if candidate >= *entries.peek().expect("nonempty List retention") {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
entries.sort_by(|left, right| {
|
retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0);
|
||||||
let left_dir = left.kind == EntryKind::Directory;
|
}
|
||||||
let right_dir = right.kind == EntryKind::Directory;
|
retained_path_bytes = retained_path_bytes.saturating_add(list_entry_bytes(&candidate.0));
|
||||||
right_dir
|
entries.push(candidate);
|
||||||
.cmp(&left_dir)
|
// Evict greatest keys, never stop scanning: enumeration order is
|
||||||
.then_with(|| left.path.as_str().cmp(right.path.as_str()))
|
// unspecified and a later entry may precede every retained key.
|
||||||
});
|
while retained_path_bytes > crate::MAX_RESULT_PATH_BYTES {
|
||||||
let total_entries = entries.len();
|
retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0);
|
||||||
let total_bytes = entries.iter().map(|entry| entry.size).sum();
|
// Never grow cardinality again after a byte-bound eviction: a
|
||||||
let truncated = provider_truncated || entries.len() > request.limit;
|
// later short path must not leap over a discarded earlier key.
|
||||||
|
retained_limit = entries.len();
|
||||||
|
}
|
||||||
|
if retained_limit == 0 {
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"List entry exceeds provider response bound".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut entries: Vec<_> = entries
|
||||||
|
.into_sorted_vec()
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| entry.0)
|
||||||
|
.collect();
|
||||||
entries.truncate(request.limit);
|
entries.truncate(request.limit);
|
||||||
|
// Continuation duplicates the last path on the wire; include it in the
|
||||||
|
// aggregate provider response bound too.
|
||||||
|
let mut response_bytes: usize = entries.iter().map(list_entry_bytes).sum();
|
||||||
|
while remaining_entries > entries.len()
|
||||||
|
&& entries.last().is_some_and(|last| {
|
||||||
|
response_bytes.saturating_add(list_entry_bytes(last)) > crate::MAX_RESULT_PATH_BYTES
|
||||||
|
})
|
||||||
|
{
|
||||||
|
response_bytes -= list_entry_bytes(&entries.pop().unwrap());
|
||||||
|
}
|
||||||
|
if request.limit > 0 && remaining_entries > 0 && entries.is_empty() {
|
||||||
|
return Err(FsError::InvalidArgument(
|
||||||
|
"List entry exceeds provider response bound".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let truncated = remaining_entries > entries.len();
|
||||||
|
let next_after = if truncated {
|
||||||
|
entries.last().map(|entry| ListCursor {
|
||||||
|
kind: entry.kind,
|
||||||
|
path: entry.path.clone(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
Ok(ListResult {
|
Ok(ListResult {
|
||||||
entries,
|
entries,
|
||||||
total_entries,
|
total_entries,
|
||||||
total_bytes,
|
total_bytes,
|
||||||
truncated,
|
truncated,
|
||||||
|
next_after,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,22 @@ pub struct ListRequest {
|
|||||||
pub path: FsPath,
|
pub path: FsPath,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
pub limit: usize,
|
pub limit: usize,
|
||||||
|
/// Exclusive live continuation in the same coordinates as returned entries.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub after: Option<ListCursor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A List ordering key: directories first, then lexical path (all other kinds
|
||||||
|
/// share one ordering group). The cursor need not still exist. Each page scans
|
||||||
|
/// the live directory: insertions before the key are not revisited, insertions
|
||||||
|
/// after it may appear, and removals disappear. Renames or kind changes can move
|
||||||
|
/// an entry across the key and cause omission or repetition; no snapshot or
|
||||||
|
/// cross-page consistency is promised. Scoped wrappers translate coordinates.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ListCursor {
|
||||||
|
pub kind: EntryKind,
|
||||||
|
pub path: FsPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
@@ -293,6 +309,11 @@ pub struct ListEntry {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct ListResult {
|
pub struct ListResult {
|
||||||
pub entries: Vec<ListEntry>,
|
pub entries: Vec<ListEntry>,
|
||||||
|
/// Last returned key when more entries follow. A zero-limit request has no
|
||||||
|
/// continuation even when truncated. Totals describe all visible entries
|
||||||
|
/// in this scan, including those at or before `after`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_after: Option<ListCursor>,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||||
pub total_entries: usize,
|
pub total_entries: usize,
|
||||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
use fs_operation::{
|
||||||
|
EntryKind, FsAccessPolicy, FsError, FsPath, ListCursor, ListRequest, ListResult,
|
||||||
|
MAX_RESULT_PATH_BYTES, run_list,
|
||||||
|
};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
struct Access {
|
||||||
|
root: PathBuf,
|
||||||
|
checks: AtomicUsize,
|
||||||
|
cancel_at: usize,
|
||||||
|
enumerate: bool,
|
||||||
|
}
|
||||||
|
impl Access {
|
||||||
|
fn new(root: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
checks: AtomicUsize::new(0),
|
||||||
|
cancel_at: usize::MAX,
|
||||||
|
enumerate: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FsAccessPolicy for Access {
|
||||||
|
fn is_readable(&self, path: &Path) -> bool {
|
||||||
|
path.starts_with(&self.root) && path != self.root.join("denied")
|
||||||
|
}
|
||||||
|
fn is_writable(&self, _: &Path) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn can_enumerate_directory(&self, _: &Path, _: &Path) -> bool {
|
||||||
|
self.enumerate
|
||||||
|
}
|
||||||
|
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||||
|
if self.checks.fetch_add(1, Ordering::Relaxed) >= self.cancel_at {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Interrupted,
|
||||||
|
"cancelled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn path(value: &str) -> FsPath {
|
||||||
|
FsPath::new(value).unwrap()
|
||||||
|
}
|
||||||
|
fn page(root: &Path, limit: usize, after: Option<ListCursor>) -> ListResult {
|
||||||
|
run_list(
|
||||||
|
root,
|
||||||
|
ListRequest {
|
||||||
|
path: FsPath::root(),
|
||||||
|
limit,
|
||||||
|
after,
|
||||||
|
},
|
||||||
|
&Access::new(root),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn names(result: &ListResult) -> Vec<&str> {
|
||||||
|
result
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.path.as_str())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_pages_order_directories_first_then_paths_without_duplicates() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
for name in ["z", "b", "a"] {
|
||||||
|
std::fs::write(root.path().join(name), b"xx").unwrap();
|
||||||
|
}
|
||||||
|
for name in ["z-dir", "a-dir"] {
|
||||||
|
std::fs::create_dir(root.path().join(name)).unwrap();
|
||||||
|
}
|
||||||
|
let full = page(root.path(), usize::MAX, None);
|
||||||
|
assert_eq!(names(&full), ["a-dir", "z-dir", "a", "b", "z"]);
|
||||||
|
let mut after = None;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
loop {
|
||||||
|
let result = page(root.path(), 2, after);
|
||||||
|
assert!(result.entries.len() <= 2);
|
||||||
|
assert_eq!(result.total_entries, 5);
|
||||||
|
assert_eq!(result.total_bytes, full.total_bytes);
|
||||||
|
assert_eq!(result.truncated, result.next_after.is_some());
|
||||||
|
after = result.next_after;
|
||||||
|
entries.extend(result.entries);
|
||||||
|
if after.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(entries, full.entries);
|
||||||
|
let zero = page(root.path(), 0, None);
|
||||||
|
assert!(zero.entries.is_empty());
|
||||||
|
assert!(zero.truncated);
|
||||||
|
assert!(zero.next_after.is_none());
|
||||||
|
assert_eq!(zero.total_entries, 5);
|
||||||
|
let exhausted = page(
|
||||||
|
root.path(),
|
||||||
|
2,
|
||||||
|
Some(ListCursor {
|
||||||
|
kind: EntryKind::File,
|
||||||
|
path: path("z"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(exhausted.entries.is_empty());
|
||||||
|
assert!(!exhausted.truncated);
|
||||||
|
assert!(exhausted.next_after.is_none());
|
||||||
|
assert_eq!(exhausted.total_entries, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_live_cursor_survives_removal_and_only_observes_new_keys_after_it() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
for name in ["b", "d"] {
|
||||||
|
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let first = page(root.path(), 1, None);
|
||||||
|
assert_eq!(names(&first), ["b"]);
|
||||||
|
std::fs::remove_file(root.path().join("b")).unwrap();
|
||||||
|
for name in ["a", "c"] {
|
||||||
|
std::fs::write(root.path().join(name), b"xx").unwrap();
|
||||||
|
}
|
||||||
|
// All directories sort before a file cursor, even when created later.
|
||||||
|
std::fs::create_dir(root.path().join("z-directory")).unwrap();
|
||||||
|
let second = page(root.path(), 10, first.next_after);
|
||||||
|
assert_eq!(names(&second), ["c", "d"]);
|
||||||
|
assert_eq!(second.total_entries, 4);
|
||||||
|
assert!(!second.truncated);
|
||||||
|
assert!(second.next_after.is_none());
|
||||||
|
std::fs::remove_file(root.path().join("d")).unwrap();
|
||||||
|
std::fs::rename(root.path().join("a"), root.path().join("e")).unwrap();
|
||||||
|
let moved = page(
|
||||||
|
root.path(),
|
||||||
|
10,
|
||||||
|
Some(ListCursor {
|
||||||
|
kind: EntryKind::File,
|
||||||
|
path: path("d"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(names(&moved), ["e"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_path_byte_bound_returns_a_continuation_instead_of_losing_the_tail() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let long_count = MAX_RESULT_PATH_BYTES / (240 + 64) + 10;
|
||||||
|
// A shorter late key must not leap over a longer evicted earlier key.
|
||||||
|
let count = long_count + 50;
|
||||||
|
let expected: Vec<_> = (0..count)
|
||||||
|
.map(|n| format!("{n:05}{}", "x".repeat(if n < long_count { 235 } else { 1 })))
|
||||||
|
.collect();
|
||||||
|
for name in expected.iter().rev() {
|
||||||
|
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let first = page(root.path(), usize::MAX, None);
|
||||||
|
assert!(first.truncated);
|
||||||
|
assert_eq!(first.total_entries, count);
|
||||||
|
assert_eq!(first.total_bytes, count as u64);
|
||||||
|
let path_bytes: usize = first
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.path.as_str().len() + 64)
|
||||||
|
.sum::<usize>()
|
||||||
|
+ first.next_after.as_ref().unwrap().path.as_str().len()
|
||||||
|
+ 64;
|
||||||
|
assert!(path_bytes <= MAX_RESULT_PATH_BYTES);
|
||||||
|
let next = page(root.path(), usize::MAX, first.next_after.clone());
|
||||||
|
assert!(!next.truncated);
|
||||||
|
let got: Vec<_> = names(&first).into_iter().chain(names(&next)).collect();
|
||||||
|
assert_eq!(got, expected.iter().map(String::as_str).collect::<Vec<_>>());
|
||||||
|
let small = page(root.path(), 1, None);
|
||||||
|
assert_eq!(names(&small), [expected[0].as_str()]);
|
||||||
|
assert_eq!(small.total_entries, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_keeps_ignored_and_hidden_entries_but_respects_read_and_enumeration_authority() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(root.path().join(".gitignore"), "ignored\n").unwrap();
|
||||||
|
for name in ["ignored", ".hidden", "denied"] {
|
||||||
|
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let first = page(root.path(), 2, None);
|
||||||
|
assert_eq!(names(&first), [".gitignore", ".hidden"]);
|
||||||
|
let second = page(root.path(), 2, first.next_after);
|
||||||
|
assert_eq!(names(&second), ["ignored"]);
|
||||||
|
assert_eq!(second.total_entries, 3);
|
||||||
|
let mut access = Access::new(root.path());
|
||||||
|
access.enumerate = false;
|
||||||
|
let result = run_list(
|
||||||
|
root.path(),
|
||||||
|
ListRequest {
|
||||||
|
path: FsPath::root(),
|
||||||
|
limit: 1,
|
||||||
|
after: None,
|
||||||
|
},
|
||||||
|
&access,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.entries.is_empty());
|
||||||
|
assert!(!result.truncated);
|
||||||
|
assert!(result.next_after.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_cancellation_is_checked_even_after_the_page_is_full() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
for name in ["a", "b", "c"] {
|
||||||
|
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let mut access = Access::new(root.path());
|
||||||
|
access.cancel_at = 3;
|
||||||
|
let error = run_list(
|
||||||
|
root.path(),
|
||||||
|
ListRequest {
|
||||||
|
path: FsPath::root(),
|
||||||
|
limit: 1,
|
||||||
|
after: None,
|
||||||
|
},
|
||||||
|
&access,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_rejects_cursor_outside_the_requested_directory() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
for value in ["", "other/a"] {
|
||||||
|
let error = run_list(
|
||||||
|
root.path(),
|
||||||
|
ListRequest {
|
||||||
|
path: FsPath::root(),
|
||||||
|
limit: 1,
|
||||||
|
after: Some(ListCursor {
|
||||||
|
kind: EntryKind::File,
|
||||||
|
path: path(value),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
&Access::new(root.path()),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, FsError::InvalidArgument(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ capabilities remain authoritative. `Create` uses the `Write` capability/identity
|
|||||||
| Edit | `old_string`, `new_string`, `replace_all?` | Existing file; prior read/hash and provider validator required |
|
| 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 |
|
| 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 |
|
| Create | `path`, `content` | Bound parent directory; relative destination strictly below it; create-new, missing parents allowed |
|
||||||
|
| List | `limit?` (1..1000, default 100), `after?` (`{kind, path}`) | Bound directory; Read capability; all typed `EntryKind` cursor values accepted, path is a direct child in checkout-root-relative provider coordinates |
|
||||||
| Glob | `pattern`, `path?` | Bound directory; optional path relative beneath it |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
@@ -55,6 +56,22 @@ The constructor's empty scope layers/root output are refined by provider wrapper
|
|||||||
Tools does not synthesize authority or parse/rebase rendered filenames. Ordinary
|
Tools does not synthesize authority or parse/rebase rendered filenames. Ordinary
|
||||||
Tool dispatch remains `session.glob`/`session.grep`, with its existing error mapping.
|
Tool dispatch remains `session.glob`/`session.grep`, with its existing error mapping.
|
||||||
|
|
||||||
|
List dispatches one bounded `checkout_search` page without Bash or entry prefetch.
|
||||||
|
`CheckoutToolOutput.listing` preserves the typed `ListResult`, including
|
||||||
|
`next_after`, for Host projection into canonical Worldspace entries and cursors.
|
||||||
|
Its normal Tool output is a summary only, and `paths` is empty; every other
|
||||||
|
Operation returns `listing: None`. The Host decodes canonical cursor paths before
|
||||||
|
calling Tools, mapping the public `{kind, entry}` cursor into `{kind, path}`.
|
||||||
|
Both returned entries and the continuation must stay directly inside the bound
|
||||||
|
directory. Cursor kinds retain all typed values (`directory`, `file`, `symlink`,
|
||||||
|
`other`); ordering groups directories first and all non-directories together,
|
||||||
|
then compares paths. A truncated page must supply `next_after` matching its last
|
||||||
|
entry's ordering key; an untruncated page has no continuation. Special typed
|
||||||
|
listing entries are path coordinates, not checkout observations: direct checkout
|
||||||
|
observation remains provider File/Directory-only. Pagination is live, not a
|
||||||
|
cross-page snapshot; the same before/after bound-directory validator checks
|
||||||
|
used for search apply to List.
|
||||||
|
|
||||||
`CheckoutToolOutput.paths` contains typed checkout-root-relative search paths
|
`CheckoutToolOutput.paths` contains typed checkout-root-relative search paths
|
||||||
(from `GlobResult.paths` / `GrepResult.paths`) and the created path for Create.
|
(from `GlobResult.paths` / `GrepResult.paths`) and the created path for Create.
|
||||||
Rendered Grep text is never parsed to construct links. The optional validator is
|
Rendered Grep text is never parsed to construct links. The optional validator is
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ use crate::{ToolsError, Tracker, file_target::FileTarget};
|
|||||||
pub struct CheckoutToolOutput {
|
pub struct CheckoutToolOutput {
|
||||||
pub output: ToolOutput,
|
pub output: ToolOutput,
|
||||||
pub paths: Vec<WorkdirPath>,
|
pub paths: Vec<WorkdirPath>,
|
||||||
|
/// Typed directory page, including its provider-coordinate continuation.
|
||||||
|
/// Special entries are path coordinates, not checked checkout observations.
|
||||||
|
/// The Host owns projection into canonical Worldspace entries and cursors.
|
||||||
|
pub listing: Option<workdir::ListResult>,
|
||||||
pub validator: Option<Vec<u8>>,
|
pub validator: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +51,7 @@ pub async fn execute_checkout_tool(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let (capability, allowed): (_, &[&str]) = match tool_name {
|
let (capability, allowed): (_, &[&str]) = match tool_name {
|
||||||
"Read" => (WorkdirSessionCapability::Read, &file_fields),
|
"Read" => (WorkdirSessionCapability::Read, &file_fields),
|
||||||
|
"List" => (WorkdirSessionCapability::Read, &["limit", "after"]),
|
||||||
"Edit" => (WorkdirSessionCapability::Edit, &file_fields),
|
"Edit" => (WorkdirSessionCapability::Edit, &file_fields),
|
||||||
"Write" => (WorkdirSessionCapability::Write, &file_fields),
|
"Write" => (WorkdirSessionCapability::Write, &file_fields),
|
||||||
"Create" => (WorkdirSessionCapability::Write, &["path", "content"]),
|
"Create" => (WorkdirSessionCapability::Write, &["path", "content"]),
|
||||||
@@ -132,7 +137,7 @@ pub async fn execute_checkout_tool(
|
|||||||
crate::write::execute_write(target, tracker, params.content, Some(destination), ctx)
|
crate::write::execute_write(target, tracker, params.content, Some(destination), ctx)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
"Glob" | "Grep" => {
|
"List" | "Glob" | "Grep" => {
|
||||||
let before = selected
|
let before = selected
|
||||||
.session
|
.session
|
||||||
.checkout_observe(path.clone())
|
.checkout_observe(path.clone())
|
||||||
@@ -141,7 +146,10 @@ pub async fn execute_checkout_tool(
|
|||||||
if before.validator != validator {
|
if before.validator != validator {
|
||||||
return Err(stale_search());
|
return Err(stale_search());
|
||||||
}
|
}
|
||||||
let mut result = if tool_name == "Glob" {
|
let mut result = if tool_name == "List" {
|
||||||
|
let params: ListParams = decode(Value::Object(arguments), tool_name)?;
|
||||||
|
execute_list(selected.session.clone(), path.clone(), params).await?
|
||||||
|
} else if tool_name == "Glob" {
|
||||||
let params: crate::glob::GlobParams = decode(Value::Object(arguments), tool_name)?;
|
let params: crate::glob::GlobParams = decode(Value::Object(arguments), tool_name)?;
|
||||||
check_pattern(¶ms.pattern)?;
|
check_pattern(¶ms.pattern)?;
|
||||||
let search_path =
|
let search_path =
|
||||||
@@ -191,6 +199,120 @@ pub async fn execute_checkout_tool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Native List has no path override. `after.path` is already decoded by the Host
|
||||||
|
/// into checkout-root-relative provider coordinates, not directory-relative.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct ListParams {
|
||||||
|
#[serde(default = "default_list_limit")]
|
||||||
|
limit: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
after: Option<workdir::ListCursor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_list_limit() -> usize {
|
||||||
|
100
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_list(
|
||||||
|
session: workdir::WorkdirSessionHandle,
|
||||||
|
path: WorkdirPath,
|
||||||
|
params: ListParams,
|
||||||
|
) -> Result<CheckoutToolOutput, ToolError> {
|
||||||
|
if !(1..=1000).contains(¶ms.limit) {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"List limit must be between 1 and 1000".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(after) = ¶ms.after {
|
||||||
|
if !is_direct_child(&path, &after.path) {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"List after must name a direct child of the bound directory".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = session
|
||||||
|
.checkout_search(workdir::CheckoutSearchRequest::new(
|
||||||
|
workdir::CheckoutSearchOperation::List(workdir::ListRequest {
|
||||||
|
path: path.clone(),
|
||||||
|
limit: params.limit,
|
||||||
|
after: params.after,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(crate::file_target::checked_error)?;
|
||||||
|
let listing = match result {
|
||||||
|
workdir::CheckoutSearchResult::List(listing) => listing,
|
||||||
|
_ => return Err(crate::file_target::readonly_result_error("List")),
|
||||||
|
};
|
||||||
|
// Fail closed before the Host can publish typed links to provider results.
|
||||||
|
if listing.entries.len() > params.limit
|
||||||
|
|| listing
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.any(|entry| !is_direct_child(&path, &entry.path))
|
||||||
|
|| listing
|
||||||
|
.next_after
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|after| !is_direct_child(&path, &after.path))
|
||||||
|
{
|
||||||
|
return Err(crate::file_target::readonly_result_error(
|
||||||
|
"List page escaped bound directory or exceeded its limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// The provider ordering key is directory/non-directory group plus path.
|
||||||
|
// Preserve the typed kind; special entries remain continuable without
|
||||||
|
// acquiring a File/Directory-only checkout observation for them.
|
||||||
|
let valid_continuation = match (&listing.next_after, listing.entries.last()) {
|
||||||
|
(Some(after), Some(last)) if listing.truncated => {
|
||||||
|
after.path == last.path
|
||||||
|
&& (after.kind == workdir::EntryKind::Directory)
|
||||||
|
== (last.kind == workdir::EntryKind::Directory)
|
||||||
|
}
|
||||||
|
(None, _) => !listing.truncated,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !valid_continuation {
|
||||||
|
return Err(crate::file_target::readonly_result_error(
|
||||||
|
"List continuation does not match the truncated page's last ordering key",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let summary = format!(
|
||||||
|
"Listed {} of {} entries in {}{}",
|
||||||
|
listing.entries.len(),
|
||||||
|
listing.total_entries,
|
||||||
|
path,
|
||||||
|
if listing.truncated {
|
||||||
|
" (truncated)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(CheckoutToolOutput {
|
||||||
|
output: ToolOutput {
|
||||||
|
summary,
|
||||||
|
content: None,
|
||||||
|
attachments: Vec::new(),
|
||||||
|
},
|
||||||
|
paths: Vec::new(),
|
||||||
|
listing: Some(listing),
|
||||||
|
validator: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_direct_child(base: &WorkdirPath, path: &WorkdirPath) -> bool {
|
||||||
|
let Ok(logical) = WorkdirPath::new(path.as_str()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let parent = std::path::Path::new(logical.as_str()).parent();
|
||||||
|
let expected = if base == &WorkdirPath::root() {
|
||||||
|
std::path::Path::new("")
|
||||||
|
} else {
|
||||||
|
std::path::Path::new(base.as_str())
|
||||||
|
};
|
||||||
|
logical != WorkdirPath::root() && parent == Some(expected)
|
||||||
|
}
|
||||||
|
|
||||||
fn decode<T: DeserializeOwned>(arguments: Value, name: &str) -> Result<T, ToolError> {
|
fn decode<T: DeserializeOwned>(arguments: Value, name: &str) -> Result<T, ToolError> {
|
||||||
crate::error::decode_file_value(arguments, name)
|
crate::error::decode_file_value(arguments, name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,326 @@ impl Fixture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ListProvider {
|
||||||
|
workdir: workdir::Workdir,
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities,
|
||||||
|
result: workdir::CheckoutSearchResult,
|
||||||
|
change_validator: bool,
|
||||||
|
expected_after: Option<workdir::ListCursor>,
|
||||||
|
searched: std::sync::atomic::AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// This boundary adapter exposes only checked metadata/search. Any file content
|
||||||
|
// read, ordinary list fallback, or command execution is a test failure.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl workdir::WorkdirSession for ListProvider {
|
||||||
|
fn workdir(&self) -> &workdir::Workdir {
|
||||||
|
&self.workdir
|
||||||
|
}
|
||||||
|
fn capabilities(&self) -> workdir::WorkdirSessionCapabilities {
|
||||||
|
self.capabilities
|
||||||
|
}
|
||||||
|
async fn checkout_observe(
|
||||||
|
&self,
|
||||||
|
path: WorkdirPath,
|
||||||
|
) -> Result<workdir::CheckoutObservation, workdir::WorkdirError> {
|
||||||
|
assert_eq!(
|
||||||
|
path.as_str(),
|
||||||
|
"src",
|
||||||
|
"List observes only its bound directory"
|
||||||
|
);
|
||||||
|
Ok(workdir::CheckoutObservation {
|
||||||
|
path,
|
||||||
|
kind: workdir::EntryKind::Directory,
|
||||||
|
size: 0,
|
||||||
|
validator: vec![u8::from(
|
||||||
|
self.change_validator && self.searched.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
)],
|
||||||
|
capabilities: self.capabilities,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn checkout_search(
|
||||||
|
&self,
|
||||||
|
request: workdir::CheckoutSearchRequest,
|
||||||
|
) -> Result<workdir::CheckoutSearchResult, workdir::WorkdirError> {
|
||||||
|
let workdir::CheckoutSearchOperation::List(list) = request.operation else {
|
||||||
|
panic!("native List must dispatch a typed List request");
|
||||||
|
};
|
||||||
|
assert_eq!(list.after, self.expected_after);
|
||||||
|
assert!(
|
||||||
|
!self
|
||||||
|
.searched
|
||||||
|
.swap(true, std::sync::atomic::Ordering::SeqCst),
|
||||||
|
"List must not prefetch another page"
|
||||||
|
);
|
||||||
|
Ok(self.result.clone())
|
||||||
|
}
|
||||||
|
async fn stat(
|
||||||
|
&self,
|
||||||
|
_: workdir::StatRequest,
|
||||||
|
) -> Result<workdir::StatResult, workdir::WorkdirError> {
|
||||||
|
panic!("native List must not call stat");
|
||||||
|
}
|
||||||
|
async fn read(
|
||||||
|
&self,
|
||||||
|
_: workdir::ReadRequest,
|
||||||
|
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
|
||||||
|
panic!("native List must not read file content");
|
||||||
|
}
|
||||||
|
async fn write(
|
||||||
|
&self,
|
||||||
|
_: workdir::WriteRequest,
|
||||||
|
) -> Result<workdir::WriteResult, workdir::WorkdirError> {
|
||||||
|
panic!("native List must not write");
|
||||||
|
}
|
||||||
|
async fn edit(
|
||||||
|
&self,
|
||||||
|
_: workdir::EditRequest,
|
||||||
|
) -> Result<workdir::EditResult, workdir::WorkdirError> {
|
||||||
|
panic!("native List must not edit");
|
||||||
|
}
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
_: workdir::ListRequest,
|
||||||
|
) -> Result<workdir::ListResult, workdir::WorkdirError> {
|
||||||
|
panic!("native List must use checkout_search, not ordinary list");
|
||||||
|
}
|
||||||
|
async fn glob(
|
||||||
|
&self,
|
||||||
|
_: workdir::GlobRequest,
|
||||||
|
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
|
||||||
|
panic!("native List must not glob");
|
||||||
|
}
|
||||||
|
async fn grep(
|
||||||
|
&self,
|
||||||
|
_: workdir::GrepRequest,
|
||||||
|
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
|
||||||
|
panic!("native List must not grep");
|
||||||
|
}
|
||||||
|
async fn start_command(
|
||||||
|
&self,
|
||||||
|
_: workdir::CommandRequest,
|
||||||
|
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||||
|
panic!("native List must not execute commands");
|
||||||
|
}
|
||||||
|
async fn command_status(
|
||||||
|
&self,
|
||||||
|
_: workdir::CommandHandle,
|
||||||
|
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
|
||||||
|
panic!("native List must not use commands");
|
||||||
|
}
|
||||||
|
async fn command_output(
|
||||||
|
&self,
|
||||||
|
_: workdir::CommandOutputRequest,
|
||||||
|
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||||
|
panic!("native List must not use commands");
|
||||||
|
}
|
||||||
|
async fn cancel_command(&self, _: workdir::CommandHandle) -> Result<(), workdir::WorkdirError> {
|
||||||
|
panic!("native List must not use commands");
|
||||||
|
}
|
||||||
|
async fn close(&self) -> Result<(), workdir::WorkdirError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_with_provider(
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities,
|
||||||
|
result: workdir::CheckoutSearchResult,
|
||||||
|
change_validator: bool,
|
||||||
|
) -> Result<CheckoutToolOutput, ToolError> {
|
||||||
|
list_with_provider_after(capabilities, result, change_validator, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_with_provider_after(
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities,
|
||||||
|
result: workdir::CheckoutSearchResult,
|
||||||
|
change_validator: bool,
|
||||||
|
after: Option<workdir::ListCursor>,
|
||||||
|
) -> Result<CheckoutToolOutput, ToolError> {
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("selected").unwrap(),
|
||||||
|
Arc::new(ListProvider {
|
||||||
|
workdir: workdir::Workdir::new("list-test"),
|
||||||
|
capabilities,
|
||||||
|
result,
|
||||||
|
change_validator,
|
||||||
|
expected_after: after.clone(),
|
||||||
|
searched: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let generation = router.resolve(Some("selected")).unwrap().generation;
|
||||||
|
execute_checkout_tool(
|
||||||
|
router,
|
||||||
|
Tracker::new(),
|
||||||
|
"selected",
|
||||||
|
generation,
|
||||||
|
WorkdirPath::new("src").unwrap(),
|
||||||
|
vec![0],
|
||||||
|
"List",
|
||||||
|
json!({"limit": 1, "after": after}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_page() -> workdir::ListResult {
|
||||||
|
workdir::ListResult {
|
||||||
|
entries: vec![workdir::ListEntry {
|
||||||
|
path: WorkdirPath::new("src/a.txt").unwrap(),
|
||||||
|
kind: workdir::EntryKind::File,
|
||||||
|
size: 5,
|
||||||
|
}],
|
||||||
|
total_entries: 2,
|
||||||
|
total_bytes: 10,
|
||||||
|
truncated: true,
|
||||||
|
next_after: Some(fs_operation::ListCursor {
|
||||||
|
kind: workdir::EntryKind::File,
|
||||||
|
path: WorkdirPath::new("src/a.txt").unwrap(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_uses_read_capability_and_checked_search_without_prefetch() {
|
||||||
|
let read_only = workdir::WorkdirSessionCapabilities::EMPTY.with(WorkdirSessionCapability::Read);
|
||||||
|
let page = list_page();
|
||||||
|
let result = list_with_provider(
|
||||||
|
read_only,
|
||||||
|
workdir::CheckoutSearchResult::List(page.clone()),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.listing, Some(page));
|
||||||
|
assert_eq!(result.validator, Some(vec![0]));
|
||||||
|
let no_read = workdir::WorkdirSessionCapabilities::EMPTY.with(WorkdirSessionCapability::Glob);
|
||||||
|
let error = list_with_provider(
|
||||||
|
no_read,
|
||||||
|
workdir::CheckoutSearchResult::List(list_page()),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||||
|
assert!(error.to_string().contains("Read"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_rejects_changed_post_search_directory_validator() {
|
||||||
|
let error = list_with_provider(
|
||||||
|
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
|
workdir::CheckoutSearchResult::List(list_page()),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_checkout_stale(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_rejects_provider_pages_that_escape_or_exceed_bounds() {
|
||||||
|
let mut outside = list_page();
|
||||||
|
outside.entries[0].path = WorkdirPath::new("outside.txt").unwrap();
|
||||||
|
let mut nested = list_page();
|
||||||
|
nested.entries[0].path = WorkdirPath::new("src/nested/a.txt").unwrap();
|
||||||
|
let mut cursor_outside = list_page();
|
||||||
|
cursor_outside.next_after.as_mut().unwrap().path = WorkdirPath::new("outside.txt").unwrap();
|
||||||
|
let mut cursor_kind = list_page();
|
||||||
|
cursor_kind.next_after.as_mut().unwrap().kind = workdir::EntryKind::Directory;
|
||||||
|
let mut oversized = list_page();
|
||||||
|
oversized.entries.push(oversized.entries[0].clone());
|
||||||
|
for result in [
|
||||||
|
workdir::CheckoutSearchResult::List(outside),
|
||||||
|
workdir::CheckoutSearchResult::List(nested),
|
||||||
|
workdir::CheckoutSearchResult::List(cursor_outside),
|
||||||
|
workdir::CheckoutSearchResult::List(cursor_kind),
|
||||||
|
workdir::CheckoutSearchResult::List(oversized),
|
||||||
|
workdir::CheckoutSearchResult::Glob(workdir::GlobResult {
|
||||||
|
paths: vec![],
|
||||||
|
truncated: false,
|
||||||
|
}),
|
||||||
|
] {
|
||||||
|
let error = list_with_provider(
|
||||||
|
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
|
result,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_checkout_code(error, "checkout_unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_special_entry_cursors_preserve_kind_and_continue_the_page() {
|
||||||
|
// Provider-only special entries stay path coordinates: the adapter forbids
|
||||||
|
// content reads and does not offer direct observations for these entries.
|
||||||
|
for kind in [workdir::EntryKind::Other, workdir::EntryKind::Symlink] {
|
||||||
|
let mut first_page = list_page();
|
||||||
|
first_page.entries[0].kind = kind;
|
||||||
|
first_page.next_after.as_mut().unwrap().kind = kind;
|
||||||
|
let first = list_with_provider(
|
||||||
|
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
|
workdir::CheckoutSearchResult::List(first_page),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.listing
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.entries[0].kind, kind);
|
||||||
|
let after = first.next_after.unwrap();
|
||||||
|
assert_eq!(after.kind, kind);
|
||||||
|
let mut second_page = list_page();
|
||||||
|
second_page.entries[0].path = WorkdirPath::new("src/z.txt").unwrap();
|
||||||
|
second_page.truncated = false;
|
||||||
|
second_page.next_after = None;
|
||||||
|
let second = list_with_provider_after(
|
||||||
|
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
|
workdir::CheckoutSearchResult::List(second_page.clone()),
|
||||||
|
false,
|
||||||
|
Some(after),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.listing
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(second, second_page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_rejects_missing_or_mismatched_truncated_continuations() {
|
||||||
|
let mut missing = list_page();
|
||||||
|
missing.next_after = None;
|
||||||
|
let mut empty = list_page();
|
||||||
|
empty.entries.clear();
|
||||||
|
let mut wrong_path = list_page();
|
||||||
|
wrong_path.next_after.as_mut().unwrap().path = WorkdirPath::new("src/z.txt").unwrap();
|
||||||
|
let mut wrong_group = list_page();
|
||||||
|
wrong_group.next_after.as_mut().unwrap().kind = workdir::EntryKind::Directory;
|
||||||
|
let mut untruncated = list_page();
|
||||||
|
untruncated.truncated = false;
|
||||||
|
for page in [missing, empty, wrong_path, wrong_group, untruncated] {
|
||||||
|
let error = list_with_provider(
|
||||||
|
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
|
workdir::CheckoutSearchResult::List(page),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_checkout_code(error, "checkout_unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_checkout_stale(error: ToolError) {
|
fn assert_checkout_stale(error: ToolError) {
|
||||||
assert_checkout_code(error, "checkout_stale");
|
assert_checkout_code(error, "checkout_stale");
|
||||||
}
|
}
|
||||||
@@ -557,6 +877,183 @@ async fn checkout_search_typed_paths_and_normal_rendering() {
|
|||||||
let normal = fixture.normal("Grep", json!({"target_workdir": "main", "path": "src", "pattern": "hit", "output_mode": mode, "-C": 1})).await.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.summary, normal.summary);
|
||||||
assert_eq!(grep.output.content, normal.content);
|
assert_eq!(grep.output.content, normal.content);
|
||||||
|
assert!(grep.listing.is_none());
|
||||||
assert!(!grep.output.content.unwrap().contains("outside.txt"));
|
assert!(!grep.output.content.unwrap().contains("outside.txt"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_returns_one_typed_page_and_provider_coordinate_continuation() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
std::fs::create_dir_all(fixture.dir.path().join("src/nested")).unwrap();
|
||||||
|
std::fs::write(fixture.dir.path().join("src/a.txt"), "alpha").unwrap();
|
||||||
|
std::fs::write(fixture.dir.path().join("src/z.txt"), "z").unwrap();
|
||||||
|
std::fs::write(fixture.dir.path().join("outside.txt"), "outside").unwrap();
|
||||||
|
let (_, validator) = fixture.observation("main", "src").await;
|
||||||
|
let first = fixture
|
||||||
|
.native("main", "src", "List", json!({"limit": 1}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(first.paths.is_empty());
|
||||||
|
assert!(first.output.content.is_none());
|
||||||
|
assert!(first.output.summary.contains("Listed 1 of 3 entries"));
|
||||||
|
assert!(first.output.summary.contains("truncated"));
|
||||||
|
assert_eq!(first.validator, Some(validator));
|
||||||
|
let page = first.listing.unwrap();
|
||||||
|
assert_eq!(page.entries.len(), 1);
|
||||||
|
assert_eq!(page.entries[0].path.as_str(), "src/nested");
|
||||||
|
assert_eq!(page.entries[0].kind, workdir::EntryKind::Directory);
|
||||||
|
assert!(page.truncated);
|
||||||
|
let total_bytes = page.total_bytes;
|
||||||
|
assert!(total_bytes >= 6);
|
||||||
|
let cursor = page.next_after.unwrap();
|
||||||
|
assert_eq!(cursor.path.as_str(), "src/nested");
|
||||||
|
let second = fixture
|
||||||
|
.native("main", "src", "List", json!({"after": cursor}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let page = second.listing.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
page.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.path.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["src/a.txt", "src/z.txt"]
|
||||||
|
);
|
||||||
|
assert_eq!(page.total_entries, 3);
|
||||||
|
assert_eq!(page.total_bytes, total_bytes);
|
||||||
|
assert!(!page.truncated);
|
||||||
|
assert!(page.next_after.is_none());
|
||||||
|
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_root_file_cursor_stays_on_selected_attachment() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
std::fs::write(fixture.dir.path().join("a.txt"), "a").unwrap();
|
||||||
|
std::fs::write(fixture.dir.path().join("z.txt"), "z").unwrap();
|
||||||
|
let other = TempDir::new().unwrap();
|
||||||
|
std::fs::write(other.path().join("wrong.txt"), "wrong").unwrap();
|
||||||
|
fixture
|
||||||
|
.router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("other").unwrap(),
|
||||||
|
Arc::new(LocalWorkdirSession::new(
|
||||||
|
Scope::writable(other.path()).unwrap(),
|
||||||
|
other.path().to_path_buf(),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let first = fixture
|
||||||
|
.native("main", ".", "List", json!({"limit": 1}))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.listing
|
||||||
|
.unwrap();
|
||||||
|
let cursor = first.next_after.unwrap();
|
||||||
|
assert_eq!(cursor.kind, workdir::EntryKind::File);
|
||||||
|
assert_eq!(cursor.path.as_str(), "a.txt");
|
||||||
|
let second = fixture
|
||||||
|
.native("main", ".", "List", json!({"after": cursor}))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.listing
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(second.entries.len(), 1);
|
||||||
|
assert_eq!(second.entries[0].path.as_str(), "z.txt");
|
||||||
|
let other_page = fixture
|
||||||
|
.native("other", ".", "List", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.listing
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(other_page.entries.len(), 1);
|
||||||
|
assert_eq!(other_page.entries[0].path.as_str(), "wrong.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_defaults_to_100_and_accepts_both_limit_bounds() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
for index in 0..101 {
|
||||||
|
std::fs::write(fixture.dir.path().join(format!("{index:03}.txt")), "").unwrap();
|
||||||
|
}
|
||||||
|
for (arguments, count, truncated) in [
|
||||||
|
(json!({}), 100, true),
|
||||||
|
(json!({"limit": 1}), 1, true),
|
||||||
|
(json!({"limit": 1000}), 101, false),
|
||||||
|
] {
|
||||||
|
let result = fixture
|
||||||
|
.native("main", ".", "List", arguments)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let page = result.listing.unwrap();
|
||||||
|
assert_eq!(page.entries.len(), count);
|
||||||
|
assert_eq!(page.truncated, truncated);
|
||||||
|
assert_eq!(page.next_after.is_some(), truncated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_rejects_route_overrides_malformed_limits_and_nonchild_cursors() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
std::fs::create_dir(fixture.dir.path().join("src")).unwrap();
|
||||||
|
for arguments in [
|
||||||
|
json!({"path": "."}),
|
||||||
|
json!({"target_workdir": "other"}),
|
||||||
|
json!({"generation": 1}),
|
||||||
|
json!({"validator": []}),
|
||||||
|
json!({"limit": 0}),
|
||||||
|
json!({"limit": 1001}),
|
||||||
|
json!({"limit": -1}),
|
||||||
|
json!({"limit": 1.5}),
|
||||||
|
json!({"limit": "1"}),
|
||||||
|
json!({"limit": null}),
|
||||||
|
json!({"after": "src/a.txt"}),
|
||||||
|
json!({"after": {"path": "src/a.txt"}}),
|
||||||
|
json!({"after": {"kind": "file", "path": "src/a.txt", "extra": 1}}),
|
||||||
|
json!({"after": {"kind": "bogus", "path": "src/a.txt"}}),
|
||||||
|
json!({"after": {"kind": "file", "path": "src"}}),
|
||||||
|
json!({"after": {"kind": "directory", "path": "src/nested/deep"}}),
|
||||||
|
json!({"after": {"kind": "file", "path": "src-other/a.txt"}}),
|
||||||
|
json!({"after": {"kind": "file", "path": "a.txt"}}),
|
||||||
|
json!({"after": {"kind": "file", "path": "/src/a.txt"}}),
|
||||||
|
json!({"after": {"kind": "file", "path": "src/../a.txt"}}),
|
||||||
|
json!({"after": {"kind": "file", "path": "."}}),
|
||||||
|
] {
|
||||||
|
let error = fixture
|
||||||
|
.native("main", "src", "List", arguments.clone())
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(error, ToolError::InvalidArgument(_)),
|
||||||
|
"{arguments}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_requires_current_generation_and_directory_validator() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let (generation, validator) = fixture.observation("main", ".").await;
|
||||||
|
// No timing dependency: make the supplied observation explicitly stale.
|
||||||
|
let mut stale_validator = validator.clone();
|
||||||
|
stale_validator.push(0);
|
||||||
|
for (generation, validator) in [(generation, stale_validator), (generation + 1, validator)] {
|
||||||
|
let error = execute_checkout_tool(
|
||||||
|
fixture.router.clone(),
|
||||||
|
fixture.tracker.clone(),
|
||||||
|
"main",
|
||||||
|
generation,
|
||||||
|
WorkdirPath::root(),
|
||||||
|
validator,
|
||||||
|
"List",
|
||||||
|
json!({}),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.err()
|
||||||
|
.unwrap();
|
||||||
|
assert_checkout_stale(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ pub(crate) async fn execute_edit(
|
|||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
paths: Vec::new(),
|
paths: Vec::new(),
|
||||||
|
listing: None,
|
||||||
validator,
|
validator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ pub(crate) async fn execute_glob(
|
|||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
paths: result.paths,
|
paths: result.paths,
|
||||||
|
listing: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ pub(crate) async fn execute_grep(
|
|||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
paths: result.paths,
|
paths: result.paths,
|
||||||
|
listing: None,
|
||||||
validator: None,
|
validator: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ pub(crate) async fn execute_read(
|
|||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
paths: Vec::new(),
|
paths: Vec::new(),
|
||||||
|
listing: None,
|
||||||
validator,
|
validator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ pub(crate) async fn execute_write(
|
|||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
},
|
},
|
||||||
paths: create_path.into_iter().collect(),
|
paths: create_path.into_iter().collect(),
|
||||||
|
listing: None,
|
||||||
validator,
|
validator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,37 @@ Host generation reuse, native permission identity, read-history updates, model
|
|||||||
Operation publication, transport hosting (Runtime/Backend/External), and durable
|
Operation publication, transport hosting (Runtime/Backend/External), and durable
|
||||||
Ticket/MR review remain the parent integration's responsibilities.
|
Ticket/MR review remain the parent integration's responsibilities.
|
||||||
|
|
||||||
|
## Bounded live List pagination
|
||||||
|
|
||||||
|
`ListRequest { path, limit, after: Option<ListCursor> }` accepts an exclusive
|
||||||
|
ordering key `ListCursor { kind: EntryKind, path: FsPath }`. Directories sort
|
||||||
|
first, then paths sort lexically within the directory/non-directory groups;
|
||||||
|
File, Symlink and Other share the non-directory group. `ListResult.next_after`
|
||||||
|
is the last returned key when another page remains. Reuse that key unchanged
|
||||||
|
with the same directory and output root. Cursor paths must name a direct child
|
||||||
|
in the result coordinate frame; they are not statted and need not still exist.
|
||||||
|
The optional fields default to absent and are omitted from JSON when absent.
|
||||||
|
|
||||||
|
The shared provider engine retains at most `limit + 1` candidates, additionally
|
||||||
|
bounded by the 1 MiB aggregate path-response ceiling (including continuation),
|
||||||
|
while scanning the directory once. It never stops merely because the page is
|
||||||
|
full: enumeration order cannot determine the retained prefix. `total_entries`
|
||||||
|
and `total_bytes` describe all visible entries in the current scan, including
|
||||||
|
keys at/before `after`, not only the page or remaining suffix. A byte-bounded
|
||||||
|
page may contain fewer than `limit` entries and still provide continuation.
|
||||||
|
A zero-limit request returns totals and truncation but no continuation.
|
||||||
|
List uses Read capability and read/enumeration scope, and **does not ignore-filter**
|
||||||
|
`.gitignore`/`.ignore` entries or hide dotfiles; Glob/Grep keep their own policy.
|
||||||
|
Cancellation and the 100,000-entry traversal ceiling apply to the complete scan,
|
||||||
|
including excluded entries. External DTOs retain their existing item/path bounds
|
||||||
|
and also validate cursor paths and continuation response bytes.
|
||||||
|
|
||||||
|
Each page observes a live directory, not a snapshot, and no cross-page
|
||||||
|
consistency is promised. Insertions after the cursor may appear; earlier keys
|
||||||
|
are not revisited. Removed entries disappear, including the cursor entry.
|
||||||
|
Renames or kind changes can move an entry across the cursor and cause omission
|
||||||
|
or repetition. Even one scan is not transactional against external editors.
|
||||||
|
|
||||||
## Scoped checkout search boundary
|
## Scoped checkout search boundary
|
||||||
|
|
||||||
`CheckoutSearchRequest::new(CheckoutSearchOperation::{List, Glob, Grep}(request))`
|
`CheckoutSearchRequest::new(CheckoutSearchOperation::{List, Glob, Grep}(request))`
|
||||||
@@ -110,7 +141,8 @@ are `scope_layers: Vec<Vec<WorkdirToolScopeRule>>` and `output_root: WorkdirPath
|
|||||||
Rules union within each layer; layers intersect with each other, the current
|
Rules union within each layer; layers intersect with each other, the current
|
||||||
provider authority, and the output-root boundary. Wrappers contribute their own
|
provider authority, and the output-root boundary. Wrappers contribute their own
|
||||||
scope; caller-supplied layers can only narrow, never replace it. Each wrapper
|
scope; caller-supplied layers can only narrow, never replace it. Each wrapper
|
||||||
translates all incoming coordinates once and forwards the composed output root.
|
translates incoming provider coordinates once and forwards the composed output root.
|
||||||
|
List cursors remain output-root-relative, just like result paths.
|
||||||
Provider-rendered Grep text and every typed result path are already relative to
|
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.
|
that final root; wrappers must not prefix cwd onto returned paths again.
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,19 @@ impl CheckoutSearchRequest {
|
|||||||
}
|
}
|
||||||
let valid = match &self.operation {
|
let valid = match &self.operation {
|
||||||
CheckoutSearchOperation::List(r) => {
|
CheckoutSearchOperation::List(r) => {
|
||||||
|
if let Some(after) = &r.after {
|
||||||
|
validate_path(&after.path)?;
|
||||||
|
if after.path.is_root()
|
||||||
|
|| std::path::Path::new(after.path.as_str()).parent()
|
||||||
|
!= std::path::Path::new(r.path.as_str())
|
||||||
|
.strip_prefix(self.output_root.as_str())
|
||||||
|
.ok()
|
||||||
|
{
|
||||||
|
return Err(WorkdirError::InvalidArgument(
|
||||||
|
"List cursor must name a direct child of the listed directory".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
r.limit <= crate::external::MAX_EXTERNAL_RESULT_ITEMS
|
r.limit <= crate::external::MAX_EXTERNAL_RESULT_ITEMS
|
||||||
}
|
}
|
||||||
CheckoutSearchOperation::Glob(r) => {
|
CheckoutSearchOperation::Glob(r) => {
|
||||||
@@ -793,6 +806,7 @@ mod tests {
|
|||||||
for provider in [&local as &dyn WorkdirSession, &external] {
|
for provider in [&local as &dyn WorkdirSession, &external] {
|
||||||
let listed = provider
|
let listed = provider
|
||||||
.list(ListRequest {
|
.list(ListRequest {
|
||||||
|
after: None,
|
||||||
path: WorkdirPath::root(),
|
path: WorkdirPath::root(),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ fn fixture() -> tempfile::TempDir {
|
|||||||
async fn verify_sparse(session: &dyn WorkdirSession) {
|
async fn verify_sparse(session: &dyn WorkdirSession) {
|
||||||
let listed = session
|
let listed = session
|
||||||
.list(ListRequest {
|
.list(ListRequest {
|
||||||
|
after: None,
|
||||||
path: WorkdirPath::root(),
|
path: WorkdirPath::root(),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
})
|
})
|
||||||
@@ -75,6 +76,7 @@ async fn verify_sparse(session: &dyn WorkdirSession) {
|
|||||||
);
|
);
|
||||||
let empty = session
|
let empty = session
|
||||||
.list(ListRequest {
|
.list(ListRequest {
|
||||||
|
after: None,
|
||||||
path: path("denied"),
|
path: path("denied"),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
})
|
})
|
||||||
@@ -108,6 +110,7 @@ async fn verify_sparse(session: &dyn WorkdirSession) {
|
|||||||
let CheckoutSearchResult::List(native) = session
|
let CheckoutSearchResult::List(native) = session
|
||||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||||
ListRequest {
|
ListRequest {
|
||||||
|
after: None,
|
||||||
path: WorkdirPath::root(),
|
path: WorkdirPath::root(),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
},
|
},
|
||||||
@@ -161,6 +164,7 @@ async fn checkout_search_nested_sparse_nonrecursive_scopes_rebase_and_exclude_co
|
|||||||
.tool_session()
|
.tool_session()
|
||||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||||
ListRequest {
|
ListRequest {
|
||||||
|
after: None,
|
||||||
path: WorkdirPath::root(),
|
path: WorkdirPath::root(),
|
||||||
limit: 100
|
limit: 100
|
||||||
}
|
}
|
||||||
@@ -196,6 +200,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
|||||||
let CheckoutSearchResult::List(result) = stacked
|
let CheckoutSearchResult::List(result) = stacked
|
||||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||||
ListRequest {
|
ListRequest {
|
||||||
|
after: None,
|
||||||
path: path("keep"),
|
path: path("keep"),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
},
|
},
|
||||||
@@ -229,6 +234,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
|||||||
let entries = second
|
let entries = second
|
||||||
.tool_session()
|
.tool_session()
|
||||||
.list(ListRequest {
|
.list(ListRequest {
|
||||||
|
after: None,
|
||||||
path: WorkdirPath::root(),
|
path: WorkdirPath::root(),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
})
|
})
|
||||||
@@ -246,6 +252,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
|||||||
second
|
second
|
||||||
.tool_session()
|
.tool_session()
|
||||||
.list(ListRequest {
|
.list(ListRequest {
|
||||||
|
after: None,
|
||||||
path: path("deep"),
|
path: path("deep"),
|
||||||
limit: 100
|
limit: 100
|
||||||
})
|
})
|
||||||
@@ -292,6 +299,7 @@ async fn checkout_search_scope_layers_intersect_before_list_and_grep() {
|
|||||||
root.path().to_path_buf(),
|
root.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||||
|
after: None,
|
||||||
path: path("child"),
|
path: path("child"),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}));
|
}));
|
||||||
@@ -325,6 +333,7 @@ async fn checkout_search_wire_dto_bounds_and_provider_capabilities() {
|
|||||||
root.path().to_path_buf(),
|
root.path().to_path_buf(),
|
||||||
);
|
);
|
||||||
let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||||
|
after: None,
|
||||||
path: path("child/keep"),
|
path: path("child/keep"),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}));
|
}));
|
||||||
@@ -795,6 +804,7 @@ async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_es
|
|||||||
let CheckoutSearchResult::List(result) = source
|
let CheckoutSearchResult::List(result) = source
|
||||||
.checkout_search(CheckoutSearchRequest {
|
.checkout_search(CheckoutSearchRequest {
|
||||||
operation: CheckoutSearchOperation::List(ListRequest {
|
operation: CheckoutSearchOperation::List(ListRequest {
|
||||||
|
after: None,
|
||||||
path: path("child/denied"),
|
path: path("child/denied"),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -155,7 +155,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
|||||||
<= MAX_EXTERNAL_SCOPE_RULES
|
<= MAX_EXTERNAL_SCOPE_RULES
|
||||||
&& validate_external_operation(&match &request.operation {
|
&& validate_external_operation(&match &request.operation {
|
||||||
crate::CheckoutSearchOperation::List(r) => {
|
crate::CheckoutSearchOperation::List(r) => {
|
||||||
WorkdirSessionOperation::List(r.clone())
|
// request.validate() checks the output_root-relative
|
||||||
|
// cursor; validate the provider-coordinate path here.
|
||||||
|
let mut list = r.clone();
|
||||||
|
list.after = None;
|
||||||
|
WorkdirSessionOperation::List(list)
|
||||||
}
|
}
|
||||||
crate::CheckoutSearchOperation::Glob(r) => {
|
crate::CheckoutSearchOperation::Glob(r) => {
|
||||||
WorkdirSessionOperation::Glob(r.clone())
|
WorkdirSessionOperation::Glob(r.clone())
|
||||||
@@ -212,7 +216,14 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
|||||||
.contains(&request.max_bytes)
|
.contains(&request.max_bytes)
|
||||||
}
|
}
|
||||||
WorkdirSessionOperation::List(request) => {
|
WorkdirSessionOperation::List(request) => {
|
||||||
is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
is_root_relative(&request.path)
|
||||||
|
&& request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
|
&& request.after.as_ref().is_none_or(|after| {
|
||||||
|
is_root_relative(&after.path)
|
||||||
|
&& !after.path.is_root()
|
||||||
|
&& Path::new(after.path.as_str()).parent()
|
||||||
|
== Some(Path::new(request.path.as_str()))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
WorkdirSessionOperation::Glob(request) => {
|
WorkdirSessionOperation::Glob(request) => {
|
||||||
is_root_relative(&request.path)
|
is_root_relative(&request.path)
|
||||||
@@ -395,7 +406,20 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
|
|||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
.all(|entry| is_root_relative(&entry.path))
|
.all(|entry| is_root_relative(&entry.path))
|
||||||
&& result_paths_fit(result.entries.iter().map(|entry| &entry.path))
|
&& result.next_after.as_ref().is_none_or(|after| {
|
||||||
|
is_root_relative(&after.path)
|
||||||
|
&& result.truncated
|
||||||
|
&& result.entries.last().is_some_and(|entry| {
|
||||||
|
entry.kind == after.kind && entry.path == after.path
|
||||||
|
})
|
||||||
|
})
|
||||||
|
&& result_paths_fit(
|
||||||
|
result
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| &entry.path)
|
||||||
|
.chain(result.next_after.iter().map(|after| &after.path)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
WorkdirSessionOperationResult::Glob(result) => {
|
WorkdirSessionOperationResult::Glob(result) => {
|
||||||
result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||||
@@ -917,6 +941,7 @@ mod tests {
|
|||||||
assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(nested).is_err());
|
assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(nested).is_err());
|
||||||
|
|
||||||
let unbounded = WorkdirSessionOperation::List(ListRequest {
|
let unbounded = WorkdirSessionOperation::List(ListRequest {
|
||||||
|
after: None,
|
||||||
path: WorkdirPath::root(),
|
path: WorkdirPath::root(),
|
||||||
limit: MAX_EXTERNAL_RESULT_ITEMS + 1,
|
limit: MAX_EXTERNAL_RESULT_ITEMS + 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ use tokio::sync::broadcast;
|
|||||||
|
|
||||||
pub use fs_operation::{
|
pub use fs_operation::{
|
||||||
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
|
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
|
||||||
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest,
|
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListCursor, ListEntry,
|
||||||
ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest,
|
ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult,
|
||||||
StatResult, WriteRequest, WriteResult,
|
StatRequest, StatResult, WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
pub use http::dispatch_workdir_session_operation;
|
pub use http::dispatch_workdir_session_operation;
|
||||||
pub use local::{
|
pub use local::{
|
||||||
|
|||||||
@@ -2695,6 +2695,7 @@ mod tests {
|
|||||||
let listed = WorkdirSession::list(
|
let listed = WorkdirSession::list(
|
||||||
&workdir,
|
&workdir,
|
||||||
ListRequest {
|
ListRequest {
|
||||||
|
after: None,
|
||||||
path: WorkdirPath::new("notes").unwrap(),
|
path: WorkdirPath::new("notes").unwrap(),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
},
|
},
|
||||||
@@ -3385,6 +3386,7 @@ mod tests {
|
|||||||
let list = WorkdirSession::list(
|
let list = WorkdirSession::list(
|
||||||
&workdir,
|
&workdir,
|
||||||
ListRequest {
|
ListRequest {
|
||||||
|
after: None,
|
||||||
path: WorkdirPath::new("yoi.local").unwrap(),
|
path: WorkdirPath::new("yoi.local").unwrap(),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1005,6 +1005,9 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
.await?;
|
.await?;
|
||||||
self.ensure_read(&path, request.operation.capability())?;
|
self.ensure_read(&path, request.operation.capability())?;
|
||||||
*request.operation.path_mut() = path;
|
*request.operation.path_mut() = path;
|
||||||
|
// List cursors stay output_root-relative, just like source results.
|
||||||
|
// Composing output_root below handles their coordinates without stat
|
||||||
|
// of a key that may have disappeared between live pages.
|
||||||
request.output_root = self.resolve_path(&request.output_root)?;
|
request.output_root = self.resolve_path(&request.output_root)?;
|
||||||
for layer in &mut request.scope_layers {
|
for layer in &mut request.scope_layers {
|
||||||
for rule in layer {
|
for rule in layer {
|
||||||
@@ -1873,6 +1876,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
parent
|
parent
|
||||||
.list(ListRequest {
|
.list(ListRequest {
|
||||||
|
after: None,
|
||||||
path: fs_path("child"),
|
path: fs_path("child"),
|
||||||
limit: 100
|
limit: 100
|
||||||
})
|
})
|
||||||
@@ -1890,6 +1894,7 @@ mod tests {
|
|||||||
child
|
child
|
||||||
.tool_session()
|
.tool_session()
|
||||||
.list(ListRequest {
|
.list(ListRequest {
|
||||||
|
after: None,
|
||||||
path: FsPath::root(),
|
path: FsPath::root(),
|
||||||
limit: 100
|
limit: 100
|
||||||
})
|
})
|
||||||
@@ -1901,6 +1906,7 @@ mod tests {
|
|||||||
.tool_session()
|
.tool_session()
|
||||||
.checkout_search(crate::CheckoutSearchRequest::new(
|
.checkout_search(crate::CheckoutSearchRequest::new(
|
||||||
crate::CheckoutSearchOperation::List(ListRequest {
|
crate::CheckoutSearchOperation::List(ListRequest {
|
||||||
|
after: None,
|
||||||
path: FsPath::root(),
|
path: FsPath::root(),
|
||||||
limit: 100
|
limit: 100
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
#![cfg(target_os = "linux")]
|
||||||
|
use std::sync::Arc;
|
||||||
|
use workdir::external::{
|
||||||
|
ExternalWorkdirOperation, ExternalWorkdirOperationResult, MAX_EXTERNAL_RESULT_ITEMS,
|
||||||
|
};
|
||||||
|
use workdir::http::{WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Output};
|
||||||
|
use workdir::*;
|
||||||
|
|
||||||
|
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: manifest::SymlinkPolicy::Logical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn scope(rules: Vec<WorkdirToolScopeRule>, cwd: &str) -> WorkdirToolScope {
|
||||||
|
WorkdirToolScope {
|
||||||
|
rules,
|
||||||
|
cwd: path(cwd),
|
||||||
|
command: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn request(value: &str, limit: usize, after: Option<ListCursor>) -> ListRequest {
|
||||||
|
ListRequest {
|
||||||
|
path: path(value),
|
||||||
|
limit,
|
||||||
|
after,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn names(result: &ListResult) -> Vec<&str> {
|
||||||
|
result
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.path.as_str())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_scoped_external_pages_rebase_cursor_and_preserve_sparse_authority() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(root.path().join("child/deep")).unwrap();
|
||||||
|
for name in ["child/a", "child/z", "child/deep/secret", "outside"] {
|
||||||
|
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let source = LocalWorkdirSession::external_read_only(
|
||||||
|
Workdir::new("pagination"),
|
||||||
|
root.path(),
|
||||||
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let broker = WorkdirToolBroker::new(Arc::new(source));
|
||||||
|
let lease = broker
|
||||||
|
.scope(scope(vec![rule("child", false)], "child"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// Two actual wrappers must not duplicate child/ in the continuation.
|
||||||
|
let stacked = WorkdirToolBroker::new(lease.tool_session());
|
||||||
|
let first = stacked.list(request("", 1, None)).await.unwrap();
|
||||||
|
assert_eq!(names(&first), ["deep"]);
|
||||||
|
assert_eq!(first.next_after.as_ref().unwrap().path, path("deep"));
|
||||||
|
assert_eq!(
|
||||||
|
first.next_after.as_ref().unwrap().kind,
|
||||||
|
EntryKind::Directory
|
||||||
|
);
|
||||||
|
assert_eq!(first.total_entries, 3);
|
||||||
|
assert!(
|
||||||
|
stacked
|
||||||
|
.list(request("deep", 1, None))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let second = stacked
|
||||||
|
.list(request("", 1, first.next_after))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(names(&second), ["a"]);
|
||||||
|
std::fs::remove_file(root.path().join("child/a")).unwrap();
|
||||||
|
std::fs::write(root.path().join("child/b"), b"xx").unwrap();
|
||||||
|
let third = stacked
|
||||||
|
.list(request("", 10, second.next_after))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(names(&third), ["b", "z"]);
|
||||||
|
assert!(!third.truncated);
|
||||||
|
assert!(third.next_after.is_none());
|
||||||
|
assert_eq!(third.total_entries, 3);
|
||||||
|
lease.close().await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
stacked.list(request("", 1, None)).await,
|
||||||
|
Err(WorkdirError::SessionClosed)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_checkout_search_cursor_uses_output_root_coordinates_across_nested_wrappers() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir_all(root.path().join("child/keep")).unwrap();
|
||||||
|
for name in ["a", "b", "c"] {
|
||||||
|
std::fs::write(root.path().join("child/keep").join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let source = LocalWorkdirSession::external_read_only(
|
||||||
|
Workdir::new("rebased-pagination"),
|
||||||
|
root.path(),
|
||||||
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let broker = WorkdirToolBroker::new(Arc::new(source));
|
||||||
|
let lease = broker
|
||||||
|
.scope(scope(vec![rule("child", true)], "child"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let stacked = WorkdirToolBroker::new(lease.tool_session());
|
||||||
|
let mut search =
|
||||||
|
CheckoutSearchRequest::new(CheckoutSearchOperation::List(request("keep", 1, None)));
|
||||||
|
search.output_root = path("keep");
|
||||||
|
let CheckoutSearchResult::List(first) = stacked.checkout_search(search.clone()).await.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(names(&first), ["a"]);
|
||||||
|
if let CheckoutSearchOperation::List(list) = &mut search.operation {
|
||||||
|
list.after = first.next_after;
|
||||||
|
}
|
||||||
|
let CheckoutSearchResult::List(second) = stacked.checkout_search(search).await.unwrap() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(names(&second), ["b"]);
|
||||||
|
assert_eq!(second.next_after.unwrap().path, path("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_read_capability_suffices_without_glob_and_missing_read_is_denied() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(root.path().join("a"), b"x").unwrap();
|
||||||
|
let source = LocalWorkdirSession::materialized(
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap()),
|
||||||
|
WorkdirSessionCapabilities::from_capabilities([WorkdirSessionCapability::Read]),
|
||||||
|
);
|
||||||
|
let broker = WorkdirToolBroker::new(Arc::new(source));
|
||||||
|
let lease = broker.scope(scope(vec![rule("", true)], "")).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
names(
|
||||||
|
&lease
|
||||||
|
.tool_session()
|
||||||
|
.list(request("", 1, None))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
),
|
||||||
|
["a"]
|
||||||
|
);
|
||||||
|
let denied = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||||
|
Workdir::new("no-read"),
|
||||||
|
ExternalWorkdirRoot::pin(root.path()).unwrap(),
|
||||||
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||||
|
WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
denied.list(request("", 1, None)).await,
|
||||||
|
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Read))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_external_wire_preserves_continuation_and_rejects_unbounded_cursor() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::create_dir(root.path().join("child")).unwrap();
|
||||||
|
for name in ["a", "b"] {
|
||||||
|
std::fs::write(root.path().join("child").join(name), b"x").unwrap();
|
||||||
|
}
|
||||||
|
let source = LocalWorkdirSession::external_read_only(
|
||||||
|
Workdir::new("wire-list"),
|
||||||
|
root.path(),
|
||||||
|
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let first = source.list(request("child", 1, None)).await.unwrap();
|
||||||
|
let wire = serde_json::to_value(
|
||||||
|
ExternalWorkdirOperationResult::try_from(Output::List(first.clone())).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(wire["result"]["next_after"]["path"], "child/a");
|
||||||
|
let decoded: ExternalWorkdirOperationResult = serde_json::from_value(wire.clone()).unwrap();
|
||||||
|
assert_eq!(decoded.into_inner(), Output::List(first.clone()));
|
||||||
|
let operation =
|
||||||
|
ExternalWorkdirOperation::try_from(Op::List(request("child", 1, first.next_after)))
|
||||||
|
.unwrap();
|
||||||
|
let decoded: ExternalWorkdirOperation =
|
||||||
|
serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap();
|
||||||
|
let Output::List(next) = dispatch_workdir_session_operation(&source, decoded.into_inner())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(names(&next), ["child/b"]);
|
||||||
|
assert!(next.next_after.is_none());
|
||||||
|
for bad_path in ["/host".to_owned(), "x".repeat(4097), "outside/a".to_owned()] {
|
||||||
|
let mut bad = serde_json::to_value(Op::List(request("child", 1, None))).unwrap();
|
||||||
|
bad["request"]["after"] = serde_json::json!({"kind": "file", "path": bad_path});
|
||||||
|
assert!(serde_json::from_value::<ExternalWorkdirOperation>(bad).is_err());
|
||||||
|
let mut bad_result = wire.clone();
|
||||||
|
bad_result["result"]["next_after"]["path"] = serde_json::json!(bad_path);
|
||||||
|
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(bad_result).is_err());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
ExternalWorkdirOperation::try_from(Op::List(request(
|
||||||
|
"child",
|
||||||
|
MAX_EXTERNAL_RESULT_ITEMS + 1,
|
||||||
|
None
|
||||||
|
)))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
// Search cursors use result coordinates, not provider path coordinates.
|
||||||
|
let mut search = CheckoutSearchRequest::new(CheckoutSearchOperation::List(request(
|
||||||
|
"child",
|
||||||
|
1,
|
||||||
|
Some(ListCursor {
|
||||||
|
kind: EntryKind::File,
|
||||||
|
path: path("a"),
|
||||||
|
}),
|
||||||
|
)));
|
||||||
|
search.output_root = path("child");
|
||||||
|
let bounded = ExternalWorkdirOperation::try_from(Op::CheckoutSearch(search)).unwrap();
|
||||||
|
let Output::CheckoutSearch(CheckoutSearchResult::List(next)) =
|
||||||
|
dispatch_workdir_session_operation(&source, bounded.into_inner())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(names(&next), ["b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_external_result_counts_cursor_toward_aggregate_path_bound() {
|
||||||
|
let entries: Vec<_> = (0..252)
|
||||||
|
.map(|n| ListEntry {
|
||||||
|
path: path(&format!("{n:04}{}", "x".repeat(4092))),
|
||||||
|
kind: EntryKind::File,
|
||||||
|
size: 1,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let after = ListCursor {
|
||||||
|
kind: EntryKind::File,
|
||||||
|
path: entries.last().unwrap().path.clone(),
|
||||||
|
};
|
||||||
|
let result = ListResult {
|
||||||
|
entries,
|
||||||
|
total_entries: 253,
|
||||||
|
total_bytes: 253,
|
||||||
|
truncated: true,
|
||||||
|
next_after: None,
|
||||||
|
};
|
||||||
|
let mut wire = serde_json::to_value(Output::List(result)).unwrap();
|
||||||
|
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(wire.clone()).is_ok());
|
||||||
|
wire["result"]["next_after"] = serde_json::to_value(after).unwrap();
|
||||||
|
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(wire).is_err());
|
||||||
|
}
|
||||||
+232
-132
@@ -11,11 +11,12 @@ use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
|||||||
use serde_json::{Value as Json, json};
|
use serde_json::{Value as Json, json};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use wip_protocol::{
|
use wip_protocol::{
|
||||||
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
|
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
|
||||||
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||||
|
ReturnDeclaration, TypeExpr, Value,
|
||||||
};
|
};
|
||||||
use workdir::{
|
use workdir::{
|
||||||
CheckoutObservation, EntryKind, ListRequest, WorkdirPath, WorkdirSessionCapability as Cap,
|
CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||||
WorkdirSessionRouter,
|
WorkdirSessionRouter,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,11 +127,15 @@ impl Provider {
|
|||||||
}
|
}
|
||||||
self.project(
|
self.project(
|
||||||
ROOT,
|
ROOT,
|
||||||
descriptor(vec![operation(
|
descriptor(vec![{
|
||||||
|
let mut op = operation(
|
||||||
"list",
|
"list",
|
||||||
"List currently accessible checkouts",
|
"List accessible checkout entrances (not filesystem contents)",
|
||||||
vec![],
|
vec![],
|
||||||
)]),
|
);
|
||||||
|
op.returns.r#type = TypeExpr::Json;
|
||||||
|
op
|
||||||
|
}]),
|
||||||
state.finalize().to_vec(),
|
state.finalize().to_vec(),
|
||||||
"collection",
|
"collection",
|
||||||
Arc::new(Collection {
|
Arc::new(Collection {
|
||||||
@@ -169,8 +174,14 @@ impl Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async fn node(self: &Arc<Self>, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
async fn node(self: &Arc<Self>, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||||
|
Ok(self.publication_inner(path).await?.map(|p| p.projection))
|
||||||
|
}
|
||||||
|
async fn publication_inner(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
path: &str,
|
||||||
|
) -> Result<Option<WipPublication>, ProtocolError> {
|
||||||
if path == ROOT {
|
if path == ROOT {
|
||||||
return Ok(Some(self.collection()));
|
return WipPublication::self_scoped(self.collection()).map(Some);
|
||||||
}
|
}
|
||||||
let Some((alias, target)) = self.parse(path) else {
|
let Some((alias, target)) = self.parse(path) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -202,39 +213,86 @@ impl Provider {
|
|||||||
if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) {
|
if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
let workdir = selected.session.workdir().id().as_str().to_owned();
|
||||||
|
let root = checkout_root(&alias);
|
||||||
|
// The router guard pins this connection across both observations. Root
|
||||||
|
// lifetime is attachment lifetime, not directory-content validator state.
|
||||||
|
let scope = if target.is_root() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let root_observation =
|
||||||
|
match selected.session.checkout_observe(WorkdirPath::root()).await {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
self.entry_projection(
|
||||||
|
&root,
|
||||||
|
&alias,
|
||||||
|
selected.generation,
|
||||||
|
&workdir,
|
||||||
|
WorkdirPath::root(),
|
||||||
|
root_observation,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let Some(projection) = self.entry_projection(
|
||||||
|
path,
|
||||||
|
&alias,
|
||||||
|
selected.generation,
|
||||||
|
&workdir,
|
||||||
|
target,
|
||||||
|
observation,
|
||||||
|
) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if path == root {
|
||||||
|
return WipPublication::self_scoped(projection).map(Some);
|
||||||
|
}
|
||||||
|
let Some(scope) = scope else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(Some(WipPublication {
|
||||||
|
projection,
|
||||||
|
scope: Some(scope.object),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
fn entry_projection(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
path: &str,
|
||||||
|
alias: &str,
|
||||||
|
generation: u64,
|
||||||
|
workdir: &str,
|
||||||
|
target: WorkdirPath,
|
||||||
|
observation: CheckoutObservation,
|
||||||
|
) -> Option<WipProjection> {
|
||||||
let ops = self.operations(&observation);
|
let ops = self.operations(&observation);
|
||||||
if ops.is_empty() {
|
if ops.is_empty() {
|
||||||
return Ok(None);
|
return None;
|
||||||
}
|
}
|
||||||
let kind = if observation.kind == EntryKind::File {
|
let kind = if observation.kind == EntryKind::File {
|
||||||
"file"
|
"file"
|
||||||
} else {
|
} else {
|
||||||
"directory"
|
"directory"
|
||||||
};
|
};
|
||||||
let workdir = selected.session.workdir().id().as_str().to_owned();
|
let validator = self.wrap_validator(alias, generation, workdir, &observation.validator);
|
||||||
let validator = self.wrap_validator(
|
|
||||||
&alias,
|
|
||||||
selected.generation,
|
|
||||||
&workdir,
|
|
||||||
&observation.validator,
|
|
||||||
);
|
|
||||||
let handler = Arc::new(FileHandler {
|
let handler = Arc::new(FileHandler {
|
||||||
provider: self.clone(),
|
provider: self.clone(),
|
||||||
alias,
|
alias: alias.into(),
|
||||||
generation: selected.generation,
|
generation,
|
||||||
workdir,
|
workdir: workdir.into(),
|
||||||
target,
|
target,
|
||||||
observation,
|
observation,
|
||||||
});
|
});
|
||||||
let family = format!("{kind}-g{}", selected.generation);
|
let family = format!("{kind}-g{generation}");
|
||||||
Ok(Some(self.project(
|
let mut projection = self.project(path, descriptor(ops), validator, &family, handler);
|
||||||
path,
|
// Keep path-qualified local names (permissions/capabilities may differ
|
||||||
descriptor(ops),
|
// per target) while binding every content Interface to its live entrance.
|
||||||
validator,
|
projection.interface.scope = checkout_root(alias);
|
||||||
&family,
|
projection.interface.name.push_str("#target/");
|
||||||
handler,
|
projection.interface.name.push_str(&encode_identity(path));
|
||||||
)))
|
projection.object.interfaces = vec![projection.interface.clone()];
|
||||||
|
Some(projection)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
|
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
|
||||||
let mut ops = Vec::new();
|
let mut ops = Vec::new();
|
||||||
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
|
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
|
||||||
@@ -261,6 +319,13 @@ impl Provider {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if has(Cap::Read, "List") {
|
||||||
|
ops.push(operation(
|
||||||
|
"list",
|
||||||
|
"List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; pass returned after to continue. Limit defaults to 100, maximum 1000. List permission and provider Read/enumeration authority apply. Includes hidden/ignored names per provider List policy, unlike Glob/Grep.",
|
||||||
|
vec![parameter("limit", false, TypeExpr::Integer), parameter("after", false, cursor_type())],
|
||||||
|
));
|
||||||
|
}
|
||||||
if has(Cap::Glob, "Glob") {
|
if has(Cap::Glob, "Glob") {
|
||||||
ops.push(operation(
|
ops.push(operation(
|
||||||
"glob",
|
"glob",
|
||||||
@@ -288,6 +353,15 @@ impl Provider {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WipSubtreeProvider for Arc<Provider> {
|
impl WipSubtreeProvider for Arc<Provider> {
|
||||||
|
fn interface_target(&self, reference: &wip_protocol::InterfaceReference) -> Option<String> {
|
||||||
|
if reference.scope == ROOT {
|
||||||
|
return Some(ROOT.into());
|
||||||
|
}
|
||||||
|
let (_, encoded) = reference.name.rsplit_once("#target/")?;
|
||||||
|
let path = decode_identity(encoded)?;
|
||||||
|
let (alias, _) = self.parse(&path)?;
|
||||||
|
(reference.scope == checkout_root(&alias)).then_some(path)
|
||||||
|
}
|
||||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||||
.await
|
.await
|
||||||
@@ -298,7 +372,7 @@ impl WipSubtreeProvider for Arc<Provider> {
|
|||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
||||||
tokio::time::timeout(self.deadline, self.node(path))
|
tokio::time::timeout(self.deadline, self.publication_inner(path))
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
error(
|
error(
|
||||||
@@ -306,7 +380,6 @@ impl WipSubtreeProvider for Arc<Provider> {
|
|||||||
"checkout observation deadline",
|
"checkout observation deadline",
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.and_then(|projection| projection.map(WipPublication::self_scoped).transpose())
|
|
||||||
}
|
}
|
||||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||||
@@ -354,70 +427,9 @@ impl Provider {
|
|||||||
}
|
}
|
||||||
return Ok(paths);
|
return Ok(paths);
|
||||||
}
|
}
|
||||||
let Some((alias, target)) = self.parse(path) else {
|
// Contents are addressable, but never indexable. Host observe already
|
||||||
return Err(error(ProtocolErrorCode::NotFound, "checkout not published"));
|
// resolves the requested Object; no provider content List is needed.
|
||||||
};
|
Ok(Vec::new())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +513,7 @@ impl WipOperationHandler for FileHandler {
|
|||||||
context: WipCallContext,
|
context: WipCallContext,
|
||||||
) -> Result<WipOperationOutput, WipOperationError> {
|
) -> Result<WipOperationOutput, WipOperationError> {
|
||||||
let tool = match operation {
|
let tool = match operation {
|
||||||
|
"list" => "List",
|
||||||
"read" => "Read",
|
"read" => "Read",
|
||||||
"edit" => "Edit",
|
"edit" => "Edit",
|
||||||
"write" => "Write",
|
"write" => "Write",
|
||||||
@@ -528,7 +541,34 @@ impl WipOperationHandler for FileHandler {
|
|||||||
}
|
}
|
||||||
let mut permission_input = args.clone();
|
let mut permission_input = args.clone();
|
||||||
permission_input.insert("target_workdir".into(), json!(self.alias));
|
permission_input.insert("target_workdir".into(), json!(self.alias));
|
||||||
if tool == "Glob" || tool == "Grep" {
|
if tool == "List" {
|
||||||
|
permission_input.insert("path".into(), json!(self.target.as_str()));
|
||||||
|
if let Some(after) = args.get_mut("after") {
|
||||||
|
let entry = after.get("entry").and_then(Json::as_str).ok_or_else(|| {
|
||||||
|
protocol(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"after requires an entry",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let Some((alias, path)) = self.provider.parse(entry) else {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"invalid continuation entry",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if alias != self.alias || object_path(&alias, &path) != entry {
|
||||||
|
return Err(protocol(
|
||||||
|
ProtocolErrorCode::InvalidArguments,
|
||||||
|
"continuation must belong to this checkout",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let fields = after.as_object_mut().ok_or_else(|| {
|
||||||
|
protocol(ProtocolErrorCode::InvalidArguments, "invalid continuation")
|
||||||
|
})?;
|
||||||
|
fields.remove("entry");
|
||||||
|
fields.insert("path".into(), json!(path.as_str()));
|
||||||
|
}
|
||||||
|
} else if tool == "Glob" || tool == "Grep" {
|
||||||
let relative = args.get("path").and_then(Json::as_str).unwrap_or("");
|
let relative = args.get("path").and_then(Json::as_str).unwrap_or("");
|
||||||
permission_input.insert("path".into(), json!(join_under(&self.target, relative)?));
|
permission_input.insert("path".into(), json!(join_under(&self.target, relative)?));
|
||||||
} else if tool == "Create" {
|
} else if tool == "Create" {
|
||||||
@@ -588,49 +628,49 @@ impl WipOperationHandler for FileHandler {
|
|||||||
}
|
}
|
||||||
})?
|
})?
|
||||||
.map_err(map_tool_error)?;
|
.map_err(map_tool_error)?;
|
||||||
let links = tokio::time::timeout_at(deadline, async {
|
// Coordinates are not observations. Never prefetch unselected results;
|
||||||
let mut links = Vec::new();
|
// a vanished entry cannot fail a successful discovery Operation.
|
||||||
self.check_connection()?;
|
self.check_connection()?;
|
||||||
for path in &result.paths {
|
let mut fields = BTreeMap::from([("summary".into(), Value::String(result.output.summary))]);
|
||||||
let route = object_path(&self.alias, path);
|
if let Some(content) = result.output.content {
|
||||||
// References are validated only against the captured live connection.
|
fields.insert("content".into(), Value::String(content));
|
||||||
if self
|
|
||||||
.provider
|
|
||||||
.node(&route)
|
|
||||||
.await
|
|
||||||
.map_err(WipOperationError::Protocol)?
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
links.push(json!({"path":route}));
|
|
||||||
}
|
}
|
||||||
self.check_connection()?;
|
let items = if let Some(listing) = result.listing {
|
||||||
|
fields.insert("truncated".into(), Value::Boolean(listing.truncated));
|
||||||
|
if let Some(after) = listing.next_after {
|
||||||
|
fields.insert("after".into(), cursor_value(&self.alias, &after));
|
||||||
}
|
}
|
||||||
Ok::<_, WipOperationError>(links)
|
listing
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| {
|
||||||
|
Value::Record(BTreeMap::from([
|
||||||
|
(
|
||||||
|
"entry".into(),
|
||||||
|
Value::String(object_path(&self.alias, &item.path)),
|
||||||
|
),
|
||||||
|
("kind".into(), Value::String(kind_name(item.kind).into())),
|
||||||
|
(
|
||||||
|
"size".into(),
|
||||||
|
Value::Integer(i64::try_from(item.size).unwrap_or(i64::MAX)),
|
||||||
|
),
|
||||||
|
]))
|
||||||
})
|
})
|
||||||
.await
|
.collect()
|
||||||
.map_err(|_| {
|
|
||||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
|
||||||
WipOperationError::OutcomeUnknown("completed operation response deadline".into())
|
|
||||||
} else {
|
} else {
|
||||||
protocol(
|
result
|
||||||
ProtocolErrorCode::ResourceLimitExceeded,
|
.paths
|
||||||
"checkout result deadline",
|
.iter()
|
||||||
)
|
.map(|path| {
|
||||||
}
|
Value::Record(BTreeMap::from([(
|
||||||
})?
|
"entry".into(),
|
||||||
.map_err(|e| {
|
Value::String(object_path(&self.alias, path)),
|
||||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
)]))
|
||||||
WipOperationError::OutcomeUnknown("completed operation response unavailable".into())
|
})
|
||||||
} else {
|
.collect()
|
||||||
e
|
};
|
||||||
}
|
fields.insert("items".into(), Value::List(items));
|
||||||
})?;
|
let value = Value::Record(fields);
|
||||||
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 {
|
if let Some(raw) = result.validator {
|
||||||
Ok(WipOperationOutput::native_with_validator(
|
Ok(WipOperationOutput::native_with_validator(
|
||||||
value,
|
value,
|
||||||
@@ -693,10 +733,70 @@ fn operation(
|
|||||||
parameters,
|
parameters,
|
||||||
returns: ReturnDeclaration {
|
returns: ReturnDeclaration {
|
||||||
documentation: None,
|
documentation: None,
|
||||||
r#type: TypeExpr::Json,
|
r#type: result_type(name == "list"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration {
|
||||||
|
FieldDeclaration {
|
||||||
|
name: name.into(),
|
||||||
|
required,
|
||||||
|
documentation: None,
|
||||||
|
r#type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn cursor_type() -> TypeExpr {
|
||||||
|
TypeExpr::Record {
|
||||||
|
fields: vec![
|
||||||
|
field("kind", true, TypeExpr::String),
|
||||||
|
field("entry", true, TypeExpr::Entry),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn result_type(list: bool) -> TypeExpr {
|
||||||
|
let mut item = vec![field("entry", true, TypeExpr::Entry)];
|
||||||
|
if list {
|
||||||
|
item.extend([
|
||||||
|
field("kind", true, TypeExpr::String),
|
||||||
|
field("size", true, TypeExpr::Integer),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
let mut fields = vec![
|
||||||
|
field("summary", true, TypeExpr::String),
|
||||||
|
field("content", false, TypeExpr::String),
|
||||||
|
field(
|
||||||
|
"items",
|
||||||
|
true,
|
||||||
|
TypeExpr::List {
|
||||||
|
items: Box::new(TypeExpr::Record { fields: item }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if list {
|
||||||
|
fields.extend([
|
||||||
|
field("truncated", true, TypeExpr::Boolean),
|
||||||
|
field("after", false, cursor_type()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
TypeExpr::Record { fields }
|
||||||
|
}
|
||||||
|
fn kind_name(kind: EntryKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
EntryKind::Directory => "directory",
|
||||||
|
EntryKind::File => "file",
|
||||||
|
EntryKind::Symlink => "symlink",
|
||||||
|
EntryKind::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value {
|
||||||
|
Value::Record(BTreeMap::from([
|
||||||
|
("kind".into(), Value::String(kind_name(cursor.kind).into())),
|
||||||
|
(
|
||||||
|
"entry".into(),
|
||||||
|
Value::String(object_path(alias, &cursor.path)),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
}
|
||||||
fn descriptor(operations: Vec<OperationDeclaration>) -> InterfaceDescriptor {
|
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}
|
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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ fn first_link(value: &Value) -> &str {
|
|||||||
panic!("expected typed link List")
|
panic!("expected typed link List")
|
||||||
};
|
};
|
||||||
assert_eq!(items.len(), 1);
|
assert_eq!(items.len(), 1);
|
||||||
let Value::String(path) = field(&items[0], "path") else {
|
let Value::String(path) = field(&items[0], "entry") else {
|
||||||
panic!("expected typed link path")
|
panic!("expected typed link path")
|
||||||
};
|
};
|
||||||
path
|
path
|
||||||
@@ -669,3 +669,89 @@ async fn checkout_http_malformed_response_after_write_is_unknown_without_retry()
|
|||||||
async fn checkout_http_lost_response_after_write_is_unknown_without_retry() {
|
async fn checkout_http_lost_response_after_write_is_unknown_without_retry() {
|
||||||
watchdog(committed_write_unknown(WriteResponseFault::LostBody)).await;
|
watchdog(committed_write_unknown(WriteResponseFault::LostBody)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_checkout_list_pages_and_direct_inspect_read_use_provider_without_tree_enumeration()
|
||||||
|
{
|
||||||
|
use super::checkout_wip_tests::{json_content, model_inspect, model_invoke, model_operation};
|
||||||
|
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
|
||||||
|
std::fs::create_dir(fixture.dir.path().join("empty")).unwrap();
|
||||||
|
let remote = fixture.open(TOKEN).await.unwrap();
|
||||||
|
let (runtime, _) = native_runtime(Arc::new(remote));
|
||||||
|
let runtime = Arc::new(runtime);
|
||||||
|
let tree = json_content(
|
||||||
|
model_operation(
|
||||||
|
runtime.clone(),
|
||||||
|
"Tree",
|
||||||
|
json!({"path":"/checkouts", "depth":8}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(tree["tree"]["children"][0]["children"], json!([]));
|
||||||
|
assert!(
|
||||||
|
!fixture
|
||||||
|
.state
|
||||||
|
.operations
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|op| matches!(
|
||||||
|
op,
|
||||||
|
WorkdirSessionOperation::CheckoutSearch(_) | WorkdirSessionOperation::List(_)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
let entrance = model_inspect(runtime.clone(), "/checkouts/main").await;
|
||||||
|
let first = json_content(
|
||||||
|
model_invoke(runtime.clone(), &entrance, "list", json!({"limit":1}))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(first["items"][0]["entry"], "/checkouts/main/empty");
|
||||||
|
assert_eq!(first["items"][0]["kind"], "directory");
|
||||||
|
assert_eq!(first["truncated"], true);
|
||||||
|
let next = json_content(
|
||||||
|
model_invoke(
|
||||||
|
runtime.clone(),
|
||||||
|
&entrance,
|
||||||
|
"list",
|
||||||
|
json!({"limit":1,"after":first["after"]}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(next["items"][0]["entry"], "/checkouts/main/src");
|
||||||
|
assert_eq!(next["truncated"], false);
|
||||||
|
let deep = model_inspect(runtime.clone(), "/checkouts/main/src/deep").await;
|
||||||
|
for (operation, args) in [
|
||||||
|
("list", json!({})),
|
||||||
|
("glob", json!({"pattern":"*.txt"})),
|
||||||
|
(
|
||||||
|
"grep",
|
||||||
|
json!({"pattern":"needle","output_mode":"files_with_matches"}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let found = json_content(
|
||||||
|
model_invoke(runtime.clone(), &deep, operation, args)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let file = found["items"][0]["entry"].as_str().unwrap();
|
||||||
|
assert_eq!(file, "/checkouts/main/src/deep/a.txt");
|
||||||
|
let inspected = model_inspect(runtime.clone(), file).await;
|
||||||
|
let read = json_content(
|
||||||
|
model_invoke(runtime.clone(), &inspected, "read", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert!(read["content"].as_str().unwrap().contains("needle"));
|
||||||
|
fixture.public_output(&found.to_string());
|
||||||
|
}
|
||||||
|
let operations = fixture.state.operations.lock().unwrap();
|
||||||
|
assert!(operations.iter().any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r) if matches!(&r.operation, CheckoutSearchOperation::List(l) if l.after.is_some()))));
|
||||||
|
assert!(
|
||||||
|
!operations
|
||||||
|
.iter()
|
||||||
|
.any(|op| matches!(op, WorkdirSessionOperation::List(_)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ use tempfile::TempDir;
|
|||||||
use workdir::{
|
use workdir::{
|
||||||
CheckoutRequest, CheckoutResult, CommandHandle, CommandOutput, CommandOutputRequest,
|
CheckoutRequest, CheckoutResult, CommandHandle, CommandOutput, CommandOutputRequest,
|
||||||
CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
|
CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
|
||||||
GrepResult, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult, ReadRequest,
|
GrepResult, ListRequest, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult,
|
||||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias, WorkdirError,
|
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias,
|
||||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ struct HookedSession {
|
|||||||
observe_hook: Mutex<Option<ObserveHook>>,
|
observe_hook: Mutex<Option<ObserveHook>>,
|
||||||
stall_path: Mutex<Option<WorkdirPath>>,
|
stall_path: Mutex<Option<WorkdirPath>>,
|
||||||
stalled_observations: AtomicUsize,
|
stalled_observations: AtomicUsize,
|
||||||
|
searches: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for HookedSession {
|
impl std::fmt::Debug for HookedSession {
|
||||||
@@ -52,6 +53,7 @@ impl HookedSession {
|
|||||||
observe_hook: Mutex::new(None),
|
observe_hook: Mutex::new(None),
|
||||||
stall_path: Mutex::new(None),
|
stall_path: Mutex::new(None),
|
||||||
stalled_observations: AtomicUsize::new(0),
|
stalled_observations: AtomicUsize::new(0),
|
||||||
|
searches: AtomicUsize::new(0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +125,7 @@ impl WorkdirSession for HookedSession {
|
|||||||
&self,
|
&self,
|
||||||
request: workdir::CheckoutSearchRequest,
|
request: workdir::CheckoutSearchRequest,
|
||||||
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
|
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
|
||||||
|
self.searches.fetch_add(1, Ordering::SeqCst);
|
||||||
self.local.checkout_search(request).await
|
self.local.checkout_search(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,7 +487,7 @@ async fn collection_list_and_root_enumeration_bound_stalled_observation_and_rele
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_permit() {
|
async fn grep_returns_typed_entry_without_observing_stalled_result() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap();
|
std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap();
|
||||||
let session = HookedSession::new(&dir);
|
let session = HookedSession::new(&dir);
|
||||||
@@ -499,8 +502,8 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
|||||||
let root = checkout_root("main");
|
let root = checkout_root("main");
|
||||||
let projection = p.node(&root).await.unwrap().unwrap();
|
let projection = p.node(&root).await.unwrap().unwrap();
|
||||||
|
|
||||||
// Directory observations and the normal local grep/checkout execution can
|
// Directory/search can complete. The result path must never be observed
|
||||||
// complete. Only publishing the typed search result path gets stuck.
|
// during result publication, even when it would block indefinitely.
|
||||||
session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap()));
|
session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap()));
|
||||||
let arguments = BTreeMap::from([
|
let arguments = BTreeMap::from([
|
||||||
("pattern".into(), Value::String("needle".into())),
|
("pattern".into(), Value::String("needle".into())),
|
||||||
@@ -514,13 +517,17 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
|||||||
projection.handler.call("grep", &arguments, context()),
|
projection.handler.call("grep", &arguments, context()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("post-search link observations must share the operation deadline");
|
.expect("completed search must return without acquiring its entries");
|
||||||
assert_deadline(result, "checkout result deadline");
|
let output = result.unwrap_or_else(|_| panic!("successful search must not observe each entry"));
|
||||||
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1);
|
assert_eq!(
|
||||||
|
wip_to_json(&output.value).unwrap()["items"],
|
||||||
|
json!([{"entry": format!("{root}/a.txt")}])
|
||||||
|
);
|
||||||
|
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 0);
|
||||||
assert_permit_released(&p);
|
assert_permit_released(&p);
|
||||||
|
|
||||||
// A successful retry both checks cancellation cleanup and proves that the
|
// A second explicit search produces the same typed coordinate, without
|
||||||
// delegated search produces the typed path consumed by post-processing.
|
// acquiring it even when it could resolve successfully.
|
||||||
session.stall_observation(None);
|
session.stall_observation(None);
|
||||||
let result = tokio::time::timeout(
|
let result = tokio::time::timeout(
|
||||||
Duration::from_secs(2),
|
Duration::from_secs(2),
|
||||||
@@ -533,6 +540,33 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
|||||||
Err(_) => panic!("grep must succeed once the link observation is unstalled"),
|
Err(_) => panic!("grep must succeed once the link observation is unstalled"),
|
||||||
};
|
};
|
||||||
let value = wip_to_json(&output.value).unwrap();
|
let value = wip_to_json(&output.value).unwrap();
|
||||||
assert_eq!(value["items"], json!([{"path": format!("{root}/a.txt")}]));
|
assert_eq!(value["items"], json!([{"entry": format!("{root}/a.txt")}]));
|
||||||
assert_permit_released(&p);
|
assert_permit_released(&p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_content_children_never_call_provider_list_even_for_deep_paths() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap();
|
||||||
|
for n in 0..1100 {
|
||||||
|
std::fs::write(dir.path().join(format!("file-{n}")), "fixture").unwrap();
|
||||||
|
}
|
||||||
|
let session = HookedSession::new(&dir);
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let subtree = Arc::new(provider(router));
|
||||||
|
assert_eq!(
|
||||||
|
subtree.children(ROOT).await.unwrap(),
|
||||||
|
vec![checkout_root("main")]
|
||||||
|
);
|
||||||
|
for path in ["/checkouts/main", "/checkouts/main/nested/deep"] {
|
||||||
|
assert!(subtree.publication(path).await.unwrap().is_some());
|
||||||
|
assert!(subtree.children(path).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
assert_eq!(session.searches.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ async fn checkout_wip_search_preserves_root_and_nested_ignore_rules_and_links()
|
|||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|item| item["path"].as_str().unwrap().to_string())
|
.map(|item| item["entry"].as_str().unwrap().to_string())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
// Grep retains traversal order; Glob sorts its result paths.
|
// Grep retains traversal order; Glob sorts its result paths.
|
||||||
paths.sort();
|
paths.sort();
|
||||||
@@ -159,8 +159,8 @@ async fn checkout_wip_native_roundtrip_search_read_edit_write_create() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let grep_result: Json = serde_json::from_str(grep.content.as_deref().unwrap()).unwrap();
|
let grep_result: Json = serde_json::from_str(grep.content.as_deref().unwrap()).unwrap();
|
||||||
assert_eq!(grep_result["items"][0]["path"], file);
|
assert_eq!(grep_result["items"][0]["entry"], file);
|
||||||
let followed = grep_result["items"][0]["path"].as_str().unwrap();
|
let followed = grep_result["items"][0]["entry"].as_str().unwrap();
|
||||||
assert_eq!(followed, file);
|
assert_eq!(followed, file);
|
||||||
assert!(
|
assert!(
|
||||||
call(
|
call(
|
||||||
@@ -588,8 +588,9 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
|||||||
.observe_live("/checkouts/child", 2)
|
.observe_live("/checkouts/child", 2)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(observed.children.unwrap().len(), 1);
|
assert!(observed.children.unwrap().is_empty());
|
||||||
for (op, args) in [
|
for (op, args) in [
|
||||||
|
("list", json!({"limit":1})),
|
||||||
("glob", json!({"pattern":"**/*"})),
|
("glob", json!({"pattern":"**/*"})),
|
||||||
(
|
(
|
||||||
"grep",
|
"grep",
|
||||||
@@ -654,3 +655,339 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
|||||||
"changed"
|
"changed"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn json_content(output: ToolOutput) -> Json {
|
||||||
|
serde_json::from_str(output.content.as_deref().unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
pub(super) async fn model_operation(
|
||||||
|
r: Arc<WipRuntime>,
|
||||||
|
name: &str,
|
||||||
|
args: Json,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let (_, tool) = wip_tool_definitions(r)
|
||||||
|
.into_iter()
|
||||||
|
.map(|definition| definition())
|
||||||
|
.find(|(meta, _)| meta.name == name)
|
||||||
|
.unwrap();
|
||||||
|
tool.execute(&args.to_string(), Default::default()).await
|
||||||
|
}
|
||||||
|
pub(super) async fn model_inspect(r: Arc<WipRuntime>, path: &str) -> Json {
|
||||||
|
json_content(
|
||||||
|
model_operation(r, "Inspect", json!({"path":path}))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub(super) async fn model_invoke(
|
||||||
|
r: Arc<WipRuntime>,
|
||||||
|
inspected: &Json,
|
||||||
|
operation: &str,
|
||||||
|
arguments: Json,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":inspected["interfaces"][0]["reference"], "operation":operation, "arguments":arguments})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_depth_boundaries_are_independent_of_empty_small_or_large_contents() {
|
||||||
|
for count in [0, 2, 1100] {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap();
|
||||||
|
for n in 0..count {
|
||||||
|
std::fs::write(dir.path().join(format!("file-{n}")), "fixture").unwrap();
|
||||||
|
}
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session(&dir, true),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = runtime(router, None);
|
||||||
|
let one = r.host.observe_live("/checkouts", 1).await.unwrap();
|
||||||
|
assert!(one.children.unwrap()[0].children.is_none());
|
||||||
|
for depth in [2, 32] {
|
||||||
|
let observation = r.host.observe_live("/checkouts", depth).await.unwrap();
|
||||||
|
let entrances = observation.children.unwrap();
|
||||||
|
assert_eq!(entrances.len(), 1);
|
||||||
|
assert_eq!(entrances[0].children, Some(vec![]));
|
||||||
|
}
|
||||||
|
let world = r.host.observe_live("/", 32).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
world.children.unwrap()[0].children.as_ref().unwrap()[0].children,
|
||||||
|
Some(vec![])
|
||||||
|
);
|
||||||
|
for path in ["/checkouts/main", "/checkouts/main/nested/deep"] {
|
||||||
|
assert!(
|
||||||
|
r.host
|
||||||
|
.observe_live(path, 0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.children
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r.host.observe_live(path, 1).await.unwrap().children,
|
||||||
|
Some(vec![])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_edges() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("empty")).unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("nested/deep/a.txt"), "needle\n").unwrap();
|
||||||
|
std::fs::write(dir.path().join("z.txt"), "needle\n").unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session(&dir, true),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = Arc::new(runtime(router, None));
|
||||||
|
let entrance = model_inspect(r.clone(), "/checkouts/main").await;
|
||||||
|
assert_eq!(entrance["path"], "/checkouts/main");
|
||||||
|
assert_eq!(
|
||||||
|
entrance["interfaces"][0]["reference"]["scope"],
|
||||||
|
"/checkouts/main"
|
||||||
|
);
|
||||||
|
let projection = r
|
||||||
|
.host
|
||||||
|
.projection_live("/checkouts/main")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
entrance["object_signature"],
|
||||||
|
wip_text_view::render_object(&projection.object).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
entrance["interfaces"][0]["signature"],
|
||||||
|
wip_text_view::render_interface(&wip_text_view::Interface {
|
||||||
|
reference: &projection.interface,
|
||||||
|
descriptor: &projection.descriptor
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
entrance["interfaces"][0]["signature"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("entry")
|
||||||
|
);
|
||||||
|
let mut after = None;
|
||||||
|
let mut items = vec![];
|
||||||
|
loop {
|
||||||
|
let mut args = json!({"limit":1});
|
||||||
|
if let Some(cursor) = after {
|
||||||
|
args["after"] = cursor;
|
||||||
|
}
|
||||||
|
let page = json_content(
|
||||||
|
model_invoke(r.clone(), &entrance, "list", args)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(page["items"].as_array().unwrap().len(), 1);
|
||||||
|
items.push(page["items"][0].clone());
|
||||||
|
if page["truncated"] == false {
|
||||||
|
assert!(page.get("after").is_none());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
after = Some(page["after"].clone());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item["entry"].clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
json!("/checkouts/main/empty"),
|
||||||
|
json!("/checkouts/main/nested"),
|
||||||
|
json!("/checkouts/main/z.txt")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(items[0]["kind"], "directory");
|
||||||
|
assert_eq!(items[2]["kind"], "file");
|
||||||
|
let empty = model_inspect(r.clone(), items[0]["entry"].as_str().unwrap()).await;
|
||||||
|
assert_eq!(
|
||||||
|
json_content(
|
||||||
|
model_invoke(r.clone(), &empty, "list", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
)["items"],
|
||||||
|
json!([])
|
||||||
|
);
|
||||||
|
let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await;
|
||||||
|
assert_eq!(
|
||||||
|
deep["interfaces"][0]["reference"]["scope"],
|
||||||
|
"/checkouts/main"
|
||||||
|
);
|
||||||
|
for (operation, args) in [
|
||||||
|
("list", json!({})),
|
||||||
|
("glob", json!({"pattern":"*.txt"})),
|
||||||
|
(
|
||||||
|
"grep",
|
||||||
|
json!({"pattern":"needle", "output_mode":"files_with_matches"}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let found = json_content(
|
||||||
|
model_invoke(r.clone(), &deep, operation, args)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let path = found["items"][0]["entry"].as_str().unwrap();
|
||||||
|
assert_eq!(path, "/checkouts/main/nested/deep/a.txt");
|
||||||
|
// No result-wide eager acquisition by Host or Client.
|
||||||
|
if operation == "list" {
|
||||||
|
let state = r.state.lock().unwrap();
|
||||||
|
assert!(state.client.object(&state.session, path).is_none());
|
||||||
|
}
|
||||||
|
let inspected = model_inspect(r.clone(), path).await;
|
||||||
|
let read = json_content(
|
||||||
|
model_invoke(r.clone(), &inspected, "read", json!({}))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert!(read["content"].as_str().unwrap().contains("needle"));
|
||||||
|
}
|
||||||
|
let tree = json_content(
|
||||||
|
model_operation(r.clone(), "Tree", json!({"path":"/checkouts", "depth":8}))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(tree["tree"]["children"][0]["children"], json!([]));
|
||||||
|
std::fs::remove_file(dir.path().join("nested/deep/a.txt")).unwrap();
|
||||||
|
assert!(
|
||||||
|
model_operation(
|
||||||
|
r,
|
||||||
|
"Inspect",
|
||||||
|
json!({"path":"/checkouts/main/nested/deep/a.txt", "refresh":true})
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identity() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("a.txt"), "fixture").unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||||
|
session(&dir, true),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = runtime(
|
||||||
|
router,
|
||||||
|
Some(ToolPermissionConfig {
|
||||||
|
default_action: ToolPermissionAction::Allow,
|
||||||
|
rules: vec![manifest::ToolPermissionRule {
|
||||||
|
tool: "List".into(),
|
||||||
|
pattern: "*".into(),
|
||||||
|
action: ToolPermissionAction::Deny,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let p = r
|
||||||
|
.host
|
||||||
|
.projection_live("/checkouts/main")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(!p.descriptor.operations.iter().any(|op| op.name == "list"));
|
||||||
|
assert!(p.descriptor.operations.iter().any(|op| op.name == "glob"));
|
||||||
|
let denied = p
|
||||||
|
.handler
|
||||||
|
.call(
|
||||||
|
"list",
|
||||||
|
&BTreeMap::new(),
|
||||||
|
WipCallContext {
|
||||||
|
execution: Default::default(),
|
||||||
|
security_context: "fixture".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
matches!(denied, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::PermissionDenied)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("a.txt"), "same\n").unwrap();
|
||||||
|
let router = Arc::new(WorkdirSessionRouter::new());
|
||||||
|
let main = WorkdirAttachmentAlias::new("main").unwrap();
|
||||||
|
router.attach(main.clone(), session(&dir, true)).unwrap();
|
||||||
|
router
|
||||||
|
.attach(
|
||||||
|
WorkdirAttachmentAlias::new("main-extra").unwrap(),
|
||||||
|
session(&dir, true),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let r = runtime(router.clone(), None);
|
||||||
|
let path = "/checkouts/main/a.txt";
|
||||||
|
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||||
|
let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
fetched.scope_ref.is_none(),
|
||||||
|
"ref-less publication is legitimate"
|
||||||
|
);
|
||||||
|
// Ordinary content changes do not terminate the entrance's Interface lifetime.
|
||||||
|
std::fs::write(dir.path().join("new.txt"), "fixture").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.host
|
||||||
|
.fetch_interface_live(&p.interface)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.interface,
|
||||||
|
p.interface
|
||||||
|
);
|
||||||
|
let request = |reference, scope_ref| CallOperationRequest {
|
||||||
|
target: wip_protocol::Target {
|
||||||
|
path: path.into(),
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
interface: wip_protocol::InterfaceTarget {
|
||||||
|
reference,
|
||||||
|
scope_ref,
|
||||||
|
validator: None,
|
||||||
|
},
|
||||||
|
operation: "read".into(),
|
||||||
|
arguments: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
for scope in ["/checkouts/main-extra", "/checkouts/main/a"] {
|
||||||
|
let mut wrong = p.interface.clone();
|
||||||
|
wrong.scope = scope.into();
|
||||||
|
assert!(r.host.fetch_interface_live(&wrong).await.is_err());
|
||||||
|
let forged = request(wrong, None);
|
||||||
|
let result = r
|
||||||
|
.host
|
||||||
|
.call(
|
||||||
|
forged,
|
||||||
|
WipCallContext {
|
||||||
|
execution: Default::default(),
|
||||||
|
security_context: "fixture".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
matches!(result,Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let forged = request(p.interface.clone(), Some("old".into()));
|
||||||
|
assert!(
|
||||||
|
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||||
|
);
|
||||||
|
router.detach(&main).await.unwrap();
|
||||||
|
router.attach(main, session(&dir, true)).unwrap();
|
||||||
|
let forged = request(p.interface.clone(), None);
|
||||||
|
assert!(
|
||||||
|
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ impl WorkerFsView {
|
|||||||
.list(ListRequest {
|
.list(ListRequest {
|
||||||
path: logical.clone(),
|
path: logical.clone(),
|
||||||
limit: DIR_FILE_REF_ENTRY_LIMIT,
|
limit: DIR_FILE_REF_ENTRY_LIMIT,
|
||||||
|
after: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(ToolsError::from)
|
.map_err(ToolsError::from)
|
||||||
@@ -222,6 +223,7 @@ impl WorkerFsView {
|
|||||||
.list(ListRequest {
|
.list(ListRequest {
|
||||||
path: parent,
|
path: parent,
|
||||||
limit: COMPLETION_LIMIT,
|
limit: COMPLETION_LIMIT,
|
||||||
|
after: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -54114,6 +54114,7 @@ mod tests {
|
|||||||
WorkdirSessionOperation::List(workdir::ListRequest {
|
WorkdirSessionOperation::List(workdir::ListRequest {
|
||||||
path: workdir::WorkdirPath::root(),
|
path: workdir::WorkdirPath::root(),
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
after: None,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ is created.
|
|||||||
├── workdirs identity/lifecycle
|
├── workdirs identity/lifecycle
|
||||||
├── workdir-attachments Backend connection lifetimes
|
├── workdir-attachments Backend connection lifetimes
|
||||||
└── checkouts current accessible content entrances; list
|
└── checkouts current accessible content entrances; list
|
||||||
└── <encoded alias> directory; glob, grep, create_file
|
└── <encoded alias> directory; list, glob, grep, create_file
|
||||||
├── README.md file; read, edit, write
|
|
||||||
└── src directory; glob, grep, create_file
|
# Not indexable; resolve known paths directly or discover via Operations:
|
||||||
|
/checkouts/<encoded alias>/README.md file; read, edit, write
|
||||||
|
/checkouts/<encoded alias>/src directory; list, glob, grep, create_file
|
||||||
```
|
```
|
||||||
|
|
||||||
Operations are Interface declarations, not children or `/features/...` routes.
|
Operations are Interface declarations, not children or `/features/...` routes.
|
||||||
@@ -42,7 +44,7 @@ validates every resolved projection's path/owner/descriptor and every enumerated
|
|||||||
child's immediate-parent relationship. Static and dynamic/subtree overlaps are
|
child's immediate-parent relationship. Static and dynamic/subtree overlaps are
|
||||||
rejected. Observe resolves only the requested depth; successful observations are
|
rejected. Observe resolves only the requested depth; successful observations are
|
||||||
complete through that depth, not partial trees masked as success. Directory
|
complete through that depth, not partial trees masked as success. Directory
|
||||||
listing and native search use the shared List/Glob/Grep engines through typed
|
listing Operations and native search use the shared List/Glob/Grep engines through typed
|
||||||
`CheckoutSearchRequest` forwarding. Scoped sessions append intersecting rule
|
`CheckoutSearchRequest` forwarding. Scoped sessions append intersecting rule
|
||||||
layers, translate their cwd, and require provider-side visibility checks before
|
layers, translate their cwd, and require provider-side visibility checks before
|
||||||
entry descent/content reads. Results retain session-logical coordinates, so a
|
entry descent/content reads. Results retain session-logical coordinates, so a
|
||||||
@@ -51,6 +53,21 @@ providers fail closed rather than falling back to a broader source search.
|
|||||||
Direct resolution also uses current provider authority, not possession of a
|
Direct resolution also uses current provider authority, not possession of a
|
||||||
cached Interface.
|
cached Interface.
|
||||||
|
|
||||||
|
## Indexable boundary (T-714)
|
||||||
|
|
||||||
|
Only `/checkouts` and accessible immediate attachment entrances are indexable.
|
||||||
|
No directory content List runs during tree/observe, even at deep depth or when a
|
||||||
|
known deep directory is the observation root. Empty, small and large directories
|
||||||
|
all have the same boundary. Positive-depth content observations return
|
||||||
|
`children: []` (no **indexable** children), not an omitted field and not
|
||||||
|
an assertion that the directory is empty. At depth zero children are omitted;
|
||||||
|
`observe("/checkouts", 1)` omits entrance children, depth 2 returns [] there.
|
||||||
|
The Client retains discovered/inspected paths without adding indexable edges.
|
||||||
|
|
||||||
|
Known authorized paths remain directly resolvable and operable. Use List/Glob/Grep
|
||||||
|
for discovery, then Inspect only selected entry coordinates; no successful search
|
||||||
|
is undone when an entry disappears before Inspect. Bash/COMMAND is unnecessary.
|
||||||
|
|
||||||
## Names and connection isolation
|
## Names and connection isolation
|
||||||
|
|
||||||
Aliases use T-697's canonical encoding: nonempty ASCII letters/digits/`-`/`_` are
|
Aliases use T-697's canonical encoding: nonempty ASCII letters/digits/`-`/`_` are
|
||||||
@@ -66,18 +83,27 @@ existence alone does not expose its content. Following a reference still require
|
|||||||
current scope/capability and an accessible filesystem projection.
|
current scope/capability and an accessible filesystem projection.
|
||||||
|
|
||||||
Objects have no shared inode `ref`: hard-linked names and different attachments
|
Objects have no shared inode `ref`: hard-linked names and different attachments
|
||||||
must not be mistaken for one path-independent representation. Interface references
|
must not be mistaken for one path-independent representation. Interface
|
||||||
are opaque, path-qualified and include a fresh projection incarnation and live
|
references are structured `(scope, name)`. Each entrance is its own
|
||||||
attachment generation. Validators combine that namespace with the provider's
|
scope; content Interfaces use that entrance as ancestor scope, irrespective of
|
||||||
|
indexability. Local names bind target coordinates, projection incarnation and
|
||||||
|
live attachment generation. The provider's target-qualified-name routing hint
|
||||||
|
only selects a target; fetch re-resolves one coherent current publication and
|
||||||
|
checks the exact Interface, Descriptor, validator and scope under the pinned
|
||||||
|
router connection. It never turns a cached registration or name into authority.
|
||||||
|
Validators combine that namespace with the provider's
|
||||||
opaque identity/state validator. Restoring a Worker creates a fresh incarnation;
|
opaque identity/state validator. Restoring a Worker creates a fresh incarnation;
|
||||||
detach/alias reuse changes the router generation. An old Interface, validator or
|
detach/alias reuse changes the router generation. An old Interface, validator or
|
||||||
read-history entry cannot retarget a new connection. No separate connection ledger
|
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
|
is added. The existing router's active-operation exclusion and begin/finish/cancel
|
||||||
detach paths remain authoritative; detach does not remove the Workdir/files.
|
detach paths remain authoritative; detach does not remove the Workdir/files.
|
||||||
Collection enumeration rejects connection-set changes between sampling and
|
Collection enumeration rejects connection-set changes between sampling and
|
||||||
publication instead of pairing old items with a new validator. Search-link
|
publication instead of pairing old items with a new validator. Result publication
|
||||||
publication checks the captured attachment generation, not merely a reused
|
checks the captured attachment generation, not merely a
|
||||||
alias; response-side provider awaits are covered by the operation deadline.
|
reused alias. It maps typed provider paths without observing/statting every
|
||||||
|
result. Provider awaits are covered by the operation deadline. Objects publish
|
||||||
|
no scope_ref here; legitimate ref-less binding still checks membership,
|
||||||
|
validators, current provider authority and attachment generation.
|
||||||
|
|
||||||
## AI Operation mapping
|
## AI Operation mapping
|
||||||
|
|
||||||
@@ -89,15 +115,20 @@ below are relative to the **bound directory**, not the Worldspace or endpoint.
|
|||||||
| Object | Operation | Arguments | Result |
|
| Object | Operation | Arguments | Result |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `/checkouts` | `list` | none | bounded `items` containing alias/slug/Workdir/generation/path |
|
| `/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 | `list` | optional `limit` (default 100, 1..1000), `after` (returned `{kind, entry}`) | direct-content records `{kind, size, entry}`, `truncated`, optional `after` |
|
||||||
| 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 |
|
| directory | `glob` | `pattern`, optional `path` | normal bounded Glob text/summary plus Descriptor-typed same-checkout `entry` coordinates |
|
||||||
|
| 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 Descriptor-typed `entry` coordinates |
|
||||||
| file | `read` | optional line `offset`, `limit` | line-numbered text and normal Read summary |
|
| 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 | `edit` | `old_string`, `new_string`, optional `replace_all` | replacement count/summary and preview |
|
||||||
| file | `write` | `content` | existing-file save summary; never creation |
|
| 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 |
|
| 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
|
Native operation results declare a typed record with `summary`, optional `content`
|
||||||
`items` (`path` is an absolute WIP Object navigation coordinate, not an OS path).
|
and `items`. Each item contains `entry: entry` in its Descriptor (an absolute
|
||||||
|
Worldspace coordinate, encoded as a string in WIP Value), never a guessed ordinary
|
||||||
|
string or an OS path. List additionally declares `kind`, byte `size`, `truncated`
|
||||||
|
and optional `after: {kind: string, entry: entry}`. File operations have empty
|
||||||
|
items except Create, which returns the new coordinate.
|
||||||
Search links are constructed from **typed provider paths**, not by parsing Grep's
|
Search links are constructed from **typed provider paths**, not by parsing Grep's
|
||||||
rendered filenames/line delimiters. Provider scope remains authoritative for
|
rendered filenames/line delimiters. Provider scope remains authoritative for
|
||||||
search output as well as links. Glob/Grep share normal provider ignore/pattern,
|
search output as well as links. Glob/Grep share normal provider ignore/pattern,
|
||||||
@@ -129,12 +160,47 @@ must not record history; successful mutations update hash/change statistics only
|
|||||||
after provider success. Create is strictly beneath the bound parent, creates 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.
|
an absent file, and must not overwrite an existing destination or symlink.
|
||||||
|
|
||||||
Manifest permission identities remain Read/Edit/Write/Glob/Grep; create_file uses
|
Manifest permission identities are Read/Edit/Write/Glob/Grep plus **List** for
|
||||||
Write. The Host reconstructs the same route-bound input for policy evaluation;
|
native directory listing; create_file uses Write. List publication uses provider
|
||||||
|
Read capability and potential List allow policy; execution reconstructs the bound
|
||||||
|
directory's logical path for the exact List policy. Allowing Glob or COMMAND does
|
||||||
|
not override a List deny/ask rule, and Interface scope validity grants no file
|
||||||
|
access. Normal Tools mode is unchanged (no new top-level List tool). The Host reconstructs the same route-bound input for policy evaluation;
|
||||||
provider read/write scope, capabilities and delegated leases are checked again.
|
provider read/write scope, capabilities and delegated leases are checked again.
|
||||||
`ask` fails closed. Native replacement claims remove `/tools/Read`, `/tools/Edit`,
|
`ask` fails closed. Native replacement claims remove `/tools/Read`, `/tools/Edit`,
|
||||||
`/tools/Write`, `/tools/Glob`, `/tools/Grep`, not unrelated Tools.
|
`/tools/Write`, `/tools/Glob`, `/tools/Grep`, not unrelated Tools.
|
||||||
|
|
||||||
|
## Directory List pagination
|
||||||
|
|
||||||
|
List uses the existing provider List engine, **not** glob("*") or shell ls. It
|
||||||
|
includes direct visible empty directories, hidden and ignored names: unlike
|
||||||
|
Glob/Grep, provider List does not apply search ignore files. Existing Read scope,
|
||||||
|
readability and can_enumerate_directory checks still gate names; no ancestor or
|
||||||
|
nonrecursive enumeration permission is inferred from a known descendant.
|
||||||
|
|
||||||
|
Order is directories first, then lexical canonical provider path, with every
|
||||||
|
other kind in the second group. after is the last returned exclusive ordering
|
||||||
|
key, rebased to a canonical Worldspace entry by the Host. Pass the returned record
|
||||||
|
unchanged to continue on the same directory; a foreign checkout, non-direct child,
|
||||||
|
absolute provider path or unknown field is rejected. No cursor stat is required:
|
||||||
|
the key may have disappeared. This is **live pagination, not a snapshot**. New
|
||||||
|
keys before the cursor are not revisited; later keys may appear, removals vanish,
|
||||||
|
and renames/kind changes may omit or repeat an entry. Directory validators fence
|
||||||
|
each call, not all pages; refresh Inspect after a stale rejection before issuing
|
||||||
|
a new explicit call. Detach/reuse expires the old Interface and validator.
|
||||||
|
|
||||||
|
Each page scans the live directory under provider cancellation/deadline and the
|
||||||
|
existing traversal-entry ceiling, retaining at most limit+1 ordering candidates
|
||||||
|
and the aggregate path-byte budget. It never retains the whole unbounded listing.
|
||||||
|
The next ordering key is explicit whenever truncated; no next key means complete.
|
||||||
|
Provider resource failures stay explicit failures, not silent truncation or retries.
|
||||||
|
|
||||||
|
UTF-8 canonical names are retained verbatim; unrepresentable names are skipped by
|
||||||
|
the existing provider. Native List may reject visible symlinks under the checked
|
||||||
|
provider's no-follow policy. If a provider returns symlink/other kinds they remain
|
||||||
|
classified path coordinates, not a promise of inspectable/operable Objects:
|
||||||
|
direct checkout resolution only publishes authorized regular files/directories.
|
||||||
|
|
||||||
## Consistency, safety and resource policy
|
## Consistency, safety and resource policy
|
||||||
|
|
||||||
The provider resolves/pins the entry/parent, compares its opaque validator and
|
The provider resolves/pins the entry/parent, compares its opaque validator and
|
||||||
@@ -172,7 +238,7 @@ 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
|
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
|
cooperative deadline. The checkout adapter permits at most 16 concurrent requests
|
||||||
and bounds admission waiting and the complete operation (including result-link
|
and bounds admission waiting and the complete operation (including bounded result
|
||||||
publication) to 30 seconds each; provider path/depth, source/response,
|
publication) to 30 seconds each; provider path/depth, source/response,
|
||||||
mutation/search and transport bounds apply too. No initial context expands an
|
mutation/search and transport bounds apply too. No initial context expands an
|
||||||
entire tree. Resource-limit errors do not turn incomplete observation into a
|
entire tree. Resource-limit errors do not turn incomplete observation into a
|
||||||
@@ -182,8 +248,9 @@ truncation under the existing search contract.
|
|||||||
## Typical sequence
|
## Typical sequence
|
||||||
|
|
||||||
1. Use Tree on `/checkouts` with depth 1; Inspect `/checkouts` directly and Invoke `list`.
|
1. Use Tree on `/checkouts` with depth 1; Inspect `/checkouts` directly and Invoke `list`.
|
||||||
2. Inspect an entrance or a known deep directory directly; Invoke `glob`/`grep`.
|
2. Inspect an entrance or known deep directory directly; Invoke `list`/`glob`/`grep`.
|
||||||
3. Follow a returned file `path`, Inspect that path and Invoke line `read`.
|
For List, pass returned `after` with the next limit to obtain another live page.
|
||||||
|
3. Follow a returned file `entry`, Inspect that path and Invoke line `read`.
|
||||||
4. Call `edit` or `write`; Client-managed validators protect the exact observed
|
4. Call `edit` or `write`; Client-managed validators protect the exact observed
|
||||||
object while the shared tracker enforces the prior Read.
|
object while the shared tracker enforces the prior Read.
|
||||||
5. Rediscover after external changes; a stale rejection is not permission to skip
|
5. Rediscover after external changes; a stale rejection is not permission to skip
|
||||||
@@ -203,9 +270,14 @@ outside this feature.
|
|||||||
Run from the main repository, on Linux with `openat2` and `/proc/self/fd`:
|
Run from the main repository, on Linux with `openat2` and `/proc/self/fd`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo check --workspace --all-targets
|
cargo test -p worker --lib checkout_wip
|
||||||
|
cargo test -p workdir --test list_pagination
|
||||||
|
cargo check
|
||||||
cargo test -p fs-operation -p workdir -p tools -p worker
|
cargo test -p fs-operation -p workdir -p tools -p worker
|
||||||
cargo test -p worker-runtime -p yoi-workspace-server
|
# Workdir External/HTTP features and dependent server fixture:
|
||||||
|
cargo test -p workdir --features http-client
|
||||||
|
cargo test -p yoi-workspace-server --lib backend_workdir_session_proxy_executes_typed_operations
|
||||||
|
node scripts/verify-wip-dependencies.mjs
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -115,10 +115,76 @@ permission identity and ToolOutput/attachments.
|
|||||||
- `WipMountRegistry::mount_interface` adds a separate static Interface namespace
|
- `WipMountRegistry::mount_interface` adds a separate static Interface namespace
|
||||||
without replacing Object ownership or flattening same-named operations. It does
|
without replacing Object ownership or flattening same-named operations. It does
|
||||||
not overlay stale registration metadata onto a live subtree provider.
|
not overlay stale registration metadata onto a live subtree provider.
|
||||||
- FS indexable boundaries and directory.list remain provider policy. This change
|
- FS indexable boundaries and directory.list are provider policy. T-714 now
|
||||||
deliberately does not implement the separate T-714 boundary/list migration.
|
implements entrance-only trees and bounded typed discovery (details below).
|
||||||
Relevant transport/commit tests are `checkout_wip_tests.rs`,
|
Transport/commit tests are `checkout_wip_tests.rs`, `checkout_http_tests.rs`,
|
||||||
`checkout_http_tests.rs`, and `checkout_race_tests.rs`.
|
and `checkout_race_tests.rs`.
|
||||||
- Worldspace state is runtime-only. Historical opaque references in append-only
|
- Worldspace state is runtime-only. Historical opaque references in append-only
|
||||||
session/tool history are not migrated into cache or parsed for a guessed scope;
|
session/tool history are not migrated into cache or parsed for a guessed scope;
|
||||||
a restored Client starts with fresh observations and direct path Inspect.
|
a restored Client starts with fresh observations and direct path Inspect.
|
||||||
|
|
||||||
|
## T-714 checkout discovery validation
|
||||||
|
|
||||||
|
Based on integrated T-713 at `f9d4c97f`, still using the exact released crates.io
|
||||||
|
0.2.0 SDK and official renderer. No alternate wire, renderer or upstream version
|
||||||
|
was introduced. The checkout projection design documents provider live pagination,
|
||||||
|
its ordering/cursor/byte ceilings, special-entry policy and the distinction between
|
||||||
|
indexable emptiness and empty filesystem directories.
|
||||||
|
|
||||||
|
Focused regressions:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p worker --lib checkout_wip
|
||||||
|
cargo test -p worker --lib checkout::race_tests
|
||||||
|
cargo test -p worker --lib checkout_http_tests
|
||||||
|
cargo test -p worker --lib ancestor_tests
|
||||||
|
cargo test -p tools --lib checkout_list
|
||||||
|
cargo test -p fs-operation --test list_pagination
|
||||||
|
cargo test -p workdir --test list_pagination
|
||||||
|
```
|
||||||
|
|
||||||
|
Host depth-zero omission / positive-depth `children: []`, empty/small/1100-entry
|
||||||
|
folders, direct deep roots and zero provider content-List calls during observation
|
||||||
|
are tested independently of Client output. Registered model tools exercise entrance
|
||||||
|
self-scope and deep target ancestor-scope, paginated List/Glob/Grep typed entries,
|
||||||
|
lazy selected Inspect with official signatures/path context, and Read without
|
||||||
|
COMMAND. Acquired paths never become tree edges; a stalled result observation is
|
||||||
|
never awaited and disappearance only fails a later explicit Inspect. Remote HTTP
|
||||||
|
checks verify List continuation on the wire with no ordinary List fallback or
|
||||||
|
response credentials/host-path leakage. Provider tests cover scoped External and
|
||||||
|
nested cwd/output_root rebasing, nonrecursive enumeration/read authority, ignore
|
||||||
|
policy, live mutations, byte bounds, cancellation and malicious cursor/result DTOs.
|
||||||
|
|
||||||
|
`WipSubtreeProvider::interface_target` is a routing hint for target-qualified local
|
||||||
|
names under ancestor scopes. The default retains self/shared-scope behavior. Host
|
||||||
|
checks provider ownership and resolves **one** coherent current target/scope
|
||||||
|
publication; it never treats the hint as authorization or fetches a scope in an
|
||||||
|
independent mutable lookup. Focused Host tests reject forged/outside/prefix/missing
|
||||||
|
hints, ref-less deletion and absent membership while preserving existing ancestor
|
||||||
|
ref/validator and in-flight snapshots. Actual checkout tests reject sibling/prefix
|
||||||
|
scopes, supplied refs when current publication has none and stale alias generations;
|
||||||
|
ordinary content updates do not terminate the entrance Interface lifetime.
|
||||||
|
|
||||||
|
Completion validation:
|
||||||
|
|
||||||
|
- Root `cargo check` passed.
|
||||||
|
- `cargo test -p fs-operation -p workdir -p tools -p worker` passed: FS **58 unit +
|
||||||
|
6 pagination**; Workdir **125 unit + 5 pagination**; Tools **84 unit + 39
|
||||||
|
integration + 1 doctest**; Worker **901 unit + 110 integration**.
|
||||||
|
- `cargo test -p workdir --features http-client` passed (**125 + 5**).
|
||||||
|
- `cargo test -p yoi-workspace-server` passed (**691 unit + 11 CLI**).
|
||||||
|
- `cargo test -p server-api` passed (**83**, including checked-in OpenAPI/TypeScript
|
||||||
|
freshness). No generated API artifact changes were needed.
|
||||||
|
- `node scripts/verify-wip-dependencies.mjs` confirmed one crates.io 0.2.0 Protocol
|
||||||
|
source and all four exact SDK packages. `cargo fmt --all -- --check` and
|
||||||
|
`git diff --check HEAD` passed.
|
||||||
|
|
||||||
|
The first dependent Server test build exposed predecessor fixtures still using
|
||||||
|
removed `discover`/opaque strings/private `call`. Those tests now select structured
|
||||||
|
references from actual path Inspect and use public Tree/Inspect/Invoke; the complete
|
||||||
|
Server suite above includes configuration integration and the typed Workdir proxy.
|
||||||
|
Its dev-only Protocol dependency reuses the same locked SDK source; the resolved
|
||||||
|
package/version set is unchanged.
|
||||||
|
|
||||||
|
No full workspace test, full Nix/image build, deployment, target integration or
|
||||||
|
live dogfood update is claimed. Target movement remains Orchestrator authority.
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# T-714: Ticket authority read rejected after implementation validation
|
||||||
|
|
||||||
|
## Observed boundary
|
||||||
|
|
||||||
|
On the continuation turn, `ShowTicket({ id: "T-714", event_limit: 5 })`
|
||||||
|
returned `Workspace Ticket API request failed with HTTP status 401`.
|
||||||
|
The same assigned Coder session had successfully read the Ticket and recorded
|
||||||
|
one shared-seam coordination decision earlier. This report does not diagnose
|
||||||
|
whether the rejection is authentication expiry, capability invalidation, or
|
||||||
|
another Backend condition.
|
||||||
|
|
||||||
|
## Consequence
|
||||||
|
|
||||||
|
The local checkout implementation and validation were complete. Git retained the
|
||||||
|
assigned `work/t-714-checkout-discovery` branch and all changes; coherent local
|
||||||
|
commits were authorized by the user. Publication/MR creation and review were
|
||||||
|
paused because a fresh authoritative Ticket/MR read could not be obtained.
|
||||||
|
No alternate actor, CLI, Backend storage access or replacement control-plane
|
||||||
|
path was attempted. No target integration or live dogfood update occurred.
|
||||||
|
|
||||||
|
## Improvement / recovery
|
||||||
|
|
||||||
|
Provide an actionable typed error distinction between authentication failure and
|
||||||
|
capability rejection, and a host-owned way to restore valid transport authority
|
||||||
|
without changing actor identity or bypassing the rejected capability. After that,
|
||||||
|
reread the Ticket and any linked MR, normally publish only its original source
|
||||||
|
selector, verify exact provider HEAD, and request an independently bound review.
|
||||||
|
The local implementation/validation evidence is in commits and
|
||||||
|
`docs/development/wip-integration-validation.md`; no approval is claimed here.
|
||||||
Reference in New Issue
Block a user