chore: merge develop into hare/develop
This commit is contained in:
@@ -79,7 +79,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
selector: Some("builtin:ticket-worker"),
|
||||
path: "profiles/ticket-worker.dcdl",
|
||||
source: include_str!("../../../resources/profiles/ticket-worker.dcdl"),
|
||||
description: "General Ticket work with typed progress and completion tools.",
|
||||
description: "General intent-led Ticket work with optional result recording and authorized conclusion decisions.",
|
||||
imports: &[BuiltinProfileImport {
|
||||
specifier: "./default.dcdl",
|
||||
resolved_path: DEFAULT_PATH,
|
||||
@@ -89,7 +89,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
selector: Some("builtin:coder"),
|
||||
path: "profiles/coder.dcdl",
|
||||
source: include_str!("../../../resources/profiles/coder.dcdl"),
|
||||
description: "Ticket implementation with direct Reviewer SubWorkers.",
|
||||
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
@@ -117,7 +117,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
selector: Some("builtin:orchestrator"),
|
||||
path: "profiles/orchestrator.dcdl",
|
||||
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
|
||||
description: "Workspace orchestration and Worker control.",
|
||||
description: "Intent-led Ticket orchestration with separate guarded MR integration authority.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
|
||||
@@ -823,7 +823,7 @@ mod tests {
|
||||
"display_name": "Embedded",
|
||||
"built_in": true,
|
||||
"worker_creation_available": true,
|
||||
"working_directory_required": false,
|
||||
"supports_workdir_attachments": false,
|
||||
"status": "online",
|
||||
"diagnostics": []
|
||||
}],
|
||||
|
||||
@@ -549,7 +549,7 @@ impl BackendWorkspaceProductClient {
|
||||
let runtime = options
|
||||
.runtimes
|
||||
.iter()
|
||||
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
||||
.find(|runtime| runtime.worker_creation_available)
|
||||
.ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
||||
@@ -1340,7 +1340,7 @@ mod tests {
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
(
|
||||
"200 OK",
|
||||
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
||||
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"supports_workdir_attachments":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
||||
),
|
||||
(
|
||||
"200 OK",
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
match slug {
|
||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 4,
|
||||
revision: 5,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}),
|
||||
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 4,
|
||||
revision: 5,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}];
|
||||
|
||||
@@ -941,6 +941,7 @@ mod tests {
|
||||
let list_error = run_list(
|
||||
&root,
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: FsPath::new("selected-dir").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
@@ -1008,6 +1009,7 @@ mod tests {
|
||||
let listed = run_list(
|
||||
&root,
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: FsPath::new("notes").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BinaryHeap;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io::{BufReader, Read};
|
||||
@@ -7,9 +9,9 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{
|
||||
AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind,
|
||||
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest,
|
||||
ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
direct_symlink,
|
||||
FsAccessPolicy, FsError, FsPath, ListCursor, ListEntry, ListRequest, ListResult,
|
||||
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult,
|
||||
WriteRequest, WriteResult, direct_symlink,
|
||||
};
|
||||
|
||||
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(
|
||||
root: &Path,
|
||||
request: ListRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<ListResult, FsError> {
|
||||
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
|
||||
.check_cancelled()
|
||||
.map_err(|error| map_io(&logical, error))?;
|
||||
@@ -462,11 +507,15 @@ pub fn run_list(
|
||||
total_entries: 0,
|
||||
total_bytes: 0,
|
||||
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 provider_truncated = false;
|
||||
let mut total_entries = 0usize;
|
||||
let mut total_bytes = 0u64;
|
||||
let mut remaining_entries = 0usize;
|
||||
let read_dir = access
|
||||
.open_read_dir(&logical_base, &path)
|
||||
.map_err(|error| map_io(&logical, error))?;
|
||||
@@ -513,34 +562,77 @@ pub fn run_list(
|
||||
let Ok(result_path) = FsPath::new(relative) else {
|
||||
continue;
|
||||
};
|
||||
let retained = result_path.as_str().len().saturating_add(64);
|
||||
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
||||
provider_truncated = true;
|
||||
break;
|
||||
total_entries += 1;
|
||||
total_bytes = total_bytes.saturating_add(metadata.len());
|
||||
if request.after.as_ref().is_some_and(|after| {
|
||||
list_order(kind, &result_path, after.kind, &after.path) != Ordering::Greater
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
retained_path_bytes = retained_path_bytes.saturating_add(retained);
|
||||
entries.push(ListEntry {
|
||||
remaining_entries += 1;
|
||||
let candidate = RetainedListEntry(ListEntry {
|
||||
path: result_path,
|
||||
kind,
|
||||
size: metadata.len(),
|
||||
});
|
||||
if entries.len() == retained_limit {
|
||||
if candidate >= *entries.peek().expect("nonempty List retention") {
|
||||
continue;
|
||||
}
|
||||
retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0);
|
||||
}
|
||||
retained_path_bytes = retained_path_bytes.saturating_add(list_entry_bytes(&candidate.0));
|
||||
entries.push(candidate);
|
||||
// Evict greatest keys, never stop scanning: enumeration order is
|
||||
// unspecified and a later entry may precede every retained key.
|
||||
while retained_path_bytes > crate::MAX_RESULT_PATH_BYTES {
|
||||
retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0);
|
||||
// Never grow cardinality again after a byte-bound eviction: a
|
||||
// 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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
entries.sort_by(|left, right| {
|
||||
let left_dir = left.kind == EntryKind::Directory;
|
||||
let right_dir = right.kind == EntryKind::Directory;
|
||||
right_dir
|
||||
.cmp(&left_dir)
|
||||
.then_with(|| left.path.as_str().cmp(right.path.as_str()))
|
||||
});
|
||||
let total_entries = entries.len();
|
||||
let total_bytes = entries.iter().map(|entry| entry.size).sum();
|
||||
let truncated = provider_truncated || entries.len() > request.limit;
|
||||
let mut entries: Vec<_> = entries
|
||||
.into_sorted_vec()
|
||||
.into_iter()
|
||||
.map(|entry| entry.0)
|
||||
.collect();
|
||||
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 {
|
||||
entries,
|
||||
total_entries,
|
||||
total_bytes,
|
||||
truncated,
|
||||
next_after,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,22 @@ pub struct ListRequest {
|
||||
pub path: FsPath,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_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)]
|
||||
@@ -293,6 +309,11 @@ pub struct ListEntry {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListResult {
|
||||
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))]
|
||||
pub total_entries: usize,
|
||||
#[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(_)));
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,9 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub workspace_config: Option<WorkspaceConfigFeatureConfigPartial>,
|
||||
pub workspace_config: Option<ActivationFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub drive: Option<ActivationFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
@@ -149,7 +151,12 @@ impl FeatureConfigPartial {
|
||||
workspace_config: merge_option(
|
||||
self.workspace_config,
|
||||
other.workspace_config,
|
||||
WorkspaceConfigFeatureConfigPartial::merge,
|
||||
ActivationFeatureConfigPartial::merge,
|
||||
),
|
||||
drive: merge_option(
|
||||
self.drive,
|
||||
other.drive,
|
||||
ActivationFeatureConfigPartial::merge,
|
||||
),
|
||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||
merge_request: merge_option(
|
||||
@@ -183,12 +190,12 @@ impl FeatureFlagConfigPartial {
|
||||
/// Activation only: no Workspace/grant/source/permission fields are accepted.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigFeatureConfigPartial {
|
||||
pub struct ActivationFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfigFeatureConfigPartial {
|
||||
impl ActivationFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
@@ -196,15 +203,15 @@ impl WorkspaceConfigFeatureConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkspaceConfigFeatureConfigPartial> for FeatureFlagConfig {
|
||||
fn from(value: WorkspaceConfigFeatureConfigPartial) -> Self {
|
||||
impl From<ActivationFeatureConfigPartial> for FeatureFlagConfig {
|
||||
fn from(value: ActivationFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FeatureFlagConfig> for WorkspaceConfigFeatureConfigPartial {
|
||||
impl From<FeatureFlagConfig> for ActivationFeatureConfigPartial {
|
||||
fn from(value: FeatureFlagConfig) -> Self {
|
||||
Self {
|
||||
enabled: Some(value.enabled),
|
||||
@@ -448,6 +455,7 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.workspace_config
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
drive: value.drive.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
ticket: value
|
||||
.ticket
|
||||
.map(TicketFeatureConfig::from)
|
||||
@@ -643,6 +651,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
manage_workdir: Some(value.manage_workdir.into()),
|
||||
workdir_catalog: Some(value.workdir_catalog.into()),
|
||||
workspace_config: Some(value.workspace_config.into()),
|
||||
drive: Some(value.drive.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
merge_request: Some(value.merge_request.into()),
|
||||
orchestration: Some(value.orchestration.into()),
|
||||
@@ -2311,6 +2320,93 @@ worker_max_turns = 7
|
||||
assert!(!manifest.feature.merge_request.any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_defaults_disabled_in_partial_resolved_and_missing_snapshot_fields() {
|
||||
for source in ["", "[feature]\n", "[feature.drive]\n"] {
|
||||
let partial = WorkerManifestConfig::from_toml(source).unwrap();
|
||||
let manifest: WorkerManifest = minimal_valid().merge(partial).try_into().unwrap();
|
||||
assert!(!manifest.feature.drive.enabled, "{source}");
|
||||
}
|
||||
for source in ["{}", r#"{"drive": {}}"#] {
|
||||
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
|
||||
assert!(!feature.drive.enabled);
|
||||
}
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
let mut snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||
snapshot["manifest"]["feature"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("drive");
|
||||
let restored = crate::read_persisted_worker_manifest_snapshot(snapshot).unwrap();
|
||||
assert!(!restored.feature.drive.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_activation_merges_and_roundtrips_config_and_snapshots() {
|
||||
for enabled in [false, true] {
|
||||
let lower =
|
||||
WorkerManifestConfig::from_toml(&format!("[feature.drive]\nenabled = {enabled}\n"))
|
||||
.unwrap();
|
||||
let empty = WorkerManifestConfig::from_toml("[feature.drive]\n").unwrap();
|
||||
let manifest: WorkerManifest = minimal_valid()
|
||||
.merge(lower.clone())
|
||||
.merge(empty)
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(manifest.feature.drive.enabled, enabled);
|
||||
assert!(!manifest.feature.workspace_config.enabled);
|
||||
assert!(!manifest.feature.manage_workdir.enabled);
|
||||
let partial = FeatureConfigPartial::from(manifest.feature.clone());
|
||||
assert_eq!(partial.drive.as_ref().unwrap().enabled, Some(enabled));
|
||||
let restored: FeatureConfigPartial =
|
||||
toml::from_str(&toml::to_string(&partial).unwrap()).unwrap();
|
||||
assert_eq!(FeatureConfig::from(restored), manifest.feature);
|
||||
let json = serde_json::to_value(&manifest.feature).unwrap();
|
||||
assert_eq!(json["drive"], serde_json::json!({"enabled": enabled}));
|
||||
assert_eq!(
|
||||
serde_json::from_value::<FeatureConfig>(json).unwrap(),
|
||||
manifest.feature
|
||||
);
|
||||
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||
let restored = crate::read_persisted_worker_manifest_snapshot(snapshot).unwrap();
|
||||
assert_eq!(restored.feature.drive.enabled, enabled);
|
||||
let upper = WorkerManifestConfig::from_toml(&format!(
|
||||
"[feature.drive]\nenabled = {}\n",
|
||||
!enabled
|
||||
))
|
||||
.unwrap();
|
||||
let overridden: WorkerManifest = minimal_valid()
|
||||
.merge(lower)
|
||||
.merge(upper)
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(overridden.feature.drive.enabled, !enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_activation_rejects_authority_and_unknown_fields() {
|
||||
for (field, value) in [
|
||||
("workspace_id", "\"workspace-1\""),
|
||||
("grant_id", "\"grant-1\""),
|
||||
("access", "\"read_write\""),
|
||||
("host_path", "\"/host/drive\""),
|
||||
("source", "\"drive\""),
|
||||
("writable", "true"),
|
||||
("enabeld", "true"),
|
||||
] {
|
||||
let source = format!("[feature.drive]\nenabled = true\n{field} = {value}\n");
|
||||
assert!(WorkerManifestConfig::from_toml(&source).is_err(), "{field}");
|
||||
assert!(
|
||||
toml::from_str::<FeatureConfig>(&format!(
|
||||
"[drive]\nenabled = true\n{field} = {value}\n"
|
||||
))
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_defaults_disabled_for_partial_and_resolved_configs() {
|
||||
assert!(!FeatureConfig::default().workspace_config.enabled);
|
||||
|
||||
@@ -143,8 +143,12 @@ pub struct FeatureConfig {
|
||||
pub workdir_catalog: FeatureFlagConfig,
|
||||
/// Opt-in WIP-only logical Workspace configuration feature. This flag is
|
||||
/// not an access grant; Backend-owned Worker grants remain authoritative.
|
||||
#[serde(default, deserialize_with = "deserialize_workspace_config")]
|
||||
#[serde(default, deserialize_with = "deserialize_activation_feature")]
|
||||
pub workspace_config: FeatureFlagConfig,
|
||||
/// Opt-in Drive tools and native WIP surface. Activation grants no Drive
|
||||
/// access: Backend-owned Worker grants are checked on every operation.
|
||||
#[serde(default, deserialize_with = "deserialize_activation_feature")]
|
||||
pub drive: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub ticket: TicketFeatureConfig,
|
||||
#[serde(default)]
|
||||
@@ -169,11 +173,11 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_workspace_config<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
|
||||
fn deserialize_activation_feature<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(config::WorkspaceConfigFeatureConfigPartial::deserialize(deserializer)?.into())
|
||||
Ok(config::ActivationFeatureConfigPartial::deserialize(deserializer)?.into())
|
||||
}
|
||||
|
||||
impl Default for FeatureConfig {
|
||||
@@ -192,6 +196,7 @@ impl Default for FeatureConfig {
|
||||
manage_workdir: FeatureFlagConfig::disabled(),
|
||||
workdir_catalog: default_workdir_catalog(),
|
||||
workspace_config: FeatureFlagConfig::disabled(),
|
||||
drive: FeatureFlagConfig::disabled(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
merge_request: MergeRequestFeatureConfig::default(),
|
||||
orchestration: FeatureFlagConfig::disabled(),
|
||||
|
||||
@@ -1279,6 +1279,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_drive_activation_is_workspace_role_specific_and_excludes_jobs() {
|
||||
// Production DCDL imports are the policy boundary: changing the shared
|
||||
// base must not accidentally activate Drive in a read-only Job/Reviewer.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let registry = ProfileDiscovery::with_sources(None, None)
|
||||
.discover()
|
||||
.unwrap();
|
||||
for entry in registry.entries() {
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, &entry.name),
|
||||
ProfileResolveOptions::with_worker_name("drive-profile-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
let expected = matches!(
|
||||
entry.name.as_str(),
|
||||
"default" | "ticket-worker" | "coder" | "companion" | "intake" | "orchestrator"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.manifest.feature.drive.enabled, expected,
|
||||
"{}",
|
||||
entry.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_worker_profile_enables_work_reporting_without_authoring_or_integration() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -9856,7 +9856,9 @@ pub struct WorkerLaunchRuntimeOption {
|
||||
pub display_name: String,
|
||||
pub built_in: bool,
|
||||
pub worker_creation_available: bool,
|
||||
pub working_directory_required: bool,
|
||||
/// Whether this Runtime supports explicitly selected Workdir attachments.
|
||||
/// Workdirs are optional for generic launches; embedded Runtime does not support them.
|
||||
pub supports_workdir_attachments: bool,
|
||||
pub status: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
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
|
||||
(from `GlobResult.paths` / `GrepResult.paths`) and the created path for Create.
|
||||
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 output: ToolOutput,
|
||||
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>>,
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ pub async fn execute_checkout_tool(
|
||||
.unwrap_or_default();
|
||||
let (capability, allowed): (_, &[&str]) = match tool_name {
|
||||
"Read" => (WorkdirSessionCapability::Read, &file_fields),
|
||||
"List" => (WorkdirSessionCapability::Read, &["limit", "after"]),
|
||||
"Edit" => (WorkdirSessionCapability::Edit, &file_fields),
|
||||
"Write" => (WorkdirSessionCapability::Write, &file_fields),
|
||||
"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)
|
||||
.await
|
||||
}
|
||||
"Glob" | "Grep" => {
|
||||
"List" | "Glob" | "Grep" => {
|
||||
let before = selected
|
||||
.session
|
||||
.checkout_observe(path.clone())
|
||||
@@ -141,7 +146,10 @@ pub async fn execute_checkout_tool(
|
||||
if before.validator != validator {
|
||||
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)?;
|
||||
check_pattern(¶ms.pattern)?;
|
||||
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> {
|
||||
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) {
|
||||
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();
|
||||
assert_eq!(grep.output.summary, normal.summary);
|
||||
assert_eq!(grep.output.content, normal.content);
|
||||
assert!(grep.listing.is_none());
|
||||
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(),
|
||||
},
|
||||
paths: Vec::new(),
|
||||
listing: None,
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ pub(crate) async fn execute_glob(
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: result.paths,
|
||||
listing: None,
|
||||
validator: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ pub(crate) async fn execute_grep(
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: result.paths,
|
||||
listing: None,
|
||||
validator: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ mod grep;
|
||||
mod read;
|
||||
mod routing;
|
||||
mod search_target;
|
||||
mod view_image;
|
||||
pub mod view_image;
|
||||
mod web;
|
||||
mod write;
|
||||
|
||||
@@ -33,7 +33,9 @@ pub use glob::glob_tool;
|
||||
pub use grep::grep_tool;
|
||||
pub use read::read_tool;
|
||||
pub use tracker::{ChangeStat, Tracker};
|
||||
pub use view_image::{routed_view_image_tool, view_image_tool};
|
||||
pub use view_image::{
|
||||
detect_image_mime, read_workdir_bytes, routed_view_image_tool, view_image_tool,
|
||||
};
|
||||
pub use web::{web_fetch_tool, web_search_tool};
|
||||
pub use write::write_tool;
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(crate) async fn execute_read(
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: Vec::new(),
|
||||
listing: None,
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ use agen::tool::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use workdir::{ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
use sha2::{Digest, Sha256};
|
||||
use workdir::{
|
||||
ContentHash, ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter,
|
||||
};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
|
||||
@@ -49,7 +52,7 @@ impl Tool for ViewImageTool {
|
||||
workdir::WorkdirSessionCapability::Read,
|
||||
)?;
|
||||
let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?;
|
||||
let bytes = read_image_bytes(selected.session.as_ref(), path.clone())
|
||||
let bytes = read_workdir_bytes(selected.session.as_ref(), path.clone(), MAX_IMAGE_BYTES)
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
let mime_type = detect_image_mime(&bytes).ok_or_else(|| {
|
||||
@@ -70,9 +73,13 @@ impl Tool for ViewImageTool {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_image_bytes(
|
||||
/// Read arbitrary bytes through the scoped provider, bounded by `max_total_bytes`.
|
||||
/// Checks every chunk's identity, range, size and hash, then verifies the assembled
|
||||
/// SHA256 against the provider's pinned [`ContentHash`]. No MIME restriction applies.
|
||||
pub async fn read_workdir_bytes(
|
||||
session: &dyn workdir::WorkdirSession,
|
||||
path: WorkdirPath,
|
||||
max_total_bytes: usize,
|
||||
) -> Result<Vec<u8>, workdir::WorkdirError> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut offset = 0_u64;
|
||||
@@ -116,16 +123,16 @@ async fn read_image_bytes(
|
||||
"bounded binary read returned a truncated or invalid byte range".to_string(),
|
||||
));
|
||||
}
|
||||
if result.total_bytes > MAX_IMAGE_BYTES as u64 {
|
||||
if result.total_bytes > max_total_bytes as u64 {
|
||||
return Err(workdir::WorkdirError::InvalidArgument(format!(
|
||||
"image exceeds the {MAX_IMAGE_BYTES}-byte limit"
|
||||
"file exceeds the {max_total_bytes}-byte limit"
|
||||
)));
|
||||
}
|
||||
if expected_total.is_some_and(|total| total != result.total_bytes)
|
||||
|| expected_hash.is_some_and(|hash| hash != result.content_hash)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Conflict(
|
||||
"image changed while it was being read; retry ViewImage".to_string(),
|
||||
"file changed while it was being read; retry the read".to_string(),
|
||||
));
|
||||
}
|
||||
if expected_total.is_none() {
|
||||
@@ -146,7 +153,13 @@ async fn read_image_bytes(
|
||||
if result.eof {
|
||||
if bytes.len() as u64 != result.total_bytes {
|
||||
return Err(workdir::WorkdirError::Transport(
|
||||
"bounded binary read returned a partial image".to_string(),
|
||||
"bounded binary read returned partial file bytes".to_string(),
|
||||
));
|
||||
}
|
||||
let assembled_hash: ContentHash = Sha256::digest(&bytes).into();
|
||||
if assembled_hash != result.content_hash {
|
||||
return Err(workdir::WorkdirError::Transport(
|
||||
"bounded binary read assembled bytes do not match the content hash".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(bytes);
|
||||
@@ -172,7 +185,8 @@ pub fn routed_view_image_tool(router: Arc<WorkdirSessionRouter>) -> ToolDefiniti
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
|
||||
/// Detect PNG, JPEG, GIF or WebP signatures without trusting a file extension.
|
||||
pub fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Some("image/png")
|
||||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
@@ -189,6 +203,189 @@ fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::TempDir;
|
||||
use workdir::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BinarySession {
|
||||
local: LocalWorkdirSession,
|
||||
responses: Mutex<VecDeque<ReadBytesResult>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkdirSession for BinarySession {
|
||||
fn workdir(&self) -> &Workdir {
|
||||
self.local.workdir()
|
||||
}
|
||||
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||
self.local.capabilities()
|
||||
}
|
||||
async fn read_bytes(
|
||||
&self,
|
||||
request: ReadBytesRequest,
|
||||
) -> Result<ReadBytesResult, WorkdirError> {
|
||||
let response = self.responses.lock().unwrap().pop_front().unwrap();
|
||||
if request.offset > 0 {
|
||||
assert!(request.expected_hash.is_some());
|
||||
}
|
||||
assert!(request.max_bytes <= IMAGE_READ_CHUNK_BYTES);
|
||||
Ok(response)
|
||||
}
|
||||
async fn stat(&self, r: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
self.local.stat(r).await
|
||||
}
|
||||
async fn read(&self, r: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
self.local.read(r).await
|
||||
}
|
||||
async fn write(&self, r: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
self.local.write(r).await
|
||||
}
|
||||
async fn edit(&self, r: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
self.local.edit(r).await
|
||||
}
|
||||
async fn list(&self, r: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
self.local.list(r).await
|
||||
}
|
||||
async fn glob(&self, r: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
self.local.glob(r).await
|
||||
}
|
||||
async fn grep(&self, r: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
self.local.grep(r).await
|
||||
}
|
||||
async fn start_command(&self, r: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||
self.local.start_command(r).await
|
||||
}
|
||||
async fn command_status(&self, r: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||
self.local.command_status(r).await
|
||||
}
|
||||
async fn command_output(
|
||||
&self,
|
||||
r: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
self.local.command_output(r).await
|
||||
}
|
||||
async fn cancel_command(&self, r: CommandHandle) -> Result<(), WorkdirError> {
|
||||
self.local.cancel_command(r).await
|
||||
}
|
||||
async fn close(&self) -> Result<(), WorkdirError> {
|
||||
self.local.close().await
|
||||
}
|
||||
}
|
||||
|
||||
fn binary_session(dir: &TempDir, responses: Vec<ReadBytesResult>) -> BinarySession {
|
||||
BinarySession {
|
||||
local: LocalWorkdirSession::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().into(),
|
||||
),
|
||||
responses: Mutex::new(responses.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk(bytes: &[u8], offset: u64, total: u64, hash: ContentHash) -> ReadBytesResult {
|
||||
ReadBytesResult {
|
||||
path: WorkdirPath::new("artifact.bin").unwrap(),
|
||||
bytes: bytes.to_vec(),
|
||||
offset,
|
||||
total_bytes: total,
|
||||
content_hash: hash,
|
||||
eof: offset + bytes.len() as u64 == total,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_binary_read_rejects_bytes_with_forged_consistent_hash() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let hash = Sha256::digest(b"abcd").into();
|
||||
let session = binary_session(
|
||||
&dir,
|
||||
vec![chunk(b"ab", 0, 4, hash), chunk(b"cx", 2, 4, hash)],
|
||||
);
|
||||
let error = read_workdir_bytes(&session, WorkdirPath::new("artifact.bin").unwrap(), 4)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, WorkdirError::Transport(message) if message.contains("content hash"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_binary_read_rejects_invalid_provider_chunk_metadata() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let hash = Sha256::digest(b"abcd").into();
|
||||
let valid = chunk(b"abcd", 0, 4, hash);
|
||||
let mut cases = Vec::new();
|
||||
let mut wrong_path = valid.clone();
|
||||
wrong_path.path = WorkdirPath::new("other.bin").unwrap();
|
||||
cases.push(wrong_path);
|
||||
let mut wrong_offset = valid.clone();
|
||||
wrong_offset.offset = 1;
|
||||
cases.push(wrong_offset);
|
||||
let mut wrong_eof = valid.clone();
|
||||
wrong_eof.eof = false;
|
||||
cases.push(wrong_eof);
|
||||
let mut wrong_total = valid.clone();
|
||||
wrong_total.total_bytes = 3;
|
||||
cases.push(wrong_total);
|
||||
cases.push(chunk(b"", 0, 4, hash));
|
||||
cases.push(chunk(
|
||||
&vec![0; IMAGE_READ_CHUNK_BYTES + 1],
|
||||
0,
|
||||
(IMAGE_READ_CHUNK_BYTES + 1) as u64,
|
||||
hash,
|
||||
));
|
||||
for response in cases {
|
||||
let session = binary_session(&dir, vec![response]);
|
||||
let error = read_workdir_bytes(&session, valid.path.clone(), 16 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::Transport(_)), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_binary_read_rejects_hash_or_size_changes_between_chunks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let hash = Sha256::digest(b"abcd").into();
|
||||
for second in [chunk(b"cd", 2, 4, [0; 32]), chunk(b"cd", 2, 5, hash)] {
|
||||
let session = binary_session(&dir, vec![chunk(b"ab", 0, 4, hash), second]);
|
||||
let error = read_workdir_bytes(&session, WorkdirPath::new("artifact.bin").unwrap(), 5)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::Conflict(_)), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_binary_read_accepts_arbitrary_bytes_at_caller_limit_and_rejects_larger_files()
|
||||
{
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bytes = vec![0xff; 16 * 1024 * 1024];
|
||||
std::fs::write(dir.path().join("artifact.bin"), &bytes).unwrap();
|
||||
let session =
|
||||
LocalWorkdirSession::new(Scope::writable(dir.path()).unwrap(), dir.path().into());
|
||||
let path = WorkdirPath::new("artifact.bin").unwrap();
|
||||
assert_eq!(
|
||||
read_workdir_bytes(&session, path.clone(), bytes.len())
|
||||
.await
|
||||
.unwrap(),
|
||||
bytes
|
||||
);
|
||||
assert!(matches!(
|
||||
read_workdir_bytes(&session, path.clone(), bytes.len() - 1).await,
|
||||
Err(WorkdirError::InvalidArgument(_))
|
||||
));
|
||||
std::fs::write(dir.path().join("artifact.bin"), []).unwrap();
|
||||
assert!(
|
||||
read_workdir_bytes(&session, path, 0)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_supported_image_signatures_without_trusting_extensions() {
|
||||
|
||||
@@ -125,6 +125,7 @@ pub(crate) async fn execute_write(
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: create_path.into_iter().collect(),
|
||||
listing: None,
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,13 +120,6 @@ impl FormState {
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
if runtime.working_directory_required {
|
||||
self.status =
|
||||
"The selected Runtime requires a workdir; this launch flow does not select one yet."
|
||||
.to_string();
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
let Some(profile) = self.current_profile(options) else {
|
||||
self.status = "No Worker profile is available.".to_string();
|
||||
self.field = Field::Profile;
|
||||
@@ -377,8 +370,8 @@ fn profile_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> Str
|
||||
fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
|
||||
let availability = if !runtime.worker_creation_available {
|
||||
"unavailable"
|
||||
} else if runtime.working_directory_required {
|
||||
"workdir required"
|
||||
} else if runtime.supports_workdir_attachments {
|
||||
"optional workdirs"
|
||||
} else {
|
||||
"no workdir"
|
||||
};
|
||||
@@ -389,7 +382,7 @@ fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
|
||||
}
|
||||
|
||||
fn runtime_supports_workdirless_creation(runtime: &BackendWorkerLaunchRuntimeOption) -> bool {
|
||||
runtime.worker_creation_available && !runtime.working_directory_required
|
||||
runtime.worker_creation_available
|
||||
}
|
||||
|
||||
fn cycle_index(current: usize, len: usize, delta: isize) -> usize {
|
||||
@@ -413,7 +406,7 @@ mod tests {
|
||||
display_name: "External".to_string(),
|
||||
built_in: false,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: true,
|
||||
supports_workdir_attachments: true,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
@@ -422,7 +415,7 @@ mod tests {
|
||||
display_name: "Embedded".to_string(),
|
||||
built_in: true,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: false,
|
||||
supports_workdir_attachments: false,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
@@ -454,20 +447,18 @@ mod tests {
|
||||
let state = FormState::new(&options);
|
||||
assert_eq!(
|
||||
state.current_runtime(&options).unwrap().runtime_id,
|
||||
"embedded"
|
||||
"external"
|
||||
);
|
||||
assert_eq!(state.current_profile(&options).unwrap().id, "builtin:coder");
|
||||
assert_eq!(state.display_name, "Worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_required_runtime_cannot_be_submitted() {
|
||||
fn attachment_capable_runtime_can_submit_without_resources() {
|
||||
let options = options();
|
||||
let mut state = FormState::new(&options);
|
||||
state.runtime_index = 0;
|
||||
assert_eq!(state.submit(&options), None);
|
||||
assert!(state.status.contains("requires a workdir"));
|
||||
assert_eq!(state.field, Field::Runtime);
|
||||
assert_eq!(state.submit(&options).unwrap().runtime_id, "external");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -101,6 +101,37 @@ Host generation reuse, native permission identity, read-history updates, model
|
||||
Operation publication, transport hosting (Runtime/Backend/External), and durable
|
||||
Ticket/MR review remain the parent integration's responsibilities.
|
||||
|
||||
## 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
|
||||
|
||||
`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
|
||||
provider authority, and the output-root boundary. Wrappers contribute their own
|
||||
scope; caller-supplied layers can only narrow, never replace it. Each wrapper
|
||||
translates all incoming coordinates once and forwards the composed output root.
|
||||
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
|
||||
that final root; wrappers must not prefix cwd onto returned paths again.
|
||||
|
||||
|
||||
@@ -167,6 +167,19 @@ impl CheckoutSearchRequest {
|
||||
}
|
||||
let valid = match &self.operation {
|
||||
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
|
||||
}
|
||||
CheckoutSearchOperation::Glob(r) => {
|
||||
@@ -793,6 +806,7 @@ mod tests {
|
||||
for provider in [&local as &dyn WorkdirSession, &external] {
|
||||
let listed = provider
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ fn fixture() -> tempfile::TempDir {
|
||||
async fn verify_sparse(session: &dyn WorkdirSession) {
|
||||
let listed = session
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
})
|
||||
@@ -75,6 +76,7 @@ async fn verify_sparse(session: &dyn WorkdirSession) {
|
||||
);
|
||||
let empty = session
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: path("denied"),
|
||||
limit: 100,
|
||||
})
|
||||
@@ -108,6 +110,7 @@ async fn verify_sparse(session: &dyn WorkdirSession) {
|
||||
let CheckoutSearchResult::List(native) = session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
},
|
||||
@@ -161,6 +164,7 @@ async fn checkout_search_nested_sparse_nonrecursive_scopes_rebase_and_exclude_co
|
||||
.tool_session()
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100
|
||||
}
|
||||
@@ -196,6 +200,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
||||
let CheckoutSearchResult::List(result) = stacked
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: path("keep"),
|
||||
limit: 100,
|
||||
},
|
||||
@@ -229,6 +234,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
||||
let entries = second
|
||||
.tool_session()
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
})
|
||||
@@ -246,6 +252,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
||||
second
|
||||
.tool_session()
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: path("deep"),
|
||||
limit: 100
|
||||
})
|
||||
@@ -292,6 +299,7 @@ async fn checkout_search_scope_layers_intersect_before_list_and_grep() {
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: path("child"),
|
||||
limit: 100,
|
||||
}));
|
||||
@@ -325,6 +333,7 @@ async fn checkout_search_wire_dto_bounds_and_provider_capabilities() {
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: path("child/keep"),
|
||||
limit: 100,
|
||||
}));
|
||||
@@ -795,6 +804,7 @@ async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_es
|
||||
let CheckoutSearchResult::List(result) = source
|
||||
.checkout_search(CheckoutSearchRequest {
|
||||
operation: CheckoutSearchOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: path("child/denied"),
|
||||
limit: 100,
|
||||
}),
|
||||
|
||||
@@ -155,7 +155,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
||||
<= MAX_EXTERNAL_SCOPE_RULES
|
||||
&& validate_external_operation(&match &request.operation {
|
||||
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) => {
|
||||
WorkdirSessionOperation::Glob(r.clone())
|
||||
@@ -212,7 +216,14 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
||||
.contains(&request.max_bytes)
|
||||
}
|
||||
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) => {
|
||||
is_root_relative(&request.path)
|
||||
@@ -395,7 +406,20 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
|
||||
.entries
|
||||
.iter()
|
||||
.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) => {
|
||||
result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
@@ -917,6 +941,7 @@ mod tests {
|
||||
assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(nested).is_err());
|
||||
|
||||
let unbounded = WorkdirSessionOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: MAX_EXTERNAL_RESULT_ITEMS + 1,
|
||||
});
|
||||
|
||||
@@ -33,9 +33,9 @@ use tokio::sync::broadcast;
|
||||
|
||||
pub use fs_operation::{
|
||||
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
|
||||
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest,
|
||||
ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest,
|
||||
StatResult, WriteRequest, WriteResult,
|
||||
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListCursor, ListEntry,
|
||||
ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult,
|
||||
StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
pub use http::dispatch_workdir_session_operation;
|
||||
pub use local::{
|
||||
|
||||
@@ -2695,6 +2695,7 @@ mod tests {
|
||||
let listed = WorkdirSession::list(
|
||||
&workdir,
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::new("notes").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
@@ -3385,6 +3386,7 @@ mod tests {
|
||||
let list = WorkdirSession::list(
|
||||
&workdir,
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::new("yoi.local").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
|
||||
@@ -1005,6 +1005,9 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
.await?;
|
||||
self.ensure_read(&path, request.operation.capability())?;
|
||||
*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)?;
|
||||
for layer in &mut request.scope_layers {
|
||||
for rule in layer {
|
||||
@@ -1873,6 +1876,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
parent
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: fs_path("child"),
|
||||
limit: 100
|
||||
})
|
||||
@@ -1890,6 +1894,7 @@ mod tests {
|
||||
child
|
||||
.tool_session()
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: FsPath::root(),
|
||||
limit: 100
|
||||
})
|
||||
@@ -1901,6 +1906,7 @@ mod tests {
|
||||
.tool_session()
|
||||
.checkout_search(crate::CheckoutSearchRequest::new(
|
||||
crate::CheckoutSearchOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: FsPath::root(),
|
||||
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());
|
||||
}
|
||||
@@ -42,7 +42,11 @@ pub enum RuntimeAuthError {
|
||||
#[error("runtime request proof contains an invalid `{0}` claim")]
|
||||
InvalidClaim(&'static str),
|
||||
#[error("runtime request proof does not match the HTTP request")]
|
||||
ClaimMismatch,
|
||||
ClaimMismatch(RuntimeRequestClaim),
|
||||
#[error("runtime request proof was issued in the future")]
|
||||
RequestIssuedInFuture { iat: i64, exp: i64, now: i64 },
|
||||
#[error("runtime request proof has expired")]
|
||||
RequestExpired { iat: i64, exp: i64, now: i64 },
|
||||
#[error("unknown token issuer `{0}`")]
|
||||
UnknownIssuer(String),
|
||||
#[error("invalid token signature")]
|
||||
@@ -171,6 +175,19 @@ pub struct RuntimeAuthContext {
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// The first mismatching signed claim, in verification order. No input values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeRequestClaim {
|
||||
Issuer,
|
||||
Audience,
|
||||
Workspace,
|
||||
Worker,
|
||||
Permission,
|
||||
Method,
|
||||
Path,
|
||||
BodyDigest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeRequestSourceClaims {
|
||||
pub iss: String,
|
||||
@@ -293,19 +310,54 @@ pub fn verify_runtime_request_source(
|
||||
public_key,
|
||||
)?;
|
||||
let claims = signed.claims;
|
||||
if claims.iss != expected.identity_id
|
||||
|| claims.aud != expected.audience
|
||||
|| claims.workspace_id != expected.workspace_id
|
||||
|| claims.worker_id.as_deref() != expected.worker_id
|
||||
|| claims.permission != expected.permission
|
||||
|| claims.method != expected.method
|
||||
|| claims.path != expected.path
|
||||
|| claims.body_digest != expected.body_digest
|
||||
{
|
||||
return Err(RuntimeAuthError::ClaimMismatch);
|
||||
for (matches, claim) in [
|
||||
(
|
||||
claims.iss == expected.identity_id,
|
||||
RuntimeRequestClaim::Issuer,
|
||||
),
|
||||
(
|
||||
claims.aud == expected.audience,
|
||||
RuntimeRequestClaim::Audience,
|
||||
),
|
||||
(
|
||||
claims.workspace_id == expected.workspace_id,
|
||||
RuntimeRequestClaim::Workspace,
|
||||
),
|
||||
(
|
||||
claims.worker_id.as_deref() == expected.worker_id,
|
||||
RuntimeRequestClaim::Worker,
|
||||
),
|
||||
(
|
||||
claims.permission == expected.permission,
|
||||
RuntimeRequestClaim::Permission,
|
||||
),
|
||||
(
|
||||
claims.method == expected.method,
|
||||
RuntimeRequestClaim::Method,
|
||||
),
|
||||
(claims.path == expected.path, RuntimeRequestClaim::Path),
|
||||
(
|
||||
claims.body_digest == expected.body_digest,
|
||||
RuntimeRequestClaim::BodyDigest,
|
||||
),
|
||||
] {
|
||||
if !matches {
|
||||
return Err(RuntimeAuthError::ClaimMismatch(claim));
|
||||
}
|
||||
}
|
||||
if claims.iat > expected.now_unix || claims.exp < expected.now_unix {
|
||||
return Err(RuntimeAuthError::Expired);
|
||||
if claims.iat > expected.now_unix {
|
||||
return Err(RuntimeAuthError::RequestIssuedInFuture {
|
||||
iat: claims.iat,
|
||||
exp: claims.exp,
|
||||
now: expected.now_unix,
|
||||
});
|
||||
}
|
||||
if claims.exp < expected.now_unix {
|
||||
return Err(RuntimeAuthError::RequestExpired {
|
||||
iat: claims.iat,
|
||||
exp: claims.exp,
|
||||
now: expected.now_unix,
|
||||
});
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
@@ -705,7 +757,9 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(&proof, &trusted.public_key, &changed_body),
|
||||
Err(RuntimeAuthError::ClaimMismatch)
|
||||
Err(RuntimeAuthError::ClaimMismatch(
|
||||
RuntimeRequestClaim::BodyDigest
|
||||
))
|
||||
));
|
||||
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||
assert!(matches!(
|
||||
@@ -714,6 +768,183 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_request_proof_preserves_inclusive_time_bounds_and_reports_exact_rejections() {
|
||||
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||
let signer = RuntimeRequestSourceSigner::from_identity(&runtime);
|
||||
let proof = signer
|
||||
.issue(
|
||||
"server",
|
||||
"workspace",
|
||||
None,
|
||||
"permission",
|
||||
"POST",
|
||||
"/request",
|
||||
b"{}",
|
||||
90,
|
||||
10,
|
||||
)
|
||||
.unwrap();
|
||||
let digest = request_body_digest(b"{}");
|
||||
let expected = RuntimeRequestSourceExpectation {
|
||||
identity_id: "runtime-main",
|
||||
audience: "server",
|
||||
workspace_id: "workspace",
|
||||
worker_id: None,
|
||||
permission: "permission",
|
||||
method: "POST",
|
||||
path: "/request",
|
||||
body_digest: &digest,
|
||||
now_unix: 90,
|
||||
};
|
||||
for now in [90, 99, 100] {
|
||||
assert!(
|
||||
verify_runtime_request_source(
|
||||
&proof,
|
||||
&runtime.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
now_unix: now,
|
||||
..expected.clone()
|
||||
}
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
for (now, future) in [(89, true), (101, false)] {
|
||||
let error = verify_runtime_request_source(
|
||||
&proof,
|
||||
&runtime.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
now_unix: now,
|
||||
..expected.clone()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
match error {
|
||||
RuntimeAuthError::RequestIssuedInFuture {
|
||||
iat,
|
||||
exp,
|
||||
now: verified,
|
||||
} if future => assert_eq!((iat, exp, verified), (90, 100, now)),
|
||||
RuntimeAuthError::RequestExpired {
|
||||
iat,
|
||||
exp,
|
||||
now: verified,
|
||||
} if !future => assert_eq!((iat, exp, verified), (90, 100, now)),
|
||||
other => panic!("unexpected rejection: {other:?}"),
|
||||
}
|
||||
}
|
||||
for (claim, expectation) in [
|
||||
(
|
||||
RuntimeRequestClaim::Issuer,
|
||||
RuntimeRequestSourceExpectation {
|
||||
identity_id: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Audience,
|
||||
RuntimeRequestSourceExpectation {
|
||||
audience: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Workspace,
|
||||
RuntimeRequestSourceExpectation {
|
||||
workspace_id: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Worker,
|
||||
RuntimeRequestSourceExpectation {
|
||||
worker_id: Some("other"),
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Permission,
|
||||
RuntimeRequestSourceExpectation {
|
||||
permission: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Method,
|
||||
RuntimeRequestSourceExpectation {
|
||||
method: "GET",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Path,
|
||||
RuntimeRequestSourceExpectation {
|
||||
path: "/other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::BodyDigest,
|
||||
RuntimeRequestSourceExpectation {
|
||||
body_digest: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
matches!(verify_runtime_request_source(&proof, &runtime.public_key, &expectation), Err(RuntimeAuthError::ClaimMismatch(actual)) if actual == claim)
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(
|
||||
&proof,
|
||||
&runtime.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
audience: "other",
|
||||
now_unix: 101,
|
||||
..expected.clone()
|
||||
}
|
||||
),
|
||||
Err(RuntimeAuthError::ClaimMismatch(
|
||||
RuntimeRequestClaim::Audience
|
||||
))
|
||||
));
|
||||
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(
|
||||
&proof,
|
||||
&spoofed.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
now_unix: 101,
|
||||
..expected.clone()
|
||||
}
|
||||
),
|
||||
Err(RuntimeAuthError::InvalidSignature)
|
||||
));
|
||||
let mut claims = decode_runtime_request_source_claims(&proof).unwrap();
|
||||
claims.iat = 101;
|
||||
claims.exp = 89;
|
||||
let contradictory = sign_json_token(
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
|
||||
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
|
||||
&runtime.signing_key().unwrap(),
|
||||
&claims,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(
|
||||
&contradictory,
|
||||
&runtime.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
now_unix: 100,
|
||||
..expected.clone()
|
||||
}
|
||||
),
|
||||
Err(RuntimeAuthError::RequestIssuedInFuture { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() {
|
||||
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||
@@ -749,7 +980,9 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace),
|
||||
Err(RuntimeAuthError::ClaimMismatch)
|
||||
Err(RuntimeAuthError::ClaimMismatch(
|
||||
RuntimeRequestClaim::Workspace
|
||||
))
|
||||
));
|
||||
let expired = RuntimeRequestSourceExpectation {
|
||||
now_unix: 101,
|
||||
@@ -757,7 +990,11 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(&proof, &runtime.public_key, &expired),
|
||||
Err(RuntimeAuthError::Expired)
|
||||
Err(RuntimeAuthError::RequestExpired {
|
||||
iat: 90,
|
||||
exp: 100,
|
||||
now: 101
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
|
||||
use crate::workspace_request::{
|
||||
RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient, RuntimeWorkspaceRequestError,
|
||||
};
|
||||
use worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
|
||||
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
WorkspaceServerOperation,
|
||||
WorkspaceBinaryRequest, WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError,
|
||||
WorkspacePromptCatalogResolution, WorkspacePromptProjection, WorkspaceRequest,
|
||||
WorkspaceRequestMethod, WorkspaceResponse, WorkspaceServerOperation,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
@@ -381,6 +383,64 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
self
|
||||
}
|
||||
|
||||
fn execute_binary_with_optional_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
let method = match request.method {
|
||||
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
||||
WorkspaceRequestMethod::Post => reqwest::Method::POST,
|
||||
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
|
||||
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
|
||||
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
||||
};
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if request.body.is_some() {
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
}
|
||||
let response = self
|
||||
.request_client
|
||||
.execute_binary_blocking(RuntimeWorkspaceRequest {
|
||||
method,
|
||||
path_and_query: request.path,
|
||||
body: request.body.unwrap_or_default(),
|
||||
headers,
|
||||
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
|
||||
worker_id: Some(self.worker_id.clone()),
|
||||
timeout,
|
||||
max_response_bytes: request.max_response_bytes,
|
||||
})
|
||||
.map_err(|error| {
|
||||
// Never expose request URLs, signed proofs, or binary bodies in transport errors.
|
||||
let message = match error {
|
||||
RuntimeWorkspaceRequestError::InvalidRequest(_) => {
|
||||
"invalid Workspace binary request"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::Sign(_) => {
|
||||
"Workspace binary request authorization failed"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::ResponseTooLarge { .. } => {
|
||||
"Workspace binary response exceeded limit"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::Transport { timeout: true, .. } => {
|
||||
"Workspace binary request timed out"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::Transport { .. } => {
|
||||
"Workspace binary request failed"
|
||||
}
|
||||
};
|
||||
WorkspaceClientError::Request(message.to_string())
|
||||
})?;
|
||||
Ok(WorkspaceBinaryResponse {
|
||||
status: response.status.as_u16(),
|
||||
body: response.body,
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_with_optional_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
@@ -481,6 +541,21 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
self.execute_with_optional_timeout(request, self.request_timeout)
|
||||
}
|
||||
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.execute_binary_with_optional_timeout(request, self.request_timeout)
|
||||
}
|
||||
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.execute_binary_with_optional_timeout(request, Some(timeout))
|
||||
}
|
||||
|
||||
fn execute_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
@@ -789,6 +864,216 @@ mod tests {
|
||||
request_body_digest, verify_worker_mutation_source_proof,
|
||||
};
|
||||
|
||||
fn binary_loopback(response: Vec<u8>) -> (String, std::thread::JoinHandle<(String, Vec<u8>)>) {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut head = Vec::new();
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
let mut byte = [0];
|
||||
stream.read_exact(&mut byte).unwrap();
|
||||
head.push(byte[0]);
|
||||
assert!(head.len() < 16 * 1024, "request headers too large");
|
||||
}
|
||||
let head = String::from_utf8(head).unwrap();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mut body = vec![0; length];
|
||||
stream.read_exact(&mut body).unwrap();
|
||||
// Oversize-response tests may close the connection before all bytes are sent.
|
||||
let _ = stream.write_all(&response);
|
||||
(head, body)
|
||||
});
|
||||
(base_url, server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() {
|
||||
use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source};
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||
for (method, wire_method, body, reply) in [
|
||||
(
|
||||
WorkspaceRequestMethod::Get,
|
||||
"GET",
|
||||
None,
|
||||
vec![0, 255, 128, 13],
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"POST",
|
||||
Some(vec![0, 255, 128, 13, 10]),
|
||||
vec![255, 0],
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Put,
|
||||
"PUT",
|
||||
Some(vec![255; 16 * 1024 * 1024]),
|
||||
vec![0],
|
||||
),
|
||||
] {
|
||||
let path = "/api/w/workspace-a/drive/entries/a/content?offset=0&limit=65536&name=a%2Fb";
|
||||
let mut response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
reply.len()
|
||||
)
|
||||
.into_bytes();
|
||||
response.extend_from_slice(&reply);
|
||||
let (base_url, server) = binary_loopback(response);
|
||||
let client =
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
|
||||
.with_runtime_request_source(&identity, "server-a");
|
||||
let result = client
|
||||
.execute_binary_with_timeout(
|
||||
WorkspaceBinaryRequest {
|
||||
method,
|
||||
path: path.into(),
|
||||
body: body.clone(),
|
||||
max_response_bytes: reply.len(),
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
assert_eq!(result.body, reply);
|
||||
let (head, received) = server.join().unwrap();
|
||||
assert_eq!(received, body.unwrap_or_default());
|
||||
assert!(head.starts_with(&format!("{wire_method} {path} HTTP/1.1\r\n")));
|
||||
let header = |name: &str| {
|
||||
head.lines().find_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
key.eq_ignore_ascii_case(name).then(|| value.trim())
|
||||
})
|
||||
};
|
||||
assert_eq!(header("x-yoi-runtime-id"), Some("runtime-a"));
|
||||
assert_eq!(header("x-yoi-worker-id"), Some("worker-a"));
|
||||
assert_eq!(header("authorization"), None);
|
||||
if method != WorkspaceRequestMethod::Get {
|
||||
assert_eq!(header("content-type"), Some("application/octet-stream"));
|
||||
}
|
||||
let digest = request_body_digest(&received);
|
||||
let expected = RuntimeRequestSourceExpectation {
|
||||
identity_id: "runtime-a",
|
||||
audience: "server-a",
|
||||
workspace_id: "workspace-a",
|
||||
worker_id: Some("worker-a"),
|
||||
permission: WORKSPACE_REQUEST_PERMISSION,
|
||||
method: wire_method,
|
||||
path,
|
||||
body_digest: &digest,
|
||||
now_unix: unix_now_seconds() as i64,
|
||||
};
|
||||
let token = header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER).unwrap();
|
||||
verify_runtime_request_source(token, &identity.public_key, &expected).unwrap();
|
||||
let altered = RuntimeRequestSourceExpectation {
|
||||
body_digest: &request_body_digest(b"altered"),
|
||||
..expected.clone()
|
||||
};
|
||||
assert!(verify_runtime_request_source(token, &identity.public_key, &altered).is_err());
|
||||
let altered = RuntimeRequestSourceExpectation {
|
||||
path: "/api/w/workspace-a/drive/entries/a/content?offset=1",
|
||||
..expected
|
||||
};
|
||||
assert!(verify_runtime_request_source(token, &identity.public_key, &altered).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_responses_enforce_declared_and_streamed_limits_with_fixed_errors() {
|
||||
for response in [
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nabcde".to_vec(),
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\nab\r\n3\r\ncde\r\n0\r\n\r\n".to_vec(),
|
||||
b"HTTP/1.1 500 Error\r\nConnection: close\r\n\r\nabcde".to_vec(),
|
||||
] {
|
||||
let (base_url, server) = binary_loopback(response);
|
||||
let client = RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
|
||||
let error = client.execute_binary(WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: "/api/w/workspace-a/drive/entries/a/content?private=secret".into(),
|
||||
body: None, max_response_bytes: 4,
|
||||
}).unwrap_err();
|
||||
server.join().unwrap();
|
||||
assert_eq!(error.to_string(), "workspace request failed: Workspace binary response exceeded limit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_redirect_returns_status_without_forwarding_authentication() {
|
||||
let (base_url, server) = binary_loopback(b"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://127.0.0.1:1/private\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec());
|
||||
let client =
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
|
||||
let result = client
|
||||
.execute_binary(WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Post,
|
||||
path: "/api/w/workspace-a/drive/upload".into(),
|
||||
body: Some(vec![0, 255]),
|
||||
max_response_bytes: 0,
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
assert_eq!(result.status, 307);
|
||||
assert!(result.body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_invalid_or_normalized_targets_fail_with_fixed_errors_before_http() {
|
||||
let client = RuntimeOwnedWorkspaceClient::new(
|
||||
"workspace-a",
|
||||
"http://127.0.0.1:1",
|
||||
"runtime-a",
|
||||
"worker-a",
|
||||
);
|
||||
for path in [
|
||||
"https://example.com/secret",
|
||||
"//example.com/secret",
|
||||
"/a/../secret",
|
||||
"/a#secret",
|
||||
"/a\\secret",
|
||||
"/a b",
|
||||
] {
|
||||
let error = client
|
||||
.execute_binary(WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: path.into(),
|
||||
body: None,
|
||||
max_response_bytes: 4,
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"workspace request failed: invalid Workspace binary request"
|
||||
);
|
||||
}
|
||||
let error = client
|
||||
.execute_binary_with_timeout(
|
||||
WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Post,
|
||||
path: "/secret?private=value".into(),
|
||||
body: Some(vec![255]),
|
||||
max_response_bytes: 4,
|
||||
},
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"workspace request failed: Workspace binary request failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_prompt_projection_uses_the_shared_runtime_cache_without_http() {
|
||||
let cache = Arc::new(WorkspacePromptProjectionCache::default());
|
||||
|
||||
@@ -255,7 +255,7 @@ impl RuntimeWorkspaceRequestClient {
|
||||
request: RuntimeWorkspaceRequest,
|
||||
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||
let client = self.clone();
|
||||
std::thread::spawn(move || client.execute_blocking_inner(request))
|
||||
std::thread::spawn(move || client.execute_blocking_inner(request, true))
|
||||
.join()
|
||||
.map_err(|_| RuntimeWorkspaceRequestError::Transport {
|
||||
message: "Workspace request thread panicked".to_string(),
|
||||
@@ -263,15 +263,45 @@ impl RuntimeWorkspaceRequestClient {
|
||||
})?
|
||||
}
|
||||
|
||||
fn execute_blocking_inner(
|
||||
/// Binary requests must retain their exact target and never follow redirects.
|
||||
pub(crate) fn execute_binary_blocking(
|
||||
&self,
|
||||
request: RuntimeWorkspaceRequest,
|
||||
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||
let client = self.clone();
|
||||
std::thread::spawn(move || client.execute_blocking_inner(request, false))
|
||||
.join()
|
||||
.map_err(|_| RuntimeWorkspaceRequestError::Transport {
|
||||
message: "Workspace binary request thread panicked".to_string(),
|
||||
timeout: false,
|
||||
})?
|
||||
}
|
||||
|
||||
fn execute_blocking_inner(
|
||||
&self,
|
||||
request: RuntimeWorkspaceRequest,
|
||||
follow_redirects: bool,
|
||||
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||
let prepared = self.prepare(&request)?;
|
||||
if !follow_redirects {
|
||||
let mut target = prepared.url.path().to_string();
|
||||
if let Some(query) = prepared.url.query() {
|
||||
target.push('?');
|
||||
target.push_str(query);
|
||||
}
|
||||
if prepared.url.fragment().is_some() || target != request.path_and_query {
|
||||
return Err(RuntimeWorkspaceRequestError::InvalidRequest(
|
||||
"binary request target must be exact".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// The blocking client defaults to 30 seconds. Apply None explicitly so
|
||||
// provider-owned command waits are not cut short by an HTTP deadline.
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(request.timeout)
|
||||
let mut client_builder = reqwest::blocking::Client::builder().timeout(request.timeout);
|
||||
if !follow_redirects {
|
||||
client_builder = client_builder.redirect(reqwest::redirect::Policy::none());
|
||||
}
|
||||
let client = client_builder
|
||||
.build()
|
||||
.map_err(RuntimeWorkspaceRequestError::transport)?;
|
||||
let mut builder = client
|
||||
|
||||
+267
-148
@@ -11,11 +11,12 @@ use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||
use serde_json::{Value as Json, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use wip_protocol::{
|
||||
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
|
||||
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
||||
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
use workdir::{
|
||||
CheckoutObservation, EntryKind, ListRequest, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||
CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||
WorkdirSessionRouter,
|
||||
};
|
||||
|
||||
@@ -126,11 +127,15 @@ impl Provider {
|
||||
}
|
||||
self.project(
|
||||
ROOT,
|
||||
descriptor(vec![operation(
|
||||
"list",
|
||||
"List currently accessible checkouts",
|
||||
vec![],
|
||||
)]),
|
||||
descriptor(vec![{
|
||||
let mut op = operation(
|
||||
"list",
|
||||
"List accessible checkout entrances (not filesystem contents)",
|
||||
vec![],
|
||||
);
|
||||
op.returns.r#type = TypeExpr::Json;
|
||||
op
|
||||
}]),
|
||||
state.finalize().to_vec(),
|
||||
"collection",
|
||||
Arc::new(Collection {
|
||||
@@ -169,8 +174,14 @@ impl Provider {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return Ok(Some(self.collection()));
|
||||
return WipPublication::self_scoped(self.collection()).map(Some);
|
||||
}
|
||||
let Some((alias, target)) = self.parse(path) else {
|
||||
return Ok(None);
|
||||
@@ -202,39 +213,86 @@ impl Provider {
|
||||
if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) {
|
||||
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);
|
||||
if ops.is_empty() {
|
||||
return Ok(None);
|
||||
return None;
|
||||
}
|
||||
let kind = if observation.kind == EntryKind::File {
|
||||
"file"
|
||||
} else {
|
||||
"directory"
|
||||
};
|
||||
let workdir = selected.session.workdir().id().as_str().to_owned();
|
||||
let validator = self.wrap_validator(
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
&observation.validator,
|
||||
);
|
||||
let validator = self.wrap_validator(alias, generation, workdir, &observation.validator);
|
||||
let handler = Arc::new(FileHandler {
|
||||
provider: self.clone(),
|
||||
alias,
|
||||
generation: selected.generation,
|
||||
workdir,
|
||||
alias: alias.into(),
|
||||
generation,
|
||||
workdir: workdir.into(),
|
||||
target,
|
||||
observation,
|
||||
});
|
||||
let family = format!("{kind}-g{}", selected.generation);
|
||||
Ok(Some(self.project(
|
||||
path,
|
||||
descriptor(ops),
|
||||
validator,
|
||||
&family,
|
||||
handler,
|
||||
)))
|
||||
let family = format!("{kind}-g{generation}");
|
||||
let mut projection = self.project(path, descriptor(ops), validator, &family, handler);
|
||||
// Keep path-qualified local names (permissions/capabilities may differ
|
||||
// per target) while binding every content Interface to its live entrance.
|
||||
projection.interface.scope = checkout_root(alias);
|
||||
projection.interface.name.push_str("#target/");
|
||||
projection.interface.name.push_str(&encode_identity(path));
|
||||
projection.object.interfaces = vec![projection.interface.clone()];
|
||||
Some(projection)
|
||||
}
|
||||
|
||||
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
|
||||
let mut ops = Vec::new();
|
||||
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
|
||||
@@ -261,6 +319,13 @@ impl Provider {
|
||||
));
|
||||
}
|
||||
} 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") {
|
||||
ops.push(operation(
|
||||
"glob",
|
||||
@@ -288,6 +353,15 @@ impl Provider {
|
||||
|
||||
#[async_trait]
|
||||
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> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
.await
|
||||
@@ -298,7 +372,7 @@ impl WipSubtreeProvider for Arc<Provider> {
|
||||
)
|
||||
})?
|
||||
.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
|
||||
.map_err(|_| {
|
||||
error(
|
||||
@@ -306,7 +380,6 @@ impl WipSubtreeProvider for Arc<Provider> {
|
||||
"checkout observation deadline",
|
||||
)
|
||||
})?
|
||||
.and_then(|projection| projection.map(WipPublication::self_scoped).transpose())
|
||||
}
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
@@ -354,70 +427,9 @@ impl Provider {
|
||||
}
|
||||
return Ok(paths);
|
||||
}
|
||||
let Some((alias, target)) = self.parse(path) else {
|
||||
return Err(error(ProtocolErrorCode::NotFound, "checkout not published"));
|
||||
};
|
||||
let selected = self
|
||||
.router
|
||||
.resolve(Some(&alias))
|
||||
.map_err(|_| error(ProtocolErrorCode::NotFound, "attachment expired"))?;
|
||||
let o = selected
|
||||
.session
|
||||
.checkout_observe(target.clone())
|
||||
.await
|
||||
.map_err(|_| error(ProtocolErrorCode::NotFound, "checkout not published"))?;
|
||||
if o.kind != EntryKind::Directory {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if !self.allowed(
|
||||
"Glob",
|
||||
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":"*"}),
|
||||
) && !self.allowed(
|
||||
"Grep",
|
||||
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":""}),
|
||||
) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let listing = selected
|
||||
.session
|
||||
.checkout_search(workdir::CheckoutSearchRequest::new(
|
||||
workdir::CheckoutSearchOperation::List(ListRequest {
|
||||
path: target,
|
||||
limit: MAX_NODES + 1,
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout listing unavailable"))?;
|
||||
let workdir::CheckoutSearchResult::List(listing) = listing else {
|
||||
return Err(error(
|
||||
ProtocolErrorCode::Internal,
|
||||
"mismatched checkout listing",
|
||||
));
|
||||
};
|
||||
if listing.truncated || listing.entries.len() > MAX_NODES {
|
||||
return Err(error(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"directory observation exceeds node bound",
|
||||
));
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for entry in listing.entries {
|
||||
let p = object_path(&alias, &entry.path);
|
||||
if self.node(&p).await?.is_some() {
|
||||
paths.push(p);
|
||||
}
|
||||
}
|
||||
if !self
|
||||
.router
|
||||
.resolve(Some(&alias))
|
||||
.is_ok_and(|current| current.generation == selected.generation)
|
||||
{
|
||||
return Err(error(
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
"attachment changed",
|
||||
));
|
||||
}
|
||||
Ok(paths)
|
||||
// Contents are addressable, but never indexable. Host observe already
|
||||
// resolves the requested Object; no provider content List is needed.
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +503,76 @@ impl FileHandler {
|
||||
))
|
||||
}
|
||||
}
|
||||
/// Classify post-success publication failures separately from pre-effect
|
||||
/// provider errors; result coordinates remain lazy for all operations.
|
||||
fn publish_result(
|
||||
&self,
|
||||
tool: &str,
|
||||
result: tools::CheckoutToolOutput,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
// Coordinates are not observations. Never prefetch unselected results;
|
||||
// a vanished entry cannot fail a successful discovery Operation.
|
||||
self.check_connection().map_err(|error| {
|
||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||
// The provider has already succeeded: connection loss while
|
||||
// publishing cannot prove that a committed effect was rejected.
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"completed file operation lost its attachment before result publication".into(),
|
||||
)
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})?;
|
||||
let mut fields = BTreeMap::from([("summary".into(), Value::String(result.output.summary))]);
|
||||
if let Some(content) = result.output.content {
|
||||
fields.insert("content".into(), Value::String(content));
|
||||
}
|
||||
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));
|
||||
}
|
||||
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)),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
result
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
Value::Record(BTreeMap::from([(
|
||||
"entry".into(),
|
||||
Value::String(object_path(&self.alias, path)),
|
||||
)]))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
fields.insert("items".into(), Value::List(items));
|
||||
let value = Value::Record(fields);
|
||||
if let Some(raw) = result.validator {
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider
|
||||
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
|
||||
))
|
||||
} else {
|
||||
Ok(WipOperationOutput::native(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for FileHandler {
|
||||
@@ -501,6 +583,7 @@ impl WipOperationHandler for FileHandler {
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let tool = match operation {
|
||||
"list" => "List",
|
||||
"read" => "Read",
|
||||
"edit" => "Edit",
|
||||
"write" => "Write",
|
||||
@@ -528,7 +611,34 @@ impl WipOperationHandler for FileHandler {
|
||||
}
|
||||
let mut permission_input = args.clone();
|
||||
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("");
|
||||
permission_input.insert("path".into(), json!(join_under(&self.target, relative)?));
|
||||
} else if tool == "Create" {
|
||||
@@ -588,58 +698,7 @@ impl WipOperationHandler for FileHandler {
|
||||
}
|
||||
})?
|
||||
.map_err(map_tool_error)?;
|
||||
let links = tokio::time::timeout_at(deadline, async {
|
||||
let mut links = Vec::new();
|
||||
self.check_connection()?;
|
||||
for path in &result.paths {
|
||||
let route = object_path(&self.alias, path);
|
||||
// References are validated only against the captured live connection.
|
||||
if self
|
||||
.provider
|
||||
.node(&route)
|
||||
.await
|
||||
.map_err(WipOperationError::Protocol)?
|
||||
.is_some()
|
||||
{
|
||||
links.push(json!({"path":route}));
|
||||
}
|
||||
self.check_connection()?;
|
||||
}
|
||||
Ok::<_, WipOperationError>(links)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||
WipOperationError::OutcomeUnknown("completed operation response deadline".into())
|
||||
} else {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"checkout result deadline",
|
||||
)
|
||||
}
|
||||
})?
|
||||
.map_err(|e| {
|
||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||
WipOperationError::OutcomeUnknown("completed operation response unavailable".into())
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let value = json_to_wip(
|
||||
&json!({"summary":result.output.summary,"content":result.output.content,"items":links}),
|
||||
)
|
||||
.map_err(|_| {
|
||||
WipOperationError::OutcomeUnknown("invalid completed file operation result".into())
|
||||
})?;
|
||||
if let Some(raw) = result.validator {
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider
|
||||
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
|
||||
))
|
||||
} else {
|
||||
Ok(WipOperationOutput::native(value))
|
||||
}
|
||||
self.publish_result(tool, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,10 +752,70 @@ fn operation(
|
||||
parameters,
|
||||
returns: ReturnDeclaration {
|
||||
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 {
|
||||
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")
|
||||
};
|
||||
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")
|
||||
};
|
||||
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() {
|
||||
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::{
|
||||
CheckoutRequest, CheckoutResult, CommandHandle, CommandOutput, CommandOutputRequest,
|
||||
CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
|
||||
GrepResult, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult, ReadRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias, WorkdirError,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
GrepResult, ListRequest, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias,
|
||||
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ struct HookedSession {
|
||||
observe_hook: Mutex<Option<ObserveHook>>,
|
||||
stall_path: Mutex<Option<WorkdirPath>>,
|
||||
stalled_observations: AtomicUsize,
|
||||
searches: AtomicUsize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HookedSession {
|
||||
@@ -52,6 +53,7 @@ impl HookedSession {
|
||||
observe_hook: Mutex::new(None),
|
||||
stall_path: Mutex::new(None),
|
||||
stalled_observations: AtomicUsize::new(0),
|
||||
searches: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,6 +125,7 @@ impl WorkdirSession for HookedSession {
|
||||
&self,
|
||||
request: workdir::CheckoutSearchRequest,
|
||||
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
|
||||
self.searches.fetch_add(1, Ordering::SeqCst);
|
||||
self.local.checkout_search(request).await
|
||||
}
|
||||
|
||||
@@ -484,7 +487,7 @@ async fn collection_list_and_root_enumeration_bound_stalled_observation_and_rele
|
||||
}
|
||||
|
||||
#[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();
|
||||
std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap();
|
||||
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 projection = p.node(&root).await.unwrap().unwrap();
|
||||
|
||||
// Directory observations and the normal local grep/checkout execution can
|
||||
// complete. Only publishing the typed search result path gets stuck.
|
||||
// Directory/search can complete. The result path must never be observed
|
||||
// during result publication, even when it would block indefinitely.
|
||||
session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap()));
|
||||
let arguments = BTreeMap::from([
|
||||
("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()),
|
||||
)
|
||||
.await
|
||||
.expect("post-search link observations must share the operation deadline");
|
||||
assert_deadline(result, "checkout result deadline");
|
||||
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1);
|
||||
.expect("completed search must return without acquiring its entries");
|
||||
let output = result.unwrap_or_else(|_| panic!("successful search must not observe each entry"));
|
||||
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);
|
||||
|
||||
// A successful retry both checks cancellation cleanup and proves that the
|
||||
// delegated search produces the typed path consumed by post-processing.
|
||||
// A second explicit search produces the same typed coordinate, without
|
||||
// acquiring it even when it could resolve successfully.
|
||||
session.stall_observation(None);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
@@ -533,6 +540,230 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
||||
Err(_) => panic!("grep must succeed once the link observation is unstalled"),
|
||||
};
|
||||
let value = wip_to_json(&output.value).unwrap();
|
||||
assert_eq!(value["items"], json!([{"path": format!("{root}/a.txt")}]));
|
||||
assert_eq!(value["items"], json!([{"entry": format!("{root}/a.txt")}]));
|
||||
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);
|
||||
}
|
||||
|
||||
/// Deterministic adapter fixture for the production post-success publication
|
||||
/// boundary. Execute through the real provider/Tools, then detach after the
|
||||
/// routed operation guard has finished, before publishing the committed result.
|
||||
/// No race timing or production-only test hook is needed.
|
||||
struct DetachBeforePublication {
|
||||
file: FileHandler,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for DetachBeforePublication {
|
||||
async fn call(
|
||||
&self,
|
||||
operation: &str,
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let tool = match operation {
|
||||
"write" => "Write",
|
||||
"edit" => "Edit",
|
||||
"create_file" => "Create",
|
||||
_ => panic!("mutation fixture only"),
|
||||
};
|
||||
let result = tools::execute_checkout_tool(
|
||||
self.file.provider.router.clone(),
|
||||
self.file.provider.tracker.clone(),
|
||||
&self.file.alias,
|
||||
self.file.generation,
|
||||
self.file.target.clone(),
|
||||
self.file.observation.validator.clone(),
|
||||
tool,
|
||||
wip_to_json(&Value::Record(arguments.clone())).unwrap(),
|
||||
context.execution,
|
||||
)
|
||||
.await
|
||||
.map_err(map_tool_error)?;
|
||||
self.file
|
||||
.provider
|
||||
.router
|
||||
.detach(&WorkdirAttachmentAlias::new(&self.file.alias).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
self.file.publish_result(tool, result)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_checkout_mutation_losing_connection_is_unknown_not_rejected_or_replayed() {
|
||||
use crate::wip::{WipAuditOutcome, WipRuntime};
|
||||
for (operation, arguments, target, result_path, expected) in [
|
||||
(
|
||||
"write",
|
||||
json!({"content":"saved\n"}),
|
||||
"a.txt",
|
||||
"a.txt",
|
||||
"saved\n",
|
||||
),
|
||||
(
|
||||
"edit",
|
||||
json!({"old_string":"old", "new_string":"changed"}),
|
||||
"a.txt",
|
||||
"a.txt",
|
||||
"changed\n",
|
||||
),
|
||||
(
|
||||
"create_file",
|
||||
json!({"path":"new.txt", "content":"created\n"}),
|
||||
"dir",
|
||||
"dir/new.txt",
|
||||
"created\n",
|
||||
),
|
||||
] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("dir")).unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "old\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
HookedSession::new(&dir),
|
||||
)
|
||||
.unwrap();
|
||||
let p = provider(router.clone());
|
||||
if operation != "create_file" {
|
||||
p.node("/checkouts/main/a.txt")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.handler
|
||||
.call("read", &BTreeMap::new(), context())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("prior Read failed"));
|
||||
}
|
||||
let selected = router.resolve(Some("main")).unwrap();
|
||||
let target = WorkdirPath::new(target).unwrap();
|
||||
let observation = selected
|
||||
.session
|
||||
.checkout_observe(target.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let file = FileHandler {
|
||||
provider: p.clone(),
|
||||
alias: "main".into(),
|
||||
generation: selected.generation,
|
||||
workdir: selected.session.workdir().id().as_str().into(),
|
||||
target,
|
||||
observation,
|
||||
};
|
||||
drop(selected);
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let route = object_path("main", &file.target);
|
||||
let mut projection = p.node(&route).await.unwrap().unwrap();
|
||||
let interface = projection.interface.clone();
|
||||
projection.handler = Arc::new(DetachBeforePublication {
|
||||
file,
|
||||
calls: calls.clone(),
|
||||
});
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry
|
||||
.allocate_namespace("checkout", "checkouts")
|
||||
.unwrap();
|
||||
// Static observation metadata isolates the response boundary; real
|
||||
// execution still uses checked provider authority and prior Read.
|
||||
registry
|
||||
.mount(p.node("/checkouts/main").await.unwrap().unwrap())
|
||||
.unwrap();
|
||||
registry.mount(projection).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "post-commit-test".into()).unwrap();
|
||||
let error = runtime
|
||||
.invoke(
|
||||
route,
|
||||
interface,
|
||||
operation.into(),
|
||||
arguments,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("outcome unknown"),
|
||||
"{operation}: {error}"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.audit().last().unwrap().outcome,
|
||||
WipAuditOutcome::OutcomeUnknown
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(result_path)).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"completed mutation must never replay"
|
||||
);
|
||||
assert_eq!(runtime.metrics().operation_round_trips, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readonly_checkout_result_losing_connection_keeps_validator_mismatch() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let alias = WorkdirAttachmentAlias::new("main").unwrap();
|
||||
router
|
||||
.attach(alias.clone(), HookedSession::new(&dir))
|
||||
.unwrap();
|
||||
let selected = router.resolve(Some("main")).unwrap();
|
||||
let file = FileHandler {
|
||||
provider: provider(router.clone()),
|
||||
alias: "main".into(),
|
||||
generation: selected.generation,
|
||||
workdir: selected.session.workdir().id().as_str().into(),
|
||||
target: WorkdirPath::root(),
|
||||
observation: selected
|
||||
.session
|
||||
.checkout_observe(WorkdirPath::root())
|
||||
.await
|
||||
.unwrap(),
|
||||
};
|
||||
drop(selected);
|
||||
router.detach(&alias).await.unwrap();
|
||||
for tool in ["Read", "List", "Glob", "Grep"] {
|
||||
let result = tools::CheckoutToolOutput {
|
||||
output: agen::tool::ToolOutput {
|
||||
summary: "finished".into(),
|
||||
content: None,
|
||||
attachments: vec![],
|
||||
},
|
||||
paths: vec![],
|
||||
listing: None,
|
||||
validator: None,
|
||||
};
|
||||
assert_validator_mismatch(file.publish_result(tool, result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ async fn checkout_wip_search_preserves_root_and_nested_ignore_rules_and_links()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["path"].as_str().unwrap().to_string())
|
||||
.map(|item| item["entry"].as_str().unwrap().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
// Grep retains traversal order; Glob sorts its result paths.
|
||||
paths.sort();
|
||||
@@ -159,8 +159,8 @@ async fn checkout_wip_native_roundtrip_search_read_edit_write_create() {
|
||||
.await
|
||||
.unwrap();
|
||||
let grep_result: Json = serde_json::from_str(grep.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(grep_result["items"][0]["path"], file);
|
||||
let followed = grep_result["items"][0]["path"].as_str().unwrap();
|
||||
assert_eq!(grep_result["items"][0]["entry"], file);
|
||||
let followed = grep_result["items"][0]["entry"].as_str().unwrap();
|
||||
assert_eq!(followed, file);
|
||||
assert!(
|
||||
call(
|
||||
@@ -588,8 +588,9 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||
.observe_live("/checkouts/child", 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed.children.unwrap().len(), 1);
|
||||
assert!(observed.children.unwrap().is_empty());
|
||||
for (op, args) in [
|
||||
("list", json!({"limit":1})),
|
||||
("glob", json!({"pattern":"**/*"})),
|
||||
(
|
||||
"grep",
|
||||
@@ -654,3 +655,339 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||
"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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1628,6 +1628,15 @@ where
|
||||
);
|
||||
}
|
||||
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
|
||||
let drive_feature = crate::feature::builtin::drive::DriveFeature::configured(
|
||||
worker.workspace_client_handle(),
|
||||
feature_config.drive.enabled,
|
||||
worker.workdir_sessions(),
|
||||
)
|
||||
.map(|feature| feature.with_permissions(worker.manifest().permissions.clone()));
|
||||
if let Some(module) = &drive_feature {
|
||||
feature_registry.add_module(module.clone());
|
||||
}
|
||||
let workspace_config_feature =
|
||||
crate::feature::builtin::workspace_config::WorkspaceConfigFeature::configured(
|
||||
worker.workspace_client_handle(),
|
||||
@@ -1728,6 +1737,15 @@ where
|
||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||
let wip_permissions = worker.manifest().permissions.clone();
|
||||
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
|
||||
if wip_mode && let Some(feature) = &drive_feature {
|
||||
crate::feature::builtin::drive::wip::mount_drive_wip(&mut wip_mount_registry, feature)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Drive WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if let Some(feature) = &workspace_config_feature {
|
||||
crate::feature::builtin::workspace_config::wip::mount_workspace_config_wip(
|
||||
&mut wip_mount_registry,
|
||||
@@ -3966,6 +3984,149 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DriveWorkspaceHost {
|
||||
available: bool,
|
||||
workspace: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl crate::worker::WorkspaceClient for DriveWorkspaceHost {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.workspace
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
self.available
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: crate::worker::WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
|
||||
{
|
||||
// Native discovery must not enumerate or perform any mutation.
|
||||
assert_eq!(request.path, "/api/w/workspace-1/drive/root");
|
||||
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
|
||||
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
|
||||
"parent": null, "name": "", "kind": "folder", "revision": "1",
|
||||
"size": null, "content_type": null, "updated_by": "test",
|
||||
"updated_at": "2026-10-08T00:00:00Z",
|
||||
"latest_url": "/api/w/workspace-1/drive/download?entry_workspace_id=workspace-1&id=1"
|
||||
}).to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_controller_wiring_requires_activation_and_workspace_host_in_both_modes() {
|
||||
use crate::feature::FeatureModule as _;
|
||||
for mode in [manifest::WorkerMode::Tools, manifest::WorkerMode::Wip] {
|
||||
for (enabled, available, workspace) in [
|
||||
(false, true, Some("workspace-1")),
|
||||
(true, false, Some("workspace-1")),
|
||||
(true, true, None),
|
||||
(true, true, Some("workspace-1")),
|
||||
] {
|
||||
let root = TempDir::new().unwrap();
|
||||
let mut manifest = manifest::WorkerManifest::from_toml(
|
||||
"[worker]\nname = 'drive-wiring'\n[model]\nscheme = 'anthropic'\nmodel_id = 'test'\n[engine]\n[scope]\n"
|
||||
).unwrap();
|
||||
manifest.worker.mode = mode;
|
||||
manifest.feature.drive.enabled = enabled;
|
||||
// Isolate Drive discovery from the independent Workspace catalog.
|
||||
manifest.feature.workdir_catalog.enabled = false;
|
||||
let client = ScriptedClient::new(vec![finished_response()]);
|
||||
let requests = client.requests.clone();
|
||||
let engine = Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client);
|
||||
let store = session_store::CombinedStore::new(
|
||||
session_store::FsStore::new(root.path().join("sessions")).unwrap(),
|
||||
session_store::FsWorkerStore::new(root.path().join("workers")).unwrap(),
|
||||
);
|
||||
let host: Arc<dyn crate::worker::WorkspaceClient> =
|
||||
Arc::new(DriveWorkspaceHost {
|
||||
available,
|
||||
workspace,
|
||||
});
|
||||
let context = crate::worker::WorkerWorkspaceContext::with_client(
|
||||
workspace.map(|id| crate::worker::WorkspaceId::new(id).unwrap()),
|
||||
host.clone(),
|
||||
);
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
engine,
|
||||
store,
|
||||
context,
|
||||
crate::worker::WorkerFilesystemAuthority::local(
|
||||
root.path().into(),
|
||||
root.path().into(),
|
||||
),
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.set_system_prompt_template(
|
||||
crate::SystemPromptTemplate::parse(
|
||||
"default",
|
||||
crate::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let descriptor = crate::feature::builtin::drive::DriveFeature::new(
|
||||
host,
|
||||
worker.workdir_sessions(),
|
||||
)
|
||||
.descriptor();
|
||||
register_worker_tools(
|
||||
&mut worker,
|
||||
root.path().join("bash-output"),
|
||||
root.path().join("runtime"),
|
||||
SpawnedWorkerRegistry::new_for_internal_services(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let configured = enabled && available && workspace.is_some();
|
||||
let tools = worker.engine().tool_server_handle();
|
||||
tools.flush_pending();
|
||||
for tool in &descriptor.tools {
|
||||
assert_eq!(
|
||||
tools.get_tool(&tool.name).is_some(),
|
||||
configured && mode == manifest::WorkerMode::Tools,
|
||||
"{mode:?}, enabled={enabled}, available={available}, workspace={workspace:?}, tool={}",
|
||||
tool.name
|
||||
);
|
||||
}
|
||||
if mode == manifest::WorkerMode::Wip {
|
||||
let (_, tree) = tools.get_tool("Tree").unwrap();
|
||||
let output = tree
|
||||
.execute(
|
||||
r#"{"path":"/drive","depth":1}"#,
|
||||
ToolExecutionContext::new("drive-wiring-tree", "batch", 0),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(output.is_ok(), configured, "{output:?}");
|
||||
if let Ok(output) = output {
|
||||
let tree: serde_json::Value =
|
||||
serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
assert_eq!(tree["path"], "/drive");
|
||||
// A self-scoped entrance is an enumeration boundary,
|
||||
// not an observed empty Workspace hierarchy.
|
||||
assert_eq!(tree["children"], serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
worker.run_text("Inspect enabled features").await.unwrap();
|
||||
let requests = requests.lock().unwrap();
|
||||
let prompt = requests[0].system_prompt.as_deref().unwrap_or("");
|
||||
assert_eq!(
|
||||
prompt.contains("## Workspace Drive"),
|
||||
configured,
|
||||
"{mode:?}, enabled={enabled}, available={available}, workspace={workspace:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LargeOutputTool;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod backend_job_result;
|
||||
pub mod chat_invocation;
|
||||
pub mod drive;
|
||||
pub mod flow_transition;
|
||||
pub mod manage_workdir;
|
||||
pub mod manage_worker;
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
//! Worker Drive operations use only the injected Workspace API. Observations
|
||||
//! below are bounded preconditions, never a hierarchy replica or a grant cache.
|
||||
pub mod backend;
|
||||
pub(crate) mod delegation;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod wip;
|
||||
|
||||
use crate::{
|
||||
feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
ToolContribution, ToolDeclaration,
|
||||
},
|
||||
worker::WorkspaceClient,
|
||||
};
|
||||
use agen::tool::{
|
||||
Attachment, ImageAttachment, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta,
|
||||
ToolOutput,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use backend::{DriveBackend, DriveError};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use serde_json::{Value, json};
|
||||
use server_api::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use workdir::{WorkdirPath, WorkdirSessionCapability, WorkdirSessionRouter};
|
||||
|
||||
pub const FEATURE_ID: &str = "drive";
|
||||
const MAX_OBSERVATIONS: usize = 256;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Observations {
|
||||
entries: HashMap<String, DriveEntry>,
|
||||
order: VecDeque<String>,
|
||||
}
|
||||
impl Observations {
|
||||
fn insert(&mut self, entry: DriveEntry) {
|
||||
let key = entry.entry.node_id.clone();
|
||||
self.order.retain(|old| old != &key);
|
||||
self.order.push_back(key.clone());
|
||||
self.entries.insert(key, entry);
|
||||
while self.order.len() > MAX_OBSERVATIONS {
|
||||
if let Some(old) = self.order.pop_front() {
|
||||
self.entries.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn remove(&mut self, key: &str) {
|
||||
self.entries.remove(key);
|
||||
self.order.retain(|old| old != key);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DriveFeature {
|
||||
pub(super) backend: DriveBackend,
|
||||
pub(super) router: Arc<WorkdirSessionRouter>,
|
||||
observations: Arc<Mutex<Observations>>,
|
||||
nonce: Arc<str>,
|
||||
pub(super) permissions: Option<manifest::ToolPermissionConfig>,
|
||||
}
|
||||
impl DriveFeature {
|
||||
pub fn configured(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
enabled: bool,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
) -> Option<Self> {
|
||||
(enabled
|
||||
&& client.is_available()
|
||||
&& client
|
||||
.workspace_id()
|
||||
.is_some_and(|id| !id.is_empty() && !id.chars().any(char::is_control)))
|
||||
.then(|| Self::new(client, router))
|
||||
}
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>, router: Arc<WorkdirSessionRouter>) -> Self {
|
||||
Self {
|
||||
backend: DriveBackend::new(client),
|
||||
router,
|
||||
observations: Arc::new(Mutex::new(Observations::default())),
|
||||
nonce: uuid::Uuid::now_v7().to_string().into(),
|
||||
permissions: None,
|
||||
}
|
||||
}
|
||||
pub fn with_permissions(mut self, permissions: Option<manifest::ToolPermissionConfig>) -> Self {
|
||||
self.permissions = permissions;
|
||||
self
|
||||
}
|
||||
pub fn tools(&self) -> Vec<(&'static str, ToolDefinition)> {
|
||||
SPECS
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
let feature = self.clone();
|
||||
let spec = *spec;
|
||||
let definition: ToolDefinition = Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(spec.name)
|
||||
.description(spec.description)
|
||||
.input_schema((spec.schema)()),
|
||||
Arc::new(DriveTool {
|
||||
feature: feature.clone(),
|
||||
name: spec.name,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
(spec.name, definition)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn observe(&self, entry: DriveEntry) -> Result<(), DriveError> {
|
||||
self.backend.validate_entry(&entry)?;
|
||||
self.observations
|
||||
.lock()
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
.insert(entry);
|
||||
Ok(())
|
||||
}
|
||||
fn observed(
|
||||
&self,
|
||||
id: &DriveEntryRef,
|
||||
pinned: Option<&DriveEntry>,
|
||||
) -> Result<DriveEntry, DriveError> {
|
||||
self.backend.validate_ref(id)?;
|
||||
if let Some(pinned) = pinned {
|
||||
if pinned.entry != *id {
|
||||
return Err(DriveError::Denied);
|
||||
}
|
||||
return Ok(pinned.clone());
|
||||
}
|
||||
self.observations.lock().map_err(|_| DriveError::Unavailable)?.entries.get(&id.node_id).cloned().ok_or_else(|| DriveError::Invalid("read or inspect metadata for this entry before mutation; observations are bounded and not persisted authority".into()))
|
||||
}
|
||||
fn request_id(&self, context: &ToolExecutionContext) -> String {
|
||||
digest(format!("{}:{}", self.nonce, context.execution_id()).as_bytes())
|
||||
}
|
||||
pub(super) async fn execute(
|
||||
&self,
|
||||
name: &str,
|
||||
input: &str,
|
||||
context: &ToolExecutionContext,
|
||||
pinned: Option<&DriveEntry>,
|
||||
) -> Result<DriveOutput, DriveError> {
|
||||
self.backend.workspace()?;
|
||||
let request_id = self.request_id(context);
|
||||
let mut attachments = Vec::new();
|
||||
let mut mutated_id = None;
|
||||
let value = match name {
|
||||
"DriveRoot" => {
|
||||
let _: EmptyArgs = decode(input)?;
|
||||
let entry = self.backend.root().await?;
|
||||
self.observe(entry.clone())?;
|
||||
entry_value(entry)
|
||||
}
|
||||
"DriveMetadata" => {
|
||||
let args: EntryArgs = decode(input)?;
|
||||
let entry = self.backend.metadata(&args.entry).await?;
|
||||
self.observe(entry.clone())?;
|
||||
entry_value(entry)
|
||||
}
|
||||
"DriveList" => {
|
||||
let args: ListArgs = decode(input)?;
|
||||
if args
|
||||
.limit
|
||||
.is_some_and(|n| n == 0 || n > DRIVE_PAGE_MAX_LIMIT)
|
||||
{
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let parent = match args.parent {
|
||||
Some(parent) => parent,
|
||||
None => self.backend.root().await?.entry,
|
||||
};
|
||||
let response = self.backend.list(&parent, args.limit, args.after).await?;
|
||||
page_value(response)
|
||||
}
|
||||
"DriveSearch" => {
|
||||
let args: DriveSearchQuery = decode(input)?;
|
||||
if args.query.len() > 512 || args.after.as_ref().is_some_and(|s| s.len() > 2048) {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
page_value(self.backend.search(args).await?)
|
||||
}
|
||||
"DriveRead" => {
|
||||
let args: ReadArgs = decode(input)?;
|
||||
if args.max_bytes == 0 || args.max_bytes > DRIVE_CHUNK_MAX_BYTES {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let read = self.backend.read(&args.entry, args.max_bytes).await?;
|
||||
if pinned.is_some_and(|entry| entry.revision != read.entry.revision) {
|
||||
return Err(DriveError::Conflict);
|
||||
}
|
||||
self.observe(read.entry.clone())?;
|
||||
json!({"entry": entry_value(read.entry), "text": read.text, "truncated":read.truncated})
|
||||
}
|
||||
"DriveViewImage" => {
|
||||
let args: EntryArgs = decode(input)?;
|
||||
let entry = if let Some(entry) = pinned {
|
||||
self.observed(&args.entry, Some(entry))?
|
||||
} else {
|
||||
self.backend.metadata(&args.entry).await?
|
||||
};
|
||||
let bytes = self
|
||||
.backend
|
||||
.bytes(&entry, tools::view_image::MAX_IMAGE_BYTES)
|
||||
.await?;
|
||||
let media = tools::view_image::detect_image_mime(&bytes).ok_or_else(|| {
|
||||
DriveError::Invalid("supported image bytes required: PNG/JPEG/GIF/WebP".into())
|
||||
})?;
|
||||
let size = bytes.len();
|
||||
attachments.push(Attachment::Image(ImageAttachment::new(
|
||||
media,
|
||||
Arc::<[u8]>::from(bytes),
|
||||
)));
|
||||
self.observe(entry.clone())?;
|
||||
json!({"entry":entry_value(entry), "attached":true, "mime_type":media, "bytes":size})
|
||||
}
|
||||
"DriveCreateFolder" => {
|
||||
let args: FolderArgs = decode(input)?;
|
||||
self.backend.validate_ref(&args.parent)?;
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::CreateFolder {
|
||||
parent: args.parent,
|
||||
name: args.name,
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveCreateText" => {
|
||||
let args: CreateArgs = decode(input)?;
|
||||
self.backend.validate_ref(&args.parent)?;
|
||||
let text = checked_text(args.content)?;
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::CreateText {
|
||||
parent: args.parent,
|
||||
name: args.name,
|
||||
text,
|
||||
content_type: args.content_type,
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveWrite" => {
|
||||
let args: WriteArgs = decode(input)?;
|
||||
let entry = self.observed(&args.entry, pinned)?;
|
||||
let text = checked_text(args.content)?;
|
||||
mutated_id = Some(entry.entry.node_id.clone());
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::UpdateText {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
text,
|
||||
content_type: entry.content_type.unwrap_or_else(default_media),
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveEdit" => {
|
||||
let args: EditArgs = decode(input)?;
|
||||
let entry = self.observed(&args.entry, pinned)?;
|
||||
let edit = fs_operation::text::EditArgs {
|
||||
old_string: args.old_string,
|
||||
new_string: args.new_string,
|
||||
replace_all: args.replace_all,
|
||||
};
|
||||
edit.validate(text_limits()).map_err(text_error)?;
|
||||
let read = self
|
||||
.backend
|
||||
.read(&entry.entry, DRIVE_TEXT_MAX_BYTES as u32)
|
||||
.await?;
|
||||
if read.entry.revision != entry.revision {
|
||||
return Err(DriveError::Conflict);
|
||||
}
|
||||
if read.truncated {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let text = fs_operation::text::edit(&read.text, &edit, text_limits())
|
||||
.map_err(text_error)?
|
||||
.content;
|
||||
mutated_id = Some(entry.entry.node_id.clone());
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::UpdateText {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
text,
|
||||
content_type: entry.content_type.unwrap_or_else(default_media),
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveRelocate" => {
|
||||
let args: RelocateArgs = decode(input)?;
|
||||
self.backend.validate_ref(&args.parent)?;
|
||||
let entry = self.observed(&args.entry, pinned)?;
|
||||
mutated_id = Some(entry.entry.node_id.clone());
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::Relocate {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
parent: args.parent,
|
||||
name: args.name,
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveDelete" => {
|
||||
let args: EntryArgs = decode(input)?;
|
||||
let entry = self.observed(&args.entry, pinned)?;
|
||||
mutated_id = Some(entry.entry.node_id.clone());
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::Delete {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveSaveWorkdir" => {
|
||||
let args: SaveArgs = decode(input)?;
|
||||
self.backend.validate_ref(&args.parent)?;
|
||||
// Drive API rechecks write at publication; source uses the selected
|
||||
// Workdir read broker, with no OS-path or command fallback.
|
||||
let selected = self
|
||||
.router
|
||||
.resolve(Some(&args.target_workdir))
|
||||
.map_err(|_| DriveError::Denied)?;
|
||||
if !selected
|
||||
.session
|
||||
.capabilities()
|
||||
.supports(WorkdirSessionCapability::Read)
|
||||
{
|
||||
return Err(DriveError::Denied);
|
||||
}
|
||||
if args.path.starts_with('/')
|
||||
|| args.path.contains('\\')
|
||||
|| args.path.split('/').any(|part| part == "..")
|
||||
{
|
||||
return Err(DriveError::Invalid(
|
||||
"logical Workdir-relative path required".into(),
|
||||
));
|
||||
}
|
||||
let path = WorkdirPath::new(&args.path).map_err(|_| {
|
||||
DriveError::Invalid("logical Workdir-relative path required".into())
|
||||
})?;
|
||||
let bytes = tools::view_image::read_workdir_bytes(
|
||||
selected.session.as_ref(),
|
||||
path,
|
||||
DRIVE_FILE_MAX_BYTES as usize,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
DriveError::Invalid(
|
||||
"Workdir source denied, changed, oversized or unavailable".into(),
|
||||
)
|
||||
})?;
|
||||
let query = DriveUploadQuery {
|
||||
operation: DriveUploadOperation::Create,
|
||||
request_id,
|
||||
entry_workspace_id: args.parent.workspace_id,
|
||||
parent_id: Some(args.parent.node_id),
|
||||
name: Some(args.name),
|
||||
id: None,
|
||||
expected_revision: None,
|
||||
content_type: args.content_type,
|
||||
size: bytes.len() as u32,
|
||||
sha256: digest(&bytes),
|
||||
};
|
||||
mutation_value(self.backend.upload(query, bytes).await?)
|
||||
}
|
||||
"DriveRequestStatus" => {
|
||||
let args: StatusArgs = decode(input)?;
|
||||
serde_json::to_value(self.backend.status(&args.request_id).await?)
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
}
|
||||
_ => return Err(DriveError::Invalid("unknown operation".into())),
|
||||
};
|
||||
// A successful mutation does not silently substitute its new revision
|
||||
// into a prior observation. The caller observes explicitly next time.
|
||||
if let Some(id) = mutated_id {
|
||||
self.observations
|
||||
.lock()
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
.remove(&id);
|
||||
}
|
||||
Ok(DriveOutput { value, attachments })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct DriveOutput {
|
||||
pub value: Value,
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
impl DriveOutput {
|
||||
fn tool(self, name: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput {
|
||||
summary: format!("{name} completed through Workspace Drive API"),
|
||||
content: Some(
|
||||
serde_json::to_string(&self.value)
|
||||
.map_err(|_| ToolError::Internal("Drive result encoding failed".into()))?,
|
||||
),
|
||||
attachments: self.attachments,
|
||||
})
|
||||
}
|
||||
}
|
||||
struct DriveTool {
|
||||
feature: DriveFeature,
|
||||
name: &'static str,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Tool for DriveTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input: &str,
|
||||
context: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.feature
|
||||
.execute(self.name, input, &context, None)
|
||||
.await
|
||||
.map_err(tool_error)?
|
||||
.tool(self.name)
|
||||
}
|
||||
}
|
||||
fn tool_error(error: DriveError) -> ToolError {
|
||||
match error {
|
||||
DriveError::Conflict => ToolError::StructuredConflict {
|
||||
code: "drive_conflict".into(),
|
||||
message: error.to_string(),
|
||||
},
|
||||
DriveError::Invalid(_) | DriveError::Limit => ToolError::InvalidArgument(error.to_string()),
|
||||
_ => ToolError::ExecutionFailed(error.to_string()),
|
||||
}
|
||||
}
|
||||
impl FeatureModule for DriveFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID,"Workspace Drive").with_description("Explicit Backend-authorized Drive documents, images and artifact publication; no Workdir required").with_instruction(drive_instruction());
|
||||
for spec in SPECS {
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(spec.name, spec.description));
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context
|
||||
.instructions()
|
||||
.register(crate::feature::FeatureInstructionContribution::new(
|
||||
drive_instruction(),
|
||||
))?;
|
||||
for (name, definition) in self.tools() {
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn drive_instruction() -> crate::feature::FeatureInstructionDeclaration {
|
||||
crate::feature::FeatureInstructionDeclaration::new(
|
||||
crate::feature::FeatureInstructionId::builtin("drive-workflow"),
|
||||
"common.drive",
|
||||
"Authorized Drive operation guidance",
|
||||
)
|
||||
.expect("static Drive instruction")
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EmptyArgs {}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EntryArgs {
|
||||
entry: DriveEntryRef,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListArgs {
|
||||
#[serde(default)]
|
||||
parent: Option<DriveEntryRef>,
|
||||
#[serde(default)]
|
||||
#[schemars(range(min = 1, max = 200))]
|
||||
limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
after: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ReadArgs {
|
||||
entry: DriveEntryRef,
|
||||
#[serde(default = "default_read")]
|
||||
#[schemars(range(min = 1, max = 65536))]
|
||||
max_bytes: u32,
|
||||
}
|
||||
fn default_read() -> u32 {
|
||||
DRIVE_CHUNK_MAX_BYTES
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FolderArgs {
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateArgs {
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
content: String,
|
||||
#[serde(default = "default_media")]
|
||||
content_type: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WriteArgs {
|
||||
entry: DriveEntryRef,
|
||||
content: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EditArgs {
|
||||
entry: DriveEntryRef,
|
||||
old_string: String,
|
||||
new_string: String,
|
||||
#[serde(default)]
|
||||
replace_all: bool,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RelocateArgs {
|
||||
entry: DriveEntryRef,
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SaveArgs {
|
||||
target_workdir: String,
|
||||
path: String,
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
#[serde(default = "default_binary_media")]
|
||||
content_type: String,
|
||||
}
|
||||
fn default_binary_media() -> String {
|
||||
"application/octet-stream".into()
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StatusArgs {
|
||||
request_id: String,
|
||||
}
|
||||
fn default_media() -> String {
|
||||
"text/markdown".into()
|
||||
}
|
||||
fn decode<T: DeserializeOwned>(input: &str) -> Result<T, DriveError> {
|
||||
if input.len() > 512 * 1024 {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
serde_json::from_str(input)
|
||||
.map_err(|_| DriveError::Invalid("input shape or bound rejected".into()))
|
||||
}
|
||||
fn text_limits() -> fs_operation::text::TextLimits {
|
||||
fs_operation::text::TextLimits {
|
||||
max_input_bytes: Some(DRIVE_TEXT_MAX_BYTES),
|
||||
max_output_bytes: Some(DRIVE_TEXT_MAX_BYTES),
|
||||
max_replacements: None,
|
||||
}
|
||||
}
|
||||
fn checked_text(text: String) -> Result<String, DriveError> {
|
||||
fs_operation::text::write(text, text_limits())
|
||||
.map(|out| out.content)
|
||||
.map_err(text_error)
|
||||
}
|
||||
fn text_error(error: fs_operation::text::TextError) -> DriveError {
|
||||
DriveError::Invalid(error.to_string())
|
||||
}
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn entry_path(entry: &DriveEntryRef) -> String {
|
||||
format!(
|
||||
"/drive/{}/{}",
|
||||
crate::feature::builtin::manage_workdir::wip::encode_identity(&entry.workspace_id),
|
||||
entry.node_id
|
||||
)
|
||||
}
|
||||
fn entry_value(entry: DriveEntry) -> Value {
|
||||
json!({"path":entry_path(&entry.entry),"metadata":entry})
|
||||
}
|
||||
fn page_value(page: DriveListResponse) -> Value {
|
||||
json!({"entries":page.entries.into_iter().map(entry_value).collect::<Vec<_>>(),"next_after":page.next_after})
|
||||
}
|
||||
fn mutation_value(response: DriveMutationResponse) -> Value {
|
||||
json!({"request_id":response.request_id,"entry":response.entry.map(entry_value)})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Spec {
|
||||
pub name: &'static str,
|
||||
pub operation: &'static str,
|
||||
pub description: &'static str,
|
||||
pub schema: fn() -> Value,
|
||||
}
|
||||
fn schema<T: JsonSchema>() -> Value {
|
||||
serde_json::to_value(schemars::schema_for!(T)).expect("static Drive argument schema")
|
||||
}
|
||||
pub(super) const SPECS: &[Spec] = &[
|
||||
Spec {
|
||||
name: "DriveRoot",
|
||||
operation: "root",
|
||||
description: "Discover the authorized Workspace Drive root. Profile activation is not a grant; no Workdir is required.",
|
||||
schema: schema::<EmptyArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveMetadata",
|
||||
operation: "metadata",
|
||||
description: "Inspect one Workspace-bound stable entry ID and observe its revision for mutation.",
|
||||
schema: schema::<EntryArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveList",
|
||||
operation: "list",
|
||||
description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or observe revisions for writes.",
|
||||
schema: schema::<ListArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveSearch",
|
||||
operation: "search",
|
||||
description: "Search names and optionally bounded text through Backend paged search; no embedding/index. Use the returned cursor to continue.",
|
||||
schema: schema::<DriveSearchQuery>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveRead",
|
||||
operation: "read",
|
||||
description: "Read up to 64 KiB UTF-8 text and observe entry revision. Past Session text remains an immutable observation.",
|
||||
schema: schema::<ReadArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveCreateFolder",
|
||||
operation: "create_folder",
|
||||
description: "Create a folder through the explicit Drive write grant. Same-name conflicts are not overwritten.",
|
||||
schema: schema::<FolderArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveCreateText",
|
||||
operation: "create_text",
|
||||
description: "Create bounded UTF-8 text (default Markdown), <=64 KiB. Success means DB publication committed.",
|
||||
schema: schema::<CreateArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveWrite",
|
||||
operation: "write",
|
||||
description: "Replace <=64 KiB text using a previously read/metadata-observed revision. Conflicts are not retried with latest revision.",
|
||||
schema: schema::<WriteArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveEdit",
|
||||
operation: "edit",
|
||||
description: "Partial text edit using previously observed revision and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.",
|
||||
schema: schema::<EditArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveRelocate",
|
||||
operation: "relocate",
|
||||
description: "Rename and/or move an observed entry with revision precondition; stable ID and URL remain unchanged.",
|
||||
schema: schema::<RelocateArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveDelete",
|
||||
operation: "delete",
|
||||
description: "Delete one observed entry with revision precondition. A recreated name has a different ID; stale observations cannot target it.",
|
||||
schema: schema::<EntryArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveViewImage",
|
||||
operation: "view_image",
|
||||
description: "Attach revision-fixed PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.",
|
||||
schema: schema::<EntryArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveSaveWorkdir",
|
||||
operation: "save_workdir",
|
||||
description: "Publish a <=16 MiB artifact using selected Workdir alias and logical path plus Drive parent/name. Both source read and destination write are required. No Bash/raw host path/base64 input.",
|
||||
schema: schema::<SaveArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveRequestStatus",
|
||||
operation: "request_status",
|
||||
description: "Query the same mutation request ID after OutcomeUnknown. Uncommitted snapshot is not proof of failure; never blindly recreate the request.",
|
||||
schema: schema::<StatusArgs>,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,470 @@
|
||||
//! Identity-bound Drive API adapter. The Backend owns grants, hierarchy, CAS and
|
||||
//! receipts. In particular, a lost mutation response is queried, never resent.
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use crate::worker::{
|
||||
WorkspaceBinaryRequest, WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use server_api::*;
|
||||
|
||||
pub(super) const DEADLINE: Duration = Duration::from_secs(30);
|
||||
const RESPONSE_LIMIT: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DriveBackend {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum DriveError {
|
||||
#[error("Drive access denied")]
|
||||
Denied,
|
||||
#[error("Drive resource not found")]
|
||||
NotFound,
|
||||
#[error("Drive revision or name conflict; observe explicitly before another operation")]
|
||||
Conflict,
|
||||
#[error("Invalid Drive arguments: {0}")]
|
||||
Invalid(String),
|
||||
#[error("Drive request exceeds a limit")]
|
||||
Limit,
|
||||
#[error("Drive storage or transport unavailable")]
|
||||
Unavailable,
|
||||
#[error(
|
||||
"Drive mutation outcome unknown; request_id={0}; query request_status; do not blindly repeat"
|
||||
)]
|
||||
OutcomeUnknown(String),
|
||||
}
|
||||
|
||||
impl DriveBackend {
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
pub fn workspace(&self) -> Result<&str, DriveError> {
|
||||
self.client
|
||||
.workspace_id()
|
||||
.filter(|id| !id.is_empty() && self.client.is_available())
|
||||
.ok_or(DriveError::Denied)
|
||||
}
|
||||
fn base(&self) -> Result<String, DriveError> {
|
||||
Ok(format!("/api/w/{}/drive", encode(self.workspace()?)))
|
||||
}
|
||||
pub(super) fn validate_entry(&self, entry: &DriveEntry) -> Result<(), DriveError> {
|
||||
self.validate_ref(&entry.entry)?;
|
||||
if let Some(parent) = &entry.parent {
|
||||
self.validate_ref(parent)?;
|
||||
}
|
||||
if !decimal(&entry.revision) || entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES) {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_ref(&self, entry: &DriveEntryRef) -> Result<(), DriveError> {
|
||||
if entry.workspace_id != self.workspace()? {
|
||||
return Err(DriveError::Denied);
|
||||
}
|
||||
if !decimal(&entry.node_id) {
|
||||
return Err(DriveError::Invalid(
|
||||
"canonical decimal node ID required".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn json<T: DeserializeOwned + Send + 'static>(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
mutation: bool,
|
||||
request_id: &str,
|
||||
) -> Result<T, DriveError> {
|
||||
let client = self.client.clone();
|
||||
let response = tokio::time::timeout(
|
||||
DEADLINE,
|
||||
tokio::task::spawn_blocking(move || client.execute_with_timeout(request, DEADLINE)),
|
||||
)
|
||||
.await;
|
||||
let unknown = || {
|
||||
if mutation {
|
||||
DriveError::OutcomeUnknown(request_id.into())
|
||||
} else {
|
||||
DriveError::Unavailable
|
||||
}
|
||||
};
|
||||
let response = response
|
||||
.map_err(|_| unknown())?
|
||||
.map_err(|_| unknown())?
|
||||
.map_err(|_| unknown())?;
|
||||
if response.body.len() > RESPONSE_LIMIT {
|
||||
return Err(if mutation {
|
||||
unknown()
|
||||
} else {
|
||||
DriveError::Limit
|
||||
});
|
||||
}
|
||||
if !response.is_success() {
|
||||
return Err(classify(
|
||||
response.status,
|
||||
response.body.as_bytes(),
|
||||
mutation,
|
||||
request_id,
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&response.body).map_err(|_| unknown())
|
||||
}
|
||||
async fn get<T: DeserializeOwned + Send + 'static, Q: Serialize>(
|
||||
&self,
|
||||
suffix: &str,
|
||||
query: &Q,
|
||||
) -> Result<T, DriveError> {
|
||||
let query = query_string(query)?;
|
||||
self.json(
|
||||
WorkspaceRequest::get(format!("{}{suffix}{query}", self.base()?)),
|
||||
false,
|
||||
"",
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn root(&self) -> Result<DriveEntry, DriveError> {
|
||||
let entry: DriveEntry = self
|
||||
.json(
|
||||
WorkspaceRequest::get(format!("{}/root", self.base()?)),
|
||||
false,
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
self.validate_entry(&entry)?;
|
||||
if entry.kind != DriveEntryKind::Folder || entry.parent.is_some() {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(entry)
|
||||
}
|
||||
pub async fn metadata(&self, entry: &DriveEntryRef) -> Result<DriveEntry, DriveError> {
|
||||
self.validate_ref(entry)?;
|
||||
let result: DriveEntry = self
|
||||
.get(
|
||||
"/metadata",
|
||||
&DriveEntryQuery {
|
||||
entry_workspace_id: entry.workspace_id.clone(),
|
||||
id: entry.node_id.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.validate_entry(&result)?;
|
||||
if result.entry != *entry {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn list(
|
||||
&self,
|
||||
parent: &DriveEntryRef,
|
||||
limit: Option<u32>,
|
||||
after: Option<String>,
|
||||
) -> Result<DriveListResponse, DriveError> {
|
||||
self.validate_ref(parent)?;
|
||||
let result: DriveListResponse = self
|
||||
.get(
|
||||
"/list",
|
||||
&DriveListQuery {
|
||||
entry_workspace_id: parent.workspace_id.clone(),
|
||||
id: parent.node_id.clone(),
|
||||
limit,
|
||||
after,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.validate_page(
|
||||
&result,
|
||||
limit
|
||||
.unwrap_or(DRIVE_PAGE_MAX_LIMIT)
|
||||
.min(DRIVE_PAGE_MAX_LIMIT),
|
||||
)?;
|
||||
if result
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.parent.as_ref() != Some(parent))
|
||||
{
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn search(&self, query: DriveSearchQuery) -> Result<DriveListResponse, DriveError> {
|
||||
let result: DriveListResponse = self.get("/search", &query).await?;
|
||||
self.validate_page(
|
||||
&result,
|
||||
query
|
||||
.limit
|
||||
.unwrap_or(DRIVE_SEARCH_MAX_LIMIT)
|
||||
.min(DRIVE_SEARCH_MAX_LIMIT),
|
||||
)?;
|
||||
Ok(result)
|
||||
}
|
||||
fn validate_page(&self, page: &DriveListResponse, limit: u32) -> Result<(), DriveError> {
|
||||
if page.entries.len() > limit as usize {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
for entry in &page.entries {
|
||||
self.validate_entry(entry)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn read(
|
||||
&self,
|
||||
entry: &DriveEntryRef,
|
||||
max_bytes: u32,
|
||||
) -> Result<DriveReadTextResponse, DriveError> {
|
||||
self.validate_ref(entry)?;
|
||||
let result: DriveReadTextResponse = self
|
||||
.get(
|
||||
"/read-text",
|
||||
&DriveReadTextQuery {
|
||||
entry_workspace_id: entry.workspace_id.clone(),
|
||||
id: entry.node_id.clone(),
|
||||
max_bytes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.validate_entry(&result.entry)?;
|
||||
if result.entry.entry != *entry || result.text.len() > max_bytes as usize {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn bytes(&self, entry: &DriveEntry, limit: usize) -> Result<Vec<u8>, DriveError> {
|
||||
self.validate_entry(entry)?;
|
||||
let total = entry
|
||||
.size
|
||||
.ok_or_else(|| DriveError::Invalid("file required".into()))?;
|
||||
if total as usize > limit {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(total as usize);
|
||||
// Even an empty file gets a revision-fixed read and current authorization.
|
||||
loop {
|
||||
let offset = bytes.len() as u32;
|
||||
let length = total.saturating_sub(offset).clamp(1, DRIVE_CHUNK_MAX_BYTES);
|
||||
let query = query_string(&DriveReadChunkQuery {
|
||||
entry_workspace_id: entry.entry.workspace_id.clone(),
|
||||
id: entry.entry.node_id.clone(),
|
||||
expected_revision: entry.revision.clone(),
|
||||
offset,
|
||||
length,
|
||||
})?;
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: format!("{}/read-chunk{query}", self.base()?),
|
||||
body: None,
|
||||
max_response_bytes: DRIVE_CHUNK_MAX_BYTES as usize,
|
||||
};
|
||||
let client = self.client.clone();
|
||||
let response = tokio::time::timeout(
|
||||
DEADLINE,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
client.execute_binary_with_timeout(request, DEADLINE)
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
.map_err(|_| DriveError::Unavailable)?;
|
||||
if !(200..300).contains(&response.status) {
|
||||
return Err(classify(response.status, &response.body, false, ""));
|
||||
}
|
||||
let expected = total.saturating_sub(offset).min(length) as usize;
|
||||
if response.body.len() != expected {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
bytes.extend_from_slice(&response.body);
|
||||
if bytes.len() == total as usize {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn reconcile(
|
||||
&self,
|
||||
id: &str,
|
||||
result: Result<DriveMutationResponse, DriveError>,
|
||||
) -> Result<DriveMutationResponse, DriveError> {
|
||||
// A parseable success can still be an invalid completion. Classify it
|
||||
// before reconciliation so it also gets one original-request inquiry.
|
||||
let result = result.and_then(|response| {
|
||||
self.validate_completion(id, &response)
|
||||
.map_err(|_| DriveError::OutcomeUnknown(id.into()))?;
|
||||
Ok(response)
|
||||
});
|
||||
match result {
|
||||
Err(DriveError::OutcomeUnknown(_)) => match self.status(id).await {
|
||||
Ok(DriveRequestStatusResponse {
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(response),
|
||||
..
|
||||
}) => Ok(response), // status validates both IDs and entry metadata
|
||||
_ => Err(DriveError::OutcomeUnknown(id.into())),
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
fn validate_completion(
|
||||
&self,
|
||||
id: &str,
|
||||
response: &DriveMutationResponse,
|
||||
) -> Result<(), DriveError> {
|
||||
if response.request_id != id {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
if let Some(entry) = &response.entry {
|
||||
self.validate_entry(entry)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn mutate(
|
||||
&self,
|
||||
request: DriveMutationRequest,
|
||||
) -> Result<DriveMutationResponse, DriveError> {
|
||||
let body = serde_json::to_string(&request)
|
||||
.map_err(|_| DriveError::Invalid("invalid mutation".into()))?;
|
||||
if body.len() > 512 * 1024 {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let result = self
|
||||
.json(
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{}/mutate", self.base()?),
|
||||
body,
|
||||
),
|
||||
true,
|
||||
&request.request_id,
|
||||
)
|
||||
.await;
|
||||
self.reconcile(&request.request_id, result).await
|
||||
}
|
||||
pub async fn upload(
|
||||
&self,
|
||||
query: DriveUploadQuery,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<DriveMutationResponse, DriveError> {
|
||||
if bytes.len() > DRIVE_FILE_MAX_BYTES as usize {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let path = format!("{}/upload{}", self.base()?, query_string(&query)?);
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Put,
|
||||
path,
|
||||
body: Some(bytes),
|
||||
max_response_bytes: RESPONSE_LIMIT,
|
||||
};
|
||||
let client = self.client.clone();
|
||||
let result = tokio::time::timeout(
|
||||
DEADLINE,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
client.execute_binary_with_timeout(request, DEADLINE)
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let result = match result {
|
||||
Ok(Ok(Ok(response))) if (200..300).contains(&response.status) => {
|
||||
serde_json::from_slice(&response.body)
|
||||
.map_err(|_| DriveError::OutcomeUnknown(query.request_id.clone()))
|
||||
}
|
||||
Ok(Ok(Ok(response))) => Err(classify(
|
||||
response.status,
|
||||
&response.body,
|
||||
true,
|
||||
&query.request_id,
|
||||
)),
|
||||
_ => Err(DriveError::OutcomeUnknown(query.request_id.clone())),
|
||||
};
|
||||
self.reconcile(&query.request_id, result).await
|
||||
}
|
||||
pub async fn status(&self, request_id: &str) -> Result<DriveRequestStatusResponse, DriveError> {
|
||||
if request_id.is_empty() || request_id.len() > 256 {
|
||||
return Err(DriveError::Invalid("invalid request ID".into()));
|
||||
}
|
||||
let response: DriveRequestStatusResponse = self
|
||||
.json(
|
||||
WorkspaceRequest::get(format!("{}/requests/{}", self.base()?, encode(request_id))),
|
||||
false,
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
if response.request_id != request_id {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
if let Some(result) = &response.response {
|
||||
self.validate_completion(request_id, result)?;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn decimal(value: &str) -> bool {
|
||||
value
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(|c| matches!(c, b'1'..=b'9'))
|
||||
&& value.bytes().all(|c| c.is_ascii_digit())
|
||||
&& value.parse::<i64>().is_ok_and(|id| id > 0)
|
||||
}
|
||||
pub(super) fn encode(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|b| {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_') {
|
||||
(b as char).to_string()
|
||||
} else {
|
||||
format!("%{b:02X}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn query_string<T: Serialize>(value: &T) -> Result<String, DriveError> {
|
||||
let value =
|
||||
serde_json::to_value(value).map_err(|_| DriveError::Invalid("invalid query".into()))?;
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| DriveError::Invalid("invalid query".into()))?;
|
||||
let pairs: Vec<_> = object
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_null())
|
||||
.map(|(k, v)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
encode(k),
|
||||
encode(
|
||||
v.as_str()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| v.to_string())
|
||||
.as_str()
|
||||
)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok(if pairs.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("?{}", pairs.join("&"))
|
||||
})
|
||||
}
|
||||
fn classify(status: u16, body: &[u8], mutation: bool, id: &str) -> DriveError {
|
||||
if let Ok(error) = serde_json::from_slice::<DriveApiError>(body) {
|
||||
if error.classification == DriveFailureClassification::Unknown {
|
||||
return DriveError::OutcomeUnknown(id.into());
|
||||
}
|
||||
return match error.code {
|
||||
DriveApiErrorCode::Denied => DriveError::Denied,
|
||||
DriveApiErrorCode::NotFound => DriveError::NotFound,
|
||||
DriveApiErrorCode::Conflict => DriveError::Conflict,
|
||||
DriveApiErrorCode::Invalid => DriveError::Invalid("Backend rejected arguments".into()),
|
||||
DriveApiErrorCode::Limit => DriveError::Limit,
|
||||
DriveApiErrorCode::StorageUnavailable => DriveError::Unavailable,
|
||||
DriveApiErrorCode::OutcomeUnknown => DriveError::OutcomeUnknown(id.into()),
|
||||
};
|
||||
}
|
||||
if matches!(status, 401 | 403) {
|
||||
DriveError::Denied
|
||||
} else if mutation {
|
||||
DriveError::OutcomeUnknown(id.into())
|
||||
} else {
|
||||
DriveError::Unavailable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//! Fail-closed Drive boundary for parent-owned Internal SubWorkers, including Reviewers.
|
||||
//!
|
||||
//! Internal children have no Backend Drive identity. Explicit internal Drive delegation is not
|
||||
//! supported until the Backend can approve scope bound to that child identity. A parent's current
|
||||
//! grant, enabled profile, or Reviewer attestation is not a child Drive grant. Registered Runtime
|
||||
//! Workers must use their own identity-bound, current Backend grants; this wrapper creates neither
|
||||
//! identities nor grants and keeps no local authorization cache.
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use crate::worker::{
|
||||
ReviewerContext, WorkerWorkspaceContext, WorkspaceBinaryRequest, WorkspaceBinaryResponse,
|
||||
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution, WorkspaceRequest,
|
||||
WorkspaceResponse, WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest,
|
||||
};
|
||||
|
||||
/// Removes Drive authority without changing the selected client's other authority or behavior.
|
||||
/// Install outside any Reviewer client so its trusted review submission attestation is preserved.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InternalChildWorkspaceClient {
|
||||
inner: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
impl InternalChildWorkspaceClient {
|
||||
pub(crate) fn new(inner: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
fn check_path(path: &str) -> Result<(), WorkspaceClientError> {
|
||||
// Classify the route, not the method: even readonly Drive access must not inherit the
|
||||
// parent's identity. Decode route segments and normalize dot segments before checking,
|
||||
// so transport/router normalization cannot turn an allowed alias into a Drive request.
|
||||
let path = path.split(['?', '#']).next().unwrap_or(path);
|
||||
let mut decoded = Vec::with_capacity(path.len());
|
||||
// HTTP URL parsers discard literal tabs/newlines before route normalization.
|
||||
let bytes: Vec<_> = path
|
||||
.bytes()
|
||||
.filter(|byte| !matches!(byte, b'\t' | b'\r' | b'\n'))
|
||||
.collect();
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%'
|
||||
&& index + 2 < bytes.len()
|
||||
&& let (Some(high), Some(low)) =
|
||||
(hex_digit(bytes[index + 1]), hex_digit(bytes[index + 2]))
|
||||
{
|
||||
decoded.push(high * 16 + low);
|
||||
index += 3;
|
||||
} else {
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
let decoded = String::from_utf8_lossy(&decoded).replace('\\', "/");
|
||||
let mut segments = Vec::new();
|
||||
for segment in decoded.split('/') {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
segments.pop();
|
||||
}
|
||||
segment => segments.push(segment),
|
||||
}
|
||||
}
|
||||
if matches!(segments.as_slice(), ["api", "w", _, "drive", ..]) {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
"Internal SubWorker Drive access denied: no identity-bound Backend-approved child scope; explicit internal Drive delegation is not supported".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_digit(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep Workspace identity and prompt context, but never inherit Drive authority.
|
||||
pub(crate) fn restrict_internal_child_drive(
|
||||
context: WorkerWorkspaceContext,
|
||||
) -> WorkerWorkspaceContext {
|
||||
WorkerWorkspaceContext::with_client(
|
||||
context.workspace_id().cloned(),
|
||||
Arc::new(InternalChildWorkspaceClient::new(context.client_handle())),
|
||||
)
|
||||
}
|
||||
|
||||
impl WorkspaceClient for InternalChildWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.inner.workspace_id()
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
self.inner.kind()
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
self.inner.is_available()
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Self::check_path(&request.path)?;
|
||||
self.inner.execute(request)
|
||||
}
|
||||
|
||||
fn execute_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Self::check_path(&request.path)?;
|
||||
self.inner.execute_with_timeout(request, timeout)
|
||||
}
|
||||
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
Self::check_path(&request.path)?;
|
||||
self.inner.execute_binary(request)
|
||||
}
|
||||
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
Self::check_path(&request.path)?;
|
||||
self.inner.execute_binary_with_timeout(request, timeout)
|
||||
}
|
||||
|
||||
fn execute_server_operation(
|
||||
&self,
|
||||
operation: WorkspaceServerOperation,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.inner.execute_server_operation(operation)
|
||||
}
|
||||
|
||||
fn current_prompt_projection(
|
||||
&self,
|
||||
minimum_revision: Option<u64>,
|
||||
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||
self.inner.current_prompt_projection(minimum_revision)
|
||||
}
|
||||
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
request: WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<server_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
self.inner.list_workspace_workers(request)
|
||||
}
|
||||
|
||||
fn execute_worker_remove(
|
||||
&self,
|
||||
target_runtime_id: &str,
|
||||
target_worker_id: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.inner
|
||||
.execute_worker_remove(target_runtime_id, target_worker_id, reason)
|
||||
}
|
||||
|
||||
fn reviewer_context(&self) -> Option<&ReviewerContext> {
|
||||
self.inner.reviewer_context()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::worker::{ReviewerChildWorkspaceClient, WorkspaceRequestMethod};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Call {
|
||||
Json(WorkspaceRequest, Option<Duration>),
|
||||
Binary(WorkspaceBinaryRequest, Option<Duration>),
|
||||
Projection(Option<u64>),
|
||||
Discovery(WorkspaceWorkerDiscoveryRequest),
|
||||
Remove(String, String, String),
|
||||
Server,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RecordingClient {
|
||||
calls: Mutex<Vec<Call>>,
|
||||
}
|
||||
|
||||
impl RecordingClient {
|
||||
fn response(&self, call: Call) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.calls.lock().unwrap().push(call);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 202,
|
||||
body: "forwarded".into(),
|
||||
})
|
||||
}
|
||||
fn binary_response(
|
||||
&self,
|
||||
call: Call,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.calls.lock().unwrap().push(call);
|
||||
Ok(WorkspaceBinaryResponse {
|
||||
status: 206,
|
||||
body: vec![0, 255],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceClient for RecordingClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("ws")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"recording"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.response(Call::Json(request, None))
|
||||
}
|
||||
fn execute_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.response(Call::Json(request, Some(timeout)))
|
||||
}
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.binary_response(Call::Binary(request, None))
|
||||
}
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.binary_response(Call::Binary(request, Some(timeout)))
|
||||
}
|
||||
fn current_prompt_projection(
|
||||
&self,
|
||||
revision: Option<u64>,
|
||||
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||
self.calls.lock().unwrap().push(Call::Projection(revision));
|
||||
Err(WorkspaceClientError::Request("projection marker".into()))
|
||||
}
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
request: WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<server_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
self.calls.lock().unwrap().push(Call::Discovery(request));
|
||||
Err(WorkspaceClientError::Request("discovery marker".into()))
|
||||
}
|
||||
fn execute_worker_remove(
|
||||
&self,
|
||||
runtime: &str,
|
||||
worker: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.response(Call::Remove(runtime.into(), worker.into(), reason.into()))
|
||||
}
|
||||
fn execute_server_operation(
|
||||
&self,
|
||||
_: WorkspaceServerOperation,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.response(Call::Server)
|
||||
}
|
||||
}
|
||||
|
||||
fn binary(method: WorkspaceRequestMethod, path: &str) -> WorkspaceBinaryRequest {
|
||||
WorkspaceBinaryRequest {
|
||||
method,
|
||||
path: path.into(),
|
||||
body: Some(vec![0, 128, 255]),
|
||||
max_response_bytes: 17,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_children_deny_all_drive_transports_without_parent_side_effects() {
|
||||
let inner = Arc::new(RecordingClient::default());
|
||||
for reviewer in [false, true] {
|
||||
let selected: Arc<dyn WorkspaceClient> = if reviewer {
|
||||
Arc::new(ReviewerChildWorkspaceClient::new(
|
||||
inner.clone(),
|
||||
ReviewerContext {
|
||||
ticket_id: "T1".into(),
|
||||
merge_request_id: "MR1".into(),
|
||||
},
|
||||
"attestation".into(),
|
||||
))
|
||||
} else {
|
||||
inner.clone()
|
||||
};
|
||||
let client = InternalChildWorkspaceClient::new(selected);
|
||||
for path in [
|
||||
"/api/w/ws/drive",
|
||||
"/api/w/ws/drive?limit=1",
|
||||
"/api/w/other/drive/root",
|
||||
"/api/w/ws/drive/read-chunk?id=1",
|
||||
"/api/w/ws/drive/upload",
|
||||
"/api/w/ws/drive/requests/request-id",
|
||||
"/api/w/ws/%64rive/root",
|
||||
"/api/w/ws/tickets/../drive/root",
|
||||
"/api/w/ws/tickets/%2e%2e/drive/root",
|
||||
"/api/w/ws%2fdrive/root",
|
||||
"/api/w/ws/dr\tive/root",
|
||||
"/api/w/ws/dri\r\nve/root",
|
||||
] {
|
||||
for method in [
|
||||
WorkspaceRequestMethod::Get,
|
||||
WorkspaceRequestMethod::Post,
|
||||
WorkspaceRequestMethod::Put,
|
||||
WorkspaceRequestMethod::Patch,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
] {
|
||||
let request = WorkspaceRequest::json(method, path, "{}");
|
||||
for result in [
|
||||
client.execute(request.clone()),
|
||||
client.execute_with_timeout(request, Duration::from_secs(2)),
|
||||
] {
|
||||
assert!(
|
||||
matches!(result, Err(WorkspaceClientError::Unavailable(message)) if message.contains("Drive access denied")),
|
||||
"path={path}, reviewer={reviewer}"
|
||||
);
|
||||
}
|
||||
for result in [
|
||||
client.execute_binary(binary(method, path)),
|
||||
client.execute_binary_with_timeout(
|
||||
binary(method, path),
|
||||
Duration::from_secs(2),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
matches!(result, Err(WorkspaceClientError::Unavailable(message)) if message.contains("Drive access denied")),
|
||||
"path={path}, reviewer={reviewer}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(inner.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_drive_requests_preserve_payloads_deadlines_and_special_operations() {
|
||||
let inner = Arc::new(RecordingClient::default());
|
||||
let client = InternalChildWorkspaceClient::new(inner.clone());
|
||||
let request = WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/ws/tickets/query",
|
||||
"{\"query\":\"drive\"}",
|
||||
);
|
||||
let bytes = binary(WorkspaceRequestMethod::Put, "/api/w/ws/files/content");
|
||||
let timeout = Duration::from_millis(37);
|
||||
let expected = WorkspaceResponse {
|
||||
status: 202,
|
||||
body: "forwarded".into(),
|
||||
};
|
||||
assert_eq!(client.execute(request.clone()).unwrap(), expected);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_with_timeout(request.clone(), timeout)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
let expected_bytes = WorkspaceBinaryResponse {
|
||||
status: 206,
|
||||
body: vec![0, 255],
|
||||
};
|
||||
assert_eq!(
|
||||
client.execute_binary(bytes.clone()).unwrap(),
|
||||
expected_bytes
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_binary_with_timeout(bytes.clone(), timeout)
|
||||
.unwrap(),
|
||||
expected_bytes
|
||||
);
|
||||
assert!(
|
||||
matches!(client.current_prompt_projection(Some(42)), Err(WorkspaceClientError::Request(message)) if message == "projection marker")
|
||||
);
|
||||
let discovery = WorkspaceWorkerDiscoveryRequest {
|
||||
cursor: Some("next".into()),
|
||||
limit: 3,
|
||||
query: Some("coder".into()),
|
||||
};
|
||||
assert!(
|
||||
matches!(client.list_workspace_workers(discovery.clone()), Err(WorkspaceClientError::Request(message)) if message == "discovery marker")
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_worker_remove("runtime", "worker", "reason")
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_server_operation(WorkspaceServerOperation::WorkerControlList)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(client.workspace_id(), Some("ws"));
|
||||
assert_eq!(client.kind(), "recording");
|
||||
assert!(client.is_available());
|
||||
assert_eq!(
|
||||
*inner.calls.lock().unwrap(),
|
||||
vec![
|
||||
Call::Json(request.clone(), None),
|
||||
Call::Json(request, Some(timeout)),
|
||||
Call::Binary(bytes.clone(), None),
|
||||
Call::Binary(bytes, Some(timeout)),
|
||||
Call::Projection(Some(42)),
|
||||
Call::Discovery(discovery),
|
||||
Call::Remove("runtime".into(), "worker".into(), "reason".into()),
|
||||
Call::Server,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_restriction_preserves_reviewer_context_and_review_attestation() {
|
||||
let inner = Arc::new(RecordingClient::default());
|
||||
let context = ReviewerContext {
|
||||
ticket_id: "T1".into(),
|
||||
merge_request_id: "MR1".into(),
|
||||
};
|
||||
let client =
|
||||
InternalChildWorkspaceClient::new(Arc::new(ReviewerChildWorkspaceClient::new(
|
||||
inner.clone(),
|
||||
context.clone(),
|
||||
"trusted-token".into(),
|
||||
)));
|
||||
assert_eq!(client.reviewer_context(), Some(&context));
|
||||
assert_eq!(client.kind(), "runtime-reviewer-child");
|
||||
let request = WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/ws/merge-requests/MR1/reviews",
|
||||
"{\"verdict\":\"approve\"}",
|
||||
);
|
||||
// Reviewer timeout execution uses its existing JSON attestation path, not raw parent access.
|
||||
client
|
||||
.execute_with_timeout(request, Duration::from_secs(1))
|
||||
.unwrap();
|
||||
let calls = inner.calls.lock().unwrap();
|
||||
let [Call::Json(request, None)] = calls.as_slice() else {
|
||||
panic!("unexpected calls: {calls:?}")
|
||||
};
|
||||
let body: serde_json::Value = serde_json::from_str(request.body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["capability_token"], "trusted-token");
|
||||
assert_eq!(body["verdict"], "approve");
|
||||
drop(calls);
|
||||
let count = inner.calls.lock().unwrap().len();
|
||||
assert!(
|
||||
client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Delete,
|
||||
"/api/w/ws/tickets/T1",
|
||||
"{}"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(inner.calls.lock().unwrap().len(), count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Client {
|
||||
responses: Mutex<VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
impl Client {
|
||||
fn new(responses: Vec<WorkspaceResponse>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
responses: Mutex::new(responses.into_iter().map(Ok).collect()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
impl WorkspaceClient for Client {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected Backend call")
|
||||
}
|
||||
}
|
||||
fn response(value: impl Serialize) -> WorkspaceResponse {
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&value).unwrap(),
|
||||
}
|
||||
}
|
||||
fn entry(id: &str, revision: &str) -> DriveEntry {
|
||||
DriveEntry {
|
||||
entry: DriveEntryRef {
|
||||
workspace_id: "workspace".into(),
|
||||
node_id: id.into(),
|
||||
},
|
||||
parent: Some(DriveEntryRef {
|
||||
workspace_id: "workspace".into(),
|
||||
node_id: "1".into(),
|
||||
}),
|
||||
name: "note.md".into(),
|
||||
kind: DriveEntryKind::File,
|
||||
revision: revision.into(),
|
||||
size: Some(5),
|
||||
content_type: Some("text/markdown".into()),
|
||||
updated_by: "worker".into(),
|
||||
updated_at: "now".into(),
|
||||
latest_url: format!("/api/w/workspace/drive/download?id={id}"),
|
||||
}
|
||||
}
|
||||
fn feature(client: Arc<Client>) -> DriveFeature {
|
||||
DriveFeature::new(client, Arc::new(WorkdirSessionRouter::new()))
|
||||
}
|
||||
async fn execute(
|
||||
feature: &DriveFeature,
|
||||
name: &str,
|
||||
input: Value,
|
||||
) -> Result<DriveOutput, DriveError> {
|
||||
feature
|
||||
.execute(
|
||||
name,
|
||||
&input.to_string(),
|
||||
&ToolExecutionContext::new(name, "batch", 0),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions() {
|
||||
let client = Client::new(Vec::new());
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
assert!(DriveFeature::configured(client.clone(), false, router.clone()).is_none());
|
||||
assert!(
|
||||
DriveFeature::configured(
|
||||
crate::worker::unavailable_workspace_client(None, "standalone"),
|
||||
true,
|
||||
router
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let feature = feature(client);
|
||||
for (name, definition) in feature.tools() {
|
||||
let (meta, _) = definition();
|
||||
assert_eq!(name, meta.name);
|
||||
let schema = meta.input_schema.to_string();
|
||||
assert!(!schema.contains("expected_revision"), "{name}");
|
||||
assert!(!schema.contains("grant_id"), "{name}");
|
||||
}
|
||||
assert_eq!(feature.descriptor().instructions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreign_or_unobserved_entry_cannot_dispatch_mutation() {
|
||||
let client = Client::new(Vec::new());
|
||||
let feature = feature(client.clone());
|
||||
let own = json!({"workspace_id":"workspace","node_id":"9007199254740993"});
|
||||
assert!(matches!(
|
||||
execute(&feature, "DriveWrite", json!({"entry":own,"content":"new"})).await,
|
||||
Err(DriveError::Invalid(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
execute(
|
||||
&feature,
|
||||
"DriveDelete",
|
||||
json!({"entry":{"workspace_id":"other","node_id":"1"}})
|
||||
)
|
||||
.await,
|
||||
Err(DriveError::Denied)
|
||||
));
|
||||
for id in [json!(1), json!("01"), json!("9223372036854775808")] {
|
||||
assert!(matches!(
|
||||
execute(
|
||||
&feature,
|
||||
"DriveMetadata",
|
||||
json!({"entry":{"workspace_id":"workspace","node_id":id}})
|
||||
)
|
||||
.await,
|
||||
Err(DriveError::Invalid(_))
|
||||
));
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_write() {
|
||||
let old = entry("2", "1");
|
||||
let current = entry("2", "2");
|
||||
let client = Client::new(vec![response(DriveReadTextResponse {
|
||||
entry: current,
|
||||
text: "hello".into(),
|
||||
truncated: false,
|
||||
})]);
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let result = execute(
|
||||
&feature,
|
||||
"DriveEdit",
|
||||
json!({"entry":old.entry,"old_string":"hello","new_string":"new"}),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(DriveError::Conflict)));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(feature.observed(&old.entry, None).unwrap().revision, "1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_edit_rules_reject_ambiguous_and_truncated_preimages_without_mutation() {
|
||||
for (text, truncated) in [("hello hello", false), ("hello", true)] {
|
||||
let old = entry("2", "1");
|
||||
let client = Client::new(vec![response(DriveReadTextResponse {
|
||||
entry: old.clone(),
|
||||
text: text.into(),
|
||||
truncated,
|
||||
})]);
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
assert!(
|
||||
execute(
|
||||
&feature,
|
||||
"DriveEdit",
|
||||
json!({"entry":old.entry,"old_string":"hello","new_string":"new"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|r| r.method == crate::worker::WorkspaceRequestMethod::Get)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_explicit_observation()
|
||||
{
|
||||
let old = entry("9007199254740993", "9007199254740993");
|
||||
let updated = entry("9007199254740993", "9007199254740994");
|
||||
let client = Client::new(Vec::new());
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let id = feature.request_id(&context);
|
||||
client
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(Ok(response(DriveMutationResponse {
|
||||
request_id: id,
|
||||
entry: Some(updated),
|
||||
})));
|
||||
feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":old.entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["mutation"]["expected_revision"], "9007199254740993");
|
||||
assert_eq!(body["mutation"]["id"]["node_id"], "9007199254740993");
|
||||
drop(requests);
|
||||
assert!(feature.observed(&old.entry, None).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lost_mutation_response_queries_same_receipt_without_resending() {
|
||||
let old = entry("2", "1");
|
||||
let client = Client::new(Vec::new());
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let id = feature.request_id(&context);
|
||||
client.responses.lock().unwrap().extend([
|
||||
Err(WorkspaceClientError::Request("lost".into())),
|
||||
Ok(response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(DriveMutationResponse {
|
||||
request_id: id.clone(),
|
||||
entry: Some(entry("2", "2")),
|
||||
}),
|
||||
})),
|
||||
]);
|
||||
feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":old.entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|r| r.method == crate::worker::WorkspaceRequestMethod::Post)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncommitted_receipt_snapshot_does_not_turn_unknown_into_failure_or_retry() {
|
||||
let old = entry("2", "1");
|
||||
let client = Client::new(Vec::new());
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let id = feature.request_id(&context);
|
||||
client.responses.lock().unwrap().extend([
|
||||
Err(WorkspaceClientError::Request("lost".into())),
|
||||
Ok(response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Uncommitted,
|
||||
response: None,
|
||||
})),
|
||||
]);
|
||||
let result = feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":old.entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result,Err(DriveError::OutcomeUnknown(ref observed)) if observed==&id));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
fn invalid_completions(id: &str) -> Vec<DriveMutationResponse> {
|
||||
let mut invalid_entry = entry("2", "2");
|
||||
invalid_entry.revision = "not-a-revision".into();
|
||||
vec![
|
||||
DriveMutationResponse {
|
||||
request_id: "another-request".into(),
|
||||
entry: Some(entry("2", "2")),
|
||||
},
|
||||
DriveMutationResponse {
|
||||
request_id: id.into(),
|
||||
entry: Some(invalid_entry),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_mutation_completion_recovers_from_original_receipt_without_resending() {
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let client = Client::new(vec![]);
|
||||
let feature = feature(client.clone());
|
||||
let id = feature.request_id(&context);
|
||||
for completion in invalid_completions(&id) {
|
||||
let committed = DriveMutationResponse {
|
||||
request_id: id.clone(),
|
||||
entry: Some(entry("2", "2")),
|
||||
};
|
||||
client.requests.lock().unwrap().clear();
|
||||
client.responses.lock().unwrap().extend([
|
||||
Ok(response(completion)),
|
||||
Ok(response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(committed.clone()),
|
||||
})),
|
||||
]);
|
||||
feature.observe(entry("2", "1")).unwrap();
|
||||
let output = feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":entry("2", "1").entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.value["request_id"], id);
|
||||
assert_eq!(output.value["entry"]["metadata"]["revision"], "2");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
requests[0].method,
|
||||
crate::worker::WorkspaceRequestMethod::Post
|
||||
);
|
||||
assert_eq!(
|
||||
requests[1].method,
|
||||
crate::worker::WorkspaceRequestMethod::Get
|
||||
);
|
||||
assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["request_id"], id);
|
||||
assert_eq!(body["mutation"]["expected_revision"], "1");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_completion_and_unresolved_or_invalid_receipt_stay_unknown_after_one_query() {
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let client = Client::new(vec![]);
|
||||
let feature = feature(client.clone());
|
||||
let id = feature.request_id(&context);
|
||||
let committed = DriveMutationResponse {
|
||||
request_id: id.clone(),
|
||||
entry: Some(entry("2", "2")),
|
||||
};
|
||||
let mut receipts = vec![
|
||||
response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Uncommitted,
|
||||
response: None,
|
||||
}),
|
||||
response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: None,
|
||||
}),
|
||||
response(DriveRequestStatusResponse {
|
||||
request_id: "another-request".into(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(committed),
|
||||
}),
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "invalid-json".into(),
|
||||
},
|
||||
];
|
||||
receipts.extend(invalid_completions(&id).into_iter().map(|completion| {
|
||||
response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(completion),
|
||||
})
|
||||
}));
|
||||
for completion in invalid_completions(&id) {
|
||||
for receipt in &receipts {
|
||||
client.requests.lock().unwrap().clear();
|
||||
client
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend([Ok(response(&completion)), Ok(receipt.clone())]);
|
||||
feature.observe(entry("2", "1")).unwrap();
|
||||
let result = feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":entry("2", "1").entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(DriveError::OutcomeUnknown(ref observed)) if observed == &id)
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
requests[0].method,
|
||||
crate::worker::WorkspaceRequestMethod::Post
|
||||
);
|
||||
assert_eq!(
|
||||
requests[1].method,
|
||||
crate::worker::WorkspaceRequestMethod::Get
|
||||
);
|
||||
assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observation_eviction_never_substitutes_another_entry() {
|
||||
let feature = feature(Client::new(Vec::new()));
|
||||
let first = entry("2", "1");
|
||||
feature.observe(first.clone()).unwrap();
|
||||
for id in 3..=258 {
|
||||
feature.observe(entry(&id.to_string(), "1")).unwrap();
|
||||
}
|
||||
assert!(feature.observed(&first.entry, None).is_err());
|
||||
let state = feature.observations.lock().unwrap();
|
||||
assert_eq!(state.entries.len(), MAX_OBSERVATIONS);
|
||||
assert_eq!(state.order.len(), MAX_OBSERVATIONS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_error_details_are_not_reflected_and_conflict_is_not_retried() {
|
||||
let client = Client::new(vec![WorkspaceResponse {
|
||||
status: 409,
|
||||
body: serde_json::to_string(&DriveApiError {
|
||||
code: DriveApiErrorCode::Conflict,
|
||||
classification: DriveFailureClassification::NotCommitted,
|
||||
message: "/secret/provider/path".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
}]);
|
||||
let feature = feature(client.clone());
|
||||
let old = entry("2", "1");
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let result = execute(&feature, "DriveDelete", json!({"entry":old.entry}))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(matches!(result, DriveError::Conflict));
|
||||
assert!(!result.to_string().contains("secret"));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds_identity() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let entry = entry("9007199254740993", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
let feature = feature(client.clone());
|
||||
let mut mounts = crate::wip::WipMountRegistry::new();
|
||||
wip::mount_drive_wip(&mut mounts, &feature).unwrap();
|
||||
let provider = wip::DriveProvider(feature);
|
||||
let path = entry_path(&entry.entry);
|
||||
let publication = provider.publication(&path).await.unwrap().unwrap();
|
||||
assert_eq!(publication.projection.route, path);
|
||||
assert_eq!(
|
||||
publication.scope.as_ref().unwrap().r#ref,
|
||||
publication.projection.object.r#ref
|
||||
);
|
||||
assert_eq!(publication.projection.object.validator, Some(b"3".to_vec()));
|
||||
assert!(provider.children("/drive").await.unwrap().is_empty());
|
||||
assert!(
|
||||
provider
|
||||
.publication("/drive/other/9007199254740993")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let operations = &publication.projection.descriptor.operations;
|
||||
assert!(operations.iter().any(|op| op.name == "view_image"));
|
||||
assert!(
|
||||
!operations
|
||||
.iter()
|
||||
.flat_map(|op| &op.parameters)
|
||||
.any(|parameter| parameter.name == "expected_revision")
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ChunkClient {
|
||||
json: Arc<Client>,
|
||||
chunks: Mutex<VecDeque<crate::worker::WorkspaceBinaryResponse>>,
|
||||
requests: Mutex<Vec<crate::worker::WorkspaceBinaryRequest>>,
|
||||
}
|
||||
impl WorkspaceClient for ChunkClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-chunk-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.json.execute(request)
|
||||
}
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: crate::worker::WorkspaceBinaryRequest,
|
||||
) -> Result<crate::worker::WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(self
|
||||
.chunks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected binary read"))
|
||||
}
|
||||
}
|
||||
fn chunk_client(
|
||||
metadata: DriveEntry,
|
||||
chunks: Vec<crate::worker::WorkspaceBinaryResponse>,
|
||||
) -> Arc<ChunkClient> {
|
||||
Arc::new(ChunkClient {
|
||||
json: Client::new(vec![response(metadata)]),
|
||||
chunks: Mutex::new(chunks.into()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
fn chunk(bytes: Vec<u8>) -> crate::worker::WorkspaceBinaryResponse {
|
||||
crate::worker::WorkspaceBinaryResponse {
|
||||
status: 200,
|
||||
body: bytes,
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_content() {
|
||||
let mut metadata = entry("2", "9007199254740993");
|
||||
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
bytes.resize(DRIVE_CHUNK_MAX_BYTES as usize + 13, 42);
|
||||
metadata.size = Some(bytes.len() as u32);
|
||||
let client = chunk_client(
|
||||
metadata.clone(),
|
||||
vec![
|
||||
chunk(bytes[..DRIVE_CHUNK_MAX_BYTES as usize].to_vec()),
|
||||
chunk(bytes[DRIVE_CHUNK_MAX_BYTES as usize..].to_vec()),
|
||||
],
|
||||
);
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
let output = execute(&feature, "DriveViewImage", json!({"entry":metadata.entry}))
|
||||
.await
|
||||
.unwrap();
|
||||
let Attachment::Image(image) = &output.attachments[0];
|
||||
assert_eq!(image.data(), bytes);
|
||||
assert!(output.value.to_string().len() < 2048);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in &*requests {
|
||||
assert!(request.path.contains("expected_revision=9007199254740993"));
|
||||
assert_eq!(request.max_response_bytes, DRIVE_CHUNK_MAX_BYTES as usize);
|
||||
}
|
||||
assert!(requests[0].path.split('&').any(|pair| pair == "offset=0"));
|
||||
assert!(
|
||||
requests[1]
|
||||
.path
|
||||
.split('&')
|
||||
.any(|pair| pair == "offset=65536")
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_retrying() {
|
||||
for next in [
|
||||
crate::worker::WorkspaceBinaryResponse {
|
||||
status: 409,
|
||||
body: serde_json::to_vec(&DriveApiError::new(DriveApiErrorCode::Conflict)).unwrap(),
|
||||
},
|
||||
chunk(vec![1]), // second range requires 13 bytes, not a successful prefix
|
||||
] {
|
||||
let mut metadata = entry("2", "3");
|
||||
metadata.size = Some(DRIVE_CHUNK_MAX_BYTES + 13);
|
||||
let mut first = vec![0; DRIVE_CHUNK_MAX_BYTES as usize];
|
||||
first[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
let client = chunk_client(metadata.clone(), vec![chunk(first), next]);
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
assert!(
|
||||
execute(&feature, "DriveViewImage", json!({"entry":metadata.entry}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
2,
|
||||
"must not retry an expired/truncated image"
|
||||
);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| request.path.contains("expected_revision=3&"))
|
||||
);
|
||||
assert_eq!(
|
||||
client.json.requests.lock().unwrap().len(),
|
||||
1,
|
||||
"must not fetch a new revision"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() {
|
||||
let mut metadata = entry("2", "1");
|
||||
metadata.size = Some(tools::view_image::MAX_IMAGE_BYTES as u32 + 1);
|
||||
let client = chunk_client(metadata.clone(), vec![]);
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
assert!(matches!(
|
||||
execute(&feature, "DriveViewImage", json!({"entry":metadata.entry})).await,
|
||||
Err(DriveError::Limit)
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
assert!(
|
||||
execute(
|
||||
&feature,
|
||||
"DriveRead",
|
||||
json!({"entry":metadata.entry,"expected_revision":"1"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(client.json.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Native non-indexable Drive entries. Publication resolves just one ID through
|
||||
//! current Backend authorization; the Client owns identity/revision validators.
|
||||
use super::{
|
||||
DriveFeature, SPECS, Spec,
|
||||
backend::{DriveError, decimal},
|
||||
digest, entry_path,
|
||||
};
|
||||
use crate::wip::{
|
||||
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount,
|
||||
WipSubtreeProvider, contextual_reference, json_to_wip, wip_to_json,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use server_api::{DriveEntry, DriveEntryKind, DriveEntryRef};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use wip_protocol::{
|
||||
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
|
||||
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
|
||||
const ROOT: &str = "/drive";
|
||||
const INTERFACE: &str = "yoi.drive/entry/v1";
|
||||
|
||||
pub fn mount_drive_wip(
|
||||
registry: &mut WipMountRegistry,
|
||||
feature: &DriveFeature,
|
||||
) -> Result<(), WipMountError> {
|
||||
registry.allocate_namespace("drive", "drive")?;
|
||||
// Registration is only a namespace entrance. The subtree provides the
|
||||
// current target/scope/descriptor/handler in a single self-scoped snapshot.
|
||||
let names = root_specs();
|
||||
let descriptor = descriptor(&names, true);
|
||||
registry.mount(WipProjection {
|
||||
route: ROOT.into(),
|
||||
capability: "drive:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: "drive".into(),
|
||||
description: Some(
|
||||
"Authorized Workspace Drive; discover entries via bounded list/search, not tree"
|
||||
.into(),
|
||||
),
|
||||
interfaces: vec![contextual_reference(INTERFACE, ROOT)],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
},
|
||||
interface: contextual_reference(INTERFACE, ROOT),
|
||||
interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()),
|
||||
descriptor,
|
||||
handler: Arc::new(Unresolved),
|
||||
})?;
|
||||
registry.mount_subtree(WipSubtreeMount {
|
||||
root: ROOT.into(),
|
||||
provider: Arc::new(DriveProvider(feature.clone())),
|
||||
})?;
|
||||
registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?;
|
||||
Ok(())
|
||||
}
|
||||
struct Unresolved;
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for Unresolved {
|
||||
async fn call(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
Err(map_error(DriveError::Denied))
|
||||
}
|
||||
}
|
||||
pub(super) struct DriveProvider(pub(super) DriveFeature);
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for DriveProvider {
|
||||
// Arbitrary tree depth is safe: there are deliberately no indexable entries.
|
||||
fn max_depth(&self) -> u32 {
|
||||
32
|
||||
}
|
||||
fn max_nodes(&self) -> usize {
|
||||
1
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let result = if path == ROOT {
|
||||
self.0.backend.root().await
|
||||
} else {
|
||||
let Some(entry) = parse_entry(&self.0, path).map_err(protocol)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.0.backend.metadata(&entry).await
|
||||
};
|
||||
let entry = match result {
|
||||
Ok(entry) => entry,
|
||||
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
|
||||
Err(error) => return Err(protocol(error)),
|
||||
};
|
||||
let root = path == ROOT;
|
||||
let specs = if root {
|
||||
root_specs()
|
||||
} else {
|
||||
entry_specs(&entry)
|
||||
};
|
||||
let descriptor = descriptor(&specs, root);
|
||||
let reference = contextual_reference(INTERFACE, path);
|
||||
let validator = entry.revision.as_bytes().to_vec();
|
||||
let object = Object {
|
||||
name: if root {
|
||||
"drive".into()
|
||||
} else {
|
||||
entry.entry.node_id.clone()
|
||||
},
|
||||
description: Some(format!(
|
||||
"Workspace Drive {:?} {:?}; stable ID {}, bodies only through bounded operations",
|
||||
entry.kind, entry.name, entry.entry.node_id
|
||||
)),
|
||||
interfaces: vec![reference.clone()],
|
||||
r#ref: Some(format!(
|
||||
"drive:{}:{}",
|
||||
entry.entry.workspace_id, entry.entry.node_id
|
||||
)),
|
||||
validator: Some(validator),
|
||||
};
|
||||
let projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "drive:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object,
|
||||
interface: reference,
|
||||
interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()),
|
||||
descriptor,
|
||||
handler: Arc::new(DriveHandler {
|
||||
feature: self.0.clone(),
|
||||
entry,
|
||||
specs,
|
||||
root,
|
||||
}),
|
||||
};
|
||||
WipPublication::self_scoped(projection).map(Some)
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
fn root_specs() -> Vec<Spec> {
|
||||
SPECS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|spec| {
|
||||
matches!(
|
||||
spec.operation,
|
||||
"root"
|
||||
| "metadata"
|
||||
| "list"
|
||||
| "search"
|
||||
| "create_folder"
|
||||
| "create_text"
|
||||
| "save_workdir"
|
||||
| "request_status"
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn entry_specs(entry: &DriveEntry) -> Vec<Spec> {
|
||||
SPECS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|spec| match spec.operation {
|
||||
"metadata" => true,
|
||||
"relocate" | "delete" => entry.parent.is_some(),
|
||||
"list" | "create_folder" | "create_text" | "save_workdir" => {
|
||||
entry.kind == DriveEntryKind::Folder
|
||||
}
|
||||
"read" | "write" | "edit" | "view_image" => entry.kind == DriveEntryKind::File,
|
||||
_ => false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
|
||||
ParameterDeclaration {
|
||||
name: name.into(),
|
||||
required,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client manage revisions; never supply validators or retry conflicts/outcome-unknown with a new revision. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)],
|
||||
"write"|"edit"=>crate::file_operation::parameters(spec.operation,false).expect("shared text arguments"),
|
||||
"create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
|
||||
"create_folder"=>vec![parameter("name",true,TypeExpr::String)],
|
||||
"relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)],
|
||||
"save_workdir"=>vec![parameter("target_workdir",true,TypeExpr::String),parameter("path",true,TypeExpr::String),parameter("name",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
|
||||
"request_status"=>vec![parameter("request_id",true,TypeExpr::String)],
|
||||
_=>vec![],
|
||||
},returns:ReturnDeclaration {documentation:None,r#type:TypeExpr::Json}}).collect()}
|
||||
}
|
||||
struct DriveHandler {
|
||||
feature: DriveFeature,
|
||||
entry: DriveEntry,
|
||||
specs: Vec<Spec>,
|
||||
root: bool,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for DriveHandler {
|
||||
async fn call(
|
||||
&self,
|
||||
operation: &str,
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let spec = self
|
||||
.specs
|
||||
.iter()
|
||||
.find(|spec| spec.operation == operation)
|
||||
.ok_or_else(|| {
|
||||
failure(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"Drive operation is not published",
|
||||
)
|
||||
})?;
|
||||
let declaration = descriptor(&self.specs, self.root)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.unwrap();
|
||||
if arguments
|
||||
.keys()
|
||||
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
|
||||
{
|
||||
return Err(map_error(DriveError::Invalid(
|
||||
"unrecognized arguments; revision metadata is Client-managed".into(),
|
||||
)));
|
||||
}
|
||||
let mut input = wip_to_json(&Value::Record(arguments.clone()))
|
||||
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?;
|
||||
let fields = input
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?;
|
||||
match operation {
|
||||
"metadata" | "read" | "write" | "edit" | "delete" | "view_image" => {
|
||||
fields.insert("entry".into(), json!(self.entry.entry));
|
||||
}
|
||||
"list" | "create_folder" | "create_text" | "save_workdir" => {
|
||||
fields.insert("parent".into(), json!(self.entry.entry));
|
||||
}
|
||||
"relocate" => {
|
||||
let path = match arguments.get("parent") {
|
||||
Some(Value::String(path)) => path.as_str(),
|
||||
_ => {
|
||||
return Err(map_error(DriveError::Invalid(
|
||||
"parent must be a Drive entry".into(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
let parent = if path == ROOT {
|
||||
self.feature.backend.root().await.map_err(map_error)?.entry
|
||||
} else {
|
||||
parse_entry(&self.feature, path)
|
||||
.map_err(map_error)?
|
||||
.ok_or_else(|| map_error(DriveError::Denied))?
|
||||
};
|
||||
fields.insert("parent".into(), json!(parent));
|
||||
fields.insert("entry".into(), json!(self.entry.entry));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(permissions) = &self.feature.permissions {
|
||||
if !matches!(
|
||||
crate::permission::permission_action_for(permissions, spec.name, &input),
|
||||
manifest::ToolPermissionAction::Allow
|
||||
) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"Drive operation denied by tool permission; approval unavailable in native invocation",
|
||||
));
|
||||
}
|
||||
}
|
||||
let output = self
|
||||
.feature
|
||||
.execute(
|
||||
spec.name,
|
||||
&input.to_string(),
|
||||
&context.execution,
|
||||
Some(&self.entry),
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
let value = json_to_wip(&output.value).map_err(|_| {
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"Completed Drive result unavailable; query request_status before another mutation"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
// Attachments use the existing ToolOutput side channel and durable Engine
|
||||
// capture, not transient request injection or URL-only success.
|
||||
let result = if matches!(operation, "write" | "edit" | "relocate") {
|
||||
let revision = output.value["entry"]["metadata"]["revision"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
WipOperationError::OutcomeUnknown("Committed Drive revision unavailable".into())
|
||||
})?;
|
||||
WipOperationOutput::native_with_validator(value, revision.as_bytes().to_vec())
|
||||
} else {
|
||||
WipOperationOutput::native(value)
|
||||
};
|
||||
Ok(result.with_attachments(output.attachments))
|
||||
}
|
||||
}
|
||||
fn parse_entry(feature: &DriveFeature, path: &str) -> Result<Option<DriveEntryRef>, DriveError> {
|
||||
let workspace = feature.backend.workspace()?;
|
||||
let prefix = format!(
|
||||
"/drive/{}/",
|
||||
crate::feature::builtin::manage_workdir::wip::encode_identity(workspace)
|
||||
);
|
||||
let Some(id) = path.strip_prefix(&prefix) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !decimal(id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let entry = DriveEntryRef {
|
||||
workspace_id: workspace.into(),
|
||||
node_id: id.into(),
|
||||
};
|
||||
if entry_path(&entry) != path {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(entry))
|
||||
}
|
||||
fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
}
|
||||
fn map_error(error: DriveError) -> WipOperationError {
|
||||
let code = match &error {
|
||||
DriveError::Denied => ProtocolErrorCode::PermissionDenied,
|
||||
DriveError::NotFound => ProtocolErrorCode::NotFound,
|
||||
DriveError::Conflict => ProtocolErrorCode::ValidatorMismatch,
|
||||
DriveError::Invalid(_) => ProtocolErrorCode::InvalidArguments,
|
||||
DriveError::Limit => ProtocolErrorCode::ResourceLimitExceeded,
|
||||
DriveError::Unavailable => ProtocolErrorCode::Internal,
|
||||
DriveError::OutcomeUnknown(_) => {
|
||||
return WipOperationError::OutcomeUnknown(error.to_string());
|
||||
}
|
||||
};
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code,
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
fn protocol(error: DriveError) -> ProtocolError {
|
||||
match map_error(error) {
|
||||
WipOperationError::Protocol(error) => error,
|
||||
_ => ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "Drive observation unavailable".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -870,12 +870,14 @@ impl WorkerOperation {
|
||||
"Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit."
|
||||
}
|
||||
Self::Cancel => "Cancel the current turn of a known Runtime Worker when allowed.",
|
||||
Self::Stop => "Stop a known Runtime Worker when allowed.",
|
||||
Self::Stop => {
|
||||
"Stop a known Runtime Worker when allowed. Stop is independent of Ticket conclusion: it does not end unfinished work, remove retained session records, release attachments, or delete Workdirs."
|
||||
}
|
||||
Self::Restore => {
|
||||
"Restore a stopped Backend/Runtime Worker session in the current Workspace. Supply expected_observation_token from WorkerList; a conflict requires refreshing and deliberate new intent. New requests default to the durable Tool call ID. For explicit recovery of an unknown/pending outcome, supply the reported request_id and the original token unchanged; never create a new intent or automatically retry."
|
||||
}
|
||||
Self::Remove => {
|
||||
"Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||
"Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Conclusion, stopping, retention/removal, attachment release, and Workdir deletion are separate decisions. Removal releases attachments but preserves Workdir materialization. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2050,6 +2052,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lifecycle_descriptions_separate_unfinished_work_retention_and_attachment_cleanup() {
|
||||
let stop = worker_tool_declaration(WorkerOperation::Stop);
|
||||
assert!(stop.description.contains("does not end unfinished work"));
|
||||
assert!(
|
||||
stop.description
|
||||
.contains("release attachments, or delete Workdirs")
|
||||
);
|
||||
let remove = worker_tool_declaration(WorkerOperation::Remove);
|
||||
assert!(
|
||||
remove
|
||||
.description
|
||||
.contains("Terminal Ticket responsibility is retained")
|
||||
);
|
||||
assert!(remove.description.contains("does not require unassignment"));
|
||||
assert!(
|
||||
remove
|
||||
.description
|
||||
.contains("releases attachments but preserves Workdir materialization")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_remove_forwards_only_target_and_bounded_reason() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -27,8 +27,7 @@ use wip_protocol::{
|
||||
|
||||
pub const FEATURE_ID: &str = "merge_request";
|
||||
const FEATURE_NAME: &str = "Merge Request tools";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Operation-specific Merge Request workflow tools over Workspace authority.";
|
||||
const FEATURE_DESCRIPTION: &str = "Optional repository MR workflow with trusted review and separate Orchestrator integration authority; not a universal Ticket conclusion gate.";
|
||||
const FEATURE_INSTRUCTION_ID: &str = "merge_request.workflow";
|
||||
pub const FEATURE_PROMPT_REF: &str = "common.merge_request";
|
||||
|
||||
@@ -1087,10 +1086,10 @@ pub fn description(n: &str) -> Option<&'static str> {
|
||||
"Open one repository-scoped Merge Request linked to the Ticket; reuse an existing Merge Request for that repository and advance only its selector_from with a normal non-force push.",
|
||||
),
|
||||
"CompleteMergeRequest" => Some(
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request using current trusted exact-source approval and provider evidence. This records an already-applied integration, not a Ticket conclusion or assignment release; a review-not-required Ticket request does not bypass MR integration guards.",
|
||||
),
|
||||
"ReviewMergeRequest" => Some(
|
||||
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
||||
"Submit the injected trusted Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not. The capability authenticates the attempt, not the verdict: independent natural-language judgment and validation remain the Reviewer's responsibility.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::feature::{
|
||||
|
||||
const FEATURE_ID: &str = "orchestration";
|
||||
const TOOL_NAME: &str = "SpawnTicketWorker";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OrchestrationFeature;
|
||||
@@ -41,10 +42,7 @@ impl FeatureModule for OrchestrationFeature {
|
||||
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||
"SpawnTicketWorker requires Workspace Worker lifecycle authority",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
TOOL_NAME,
|
||||
"Spawn and atomically assign a configurable Worker for a Ticket. Supply a registered profile selector, an initial request, and optionally a Flow selector. Workdir attachments may be empty; Backend authority validates claims and derives each attachment's access from the Ticket targets. The guarded operation records acceptance only after spawn, initial input, assignment, and resource finalization. Selecting a profile or Flow does not grant review authority.",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(TOOL_NAME, TOOL_DESCRIPTION))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -81,8 +79,10 @@ struct SpawnTicketWorkerWorkdirInput {
|
||||
struct SpawnTicketWorkerInput {
|
||||
ticket_id: String,
|
||||
runtime_id: String,
|
||||
/// Registered profile selector. Backend resolves the profile and validates claims.
|
||||
profile: String,
|
||||
/// Optional registered profile selector; omitted/null selects builtin:ticket-worker.
|
||||
/// Backend resolves the profile and validates claims, not natural-language satisfaction.
|
||||
#[serde(default)]
|
||||
profile: Option<String>,
|
||||
/// Initial user request delivered through the normal typed submission path.
|
||||
initial_request: String,
|
||||
/// Optional Flow selector, delivered as a typed Flow segment before the request.
|
||||
@@ -116,7 +116,11 @@ impl Tool for SpawnTicketWorkerTool {
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
||||
let runtime_id = authority_id(input.runtime_id, "runtime_id")?;
|
||||
let profile = non_empty(input.profile, "profile")?;
|
||||
let profile = input
|
||||
.profile
|
||||
.map(|profile| non_empty(profile, "profile"))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| "builtin:ticket-worker".into());
|
||||
let initial_request = non_empty(input.initial_request, "initial_request")?;
|
||||
let flow = input.flow.map(|flow| non_empty(flow, "flow")).transpose()?;
|
||||
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
|
||||
@@ -196,9 +200,7 @@ fn definition(
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput))
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let meta = ToolMeta::new(TOOL_NAME)
|
||||
.description(
|
||||
"Spawn and atomically assign a configurable Ticket Worker with a registered profile, initial request, optional typed Flow, and zero or more alias-keyed Workdir attachments. Backend validates claims and resources; profile and Flow selection do not grant review authority.",
|
||||
)
|
||||
.description(TOOL_DESCRIPTION)
|
||||
.input_schema(schema);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketWorkerTool {
|
||||
ticket_service: ticket_service.clone(),
|
||||
@@ -389,6 +391,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let definition = definition(
|
||||
Arc::new(FixedTicketService(TicketWorkflowState::Ready)),
|
||||
service.clone(),
|
||||
);
|
||||
let (meta, tool) = definition();
|
||||
let required = meta.input_schema["required"].as_array().unwrap();
|
||||
assert!(!required.iter().any(|field| field == "profile"));
|
||||
assert!(
|
||||
meta.description
|
||||
.contains("neither trusted review nor merge authority")
|
||||
);
|
||||
for profile in [None, Some(serde_json::Value::Null)] {
|
||||
let mut input = input();
|
||||
input.as_object_mut().unwrap().remove("profile");
|
||||
if let Some(profile) = profile {
|
||||
input["profile"] = profile;
|
||||
}
|
||||
input["initial_request"] = serde_json::json!(
|
||||
"Research the question; review is not required. Return without concluding the Ticket."
|
||||
);
|
||||
tool.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-default", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for request in service.requests.lock().unwrap().iter() {
|
||||
assert_eq!(request.profile, "builtin:ticket-worker");
|
||||
assert!(request.workdir_attachments.is_empty());
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![Segment::text(
|
||||
"Research the question; review is not required. Return without concluding the Ticket."
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
|
||||
let service = Arc::new(RecordingService {
|
||||
@@ -519,7 +563,7 @@ mod tests {
|
||||
"unexpected error for {field}: {error}"
|
||||
);
|
||||
}
|
||||
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||
for field in ["ticket_id", "runtime_id", "initial_request"] {
|
||||
let mut input = input();
|
||||
input.as_object_mut().unwrap().remove(field);
|
||||
let error = tool
|
||||
@@ -567,13 +611,13 @@ mod tests {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)).unwrap();
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||
for field in ["ticket_id", "runtime_id", "initial_request"] {
|
||||
assert!(
|
||||
required.contains(&serde_json::json!(field)),
|
||||
"missing required field {field}"
|
||||
);
|
||||
}
|
||||
for field in ["flow", "workdir_attachments"] {
|
||||
for field in ["profile", "flow", "workdir_attachments"] {
|
||||
assert!(schema["properties"].get(field).is_some());
|
||||
assert!(!required.contains(&serde_json::json!(field)));
|
||||
}
|
||||
|
||||
@@ -335,9 +335,9 @@ struct WorkspaceTicketCloseInput {
|
||||
references: Vec<ticket::TicketReference>,
|
||||
}
|
||||
|
||||
const TICKET_CLOSE_DESCRIPTION: &str = "Close a Ticket with resolution, item/state CAS, and replay key. No Merge Request or enforced state sequence is required. Actor identity is transport-bound; this does not integrate Merge Requests or expand Workdir grants.";
|
||||
const COMPLETE_TICKET_DESCRIPTION: &str = "Record a Ticket completion decision with reason, item/state CAS, and replay key. Merge Requests and approvals are not prerequisites. The Backend authorizes the assigned Worker or registered Workspace Orchestrator; roles supplied by the model never grant authority.";
|
||||
const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. This does not start Workers, queue dependencies, integrate Merge Requests, or enlarge live Workdir grants; the Backend validates actor authority.";
|
||||
const TICKET_CLOSE_DESCRIPTION: &str = "Record an authorized Ticket closure decision with resolution, item/state CAS, and replay key when the requested return path calls for closure. No Merge Request or enforced state sequence is required. Backend checks actor authority, not natural-language satisfaction; this does not integrate MRs, stop/remove Workers, clean attachments, or expand Workdir grants.";
|
||||
const COMPLETE_TICKET_DESCRIPTION: &str = "Record an authorized Ticket completion decision with reason, item/state CAS, and replay key when user intent calls for completion; return without conclusion need not call this tool. Merge Requests and approvals are not prerequisites. Backend checks assigned Worker/Workspace Orchestrator authority, not real judgment of natural-language satisfaction; model-supplied roles never grant authority. Conclusion ends unfinished work while retaining terminal responsibility for display; it does not grant review/merge authority or stop/remove Workers and attachments.";
|
||||
const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. Backend checks actor authority, not natural-language satisfaction. This does not start/stop Workers, queue dependencies, integrate Merge Requests, clean attachments, or enlarge live Workdir grants.";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkspaceTicketDecisionKind {
|
||||
@@ -508,8 +508,7 @@ fn workspace_ticket_tool_description(name: &str) -> String {
|
||||
|
||||
const FEATURE_ID: &str = "ticket";
|
||||
const FEATURE_NAME: &str = "Ticket tools";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Typed Ticket operations through the authoritative Workspace API.";
|
||||
const FEATURE_DESCRIPTION: &str = "Intent-led Ticket reads and authorized decisions through the Workspace API; no universal MR/approval gate, and no Backend judgment of natural-language satisfaction.";
|
||||
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
||||
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
|
||||
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||
@@ -2945,6 +2944,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ticket_decision_descriptions_preserve_intent_and_independent_lifecycle() {
|
||||
let tools = native_ticket_tools(
|
||||
decision_client(200, TicketWorkflowState::Ready),
|
||||
TicketFeatureAccess::work_report(),
|
||||
)
|
||||
.unwrap();
|
||||
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
|
||||
let complete = descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.find(|op| op.name == "complete")
|
||||
.unwrap();
|
||||
let summary = &complete.documentation.as_ref().unwrap().summary;
|
||||
assert!(summary.contains("Merge Requests and approvals are not prerequisites"));
|
||||
assert!(summary.contains("return without conclusion need not call this tool"));
|
||||
assert!(summary.contains("not real judgment"));
|
||||
assert!(summary.contains("retaining terminal responsibility"));
|
||||
assert!(summary.contains("does not grant review/merge authority or stop/remove Workers"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() {
|
||||
let client = decision_client(200, TicketWorkflowState::Done);
|
||||
|
||||
@@ -143,6 +143,7 @@ impl WorkerFsView {
|
||||
.list(ListRequest {
|
||||
path: logical.clone(),
|
||||
limit: DIR_FILE_REF_ENTRY_LIMIT,
|
||||
after: None,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)
|
||||
@@ -222,6 +223,7 @@ impl WorkerFsView {
|
||||
.list(ListRequest {
|
||||
path: parent,
|
||||
limit: COMPLETION_LIMIT,
|
||||
after: None,
|
||||
})
|
||||
.await
|
||||
else {
|
||||
|
||||
@@ -284,6 +284,7 @@ fn validate_profile_with_grant(
|
||||
manage_workdir,
|
||||
workdir_catalog,
|
||||
workspace_config,
|
||||
drive,
|
||||
ticket,
|
||||
merge_request,
|
||||
orchestration,
|
||||
@@ -315,6 +316,7 @@ fn validate_profile_with_grant(
|
||||
("feature.objective", objective.enabled),
|
||||
("feature.manage_workdir", manage_workdir.enabled),
|
||||
("feature.workspace_config", workspace_config.enabled),
|
||||
("feature.drive", drive.enabled),
|
||||
(
|
||||
"feature.ticket",
|
||||
ticket.enabled || ticket.authoring || ticket.thread || ticket.intake || ticket.workflow,
|
||||
|
||||
@@ -65,9 +65,9 @@ pub use session_history::{
|
||||
pub use shared_state::WorkerSharedState;
|
||||
pub use worker::{
|
||||
LocalWorkingDirectory, SubjektivSessionAttributionLifecycle, Worker, WorkerError,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution,
|
||||
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspaceServerOperation,
|
||||
WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, marker_workspace_client,
|
||||
unavailable_workspace_client,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest,
|
||||
WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
|
||||
WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest,
|
||||
marker_workspace_client, unavailable_workspace_client,
|
||||
};
|
||||
|
||||
@@ -1034,7 +1034,8 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("This policy governs naming only"));
|
||||
assert!(rendered.contains("Coder cleanup is a separate post-completion decision"));
|
||||
assert!(rendered.contains("Worker cleanup is a separate lifecycle decision"));
|
||||
assert!(rendered.contains("not an automatic consequence of a Ticket state"));
|
||||
assert!(rendered.contains("perform one cleanup pass before ending the orchestration turn"));
|
||||
assert!(rendered.contains("Never predeclare `delete_on_completion`"));
|
||||
assert!(rendered.contains("call `WorkerStop`"));
|
||||
|
||||
@@ -322,6 +322,154 @@ fn append_trailing_section(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::feature::builtin::merge_request::MergeRequestFeature;
|
||||
use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess};
|
||||
use crate::hook::HookRegistryBuilder;
|
||||
use crate::worker::TestWorkspaceHttpClient;
|
||||
use manifest::MergeRequestFeatureConfig;
|
||||
|
||||
// These builtin resources are the model-visible workflow compatibility contract.
|
||||
// Fixtures prove composition and tool descriptions, not an agent's real judgment.
|
||||
fn ticket_workflow_fixture(
|
||||
role: &str,
|
||||
mr_config: MergeRequestFeatureConfig,
|
||||
) -> (String, Vec<agen::tool::ToolMeta>) {
|
||||
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
|
||||
let mut tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(TicketFeature::new(
|
||||
client.clone(),
|
||||
TicketFeatureAccess {
|
||||
thread: true,
|
||||
workflow: true,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.with_module(MergeRequestFeature::new(client, mr_config))
|
||||
.install_into_pending(&mut tools, &mut hooks);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
let metas: Vec<_> = tools.into_iter().map(|definition| definition().0).collect();
|
||||
let instructions = report.installed_instruction_contributions();
|
||||
let prompts = PromptCatalog::builtins_only().unwrap();
|
||||
let scope = Scope::empty();
|
||||
let rendered = SystemPromptTemplate::parse(role, PromptCatalogSource::builtins_only())
|
||||
.unwrap()
|
||||
.render(&SystemPromptContext {
|
||||
now: DateTime::parse_from_rfc3339("2026-10-08T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
cwd: Cow::Borrowed("."),
|
||||
language: "en",
|
||||
scope: &scope,
|
||||
tool_names: metas.iter().map(|meta| meta.name.clone()).collect(),
|
||||
feature_instructions: &instructions,
|
||||
agents_md: None,
|
||||
resident_summary: None,
|
||||
prompts: &prompts,
|
||||
})
|
||||
.unwrap();
|
||||
(rendered, metas)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_ticket_instructions_allow_review_not_required_without_mr_gate() {
|
||||
let (prompt, tools) =
|
||||
ticket_workflow_fixture("role.ticket_worker", MergeRequestFeatureConfig::default());
|
||||
assert!(prompt.contains("No Merge Request or approval is a prerequisite"));
|
||||
assert!(prompt.contains("optional Ticket comment"));
|
||||
assert!(prompt.contains("optional Drive document/artifact"));
|
||||
assert!(!prompt.contains("## Merge Request workflow"));
|
||||
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("Merge Requests and approvals are not prerequisites")
|
||||
);
|
||||
assert!(complete.description.contains("not real judgment"));
|
||||
assert!(
|
||||
complete.input_schema["properties"]
|
||||
.get("approval_event_id")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_code_instructions_preserve_independent_review_before_merge() {
|
||||
let (prompt, tools) = ticket_workflow_fixture(
|
||||
"role.orchestrator",
|
||||
MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
readiness_check: true,
|
||||
complete: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(prompt.contains("Trusted Reviewer authority"));
|
||||
assert!(
|
||||
prompt.contains(
|
||||
"Target integration and `CompleteMergeRequest` are Orchestrator authority"
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("does not bypass the guarded review requirements for MR integration")
|
||||
);
|
||||
assert!(prompt.contains("not a universal Ticket pipeline"));
|
||||
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteMergeRequest")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("trusted exact-source approval")
|
||||
);
|
||||
assert!(
|
||||
complete.input_schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|field| field == "approval_event_id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_ticket_instructions_allow_return_without_conclusion_and_separate_cleanup() {
|
||||
for role in ["role.ticket_worker", "role.coder", "role.orchestrator"] {
|
||||
let (prompt, tools) =
|
||||
ticket_workflow_fixture(role, MergeRequestFeatureConfig::default());
|
||||
assert!(prompt.contains("return without conclusion"), "{role}");
|
||||
assert!(
|
||||
prompt.contains(
|
||||
"a turn ending or a result report is not itself a completion decision"
|
||||
),
|
||||
"{role}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("stopping a Worker alone does not end unfinished work"),
|
||||
"{role}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("does not stop, remove, or clean up"),
|
||||
"{role}"
|
||||
);
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("return without conclusion need not call this tool")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() {
|
||||
|
||||
@@ -28,6 +28,7 @@ use workdir::{
|
||||
|
||||
use crate::PromptCatalogSource;
|
||||
use crate::controller::register_worker_tools;
|
||||
use crate::feature::builtin::drive::delegation::restrict_internal_child_drive;
|
||||
use crate::internal_worker::{
|
||||
EphemeralSessionStore, InternalWorkerSessionStatus, InternalWorkerVisibility,
|
||||
prepare_internal_worker_session,
|
||||
@@ -327,6 +328,9 @@ pub struct SubWorkerSpawnTool {
|
||||
/// Compact selector list shared by tool description and diagnostics.
|
||||
available_profiles: AvailableProfiles,
|
||||
internal_client_override: Option<Box<dyn agen::llm_client::LlmClient>>,
|
||||
#[cfg(test)]
|
||||
installed_child_workspace_client:
|
||||
std::sync::Mutex<Option<Arc<dyn crate::worker::WorkspaceClient>>>,
|
||||
}
|
||||
|
||||
impl SubWorkerSpawnTool {
|
||||
@@ -362,6 +366,8 @@ impl SubWorkerSpawnTool {
|
||||
prompt_loader,
|
||||
available_profiles,
|
||||
internal_client_override: None,
|
||||
#[cfg(test)]
|
||||
installed_child_workspace_client: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -482,6 +488,9 @@ impl Tool for SubWorkerSpawnTool {
|
||||
// Delegated children stay bound to their scoped session and cannot use
|
||||
// Workspace attachment tools to replace it with parent-level authority.
|
||||
child_manifest.feature.manage_workdir.enabled = false;
|
||||
// Internal children (including Reviewers) have no Backend Drive identity or approved
|
||||
// child scope. Profiles and the parent's current Drive grant cannot delegate it.
|
||||
child_manifest.feature.drive.enabled = false;
|
||||
let reviewer_capability = input.review.as_ref().map(|review| {
|
||||
(
|
||||
review.ticket_id.clone(),
|
||||
@@ -523,6 +532,12 @@ impl Tool for SubWorkerSpawnTool {
|
||||
} else {
|
||||
self.workspace_context.clone()
|
||||
};
|
||||
let child_workspace_context = restrict_internal_child_drive(child_workspace_context);
|
||||
#[cfg(test)]
|
||||
{
|
||||
*self.installed_child_workspace_client.lock().unwrap() =
|
||||
Some(child_workspace_context.client_handle());
|
||||
}
|
||||
let store = EphemeralSessionStore::default();
|
||||
let filesystem_authority = WorkerFilesystemAuthority::None;
|
||||
let mut child = Worker::<Box<dyn agen::llm_client::LlmClient>, EphemeralSessionStore>::from_internal_manifest_with_context(
|
||||
@@ -1576,6 +1591,181 @@ enabled = false
|
||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct DriveAllowingParentClient {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for DriveAllowingParentClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-test")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-allowing-parent"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn internal_spawn_profiles_never_inherit_parent_drive_tools_or_client_authority() {
|
||||
for (profile, review) in [
|
||||
("inherit", false),
|
||||
("default", false),
|
||||
("builtin:reviewer", true),
|
||||
] {
|
||||
let runtime = TempDir::new().unwrap();
|
||||
let workspace_root = runtime.path().join("project");
|
||||
let bash_output_dir = runtime.path().join("bash-output");
|
||||
let drive_profile =
|
||||
format!("{INTERNAL_REVIEWER_PROFILE}\n[feature.drive]\nenabled = true\n");
|
||||
let available_profiles = write_project_profile_registry(
|
||||
&workspace_root,
|
||||
Some("reviewer"),
|
||||
&[("reviewer", "reviewer.toml", &drive_profile)],
|
||||
);
|
||||
let mut manifest = parent_manifest(&workspace_root, None);
|
||||
manifest.feature.drive.enabled = true;
|
||||
manifest
|
||||
.scope
|
||||
.allow
|
||||
.push(abs_rule(&bash_output_dir, Permission::Read));
|
||||
let spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap());
|
||||
let registry =
|
||||
SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
|
||||
let parent_client = Arc::new(DriveAllowingParentClient::default());
|
||||
let drive_request = WorkspaceRequest::get("/api/w/workspace-test/drive/root");
|
||||
assert!(
|
||||
parent_client
|
||||
.execute(drive_request.clone())
|
||||
.unwrap()
|
||||
.is_success()
|
||||
);
|
||||
let workdir_tool_broker = workdir::WorkdirToolBrokerRouter::from_single(
|
||||
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
|
||||
Arc::new(workdir::LocalWorkdirSession::materialized_bound(
|
||||
workdir::Workdir::new("test-workdir"),
|
||||
workspace_root.clone(),
|
||||
workspace_root.clone(),
|
||||
spawner_scope.clone(),
|
||||
workdir::WorkdirSessionCapabilities::ALL,
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let tool = SubWorkerSpawnTool::new(
|
||||
"parent".into(),
|
||||
crate::worker::WorkerWorkspaceContext::with_client(
|
||||
Some(WorkspaceId::new("workspace-test").unwrap()),
|
||||
parent_client.clone(),
|
||||
),
|
||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||
runtime.path().to_path_buf(),
|
||||
bash_output_dir,
|
||||
workspace_root.clone(),
|
||||
Some(workdir_tool_broker),
|
||||
registry.clone(),
|
||||
manifest.clone(),
|
||||
PromptCatalogSource::builtins_only(),
|
||||
available_profiles,
|
||||
)
|
||||
.with_internal_client(Box::new(ScriptedInternalClient {
|
||||
calls: calls.clone(),
|
||||
parent_scope: spawner_scope,
|
||||
delegated_path: workspace_root,
|
||||
observed_parent_write_revoked: Arc::new(AtomicBool::new(false)),
|
||||
observed_instruction_override: Arc::new(AtomicBool::new(false)),
|
||||
fail_requests: Arc::new(AtomicBool::new(false)),
|
||||
}));
|
||||
let mut input = serde_json::json!({
|
||||
"name": "child", "profile": profile, "instruction": "role.reviewer",
|
||||
"task": "review", "scope": [{"target": ".", "permission": "write"}],
|
||||
"command": true,
|
||||
});
|
||||
if review {
|
||||
input["review"] = serde_json::json!({"ticket_id": "T1", "merge_request_id": "MR1"});
|
||||
}
|
||||
tool.execute(
|
||||
&input.to_string(),
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("profile={profile}: {error:?}"));
|
||||
let record = registry.get_internal("child").unwrap();
|
||||
assert_eq!(
|
||||
record.session.wait_until_idle().await,
|
||||
InternalWorkerSessionStatus::Idle
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(
|
||||
manifest.feature.drive.enabled,
|
||||
"parent must keep Drive enabled"
|
||||
);
|
||||
assert!(
|
||||
!record
|
||||
.installed_tools
|
||||
.iter()
|
||||
.any(|name| name.starts_with("Drive")),
|
||||
"profile={profile}, installed tools={:?}",
|
||||
record.installed_tools
|
||||
);
|
||||
let child_client = tool
|
||||
.installed_child_workspace_client
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap();
|
||||
let before = parent_client.requests.lock().unwrap().len();
|
||||
assert!(
|
||||
matches!(child_client.execute(drive_request), Err(WorkspaceClientError::Unavailable(message)) if message.contains("Drive access denied")),
|
||||
"profile={profile}"
|
||||
);
|
||||
assert_eq!(parent_client.requests.lock().unwrap().len(), before);
|
||||
if review {
|
||||
assert_eq!(
|
||||
child_client.reviewer_context(),
|
||||
Some(&ReviewerContext {
|
||||
ticket_id: "T1".into(),
|
||||
merge_request_id: "MR1".into(),
|
||||
})
|
||||
);
|
||||
child_client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/workspace-test/merge-requests/MR1/reviews",
|
||||
"{\"verdict\":\"approve\"}",
|
||||
))
|
||||
.unwrap();
|
||||
let requests = parent_client.requests.lock().unwrap();
|
||||
let review_body: serde_json::Value =
|
||||
serde_json::from_str(requests.last().unwrap().body.as_ref().unwrap()).unwrap();
|
||||
assert!(
|
||||
review_body["capability_token"]
|
||||
.as_str()
|
||||
.is_some_and(|token| !token.is_empty())
|
||||
);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.any(|request| request.path.ends_with("/review-capabilities"))
|
||||
);
|
||||
}
|
||||
record.session.stop().await.unwrap();
|
||||
registry.remove_internal("child").await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_subworker_spawn_keeps_worker_host_output_path_out_of_workdir_scope() {
|
||||
let runtime = TempDir::new().unwrap();
|
||||
|
||||
+363
-26
@@ -14,7 +14,9 @@ use std::sync::{Arc, Mutex};
|
||||
use agen::Engine;
|
||||
use agen::llm_client::client::LlmClient;
|
||||
use agen::state::Mutable;
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use agen::tool::{
|
||||
Attachment, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
@@ -99,8 +101,8 @@ pub struct WipCallContext {
|
||||
pub security_context: String,
|
||||
}
|
||||
|
||||
/// Successful operation value. Compatibility projections can retain an ordinary
|
||||
/// ToolOutput so images and pruning semantics remain on the existing Engine path.
|
||||
/// Successful operation value. Native attachments and compatibility ToolOutputs
|
||||
/// use the existing Engine side channel, separate from the validated WIP value.
|
||||
pub struct WipOperationOutput {
|
||||
pub value: Value,
|
||||
tool_output: Option<ToolOutput>,
|
||||
@@ -125,6 +127,20 @@ impl WipOperationOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach model-visible binary content without replacing the native result
|
||||
/// or its exact post-operation validator. Invoke returns these attachments
|
||||
/// through ToolOutput so Engine capture/history can persist their bytes.
|
||||
pub fn with_attachments(mut self, attachments: Vec<Attachment>) -> Self {
|
||||
self.tool_output
|
||||
.get_or_insert_with(|| ToolOutput {
|
||||
summary: String::new(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
.attachments = attachments;
|
||||
self
|
||||
}
|
||||
|
||||
fn compatibility(value: Value, output: ToolOutput) -> Self {
|
||||
Self {
|
||||
value,
|
||||
@@ -134,6 +150,13 @@ impl WipOperationOutput {
|
||||
}
|
||||
}
|
||||
|
||||
enum WipToolOutput {
|
||||
/// Only binary attachments accompany the validated native completion.
|
||||
Native(ToolOutput),
|
||||
/// Existing compatibility/error output retains its summary and pruning policy.
|
||||
Passthrough(Result<ToolOutput, ToolError>),
|
||||
}
|
||||
|
||||
pub enum WipOperationError {
|
||||
Protocol(ProtocolError),
|
||||
Cancelled(ToolOutput),
|
||||
@@ -288,6 +311,13 @@ pub trait WipSubtreeProvider: Send + Sync {
|
||||
/// looking up a mutable ancestor after resolving the target. Self-scoped
|
||||
/// publishers can use `WipPublication::self_scoped` on their pinned snapshot.
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError>;
|
||||
/// Routing hint for target-qualified local Interface names in an ancestor
|
||||
/// scope. This grants no authority: Host re-resolves one coherent current
|
||||
/// publication, checks the exact reference, and forbids escaping this owner.
|
||||
/// Self/shared-scope providers retain the ordinary scope Object route.
|
||||
fn interface_target(&self, reference: &InterfaceReference) -> Option<String> {
|
||||
Some(reference.scope.clone())
|
||||
}
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError>;
|
||||
}
|
||||
|
||||
@@ -1248,8 +1278,29 @@ impl WipHost {
|
||||
reference
|
||||
.validate()
|
||||
.map_err(|e| protocol_error(ProtocolErrorCode::InvalidRequest, e.to_string()))?;
|
||||
let target = if let Some(mount) = self.subtree(&reference.scope) {
|
||||
let target = mount.provider.interface_target(reference).ok_or_else(|| {
|
||||
protocol_error(
|
||||
ProtocolErrorCode::InterfaceNotFound,
|
||||
"interface is not published",
|
||||
)
|
||||
})?;
|
||||
if wip_protocol::validate_path(&target).is_err()
|
||||
|| !self
|
||||
.subtree(&target)
|
||||
.is_some_and(|owner| owner.root == mount.root)
|
||||
{
|
||||
return Err(protocol_error(
|
||||
ProtocolErrorCode::InterfaceNotFound,
|
||||
"interface target is outside its provider",
|
||||
));
|
||||
}
|
||||
target
|
||||
} else {
|
||||
reference.scope.clone()
|
||||
};
|
||||
if let Some(publication) = self
|
||||
.publication_for_interface_live(&reference.scope, reference)
|
||||
.publication_for_interface_live(&target, reference)
|
||||
.await?
|
||||
&& publication.projection.interface == *reference
|
||||
&& let Some(scope) = publication.scope
|
||||
@@ -2227,15 +2278,22 @@ impl WipRuntime {
|
||||
guard.disarm();
|
||||
let completion = completion.map_err(client_tool_error)?;
|
||||
self.push_audit(prepared.id, path, operation, audit_outcome);
|
||||
if let Some(result) = passthrough {
|
||||
if result.is_ok() {
|
||||
self.metrics
|
||||
.operation_successes
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
let mut attachments = Vec::new();
|
||||
if let Some(passthrough) = passthrough {
|
||||
match passthrough {
|
||||
WipToolOutput::Passthrough(result) => {
|
||||
if result.is_ok() {
|
||||
self.metrics
|
||||
.operation_successes
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
WipToolOutput::Native(output) => attachments = output.attachments,
|
||||
}
|
||||
return result;
|
||||
}
|
||||
render_call_completion(completion).map(|output| {
|
||||
render_call_completion(completion).map(|mut output| {
|
||||
output.attachments = attachments;
|
||||
self.metrics
|
||||
.operation_successes
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -2338,14 +2396,7 @@ impl WipRuntime {
|
||||
&self,
|
||||
request: &Request<Vec<u8>>,
|
||||
execution: ToolExecutionContext,
|
||||
) -> Result<
|
||||
(
|
||||
Response<Vec<u8>>,
|
||||
Option<Result<ToolOutput, ToolError>>,
|
||||
WipAuditOutcome,
|
||||
),
|
||||
ToolError,
|
||||
> {
|
||||
) -> Result<(Response<Vec<u8>>, Option<WipToolOutput>, WipAuditOutcome), ToolError> {
|
||||
let rejected = |error: ProtocolError| -> Result<_, ToolError> {
|
||||
Ok((
|
||||
encode_protocol_error_response(
|
||||
@@ -2385,6 +2436,7 @@ impl WipRuntime {
|
||||
return rejected(error);
|
||||
}
|
||||
let projection = projection.projection;
|
||||
let kind = projection.kind;
|
||||
let descriptor = projection.descriptor.clone();
|
||||
let target_validator = projection.object.validator.clone();
|
||||
let request_value =
|
||||
@@ -2443,8 +2495,10 @@ impl WipRuntime {
|
||||
.map_err(|error| ToolError::Internal(error.to_string()))?;
|
||||
return Ok((
|
||||
response,
|
||||
Some(Err(ToolError::ExecutionFailed(format!(
|
||||
"WIP operation outcome unknown; do not retry automatically: {message}"
|
||||
Some(WipToolOutput::Passthrough(Err(ToolError::ExecutionFailed(
|
||||
format!(
|
||||
"WIP operation outcome unknown; do not retry automatically: {message}"
|
||||
),
|
||||
)))),
|
||||
WipAuditOutcome::OutcomeUnknown,
|
||||
));
|
||||
@@ -2452,7 +2506,10 @@ impl WipRuntime {
|
||||
};
|
||||
Ok((
|
||||
response,
|
||||
output.tool_output.map(Ok),
|
||||
output.tool_output.map(|output| match kind {
|
||||
WipProjectionKind::Native => WipToolOutput::Native(output),
|
||||
WipProjectionKind::Compatibility => WipToolOutput::Passthrough(Ok(output)),
|
||||
}),
|
||||
WipAuditOutcome::Success,
|
||||
))
|
||||
}
|
||||
@@ -2477,7 +2534,9 @@ impl WipRuntime {
|
||||
.map_err(|encode| ToolError::Internal(encode.to_string()))?;
|
||||
Ok((
|
||||
response,
|
||||
Some(Err(ToolError::Cancelled(output))),
|
||||
Some(WipToolOutput::Passthrough(Err(ToolError::Cancelled(
|
||||
output,
|
||||
)))),
|
||||
WipAuditOutcome::Cancelled,
|
||||
))
|
||||
}
|
||||
@@ -2493,7 +2552,9 @@ impl WipRuntime {
|
||||
.map_err(|encode| ToolError::Internal(encode.to_string()))?;
|
||||
Ok((
|
||||
response,
|
||||
Some(Err(ToolError::Interrupted(output))),
|
||||
Some(WipToolOutput::Passthrough(Err(ToolError::Interrupted(
|
||||
output,
|
||||
)))),
|
||||
WipAuditOutcome::Interrupted,
|
||||
))
|
||||
}
|
||||
@@ -2506,8 +2567,10 @@ impl WipRuntime {
|
||||
.map_err(|encode| ToolError::Internal(encode.to_string()))?;
|
||||
Ok((
|
||||
response,
|
||||
Some(Err(ToolError::ExecutionFailed(format!(
|
||||
"WIP operation outcome unknown; do not retry automatically: {message}"
|
||||
Some(WipToolOutput::Passthrough(Err(ToolError::ExecutionFailed(
|
||||
format!(
|
||||
"WIP operation outcome unknown; do not retry automatically: {message}"
|
||||
),
|
||||
)))),
|
||||
WipAuditOutcome::OutcomeUnknown,
|
||||
))
|
||||
@@ -5072,6 +5135,280 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeImage {
|
||||
invalid_result: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for NativeImage {
|
||||
async fn call(
|
||||
&self,
|
||||
_operation: &str,
|
||||
_arguments: &BTreeMap<String, Value>,
|
||||
_context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let value = if self.invalid_result {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::String("Attached native image".into())
|
||||
};
|
||||
Ok(
|
||||
WipOperationOutput::native_with_validator(value, vec![7]).with_attachments(vec![
|
||||
Attachment::Image(agen::tool::ImageAttachment::new(
|
||||
"image/png",
|
||||
include_bytes!("../../tools/tests/fixtures/view-image/valid.png").to_vec(),
|
||||
)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn native_image_registry(invalid_result: bool) -> WipMountRegistry {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("native", "native").unwrap();
|
||||
registry
|
||||
.mount(WipProjection {
|
||||
route: "/native/image".into(),
|
||||
capability: "native:image".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: "image".into(),
|
||||
description: None,
|
||||
interfaces: vec![root_reference("yoi.native/image/v1")],
|
||||
r#ref: Some("native:image".into()),
|
||||
validator: Some(vec![1]),
|
||||
},
|
||||
interface: root_reference("yoi.native/image/v1"),
|
||||
descriptor: InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: None,
|
||||
types: Vec::new(),
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "view".into(),
|
||||
documentation: None,
|
||||
parameters: Vec::new(),
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
},
|
||||
}],
|
||||
},
|
||||
interface_validator: Some(vec![1]),
|
||||
handler: Arc::new(NativeImage { invalid_result }),
|
||||
})
|
||||
.unwrap();
|
||||
registry
|
||||
}
|
||||
|
||||
fn native_image_invoke() -> String {
|
||||
json!({
|
||||
"path":"/native/image",
|
||||
"interface":{"scope":"/","name":"yoi.native/image/v1"},
|
||||
"operation":"view",
|
||||
"arguments":{}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_invoke_returns_image_bytes_and_preserves_result_and_validator() {
|
||||
let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
|
||||
let runtime = install_wip_mode_with_mounts(
|
||||
&mut engine,
|
||||
None,
|
||||
"worker-a".into(),
|
||||
native_image_registry(false),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = engine.tool_server_handle();
|
||||
tools.flush_pending();
|
||||
tools
|
||||
.call_tool(
|
||||
"Inspect",
|
||||
r#"{"path":"/native/image"}"#,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let output = tools
|
||||
.call_tool(
|
||||
"Invoke",
|
||||
&native_image_invoke(),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.summary, "WIP operation completed");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Json>(output.content.as_deref().unwrap()).unwrap(),
|
||||
json!("Attached native image")
|
||||
);
|
||||
let [Attachment::Image(image)] = output.attachments.as_slice() else {
|
||||
panic!("Invoke must return an image, not a URL")
|
||||
};
|
||||
assert_eq!(image.mime_type(), "image/png");
|
||||
assert_eq!(
|
||||
image.data(),
|
||||
include_bytes!("../../tools/tests/fixtures/view-image/valid.png")
|
||||
);
|
||||
let state = runtime.state.lock().unwrap();
|
||||
let calls = state.client.call_history(&state.session).unwrap();
|
||||
assert!(
|
||||
matches!(&calls.back().unwrap().outcome, CallOutcome::Success(response) if response.result == Value::String("Attached native image".into()) && response.validator == Some(vec![7]))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_attachments_do_not_bypass_return_value_validation() {
|
||||
let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
|
||||
install_wip_mode_with_mounts(
|
||||
&mut engine,
|
||||
None,
|
||||
"worker-a".into(),
|
||||
native_image_registry(true),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = engine.tool_server_handle();
|
||||
tools.flush_pending();
|
||||
tools
|
||||
.call_tool(
|
||||
"Inspect",
|
||||
r#"{"path":"/native/image"}"#,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = tools
|
||||
.call_tool(
|
||||
"Invoke",
|
||||
&native_image_invoke(),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("outcome unknown"), "{error}");
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct NativeImageClient {
|
||||
events: Arc<Mutex<VecDeque<Vec<agen::llm_client::event::Event>>>>,
|
||||
requests: Arc<Mutex<Vec<LlmRequest>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for NativeImageClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
async fn stream(&self, request: LlmRequest) -> Result<ResponseStream, ClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
let events = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected model request");
|
||||
Ok(Box::pin(stream::iter(events.into_iter().map(Ok))))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_invoke_image_survives_engine_capture_and_durable_history_replay() {
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
let completed = || {
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})
|
||||
};
|
||||
let client = NativeImageClient {
|
||||
events: Arc::new(Mutex::new(VecDeque::from([
|
||||
vec![
|
||||
Event::tool_use_start(0, "native-image-call", "Invoke"),
|
||||
Event::tool_input_delta(0, &native_image_invoke()),
|
||||
Event::tool_use_stop(0),
|
||||
completed(),
|
||||
],
|
||||
vec![completed()],
|
||||
]))),
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let mut engine = Engine::new(client.clone());
|
||||
install_wip_mode_with_mounts(
|
||||
&mut engine,
|
||||
None,
|
||||
"worker-a".into(),
|
||||
native_image_registry(false),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = engine.tool_server_handle();
|
||||
tools.flush_pending();
|
||||
tools
|
||||
.call_tool(
|
||||
"Inspect",
|
||||
r#"{"path":"/native/image"}"#,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&captured);
|
||||
engine.on_history_append(move |item| {
|
||||
sink.lock().unwrap().push(item.clone());
|
||||
Ok(())
|
||||
});
|
||||
let mut history = agen::History::new();
|
||||
assert!(matches!(
|
||||
engine
|
||||
.run(&mut history, "view the native image")
|
||||
.await
|
||||
.result,
|
||||
agen::EngineRunExit::Finished
|
||||
));
|
||||
let saved = history.items().find(|item| matches!(item, agen::Item::ToolResult { call_id, .. } if call_id == "native-image-call")).unwrap();
|
||||
let agen::Item::ToolResult {
|
||||
attachments,
|
||||
is_error,
|
||||
content,
|
||||
..
|
||||
} = saved
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(!is_error);
|
||||
let [Attachment::Image(image)] = attachments.as_slice() else {
|
||||
panic!("durable Invoke result lost image")
|
||||
};
|
||||
assert_eq!(
|
||||
image.data(),
|
||||
include_bytes!("../../tools/tests/fixtures/view-image/valid.png")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Json>(content.as_deref().unwrap()).unwrap(),
|
||||
json!("Attached native image")
|
||||
);
|
||||
assert!(captured.lock().unwrap().iter().any(|item| item == saved));
|
||||
assert!(
|
||||
client.requests.lock().unwrap()[1]
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item == saved)
|
||||
);
|
||||
let log = session_store::LogEntry::AnnotatedToolResult {
|
||||
ts: 1,
|
||||
entry: session_store::LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::from(saved),
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
},
|
||||
};
|
||||
let serialized = serde_json::to_string(&log).unwrap();
|
||||
let restored: session_store::LogEntry = serde_json::from_str(&serialized).unwrap();
|
||||
assert_eq!(&session_store::collect_state(&[restored]).history[0], saved);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end() {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
|
||||
@@ -538,3 +538,84 @@ async fn same_name_scope_replacement_or_loss_of_ref_rejects_old_metadata_without
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
struct TargetQualifiedProvider {
|
||||
inner: Arc<AncestorProvider>,
|
||||
target_hint: Mutex<Option<String>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for TargetQualifiedProvider {
|
||||
fn interface_target(&self, _: &InterfaceReference) -> Option<String> {
|
||||
self.target_hint.lock().unwrap().clone()
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
self.inner.publication(path).await
|
||||
}
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
self.inner.children(path).await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ancestor_interface_target_hint_fetches_one_current_publication_and_never_grants_authority()
|
||||
{
|
||||
let (_, inner) = fixture("/github", true, false);
|
||||
let provider = Arc::new(TargetQualifiedProvider {
|
||||
inner: inner.clone(),
|
||||
target_hint: Mutex::new(Some(inner.target_path.clone())),
|
||||
});
|
||||
let mut mounts = WipMountRegistry::new();
|
||||
mounts.allocate_namespace("scope", "github").unwrap();
|
||||
mounts
|
||||
.mount(projection(
|
||||
"/github",
|
||||
"/github",
|
||||
"read",
|
||||
inner.calls.clone(),
|
||||
))
|
||||
.unwrap();
|
||||
mounts
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: "/github".into(),
|
||||
provider: provider.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
let host = WipHost::new(mounts);
|
||||
let selected = reference("/github", "read");
|
||||
let fetched = host.fetch_interface_live(&selected).await.unwrap();
|
||||
assert_eq!(fetched.scope_ref, Some("live-scope".into()));
|
||||
assert_eq!(
|
||||
inner.state.lock().unwrap().lookups,
|
||||
[inner.target_path.clone()]
|
||||
);
|
||||
// A hint must not fabricate a Descriptor or restore a deleted scope lifetime.
|
||||
inner.state.lock().unwrap().visible = false;
|
||||
assert_eq!(
|
||||
host.fetch_interface_live(&selected).await.unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
inner.state.lock().unwrap().visible = true;
|
||||
let forged = reference("/github", "not-published");
|
||||
assert_eq!(
|
||||
host.fetch_interface_live(&forged).await.unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
for hint in [
|
||||
None,
|
||||
Some("/outside"),
|
||||
Some("/github-extra/item"),
|
||||
Some("/github/../item"),
|
||||
Some("/github/missing"),
|
||||
] {
|
||||
*provider.target_hint.lock().unwrap() = hint.map(str::to_owned);
|
||||
inner.state.lock().unwrap().lookups.clear();
|
||||
assert_eq!(
|
||||
host.fetch_interface_live(&selected).await.unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
if hint != Some("/github/missing") {
|
||||
assert!(inner.state.lock().unwrap().lookups.is_empty());
|
||||
}
|
||||
}
|
||||
assert_eq!(inner.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
@@ -639,6 +639,27 @@ impl WorkspaceRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw-byte request through a host-bound Workspace client, separate from JSON requests.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceBinaryRequest {
|
||||
pub method: WorkspaceRequestMethod,
|
||||
pub path: String,
|
||||
pub body: Option<Vec<u8>>,
|
||||
pub max_response_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceBinaryResponse {
|
||||
pub status: u16,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl WorkspaceBinaryResponse {
|
||||
pub fn is_success(&self) -> bool {
|
||||
(200..300).contains(&self.status)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkspaceRequestMethod {
|
||||
Get,
|
||||
@@ -859,6 +880,24 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
||||
self.execute(request)
|
||||
}
|
||||
|
||||
/// Binary transport is unavailable unless a host-bound implementation opts in.
|
||||
fn execute_binary(
|
||||
&self,
|
||||
_request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
Err(WorkspaceClientError::Unavailable(
|
||||
"Workspace binary transport is unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
_timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.execute_binary(request)
|
||||
}
|
||||
|
||||
fn execute_server_operation(
|
||||
&self,
|
||||
operation: WorkspaceServerOperation,
|
||||
@@ -961,6 +1000,31 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient {
|
||||
self.inner.current_prompt_projection(minimum_revision)
|
||||
}
|
||||
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
if request.method != WorkspaceRequestMethod::Get {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
"Reviewer child binary Workspace authority is read-only".to_string(),
|
||||
));
|
||||
}
|
||||
self.inner.execute_binary(request)
|
||||
}
|
||||
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
if request.method != WorkspaceRequestMethod::Get {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
"Reviewer child binary Workspace authority is read-only".to_string(),
|
||||
));
|
||||
}
|
||||
self.inner.execute_binary_with_timeout(request, timeout)
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
mut request: WorkspaceRequest,
|
||||
@@ -1157,6 +1221,123 @@ mod reviewer_client_tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_transport_defaults_to_denial_without_json_fallback() {
|
||||
let client = RecordingWorkspaceClient::default();
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: "/api/w/ws/drive/entries/a/content".into(),
|
||||
body: None,
|
||||
max_response_bytes: 64 * 1024,
|
||||
};
|
||||
assert!(matches!(
|
||||
client.execute_binary(request.clone()),
|
||||
Err(WorkspaceClientError::Unavailable(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
client.execute_binary_with_timeout(request, Duration::from_secs(1)),
|
||||
Err(WorkspaceClientError::Unavailable(_))
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct BinaryRecordingClient {
|
||||
requests: Mutex<Vec<(WorkspaceBinaryRequest, Option<Duration>)>>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for BinaryRecordingClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("ws")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"binary-recording"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(&self, _: WorkspaceRequest) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
panic!("binary request must not use JSON fallback")
|
||||
}
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push((request, None));
|
||||
Ok(WorkspaceBinaryResponse {
|
||||
status: 200,
|
||||
body: vec![0, 255],
|
||||
})
|
||||
}
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push((request, Some(timeout)));
|
||||
Ok(WorkspaceBinaryResponse {
|
||||
status: 200,
|
||||
body: vec![0, 255],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_binary_get_preserves_request_limit_and_timeout() {
|
||||
let inner = Arc::new(BinaryRecordingClient::default());
|
||||
let client = reviewer_client(inner.clone());
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: "/api/w/ws/drive/entries/a/content?offset=2&limit=4".into(),
|
||||
body: None,
|
||||
max_response_bytes: 4,
|
||||
};
|
||||
assert_eq!(
|
||||
client.execute_binary(request.clone()).unwrap().body,
|
||||
vec![0, 255]
|
||||
);
|
||||
let timeout = Duration::from_secs(2);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_binary_with_timeout(request.clone(), timeout)
|
||||
.unwrap()
|
||||
.body,
|
||||
vec![0, 255]
|
||||
);
|
||||
assert_eq!(
|
||||
*inner.requests.lock().unwrap(),
|
||||
vec![(request.clone(), None), (request, Some(timeout))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_binary_mutations_are_denied_even_for_attested_review_path() {
|
||||
let inner = Arc::new(BinaryRecordingClient::default());
|
||||
let client = reviewer_client(inner.clone());
|
||||
for method in [
|
||||
WorkspaceRequestMethod::Post,
|
||||
WorkspaceRequestMethod::Put,
|
||||
WorkspaceRequestMethod::Patch,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
] {
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method,
|
||||
path: "/api/w/ws/merge-requests/MR1/reviews".into(),
|
||||
body: Some(vec![0, 255]),
|
||||
max_response_bytes: 4,
|
||||
};
|
||||
assert!(matches!(
|
||||
client.execute_binary(request.clone()),
|
||||
Err(WorkspaceClientError::Unavailable(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
client.execute_binary_with_timeout(request, Duration::from_secs(1)),
|
||||
Err(WorkspaceClientError::Unavailable(_))
|
||||
));
|
||||
}
|
||||
assert!(inner.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewer_child_client_allows_typed_ticket_reads() {
|
||||
let inner = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -56,6 +56,7 @@ zeroize.workspace = true
|
||||
webauthn-rs = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wip-protocol.workspace = true
|
||||
agen.workspace = true
|
||||
tempfile.workspace = true
|
||||
tools.workspace = true
|
||||
|
||||
@@ -2632,34 +2632,30 @@ fn runtime_request_proof_rejection(
|
||||
method: &str,
|
||||
path: &str,
|
||||
workspace_id: &str,
|
||||
error: &crate::worker_source::WorkerMutationSourceProofError,
|
||||
error: &crate::worker_source::RuntimeRequestProofError,
|
||||
) -> Response {
|
||||
use crate::worker_source::WorkerMutationSourceProofError;
|
||||
|
||||
// Do not format the error itself: Authority and MissingPermission can carry
|
||||
// internal or request-derived strings. Only emit allowlisted reason codes.
|
||||
let reason = match error {
|
||||
WorkerMutationSourceProofError::Missing => "missing",
|
||||
WorkerMutationSourceProofError::Invalid => "invalid",
|
||||
WorkerMutationSourceProofError::WrongAudience => "wrong_audience",
|
||||
WorkerMutationSourceProofError::WrongWorkspace => "wrong_workspace",
|
||||
WorkerMutationSourceProofError::WrongActor => "wrong_actor",
|
||||
WorkerMutationSourceProofError::MissingPermission(_) => "missing_permission",
|
||||
WorkerMutationSourceProofError::Expired => "expired",
|
||||
WorkerMutationSourceProofError::RevokedRuntimeTrust => "runtime_trust_missing_or_revoked",
|
||||
WorkerMutationSourceProofError::Replay => "replay",
|
||||
WorkerMutationSourceProofError::WorkerCatalogMembership => "worker_catalog_membership",
|
||||
WorkerMutationSourceProofError::Authority(_) => "authority_unavailable",
|
||||
};
|
||||
// Never format an error or raw claims. IDs are fixed-length fingerprints;
|
||||
// verification labels describe proof-check progress, not authorization.
|
||||
let path = path.split('?').next().unwrap_or(path);
|
||||
let log_path = bounded_auth_log_value(path, 512);
|
||||
let method = bounded_auth_log_value(method, 32);
|
||||
let workspace_id = bounded_auth_log_value(workspace_id, 128);
|
||||
let request_id = uuid::Uuid::now_v7().to_string();
|
||||
tracing::warn!(
|
||||
target: "yoi::auth",
|
||||
event = "runtime_request_proof_rejected",
|
||||
method,
|
||||
path,
|
||||
workspace_id,
|
||||
method, path = log_path, workspace_id, request_id,
|
||||
status = 401,
|
||||
reason,
|
||||
reason = error.reason,
|
||||
proof_verification = error.verification,
|
||||
claimed_runtime_id_hash = error.runtime_id_hash.as_deref(),
|
||||
claimed_worker_id_hash = error.worker_id_hash.as_deref(),
|
||||
claimed_token_id_hash = error.token_id_hash.as_deref(),
|
||||
issued_at_unix = error.iat,
|
||||
expires_at_unix = error.exp,
|
||||
verified_at_unix = error.now,
|
||||
time_delta_seconds = error.time_delta_seconds,
|
||||
time_tolerance_seconds = error.time_delta_seconds.map(|_| 0_u64),
|
||||
"Runtime request proof rejected",
|
||||
);
|
||||
repository_api_rejection(
|
||||
@@ -2669,6 +2665,17 @@ fn runtime_request_proof_rejection(
|
||||
)
|
||||
}
|
||||
|
||||
fn bounded_auth_log_value(value: &str, max_bytes: usize) -> String {
|
||||
let mut result = String::new();
|
||||
for ch in value.chars().filter(|ch| !ch.is_control()) {
|
||||
if result.len() + ch.len_utf8() > max_bytes {
|
||||
break;
|
||||
}
|
||||
result.push(ch);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Authored configuration editors are user-facing authority, not a generic
|
||||
/// Runtime workspace.request capability. Worker configuration access is only
|
||||
/// through the grant- and connection-bound WIP adapter. Keep runtime projections
|
||||
@@ -30511,7 +30518,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult<WorkerLaunchO
|
||||
display_name: runtime.label,
|
||||
built_in,
|
||||
worker_creation_available: runtime.worker_creation_available,
|
||||
working_directory_required: !built_in,
|
||||
supports_workdir_attachments: !built_in,
|
||||
status: runtime.status,
|
||||
diagnostics: runtime
|
||||
.diagnostics
|
||||
@@ -32764,6 +32771,7 @@ mod tests {
|
||||
mod ticket_worker_claim_tests;
|
||||
mod ticket_worker_launch_tests;
|
||||
mod value_profiles_tests;
|
||||
mod worker_drive_tests;
|
||||
mod worker_operations_tests;
|
||||
include!("server_workspace_config_tests.rs");
|
||||
use super::*;
|
||||
@@ -36780,7 +36788,12 @@ mod tests {
|
||||
assert_eq!(builtin.definition.name, "coder-review");
|
||||
assert_eq!(builtin.selector.to_string(), "builtin:coder-review");
|
||||
assert_eq!(builtin.flow_id, "builtin:coder-review");
|
||||
assert_eq!(builtin.revision, 4);
|
||||
assert_eq!(
|
||||
builtin.revision,
|
||||
flow::builtin_flow_source(flow::CODER_REVIEW_FLOW_SLUG)
|
||||
.unwrap()
|
||||
.revision
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_flow_sources(&api.config.workspace_id)
|
||||
@@ -54157,6 +54170,7 @@ mod tests {
|
||||
WorkdirSessionOperation::List(workdir::ListRequest {
|
||||
path: workdir::WorkdirPath::root(),
|
||||
limit: 10,
|
||||
after: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -55565,7 +55579,10 @@ mod tests {
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(crate::worker_source::WorkerMutationSourceProofError::RevokedRuntimeTrust)
|
||||
Err(crate::worker_source::RuntimeRequestProofError {
|
||||
reason: "runtime_trust_missing_or_revoked",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
@@ -55641,7 +55658,10 @@ mod tests {
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(crate::worker_source::WorkerMutationSourceProofError::WorkerCatalogMembership)
|
||||
Err(crate::worker_source::RuntimeRequestProofError {
|
||||
reason: "worker_catalog_membership",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
@@ -56691,12 +56711,12 @@ mod tests {
|
||||
.iter()
|
||||
.find(|runtime| runtime["runtime_id"] == EMBEDDED_WORKER_RUNTIME_ID)
|
||||
.expect("embedded runtime launch option");
|
||||
assert_eq!(embedded_runtime["working_directory_required"], false);
|
||||
assert_eq!(embedded_runtime["supports_workdir_attachments"], false);
|
||||
let team_runtime = runtimes
|
||||
.iter()
|
||||
.find(|runtime| runtime["runtime_id"] == "team-runtime")
|
||||
.expect("team runtime launch option");
|
||||
assert_eq!(team_runtime["working_directory_required"], true);
|
||||
assert_eq!(team_runtime["supports_workdir_attachments"], true);
|
||||
|
||||
let removal_request = serde_json::json!({
|
||||
"operation_id": "remove-team-runtime",
|
||||
|
||||
@@ -13,62 +13,58 @@ impl Write for CapturedAuthLog {
|
||||
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_proof_diagnostics_redact_error_payloads() {
|
||||
use crate::worker_source::WorkerMutationSourceProofError;
|
||||
|
||||
for (error, reason) in [
|
||||
(
|
||||
WorkerMutationSourceProofError::Authority("private-database-path".to_string()),
|
||||
"authority_unavailable",
|
||||
),
|
||||
(
|
||||
WorkerMutationSourceProofError::MissingPermission("private-permission".to_string()),
|
||||
"missing_permission",
|
||||
),
|
||||
] {
|
||||
let logs = CapturedAuthLog::default();
|
||||
let writer = logs.clone();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
impl CapturedAuthLog {
|
||||
fn subscriber(&self) -> impl tracing::Subscriber + Send + Sync + use<> {
|
||||
let writer = self.clone();
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_writer(move || writer.clone())
|
||||
.finish();
|
||||
tracing::subscriber::with_default(subscriber, || {
|
||||
let response = runtime_request_proof_rejection(
|
||||
"POST",
|
||||
"/api/w/workspace/workers/self/workdir-session/operations?secret=query-secret",
|
||||
"workspace",
|
||||
&error,
|
||||
);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
});
|
||||
let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
|
||||
let event: Value = serde_json::from_str(text.trim()).unwrap();
|
||||
assert_eq!(event["reason"], reason);
|
||||
assert!(!text.contains("private-"), "auth log leaked error details");
|
||||
assert!(
|
||||
!text.contains("query-secret"),
|
||||
"auth log leaked query values"
|
||||
.finish()
|
||||
}
|
||||
fn event(&self) -> (Value, String) {
|
||||
let text = String::from_utf8(self.0.lock().unwrap().clone()).unwrap();
|
||||
let events: Vec<Value> = text
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<Value>(line).unwrap())
|
||||
.filter(|event| event["event"] == "runtime_request_proof_rejected")
|
||||
.collect();
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"missing or duplicated auth diagnostic: {text}"
|
||||
);
|
||||
(events[0].clone(), text)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_routers() {
|
||||
async fn rejected_runtime_proofs_log_reason_and_verification_without_secrets_on_both_routers() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(workspace.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
|
||||
let signer = RuntimeRequestSourceSigner::from_identity(&identity);
|
||||
let revoked = RuntimeIdentityMaterial::generate("revoked-runtime-secret").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &revoked, "revoked-runtime-secret");
|
||||
SqliteWorkspaceStore::open(&api.config.database_path)
|
||||
.unwrap()
|
||||
.revoke_workspace_runtime_binding(
|
||||
TEST_WORKSPACE_ID,
|
||||
"revoked-runtime-secret",
|
||||
"2026-10-08T00:00:00Z",
|
||||
)
|
||||
.unwrap();
|
||||
let spoofed = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let unbound_id = "unverified-runtime-secret".repeat(1000);
|
||||
let unbound = RuntimeIdentityMaterial::generate(&unbound_id).unwrap();
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
|
||||
let target = format!("{path}?access_token=query-secret");
|
||||
let body = "body-secret";
|
||||
@@ -81,24 +77,94 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout
|
||||
];
|
||||
|
||||
for app in apps {
|
||||
for reason in ["expired", "replay", "invalid"] {
|
||||
for (case, verification) in [
|
||||
("expired", "signature_verified"),
|
||||
("issued_in_future", "signature_verified"),
|
||||
("replay", "request_verified"),
|
||||
("invalid_format", "unverified"),
|
||||
("invalid_encoding", "unverified"),
|
||||
("malformed_claims", "unverified"),
|
||||
("invalid_signature", "unverified"),
|
||||
("tampered_signature", "unverified"),
|
||||
("revoked_runtime", "unverified"),
|
||||
("runtime_trust_missing_or_revoked", "unverified"),
|
||||
("audience_mismatch", "signature_verified"),
|
||||
("workspace_mismatch", "signature_verified"),
|
||||
("permission_mismatch", "signature_verified"),
|
||||
("method_mismatch", "signature_verified"),
|
||||
("path_mismatch", "signature_verified"),
|
||||
("body_digest_mismatch", "signature_verified"),
|
||||
("worker_catalog_membership", "request_verified"),
|
||||
] {
|
||||
let reason = match case {
|
||||
"tampered_signature" => "invalid_signature",
|
||||
"revoked_runtime" => "runtime_trust_missing_or_revoked",
|
||||
other => other,
|
||||
};
|
||||
let now = i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap();
|
||||
let proof = if reason == "invalid" {
|
||||
"invalid-proof-secret".to_string()
|
||||
} else {
|
||||
signer
|
||||
let signing_identity = match case {
|
||||
"invalid_signature" => &spoofed,
|
||||
"revoked_runtime" => &revoked,
|
||||
"runtime_trust_missing_or_revoked" => &unbound,
|
||||
_ => &identity,
|
||||
};
|
||||
let proof = match reason {
|
||||
"invalid_format" => "invalid-proof-secret".repeat(1000),
|
||||
"invalid_encoding" => "yoi-runtime-request-v1.!.!".to_string(),
|
||||
"malformed_claims" => "yoi-runtime-request-v1.e30.AA".to_string(), // {} claims
|
||||
_ => RuntimeRequestSourceSigner::from_identity(signing_identity)
|
||||
.issue(
|
||||
"server-test",
|
||||
TEST_WORKSPACE_ID,
|
||||
None,
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
"POST",
|
||||
&target,
|
||||
body.as_bytes(),
|
||||
if reason == "expired" { now - 120 } else { now },
|
||||
30,
|
||||
if reason == "audience_mismatch" {
|
||||
"audience-secret"
|
||||
} else {
|
||||
"server-test"
|
||||
},
|
||||
if reason == "workspace_mismatch" {
|
||||
"workspace-secret"
|
||||
} else {
|
||||
TEST_WORKSPACE_ID
|
||||
},
|
||||
(reason == "worker_catalog_membership").then_some("worker-secret"),
|
||||
if reason == "permission_mismatch" {
|
||||
"permission-secret"
|
||||
} else {
|
||||
WORKSPACE_REQUEST_PERMISSION
|
||||
},
|
||||
if reason == "method_mismatch" {
|
||||
"GET"
|
||||
} else {
|
||||
"POST"
|
||||
},
|
||||
if reason == "path_mismatch" {
|
||||
"/path-secret"
|
||||
} else {
|
||||
&target
|
||||
},
|
||||
if reason == "body_digest_mismatch" {
|
||||
b"different-body-secret"
|
||||
} else {
|
||||
body.as_bytes()
|
||||
},
|
||||
match reason {
|
||||
"expired" => i64::MIN,
|
||||
"issued_in_future" => i64::MAX,
|
||||
_ => now,
|
||||
},
|
||||
if matches!(reason, "expired" | "issued_in_future") {
|
||||
0
|
||||
} else {
|
||||
30
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
};
|
||||
let proof = if case == "tampered_signature" {
|
||||
let (signed, signature) = proof.rsplit_once('.').unwrap();
|
||||
// Flip a significant signature byte, retaining valid base64url.
|
||||
let first = if signature.starts_with('A') { "B" } else { "A" };
|
||||
format!("{signed}.{first}{}", &signature[1..])
|
||||
} else {
|
||||
proof
|
||||
};
|
||||
if reason == "replay" {
|
||||
crate::worker_source::verify_runtime_request_source_proof(
|
||||
@@ -114,15 +180,6 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout
|
||||
.unwrap();
|
||||
}
|
||||
let logs = CapturedAuthLog::default();
|
||||
let writer = logs.clone();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.without_time()
|
||||
.with_ansi(false)
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_writer(move || writer.clone())
|
||||
.finish();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -135,39 +192,206 @@ async fn rejected_runtime_proofs_log_reason_without_request_secrets_on_both_rout
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.with_subscriber(subscriber)
|
||||
.with_subscriber(logs.subscriber())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{reason}");
|
||||
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert!(response_body.is_empty(), "{response_body:?}");
|
||||
|
||||
let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
|
||||
let events: Vec<Value> = text
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str::<Value>(line).unwrap())
|
||||
.filter(|event| event["event"] == "runtime_request_proof_rejected")
|
||||
.collect();
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"missing or duplicated auth diagnostic: {text}"
|
||||
);
|
||||
let event = &events[0];
|
||||
let (event, text) = logs.event();
|
||||
assert_eq!(event["reason"], reason);
|
||||
assert_eq!(event["proof_verification"], verification);
|
||||
assert_eq!(event["status"], 401);
|
||||
assert_eq!(event["method"], "POST");
|
||||
assert_eq!(event["path"], path);
|
||||
assert_eq!(event["workspace_id"], TEST_WORKSPACE_ID);
|
||||
assert!(uuid::Uuid::parse_str(event["request_id"].as_str().unwrap()).is_ok());
|
||||
if verification == "unverified"
|
||||
&& matches!(
|
||||
reason,
|
||||
"invalid_format" | "invalid_encoding" | "malformed_claims"
|
||||
)
|
||||
{
|
||||
assert!(event.get("claimed_runtime_id_hash").is_none());
|
||||
assert!(event.get("claimed_token_id_hash").is_none());
|
||||
} else {
|
||||
let claims =
|
||||
worker_runtime::auth::decode_runtime_request_source_claims(&proof).unwrap();
|
||||
assert_eq!(
|
||||
event["claimed_runtime_id_hash"],
|
||||
crate::worker_source::diagnostic_id_hash(&claims.iss)
|
||||
);
|
||||
assert_eq!(
|
||||
event["claimed_token_id_hash"],
|
||||
crate::worker_source::diagnostic_id_hash(&claims.jti)
|
||||
);
|
||||
if let Some(worker) = &claims.worker_id {
|
||||
assert_eq!(
|
||||
event["claimed_worker_id_hash"],
|
||||
crate::worker_source::diagnostic_id_hash(worker)
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(reason, "expired" | "issued_in_future") {
|
||||
let bound = if reason == "expired" {
|
||||
i64::MIN
|
||||
} else {
|
||||
i64::MAX
|
||||
};
|
||||
assert_eq!(event["issued_at_unix"], bound);
|
||||
assert_eq!(event["expires_at_unix"], bound);
|
||||
let verified = event["verified_at_unix"].as_i64().unwrap();
|
||||
assert_eq!(
|
||||
event["time_delta_seconds"].as_u64(),
|
||||
Some(bound.abs_diff(verified))
|
||||
);
|
||||
assert_eq!(event["time_tolerance_seconds"], 0);
|
||||
} else {
|
||||
assert!(event.get("time_delta_seconds").is_none());
|
||||
assert!(event.get("issued_at_unix").is_none());
|
||||
}
|
||||
for secret in [
|
||||
&proof,
|
||||
&identity.private_key,
|
||||
&spoofed.private_key,
|
||||
&unbound_id,
|
||||
"access_token",
|
||||
"query-secret",
|
||||
body,
|
||||
"bearer-secret",
|
||||
"audience-secret",
|
||||
"workspace-secret",
|
||||
"permission-secret",
|
||||
"path-secret",
|
||||
"worker-secret",
|
||||
"revoked-runtime-secret",
|
||||
&revoked.private_key,
|
||||
] {
|
||||
assert!(!text.contains(secret), "auth log leaked request data");
|
||||
assert!(
|
||||
!text.contains(secret),
|
||||
"auth log leaked request data: {reason}"
|
||||
);
|
||||
}
|
||||
assert!(text.len() < 2200, "unbounded diagnostic: {}", text.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_proof_setup_failure_logs_no_internal_error_and_bounds_request_metadata() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(workspace.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
|
||||
api.config.backend_base_url = None;
|
||||
let proof = RuntimeRequestSourceSigner::from_identity(&identity)
|
||||
.issue(
|
||||
"server-test",
|
||||
TEST_WORKSPACE_ID,
|
||||
None,
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
"POST",
|
||||
"/request",
|
||||
b"{}",
|
||||
90,
|
||||
10,
|
||||
)
|
||||
.unwrap();
|
||||
let error = crate::worker_source::verify_runtime_request_source_proof(
|
||||
&api,
|
||||
&proof,
|
||||
"private-workspace-secret",
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
"POST",
|
||||
"/request",
|
||||
"digest",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let logs = CapturedAuthLog::default();
|
||||
let long_path = format!("/{}?secret=query-secret", "é\n".repeat(1000));
|
||||
let response = tracing::subscriber::with_default(logs.subscriber(), || {
|
||||
runtime_request_proof_rejection(
|
||||
&"M\n".repeat(1000),
|
||||
&long_path,
|
||||
&"w\n".repeat(1000),
|
||||
&error,
|
||||
)
|
||||
});
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert!(
|
||||
to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let (event, text) = logs.event();
|
||||
assert_eq!(event["reason"], "backend_audience_unavailable");
|
||||
assert_eq!(event["proof_verification"], "unverified");
|
||||
for (field, limit) in [("method", 32), ("path", 512), ("workspace_id", 128)] {
|
||||
let value = event[field].as_str().unwrap();
|
||||
assert!(value.len() <= limit);
|
||||
assert!(!value.chars().any(char::is_control));
|
||||
}
|
||||
for secret in [&proof, "private-workspace-secret", "query-secret"] {
|
||||
assert!(!text.contains(secret));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_runtime_proof_401_keeps_generic_envelope_on_both_routers() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(workspace.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, "runtime-test");
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/repositories");
|
||||
for app in [
|
||||
build_router(api.clone()),
|
||||
workspace_server_router(WorkspaceServerApi::new(
|
||||
api.config.clone(),
|
||||
api.store.clone(),
|
||||
)),
|
||||
] {
|
||||
for issued_at in [i64::MIN, i64::MAX] {
|
||||
let proof = RuntimeRequestSourceSigner::from_identity(&identity)
|
||||
.issue(
|
||||
"server-test",
|
||||
TEST_WORKSPACE_ID,
|
||||
None,
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
"GET",
|
||||
&path,
|
||||
b"",
|
||||
issued_at,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(&path)
|
||||
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, &proof)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let actual: Value = serde_json::from_slice(&body).unwrap();
|
||||
let expected = serde_json::to_value(server_api::RepositoryApiError::new(
|
||||
401,
|
||||
"Unauthorized",
|
||||
"invalid runtime request proof",
|
||||
Vec::new(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"auth internals must not expand the external envelope"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -139,26 +139,37 @@ impl worker::WorkspaceClient for ConfigRouterClient {
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
fn config_interface(path: &str) -> String {
|
||||
// Use the published contextual interface identity, not a model-managed validator.
|
||||
let hex = path
|
||||
.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
format!("yoi.workspace-config/node/v1/@/{hex}")
|
||||
fn contextual_reference(base: &str, path: &str) -> wip_protocol::InterfaceReference {
|
||||
wip_protocol::InterfaceReference {
|
||||
scope: path.into(),
|
||||
name: base.into(),
|
||||
}
|
||||
}
|
||||
async fn config_discover(runtime: &worker::wip::WipRuntime, path: &str) {
|
||||
let observed = runtime
|
||||
.discover(path.into(), 0, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
fn config_interface(path: &str) -> wip_protocol::InterfaceReference {
|
||||
// Use the published contextual interface identity, not a model-managed validator.
|
||||
contextual_reference("yoi.workspace-config/node/v1", path)
|
||||
}
|
||||
async fn inspect_interface(runtime: &worker::wip::WipRuntime, path: &str, base: &str) -> Value {
|
||||
let tree = runtime.tree(path.into(), 0, true).await.unwrap();
|
||||
let tree: Value = serde_json::from_str(tree.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(tree["path"], path, "Object must come from live Tree: {tree}");
|
||||
let output = runtime.inspect(path.into(), true).await.unwrap();
|
||||
let observed: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(observed["path"], path);
|
||||
assert!(
|
||||
observed.contains(&config_interface(path)),
|
||||
"interface must come from live discovery: {observed}"
|
||||
observed["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|interface| {
|
||||
interface["reference"] == json!({"scope": path, "name": base})
|
||||
}),
|
||||
"interface must come from live inspection: {observed}"
|
||||
);
|
||||
runtime.inspect(config_interface(path), true).await.unwrap();
|
||||
observed
|
||||
}
|
||||
async fn config_inspect(runtime: &worker::wip::WipRuntime, path: &str) -> Value {
|
||||
inspect_interface(runtime, path, "yoi.workspace-config/node/v1").await
|
||||
}
|
||||
async fn config_call(
|
||||
runtime: &worker::wip::WipRuntime,
|
||||
@@ -167,7 +178,7 @@ async fn config_call(
|
||||
args: Value,
|
||||
) -> std::result::Result<agen::tool::ToolOutput, agen::tool::ToolError> {
|
||||
runtime
|
||||
.call(
|
||||
.invoke(
|
||||
path.into(),
|
||||
config_interface(path),
|
||||
operation.into(),
|
||||
@@ -183,23 +194,11 @@ async fn catalog_call(
|
||||
base: &str,
|
||||
operation: &str,
|
||||
) -> Value {
|
||||
let hex = path
|
||||
.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
let interface = format!("{base}/@/{hex}");
|
||||
let discovered = runtime
|
||||
.discover(path.into(), 0, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
assert!(discovered.contains(&interface), "{discovered}");
|
||||
runtime.inspect(interface.clone(), true).await.unwrap();
|
||||
inspect_interface(runtime, path, base).await;
|
||||
let output = runtime
|
||||
.call(
|
||||
.invoke(
|
||||
path.into(),
|
||||
interface,
|
||||
contextual_reference(base, path),
|
||||
operation.into(),
|
||||
json!({}),
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
@@ -378,7 +377,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
|
||||
.connection_id,
|
||||
attached.connection_id
|
||||
);
|
||||
config_discover(&wip, CONTENT_ROOT).await;
|
||||
config_inspect(&wip, CONTENT_ROOT).await;
|
||||
config_call(
|
||||
&wip,
|
||||
CONTENT_ROOT,
|
||||
@@ -388,15 +387,10 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
|
||||
.await
|
||||
.unwrap();
|
||||
let path = "/workspace-config/notes/wip.md";
|
||||
config_discover(&wip, path).await;
|
||||
assert!(
|
||||
config_call(&wip, path, "read", json!({}))
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap()
|
||||
.contains("original note")
|
||||
);
|
||||
config_inspect(&wip, path).await;
|
||||
let read = config_call(&wip, path, "read", json!({})).await.unwrap();
|
||||
let read: Value = serde_json::from_str(read.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(read["content"], "original note");
|
||||
config_call(
|
||||
&wip,
|
||||
path,
|
||||
@@ -422,7 +416,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
|
||||
config_call(&wip, path, "write", json!({"content":"replacement note"}))
|
||||
.await
|
||||
.unwrap();
|
||||
config_discover(&wip, CONTENT_ROOT).await;
|
||||
config_inspect(&wip, CONTENT_ROOT).await;
|
||||
config_call(&wip,CONTENT_ROOT,"apply_changes",json!({"changes":[
|
||||
{"kind":"create","path":"fragments/prompts.dcdl","content_type":"decodal","content":"{ common = { language = \"CONFIG_WIP_E2E\"; }; }"},
|
||||
{"kind":"update","path":"main.dcdl","content":"{ prompts = import \"./fragments/prompts.dcdl\"; } as WorkspaceConfigSchema"}
|
||||
@@ -436,7 +430,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
|
||||
.unwrap();
|
||||
let prompts = crate::prompt_settings::project_prompts_from_workspace_config(&state).unwrap();
|
||||
assert_eq!(prompts.templates["common.language"], "CONFIG_WIP_E2E");
|
||||
config_discover(&wip, path).await;
|
||||
config_inspect(&wip, path).await;
|
||||
config_call(&wip, path, "delete", json!({})).await.unwrap();
|
||||
assert!(
|
||||
fixture
|
||||
@@ -449,7 +443,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
|
||||
.get(&config_source::VirtualPath::parse("notes/wip.md").unwrap())
|
||||
.is_none()
|
||||
);
|
||||
config_discover(&wip, CONTENT_ROOT).await;
|
||||
config_inspect(&wip, CONTENT_ROOT).await;
|
||||
let before = fixture
|
||||
.api
|
||||
.config_store
|
||||
@@ -492,7 +486,7 @@ async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_im
|
||||
dispatched + 1,
|
||||
"a lost response cannot trigger automatic retry"
|
||||
);
|
||||
config_discover(&wip, CONTENT_ROOT).await;
|
||||
config_inspect(&wip, CONTENT_ROOT).await;
|
||||
let readonly = workspace_config::attach(
|
||||
&fixture.api,
|
||||
&fixture.worker,
|
||||
@@ -633,26 +627,26 @@ async fn assert_config_production_http_adapter(
|
||||
)
|
||||
.unwrap();
|
||||
assert_config_catalog(&wip, &attached).await;
|
||||
config_discover(&wip, "/workspace-config/main.dcdl").await;
|
||||
let main_interface = wip
|
||||
.inspect(config_interface("/workspace-config/main.dcdl"), true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
let main_interface = config_inspect(&wip, "/workspace-config/main.dcdl").await;
|
||||
assert!(
|
||||
!main_interface.contains("\"delete\""),
|
||||
main_interface["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|interface| !interface["signature"].as_str().unwrap().contains("delete(")),
|
||||
"required entrypoint cannot advertise delete: {main_interface}"
|
||||
);
|
||||
let main = config_call(&wip, "/workspace-config/main.dcdl", "read", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
let main: Value = serde_json::from_str(main.content.as_deref().unwrap()).unwrap();
|
||||
assert!(
|
||||
config_call(&wip, "/workspace-config/main.dcdl", "read", json!({}))
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
main["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("WorkspaceConfigSchema")
|
||||
);
|
||||
config_discover(&wip, "/workspace-config").await;
|
||||
config_inspect(&wip, "/workspace-config").await;
|
||||
config_call(
|
||||
&wip,
|
||||
"/workspace-config",
|
||||
|
||||
@@ -3,120 +3,19 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use worker_runtime::auth::{
|
||||
RuntimeRequestSourceExpectation, WorkerMutationActorKind, WorkerMutationOperation,
|
||||
WorkerMutationSourceClaims, WorkerMutationSourceExpectation,
|
||||
decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
|
||||
verify_runtime_request_source, verify_worker_mutation_source_proof,
|
||||
WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims,
|
||||
WorkerMutationSourceExpectation, decode_worker_mutation_source_claims,
|
||||
verify_worker_mutation_source_proof,
|
||||
};
|
||||
use worker_runtime::worker_source::InProcessWorkerMutationProof;
|
||||
|
||||
use crate::server::{ServerConfig, WorkspaceApi};
|
||||
use crate::store::ControlPlaneStore;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerifiedRuntimeRequestSource {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn verify_runtime_request_source_proof(
|
||||
api: &WorkspaceApi,
|
||||
proof: &str,
|
||||
workspace_id: &str,
|
||||
permission: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body_digest: &str,
|
||||
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
|
||||
verify_runtime_request_source_proof_with_store(
|
||||
api.store.as_ref(),
|
||||
&api.config,
|
||||
proof,
|
||||
workspace_id,
|
||||
permission,
|
||||
method,
|
||||
path,
|
||||
body_digest,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn verify_runtime_request_source_proof_with_store(
|
||||
store: &dyn ControlPlaneStore,
|
||||
config: &ServerConfig,
|
||||
proof: &str,
|
||||
workspace_id: &str,
|
||||
permission: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body_digest: &str,
|
||||
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
|
||||
let unverified = decode_runtime_request_source_claims(proof)
|
||||
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
|
||||
let audience = remote_audience(config, workspace_id)?;
|
||||
let trusted = store
|
||||
.get_workspace_runtime_binding(workspace_id, &unverified.iss)
|
||||
.await
|
||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
||||
.filter(|record| record.revoked_at.is_none())
|
||||
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
|
||||
let public_key = trusted.public_key.as_str();
|
||||
let expected = RuntimeRequestSourceExpectation {
|
||||
identity_id: &unverified.iss,
|
||||
audience: audience.as_ref(),
|
||||
workspace_id,
|
||||
worker_id: unverified.worker_id.as_deref(),
|
||||
permission,
|
||||
method,
|
||||
path,
|
||||
body_digest,
|
||||
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||
};
|
||||
let claims =
|
||||
verify_runtime_request_source(proof, public_key, &expected).map_err(map_auth_error)?;
|
||||
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
|
||||
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
|
||||
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
|
||||
.ok_or(WorkerMutationSourceProofError::Expired)?
|
||||
.to_rfc3339();
|
||||
if !store
|
||||
.consume_worker_mutation_source_jti(
|
||||
workspace_id,
|
||||
&claims.iss,
|
||||
&claims.jti,
|
||||
expires_at,
|
||||
now_seconds,
|
||||
&consumed_at,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
|
||||
{
|
||||
return Err(WorkerMutationSourceProofError::Replay);
|
||||
}
|
||||
if let Some(worker_id) = claims.worker_id.as_deref() {
|
||||
let worker = worker_runtime::identity::RuntimeWorkerRef {
|
||||
runtime_id: claims.iss.clone(),
|
||||
worker_id: worker_id.to_owned(),
|
||||
};
|
||||
let member = store
|
||||
.get_worker_registry(workspace_id, &worker)
|
||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
||||
let reserved = store
|
||||
.has_active_worker_create_reservation(workspace_id, &worker)
|
||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
||||
if member.is_none() && !reserved {
|
||||
return Err(WorkerMutationSourceProofError::WorkerCatalogMembership);
|
||||
}
|
||||
store
|
||||
.require_current_worker_singleton_owner(workspace_id, &worker)
|
||||
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
|
||||
}
|
||||
Ok(VerifiedRuntimeRequestSource {
|
||||
runtime_id: claims.iss,
|
||||
worker_id: claims.worker_id,
|
||||
})
|
||||
}
|
||||
mod runtime_request;
|
||||
pub use runtime_request::{
|
||||
RuntimeRequestProofError, VerifiedRuntimeRequestSource, diagnostic_id_hash,
|
||||
verify_runtime_request_source_proof, verify_runtime_request_source_proof_with_store,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PresentedWorkerMutationSourceProof<'a> {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
use worker_runtime::auth::{
|
||||
RuntimeAuthError, RuntimeRequestClaim, RuntimeRequestSourceClaims,
|
||||
RuntimeRequestSourceExpectation, decode_runtime_request_source_claims,
|
||||
verify_runtime_request_source,
|
||||
};
|
||||
|
||||
use super::{remote_audience, unix_now_seconds};
|
||||
use crate::server::{ServerConfig, WorkspaceApi};
|
||||
use crate::store::ControlPlaneStore;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerifiedRuntimeRequestSource {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Safe diagnostics only: never stores proof, raw claims, keys or store errors.
|
||||
/// Verification is progress through proof checks, NOT an authenticated principal.
|
||||
#[derive(Clone, Debug, thiserror::Error)]
|
||||
#[error("invalid runtime request proof")]
|
||||
pub struct RuntimeRequestProofError {
|
||||
pub(crate) reason: &'static str,
|
||||
pub(crate) verification: &'static str,
|
||||
pub(crate) runtime_id_hash: Option<String>,
|
||||
pub(crate) worker_id_hash: Option<String>,
|
||||
pub(crate) token_id_hash: Option<String>,
|
||||
pub(crate) iat: Option<i64>,
|
||||
pub(crate) exp: Option<i64>,
|
||||
pub(crate) now: Option<i64>,
|
||||
pub(crate) time_delta_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
impl RuntimeRequestProofError {
|
||||
fn undecoded() -> Self {
|
||||
Self {
|
||||
reason: "invalid",
|
||||
verification: "unverified",
|
||||
runtime_id_hash: None,
|
||||
worker_id_hash: None,
|
||||
token_id_hash: None,
|
||||
iat: None,
|
||||
exp: None,
|
||||
now: None,
|
||||
time_delta_seconds: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn decoded(claims: &RuntimeRequestSourceClaims) -> Self {
|
||||
Self {
|
||||
runtime_id_hash: Some(diagnostic_id_hash(&claims.iss)),
|
||||
worker_id_hash: claims.worker_id.as_deref().map(diagnostic_id_hash),
|
||||
token_id_hash: Some(diagnostic_id_hash(&claims.jti)),
|
||||
..Self::undecoded()
|
||||
}
|
||||
}
|
||||
|
||||
fn reason(mut self, reason: &'static str) -> Self {
|
||||
self.reason = reason;
|
||||
self
|
||||
}
|
||||
|
||||
fn auth_error(mut self, error: RuntimeAuthError) -> Self {
|
||||
self.reason = match error {
|
||||
RuntimeAuthError::InvalidTokenFormat => "invalid_format",
|
||||
RuntimeAuthError::InvalidBase64(_) => "invalid_encoding",
|
||||
RuntimeAuthError::MalformedClaims(_) => "malformed_claims",
|
||||
RuntimeAuthError::InvalidPublicKeyFormat => "invalid_runtime_public_key",
|
||||
RuntimeAuthError::InvalidSignature => "invalid_signature",
|
||||
RuntimeAuthError::ClaimMismatch(claim) => {
|
||||
self.verification = "signature_verified";
|
||||
match claim {
|
||||
RuntimeRequestClaim::Issuer => "issuer_mismatch",
|
||||
RuntimeRequestClaim::Audience => "audience_mismatch",
|
||||
RuntimeRequestClaim::Workspace => "workspace_mismatch",
|
||||
RuntimeRequestClaim::Worker => "worker_mismatch",
|
||||
RuntimeRequestClaim::Permission => "permission_mismatch",
|
||||
RuntimeRequestClaim::Method => "method_mismatch",
|
||||
RuntimeRequestClaim::Path => "path_mismatch",
|
||||
RuntimeRequestClaim::BodyDigest => "body_digest_mismatch",
|
||||
}
|
||||
}
|
||||
RuntimeAuthError::RequestIssuedInFuture { iat, exp, now } => {
|
||||
self.time(iat, exp, now, iat.abs_diff(now));
|
||||
"issued_in_future"
|
||||
}
|
||||
RuntimeAuthError::RequestExpired { iat, exp, now } => {
|
||||
self.time(iat, exp, now, now.abs_diff(exp));
|
||||
"expired"
|
||||
}
|
||||
_ => "invalid",
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
fn time(&mut self, iat: i64, exp: i64, now: i64, delta: u64) {
|
||||
self.verification = "signature_verified";
|
||||
self.iat = Some(iat);
|
||||
self.exp = Some(exp);
|
||||
self.now = Some(now);
|
||||
self.time_delta_seconds = Some(delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Domain-separated fixed-length fingerprints, not raw request-derived IDs.
|
||||
/// Operators can correlate a known ID by applying this same function.
|
||||
pub fn diagnostic_id_hash(id: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"yoi-runtime-proof-diagnostic-id-v1\0");
|
||||
hash.update(id.as_bytes());
|
||||
hash.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn verify_runtime_request_source_proof(
|
||||
api: &WorkspaceApi,
|
||||
proof: &str,
|
||||
workspace_id: &str,
|
||||
permission: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body_digest: &str,
|
||||
) -> Result<VerifiedRuntimeRequestSource, RuntimeRequestProofError> {
|
||||
verify_runtime_request_source_proof_with_store(
|
||||
api.store.as_ref(),
|
||||
&api.config,
|
||||
proof,
|
||||
workspace_id,
|
||||
permission,
|
||||
method,
|
||||
path,
|
||||
body_digest,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn verify_runtime_request_source_proof_with_store(
|
||||
store: &dyn ControlPlaneStore,
|
||||
config: &ServerConfig,
|
||||
proof: &str,
|
||||
workspace_id: &str,
|
||||
permission: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body_digest: &str,
|
||||
) -> Result<VerifiedRuntimeRequestSource, RuntimeRequestProofError> {
|
||||
let unverified = decode_runtime_request_source_claims(proof)
|
||||
.map_err(|error| RuntimeRequestProofError::undecoded().auth_error(error))?;
|
||||
let diagnostic = RuntimeRequestProofError::decoded(&unverified);
|
||||
let audience = remote_audience(config, workspace_id)
|
||||
.map_err(|_| diagnostic.clone().reason("backend_audience_unavailable"))?;
|
||||
let trusted = store
|
||||
.get_workspace_runtime_binding(workspace_id, &unverified.iss)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
diagnostic
|
||||
.clone()
|
||||
.reason("runtime_trust_authority_unavailable")
|
||||
})?
|
||||
.filter(|record| record.revoked_at.is_none())
|
||||
.ok_or_else(|| {
|
||||
diagnostic
|
||||
.clone()
|
||||
.reason("runtime_trust_missing_or_revoked")
|
||||
})?;
|
||||
let expected = RuntimeRequestSourceExpectation {
|
||||
identity_id: &unverified.iss,
|
||||
audience,
|
||||
workspace_id,
|
||||
worker_id: unverified.worker_id.as_deref(),
|
||||
permission,
|
||||
method,
|
||||
path,
|
||||
body_digest,
|
||||
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||
};
|
||||
let claims =
|
||||
verify_runtime_request_source(proof, &trusted.public_key, &expected).map_err(|error| {
|
||||
// Proof encoding was already decoded successfully. A base64 error
|
||||
// at this stage is in the configured public key, not the proof.
|
||||
let error = match error {
|
||||
RuntimeAuthError::InvalidBase64(_) => RuntimeAuthError::InvalidPublicKeyFormat,
|
||||
other => other,
|
||||
};
|
||||
diagnostic.clone().auth_error(error)
|
||||
})?;
|
||||
let diagnostic = RuntimeRequestProofError {
|
||||
verification: "request_verified",
|
||||
..diagnostic
|
||||
};
|
||||
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
|
||||
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
|
||||
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
|
||||
.ok_or_else(|| diagnostic.clone().reason("verification_time_out_of_range"))?
|
||||
.to_rfc3339();
|
||||
if !store
|
||||
.consume_worker_mutation_source_jti(
|
||||
workspace_id,
|
||||
&claims.iss,
|
||||
&claims.jti,
|
||||
expires_at,
|
||||
now_seconds,
|
||||
&consumed_at,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| diagnostic.clone().reason("replay_authority_unavailable"))?
|
||||
{
|
||||
return Err(diagnostic.reason("replay"));
|
||||
}
|
||||
if let Some(worker_id) = claims.worker_id.as_deref() {
|
||||
let worker = worker_runtime::identity::RuntimeWorkerRef {
|
||||
runtime_id: claims.iss.clone(),
|
||||
worker_id: worker_id.to_owned(),
|
||||
};
|
||||
let member = store
|
||||
.get_worker_registry(workspace_id, &worker)
|
||||
.map_err(|_| {
|
||||
diagnostic
|
||||
.clone()
|
||||
.reason("worker_catalog_authority_unavailable")
|
||||
})?;
|
||||
let reserved = store
|
||||
.has_active_worker_create_reservation(workspace_id, &worker)
|
||||
.map_err(|_| {
|
||||
diagnostic
|
||||
.clone()
|
||||
.reason("worker_reservation_authority_unavailable")
|
||||
})?;
|
||||
if member.is_none() && !reserved {
|
||||
return Err(diagnostic.reason("worker_catalog_membership"));
|
||||
}
|
||||
store
|
||||
.require_current_worker_singleton_owner(workspace_id, &worker)
|
||||
.map_err(|_| {
|
||||
diagnostic
|
||||
.clone()
|
||||
.reason("worker_singleton_authority_denied")
|
||||
})?;
|
||||
}
|
||||
Ok(VerifiedRuntimeRequestSource {
|
||||
runtime_id: claims.iss,
|
||||
worker_id: claims.worker_id,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user