chore: merge develop into hare/develop

This commit is contained in:
2026-10-06 20:45:19 +09:00
248 changed files with 66394 additions and 4659 deletions
Generated
+3
View File
@@ -3490,6 +3490,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.18",
"tokio",
"ts-rs",
"uuid",
@@ -5374,6 +5375,7 @@ dependencies = [
"toml",
"unicode-width",
"uuid",
"worker-runtime",
]
[[package]]
@@ -6416,6 +6418,7 @@ dependencies = [
"agen",
"arc-swap",
"async-trait",
"axum",
"base64 0.22.1",
"chrono",
"clap",
+12
View File
@@ -10,6 +10,7 @@ pub use server_api::{
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
WorkerOperationState as BackendWorkerOperationState,
WorkerRestoreRequest as BackendWorkerRestoreRequest,
WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult,
WorkerRestoreState as BackendWorkerRestoreState, WorkerSummary as BackendWorkerSummary,
@@ -259,6 +260,15 @@ impl fmt::Display for BackendRuntimeClientError {
}
}
impl BackendRuntimeClientError {
/// A stale intent is not a rejected Restore operation and must not be retried
/// with a silently refreshed token.
pub fn is_restore_observation_conflict(&self) -> bool {
matches!(self, Self::ContractApi(server_api::client_support::ClientError::Public { status, error })
if *status == reqwest::StatusCode::CONFLICT && error.error == "restore_observation_conflict")
}
}
impl std::error::Error for BackendRuntimeClientError {}
impl From<server_api::ServerApiClientError> for BackendRuntimeClientError {
@@ -502,6 +512,7 @@ pub async fn list_backend_stopped_workers(
pub async fn restore_backend_worker(
target: &BackendRuntimeTarget,
request: BackendWorkerRestoreRequest,
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
validate_target(target)?;
let api = BackendApiClient::from_stored_token(&target.base_url)?;
@@ -514,6 +525,7 @@ pub async fn restore_backend_worker(
ticket_id: None,
assignment_operation_id: None,
},
request,
)
.await
.map_err(Into::into)
+6 -6
View File
@@ -26,12 +26,12 @@ pub use backend_runtime::{
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySource, BackendWorkingDirectorySummary,
connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options,
list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session,
restore_backend_worker,
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreRequest,
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerRestoreState,
BackendWorkerSummary, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySource,
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
observe_backend_worker_session, restore_backend_worker,
};
pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
+1 -3
View File
@@ -13,9 +13,7 @@ ignore = "0.4.25"
libc = "0.2"
schemars.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2 = "0.10.9"
tempfile.workspace = true
thiserror.workspace = true
[dev-dependencies]
serde_json.workspace = true
+518
View File
@@ -0,0 +1,518 @@
//! Identity-fenced access adapter for the existing read/edit/write engine.
//!
//! Descriptor-rooted resolution is Linux-only and symlinks/mount crossings fail
//! closed. The final compare and atomic rename are NOT a kernel CAS against an
//! arbitrary external editor: external editors must cooperate with provider
//! serialization. A hostile rename in that last syscall gap is not excluded.
use crate::{AtomicWriteMode, FsAccessPolicy};
use std::fs::{File, Metadata};
use std::io::{Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
/// Identity and state, deliberately not just a content digest. atime is omitted
/// so reading does not invalidate an observation. No host paths are encoded.
pub fn identity_validator(metadata: &Metadata) -> std::io::Result<Vec<u8>> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let mut bytes = b"yoi-checkout-v1".to_vec();
for value in [
metadata.dev(),
metadata.ino(),
metadata.len(),
metadata.mode() as u64,
metadata.mtime() as u64,
metadata.mtime_nsec() as u64,
metadata.ctime() as u64,
metadata.ctime_nsec() as u64,
] {
bytes.extend_from_slice(&value.to_le_bytes());
}
Ok(bytes)
}
#[cfg(not(unix))]
{
let _ = metadata;
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"identity validators unavailable",
))
}
}
/// One pinned target and its save parent. Retained through the shared operation.
pub struct CheckedTarget<'a> {
policy: &'a dyn FsAccessPolicy,
root: File,
relative: PathBuf,
logical: PathBuf,
file: File,
parent: Option<File>,
validator: Vec<u8>,
effects_possible: AtomicBool,
}
impl<'a> CheckedTarget<'a> {
pub fn pin(
root: &File,
relative: &Path,
logical: &Path,
policy: &'a dyn FsAccessPolicy,
write: bool,
) -> std::io::Result<Self> {
policy.check_cancelled()?;
if !(if write {
policy.is_writable_paths(logical, logical)
} else {
policy.is_readable_paths(logical, logical)
}) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"checkout target denied",
));
}
let parent = if relative.as_os_str().is_empty() {
None
} else {
Some(crate::open_beneath_no_symlinks_at(
root,
relative.parent().unwrap_or(Path::new("")),
)?)
};
let file = if let Some(parent) = &parent {
crate::open_beneath_no_symlinks_at(parent, Path::new(relative.file_name().unwrap()))?
} else {
crate::open_beneath_no_symlinks_at(root, Path::new(""))?
};
let metadata = file.metadata()?;
if !metadata.is_file() && !metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"checkout requires regular file or directory",
));
}
let result = Self {
policy,
root: root.try_clone()?,
relative: relative.to_path_buf(),
logical: logical.to_path_buf(),
file,
parent,
validator: identity_validator(&metadata)?,
effects_possible: AtomicBool::new(false),
};
result.confirm()?;
Ok(result)
}
pub fn validator(&self) -> &[u8] {
&self.validator
}
pub fn metadata(&self) -> std::io::Result<Metadata> {
self.file.metadata()
}
pub fn effects_possible(&self) -> bool {
self.effects_possible.load(Ordering::Acquire)
}
pub fn confirm(&self) -> std::io::Result<()> {
self.policy.check_cancelled()?;
if let Some(parent) = &self.parent {
let named_parent = crate::open_beneath_no_symlinks_at(
&self.root,
self.relative.parent().unwrap_or(Path::new("")),
)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let pinned_metadata = parent.metadata()?;
let named_metadata = named_parent.metadata()?;
if (pinned_metadata.dev(), pinned_metadata.ino())
!= (named_metadata.dev(), named_metadata.ino())
{
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"checkout parent identity changed",
));
}
}
}
let named = crate::open_beneath_no_symlinks_at(&self.root, &self.relative)?;
if identity_validator(&named.metadata()?)? != self.validator
|| identity_validator(&self.file.metadata()?)? != self.validator
{
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"checkout observation changed",
));
}
Ok(())
}
/// Refresh a directory observation after Create, retaining the original
/// pinned target/root/parent handles. A replacement named directory cannot
/// supply the validator for effects performed in the original directory.
pub fn refresh_directory_observation(&mut self) -> std::io::Result<()> {
let metadata = self.file.metadata()?;
if !metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Create target must be directory",
));
}
self.validator = identity_validator(&metadata)?;
self.confirm()
}
/// Adapter for no-clobber creation beneath a pinned directory. Both target
/// and destination authorization remain in the provider's normal policy.
pub fn create_access<'b>(&'b self, suffix: PathBuf, logical: PathBuf) -> CheckedCreate<'b, 'a> {
CheckedCreate {
target: self,
suffix,
logical,
}
}
}
impl FsAccessPolicy for CheckedTarget<'_> {
fn is_readable(&self, p: &Path) -> bool {
self.policy.is_readable(p)
}
fn is_writable(&self, p: &Path) -> bool {
self.policy.is_writable(p)
}
fn is_readable_paths(&self, p: &Path, r: &Path) -> bool {
p == self.logical && r == p && self.policy.is_readable_paths(p, r)
}
fn is_writable_paths(&self, p: &Path, r: &Path) -> bool {
p == self.logical && r == p && self.policy.is_writable_paths(p, r)
}
fn check_cancelled(&self) -> std::io::Result<()> {
self.policy.check_cancelled()
}
fn resolve_access_path(&self, p: &Path) -> std::io::Result<PathBuf> {
Ok(p.to_path_buf())
}
fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result<Metadata> {
self.file.metadata()
}
fn open_read_file(&self, _: &Path, _: &Path) -> std::io::Result<File> {
self.confirm()?;
let mut file = self.file.try_clone()?;
file.seek(SeekFrom::Start(0))?;
Ok(file)
}
fn max_write_bytes(&self) -> Option<usize> {
Some(
self.policy
.max_write_bytes()
.unwrap_or(8 * 1024 * 1024)
.min(8 * 1024 * 1024),
)
}
fn max_edit_replacements(&self) -> Option<usize> {
Some(
self.policy
.max_edit_replacements()
.unwrap_or(10_000)
.min(10_000),
)
}
fn atomic_write_file(
&self,
_: &Path,
_: &Path,
content: &[u8],
mode: AtomicWriteMode,
) -> std::io::Result<()> {
self.confirm()?;
let parent = self.parent.as_ref().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "cannot save root")
})?;
crate::atomic_write_beneath_no_symlinks_at(
parent,
Path::new(self.relative.file_name().unwrap()),
content,
mode,
|| {
self.confirm()?;
// From here rename/encoding/post-observation failures have an unknown outcome.
self.effects_possible.store(true, Ordering::Release);
Ok(())
},
)
}
}
pub struct CheckedCreate<'b, 'a> {
target: &'b CheckedTarget<'a>,
suffix: PathBuf,
logical: PathBuf,
}
impl FsAccessPolicy for CheckedCreate<'_, '_> {
fn is_readable(&self, p: &Path) -> bool {
self.target.policy.is_readable(p)
}
fn is_writable(&self, p: &Path) -> bool {
p == self.logical && self.target.policy.is_writable_paths(p, p)
}
fn resolve_access_path(&self, p: &Path) -> std::io::Result<PathBuf> {
Ok(p.to_path_buf())
}
fn check_cancelled(&self) -> std::io::Result<()> {
self.target.policy.check_cancelled()
}
fn max_write_bytes(&self) -> Option<usize> {
self.target.max_write_bytes()
}
fn read_metadata(&self, _: &Path, _: &Path) -> std::io::Result<Metadata> {
match crate::open_beneath_no_symlinks_at(&self.target.file, &self.suffix) {
Ok(_) => Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"Create destination exists",
)),
Err(e) => Err(e),
}
}
fn atomic_write_file(
&self,
_: &Path,
_: &Path,
content: &[u8],
mode: AtomicWriteMode,
) -> std::io::Result<()> {
self.target.confirm()?;
if mode != AtomicWriteMode::CreateNew || !self.target.file.metadata()?.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Create requires directory",
));
}
// Creating parents must not turn a narrow destination grant into
// permission to populate ungranted intermediate directories.
let mut parent = self.target.logical.clone();
let mut components = self.suffix.components().peekable();
while let Some(component) = components.next() {
parent.push(component.as_os_str());
if components.peek().is_some()
&& !self.target.policy.is_writable_paths(&parent, &parent)
{
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Create parent denied",
));
}
}
// Nested mkdir may have effects before failure; never auto-retry.
self.target.effects_possible.store(true, Ordering::Release);
crate::atomic_write_beneath_no_symlinks_at(
&self.target.file,
&self.suffix,
content,
mode,
|| self.target.policy.check_cancelled(),
)
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use crate::{FsError, FsPath, ReadRequest, WriteRequest};
use sha2::{Digest, Sha256};
use std::sync::atomic::AtomicUsize;
struct Access {
path: PathBuf,
counter: AtomicUsize,
trigger: usize,
replacement: bool,
}
impl FsAccessPolicy for Access {
fn is_readable(&self, _: &Path) -> bool {
true
}
fn is_writable(&self, _: &Path) -> bool {
true
}
fn check_cancelled(&self) -> std::io::Result<()> {
if self.counter.fetch_add(1, Ordering::SeqCst) + 1 == self.trigger {
if self.replacement {
std::fs::rename(&self.path, self.path.with_extension("old"))?;
std::fs::write(&self.path, "replacement")?;
} else {
std::fs::write(&self.path, "updated")?;
}
}
Ok(())
}
}
#[test]
fn checked_save_boundary_rejects_name_substitution_without_mutating_replacement() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("a");
std::fs::write(&path, "original").unwrap();
let policy = Access {
path: path.clone(),
counter: AtomicUsize::new(0),
trigger: 4,
replacement: true,
};
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
let checked = CheckedTarget::pin(&handle, Path::new("a"), &path, &policy, true).unwrap();
policy.counter.store(0, Ordering::SeqCst);
let error = crate::run_write(
root.path(),
WriteRequest {
path: FsPath::new("a").unwrap(),
content: b"worker".to_vec(),
expected_hash: Some(Sha256::digest(b"original").into()),
},
&checked,
)
.unwrap_err();
assert!(
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::WouldBlock)
);
assert!(!checked.effects_possible());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement");
assert_eq!(
std::fs::read_to_string(path.with_extension("old")).unwrap(),
"original"
);
}
#[test]
fn checked_read_midread_name_replacement_never_pairs_old_bytes_with_new_validator() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("a");
std::fs::write(&path, "original").unwrap();
let policy = Access {
path: path.clone(),
counter: AtomicUsize::new(0),
trigger: 3,
replacement: true,
};
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
let checked = CheckedTarget::pin(&handle, Path::new("a"), &path, &policy, false).unwrap();
let validator = checked.validator().to_vec();
policy.counter.store(0, Ordering::SeqCst);
let result = crate::run_read(
root.path(),
ReadRequest {
path: FsPath::new("a").unwrap(),
offset: 0,
limit: 20,
max_bytes: 1024,
},
&checked,
);
match result {
Err(FsError::Conflict(_)) => {}
Ok(read) => {
// Same pinned inode supplies bytes and hash. Execute's final
// named-target check must reject, not refresh to replacement.
assert_eq!(read.bytes, b"original");
let expected_hash: crate::ContentHash = Sha256::digest(b"original").into();
assert_eq!(read.content_hash, expected_hash);
assert_eq!(
checked.confirm().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
}
other => panic!("unexpected Read result: {other:?}"),
}
assert_eq!(checked.validator(), validator);
assert_ne!(
identity_validator(&std::fs::metadata(&path).unwrap()).unwrap(),
validator
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement");
}
#[test]
fn create_post_parent_observation_retains_bound_directory_and_rejects_substitution() {
let root = tempfile::tempdir().unwrap();
let directory = root.path().join("d");
std::fs::create_dir(&directory).unwrap();
let policy = Access {
path: directory.clone(),
counter: AtomicUsize::new(0),
trigger: usize::MAX,
replacement: false,
};
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
let mut checked =
CheckedTarget::pin(&handle, Path::new("d"), &directory, &policy, true).unwrap();
let initial = checked.validator().to_vec();
let create = checked.create_access(PathBuf::from("new"), directory.join("new"));
crate::run_write(
root.path(),
WriteRequest {
path: FsPath::new("d/new").unwrap(),
content: b"new".to_vec(),
expected_hash: None,
},
&create,
)
.unwrap();
checked.refresh_directory_observation().unwrap();
assert_ne!(initial, checked.validator());
assert_eq!(
checked.validator(),
identity_validator(&std::fs::metadata(&directory).unwrap()).unwrap()
);
std::fs::rename(&directory, root.path().join("old")).unwrap();
std::fs::create_dir(&directory).unwrap();
assert_eq!(
checked.refresh_directory_observation().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
assert!(checked.effects_possible());
}
#[test]
fn checked_parent_swap_is_rejected_even_with_a_hardlinked_same_inode_target() {
let root = tempfile::tempdir().unwrap();
let directory = root.path().join("d");
std::fs::create_dir(&directory).unwrap();
let path = directory.join("a");
std::fs::write(&path, "original").unwrap();
let policy = Access {
path: path.clone(),
counter: AtomicUsize::new(0),
trigger: usize::MAX,
replacement: false,
};
let handle = crate::open_root_no_symlinks(root.path()).unwrap();
let checked = CheckedTarget::pin(&handle, Path::new("d/a"), &path, &policy, true).unwrap();
std::fs::rename(&directory, root.path().join("old")).unwrap();
std::fs::create_dir(&directory).unwrap();
std::fs::hard_link(root.path().join("old/a"), &path).unwrap();
assert_eq!(
checked.confirm().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
assert!(!checked.effects_possible());
}
#[test]
fn shared_text_read_rejects_mid_read_metadata_change() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("a");
std::fs::write(&path, "original").unwrap();
let policy = Access {
path,
counter: AtomicUsize::new(0),
trigger: 2,
replacement: false,
};
let error = crate::run_read(
root.path(),
ReadRequest {
path: FsPath::new("a").unwrap(),
offset: 0,
limit: 20,
max_bytes: 1024,
},
&policy,
)
.unwrap_err();
assert!(matches!(error, FsError::Conflict(_)));
}
}
@@ -0,0 +1,294 @@
//! Ignore rules loaded only through the provider's authorized descriptor opens.
//! Never use GitignoreBuilder::add/build_global: those reopen host pathnames.
use crate::{FsAccessPolicy, FsError};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
pub(crate) const MAX_IGNORE_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
const MAX_IGNORE_FILE_BYTES: u64 = 1024 * 1024;
const MAX_IGNORE_LINE_BYTES: u64 = 16 * 1024;
pub(crate) struct SourceBoundedReader<'a, R> {
pub inner: R,
pub remaining: &'a mut u64,
pub access: &'a dyn FsAccessPolicy,
}
impl<R: Read> Read for SourceBoundedReader<'_, R> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
self.access.check_cancelled()?;
if buffer.is_empty() {
return Ok(0);
}
if *self.remaining == 0 {
let mut probe = [0_u8; 1];
return match self.inner.read(&mut probe)? {
0 => Ok(0),
_ => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"search source exceeded provider byte limit",
)),
};
}
let limit = usize::try_from(*self.remaining)
.unwrap_or(usize::MAX)
.min(buffer.len());
let read = self.inner.read(&mut buffer[..limit])?;
*self.remaining = (*self.remaining).saturating_sub(read as u64);
Ok(read)
}
}
/// Shared Glob/Grep grammar and provider authorization. Glob, like WalkBuilder,
/// ignores unavailable configuration files; Grep retains its existing explicit
/// error on a rejected ignore-file open. Cancellation/bounds always fail closed.
pub(crate) fn add_checked_ignore_file(
builder: &mut GitignoreBuilder,
path: &Path,
remaining: &mut u64,
access: &dyn FsAccessPolicy,
skip_unavailable: bool,
) -> Result<bool, FsError> {
access.check_cancelled().map_err(|e| FsError::io(path, e))?;
let resolved = match access.resolve_access_path(path) {
Ok(path) => path,
Err(error) if skip_unavailable && error.kind() != std::io::ErrorKind::Interrupted => {
return Ok(false);
}
Err(error) => return Err(FsError::io(path, error)),
};
if !access.is_readable_paths(path, &resolved) {
return Ok(false);
}
let file = match access.open_read_file(path, &resolved) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) if skip_unavailable && error.kind() != std::io::ErrorKind::Interrupted => {
return Ok(false);
}
Err(error) => return Err(FsError::io(path, error)),
};
let metadata = file.metadata().map_err(|e| FsError::io(path, e))?;
if !metadata.is_file() {
return Ok(false);
}
if metadata.len() > MAX_IGNORE_FILE_BYTES {
return Err(FsError::InvalidArgument(
"ignore file exceeds provider limit 1 MiB".into(),
));
}
let bounded = SourceBoundedReader {
inner: file,
remaining,
access,
};
// Take also fences files that grow after metadata. Each line is bounded
// before allocation, and every underlying read checks cancellation.
let mut reader = BufReader::new(bounded.take(MAX_IGNORE_FILE_BYTES + 1));
let mut line = Vec::new();
let mut bytes = 0_u64;
let mut added = false;
loop {
line.clear();
// BufRead::read_line/read_until retry Interrupted internally. Explicit
// fill_buf prevents a cooperative cancellation from becoming a retry
// loop, and caps allocation before copying even a growing long line.
loop {
access.check_cancelled().map_err(|e| FsError::io(path, e))?;
let buffer = reader.fill_buf().map_err(|e| FsError::io(path, e))?;
if buffer.is_empty() {
break;
}
let count = buffer
.iter()
.position(|byte| *byte == b'\n')
.map_or(buffer.len(), |index| index + 1);
if line.len().saturating_add(count) as u64 > MAX_IGNORE_LINE_BYTES {
return Err(FsError::InvalidArgument(
"ignore line exceeds provider limit 16 KiB".into(),
));
}
let finished = buffer[count - 1] == b'\n';
line.extend_from_slice(&buffer[..count]);
reader.consume(count);
if finished {
break;
}
}
if line.is_empty() {
break;
}
bytes = bytes.saturating_add(line.len() as u64);
if bytes > MAX_IGNORE_FILE_BYTES {
return Err(FsError::InvalidArgument(
"ignore file exceeds provider limit 1 MiB".into(),
));
}
let line = std::str::from_utf8(&line)
.map_err(|_| FsError::InvalidArgument("ignore file must be UTF-8".into()))?
.trim_end_matches(['\r', '\n']);
// One invalid glob must not discard other valid rules.
if builder.add_line(Some(path.to_path_buf()), line).is_ok() {
added = true;
}
}
Ok(added)
}
struct DirectoryRules {
path: PathBuf,
ignore: Gitignore,
git_ignore: Gitignore,
exclude: Gitignore,
has_git: bool,
}
/// Request-local ancestor stack, not a file-tree cache. Only the current
/// directory's ancestry is retained; source bytes and depth bound its matchers.
pub(crate) struct DescriptorIgnores {
directories: Vec<DirectoryRules>,
remaining: u64,
require_git: bool,
skip_unavailable: bool,
}
impl DescriptorIgnores {
pub fn new(root: &Path, base: &Path, access: &dyn FsAccessPolicy) -> Result<Self, FsError> {
Self::with_options(root, base, access, true, true)
}
pub fn grep(root: &Path, base: &Path, access: &dyn FsAccessPolicy) -> Result<Self, FsError> {
// Preserve the existing descriptor Grep provider contract: gitignore
// sources apply without a .git marker and rejected opens fail closed.
Self::with_options(root, base, access, false, false)
}
fn with_options(
root: &Path,
base: &Path,
access: &dyn FsAccessPolicy,
require_git: bool,
skip_unavailable: bool,
) -> Result<Self, FsError> {
let relative = base.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("glob base is outside its provider root".into())
})?;
if relative.components().count() >= 128 {
return Err(FsError::InvalidArgument(
"ignore ancestry exceeds provider depth limit 128".into(),
));
}
let mut this = Self {
directories: Vec::new(),
remaining: MAX_IGNORE_SOURCE_BYTES,
require_git,
skip_unavailable,
};
let mut directory = root.to_path_buf();
for component in relative.components() {
this.load_directory(&directory, access)?;
directory.push(component);
}
Ok(this)
}
pub fn load_directory(
&mut self,
directory: &Path,
access: &dyn FsAccessPolicy,
) -> Result<(), FsError> {
self.directories
.retain(|rules| directory.starts_with(&rules.path));
if self
.directories
.last()
.is_some_and(|rules| rules.path == directory)
{
return Ok(());
}
if self.directories.len() >= 128 {
return Err(FsError::InvalidArgument(
"ignore ancestry exceeds provider depth limit 128".into(),
));
}
access
.check_cancelled()
.map_err(|e| FsError::io(directory, e))?;
let marker = directory.join(".git");
let git_kind = access
.resolve_access_path(&marker)
.ok()
.and_then(|resolved| {
if !access.is_readable_paths(&marker, &resolved) {
return None;
}
access
.read_metadata(&marker, &resolved)
.ok()
.map(|metadata| metadata.file_type())
});
let has_git = git_kind.is_some_and(|kind| kind.is_dir() || kind.is_file());
let mut load = |name: &str| -> Result<Gitignore, FsError> {
let mut builder = GitignoreBuilder::new(directory);
add_checked_ignore_file(
&mut builder,
&directory.join(name),
&mut self.remaining,
access,
self.skip_unavailable,
)?;
builder.build().map_err(|_| {
FsError::InvalidArgument("ignore patterns could not be compiled".into())
})
};
let ignore = load(".ignore")?;
let git_ignore = load(".gitignore")?;
// A linked-worktree .git file activates repository rules but is not a
// directory. Never chase its arbitrary host gitdir/commondir pointers.
let exclude = if git_kind.is_some_and(|kind| kind.is_dir()) {
load(".git/info/exclude")?
} else {
Gitignore::empty()
};
self.directories.push(DirectoryRules {
path: directory.to_path_buf(),
ignore,
git_ignore,
exclude,
has_git,
});
Ok(())
}
pub fn is_ignored(&mut self, path: &Path, is_dir: bool) -> bool {
self.directories
.retain(|rules| path.starts_with(&rules.path));
// .ignore has priority over all .gitignore/exclude rules. Within each
// class the nearest directory's last matching rule wins. Match entries,
// not any parents: ignored directories are pruned by the walker, while
// an explicitly selected ignored search root remains traversable.
for rules in self.directories.iter().rev() {
let matched = rules.ignore.matched(path, is_dir);
if !matched.is_none() {
return matched.is_ignore();
}
}
if self.require_git && !self.directories.iter().any(|rules| rules.has_git) {
return false;
}
let mut exclude = None;
for rules in self.directories.iter().rev() {
let matched = rules.git_ignore.matched(path, is_dir);
if !matched.is_none() {
return matched.is_ignore();
}
if exclude.is_none() {
let matched = rules.exclude.matched(path, is_dir);
if !matched.is_none() {
exclude = Some(matched.is_ignore());
}
}
if rules.has_git {
break;
}
}
exclude.unwrap_or(false)
}
}
+45 -39
View File
@@ -44,34 +44,29 @@ pub fn run_glob(
.as_ref()
.map(|traversal| traversal.path())
.unwrap_or(base);
let mut walker = WalkBuilder::new(walker_root);
walker.hidden(false).follow_links(false);
if traversal.is_some() {
// Descriptor providers must never let WalkBuilder open pathname-based
// ignore files, which may be symlinked outside the pinned root. Glob's
// explicit pattern remains the complete selection contract.
walker
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.ignore(false)
.parents(false);
let search_relative = base.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("glob base is outside its provider root".to_string())
})?;
let search_relative = search_relative.to_path_buf();
let filter_root = walker_root.to_path_buf();
walker.filter_entry(move |entry| {
entry
.path()
.strip_prefix(&filter_root)
.is_ok_and(|relative| {
relative.starts_with(&search_relative) || search_relative.starts_with(relative)
})
});
}
let logical_walk_root = traversal
.as_ref()
.and_then(|t| t.logical_root())
.unwrap_or(root);
let mut descriptor_ignores = traversal
.as_ref()
.map(|_| crate::descriptor_ignore::DescriptorIgnores::new(root, logical_walk_root, access))
.transpose()?;
let mut visited = 0_usize;
for entry in walker.build().flatten() {
let mut walk = if traversal.is_some() {
// Configuration is read via authorized provider opens, never by
// WalkBuilder's host-path ignore discovery.
crate::walk::Walk::descriptor(logical_walk_root, access)
} else {
crate::walk::Walk::Plain(
WalkBuilder::new(walker_root)
.hidden(false)
.follow_links(false)
.build(),
)
};
while let Some(entry) = walk.next() {
let entry = entry?;
access.check_cancelled().map_err(|source| FsError::Io {
path: PathBuf::from(request.path.as_str()),
source,
@@ -83,21 +78,27 @@ pub fn run_glob(
crate::MAX_TRAVERSAL_ENTRIES
)));
}
let walked_path = entry.path();
let path = if traversal.is_some() {
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
FsError::InvalidArgument(
"descriptor traversal returned a path outside its root".to_string(),
)
})?;
root.join(relative)
} else {
walked_path.to_path_buf()
};
let path = entry.path;
if path.strip_prefix(root).unwrap_or(&path).to_str().is_none() {
continue;
}
let resolved = match access.resolve_access_path(&path) {
Ok(resolved) if access.is_readable_paths(&path, &resolved) => resolved,
_ => continue,
};
if let Some(ignores) = descriptor_ignores.as_mut() {
let is_dir = entry.kind.is_some_and(|kind| kind.is_dir());
if path != logical_walk_root && ignores.is_ignored(&path, is_dir) {
if is_dir {
walk.skip_current_dir();
}
continue;
}
if is_dir {
ignores.load_directory(&path, access)?;
continue;
}
}
let Ok(file) = access.open_read_file(&path, &resolved) else {
continue;
};
@@ -111,7 +112,12 @@ pub fn run_glob(
let logical = path.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its root".to_string())
})?;
let result_path = FsPath::new(logical.to_string_lossy())?;
let Some(logical) = logical.to_str() else {
continue;
};
let Ok(result_path) = FsPath::new(logical) else {
continue;
};
let retained = result_path.as_str().len().saturating_add(16);
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
provider_truncated = true;
+326
View File
@@ -0,0 +1,326 @@
#![cfg(target_os = "linux")]
use crate::*;
use std::path::{Path, PathBuf};
use std::sync::{
Mutex,
atomic::{AtomicUsize, Ordering},
};
struct Access {
root: PathBuf,
denied: Option<PathBuf>,
opened: Mutex<Vec<PathBuf>>,
enumerated: Mutex<Vec<PathBuf>>,
checks: AtomicUsize,
cancel_at: usize,
}
impl Access {
fn new(root: &Path) -> Self {
Self {
root: root.into(),
denied: None,
opened: Mutex::new(Vec::new()),
enumerated: Mutex::new(Vec::new()),
checks: AtomicUsize::new(0),
cancel_at: usize::MAX,
}
}
}
impl FsAccessPolicy for Access {
fn is_readable(&self, path: &Path) -> bool {
path.starts_with(&self.root) && self.denied.as_ref().is_none_or(|denied| path != denied)
}
fn is_writable(&self, _: &Path) -> bool {
false
}
fn resolve_access_path(&self, path: &Path) -> std::io::Result<PathBuf> {
Ok(path.into())
}
fn check_cancelled(&self) -> std::io::Result<()> {
if self.checks.fetch_add(1, Ordering::Relaxed) >= self.cancel_at {
Err(std::io::ErrorKind::Interrupted.into())
} else {
Ok(())
}
}
fn open_read_file(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
assert!(
self.is_readable(logical),
"denied ignore/source file opened"
);
self.opened.lock().unwrap().push(logical.into());
open_beneath_no_symlinks(&self.root, resolved)
}
fn read_metadata(&self, _: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
open_beneath_no_symlinks(&self.root, resolved)?.metadata()
}
fn open_traversal_root(
&self,
_: &Path,
resolved: &Path,
) -> std::io::Result<Option<FsTraversalRoot>> {
use std::os::fd::AsRawFd;
let directory = open_beneath_no_symlinks(&self.root, resolved)?;
let walk = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
Ok(Some(FsTraversalRoot::new_at(
walk,
directory,
resolved.into(),
)))
}
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
use std::os::fd::AsRawFd;
self.enumerated.lock().unwrap().push(logical.into());
let directory = open_beneath_no_symlinks(&self.root, resolved)?;
std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))
}
}
fn glob(root: &Path, access: &dyn FsAccessPolicy) -> Result<GlobResult, FsError> {
run_glob(
root,
root,
GlobRequest {
path: FsPath::root(),
pattern: "**/*.txt".into(),
limit: 100,
},
access,
)
}
#[test]
fn descriptor_glob_prunes_ignored_directories_before_enumeration_and_loading_rules() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("ignored/deep")).unwrap();
std::fs::write(root.path().join(".ignore"), "ignored/\n").unwrap();
// This rejected directory must never even attempt to load ignore files.
std::fs::File::create(root.path().join("ignored/.ignore"))
.unwrap()
.set_len(2 * 1024 * 1024)
.unwrap();
std::fs::write(root.path().join("ignored/deep/secret.txt"), "secret").unwrap();
std::fs::write(root.path().join("visible.txt"), "visible").unwrap();
let access = Access::new(root.path());
assert_eq!(
glob(root.path(), &access).unwrap().paths,
vec![FsPath::new("visible.txt").unwrap()]
);
assert_eq!(
*access.enumerated.lock().unwrap(),
vec![root.path().to_path_buf()]
);
assert!(
!access
.opened
.lock()
.unwrap()
.iter()
.any(|path| path.starts_with(root.path().join("ignored")))
);
}
#[test]
fn descriptor_glob_denied_and_symlinked_ignore_files_cannot_change_selection() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("rules"), "visible.txt\n").unwrap();
std::fs::create_dir(root.path().join(".git")).unwrap();
std::os::unix::fs::symlink(outside.path().join("rules"), root.path().join(".gitignore"))
.unwrap();
std::fs::write(root.path().join(".ignore"), "visible.txt\n").unwrap();
std::fs::write(root.path().join("visible.txt"), "visible").unwrap();
let mut access = Access::new(root.path());
access.denied = Some(root.path().join(".ignore"));
assert_eq!(
glob(root.path(), &access).unwrap().paths,
vec![FsPath::new("visible.txt").unwrap()]
);
assert!(
!access
.opened
.lock()
.unwrap()
.contains(&root.path().join(".ignore"))
);
}
#[test]
fn descriptor_ignore_loading_checks_cancellation_without_retrying_interrupted() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join(".ignore"), "ignored.txt\n".repeat(1000)).unwrap();
let mut access = Access::new(root.path());
// First check is before resolution, second at the first fill_buf.
access.cancel_at = 1;
let error = crate::descriptor_ignore::add_checked_ignore_file(
&mut ignore::gitignore::GitignoreBuilder::new(root.path()),
&root.path().join(".ignore"),
&mut 1024_000,
&access,
true,
)
.unwrap_err();
assert!(
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
);
assert_eq!(access.checks.load(Ordering::Relaxed), 2);
}
#[test]
fn descriptor_ignore_loading_bounds_file_line_and_aggregate_bytes() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join(".ignore");
let access = Access::new(root.path());
std::fs::File::create(&path)
.unwrap()
.set_len(1024 * 1024 + 1)
.unwrap();
assert!(matches!(
glob(root.path(), &access),
Err(FsError::InvalidArgument(_))
));
std::fs::write(&path, "x".repeat(16 * 1024 + 1)).unwrap();
assert!(matches!(
glob(root.path(), &access),
Err(FsError::InvalidArgument(_))
));
std::fs::write(&path, "ignored.txt\n").unwrap();
let error = crate::descriptor_ignore::add_checked_ignore_file(
&mut ignore::gitignore::GitignoreBuilder::new(root.path()),
&path,
&mut 3,
&access,
true,
)
.unwrap_err();
assert!(
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::InvalidData)
);
}
#[test]
fn descriptor_ignore_ancestry_is_bounded_without_enumerating_a_tree() {
let root = tempfile::tempdir().unwrap();
let access = Access::new(root.path());
let base = (0..128).fold(root.path().to_path_buf(), |path, _| path.join("d"));
assert!(matches!(
crate::descriptor_ignore::DescriptorIgnores::new(root.path(), &base, &access),
Err(FsError::InvalidArgument(_))
));
assert!(access.opened.lock().unwrap().is_empty());
}
struct Plain(PathBuf);
impl FsAccessPolicy for Plain {
fn is_readable(&self, path: &Path) -> bool {
path.starts_with(&self.0)
}
fn is_writable(&self, _: &Path) -> bool {
false
}
}
#[test]
fn descriptor_glob_matches_plain_git_markers_exclude_and_nested_repository_boundaries() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("nested/.git/info")).unwrap();
std::fs::create_dir_all(root.path().join(".git/info")).unwrap();
std::fs::write(
root.path().join(".gitignore"),
"*.outer.txt\n!include.txt\n",
)
.unwrap();
std::fs::write(root.path().join(".ignore"), "always.txt\n").unwrap();
std::fs::write(
root.path().join(".git/info/exclude"),
"excluded.txt\ninclude.txt\n",
)
.unwrap();
std::fs::write(
root.path().join("nested/.git/info/exclude"),
"/excluded.txt\n",
)
.unwrap();
for name in [
"a.outer.txt",
"excluded.txt",
"include.txt",
"always.txt",
"nested/a.outer.txt",
"nested/excluded.txt",
"nested/always.txt",
] {
std::fs::write(root.path().join(name), "data").unwrap();
}
let native = glob(root.path(), &Access::new(root.path())).unwrap();
let ordinary = glob(root.path(), &Plain(root.path().into())).unwrap();
assert_eq!(native, ordinary);
assert_eq!(
native.paths,
vec![
FsPath::new("include.txt").unwrap(),
FsPath::new("nested/a.outer.txt").unwrap()
]
);
}
#[test]
fn descriptor_glob_requires_authorized_git_marker_for_gitignore_not_for_ignore() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join(".gitignore"), "gitignored.txt\n").unwrap();
std::fs::write(root.path().join(".ignore"), "ignored.txt\n").unwrap();
for name in ["gitignored.txt", "ignored.txt", "visible.txt"] {
std::fs::write(root.path().join(name), "data").unwrap();
}
let native = glob(root.path(), &Access::new(root.path())).unwrap();
// Ambient ancestors may themselves be repositories (e.g. the harness's
// TMPDIR). Their markers/ignore files are outside this provider root and
// cannot activate Git rules or be inspected by descriptor search.
assert_eq!(native.paths.len(), 2);
std::fs::create_dir(root.path().join(".git")).unwrap();
let native = glob(root.path(), &Access::new(root.path())).unwrap();
assert_eq!(
native,
glob(root.path(), &Plain(root.path().into())).unwrap()
);
assert_eq!(native.paths, vec![FsPath::new("visible.txt").unwrap()]);
let mut denied = Access::new(root.path());
denied.denied = Some(root.path().join(".git"));
assert_eq!(glob(root.path(), &denied).unwrap().paths.len(), 2);
std::fs::remove_dir(root.path().join(".git")).unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("exclude"), "visible.txt\n").unwrap();
std::fs::write(
root.path().join(".git"),
format!("gitdir: {}\n", outside.path().display()),
)
.unwrap();
// Real managed Workdirs use a .git file. Activate root .gitignore without
// failing on .git/info/exclude or following external administration paths.
let access = Access::new(root.path());
assert_eq!(
glob(root.path(), &access).unwrap().paths,
vec![FsPath::new("visible.txt").unwrap()]
);
std::fs::write(root.path().join("visible.txt"), "needle\n").unwrap();
let grep = run_grep(
root.path(),
root.path().into(),
GrepRequest {
path: FsPath::root(),
pattern: "needle".into(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: GrepOutputMode::FilesWithMatches,
offset: 0,
limit: 100,
},
&access,
)
.unwrap();
assert_eq!(grep.paths, vec![FsPath::new("visible.txt").unwrap()]);
assert!(
!access
.opened
.lock()
.unwrap()
.iter()
.any(|path| path.starts_with(outside.path()))
);
}
+34
View File
@@ -4,10 +4,19 @@
//! transport, or LLM Tool implementations. Paths are logical and root-relative;
//! providers supply host roots and access policy.
mod checked;
pub use checked::{CheckedTarget, identity_validator};
mod descriptor_ignore;
mod glob;
#[cfg(test)]
mod ignore_tests;
mod local;
mod operation;
mod search;
pub mod text;
#[cfg(test)]
mod traversal_tests;
mod walk;
use std::path::{Path, PathBuf};
@@ -272,6 +281,7 @@ pub fn atomic_write_beneath_no_symlinks_at(
#[derive(Debug)]
pub struct FsTraversalRoot {
path: PathBuf,
logical_root: Option<PathBuf>,
_directory: std::fs::File,
}
@@ -279,20 +289,44 @@ impl FsTraversalRoot {
pub fn new(path: PathBuf, directory: std::fs::File) -> Self {
Self {
path,
logical_root: None,
_directory: directory,
}
}
/// Map walked paths to this logical anchor rather than the provider root.
pub fn new_at(path: PathBuf, directory: std::fs::File, logical_root: PathBuf) -> Self {
Self {
path,
logical_root: Some(logical_root),
_directory: directory,
}
}
pub fn logical_root(&self) -> Option<&Path> {
self.logical_root.as_deref()
}
pub fn path(&self) -> &Path {
&self.path
}
}
/// Owned predicate used before walker descent. Paths are provider-logical;
/// true directories require enumeration authority, other entries require read.
pub type FsTraversalFilter = std::sync::Arc<dyn Fn(&Path, bool) -> bool + Send + Sync>;
/// Provider-owned access policy used by local filesystem operations.
pub trait FsAccessPolicy: Send + Sync {
fn is_readable(&self, path: &Path) -> bool;
fn is_writable(&self, path: &Path) -> bool;
fn can_enumerate_directory(&self, logical: &Path, resolved: &Path) -> bool {
self.is_readable_paths(logical, resolved)
}
/// Owned because the shared walker requires a 'static entry predicate.
fn traversal_filter(&self) -> Option<FsTraversalFilter> {
None
}
/// Cooperatively stop provider-owned filesystem work. Implementations may
/// use this for per-operation cancellation and deadlines.
fn check_cancelled(&self) -> std::io::Result<()> {
+285 -66
View File
@@ -162,6 +162,19 @@ fn run_read_with_limits(
}
}
#[cfg(unix)]
if crate::identity_validator(&metadata).map_err(|e| map_io(&logical, e))?
!= crate::identity_validator(
&reader
.get_ref()
.metadata()
.map_err(|e| map_io(&logical, e))?,
)
.map_err(|e| map_io(&logical, e))?
{
return Err(FsError::Conflict(logical.as_str().to_string()));
}
let total_lines =
current_line.saturating_add(usize::from(last_byte.is_some_and(|byte| byte != b'\n')));
if request.offset > total_lines && request.offset != 0 {
@@ -176,15 +189,7 @@ fn run_read_with_limits(
.min(total_lines);
let byte_truncated = selected_bytes_seen > response_limit;
if byte_truncated {
let mut byte_end = response_limit.min(selected.len());
while byte_end > 0 {
match std::str::from_utf8(&selected[..byte_end]) {
Ok(_) => break,
Err(error) if error.error_len().is_none() => byte_end -= 1,
Err(_) => break,
}
}
selected.truncate(byte_end);
selected.truncate(crate::text::bounded_utf8_len(&selected, response_limit));
}
Ok(ReadResult {
path: logical,
@@ -402,55 +407,33 @@ pub fn run_edit(
let content = String::from_utf8(bytes).map_err(|_| {
FsError::InvalidArgument(format!("{} is not valid UTF-8", logical.as_str()))
})?;
let occurrences = content.matches(&request.old_string).count();
if occurrences == 0 {
return Err(FsError::InvalidArgument(
"old_string was not found".to_string(),
));
}
if !request.replace_all && occurrences != 1 {
return Err(FsError::InvalidArgument(format!(
"old_string matched {occurrences} times; set replace_all=true or provide a unique string"
)));
}
let replacement_count = if request.replace_all { occurrences } else { 1 };
if let Some(limit) = access.max_edit_replacements()
&& replacement_count > limit
{
return Err(FsError::InvalidArgument(format!(
"{} exceeds provider Edit replacement limit {limit}",
logical.as_str()
)));
}
let removed_bytes = request
.old_string
.len()
.checked_mul(replacement_count)
.ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?;
let added_bytes = request
.new_string
.len()
.checked_mul(replacement_count)
.ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?;
let edited_len = content
.len()
.checked_sub(removed_bytes)
.and_then(|size| size.checked_add(added_bytes))
.ok_or_else(|| FsError::InvalidArgument("edited content size overflowed".to_string()))?;
enforce_write_bound(&logical, edited_len, access)?;
let edited = if request.replace_all {
content.replace(&request.old_string, &request.new_string)
} else {
content.replacen(&request.old_string, &request.new_string, 1)
let args = crate::text::EditArgs {
old_string: request.old_string,
new_string: request.new_string,
replace_all: request.replace_all,
};
debug_assert_eq!(edited.len(), edited_len);
let edited = crate::text::edit(
&content,
&args,
crate::text::TextLimits {
max_input_bytes: access.max_write_bytes(),
max_output_bytes: access.max_write_bytes(),
max_replacements: access.max_edit_replacements(),
},
)
.map_err(|error| map_text_error(&logical, error))?;
access
.atomic_write_file(&path, &target, edited.as_bytes(), AtomicWriteMode::Replace)
.atomic_write_file(
&path,
&target,
edited.content.as_bytes(),
AtomicWriteMode::Replace,
)
.map_err(|error| map_io(&logical, error))?;
Ok(EditResult {
replacements: if request.replace_all { occurrences } else { 1 },
bytes_written: edited.len(),
content_hash: hash_bytes(edited.as_bytes()),
replacements: edited.replacements,
bytes_written: edited.bytes_written,
content_hash: hash_bytes(edited.content.as_bytes()),
})
}
@@ -473,6 +456,14 @@ pub fn run_list(
if !metadata.is_dir() {
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
}
if !access.can_enumerate_directory(&logical_base, &path) {
return Ok(ListResult {
entries: Vec::new(),
total_entries: 0,
total_bytes: 0,
truncated: false,
});
}
let mut entries = Vec::new();
let mut retained_path_bytes = 0_usize;
let mut provider_truncated = false;
@@ -490,6 +481,9 @@ pub fn run_list(
)));
}
let entry = entry.map_err(|error| map_io(&logical, error))?;
if entry.file_name().to_str().is_none() {
continue;
}
let logical_absolute = logical_base.join(entry.file_name());
let resolved = match access.resolve_access_path(&logical_absolute) {
Ok(resolved) => resolved,
@@ -513,7 +507,12 @@ pub fn run_list(
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("provider returned a path outside its root".to_string())
})?;
let result_path = FsPath::new(relative.to_string_lossy())?;
let Some(relative) = relative.to_str() else {
continue;
};
let Ok(result_path) = FsPath::new(relative) else {
continue;
};
let retained = result_path.as_str().len().saturating_add(64);
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
provider_truncated = true;
@@ -550,15 +549,37 @@ fn enforce_write_bound(
bytes: usize,
access: &dyn FsAccessPolicy,
) -> Result<(), FsError> {
if let Some(limit) = access.max_write_bytes()
&& bytes > limit
{
return Err(FsError::InvalidArgument(format!(
"{} exceeds provider write limit {limit}",
logical.as_str()
)));
crate::text::check_output_bytes(
bytes,
crate::text::TextLimits {
max_output_bytes: access.max_write_bytes(),
..Default::default()
},
)
.map_err(|error| map_text_error(logical, error))
}
Ok(())
fn map_text_error(logical: &FsPath, error: crate::text::TextError) -> FsError {
use crate::text::TextError;
let message = match error {
TextError::InputTooLarge { limit, .. } | TextError::OutputTooLarge { limit, .. } => {
format!("{} exceeds provider write limit {limit}", logical.as_str())
}
TextError::ReplacementLimitExceeded { limit, .. } => format!(
"{} exceeds provider Edit replacement limit {limit}",
logical.as_str()
),
TextError::Decode(error) => format!("invalid arguments: {error}"),
TextError::EmptyOldString => "old_string must not be empty".to_string(),
TextError::IdenticalStrings => "old_string and new_string are identical".to_string(),
TextError::NotFound => "old_string was not found".to_string(),
TextError::MultipleMatches { occurrences } => format!(
"old_string matched {occurrences} times; set replace_all=true or provide a unique string"
),
TextError::Overflow => "edited content size overflowed".to_string(),
};
FsError::InvalidArgument(message)
}
fn read_mutation_preimage(
@@ -610,9 +631,21 @@ fn require_access(
write: bool,
allow_symlink_directory: bool,
) -> Result<PathBuf, FsError> {
let resolved = access
.resolve_access_path(path)
.map_err(|error| map_io(logical, error))?;
let resolved = access.resolve_access_path(path).map_err(|error| {
// A dangling direct link has no resolved identity, but still needs the
// existing sanitized repair diagnostic rather than an ordinary missing-file error.
if error.kind() == std::io::ErrorKind::NotFound
&& direct_symlink(path).is_some_and(|info| !info.target_exists)
{
FsError::BrokenSymlink {
path: PathBuf::from(logical.as_str()),
link: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"),
}
} else {
map_io(logical, error)
}
})?;
let symlink = (resolved != path).then(|| direct_symlink(path)).flatten();
if let Some(info) = symlink.as_ref()
&& !info.target_exists
@@ -773,3 +806,189 @@ mod bounded_read_tests {
assert!(result.truncated);
}
}
#[cfg(test)]
mod text_policy_tests {
use super::*;
struct Access {
root: PathBuf,
bytes: Option<usize>,
replacements: Option<usize>,
}
impl FsAccessPolicy for Access {
fn is_readable(&self, path: &Path) -> bool {
path.starts_with(&self.root)
}
fn is_writable(&self, path: &Path) -> bool {
path.starts_with(&self.root)
}
fn max_write_bytes(&self) -> Option<usize> {
self.bytes
}
fn max_edit_replacements(&self) -> Option<usize> {
self.replacements
}
}
#[test]
fn edit_policy_errors_preserve_the_preimage_and_provider_messages() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let source = "a a";
fs::write(root.join("text.txt"), source).unwrap();
let access = Access {
root: root.clone(),
bytes: Some(4),
replacements: Some(1),
};
for (old, new, all, expected_message) in [
("", "x", false, "old_string must not be empty"),
("a", "a", false, "old_string and new_string are identical"),
(
"missing",
"x",
false,
"text.txt exceeds provider write limit 4",
),
("b", "x", false, "old_string was not found"),
(
"a",
"x",
false,
"old_string matched 2 times; set replace_all=true or provide a unique string",
),
(
"a",
"x",
true,
"text.txt exceeds provider Edit replacement limit 1",
),
(
"a a",
"😀x",
false,
"text.txt exceeds provider write limit 4",
),
] {
let error = run_edit(
&root,
EditRequest {
path: FsPath::new("text.txt").unwrap(),
old_string: old.into(),
new_string: new.into(),
replace_all: all,
expected_hash: hash_bytes(source.as_bytes()),
},
&access,
)
.unwrap_err();
assert!(
matches!(error, FsError::InvalidArgument(message) if message == expected_message)
);
assert_eq!(fs::read_to_string(root.join("text.txt")).unwrap(), source);
}
let access = Access {
replacements: None,
..access
};
let error = run_edit(
&root,
EditRequest {
path: FsPath::new("text.txt").unwrap(),
old_string: "a".into(),
new_string: "xxx".into(),
replace_all: true,
expected_hash: hash_bytes(source.as_bytes()),
},
&access,
)
.unwrap_err();
assert!(
matches!(error, FsError::InvalidArgument(message) if message == "text.txt exceeds provider write limit 4")
);
assert_eq!(fs::read_to_string(root.join("text.txt")).unwrap(), source);
}
#[test]
fn edit_keeps_cas_before_content_policy_and_hashes_the_core_result() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
fs::write(root.join("text.txt"), "é😀").unwrap();
let access = Access {
root: root.clone(),
bytes: None,
replacements: None,
};
let error = run_edit(
&root,
EditRequest {
path: FsPath::new("text.txt").unwrap(),
old_string: String::new(),
new_string: "x".into(),
replace_all: false,
expected_hash: [0; 32],
},
&access,
)
.unwrap_err();
assert!(matches!(error, FsError::Conflict(_)));
let result = run_edit(
&root,
EditRequest {
path: FsPath::new("text.txt").unwrap(),
old_string: "é".into(),
new_string: "界".into(),
replace_all: false,
expected_hash: hash_bytes("é😀".as_bytes()),
},
&access,
)
.unwrap();
assert_eq!(result.replacements, 1);
assert_eq!(result.bytes_written, 7);
assert_eq!(result.content_hash, hash_bytes("界😀".as_bytes()));
assert_eq!(fs::read_to_string(root.join("text.txt")).unwrap(), "界😀");
}
#[test]
fn local_write_remains_binary_safe_and_maps_shared_bounds() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let access = Access {
root: root.clone(),
bytes: Some(2),
replacements: None,
};
let result = run_write(
&root,
WriteRequest {
path: FsPath::new("binary").unwrap(),
content: vec![0xff, 0],
expected_hash: None,
},
&access,
)
.unwrap();
assert_eq!(result.bytes_written, 2);
assert_eq!(fs::read(root.join("binary")).unwrap(), vec![0xff, 0]);
let error = run_write(
&root,
WriteRequest {
path: FsPath::new("large").unwrap(),
content: vec![0xff, 0, 1],
expected_hash: None,
},
&access,
)
.unwrap_err();
assert!(
matches!(error, FsError::InvalidArgument(message) if message == "large exceeds provider write limit 2")
);
assert!(!root.join("large").exists());
}
}
+4
View File
@@ -353,6 +353,10 @@ pub struct GrepRequest {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GrepResult {
/// Exact typed provider paths represented in this returned report, not all
/// candidate/matched source files. Older providers may omit this field.
#[serde(default)]
pub paths: Vec<FsPath>,
/// Provider-rendered bounded grep report. Keeping rendering here avoids
/// transferring candidate files across a remote provider boundary.
pub output: String,
+87 -216
View File
@@ -1,50 +1,20 @@
use std::collections::{BTreeMap, HashSet};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::io::{BufRead, BufReader, Read};
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::descriptor_ignore::{DescriptorIgnores, SourceBoundedReader};
use crate::FsAccessPolicy;
use grep_regex::RegexMatcherBuilder;
use grep_searcher::sinks::UTF8 as UTF8Sink;
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
use ignore::WalkBuilder;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::overrides::{Override, OverrideBuilder};
use ignore::types::{Types, TypesBuilder};
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
struct SourceBoundedReader<'a, R> {
inner: R,
remaining: &'a mut u64,
access: &'a dyn FsAccessPolicy,
}
impl<R: Read> Read for SourceBoundedReader<'_, R> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
self.access.check_cancelled()?;
if buffer.is_empty() {
return Ok(0);
}
if *self.remaining == 0 {
let mut probe = [0_u8; 1];
return match self.inner.read(&mut probe)? {
0 => Ok(0),
_ => Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"grep source exceeded provider byte limit",
)),
};
}
let limit = usize::try_from(*self.remaining)
.unwrap_or(usize::MAX)
.min(buffer.len());
let read = self.inner.read(&mut buffer[..limit])?;
*self.remaining = (*self.remaining).saturating_sub(read as u64);
Ok(read)
}
}
struct ContentLine {
path: PathBuf,
line_number: Option<u64>,
@@ -79,6 +49,28 @@ fn reserve_report_bytes(report: &mut GrepReport, bytes: usize) -> bool {
impl GrepReport {
fn into_result(self, root: &Path) -> GrepResult {
// Build Object paths from retained provider identities, never by parsing
// rendered text. Offset, limit and truncation already selected this set.
let paths = match self.mode {
GrepOutputMode::FilesWithMatches => self
.files
.iter()
.filter_map(|path| logical_path(root, path))
.collect(),
GrepOutputMode::Count => self
.counts
.iter()
.filter_map(|(path, _)| logical_path(root, path))
.collect(),
GrepOutputMode::Content => self
.lines
.iter()
.map(|line| line.path.as_path())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.filter_map(|path| logical_path(root, path))
.collect(),
};
let (match_count, matched_files) = match self.mode {
GrepOutputMode::FilesWithMatches => (self.files.len(), self.files.len()),
GrepOutputMode::Count => (
@@ -116,6 +108,7 @@ impl GrepReport {
}
}
GrepResult {
paths,
output,
match_count,
matched_files,
@@ -166,11 +159,15 @@ fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: b
output
}
fn logical_path(root: &Path, path: &Path) -> Option<crate::FsPath> {
crate::FsPath::new_scoped(path.strip_prefix(root).unwrap_or(path).to_str()?).ok()
}
fn logical_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
logical_path(root, path)
.expect("only representable provider paths enter a report")
.as_str()
.to_string()
}
const DEFAULT_HEAD_LIMIT: usize = 250;
@@ -207,119 +204,6 @@ fn direct_file_selected(path: &Path, overrides: Option<&Override>, types: Option
&& !types.is_some_and(|filter| filter.matched(path, false).is_ignore())
}
#[derive(Default)]
struct DescriptorIgnoreMatchers {
loaded_directories: HashSet<PathBuf>,
by_directory: BTreeMap<PathBuf, Gitignore>,
}
impl DescriptorIgnoreMatchers {
fn load_directory(
&mut self,
directory: &Path,
explicit_base: &Path,
source_bytes_remaining: &mut u64,
access: &dyn FsAccessPolicy,
) -> Result<(), FsError> {
if !self.loaded_directories.insert(directory.to_path_buf()) {
return Ok(());
}
let mut builder = GitignoreBuilder::new(directory);
let mut has_patterns = false;
for name in [".gitignore", ".ignore"] {
let path = directory.join(name);
let resolved = access
.resolve_access_path(&path)
.map_err(|error| FsError::io(&path, error))?;
if !access.is_readable_paths(&path, &resolved) {
continue;
}
let file = match access.open_read_file(&path, &resolved) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(FsError::io(&path, error)),
};
if !file
.metadata()
.map_err(|error| FsError::io(&path, error))?
.is_file()
{
continue;
}
let bounded = SourceBoundedReader {
inner: file,
remaining: source_bytes_remaining,
access,
};
let mut reader = BufReader::new(bounded);
let mut line = String::new();
let mut line_number = 0_u64;
loop {
line.clear();
let read = reader
.read_line(&mut line)
.map_err(|error| FsError::io(&path, error))?;
if read == 0 {
break;
}
line_number = line_number.saturating_add(1);
if line.len() > 1024 * 1024 {
return Err(FsError::InvalidArgument(format!(
"ignore pattern line {line_number} exceeds provider limit"
)));
}
let line = line.trim_end_matches(['\r', '\n']);
// Match the ignore crate's partial-error behavior: one invalid
// pattern does not discard the remaining valid lines.
if builder.add_line(Some(path.clone()), line).is_ok() {
has_patterns = true;
}
}
}
if !has_patterns {
return Ok(());
}
let matcher = builder.build().map_err(|_| {
FsError::InvalidArgument("ignore patterns could not be compiled".to_string())
})?;
// An explicitly selected search root is traversed even when a strict
// ancestor ignores that directory, matching path-backed Grep behavior.
if directory != explicit_base
&& explicit_base.starts_with(directory)
&& matcher
.matched_path_or_any_parents(explicit_base, true)
.is_ignore()
{
return Ok(());
}
self.by_directory.insert(directory.to_path_buf(), matcher);
Ok(())
}
fn is_ignored(&self, root: &Path, path: &Path) -> bool {
let mut directories = path
.parent()
.into_iter()
.flat_map(Path::ancestors)
.take_while(|directory| directory.starts_with(root))
.collect::<Vec<_>>();
directories.reverse();
let mut ignored = false;
for directory in directories {
let Some(matcher) = self.by_directory.get(directory) else {
continue;
};
let matched = matcher.matched_path_or_any_parents(path, false);
if matched.is_ignore() {
ignored = true;
} else if matched.is_whitelist() {
ignored = false;
}
}
ignored
}
}
struct GrepParams {
pattern: String,
path: Option<PathBuf>,
@@ -464,6 +348,9 @@ pub fn run_grep(
retained_bytes: 0,
truncated: false,
};
if head_limit == 0 {
return Ok(report.into_result(root));
}
let mut matching_files_seen = 0;
let mut matches_seen = 0;
@@ -474,6 +361,7 @@ pub fn run_grep(
&mut searcher,
&matcher,
&base,
root,
mode,
&mut report,
&mut matching_files_seen,
@@ -494,6 +382,10 @@ pub fn run_grep(
.as_ref()
.map(|traversal| traversal.path())
.unwrap_or(&base);
let logical_walk_root = traversal
.as_ref()
.and_then(|t| t.logical_root())
.unwrap_or(root);
let mut walker = WalkBuilder::new(walker_root);
if traversal.is_some() {
walker
@@ -506,19 +398,6 @@ pub fn run_grep(
.ignore(false)
.parents(false)
.follow_links(false);
let search_relative = base.strip_prefix(root).map_err(|_| {
FsError::InvalidArgument("grep base is outside its provider root".to_string())
})?;
let search_relative = search_relative.to_path_buf();
let filter_root = walker_root.to_path_buf();
walker.filter_entry(move |entry| {
entry
.path()
.strip_prefix(&filter_root)
.is_ok_and(|relative| {
relative.starts_with(&search_relative) || search_relative.starts_with(relative)
})
});
} else {
walker
.hidden(true)
@@ -538,32 +417,17 @@ pub fn run_grep(
let mut visited = 0_usize;
let mut source_bytes_remaining = crate::MAX_GREP_SOURCE_BYTES;
let mut descriptor_ignores = DescriptorIgnoreMatchers::default();
if traversal.is_some() {
let mut directory = root.to_path_buf();
descriptor_ignores.load_directory(
&directory,
&base,
&mut source_bytes_remaining,
access,
)?;
for component in base
.strip_prefix(root)
.map_err(|_| {
FsError::InvalidArgument("grep base is outside its provider root".to_string())
})?
.components()
{
directory.push(component);
descriptor_ignores.load_directory(
&directory,
&base,
&mut source_bytes_remaining,
access,
)?;
}
}
for entry in walker.build().flatten() {
let mut descriptor_ignores = traversal
.as_ref()
.map(|_| DescriptorIgnores::grep(root, logical_walk_root, access))
.transpose()?;
let mut walk = if traversal.is_some() {
crate::walk::Walk::descriptor(logical_walk_root, access)
} else {
crate::walk::Walk::Plain(walker.build())
};
while let Some(entry) = walk.next() {
let entry = entry?;
access
.check_cancelled()
.map_err(|error| FsError::io(&base, error))?;
@@ -574,39 +438,41 @@ pub fn run_grep(
crate::MAX_TRAVERSAL_ENTRIES
)));
}
let walked_path = entry.path();
let path = if traversal.is_some() {
let relative = walked_path.strip_prefix(walker_root).map_err(|_| {
FsError::InvalidArgument(
"descriptor traversal returned a path outside its root".to_string(),
)
})?;
root.join(relative)
} else {
walked_path.to_path_buf()
};
let file_type = entry.file_type();
if traversal.is_some() && file_type.as_ref().is_some_and(|kind| kind.is_dir()) {
descriptor_ignores.load_directory(&path, &base, &mut source_bytes_remaining, access)?;
let path = entry.path;
if logical_path(root, &path).is_none() {
continue;
}
if !file_type.map(|kind| kind.is_file()).unwrap_or(false) {
continue;
}
if traversal.is_some() {
let file_type = entry.kind;
if let Some(ignores) = descriptor_ignores.as_mut() {
let relative = path.strip_prefix(&base).map_err(|_| {
FsError::InvalidArgument(
"descriptor traversal returned a path outside its search base".to_string(),
)
})?;
if relative
.components()
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
|| descriptor_ignores.is_ignored(root, &path)
|| !direct_file_selected(&path, overrides.as_ref(), types.as_ref())
let is_dir = file_type.is_some_and(|kind| kind.is_dir());
if path != logical_walk_root
&& (relative.components().any(|component| {
component
.as_os_str()
.to_str()
.is_some_and(|name| name.starts_with('.'))
}) || ignores.is_ignored(&path, is_dir))
{
if is_dir {
walk.skip_current_dir();
}
continue;
}
if is_dir {
ignores.load_directory(&path, access)?;
continue;
}
if !direct_file_selected(&path, overrides.as_ref(), types.as_ref()) {
continue;
}
}
if !file_type.map(|kind| kind.is_file()).unwrap_or(false) {
continue;
}
let readable = access
.resolve_access_path(&path)
@@ -618,6 +484,7 @@ pub fn run_grep(
&mut searcher,
&matcher,
&path,
root,
mode,
&mut report,
&mut matching_files_seen,
@@ -639,6 +506,7 @@ fn scan_path(
searcher: &mut Searcher,
matcher: &grep_regex::RegexMatcher,
path: &Path,
root: &Path,
mode: GrepOutputMode,
report: &mut GrepReport,
matching_files_seen: &mut usize,
@@ -648,6 +516,9 @@ fn scan_path(
source_bytes_remaining: &mut u64,
access: &dyn FsAccessPolicy,
) -> Result<bool, FsError> {
if logical_path(root, path).is_none() {
return Ok(false);
}
let resolved = access
.resolve_access_path(path)
.map_err(|error| FsError::io(path, error))?;
@@ -678,7 +549,7 @@ fn scan_path(
return Ok(false);
}
if *matching_files_seen >= offset {
if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(1)) {
if !reserve_report_bytes(report, path.as_os_str().len().saturating_add(1)) {
return Ok(true);
}
report.files.push(path.to_path_buf());
@@ -695,7 +566,7 @@ fn scan_path(
return Ok(false);
}
if *matching_files_seen >= offset {
if !reserve_report_bytes(report, path.to_string_lossy().len().saturating_add(32)) {
if !reserve_report_bytes(report, path.as_os_str().len().saturating_add(32)) {
return Ok(true);
}
report.counts.push((path.to_path_buf(), count));
@@ -780,7 +651,7 @@ impl ContentSink<'_> {
fn reserve(&mut self, content_bytes: usize) -> bool {
let retained = self
.path
.to_string_lossy()
.as_os_str()
.len()
.saturating_add(content_bytes)
.saturating_add(64);
+900
View File
@@ -0,0 +1,900 @@
//! Pure text-operation policy shared by filesystem providers and operation frontends.
//!
//! This module owns no paths, descriptors, version fences, or I/O. Limits count
//! UTF-8 bytes, not characters, and default to unlimited.
use schemars::JsonSchema;
use serde::{Deserialize, de::DeserializeOwned};
use thiserror::Error;
/// The shared text Operation definitions, independent of paths and frontends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextOperation {
Read,
ReadLines,
Edit,
Write,
}
impl TextOperation {
/// Resolve a lower-case Operation name. A frontend chooses whether its
/// `read` Operation accepts whole-text or line-range arguments.
pub fn from_name(name: &str, line_read: bool) -> Option<Self> {
match name {
"read" if line_read => Some(Self::ReadLines),
"read" => Some(Self::Read),
"edit" => Some(Self::Edit),
"write" => Some(Self::Write),
_ => None,
}
}
pub fn argument_schema(self) -> schemars::Schema {
match self {
Self::Read => schemars::schema_for!(ReadArgs),
Self::ReadLines => schemars::schema_for!(LineReadArgs),
Self::Edit => schemars::schema_for!(EditArgs),
Self::Write => schemars::schema_for!(WriteArgs),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
pub struct EditArgs {
/// String to replace. Must be nonempty and unique unless replace_all is true.
pub old_string: String,
/// Replacement text. Must differ from old_string; may be empty to delete it.
pub new_string: String,
/// Replace every non-overlapping occurrence. Defaults to false.
#[serde(default)]
pub replace_all: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
pub struct WriteArgs {
/// Full text to write.
pub content: String,
}
/// Whole-text reads accept no operation-specific arguments.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReadArgs {}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema)]
pub struct LineReadArgs {
/// Zero-based line offset. Omitted or null means zero.
#[serde(default)]
pub offset: Option<usize>,
/// Maximum lines to return. Omitted or null uses the frontend's default.
#[serde(default)]
pub limit: Option<usize>,
}
impl LineReadArgs {
pub fn range(&self, default_limit: usize) -> (usize, usize) {
(
self.offset.unwrap_or(0),
self.limit.unwrap_or(default_limit).max(1),
)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TextLimits {
pub max_input_bytes: Option<usize>,
pub max_output_bytes: Option<usize>,
pub max_replacements: Option<usize>,
}
#[derive(Debug, Error)]
pub enum TextError {
#[error("invalid arguments: {0}")]
Decode(#[source] serde_json::Error),
#[error("old_string must not be empty")]
EmptyOldString,
#[error("old_string and new_string are identical")]
IdenticalStrings,
#[error("old_string was not found")]
NotFound,
#[error(
"old_string matched {occurrences} times; set replace_all=true or provide a unique string"
)]
MultipleMatches { occurrences: usize },
#[error("input size {bytes} exceeds limit {limit}")]
InputTooLarge { bytes: usize, limit: usize },
#[error("output size {bytes} exceeds limit {limit}")]
OutputTooLarge { bytes: usize, limit: usize },
#[error("replacement count {replacements} exceeds limit {limit}")]
ReplacementLimitExceeded { replacements: usize, limit: usize },
#[error("edited content size overflowed")]
Overflow,
}
pub fn decode<T: DeserializeOwned>(value: serde_json::Value) -> Result<T, TextError> {
serde_json::from_value(value).map_err(TextError::Decode)
}
/// Decode raw Tool JSON directly, retaining duplicate-field rejection rather
/// than losing that information through an intermediate JSON object.
pub fn decode_json<T: DeserializeOwned>(input: &str) -> Result<T, TextError> {
serde_json::from_str(input).map_err(TextError::Decode)
}
impl EditArgs {
/// Validate argument-only policy before a caller acquires a preimage.
pub fn validate(&self, limits: TextLimits) -> Result<(), TextError> {
if self.old_string.is_empty() {
return Err(TextError::EmptyOldString);
}
if self.old_string == self.new_string {
return Err(TextError::IdenticalStrings);
}
check_input_bytes(self.old_string.len(), limits)?;
check_input_bytes(self.new_string.len(), limits)?;
Ok(())
}
}
/// A numbered, bounded text response with provider line metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rendered {
pub body: String,
pub line_count: usize,
pub total_lines: usize,
pub truncated: bool,
}
const RENDERED_TRUNCATION_MARKER: &str = "\n[truncated at rendered output byte limit]\n";
/// Render selected text with one-based line numbers and a hard UTF-8 byte bound.
///
/// As in the Tool renderer, room for the truncation marker is always reserved,
/// and a partially emitted numbered line counts as a line. Tiny bounds emit
/// only the marker prefix that fits. Line numbers saturate rather than wrap.
pub fn render_numbered(
text: &str,
start_line: usize,
total_lines: usize,
truncated: bool,
max_bytes: usize,
) -> Rendered {
let budget = max_bytes.saturating_sub(RENDERED_TRUNCATION_MARKER.len());
let mut body = String::with_capacity(text.len().min(max_bytes));
let mut line_count = 0usize;
let mut output_truncated = false;
for (index, line) in text.lines().enumerate() {
// Build only the small numeric prefix, not an unbounded copy of a line.
let number = start_line.saturating_add(index).saturating_add(1);
let prefix = format!("{number:>6}\t");
let numbered_len = prefix.len().saturating_add(line.len()).saturating_add(1);
let mut remaining = budget.saturating_sub(body.len());
if remaining > 0 {
line_count = line_count.saturating_add(1);
}
let line_truncated = numbered_len > remaining;
for fragment in [prefix.as_str(), line, "\n"] {
let mut end = remaining.min(fragment.len());
while !fragment.is_char_boundary(end) {
end -= 1;
}
body.push_str(&fragment[..end]);
remaining -= end;
// Do not skip a character that failed to fit and then emit later
// fragments; the response must be a prefix of the numbered line.
if end < fragment.len() {
break;
}
}
if line_truncated {
let marker_len = max_bytes
.saturating_sub(body.len())
.min(RENDERED_TRUNCATION_MARKER.len());
body.push_str(&RENDERED_TRUNCATION_MARKER[..marker_len]);
output_truncated = true;
break;
}
}
Rendered {
body,
line_count,
total_lines,
truncated: start_line > 0 || truncated || output_truncated,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditOutcome {
pub content: String,
pub replacements: usize,
pub bytes_written: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextOutcome {
pub content: String,
pub bytes: usize,
}
pub fn edit(original: &str, args: &EditArgs, limits: TextLimits) -> Result<EditOutcome, TextError> {
args.validate(limits)?;
check_input_bytes(original.len(), limits)?;
let occurrences = original.matches(&args.old_string).count();
if occurrences == 0 {
return Err(TextError::NotFound);
}
if !args.replace_all && occurrences != 1 {
return Err(TextError::MultipleMatches { occurrences });
}
let replacements = occurrences;
if let Some(limit) = limits.max_replacements
&& replacements > limit
{
return Err(TextError::ReplacementLimitExceeded {
replacements,
limit,
});
}
// All arithmetic and output policy are checked before allocating the result.
let bytes_written = edited_len(
original.len(),
args.old_string.len(),
args.new_string.len(),
replacements,
)?;
check_output_bytes(bytes_written, limits)?;
let mut content = String::with_capacity(bytes_written);
let mut cursor = 0;
for (start, matched) in original.match_indices(&args.old_string) {
content.push_str(&original[cursor..start]);
content.push_str(&args.new_string);
cursor = start + matched.len();
}
content.push_str(&original[cursor..]);
debug_assert_eq!(content.len(), bytes_written);
Ok(EditOutcome {
content,
replacements,
bytes_written,
})
}
fn edited_len(
original: usize,
old: usize,
new: usize,
replacements: usize,
) -> Result<usize, TextError> {
let removed = old.checked_mul(replacements).ok_or(TextError::Overflow)?;
let added = new.checked_mul(replacements).ok_or(TextError::Overflow)?;
let bytes = original
.checked_sub(removed)
.and_then(|bytes| bytes.checked_add(added))
.ok_or(TextError::Overflow)?;
// String/Vec capacity is bounded by isize::MAX even on platforms where usize
// arithmetic still fits. Reject that case before String::with_capacity.
if bytes > isize::MAX as usize {
return Err(TextError::Overflow);
}
Ok(bytes)
}
pub fn write(content: String, limits: TextLimits) -> Result<TextOutcome, TextError> {
text_outcome(content, limits)
}
pub fn read(content: String, limits: TextLimits) -> Result<TextOutcome, TextError> {
text_outcome(content, limits)
}
fn text_outcome(content: String, limits: TextLimits) -> Result<TextOutcome, TextError> {
let bytes = content.len();
check_input_bytes(bytes, limits)?;
check_output_bytes(bytes, limits)?;
Ok(TextOutcome { content, bytes })
}
pub(crate) fn check_input_bytes(bytes: usize, limits: TextLimits) -> Result<(), TextError> {
if let Some(limit) = limits.max_input_bytes
&& bytes > limit
{
return Err(TextError::InputTooLarge { bytes, limit });
}
Ok(())
}
pub(crate) fn check_output_bytes(bytes: usize, limits: TextLimits) -> Result<(), TextError> {
if let Some(limit) = limits.max_output_bytes
&& bytes > limit
{
return Err(TextError::OutputTooLarge { bytes, limit });
}
Ok(())
}
/// Keep valid UTF-8 prefixes intact without changing byte-read behavior on
/// genuinely invalid UTF-8. Providers must remain able to return binary data.
pub(crate) fn bounded_utf8_len(bytes: &[u8], max_bytes: usize) -> usize {
let mut end = max_bytes.min(bytes.len());
while end > 0 {
match std::str::from_utf8(&bytes[..end]) {
Ok(_) => break,
Err(error) if error.error_len().is_none() => end -= 1,
Err(_) => break,
}
}
end
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn args(old: &str, new: &str, replace_all: bool) -> EditArgs {
EditArgs {
old_string: old.into(),
new_string: new.into(),
replace_all,
}
}
#[test]
fn decode_uses_shared_argument_defaults_and_types() {
let edit = decode::<EditArgs>(json!({"old_string": "a", "new_string": "b"})).unwrap();
assert!(!edit.replace_all);
assert!(
decode::<EditArgs>(json!({"old_string": "a", "new_string": "b", "replace_all": true}))
.unwrap()
.replace_all
);
assert_eq!(
decode::<WriteArgs>(json!({"content": "😀"}))
.unwrap()
.content,
"😀"
);
assert_eq!(decode::<ReadArgs>(json!({})).unwrap(), ReadArgs {});
for value in [
json!({"old_string": "a"}),
json!({"old_string": 2, "new_string": "b"}),
json!({"old_string": "a", "new_string": "b", "replace_all": null}),
] {
assert!(matches!(
decode::<EditArgs>(value),
Err(TextError::Decode(_))
));
}
assert!(matches!(
decode::<WriteArgs>(json!({})),
Err(TextError::Decode(_))
));
assert!(matches!(
decode::<ReadArgs>(json!({"offset": 0})),
Err(TextError::Decode(_))
));
assert!(matches!(
decode::<ReadArgs>(json!(null)),
Err(TextError::Decode(_))
));
}
#[test]
fn line_range_preserves_null_defaults_and_minimum_one() {
for value in [json!({}), json!({"offset": null, "limit": null})] {
assert_eq!(
decode::<LineReadArgs>(value).unwrap().range(2000),
(0, 2000)
);
}
assert_eq!(
decode::<LineReadArgs>(json!({"offset": 7, "limit": 0}))
.unwrap()
.range(99),
(7, 1)
);
assert_eq!(LineReadArgs::default().range(0), (0, 1));
assert_eq!(
LineReadArgs {
offset: Some(usize::MAX),
limit: Some(usize::MAX)
}
.range(1),
(usize::MAX, usize::MAX)
);
for value in [
json!({"offset": -1}),
json!({"limit": "2"}),
json!({"limit": 1.5}),
] {
assert!(matches!(
decode::<LineReadArgs>(value),
Err(TextError::Decode(_))
));
}
}
#[test]
fn schema_is_the_argument_contract() {
let edit = serde_json::to_value(schemars::schema_for!(EditArgs)).unwrap();
assert_eq!(edit["required"], json!(["old_string", "new_string"]));
assert_eq!(edit["properties"]["replace_all"]["default"], json!(false));
let read = serde_json::to_value(schemars::schema_for!(ReadArgs)).unwrap();
assert_eq!(read["additionalProperties"], json!(false));
assert!(
read.get("properties")
.is_none_or(|properties| properties == &json!({}))
);
let lines = serde_json::to_value(schemars::schema_for!(LineReadArgs)).unwrap();
assert_eq!(
lines["properties"]["offset"]["type"],
json!(["integer", "null"])
);
assert_eq!(lines["properties"]["limit"]["default"], json!(null));
}
#[test]
fn edit_rejects_empty_identical_missing_and_ambiguous_matches() {
assert!(matches!(
args("", "x", true).validate(TextLimits::default()),
Err(TextError::EmptyOldString)
));
assert!(matches!(
edit("a", &args("a", "a", false), TextLimits::default()),
Err(TextError::IdenticalStrings)
));
assert!(matches!(
edit("a", &args("b", "c", false), TextLimits::default()),
Err(TextError::NotFound)
));
assert!(matches!(
edit("aa", &args("a", "b", false), TextLimits::default()),
Err(TextError::MultipleMatches { occurrences: 2 })
));
assert!(matches!(
edit("", &args("a", "", false), TextLimits::default()),
Err(TextError::NotFound)
));
}
#[test]
fn edit_handles_unique_all_deletion_literal_and_nonoverlapping_matches() {
for (original, old, new, all, expected, replacements) in [
(
"before old after",
"old",
"new",
false,
"before new after",
1,
),
("aaa", "aa", "x", true, "xa", 1),
("a-a-a", "a", "", true, "--", 3),
("[a].* [a].*", "[a].*", "$1", true, "$1 $1", 2),
("x", "x", "", false, "", 1),
] {
let result = edit(original, &args(old, new, all), TextLimits::default()).unwrap();
assert_eq!(result.content, expected);
assert_eq!(result.replacements, replacements);
assert_eq!(result.bytes_written, expected.len());
}
}
#[test]
fn edit_preserves_unicode_and_counts_bytes() {
let result = edit("é😀\né😀", &args("é😀", "界", true), TextLimits::default()).unwrap();
assert_eq!(result.content, "界\n界");
assert_eq!(result.replacements, 2);
assert_eq!(result.bytes_written, 7);
// Matching is literal, without Unicode normalization.
assert!(matches!(
edit("e\u{301}", &args("é", "x", false), TextLimits::default()),
Err(TextError::NotFound)
));
}
#[test]
fn edit_input_limits_cover_arguments_and_preimage_with_inclusive_boundaries() {
let limits = TextLimits {
max_input_bytes: Some(4),
..Default::default()
};
args("😀", "a", false).validate(limits).unwrap();
assert!(matches!(
args("😀x", "a", false).validate(limits),
Err(TextError::InputTooLarge { bytes: 5, limit: 4 })
));
assert!(matches!(
args("a", "😀x", false).validate(limits),
Err(TextError::InputTooLarge { bytes: 5, limit: 4 })
));
assert!(matches!(
edit("aaaaa", &args("a", "b", true), limits),
Err(TextError::InputTooLarge { bytes: 5, limit: 4 })
));
assert_eq!(
edit("aaaa", &args("a", "b", true), limits).unwrap().content,
"bbbb"
);
}
#[test]
fn edit_output_and_replacement_limits_apply_before_constructing_result() {
let args = args("a", "😀", true);
let limits = TextLimits {
max_output_bytes: Some(7),
..Default::default()
};
assert!(matches!(
edit("aa", &args, limits),
Err(TextError::OutputTooLarge { bytes: 8, limit: 7 })
));
assert_eq!(
edit(
"aa",
&args,
TextLimits {
max_output_bytes: Some(8),
..limits
}
)
.unwrap()
.bytes_written,
8
);
assert!(matches!(
edit(
"aa",
&args,
TextLimits {
max_replacements: Some(1),
..Default::default()
}
),
Err(TextError::ReplacementLimitExceeded {
replacements: 2,
limit: 1
})
));
assert!(matches!(
edit(
"a",
&args,
TextLimits {
max_replacements: Some(0),
..Default::default()
}
),
Err(TextError::ReplacementLimitExceeded {
replacements: 1,
limit: 0
})
));
assert_eq!(
edit(
"aa",
&args,
TextLimits {
max_replacements: Some(2),
..Default::default()
}
)
.unwrap()
.replacements,
2
);
assert_eq!(
edit(
"a",
&EditArgs {
old_string: "a".into(),
new_string: String::new(),
replace_all: false
},
TextLimits {
max_output_bytes: Some(0),
..Default::default()
}
)
.unwrap()
.content,
""
);
}
#[test]
fn raw_json_decode_preserves_duplicate_argument_rejection() {
for input in [
r#"{"old_string":"a","old_string":"b","new_string":"c"}"#,
r#"{"old_string":"a","new_string":"b","replace_all":false,"replace_all":true}"#,
] {
assert!(matches!(
decode_json::<EditArgs>(input),
Err(TextError::Decode(_))
));
}
assert!(matches!(
decode_json::<WriteArgs>(r#"{"content":"a","content":"b"}"#),
Err(TextError::Decode(_))
));
}
#[test]
fn size_arithmetic_rejects_every_overflow_before_preallocation() {
// Exercise impossible-to-allocate sizes through the exact helper edit
// invokes before allocating; no giant strings need to be constructed.
for (original, old, new, count) in [
(usize::MAX, usize::MAX, 0, 2), // removed multiplication
(2, 1, usize::MAX, 2), // added multiplication
(1, 2, 0, 1), // subtraction
(usize::MAX, 1, 2, 1), // addition
(1, 1, isize::MAX as usize + 1, 1), // allocation capacity
] {
assert!(matches!(
edited_len(original, old, new, count),
Err(TextError::Overflow)
));
}
assert_eq!(
edited_len(1, 1, isize::MAX as usize, 1).unwrap(),
isize::MAX as usize
);
assert_eq!(edited_len(8, 2, 1, 3).unwrap(), 5);
}
#[test]
fn read_and_write_preserve_whole_text_and_enforce_both_byte_limits() {
for operation in [read, write] {
assert_eq!(
operation(String::new(), TextLimits::default()).unwrap(),
TextOutcome {
content: String::new(),
bytes: 0
}
);
let content = "é\r\n😀\n";
let limits = TextLimits {
max_input_bytes: Some(content.len()),
max_output_bytes: Some(content.len()),
max_replacements: Some(0),
};
let result = operation(content.into(), limits).unwrap();
assert_eq!(result.content, content);
assert_eq!(result.bytes, content.len());
assert!(matches!(
operation(
"😀".into(),
TextLimits {
max_input_bytes: Some(3),
..Default::default()
}
),
Err(TextError::InputTooLarge { bytes: 4, limit: 3 })
));
assert!(matches!(
operation(
"😀".into(),
TextLimits {
max_output_bytes: Some(3),
..Default::default()
}
),
Err(TextError::OutputTooLarge { bytes: 4, limit: 3 })
));
assert!(matches!(
operation(
"a".into(),
TextLimits {
max_output_bytes: Some(0),
..Default::default()
}
),
Err(TextError::OutputTooLarge { bytes: 1, limit: 0 })
));
}
}
#[test]
fn utf8_byte_bounds_preserve_binary_compatibility() {
for (limit, expected) in [
(0, 0),
(1, 1),
(2, 1),
(3, 1),
(4, 1),
(5, 5),
(usize::MAX, 6),
] {
assert_eq!(bounded_utf8_len("a😀z".as_bytes(), limit), expected);
}
assert_eq!(bounded_utf8_len(&[0xff, 0xfe, 0x00], 2), 2);
}
#[test]
fn operation_names_and_schemas_are_shared_authority() {
assert_eq!(
TextOperation::from_name("read", false),
Some(TextOperation::Read)
);
assert_eq!(
TextOperation::from_name("read", true),
Some(TextOperation::ReadLines)
);
for line_read in [false, true] {
assert_eq!(
TextOperation::from_name("edit", line_read),
Some(TextOperation::Edit)
);
assert_eq!(
TextOperation::from_name("write", line_read),
Some(TextOperation::Write)
);
for name in ["Read", "WRITE", "read_lines", "stat", "", " read"] {
assert_eq!(TextOperation::from_name(name, line_read), None);
}
}
for (operation, expected) in [
(TextOperation::Read, schemars::schema_for!(ReadArgs)),
(
TextOperation::ReadLines,
schemars::schema_for!(LineReadArgs),
),
(TextOperation::Edit, schemars::schema_for!(EditArgs)),
(TextOperation::Write, schemars::schema_for!(WriteArgs)),
] {
assert_eq!(operation.argument_schema(), expected);
}
let schema = serde_json::to_value(TextOperation::Edit.argument_schema()).unwrap();
assert!(
schema["properties"]["new_string"]["description"]
.as_str()
.unwrap()
.contains("Must differ")
);
}
#[test]
fn renderer_preserves_numbering_lines_and_provider_metadata() {
let result = render_numbered("alpha\r\n\nβeta\n", 0, 3, false, 1024);
assert_eq!(result.body, " 1\talpha\n 2\t\n 3\tβeta\n");
assert_eq!(result.line_count, 3);
assert_eq!(result.total_lines, 3);
assert!(!result.truncated);
let result = render_numbered("last", 9, 10, false, 1024);
assert_eq!(result.body, " 10\tlast\n");
assert!(result.truncated);
assert!(render_numbered("last", 0, 10, true, 1024).truncated);
assert_eq!(
render_numbered("", 0, 0, false, 0),
Rendered {
body: String::new(),
line_count: 0,
total_lines: 0,
truncated: false,
}
);
assert!(render_numbered("", 1, 1, false, 0).truncated);
}
#[test]
fn renderer_reserves_marker_and_counts_partial_lines_as_before() {
let marker = RENDERED_TRUNCATION_MARKER;
let result = render_numbered("a\nb", 0, 2, false, marker.len() + 9);
assert_eq!(result.body, format!(" 1\ta\n{marker}"));
assert_eq!(result.line_count, 1);
assert_eq!(result.total_lines, 2);
assert!(result.truncated);
let result = render_numbered("a", 0, 1, false, marker.len() + 1);
assert_eq!(result.body, format!(" {marker}"));
assert_eq!(result.line_count, 1);
assert!(result.truncated);
let result = render_numbered("a", 0, 1, false, marker.len() + 9);
assert_eq!(result.body, " 1\ta\n");
assert_eq!(result.line_count, 1);
assert!(!result.truncated);
}
#[test]
fn renderer_tiny_bounds_unicode_and_large_offsets_never_overflow() {
let marker = RENDERED_TRUNCATION_MARKER;
for max_bytes in 0..=marker.len() {
let result = render_numbered("😀", 0, 1, false, max_bytes);
assert_eq!(result.body, marker[..max_bytes]);
assert_eq!(result.line_count, 0);
assert!(result.truncated);
}
for extra in 1..=4 {
let result = render_numbered("😀😀", 0, 1, false, marker.len() + 7 + extra);
let expected = if extra < 4 {
" 1\t"
} else {
" 1\t😀"
};
assert_eq!(result.body, format!("{expected}{marker}"));
assert_eq!(result.line_count, 1);
assert!(result.body.len() <= marker.len() + 7 + extra);
assert!(result.truncated);
}
let result = render_numbered("a\nb", usize::MAX, usize::MAX, false, usize::MAX);
assert_eq!(
result.body,
format!("{}\ta\n{}\tb\n", usize::MAX, usize::MAX)
);
assert_eq!(result.line_count, 2);
assert_eq!(result.total_lines, usize::MAX);
assert!(result.truncated);
}
#[test]
fn renderer_matches_original_behavior_for_ordinary_bounds() {
fn original(
text: &str,
start_line: usize,
total_lines: usize,
truncated: bool,
max_bytes: usize,
) -> Rendered {
let marker = RENDERED_TRUNCATION_MARKER;
let budget = max_bytes - marker.len();
let mut body = String::with_capacity(text.len().min(max_bytes));
let mut line_count = 0;
let mut output_truncated = false;
for (index, line) in text.lines().enumerate() {
let numbered = format!("{:>6}\t{}\n", start_line + index + 1, line);
let remaining = budget.saturating_sub(body.len());
if remaining > 0 {
line_count += 1;
}
if numbered.len() > remaining {
let mut end = remaining;
while !numbered.is_char_boundary(end) {
end -= 1;
}
body.push_str(&numbered[..end]);
body.push_str(marker);
output_truncated = true;
break;
}
body.push_str(&numbered);
}
Rendered {
body,
line_count,
total_lines,
truncated: start_line > 0 || truncated || output_truncated,
}
}
for text in ["", "a", "\n", "😀é界\nsecond\n", "first\r\n\r\nlast"] {
for start in [0, 1, 999_999] {
for bound in RENDERED_TRUNCATION_MARKER.len()..=160 {
let result = render_numbered(text, start, 99, false, bound);
assert_eq!(result, original(text, start, 99, false, bound));
assert!(result.body.len() <= bound);
}
}
}
}
#[test]
fn errors_keep_actionable_messages_and_decode_source() {
assert_eq!(
TextError::EmptyOldString.to_string(),
"old_string must not be empty"
);
assert_eq!(
TextError::IdenticalStrings.to_string(),
"old_string and new_string are identical"
);
assert_eq!(TextError::NotFound.to_string(), "old_string was not found");
assert_eq!(
TextError::MultipleMatches { occurrences: 3 }.to_string(),
"old_string matched 3 times; set replace_all=true or provide a unique string"
);
assert_eq!(
TextError::Overflow.to_string(),
"edited content size overflowed"
);
let error = decode::<ReadArgs>(json!({"unknown": true})).unwrap_err();
assert!(std::error::Error::source(&error).is_some());
}
}
+108
View File
@@ -0,0 +1,108 @@
#![cfg(target_os = "linux")]
use crate::*;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
struct Filtered {
root: PathBuf,
seen: Arc<Mutex<Vec<PathBuf>>>,
}
impl FsAccessPolicy for Filtered {
fn is_readable(&self, path: &Path) -> bool {
path.starts_with(&self.root) && !path.starts_with(self.root.join("search/denied"))
}
fn is_writable(&self, _: &Path) -> bool {
false
}
fn open_traversal_root(
&self,
_: &Path,
resolved: &Path,
) -> std::io::Result<Option<FsTraversalRoot>> {
use std::os::fd::AsRawFd;
let file = std::fs::File::open(resolved)?;
let walk = PathBuf::from(format!("/proc/self/fd/{}", file.as_raw_fd()));
Ok(Some(FsTraversalRoot::new_at(
walk,
file,
resolved.to_path_buf(),
)))
}
fn traversal_filter(&self) -> Option<FsTraversalFilter> {
let denied = self.root.join("search/denied");
let seen = self.seen.clone();
Some(Arc::new(move |path, _| {
seen.lock().unwrap().push(path.to_path_buf());
!path.starts_with(&denied)
}))
}
fn open_read_file(&self, _: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
assert!(
!resolved.starts_with(self.root.join("search/denied")),
"denied source opened"
);
std::fs::File::open(resolved)
}
}
#[test]
fn descriptor_search_anchor_and_predicate_prune_before_denied_directory_descent() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("search/denied/deep")).unwrap();
std::fs::write(root.path().join("search/allowed.txt"), "needle allowed").unwrap();
std::fs::write(
root.path().join("search/denied/deep/secret.txt"),
"needle secret",
)
.unwrap();
std::fs::write(root.path().join("outside.txt"), "needle outside").unwrap();
let policy = Filtered {
root: root.path().to_path_buf(),
seen: Arc::new(Mutex::new(Vec::new())),
};
let glob = run_glob(
root.path(),
&root.path().join("search"),
GlobRequest {
path: FsPath::new("search").unwrap(),
pattern: "**/*.txt".into(),
limit: 100,
},
&policy,
)
.unwrap();
assert_eq!(glob.paths, vec![FsPath::new("search/allowed.txt").unwrap()]);
let grep = run_grep(
root.path(),
root.path().join("search"),
GrepRequest {
path: FsPath::new("search").unwrap(),
pattern: "needle".into(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: GrepOutputMode::Content,
offset: 0,
limit: 100,
},
&policy,
)
.unwrap();
assert_eq!(grep.paths, vec![FsPath::new("search/allowed.txt").unwrap()]);
let seen = policy.seen.lock().unwrap();
assert!(
seen.iter()
.any(|path| path == &root.path().join("search/denied"))
);
assert!(
!seen
.iter()
.any(|path| path.starts_with(root.path().join("search/denied/deep")))
);
assert!(
!seen
.iter()
.any(|path| path == &root.path().join("outside.txt"))
);
}
+233
View File
@@ -0,0 +1,233 @@
//! Bounded descriptor-provider walk shared by Glob and Grep. Unlike a
//! WalkBuilder predicate, this iterator counts even rejected entries and checks
//! cancellation while scanning them. Denied directories are never enumerated.
use crate::{FsAccessPolicy, FsError, FsTraversalFilter};
use std::path::{Path, PathBuf};
pub(crate) struct Entry {
pub path: PathBuf,
pub kind: Option<std::fs::FileType>,
}
pub(crate) enum Walk<'a> {
Plain(ignore::Walk),
Descriptor(DescriptorWalk<'a>),
}
impl<'a> Walk<'a> {
/// A rejected directory has been yielded but not enumerated yet. Keep
/// ignore pruning inside the same bounded descriptor walk as scope pruning.
pub fn skip_current_dir(&mut self) {
if let Self::Descriptor(walk) = self {
walk.pending = None;
}
}
pub fn descriptor(root: &Path, access: &'a dyn FsAccessPolicy) -> Self {
Self::Descriptor(DescriptorWalk {
access,
filter: access.traversal_filter(),
pending: Some((root.to_path_buf(), 0)),
root: Some(root.to_path_buf()),
stack: Vec::new(),
visited: 0,
failed: false,
})
}
}
impl Iterator for Walk<'_> {
type Item = Result<Entry, FsError>;
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Plain(walk) => loop {
match walk.next()? {
Ok(entry) => {
return Some(Ok(Entry {
path: entry.path().to_path_buf(),
kind: entry.file_type(),
}));
}
Err(_) => continue,
}
},
Self::Descriptor(walk) => walk.next(),
}
}
}
pub(crate) struct DescriptorWalk<'a> {
access: &'a dyn FsAccessPolicy,
filter: Option<FsTraversalFilter>,
root: Option<PathBuf>,
pending: Option<(PathBuf, usize)>,
stack: Vec<(PathBuf, usize, std::fs::ReadDir)>,
visited: usize,
failed: bool,
}
impl DescriptorWalk<'_> {
fn next_entry(&mut self) -> Result<Option<Entry>, FsError> {
if let Some(root) = self.root.take() {
self.access
.check_cancelled()
.map_err(|e| FsError::io(&root, e))?;
let resolved = self
.access
.resolve_access_path(&root)
.map_err(|e| FsError::io(&root, e))?;
let metadata = self
.access
.read_metadata(&root, &resolved)
.map_err(|e| FsError::io(&root, e))?;
return Ok(Some(Entry {
path: root,
kind: Some(metadata.file_type()),
}));
}
loop {
if let Some((path, depth)) = self.pending.take() {
self.access
.check_cancelled()
.map_err(|e| FsError::io(&path, e))?;
let selected = self
.filter
.as_ref()
.is_none_or(|filter| filter(&path, true));
let resolved = self
.access
.resolve_access_path(&path)
.map_err(|e| FsError::io(&path, e))?;
if selected && self.access.can_enumerate_directory(&path, &resolved) {
if depth >= 128 {
return Err(FsError::InvalidArgument(
"descriptor traversal depth exceeds provider limit 128".into(),
));
}
let entries = self
.access
.open_read_dir(&path, &resolved)
.map_err(|e| FsError::io(&path, e))?;
self.stack.push((path, depth, entries));
}
}
let Some((parent, depth, entries)) = self.stack.last_mut() else {
return Ok(None);
};
self.access
.check_cancelled()
.map_err(|e| FsError::io(&*parent, e))?;
let Some(entry) = entries.next() else {
self.stack.pop();
continue;
};
self.visited = self.visited.saturating_add(1);
if self.visited > crate::MAX_TRAVERSAL_ENTRIES {
return Err(FsError::InvalidArgument(
"descriptor traversal exceeds provider entry limit".into(),
));
}
let entry = entry.map_err(|e| FsError::io(&*parent, e))?;
if entry.file_name().to_str().is_none() {
continue;
}
let path = parent.join(entry.file_name());
let kind = entry.file_type().map_err(|e| FsError::io(&path, e))?;
let selected = self
.filter
.as_ref()
.is_none_or(|filter| filter(&path, kind.is_dir()));
if !selected {
continue;
}
let resolved = match self.access.resolve_access_path(&path) {
Ok(path) => path,
Err(_) => continue,
};
if !self.access.is_readable_paths(&path, &resolved) {
continue;
}
if kind.is_dir() {
self.pending = Some((path.clone(), depth.saturating_add(1)));
}
return Ok(Some(Entry {
path,
kind: Some(kind),
}));
}
}
}
impl Iterator for DescriptorWalk<'_> {
type Item = Result<Entry, FsError>;
fn next(&mut self) -> Option<Self::Item> {
if self.failed {
return None;
}
match self.next_entry() {
Ok(entry) => entry.map(Ok),
Err(error) => {
self.failed = true;
Some(Err(error))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
struct RejectFiles {
calls: AtomicUsize,
cancel_at: usize,
}
impl FsAccessPolicy for RejectFiles {
fn is_readable(&self, _: &Path) -> bool {
true
}
fn is_writable(&self, _: &Path) -> bool {
false
}
fn traversal_filter(&self) -> Option<FsTraversalFilter> {
Some(Arc::new(|_, directory| directory))
}
fn check_cancelled(&self) -> std::io::Result<()> {
if self.calls.fetch_add(1, Ordering::Relaxed) >= self.cancel_at {
Err(std::io::Error::from(std::io::ErrorKind::Interrupted))
} else {
Ok(())
}
}
}
#[test]
fn descriptor_walk_counts_rejected_entries_toward_the_entry_budget() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("rejected"), "must not be opened").unwrap();
let policy = RejectFiles {
calls: AtomicUsize::new(0),
cancel_at: usize::MAX,
};
let Walk::Descriptor(mut walk) = Walk::descriptor(root.path(), &policy) else {
panic!()
};
assert!(walk.next().unwrap().is_ok());
walk.visited = crate::MAX_TRAVERSAL_ENTRIES;
assert!(matches!(
walk.next().unwrap(),
Err(FsError::InvalidArgument(_))
));
}
#[test]
fn descriptor_walk_checks_cancellation_while_scanning_only_rejected_entries() {
let root = tempfile::tempdir().unwrap();
for index in 0..20 {
std::fs::write(root.path().join(index.to_string()), "must not be opened").unwrap();
}
let policy = RejectFiles {
calls: AtomicUsize::new(0),
cancel_at: 4,
};
let mut walk = Walk::descriptor(root.path(), &policy);
assert!(walk.next().unwrap().is_ok());
assert!(
matches!(walk.next().unwrap(), Err(FsError::Io { source, .. }) if source.kind() == std::io::ErrorKind::Interrupted)
);
}
}
+2
View File
@@ -25,6 +25,8 @@ Does not own:
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
`feature.workdir_catalog.enabled` defaults to `true` for read-only Repository/Workdir/attachment reference discovery in WIP mode. It is independent of `feature.manage_workdir` (which defaults disabled), registers no normal Tools, and does not grant filesystem, attachment, or Workspace authority. Permissions remain independent. Standalone Tools-mode profiles may retain this default; only WIP installation consumes the flag. Set it to `false` to opt out.
## See also
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
+272 -4
View File
@@ -51,8 +51,9 @@ pub struct WorkerManifestConfig {
/// is disabled; `Some` requires `default_action` during final resolve.
#[serde(default)]
pub permissions: Option<PermissionConfigPartial>,
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
/// disabled after cascade merge.
/// Built-in feature/tool-surface enablement. Absent tool flags resolve
/// disabled after cascade merge; the read-only WIP workdir catalog defaults
/// enabled without granting authority.
#[serde(default)]
pub feature: FeatureConfigPartial,
/// Explicit Model Context Protocol provider declarations. Config parsing
@@ -95,6 +96,10 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub manage_workdir: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub workspace_config: Option<WorkspaceConfigFeatureConfigPartial>,
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
@@ -136,6 +141,16 @@ impl FeatureConfigPartial {
other.manage_workdir,
FeatureFlagConfigPartial::merge,
),
workdir_catalog: merge_option(
self.workdir_catalog,
other.workdir_catalog,
FeatureFlagConfigPartial::merge,
),
workspace_config: merge_option(
self.workspace_config,
other.workspace_config,
WorkspaceConfigFeatureConfigPartial::merge,
),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
merge_request: merge_option(
self.merge_request,
@@ -165,6 +180,38 @@ 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 {
#[serde(default)]
pub enabled: Option<bool>,
}
impl WorkspaceConfigFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
}
}
}
impl From<WorkspaceConfigFeatureConfigPartial> for FeatureFlagConfig {
fn from(value: WorkspaceConfigFeatureConfigPartial) -> Self {
Self {
enabled: value.enabled.unwrap_or_default(),
}
}
}
impl From<FeatureFlagConfig> for WorkspaceConfigFeatureConfigPartial {
fn from(value: FeatureFlagConfig) -> Self {
Self {
enabled: Some(value.enabled),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerFeatureConfigPartial {
#[serde(default)]
@@ -241,6 +288,8 @@ pub struct SubjektivFeatureConfigPartial {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub consolidation_tools: Option<bool>,
#[serde(default)]
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
}
@@ -248,6 +297,7 @@ impl SubjektivFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
consolidation_tools: other.consolidation_tools.or(self.consolidation_tools),
extraction: merge_option(
self.extraction,
other.extraction,
@@ -388,6 +438,16 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.manage_workdir
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
workdir_catalog: FeatureFlagConfig {
enabled: value
.workdir_catalog
.and_then(|flag| flag.enabled)
.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
},
workspace_config: value
.workspace_config
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
ticket: value
.ticket
.map(TicketFeatureConfig::from)
@@ -487,6 +547,7 @@ impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
Self {
profile: SubjektivFeatureProfileConfig {
enabled: value.enabled.unwrap_or_default(),
consolidation_tools: value.consolidation_tools.unwrap_or_default(),
extraction: MemoryExtractionProfileConfig {
enabled: extraction.enabled.unwrap_or(true),
model: extraction.model,
@@ -506,6 +567,7 @@ impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
fn from(value: ResolvedSubjektivFeatureConfig) -> Self {
Self {
enabled: Some(value.profile.enabled),
consolidation_tools: Some(value.profile.consolidation_tools),
extraction: Some(MemoryExtractionProfileConfigPartial {
enabled: Some(value.profile.extraction.enabled),
model: value.profile.extraction.model,
@@ -579,6 +641,8 @@ impl From<FeatureConfig> for FeatureConfigPartial {
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()),
workdir_catalog: Some(value.workdir_catalog.into()),
workspace_config: Some(value.workspace_config.into()),
ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()),
@@ -766,7 +830,8 @@ impl WorkerManifestConfig {
/// Base config populated with the in-code per-field defaults listed in
/// [`crate::defaults`]. This is not a selectable Profile and does not
/// enable a launch capability surface. Profile and one-file Manifest
/// enable launch authority or normal Tools. The read-only WIP workdir
/// catalog defaults enabled independently. Profile and one-file Manifest
/// resolvers start from this layer so every per-field default lives at
/// exactly one call site (the `defaults` module).
///
@@ -2232,7 +2297,7 @@ worker_max_turns = 7
}
#[test]
fn feature_flags_default_disabled_in_resolved_manifest() {
fn tool_feature_flags_default_disabled_except_read_only_workdir_catalog() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.profile.enabled);
@@ -2240,10 +2305,213 @@ worker_max_turns = 7
assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled);
assert!(!manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.workdir_catalog.enabled);
assert!(!manifest.feature.workspace_config.enabled);
assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.merge_request.any());
}
#[test]
fn workspace_config_defaults_disabled_for_partial_and_resolved_configs() {
assert!(!FeatureConfig::default().workspace_config.enabled);
assert!(FeatureConfigPartial::default().workspace_config.is_none());
for source in ["", "[feature]\n", "[feature.workspace_config]\n"] {
let partial = WorkerManifestConfig::from_toml(source).unwrap();
assert!(
!FeatureConfig::from(partial.feature)
.workspace_config
.enabled
);
}
for source in ["{}", r#"{"workspace_config": {}}"#] {
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
assert!(!feature.workspace_config.enabled);
}
}
#[test]
fn workspace_config_enabled_only_merges_and_roundtrips_snapshots() {
for enabled in [false, true] {
let lower = WorkerManifestConfig::from_toml(&format!(
"[feature.workspace_config]\nenabled = {enabled}\n"
))
.unwrap();
let empty = WorkerManifestConfig::from_toml("[feature.workspace_config]\n").unwrap();
let manifest: WorkerManifest = minimal_valid()
.merge(lower.clone())
.merge(empty)
.try_into()
.unwrap();
assert_eq!(manifest.feature.workspace_config.enabled, enabled);
assert!(!manifest.feature.manage_workdir.enabled);
let partial = FeatureConfigPartial::from(manifest.feature.clone());
assert_eq!(
partial.workspace_config.as_ref().unwrap().enabled,
Some(enabled)
);
let serialized = toml::to_string(&partial).unwrap();
let restored: FeatureConfigPartial = toml::from_str(&serialized).unwrap();
assert_eq!(FeatureConfig::from(restored), manifest.feature);
let json = serde_json::to_value(&manifest.feature).unwrap();
assert_eq!(
json["workspace_config"],
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();
assert_eq!(
crate::read_persisted_worker_manifest_snapshot(snapshot.clone())
.unwrap()
.feature
.workspace_config
.enabled,
enabled
);
let mut old_snapshot = snapshot;
old_snapshot["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("workspace_config");
assert!(
!crate::read_persisted_worker_manifest_snapshot(old_snapshot)
.unwrap()
.feature
.workspace_config
.enabled
);
let upper = WorkerManifestConfig::from_toml(&format!(
"[feature.workspace_config]\nenabled = {}\n",
!enabled
))
.unwrap();
let manifest: WorkerManifest = minimal_valid()
.merge(lower)
.merge(upper)
.try_into()
.unwrap();
assert_eq!(manifest.feature.workspace_config.enabled, !enabled);
}
}
#[test]
fn workspace_config_rejects_manifest_authority_fields() {
for (field, value) in [
("grant_id", "\"grant-1\""),
("workspace_id", "\"workspace-1\""),
("access", "\"read_write\""),
("config_root", "\"/host/config\""),
("source", "\"config\""),
] {
let source = format!("[feature.workspace_config]\nenabled = true\n{field} = {value}\n");
assert!(WorkerManifestConfig::from_toml(&source).is_err(), "{field}");
assert!(
toml::from_str::<FeatureConfig>(&format!(
"[workspace_config]\nenabled = true\n{field} = {value}\n"
))
.is_err(),
"{field}"
);
}
}
#[test]
fn workdir_catalog_defaults_agree_for_partial_and_resolved_configs() {
assert!(FeatureConfig::default().workdir_catalog.enabled);
assert!(FeatureConfigPartial::default().workdir_catalog.is_none());
for source in ["", "[feature]\n", "[feature.workdir_catalog]\n"] {
let partial = WorkerManifestConfig::from_toml(source).unwrap();
let feature = FeatureConfig::from(partial.feature);
assert!(feature.workdir_catalog.enabled, "{source}");
let value: toml::Value = toml::from_str(source).unwrap();
let resolved: FeatureConfig = serde_json::from_value(
serde_json::to_value(
value
.get("feature")
.cloned()
.unwrap_or_else(|| toml::Value::Table(Default::default())),
)
.unwrap(),
)
.unwrap();
assert_eq!(feature, resolved);
}
for source in ["{}", r#"{"workdir_catalog": {}}"#] {
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
assert!(feature.workdir_catalog.enabled);
assert!(!feature.manage_workdir.enabled);
}
}
#[test]
fn workdir_catalog_is_independent_and_merges_partial_layers() {
for catalog in [false, true] {
for manage in [false, true] {
let source = format!(
"[feature.workdir_catalog]\nenabled = {catalog}\n\
[feature.manage_workdir]\nenabled = {manage}\n"
);
let lower = WorkerManifestConfig::from_toml(&source).unwrap();
let empty = WorkerManifestConfig::from_toml("[feature.workdir_catalog]").unwrap();
let cfg = minimal_valid().merge(lower.clone()).merge(empty);
let manifest: WorkerManifest = cfg.try_into().unwrap();
assert_eq!(manifest.feature.workdir_catalog.enabled, catalog);
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
let override_source =
format!("[feature.workdir_catalog]\nenabled = {}\n", !catalog);
let upper = WorkerManifestConfig::from_toml(&override_source).unwrap();
let manifest: WorkerManifest = minimal_valid()
.merge(lower)
.merge(upper)
.try_into()
.unwrap();
assert_eq!(manifest.feature.workdir_catalog.enabled, !catalog);
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
}
}
}
#[test]
fn workdir_catalog_roundtrips_config_and_persisted_snapshots() {
for enabled in [false, true] {
let mut cfg = minimal_valid();
cfg.feature.workdir_catalog = Some(FeatureFlagConfigPartial {
enabled: Some(enabled),
});
let manifest: WorkerManifest = cfg.try_into().unwrap();
let partial = FeatureConfigPartial::from(manifest.feature.clone());
assert_eq!(
partial.workdir_catalog.as_ref().unwrap().enabled,
Some(enabled)
);
let serialized = toml::to_string(&partial).unwrap();
let partial: FeatureConfigPartial = toml::from_str(&serialized).unwrap();
assert_eq!(FeatureConfig::from(partial), manifest.feature);
let serialized = serde_json::to_value(&manifest.feature).unwrap();
assert_eq!(serialized["workdir_catalog"]["enabled"], enabled);
let feature: FeatureConfig = serde_json::from_value(serialized).unwrap();
assert_eq!(feature, manifest.feature);
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
let restored =
crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
assert_eq!(restored.feature.workdir_catalog.enabled, enabled);
assert!(!restored.feature.manage_workdir.enabled);
let mut legacy = snapshot;
legacy["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("workdir_catalog");
let restored = crate::read_persisted_worker_manifest_snapshot(legacy).unwrap();
assert!(restored.feature.workdir_catalog.enabled);
assert!(!restored.feature.manage_workdir.enabled);
}
}
#[test]
fn from_toml_parses_explicit_feature_flags() {
let cfg = WorkerManifestConfig::from_toml(
+4
View File
@@ -6,6 +6,10 @@
//! `pub const`. Both paths read from this module, so changing a
//! default requires editing exactly one line.
/// Read-only Repository/Workdir/attachment reference discovery in WIP mode.
/// This enables a reference provider, not normal Tools or any authority grant.
pub const WORKDIR_CATALOG_ENABLED: bool = true;
/// Byte-size cap applied to any tool's `content` output when no
/// per-tool override is set. See [`crate::ToolOutputLimits`].
pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
+44 -2
View File
@@ -65,8 +65,9 @@ pub struct WorkerManifest {
/// permission layer is disabled and tool calls run as before.
#[serde(default)]
pub permissions: Option<ToolPermissionConfig>,
/// Explicit built-in feature/tool-surface enablement. Omitted feature flags
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
/// Built-in feature/tool-surface enablement. Omitted tool features resolve
/// disabled; the read-only WIP workdir catalog defaults enabled without
/// granting authority or registering normal Tools.
#[serde(default)]
pub feature: FeatureConfig,
/// Explicit external Model Context Protocol provider configuration. This
@@ -131,6 +132,19 @@ pub struct FeatureConfig {
pub objective: FeatureFlagConfig,
#[serde(default)]
pub manage_workdir: FeatureFlagConfig,
/// Read-only Repository/Workdir/attachment reference discovery. Defaults
/// enabled, is installed only in WIP mode, and exposes no normal Tools.
/// Independent of `manage_workdir`; permissions and host authority remain
/// separate, and discovering a reference grants no attachment or file access.
#[serde(
default = "default_workdir_catalog",
deserialize_with = "deserialize_workdir_catalog"
)]
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")]
pub workspace_config: FeatureFlagConfig,
#[serde(default)]
pub ticket: TicketFeatureConfig,
#[serde(default)]
@@ -139,6 +153,29 @@ pub struct FeatureConfig {
pub orchestration: FeatureFlagConfig,
}
fn default_workdir_catalog() -> FeatureFlagConfig {
FeatureFlagConfig {
enabled: defaults::WORKDIR_CATALOG_ENABLED,
}
}
fn deserialize_workdir_catalog<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
where
D: serde::Deserializer<'de>,
{
let flag = config::FeatureFlagConfigPartial::deserialize(deserializer)?;
Ok(FeatureFlagConfig {
enabled: flag.enabled.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
})
}
fn deserialize_workspace_config<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(config::WorkspaceConfigFeatureConfigPartial::deserialize(deserializer)?.into())
}
impl Default for FeatureConfig {
fn default() -> Self {
Self {
@@ -153,6 +190,8 @@ impl Default for FeatureConfig {
workspace_worker_discovery: FeatureFlagConfig::disabled(),
objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(),
workdir_catalog: default_workdir_catalog(),
workspace_config: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(),
@@ -363,6 +402,8 @@ impl ResolvedMemoryFeatureConfig {
#[serde(default, deny_unknown_fields)]
pub struct SubjektivFeatureProfileConfig {
pub enabled: bool,
/// Explicit consolidation tool policy; never grants Job or subject authority.
pub consolidation_tools: bool,
pub extraction: MemoryExtractionProfileConfig,
}
@@ -370,6 +411,7 @@ impl Default for SubjektivFeatureProfileConfig {
fn default() -> Self {
Self {
enabled: false,
consolidation_tools: false,
extraction: MemoryExtractionProfileConfig::default(),
}
}
+55
View File
@@ -181,6 +181,8 @@ pub fn validate_profile_execution_target(
if feature.flow.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Flow);
}
// workdir_catalog is a WIP-only reference provider, not Workspace authority
// or normal Tools. It must not prevent standalone Tools-mode launches.
if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
}
@@ -1482,6 +1484,59 @@ mod tests {
assert!(!resolved.manifest.feature.manage_workdir.enabled);
}
#[test]
fn workdir_catalog_does_not_require_workspace_authority_or_grant_scope() {
let tmp = TempDir::new().unwrap();
for catalog in [None, Some(false), Some(true)] {
let mut artifact = serde_json::json!({
"worker": { "mode": "tools" },
"feature": { "manage_workdir": { "enabled": false } },
"permissions": { "default_action": "deny" }
});
if let Some(enabled) = catalog {
artifact["feature"]["workdir_catalog"] = serde_json::json!({ "enabled": enabled });
}
let resolved = resolve_profile_artifact_value(
artifact,
ProfileSource::Archive {
archive_id: "catalog-test".into(),
source: "test".into(),
},
tmp.path(),
"catalog-worker",
)
.unwrap();
let manifest = resolved.manifest;
assert_eq!(
manifest.feature.workdir_catalog.enabled,
catalog.unwrap_or(true)
);
assert!(!manifest.feature.manage_workdir.enabled);
assert_eq!(manifest.worker.mode, crate::WorkerMode::Tools);
assert!(manifest.scope.allow.is_empty());
assert!(manifest.delegation_scope.allow.is_empty());
assert_eq!(
manifest.permissions.as_ref().unwrap().default_action,
crate::ToolPermissionAction::Deny
);
validate_profile_execution_target(&manifest, ProfileExecutionTarget::Standalone)
.unwrap();
let mut manage_manifest = manifest;
manage_manifest.feature.manage_workdir.enabled = true;
let error = validate_profile_execution_target(
&manage_manifest,
ProfileExecutionTarget::Standalone,
)
.unwrap_err();
assert!(matches!(
error,
ProfileError::UnsupportedExecutionTarget { requirements, .. }
if requirements == vec![WorkspaceAuthorityRequirement::ManageWorkdir]
));
}
}
#[test]
fn standalone_rejects_profiles_that_require_workspace_authority() {
let tmp = TempDir::new().unwrap();
+1
View File
@@ -14,6 +14,7 @@ json-schema = ["dep:schemars"]
schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
sha2.workspace = true
tokio = { workspace = true, features = ["io-util"], optional = true }
ts-rs = { version = "12.0.1", optional = true }
+949
View File
@@ -0,0 +1,949 @@
//! Declarative chat Feature invocation contract.
//!
//! The input spelling is deliberately bounded and unambiguous:
//! `/name(arg=value, "positional value")`. Parentheses terminate arguments,
//! so following prose is never consumed. Strings use JSON-style quoting and
//! escaping. Unquoted values are limited to booleans, integers, enum-like words,
//! and paths without comma/parenthesis/whitespace. This module parses input for
//! clients and validates the structured payload again at the host boundary.
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
/// UTF-8 byte bound shared by input validation and durable recovery keys.
pub const MAX_FEATURE_INVOCATION_ID_BYTES: usize = 128;
/// Installed descriptors must fit a public completion entry without truncation.
pub const MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES: usize = 32 * 1024;
/// Source-qualified identity of one invocation contribution, for example
/// `builtin:attachments/attach`. Display names and aliases are never authority.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocationIdentity(pub String);
impl fmt::Display for FeatureInvocationIdentity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum FeatureInvocationSyntax {
/// `/name(value, key=value)` with JSON-style quoted strings and `)` as the
/// explicit boundary before following natural language.
Parenthesized,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InvocationArgumentType {
String,
Integer,
Boolean,
Enum {
values: Vec<String>,
},
/// A Worker-readable path. This does not grant filesystem authority.
WorkerFile,
/// A resource selected and staged by the client. It must never be resolved
/// as a path on the Worker host.
ClientFile,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum InvocationCompletion {
None,
Static {
values: Vec<String>,
},
WorkerFile,
ClientFile,
/// Host-owned provider selected by source-qualified id. Metadata lookup is
/// side-effect free; implementations never cross this wire boundary.
Provider {
provider: String,
},
}
impl Default for InvocationCompletion {
fn default() -> Self {
Self::None
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct InvocationArgumentDescriptor {
pub name: String,
/// Zero-based positional index. `None` means named-only. A positional
/// argument may also be supplied by name; this makes chip editing stable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<u16>,
#[serde(default)]
pub required: bool,
pub value_type: InvocationArgumentType,
#[serde(default)]
pub completion: InvocationCompletion,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum InvocationClientAdapter {
/// The client stages a local file and replaces the draft invocation with an
/// `UploadedFile` chip. Local paths are not sent to or persisted by Worker.
Attachment,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocationDescriptor {
pub identity: FeatureInvocationIdentity,
pub name: String,
#[serde(default)]
pub aliases: Vec<String>,
pub display_name: String,
pub description: String,
pub syntax: FeatureInvocationSyntax,
#[serde(default)]
pub arguments: Vec<InvocationArgumentDescriptor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_adapter: Option<InvocationClientAdapter>,
}
impl FeatureInvocationDescriptor {
pub fn usage(&self) -> String {
let mut usage = format!("/{}(", self.name);
for (index, argument) in self.arguments.iter().enumerate() {
if index > 0 {
usage.push_str(", ");
}
if argument.position.is_none() {
usage.push_str(&argument.name);
usage.push('=');
}
let marker = match &argument.value_type {
InvocationArgumentType::String => "text",
InvocationArgumentType::Integer => "integer",
InvocationArgumentType::Boolean => "true|false",
InvocationArgumentType::Enum { .. } => "choice",
InvocationArgumentType::WorkerFile => "worker-path",
InvocationArgumentType::ClientFile => "local-file",
};
if argument.required {
usage.push('<');
usage.push_str(marker);
usage.push('>');
} else {
usage.push('[');
usage.push_str(marker);
usage.push(']');
}
}
usage.push(')');
usage
}
pub fn validate(&self) -> Result<(), InvocationValidationError> {
const MAX_PUBLIC_NAMES: usize = 16;
const MAX_ARGUMENTS: usize = 32;
const MAX_METADATA_BYTES: usize = 4096;
validate_invocation_name(&self.name)?;
if self.aliases.len() > MAX_PUBLIC_NAMES || self.arguments.len() > MAX_ARGUMENTS {
return Err(InvocationValidationError::InvalidDescriptor(
"invocation descriptor exceeds collection limits".into(),
));
}
if self.display_name.len() > MAX_METADATA_BYTES
|| self.description.len() > MAX_METADATA_BYTES
{
return Err(InvocationValidationError::InvalidDescriptor(
"invocation metadata exceeds byte limits".into(),
));
}
if self.identity.0.trim().is_empty()
|| !self.identity.0.contains(':')
|| self.identity.0.len() > 256
{
return Err(InvocationValidationError::InvalidDescriptor(
"invocation identity must be source-qualified".into(),
));
}
let mut names = BTreeSet::new();
let mut positions = BTreeSet::new();
for name in std::iter::once(&self.name).chain(self.aliases.iter()) {
validate_invocation_name(name)?;
if !names.insert(name) {
return Err(InvocationValidationError::InvalidDescriptor(format!(
"duplicate invocation name or alias `{name}`"
)));
}
}
let mut argument_names = BTreeSet::new();
for argument in &self.arguments {
validate_argument_name(&argument.name)?;
if !argument_names.insert(argument.name.as_str()) {
return Err(InvocationValidationError::InvalidDescriptor(format!(
"duplicate argument `{}`",
argument.name
)));
}
if let Some(position) = argument.position
&& !positions.insert(position)
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"duplicate positional index `{position}`"
)));
}
if argument
.description
.as_ref()
.is_some_and(|value| value.len() > MAX_METADATA_BYTES)
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"argument `{}` description exceeds byte limits",
argument.name
)));
}
if let InvocationCompletion::Provider { provider } = &argument.completion
&& (provider.trim().is_empty() || provider.len() > 256)
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"argument `{}` has an invalid completion provider",
argument.name
)));
}
if matches!(&argument.value_type, InvocationArgumentType::Enum { values }
if values.len() > 256 || values.iter().any(|value| value.len() > MAX_METADATA_BYTES))
|| matches!(&argument.completion, InvocationCompletion::Static { values }
if values.len() > 256 || values.iter().any(|value| value.len() > MAX_METADATA_BYTES))
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"argument `{}` completion values exceed limits",
argument.name
)));
}
if matches!(&argument.value_type, InvocationArgumentType::Enum { values } if values.is_empty())
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"enum argument `{}` has no values",
argument.name
)));
}
}
for (expected, actual) in positions.iter().copied().enumerate() {
if actual as usize != expected {
return Err(InvocationValidationError::InvalidDescriptor(
"positional argument indices must be contiguous from zero".into(),
));
}
}
if serde_json::to_vec(self).map_or(true, |encoded| {
encoded.len() > MAX_FEATURE_INVOCATION_DESCRIPTOR_BYTES
}) {
return Err(InvocationValidationError::InvalidDescriptor(
"invocation descriptor exceeds the public completion byte limit".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum InvocationValue {
String(String),
Integer(i32),
Boolean(bool),
}
impl InvocationValue {
pub fn display_input(&self) -> String {
match self {
Self::String(value) => quote_invocation_string(value),
Self::Integer(value) => value.to_string(),
Self::Boolean(value) => value.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct InvocationArgumentValue {
pub name: String,
pub value: InvocationValue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocation {
/// Stable client-generated id retained across queueing and transport retry.
pub invocation_id: String,
pub identity: FeatureInvocationIdentity,
/// Selected public name, retained for exact draft/history restoration. Host
/// resolution is always by `identity`.
pub name: String,
pub arguments: Vec<InvocationArgumentValue>,
}
impl FeatureInvocation {
pub fn display_input(&self) -> String {
let mut input = format!("/{}(", self.name);
for (index, argument) in self.arguments.iter().enumerate() {
if index > 0 {
input.push_str(", ");
}
input.push_str(&argument.name);
input.push('=');
input.push_str(&argument.value.display_input());
}
input.push(')');
input
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum FeatureInvocationStatus {
Succeeded,
Failed,
OutcomeUnknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct FeatureInvocationResult {
pub invocation_id: String,
pub identity: FeatureInvocationIdentity,
pub status: FeatureInvocationStatus,
pub message: String,
/// Bounded model-visible context produced by the handler. Credentials and
/// executable implementation details must not be included.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedFeatureInvocation {
pub invocation: FeatureInvocation,
/// UTF-8 byte offset immediately after the closing parenthesis.
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvocationValidationError {
#[error("invalid invocation descriptor: {0}")]
InvalidDescriptor(String),
#[error("incomplete invocation at byte {0}")]
Incomplete(usize),
#[error("unknown argument `{0}`")]
UnknownArgument(String),
#[error("duplicate argument `{0}`")]
DuplicateArgument(String),
#[error("missing required argument `{0}`")]
MissingRequired(String),
#[error("too many positional arguments")]
TooManyPositional,
#[error("invalid value for `{argument}`: {message}")]
InvalidValue { argument: String, message: String },
#[error("invocation identity does not match descriptor")]
IdentityMismatch,
#[error("invocation name does not match descriptor")]
NameMismatch,
#[error("invalid invocation syntax at byte {offset}: {message}")]
Syntax { offset: usize, message: String },
}
pub fn validate_feature_invocation(
descriptor: &FeatureInvocationDescriptor,
invocation: &FeatureInvocation,
) -> Result<(), InvocationValidationError> {
descriptor.validate()?;
if invocation.identity != descriptor.identity {
return Err(InvocationValidationError::IdentityMismatch);
}
if invocation.name != descriptor.name && !descriptor.aliases.contains(&invocation.name) {
return Err(InvocationValidationError::NameMismatch);
}
if invocation.invocation_id.trim().is_empty()
|| invocation.invocation_id.len() > MAX_FEATURE_INVOCATION_ID_BYTES
{
return Err(InvocationValidationError::InvalidValue {
argument: "invocation_id".into(),
message: "must be nonempty and at most 128 UTF-8 bytes".into(),
});
}
if invocation.arguments.len() > 32 {
return Err(InvocationValidationError::InvalidValue {
argument: "arguments".into(),
message: "too many arguments".into(),
});
}
let mut values = BTreeMap::new();
for argument in &invocation.arguments {
if values
.insert(argument.name.as_str(), &argument.value)
.is_some()
{
return Err(InvocationValidationError::DuplicateArgument(
argument.name.clone(),
));
}
}
for name in values.keys() {
if !descriptor
.arguments
.iter()
.any(|argument| argument.name == **name)
{
return Err(InvocationValidationError::UnknownArgument(
(*name).to_string(),
));
}
}
for argument in &descriptor.arguments {
match values.get(argument.name.as_str()) {
Some(value) => validate_value(argument, value)?,
None if argument.required => {
return Err(InvocationValidationError::MissingRequired(
argument.name.clone(),
));
}
None => {}
}
}
Ok(())
}
/// Parse one complete parenthesized invocation starting at `start`. Callers
/// decide when input becomes a typed chip; parsing alone never executes it.
pub fn parse_feature_invocation(
input: &str,
start: usize,
descriptor: &FeatureInvocationDescriptor,
invocation_id: impl Into<String>,
) -> Result<ParsedFeatureInvocation, InvocationValidationError> {
descriptor.validate()?;
if !input.is_char_boundary(start) || input.as_bytes().get(start) != Some(&b'/') {
return Err(InvocationValidationError::Syntax {
offset: start,
message: "expected `/`".into(),
});
}
let mut cursor = start + 1;
let name_start = cursor;
while let Some(byte) = input.as_bytes().get(cursor) {
if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'_') {
cursor += 1;
} else {
break;
}
}
let name = &input[name_start..cursor];
if name != descriptor.name && !descriptor.aliases.iter().any(|alias| alias == name) {
return Err(InvocationValidationError::NameMismatch);
}
skip_ws(input, &mut cursor);
if input.as_bytes().get(cursor) != Some(&b'(') {
return Err(InvocationValidationError::Incomplete(cursor));
}
cursor += 1;
let positional = descriptor
.arguments
.iter()
.filter_map(|argument| argument.position.map(|position| (position, argument)))
.collect::<BTreeMap<_, _>>();
let mut next_position = 0u16;
let mut parsed = Vec::<InvocationArgumentValue>::new();
loop {
skip_ws(input, &mut cursor);
match input.as_bytes().get(cursor) {
Some(b')') => {
cursor += 1;
break;
}
None => return Err(InvocationValidationError::Incomplete(cursor)),
_ => {}
}
let token_start = cursor;
let possible_name = parse_identifier(input, &mut cursor);
let after_name = cursor;
skip_ws(input, &mut cursor);
let (argument, raw_value) = if !possible_name.is_empty()
&& input.as_bytes().get(cursor) == Some(&b'=')
{
cursor += 1;
skip_ws(input, &mut cursor);
let argument = descriptor
.arguments
.iter()
.find(|argument| argument.name == possible_name)
.ok_or_else(|| InvocationValidationError::UnknownArgument(possible_name.into()))?;
(argument, parse_raw_value(input, &mut cursor)?)
} else {
cursor = token_start;
let argument = positional
.get(&next_position)
.copied()
.ok_or(InvocationValidationError::TooManyPositional)?;
next_position += 1;
(argument, parse_raw_value(input, &mut cursor)?)
};
if parsed.iter().any(|value| value.name == argument.name) {
return Err(InvocationValidationError::DuplicateArgument(
argument.name.clone(),
));
}
let value = coerce_value(argument, raw_value)?;
parsed.push(InvocationArgumentValue {
name: argument.name.clone(),
value,
});
skip_ws(input, &mut cursor);
match input.as_bytes().get(cursor) {
Some(b',') => cursor += 1,
Some(b')') => {}
None => return Err(InvocationValidationError::Incomplete(cursor)),
_ => {
return Err(InvocationValidationError::Syntax {
offset: after_name.max(cursor),
message: "expected `,` or `)`".into(),
});
}
}
}
let invocation = FeatureInvocation {
invocation_id: invocation_id.into(),
identity: descriptor.identity.clone(),
name: name.to_string(),
arguments: parsed,
};
validate_feature_invocation(descriptor, &invocation)?;
Ok(ParsedFeatureInvocation {
invocation,
end: cursor,
})
}
#[derive(Debug)]
enum RawValue {
Quoted(String),
Bare(String),
}
fn parse_raw_value(input: &str, cursor: &mut usize) -> Result<RawValue, InvocationValidationError> {
if input.as_bytes().get(*cursor) == Some(&b'"') {
let start = *cursor;
*cursor += 1;
let mut escaped = false;
loop {
let Some(ch) = input[*cursor..].chars().next() else {
return Err(InvocationValidationError::Incomplete(*cursor));
};
*cursor += ch.len_utf8();
if ch == '"' && !escaped {
let value =
serde_json::from_str::<String>(&input[start..*cursor]).map_err(|error| {
InvocationValidationError::Syntax {
offset: start,
message: error.to_string(),
}
})?;
return Ok(RawValue::Quoted(value));
}
escaped = ch == '\\' && !escaped;
}
}
let start = *cursor;
while let Some(ch) = input[*cursor..].chars().next() {
if ch == ',' || ch == ')' || ch.is_whitespace() {
break;
}
*cursor += ch.len_utf8();
}
if *cursor == start {
return Err(InvocationValidationError::Syntax {
offset: start,
message: "expected argument value".into(),
});
}
Ok(RawValue::Bare(input[start..*cursor].to_string()))
}
fn coerce_value(
descriptor: &InvocationArgumentDescriptor,
raw: RawValue,
) -> Result<InvocationValue, InvocationValidationError> {
let text = match raw {
RawValue::Quoted(value) | RawValue::Bare(value) => value,
};
let invalid = |message: &str| InvocationValidationError::InvalidValue {
argument: descriptor.name.clone(),
message: message.into(),
};
match &descriptor.value_type {
InvocationArgumentType::String
| InvocationArgumentType::WorkerFile
| InvocationArgumentType::ClientFile => Ok(InvocationValue::String(text)),
InvocationArgumentType::Integer => {
let digits = text.strip_prefix('-').unwrap_or(&text);
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(invalid("expected a decimal integer"));
}
text.parse::<i32>()
.map(InvocationValue::Integer)
.map_err(|_| invalid("expected a signed 32-bit integer"))
}
InvocationArgumentType::Boolean => match text.as_str() {
"true" => Ok(InvocationValue::Boolean(true)),
"false" => Ok(InvocationValue::Boolean(false)),
_ => Err(invalid("expected `true` or `false`")),
},
InvocationArgumentType::Enum { values } => {
if values.contains(&text) {
Ok(InvocationValue::String(text))
} else {
Err(invalid("value is not in the declared enum"))
}
}
}
}
fn validate_value(
descriptor: &InvocationArgumentDescriptor,
value: &InvocationValue,
) -> Result<(), InvocationValidationError> {
let valid = match (&descriptor.value_type, value) {
(
InvocationArgumentType::String
| InvocationArgumentType::WorkerFile
| InvocationArgumentType::ClientFile,
InvocationValue::String(value),
) => value.len() <= 16 * 1024,
(InvocationArgumentType::Integer, InvocationValue::Integer(_)) => true,
(InvocationArgumentType::Boolean, InvocationValue::Boolean(_)) => true,
(InvocationArgumentType::Enum { values }, InvocationValue::String(value)) => {
values.contains(value)
}
_ => false,
};
if valid {
Ok(())
} else {
Err(InvocationValidationError::InvalidValue {
argument: descriptor.name.clone(),
message: "structured value does not match the declared type".into(),
})
}
}
fn validate_invocation_name(name: &str) -> Result<(), InvocationValidationError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|| !name.as_bytes()[0].is_ascii_lowercase()
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"invalid invocation name `{name}`"
)));
}
Ok(())
}
fn validate_argument_name(name: &str) -> Result<(), InvocationValidationError> {
if name.is_empty()
|| !name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
|| !name.as_bytes()[0].is_ascii_lowercase()
{
return Err(InvocationValidationError::InvalidDescriptor(format!(
"invalid argument name `{name}`"
)));
}
Ok(())
}
fn parse_identifier<'a>(input: &'a str, cursor: &mut usize) -> &'a str {
let start = *cursor;
while let Some(byte) = input.as_bytes().get(*cursor) {
if byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_' {
*cursor += 1;
} else {
break;
}
}
&input[start..*cursor]
}
fn skip_ws(input: &str, cursor: &mut usize) {
while let Some(ch) = input[*cursor..].chars().next() {
if !ch.is_whitespace() {
break;
}
*cursor += ch.len_utf8();
}
}
pub fn quote_invocation_string(value: &str) -> String {
// Keep the spelling identical to Web's JSON.stringify, including control
// characters and JSON unicode escapes accepted by both common parsers.
serde_json::to_string(value).expect("serializing a string cannot fail")
}
#[cfg(test)]
mod tests {
use super::*;
fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("builtin:test/run".into()),
name: "run".into(),
aliases: vec!["execute".into()],
display_name: "Run".into(),
description: "test invocation".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![
InvocationArgumentDescriptor {
name: "path".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::WorkerFile,
description: None,
},
InvocationArgumentDescriptor {
name: "mode".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Enum {
values: vec!["fast".into(), "safe".into()],
},
completion: InvocationCompletion::Static {
values: vec!["fast".into(), "safe".into()],
},
description: None,
},
],
client_adapter: None,
}
}
#[test]
fn parses_unicode_quoted_paths_and_stops_before_natural_language() {
let input = "/run(\"資料/my file / x\", mode=safe) この後は本文";
let parsed = parse_feature_invocation(input, 0, &descriptor(), "invoke-1").unwrap();
assert_eq!(&input[parsed.end..], " この後は本文");
assert_eq!(
parsed.invocation.arguments,
vec![
InvocationArgumentValue {
name: "path".into(),
value: InvocationValue::String("資料/my file / x".into()),
},
InvocationArgumentValue {
name: "mode".into(),
value: InvocationValue::String("safe".into()),
},
]
);
}
#[test]
fn reports_incomplete_unknown_duplicate_missing_and_type_errors() {
assert!(matches!(
parse_feature_invocation("/run(\"x\"", 0, &descriptor(), "1"),
Err(InvocationValidationError::Incomplete(_))
));
assert!(matches!(
parse_feature_invocation("/run(path=x, nope=y)", 0, &descriptor(), "1"),
Err(InvocationValidationError::UnknownArgument(name)) if name == "nope"
));
assert!(matches!(
parse_feature_invocation("/run(path=x, path=y)", 0, &descriptor(), "1"),
Err(InvocationValidationError::DuplicateArgument(name)) if name == "path"
));
assert!(matches!(
parse_feature_invocation("/run(mode=fast)", 0, &descriptor(), "1"),
Err(InvocationValidationError::MissingRequired(name)) if name == "path"
));
assert!(matches!(
parse_feature_invocation("/run(x, mode=unknown)", 0, &descriptor(), "1"),
Err(InvocationValidationError::InvalidValue { argument, .. }) if argument == "mode"
));
}
#[test]
fn descriptors_cannot_install_with_undiscoverable_oversized_metadata() {
let mut descriptor = descriptor();
descriptor.arguments[0].completion = InvocationCompletion::Static {
values: vec!["a".repeat(1024); 40],
};
assert!(
matches!(descriptor.validate(), Err(InvocationValidationError::InvalidDescriptor(message)) if message.contains("completion byte limit"))
);
}
#[test]
fn client_and_recovery_invocation_key_bound_is_shared() {
let descriptor = descriptor();
assert!(
parse_feature_invocation(
"/run(x)",
0,
&descriptor,
"a".repeat(MAX_FEATURE_INVOCATION_ID_BYTES)
)
.is_ok()
);
assert!(
parse_feature_invocation(
"/run(x)",
0,
&descriptor,
"a".repeat(MAX_FEATURE_INVOCATION_ID_BYTES + 1)
)
.is_err()
);
assert!(parse_feature_invocation("/run(x)", 0, &descriptor, "界".repeat(43)).is_err());
}
#[test]
fn structured_payload_validation_does_not_trust_client_identity_or_types() {
let descriptor = descriptor();
let original = parse_feature_invocation("/run(path=x)", 0, &descriptor, "stable")
.unwrap()
.invocation;
let mut changed = original.clone();
changed.identity = FeatureInvocationIdentity("builtin:other/run".into());
assert_eq!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::IdentityMismatch)
);
changed = original.clone();
changed.name = "unregistered".into();
assert_eq!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::NameMismatch)
);
changed = original.clone();
changed.arguments[0].value = InvocationValue::Boolean(true);
assert!(matches!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::InvalidValue { .. })
));
changed = original.clone();
changed.arguments.push(changed.arguments[0].clone());
assert!(matches!(
validate_feature_invocation(&descriptor, &changed),
Err(InvocationValidationError::DuplicateArgument(_))
));
changed = original;
changed.invocation_id.clear();
assert!(validate_feature_invocation(&descriptor, &changed).is_err());
}
#[test]
fn integer_boolean_and_utf8_offsets_use_the_common_typed_contract() {
let mut descriptor = descriptor();
descriptor.arguments[0].value_type = InvocationArgumentType::Integer;
descriptor.arguments[1].value_type = InvocationArgumentType::Boolean;
let parsed = parse_feature_invocation(
"本文 /execute(-2147483648, mode=true) 後",
"本文 ".len(),
&descriptor,
"typed",
)
.unwrap();
assert_eq!(
parsed.invocation.arguments[0].value,
InvocationValue::Integer(i32::MIN)
);
assert_eq!(
parsed.invocation.arguments[1].value,
InvocationValue::Boolean(true)
);
for invalid in ["/run(2147483648)", "/run(1.0)", "/run(1, mode=yes)"] {
assert!(parse_feature_invocation(invalid, 0, &descriptor, "typed").is_err());
}
assert!(parse_feature_invocation("本文 /run(1)", 1, &descriptor, "typed").is_err());
assert!(parse_feature_invocation("/run(1)", usize::MAX, &descriptor, "typed").is_err());
}
#[test]
fn json_escapes_and_multiple_invocations_have_explicit_boundaries() {
let input = r#"/run("\u8cc7\u6599\/a\b\f", mode=safe) prose /execute("second") end"#;
let first = parse_feature_invocation(input, 0, &descriptor(), "first").unwrap();
assert_eq!(
first.invocation.arguments[0].value,
InvocationValue::String("資料/a\u{8}\u{c}".into())
);
let next = input[first.end..].find("/execute").unwrap() + first.end;
let second = parse_feature_invocation(input, next, &descriptor(), "second").unwrap();
assert_eq!(&input[second.end..], " end");
assert!(matches!(
parse_feature_invocation(r#"/run("\uD800")"#, 0, &descriptor(), "bad"),
Err(InvocationValidationError::Syntax { .. })
));
let value = "controls \u{0}\u{8}\u{c}";
let spelling = format!("/run({})", quote_invocation_string(value));
assert_eq!(
parse_feature_invocation(&spelling, 0, &descriptor(), "control")
.unwrap()
.invocation
.arguments[0]
.value,
InvocationValue::String(value.into())
);
}
#[test]
fn typed_segment_round_trips_without_flattening_identity_or_arguments() {
let parsed =
parse_feature_invocation("/run(\"x y\", mode=fast)", 0, &descriptor(), "1").unwrap();
let segment = crate::Segment::FeatureInvoke {
invocation: parsed.invocation,
};
let json = serde_json::to_string(&segment).unwrap();
assert!(json.contains("\"kind\":\"feature_invoke\""));
assert!(json.contains("builtin:test/run"));
assert_eq!(
serde_json::from_str::<crate::Segment>(&json).unwrap(),
segment
);
}
#[test]
fn quoting_round_trips_spaces_unicode_slashes_and_escapes() {
let value = "a b/界\\\"\n";
let input = format!("/run({})", quote_invocation_string(value));
let parsed = parse_feature_invocation(&input, 0, &descriptor(), "1").unwrap();
assert_eq!(
parsed.invocation.arguments[0].value,
InvocationValue::String(value.into())
);
}
}
+133 -10
View File
@@ -1,4 +1,5 @@
pub mod identity;
pub mod invocation;
#[cfg(feature = "stream")]
pub mod stream;
pub mod subscription;
@@ -10,6 +11,14 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub use identity::{WorkerId, WorkerIdParseError};
pub use invocation::{
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, FeatureInvocationStatus, FeatureInvocationSyntax,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
InvocationClientAdapter, InvocationCompletion, InvocationValidationError, InvocationValue,
ParsedFeatureInvocation, parse_feature_invocation, quote_invocation_string,
validate_feature_invocation,
};
/// Allocate an opaque idempotency key for one client Submit request.
pub fn new_submission_request_id() -> String {
@@ -62,6 +71,17 @@ impl Default for AuthenticatedInputSource {
}
}
// Tracked methods may carry serialized transport provenance, but wire values
// never grant authority. Keep this field recognized under fail-closed decoding
// while replacing it with UntrustedWire, including malformed/forged values.
fn ignore_wire_input_source<'de, D>(deserializer: D) -> Result<AuthenticatedInputSource, D::Error>
where
D: serde::Deserializer<'de>,
{
let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
Ok(AuthenticatedInputSource::UntrustedWire)
}
impl AuthenticatedInputSource {
pub fn namespace(&self) -> String {
match self {
@@ -224,7 +244,12 @@ impl From<WorkerStatus> for WorkerStateSnapshot {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
#[serde(
tag = "method",
content = "params",
rename_all = "snake_case",
deny_unknown_fields
)]
pub enum Method {
/// Durably accept typed input for immediate activation or the session FIFO.
/// Running or Paused Workers queue Submit for a later turn; use Notify for
@@ -244,7 +269,7 @@ pub enum Method {
SubmitTracked {
submission_request_id: String,
input: Vec<Segment>,
#[serde(skip_deserializing, default)]
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
/// Agent-controlled input: accept only at an Idle controller boundary, never
@@ -254,7 +279,7 @@ pub enum Method {
SubmitIfIdle {
submission_request_id: String,
input: Vec<Segment>,
#[serde(skip_deserializing, default)]
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
/// Human-readable text injected into the target Worker's LLM context as a
@@ -272,7 +297,7 @@ pub enum Method {
NotifyTracked {
notification_request_id: String,
message: String,
#[serde(skip_deserializing, default)]
#[serde(default, deserialize_with = "ignore_wire_input_source")]
source: AuthenticatedInputSource,
},
/// Typed lifecycle report from a child Worker to its direct parent.
@@ -336,6 +361,12 @@ pub enum Method {
ListCompletions {
kind: CompletionKind,
prefix: String,
/// Client query generation. Echoed verbatim so identical-prefix ABA
/// responses cannot cross edits, target switches, or permission changes.
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<CompletionContext>,
},
/// List Workers visible to this Worker from durable Worker state and the spawned-child
/// registry. This is not a host-wide Worker universe query.
@@ -609,6 +640,10 @@ pub enum Segment {
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
/// `@<path>` placeholder either way.
FileRef { path: String },
/// Explicitly selected, source-qualified Feature invocation. The host never
/// promotes free text to this variant and validates it against the installed
/// declaration before execution.
FeatureInvoke { invocation: FeatureInvocation },
/// Source-qualified request for the host to start a Flow before committing
/// this user input. Runtime resolves it through Workspace authority and
/// replaces it with the entered state's instructions before Worker-side
@@ -677,6 +712,9 @@ impl Segment {
out.push('@');
out.push_str(path);
}
Segment::FeatureInvoke { invocation } => {
out.push_str(&invocation.display_input());
}
Segment::Flow { selector } => {
out.push_str("[Flow: ");
out.push_str(selector);
@@ -1348,6 +1386,12 @@ pub enum Event {
/// wired up.
Completions {
kind: CompletionKind,
#[serde(default)]
prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
context: Option<CompletionContext>,
entries: Vec<CompletionEntry>,
},
/// Reply to `Method::ListRewindTargets`. Clients should only open a picker
@@ -1460,25 +1504,42 @@ pub enum AlertSource {
/// Kind of completion requested by `Method::ListCompletions`.
///
/// Mirrors the completion prefix sigil: `@` → `File`.
/// `File` is the existing `@` path lane. `Feature` lists installed chat
/// invocations. `FeatureArgument` resolves a declared argument using the
/// source-qualified [`CompletionContext`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CompletionKind {
File,
Feature,
FeatureArgument,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct CompletionContext {
pub invocation: FeatureInvocationIdentity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub argument: Option<String>,
}
/// One completion candidate for a prefix query.
///
/// `value` is a path (file kind).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct CompletionEntry {
pub value: String,
#[serde(default)]
pub is_dir: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub invocation: Option<FeatureInvocationDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -2114,6 +2175,27 @@ mod tests {
);
}
#[test]
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
for method in ["notify", "notify_tracked"] {
for key in ["input", "segments", "invocation"] {
let mut params =
serde_json::json!({"notification_request_id":"request", "message":"advisory"});
params[key] = serde_json::json!([{ "kind":"feature_invoke", "invocation": { "identity":"builtin:test/run" } }]);
assert!(
serde_json::from_value::<Method>(
serde_json::json!({"method":method,"params":params})
)
.is_err()
);
}
}
// Text is still text: no decoding or Host scan promotes it to an invoke.
assert!(
matches!(serde_json::from_value::<Method>(serde_json::json!({"method":"notify", "params":{"notification_request_id":"text","message":"/run()"}})).unwrap(), Method::Notify { message, .. } if message == "/run()")
);
}
#[test]
fn segment_unknown_variant_decodes_as_unknown() {
// A future client sends a segment kind this Worker has never heard of.
@@ -2352,6 +2434,8 @@ mod tests {
let method = Method::ListCompletions {
kind: CompletionKind::File,
prefix: "src/".into(),
request_id: None,
context: None,
};
let json = serde_json::to_string(&method).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
@@ -2361,7 +2445,7 @@ mod tests {
let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded {
Method::ListCompletions { kind, prefix } => {
Method::ListCompletions { kind, prefix, .. } => {
assert_eq!(kind, CompletionKind::File);
assert_eq!(prefix, "src/");
}
@@ -2369,13 +2453,52 @@ mod tests {
}
}
#[test]
fn completion_generation_round_trips_for_identical_query_correlation() {
let context = CompletionContext {
invocation: FeatureInvocationIdentity("builtin:test/run".into()),
argument: Some("mode".into()),
};
for request_id in ["query-1", "query-2"] {
let method = Method::ListCompletions {
kind: CompletionKind::FeatureArgument,
prefix: "s".into(),
request_id: Some(request_id.into()),
context: Some(context.clone()),
};
let decoded: Method =
serde_json::from_str(&serde_json::to_string(&method).unwrap()).unwrap();
assert!(
matches!(decoded, Method::ListCompletions { request_id: Some(id), context: Some(restored), .. } if id == request_id && restored == context)
);
let event = Event::Completions {
kind: CompletionKind::FeatureArgument,
prefix: "s".into(),
request_id: Some(request_id.into()),
context: Some(context.clone()),
entries: Vec::new(),
};
let decoded: Event =
serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
assert!(
matches!(decoded, Event::Completions { request_id: Some(id), context: Some(restored), .. } if id == request_id && restored == context)
);
}
}
#[test]
fn event_completions_format_and_default_is_dir() {
let event = Event::Completions {
kind: CompletionKind::File,
prefix: "src/".into(),
request_id: None,
context: None,
entries: vec![CompletionEntry {
value: "src/main.rs".into(),
is_dir: false,
description: None,
usage: None,
invocation: None,
}],
};
let json = serde_json::to_string(&event).unwrap();
@@ -2389,7 +2512,7 @@ mod tests {
r#"{"event":"completions","data":{"kind":"file","entries":[{"value":"main.rs"}]}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded {
Event::Completions { kind, entries } => {
Event::Completions { kind, entries, .. } => {
assert_eq!(kind, CompletionKind::File);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].value, "main.rs");
+10
View File
@@ -651,6 +651,10 @@ impl SubscriptionWorkerJob {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct SubscriptionWorker {
/// Opaque Runtime-owned Restore observation identity, never a display-state hash.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub restore_observation_token: Option<String>,
pub worker_id: SubscriptionWorkerId,
/// Set by the Workspace Server when projecting a Runtime-owned Worker to clients.
/// Runtime producers leave this unset because the connection identifies the Runtime.
@@ -689,6 +693,9 @@ pub struct SubscriptionWorker {
impl SubscriptionWorker {
pub fn validate(&self) -> Result<(), SubscriptionValidationError> {
self.worker_id.validate()?;
if let Some(token) = &self.restore_observation_token {
validate_identifier("restore_observation_token", token, MAX_RESOURCE_ID_BYTES)?;
}
if let Some(runtime_id) = &self.runtime_id {
validate_identifier("runtime_id", runtime_id, MAX_RESOURCE_ID_BYTES)?;
}
@@ -962,6 +969,7 @@ mod tests {
fn worker(value: &str) -> SubscriptionWorker {
SubscriptionWorker {
restore_observation_token: None,
worker_id: worker_id(value),
runtime_id: None,
resource_key: None,
@@ -1197,6 +1205,8 @@ mod tests {
method: crate::Method::ListCompletions {
kind: crate::CompletionKind::File,
prefix: "src/".to_string(),
request_id: None,
context: None,
},
},
));
+30 -13
View File
@@ -6,20 +6,23 @@ use ts_rs::{Config, TS};
use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompactionTrigger, CompletionEntry, CompletionKind, ContextTokenSource, ContextUsage,
ErrorCode, Event, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
CompactionTrigger, CompletionContext, CompletionEntry, CompletionKind, ContextTokenSource,
ContextUsage, ErrorCode, Event, FeatureInvocation, FeatureInvocationDescriptor,
FeatureInvocationIdentity, FeatureInvocationResult, FeatureInvocationStatus,
FeatureInvocationSyntax, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
ReasoningConfig, RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult,
RunResumeSource, RunYieldReason, ScopeRule, Segment, SessionContentPart,
SessionConversationTurn, SessionEntryProvenance, SessionHistoryLineageBoundary,
SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent,
Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef,
PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig,
RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult, RunResumeSource,
RunYieldReason, ScopeRule, Segment, SessionContentPart, SessionConversationTurn,
SessionEntryProvenance, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
subscription::{
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
@@ -64,6 +67,19 @@ pub fn generated_protocol_types() -> String {
push_decl::<AlertLevel>(&cfg, &mut output);
push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<CompletionContext>(&cfg, &mut output);
push_decl::<FeatureInvocationIdentity>(&cfg, &mut output);
push_decl::<FeatureInvocationSyntax>(&cfg, &mut output);
push_decl::<InvocationArgumentType>(&cfg, &mut output);
push_decl::<InvocationCompletion>(&cfg, &mut output);
push_decl::<InvocationArgumentDescriptor>(&cfg, &mut output);
push_decl::<InvocationClientAdapter>(&cfg, &mut output);
push_decl::<FeatureInvocationDescriptor>(&cfg, &mut output);
push_decl::<InvocationValue>(&cfg, &mut output);
push_decl::<InvocationArgumentValue>(&cfg, &mut output);
push_decl::<FeatureInvocation>(&cfg, &mut output);
push_decl::<FeatureInvocationStatus>(&cfg, &mut output);
push_decl::<FeatureInvocationResult>(&cfg, &mut output);
push_decl::<WorkerStatus>(&cfg, &mut output);
push_decl::<WorkerCommandEnvelope>(&cfg, &mut output);
push_decl::<WorkerCommandKind>(&cfg, &mut output);
@@ -230,6 +246,7 @@ fn rust_serialized_subscription_frame_fixtures() -> Vec<SubscriptionFrame> {
let external_workdir_id = SubscriptionWorkdirId::new("fixture-external-workdir")
.expect("fixture external Workdir id must be valid");
let worker = SubscriptionWorker {
restore_observation_token: Some("fixture-restore-observation".into()),
worker_id: worker_id.clone(),
runtime_id: Some("fixture-runtime".to_string()),
resource_key: Some("fixture-worker-resource".to_string()),
@@ -378,6 +378,7 @@ function validateWorkers(workers: SubscriptionWorker[]): boolean {
}
function validateWorker(worker: SubscriptionWorker): boolean {
if (worker.restore_observation_token != null && !validateIdentifier(worker.restore_observation_token, MAX_RESOURCE_ID_BYTES)) return false;
if (!validateIdentifier(worker.worker_id, MAX_RESOURCE_ID_BYTES)) return false;
if (worker.runtime_id != null && !validateIdentifier(worker.runtime_id, MAX_RESOURCE_ID_BYTES)) {
return false;
+153 -4
View File
@@ -420,6 +420,18 @@ pub struct WorkerInput {
pub segments: Option<Vec<Segment>>,
}
/// Host-authored Job/attempt identity, independent of Profile selection.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BackendJobExecutionBinding {
pub job_id: String,
pub attempt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_revision: Option<String>,
#[serde(default)]
pub subjektiv_consolidation: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CreateWorkerRequest {
pub worker_id: WorkerId,
@@ -448,10 +460,14 @@ pub struct CreateWorkerRequest {
/// never activates subjektiv for an ordinary Workspace Worker.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub subjektiv_attached: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_job: Option<BackendJobExecutionBinding>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkerSummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restore_observation_token: Option<String>,
pub worker_ref: WorkerRef,
pub worker_id: WorkerId,
pub status: WorkerStatus,
@@ -484,6 +500,62 @@ pub struct WorkerResponse {
pub worker: WorkerDetail,
}
/// Explicit observed Restore intent. Preparation is authored by the Workspace
/// Backend, not browser/Tool JSON, and applied only after Runtime admission.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerRestoreRequest {
pub expected_observation_token: String,
pub request_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preparation: Option<WorkerRestorePreparation>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerRestorePreparation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_api: Option<WorkspaceApiRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir_attachments: Option<Vec<LogicalWorkdirAttachment>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repository_access: Vec<WorkingDirectoryRepositoryAccessRequest>,
/// Read-only references resolved by Workspace only after this operation owns admission.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repository_access_workdirs: Vec<RepositoryAccessWorkdirReference>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RepositoryAccessWorkdirReference {
pub runtime_id: String,
pub working_directory_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerRestoreCoordinationRequest {
pub expected_observation_token: String,
pub request_id: String,
/// None means owner/result recovery only; it can never admit a new intent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preparation: Option<WorkerRestorePreparation>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkerRestoreCoordinationResponse {
pub result: Option<WorkerRestoreResponse>,
/// Original read-only preparation, only when the existing operation still needs it.
pub preparation: Option<WorkerRestorePreparation>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryRepositoryAccessRequest {
pub working_directory_id: String,
pub materialization: RepositoryMaterializationContext,
}
/// Explicit restore outcome returned for every reachable Runtime restore attempt,
/// including preflight rejection and uncertain post-side-effect reconciliation.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -567,12 +639,20 @@ pub struct CompletionRequest {
pub kind: CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CompletionResponse {
pub kind: CompletionKind,
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
pub entries: Vec<CompletionEntry>,
}
@@ -992,11 +1072,18 @@ pub trait RuntimeApi {
#[body] request: WorkerLifecycleRequest,
) -> Result<WorkerLifecycleResponse, RuntimeApiError>;
#[post("/v1/workers/{worker_id}/restore", status = 200, error_status = 400)]
#[post("/v1/workers/{worker_id}/restore/coordinate", status = 200, error_status = 400, additional_error_statuses = [409])]
async fn coordinate_worker_restore(
&self,
#[path] worker_id: String,
#[body] request: WorkerRestoreCoordinationRequest,
) -> Result<WorkerRestoreCoordinationResponse, RuntimeApiError>;
#[post("/v1/workers/{worker_id}/restore", status = 200, error_status = 400, additional_error_statuses = [409])]
async fn restore_worker(
&self,
#[path] worker_id: String,
#[body] request: EmptyObjectRequest,
#[body] request: WorkerRestoreRequest,
) -> Result<WorkerRestoreResponse, RuntimeApiError>;
#[post(
@@ -1202,6 +1289,61 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
mod tests {
use super::*;
#[test]
fn restore_request_is_mandatory_and_conflict_is_not_an_operation_state() {
for invalid in [
serde_json::json!({}),
serde_json::json!({"request_id":"r"}),
serde_json::json!({"expected_observation_token":"t"}),
] {
assert!(serde_json::from_value::<WorkerRestoreRequest>(invalid).is_err());
}
let request: WorkerRestoreRequest = serde_json::from_value(
serde_json::json!({"expected_observation_token":"t", "request_id":"r"}),
)
.unwrap();
assert!(request.preparation.is_none());
for state in [
"accepted",
"rejected",
"rolled_back",
"reconciliation_required",
] {
assert!(serde_json::from_value::<WorkerRestoreState>(serde_json::json!(state)).is_ok());
}
assert!(
serde_json::from_value::<WorkerRestoreState>(serde_json::json!(
"restore_observation_conflict"
))
.is_err()
);
let conflict = RuntimeApiError::new(
409,
"restore_observation_conflict",
"refresh before a new intent",
);
assert_eq!(conflict.status(), 409);
assert_eq!(
serde_json::to_value(conflict).unwrap()["error"]["code"],
"restore_observation_conflict"
);
}
#[test]
fn feature_argument_completion_wire_preserves_source_and_argument() {
let json = serde_json::json!({"kind":"feature_argument", "prefix":"資料/", "context": {"invocation":"builtin:test/prepare", "argument":"path"}});
let request: CompletionRequest = serde_json::from_value(json.clone()).unwrap();
assert_eq!(request.kind, CompletionKind::FeatureArgument);
assert_eq!(
request.context.as_ref().unwrap().argument.as_deref(),
Some("path")
);
assert_eq!(serde_json::to_value(request).unwrap(), json);
let legacy: CompletionRequest =
serde_json::from_value(serde_json::json!({"kind":"file"})).unwrap();
assert!(legacy.context.is_none());
}
#[derive(Clone)]
struct RoundTripService;
@@ -1365,10 +1507,17 @@ mod tests {
) -> Result<WorkerLifecycleResponse, RuntimeApiError> {
Err(test_error(501))
}
async fn coordinate_worker_restore(
&self,
_worker_id: String,
_request: WorkerRestoreCoordinationRequest,
) -> Result<WorkerRestoreCoordinationResponse, RuntimeApiError> {
Err(test_error(501))
}
async fn restore_worker(
&self,
_worker_id: String,
_request: EmptyObjectRequest,
_request: WorkerRestoreRequest,
) -> Result<WorkerRestoreResponse, RuntimeApiError> {
Err(test_error(501))
}
@@ -1468,7 +1617,7 @@ mod tests {
#[test]
fn contract_inventory_is_complete_and_unique() {
let operations = RuntimeApiMetadata::OPERATIONS;
assert_eq!(operations.len(), 19);
assert_eq!(operations.len(), 20);
let mut routes = operations
.iter()
.map(|operation| (format!("{:?}", operation.method), operation.path))
+476 -7
View File
@@ -11,6 +11,7 @@ pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod repository_openapi_typescript;
mod workspace_config;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -18,6 +19,7 @@ use webauthn_rs_proto::{
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
RequestChallengeResponse,
};
pub use workspace_config::*;
#[allow(dead_code)]
#[derive(JsonSchema)]
@@ -190,10 +192,32 @@ impl_openapi_schema!(
WorkingDirectoryListResponse,
WorkingDirectoryDetailResponse,
WorkingDirectoryCreateResponse,
CurrentWorkerWorkdirCatalogQuery,
CurrentWorkerWorkdirCatalogResponse,
CurrentWorkerWorkdirAttachRequest,
CurrentWorkerWorkdirAttachmentResponse,
CurrentWorkerWorkdirAttachmentItem,
CurrentWorkerWorkdirAttachmentListQuery,
CurrentWorkerWorkdirAttachmentListResponse,
CurrentWorkerWorkdirDetachQuery,
CurrentWorkerWorkdirOperationRequest,
CurrentWorkerWorkdirOperationResponse,
WorkspaceConfigAccess,
WorkspaceConfigNodeKind,
WorkspaceConfigFailureClassification,
WorkspaceConfigAttachRequest,
WorkspaceConfigAttachment,
WorkspaceConfigCurrentResponse,
WorkspaceConfigObserveRequest,
WorkspaceConfigNode,
WorkspaceConfigObserveResponse,
WorkspaceConfigReadRequest,
WorkspaceConfigReadResponse,
WorkspaceConfigCommitRequest,
WorkspaceConfigCommitResponse,
WorkspaceConfigApiError,
WorkspaceConfigGrantCreateRequest,
WorkspaceConfigGrantResponse,
ExternalWorkdirGrantCreateRequest,
ExternalWorkdirGrantResponse,
WorkspaceResponse,
@@ -237,6 +261,7 @@ impl_openapi_schema!(
MemoryConsolidateStagingRequest,
MemoryConsolidationResponse,
SubjektivSubjectCreateRequest,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectListQuery,
SubjektivSubjectListResponse,
SubjektivSubjectResponse,
@@ -364,6 +389,7 @@ impl_openapi_schema!(
WorkspaceRuntimeListResponse,
RuntimeWorkerListResponse,
WorkerRestoreResponse,
WorkerRestoreRequest,
WorkerControlSubject,
WorkerControlRecord,
WorkerControlListResponse,
@@ -1163,6 +1189,20 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[path] subject_id: String,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[patch(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn subjektiv_subject_behavior_update(
&self,
#[path] workspace_id: String,
#[path] subject_id: String,
#[body] request: SubjektivSubjectBehaviorUpdateRequest,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
status = 200,
@@ -1513,6 +1553,7 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
#[body] request: WorkerRestoreRequest,
) -> Result<WorkerRestoreResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/worker-observation/sessions",
@@ -2038,8 +2079,10 @@ pub trait ServerApi {
)]
async fn runtime_worker_restore_alias(
&self,
#[extension] context: ServerRequestContext,
#[path] runtime_id: String,
#[path] worker_id: String,
#[body] request: WorkerRestoreRequest,
) -> Result<WorkerRestoreResponse, RepositoryApiError>;
#[post(
@@ -2052,10 +2095,12 @@ pub trait ServerApi {
)]
async fn runtime_worker_restore(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
#[query] query: RestoreTicketAssignmentQuery,
#[body] request: WorkerRestoreRequest,
) -> Result<WorkerRestoreResponse, RepositoryApiError>;
#[put(
@@ -2068,6 +2113,7 @@ pub trait ServerApi {
)]
async fn runtime_worker_pin(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
@@ -2083,6 +2129,7 @@ pub trait ServerApi {
)]
async fn runtime_worker_unpin(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
@@ -2112,6 +2159,7 @@ pub trait ServerApi {
)]
async fn runtime_cleanup_execute(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[body] request: ExecuteRuntimeCleanupRequest,
@@ -2127,6 +2175,7 @@ pub trait ServerApi {
)]
async fn runtime_worker_input_alias(
&self,
#[extension] context: ServerRequestContext,
#[path] runtime_id: String,
#[path] worker_id: String,
#[body] request: RuntimeWorkerInputRequest,
@@ -2142,6 +2191,7 @@ pub trait ServerApi {
)]
async fn runtime_worker_input(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
@@ -2258,6 +2308,7 @@ pub trait ServerApi {
)]
async fn runtime_worker_stop_alias(
&self,
#[extension] context: ServerRequestContext,
#[path] runtime_id: String,
#[path] worker_id: String,
#[body] request: RuntimeWorkerLifecycleRequest,
@@ -2273,6 +2324,7 @@ pub trait ServerApi {
)]
async fn runtime_worker_stop(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
@@ -2289,6 +2341,7 @@ pub trait ServerApi {
)]
async fn runtime_worker_cancel_alias(
&self,
#[extension] context: ServerRequestContext,
#[path] runtime_id: String,
#[path] worker_id: String,
#[body] request: RuntimeWorkerLifecycleRequest,
@@ -2304,6 +2357,7 @@ pub trait ServerApi {
)]
async fn runtime_worker_cancel(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] runtime_id: String,
#[path] worker_id: String,
@@ -2549,6 +2603,81 @@ pub trait ServerApi {
#[path] working_directory_id: String,
#[body] request: WorkingDirectoryRemovalRequest,
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
#[get("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
async fn current_worker_workspace_config_get(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<WorkspaceConfigCurrentResponse, WorkspaceConfigApiError>;
#[post("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
async fn current_worker_workspace_config_attach(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: WorkspaceConfigAttachRequest,
) -> Result<WorkspaceConfigAttachment, WorkspaceConfigApiError>;
#[post("/api/w/{workspace_id}/workers/self/workspace-config/observe", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
async fn current_worker_workspace_config_observe(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: WorkspaceConfigObserveRequest,
) -> Result<WorkspaceConfigObserveResponse, WorkspaceConfigApiError>;
#[post("/api/w/{workspace_id}/workers/self/workspace-config/read", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
async fn current_worker_workspace_config_read(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: WorkspaceConfigReadRequest,
) -> Result<WorkspaceConfigReadResponse, WorkspaceConfigApiError>;
#[post("/api/w/{workspace_id}/workers/self/workspace-config/commit", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
async fn current_worker_workspace_config_commit(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: WorkspaceConfigCommitRequest,
) -> Result<WorkspaceConfigCommitResponse, WorkspaceConfigApiError>;
#[post("/api/w/{workspace_id}/workspace-config-grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,500], bearer_auth = true, browser_auth = true)]
async fn workspace_config_grant_create(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[body] request: WorkspaceConfigGrantCreateRequest,
) -> Result<WorkspaceConfigGrantResponse, WorkspaceConfigApiError>;
#[delete("/api/w/{workspace_id}/workspace-config-grants/{grant_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,500], bearer_auth = true, browser_auth = true)]
async fn workspace_config_grant_revoke(
&self,
#[extension] actor: RequestActor,
#[path] workspace_id: String,
#[path] grant_id: String,
) -> Result<WorkspaceConfigGrantResponse, WorkspaceConfigApiError>;
#[get(
"/api/w/{workspace_id}/workers/self/workdir-catalog",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
openapi = false,
)]
async fn current_worker_workdir_catalog(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[query] query: CurrentWorkerWorkdirCatalogQuery,
) -> Result<CurrentWorkerWorkdirCatalogResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/workers/self/workdir-attachments",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
openapi = false,
)]
async fn current_worker_workdir_attachment_list(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[query] query: CurrentWorkerWorkdirAttachmentListQuery,
) -> Result<CurrentWorkerWorkdirAttachmentListResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/workers/self/workdir-attachments",
status = 200,
@@ -2574,6 +2703,7 @@ pub trait ServerApi {
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] alias: String,
#[query] query: CurrentWorkerWorkdirDetachQuery,
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/workers/self/workdir-session/operations",
@@ -4579,9 +4709,20 @@ pub enum SubjektivSubjectState {
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectCreateRequest {
pub role: String,
#[serde(default)]
pub behavior_md: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectBehaviorUpdateRequest {
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub expected_behavior_revision: u64,
pub behavior_md: String,
}
pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100;
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -4609,6 +4750,9 @@ pub struct SubjektivSubjectListResponse {
pub struct SubjektivSubjectResponse {
pub id: String,
pub role: String,
pub behavior_md: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub behavior_revision: u64,
pub state: SubjektivSubjectState,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub store_revision: u64,
@@ -5386,6 +5530,9 @@ pub struct SubjektivSurfacePrepareRequest {}
#[serde(deny_unknown_fields)]
pub struct SubjektivSurfacePrepareResponse {
pub generation_id: String,
/// Existing ready surface at this exact store revision, if no rebuild is needed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_snapshot_id: Option<String>,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub store_revision: u64,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
@@ -5442,10 +5589,22 @@ pub struct SubjektivSurfaceFailureResponse {
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivResidentContextOutput {
/// Exact user-managed document. Empty means explicitly unset.
pub behavior_md: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub behavior_revision: u64,
/// Independently-fresh generated Memory projection.
pub memory_surface: memory::backend::MemoryResidentSummaryOutput,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendOperation {
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
Query(SubjektivMemoryQueryRequest),
Read(SubjektivMemoryReadRequest),
ListRevisions(SubjektivMemoryListRevisionsRequest),
@@ -5618,6 +5777,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendResponse {
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
ResidentContext(SubjektivResidentContextOutput),
Query(SubjektivMemoryQueryResponse),
Read(SubjektivMemoryReadResponse),
ListRevisions(SubjektivMemoryListRevisionsResponse),
@@ -6248,6 +6408,7 @@ pub struct Diagnostic {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryMaterializerKind {
LogicalWorkspaceConfig,
#[default]
RuntimeGitClone,
ClientHostedExternal,
@@ -6387,6 +6548,11 @@ pub struct RuntimeWorkingDirectorySummary {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkingDirectorySource {
WorkspaceConfig {
access: WorkspaceConfigAccess,
content_path: String,
purpose: String,
},
Repository {
repository_key: String,
},
@@ -6754,6 +6920,69 @@ pub struct CurrentWorkerWorkdirAttachRequest {
pub working_directory_id: String,
}
/// Stable Workdir-ID keyset paging over the authoritative inventory, not a Runtime snapshot.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirCatalogQuery {
/// Defaults to 50; valid range is 1..=100 scanned registry records.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
/// Exclusive stable Workdir ID. An empty policy-filtered page can still have a next cursor.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirCatalogResponse {
pub workspace_id: String,
pub items: Vec<WorkingDirectorySummary>,
pub next_cursor: Option<String>,
/// Opaque digest of the complete authoritative inventory and its occupancy.
pub revision: String,
}
/// Bounded, alias-ordered paging over the current Worker's active attachments.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<u32>,
/// Exact caller-scoped connection lookup, applied before paging; 1..=128 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentItem {
pub alias: String,
pub working_directory_id: String,
pub capabilities: workdir::WorkdirSessionCapabilities,
/// Opaque durable identity of this attachment lifetime, not a Workdir ID.
pub connection_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentListResponse {
pub workspace_id: String,
pub items: Vec<CurrentWorkerWorkdirAttachmentItem>,
pub next_offset: Option<u32>,
/// Opaque digest of this caller's complete active connection set, independent of query paging/filtering.
pub revision: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirDetachQuery {
/// When omitted, retain the normal Tool's unconditional detach contract.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_connection_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentResponse {
@@ -8233,7 +8462,7 @@ pub struct RuntimeWorkerInputRequest {
pub kind: Option<String>,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<serde_json::Value>>,
pub segments: Option<Vec<protocol::Segment>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -8268,9 +8497,13 @@ pub struct RuntimeWorkerLifecycleResult {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuntimeWorkerCompletionsRequest {
pub kind: serde_json::Value,
pub kind: protocol::CompletionKind,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
@@ -8278,9 +8511,13 @@ pub struct RuntimeWorkerCompletionsRequest {
pub struct RuntimeWorkerCompletionsResult {
pub runtime_id: String,
pub worker_id: String,
pub kind: serde_json::Value,
pub kind: protocol::CompletionKind,
pub prefix: String,
pub entries: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<protocol::CompletionContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
pub entries: Vec<protocol::CompletionEntry>,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
@@ -9364,6 +9601,10 @@ pub struct RuntimeWorkerWorkdirAttachmentSummary {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkerSummary {
/// Opaque Runtime-owned observation fence used by explicit Restore intent.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub restore_observation_token: Option<String>,
pub runtime_id: String,
pub worker_id: String,
pub resource_key: String,
@@ -9600,6 +9841,16 @@ pub enum WorkerRestoreState {
ReconciliationRequired,
}
/// One explicit Restore intent. Retries retain both fields; conflicts require a
/// fresh observation and a deliberate new intent, not automatic token replacement.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkerRestoreRequest {
pub expected_observation_token: String,
pub request_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -9861,9 +10112,11 @@ pub fn memory_api_typescript() -> String {
// `ts-rs` solely for this read-only projection.
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
export type SubjektivSubjectCreateRequest = { role: string, };
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
@@ -9896,8 +10149,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_
let limits = format!(
"export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;"
);
let subjektiv_limits = format!(
"export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;"
);
format!(
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
@@ -10572,6 +10828,22 @@ pub fn legacy_catalog_typescript() -> String {
ToolchainContract::decl(&config),
WorkspaceConfigTreeResponse::decl(&config),
ConfigCommitRequest::decl(&config),
WorkspaceConfigAccess::decl(&config),
WorkspaceConfigNodeKind::decl(&config),
WorkspaceConfigFailureClassification::decl(&config),
WorkspaceConfigAttachRequest::decl(&config),
WorkspaceConfigAttachment::decl(&config),
WorkspaceConfigCurrentResponse::decl(&config),
WorkspaceConfigObserveRequest::decl(&config),
WorkspaceConfigNode::decl(&config),
WorkspaceConfigObserveResponse::decl(&config),
WorkspaceConfigReadRequest::decl(&config),
WorkspaceConfigReadResponse::decl(&config),
WorkspaceConfigCommitRequest::decl(&config),
WorkspaceConfigCommitResponse::decl(&config),
WorkspaceConfigApiError::decl(&config),
WorkspaceConfigGrantCreateRequest::decl(&config),
WorkspaceConfigGrantResponse::decl(&config),
ProfileSettingsResponse::decl(&config),
WorkspaceProfileSummary::decl(&config),
WorkspaceProfileSourceSummary::decl(&config),
@@ -11443,6 +11715,28 @@ mod tests {
);
}
#[test]
fn worker_restore_request_requires_observation_and_request_identity() {
let valid = serde_json::json!({"expected_observation_token":"opaque-generation", "request_id":"restore-request"});
let request: WorkerRestoreRequest = serde_json::from_value(valid.clone()).unwrap();
assert_eq!(serde_json::to_value(request).unwrap(), valid);
for invalid in [
serde_json::json!({}),
serde_json::json!({"request_id":"restore-request"}),
serde_json::json!({"expected_observation_token":"opaque-generation"}),
serde_json::json!({"expected_observation_token":"opaque-generation", "request_id":"restore-request", "command_id":701}),
] {
assert!(serde_json::from_value::<WorkerRestoreRequest>(invalid).is_err());
}
let contract = canonical_openapi_document().unwrap().to_json().unwrap();
let contract: serde_json::Value = serde_json::from_str(&contract).unwrap();
let restore = &contract["paths"]["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/restore"]
["post"];
assert_eq!(restore["requestBody"]["required"], true);
assert!(restore["responses"]["409"].is_object());
assert!(contract["components"]["schemas"]["WorkerRestoreRequest"].is_object());
}
#[test]
fn worker_restore_result_rejects_unknown_fields() {
let result = serde_json::from_value::<WorkerRestoreResult>(serde_json::json!({
@@ -11930,6 +12224,135 @@ mod tests {
);
}
#[test]
fn current_worker_attachment_contract_keeps_normal_tool_response_closed() {
let lookup = CurrentWorkerWorkdirAttachmentListQuery {
limit: Some(1),
offset: None,
connection_id: Some("opaque-connection".to_string()),
};
let value = serde_json::json!({"limit": 1, "connection_id": "opaque-connection"});
assert_eq!(serde_json::to_value(&lookup).unwrap(), value);
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(value).unwrap(),
lookup
);
assert_eq!(
serde_json::to_value(CurrentWorkerWorkdirAttachmentListQuery::default()).unwrap(),
serde_json::json!({})
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
serde_json::json!({"connection_id": 123})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
serde_json::json!({"expected_connection_id": "wrong-field"})
)
.is_err()
);
let query: CurrentWorkerWorkdirDetachQuery =
serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(query.expected_connection_id, None);
assert_eq!(serde_json::to_value(query).unwrap(), serde_json::json!({}));
assert!(
serde_json::from_value::<CurrentWorkerWorkdirDetachQuery>(
serde_json::json!({"connection_id":"wrong-field"})
)
.is_err()
);
let response = CurrentWorkerWorkdirAttachmentResponse {
workspace_id: "workspace".to_string(),
alias: "checkout".to_string(),
working_directory_id: "workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
attached: false,
};
let value = serde_json::to_value(&response).unwrap();
assert_eq!(value.as_object().unwrap().len(), 5);
assert!(value.get("connection_id").is_none());
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentResponse>(value).unwrap(),
response
);
let list = CurrentWorkerWorkdirAttachmentListResponse {
workspace_id: "workspace".to_string(),
revision: "opaque-revision".to_string(),
next_offset: Some(1),
items: vec![CurrentWorkerWorkdirAttachmentItem {
alias: "checkout".to_string(),
working_directory_id: "workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
connection_id: "opaque".to_string(),
}],
};
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
serde_json::to_value(&list).unwrap()
)
.unwrap(),
list
);
}
#[test]
fn current_worker_workdir_catalog_contract_is_bounded_and_revision_is_required() {
let query = CurrentWorkerWorkdirCatalogQuery {
limit: Some(100),
cursor: Some("stable-id".to_string()),
};
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::to_value(&query).unwrap()
)
.unwrap(),
query
);
assert_eq!(
serde_json::to_value(CurrentWorkerWorkdirCatalogQuery::default()).unwrap(),
serde_json::json!({})
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::json!({"offset": 0})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::json!({"limit": -1})
)
.is_err()
);
let response = CurrentWorkerWorkdirCatalogResponse {
workspace_id: "workspace".to_string(),
items: Vec::new(),
next_cursor: Some("scanned-legacy-row".to_string()),
revision: "opaque".to_string(),
};
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogResponse>(
serde_json::to_value(&response).unwrap()
)
.unwrap(),
response
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogResponse>(
serde_json::json!({"workspace_id": "workspace", "items": [], "next_cursor": null})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
serde_json::json!({"workspace_id": "workspace", "items": [], "next_offset": null})
)
.is_err()
);
}
#[test]
fn workspace_and_repository_response_shapes_round_trip() {
let workspace = serde_json::json!({
@@ -12661,6 +13084,45 @@ mod tests {
assert!(serde_json::from_value::<CompanionTranscriptItem>(private_item).is_err());
}
#[test]
fn structured_invocation_input_round_trips_through_typed_api() {
let json = serde_json::json!({"kind":"user", "content":"", "segments":[{"kind":"feature_invoke", "invocation":{
"invocation_id":"stable-invoke", "identity":"builtin:test/prepare", "name":"prepare",
"arguments":[{"name":"path", "value":{"kind":"string","value":"資料/a b"}}]
}}]});
let request: RuntimeWorkerInputRequest = serde_json::from_value(json.clone()).unwrap();
assert!(
matches!(&request.segments.as_ref().unwrap()[0], protocol::Segment::FeatureInvoke { invocation }
if invocation.identity.0 == "builtin:test/prepare" && invocation.invocation_id == "stable-invoke")
);
assert_eq!(serde_json::to_value(request).unwrap(), json);
let mut invalid = json;
invalid["segments"][0]["invocation"]["arguments"][0]["value"] =
serde_json::json!({"kind":"integer","value":2147483648_i64});
assert!(serde_json::from_value::<RuntimeWorkerInputRequest>(invalid).is_err());
}
#[test]
fn feature_completion_contract_is_typed_and_preserves_argument_context() {
let json = serde_json::json!({"kind":"feature_argument", "prefix":"資料/", "context":{"invocation":"builtin:test/prepare","argument":"path"}});
let request: RuntimeWorkerCompletionsRequest =
serde_json::from_value(json.clone()).unwrap();
assert_eq!(request.kind, protocol::CompletionKind::FeatureArgument);
assert_eq!(serde_json::to_value(request).unwrap(), json);
assert!(
serde_json::from_value::<RuntimeWorkerCompletionsRequest>(
serde_json::json!({"kind":"unknown"})
)
.is_err()
);
assert!(
serde_json::from_value::<RuntimeWorkerCompletionsRequest>(
serde_json::json!({"kind":"feature_argument", "context":{"invocation":123}})
)
.is_err()
);
}
#[test]
fn generated_companion_api_contract_is_current() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
@@ -12895,6 +13357,13 @@ mod openapi_artifact_tests {
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
const SIGNED_INTERNAL: &[&str] = &[
"current_worker_workspace_config_get",
"current_worker_workspace_config_attach",
"current_worker_workspace_config_observe",
"current_worker_workspace_config_read",
"current_worker_workspace_config_commit",
"current_worker_workdir_catalog",
"current_worker_workdir_attachment_list",
"current_worker_workdir_attach",
"current_worker_workdir_detach",
"current_worker_workdir_operation",
@@ -872,14 +872,14 @@ fn validate_runtime_operations(
"/api/runtimes/{runtime_id}/workers/{worker_id}/restore",
"post",
"runtime_worker_restore_alias",
None,
Some("WorkerRestoreRequest"),
&[("200", "WorkerRestoreResponse")],
),
runtime_operation(
"/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/restore",
"post",
"runtime_worker_restore",
None,
Some("WorkerRestoreRequest"),
&[("200", "WorkerRestoreResponse")],
),
runtime_operation(
@@ -1227,8 +1227,16 @@ fn json_content_schema<'a>(
}
fn schema_reference<'a>(value: &'a Value, context: &str) -> Result<&'a str, GenerationError> {
const ALLOWED_REFERENCE_SIBLINGS: &[&str] =
&["$ref", "$comment", "deprecated", "description", "title"];
// `default` is a JSON Schema annotation, not a constraint or a replacement
// for the referenced type. Schemars emits it for defaulted enum fields.
const ALLOWED_REFERENCE_SIBLINGS: &[&str] = &[
"$ref",
"$comment",
"default",
"deprecated",
"description",
"title",
];
let schema = object(value, context)?;
if let Some(keyword) = schema
.keys()
@@ -2023,6 +2031,27 @@ mod tests {
assert!(error.to_string().contains("ambiguous anyOf"));
}
#[test]
fn reference_default_annotations_do_not_replace_the_referenced_type() {
let value = serde_json::json!({"$ref":"#/components/schemas/InvocationCompletion", "default":{"kind":"none"}});
assert_eq!(
schema_reference(&value, "completion").unwrap(),
"#/components/schemas/InvocationCompletion"
);
let output = generate_runtime_typescript(
&crate::canonical_openapi_document()
.unwrap()
.to_json()
.unwrap(),
)
.unwrap();
assert!(output.contains("completion?: InvocationCompletion"));
assert!(output.contains("export type FeatureInvocation ="));
assert!(output.contains(
"export type CompletionKind = \"file\" | \"feature\" | \"feature_argument\""
));
}
#[test]
fn generator_rejects_lossy_reference_siblings() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
+284
View File
@@ -0,0 +1,284 @@
//! Grant-bound logical Workspace configuration transport. No host filesystem contract.
use super::{ConfigCommitRequest, ConfigContentType};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceConfigAccess {
ReadOnly,
ReadWrite,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceConfigNodeKind {
Directory,
File,
Missing,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceConfigFailureClassification {
NotCommitted,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigAttachRequest {
#[serde(default)]
pub alias: Option<String>,
#[serde(default)]
pub access: Option<WorkspaceConfigAccess>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigAttachment {
pub workspace_id: String,
pub connection_id: String,
pub alias: String,
pub working_directory_id: String,
pub access: WorkspaceConfigAccess,
pub name: String,
pub purpose: String,
pub content_path: String,
pub already_attached: bool,
}
/// Named transport wrapper required by the generated API; JSON remains attachment-or-null.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(transparent)]
pub struct WorkspaceConfigCurrentResponse(pub Option<WorkspaceConfigAttachment>);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigObserveRequest {
pub connection_id: String,
pub paths: Vec<String>,
pub depth: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigNode {
pub path: String,
pub kind: WorkspaceConfigNodeKind,
pub validator: String,
pub digest: Option<String>,
pub content_type: Option<ConfigContentType>,
pub operations: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigObserveResponse {
pub connection_id: String,
pub validator: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub revision: u64,
pub digest: String,
/// Canonical commit entrypoints from this exact metadata snapshot.
pub entrypoints: Vec<String>,
pub nodes: Vec<WorkspaceConfigNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigReadRequest {
pub connection_id: String,
pub path: String,
pub validator: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigReadResponse {
pub path: String,
pub content: String,
pub content_type: ConfigContentType,
pub digest: String,
pub validator: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigCommitRequest {
pub connection_id: String,
pub validator: String,
pub request: ConfigCommitRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigCommitResponse {
pub validator: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub revision: u64,
pub digest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigApiError {
pub status: u16,
pub code: String,
pub message: String,
pub classification: WorkspaceConfigFailureClassification,
}
impl api_macros::HttpError for WorkspaceConfigApiError {
fn status_code(&self) -> u16 {
self.status
}
}
impl api_macros::HttpRequestError for WorkspaceConfigApiError {
fn from_request_rejection(status: u16, _message: String) -> Self {
Self {
status,
code: "invalid_request".into(),
message: "Invalid configuration request".into(),
classification: WorkspaceConfigFailureClassification::NotCommitted,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigGrantCreateRequest {
pub runtime_id: String,
pub worker_id: String,
pub access: WorkspaceConfigAccess,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfigGrantResponse {
pub grant_id: String,
pub workspace_id: String,
pub runtime_id: String,
pub worker_id: String,
pub working_directory_id: String,
pub access: WorkspaceConfigAccess,
pub revoked: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ApiContract, HttpMethod, ServerApiMetadata};
#[test]
fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() {
let value = serde_json::json!({"connection_id":"c", "validator":"v", "revision":7, "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]});
let response: WorkspaceConfigObserveResponse =
serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), value);
for field in ["revision", "digest", "entrypoints"] {
let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(missing).is_err());
}
let mut with_body = value;
with_body["nodes"][0]["content"] = serde_json::json!("must never appear here");
assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(with_body).is_err());
assert!(
serde_json::from_value::<WorkspaceConfigGrantCreateRequest>(
serde_json::json!({"runtime_id":"r","worker_id":"w","access":"read_write_command"})
)
.is_err()
);
assert!(
serde_json::from_value::<WorkspaceConfigAttachRequest>(
serde_json::json!({"grant_id":"invented"})
)
.is_err()
);
assert_eq!(
serde_json::to_value(WorkspaceConfigCurrentResponse(None)).unwrap(),
serde_json::Value::Null
);
}
#[test]
fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() {
for classification in [
WorkspaceConfigFailureClassification::NotCommitted,
WorkspaceConfigFailureClassification::Unknown,
] {
let error = WorkspaceConfigApiError {
status: 500,
code: "unavailable".into(),
message: "safe".into(),
classification,
};
assert_eq!(
serde_json::from_value::<WorkspaceConfigApiError>(
serde_json::to_value(&error).unwrap()
)
.unwrap(),
error
);
assert_eq!(api_macros::HttpError::status_code(&error), 500);
}
let error =
<WorkspaceConfigApiError as api_macros::HttpRequestError>::from_request_rejection(
413,
"/private/secret".into(),
);
assert_eq!(
error.classification,
WorkspaceConfigFailureClassification::NotCommitted
);
assert!(!error.message.contains("private"));
}
#[test]
fn workspace_config_generated_routes_are_exact_and_grants_are_separate() {
for (name, method, suffix) in [
("get", HttpMethod::Get, ""),
("attach", HttpMethod::Post, ""),
("observe", HttpMethod::Post, "/observe"),
("read", HttpMethod::Post, "/read"),
("commit", HttpMethod::Post, "/commit"),
] {
let name = format!("current_worker_workspace_config_{name}");
let operation = ServerApiMetadata::OPERATIONS
.iter()
.find(|operation| operation.operation_id == name)
.unwrap();
assert_eq!(operation.method, method);
assert_eq!(
operation.path,
format!("/api/w/{{workspace_id}}/workers/self/workspace-config{suffix}")
);
}
for (name, method, path) in [
(
"workspace_config_grant_create",
HttpMethod::Post,
"/api/w/{workspace_id}/workspace-config-grants",
),
(
"workspace_config_grant_revoke",
HttpMethod::Delete,
"/api/w/{workspace_id}/workspace-config-grants/{grant_id}",
),
] {
let operation = ServerApiMetadata::OPERATIONS
.iter()
.find(|operation| operation.operation_id == name)
.unwrap();
assert_eq!(operation.method, method);
assert_eq!(operation.path, path);
}
}
}
+5 -2
View File
@@ -383,8 +383,11 @@ impl Store for FsStore {
content.push_str(&serde_json::to_string(entry)?);
content.push('\n');
}
fs::write(&path, content.as_bytes())?;
Ok(())
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
crate::worker_session_store::atomic_write_bytes(&path, content.as_bytes())
}
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError> {
+8 -1
View File
@@ -110,7 +110,14 @@ pub trait Store: Send + Sync {
/// segment ID without its session ID (e.g. legacy `--session <UUID>`).
fn lookup_session_of(&self, segment_id: SegmentId) -> Result<Option<SessionId>, StoreError>;
/// Create a new segment within `session_id`, with initial entries.
/// Create or atomically replace a segment within `session_id` with all entries.
///
/// Readers and crash recovery must observe either the previous complete log
/// or the complete replacement, never a truncated prefix or partial record.
/// Failure before replacement leaves the old log intact; an error after the
/// atomic commit (for example a durability sync failure) may leave the complete
/// replacement. Filesystem implementations must sync replacement bytes before
/// rename and the containing directory afterward before reporting success.
fn create_segment(
&self,
session_id: SessionId,
+63
View File
@@ -123,6 +123,14 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Result of an explicitly selected chat Feature invocation. The typed
/// result is retained for clients; `body` is the exact bounded context made
/// visible to the Worker model.
FeatureInvocationResult {
result: protocol::FeatureInvocationResult,
body: String,
},
/// `@<path>` file reference resolution. `body` is the rendered
/// LLM-context text (`[File: <path>]\n…` for regular files,
/// `[Dir: <path>]\n…` for directory listings, possibly with a
@@ -167,6 +175,15 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>,
},
/// A Host-fetched change to user-managed Subject behavior. Each changed
/// revision is appended rather than rewriting prior prompt/session content.
SubjectBehaviorRefresh {
body: String,
behavior_revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Synthetic note inserted after an interrupted turn before the next
/// user input. `body` is the exact LLM-context text explaining that the
/// previous turn was cut short.
@@ -184,6 +201,7 @@ impl SystemItem {
match self {
SystemItem::Notification { body, .. } => body.clone(),
SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FeatureInvocationResult { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::SkillActivation { body, .. } => body.clone(),
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
@@ -192,6 +210,7 @@ impl SystemItem {
}
SystemItem::TaskReminder { body, .. } => body.clone(),
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
SystemItem::Interrupt { body, .. } => body.clone(),
}
}
@@ -208,12 +227,14 @@ impl SystemItem {
match self {
SystemItem::Notification { .. } => "notification",
SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FeatureInvocationResult { .. } => "feature_invocation_result",
SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::SkillActivation { .. } => "skill_activation",
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
SystemItem::Interrupt { .. } => "interrupt",
}
}
@@ -270,6 +291,25 @@ mod tests {
));
}
#[test]
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() {
let item = SystemItem::SubjectBehaviorRefresh {
body: "current Subject context".to_string(),
behavior_revision: 7,
prompt_provenance: None,
};
let raw = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.history_text(), "current Subject context");
assert!(matches!(
parsed,
SystemItem::SubjectBehaviorRefresh {
behavior_revision: 7,
..
}
));
}
#[test]
fn notification_history_text_returns_stored_body() {
let item = SystemItem::Notification {
@@ -363,6 +403,29 @@ mod tests {
}
}
#[test]
fn invocation_result_retains_typed_outcome_and_exact_context() {
let result = protocol::FeatureInvocationResult {
invocation_id: "stable-invoke".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
status: protocol::FeatureInvocationStatus::OutcomeUnknown,
message: "operation may have committed".into(),
context: None,
};
let body = "Outcome unknown; do not retry automatically";
let item = SystemItem::FeatureInvocationResult {
result: result.clone(),
body: body.into(),
};
let restored: SystemItem =
serde_json::from_str(&serde_json::to_string(&item).unwrap()).unwrap();
assert_eq!(restored.history_text(), body);
assert_eq!(restored.kind_label(), "feature_invocation_result");
assert!(
matches!(restored, SystemItem::FeatureInvocationResult { result: saved, .. } if saved == result)
);
}
#[test]
fn round_trip_worker_event() {
let item = SystemItem::WorkerEvent {
@@ -1166,7 +1166,7 @@ fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreEr
atomic_write_bytes(path, &bytes)
}
fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
pub(crate) fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
let parent = path
.parent()
.ok_or_else(|| std::io::Error::other("Worker Session path has no parent"))?;
@@ -115,6 +115,100 @@ fn create_segment_writes_all_entries() {
assert_eq!(state.session_id, Some(sid));
}
#[test]
fn create_segment_replacement_keeps_open_old_log_and_commits_complete_new_log() {
use std::io::Read;
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let sid = new_session_id();
let segid = new_segment_id();
let old = vec![
nil_session_start(1, sid),
LogEntry::TurnEnd {
ts: 2,
turn_count: 1,
},
];
store.create_segment(sid, segid, &old).unwrap();
let path = dir
.path()
.join(sid.to_string())
.join(format!("{segid}.jsonl"));
let old_bytes = std::fs::read(&path).unwrap();
let mut old_reader = std::fs::File::open(&path).unwrap();
let new = vec![
nil_session_start(1, sid),
LogEntry::Extension {
ts: 3,
domain: "recovery-checkpoint".into(),
payload: serde_json::json!({"receipt": "retained"}),
},
];
store.create_segment(sid, segid, &new).unwrap();
let mut retained_inode = Vec::new();
old_reader.read_to_end(&mut retained_inode).unwrap();
// An in-place fs::write would truncate the inode held by this reader.
assert_eq!(retained_inode, old_bytes);
let reopened = FsStore::new(dir.path()).unwrap();
assert_eq!(
serde_json::to_value(reopened.read_all(sid, segid).unwrap()).unwrap(),
serde_json::to_value(&new).unwrap()
);
assert_eq!(reopened.read_entry_count(sid, segid).unwrap(), new.len());
assert_eq!(
std::fs::read_dir(path.parent().unwrap()).unwrap().count(),
1,
"successful replacement must not leave temporary files"
);
}
#[test]
fn create_segment_atomic_replacement_readers_only_observe_complete_old_or_new() {
let dir = tempfile::tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let sid = new_session_id();
let segid = new_segment_id();
let old = vec![
nil_session_start(1, sid),
LogEntry::Extension {
ts: 2,
domain: "old-evidence".into(),
payload: serde_json::json!({"result": "old"}),
},
];
let new = vec![
nil_session_start(1, sid),
LogEntry::Extension {
ts: 3,
domain: "new-checkpoint".into(),
payload: serde_json::json!({"result": "new"}),
},
];
store.create_segment(sid, segid, &old).unwrap();
let old_json = serde_json::to_value(&old).unwrap();
let new_json = serde_json::to_value(&new).unwrap();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let reader_store = store.clone();
let reader_barrier = barrier.clone();
let reader = std::thread::spawn(move || {
reader_barrier.wait();
for _ in 0..1000 {
let observed =
serde_json::to_value(reader_store.read_all(sid, segid).unwrap()).unwrap();
assert!(
observed == old_json || observed == new_json,
"replacement must never expose a partial log: {observed}"
);
}
});
barrier.wait();
for _ in 0..50 {
store.create_segment(sid, segid, &new).unwrap();
store.create_segment(sid, segid, &old).unwrap();
}
reader.join().unwrap();
}
#[test]
fn list_sessions_and_segments() {
let dir = tempfile::tempdir().unwrap();
@@ -606,6 +606,70 @@ fn rewound_fork_preserves_uploaded_file_segments_in_snapshot() {
)));
}
#[test]
fn feature_invocations_survive_replay_snapshot_and_rewind_without_reparsing() {
let (_dir, store) = make_store();
let config = RequestConfig::default();
let (sid, segid) = session_store::create_segment(
&store,
SegmentStartState {
system_prompt: Some("System prompt"),
config: &config,
history: Vec::new(),
user_segments: Vec::new(),
},
)
.unwrap();
let segments = vec![
Segment::Text {
content: "before ".into(),
},
Segment::FeatureInvoke {
invocation: protocol::FeatureInvocation {
invocation_id: "stable-request-1".into(),
identity: protocol::FeatureInvocationIdentity("builtin:test/prepare".into()),
name: "prepare".into(),
arguments: vec![protocol::InvocationArgumentValue {
name: "path".into(),
value: protocol::InvocationValue::String("資料/a b".into()),
}],
},
},
Segment::Text {
content: " after /prepare(unselected)".into(),
},
];
session_store::save_user_input(
&store,
sid,
segid,
segments.clone(),
annotated(&[Item::user_message(Segment::flatten_to_text(&segments))]),
)
.unwrap();
session_store::save_turn_end(&store, sid, segid, 1).unwrap();
let entries = store.read_all(sid, segid).unwrap();
assert_eq!(
collect_state(&entries).user_segments,
vec![segments.clone()]
);
let fork_id = session_store::fork_at(&store, sid, segid, 1).unwrap();
let fork_entries = store.read_all(sid, fork_id).unwrap();
assert_eq!(
collect_state(&fork_entries).user_segments,
vec![segments.clone()]
);
let snapshot = session_store::public_snapshot::project_session_snapshot(sid, &fork_entries);
assert!(snapshot.entries.iter().any(|entry| matches!(
&entry.data, SessionSnapshotEntryData::UserInput { segments: restored } if restored == &segments
)));
// Original history remains append-only and retains the same exact typed intent.
assert_eq!(
serde_json::to_value(store.read_all(sid, segid).unwrap()).unwrap(),
serde_json::to_value(entries).unwrap()
);
}
#[tokio::test]
async fn session_config_changed_logged() {
let (_dir, store) = make_store();
+53
View File
@@ -24,6 +24,59 @@ Does not own:
A tool implementation must assume model input is untrusted. Permission policy, scope checks, output bounding, and redaction are part of the safety boundary, not optional UI behavior.
## Checkout-native processing
`execute_checkout_tool` (also exported through `tools::checkout`) accepts the
original `WorkdirSessionRouter`, shared `Tracker`, route-bound alias/generation,
logical target and provider validator. It does not create an attachment or router.
The Host owns native publication and permission identity; provider scope and
capabilities remain authoritative. `Create` uses the `Write` capability/identity.
| Operation | Arguments (only these keys accepted) | Target |
| --- | --- | --- |
| Read | `offset?`, `limit?` | Existing file; same numbered lines, defaults and byte bound as Tool Read |
| Edit | `old_string`, `new_string`, `replace_all?` | Existing file; prior read/hash and provider validator required |
| Write | `content` | Existing file only; prior read/hash and provider validator required |
| Create | `path`, `content` | Bound parent directory; relative destination strictly below it; create-new, missing parents allowed |
| Glob | `pattern`, `path?` | Bound directory; optional path relative beneath it |
| Grep | `pattern`, `path?`, `glob?`, `type?`, `case_insensitive?`, `-B?`, `-A?`, `-C?`, `multiline?`, `output_mode?`, `head_limit?`, `offset?` | Bound directory; same provider search/options/rendering as Tool Grep |
Native arguments cannot override `file_path`, `target_workdir`, generation or
validator. Absolute/parent-traversing directory-relative paths are rejected.
Checkout file calls share Tool processing through an optional checked `FileTarget`
and use `checkout_execute`, never a separate Worker-side filesystem engine.
Read records the provider's full content hash **and total line count**, including
partial reads, in the original alias/generation namespace. Normal and native
observations/mutations interoperate. Only successful mutations update tracking.
Glob/Grep share normal request defaults/options and rendering through `SearchTarget`.
Only native dispatch uses `CheckoutSearchRequest::new` and `checkout_search`.
The constructor's empty scope layers/root output are refined by provider wrappers;
Tools does not synthesize authority or parse/rebase rendered filenames. Ordinary
Tool dispatch remains `session.glob`/`session.grep`, with its existing error mapping.
`CheckoutToolOutput.paths` contains typed checkout-root-relative search paths
(from `GlobResult.paths` / `GrepResult.paths`) and the created path for Create.
Rendered Grep text is never parsed to construct links. The optional validator is
the provider's returned observation validator, unchanged. Create's result
observation describes the bound parent target, while returned paths and tracker
updates describe the destination. Search checks the bound observation before and
after the bounded checkout provider search, but does **not** guarantee an atomic tree snapshot
or prevent external edits to descendants. File validator/hash checks are inside
the provider's checked execution boundary. Checked native provider conflicts and
native generation/search fences use `ToolError::StructuredConflict` with code
`checkout_stale`; ordinary Tool conflicts retain `ExecutionFailed`. Checked-only
known provider refusals use `checkout_denied` (scope/capability/read-only policy),
`checkout_invalid` (invalid arguments/paths/target kind/search syntax), or
`checkout_unavailable` (closed session/unavailable capacity). A disappeared bound
target is `checkout_stale`. These codes preserve known no-effect rejection for the
Host; ordinary Tool error mappings are unchanged. Ambiguous transport/IO and
operation failures remain `ExecutionFailed`, as does explicit provider
`OutcomeUnknown`; a failure after mutation must not
be automatically retried. A malformed post-mutation provider result is likewise
reported explicitly as outcome unknown. Explicit mismatched Read/Glob/Grep results
are readonly failures classified as `checkout_unavailable`, not stale or unknown.
## See also
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
+258
View File
@@ -0,0 +1,258 @@
//! Native checkout entrypoint using the same processing as ordinary filesystem Tools.
//!
//! The Host binds alias, generation, path and validator from the checkout route; none
//! can be overridden by Operation arguments. File reads/mutations dispatch through
//! the provider's checked execution boundary. Search shares normal request and
//! rendering processing but dispatches through checkout_search so provider scope
//! layers and output rebasing are preserved; checks bracket the bound directory
//! observation, not an atomic snapshot of the searched tree.
use std::sync::Arc;
use agen::tool::{ToolError, ToolExecutionContext, ToolOutput};
use serde::de::DeserializeOwned;
use serde_json::Value;
use workdir::{WorkdirPath, WorkdirSessionCapability, WorkdirSessionRouter};
use crate::{ToolsError, Tracker, file_target::FileTarget};
pub struct CheckoutToolOutput {
pub output: ToolOutput,
pub paths: Vec<WorkdirPath>,
pub validator: Option<Vec<u8>>,
}
/// Execute an exact named native Operation on a route-bound checkout target.
/// `Create.path` and search `path` are relative to the bound directory; returned
/// paths are typed checkout-root-relative provider paths, never parsed output.
#[allow(clippy::too_many_arguments)]
pub async fn execute_checkout_tool(
router: Arc<WorkdirSessionRouter>,
tracker: Tracker,
alias: &str,
generation: u64,
path: WorkdirPath,
validator: Vec<u8>,
tool_name: &str,
arguments: Value,
ctx: ToolExecutionContext,
) -> Result<CheckoutToolOutput, ToolError> {
// Native file arguments come from the same definitions used by ordinary
// Tools and both WIP projections. Route fields are intentionally not in them.
let file_schema = fs_operation::text::TextOperation::from_name(&tool_name.to_lowercase(), true)
.map(|operation| operation.argument_schema());
let file_fields: Vec<&str> = file_schema
.as_ref()
.and_then(|schema| schema.as_value()["properties"].as_object())
.map(|properties| properties.keys().map(String::as_str).collect())
.unwrap_or_default();
let (capability, allowed): (_, &[&str]) = match tool_name {
"Read" => (WorkdirSessionCapability::Read, &file_fields),
"Edit" => (WorkdirSessionCapability::Edit, &file_fields),
"Write" => (WorkdirSessionCapability::Write, &file_fields),
"Create" => (WorkdirSessionCapability::Write, &["path", "content"]),
"Glob" => (WorkdirSessionCapability::Glob, &["pattern", "path"]),
"Grep" => (
WorkdirSessionCapability::Grep,
&[
"pattern",
"path",
"glob",
"type",
"case_insensitive",
"-B",
"-A",
"-C",
"multiline",
"output_mode",
"head_limit",
"offset",
],
),
_ => {
return Err(ToolError::InvalidArgument(format!(
"unknown checkout Operation: {tool_name}"
)));
}
};
let mut arguments = arguments
.as_object()
.cloned()
.ok_or_else(|| ToolError::InvalidArgument("checkout arguments must be an object".into()))?;
if let Some(key) = arguments
.keys()
.find(|key| !allowed.contains(&key.as_str()))
{
return Err(ToolError::InvalidArgument(format!(
"unknown or route override checkout argument: {key}"
)));
}
// Do not rebuild a singleton router: this is the original router identity and
// tracker namespace shared with normal Tool calls and its detach/reuse fence.
let selected = crate::routing::resolve_session(&router, Some(alias), capability)?;
if selected.generation != generation {
return Err(stale_checkout(
"checkout attachment generation changed; resolve the checkout again",
));
}
// Checkout paths are always logical and cannot use Tool-mode absolute scope.
let path = WorkdirPath::new(path.as_str()).map_err(ToolsError::from)?;
let tracker = tracker.scoped_attachment(&selected.alias, selected.generation);
let target = FileTarget {
session: selected.session.clone(),
path: path.clone(),
validator: Some(validator.clone()),
};
match tool_name {
"Read" | "Edit" | "Write" => {
arguments.insert("file_path".into(), Value::String(path.to_string()));
let arguments = Value::Object(arguments);
match tool_name {
"Read" => {
crate::read::execute_read(target, tracker, decode(arguments, tool_name)?).await
}
"Edit" => {
crate::edit::execute_edit(target, tracker, decode(arguments, tool_name)?, ctx)
.await
}
_ => {
let params: crate::write::WriteParams = decode(arguments, tool_name)?;
crate::write::execute_write(target, tracker, params.write.content, None, ctx)
.await
}
}
}
"Create" => {
#[derive(serde::Deserialize)]
struct CreateParams {
path: String,
content: String,
}
let params: CreateParams = decode(Value::Object(arguments), tool_name)?;
let destination = relative_target(&path, &params.path, true)?;
crate::write::execute_write(target, tracker, params.content, Some(destination), ctx)
.await
}
"Glob" | "Grep" => {
let before = selected
.session
.checkout_observe(path.clone())
.await
.map_err(crate::file_target::checked_error)?;
if before.validator != validator {
return Err(stale_search());
}
let mut result = if tool_name == "Glob" {
let params: crate::glob::GlobParams = decode(Value::Object(arguments), tool_name)?;
check_pattern(&params.pattern)?;
let search_path =
relative_target(&path, params.path.as_deref().unwrap_or("."), false)?;
crate::glob::execute_glob(
crate::search_target::SearchTarget::checkout(selected.session.clone()),
search_path,
params.pattern,
)
.await?
} else {
let params: crate::grep::GrepParams = decode(Value::Object(arguments), tool_name)?;
if let Some(glob) = &params.glob {
check_pattern(glob)?;
}
let search_path =
relative_target(&path, params.path.as_deref().unwrap_or("."), false)?;
crate::grep::execute_grep(
crate::search_target::SearchTarget::checkout(selected.session.clone()),
search_path,
params,
)
.await?
};
// Reject rather than emit a link/rendered path outside the binding.
// Provider scope is still authoritative for both traversal and content.
for result_path in &result.paths {
let logical = WorkdirPath::new(result_path.as_str()).map_err(ToolsError::from)?;
if !is_beneath(&path, &logical) {
return Err(crate::file_target::readonly_result_error(
"search paths escaped bound directory",
));
}
}
let after = selected
.session
.checkout_observe(path)
.await
.map_err(crate::file_target::checked_error)?;
if after.validator != before.validator {
return Err(stale_search());
}
result.validator = Some(after.validator);
Ok(result)
}
_ => unreachable!("Operation names checked above"),
}
}
fn decode<T: DeserializeOwned>(arguments: Value, name: &str) -> Result<T, ToolError> {
crate::error::decode_file_value(arguments, name)
}
fn stale_search() -> ToolError {
stale_checkout("checkout directory changed; resolve it again before searching")
}
pub(crate) fn stale_checkout(message: impl Into<String>) -> ToolError {
ToolError::StructuredConflict {
code: "checkout_stale".into(),
message: message.into(),
}
}
fn is_beneath(base: &WorkdirPath, path: &WorkdirPath) -> bool {
base == &WorkdirPath::root()
|| std::path::Path::new(path.as_str()).starts_with(std::path::Path::new(base.as_str()))
}
fn relative_target(
base: &WorkdirPath,
relative: &str,
strict: bool,
) -> Result<WorkdirPath, ToolError> {
if relative.is_empty()
|| relative.starts_with('/')
|| relative.contains('\\')
|| relative.split('/').any(|part| part == "..")
{
return Err(ToolError::InvalidArgument(
"path must be relative beneath the bound checkout directory, without parent traversal"
.into(),
));
}
let relative = WorkdirPath::new(relative).map_err(ToolsError::from)?;
let joined = if relative == WorkdirPath::root() {
base.clone()
} else if base == &WorkdirPath::root() {
relative
} else {
WorkdirPath::new(&format!("{}/{}", base.as_str(), relative.as_str()))
.map_err(ToolsError::from)?
};
if !is_beneath(base, &joined) || (strict && joined == *base) {
return Err(ToolError::InvalidArgument(
"destination must be strictly beneath the bound checkout directory".into(),
));
}
Ok(joined)
}
fn check_pattern(pattern: &str) -> Result<(), ToolError> {
// Patterns filter descendants, not an alternate filesystem starting point.
if pattern.starts_with('/') || pattern.split('/').any(|part| part == "..") {
return Err(ToolError::InvalidArgument(
"search glob must stay beneath the bound checkout directory".into(),
));
}
Ok(())
}
#[cfg(test)]
#[path = "checkout_tests.rs"]
mod tests;
+562
View File
@@ -0,0 +1,562 @@
use super::*;
use manifest::Scope;
use serde_json::json;
use tempfile::TempDir;
use workdir::{LocalWorkdirSession, WorkdirAttachmentAlias};
struct Fixture {
dir: TempDir,
router: Arc<WorkdirSessionRouter>,
tracker: Tracker,
}
impl Fixture {
fn new() -> Self {
let dir = TempDir::new().unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
let fixture = Self {
dir,
router,
tracker: Tracker::new(),
};
fixture.attach("main");
fixture
}
fn attach(&self, alias: &str) {
self.router
.attach(
WorkdirAttachmentAlias::new(alias).unwrap(),
Arc::new(LocalWorkdirSession::new(
Scope::writable(self.dir.path()).unwrap(),
self.dir.path().to_path_buf(),
)),
)
.unwrap();
}
async fn observation(&self, alias: &str, path: &str) -> (u64, Vec<u8>) {
let selected = self.router.resolve(Some(alias)).unwrap();
let observation = selected
.session
.checkout_observe(WorkdirPath::new(path).unwrap())
.await
.unwrap();
(selected.generation, observation.validator)
}
async fn native(
&self,
alias: &str,
path: &str,
name: &str,
arguments: Value,
) -> Result<CheckoutToolOutput, ToolError> {
let (generation, validator) = self.observation(alias, path).await;
execute_checkout_tool(
self.router.clone(),
self.tracker.clone(),
alias,
generation,
WorkdirPath::new(path).unwrap(),
validator,
name,
arguments,
Default::default(),
)
.await
}
async fn normal(&self, name: &str, arguments: Value) -> Result<ToolOutput, ToolError> {
let definitions = crate::routed_builtin_tools(
self.router.clone(),
self.tracker.clone(),
Default::default(),
);
for definition in definitions {
let (meta, tool) = definition();
if meta.name == name {
return tool
.execute(&arguments.to_string(), Default::default())
.await;
}
}
panic!("missing Tool {name}")
}
}
fn assert_checkout_stale(error: ToolError) {
assert_checkout_code(error, "checkout_stale");
}
fn assert_checkout_code(error: ToolError, expected: &str) {
match error {
ToolError::StructuredConflict { code, .. } => assert_eq!(code, expected),
other => panic!("expected {expected} structured conflict, got {other:?}"),
}
}
#[test]
fn checkout_known_refusals_have_typed_codes_without_changing_normal_tools() {
use workdir::{WorkdirError, WorkdirSessionCapability};
let cases = [
(WorkdirError::Denied("denied".into()), "checkout_denied"),
(
WorkdirError::OutOfScope("outside".into()),
"checkout_denied",
),
(
WorkdirError::ReadOnly("read only".into()),
"checkout_denied",
),
(
WorkdirError::Unsupported(WorkdirSessionCapability::Write),
"checkout_denied",
),
(
WorkdirError::InvalidArgument("bad argument".into()),
"checkout_invalid",
),
(
WorkdirError::InvalidPath("bad path".into()),
"checkout_invalid",
),
(
WorkdirError::Unavailable("budget exhausted".into()),
"checkout_unavailable",
),
(WorkdirError::NotFound("missing".into()), "checkout_stale"),
(WorkdirError::SessionClosed, "checkout_unavailable"),
(
WorkdirError::UnsupportedOperation("unsupported".into()),
"checkout_denied",
),
(
WorkdirError::UnknownCommand("missing command".into()),
"checkout_invalid",
),
(
WorkdirError::RelativePath("relative".into()),
"checkout_invalid",
),
(
WorkdirError::IsDirectory("directory".into()),
"checkout_invalid",
),
(
WorkdirError::InvalidGlob("bad glob".into()),
"checkout_invalid",
),
(
WorkdirError::InvalidRegex("bad regex".into()),
"checkout_invalid",
),
];
for (error, code) in cases {
assert_checkout_code(crate::file_target::checked_error(error), code);
}
assert!(matches!(
ToolError::from(ToolsError::from(WorkdirError::Unavailable(
"unavailable".into()
))),
ToolError::ExecutionFailed(_)
));
assert!(matches!(
ToolError::from(ToolsError::from(WorkdirError::Unsupported(
WorkdirSessionCapability::Write
))),
ToolError::InvalidArgument(_)
));
}
#[test]
fn checkout_conflict_classification_preserves_normal_tools_and_unknown_outcomes() {
assert_checkout_stale(crate::file_target::checked_error(
workdir::WorkdirError::Conflict("a.txt".into()),
));
assert_checkout_stale(stale_search());
assert!(matches!(
ToolError::from(ToolsError::from(workdir::WorkdirError::Conflict(
"a.txt".into()
))),
ToolError::ExecutionFailed(_)
));
assert!(matches!(
crate::file_target::checked_error(workdir::WorkdirError::OutcomeUnknown(
"may have committed".into()
)),
ToolError::ExecutionFailed(_)
));
}
#[test]
fn checkout_ambiguous_transport_and_operation_failures_remain_unknown() {
use workdir::WorkdirError;
// Even refusal-looking text in an ambiguous transport error is not proof
// that the provider rejected the request before any effects.
for error in [
WorkdirError::Transport("denied (ambiguous response)".into()),
WorkdirError::OperationFailed,
] {
assert!(matches!(
crate::file_target::checked_error(error),
ToolError::ExecutionFailed(_)
));
}
}
#[test]
fn checkout_mismatched_readonly_results_are_unavailable_not_stale_or_unknown() {
use workdir::{CheckoutSearchResult, GlobResult, GrepResult};
let glob = CheckoutSearchResult::Glob(GlobResult {
paths: Vec::new(),
truncated: false,
});
let grep = CheckoutSearchResult::Grep(GrepResult {
output: String::new(),
match_count: 0,
matched_files: 0,
truncated: false,
paths: Vec::new(),
});
assert_checkout_code(
crate::search_target::grep_result(glob).err().unwrap(),
"checkout_unavailable",
);
assert_checkout_code(
crate::search_target::glob_result(grep).err().unwrap(),
"checkout_unavailable",
);
assert_checkout_code(
crate::file_target::readonly_result_error("Read"),
"checkout_unavailable",
);
}
#[test]
fn checkout_relative_path_boundaries() {
let base = WorkdirPath::new("src").unwrap();
assert_eq!(
relative_target(&base, "nested/a.txt", true)
.unwrap()
.as_str(),
"src/nested/a.txt"
);
assert_eq!(relative_target(&base, ".", false).unwrap(), base);
assert_eq!(
relative_target(&WorkdirPath::root(), "a.txt", true)
.unwrap()
.as_str(),
"a.txt"
);
for bad in [
"",
"..",
"../outside",
"nested/../../outside",
"/absolute",
"a\\..\\outside",
] {
assert!(
relative_target(&base, bad, false).is_err(),
"accepted {bad}"
);
}
assert!(relative_target(&base, ".", true).is_err());
assert!(!is_beneath(
&base,
&WorkdirPath::new("src-other/a.txt").unwrap()
));
assert!(check_pattern("../*").is_err());
assert!(check_pattern("/tmp/*").is_err());
assert!(check_pattern("**/*.rs").is_ok());
}
#[tokio::test]
async fn checkout_read_and_normal_edit_share_hash_and_line_count() {
let fixture = Fixture::new();
std::fs::write(fixture.dir.path().join("a.txt"), "alpha\nbeta\ngamma\n").unwrap();
let read = fixture
.native("main", "a.txt", "Read", json!({"offset": 1, "limit": 1}))
.await
.unwrap();
assert!(read.output.summary.contains("[2..2] of 3"));
assert_eq!(read.output.content.as_deref(), Some(" 2\tbeta\n"));
assert!(read.validator.is_some());
let selected = fixture.router.resolve(Some("main")).unwrap();
let tracker = fixture
.tracker
.scoped_attachment(&selected.alias, selected.generation);
assert_eq!(
tracker.observed_workdir_line_count(&WorkdirPath::new("a.txt").unwrap()),
Some(3)
);
fixture.normal("Edit", json!({"target_workdir": "main", "file_path": "a.txt", "old_string": "beta", "new_string": "changed"})).await.unwrap();
let write = fixture
.native("main", "a.txt", "Write", json!({"content": "new\n"}))
.await
.unwrap();
assert!(write.output.summary.contains("Overwrote"));
assert_eq!(fixture.tracker.change_stat().deleted, 4);
let reread = fixture
.native("main", "a.txt", "Read", json!({}))
.await
.unwrap();
assert_eq!(reread.output.content.as_deref(), Some(" 1\tnew\n"));
}
#[tokio::test]
async fn checkout_normal_read_then_native_edit_uniqueness_and_replace_all() {
let fixture = Fixture::new();
std::fs::write(fixture.dir.path().join("a.txt"), "x x x\n").unwrap();
let before_read = fixture
.native(
"main",
"a.txt",
"Edit",
json!({"old_string": "x", "new_string": "y"}),
)
.await
.err()
.unwrap();
assert!(before_read.to_string().contains("has not been read"));
fixture
.normal(
"Read",
json!({"target_workdir": "main", "file_path": "a.txt"}),
)
.await
.unwrap();
let not_unique = fixture
.native(
"main",
"a.txt",
"Edit",
json!({"old_string": "x", "new_string": "y"}),
)
.await
.err()
.unwrap();
assert_checkout_code(not_unique, "checkout_invalid");
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
let edit = fixture
.native(
"main",
"a.txt",
"Edit",
json!({"old_string": "x", "new_string": "y", "replace_all": true}),
)
.await
.unwrap();
assert!(edit.output.summary.contains("3 replacements"));
assert_eq!(
std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(),
"y y y\n"
);
fixture.normal("Write", json!({"target_workdir": "main", "file_path": "a.txt", "content": "normal after native"})).await.unwrap();
}
#[tokio::test]
async fn checkout_alias_and_generation_fences() {
let fixture = Fixture::new();
fixture.attach("other");
std::fs::write(fixture.dir.path().join("a.txt"), "same\n").unwrap();
fixture
.native("main", "a.txt", "Read", json!({}))
.await
.unwrap();
let wrong_alias = fixture
.native("other", "a.txt", "Write", json!({"content": "not allowed"}))
.await
.err()
.unwrap();
assert!(wrong_alias.to_string().contains("has not been read"));
let (old_generation, old_validator) = fixture.observation("main", "a.txt").await;
fixture
.router
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
.await
.unwrap();
fixture.attach("main");
let error = execute_checkout_tool(
fixture.router.clone(),
fixture.tracker.clone(),
"main",
old_generation,
WorkdirPath::new("a.txt").unwrap(),
old_validator,
"Read",
json!({}),
Default::default(),
)
.await
.err()
.unwrap();
assert!(error.to_string().contains("generation changed"));
assert_checkout_stale(error);
let fresh_generation_no_read = fixture
.native("main", "a.txt", "Write", json!({"content": "not allowed"}))
.await
.err()
.unwrap();
assert!(
fresh_generation_no_read
.to_string()
.contains("has not been read")
);
}
#[tokio::test]
async fn checkout_stale_validator_and_hash_are_independent() {
let fixture = Fixture::new();
std::fs::write(fixture.dir.path().join("a.txt"), "old\n").unwrap();
fixture
.native("main", "a.txt", "Read", json!({}))
.await
.unwrap();
let (generation, validator) = fixture.observation("main", "a.txt").await;
std::fs::write(fixture.dir.path().join("a.txt"), "externally changed\n").unwrap();
let stale = execute_checkout_tool(
fixture.router.clone(),
fixture.tracker.clone(),
"main",
generation,
WorkdirPath::new("a.txt").unwrap(),
validator,
"Write",
json!({"content": "bad"}),
Default::default(),
)
.await
.err()
.unwrap();
assert_checkout_stale(stale);
let hash = fixture
.native("main", "a.txt", "Write", json!({"content": "bad"}))
.await
.err()
.unwrap();
assert_checkout_stale(hash);
assert_eq!(
std::fs::read_to_string(fixture.dir.path().join("a.txt")).unwrap(),
"externally changed\n"
);
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
}
#[tokio::test]
async fn checkout_create_new_missing_parents_never_overwrites() {
let fixture = Fixture::new();
let created = fixture
.native(
"main",
".",
"Create",
json!({"path": "nested/deep/a.txt", "content": "new\n"}),
)
.await
.unwrap();
assert!(created.output.summary.contains("Created"));
assert_eq!(
created.paths,
vec![WorkdirPath::new("nested/deep/a.txt").unwrap()]
);
let duplicate = fixture
.native(
"main",
".",
"Create",
json!({"path": "nested/deep/a.txt", "content": "overwrite"}),
)
.await;
assert!(duplicate.is_err());
assert_eq!(
std::fs::read_to_string(fixture.dir.path().join("nested/deep/a.txt")).unwrap(),
"new\n"
);
// Successful creation updates the same tracker as normal Tool Write.
fixture.normal("Write", json!({"target_workdir": "main", "file_path": "nested/deep/a.txt", "content": "changed"})).await.unwrap();
}
#[tokio::test]
async fn checkout_rejects_argument_overrides_and_escapes() {
let fixture = Fixture::new();
std::fs::write(fixture.dir.path().join("a.txt"), "safe").unwrap();
for (name, arguments) in [
("Read", json!({"file_path": "other"})),
("Read", json!({"target_workdir": "other"})),
("Write", json!({"content": "bad", "validator": []})),
("Create", json!({"path": "../outside", "content": "bad"})),
("Glob", json!({"pattern": "*", "path": "/absolute"})),
("Grep", json!({"pattern": "safe", "path": "../outside"})),
("Other", json!({})),
] {
let path = if ["Create", "Glob", "Grep"].contains(&name) {
"."
} else {
"a.txt"
};
let error = fixture
.native("main", path, name, arguments)
.await
.err()
.unwrap();
assert!(
matches!(error, ToolError::InvalidArgument(_)),
"{name}: {error}"
);
}
}
#[tokio::test]
async fn checkout_search_typed_paths_and_normal_rendering() {
let fixture = Fixture::new();
std::fs::create_dir_all(fixture.dir.path().join("src/nested")).unwrap();
std::fs::write(
fixture.dir.path().join("src/nested/a.txt"),
"context\nhit\nafter\n",
)
.unwrap();
std::fs::write(fixture.dir.path().join("outside.txt"), "hit\n").unwrap();
let glob = fixture
.native(
"main",
"src",
"Glob",
json!({"pattern": "**/*.txt", "path": "nested"}),
)
.await
.unwrap();
assert_eq!(
glob.paths,
vec![WorkdirPath::new("src/nested/a.txt").unwrap()]
);
let normal = fixture
.normal(
"Glob",
json!({"target_workdir": "main", "pattern": "**/*.txt", "path": "src/nested"}),
)
.await
.unwrap();
assert_eq!(glob.output.summary, normal.summary);
assert_eq!(glob.output.content, normal.content);
for mode in ["files_with_matches", "content", "count"] {
let grep = fixture
.native(
"main",
"src",
"Grep",
json!({"pattern": "hit", "output_mode": mode, "-C": 1}),
)
.await
.unwrap();
assert_eq!(
grep.paths,
vec![WorkdirPath::new("src/nested/a.txt").unwrap()]
);
let normal = fixture.normal("Grep", json!({"target_workdir": "main", "path": "src", "pattern": "hit", "output_mode": mode, "-C": 1})).await.unwrap();
assert_eq!(grep.output.summary, normal.summary);
assert_eq!(grep.output.content, normal.content);
assert!(!grep.output.content.unwrap().contains("outside.txt"));
}
}
+66 -39
View File
@@ -9,7 +9,7 @@ use serde::Deserialize;
use crate::error::ToolsError;
use crate::tracker::Tracker;
use workdir::{EditRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
const DESCRIPTION: &str = "Replace a substring in an existing file in the selected Workdir attachment. By default \
`old_string` must be unique in the file; set `replace_all: true` to replace \
@@ -23,13 +23,8 @@ pub(crate) struct EditParams {
pub target_workdir: Option<String>,
/// Logical path relative to the bound Workdir root.
pub file_path: String,
/// String to replace. Must be unique in the file unless `replace_all` is true.
pub old_string: String,
/// Replacement string. Must differ from `old_string`.
pub new_string: String,
/// Replace all occurrences. Defaults to false.
#[serde(default)]
pub replace_all: bool,
#[serde(flatten)]
pub edit: fs_operation::text::EditArgs,
}
pub(crate) struct EditTool {
@@ -44,8 +39,7 @@ impl Tool for EditTool {
input_json: &str,
ctx: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: EditParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid Edit input: {e}")))?;
let params: EditParams = crate::error::decode_file_input(input_json, "Edit")?;
let selected = crate::routing::resolve_session(
&self.router,
@@ -56,57 +50,69 @@ impl Tool for EditTool {
.tracker
.scoped_attachment(&selected.alias, selected.generation);
let path = WorkdirPath::new(&params.file_path).map_err(ToolsError::from)?;
tracing::debug!(path = %path, replace_all = params.replace_all, "Edit");
if params.old_string.is_empty() {
return Err(ToolError::InvalidArgument(
"old_string must not be empty".into(),
));
Ok(execute_edit(
crate::file_target::FileTarget {
session: selected.session,
path,
validator: None,
},
tracker,
params,
ctx,
)
.await?
.output)
}
if params.old_string == params.new_string {
return Err(ToolError::InvalidArgument(
"old_string and new_string are identical".into(),
));
}
pub(crate) async fn execute_edit(
target: crate::file_target::FileTarget,
tracker: Tracker,
params: EditParams,
ctx: agen::tool::ToolExecutionContext,
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
let path = &target.path;
tracing::debug!(path = %path, replace_all = params.edit.replace_all, "Edit");
params
.edit
.validate(Default::default())
.map_err(crate::error::text_error)?;
let mutation_key = PathBuf::from(path.as_str());
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
let expected_hash = tracker.expected_workdir_hash(&path)?;
let result = selected
.session
.edit(EditRequest {
path: path.clone(),
old_string: params.old_string.clone(),
new_string: params.new_string.clone(),
replace_all: params.replace_all,
let expected_hash = tracker.expected_workdir_hash(path)?;
let (result, validator) = target
.edit(
params.edit.old_string.clone(),
params.edit.new_string.clone(),
params.edit.replace_all,
expected_hash,
})
.await
.map_err(ToolsError::from)?;
)
.await?;
let replacements = result.replacements;
tracker.record_workdir_edit(
&path,
path,
result.content_hash,
replacements,
params.new_string.lines().count(),
params.old_string.lines().count(),
params.edit.new_string.lines().count(),
params.edit.old_string.lines().count(),
);
let summary = format!(
"Edited {} ({} replacement{})",
path,
replacements,
if replacements == 1 { "" } else { "s" }
);
let preview = make_preview(&params.new_string, &params.new_string);
Ok(ToolOutput {
let preview = make_preview(&params.edit.new_string, &params.edit.new_string);
Ok(crate::checkout::CheckoutToolOutput {
output: ToolOutput {
summary,
content: Some(preview),
attachments: Vec::new(),
},
paths: Vec::new(),
validator,
})
}
}
/// Build a small line-numbered snippet centered on the first occurrence of
/// `needle` in `text`. Shows ±3 surrounding lines.
@@ -182,6 +188,27 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn edit_shared_validation_rejects_identical_text_before_read_or_mutation() {
let (dir, fs, tracker) = setup();
let file = dir.path().join("a.txt");
std::fs::write(&file, "same").unwrap();
let (_, tool) = edit_tool(fs, tracker.clone())();
let error = tool
.execute(
&serde_json::json!({"file_path":"a.txt","old_string":"same","new_string":"same"})
.to_string(),
Default::default(),
)
.await
.unwrap_err();
assert!(
matches!(error, ToolError::InvalidArgument(message) if message == "old_string and new_string are identical")
);
assert_eq!(std::fs::read_to_string(file).unwrap(), "same");
assert_eq!(tracker.change_stat(), crate::ChangeStat::default());
}
#[tokio::test]
async fn edit_unique_replacement() {
let (dir, fs, tracker) = setup();
+58 -1
View File
@@ -44,7 +44,8 @@ impl From<ToolsError> for ToolError {
| workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_),
| workdir::WorkdirError::Conflict(_)
| workdir::WorkdirError::OutcomeUnknown(_),
) => ToolError::ExecutionFailed(err.to_string()),
ToolsError::FileSystem(_)
| ToolsError::WorkdirSession(_)
@@ -57,11 +58,67 @@ impl From<ToolsError> for ToolError {
}
}
/// Pure text failures are pre-dispatch argument failures, never unknown outcomes.
pub(crate) fn text_error(error: fs_operation::text::TextError) -> ToolError {
ToolError::InvalidArgument(error.to_string())
}
pub(crate) fn decode_file_input<T: serde::de::DeserializeOwned>(
input: &str,
name: &str,
) -> Result<T, ToolError> {
fs_operation::text::decode_json(input).map_err(|error| decode_error(error, name))
}
pub(crate) fn decode_file_value<T: serde::de::DeserializeOwned>(
value: serde_json::Value,
name: &str,
) -> Result<T, ToolError> {
fs_operation::text::decode(value).map_err(|error| decode_error(error, name))
}
fn decode_error(error: fs_operation::text::TextError, name: &str) -> ToolError {
match error {
fs_operation::text::TextError::Decode(error) => {
ToolError::InvalidArgument(format!("invalid {name} input: {error}"))
}
error => text_error(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
use workdir::http::{WorkdirTransportError, WorkdirTransportErrorCode};
#[test]
fn flattened_file_tool_arguments_keep_raw_json_duplicate_rejection() {
let error = decode_file_input::<crate::edit::EditParams>(
r#"{"file_path":"a.txt","old_string":"a","old_string":"b","new_string":"c"}"#,
"Edit",
)
.unwrap_err();
assert!(
matches!(error, ToolError::InvalidArgument(message) if message.contains("duplicate field `old_string`"))
);
}
#[test]
fn checkout_outcome_unknown_remains_execution_failure() {
let error = ToolError::from(ToolsError::WorkdirSession(
workdir::WorkdirError::OutcomeUnknown(
"mutation may have committed; do not retry automatically".into(),
),
));
match error {
ToolError::ExecutionFailed(message) => {
assert!(message.contains("mutation may have committed"));
assert!(message.contains("do not retry automatically"));
}
other => panic!("outcome unknown must not become invalid input: {other:?}"),
}
}
#[test]
fn local_workdir_content_conflict_is_retryable_execution_failure() {
let error = ToolError::from(ToolsError::WorkdirSession(
+186
View File
@@ -0,0 +1,186 @@
//! Shared provider dispatch for Tool and checkout file processing.
use agen::tool::ToolError;
use workdir::{
CheckoutOperation, CheckoutOutput, CheckoutRequest, ContentHash, EditRequest, EditResult,
ReadRequest, ReadResult, WorkdirPath, WorkdirSessionHandle, WriteRequest, WriteResult,
};
use crate::ToolsError;
pub(crate) struct FileTarget {
pub session: WorkdirSessionHandle,
pub path: WorkdirPath,
pub validator: Option<Vec<u8>>,
}
impl FileTarget {
async fn checked(
&self,
operation: CheckoutOperation,
) -> Result<(CheckoutOutput, Option<Vec<u8>>), ToolError> {
let result = self
.session
.checkout_execute(CheckoutRequest {
target: self.path.clone(),
validator: self
.validator
.clone()
.expect("checked dispatch requires validator"),
operation,
})
.await
.map_err(checked_error)?;
Ok((result.output, Some(result.observation.validator)))
}
pub async fn read(
&self,
offset: usize,
limit: usize,
max_bytes: usize,
) -> Result<(ReadResult, Option<Vec<u8>>), ToolError> {
if self.validator.is_some() {
let (output, validator) = self
.checked(CheckoutOperation::Read {
offset,
limit,
max_bytes,
})
.await?;
match output {
CheckoutOutput::Read(result) => Ok((result, validator)),
_ => Err(readonly_result_error("Read")),
}
} else {
let result = self
.session
.read(ReadRequest {
path: self.path.clone(),
offset,
limit,
max_bytes,
})
.await
.map_err(ToolsError::from)?;
Ok((result, None))
}
}
pub async fn edit(
&self,
old_string: String,
new_string: String,
replace_all: bool,
expected_hash: ContentHash,
) -> Result<(EditResult, Option<Vec<u8>>), ToolError> {
if self.validator.is_some() {
let (output, validator) = self
.checked(CheckoutOperation::Edit {
old_string,
new_string,
replace_all,
expected_hash,
})
.await?;
match output {
CheckoutOutput::Edit(result) => Ok((result, validator)),
_ => Err(unknown_result("Edit")),
}
} else {
let result = self
.session
.edit(EditRequest {
path: self.path.clone(),
old_string,
new_string,
replace_all,
expected_hash,
})
.await
.map_err(ToolsError::from)?;
Ok((result, None))
}
}
pub async fn write(
&self,
content: Vec<u8>,
expected_hash: Option<ContentHash>,
create_path: Option<WorkdirPath>,
) -> Result<(WriteResult, Option<Vec<u8>>), ToolError> {
if self.validator.is_some() {
let operation = match create_path {
Some(path) => CheckoutOperation::Create { path, content },
None => CheckoutOperation::Write {
content,
expected_hash: expected_hash
.expect("existing checkout Write requires prior read"),
},
};
let (output, validator) = self.checked(operation).await?;
match output {
CheckoutOutput::Write(result) => Ok((result, validator)),
_ => Err(unknown_result("Write/Create")),
}
} else {
let result = self
.session
.write(WriteRequest {
path: self.path.clone(),
content,
expected_hash,
})
.await
.map_err(ToolsError::from)?;
Ok((result, None))
}
}
}
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
use workdir::WorkdirError;
let code = match &error {
WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale",
WorkdirError::Denied { .. }
| WorkdirError::OutOfScope { .. }
| WorkdirError::ReadOnly { .. }
| WorkdirError::Unsupported { .. }
| WorkdirError::UnsupportedOperation { .. }
| WorkdirError::SymlinkOutOfScope { .. }
| WorkdirError::SymlinkDirectoryNotTraversed { .. } => "checkout_denied",
WorkdirError::InvalidArgument { .. }
| WorkdirError::InvalidPath { .. }
| WorkdirError::UnknownCommand { .. }
| WorkdirError::RelativePath { .. }
| WorkdirError::BrokenSymlink { .. }
| WorkdirError::SymlinkTargetIsDirectory { .. }
| WorkdirError::IsDirectory { .. }
| WorkdirError::InvalidGlob { .. }
| WorkdirError::InvalidRegex { .. } => "checkout_invalid",
WorkdirError::Unavailable { .. } | WorkdirError::SessionClosed => "checkout_unavailable",
// IO/transport failures may arrive after effects have begun. Do not infer
// no-effects from their text or strip explicit OutcomeUnknown evidence.
WorkdirError::Io { .. }
| WorkdirError::Transport { .. }
| WorkdirError::OutcomeUnknown { .. }
| WorkdirError::OperationFailed => {
return ToolsError::from(error).into();
}
};
ToolError::StructuredConflict {
code: code.into(),
message: error.to_string(),
}
}
pub(crate) fn readonly_result_error(operation: &str) -> ToolError {
checked_error(workdir::WorkdirError::Unavailable(format!(
"provider returned an invalid readonly checkout {operation} result"
)))
}
fn unknown_result(operation: &str) -> ToolError {
ToolError::ExecutionFailed(format!(
"Outcome unknown: provider returned wrong checkout {operation} result after mutation; do not automatically retry"
))
}
+26 -11
View File
@@ -11,15 +11,15 @@ use crate::ToolsError;
const RESULT_LIMIT: usize = 1000;
#[derive(Debug, Deserialize, JsonSchema)]
struct GlobParams {
pub(crate) struct GlobParams {
/// Worker-local alias of the Workdir attachment to use.
#[serde(default)]
target_workdir: Option<String>,
pub target_workdir: Option<String>,
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
pattern: String,
pub pattern: String,
/// Logical Workdir-relative directory. Defaults to the Workdir root.
#[serde(default)]
path: Option<String>,
pub path: Option<String>,
}
struct GlobTool {
@@ -44,17 +44,29 @@ impl Tool for GlobTool {
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
None => WorkdirPath::root(),
};
let pattern = params.pattern;
Ok(execute_glob(
crate::search_target::SearchTarget::tool(selected.session),
path,
params.pattern,
)
.await?
.output)
}
}
pub(crate) async fn execute_glob(
target: crate::search_target::SearchTarget,
path: WorkdirPath,
pattern: String,
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
tracing::debug!(%pattern, %path, "Glob");
let result = selected
.session
let result = target
.glob(GlobRequest {
pattern: pattern.clone(),
path,
limit: RESULT_LIMIT,
})
.await
.map_err(ToolsError::from)?;
.await?;
let mut body = result
.paths
.iter()
@@ -74,13 +86,16 @@ impl Tool for GlobTool {
} else {
format!("Found {} file(s) matching {pattern}", result.paths.len())
};
Ok(ToolOutput {
Ok(crate::checkout::CheckoutToolOutput {
output: ToolOutput {
summary,
content: (!body.is_empty()).then_some(body),
attachments: Vec::new(),
},
paths: result.paths,
validator: None,
})
}
}
pub fn glob_tool(session: WorkdirSessionHandle) -> ToolDefinition {
routed_glob_tool(crate::routing::singleton_router(session))
+27 -11
View File
@@ -22,16 +22,16 @@ enum OutputMode {
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GrepParams {
pub(crate) struct GrepParams {
/// Worker-local alias of the Workdir attachment to use.
#[serde(default)]
target_workdir: Option<String>,
pattern: String,
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
#[serde(default)]
path: Option<String>,
pub path: Option<String>,
#[serde(default)]
glob: Option<String>,
pub glob: Option<String>,
#[serde(default, rename = "type")]
file_type: Option<String>,
#[serde(default)]
@@ -70,10 +70,25 @@ impl Tool for GrepTool {
params.target_workdir.as_deref(),
workdir::WorkdirSessionCapability::Grep,
)?;
let path = match params.path {
Some(path) => WorkdirPath::new_scoped(&path).map_err(ToolsError::from)?,
let path = match params.path.as_deref() {
Some(path) => WorkdirPath::new_scoped(path).map_err(ToolsError::from)?,
None => WorkdirPath::root(),
};
Ok(execute_grep(
crate::search_target::SearchTarget::tool(selected.session),
path,
params,
)
.await?
.output)
}
}
pub(crate) async fn execute_grep(
target: crate::search_target::SearchTarget,
path: WorkdirPath,
params: GrepParams,
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
let mode = match params.output_mode.unwrap_or_default() {
OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches,
OutputMode::Content => GrepOutputMode::Content,
@@ -84,8 +99,7 @@ impl Tool for GrepTool {
.map(|context| (context, context))
.unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0)));
let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
let result = selected
.session
let result = target
.grep(GrepRequest {
pattern: params.pattern,
path,
@@ -99,8 +113,7 @@ impl Tool for GrepTool {
limit: head_limit,
offset: params.offset.unwrap_or(0),
})
.await
.map_err(ToolsError::from)?;
.await?;
let summary = if result.match_count == 0 {
match mode {
@@ -127,13 +140,16 @@ impl Tool for GrepTool {
} else {
summary
};
Ok(ToolOutput {
Ok(crate::checkout::CheckoutToolOutput {
output: ToolOutput {
summary,
content: (!result.output.is_empty()).then_some(result.output),
attachments: Vec::new(),
},
paths: result.paths,
validator: None,
})
}
}
pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
routed_grep_tool(crate::routing::singleton_router(session))
+4
View File
@@ -9,20 +9,24 @@
//! and command capability, while the Runtime process and OS user remain the
//! trusted execution boundary.
pub mod checkout;
pub mod error;
pub mod tracker;
mod bash;
mod edit;
mod file_target;
mod glob;
mod grep;
mod read;
mod routing;
mod search_target;
mod view_image;
mod web;
mod write;
pub use bash::bash_tool;
pub use checkout::{CheckoutToolOutput, execute_checkout_tool};
pub use edit::edit_tool;
pub use error::ToolsError;
pub use glob::glob_tool;
+75 -78
View File
@@ -8,7 +8,7 @@ use serde::Deserialize;
use crate::error::ToolsError;
use crate::tracker::Tracker;
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
use workdir::{WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
const DESCRIPTION: &str = "Read a text file from a Workdir attachment selected by its Worker-local alias. \
Supports offset/limit for large files. Returns line-numbered output (1-based). \
@@ -26,12 +26,8 @@ pub(crate) struct ReadParams {
pub target_workdir: Option<String>,
/// Workdir-relative path, or an absolute path covered by readable scope.
pub file_path: String,
/// 0-based line offset from the start. Defaults to 0.
#[serde(default)]
pub offset: Option<usize>,
/// Maximum number of lines to return. Defaults to 2000.
#[serde(default)]
pub limit: Option<usize>,
#[serde(flatten)]
pub read: fs_operation::text::LineReadArgs,
}
pub(crate) struct ReadTool {
@@ -46,8 +42,7 @@ impl Tool for ReadTool {
input_json: &str,
_ctx: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ReadParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid Read input: {e}")))?;
let params: ReadParams = crate::error::decode_file_input(input_json, "Read")?;
let selected = crate::routing::resolve_session(
&self.router,
params.target_workdir.as_deref(),
@@ -56,104 +51,80 @@ impl Tool for ReadTool {
let tracker = self
.tracker
.scoped_attachment(&selected.alias, selected.generation);
let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
Ok(execute_read(
crate::file_target::FileTarget {
session: selected.session,
path: WorkdirPath::new_scoped(&params.file_path).map_err(ToolsError::from)?,
validator: None,
},
tracker,
params,
)
.await?
.output)
}
}
let path = WorkdirPath::new_scoped(&params.file_path).map_err(ToolsError::from)?;
pub(crate) async fn execute_read(
target: crate::file_target::FileTarget,
tracker: Tracker,
params: ReadParams,
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
let (offset, limit) = params.read.range(DEFAULT_LIMIT);
let path = &target.path;
tracing::debug!(path = %path, offset, limit, "Read");
let result = selected
.session
.read(ReadRequest {
path: path.clone(),
offset,
limit,
max_bytes: PROVIDER_BYTE_LIMIT,
})
.await
.map_err(ToolsError::from)?;
tracker.record_workdir_hash(&path, result.content_hash);
let text = String::from_utf8_lossy(&result.bytes).into_owned();
let (result, validator) = target.read(offset, limit, PROVIDER_BYTE_LIMIT).await?;
tracker.record_workdir_observation(path, result.content_hash, result.total_lines);
// Source/response bounds were enforced by the streaming provider. Lossy
// UTF-8 conversion is the existing Tool presentation contract; rendering
// below independently bounds its possible byte expansion.
let text = fs_operation::text::read(
String::from_utf8_lossy(&result.bytes).into_owned(),
Default::default(),
)
.map_err(crate::error::text_error)?;
let rendered = render_provider_read(
&text,
&text.content,
result.start_line,
result.total_lines,
result.truncated,
);
let summary = if rendered.truncated {
format!(
"Read {} line(s) [{}..{}] of {} from {}",
rendered.line_count,
offset + 1,
offset + rendered.line_count,
offset.saturating_add(1),
offset.saturating_add(rendered.line_count),
rendered.total_lines,
path
)
} else {
format!("Read {} line(s) from {}", rendered.line_count, path)
};
Ok(ToolOutput {
Ok(crate::checkout::CheckoutToolOutput {
output: ToolOutput {
summary,
content: Some(rendered.body),
attachments: Vec::new(),
},
paths: Vec::new(),
validator,
})
}
}
struct Rendered {
body: String,
line_count: usize,
total_lines: usize,
truncated: bool,
}
fn render_provider_read(
text: &str,
start_line: usize,
total_lines: usize,
truncated: bool,
) -> Rendered {
use std::fmt::Write as _;
let lines = text.lines().collect::<Vec<_>>();
let mut body = String::with_capacity(text.len().saturating_add(lines.len() * 8));
for (index, line) in lines.iter().enumerate() {
let _ = writeln!(&mut body, "{:>6}\t{}", start_line + index + 1, line);
}
Rendered {
body,
line_count: lines.len(),
) -> fs_operation::text::Rendered {
fs_operation::text::render_numbered(
text,
start_line,
total_lines,
truncated: start_line > 0 || truncated,
}
}
/// Format a slice of lines from `text` with `cat -n` style 1-based line
/// numbers. Pure function — no I/O, no history touching.
#[cfg(test)]
fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
let all_lines: Vec<&str> = text.lines().collect();
let total_lines = all_lines.len();
let start = offset.min(total_lines);
let end = start.saturating_add(limit).min(total_lines);
let slice = &all_lines[start..end];
let line_count = slice.len();
use std::fmt::Write as _;
let mut body = String::with_capacity(text.len().saturating_add(line_count * 8));
for (i, line) in slice.iter().enumerate() {
let lineno = start + i + 1;
let _ = writeln!(&mut body, "{:>6}\t{}", lineno, line);
}
Rendered {
body,
line_count,
total_lines,
truncated: start > 0 || end < total_lines,
}
truncated,
PROVIDER_BYTE_LIMIT,
)
}
/// Factory for the `Read` tool bound to one compatibility session.
@@ -195,6 +166,32 @@ mod tests {
(dir, session, Tracker::new())
}
#[test]
fn read_numbering_and_unicode_expansion_keep_rendered_output_bounded() {
for text in [
"\n".repeat(PROVIDER_BYTE_LIMIT),
"界".repeat(PROVIDER_BYTE_LIMIT),
"\u{1}".repeat(PROVIDER_BYTE_LIMIT),
] {
let rendered = render_provider_read(&text, 0, text.lines().count(), false);
assert!(rendered.body.len() <= PROVIDER_BYTE_LIMIT);
assert!(serde_json::to_vec(&rendered.body).unwrap().len() < 2 * 1024 * 1024);
}
let rendered = render_provider_read(
&"\n".repeat(PROVIDER_BYTE_LIMIT),
0,
PROVIDER_BYTE_LIMIT,
false,
);
assert!(rendered.truncated);
assert!(rendered.line_count < PROVIDER_BYTE_LIMIT);
assert!(
rendered
.body
.contains("truncated at rendered output byte limit")
);
}
#[tokio::test]
async fn read_tool_basic_records_history() {
let (dir, fs, tracker) = setup();
+81
View File
@@ -0,0 +1,81 @@
//! Provider dispatch shared by native and ordinary search processing.
//! Request construction/defaults and rendering remain in Glob/Grep; this only
//! selects the provider surface and verifies the returned readonly result kind.
use agen::tool::ToolError;
use workdir::{
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult, GlobRequest, GlobResult,
GrepRequest, GrepResult, WorkdirSessionHandle,
};
use crate::{ToolsError, file_target::checked_error};
pub(crate) struct SearchTarget {
session: WorkdirSessionHandle,
checkout: bool,
}
impl SearchTarget {
pub fn tool(session: WorkdirSessionHandle) -> Self {
Self {
session,
checkout: false,
}
}
pub fn checkout(session: WorkdirSessionHandle) -> Self {
Self {
session,
checkout: true,
}
}
pub async fn glob(&self, request: GlobRequest) -> Result<GlobResult, ToolError> {
if self.checkout {
let result = self
.session
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
request,
)))
.await
.map_err(checked_error)?;
glob_result(result)
} else {
self.session
.glob(request)
.await
.map_err(|error| ToolsError::from(error).into())
}
}
pub async fn grep(&self, request: GrepRequest) -> Result<GrepResult, ToolError> {
if self.checkout {
let result = self
.session
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
request,
)))
.await
.map_err(checked_error)?;
grep_result(result)
} else {
self.session
.grep(request)
.await
.map_err(|error| ToolsError::from(error).into())
}
}
}
pub(crate) fn glob_result(result: CheckoutSearchResult) -> Result<GlobResult, ToolError> {
match result {
CheckoutSearchResult::Glob(result) => Ok(result),
_ => Err(crate::file_target::readonly_result_error("Glob")),
}
}
pub(crate) fn grep_result(result: CheckoutSearchResult) -> Result<GrepResult, ToolError> {
match result {
CheckoutSearchResult::Grep(result) => Ok(result),
_ => Err(crate::file_target::readonly_result_error("Grep")),
}
}
+19
View File
@@ -256,6 +256,25 @@ impl Tracker {
.copied()
}
/// Record the full provider content hash and total line count, even for a sliced Read.
/// Keeping both under one lock prevents pairing counts from a different observation.
pub fn record_workdir_observation(
&self,
path: &workdir::WorkdirPath,
hash: workdir::ContentHash,
total_lines: usize,
) {
let key = self.key(PathBuf::from(path.as_str()));
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.line_counts.insert(key.clone(), total_lines);
inner.hashes.insert(key.clone(), hash);
inner.recency.retain(|candidate| candidate != &key);
inner.recency.push_front(key);
if inner.recency.len() > RECENCY_CAPACITY {
inner.recency.pop_back();
}
}
pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) {
let key = self.key(PathBuf::from(path.as_str()));
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
+57 -27
View File
@@ -9,10 +9,7 @@ use serde::Deserialize;
use crate::error::ToolsError;
use crate::tracker::Tracker;
use workdir::{
StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter,
WriteRequest,
};
use workdir::{StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
const DESCRIPTION: &str = "Create a new file or overwrite an existing one in the selected Workdir attachment. \
Missing parent directories within scope are created automatically. Existing files must have been read first \
@@ -25,8 +22,8 @@ pub(crate) struct WriteParams {
pub target_workdir: Option<String>,
/// Logical path relative to the bound Workdir root.
pub file_path: String,
/// Full content to write. Overwrites any existing content.
pub content: String,
#[serde(flatten)]
pub write: fs_operation::text::WriteArgs,
}
pub(crate) struct WriteTool {
@@ -41,8 +38,7 @@ impl Tool for WriteTool {
input_json: &str,
ctx: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: WriteParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid Write input: {e}")))?;
let params: WriteParams = crate::error::decode_file_input(input_json, "Write")?;
let selected = crate::routing::resolve_session(
&self.router,
@@ -53,34 +49,65 @@ impl Tool for WriteTool {
.tracker
.scoped_attachment(&selected.alias, selected.generation);
let path = WorkdirPath::new(&params.file_path).map_err(ToolsError::from)?;
tracing::debug!(path = %path, bytes = params.content.len(), "Write");
Ok(execute_write(
crate::file_target::FileTarget {
session: selected.session,
path,
validator: None,
},
tracker,
params.write.content,
None,
ctx,
)
.await?
.output)
}
}
/// `create_path` is a checked parent-relative create-new target, never an overwrite.
pub(crate) async fn execute_write(
target: crate::file_target::FileTarget,
tracker: Tracker,
content: String,
create_path: Option<WorkdirPath>,
ctx: agen::tool::ToolExecutionContext,
) -> Result<crate::checkout::CheckoutToolOutput, ToolError> {
// Provider size/access checks still run inside the checked save. The shared
// core owns text semantics, not the provider's bounds or lifecycle.
let content = fs_operation::text::write(content, Default::default())
.map_err(crate::error::text_error)?
.content;
let path = create_path.as_ref().unwrap_or(&target.path);
tracing::debug!(path = %path, bytes = content.len(), "Write");
let mutation_key = PathBuf::from(path.as_str());
let _mutation_permit = tracker.acquire_mutation(&mutation_key, &ctx).await;
let expected_hash = match selected
let expected_hash = if create_path.is_some() {
None
} else if target.validator.is_some() {
// Native Write is existing-file-only, and always requires a prior observation.
Some(tracker.expected_workdir_hash(path)?)
} else {
match target
.session
.stat(StatRequest { path: path.clone() })
.await
{
Ok(_) => Some(tracker.expected_workdir_hash(&path)?),
Ok(_) => Some(tracker.expected_workdir_hash(path)?),
Err(WorkdirError::NotFound(_)) => None,
Err(error) => return Err(ToolsError::from(error).into()),
}
};
let old_line_count = tracker.observed_workdir_line_count(&path).unwrap_or(0);
let outcome = selected
.session
.write(WriteRequest {
path: path.clone(),
content: params.content.as_bytes().to_vec(),
let old_line_count = tracker.observed_workdir_line_count(path).unwrap_or(0);
let (outcome, validator) = target
.write(
content.as_bytes().to_vec(),
expected_hash,
})
.await
.map_err(ToolsError::from)?;
tracker.record_change(params.content.lines().count(), old_line_count);
tracker.record_workdir_content(&path, params.content.as_bytes());
create_path.clone(),
)
.await?;
tracker.record_change(content.lines().count(), old_line_count);
tracker.record_workdir_content(path, content.as_bytes());
let summary = format!(
"{} {} ({} bytes)",
if outcome.created {
@@ -91,13 +118,16 @@ impl Tool for WriteTool {
path,
outcome.bytes_written
);
Ok(ToolOutput {
Ok(crate::checkout::CheckoutToolOutput {
output: ToolOutput {
summary,
content: None,
attachments: Vec::new(),
},
paths: create_path.into_iter().collect(),
validator,
})
}
}
/// Factory for the `Write` tool bound to one compatibility session.
pub fn write_tool(session: WorkdirSessionHandle, tracker: Tracker) -> ToolDefinition {
+13 -6
View File
@@ -278,7 +278,7 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
let bash = call(
&reg.get("Bash"),
json!({ "target_workdir": "right", "command": "pwd" }),
json!({ "target_workdir": "right", "command": "pwd", "cwd": "." }),
)
.await;
assert_eq!(
@@ -1005,7 +1005,7 @@ async fn bash_inherits_workdir_cwd() {
// `pwd` should canonicalize to the workspace root we set up.
let (dir, _spill, reg) = setup();
let bash = reg.get("Bash");
let out = call(&bash, json!({ "command": "pwd" })).await;
let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await;
let body = out.content.unwrap();
let actual = std::fs::canonicalize(body.trim()).unwrap();
let expected = std::fs::canonicalize(dir.path()).unwrap();
@@ -1016,7 +1016,11 @@ async fn bash_inherits_workdir_cwd() {
async fn bash_provider_output_exposes_readable_retained_path() {
let (_dir, spill, reg) = setup();
let bash = reg.get("Bash");
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
let out = call(
&bash,
json!({ "command": "printf 'x%.0s' {1..20480}", "cwd": "." }),
)
.await;
let body = out.content.unwrap();
assert!(body.contains("bounded WorkdirSession command output"));
assert!(body.contains("full output saved to"));
@@ -1038,7 +1042,7 @@ async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
"printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'",
marker.display()
);
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap();
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
let bash = reg.get("Bash");
let executing = bash.clone();
@@ -1084,7 +1088,7 @@ async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
let (dir, _spill, reg) = setup();
let marker = dir.path().join("must-not-survive-force-close");
let command = format!("sleep 1; touch {}", marker.display());
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap();
let bash = reg.get("Bash");
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context);
@@ -1103,7 +1107,10 @@ async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
);
let output = bash
.execute(r#"{"command":"printf 'reused'"}"#, Default::default())
.execute(
r#"{"command":"printf 'reused'","cwd":"."}"#,
Default::default(),
)
.await
.expect("workdir session remains reusable after cleanup");
assert_eq!(output.content.as_deref(), Some("reused"));
+1
View File
@@ -31,5 +31,6 @@ async-trait.workspace = true
agen.workspace = true
[dev-dependencies]
worker-runtime.workspace = true
async-trait.workspace = true
tempfile = { workspace = true }
+30
View File
@@ -28,3 +28,33 @@ The TUI should display committed events and Worker snapshots rather than inventi
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
## Feature invocation editing
`/` discovers enabled Feature declarations with descriptions and usage. Choose a
name or alias with Tab/Enter, complete its parenthesized arguments, and press
Enter to confirm that selected occurrence as a typed chip. A later Enter sends
the message. Raw slash text and paste content are never dispatched as commands.
Use Alt+Enter beside a Feature chip to rediscover its declaration and reopen its
arguments; Backspace/Delete removes a chip atomically. Typed chips survive local
history, draft restoration, transport retry, and rewind.
Every completion and chip-re-edit query carries a fresh `request_id`. Replies
must echo that nonce and match the active query context, composer edit/cursor
revision, authority snapshot generation, and Worker target. Identical-prefix ABA
edits, cancelled/reopened queries, permission snapshots, and Worker/view switches
cannot reuse older replies or candidates. Uncorrelated replies are ignored, even
for File queries where the wire field remains optional for legacy callers.
The declared attachment adapter stages a client-local file in place, replacing
its staging chip with an `UploadedFile` reference when ready. Local file argument
completion runs on the client, not Worker. Alt+Enter beside a failed staging chip
retries with the same upload ID; deleting an unsent chip cancels only its owned
staging resource. Accepted, restored, or history attachments are never deleted by
composer cleanup. Staging blocks Send/Notify and history navigation until it is
ready, retried, or removed. There is no raw `/attach` or `/clear-attachments`
command lane.
Attachment staging remains Backend-only: the existing Standalone transport has
no client-local file-upload staging API. The TUI reports that limitation and
preserves the draft invocation; it never falls back to raw path dispatch.
+813 -42
View File
File diff suppressed because it is too large Load Diff
+487 -15
View File
@@ -24,12 +24,19 @@ use crate::inline_terminal::with_inline_terminal;
const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
#[derive(Debug)]
enum RestoreAttemptError {
Conflict,
Unknown(io::Error),
}
#[async_trait::async_trait]
trait BackendWorkerLifecycle {
async fn restore(
&self,
target: &client::BackendRuntimeTarget,
) -> Result<BackendWorkerRestoreResponse, io::Error>;
request: client::BackendWorkerRestoreRequest,
) -> Result<BackendWorkerRestoreResponse, RestoreAttemptError>;
async fn observe(
&self,
@@ -44,12 +51,18 @@ impl BackendWorkerLifecycle for LiveBackendWorkerLifecycle {
async fn restore(
&self,
target: &client::BackendRuntimeTarget,
) -> Result<BackendWorkerRestoreResponse, io::Error> {
restore_backend_worker(target).await.map_err(|error| {
io::Error::other(format!(
request: client::BackendWorkerRestoreRequest,
) -> Result<BackendWorkerRestoreResponse, RestoreAttemptError> {
restore_backend_worker(target, request)
.await
.map_err(|error| {
if error.is_restore_observation_conflict() {
return RestoreAttemptError::Conflict;
}
RestoreAttemptError::Unknown(io::Error::other(format!(
"failed to restore Backend Worker {}: {error}",
target.display_label()
))
)))
})
}
@@ -133,8 +146,21 @@ pub(crate) async fn run(
let attach_target = target
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
.map_err(|error| io::Error::other(error.to_string()))?;
let attach_target =
prepare_selected_worker(&LiveBackendWorkerLifecycle, attach_target, intent).await?;
let restore_request =
selected
.restore_observation_token
.map(|token| client::BackendWorkerRestoreRequest {
expected_observation_token: token,
request_id: uuid::Uuid::now_v7().to_string(),
});
let attach_target = prepare_selected_worker(
&LiveBackendWorkerLifecycle,
attach_target,
intent,
restore_request,
confirm_restore_recovery,
)
.await?;
return console::run_backend_runtime(attach_target).await;
}
}
@@ -143,10 +169,62 @@ async fn prepare_selected_worker(
lifecycle: &impl BackendWorkerLifecycle,
mut target: client::BackendRuntimeTarget,
intent: BackendWorkerPickerIntent,
restore_request: Option<client::BackendWorkerRestoreRequest>,
mut confirm_recovery: impl FnMut(&str) -> io::Result<bool>,
) -> Result<client::BackendRuntimeTarget, io::Error> {
if intent == BackendWorkerPickerIntent::Resume {
let response = lifecycle.restore(&target).await?;
ensure_restore_accepted(&response)?;
let request = restore_request.ok_or_else(|| {
io::Error::other("Worker has no current Restore observation; refresh and select again")
})?;
loop {
let unknown = match lifecycle.restore(&target, request.clone()).await {
Ok(response) if response.result.state == BackendWorkerRestoreState::Accepted => {
break;
}
Ok(response) => {
let error = ensure_restore_accepted(&response).unwrap_err();
if response.result.state != BackendWorkerRestoreState::ReconciliationRequired {
return Err(error);
}
error
}
Err(RestoreAttemptError::Conflict) => {
return Err(io::Error::other(
"Restore observation changed; refresh the Worker list and select again. No new Restore was started.",
));
}
Err(RestoreAttemptError::Unknown(error)) => error,
};
// Refresh is evidence only: GET cannot reconcile a pending Restore.
// A changed observation must never replace this admitted/unknown tuple.
let observation = match lifecycle.observe(&target).await {
Ok(observation) => match observation.observation {
WorkerSessionAvailability::LiveProtocol => "live_protocol".into(),
WorkerSessionAvailability::RetainedSnapshot { .. } => {
"retained_snapshot".into()
}
WorkerSessionAvailability::Unavailable { reason, message } => format!(
"{reason:?}: {}",
message.chars().take(512).collect::<String>()
),
},
Err(error) => format!("refresh failed: {error}"),
};
let detail = format!(
"Restore outcome unknown/pending: {}. Current observation: {}. Recovery request_id={}, expected_observation_token={}. Retry only this exact request; do not start a new Restore intent.",
unknown.to_string().chars().take(512).collect::<String>(),
observation.chars().take(512).collect::<String>(),
request.request_id,
request.expected_observation_token,
);
// Every subsequent POST requires a new explicit user confirmation.
let retry = confirm_recovery(&detail).map_err(|error| {
io::Error::other(format!("{detail}; recovery prompt failed: {error}"))
})?;
if !retry {
return Err(io::Error::other(detail));
}
}
let observation = lifecycle.observe(&target).await?;
require_live_resume_observation(observation.observation)?;
return Ok(target);
@@ -157,8 +235,34 @@ async fn prepare_selected_worker(
Ok(target)
}
fn confirm_restore_recovery(detail: &str) -> io::Result<bool> {
with_inline_terminal(8, |terminal| {
loop {
terminal.draw(|frame| {
let message = vec![
Line::from("Restore needs explicit result recovery"),
Line::from("Enter: retry the SAME request Esc/Ctrl-C: cancel recovery"),
Line::from(detail),
];
frame.render_widget(
Paragraph::new(message).wrap(ratatui::widgets::Wrap { trim: false }),
frame.area(),
);
})?;
match poll_event()? {
Some(Action::Submit) => return Ok(true),
Some(Action::Cancel) => return Ok(false),
_ => {}
}
}
})
}
fn is_resume_candidate(worker: &BackendWorkerSummary) -> bool {
worker.worker_state.is_none()
worker.availability == protocol::subscription::SubscriptionWorkerAvailability::Observed
&& worker.state == "stopped"
&& worker.worker_state.is_none()
&& worker.restore_observation_token.is_some()
}
fn ensure_restore_accepted(response: &BackendWorkerRestoreResponse) -> Result<(), io::Error> {
@@ -526,6 +630,18 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
BackendWorkingDirectorySource::Repository { repository_key } => {
(repository_key.as_str(), String::new())
}
BackendWorkingDirectorySource::WorkspaceConfig { .. } => {
// Logical config access is not host filesystem or COMMAND authority.
let permissions = &attachment.effective_permissions;
let mut categories = Vec::new();
if permissions.read {
categories.push("READ");
}
if permissions.write {
categories.push("EDIT");
}
("workspace-config", format!("・{}", categories.join("・")))
}
BackendWorkingDirectorySource::ExternalGrant { .. } => {
let permissions = &attachment.effective_permissions;
let mut categories = Vec::new();
@@ -558,6 +674,7 @@ mod tests {
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
BackendWorkerSummary {
restore_observation_token: Some("observed-worker-generation".into()),
runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(),
resource_key: "W-1".to_string(),
@@ -630,7 +747,8 @@ mod tests {
async fn restore(
&self,
_target: &client::BackendRuntimeTarget,
) -> Result<BackendWorkerRestoreResponse, io::Error> {
_request: client::BackendWorkerRestoreRequest,
) -> Result<BackendWorkerRestoreResponse, RestoreAttemptError> {
self.calls.lock().unwrap().push("restore");
Ok(self.restore_response.clone())
}
@@ -661,6 +779,312 @@ mod tests {
serde_json::from_value(value).unwrap()
}
// Exercise the existing picker flow against real Runtime admission, not a
// fake request -> operation-ID association. Only execution and transport are
// faked; Runtime owns the pending operation and receipt reconciliation.
#[derive(Default)]
struct PendingRuntimeBackend {
operations: std::sync::Mutex<Vec<worker_runtime::execution::WorkerLifecycleOperationId>>,
}
impl worker_runtime::execution::WorkerExecutionBackend for PendingRuntimeBackend {
fn backend_id(&self) -> &str {
"tui-recovery-test"
}
fn spawn_worker(
&self,
_: worker_runtime::execution::WorkerExecutionSpawnRequest,
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
worker_runtime::execution::WorkerExecutionSpawnResult::connected(
protocol::WorkerStateSnapshot {
last_command_id: 0,
last_finished_submission_request_id: None,
state: protocol::WorkerState::Idle,
},
Vec::new(),
)
}
fn restore_worker(
&self,
request: worker_runtime::execution::WorkerExecutionRestoreRequest,
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
use worker_runtime::execution::*;
let mut operations = self.operations.lock().unwrap();
operations.push(request.operation_id);
if operations.len() == 1 {
WorkerExecutionSpawnResult::ReconciliationRequired {
result: WorkerExecutionResult::errored(
WorkerExecutionOperation::Restore,
"unknown execution acknowledgement",
),
worker_state: None,
workdir_attachments: Vec::new(),
}
} else {
WorkerExecutionSpawnResult::connected(
protocol::WorkerStateSnapshot {
last_command_id: 0,
last_finished_submission_request_id: None,
state: protocol::WorkerState::Idle,
},
Vec::new(),
)
}
}
fn stop_worker(
&self,
_: &worker_runtime::identity::WorkerRef,
) -> worker_runtime::execution::WorkerExecutionResult {
worker_runtime::execution::WorkerExecutionResult::accepted(
worker_runtime::execution::WorkerExecutionOperation::Stop,
)
}
fn dispatch_input(
&self,
_: &worker_runtime::identity::WorkerRef,
_: worker_runtime::interaction::WorkerInput,
) -> worker_runtime::execution::WorkerExecutionResult {
panic!("Restore must not dispatch input")
}
}
struct RuntimeRecoveryLifecycle {
runtime: worker_runtime::Runtime,
worker: worker_runtime::identity::WorkerRef,
lose_first_reply: bool,
requests: std::sync::Mutex<
Vec<(
client::BackendRuntimeTarget,
client::BackendWorkerRestoreRequest,
)>,
>,
observations: std::sync::Mutex<usize>,
}
#[async_trait::async_trait]
impl BackendWorkerLifecycle for RuntimeRecoveryLifecycle {
async fn restore(
&self,
target: &client::BackendRuntimeTarget,
request: client::BackendWorkerRestoreRequest,
) -> Result<BackendWorkerRestoreResponse, RestoreAttemptError> {
self.requests
.lock()
.unwrap()
.push((target.clone(), request.clone()));
assert_eq!(target.worker_id, self.worker.worker_id.to_string());
let result = self
.runtime
.restore_worker_operation(
&self.worker,
serde_json::from_value(serde_json::to_value(request).unwrap()).unwrap(),
)
.map_err(|error| match error {
worker_runtime::error::RuntimeError::RestoreObservationConflict { .. } => {
RestoreAttemptError::Conflict
}
error => RestoreAttemptError::Unknown(io::Error::other(error.to_string())),
})?;
if self.lose_first_reply && self.requests.lock().unwrap().len() == 1 {
return Err(RestoreAttemptError::Unknown(io::Error::new(
io::ErrorKind::ConnectionReset,
"reply lost after admission",
)));
}
let mut response = restore_response(result.state);
response.runtime_id = target.runtime_id.clone();
response.worker_id = target.worker_id.clone();
Ok(response)
}
async fn observe(
&self,
_: &client::BackendRuntimeTarget,
) -> Result<WorkspaceWorkerSessionResponse, io::Error> {
*self.observations.lock().unwrap() += 1;
Ok(session_observation(
serde_json::json!({"availability": "live_protocol"}),
))
}
}
fn pending_runtime_lifecycle(
lose_first_reply: bool,
) -> (
RuntimeRecoveryLifecycle,
std::sync::Arc<PendingRuntimeBackend>,
client::BackendRuntimeTarget,
client::BackendWorkerRestoreRequest,
) {
use worker_runtime::catalog::*;
let backend = std::sync::Arc::new(PendingRuntimeBackend::default());
let runtime =
worker_runtime::Runtime::with_execution_backend(Default::default(), backend.clone())
.unwrap();
let archive = worker_runtime::profile_archive::ProfileSourceArchive::build(
worker_runtime::profile_archive::ProfileSourceArchiveInput {
id: "tui-recovery-profile".into(),
entrypoints: std::collections::BTreeMap::from([(
"builtin:coder".into(),
"coder.dcdl".into(),
)]),
imports: Default::default(),
sources: std::collections::BTreeMap::from([("coder.dcdl".into(), "{}".into())]),
},
)
.unwrap();
let worker = runtime
.create_worker(CreateWorkerRequest {
worker_id: protocol::WorkerId::now_v7(),
create_fingerprint: "tui-recovery-create".into(),
profile: ProfileSelector::Builtin("builtin:coder".into()),
display_name: None,
profile_source: ProfileSourceArchiveSource::Embedded { archive },
config_bundle: None,
initial_input: None,
workdir_attachment_requests: Vec::new(),
workdir_attachments: Vec::new(),
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
memory_settings: Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "local".into(),
settings_revision: 1,
language: "English".into(),
}),
subjektiv_attached: false,
})
.unwrap();
runtime.stop_worker(&worker.worker_ref, None).unwrap();
let request = client::BackendWorkerRestoreRequest {
expected_observation_token: runtime
.worker_detail(&worker.worker_ref)
.unwrap()
.restore_observation_token
.unwrap(),
request_id: "tui-original-request".into(),
};
let target = client::BackendRuntimeTarget::new(
"http://127.0.0.1:3000",
"workspace-a",
"runtime-a",
worker.worker_id.to_string(),
);
(
RuntimeRecoveryLifecycle {
runtime,
worker: worker.worker_ref,
lose_first_reply,
requests: Default::default(),
observations: Default::default(),
},
backend,
target,
request,
)
}
#[tokio::test]
async fn resume_explicit_transport_and_pending_retries_preserve_tuple_and_runtime_operation() {
for lost_reply in [true, false] {
let (lifecycle, backend, target, request) = pending_runtime_lifecycle(lost_reply);
let mut confirmations = 0;
let prepared = prepare_selected_worker(
&lifecycle,
target.clone(),
BackendWorkerPickerIntent::Resume,
Some(request.clone()),
|detail| {
confirmations += 1;
assert_eq!(
backend.operations.lock().unwrap().len(),
1,
"no POST before explicit retry confirmation"
);
assert!(detail.contains(&format!("request_id={}", request.request_id)));
assert!(detail.contains(&format!(
"expected_observation_token={}",
request.expected_observation_token
)));
assert_ne!(
lifecycle
.runtime
.worker_detail(&lifecycle.worker)
.unwrap()
.restore_observation_token
.as_ref(),
Some(&request.expected_observation_token),
"fresh observation must not replace recovery tuple"
);
Ok(true)
},
)
.await
.unwrap();
assert_eq!(prepared, target);
assert_eq!(confirmations, 1);
assert_eq!(
lifecycle.requests.lock().unwrap().as_slice(),
&[(target.clone(), request.clone()), (target, request)]
);
let operations = backend.operations.lock().unwrap();
assert_eq!(operations.len(), 2);
assert_eq!(
operations[0], operations[1],
"Runtime pending lifecycle operation must not be replaced"
);
assert_eq!(
*lifecycle.observations.lock().unwrap(),
2,
"GET after uncertainty and after acceptance"
);
}
}
#[tokio::test]
async fn resume_cancelled_unknown_recovery_has_no_automatic_retry_and_reports_original_identity()
{
let (lifecycle, backend, target, request) = pending_runtime_lifecycle(true);
let error = prepare_selected_worker(
&lifecycle,
target,
BackendWorkerPickerIntent::Resume,
Some(request.clone()),
|_| Ok(false),
)
.await
.unwrap_err()
.to_string();
assert!(error.contains(&request.request_id));
assert!(error.contains(&request.expected_observation_token));
assert_eq!(backend.operations.lock().unwrap().len(), 1);
assert_eq!(lifecycle.requests.lock().unwrap().len(), 1);
assert_eq!(*lifecycle.observations.lock().unwrap(), 1);
}
#[tokio::test]
async fn resume_stale_conflict_never_offers_same_intent_retry() {
let (lifecycle, backend, target, mut request) = pending_runtime_lifecycle(false);
request.expected_observation_token = "stale-token".into();
let error = prepare_selected_worker(
&lifecycle,
target,
BackendWorkerPickerIntent::Resume,
Some(request),
|_| panic!("stale conflict must not offer unknown-result retry"),
)
.await
.unwrap_err()
.to_string();
assert!(error.contains("refresh the Worker list"));
assert!(backend.operations.lock().unwrap().is_empty());
assert_eq!(*lifecycle.observations.lock().unwrap(), 0);
}
#[tokio::test]
async fn resume_restores_before_requiring_live_observation() {
let lifecycle = FakeBackendWorkerLifecycle {
@@ -677,8 +1101,16 @@ mod tests {
"worker-a",
);
let prepared =
prepare_selected_worker(&lifecycle, target, BackendWorkerPickerIntent::Resume)
let prepared = prepare_selected_worker(
&lifecycle,
target,
BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(),
request_id: "resume-operation".into(),
}),
|_| Ok(false),
)
.await
.unwrap();
@@ -704,7 +1136,16 @@ mod tests {
"worker-a",
);
let error = prepare_selected_worker(&lifecycle, target, BackendWorkerPickerIntent::Resume)
let error = prepare_selected_worker(
&lifecycle,
target,
BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(),
request_id: "resume-operation".into(),
}),
|_| Ok(false),
)
.await
.unwrap_err()
.to_string();
@@ -724,7 +1165,7 @@ mod tests {
assert!(!is_resume_candidate(&live));
assert!(is_resume_candidate(&stopped));
assert!(is_resume_candidate(&unknown));
assert!(!is_resume_candidate(&unknown));
}
#[test]
@@ -860,6 +1301,37 @@ mod tests {
assert!(!text.contains("active clean"));
}
#[test]
fn logical_config_attachment_labels_editability_without_command_or_host_path() {
for (access, write, expected) in [
("read_only", false, "READ"),
("read_write", true, "READ・EDIT"),
] {
let mut worker = worker("runtime-a", "worker-b", None);
worker.workdir_attachments = vec![serde_json::from_value(serde_json::json!({
"alias": "workspace-config",
"effective_permissions": {"read": true, "write": write, "command": false},
"working_directory": {
"working_directory_id": "logical-config-1",
"display_name": "Workspace configuration",
"source": {"kind": "workspace_config", "access": access,
"content_path": "/workspace-config", "purpose": "Workspace configuration"},
"materializer_kind": "logical_workspace_config",
"status": "active"
}
})).unwrap()];
let text = working_directory_text(&worker);
assert_eq!(
text,
format!(
"wd:workspace-config:Workspace configuration・{expected}・logical-config-1"
)
);
assert!(!text.contains("COMMAND"));
assert!(!text.contains("/workspace-config"));
}
}
#[test]
fn worker_rows_align_identity_name_state_and_workdir_columns() {
let mut short = worker("runtime-a", "worker-a", None);
File diff suppressed because it is too large Load Diff
+654 -27
View File
@@ -115,12 +115,31 @@ impl FlowRefAtom {
}
}
#[derive(Debug, Clone)]
pub struct FeatureInvokeAtom {
pub invocation: protocol::FeatureInvocation,
}
impl FeatureInvokeAtom {
pub fn label(&self) -> String {
self.invocation.display_input()
}
}
#[derive(Debug, Clone)]
pub enum Atom {
Char(char),
Unknown,
AttachmentStage {
id: String,
file_name: String,
failed: bool,
},
Paste(PasteRef),
PasteArtifact(protocol::PasteArtifactRef),
UploadedFile(protocol::UploadedFileRef),
FileRef(FileRefAtom),
FeatureInvoke(FeatureInvokeAtom),
FlowRef(FlowRefAtom),
}
@@ -130,6 +149,23 @@ impl Atom {
fn chip(&self) -> Option<(Style, String)> {
match self {
Atom::Char(_) => None,
Atom::Unknown => Some((
Style::default().fg(Color::Red),
"[unknown input segment]".into(),
)),
Atom::AttachmentStage {
file_name, failed, ..
} => Some((
Style::default().fg(Color::Cyan),
format!(
"[Attached {file_name} | {}]",
if *failed {
"failed; Alt+Enter retries"
} else {
"uploading"
}
),
)),
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
Atom::PasteArtifact(artifact) => Some((
Style::default().fg(Color::Magenta),
@@ -143,7 +179,17 @@ impl Atom {
artifact.created_at_ms
),
)),
Atom::UploadedFile(file) => Some((
Style::default().fg(Color::Cyan),
format!(
"[Attached {} | {} bytes, ready]",
file.file_name, file.byte_len
),
)),
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
Atom::FeatureInvoke(invocation) => {
Some((Style::default().fg(Color::Yellow), invocation.label()))
}
Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())),
}
}
@@ -173,9 +219,14 @@ enum WordKind {
fn atom_class(atom: &Atom) -> AtomClass {
match atom {
Atom::Char(c) => char_class(*c),
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
AtomClass::Chip
}
Atom::Unknown
| Atom::AttachmentStage { .. }
| Atom::Paste(_)
| Atom::PasteArtifact(_)
| Atom::UploadedFile(_)
| Atom::FileRef(_)
| Atom::FeatureInvoke(_)
| Atom::FlowRef(_) => AtomClass::Chip,
}
}
@@ -195,12 +246,53 @@ fn char_class(c: char) -> AtomClass {
}
}
fn first_unquoted_char(value: &str, target: char) -> Option<usize> {
unquoted_char_indices(value).find_map(|(index, ch)| (ch == target).then_some(index))
}
fn last_unquoted_char(value: &str, target: char) -> Option<usize> {
unquoted_char_indices(value)
.filter_map(|(index, ch)| (ch == target).then_some(index))
.last()
}
fn unquoted_char_indices(value: &str) -> impl Iterator<Item = (usize, char)> + '_ {
let mut quoted = false;
let mut escaped = false;
value.char_indices().filter(move |(_, ch)| {
if !escaped && *ch == '"' {
quoted = !quoted;
escaped = false;
return false;
}
let visible = !quoted;
escaped = quoted && !escaped && *ch == '\\';
if *ch != '\\' {
escaped = false;
}
visible
})
}
#[derive(Clone)]
struct SelectedInvocation {
start: usize,
descriptor: protocol::FeatureInvocationDescriptor,
invocation_id: String,
}
pub struct InputBuffer {
/// Monotonic semantic edit/cursor revision, including ABA changes.
revision: u64,
atoms: Vec<Atom>,
selected_invocations: Vec<SelectedInvocation>,
removed_attachment_stages: Vec<String>,
/// Insertion point in `0..=atoms.len()`.
cursor: usize,
/// Top wrapped row of the visible composer viewport.
scroll_offset: usize,
/// Uploaded draft chips removed by explicit editing and awaiting client-side cleanup.
removed_uploaded_files: Vec<protocol::UploadedFileRef>,
/// Monotonic counter reused across the TUI process lifetime.
next_paste_id: u32,
}
@@ -208,20 +300,34 @@ pub struct InputBuffer {
impl Default for InputBuffer {
fn default() -> Self {
Self {
revision: 0,
atoms: Vec::new(),
selected_invocations: Vec::new(),
removed_attachment_stages: Vec::new(),
cursor: 0,
scroll_offset: 0,
removed_uploaded_files: Vec::new(),
next_paste_id: 1,
}
}
}
impl InputBuffer {
pub fn revision(&self) -> u64 {
self.revision
}
fn bump_revision(&mut self) {
self.revision = self.revision.wrapping_add(1);
}
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
self.bump_revision();
self.selected_invocations.clear();
self.atoms.clear();
self.cursor = 0;
self.scroll_offset = 0;
@@ -243,6 +349,8 @@ impl InputBuffer {
/// by [`submit_segments`](Self::submit_segments), preserving typed chips
/// and placing the cursor at the end of the restored input.
pub fn replace_with_segments(&mut self, segments: &[protocol::Segment]) {
self.bump_revision();
self.selected_invocations.clear();
self.atoms.clear();
for segment in segments {
match segment {
@@ -267,24 +375,24 @@ impl InputBuffer {
self.atoms.push(Atom::PasteArtifact(artifact.clone()));
}
protocol::Segment::UploadedFile { file } => {
self.atoms.extend(
format!("[Attached file: {}]", file.file_name)
.chars()
.map(Atom::Char),
);
self.atoms.push(Atom::UploadedFile(file.clone()));
}
protocol::Segment::FileRef { path } => {
self.atoms
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
}
protocol::Segment::FeatureInvoke { invocation } => {
self.atoms.push(Atom::FeatureInvoke(FeatureInvokeAtom {
invocation: invocation.clone(),
}));
}
protocol::Segment::Flow { selector } => {
self.atoms.push(Atom::FlowRef(FlowRefAtom {
selector: selector.clone(),
}));
}
protocol::Segment::Unknown => {
self.atoms
.extend("[unknown input segment]".chars().map(Atom::Char));
self.atoms.push(Atom::Unknown);
}
}
}
@@ -292,6 +400,8 @@ impl InputBuffer {
}
pub fn insert_char(&mut self, c: char) {
self.bump_revision();
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::Char(c));
self.cursor += 1;
}
@@ -307,6 +417,10 @@ impl InputBuffer {
for atom in &self.atoms {
match atom {
Atom::Char(c) => text.push(*c),
Atom::Unknown => text.push_str("[unknown input segment]"),
Atom::AttachmentStage { file_name, .. } => {
text.push_str(&format!("[Staging {file_name}]"))
}
Atom::Paste(paste) => text.push_str(&paste.content),
Atom::PasteArtifact(artifact) => {
text.push_str(&protocol::Segment::flatten_to_text(&[
@@ -315,7 +429,11 @@ impl InputBuffer {
},
]))
}
Atom::UploadedFile(file) => {
text.push_str(&format!("[Attached file: {}]", file.file_name));
}
Atom::FileRef(file) => text.push_str(&file.path),
Atom::FeatureInvoke(invocation) => text.push_str(&invocation.label()),
Atom::FlowRef(flow) => text.push_str(&flow.selector),
}
}
@@ -327,6 +445,7 @@ impl InputBuffer {
}
pub fn insert_paste(&mut self, content: String) {
self.bump_revision();
let measurement = measure_paste(&content);
if measurement.presentation() == PastePresentation::Text {
self.insert_str(&content);
@@ -335,6 +454,7 @@ impl InputBuffer {
let id = self.next_paste_id;
self.next_paste_id = self.next_paste_id.wrapping_add(1);
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(
self.cursor,
Atom::Paste(PasteRef {
@@ -347,14 +467,236 @@ impl InputBuffer {
self.cursor += 1;
}
#[cfg(test)]
pub fn insert_uploaded_file(&mut self, file: protocol::UploadedFileRef) {
self.bump_revision();
self.adjust_selection(self.cursor, self.cursor, 1);
self.atoms.insert(self.cursor, Atom::UploadedFile(file));
self.cursor += 1;
}
// Selections belong to an exact occurrence, never to all text matching a name.
fn adjust_selection(&mut self, start: usize, end: usize, inserted: usize) {
self.selected_invocations.retain_mut(|selection| {
if start < end && (start..end).contains(&selection.start) {
return false;
}
if selection.start >= end {
selection.start = selection.start - (end - start) + inserted;
}
true
});
}
pub fn select_feature_invocation(
&mut self,
start: usize,
descriptor: protocol::FeatureInvocationDescriptor,
) {
self.bump_revision();
self.selected_invocations
.retain(|selection| selection.start != start);
self.selected_invocations.push(SelectedInvocation {
start,
descriptor,
invocation_id: protocol::new_submission_request_id(),
});
}
pub fn has_selected_feature_invocations(&self) -> bool {
!self.selected_invocations.is_empty()
}
pub fn retry_attachment_stage(&mut self, id: &str) {
self.bump_revision();
for atom in &mut self.atoms {
if let Atom::AttachmentStage {
id: stage_id,
failed,
..
} = atom
{
if stage_id == id {
*failed = false;
}
}
}
}
pub fn finalize_feature_invocations(&mut self) -> Result<(), String> {
// Validate first so invalid arguments cannot partially chip a draft.
let mut parsed = Vec::new();
for selection in &self.selected_invocations {
let text = self.atoms[selection.start..]
.iter()
.take_while(|atom| matches!(atom, Atom::Char(_)))
.filter_map(|atom| {
if let Atom::Char(ch) = atom {
Some(*ch)
} else {
None
}
})
.collect::<String>();
let invocation = protocol::parse_feature_invocation(
&text,
0,
&selection.descriptor,
selection.invocation_id.clone(),
)
.map_err(|error| error.to_string())?;
parsed.push((
selection.start,
text[..invocation.end].chars().count(),
invocation.invocation,
));
}
parsed.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start));
for (start, count, invocation) in parsed {
self.replace_atoms(
start,
start + count,
vec![Atom::FeatureInvoke(FeatureInvokeAtom { invocation })],
);
}
Ok(())
}
fn replace_atoms(&mut self, start: usize, end: usize, atoms: Vec<Atom>) {
self.bump_revision();
let inserted = atoms.len();
self.adjust_selection(start, end, inserted);
let removed = self.atoms.splice(start..end, atoms).collect::<Vec<_>>();
for atom in removed {
self.record_removed_atom(atom);
}
if self.cursor >= end {
self.cursor = self.cursor - (end - start) + inserted;
} else if self.cursor > start {
self.cursor = start + inserted;
}
}
pub fn adjacent_feature_invocation(&self) -> Option<protocol::FeatureInvocation> {
[self.cursor.checked_sub(1), Some(self.cursor)]
.into_iter()
.flatten()
.find_map(|index| match self.atoms.get(index) {
Some(Atom::FeatureInvoke(atom)) => Some(atom.invocation.clone()),
_ => None,
})
}
pub fn edit_feature_invocation(
&mut self,
id: &str,
descriptor: protocol::FeatureInvocationDescriptor,
) -> bool {
let Some((index, invocation)) =
self.atoms
.iter()
.enumerate()
.find_map(|(index, atom)| match atom {
Atom::FeatureInvoke(atom) if atom.invocation.invocation_id == id => {
Some((index, atom.invocation.clone()))
}
_ => None,
})
else {
return false;
};
if invocation.identity != descriptor.identity {
return false;
}
let text = invocation.display_input();
self.replace_atoms(index, index + 1, text.chars().map(Atom::Char).collect());
self.cursor = index + text.chars().count() - 1;
self.selected_invocations.push(SelectedInvocation {
start: index,
descriptor,
invocation_id: invocation.invocation_id,
});
true
}
pub fn stage_attachment(&mut self, invocation_id: &str, id: String, file_name: String) -> bool {
let Some(index) = self.atoms.iter().position(|atom| matches!(atom, Atom::FeatureInvoke(atom) if atom.invocation.invocation_id == invocation_id)) else { return false; };
self.replace_atoms(
index,
index + 1,
vec![Atom::AttachmentStage {
id,
file_name,
failed: false,
}],
);
true
}
pub fn has_attachment_stages(&self) -> bool {
self.atoms
.iter()
.any(|atom| matches!(atom, Atom::AttachmentStage { .. }))
}
pub fn attachment_stage_ids(&self) -> Vec<String> {
self.atoms
.iter()
.filter_map(|atom| {
if let Atom::AttachmentStage { id, .. } = atom {
Some(id.clone())
} else {
None
}
})
.collect()
}
pub fn adjacent_failed_attachment(&self) -> Option<String> {
[self.cursor.checked_sub(1), Some(self.cursor)]
.into_iter()
.flatten()
.find_map(|index| match self.atoms.get(index) {
Some(Atom::AttachmentStage {
id, failed: true, ..
}) => Some(id.clone()),
_ => None,
})
}
pub fn finish_attachment_stage(
&mut self,
id: &str,
file: Option<protocol::UploadedFileRef>,
) -> bool {
self.bump_revision();
let Some(index) = self.atoms.iter().position(
|atom| matches!(atom, Atom::AttachmentStage { id: stage_id, .. } if stage_id == id),
) else {
return false;
};
if let Some(file) = file {
self.atoms[index] = Atom::UploadedFile(file);
} else if let Atom::AttachmentStage { failed, .. } = &mut self.atoms[index] {
*failed = true;
}
true
}
pub fn take_removed_attachment_stages(&mut self) -> Vec<String> {
std::mem::take(&mut self.removed_attachment_stages)
}
/// Replace `atoms[start..self.cursor]` (the in-flight `@<typed>` /
/// active `@<typed>` file token) with the corresponding chip atom
/// and place the cursor right after the chip. Used by the completion
/// confirm path.
pub fn replace_with_file_ref(&mut self, start: usize, path: String) {
self.atoms.drain(start..self.cursor);
self.atoms
.insert(start, Atom::FileRef(FileRefAtom { path }));
self.replace_atoms(
start,
self.cursor,
vec![Atom::FileRef(FileRefAtom { path })],
);
self.cursor = start + 1;
}
@@ -363,14 +705,177 @@ impl InputBuffer {
/// completion path: the popup-selected entry is inserted as raw
/// text (not a chip) so the user can keep typing — e.g. drill into
/// a directory whose value ends with `/`.
pub fn replace_with_text_at(&mut self, start: usize, text: &str) {
self.atoms.drain(start..self.cursor);
let mut idx = start;
for c in text.chars() {
self.atoms.insert(idx, Atom::Char(c));
idx += 1;
pub fn char_at(&self, index: usize) -> Option<char> {
match self.atoms.get(index) {
Some(Atom::Char(ch)) => Some(*ch),
_ => None,
}
self.cursor = idx;
}
pub fn replace_with_text_at(&mut self, start: usize, text: &str) {
self.replace_atoms(start, self.cursor, text.chars().map(Atom::Char).collect());
self.cursor = start + text.chars().count();
}
pub fn can_complete_argument_name(&self, prefix_start: usize) -> bool {
let mut before = prefix_start;
while self.char_at(before).is_some_and(char::is_whitespace) {
let Some(previous) = before.checked_sub(1) else {
return false;
};
before = previous;
}
matches!(self.char_at(before), Some('(' | ','))
}
pub fn replace_feature_name_completion(&mut self, start: usize, text: &str) {
let mut end = self.cursor;
while self
.char_at(end)
.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '-')
{
end += 1;
}
if self.char_at(end) == Some('(') {
end += 1;
}
self.replace_atoms(start, end, text.chars().map(Atom::Char).collect());
self.cursor = start + text.chars().count();
}
pub fn replace_argument_completion(&mut self, start: usize, text: &str) {
// Replace the whole value including an existing closing quote when editing its middle.
let mut end = self.cursor;
let quoted = self.char_at(start) == Some('"');
let mut escaped = false;
while let Some(ch) = self.char_at(end) {
if !quoted && (ch == ',' || ch == ')' || ch.is_whitespace()) {
break;
}
end += 1;
if quoted && !escaped && ch == '"' {
break;
}
escaped = ch == '\\' && !escaped;
if ch != '\\' {
escaped = false;
}
}
if quoted && self.char_at(end.saturating_sub(1)) != Some('"') {
end = self.cursor;
}
self.replace_atoms(start, end, text.chars().map(Atom::Char).collect());
self.cursor = start + text.chars().count();
}
pub fn pending_feature_argument_completion(
&self,
descriptors: &[protocol::FeatureInvocationDescriptor],
) -> Option<(
protocol::CompletionKind,
usize,
String,
protocol::CompletionContext,
)> {
if self.cursor == 0 {
return None;
}
let selection = self
.selected_invocations
.iter()
.filter(|selected| selected.start < self.cursor)
.max_by_key(|selected| selected.start)?;
let run_start = selection.start;
let descriptor = &selection.descriptor;
if !descriptors
.iter()
.any(|candidate| candidate.identity == descriptor.identity)
{
return None;
}
let invocation = self.atoms[run_start..self.cursor]
.iter()
.map(|atom| match atom {
Atom::Char(ch) => Some(*ch),
_ => None,
})
.collect::<Option<String>>()?;
let open = invocation.find('(')?;
let arguments = &invocation[open + 1..];
if first_unquoted_char(arguments, ')').is_some() {
return None;
}
let current_start = last_unquoted_char(arguments, ',').map_or(0, |index| index + 1);
let current = &arguments[current_start..];
let leading = current.len() - current.trim_start().len();
let current = &current[leading..];
let (argument, prefix_offset, mut prefix) =
if let Some(equal) = first_unquoted_char(current, '=') {
let name = current[..equal].trim();
let mut value = &current[equal + 1..];
let mut offset = equal + 1;
let value_leading = value.len() - value.trim_start().len();
value = value.trim_start();
offset += value_leading;
if let Some(unquoted) = value.strip_prefix('"') {
value = unquoted;
offset += 1;
}
(Some(name.to_string()), offset, value.to_string())
} else {
let mut previous_start = 0;
let mut position = 0;
for comma in unquoted_char_indices(&arguments[..current_start])
.filter_map(|(index, ch)| (ch == ',').then_some(index))
{
if first_unquoted_char(&arguments[previous_start..comma], '=').is_none() {
position += 1;
}
previous_start = comma + 1;
}
let positional = descriptor
.arguments
.iter()
.find(|argument| argument.position == Some(position));
if (current.is_empty() && positional.is_none())
|| (current.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_'
}) && positional.is_none())
{
(None, 0, current.to_string())
} else {
let mut value = current;
let mut offset = 0;
if let Some(unquoted) = value.strip_prefix('"') {
value = unquoted;
offset = 1;
}
(
positional.map(|argument| argument.name.clone()),
offset,
value.to_string(),
)
}
};
if prefix_offset > 0 && current[..prefix_offset].ends_with('"') {
if serde_json::from_str::<String>(&format!("\"{}", prefix)).is_ok() {
return None;
}
prefix = serde_json::from_str::<String>(&format!("\"{}\"", prefix)).ok()?;
}
let prefix_start = run_start
+ invocation[..open + 1].chars().count()
+ arguments[..current_start + leading].chars().count()
+ current[..prefix_offset].chars().count();
Some((
protocol::CompletionKind::FeatureArgument,
prefix_start.saturating_sub(1),
prefix,
protocol::CompletionContext {
invocation: descriptor.identity.clone(),
argument,
},
))
}
/// If the cursor is currently inside a `@<typed>` /
@@ -398,7 +903,9 @@ impl InputBuffer {
}
let kind = match c {
'@' => Some(protocol::CompletionKind::File),
'/' if !typed.contains(['(', ')', '=', ',']) => {
Some(protocol::CompletionKind::Feature)
}
_ => None,
};
if let Some(k) = kind {
@@ -422,17 +929,29 @@ impl InputBuffer {
None
}
pub fn take_removed_uploaded_files(&mut self) -> Vec<protocol::UploadedFileRef> {
std::mem::take(&mut self.removed_uploaded_files)
}
fn record_removed_atom(&mut self, atom: Atom) {
match atom {
Atom::UploadedFile(file) => self.removed_uploaded_files.push(file),
Atom::AttachmentStage { id, .. } => self.removed_attachment_stages.push(id),
_ => {}
}
}
pub fn delete_before(&mut self) {
if self.cursor == 0 {
return;
}
self.cursor -= 1;
self.atoms.remove(self.cursor);
self.replace_atoms(self.cursor, self.cursor + 1, Vec::new());
}
pub fn delete_after(&mut self) {
if self.cursor < self.atoms.len() {
self.atoms.remove(self.cursor);
self.replace_atoms(self.cursor, self.cursor + 1, Vec::new());
}
}
@@ -442,14 +961,16 @@ impl InputBuffer {
let end = self.cursor;
self.move_word_left();
let start = self.cursor;
self.atoms.drain(start..end);
self.replace_atoms(start, end, Vec::new());
}
pub fn move_left(&mut self) {
self.bump_revision();
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_right(&mut self) {
self.bump_revision();
self.cursor = (self.cursor + 1).min(self.atoms.len());
}
@@ -457,6 +978,7 @@ impl InputBuffer {
/// atoms sharing the same [`AtomClass`] — so `Word(Hiragana)` next to
/// `Word(Han)` are separate blocks, and a `Paste` atom is its own block.
pub fn move_word_left(&mut self) {
self.bump_revision();
while self.cursor > 0 && atom_class(&self.atoms[self.cursor - 1]) == AtomClass::Sep {
self.cursor -= 1;
}
@@ -471,6 +993,7 @@ impl InputBuffer {
/// Move forward by one word. Mirror of [`move_word_left`].
pub fn move_word_right(&mut self) {
self.bump_revision();
while self.cursor < self.atoms.len()
&& atom_class(&self.atoms[self.cursor]) == AtomClass::Sep
{
@@ -486,6 +1009,7 @@ impl InputBuffer {
}
pub fn move_start(&mut self) {
self.bump_revision();
self.cursor = 0;
}
@@ -534,11 +1058,13 @@ impl InputBuffer {
}
pub fn move_home(&mut self) {
self.bump_revision();
let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].0;
}
pub fn move_end(&mut self) {
self.bump_revision();
let (ranges, line, _) = self.logical_line_and_col();
self.cursor = ranges[line].1;
}
@@ -546,6 +1072,7 @@ impl InputBuffer {
/// Move one logical line up, preserving column (atom count from
/// current line start). No-op if already on the first line.
pub fn move_up(&mut self) {
self.bump_revision();
let (ranges, line, col) = self.logical_line_and_col();
if line == 0 {
return;
@@ -556,6 +1083,7 @@ impl InputBuffer {
/// Move one logical line down, preserving column.
pub fn move_down(&mut self) {
self.bump_revision();
let (ranges, line, col) = self.logical_line_and_col();
let Some(&(start, end)) = ranges.get(line + 1) else {
return;
@@ -580,6 +1108,12 @@ impl InputBuffer {
for a in &self.atoms {
match a {
Atom::Char(c) => buf.push(*c),
Atom::Unknown => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Unknown);
}
// Staging is client-owned and never a submit payload.
Atom::AttachmentStage { .. } => {}
Atom::Paste(p) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Paste {
@@ -595,12 +1129,22 @@ impl InputBuffer {
artifact: artifact.clone(),
});
}
Atom::UploadedFile(file) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::UploadedFile { file: file.clone() });
}
Atom::FileRef(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::FileRef {
path: r.path.clone(),
});
}
Atom::FeatureInvoke(invocation) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::FeatureInvoke {
invocation: invocation.invocation.clone(),
});
}
Atom::FlowRef(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::Flow {
@@ -1531,9 +2075,14 @@ mod word_motion_tests {
for a in &buf.atoms {
match a {
Atom::Char(c) => out.push(*c),
Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
out.push_str("<P>")
}
Atom::Unknown
| Atom::AttachmentStage { .. }
| Atom::Paste(_)
| Atom::PasteArtifact(_)
| Atom::UploadedFile(_)
| Atom::FileRef(_)
| Atom::FeatureInvoke(_)
| Atom::FlowRef(_) => out.push_str("<P>"),
}
}
out
@@ -1621,3 +2170,81 @@ mod word_motion_tests {
assert_eq!(cursor(&buf), 7); // after "った"
}
}
#[cfg(test)]
mod feature_invocation_input_tests {
use super::*;
use protocol::{
FeatureInvocationDescriptor, FeatureInvocationIdentity, FeatureInvocationSyntax,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationCompletion, Segment,
UploadedFileAvailability, UploadedFileRef,
};
fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("builtin:test/run".into()),
name: "run".into(),
aliases: Vec::new(),
display_name: "Run".into(),
description: "test".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "path".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::String,
completion: InvocationCompletion::None,
description: None,
}],
client_adapter: None,
}
}
#[test]
fn selected_invocations_become_typed_atoms_and_preserve_following_prose() {
let mut input = InputBuffer::new();
input.insert_str("before /run(\"資料/a b\") after /run(\"x/y\") done");
input.select_feature_invocation(7, descriptor());
input.select_feature_invocation(
"before /run(\"資料/a b\") after ".chars().count(),
descriptor(),
);
input.finalize_feature_invocations().unwrap();
let segments = input.submit_segments();
assert_eq!(
segments
.iter()
.filter(|segment| matches!(segment, Segment::FeatureInvoke { .. }))
.count(),
2
);
assert!(matches!(segments.last(), Some(Segment::Text { content }) if content == " done"));
let mut unselected = InputBuffer::new();
unselected.insert_str("/run(\"x\")");
unselected.finalize_feature_invocations().unwrap();
assert!(matches!(
unselected.submit_segments().as_slice(),
[Segment::Text { content }] if content == "/run(\"x\")"
));
}
#[test]
fn deleting_an_uploaded_file_chip_reports_exact_staged_resource() {
let file = UploadedFileRef {
artifact_id: "artifact-1".into(),
file_name: "report.md".into(),
media_type: "text/markdown".into(),
created_at_ms: 1,
availability: UploadedFileAvailability::Available,
byte_len: 4,
sha256: "a".repeat(64),
source_entry_id: None,
};
let mut input = InputBuffer::new();
input.insert_uploaded_file(file.clone());
input.delete_before();
assert_eq!(input.take_removed_uploaded_files(), vec![file]);
assert!(input.take_removed_uploaded_files().is_empty());
}
}
+653
View File
@@ -0,0 +1,653 @@
//! T-696 client contract regressions, exercised through real composer selection.
use crate::{app::App, input::InputBuffer};
use protocol::*;
pub(crate) fn descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity("plugin:test/run".into()),
name: "run".into(),
aliases: vec!["execute".into()],
display_name: "Run".into(),
description: "Run a declared action".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![
InvocationArgumentDescriptor {
name: "path".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::WorkerFile,
completion: InvocationCompletion::WorkerFile,
description: None,
},
InvocationArgumentDescriptor {
name: "mode".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Enum {
values: vec!["safe".into(), "fast".into()],
},
completion: InvocationCompletion::Static {
values: vec!["safe".into(), "fast".into()],
},
description: None,
},
InvocationArgumentDescriptor {
name: "count".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Integer,
completion: InvocationCompletion::None,
description: None,
},
InvocationArgumentDescriptor {
name: "force".into(),
position: None,
required: false,
value_type: InvocationArgumentType::Boolean,
completion: InvocationCompletion::None,
description: None,
},
],
client_adapter: None,
}
}
pub(crate) fn attachment_descriptor() -> FeatureInvocationDescriptor {
let mut descriptor = descriptor();
// No hardcoded builtin identity, public name, or argument name in adapter dispatch.
descriptor.identity = FeatureInvocationIdentity("plugin:files/add".into());
descriptor.name = "add-file".into();
descriptor.aliases.clear();
descriptor.arguments.truncate(1);
descriptor.arguments[0].name = "resource".into();
descriptor.arguments[0].value_type = InvocationArgumentType::ClientFile;
descriptor.arguments[0].completion = InvocationCompletion::ClientFile;
descriptor.client_adapter = Some(InvocationClientAdapter::Attachment);
descriptor
}
pub(crate) fn uploaded_file() -> UploadedFileRef {
UploadedFileRef {
artifact_id: "artifact-owned".into(),
file_name: "資料 a.txt".into(),
media_type: "text/plain".into(),
created_at_ms: 1,
availability: UploadedFileAvailability::Available,
byte_len: 4,
sha256: "a".repeat(64),
source_entry_id: None,
}
}
pub(crate) fn select(
app: &mut App,
descriptor: FeatureInvocationDescriptor,
name: &str,
) -> Option<Method> {
app.insert_char('/');
assert!(matches!(
app.refresh_completion(),
Some(Method::ListCompletions {
kind: CompletionKind::Feature,
..
})
));
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: String::new(),
context: None,
entries: vec![CompletionEntry {
value: name.into(),
invocation: Some(descriptor),
..Default::default()
}],
});
app.apply_completion_text()
}
fn complete(app: &mut App, value: &str, is_dir: bool) -> Option<Method> {
let state = app.completion.as_ref().expect("argument completion");
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: state.kind,
prefix: state.prefix.clone(),
context: state.context.clone(),
entries: vec![CompletionEntry {
value: value.into(),
is_dir,
..Default::default()
}],
});
app.apply_completion_text()
}
#[test]
fn feature_invocation_discovery_alias_selection_and_confirmation_are_occurrence_scoped() {
let mut app = App::new("test".into());
app.input.insert_str("説明 ");
assert!(
matches!(select(&mut app, descriptor(), "execute"), Some(Method::ListCompletions {
kind: CompletionKind::FeatureArgument, context: Some(CompletionContext { argument: Some(argument), .. }), ..
}) if argument == "path")
);
app.input
.insert_str("\"資料/a b\") after /execute(\"not selected\")");
assert!(
app.submit_input().is_none(),
"first Enter confirms typed chips, not Send"
);
let segments = app.input.submit_segments();
assert!(
matches!(&segments[1], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity && invocation.name == "execute")
);
assert!(
matches!(segments.last(), Some(Segment::Text { content }) if content == " after /execute(\"not selected\")")
);
assert!(matches!(app.submit_input(), Some(Method::Submit { input, .. }) if input == segments));
}
#[test]
fn feature_invocation_raw_text_paste_and_unknown_payloads_do_not_dispatch() {
let mut app = App::new("test".into());
app.input
.insert_str("/run(x) /attach(/tmp/a.txt) /clear-attachments");
assert!(
matches!(app.submit_input(), Some(Method::Submit { input, .. }) if matches!(input.as_slice(), [Segment::Text { .. }]))
);
let unknown: Segment =
serde_json::from_str(r#"{"kind":"future_invocation","identity":"plugin:test/run"}"#)
.unwrap();
app.input.replace_with_segments(&[unknown]);
assert_eq!(app.input.submit_segments(), vec![Segment::Unknown]);
let mut input = InputBuffer::new();
input.insert_paste("/run(x) ".repeat(20));
input.finalize_feature_invocations().unwrap();
assert!(matches!(
input.submit_segments().as_slice(),
[Segment::Paste { .. }]
));
}
#[test]
fn feature_invocation_argument_insertion_quotes_unicode_spaces_escapes_and_keeps_prose() {
let mut app = App::new("test".into());
app.input.insert_str("界 ");
select(&mut app, descriptor(), "run");
let value = "資料/a b/quote\"slash\\end";
complete(&mut app, value, false);
app.input.insert_str(", mode=sa");
app.refresh_completion();
complete(&mut app, "safe", false);
app.input.insert_str(", count=1");
app.refresh_completion();
complete(&mut app, "12", false);
app.input.insert_str(", force=f");
app.refresh_completion();
complete(&mut app, "false", false);
app.input.insert_str(") trailing prose");
assert!(app.submit_input().is_none());
let segments = app.input.submit_segments();
let Segment::FeatureInvoke { invocation } = &segments[1] else {
panic!("typed chip expected")
};
assert_eq!(
invocation.arguments[0].value,
InvocationValue::String(value.into())
);
assert_eq!(invocation.arguments[2].value, InvocationValue::Integer(12));
assert_eq!(
invocation.arguments[3].value,
InvocationValue::Boolean(false)
);
assert!(
matches!(segments.last(), Some(Segment::Text { content }) if content == " trailing prose")
);
}
#[test]
fn feature_invocation_argument_names_and_directory_drill_in_are_safe() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
assert!(
matches!(complete(&mut app, "資料 dir", true), Some(Method::ListCompletions { prefix, .. }) if prefix == "資料 dir/")
);
complete(&mut app, "資料 dir/file name.txt", false);
app.input.insert_str(", mo");
app.refresh_completion();
assert!(
app.completion
.as_ref()
.unwrap()
.context
.as_ref()
.unwrap()
.argument
.is_none()
);
assert!(
matches!(complete(&mut app, "mode=", false), Some(Method::ListCompletions { context: Some(CompletionContext { argument: Some(argument), .. }), .. }) if argument == "mode")
);
complete(&mut app, "fast", false);
app.input.insert_char(')');
assert!(app.submit_input().is_none());
assert!(matches!(
app.input.submit_segments().as_slice(),
[Segment::FeatureInvoke { .. }]
));
}
#[test]
fn feature_invocation_completion_replaces_middle_value_without_suffix_corruption() {
let mut input = InputBuffer::new();
input.insert_str("界 /run(path= \"資料/old name\", mode=safe) prose");
input.select_feature_invocation(2, descriptor());
input.move_home();
for _ in 0.."界 /run(path= \"資料/ol".chars().count() {
input.move_right();
}
let (_, before, prefix, context) = input
.pending_feature_argument_completion(&[descriptor()])
.unwrap();
assert_eq!(prefix, "資料/ol");
assert_eq!(context.argument.as_deref(), Some("path"));
let start = before; // preceding atom is the opening quote
input.replace_argument_completion(start, &quote_invocation_string("資料/new name"));
assert_eq!(
input.plain_text(),
"界 /run(path= \"資料/new name\", mode=safe) prose"
);
input.finalize_feature_invocations().unwrap();
assert!(
matches!(input.submit_segments().last(), Some(Segment::Text { content }) if content == " prose")
);
}
#[test]
fn feature_invocation_validation_is_atomic_for_multiple_selected_calls() {
for invalid in [
"/run()",
"/run(x, nope=y)",
"/run(x, mode=unknown)",
"/run(x, count=oops)",
"/run(x, force=yes)",
"/run(x, path=y)",
"/run(\"unfinished",
] {
let mut input = InputBuffer::new();
input.insert_str("/run(ok) then ");
let second = input.plain_text().chars().count();
input.insert_str(invalid);
input.select_feature_invocation(0, descriptor());
input.select_feature_invocation(second, descriptor());
let before = input.submit_segments();
assert!(input.finalize_feature_invocations().is_err(), "{invalid}");
assert_eq!(input.submit_segments(), before);
}
}
#[test]
fn feature_invocation_selection_tracks_cursor_edits_and_does_not_survive_deleting_its_sigil() {
let mut input = InputBuffer::new();
input.insert_str("/run(x)");
input.select_feature_invocation(0, descriptor());
input.move_home();
input.insert_str("prefix ");
input.finalize_feature_invocations().unwrap();
assert!(matches!(
&input.submit_segments()[1],
Segment::FeatureInvoke { .. }
));
input.replace_with_segments(&[Segment::text("/run(y)")]);
input.select_feature_invocation(0, descriptor());
input.move_home();
input.delete_after();
input.insert_char('/');
input.finalize_feature_invocations().unwrap();
assert_eq!(input.submit_segments(), vec![Segment::text("/run(y)")]);
}
#[test]
fn feature_invocation_chip_reedit_preserves_identity_id_and_typed_arguments() {
let parsed = parse_feature_invocation(
"/execute(\"a b\", mode=safe, count=2)",
0,
&descriptor(),
"stable-id",
)
.unwrap()
.invocation;
let segments = vec![
Segment::FeatureInvoke {
invocation: parsed.clone(),
},
Segment::UploadedFile {
file: uploaded_file(),
},
Segment::Flow {
selector: "review".into(),
},
Segment::FileRef {
path: "src/".into(),
},
];
let mut input = InputBuffer::new();
input.replace_with_segments(&segments);
assert_eq!(input.submit_segments(), segments);
assert!(input.edit_feature_invocation("stable-id", descriptor()));
input.finalize_feature_invocations().unwrap();
assert_eq!(input.submit_segments(), segments);
assert!(!input.edit_feature_invocation("stable-id", attachment_descriptor()));
}
#[test]
fn feature_invocation_reedit_unknown_disabled_or_stale_keeps_original_chip() {
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id")
.unwrap()
.invocation;
let mut app = App::new("test".into());
let segments = vec![Segment::FeatureInvoke { invocation }];
app.input.replace_with_segments(&segments);
assert!(matches!(
app.edit_adjacent_feature_invocation(),
Some(Method::ListCompletions { .. })
));
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "run".into(),
context: None,
entries: Vec::new(),
});
assert_eq!(app.input.submit_segments(), segments);
app.edit_adjacent_feature_invocation();
app.input.move_home();
app.refresh_completion();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "run".into(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(descriptor()),
..Default::default()
}],
});
assert_eq!(app.input.submit_segments(), segments);
}
#[test]
fn feature_invocation_generic_attachment_adapter_never_records_or_sends_local_path() {
let mut app = App::new("test".into());
select(&mut app, attachment_descriptor(), "add-file");
app.input.insert_str("\"/tmp/資料 a.txt\") keep this prose");
assert!(app.submit_input().is_none());
assert_eq!(app.input_history_len(), 0);
let adapters = app.attachment_invocations();
assert_eq!(adapters.len(), 1);
assert_eq!(adapters[0].1.to_str().unwrap(), "/tmp/資料 a.txt");
app.input
.stage_attachment(&adapters[0].0, "staging-id".into(), "資料 a.txt".into());
assert!(app.submit_input().is_none());
assert!(app.submit_notify_input().is_none());
app.input
.finish_attachment_stage("staging-id", Some(uploaded_file()));
let Method::Submit { input, .. } = app.submit_input().unwrap() else {
panic!("submit expected")
};
assert!(matches!(&input[0], Segment::UploadedFile { .. }));
assert!(!serde_json::to_string(&input).unwrap().contains("/tmp/"));
assert!(
matches!(input.last(), Some(Segment::Text { content }) if content == " keep this prose")
);
}
#[test]
fn feature_invocation_staging_deletion_and_late_completion_do_not_resurrect_chips() {
for delete in [0, 1, 2] {
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "invoke")
.unwrap()
.invocation;
let mut input = InputBuffer::new();
input.replace_with_segments(&[Segment::FeatureInvoke { invocation }]);
input.stage_attachment("invoke", "stage".into(), "a.txt".into());
match delete {
0 => input.delete_before(),
1 => {
input.move_left();
input.delete_after();
}
_ => input.delete_word_before(),
}
assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]);
assert!(!input.finish_attachment_stage("stage", Some(uploaded_file())));
assert!(input.is_empty());
}
}
#[test]
fn feature_invocation_typed_history_draft_transport_and_rewind_restore_without_flattening() {
let invocation = parse_feature_invocation(
"/run(\"資料/a b\", mode=safe)",
0,
&descriptor(),
"stable-id",
)
.unwrap()
.invocation;
let segments = vec![
Segment::text("before "),
Segment::FeatureInvoke { invocation },
Segment::UploadedFile {
file: uploaded_file(),
},
Segment::Paste {
id: 2,
chars: 4,
lines: 1,
content: "data".into(),
},
Segment::FileRef {
path: "src/".into(),
},
Segment::Flow {
selector: "review".into(),
},
];
let mut app = App::new("test".into());
app.input.replace_with_segments(&segments);
let method = app.submit_input().unwrap();
app.restore_unsent_run(&method);
assert_eq!(app.input.submit_segments(), segments);
app.input.clear();
let draft = vec![
Segment::text("draft "),
Segment::UploadedFile {
file: uploaded_file(),
},
];
app.input.replace_with_segments(&draft);
app.input.move_home();
assert!(app.browse_input_history_older());
assert_eq!(app.input.submit_segments(), segments);
assert!(app.browse_input_history_newer());
assert_eq!(app.input.submit_segments(), draft);
app.input.clear();
app.handle_worker_event(Event::RewindApplied {
session: SessionSnapshot {
entries: vec![],
pending_submissions: PendingSubmissionsSnapshot::default(),
},
input: segments.clone(),
summary: RewindSummary {
truncated_to_entries: 1,
discarded_entries: 2,
tool_side_effect_warning: false,
},
});
assert_eq!(app.input.submit_segments(), segments);
}
#[test]
fn feature_invocation_stale_argument_context_and_unvalidated_discovery_entries_are_ignored() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
let state = app.completion.as_ref().unwrap();
let prefix = state.prefix.clone();
let mut context = state.context.clone().unwrap();
context.invocation = FeatureInvocationIdentity("plugin:other/run".into());
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::FeatureArgument,
prefix,
context: Some(context),
entries: vec![CompletionEntry {
value: "unsafe".into(),
..Default::default()
}],
});
assert!(app.completion.as_ref().unwrap().entries.is_empty());
let mut app = App::new("test".into());
app.insert_char('/');
app.refresh_completion();
let mut invalid = descriptor();
invalid.identity = FeatureInvocationIdentity("unqualified".into());
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: String::new(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(invalid),
..Default::default()
}],
});
assert!(app.apply_completion_text().is_none());
assert_eq!(app.input.plain_text(), "/");
}
#[test]
fn feature_invocation_multiple_selected_calls_preserve_prose_and_source_qualified_identity() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
app.input.insert_str("x) prose ");
let mut other = descriptor();
other.identity = FeatureInvocationIdentity("plugin:other/second".into());
other.name = "second".into();
other.aliases.clear();
select(&mut app, other.clone(), "second");
app.input.insert_str("\"y z\") done");
assert!(app.submit_input().is_none());
let segments = app.input.submit_segments();
assert!(
matches!(&segments[0], Segment::FeatureInvoke { invocation } if invocation.identity == descriptor().identity)
);
assert!(matches!(&segments[1], Segment::Text { content } if content == " prose "));
assert!(
matches!(&segments[2], Segment::FeatureInvoke { invocation } if invocation.identity == other.identity)
);
assert!(matches!(&segments[3], Segment::Text { content } if content == " done"));
}
#[test]
fn feature_invocation_range_replacement_reports_each_removed_upload_and_stage() {
let invocation = parse_feature_invocation("/run(x)", 0, &descriptor(), "id")
.unwrap()
.invocation;
let mut input = InputBuffer::new();
input.replace_with_segments(&[
Segment::UploadedFile {
file: uploaded_file(),
},
Segment::text(" "),
Segment::FeatureInvoke { invocation },
]);
input.stage_attachment("id", "stage".into(), "a.txt".into());
input.replace_with_text_at(0, "replacement");
assert_eq!(input.take_removed_uploaded_files(), vec![uploaded_file()]);
assert_eq!(input.take_removed_attachment_stages(), vec!["stage"]);
assert_eq!(input.plain_text(), "replacement");
}
#[test]
fn feature_invocation_argument_positions_ignore_previous_named_arguments() {
let mut input = InputBuffer::new();
input.insert_str("/run(mode=safe, \"資料/a");
input.select_feature_invocation(0, descriptor());
let (_, _, prefix, context) = input
.pending_feature_argument_completion(&[descriptor()])
.unwrap();
assert_eq!(context.argument.as_deref(), Some("path"));
assert_eq!(prefix, "資料/a");
}
#[test]
fn feature_invocation_name_completion_replaces_suffix_at_middle_cursor() {
let mut app = App::new("test".into());
app.input.insert_str("/run() prose");
app.input.move_home();
app.input.move_right();
app.input.move_right();
app.refresh_completion();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "r".into(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(descriptor()),
..Default::default()
}],
});
app.apply_completion_text();
assert_eq!(app.input.plain_text(), "/run() prose");
}
#[test]
fn feature_invocation_discovery_expands_aliases_from_host_metadata() {
let mut app = App::new("test".into());
app.input.insert_str("/exe");
app.refresh_completion();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: CompletionKind::Feature,
prefix: "exe".into(),
context: None,
entries: vec![CompletionEntry {
value: "run".into(),
invocation: Some(descriptor()),
..Default::default()
}],
});
assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "execute");
app.apply_completion_text();
assert_eq!(app.input.plain_text(), "/execute(");
}
#[test]
fn feature_invocation_named_arguments_are_suggested_alongside_positional_values() {
let mut app = App::new("test".into());
select(&mut app, descriptor(), "run");
app.input.insert_str("pa");
app.refresh_completion();
let state = app.completion.as_ref().unwrap();
app.handle_worker_event(Event::Completions {
request_id: app.completion_request_id(),
kind: state.kind,
prefix: state.prefix.clone(),
context: state.context.clone(),
entries: Vec::new(),
});
assert_eq!(app.completion.as_ref().unwrap().entries[0].value, "path=");
app.apply_completion_text();
assert_eq!(app.input.plain_text(), "/run(path=");
app.input.insert_str("\"資料/path\")");
assert!(app.submit_input().is_none());
assert!(matches!(
app.input.submit_segments().as_slice(),
[Segment::FeatureInvoke { .. }]
));
}
+2
View File
@@ -13,6 +13,8 @@ mod console;
mod e2e_observer;
mod inline_terminal;
mod input;
#[cfg(test)]
mod invocation_tests;
pub mod keys;
mod markdown;
mod scroll;
+46 -8
View File
@@ -366,6 +366,23 @@ fn task_status_mark(status: TaskStatus) -> (&'static str, Style) {
/// taller than that single row); `Clear` blanks the cells first so
/// underlying text doesn't bleed through. The popup width matches the
/// widest visible label, capped at the input-area width.
fn completion_entry_label(entry: &CompletionEntry) -> String {
if let Some(invocation) = &entry.invocation {
format!(
"/{} — {} — {}",
entry.value,
invocation.description,
invocation.usage()
)
} else {
let suffix = if entry.is_dir { "/" } else { "" };
match &entry.description {
Some(description) => format!("{}{suffix} — {description}", entry.value),
None => format!("{}{suffix}", entry.value),
}
}
}
fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area: Rect) {
let entries = &state.entries;
if entries.is_empty() || input_area.y == 0 {
@@ -380,16 +397,10 @@ fn draw_completion_popup(frame: &mut Frame, state: &CompletionState, input_area:
};
let view_end = (view_start + visible).min(entries.len());
let label_for = |entry: &CompletionEntry| {
let mut s = entry.value.clone();
if entry.is_dir {
s.push('/');
}
s
};
let label_for = |entry: &CompletionEntry| completion_entry_label(entry);
let max_label = entries[view_start..view_end]
.iter()
.map(|e| label_for(e).chars().count() as u16)
.map(|e| label_for(e).width().min(u16::MAX as usize) as u16)
.max()
.unwrap_or(0);
let popup_w = max_label.saturating_add(2).min(input_area.width).max(1);
@@ -1341,6 +1352,10 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
),
),
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
Segment::FeatureInvoke { invocation } => (
Style::default().fg(Color::Yellow),
invocation.display_input(),
),
Segment::Flow { selector } => (
Style::default().fg(Color::Yellow),
format!("[Flow: {selector}]"),
@@ -1375,6 +1390,7 @@ fn segment_display_text(seg: &Segment) -> String {
file.availability.as_str()
),
Segment::FileRef { path } => format!("@{path}"),
Segment::FeatureInvoke { invocation } => invocation.display_input(),
Segment::Flow { selector } => format!("[Flow: {selector}]"),
Segment::Unknown => "[unknown segment]".to_owned(),
}
@@ -2186,6 +2202,28 @@ mod tests {
});
}
#[test]
fn feature_invocation_completion_labels_include_alias_description_usage_and_argument_help() {
let descriptor = crate::invocation_tests::descriptor();
let entry = CompletionEntry {
value: "execute".into(),
invocation: Some(descriptor.clone()),
..Default::default()
};
let label = completion_entry_label(&entry);
assert!(label.starts_with("/execute"));
assert!(label.contains(&descriptor.description));
assert!(label.contains(&descriptor.usage()));
assert_eq!(
completion_entry_label(&CompletionEntry {
value: "mode=".into(),
description: Some("execution mode".into()),
..Default::default()
}),
"mode= — execution mode"
);
}
#[test]
fn run_status_line_matches_console_metrics_and_spinner_frame() {
let now = Instant::now();
+167
View File
@@ -0,0 +1,167 @@
# Request-time checkout provider boundary
`workdir::checkout` (also crate-root reexports) exposes `CheckoutObservation`,
`CheckoutRequest`, `CheckoutOperation`, `CheckoutOutput`, and `CheckoutResult`.
`WorkdirSession::checkout_observe(path)` defaults to unsupported; execution also
fails closed by default. Observation is metadata-only, with no recursive tree
walk. Directory discovery/search remains the ordinary bounded List/Glob/Grep
provider engine. GrepResult.paths carries exact typed provider paths appearing
in the returned report, respecting its offset/limit/truncation; filenames are
never parsed from rendered text. List/Glob/Grep skip unrepresentable non-UTF8
paths, preserving valid Unicode exactly without normalization. Native callers supply their own attachment generation/namespace
fence; this provider validator is not a substitute for that fence.
* Read: line `offset`, `limit`, `max_bytes`; same `ReadResult` (bytes, start line,
total lines, full-source content hash, truncation) as normal Tools. Formatting
line numbers and recording the returned hash in caller read-history are caller
responsibilities. The validator is **not** permission to bypass read-before-write.
* Write: replace existing file with `content` and required `expected_hash`.
* Edit: existing UTF-8 file, unique `old_string` unless `replace_all`, required
`expected_hash`; same match/replacement/result-size checks as Tools.
* Create: target must be an observed directory; `path` is **checkout-root
relative**, strictly below target, not target-relative. No existing file can
be overwritten. Intermediate directories and destination must be writable.
* Every result observation describes the bound request target. Create returns
the updated **parent directory** observation from the retained pinned target,
not the created destination. Read returns the read state's observation.
## Shared implementation
`fs_operation::CheckedTarget` is an `FsAccessPolicy` adapter around pinned target,
save-parent, and provider-root descriptors. It calls the ordinary
`run_read_bounded`, `run_edit`, and `run_write` functions, including full-source
hashing, UTF-8/string matching, bounds, and atomic temp-save primitive; there is
no second content-operation engine. Shared text reads now check descriptor
metadata before/after reading. Validator bytes encode device/inode/size/mode/
mtime/ctime with nanoseconds, excluding atime and host paths; same-content inode
replacement is distinguishable. Successful native Read rechecks the named target
and pinned descriptor before returning hash and observation.
Local sessions serialize checked observe/execute and ordinary trait Read/Write/
Edit using one nonqueueing session lock. Expected validator comparison happens
inside that boundary. The adapter retains handles across reading and save,
rechecks named target + save-parent identity at save boundary, and writes a new
temporary inode then atomically renames it, isolating existing hardlink aliases.
External sessions retain the approved descriptor root after pathname rename or
replacement. Checkout resolution rejects symlinks, special files, mount crossings,
absolute/traversal paths and unavailable descriptor primitives, rather than
weakening confinement. This currently requires Linux `openat2` and `/proc/self/fd`.
Ordinary Tools preserve their existing explicit symlink-policy behavior.
Scoped sessions use their existing scope mutex, current validity/source
capability checks, provider authorization and write leases; Create also checks
intermediate paths. Observation capabilities are attenuated per path and live
write leases, never broadened to the whole attachment. ReadOnly removes mutation
capabilities and rejects mutations. Routed operations use existing active-op
accounting and detach fencing. Remote HTTP forwards exact operation/result types
and validates returned operation/path pairing. External DTOs validate checkout
path/validator/content/result bounds. No provider endpoint or host root is added
to the observation.
## Effects and limits
Absent paths during checkout observation return `NotFound` (including a missing
ancestor or a non-directory ancestor); native discovery can return no route.
An execution against a previously observed target that disappeared instead
returns `Conflict`, consistently at pinning, shared-engine, and final-check
boundaries. Conflict means stale validator/hash/name or existing Create destination before
save effects. `WorkdirError::OutcomeUnknown` has a separate sanitized wire code
`outcome_unknown` (HTTP 500), never an automatic retry. Partial parent creation,
possible commit failure, or failed post-save observation is outcome unknown.
Remote lost/malformed/mismatched mutation responses are also outcome unknown.
Create failures conservatively report unknown once parent creation may begin,
even if a particular failure happened without a persisted effect.
Bounds: path <=4096 UTF-8 bytes/128 components, validator <=256 bytes; Read source
64 MiB, retained response <=1 MiB (or stricter provider read limits), Write/Edit
preimage and result <=8 MiB (or stricter provider limit), Edit replacements
<=10,000 (or stricter provider limit). One checked operation per Local session,
with bounded retained handles and no waiting queue. Cancellation/deadline checks
use the existing provider guard, or a 30-second cooperative guard for Local
checkout operations. Blocking kernel/filesystem calls are not preempted; this
is **not** a hard wall-time guarantee for hung storage. Transport request-body
bounds before serde allocation remain the hosting transport's responsibility.
### External-editor limitations (not a magic advisory-lock guarantee)
No advisory `flock` is claimed to exclude arbitrary editors. Provider-session
serialization does not lock other sessions/processes. Metadata checks detect
concurrent reads/edits up to each check, but are not a transactional snapshot
against privileged actors forging metadata. Linux offers no atomic
compare-identity-and-replace-by-name primitive here: an uncooperative external
rename/change between the final validator check and rename can still race the
save. The pinned handles prevent following replacements/symlinks into arbitrary
objects, and deterministic substitution before the save boundary is rejected,
but the final syscall gap is an explicit limitation. Callers must not interpret
this as linearizability against all external filesystem mutations. Likewise,
post-save observation is the then-current named state, not an exclusion of an
external edit after save. Crash durability of parent directories is not claimed.
Host generation reuse, native permission identity, read-history updates, model
Operation publication, transport hosting (Runtime/Backend/External), and durable
Ticket/MR review remain the parent integration's responsibilities.
## Scoped checkout search boundary
`CheckoutSearchRequest::new(CheckoutSearchOperation::{List, Glob, Grep}(request))`
uses the current session's logical root as its result frame. The typed result is
`CheckoutSearchResult::{List, Glob, Grep}`. The request's internal context fields
are `scope_layers: Vec<Vec<WorkdirToolScopeRule>>` and `output_root: WorkdirPath`.
Rules union within each layer; layers intersect with each other, the current
provider authority, and the output-root boundary. Wrappers contribute their own
scope; caller-supplied layers can only narrow, never replace it. Each wrapper
translates all incoming coordinates once and forwards the composed output root.
Provider-rendered Grep text and every typed result path are already relative to
that final root; wrappers must not prefix cwd onto returned paths again.
Local/External use the same shared `run_list`, `run_glob`, `run_grep` engines.
`SearchAccess` gates metadata/content opens and enumeration. Shared List checks
`can_enumerate_directory`; Glob/Grep use the shared bounded descriptor walk and
owned `traversal_filter` policy to prune before descending/opening directories.
The walk anchors at the actual search directory, not at a broader provider
root. It counts rejected entries too, checks cancellation while scanning them,
and limits descent to 128 levels (bounded retained directory handles). Existing
provider-owned ignore loading, glob/type selection, grep context/offset/limit,
rendering, and source/result limits remain in their shared engines. Unreadable
ignore files are not opened to discover out-of-scope contents.
Descriptor search loads `.ignore`, `.gitignore` and an authorized directory's
`.git/info/exclude` through the same `FsAccessPolicy` opens as other sources.
It preserves anchored rules, negation, nearest-directory precedence within a
source class, `.ignore` precedence over Git rules, and nested repository
boundaries. Ignored directories are pruned before enumeration or loading their
own rules; explicitly selected ignored search roots are still traversable.
Only the current ancestor matchers are retained, never a second tree/store.
Glob requires an authorized `.git` directory/file marker for Git rules. Grep
retains the existing descriptor-provider contract of applying `.gitignore`
without a marker and rejecting unavailable/symlinked ignore opens; Glob skips
unavailable ignore sources like its ordinary walker. Scope-denied sources are
skipped by both before open. Bounds/cancellation failures are never retried.
Configuration discovery cannot expand provider authority. Ambient ancestors
outside the search result/provider root, global Git configuration/ignore files,
and linked-worktree `.git`/`commondir` administration pointers are not followed.
A `.git` file activates repository matching without treating it as a directory
or opening the host path it contains. In-root readable ignore rules remain
active, including inherited rules above a nested base. Descriptor ignore text
must be UTF-8, bounded to 1 MiB/file, 16 KiB/line and 8 MiB aggregate per search;
limit violations fail rather than returning silently incomplete filtering.
Grep's separate candidate-content budget remains 64 MiB. Ordinary unscoped
path-backed Tools keep their original discovery policy.
Scoped ordinary trait List/Glob/Grep use this boundary as well, returning
session-logical coordinates. Unscoped normal Tools continue using their original
trait/provider behavior. A provider without checkout search support returns
UnsupportedOperation; there is no broad traversal fallback. Native/scoped
searches intentionally fail closed on symlink paths, including logical-policy
aliases which normal unscoped Tools may follow. A visible symlink may therefore
cause a native List failure rather than expanding authority. Descriptor support
is currently Linux-only. Search remains request-time traversal, not a snapshot
or transactional exclusion of external editors.
HTTP operation/result variant: `CheckoutSearch`. External DTO validation rejects
absolute/out-of-root paths, empty scope layers, oversized paths/patterns/context/
results and invalid result paths. Generic request ceilings are 16 layers and 256
aggregate rules; External transport further limits aggregate rules to 64. Search
result limits follow the existing shared List/Glob/Grep wire bounds. Runtime and
Backend forwarding outside this crate remain parent integration work.
File diff suppressed because it is too large Load Diff
+817
View File
@@ -0,0 +1,817 @@
#![cfg(target_os = "linux")]
use crate::*;
use std::sync::Arc;
fn path(value: &str) -> WorkdirPath {
WorkdirPath::new(value).unwrap()
}
fn rule(value: &str, recursive: bool) -> WorkdirToolScopeRule {
WorkdirToolScopeRule {
target: path(value),
recursive,
permission: WorkdirToolScopePermission::Read,
symlink_policy: Default::default(),
}
}
fn scope(rules: Vec<WorkdirToolScopeRule>, cwd: &str) -> WorkdirToolScope {
WorkdirToolScope {
rules,
cwd: path(cwd),
command: false,
}
}
fn grep(mode: GrepOutputMode) -> GrepRequest {
GrepRequest {
path: WorkdirPath::root(),
pattern: "needle".into(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: mode,
offset: 0,
limit: 100,
}
}
fn fixture() -> tempfile::TempDir {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("child/keep")).unwrap();
std::fs::create_dir_all(root.path().join("child/denied/deep")).unwrap();
std::fs::write(root.path().join("child/a.txt"), "needle allowed direct").unwrap();
std::fs::write(
root.path().join("child/keep/yes.txt"),
"needle allowed subtree",
)
.unwrap();
std::fs::write(
root.path().join("child/denied/deep/secret.txt"),
"needle secret",
)
.unwrap();
// Opening this out-of-scope source would exhaust the shared grep source bound.
std::fs::File::create(root.path().join("child/denied/huge.txt"))
.unwrap()
.set_len(65 * 1024 * 1024)
.unwrap();
std::fs::write(root.path().join("outside.txt"), "needle outside").unwrap();
root
}
async fn verify_sparse(session: &dyn WorkdirSession) {
let listed = session
.list(ListRequest {
path: WorkdirPath::root(),
limit: 100,
})
.await
.unwrap();
assert_eq!(
listed
.entries
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>(),
vec![path("denied"), path("keep"), path("a.txt")]
);
let empty = session
.list(ListRequest {
path: path("denied"),
limit: 100,
})
.await
.unwrap();
assert!(empty.entries.is_empty());
assert_eq!(empty.total_entries, 0);
let globbed = session
.glob(GlobRequest {
path: WorkdirPath::root(),
pattern: "**/*.txt".into(),
limit: 100,
})
.await
.unwrap();
assert_eq!(globbed.paths, vec![path("a.txt"), path("keep/yes.txt")]);
for mode in [
GrepOutputMode::Content,
GrepOutputMode::Count,
GrepOutputMode::FilesWithMatches,
] {
let result = session.grep(grep(mode)).await.unwrap();
let mut paths = result.paths;
paths.sort_by(|a, b| a.as_str().cmp(b.as_str()));
assert_eq!(paths, vec![path("a.txt"), path("keep/yes.txt")]);
assert!(!result.output.contains("child/"));
assert!(!result.output.contains("secret"));
assert!(!result.output.contains("outside"));
assert_eq!(result.matched_files, 2);
}
let CheckoutSearchResult::List(native) = session
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
ListRequest {
path: WorkdirPath::root(),
limit: 100,
},
)))
.await
.unwrap()
else {
panic!()
};
assert_eq!(listed, native);
}
#[tokio::test]
async fn checkout_search_nested_sparse_nonrecursive_scopes_rebase_and_exclude_content() {
let root = fixture();
let source = Arc::new(LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
));
let parent = WorkdirToolBroker::new(source);
let first = parent
.scope(scope(vec![rule("child", true)], "child"))
.await
.unwrap();
let nested = first
.scope(scope(
vec![rule("child", false), rule("child/keep", true)],
"child",
))
.await
.unwrap();
verify_sparse(&*nested.tool_session()).await;
// Nested incoming layers cannot replace the session's own sparse layer.
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest {
path: WorkdirPath::root(),
pattern: "**/*.txt".into(),
limit: 100,
}));
request.scope_layers.push(vec![rule("", true)]);
let CheckoutSearchResult::Glob(result) = nested
.tool_session()
.checkout_search(request)
.await
.unwrap()
else {
panic!()
};
assert_eq!(result.paths, vec![path("a.txt"), path("keep/yes.txt")]);
nested.close().await.unwrap();
assert!(matches!(
nested
.tool_session()
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
ListRequest {
path: WorkdirPath::root(),
limit: 100
}
)))
.await,
Err(WorkdirError::SessionClosed)
));
}
#[tokio::test]
async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
let root = fixture();
let parent = WorkdirToolBroker::new(Arc::new(LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
)));
let first = parent
.scope(scope(vec![rule("child", true)], "child"))
.await
.unwrap();
// Two broker wrappers, not only leases that share a source.
let stacked = WorkdirToolBroker::new(first.tool_session());
let mut request =
CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(grep(GrepOutputMode::Content)));
*request.operation.path_mut() = path("keep");
request.output_root = path("keep");
request.scope_layers = vec![vec![rule("keep", false)]];
let CheckoutSearchResult::Grep(result) = stacked.checkout_search(request).await.unwrap() else {
panic!()
};
assert_eq!(result.paths, vec![path("yes.txt")]);
assert!(result.output.starts_with("yes.txt\n"));
// The outer context is relative to the inner session; no duplicated child/.
let CheckoutSearchResult::List(result) = stacked
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
ListRequest {
path: path("keep"),
limit: 100,
},
)))
.await
.unwrap()
else {
panic!()
};
assert_eq!(result.entries[0].path, path("keep/yes.txt"));
// A second actual scoped wrapper has its own cwd and nonrecursive layer.
// Logical rules avoid requesting resolved authorization from a wrapper
// that conservatively does not expose host-side resolution authority.
std::fs::create_dir(root.path().join("child/keep/deep")).unwrap();
std::fs::write(
root.path().join("child/keep/deep/secret"),
"needle hidden nested",
)
.unwrap();
let mut own_rule = rule("keep", false);
own_rule.symlink_policy = manifest::SymlinkPolicy::Logical;
let second = stacked.scope(scope(vec![own_rule], "keep")).await.unwrap();
let result = second
.tool_session()
.grep(grep(GrepOutputMode::Content))
.await
.unwrap();
assert_eq!(result.paths, vec![path("yes.txt")]);
assert!(result.output.starts_with("yes.txt\n"));
assert!(!result.output.contains("hidden nested"));
let entries = second
.tool_session()
.list(ListRequest {
path: WorkdirPath::root(),
limit: 100,
})
.await
.unwrap()
.entries;
assert_eq!(
entries
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>(),
vec![path("deep"), path("yes.txt")]
);
assert!(
second
.tool_session()
.list(ListRequest {
path: path("deep"),
limit: 100
})
.await
.unwrap()
.entries
.is_empty()
);
}
#[tokio::test]
async fn checkout_search_external_keeps_approved_root_and_sparse_authority() {
let root = fixture();
let external = LocalWorkdirSession::external_read_only(
Workdir::new("external-search"),
root.path(),
BoundedReadLimits::EXTERNAL_DEFAULT,
)
.unwrap();
let broker = WorkdirToolBroker::new(Arc::new(external));
let lease = broker
.scope(scope(
vec![rule("child", false), rule("child/keep", true)],
"child",
))
.await
.unwrap();
let parent = tempfile::tempdir().unwrap();
let approved = parent.path().join("approved");
std::fs::rename(root.path(), &approved).unwrap();
std::fs::create_dir(root.path()).unwrap();
std::fs::create_dir(root.path().join("child")).unwrap();
std::fs::write(
root.path().join("child/replacement.txt"),
"needle replacement",
)
.unwrap();
verify_sparse(&*lease.tool_session()).await;
}
#[tokio::test]
async fn checkout_search_scope_layers_intersect_before_list_and_grep() {
let root = fixture();
let source = LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
);
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
path: path("child"),
limit: 100,
}));
request.output_root = path("child");
request.scope_layers = vec![
vec![rule("child", false), rule("child/keep", true)],
vec![rule("child", false)],
];
let CheckoutSearchResult::List(result) = source.checkout_search(request.clone()).await.unwrap()
else {
panic!()
};
assert_eq!(result.entries.len(), 3);
request.operation = CheckoutSearchOperation::Grep(GrepRequest {
path: path("child"),
..grep(GrepOutputMode::Content)
});
let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else {
panic!()
};
assert_eq!(result.paths, vec![path("a.txt")]);
assert!(!result.output.contains("subtree"));
}
#[tokio::test]
async fn checkout_search_wire_dto_bounds_and_provider_capabilities() {
use crate::external::{ExternalWorkdirOperation, ExternalWorkdirOperationResult};
use crate::http::{WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Output};
let root = fixture();
let source = LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
);
let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
path: path("child/keep"),
limit: 100,
}));
let wire = Op::CheckoutSearch(request.clone());
assert!(ExternalWorkdirOperation::try_from(wire.clone()).is_ok());
assert_eq!(
serde_json::from_value::<Op>(serde_json::to_value(&wire).unwrap()).unwrap(),
wire
);
let result = crate::http::dispatch_workdir_session_operation(&source, wire)
.await
.unwrap();
assert!(matches!(
&result,
Output::CheckoutSearch(CheckoutSearchResult::List(_))
));
assert!(ExternalWorkdirOperationResult::try_from(result.clone()).is_ok());
assert_eq!(
serde_json::from_value::<Output>(serde_json::to_value(&result).unwrap()).unwrap(),
result
);
let mut bad = request.clone();
bad.output_root = WorkdirPath::new_scoped("/host").unwrap();
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let mut bad = request.clone();
bad.scope_layers = vec![vec![rule("", true)]; 17];
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let mut bad = request.clone();
bad.scope_layers = vec![vec![rule("", true); 257]];
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let mut bad = request.clone();
bad.scope_layers = vec![vec![rule("", true); 65]];
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let mut bad = request;
bad.scope_layers = vec![Vec::new()];
assert!(ExternalWorkdirOperation::try_from(Op::CheckoutSearch(bad)).is_err());
let bad_result = Output::CheckoutSearch(CheckoutSearchResult::Glob(GlobResult {
paths: vec![WorkdirPath::new_scoped("/host").unwrap()],
truncated: false,
}));
assert!(ExternalWorkdirOperationResult::try_from(bad_result).is_err());
}
fn write_ignore_fixture(root: &std::path::Path, relative: &str, contents: &str) {
let file = root.join(relative);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, contents).unwrap();
}
async fn verify_ignore_search_parity(session: &dyn WorkdirSession, base: &str, expected: &[&str]) {
let request = GlobRequest {
path: path(base),
pattern: "**/*.txt".into(),
limit: 100,
};
// On a Local session this is the actual ordinary WalkBuilder-backed path,
// not a broker wrapper that would route both calls through checkout_search.
let ordinary = session.glob(request.clone()).await.unwrap();
assert_eq!(
ordinary.paths,
expected.iter().map(|value| path(value)).collect::<Vec<_>>(),
"ordinary Glob fixture semantics at {base:?}"
);
assert!(!ordinary.truncated);
let CheckoutSearchResult::Glob(native) = session
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
request,
)))
.await
.unwrap()
else {
panic!("expected native Glob result")
};
assert_eq!(native, ordinary, "native Glob parity at {base:?}");
// Content mode renders paths in sorted order, avoiding dependence on the
// different walkers' visitation order. All fixture content has one match.
let request = GrepRequest {
path: path(base),
..grep(GrepOutputMode::Content)
};
let ordinary = session.grep(request.clone()).await.unwrap();
assert_eq!(
ordinary.paths,
expected.iter().map(|value| path(value)).collect::<Vec<_>>(),
"ordinary Grep fixture semantics at {base:?}"
);
assert_eq!(ordinary.matched_files, expected.len());
assert_eq!(ordinary.match_count, expected.len());
assert!(!ordinary.truncated);
let CheckoutSearchResult::Grep(native) = session
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
request,
)))
.await
.unwrap()
else {
panic!("expected native Grep result")
};
assert_eq!(native, ordinary, "native Grep parity at {base:?}");
}
#[tokio::test]
async fn checkout_search_local_glob_inherits_root_and_nested_ignore_rules() {
// Exercise the two ignore sources independently so rule precedence cannot
// conceal either missing source. A .git marker activates .gitignore rules.
for ignore_name in [".ignore", ".gitignore"] {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join(".git")).unwrap();
write_ignore_fixture(
root.path(),
ignore_name,
"root-only.txt\n/root-anchored.txt\n*.drop.txt\n!keep.drop.txt\ninherited.txt\npruned/\n",
);
write_ignore_fixture(
root.path(),
&format!("nested/{ignore_name}"),
"/nested-anchored.txt\nnested-only.txt\n!inherited.txt\nignored-dir/\n!ignored-dir/rescue.txt\n",
);
for relative in [
"visible.txt",
"root-only.txt",
"root-anchored.txt",
"discard.drop.txt",
"keep.drop.txt",
"inherited.txt",
"pruned/rescue.txt",
"nested/visible.txt",
"nested/root-only.txt",
"nested/root-anchored.txt",
"nested/nested-only.txt",
"nested/nested-anchored.txt",
"nested/discard.drop.txt",
"nested/keep.drop.txt",
"nested/inherited.txt",
"nested/pruned/rescue.txt",
"nested/ignored-dir/rescue.txt",
"nested/deep/nested-anchored.txt",
] {
write_ignore_fixture(root.path(), relative, "needle fixture\n");
}
let source = LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
);
verify_ignore_search_parity(
&source,
"",
&[
"keep.drop.txt",
"nested/deep/nested-anchored.txt",
"nested/inherited.txt",
"nested/keep.drop.txt",
"nested/root-anchored.txt",
"nested/visible.txt",
"visible.txt",
],
)
.await;
// Starting explicitly below the provider root must still inherit the
// root's rules and retain provider-relative (not base-relative) paths.
verify_ignore_search_parity(
&source,
"nested",
&[
"nested/deep/nested-anchored.txt",
"nested/inherited.txt",
"nested/keep.drop.txt",
"nested/root-anchored.txt",
"nested/visible.txt",
],
)
.await;
verify_ignore_search_parity(&source, "nested/deep", &["nested/deep/nested-anchored.txt"])
.await;
}
}
#[tokio::test]
async fn checkout_search_local_glob_preserves_ignore_source_precedence_across_levels() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join(".git")).unwrap();
write_ignore_fixture(
root.path(),
".ignore",
"priority.txt\n!ignore-wins.txt\nroot-ignore.txt\n",
);
write_ignore_fixture(
root.path(),
".gitignore",
"ignore-wins.txt\nrevived-git.txt\n",
);
write_ignore_fixture(root.path(), "nested/.ignore", "!root-ignore.txt\n");
write_ignore_fixture(
root.path(),
"nested/.gitignore",
"!priority.txt\n!revived-git.txt\n",
);
for relative in [
"priority.txt",
"ignore-wins.txt",
"root-ignore.txt",
"revived-git.txt",
"nested/priority.txt",
"nested/ignore-wins.txt",
"nested/root-ignore.txt",
"nested/revived-git.txt",
] {
write_ignore_fixture(root.path(), relative, "needle fixture\n");
}
let source = LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
);
// .ignore outranks .gitignore even when the .gitignore is deeper. Within
// the same source kind, a deeper negation can override an ancestor rule.
verify_ignore_search_parity(
&source,
"",
&[
"ignore-wins.txt",
"nested/ignore-wins.txt",
"nested/revived-git.txt",
"nested/root-ignore.txt",
],
)
.await;
verify_ignore_search_parity(
&source,
"nested",
&[
"nested/ignore-wins.txt",
"nested/revived-git.txt",
"nested/root-ignore.txt",
],
)
.await;
}
#[tokio::test]
async fn checkout_search_scoped_broker_skips_denied_ancestor_ignore_files() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join(".git")).unwrap();
for (relative, contents) in [
(".ignore", "root-ignore-visible.txt\n"),
(".gitignore", "root-git-visible.txt\n"),
("parent/.ignore", "parent-ignore-visible.txt\n"),
("parent/.gitignore", "parent-git-visible.txt\n"),
("parent/nested/.ignore", "allowed-ignore.txt\n"),
] {
write_ignore_fixture(root.path(), relative, contents);
}
for name in [
"root-ignore-visible.txt",
"root-git-visible.txt",
"parent-ignore-visible.txt",
"parent-git-visible.txt",
"allowed-ignore.txt",
] {
write_ignore_fixture(
root.path(),
&format!("parent/nested/{name}"),
"needle fixture\n",
);
}
let source = Arc::new(LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
));
// Without a scope layer, ordinary Local traversal really does inherit
// these ancestor rules. Each denied source suppresses a different file.
assert!(
source
.glob(GlobRequest {
path: path("parent/nested"),
pattern: "**/*.txt".into(),
limit: 100,
})
.await
.unwrap()
.paths
.is_empty()
);
// Make accidental consumption observable even if a matcher were to drop
// the valid patterns: these denied sparse sources exceed the shared Grep
// source budget. The allowed subtree remains tiny.
for relative in [
".ignore",
".gitignore",
"parent/.ignore",
"parent/.gitignore",
] {
std::fs::OpenOptions::new()
.write(true)
.open(root.path().join(relative))
.unwrap()
.set_len(65 * 1024 * 1024)
.unwrap();
}
let broker = WorkdirToolBroker::new(source.clone());
let lease = broker
.scope(scope(vec![rule("parent/nested", true)], "parent/nested"))
.await
.unwrap();
// Broker .glob/.grep use the native path under a scope. Compare those
// public entry points with explicit checkout_search and assert rebasing.
verify_ignore_search_parity(
&*lease.tool_session(),
"",
&[
"parent-git-visible.txt",
"parent-ignore-visible.txt",
"root-git-visible.txt",
"root-ignore-visible.txt",
],
)
.await;
// Also exercise a Local native request with an unre-based provider root:
// allowed descendants do not grant authority to ancestor ignore sources.
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(GlobRequest {
path: path("parent/nested"),
pattern: "**/*.txt".into(),
limit: 100,
}));
request.scope_layers = vec![vec![rule("parent/nested", true)]];
let CheckoutSearchResult::Glob(result) = source.checkout_search(request).await.unwrap() else {
panic!("expected native Glob result")
};
assert_eq!(
result.paths,
vec![
path("parent/nested/parent-git-visible.txt"),
path("parent/nested/parent-ignore-visible.txt"),
path("parent/nested/root-git-visible.txt"),
path("parent/nested/root-ignore-visible.txt"),
]
);
assert!(!result.truncated);
lease.close().await.unwrap();
}
#[tokio::test]
async fn checkout_search_native_skips_symlinked_ignore_sources_without_outside_effects() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join(".git")).unwrap();
std::fs::create_dir(root.path().join("nested")).unwrap();
// Following any of these sources would hide an otherwise visible file.
// The native descriptor provider must safely skip rejected ignore opens,
// including when the source belongs to an explicitly selected base.
for (relative, hidden) in [
(".ignore", "root-ignore-visible.txt"),
(".gitignore", "root-git-visible.txt"),
("nested/.ignore", "nested-ignore-visible.txt"),
("nested/.gitignore", "nested-git-visible.txt"),
] {
let target = outside.path().join(relative.replace('/', "-"));
let contents = format!("{hidden}\n");
std::fs::write(&target, &contents).unwrap();
std::os::unix::fs::symlink(&target, root.path().join(relative)).unwrap();
let visible = if relative.starts_with("nested/") {
format!("nested/{hidden}")
} else {
hidden.to_string()
};
write_ignore_fixture(root.path(), &visible, "needle fixture\n");
}
write_ignore_fixture(outside.path(), "secret.txt", "needle outside\n");
let source = LocalWorkdirSession::new(
manifest::Scope::writable(root.path()).unwrap(),
root.path().to_path_buf(),
);
for (base, expected) in [
(
"",
vec![
path("nested/nested-git-visible.txt"),
path("nested/nested-ignore-visible.txt"),
path("root-git-visible.txt"),
path("root-ignore-visible.txt"),
],
),
(
"nested",
vec![
path("nested/nested-git-visible.txt"),
path("nested/nested-ignore-visible.txt"),
],
),
] {
let CheckoutSearchResult::Glob(result) = source
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Glob(
GlobRequest {
path: path(base),
pattern: "**/*.txt".into(),
limit: 100,
},
)))
.await
.unwrap()
else {
panic!("expected native Glob result")
};
assert_eq!(result.paths, expected);
assert!(!result.truncated);
// Grep's existing descriptor contract rejects an unauthorized ignore
// open, rather than ignoring its error as Glob does. Both behaviors
// preserve confinement; do not relax Grep merely to match Glob.
let error = source
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(
GrepRequest {
path: path(base),
..grep(GrepOutputMode::Content)
},
)))
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::OutOfScope(_)));
}
for (relative, hidden) in [
(".ignore", "root-ignore-visible.txt"),
(".gitignore", "root-git-visible.txt"),
("nested/.ignore", "nested-ignore-visible.txt"),
("nested/.gitignore", "nested-git-visible.txt"),
] {
assert!(
std::fs::symlink_metadata(root.path().join(relative))
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(
std::fs::read_to_string(outside.path().join(relative.replace('/', "-"))).unwrap(),
format!("{hidden}\n")
);
}
assert_eq!(
std::fs::read_to_string(outside.path().join("secret.txt")).unwrap(),
"needle outside\n"
);
}
#[tokio::test]
async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_escape() {
let root = fixture();
let current = manifest::Scope::from_config(&manifest::ScopeConfig {
allow: vec![manifest::ScopeRule {
target: root.path().join("child"),
permission: manifest::Permission::Read,
recursive: false,
symlink_policy: Default::default(),
}],
deny: Vec::new(),
})
.unwrap();
let source = LocalWorkdirSession::new(current, root.path().to_path_buf());
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest {
path: path("child"),
..grep(GrepOutputMode::Content)
}));
request.output_root = path("child");
// A broad caller layer cannot enlarge the provider's nonrecursive authority.
request.scope_layers = vec![vec![rule("", true)]];
let CheckoutSearchResult::Grep(result) = source.checkout_search(request).await.unwrap() else {
panic!()
};
assert_eq!(result.paths, vec![path("a.txt")]);
let CheckoutSearchResult::List(result) = source
.checkout_search(CheckoutSearchRequest {
operation: CheckoutSearchOperation::List(ListRequest {
path: path("child/denied"),
limit: 100,
}),
output_root: path("child"),
scope_layers: Vec::new(),
})
.await
.unwrap()
else {
panic!()
};
assert!(result.entries.is_empty());
std::os::unix::fs::symlink("../outside.txt", root.path().join("child/link.txt")).unwrap();
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::Grep(GrepRequest {
path: path("child/link.txt"),
..grep(GrepOutputMode::Content)
}));
request.output_root = path("child");
assert!(source.checkout_search(request).await.is_err());
}
+103
View File
@@ -149,6 +149,56 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
WorkdirSessionOperation::ScopeRulesOverlap(request) => {
is_root_relative(&request.left.target) && is_root_relative(&request.right.target)
}
WorkdirSessionOperation::CheckoutSearch(request) => {
request.validate().is_ok()
&& request.scope_layers.iter().map(Vec::len).sum::<usize>()
<= MAX_EXTERNAL_SCOPE_RULES
&& validate_external_operation(&match &request.operation {
crate::CheckoutSearchOperation::List(r) => {
WorkdirSessionOperation::List(r.clone())
}
crate::CheckoutSearchOperation::Glob(r) => {
WorkdirSessionOperation::Glob(r.clone())
}
crate::CheckoutSearchOperation::Grep(r) => {
WorkdirSessionOperation::Grep(r.clone())
}
})
.is_ok()
}
WorkdirSessionOperation::CheckoutObserve(path) => {
is_root_relative(path) && crate::checkout::validate_path(path).is_ok()
}
WorkdirSessionOperation::CheckoutExecute(request) => {
is_root_relative(&request.target)
&& crate::checkout::validate_path(&request.target).is_ok()
&& !request.validator.is_empty()
&& request.validator.len() <= 256
&& match &request.operation {
crate::CheckoutOperation::Read {
limit, max_bytes, ..
} => {
*limit <= 1_000_000
&& *max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
}
crate::CheckoutOperation::Write { content, .. } => {
content.len() <= MAX_EXTERNAL_WRITE_BYTES
}
crate::CheckoutOperation::Edit {
old_string,
new_string,
..
} => old_string
.len()
.checked_add(new_string.len())
.is_some_and(|size| size <= MAX_EXTERNAL_WRITE_BYTES),
crate::CheckoutOperation::Create { path, content } => {
is_root_relative(path)
&& crate::checkout::create_relative_path(&request.target, path).is_ok()
&& content.len() <= MAX_EXTERNAL_WRITE_BYTES
}
}
}
WorkdirSessionOperation::Stat(request) => is_root_relative(&request.path),
WorkdirSessionOperation::Read(request) => {
is_root_relative(&request.path)
@@ -272,10 +322,59 @@ fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> boo
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
}
fn valid_checkout_observation(observation: &crate::CheckoutObservation) -> bool {
is_root_relative(&observation.path)
&& !observation.validator.is_empty()
&& observation.validator.len() <= 256
&& matches!(
observation.kind,
crate::EntryKind::File | crate::EntryKind::Directory
)
&& (observation.kind == crate::EntryKind::Directory
|| observation.size <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes)
}
fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
let result_is_valid = match result {
WorkdirSessionOperationResult::AuthorizeScope
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
WorkdirSessionOperationResult::CheckoutSearch(result) => {
validate_external_result(&match result {
crate::CheckoutSearchResult::List(r) => {
WorkdirSessionOperationResult::List(r.clone())
}
crate::CheckoutSearchResult::Glob(r) => {
WorkdirSessionOperationResult::Glob(r.clone())
}
crate::CheckoutSearchResult::Grep(r) => {
WorkdirSessionOperationResult::Grep(r.clone())
}
})
.is_ok()
}
WorkdirSessionOperationResult::CheckoutObserve(observation) => {
valid_checkout_observation(observation)
}
WorkdirSessionOperationResult::CheckoutExecute(result) => {
valid_checkout_observation(&result.observation)
&& match &result.output {
crate::CheckoutOutput::Read(read) => {
read.path == result.observation.path
&& validate_external_result(&WorkdirSessionOperationResult::Read(
read.clone(),
))
.is_ok()
}
crate::CheckoutOutput::Write(write) => validate_external_result(
&WorkdirSessionOperationResult::Write(write.clone()),
)
.is_ok(),
crate::CheckoutOutput::Edit(edit) => {
validate_external_result(&WorkdirSessionOperationResult::Edit(edit.clone()))
.is_ok()
}
}
}
WorkdirSessionOperationResult::Stat(result) => is_root_relative(&result.path),
WorkdirSessionOperationResult::Read(result) => {
is_root_relative(&result.path)
@@ -307,6 +406,9 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
result.output.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
&& result.match_count <= MAX_EXTERNAL_RESULT_ITEMS
&& result.matched_files <= MAX_EXTERNAL_RESULT_ITEMS
&& result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
&& result.paths.iter().all(is_root_relative)
&& result_paths_fit(&result.paths)
}
WorkdirSessionOperationResult::Write(result) => {
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
@@ -821,6 +923,7 @@ mod tests {
assert!(ExternalWorkdirOperation::try_from(unbounded).is_err());
let oversized_result = WorkdirSessionOperationResult::Grep(GrepResult {
paths: Vec::new(),
output: "x".repeat(BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1),
match_count: 1,
matched_files: 1,
+136 -1
View File
@@ -64,6 +64,9 @@ pub struct OpenWorkdirSessionResponse {
pub enum WorkdirSessionOperation {
AuthorizeScope(WorkdirScopeAuthorizationRequest),
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
CheckoutSearch(crate::CheckoutSearchRequest),
CheckoutObserve(crate::WorkdirPath),
CheckoutExecute(crate::CheckoutRequest),
Stat(StatRequest),
Read(ReadRequest),
ReadBytes(ReadBytesRequest),
@@ -96,6 +99,9 @@ pub struct WorkdirSessionOperationRequest {
pub enum WorkdirSessionOperationResult {
AuthorizeScope,
ScopeRulesOverlap { overlaps: bool },
CheckoutSearch(crate::CheckoutSearchResult),
CheckoutObserve(crate::CheckoutObservation),
CheckoutExecute(crate::CheckoutResult),
Stat(StatResult),
Read(ReadResult),
ReadBytes(ReadBytesResult),
@@ -129,6 +135,18 @@ pub async fn dispatch_workdir_session_operation(
.scope_rules_overlap(request)
.await
.map(|overlaps| WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps }),
WorkdirSessionOperation::CheckoutSearch(request) => session
.checkout_search(request)
.await
.map(WorkdirSessionOperationResult::CheckoutSearch),
WorkdirSessionOperation::CheckoutObserve(path) => session
.checkout_observe(path)
.await
.map(WorkdirSessionOperationResult::CheckoutObserve),
WorkdirSessionOperation::CheckoutExecute(request) => session
.checkout_execute(request)
.await
.map(WorkdirSessionOperationResult::CheckoutExecute),
WorkdirSessionOperation::Stat(request) => session
.stat(request)
.await
@@ -186,6 +204,7 @@ pub async fn dispatch_workdir_session_operation(
pub enum WorkdirTransportErrorCode {
NotFound,
Conflict,
OutcomeUnknown,
Unsupported,
InvalidRequest,
Denied,
@@ -208,6 +227,7 @@ impl WorkdirTransportErrorCode {
match self {
Self::NotFound => "not_found",
Self::Conflict => "conflict",
Self::OutcomeUnknown => "outcome_unknown",
Self::Unsupported => "unsupported",
Self::InvalidRequest => "invalid_request",
Self::Denied => "denied",
@@ -239,7 +259,7 @@ impl WorkdirTransportErrorCode {
| Self::IsDirectory
| Self::SymlinkDirectoryNotTraversed => 400,
Self::Unavailable => 503,
Self::Io | Self::Internal => 500,
Self::Io | Self::Internal | Self::OutcomeUnknown => 500,
Self::Transport => 502,
}
}
@@ -256,6 +276,10 @@ impl WorkdirTransportError {
pub fn from_workdir_error(error: &WorkdirError) -> Self {
use WorkdirTransportErrorCode as Code;
let (code, message) = match error {
WorkdirError::OutcomeUnknown(_) => (
Code::OutcomeUnknown,
"Workdir operation may have effects; inspect before retrying",
),
WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"),
WorkdirError::Conflict(_) => (
Code::Conflict,
@@ -330,6 +354,9 @@ impl WorkdirTransportError {
match self.code {
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
Code::Conflict => WorkdirError::Conflict(self.message),
Code::OutcomeUnknown => WorkdirError::OutcomeUnknown(
"Workdir operation may have effects; inspect before retrying".into(),
),
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
Code::Denied => WorkdirError::Denied(self.message),
@@ -564,6 +591,114 @@ mod client {
}
}
async fn checkout_search(
&self,
request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
request.validate()?;
let expected = request.operation.capability();
match self
.operate(WorkdirSessionOperation::CheckoutSearch(request))
.await?
{
WorkdirSessionOperationResult::CheckoutSearch(result)
if matches!(
(&result, expected),
(
crate::CheckoutSearchResult::List(_),
crate::WorkdirSessionCapability::Read
) | (
crate::CheckoutSearchResult::Glob(_),
crate::WorkdirSessionCapability::Glob
) | (
crate::CheckoutSearchResult::Grep(_),
crate::WorkdirSessionCapability::Grep
)
) =>
{
crate::external::ExternalWorkdirOperationResult::try_from(
WorkdirSessionOperationResult::CheckoutSearch(result.clone()),
)
.map_err(|_| Self::mismatch("bounded checkout_search"))?;
Ok(result)
}
_ => Err(Self::mismatch("checkout_search")),
}
}
async fn checkout_observe(
&self,
path: crate::WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
match self
.operate(WorkdirSessionOperation::CheckoutObserve(path.clone()))
.await?
{
WorkdirSessionOperationResult::CheckoutObserve(mut result)
if result.path == path
&& !result.validator.is_empty()
&& result.validator.len() <= 256 =>
{
result.capabilities = result.capabilities.intersection(self.capabilities());
Ok(result)
}
_ => Err(Self::mismatch("checkout_observe")),
}
}
async fn checkout_execute(
&self,
request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read;
let expected_path = request.target.clone();
let expected_output = request.operation.capability();
match self
.operate(WorkdirSessionOperation::CheckoutExecute(request))
.await
{
Ok(WorkdirSessionOperationResult::CheckoutExecute(mut result))
if result.observation.path == expected_path
&& !result.observation.validator.is_empty()
&& result.observation.validator.len() <= 256
&& match (&result.output, expected_output) {
(
crate::CheckoutOutput::Read(read),
crate::WorkdirSessionCapability::Read,
) => {
read.path == expected_path
&& read.bytes.len()
<= crate::BoundedReadLimits::EXTERNAL_DEFAULT
.max_response_bytes
}
(
crate::CheckoutOutput::Write(_),
crate::WorkdirSessionCapability::Write,
)
| (
crate::CheckoutOutput::Edit(_),
crate::WorkdirSessionCapability::Edit,
) => true,
_ => false,
} =>
{
result.observation.capabilities = result
.observation
.capabilities
.intersection(self.capabilities());
Ok(result)
}
Ok(_) if mutation => Err(WorkdirError::OutcomeUnknown(
"mismatched response after checkout mutation".into(),
)),
Ok(_) => Err(Self::mismatch("checkout_execute")),
Err(WorkdirError::Unavailable(_) | WorkdirError::Transport(_)) if mutation => {
Err(WorkdirError::OutcomeUnknown(
"checkout transport failed; effects may have occurred".into(),
))
}
Err(error) => Err(error),
}
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
WorkdirSessionOperationResult::Stat(result) => Ok(result),
+36
View File
@@ -5,6 +5,11 @@
//! bound to one Worker. Tools consume sessions; they do not own Workdir
//! materialization or cleanup.
pub mod checkout;
pub use checkout::{
CheckoutObservation, CheckoutOperation, CheckoutOutput, CheckoutRequest, CheckoutResult,
CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult,
};
pub mod external;
pub mod http;
mod local;
@@ -13,6 +18,8 @@ mod router;
mod scope;
pub mod workspace;
#[cfg(test)]
mod checkout_search_tests;
#[cfg(test)]
mod external_local_tests;
@@ -219,6 +226,31 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
))
}
async fn checkout_search(
&self,
_request: CheckoutSearchRequest,
) -> Result<CheckoutSearchResult, WorkdirError> {
Err(WorkdirError::UnsupportedOperation(
"scoped checkout traversal".into(),
))
}
async fn checkout_observe(
&self,
_path: WorkdirPath,
) -> Result<CheckoutObservation, WorkdirError> {
Err(WorkdirError::UnsupportedOperation(
"checkout observation".into(),
))
}
async fn checkout_execute(
&self,
_request: CheckoutRequest,
) -> Result<CheckoutResult, WorkdirError> {
Err(WorkdirError::UnsupportedOperation(
"checked checkout execution".into(),
))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
async fn read_bytes(
@@ -289,6 +321,10 @@ pub enum WorkdirError {
#[error("{0}")]
Conflict(String),
/// Effects may have happened. Do not automatically retry the operation.
#[error("Workdir operation outcome is unknown: {0}")]
OutcomeUnknown(String),
#[error("unknown Workdir session command: {0}")]
UnknownCommand(String),
+527 -8
View File
@@ -204,7 +204,7 @@ impl CommandTelemetry {
}
}
#[derive(Debug)]
#[derive(Debug, Clone)]
struct ScopeAccess {
scope: Arc<Scope>,
root: PathBuf,
@@ -314,13 +314,12 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
"path is not a directory",
));
}
let root = self
.pinned_root
.as_ref()
.expect("confined traversal requires a pinned root")
.try_clone()?;
let path = PathBuf::from(format!("/proc/self/fd/{}", root.as_raw_fd()));
return Ok(Some(fs_operation::FsTraversalRoot::new(path, root)));
let path = PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd()));
return Ok(Some(fs_operation::FsTraversalRoot::new_at(
path,
directory,
resolved.to_path_buf(),
)));
}
#[cfg(not(target_os = "linux"))]
Err(std::io::Error::new(
@@ -443,6 +442,118 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
}
}
/// A read-only, conjunctively narrowed descriptor policy for native searches.
#[derive(Debug, Clone)]
struct SearchAccess {
inner: ScopeAccess,
layers: Vec<Vec<crate::WorkdirToolScopeRule>>,
output_root: PathBuf,
source_allows: Arc<Vec<manifest::ScopeRule>>,
}
impl SearchAccess {
fn logical_path(&self, path: &Path) -> Option<WorkdirPath> {
WorkdirPath::new(path.strip_prefix(&self.inner.root).ok()?.to_str()?).ok()
}
fn read_allowed(&self, logical: &Path, resolved: &Path) -> bool {
use fs_operation::FsAccessPolicy;
let Some(path) = self.logical_path(logical) else {
return false;
};
logical.starts_with(&self.output_root)
&& self.inner.is_readable_paths(logical, resolved)
&& self.layers.iter().all(|layer| {
layer.iter().any(|rule| {
crate::scope::rule_allows_path(rule, &path, WorkdirToolScopePermission::Read)
})
})
}
fn enumeration_allowed(&self, logical: &Path, resolved: &Path) -> bool {
self.read_allowed(logical, resolved)
&& self.source_allows.iter().any(|rule| {
if rule.recursive {
logical.starts_with(&rule.target) || rule.target.starts_with(logical)
} else {
logical == rule.target || rule.target.starts_with(logical)
}
})
&& self.layers.iter().all(|layer| {
layer.iter().any(|rule| {
let target = self.inner.root.join(rule.target.as_str());
if rule.recursive {
logical.starts_with(&target) || target.starts_with(logical)
} else {
logical == target || target.starts_with(logical)
}
})
})
}
fn require_read(&self, logical: &Path, resolved: &Path) -> std::io::Result<()> {
if self.read_allowed(logical, resolved) {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"checkout search path denied",
))
}
}
}
impl fs_operation::FsAccessPolicy for SearchAccess {
fn is_readable(&self, path: &Path) -> bool {
self.read_allowed(path, path)
}
fn is_writable(&self, _: &Path) -> bool {
false
}
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
self.read_allowed(logical, resolved)
}
fn can_enumerate_directory(&self, logical: &Path, resolved: &Path) -> bool {
self.enumeration_allowed(logical, resolved)
}
fn traversal_filter(&self) -> Option<fs_operation::FsTraversalFilter> {
let policy = self.clone();
Some(Arc::new(move |path, directory| {
if directory {
policy.enumeration_allowed(path, path)
} else {
policy.read_allowed(path, path)
}
}))
}
fn check_cancelled(&self) -> std::io::Result<()> {
self.inner.check_cancelled()
}
fn resolve_access_path(&self, logical: &Path) -> std::io::Result<PathBuf> {
self.inner.resolve_access_path(logical)
}
fn open_traversal_root(
&self,
logical: &Path,
resolved: &Path,
) -> std::io::Result<Option<fs_operation::FsTraversalRoot>> {
self.require_read(logical, resolved)?;
self.inner.open_traversal_root(logical, resolved)
}
fn open_read_file(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::File> {
self.require_read(logical, resolved)?;
self.inner.open_read_file(logical, resolved)
}
fn read_metadata(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::Metadata> {
self.require_read(logical, resolved)?;
self.inner.read_metadata(logical, resolved)
}
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
if !self.enumeration_allowed(logical, resolved) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"checkout directory enumeration denied",
));
}
self.inner.open_read_dir(logical, resolved)
}
}
fn path_sets_overlap(
left: &Path,
left_recursive: bool,
@@ -491,6 +602,7 @@ struct LocalWorkdirSessionInner {
reject_symlinks: bool,
closed: AtomicBool,
close_lock: Mutex<()>,
checkout_lock: StdMutex<()>,
next_command_id: AtomicU64,
commands: Mutex<HashMap<String, LocalCommand>>,
command_telemetry: CommandTelemetry,
@@ -911,6 +1023,7 @@ impl LocalWorkdirSession {
reject_symlinks,
closed: AtomicBool::new(false),
close_lock: Mutex::new(()),
checkout_lock: StdMutex::new(()),
next_command_id: AtomicU64::new(1),
commands: Mutex::new(HashMap::new()),
command_telemetry: CommandTelemetry::new(),
@@ -1129,6 +1242,69 @@ impl LocalWorkdirSession {
Ok(())
}
fn checkout_access(&self) -> ScopeAccess {
let guard = self
.operation_guard
.clone()
.unwrap_or_else(|| OperationGuard {
cancelled: Arc::new(AtomicBool::new(false)),
deadline: Instant::now() + Duration::from_secs(30),
});
ScopeAccess::new(
self.inner.scope.snapshot(),
&self.inner.root,
self.inner.pinned_root.clone(),
self.inner.reject_symlinks,
Some(guard),
)
}
fn checkout_root(&self) -> Result<Arc<std::fs::File>, WorkdirError> {
match &self.inner.pinned_root {
Some(root) => Ok(root.clone()),
None => fs_operation::open_root_no_symlinks(&self.inner.root)
.map(Arc::new)
.map_err(|e| WorkdirError::io(Path::new("<checkout-root>"), e)),
}
}
fn checkout_observation(
&self,
path: WorkdirPath,
pinned: &fs_operation::CheckedTarget<'_>,
) -> Result<crate::CheckoutObservation, WorkdirError> {
let metadata = pinned
.metadata()
.map_err(|e| WorkdirError::io(Path::new(path.as_str()), e))?;
if fs_operation::identity_validator(&metadata).map_err(|e| checkout_io(&path, e))?
!= pinned.validator()
{
return Err(WorkdirError::Conflict(
"checkout changed during observation".into(),
));
}
let access = self.scope_access();
let absolute = self.resolve(&path);
let capabilities = if manifest::Scope::is_writable(&access.scope, &absolute) {
self.capabilities()
.intersection(WorkdirSessionCapabilities::READ_WRITE)
} else {
self.capabilities()
.intersection(WorkdirSessionCapabilities::READ_ONLY)
};
Ok(crate::CheckoutObservation {
path,
kind: if metadata.is_dir() {
crate::EntryKind::Directory
} else {
crate::EntryKind::File
},
size: metadata.len(),
validator: pinned.validator().to_vec(),
capabilities,
})
}
fn scope_access(&self) -> ScopeAccess {
ScopeAccess::new(
self.inner.scope.snapshot(),
@@ -1236,6 +1412,255 @@ impl WorkdirSession for LocalWorkdirSession {
))
}
async fn checkout_search(
&self,
mut request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
use crate::{CheckoutSearchOperation as Op, CheckoutSearchResult as Output};
self.ensure_capability(request.operation.capability())?;
request.validate()?;
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?;
let provider_root = self.checkout_root()?;
let mut inner = self.checkout_access();
inner.pinned_root = Some(provider_root);
inner.reject_symlinks = true;
let output_root = self.resolve(&request.output_root);
let logical = request.operation.path().clone();
let relative = Path::new(logical.as_str())
.strip_prefix(request.output_root.as_str())
.map_err(|_| WorkdirError::Denied("checkout search escaped output root".into()))?;
*request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| {
WorkdirError::InvalidPath("checkout search path is not UTF8".into())
})?)?;
let source_allows = Arc::new(inner.scope.allow_rules());
let access = SearchAccess {
inner,
layers: request.scope_layers,
output_root: output_root.clone(),
source_allows,
};
let result = match request.operation {
Op::List(r) => fs_operation::run_list(&output_root, r, &access).map(Output::List),
Op::Glob(r) => {
fs_operation::run_glob(&output_root, &self.resolve(&logical), r, &access)
.map(Output::Glob)
}
Op::Grep(r) => fs_operation::run_grep(&output_root, self.resolve(&logical), r, &access)
.map(Output::Grep),
};
result
.map_err(WorkdirError::from)
.map_err(|error| sanitize_error(error, &logical))
}
async fn checkout_observe(
&self,
path: WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
crate::checkout::validate_path(&path)?;
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?;
let root = self.checkout_root().map_err(|error| match error {
WorkdirError::Io { source, .. } => checkout_observe_io(&path, source),
error => error,
})?;
let access = self.checkout_access();
let pinned = fs_operation::CheckedTarget::pin(
&root,
Path::new(path.as_str()),
&self.resolve(&path),
&access,
false,
)
.map_err(|e| checkout_observe_io(&path, e))?;
self.checkout_observation(path.clone(), &pinned)
.map_err(|error| match error {
WorkdirError::Io { source, .. } => checkout_observe_io(&path, source),
error => error,
})
}
async fn checkout_execute(
&self,
request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
use crate::{CheckoutOperation as Op, CheckoutOutput as Output};
self.ensure_capability(request.operation.capability())?;
crate::checkout::validate_path(&request.target)?;
if request.validator.is_empty() || request.validator.len() > 256 {
return Err(WorkdirError::InvalidArgument(
"invalid checkout validator".into(),
));
}
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("checkout provider busy".into()))?;
let path = request.target;
let root = self
.checkout_root()
.map_err(|error| checkout_execute_error(&path, error))?;
let access = self.checkout_access();
let mut pinned = fs_operation::CheckedTarget::pin(
&root,
Path::new(path.as_str()),
&self.resolve(&path),
&access,
request.operation.capability() != WorkdirSessionCapability::Read,
)
.map_err(|e| checkout_io(&path, e))?;
if pinned.validator() != request.validator {
return Err(WorkdirError::Conflict(
"checkout observation changed; observe and read again".into(),
));
}
let is_create = matches!(&request.operation, Op::Create { .. });
let output = match request.operation {
Op::Read {
offset,
limit,
max_bytes,
} => {
if limit > 1_000_000
|| max_bytes > BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
{
return Err(WorkdirError::InvalidArgument(
"checkout Read exceeds provider bounds".into(),
));
}
fs_operation::run_read_bounded(
&self.inner.root,
ReadRequest {
path: path.clone(),
offset,
limit,
max_bytes,
},
&pinned,
self.inner
.read_limits
.unwrap_or(BoundedReadLimits::EXTERNAL_DEFAULT),
)
.map(Output::Read)
}
Op::Write {
content,
expected_hash,
} => fs_operation::run_write(
&self.inner.root,
WriteRequest {
path: path.clone(),
content,
expected_hash: Some(expected_hash),
},
&pinned,
)
.map(Output::Write),
Op::Edit {
old_string,
new_string,
replace_all,
expected_hash,
} => {
if old_string.len().saturating_add(new_string.len()) > 8 * 1024 * 1024 {
return Err(WorkdirError::InvalidArgument(
"checkout Edit exceeds provider bounds".into(),
));
}
fs_operation::run_edit(
&self.inner.root,
EditRequest {
path: path.clone(),
old_string,
new_string,
replace_all,
expected_hash,
},
&pinned,
)
.map(Output::Edit)
}
Op::Create {
path: destination,
content,
} => {
let suffix = crate::checkout::create_relative_path(&path, &destination)?;
let create = pinned.create_access(suffix, self.resolve(&destination));
fs_operation::run_write(
&self.inner.root,
WriteRequest {
path: destination,
content,
expected_hash: None,
},
&create,
)
.map(Output::Write)
}
};
let output = output.map_err(|error| {
if pinned.effects_possible() {
WorkdirError::OutcomeUnknown(
"checkout save or parent creation may have effects; inspect before retry"
.into(),
)
} else {
checkout_execute_error(&path, WorkdirError::from(error))
}
})?;
if matches!(&output, Output::Read(_)) {
pinned.confirm().map_err(|e| checkout_io(&path, e))?;
return Ok(crate::CheckoutResult {
observation: self
.checkout_observation(path.clone(), &pinned)
.map_err(|error| checkout_execute_error(&path, error))?,
output,
});
}
if is_create {
pinned.refresh_directory_observation().map_err(|_| {
WorkdirError::OutcomeUnknown(
"checkout Create may have effects but parent observation failed".into(),
)
})?;
let observation = self.checkout_observation(path, &pinned).map_err(|_| {
WorkdirError::OutcomeUnknown(
"checkout Create may have effects but parent observation failed".into(),
)
})?;
return Ok(crate::CheckoutResult {
observation,
output,
});
}
let after = fs_operation::CheckedTarget::pin(
&root,
Path::new(path.as_str()),
&self.resolve(&path),
&access,
true,
)
.map_err(|_| {
WorkdirError::OutcomeUnknown("checkout saved but post-save observation failed".into())
})?;
let observation = self.checkout_observation(path, &after).map_err(|_| {
WorkdirError::OutcomeUnknown("checkout saved but post-save observation failed".into())
})?;
Ok(crate::CheckoutResult {
observation,
output,
})
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
@@ -1247,6 +1672,11 @@ impl WorkdirSession for LocalWorkdirSession {
}
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?;
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
self.validate_operation_path(&request.path)?;
@@ -1277,6 +1707,11 @@ impl WorkdirSession for LocalWorkdirSession {
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?;
self.ensure_capability(WorkdirSessionCapability::Write)?;
let logical = request.path.clone();
self.validate_operation_path(&request.path)?;
@@ -1287,6 +1722,11 @@ impl WorkdirSession for LocalWorkdirSession {
}
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
let _serial = self
.inner
.checkout_lock
.try_lock()
.map_err(|_| WorkdirError::Unavailable("filesystem provider busy".into()))?;
self.ensure_capability(WorkdirSessionCapability::Edit)?;
let logical = request.path.clone();
self.validate_operation_path(&request.path)?;
@@ -1562,6 +2002,44 @@ fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> C
}
}
// Discovery of an absent route is not a stale-validator execution attempt.
fn checkout_observe_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError {
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) {
WorkdirError::NotFound(PathBuf::from(path.as_str()))
} else {
checkout_io(path, error)
}
}
// All pre-effect execution stages preserve the same stale-name classification,
// including FsError::NotFound produced by the shared engine after pinning.
fn checkout_execute_error(path: &WorkdirPath, error: WorkdirError) -> WorkdirError {
match error {
WorkdirError::NotFound(_) => {
WorkdirError::Conflict("checkout observation changed; observe and read again".into())
}
WorkdirError::Io { source, .. } => checkout_io(path, source),
error => sanitize_error(error, path),
}
}
fn checkout_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError {
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::NotFound
| std::io::ErrorKind::NotADirectory
| std::io::ErrorKind::AlreadyExists
) {
WorkdirError::Conflict("checkout observation changed; observe and read again".into())
} else {
sanitize_error(WorkdirError::io(Path::new(path.as_str()), error), path)
}
}
fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
let path = PathBuf::from(logical.as_str());
match error {
@@ -2083,6 +2561,47 @@ mod tests {
use std::fs;
use tempfile::TempDir;
#[test]
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
let path = WorkdirPath::new("missing").unwrap();
for kind in [
std::io::ErrorKind::NotFound,
std::io::ErrorKind::NotADirectory,
] {
assert!(
matches!(checkout_observe_io(&path, std::io::Error::from(kind)), WorkdirError::NotFound(p) if p == Path::new("missing"))
);
assert!(matches!(
checkout_io(&path, std::io::Error::from(kind)),
WorkdirError::Conflict(_)
));
assert!(matches!(
checkout_execute_error(
&path,
WorkdirError::io(Path::new("<provider-root>"), std::io::Error::from(kind))
),
WorkdirError::Conflict(_)
));
}
let shared_error =
WorkdirError::from(fs_operation::FsError::NotFound(PathBuf::from("missing")));
assert!(matches!(
checkout_execute_error(&path, shared_error),
WorkdirError::Conflict(_)
));
assert!(matches!(
checkout_observe_io(&path, std::io::Error::from(std::io::ErrorKind::WouldBlock)),
WorkdirError::Conflict(_)
));
assert!(matches!(
checkout_execute_error(
&path,
WorkdirError::OutcomeUnknown("possible save effects".into())
),
WorkdirError::OutcomeUnknown(_)
));
}
fn make_fs(dir: &TempDir) -> LocalWorkdirSession {
LocalWorkdirSession::new(
Scope::writable(dir.path()).unwrap(),
+59
View File
@@ -248,6 +248,29 @@ impl WorkdirSession for RoutedWorkdirSession {
self.state.session.scope_rules_overlap(request).await
}
async fn checkout_search(
&self,
request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.checkout_search(request).await
}
async fn checkout_observe(
&self,
path: crate::WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.checkout_observe(path).await
}
async fn checkout_execute(
&self,
request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.checkout_execute(request).await
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.stat(request).await
@@ -650,6 +673,42 @@ mod tests {
))
}
#[tokio::test]
async fn checkout_routes_use_active_operation_detach_fence_and_old_handles_stay_closed() {
let root = TempDir::new().unwrap();
std::fs::write(root.path().join("a"), "contents").unwrap();
let router = WorkdirSessionRouter::new();
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
router.attach(alias.clone(), session("wd", &root)).unwrap();
let old = router.resolve(Some("checkout")).unwrap().session;
old.checkout_observe(crate::WorkdirPath::new("a").unwrap())
.await
.unwrap();
let state = router.sessions.read().unwrap().get(&alias).unwrap().clone();
let active = state.enter().unwrap();
assert!(router.begin_detach(&alias).await.is_err());
drop(active);
router.detach(&alias).await.unwrap();
assert!(
old.checkout_observe(crate::WorkdirPath::new("a").unwrap())
.await
.is_err()
);
router.attach(alias, session("wd-new", &root)).unwrap();
router
.resolve(Some("checkout"))
.unwrap()
.session
.checkout_observe(crate::WorkdirPath::new("a").unwrap())
.await
.unwrap();
assert!(
old.checkout_observe(crate::WorkdirPath::new("a").unwrap())
.await
.is_err()
);
}
#[tokio::test]
async fn router_holds_multiple_aliases_without_implicit_primary() {
let left = TempDir::new().unwrap();
+256
View File
@@ -994,6 +994,138 @@ impl WorkdirSession for ScopedWorkdirSession {
self.capabilities
}
async fn checkout_search(
&self,
mut request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
request.validate()?;
let path = self
.resolve_operation_path(request.operation.path(), WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, request.operation.capability())?;
*request.operation.path_mut() = path;
request.output_root = self.resolve_path(&request.output_root)?;
for layer in &mut request.scope_layers {
for rule in layer {
rule.target = self.resolve_path(&rule.target)?;
}
}
if let Some(rules) = &self.scope {
request.scope_layers.push(rules.clone());
}
request.validate()?;
// Source results already use the composed output_root, not this source's
// cwd. Rewriting them here would duplicate cwd through nested wrappers.
let result = self.source.checkout_search(request).await?;
self.ensure_active()?;
Ok(result)
}
async fn checkout_observe(
&self,
caller_path: crate::WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
let path = self
.resolve_operation_path(&caller_path, WorkdirToolScopePermission::Read)
.await?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
let mut observation = self.source.checkout_observe(path.clone()).await?;
observation.capabilities = observation.capabilities.intersection(self.capabilities());
// Capability bits must not advertise authority over a read-only path or
// a path whose write authority is currently leased to a child.
if self
.resolve_operation_path(&caller_path, WorkdirToolScopePermission::Write)
.await
.is_err()
|| self
.ensure_write(&path, WorkdirSessionCapability::Write)
.is_err()
{
observation.capabilities = observation
.capabilities
.intersection(WorkdirSessionCapabilities::READ_ONLY);
}
observation.path = caller_path;
Ok(observation)
}
async fn checkout_execute(
&self,
mut request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
let _scope_guard = self.scope_lock.lock().await;
let caller_path = request.target.clone();
let capability = request.operation.capability();
let permission = if capability == WorkdirSessionCapability::Read {
WorkdirToolScopePermission::Read
} else {
WorkdirToolScopePermission::Write
};
let path = self
.resolve_operation_path(&request.target, permission)
.await?;
if permission == WorkdirToolScopePermission::Read {
self.ensure_read(&path, capability)?;
} else {
self.ensure_write(&path, capability)?;
}
let result_path = caller_path;
if let crate::CheckoutOperation::Create {
path: destination, ..
} = &mut request.operation
{
let suffix = crate::checkout::create_relative_path(&request.target, destination)?;
let mut intermediate = std::path::PathBuf::from(request.target.as_str());
let mut components = suffix.components().peekable();
while let Some(component) = components.next() {
intermediate.push(component.as_os_str());
if components.peek().is_some() {
let intermediate =
crate::WorkdirPath::new(intermediate.to_str().ok_or_else(|| {
WorkdirError::InvalidPath("non-UTF8 Create path".into())
})?)?;
let resolved = self
.resolve_operation_path(&intermediate, WorkdirToolScopePermission::Write)
.await?;
self.ensure_write(&resolved, WorkdirSessionCapability::Write)?;
}
}
let resolved = self
.resolve_operation_path(destination, WorkdirToolScopePermission::Write)
.await?;
self.ensure_write(&resolved, WorkdirSessionCapability::Write)?;
*destination = resolved;
}
request.target = path;
let mut result = self.source.checkout_execute(request).await?;
result.observation.path = result_path.clone();
result.observation.capabilities = result
.observation
.capabilities
.intersection(self.capabilities());
if self
.resolve_operation_path(&result_path, WorkdirToolScopePermission::Write)
.await
.is_err()
|| self
.ensure_write(
&self.resolve_path(&result_path)?,
WorkdirSessionCapability::Write,
)
.is_err()
{
result.observation.capabilities = result
.observation
.capabilities
.intersection(WorkdirSessionCapabilities::READ_ONLY);
}
if let crate::CheckoutOutput::Read(read) = &mut result.output {
read.path = result_path;
}
Ok(result)
}
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
@@ -1048,6 +1180,19 @@ impl WorkdirSession for ScopedWorkdirSession {
}
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
if self.scope.is_some() || !self.cwd.is_root() {
return match self
.checkout_search(crate::CheckoutSearchRequest::new(
crate::CheckoutSearchOperation::List(request),
))
.await?
{
crate::CheckoutSearchResult::List(result) => Ok(result),
_ => Err(WorkdirError::Unavailable(
"mismatched checkout search result".into(),
)),
};
}
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
@@ -1057,6 +1202,19 @@ impl WorkdirSession for ScopedWorkdirSession {
}
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
if self.scope.is_some() || !self.cwd.is_root() {
return match self
.checkout_search(crate::CheckoutSearchRequest::new(
crate::CheckoutSearchOperation::Glob(request),
))
.await?
{
crate::CheckoutSearchResult::Glob(result) => Ok(result),
_ => Err(WorkdirError::Unavailable(
"mismatched checkout search result".into(),
)),
};
}
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
@@ -1066,6 +1224,19 @@ impl WorkdirSession for ScopedWorkdirSession {
}
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
if self.scope.is_some() || !self.cwd.is_root() {
return match self
.checkout_search(crate::CheckoutSearchRequest::new(
crate::CheckoutSearchOperation::Grep(request),
))
.await?
{
crate::CheckoutSearchResult::Grep(result) => Ok(result),
_ => Err(WorkdirError::Unavailable(
"mismatched checkout search result".into(),
)),
};
}
let path = self
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
.await?;
@@ -1284,6 +1455,44 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
self.inner.scope_rules_overlap(request).await
}
async fn checkout_search(
&self,
request: crate::CheckoutSearchRequest,
) -> Result<crate::CheckoutSearchResult, WorkdirError> {
if !self
.capabilities()
.intersection(self.inner.capabilities())
.supports(request.operation.capability())
{
return Err(WorkdirError::Unsupported(request.operation.capability()));
}
self.inner.checkout_search(request).await
}
async fn checkout_observe(
&self,
path: crate::WorkdirPath,
) -> Result<crate::CheckoutObservation, WorkdirError> {
let mut observation = self.inner.checkout_observe(path).await?;
observation.capabilities = observation
.capabilities
.intersection(WorkdirSessionCapabilities::READ_ONLY);
Ok(observation)
}
async fn checkout_execute(
&self,
request: crate::CheckoutRequest,
) -> Result<crate::CheckoutResult, WorkdirError> {
if request.operation.capability() != WorkdirSessionCapability::Read {
return Err(WorkdirError::Unsupported(request.operation.capability()));
}
let mut result = self.inner.checkout_execute(request).await?;
result.observation.capabilities = result
.observation
.capabilities
.intersection(WorkdirSessionCapabilities::READ_ONLY);
Ok(result)
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.inner.stat(request).await
}
@@ -1654,6 +1863,53 @@ mod tests {
(WorkdirToolBroker::new(source), receiver, release)
}
#[tokio::test]
async fn checkout_search_unsupported_source_never_falls_back_to_broad_traversal() {
let root = TempDir::new().unwrap();
fs::create_dir(root.path().join("child")).unwrap();
fs::write(root.path().join("child/a"), "needle").unwrap();
let (parent, _, _) = blocking_session(root.path());
// Unscoped normal Tools still use their established provider contract.
assert_eq!(
parent
.list(ListRequest {
path: fs_path("child"),
limit: 100
})
.await
.unwrap()
.entries
.len(),
1
);
let child = parent
.scope(request("child", WorkdirToolScopePermission::Read))
.await
.unwrap();
assert!(matches!(
child
.tool_session()
.list(ListRequest {
path: FsPath::root(),
limit: 100
})
.await,
Err(WorkdirError::UnsupportedOperation(_))
));
assert!(matches!(
child
.tool_session()
.checkout_search(crate::CheckoutSearchRequest::new(
crate::CheckoutSearchOperation::List(ListRequest {
path: FsPath::root(),
limit: 100
})
))
.await,
Err(WorkdirError::UnsupportedOperation(_))
));
}
fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope {
WorkdirToolScope {
rules: vec![WorkdirToolScopeRule {
+9
View File
@@ -258,6 +258,8 @@ impl std::fmt::Debug for WorkspaceApiRef {
}
}
pub use worker::BackendJobExecutionBinding;
/// Canonical Runtime Worker creation request.
///
/// Browser/product launch semantics are resolved by a backend before this
@@ -302,6 +304,9 @@ pub struct CreateWorkerRequest {
/// may enable subjektiv policy, but only this host-owned bit activates it.
#[serde(default, skip_serializing_if = "is_false")]
pub subjektiv_attached: bool,
/// Backend-only immutable Job/attempt capability. Never inferred from Profile policy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_job: Option<BackendJobExecutionBinding>,
}
/// Last persisted Worker lifecycle status.
@@ -334,6 +339,8 @@ pub(crate) enum WorkerRestoreIntent {
/// Lightweight persisted Worker identity projection.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restore_observation_token: Option<String>,
pub worker_ref: WorkerRef,
pub worker_id: WorkerId,
pub status: WorkerStatus,
@@ -359,6 +366,8 @@ pub struct WorkerSummary {
/// Full persisted Worker identity and lifecycle detail.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerDetail {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restore_observation_token: Option<String>,
pub worker_ref: WorkerRef,
pub worker_id: WorkerId,
pub status: WorkerStatus,
+3
View File
@@ -12,6 +12,9 @@ pub enum RuntimeError {
#[error("initial worker input must be user input, got {kind}")]
InvalidInitialInputKind { kind: String },
#[error("worker {worker_id} Restore observation conflicts with the current operation")]
RestoreObservationConflict { worker_id: WorkerId },
#[error("worker {worker_id} was not found")]
WorkerNotFound { worker_id: WorkerId },
+122 -79
View File
@@ -207,34 +207,6 @@ impl WorkerExecutionResult {
}
}
/// Opaque per-Worker execution handle returned by a backend.
///
/// The handle is a typed token for routing calls back into the same backend. It
/// intentionally contains no socket path, process id, credential, manifest path,
/// or session path.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerExecutionHandle {
worker_ref: WorkerRef,
backend_id: String,
}
impl WorkerExecutionHandle {
pub fn new(worker_ref: WorkerRef, backend_id: impl Into<String>) -> Self {
Self {
worker_ref,
backend_id: backend_id.into(),
}
}
pub fn worker_ref(&self) -> &WorkerRef {
&self.worker_ref
}
pub fn backend_id(&self) -> &str {
&self.backend_id
}
}
/// Runtime hooks available to an execution backend for one Worker.
#[cfg(feature = "ws-server")]
type WorkerObservationPublisher = Arc<
@@ -348,7 +320,17 @@ impl WorkerExecutionContext {
let Some(event) = event else {
return Ok(());
};
(self.observation_publisher)(self.worker_ref.clone(), event)?;
if let Err(error) = (self.observation_publisher)(self.worker_ref.clone(), event.clone())
{
let mut state = self
.publication_state
.lock()
.map_err(|_| RuntimeError::StatePoisoned)?;
if let WorkerExecutionPublicationState::Activating(events) = &mut *state {
events.push_front(event);
}
return Err(error);
}
}
}
@@ -443,7 +425,6 @@ pub struct WorkerExecutionRestoreRequest {
pub struct WorkerExecutionStopRequest {
pub operation_id: WorkerLifecycleOperationId,
pub worker_ref: WorkerRef,
pub handle: Option<WorkerExecutionHandle>,
}
/// Runtime-side request to refresh the latest Workspace Config before Worker creation.
@@ -466,7 +447,6 @@ pub enum WorkspaceConfigFetchResult {
#[derive(Clone, Debug)]
pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
},
@@ -475,11 +455,10 @@ pub enum WorkerExecutionSpawnResult {
/// Live work started, failed, and all operation-owned resources were joined/released.
RolledBack(WorkerExecutionResult),
/// The operation crossed the live side-effect boundary and could not prove
/// either commit or complete cleanup. Optional handle/state fields preserve
/// either commit or complete cleanup. Optional state fields preserve
/// concrete execution evidence for retry reconciliation.
ReconciliationRequired {
result: WorkerExecutionResult,
handle: Option<WorkerExecutionHandle>,
worker_state: Option<protocol::WorkerStateSnapshot>,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
},
@@ -490,12 +469,10 @@ pub enum WorkerExecutionSpawnResult {
impl WorkerExecutionSpawnResult {
pub fn connected(
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
) -> Self {
Self::Connected {
handle,
worker_state,
workdir_attachments,
}
@@ -521,6 +498,57 @@ pub struct WorkerSessionAttachmentRequest {
pub attachment_id: String,
}
/// One real protocol connection, bound to the execution that was attached.
/// This is not a routing token and is never persisted as Worker authority.
#[cfg(feature = "ws-server")]
pub struct WorkerProtocolTransport {
pub(crate) worker_ref: WorkerRef,
pub snapshot: protocol::Event,
pub events: tokio::sync::mpsc::Receiver<protocol::Event>,
dispatch:
Arc<dyn Fn(Method) -> Result<Vec<protocol::Event>, WorkerExecutionResult> + Send + Sync>,
validate: Arc<dyn Fn() -> Result<(), WorkerExecutionResult> + Send + Sync>,
}
#[cfg(feature = "ws-server")]
impl WorkerProtocolTransport {
pub fn new(
worker_ref: WorkerRef,
snapshot: protocol::Event,
events: tokio::sync::mpsc::Receiver<protocol::Event>,
dispatch: Arc<
dyn Fn(Method) -> Result<Vec<protocol::Event>, WorkerExecutionResult> + Send + Sync,
>,
validate: Arc<dyn Fn() -> Result<(), WorkerExecutionResult> + Send + Sync>,
) -> Self {
Self {
worker_ref,
snapshot,
events,
dispatch,
validate,
}
}
pub fn dispatch(&self, method: Method) -> Result<Vec<protocol::Event>, WorkerExecutionResult> {
(self.dispatch)(method)
}
pub fn validate(&self) -> Result<(), WorkerExecutionResult> {
(self.validate)()
}
}
#[cfg(feature = "ws-server")]
impl fmt::Debug for WorkerProtocolTransport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WorkerProtocolTransport")
.finish_non_exhaustive()
}
}
/// The connection selects this backend; WorkerRef identifies an execution,
/// never a capability or authorization token.
pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn backend_id(&self) -> &str;
@@ -580,8 +608,9 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn activate_restored_worker(
&self,
_operation_id: WorkerLifecycleOperationId,
_handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
) -> Result<(), String> {
let _ = worker_ref;
Ok(())
}
@@ -673,20 +702,17 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
Ok(())
}
fn dispatch_input(
&self,
handle: &WorkerExecutionHandle,
input: WorkerInput,
) -> WorkerExecutionResult;
fn dispatch_input(&self, worker_ref: &WorkerRef, input: WorkerInput) -> WorkerExecutionResult;
fn upload_file(
&self,
_handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
_file_name: &str,
_media_type: &str,
_content: &[u8],
_context: Option<&session_store::UploadedFileUploadContext>,
) -> Result<UploadedFileRef, WorkerExecutionResult> {
let _ = worker_ref;
Err(WorkerExecutionResult::unsupported(
WorkerExecutionOperation::UploadFile,
"execution backend does not support file upload",
@@ -695,20 +721,18 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn delete_uploaded_file(
&self,
_handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
_artifact_id: &str,
) -> WorkerExecutionResult {
let _ = worker_ref;
WorkerExecutionResult::unsupported(
WorkerExecutionOperation::DeleteUploadedFile,
"execution backend does not support uploaded-file deletion",
)
}
fn dispatch_method(
&self,
_handle: &WorkerExecutionHandle,
_method: Method,
) -> WorkerExecutionResult {
fn dispatch_method(&self, worker_ref: &WorkerRef, _method: Method) -> WorkerExecutionResult {
let _ = worker_ref;
WorkerExecutionResult::unsupported(
WorkerExecutionOperation::ProtocolMethod,
"execution backend does not support direct Worker protocol methods",
@@ -717,31 +741,29 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn worker_completions(
&self,
_handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
_kind: protocol::CompletionKind,
_prefix: &str,
_context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
let _ = worker_ref;
Vec::new()
}
fn stop_worker_operation(&self, request: WorkerExecutionStopRequest) -> WorkerExecutionResult {
let Some(handle) = request.handle.as_ref() else {
return WorkerExecutionResult::unsupported(
WorkerExecutionOperation::Stop,
"execution backend cannot reconcile a stopped Worker without a live handle",
);
};
self.stop_worker(handle)
self.stop_worker(&request.worker_ref)
}
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
fn stop_worker(&self, worker_ref: &WorkerRef) -> WorkerExecutionResult {
let _ = worker_ref;
WorkerExecutionResult::unsupported(
WorkerExecutionOperation::Stop,
"execution backend does not support stopping workers",
)
}
fn cancel_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
fn cancel_worker(&self, worker_ref: &WorkerRef) -> WorkerExecutionResult {
let _ = worker_ref;
WorkerExecutionResult::unsupported(
WorkerExecutionOperation::Cancel,
"execution backend does not support cancelling workers",
@@ -749,7 +771,20 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
}
#[cfg(feature = "ws-server")]
fn worker_snapshot(&self, _handle: &WorkerExecutionHandle) -> Option<protocol::Event> {
fn attach_worker_protocol(
self: Arc<Self>,
worker_ref: &WorkerRef,
) -> Result<WorkerProtocolTransport, WorkerExecutionResult> {
let _ = worker_ref;
Err(WorkerExecutionResult::unsupported(
WorkerExecutionOperation::ProtocolMethod,
"execution backend does not support bound Worker protocol connections",
))
}
#[cfg(feature = "ws-server")]
fn worker_snapshot(&self, worker_ref: &WorkerRef) -> Option<protocol::Event> {
let _ = worker_ref;
None
}
}
@@ -827,9 +862,10 @@ impl WorkerExecutionBackendRef {
pub(crate) fn activate_restored_worker(
&self,
operation_id: WorkerLifecycleOperationId,
handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
) -> Result<(), String> {
self.backend.activate_restored_worker(operation_id, handle)
self.backend
.activate_restored_worker(operation_id, worker_ref)
}
pub(crate) fn restore_worker(
@@ -895,55 +931,62 @@ impl WorkerExecutionBackendRef {
pub(crate) fn dispatch_input(
&self,
handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
input: WorkerInput,
) -> WorkerExecutionResult {
self.backend.dispatch_input(handle, input)
self.backend.dispatch_input(worker_ref, input)
}
pub(crate) fn upload_file(
&self,
handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
file_name: &str,
media_type: &str,
content: &[u8],
context: Option<&session_store::UploadedFileUploadContext>,
) -> Result<UploadedFileRef, WorkerExecutionResult> {
self.backend
.upload_file(handle, file_name, media_type, content, context)
.upload_file(worker_ref, file_name, media_type, content, context)
}
pub(crate) fn delete_uploaded_file(
&self,
handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
artifact_id: &str,
) -> WorkerExecutionResult {
self.backend.delete_uploaded_file(handle, artifact_id)
self.backend.delete_uploaded_file(worker_ref, artifact_id)
}
pub(crate) fn dispatch_method(
&self,
handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
method: Method,
) -> WorkerExecutionResult {
self.backend.dispatch_method(handle, method)
self.backend.dispatch_method(worker_ref, method)
}
#[cfg(feature = "ws-server")]
pub(crate) fn worker_snapshot(
pub(crate) fn attach_worker_protocol(
&self,
handle: &WorkerExecutionHandle,
) -> Option<protocol::Event> {
self.backend.worker_snapshot(handle)
worker_ref: &WorkerRef,
) -> Result<WorkerProtocolTransport, WorkerExecutionResult> {
Arc::clone(&self.backend).attach_worker_protocol(worker_ref)
}
#[cfg(feature = "ws-server")]
pub(crate) fn worker_snapshot(&self, worker_ref: &WorkerRef) -> Option<protocol::Event> {
self.backend.worker_snapshot(worker_ref)
}
pub(crate) fn worker_completions(
&self,
handle: &WorkerExecutionHandle,
worker_ref: &WorkerRef,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
self.backend.worker_completions(handle, kind, prefix)
self.backend
.worker_completions(worker_ref, kind, prefix, context)
}
pub(crate) fn stop_worker_operation(
@@ -953,12 +996,12 @@ impl WorkerExecutionBackendRef {
self.backend.stop_worker_operation(request)
}
pub(crate) fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
self.backend.stop_worker(handle)
pub(crate) fn stop_worker(&self, worker_ref: &WorkerRef) -> WorkerExecutionResult {
self.backend.stop_worker(worker_ref)
}
pub(crate) fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
self.backend.cancel_worker(handle)
pub(crate) fn cancel_worker(&self, worker_ref: &WorkerRef) -> WorkerExecutionResult {
self.backend.cancel_worker(worker_ref)
}
}
+28 -4
View File
@@ -493,7 +493,12 @@ pub(crate) struct PersistedWorkerRestoreOperation {
#[serde(deny_unknown_fields)]
pub(crate) struct PersistedWorkerStopOperation {
pub(crate) operation_id: WorkerLifecycleOperationId,
pub(crate) request: CreateWorkerRequest,
/// Restore resources remain operation-owned until stop confirms cleanup.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) pending_restore: Option<PersistedWorkerRestoreOperation>,
/// Stop can reconcile an identity whose execution metadata is unavailable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) request: Option<CreateWorkerRequest>,
pub(crate) binding: Option<PersistedWorkerExecutionBinding>,
pub(crate) restore_intent: WorkerRestoreIntent,
pub(crate) last_settled_status: WorkerStatus,
@@ -515,6 +520,7 @@ pub(crate) enum PersistedWorkerExecutionState {
#[derive(Clone, Debug)]
pub(crate) struct PersistedWorkerRecord {
pub(crate) restore_guard: crate::runtime::RestoreGuard,
pub(crate) worker_ref: WorkerRef,
pub(crate) worker_id: WorkerId,
pub(crate) profile: ProfileSelector,
@@ -1814,6 +1820,8 @@ impl RuntimeSnapshot {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkerAggregateRecord {
#[serde(default)]
restore_guard: crate::runtime::RestoreGuard,
schema_version: u32,
worker_ref: WorkerRef,
worker_id: WorkerId,
@@ -1869,6 +1877,7 @@ impl WorkerAggregateRecord {
};
Self {
schema_version: SCHEMA_VERSION,
restore_guard: worker.restore_guard.clone(),
worker_ref: worker.worker_ref.clone(),
worker_id: worker.worker_id,
profile: worker.profile.clone(),
@@ -1897,6 +1906,7 @@ impl WorkerAggregateRecord {
}
fn validate(self, path: &Path) -> Result<PersistedWorkerRecord, RuntimeError> {
let restore_guard = self.restore_guard;
let last_finished_submission_request_id = self.last_finished_submission_request_id;
let identity = WorkerIdentityRecord {
schema_version: self.schema_version,
@@ -1942,7 +1952,7 @@ impl WorkerAggregateRecord {
});
}
(
&restore.request,
Some(&restore.request),
&restore.binding,
if restore.last_settled_status.is_active() {
WorkerRestoreIntent::Automatic
@@ -1953,7 +1963,7 @@ impl WorkerAggregateRecord {
)
}
PersistedWorkerLifecycleOperation::Stop(stop) => {
if !stop.last_settled_status.is_active() {
if !stop.last_settled_status.is_active() && stop.pending_restore.is_none() {
return Err(RuntimeError::StoreCorrupt {
operation: "read Worker stop reconciliation",
path: path.to_path_buf(),
@@ -1961,8 +1971,18 @@ impl WorkerAggregateRecord {
.to_string(),
});
}
if stop.request.is_none()
&& (stop.binding.is_some() || stop.pending_restore.is_some())
{
return Err(RuntimeError::StoreCorrupt {
operation: "read Worker stop reconciliation",
path: path.to_path_buf(),
message: "metadata-free stop cannot carry restore authority"
.to_string(),
});
}
(
&stop.request,
stop.request.as_ref(),
&stop.binding,
stop.restore_intent,
stop.last_settled_status,
@@ -1978,6 +1998,7 @@ impl WorkerAggregateRecord {
.to_string(),
});
}
if let Some(request) = request {
WorkerExecutionRecord {
schema_version: SCHEMA_VERSION,
request: request.clone(),
@@ -1985,11 +2006,13 @@ impl WorkerAggregateRecord {
restore_intent,
}
.validate_for_schema(&identity, path, SCHEMA_VERSION)?;
}
PersistedWorkerExecutionState::ReconciliationRequired(operation)
}
WorkerExecutionStateRecord::Unavailable => PersistedWorkerExecutionState::Unavailable,
};
let mut persisted = identity.into_persisted(execution_state);
persisted.restore_guard = restore_guard;
persisted.last_finished_submission_request_id = last_finished_submission_request_id;
Ok(persisted)
}
@@ -2163,6 +2186,7 @@ impl WorkerIdentityRecord {
self.workdir_attachments
};
PersistedWorkerRecord {
restore_guard: crate::runtime::RestoreGuard::default(),
worker_ref: self.worker_ref,
worker_id: self.worker_id,
profile: self.profile,
File diff suppressed because it is too large Load Diff
@@ -462,21 +462,74 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
Ok(runtime_api::WorkerLifecycleResponse { ack })
}
async fn coordinate_worker_restore(
&self,
worker_id: String,
request: runtime_api::WorkerRestoreCoordinationRequest,
) -> Result<runtime_api::WorkerRestoreCoordinationResponse, runtime_api::RuntimeApiError> {
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
let scope =
auth_workspace_scope(&self.state, auth_extension().as_ref()).map_err(api_error)?;
let runtime = self.state.runtime.clone();
let (result, preparation) = tokio::task::spawn_blocking(move || {
runtime.coordinate_worker_restore_operation(&worker_ref, request, scope.as_ref())
})
.await
.map_err(|error| {
runtime_api::RuntimeApiError::new(500, "runtime_restore_task_failed", error.to_string())
})?
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)?;
let result = result
.map(|result| -> Result<_, runtime_api::RuntimeApiError> {
Ok(runtime_api::WorkerRestoreResponse {
state: match result.state {
server_api::WorkerRestoreState::Accepted => {
runtime_api::WorkerRestoreState::Accepted
}
server_api::WorkerRestoreState::Rejected => {
runtime_api::WorkerRestoreState::Rejected
}
server_api::WorkerRestoreState::RolledBack => {
runtime_api::WorkerRestoreState::RolledBack
}
server_api::WorkerRestoreState::ReconciliationRequired => {
runtime_api::WorkerRestoreState::ReconciliationRequired
}
},
worker: result.worker.map(response).transpose()?,
reason_code: result.reason_code,
message: result.message,
})
})
.transpose()?;
Ok(runtime_api::WorkerRestoreCoordinationResponse {
result,
preparation,
})
}
async fn restore_worker(
&self,
worker_id: String,
_request: runtime_api::EmptyObjectRequest,
request: runtime_api::WorkerRestoreRequest,
) -> Result<runtime_api::WorkerRestoreResponse, runtime_api::RuntimeApiError> {
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
let result = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
{
Some(scope) => self
.state
.runtime
.restore_worker_operation_scoped(&scope, &worker_ref),
None => self.state.runtime.restore_worker_operation(&worker_ref),
}
let scope =
auth_workspace_scope(&self.state, auth_extension().as_ref()).map_err(api_error)?;
let runtime = self.state.runtime.clone();
let result = tokio::task::spawn_blocking(move || match scope {
Some(scope) => runtime.restore_worker_operation_scoped(&scope, &worker_ref, request),
None => runtime.restore_worker_operation(&worker_ref, request),
})
.await
.map_err(|error| {
runtime_api::RuntimeApiError::new(
StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
"runtime_restore_task_failed",
error.to_string(),
)
})?
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)?;
Ok(runtime_api::WorkerRestoreResponse {
@@ -562,15 +615,19 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
let entries = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
{
Some(scope) => {
self.state
.runtime
.worker_completions_scoped(&scope, &worker_ref, kind, &prefix)
}
None => self
.state
.runtime
.worker_completions(&worker_ref, kind, &prefix),
Some(scope) => self.state.runtime.worker_completions_scoped(
&scope,
&worker_ref,
kind,
&prefix,
value.context.as_ref(),
),
None => self.state.runtime.worker_completions(
&worker_ref,
kind,
&prefix,
value.context.as_ref(),
),
}
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)?;
@@ -578,6 +635,8 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
Ok(runtime_api::CompletionResponse {
kind,
prefix,
context: value.context,
request_id: value.request_id,
entries,
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -555,9 +555,15 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
WorkspaceServerOperation::WorkerControlRestore {
runtime_id,
worker_id,
request,
} => generated_workspace_response(
client
.worker_control_restore(workspace_id, runtime_id, worker_id)
.worker_control_restore(
workspace_id,
runtime_id,
worker_id,
request,
)
.await,
),
WorkspaceServerOperation::WorkerObservationSessions => {
+2
View File
@@ -55,6 +55,8 @@ wip-http = { workspace = true }
wip-protocol = { workspace = true }
[dev-dependencies]
axum = { workspace = true }
workdir = { workspace = true, features = ["http-client"] }
dotenv = "0.15.0"
futures = { workspace = true }
serial_test = "3.4.0"
+74
View File
@@ -25,6 +25,80 @@ Does not own:
A Worker is runtime authority, not UI state. It should commit model-visible events through history/session paths and keep current Worker-name state in Worker metadata rather than in transient runtime files.
## Workspace config WIP Feature
Enable the opt-in surface in a Worker profile/manifest:
```toml
[feature.workspace_config]
enabled = true
```
This setting accepts **only** `enabled` and defaults to false. Controller
registration additionally requires Workspace identity, an available injected
Workspace Client and WIP mode. It creates no grant. An owner must explicitly
create a Backend grant bound to Workspace/Runtime/Worker and read-only or
read-write access; standalone/Tools-mode config compatibility tools do not exist.
`feature::builtin::workspace_config` registers `/workspace-config` through the
common structured invocation registry (T696). Its optional `access` argument is
`effective` (default), `read_only`, or `read_write`; explicit write never silently
downgrades. Feature preparation attaches before the request reaches the assistant
and reports the logical target and effective access. Native self-attach uses the
same `WorkspaceConfigFeature::attach` path. Backend alias attachment is idempotent
under the session lock; neither entry point blindly retries uncertain outcomes.
The canonical alias is `workspace-config`; the content entrance is WIP
`/workspace-config`. Attachment identity and conditional detach use the existing
T697 Backend ledger. No OS mount, Workdir session, Bash capability, local config
copy or second attachment inventory is created. The registry source is logical,
not a Runtime filesystem target.
The request-time provider maps `/workspace-config` to the Backend's empty root
path and descendants to canonical relative source paths, for example
`/workspace-config/main.dcdl` -> `main.dcdl`. Observations return structure,
Backend validators and current operations, **not bodies**. Files publish `read`;
read-write grants additionally publish `write`, unique-substring `edit`, and
`delete`. Directories and explicit absence observations publish `create` where
Backend metadata permits it. The root publishes `apply_changes` for atomic
create/update/delete/rename sets, allowing new imported
sources and their references to be saved together. Empty directory creation,
commands and generic POSIX operations are not exposed. The adapter uses the typed
`workers/self/workspace-config` GET/attach/observe/read/commit API; it never calls
an owner config API as a bypass.
Client-managed PATH validators are compared against freshly resolved Host
metadata. Each mutation captures the ROOT validator, whole-tree revision/digest,
canonical entrypoints and node state from that same observation. Backend resolves
the live grant/link and validates canonical changes/CAS under its execution lock.
No operation accepts LLM-supplied validators or `expected_digest` fields. Returned
post-call path validators are refreshed from the exact committed revision, not
substituted with root validators or later unrelated state. UI/Worker edits and
same-bytes replacement revisions reject stale calls. Old descriptors cannot
bypass read-only restrictions, detach, revocation or Workspace boundaries.
Bounds: depth 0..8, 256 observed nodes/changes, 512-byte relative paths, 256 KiB
aggregate changed text/read content, and 30-second retrieval/operation deadlines.
Path traversal and malformed parameters fail before content calls. Typed
`NotCommitted` failures remain distinct from `Unknown`; timeouts/lost mutation
responses are unknown and never automatically retried. Raw transport/provider
errors are not copied into model/UI output. Retrieval protocol failures are
reported as failures after recording their Client cache state, not as fresh
successful observations. Saving commits the canonical Workspace configuration
and existing projection/activation pipeline; it does **not** rewrite the resolved
manifests of already-running Workers.
Public integration seams: `WorkspaceConfigFeature::for_workspace`,
`workspace_config::wip::mount_workspace_config_wip`, and
`WipRuntime::from_mounts` plus `discover`/`inspect`/`call`. Worker tests exercise the
real Feature registry, native mount and WIP Client/Host through a typed in-process
contract router: deep discovery, slash/native attach, read/edit/write,
create/delete, atomic multi-source updates, grant restrictions/revocation,
detach, cross-Workspace isolation, concurrent/same-bytes stale observations,
invalid/oversize/save/unknown failures and no retries. Those contract-router tests
are not a substitute for Backend canonical persistence, semantic validation,
projection activation or full Web-to-Backend acceptance tests.
## See also
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
+29
View File
@@ -0,0 +1,29 @@
//! Host-authored Job authority, separate from reusable Profile policy.
use serde::{Deserialize, Serialize};
/// Immutable execution capability delivered only by a trusted Backend host.
/// Names, instructions and Profiles never confer this capability.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BackendJobExecutionBinding {
pub job_id: String,
pub attempt_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_revision: Option<String>,
#[serde(default)]
pub subjektiv_consolidation: bool,
}
impl BackendJobExecutionBinding {
pub fn validate(&self) -> Result<(), &'static str> {
if [&self.job_id, &self.attempt_id]
.into_iter()
.chain(self.input_revision.iter())
.any(|value| value.trim().is_empty() || value.chars().any(char::is_control))
{
return Err("Backend Job binding contains an invalid identity or input revision");
}
Ok(())
}
}
+757
View File
@@ -0,0 +1,757 @@
//! Provider-owned request-time checkout projection. No filesystem path is opened
//! by the Worker: every observation and operation goes through the live router.
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use agen::tool::ToolError;
use async_trait::async_trait;
use manifest::{ToolPermissionAction, ToolPermissionConfig};
use serde_json::{Value as Json, json};
use sha2::{Digest, Sha256};
use wip_protocol::{
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
};
use workdir::{
CheckoutObservation, EntryKind, ListRequest, WorkdirPath, WorkdirSessionCapability as Cap,
WorkdirSessionRouter,
};
use crate::feature::builtin::manage_workdir::wip::{decode_identity, encode_identity};
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, WipSubtreeMount, WipSubtreeProvider,
json_to_wip, wip_to_json,
};
const ROOT: &str = "/checkouts";
const MAX_NODES: usize = 1024;
const DEADLINE: Duration = Duration::from_secs(30);
/// Return a stable, injective attachment-local entrance, never a host path.
pub fn checkout_root(alias: &str) -> String {
format!("{ROOT}/{}", encode_identity(alias))
}
/// Register the native content namespace independently of inventory/lifecycle
/// Features. Its capabilities come from current attachments, not Feature flags.
pub fn mount_checkouts(
registry: &mut WipMountRegistry,
router: Arc<WorkdirSessionRouter>,
tracker: tools::Tracker,
permissions: Option<ToolPermissionConfig>,
) -> Result<(), WipMountError> {
registry.allocate_namespace("checkout", "checkouts")?;
let provider = Arc::new(Provider {
router,
tracker,
permissions,
incarnation: uuid::Uuid::now_v7().to_string(),
permits: Arc::new(tokio::sync::Semaphore::new(16)),
deadline: DEADLINE,
});
registry.mount(provider.collection())?;
registry.replace_compatibility_tools(ROOT, ["Read", "Edit", "Write", "Glob", "Grep"])?;
registry.mount_subtree(WipSubtreeMount {
root: ROOT.into(),
provider: Arc::new(provider),
})
}
struct Provider {
router: Arc<WorkdirSessionRouter>,
tracker: tools::Tracker,
permissions: Option<ToolPermissionConfig>,
incarnation: String,
permits: Arc<tokio::sync::Semaphore>,
deadline: Duration,
}
impl Provider {
fn allowed(&self, tool: &str, input: &Json) -> bool {
self.permissions
.as_ref()
.is_none_or(|p| permission_action_for(p, tool, input) == ToolPermissionAction::Allow)
}
fn may_allow(&self, tool: &str) -> bool {
let Some(p) = &self.permissions else {
return true;
};
for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(tool)) {
if rule.action == ToolPermissionAction::Allow {
return true;
}
if rule.pattern == "*" {
return false;
}
}
p.default_action == ToolPermissionAction::Allow
}
fn parse(&self, path: &str) -> Option<(String, WorkdirPath)> {
wip_protocol::validate_path(path).ok()?;
let rest = path.strip_prefix("/checkouts/")?;
let (slug, tail) = rest.split_once('/').unwrap_or((rest, ""));
if tail.contains('\0') {
return None;
}
Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?))
}
fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec<u8> {
let mut hash = Sha256::new();
for value in [
self.incarnation.as_bytes(),
alias.as_bytes(),
&generation.to_be_bytes(),
workdir.as_bytes(),
raw,
] {
hash.update(value.len().to_be_bytes());
hash.update(value);
}
hash.finalize().to_vec()
}
fn collection(self: &Arc<Self>) -> WipProjection {
let mut state = Sha256::new();
state.update(&self.incarnation);
for alias in self.router.aliases() {
if let Ok(s) = self.router.resolve(Some(alias.as_str())) {
state.update(alias.as_str());
state.update(s.generation.to_be_bytes());
state.update(s.session.workdir().id().as_str());
state.update(format!("{:?}", s.session.capabilities()));
}
}
self.project(
ROOT,
descriptor(vec![operation(
"list",
"List currently accessible checkouts",
vec![],
)]),
state.finalize().to_vec(),
"collection",
Arc::new(Collection {
provider: self.clone(),
}),
)
}
fn project(
&self,
path: &str,
descriptor: InterfaceDescriptor,
validator: Vec<u8>,
kind: &str,
handler: Arc<dyn WipOperationHandler>,
) -> WipProjection {
let encoded: String = path.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
let interface = format!("yoi.checkout/{}/{kind}/v1/@/{encoded}", self.incarnation);
let interface_validator = Sha256::digest(format!("{descriptor:?}")).to_vec();
WipProjection {
route: path.into(),
capability: format!("checkout:{kind}"),
kind: WipProjectionKind::Native,
object: Object {
name: path.rsplit('/').next().unwrap_or_default().into(),
description: Some(format!("Attached Workdir {kind}")),
interfaces: vec![interface.clone()],
r#ref: None,
validator: Some(validator),
},
interface,
descriptor,
interface_validator: Some(interface_validator),
handler,
}
}
async fn node(self: &Arc<Self>, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
if path == ROOT {
return Ok(Some(self.collection()));
}
let Some((alias, target)) = self.parse(path) else {
return Ok(None);
};
let Ok(selected) = self.router.resolve(Some(&alias)) else {
return Ok(None);
};
let observation = match selected.session.checkout_observe(target.clone()).await {
Ok(o) => o,
Err(
workdir::WorkdirError::Denied(_)
| workdir::WorkdirError::OutOfScope(_)
| workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::SessionClosed
| workdir::WorkdirError::UnsupportedOperation(_)
| workdir::WorkdirError::Unsupported(_),
) => return Ok(None),
Err(_) => {
return Err(error(
ProtocolErrorCode::Internal,
"checkout observation unavailable",
));
}
};
if !matches!(observation.kind, EntryKind::File | EntryKind::Directory) {
return Ok(None);
}
let permission_input = json!({"target_workdir":alias, "file_path":target.as_str()});
if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) {
return Ok(None);
}
let ops = self.operations(&observation);
if ops.is_empty() {
return Ok(None);
}
let kind = if observation.kind == EntryKind::File {
"file"
} else {
"directory"
};
let workdir = selected.session.workdir().id().as_str().to_owned();
let validator = self.wrap_validator(
&alias,
selected.generation,
&workdir,
&observation.validator,
);
let handler = Arc::new(FileHandler {
provider: self.clone(),
alias,
generation: selected.generation,
workdir,
target,
observation,
});
let family = format!("{kind}-g{}", selected.generation);
Ok(Some(self.project(
path,
descriptor(ops),
validator,
&family,
handler,
)))
}
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
let mut ops = Vec::new();
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
if o.kind == EntryKind::File {
if has(Cap::Read, "Read") {
ops.push(operation(
"read",
"Read text with line offsets and numbered output",
crate::file_operation::parameters("read", true).unwrap(),
));
}
if has(Cap::Edit, "Edit") {
ops.push(operation(
"edit",
"Replace a unique string (requires prior Read)",
crate::file_operation::parameters("edit", true).unwrap(),
));
}
if has(Cap::Write, "Write") {
ops.push(operation(
"write",
"Overwrite this existing file (requires prior Read)",
crate::file_operation::parameters("write", true).unwrap(),
));
}
} else {
if has(Cap::Glob, "Glob") {
ops.push(operation(
"glob",
"Glob within this directory",
vec![
parameter("pattern", true, TypeExpr::String),
parameter("path", false, TypeExpr::String),
],
));
}
if has(Cap::Grep, "Grep") {
ops.push(operation(
"grep",
"Search within this directory with the existing Grep contract",
grep_parameters(),
));
}
if has(Cap::Write, "Write") {
ops.push(operation("create_file", "Create a new relative file, creating permitted missing parents; never overwrite", vec![parameter("path",true,TypeExpr::String),parameter("content",true,TypeExpr::String)]));
}
}
ops
}
}
#[async_trait]
impl WipSubtreeProvider for Arc<Provider> {
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
.await
.map_err(|_| {
error(
ProtocolErrorCode::ResourceLimitExceeded,
"checkout concurrency deadline",
)
})?
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
tokio::time::timeout(self.deadline, self.node(path))
.await
.map_err(|_| {
error(
ProtocolErrorCode::ResourceLimitExceeded,
"checkout observation deadline",
)
})?
}
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
.await
.map_err(|_| {
error(
ProtocolErrorCode::ResourceLimitExceeded,
"checkout capacity deadline",
)
})?
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
tokio::time::timeout(self.deadline, self.children_inner(path))
.await
.map_err(|_| {
error(
ProtocolErrorCode::ResourceLimitExceeded,
"checkout enumeration deadline",
)
})?
}
}
impl Provider {
async fn children_inner(self: &Arc<Self>, path: &str) -> Result<Vec<String>, ProtocolError> {
if path == ROOT {
let before = self.collection().object.validator.unwrap();
if self.router.aliases().len() > MAX_NODES {
return Err(error(
ProtocolErrorCode::ResourceLimitExceeded,
"too many checkouts",
));
}
let mut paths = Vec::new();
for alias in self.router.aliases() {
let p = checkout_root(alias.as_str());
if self.node(&p).await?.is_some() {
paths.push(p);
}
}
if before != self.collection().object.validator.unwrap() {
return Err(error(
ProtocolErrorCode::ValidatorMismatch,
"attachment collection changed",
));
}
return Ok(paths);
}
let Some((alias, target)) = self.parse(path) else {
return Err(error(ProtocolErrorCode::NotFound, "checkout not published"));
};
let selected = self
.router
.resolve(Some(&alias))
.map_err(|_| error(ProtocolErrorCode::NotFound, "attachment expired"))?;
let o = selected
.session
.checkout_observe(target.clone())
.await
.map_err(|_| error(ProtocolErrorCode::NotFound, "checkout not published"))?;
if o.kind != EntryKind::Directory {
return Ok(Vec::new());
}
if !self.allowed(
"Glob",
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":"*"}),
) && !self.allowed(
"Grep",
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":""}),
) {
return Ok(Vec::new());
}
let listing = selected
.session
.checkout_search(workdir::CheckoutSearchRequest::new(
workdir::CheckoutSearchOperation::List(ListRequest {
path: target,
limit: MAX_NODES + 1,
}),
))
.await
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout listing unavailable"))?;
let workdir::CheckoutSearchResult::List(listing) = listing else {
return Err(error(
ProtocolErrorCode::Internal,
"mismatched checkout listing",
));
};
if listing.truncated || listing.entries.len() > MAX_NODES {
return Err(error(
ProtocolErrorCode::ResourceLimitExceeded,
"directory observation exceeds node bound",
));
}
let mut paths = Vec::new();
for entry in listing.entries {
let p = object_path(&alias, &entry.path);
if self.node(&p).await?.is_some() {
paths.push(p);
}
}
if !self
.router
.resolve(Some(&alias))
.is_ok_and(|current| current.generation == selected.generation)
{
return Err(error(
ProtocolErrorCode::ValidatorMismatch,
"attachment changed",
));
}
Ok(paths)
}
}
struct Collection {
provider: Arc<Provider>,
}
#[async_trait]
impl WipOperationHandler for Collection {
async fn call(
&self,
operation: &str,
arguments: &BTreeMap<String, Value>,
_context: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
if operation != "list" || !arguments.is_empty() {
return Err(protocol(
ProtocolErrorCode::InvalidArguments,
"list accepts no arguments",
));
}
let before = self.provider.collection().object.validator.unwrap();
let paths = Arc::new(self.provider.clone())
.children(ROOT)
.await
.map_err(WipOperationError::Protocol)?;
let mut items = Vec::new();
for path in paths {
let (alias, _) = self.provider.parse(&path).expect("generated checkout path");
let s = self.provider.router.resolve(Some(&alias)).map_err(|_| {
protocol(ProtocolErrorCode::ValidatorMismatch, "attachment changed")
})?;
items.push(json!({"path":path,"slug":encode_identity(&alias),"alias":alias,
"working_directory_id":s.session.workdir().id().as_str(),"generation":s.generation}));
}
let value = json_to_wip(&json!({"items":items}))
.map_err(|_| protocol(ProtocolErrorCode::Internal, "invalid checkout result"))?;
let after = self.provider.collection().object.validator.unwrap();
if before != after {
return Err(protocol(
ProtocolErrorCode::ValidatorMismatch,
"attachment collection changed",
));
}
Ok(WipOperationOutput::native_with_validator(value, after))
}
}
struct FileHandler {
provider: Arc<Provider>,
alias: String,
generation: u64,
workdir: String,
target: WorkdirPath,
observation: CheckoutObservation,
}
impl FileHandler {
fn check_connection(&self) -> Result<(), WipOperationError> {
if self
.provider
.router
.resolve(Some(&self.alias))
.is_ok_and(|s| {
s.generation == self.generation && s.session.workdir().id().as_str() == self.workdir
})
{
Ok(())
} else {
Err(protocol(
ProtocolErrorCode::ValidatorMismatch,
"attachment changed while publishing result",
))
}
}
}
#[async_trait]
impl WipOperationHandler for FileHandler {
async fn call(
&self,
operation: &str,
arguments: &BTreeMap<String, Value>,
context: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
let tool = match operation {
"read" => "Read",
"edit" => "Edit",
"write" => "Write",
"create_file" => "Create",
"glob" => "Glob",
"grep" => "Grep",
_ => {
return Err(protocol(
ProtocolErrorCode::OperationNotFound,
"unknown checkout operation",
));
}
};
let mut args = serde_json::Map::new();
for (k, v) in arguments {
args.insert(
k.clone(),
wip_to_json(v).map_err(|_| {
protocol(
ProtocolErrorCode::InvalidArguments,
"invalid checkout argument",
)
})?,
);
}
let mut permission_input = args.clone();
permission_input.insert("target_workdir".into(), json!(self.alias));
if tool == "Glob" || tool == "Grep" {
let relative = args.get("path").and_then(Json::as_str).unwrap_or("");
permission_input.insert("path".into(), json!(join_under(&self.target, relative)?));
} else if tool == "Create" {
let relative = args.get("path").and_then(Json::as_str).ok_or_else(|| {
protocol(ProtocolErrorCode::InvalidArguments, "create requires path")
})?;
permission_input.remove("path");
permission_input.insert(
"file_path".into(),
json!(join_under(&self.target, relative)?),
);
} else {
permission_input.insert("file_path".into(), json!(self.target.as_str()));
}
if !self.provider.allowed(
if tool == "Create" { "Write" } else { tool },
&Json::Object(permission_input),
) {
return Err(protocol(
ProtocolErrorCode::PermissionDenied,
"file operation denied; approval-required policy fails closed",
));
}
let _permit = tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire())
.await
.map_err(|_| {
protocol(
ProtocolErrorCode::ResourceLimitExceeded,
"checkout operation capacity deadline",
)
})?
.map_err(|_| protocol(ProtocolErrorCode::Internal, "checkout unavailable"))?;
let deadline = tokio::time::Instant::now() + self.provider.deadline;
let result = tokio::time::timeout_at(
deadline,
tools::execute_checkout_tool(
self.provider.router.clone(),
self.provider.tracker.clone(),
&self.alias,
self.generation,
self.target.clone(),
self.observation.validator.clone(),
tool,
Json::Object(args),
context.execution,
),
)
.await
.map_err(|_| {
if matches!(tool, "Write" | "Edit" | "Create") {
WipOperationError::OutcomeUnknown("file operation deadline after dispatch".into())
} else {
protocol(
ProtocolErrorCode::ResourceLimitExceeded,
"file operation deadline",
)
}
})?
.map_err(map_tool_error)?;
let links = tokio::time::timeout_at(deadline, async {
let mut links = Vec::new();
self.check_connection()?;
for path in &result.paths {
let route = object_path(&self.alias, path);
// References are validated only against the captured live connection.
if self
.provider
.node(&route)
.await
.map_err(WipOperationError::Protocol)?
.is_some()
{
links.push(json!({"path":route}));
}
self.check_connection()?;
}
Ok::<_, WipOperationError>(links)
})
.await
.map_err(|_| {
if matches!(tool, "Write" | "Edit" | "Create") {
WipOperationError::OutcomeUnknown("completed operation response deadline".into())
} else {
protocol(
ProtocolErrorCode::ResourceLimitExceeded,
"checkout result deadline",
)
}
})?
.map_err(|e| {
if matches!(tool, "Write" | "Edit" | "Create") {
WipOperationError::OutcomeUnknown("completed operation response unavailable".into())
} else {
e
}
})?;
let value = json_to_wip(
&json!({"summary":result.output.summary,"content":result.output.content,"items":links}),
)
.map_err(|_| {
WipOperationError::OutcomeUnknown("invalid completed file operation result".into())
})?;
if let Some(raw) = result.validator {
Ok(WipOperationOutput::native_with_validator(
value,
self.provider
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
))
} else {
Ok(WipOperationOutput::native(value))
}
}
}
fn object_path(alias: &str, path: &WorkdirPath) -> String {
if path.is_root() {
checkout_root(alias)
} else {
format!("{}/{}", checkout_root(alias), path.as_str())
}
}
fn join_under(parent: &WorkdirPath, relative: &str) -> Result<String, WipOperationError> {
let child = WorkdirPath::new(relative).map_err(|_| {
protocol(
ProtocolErrorCode::InvalidArguments,
"path must stay relative to this directory",
)
})?;
if relative.starts_with('/') {
return Err(protocol(
ProtocolErrorCode::InvalidArguments,
"absolute paths are not permitted",
));
}
Ok(if parent.is_root() {
child.as_str().into()
} else if child.is_root() {
parent.as_str().into()
} else {
format!("{}/{}", parent.as_str(), child.as_str())
})
}
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
ParameterDeclaration {
name: name.into(),
required,
documentation: None,
r#type,
}
}
fn operation(
name: &str,
summary: &str,
parameters: Vec<ParameterDeclaration>,
) -> OperationDeclaration {
OperationDeclaration {
name: name.into(),
documentation: Some(Documentation {
summary: summary.into(),
details: None,
}),
parameters,
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::Json,
},
}
}
fn descriptor(operations: Vec<OperationDeclaration>) -> InterfaceDescriptor {
InterfaceDescriptor{format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation{summary:"AI-oriented attached Workdir operations".into(),details:Some("The route binds attachment and target. Line offsets are zero-based; text output numbers lines from one. Read-before-write and provider validators are both required. Paths are Worldspace references, never host paths.".into())}),types:Vec::new(),operations}
}
fn grep_parameters() -> Vec<ParameterDeclaration> {
let mut p = vec![parameter("pattern", true, TypeExpr::String)];
for name in ["path", "glob", "type", "output_mode"] {
p.push(parameter(name, false, TypeExpr::String));
}
for name in ["-A", "-B", "-C", "head_limit", "offset"] {
p.push(parameter(name, false, TypeExpr::Integer));
}
for name in ["case_insensitive", "multiline"] {
p.push(parameter(name, false, TypeExpr::Boolean));
}
p
}
fn error(code: ProtocolErrorCode, message: &str) -> ProtocolError {
ProtocolError {
code,
message: message.into(),
}
}
fn protocol(code: ProtocolErrorCode, message: &str) -> WipOperationError {
WipOperationError::Protocol(error(code, message))
}
fn map_tool_error(e: ToolError) -> WipOperationError {
match e {
ToolError::InvalidArgument(_) => protocol(
ProtocolErrorCode::InvalidArguments,
"invalid file arguments or read-before-write requirement",
),
ToolError::StructuredConflict { code, .. } => match code.as_str() {
"checkout_denied" => protocol(
ProtocolErrorCode::PermissionDenied,
"file operation refused before effects",
),
"checkout_invalid" => protocol(
ProtocolErrorCode::InvalidArguments,
"invalid checkout operation or target",
),
"checkout_unavailable" => protocol(
ProtocolErrorCode::ResourceLimitExceeded,
"checkout provider declined before effects",
),
"checkout_stale" => protocol(
ProtocolErrorCode::ValidatorMismatch,
"file or attachment observation is stale; rediscover and reread",
),
_ => WipOperationError::OutcomeUnknown("unclassified file operation failure".into()),
},
ToolError::Cancelled(o) => WipOperationError::Cancelled(o),
ToolError::Interrupted(o) => WipOperationError::Interrupted(o),
_ => WipOperationError::OutcomeUnknown(
"file operation failed; inspect current state before retry".into(),
),
}
}
#[cfg(test)]
#[path = "checkout_race_tests.rs"]
mod race_tests;
+671
View File
@@ -0,0 +1,671 @@
//! Real provider HTTP -> RemoteWorkdirSession -> router -> native WIP Client.
//! This deliberately does not exercise the production Backend grant network.
use super::*;
use axum::{
Json as AxumJson, Router,
body::Body,
extract::{Path, State},
http::{HeaderMap, StatusCode, Uri},
response::{IntoResponse, Response as AxumResponse},
routing::{delete, post},
};
use std::time::Duration;
use tempfile::TempDir;
use workdir::http::{
OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, RemoteWorkdirSession, WorkdirSessionId,
WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirTransportError,
WorkdirTransportErrorCode,
};
use workdir::{
CheckoutOperation, CheckoutRequest, CheckoutSearchOperation, LocalWorkdirSession,
WorkdirAttachmentAlias, WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
WorkdirSessionHandle, WorkdirSessionRouter, dispatch_workdir_session_operation,
};
const TOKEN: &str = "checkout-http-test-private-bearer";
const SESSION: &str = "http-test-session";
#[derive(Clone, Copy, Debug)]
enum WriteResponseFault {
Malformed,
LostBody,
}
struct ProviderState {
session: WorkdirSessionHandle,
requests: Mutex<Vec<(String, String)>>,
operations: Mutex<Vec<WorkdirSessionOperation>>,
write_fault: Mutex<Option<WriteResponseFault>>,
}
impl ProviderState {
fn authorize(&self, headers: &HeaderMap, method: &str, uri: &Uri) -> Result<(), AxumResponse> {
self.requests
.lock()
.unwrap()
.push((method.into(), uri.path().into()));
let expected = format!("Bearer {TOKEN}");
if headers.get("authorization").and_then(|h| h.to_str().ok()) != Some(expected.as_str()) {
return Err((
StatusCode::UNAUTHORIZED,
AxumJson(WorkdirTransportError {
code: WorkdirTransportErrorCode::Denied,
message: "provider authorization denied".into(),
}),
)
.into_response());
}
Ok(())
}
fn execute_calls(&self) -> usize {
self.operations
.lock()
.unwrap()
.iter()
.filter(|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(_)))
.count()
}
fn write_calls(&self) -> usize {
self.operations
.lock()
.unwrap()
.iter()
.filter(|op| {
matches!(
op,
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
operation: CheckoutOperation::Write { .. },
..
})
)
})
.count()
}
}
fn provider_error(error: WorkdirError) -> AxumResponse {
let error = WorkdirTransportError::from_workdir_error(&error);
(
StatusCode::from_u16(error.code.http_status()).unwrap(),
AxumJson(error),
)
.into_response()
}
async fn open_session(
State(state): State<Arc<ProviderState>>,
Path(id): Path<String>,
uri: Uri,
headers: HeaderMap,
AxumJson(_request): AxumJson<OpenWorkdirSessionRequest>,
) -> AxumResponse {
if let Err(response) = state.authorize(&headers, "POST", &uri) {
return response;
}
if id != state.session.workdir().id().as_str() {
return provider_error(WorkdirError::NotFound("unknown workdir".into()));
}
AxumJson(OpenWorkdirSessionResponse {
session_id: WorkdirSessionId::new(SESSION).unwrap(),
workdir_id: state.session.workdir().id().clone(),
capabilities: state.session.capabilities(),
})
.into_response()
}
async fn operate(
State(state): State<Arc<ProviderState>>,
Path(id): Path<String>,
uri: Uri,
headers: HeaderMap,
AxumJson(request): AxumJson<WorkdirSessionOperationRequest>,
) -> AxumResponse {
if let Err(response) = state.authorize(&headers, "POST", &uri) {
return response;
}
if id != SESSION {
return provider_error(WorkdirError::Unavailable("unknown session".into()));
}
let is_write = matches!(
&request.operation,
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
operation: CheckoutOperation::Write { .. },
..
})
);
state
.operations
.lock()
.unwrap()
.push(request.operation.clone());
let result =
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
match result {
// Inject only AFTER the real LocalWorkdirSession successfully committed.
Ok(result) => {
let fault = if is_write {
state.write_fault.lock().unwrap().take()
} else {
None
};
match fault {
Some(WriteResponseFault::Malformed) => (
StatusCode::OK,
[("content-type", "application/json")],
"{broken-response",
)
.into_response(),
Some(WriteResponseFault::LostBody) => {
let body = Body::from_stream(futures::stream::once(async {
Err::<Vec<u8>, _>(std::io::Error::other("response lost after commit"))
}));
AxumResponse::builder()
.status(StatusCode::OK)
.header("content-type", "application/json")
.body(body)
.unwrap()
}
None => AxumJson(result).into_response(),
}
}
Err(error) => provider_error(error),
}
}
async fn close_session(
State(state): State<Arc<ProviderState>>,
Path(id): Path<String>,
uri: Uri,
headers: HeaderMap,
) -> AxumResponse {
if let Err(response) = state.authorize(&headers, "DELETE", &uri) {
return response;
}
if id != SESSION {
return provider_error(WorkdirError::Unavailable("unknown session".into()));
}
match state.session.close().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(error) => provider_error(error),
}
}
struct HttpFixture {
dir: TempDir,
state: Arc<ProviderState>,
base_url: reqwest::Url,
server: tokio::task::JoinHandle<()>,
}
impl Drop for HttpFixture {
fn drop(&mut self) {
self.server.abort();
}
}
impl HttpFixture {
async fn new(capabilities: WorkdirSessionCapabilities) -> Self {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("src/deep")).unwrap();
std::fs::write(
dir.path().join("src/deep/a.txt"),
"first\nneedle needle\nlast\n",
)
.unwrap();
let session = Arc::new(LocalWorkdirSession::materialized(
dir.path().to_path_buf(),
dir.path().to_path_buf(),
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
capabilities,
));
let state = Arc::new(ProviderState {
session,
requests: Mutex::new(Vec::new()),
operations: Mutex::new(Vec::new()),
write_fault: Mutex::new(None),
});
let app = Router::new()
.route(
"/provider/v1/working-directories/{id}/sessions",
post(open_session),
)
.route(
"/provider/v1/workdir-sessions/{id}/operations",
post(operate),
)
.route("/provider/v1/workdir-sessions/{id}", delete(close_session))
.with_state(state.clone());
// Binding before spawn makes readiness deterministic; no sleeps or polling.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}/provider/", listener.local_addr().unwrap())
.parse()
.unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
Self {
dir,
state,
base_url,
server,
}
}
async fn open(&self, token: &str) -> Result<RemoteWorkdirSession, WorkdirError> {
RemoteWorkdirSession::open(
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(5))
.build()
.unwrap(),
self.base_url.clone(),
token.to_owned(),
self.state.session.workdir().id().clone(),
OpenWorkdirSessionRequest::default(),
)
.await
}
fn public_output(&self, text: &str) {
assert!(!text.contains(TOKEN), "bearer token leaked");
assert!(
!text.contains(self.dir.path().to_str().unwrap()),
"host path leaked"
);
assert!(
!text.contains("/provider/"),
"transport prefix leaked into Worldspace"
);
}
fn assert_endpoints(&self) {
let open = format!(
"/provider/v1/working-directories/{}/sessions",
self.state.session.workdir().id()
);
let operations = format!("/provider/v1/workdir-sessions/{SESSION}/operations");
let close = format!("/provider/v1/workdir-sessions/{SESSION}");
let requests = self.state.requests.lock().unwrap();
assert!(requests.contains(&("POST".into(), open.clone())));
assert!(requests.contains(&("POST".into(), operations.clone())));
assert_eq!(
requests
.iter()
.filter(|(method, path)| method == "DELETE" && path == &close)
.count(),
1
);
assert!(requests.iter().all(|(method, path)| (method == "POST"
&& (path == &open || path == &operations))
|| (method == "DELETE" && path == &close)));
}
}
fn native_runtime(remote: Arc<RemoteWorkdirSession>) -> (WipRuntime, Arc<WorkdirSessionRouter>) {
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote)
.unwrap();
let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
.unwrap();
let runtime = WipRuntime::new(
WipHost::new(registry),
SecurityContext::new("http-checkout-worker"),
0,
)
.unwrap();
(runtime, router)
}
async fn observe_interface(runtime: &WipRuntime, path: &str) -> String {
runtime.discover(path.into(), 0, true).await.unwrap();
// Select from the real Client's Known Space, not a direct handler invocation.
let interface = {
let state = runtime.state.lock().unwrap();
state
.client
.known_space(&state.session)
.unwrap()
.into_iter()
.find(|o| o.path == path)
.unwrap()
.object
.unwrap()
.interfaces[0]
.clone()
};
runtime.inspect(interface.clone(), true).await.unwrap();
interface
}
async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value {
let interface = observe_interface(runtime, path).await;
runtime
.call(
path.into(),
interface,
operation.into(),
args,
Default::default(),
)
.await
.unwrap();
let state = runtime.state.lock().unwrap();
match &state
.client
.call_history(&state.session)
.unwrap()
.back()
.unwrap()
.outcome
{
CallOutcome::Success(response) => response.result.clone(),
outcome => panic!("expected native Client success, got {outcome:?}"),
}
}
fn field<'a>(value: &'a Value, name: &str) -> &'a Value {
let Value::Record(fields) = value else {
panic!("expected typed Record")
};
fields.get(name).unwrap()
}
fn first_link(value: &Value) -> &str {
let Value::List(items) = field(value, "items") else {
panic!("expected typed link List")
};
assert_eq!(items.len(), 1);
let Value::String(path) = field(&items[0], "path") else {
panic!("expected typed link path")
};
path
}
async fn watchdog(future: impl std::future::Future<Output = ()>) {
tokio::time::timeout(Duration::from_secs(20), future)
.await
.expect("HTTP checkout test watchdog");
}
#[tokio::test]
async fn checkout_http_native_search_links_read_edit_write_create_and_close() {
watchdog(async {
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
let (runtime, router) = native_runtime(remote);
let glob = native_call(
&runtime,
"/checkouts/main",
"glob",
json!({"pattern":"**/*.txt"}),
)
.await;
let file = first_link(&glob).to_owned();
assert_eq!(file, "/checkouts/main/src/deep/a.txt");
let grep = native_call(
&runtime,
"/checkouts/main/src",
"grep",
json!({
"pattern":"needle", "output_mode":"files_with_matches"
}),
)
.await;
assert_eq!(first_link(&grep), file);
let read = native_call(
&runtime,
first_link(&grep),
"read",
json!({"offset":1,"limit":1}),
)
.await;
let Value::String(content) = field(&read, "content") else {
panic!("expected line Read text")
};
assert_eq!(content, " 2\tneedle needle\n");
let edit = native_call(
&runtime,
&file,
"edit",
json!({
"old_string":"needle", "new_string":"done", "replace_all":true
}),
)
.await;
assert_eq!(
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
"first\ndone done\nlast\n"
);
let write = native_call(&runtime, &file, "write", json!({"content":"saved\n"})).await;
assert_eq!(
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
"saved\n"
);
let create = native_call(
&runtime,
"/checkouts/main/src",
"create_file",
json!({
"path":"new/deeper.txt", "content":"created\n"
}),
)
.await;
assert_eq!(
std::fs::read_to_string(fixture.dir.path().join("src/new/deeper.txt")).unwrap(),
"created\n"
);
let created = native_call(
&runtime,
"/checkouts/main/src/new/deeper.txt",
"read",
json!({}),
)
.await;
for value in [&glob, &grep, &read, &edit, &write, &create, &created] {
fixture.public_output(&serde_json::to_string(&wip_to_json(value).unwrap()).unwrap());
}
let operations = fixture.state.operations.lock().unwrap();
assert!(
operations
.iter()
.any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r)
if matches!(r.operation, CheckoutSearchOperation::Glob(_))))
);
assert!(
operations
.iter()
.any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r)
if matches!(r.operation, CheckoutSearchOperation::Grep(_))))
);
assert!(operations.iter().any(
|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(r)
if matches!(r.operation, CheckoutOperation::Edit { .. }))
));
assert!(operations.iter().any(
|op| matches!(op, WorkdirSessionOperation::CheckoutExecute(r)
if matches!(r.operation, CheckoutOperation::Create { .. }))
));
drop(operations);
assert_eq!(fixture.state.write_calls(), 1);
router
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
.await
.unwrap();
fixture.assert_endpoints();
})
.await;
}
#[tokio::test]
async fn checkout_http_wrong_bearer_and_readonly_caps_fail_closed() {
watchdog(async {
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
let error = fixture.open("wrong-bearer").await.unwrap_err();
assert!(matches!(error, WorkdirError::Denied(_)));
fixture.public_output(&error.to_string());
assert!(fixture.state.operations.lock().unwrap().is_empty());
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
assert_eq!(remote.capabilities(), WorkdirSessionCapabilities::READ_ONLY);
let (runtime, router) = native_runtime(remote.clone());
let file = "/checkouts/main/src/deep/a.txt";
native_call(&runtime, file, "read", json!({})).await;
let interface = observe_interface(&runtime, file).await;
{
let state = runtime.state.lock().unwrap();
let observed = state.client.interface(&state.session, &interface).unwrap();
let descriptor = observed.descriptor.as_ref().unwrap();
assert_eq!(
descriptor
.operations
.iter()
.map(|op| op.name.as_str())
.collect::<Vec<_>>(),
["read"]
);
}
let directory_interface = observe_interface(&runtime, "/checkouts/main").await;
let before = fixture.state.execute_calls();
for (path, interface, operation, args) in [
(
file,
interface.clone(),
"write",
json!({"content":"forbidden"}),
),
(
file,
interface,
"edit",
json!({"old_string":"needle", "new_string":"forbidden"}),
),
(
"/checkouts/main",
directory_interface,
"create_file",
json!({"path":"forbidden.txt","content":"bad"}),
),
] {
let error = runtime
.call(
path.into(),
interface,
operation.into(),
args,
Default::default(),
)
.await
.unwrap_err();
fixture.public_output(&error.to_string());
}
// Preflight may observe objects, but no rejected mutation reaches execution.
assert_eq!(fixture.state.execute_calls(), before);
// A caller bypassing WIP publication still cannot mutate at the provider.
let parent = remote.checkout_observe(WorkdirPath::root()).await.unwrap();
let error = remote
.checkout_execute(CheckoutRequest {
target: parent.path,
validator: parent.validator,
operation: CheckoutOperation::Create {
path: WorkdirPath::new("forbidden.txt").unwrap(),
content: b"bad".to_vec(),
},
})
.await
.unwrap_err();
assert!(matches!(
error,
WorkdirError::Unsupported(_)
| WorkdirError::UnsupportedOperation(_)
| WorkdirError::ReadOnly(_)
| WorkdirError::Denied(_)
));
fixture.public_output(&error.to_string());
assert_eq!(
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
"first\nneedle needle\nlast\n"
);
assert!(!fixture.dir.path().join("forbidden.txt").exists());
router
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
.await
.unwrap();
fixture.assert_endpoints();
})
.await;
}
async fn committed_write_unknown(fault: WriteResponseFault) {
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
let (runtime, router) = native_runtime(remote);
let file = "/checkouts/main/src/deep/a.txt";
native_call(&runtime, file, "read", json!({})).await;
let interface = observe_interface(&runtime, file).await;
*fixture.state.write_fault.lock().unwrap() = Some(fault);
let error = runtime
.call(
file.into(),
interface,
"write".into(),
json!({"content":"committed\n"}),
Default::default(),
)
.await
.unwrap_err();
assert!(error.to_string().contains("outcome unknown"));
assert!(error.to_string().contains("do not retry automatically"));
fixture.public_output(&error.to_string());
assert_eq!(
runtime.audit().last().unwrap().outcome,
WipAuditOutcome::OutcomeUnknown
);
{
let state = runtime.state.lock().unwrap();
assert!(matches!(
state
.client
.call_history(&state.session)
.unwrap()
.back()
.unwrap()
.outcome,
CallOutcome::Unknown { .. }
));
}
assert_eq!(
std::fs::read_to_string(fixture.dir.path().join("src/deep/a.txt")).unwrap(),
"committed\n"
);
assert_eq!(
fixture.state.write_calls(),
1,
"committed Write must not be automatically retried"
);
// Explicit inspection of the effect is safe; it must not replay the mutation.
let read = native_call(&runtime, file, "read", json!({})).await;
fixture.public_output(&serde_json::to_string(&wip_to_json(&read).unwrap()).unwrap());
let Value::String(content) = field(&read, "content") else {
panic!("expected Read text")
};
assert!(content.contains("committed"));
assert_eq!(fixture.state.write_calls(), 1);
router
.detach(&WorkdirAttachmentAlias::new("main").unwrap())
.await
.unwrap();
fixture.assert_endpoints();
}
#[tokio::test]
async fn checkout_http_malformed_response_after_write_is_unknown_without_retry() {
watchdog(committed_write_unknown(WriteResponseFault::Malformed)).await;
}
#[tokio::test]
async fn checkout_http_lost_response_after_write_is_unknown_without_retry() {
watchdog(committed_write_unknown(WriteResponseFault::LostBody)).await;
}
+538
View File
@@ -0,0 +1,538 @@
//! Deterministic attachment races at provider observation boundaries.
//!
//! These tests deliberately avoid spawned tasks, sleeps, and detach attempts
//! inside a routed operation (which is busy and cannot be detached). Deadline
//! tests use the provider's configurable deadline and a permanently pending
//! observation, without Tokio's test-util clock or the production 30-second wait.
use super::*;
use std::future::pending;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use tempfile::TempDir;
use workdir::{
CheckoutRequest, CheckoutResult, CommandHandle, CommandOutput, CommandOutputRequest,
CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
GrepResult, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult, ReadRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias, WorkdirError,
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
};
type ObserveHook = Box<dyn FnOnce(&WorkdirPath) + Send>;
/// Shared local provider with a one-shot, synchronous observation boundary.
/// Take the callback out of the lock before invoking it: it may call the router.
struct HookedSession {
local: LocalWorkdirSession,
observe_hook: Mutex<Option<ObserveHook>>,
stall_path: Mutex<Option<WorkdirPath>>,
stalled_observations: AtomicUsize,
}
impl std::fmt::Debug for HookedSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HookedSession")
.field("local", &self.local)
.field("stall_path", &self.stall_path)
.field("stalled_observations", &self.stalled_observations)
.finish_non_exhaustive()
}
}
impl HookedSession {
fn new(dir: &TempDir) -> Arc<Self> {
Arc::new(Self {
local: LocalWorkdirSession::materialized(
dir.path().to_path_buf(),
dir.path().to_path_buf(),
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
WorkdirSessionCapabilities::READ_WRITE,
),
observe_hook: Mutex::new(None),
stall_path: Mutex::new(None),
stalled_observations: AtomicUsize::new(0),
})
}
fn on_next_observe(&self, hook: impl FnOnce(&WorkdirPath) + Send + 'static) {
let mut slot = self.observe_hook.lock().unwrap();
assert!(slot.is_none(), "an observation hook is already armed");
*slot = Some(Box::new(hook));
}
fn stall_observation(&self, path: Option<WorkdirPath>) {
*self.stall_path.lock().unwrap() = path;
}
}
#[async_trait]
impl WorkdirSession for HookedSession {
fn workdir(&self) -> &Workdir {
self.local.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.local.capabilities()
}
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
self.local.authorize_scope_path(request).await
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
self.local.scope_rules_overlap(request).await
}
async fn checkout_observe(
&self,
path: WorkdirPath,
) -> Result<CheckoutObservation, WorkdirError> {
let hook = { self.observe_hook.lock().unwrap().take() };
if let Some(hook) = hook {
hook(&path);
}
let should_stall = {
self.stall_path
.lock()
.unwrap()
.as_ref()
.is_some_and(|stall| stall.as_str() == path.as_str())
};
if should_stall {
self.stalled_observations.fetch_add(1, Ordering::SeqCst);
return pending().await;
}
self.local.checkout_observe(path).await
}
async fn checkout_execute(
&self,
request: CheckoutRequest,
) -> Result<CheckoutResult, WorkdirError> {
self.local.checkout_execute(request).await
}
async fn checkout_search(
&self,
request: workdir::CheckoutSearchRequest,
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
self.local.checkout_search(request).await
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.local.stat(request).await
}
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
self.local.read(request).await
}
async fn read_bytes(&self, request: ReadBytesRequest) -> Result<ReadBytesResult, WorkdirError> {
self.local.read_bytes(request).await
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
self.local.write(request).await
}
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
self.local.edit(request).await
}
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
self.local.list(request).await
}
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
self.local.glob(request).await
}
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
self.local.grep(request).await
}
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
self.local.start_command(request).await
}
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
self.local.command_status(handle).await
}
async fn command_output(
&self,
request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
self.local.command_output(request).await
}
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
self.local.cancel_command(handle).await
}
async fn close(&self) -> Result<(), WorkdirError> {
self.local.close().await
}
}
fn provider(router: Arc<WorkdirSessionRouter>) -> Arc<Provider> {
Arc::new(Provider {
router,
tracker: tools::Tracker::new(),
permissions: None,
incarnation: "checkout-race-tests".into(),
permits: Arc::new(tokio::sync::Semaphore::new(16)),
deadline: DEADLINE,
})
}
#[tokio::test]
async fn checkout_wip_file_operations_share_core_and_keep_prior_read_boundary() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("text.txt"), "旧 旧\n").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
HookedSession::new(&dir),
)
.unwrap();
let provider = provider(router);
let route = format!("{}/text.txt", checkout_root("main"));
let projection = provider.node(&route).await.unwrap().unwrap();
let invalid = BTreeMap::from([
("old_string".into(), Value::String("旧".into())),
("new_string".into(), Value::String("旧".into())),
]);
assert!(
matches!(projection.handler.call("edit", &invalid, context()).await,
Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::InvalidArguments)
);
let edit = BTreeMap::from([
("old_string".into(), Value::String("旧".into())),
("new_string".into(), Value::String("新".into())),
("replace_all".into(), Value::Boolean(true)),
]);
// Valid shared arguments do not bypass the provider's Read requirement.
assert!(
projection
.handler
.call("edit", &edit, context())
.await
.is_err()
);
assert_eq!(
std::fs::read_to_string(dir.path().join("text.txt")).unwrap(),
"旧 旧\n"
);
projection
.handler
.call("read", &BTreeMap::new(), context())
.await
.unwrap_or_else(|_| panic!("shared read failed"));
projection
.handler
.call("edit", &edit, context())
.await
.unwrap_or_else(|_| panic!("shared edit failed after read"));
assert_eq!(
std::fs::read_to_string(dir.path().join("text.txt")).unwrap(),
"新 新\n"
);
let projection = provider.node(&route).await.unwrap().unwrap();
projection
.handler
.call(
"write",
&BTreeMap::from([("content".into(), Value::String("written\n".into()))]),
context(),
)
.await
.unwrap_or_else(|_| panic!("shared write failed after read/edit"));
assert_eq!(
std::fs::read_to_string(dir.path().join("text.txt")).unwrap(),
"written\n"
);
}
fn deadline_provider(router: Arc<WorkdirSessionRouter>) -> Arc<Provider> {
let mut p = provider(router);
let settings = Arc::get_mut(&mut p).unwrap();
// Allow ample time for the tiny local search before stalling post-processing.
// The outer test watchdog is intentionally much more generous than this.
settings.deadline = Duration::from_millis(100);
settings.permits = Arc::new(tokio::sync::Semaphore::new(1));
p
}
fn context() -> WipCallContext {
WipCallContext {
execution: Default::default(),
security_context: "checkout-race-tests".into(),
}
}
fn assert_validator_mismatch<T>(result: Result<T, WipOperationError>) {
match result {
Err(WipOperationError::Protocol(e)) => {
assert_eq!(e.code, ProtocolErrorCode::ValidatorMismatch);
}
Err(_) => panic!("expected ValidatorMismatch, received a non-protocol error"),
Ok(_) => panic!("a changed attachment snapshot must not be published"),
}
}
#[tokio::test]
async fn collection_list_rejects_alias_attached_during_observation_and_retries_coherently() {
let main_dir = TempDir::new().unwrap();
let extra_dir = TempDir::new().unwrap();
let main = HookedSession::new(&main_dir);
let extra: WorkdirSessionHandle = HookedSession::new(&extra_dir);
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(WorkdirAttachmentAlias::new("main").unwrap(), main.clone())
.unwrap();
let p = provider(router.clone());
let before = p.collection().object.validator;
let hook_router = router.clone();
main.on_next_observe(move |path| {
assert!(path.is_root(), "collection enumeration observes the root");
hook_router
.attach(WorkdirAttachmentAlias::new("extra").unwrap(), extra)
.unwrap();
});
let collection = Collection {
provider: p.clone(),
};
assert_validator_mismatch(collection.call("list", &BTreeMap::new(), context()).await);
assert_ne!(before, p.collection().object.validator);
assert!(router.resolve(Some("extra")).is_ok(), "the hook must run");
// The callback was consumed. A fresh snapshot includes both aliases, with
// identity metadata from exactly the router state being enumerated.
let output = match collection.call("list", &BTreeMap::new(), context()).await {
Ok(output) => output,
Err(_) => panic!("the stable retry must succeed"),
};
let value = wip_to_json(&output.value).unwrap();
let items = value["items"].as_array().unwrap();
assert_eq!(items.len(), 2);
for alias in ["extra", "main"] {
let item = items
.iter()
.find(|item| item["alias"] == json!(alias))
.expect("each attached alias must appear in the stable snapshot");
let selected = router.resolve(Some(alias)).unwrap();
assert_eq!(item["alias"], json!(alias));
assert_eq!(item["path"], json!(checkout_root(alias)));
assert_eq!(item["slug"], json!(encode_identity(alias)));
assert_eq!(item["generation"], json!(selected.generation));
assert_eq!(
item["working_directory_id"],
json!(selected.session.workdir().id().as_str())
);
}
}
#[tokio::test]
async fn root_enumeration_rejects_alias_attached_during_observation() {
let main_dir = TempDir::new().unwrap();
let extra_dir = TempDir::new().unwrap();
let main = HookedSession::new(&main_dir);
let extra: WorkdirSessionHandle = HookedSession::new(&extra_dir);
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(WorkdirAttachmentAlias::new("main").unwrap(), main.clone())
.unwrap();
let hook_router = router.clone();
main.on_next_observe(move |_| {
hook_router
.attach(WorkdirAttachmentAlias::new("extra").unwrap(), extra)
.unwrap();
});
let subtree = Arc::new(provider(router.clone()));
match subtree.children(ROOT).await {
Err(e) => assert_eq!(e.code, ProtocolErrorCode::ValidatorMismatch),
Ok(_) => panic!("enumeration must reject a mixed attachment snapshot"),
}
assert!(router.resolve(Some("extra")).is_ok(), "the hook must run");
let mut paths = subtree.children(ROOT).await.unwrap();
paths.sort();
assert_eq!(paths, vec![checkout_root("extra"), checkout_root("main")]);
}
#[tokio::test]
async fn file_handler_connection_fences_detach_and_same_session_alias_reuse() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("file.txt"), "unchanged\n").unwrap();
let session: WorkdirSessionHandle = HookedSession::new(&dir);
let router = Arc::new(WorkdirSessionRouter::new());
let alias = WorkdirAttachmentAlias::new("main").unwrap();
router.attach(alias.clone(), session.clone()).unwrap();
let selected = router.resolve(Some("main")).unwrap();
let target = WorkdirPath::new("file.txt").unwrap();
let observation = selected
.session
.checkout_observe(target.clone())
.await
.unwrap();
let mut handler = FileHandler {
provider: provider(router.clone()),
alias: "main".into(),
generation: selected.generation,
workdir: selected.session.workdir().id().as_str().into(),
target,
observation,
};
drop(selected);
assert!(handler.check_connection().is_ok());
// No routed operation is active here. Reuse the exact same provider identity
// to ensure generation, not changed workdir ID/content, causes rejection.
// Detach may close the local session; no subsequent filesystem operation is
// made on it. This test only exercises the router's connection identity.
router.detach(&alias).await.unwrap();
assert_validator_mismatch(handler.check_connection());
router.attach(alias, session).unwrap();
let replacement = router.resolve(Some("main")).unwrap();
assert_ne!(replacement.generation, handler.generation);
assert_eq!(replacement.session.workdir().id().as_str(), handler.workdir);
assert_validator_mismatch(handler.check_connection());
// Positive control: the new generation is accepted with the same identity.
handler.generation = replacement.generation;
assert!(handler.check_connection().is_ok());
}
fn assert_deadline<T>(result: Result<T, WipOperationError>, message: &str) {
match result {
Err(WipOperationError::Protocol(e)) => {
assert_eq!(e.code, ProtocolErrorCode::ResourceLimitExceeded);
assert_eq!(e.message, message);
}
Err(_) => panic!("expected a protocol deadline error"),
Ok(_) => panic!("a stalled provider observation must time out"),
}
}
fn assert_permit_released(p: &Provider) {
assert_eq!(p.permits.available_permits(), 1);
let permit = p
.permits
.try_acquire()
.expect("the timed-out operation must release its permit");
drop(permit);
}
#[tokio::test]
async fn collection_list_and_root_enumeration_bound_stalled_observation_and_release_permit() {
let dir = TempDir::new().unwrap();
let session = HookedSession::new(&dir);
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session.clone(),
)
.unwrap();
session.stall_observation(Some(WorkdirPath::new("").unwrap()));
let p = deadline_provider(router);
let collection = Collection {
provider: p.clone(),
};
// The outer watchdog prevents a missing production timeout from hanging the
// suite. The exact protocol message proves the inner enumeration timed out.
let result = tokio::time::timeout(
Duration::from_secs(2),
collection.call("list", &BTreeMap::new(), context()),
)
.await
.expect("Collection.list must be bounded by the provider deadline");
assert_deadline(result, "checkout enumeration deadline");
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1);
assert_permit_released(&p);
// Exercise the subtree entry point independently with the stall still armed.
let subtree = Arc::new(p.clone());
let result = tokio::time::timeout(Duration::from_secs(2), subtree.children(ROOT))
.await
.expect("root enumeration must be bounded by the provider deadline")
.map_err(WipOperationError::Protocol);
assert_deadline(result, "checkout enumeration deadline");
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 2);
assert_permit_released(&p);
// Removing the stall makes the same provider usable immediately, without
// resetting its semaphore or waiting for any background task to finish.
session.stall_observation(None);
let paths = tokio::time::timeout(Duration::from_secs(2), subtree.children(ROOT))
.await
.expect("enumeration must remain usable after cancellation")
.unwrap();
assert_eq!(paths, vec![checkout_root("main")]);
assert_permit_released(&p);
}
#[tokio::test]
async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_permit() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap();
let session = HookedSession::new(&dir);
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session.clone(),
)
.unwrap();
let p = deadline_provider(router);
let root = checkout_root("main");
let projection = p.node(&root).await.unwrap().unwrap();
// Directory observations and the normal local grep/checkout execution can
// complete. Only publishing the typed search result path gets stuck.
session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap()));
let arguments = BTreeMap::from([
("pattern".into(), Value::String("needle".into())),
(
"output_mode".into(),
Value::String("files_with_matches".into()),
),
]);
let result = tokio::time::timeout(
Duration::from_secs(2),
projection.handler.call("grep", &arguments, context()),
)
.await
.expect("post-search link observations must share the operation deadline");
assert_deadline(result, "checkout result deadline");
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1);
assert_permit_released(&p);
// A successful retry both checks cancellation cleanup and proves that the
// delegated search produces the typed path consumed by post-processing.
session.stall_observation(None);
let result = tokio::time::timeout(
Duration::from_secs(2),
projection.handler.call("grep", &arguments, context()),
)
.await
.expect("grep must remain usable after cancelling result publication");
let output = match result {
Ok(output) => output,
Err(_) => panic!("grep must succeed once the link observation is unstalled"),
};
let value = wip_to_json(&output.value).unwrap();
assert_eq!(value["items"], json!([{"path": format!("{root}/a.txt")}]));
assert_permit_released(&p);
}
+656
View File
@@ -0,0 +1,656 @@
//! Exercises native checkout interactions through the real WIP Client/HTTP codec.
use super::*;
use tempfile::TempDir;
use workdir::{
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionCapabilities, WorkdirSessionHandle,
WorkdirSessionRouter,
};
fn session(dir: &TempDir, read_only: bool) -> WorkdirSessionHandle {
let caps = if read_only {
WorkdirSessionCapabilities::READ_ONLY
} else {
WorkdirSessionCapabilities::READ_WRITE
};
Arc::new(LocalWorkdirSession::materialized(
dir.path().to_path_buf(),
dir.path().to_path_buf(),
manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap()),
caps,
))
}
fn runtime(
router: Arc<WorkdirSessionRouter>,
permissions: Option<ToolPermissionConfig>,
) -> WipRuntime {
let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), permissions)
.unwrap();
WipRuntime::new(
WipHost::new(registry),
SecurityContext::new("checkout-test-worker"),
0,
)
.unwrap()
}
async fn interface(r: &WipRuntime, path: &str) -> String {
r.discover(path.into(), 0, true).await.unwrap();
let p = r.host.projection_live(path).await.unwrap().unwrap();
r.inspect(p.interface.clone(), true).await.unwrap();
p.interface
}
async fn call(
r: &WipRuntime,
path: &str,
operation: &str,
args: Json,
) -> Result<ToolOutput, ToolError> {
let i = interface(r, path).await;
r.call(path.into(), i, operation.into(), args, Default::default())
.await
}
#[tokio::test]
async fn checkout_wip_search_preserves_root_and_nested_ignore_rules_and_links() {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
std::fs::create_dir(dir.path().join("nested")).unwrap();
for (name, content) in [
(".ignore", "root-denied.txt\npriority.txt\n"),
(".gitignore", "git-denied.txt\n"),
("nested/.gitignore", "!priority.txt\nnested-denied.txt\n"),
] {
std::fs::write(dir.path().join(name), content).unwrap();
}
for name in [
"visible.txt",
"root-denied.txt",
"git-denied.txt",
"nested/visible.txt",
"nested/root-denied.txt",
"nested/git-denied.txt",
"nested/priority.txt",
"nested/nested-denied.txt",
] {
std::fs::write(dir.path().join(name), "needle fixture\n").unwrap();
}
let source = session(&dir, false);
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(WorkdirAttachmentAlias::new("main").unwrap(), source.clone())
.unwrap();
let r = runtime(router, None);
for base in ["", "nested"] {
let ordinary = source
.glob(workdir::GlobRequest {
path: workdir::WorkdirPath::new(base).unwrap(),
pattern: "**/*.txt".into(),
limit: 100,
})
.await
.unwrap();
let expected = ordinary
.paths
.iter()
.map(|path| format!("/checkouts/main/{}", path.as_str()))
.collect::<Vec<_>>();
assert_eq!(expected.len(), if base.is_empty() { 2 } else { 1 });
let route = if base.is_empty() {
"/checkouts/main"
} else {
"/checkouts/main/nested"
};
for (operation, args) in [
("glob", json!({"pattern":"**/*.txt"})),
(
"grep",
json!({"pattern":"needle","output_mode":"files_with_matches"}),
),
] {
let output = call(&r, route, operation, args).await.unwrap();
let result: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
let mut paths = result["items"]
.as_array()
.unwrap()
.iter()
.map(|item| item["path"].as_str().unwrap().to_string())
.collect::<Vec<_>>();
// Grep retains traversal order; Glob sorts its result paths.
paths.sort();
assert_eq!(paths, expected, "{operation} at {route}");
for path in paths {
assert!(r.host.projection_live(&path).await.unwrap().is_some());
}
assert!(!output.content.unwrap().contains("denied.txt"));
}
}
}
#[tokio::test]
async fn checkout_wip_native_roundtrip_search_read_edit_write_create() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("src/deep")).unwrap();
std::fs::write(
dir.path().join("src/deep/a.txt"),
"first\nneedle needle\nlast\n",
)
.unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session(&dir, false),
)
.unwrap();
let r = runtime(router, None);
let root = r.host.observe_live("/", 2).await.unwrap();
assert_eq!(root.children.unwrap()[0].object.name, "checkouts");
let file = "/checkouts/main/src/deep/a.txt";
let glob = call(&r, "/checkouts/main", "glob", json!({"pattern":"**/*.txt"}))
.await
.unwrap();
assert!(glob.content.unwrap().contains(file));
let grep = call(
&r,
"/checkouts/main/src",
"grep",
json!({"pattern":"needle","output_mode":"files_with_matches"}),
)
.await
.unwrap();
let grep_result: Json = serde_json::from_str(grep.content.as_deref().unwrap()).unwrap();
assert_eq!(grep_result["items"][0]["path"], file);
let followed = grep_result["items"][0]["path"].as_str().unwrap();
assert_eq!(followed, file);
assert!(
call(
&r,
file,
"edit",
json!({"old_string":"needle","new_string":"done"})
)
.await
.is_err()
);
let read = call(&r, file, "read", json!({"offset":1,"limit":1}))
.await
.unwrap();
assert!(read.content.unwrap().contains("needle needle"));
assert!(
call(
&r,
file,
"edit",
json!({"old_string":"needle","new_string":"done"})
)
.await
.is_err()
);
call(
&r,
file,
"edit",
json!({"old_string":"needle","new_string":"done","replace_all":true}),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("src/deep/a.txt")).unwrap(),
"first\ndone done\nlast\n"
);
call(&r, file, "write", json!({"content":"saved\n"}))
.await
.unwrap();
let reread = call(&r, file, "read", json!({})).await.unwrap();
assert!(reread.content.unwrap().contains("saved"));
call(
&r,
"/checkouts/main/src",
"create_file",
json!({"path":"new/deeper.txt","content":"created\n"}),
)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("src/new/deeper.txt")).unwrap(),
"created\n"
);
assert!(
call(
&r,
"/checkouts/main/src",
"create_file",
json!({"path":"new/deeper.txt","content":"overwrite"})
)
.await
.is_err()
);
call(&r, "/checkouts/main/src/new/deeper.txt", "read", json!({}))
.await
.unwrap();
}
#[tokio::test]
async fn checkout_wip_multiple_aliases_read_only_and_escape_rejection() {
let a = TempDir::new().unwrap();
let b = TempDir::new().unwrap();
std::fs::write(a.path().join("a.txt"), "A").unwrap();
std::fs::write(b.path().join("b.txt"), "B").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("a").unwrap(),
session(&a, false),
)
.unwrap();
router
.attach(WorkdirAttachmentAlias::new("b").unwrap(), session(&b, true))
.unwrap();
let r = runtime(router, None);
let observed = r.host.observe_live("/checkouts", 2).await.unwrap();
assert_eq!(observed.children.unwrap().len(), 2);
let bfile = r
.host
.projection_live("/checkouts/b/b.txt")
.await
.unwrap()
.unwrap();
assert_eq!(
bfile
.descriptor
.operations
.iter()
.map(|o| o.name.as_str())
.collect::<Vec<_>>(),
vec!["read"]
);
assert!(
r.host
.projection_live("/checkouts/b/a.txt")
.await
.unwrap()
.is_none()
);
assert!(
r.host
.projection_live("/checkouts/a/../b/b.txt")
.await
.is_err()
);
assert!(
call(
&r,
"/checkouts/a",
"glob",
json!({"pattern":"*","path":"../b"})
)
.await
.is_err()
);
assert!(
call(
&r,
"/checkouts/a",
"create_file",
json!({"path":"/outside.txt","content":"bad"})
)
.await
.is_err()
);
assert!(
call(
&r,
"/checkouts/a/a.txt",
"read",
json!({"target_workdir":"b"})
)
.await
.is_err()
);
assert!(
call(
&r,
"/checkouts/a/a.txt",
"read",
json!({"file_path":"b.txt"})
)
.await
.is_err()
);
}
#[tokio::test]
async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
let a = TempDir::new().unwrap();
let b = TempDir::new().unwrap();
std::fs::write(a.path().join("file.txt"), "same").unwrap();
std::fs::write(b.path().join("file.txt"), "same").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
let alias = WorkdirAttachmentAlias::new("main").unwrap();
router.attach(alias.clone(), session(&a, false)).unwrap();
let r = runtime(router.clone(), None);
let path = "/checkouts/main/file.txt";
call(&r, path, "read", json!({})).await.unwrap();
let old = r.host.projection_live(path).await.unwrap().unwrap();
router.detach(&alias).await.unwrap();
assert!(r.host.projection_live(path).await.unwrap().is_none());
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
router.attach(alias, session(&b, false)).unwrap();
let new = r.host.projection_live(path).await.unwrap().unwrap();
assert_ne!(old.interface, new.interface);
assert_ne!(old.object.validator, new.object.validator);
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
assert!(
call(
&r,
path,
"write",
json!({"content":"must read new attachment"})
)
.await
.is_err()
);
let restored = runtime(router, None);
let next = restored.host.projection_live(path).await.unwrap().unwrap();
assert_ne!(new.interface, next.interface);
assert_ne!(new.object.validator, next.object.validator);
assert!(
restored
.host
.fetch_interface_live(&new.interface)
.await
.is_err()
);
assert_eq!(
std::fs::read_to_string(b.path().join("file.txt")).unwrap(),
"same"
);
}
#[tokio::test]
async fn checkout_wip_provider_validator_rejects_same_content_inode_substitution() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("file.txt"), "same").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session(&dir, false),
)
.unwrap();
let r = runtime(router, None);
let path = "/checkouts/main/file.txt";
call(&r, path, "read", json!({})).await.unwrap();
let p = r.host.projection_live(path).await.unwrap().unwrap();
std::fs::write(dir.path().join("replacement"), "same").unwrap();
std::fs::rename(dir.path().join("replacement"), dir.path().join("file.txt")).unwrap();
// Call the captured handler directly: provider must reject even if Host validation was earlier.
let out = p
.handler
.call(
"write",
&BTreeMap::from([("content".into(), Value::String("bad".into()))]),
WipCallContext {
execution: Default::default(),
security_context: "checkout-test-worker".into(),
},
)
.await;
assert!(matches!(
out,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::ValidatorMismatch,
..
}))
));
assert_eq!(
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
"same"
);
}
#[tokio::test]
async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_replaced() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("file.txt"), "secret").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session(&dir, false),
)
.unwrap();
let permissions = ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: Vec::new(),
};
let r = runtime(router.clone(), Some(permissions));
assert!(
r.host
.projection_live("/checkouts/main/file.txt")
.await
.unwrap()
.is_none()
);
assert!(
r.host
.observe_live("/checkouts", 1)
.await
.unwrap()
.children
.unwrap()
.is_empty()
);
let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
.unwrap();
for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) {
let (meta, tool) = def();
let p = compatibility_projection(meta, tool, None).unwrap();
registry.mount(p).unwrap();
}
for tool in ["Read", "Edit", "Write", "Glob", "Grep"] {
assert!(!registry.routes().any(|p| p == format!("/tools/{tool}")));
}
assert!(registry.routes().any(|p| p == "/tools/Bash"));
}
#[tokio::test]
async fn checkout_wip_bounded_observation_and_deep_subtree_collision() {
let dir = TempDir::new().unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session(&dir, false),
)
.unwrap();
let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap();
let placeholder = registry.mounts["/checkouts"].resolved();
let mut collision = placeholder.clone();
collision.route = "/checkouts/main/a".into();
collision.object.name = "a".into();
assert!(matches!(
registry.mount(collision),
Err(WipMountError::RouteCollision { .. })
));
let host = WipHost::new(registry);
assert_eq!(
host.observe_live("/checkouts", 33).await.unwrap_err().code,
ProtocolErrorCode::ResourceLimitExceeded
);
}
#[tokio::test]
async fn checkout_wip_post_operation_validators_support_cached_chains() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("file.txt"), "old\n").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session(&dir, false),
)
.unwrap();
let r = runtime(router, None);
let path = "/checkouts/main/file.txt";
let i = interface(&r, path).await;
// No rediscovery or Interface refresh between any of these operations.
for (op, args) in [
("read", json!({})),
("edit", json!({"old_string":"old","new_string":"edited"})),
("write", json!({"content":"saved\n"})),
("read", json!({})),
] {
r.call(path.into(), i.clone(), op.into(), args, Default::default())
.await
.unwrap();
}
assert_eq!(
std::fs::read_to_string(dir.path().join("file.txt")).unwrap(),
"saved\n"
);
let parent = "/checkouts/main";
let i = interface(&r, parent).await;
for name in ["one.txt", "nested/two.txt"] {
r.call(
parent.into(),
i.clone(),
"create_file".into(),
json!({"path":name,"content":"new"}),
Default::default(),
)
.await
.unwrap();
}
assert_eq!(
std::fs::read_to_string(dir.path().join("nested/two.txt")).unwrap(),
"new"
);
}
#[tokio::test]
async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
use workdir::{
WorkdirPath, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission as Permission,
WorkdirToolScopeRule,
};
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("child")).unwrap();
std::fs::write(dir.path().join("child/a.txt"), "visible needle").unwrap();
std::fs::write(dir.path().join("private-sibling-name.txt"), "hidden needle").unwrap();
let broker = Arc::new(WorkdirToolBroker::new(session(&dir, false)));
let lease = broker
.scope(WorkdirToolScope {
rules: vec![WorkdirToolScopeRule {
target: WorkdirPath::new("child").unwrap(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}],
cwd: WorkdirPath::new("child").unwrap(),
command: false,
})
.await
.unwrap();
let parent_router = Arc::new(WorkdirSessionRouter::new());
parent_router
.attach(
WorkdirAttachmentAlias::new("parent").unwrap(),
broker.tool_session(),
)
.unwrap();
let parent = runtime(parent_router, None);
let parent_file = parent
.host
.projection_live("/checkouts/parent/child/a.txt")
.await
.unwrap()
.unwrap();
assert!(
!parent_file
.descriptor
.operations
.iter()
.any(|op| op.name == "write" || op.name == "edit")
);
let child_router = Arc::new(WorkdirSessionRouter::new());
child_router
.attach(
WorkdirAttachmentAlias::new("child").unwrap(),
lease.tool_session(),
)
.unwrap();
let child = runtime(child_router, None);
let observed = child
.host
.observe_live("/checkouts/child", 2)
.await
.unwrap();
assert_eq!(observed.children.unwrap().len(), 1);
for (op, args) in [
("glob", json!({"pattern":"**/*"})),
(
"grep",
json!({"pattern":"needle","output_mode":"files_with_matches"}),
),
] {
let output = call(&child, "/checkouts/child", op, args)
.await
.unwrap()
.content
.unwrap();
assert!(output.contains("/checkouts/child/a.txt"));
assert!(!output.contains("private-sibling-name"));
assert!(!output.contains("hidden needle"));
}
assert!(
child
.host
.projection_live("/checkouts/child/private-sibling-name.txt")
.await
.unwrap()
.is_none()
);
let file = "/checkouts/child/a.txt";
let i = interface(&child, file).await;
child
.call(
file.into(),
i.clone(),
"read".into(),
json!({}),
Default::default(),
)
.await
.unwrap();
child
.call(
file.into(),
i.clone(),
"write".into(),
json!({"content":"changed"}),
Default::default(),
)
.await
.unwrap();
lease.close().await.unwrap();
assert!(child.host.projection_live(file).await.unwrap().is_none());
assert!(
child
.call(
file.into(),
i,
"write".into(),
json!({"content":"after revoke"}),
Default::default()
)
.await
.is_err()
);
assert_eq!(
std::fs::read_to_string(dir.path().join("child/a.txt")).unwrap(),
"changed"
);
}
+267 -41
View File
@@ -64,6 +64,20 @@ pub struct WorkerHandle {
}
impl WorkerHandle {
/// Transport closure belongs to this Controller endpoint, not a WorkerRef.
pub fn protocol_is_closed(&self) -> bool {
self.method_tx.is_closed()
}
pub async fn protocol_closed(&self) {
self.method_tx.closed().await;
}
/// Compare actual endpoints when an execution-bound transport is dispatched.
pub fn same_controller(&self, other: &Self) -> bool {
self.method_tx.same_channel(&other.method_tx)
}
pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError<Method>> {
// Reject known-busy sends before channel dispatch, including maintenance
// that cannot receive methods until it finishes. The controller checks
@@ -184,6 +198,7 @@ impl WorkerHandle {
&self,
kind: protocol::CompletionKind,
prefix: &str,
context: Option<&protocol::CompletionContext>,
) -> Vec<protocol::CompletionEntry> {
match kind {
protocol::CompletionKind::File => {
@@ -196,9 +211,62 @@ impl WorkerHandle {
.map(|candidate| protocol::CompletionEntry {
value: candidate.path,
is_dir: candidate.is_dir,
description: None,
usage: None,
invocation: None,
})
.collect()
}
protocol::CompletionKind::Feature => self
.shared_state
.feature_invocations()
.feature_completions(prefix),
protocol::CompletionKind::FeatureArgument => {
let Some(context) = context else {
return Vec::new();
};
let registry = self.shared_state.feature_invocations();
let Some(argument) = context.argument.as_deref() else {
return registry.argument_name_completions(&context.invocation, prefix);
};
let worker_file = registry
.descriptor(&context.invocation)
.and_then(|descriptor| {
descriptor
.arguments
.iter()
.find(|candidate| candidate.name == argument)
})
.is_some_and(|descriptor| {
matches!(
descriptor.completion,
protocol::InvocationCompletion::WorkerFile
)
});
if worker_file {
let Some(view) = self.shared_state.fs_view() else {
return Vec::new();
};
let candidates = view.list_file_completions(prefix).await;
if registry.descriptor(&context.invocation).is_none() {
return Vec::new();
}
return crate::feature::invocation::bounded_completion_entries(
candidates
.into_iter()
.map(|candidate| protocol::CompletionEntry {
value: candidate.path,
is_dir: candidate.is_dir,
description: None,
usage: None,
invocation: None,
}),
);
}
registry
.argument_completions(&context.invocation, argument, prefix)
.await
}
}
}
@@ -435,11 +503,29 @@ fn stage_pending_notifications<St: Store + Clone>(
count
}
/// The durable input append is the activation boundary. Clear live state in
/// the same poll, before invocation preparation can checkpoint it again; the
/// oneshot only publishes the already-completed transition to the Controller.
pub(crate) fn input_committed_hook<St: Store + Clone>(
pending: crate::worker::PendingSubmissionHandle<St>,
submission_id: String,
sender: oneshot::Sender<()>,
) -> impl FnOnce() {
move || {
pending.finish_activation(&submission_id);
let _ = sender.send(());
}
}
fn notification_coalesce_remaining<St: Store + Clone>(
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
notify_buffer: &NotifyBuffer,
delay: Duration,
can_schedule_run: bool,
) -> Option<Duration> {
if !can_schedule_run {
return None;
}
let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms()
else {
return notify_buffer
@@ -801,9 +887,6 @@ impl WorkerController {
None,
)
.await?;
let command_observer = fs_for_view
.as_ref()
.and_then(|session| wire_workdir_command_events(session, &in_flight));
// Intake role Workers self-terminate only after a successful
// TicketIntakeReady turn has fully settled back to Idle. The request
@@ -846,6 +929,7 @@ impl WorkerController {
manifest_toml.clone(),
greeting,
));
shared_state.set_feature_invocations(worker.feature_invocations());
let usage_state = shared_state.clone();
let usage_events = working_event_tx.clone();
worker.engine_mut().on_usage(move |event| {
@@ -856,8 +940,8 @@ impl WorkerController {
protocol::ContextTokenSource::Measured,
);
});
if let Some(fs_for_view) = fs_for_view {
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
if let Some(fs_for_view) = fs_for_view.as_ref() {
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view.clone()));
}
runtime_dir.write_manifest(&manifest_toml).await?;
runtime_dir.write_status(&shared_state).await?;
@@ -879,8 +963,23 @@ impl WorkerController {
pending_activations,
};
// Do not start an observer before fallible head/recovery/runtime-file
// initialization. The only remaining fallible step is socket startup,
// whose error path must abort AND join this task before returning.
let command_observer = fs_for_view
.as_ref()
.and_then(|session| wire_workdir_command_events(session, &in_flight));
let socket_server = match transport {
WorkerControllerTransport::UnixSocket => Some(SocketServer::start(&handle).await?),
WorkerControllerTransport::UnixSocket => match SocketServer::start(&handle).await {
Ok(server) => Some(server),
Err(error) => {
if let Some(observer) = command_observer {
observer.abort();
let _ = observer.await;
}
return Err(error);
}
},
WorkerControllerTransport::InProcess => None,
};
@@ -1277,10 +1376,12 @@ where
),
None => durable_parent_notifications,
};
let backend_job_profile = worker.manifest().engine.instruction == "internal.backend_job_system";
let backend_job = worker.backend_job().cloned();
// Bounded Jobs complete their lifecycle synchronously, never by post-run background work.
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
let prompts = worker.prompts().clone();
let tracker = tools::Tracker::new();
if !backend_job_profile {
{
let paste_store = worker.store().clone();
let paste_session_id = worker.session_id();
worker
@@ -1327,7 +1428,9 @@ where
let worker_enabled = feature_config.worker.enabled;
let sub_worker_enabled = feature_config.sub_worker.enabled;
let mut feature_registry = FeatureRegistryBuilder::new();
if backend_job_profile {
feature_registry
.add_module(crate::feature::builtin::chat_invocation::AttachmentInvocationFeature);
if let Some(binding) = backend_job.as_ref() {
let workspace_client = worker.workspace_client_handle();
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
return Err(std::io::Error::new(
@@ -1335,11 +1438,18 @@ where
"Backend Job result capability requires Backend Workspace API authority",
));
}
feature_registry.add_module(
let mut result_feature =
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
workspace_client,
),
workspace_client.clone(),
);
if binding.subjektiv_consolidation {
let surface = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::for_job(
workspace_client, spawner_manifest.clone(), worker.llm_client_handle(),
prompts.clone(), spawner_workspace_context.clone(),
)?;
result_feature = result_feature.with_surface(surface);
}
feature_registry.add_module(result_feature);
}
let memory_profile = &worker.manifest().feature.memory.profile;
let subjektiv_profile = &worker.manifest().feature.subjektiv.profile;
@@ -1352,7 +1462,11 @@ where
let mut feature_prompt_contribution = memory_install_plan.as_ref().map(|plan| {
(
plan.resident_summary_source.clone(),
plan.system_prompt_override.clone(),
if backend_job.is_none() {
plan.system_prompt_override.clone()
} else {
None
},
)
});
let memory_lifecycle_config = worker.manifest().feature.memory.clone();
@@ -1366,7 +1480,9 @@ where
worker.prompts().load_full(),
)?;
if let Some(plan) = subjektiv_consolidation_plan {
if backend_job.is_none() {
feature_prompt_contribution = Some((None, Some(plan.system_prompt_override)));
}
feature_registry.add_module(plan.module);
}
let ordinary_subjektiv_features_enabled =
@@ -1375,6 +1491,7 @@ where
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
worker.manifest(),
worker.workspace_client_handle(),
worker.prompts(),
)?
{
let workspace_id = worker
@@ -1384,7 +1501,7 @@ where
.to_string();
if let Some(refresh_feature) =
crate::feature::builtin::memory::SubjektivResidentRestoreRefreshFeature::for_host(
worker.manifest_lifecycle_features_enabled(),
lifecycle_enabled,
Arc::clone(&resident_summary_source),
worker.prompts(),
workspace_id,
@@ -1415,7 +1532,7 @@ where
}
if let Some(memory_lifecycle) =
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
worker.manifest_lifecycle_features_enabled(),
lifecycle_enabled,
memory_lifecycle_config,
worker.committed_session_capture_handle(),
worker.session_extension_handle(),
@@ -1429,8 +1546,8 @@ where
{
feature_registry.add_module(memory_lifecycle);
}
if let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
worker.manifest_lifecycle_features_enabled(),
if backend_job.is_none() && let Some(surface_lifecycle) = crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature::from_manifest(
lifecycle_enabled,
worker.workspace_client_handle(),
spawner_manifest.clone(),
worker.llm_client_handle(),
@@ -1442,7 +1559,7 @@ where
if ordinary_subjektiv_features_enabled
&& let Some(subjektiv_lifecycle) =
crate::feature::builtin::memory_lifecycle::SubjektivLifecycleFeature::from_resolved_config(
worker.manifest_lifecycle_features_enabled(),
lifecycle_enabled,
worker.manifest().feature.subjektiv.clone(),
worker.committed_session_capture_handle(),
worker.session_extension_handle(),
@@ -1504,7 +1621,22 @@ where
),
);
}
if feature_config.manage_workdir.enabled {
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
let workspace_config_feature =
crate::feature::builtin::workspace_config::WorkspaceConfigFeature::configured(
worker.workspace_client_handle(),
feature_config.workspace_config.enabled,
wip_mode,
);
if let Some(module) = &workspace_config_feature {
feature_registry.add_module(module.clone());
}
let mut workdir_wip_feature = None;
if feature_config.manage_workdir.enabled
|| (wip_mode
&& feature_config.workdir_catalog.enabled
&& worker.workspace_client_handle().is_available())
{
// Workdir lifecycle is Workspace control-plane authority. The Worker
// receives only the injected WorkspaceClient and never Runtime URLs,
// repository paths, materializer handles, or cleanup sessions.
@@ -1520,7 +1652,7 @@ where
}
let shutdown_registry = spawned_registry.clone();
let reopen_registry = spawned_registry.clone();
feature_registry.add_module(
let module =
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
workspace_client,
worker.workdir_sessions(),
@@ -1529,8 +1661,11 @@ where
Box::pin(async move { child_registry.shutdown_internal().await })
}),
Arc::new(move || reopen_registry.reopen_internal()),
),
);
if feature_config.manage_workdir.enabled {
feature_registry.add_module(module.clone());
}
workdir_wip_feature = Some(module);
}
if feature_config.workspace_worker_discovery.enabled {
let workspace_client = worker.workspace_client_handle();
@@ -1585,20 +1720,60 @@ where
}
let host_worker_observation_provider = worker.worker_observation_provider();
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
let wip_permissions = worker.manifest().permissions.clone();
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
if let Some(feature) = &workspace_config_feature {
crate::feature::builtin::workspace_config::wip::mount_workspace_config_wip(
&mut wip_mount_registry,
feature,
)
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("mount Workspace config WIP projection: {error}"),
)
})?;
}
if wip_mode {
crate::checkout::mount_checkouts(
&mut wip_mount_registry,
worker.workdir_sessions(),
tracker.clone(),
wip_permissions.clone(),
)
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("mount checkout WIP projection: {error}"),
)
})?;
}
if wip_mode && let Some(feature) = &workdir_wip_feature {
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
&mut wip_mount_registry,
feature,
feature_config.workdir_catalog.enabled,
feature_config.manage_workdir.enabled,
wip_permissions.clone(),
)
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("mount Workdir WIP projection: {error}"),
)
})?;
}
{
let workspace_client = worker.workspace_client_handle();
let engine = worker.engine_mut();
if feature_config.merge_request.any() && wip_mode {
let feature_route = wip_mount_registry
.allocate_feature_route("merge-request")
let namespace_route = wip_mount_registry
.allocate_namespace("merge-request", "merge-requests")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Merge Request WIP route: {error}"),
format!("allocate Merge Request WIP namespace: {error}"),
)
})?;
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
@@ -1606,7 +1781,7 @@ where
workspace_client.clone(),
feature_config.merge_request,
wip_permissions.clone(),
&feature_route,
&namespace_route,
)
.map_err(|error| {
std::io::Error::new(
@@ -1623,12 +1798,12 @@ where
intake: feature_config.ticket.intake,
workflow: feature_config.ticket.workflow,
};
let feature_route = wip_mount_registry
.allocate_feature_route("ticket")
let namespace_route = wip_mount_registry
.allocate_namespace("ticket", "tickets")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Ticket WIP route: {error}"),
format!("allocate Ticket WIP namespace: {error}"),
)
})?;
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
@@ -1636,7 +1811,7 @@ where
workspace_client.clone(),
ticket_access,
wip_permissions.clone(),
&feature_route,
&namespace_route,
)
.map_err(|error| {
std::io::Error::new(
@@ -1657,19 +1832,19 @@ where
engine.register_tool(definition);
}
if wip_mode {
let feature_route = wip_mount_registry
.allocate_feature_route("objective")
let namespace_route = wip_mount_registry
.allocate_namespace("objective", "objectives")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Objective WIP route: {error}"),
format!("allocate Objective WIP namespace: {error}"),
)
})?;
crate::feature::builtin::objective::mount_workspace_http_objective_wip(
&mut wip_mount_registry,
workspace_client.clone(),
wip_permissions.clone(),
&feature_route,
&namespace_route,
)
.map_err(|error| {
std::io::Error::new(
@@ -2081,9 +2256,11 @@ async fn controller_loop<C, St>(
submission.input,
vec![extension],
submission.provenance,
move || {
let _ = input_commit_tx.send(());
},
input_committed_hook(
pending_submissions.clone(),
committed_submission.submission_id.clone(),
input_commit_tx,
),
),
&mut method_rx,
&working_event_tx,
@@ -2140,10 +2317,6 @@ async fn controller_loop<C, St>(
.await
}
};
if notify_buffer.is_empty() {
pending_submissions.finish_notification_batch();
}
if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle {
match prepare_pending_run(&pending_submissions, &notify_buffer, None, false) {
Ok(Some(next)) => {
@@ -2192,12 +2365,16 @@ async fn controller_loop<C, St>(
continue;
}
// An expired Notify deadline must not repeatedly schedule a run that
// cannot cross the invocation fence. Keep the accepted notification for
// a fresh input/recovery boundary instead of retrying business effects.
let notification_delay = (shared_state.catalog_status() == WorkerStatus::Idle)
.then(|| {
notification_coalesce_remaining(
&pending_submissions,
&notify_buffer,
notification_coalesce_delay,
worker.can_schedule_notification_run(),
)
})
.flatten();
@@ -3754,6 +3931,21 @@ mod tests {
assert!(feature_install < materialize_gate);
assert!(materialize_gate < materialize);
assert!(materialize < exposure);
let observer = startup.find("let command_observer = fs_for_view").unwrap();
let recover = startup.find(".recover_unfinished_compaction()").unwrap();
let files = startup
.find("runtime_dir.write_status(&shared_state).await?")
.unwrap();
let loop_start = startup.find("tokio::spawn(controller_loop(").unwrap();
assert!(recover < observer && files < observer && observer < loop_start);
let socket_startup = &startup[observer..loop_start];
let abort = socket_startup.find("observer.abort()").unwrap();
let join = socket_startup.find("observer.await").unwrap();
let failure = socket_startup.find("return Err(error)").unwrap();
assert!(
abort < join && join < failure,
"startup cannot detach its observer on error"
);
assert!(
startup[materialize_gate..materialize]
.contains("needs_initial_session_head_materialization")
@@ -3871,6 +4063,40 @@ mod tests {
assert!(notify_buffer.has_notification_pending());
}
#[test]
fn invocation_fence_suspends_expired_notification_deadline_without_dropping_input() {
let temp = TempDir::new().unwrap();
let pending = crate::worker::PendingSubmissionHandle::for_test(temp.path());
let buffer = NotifyBuffer::new();
pending
.accept_notification("held".into(), "held".into())
.unwrap();
let before = pending.persisted_entries_for_test().len();
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
Some(Duration::ZERO)
);
for _ in 0..4 {
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
None
);
}
assert_eq!(pending.persisted_entries_for_test().len(), before);
assert_eq!(pending.snapshot().notification_count, 1);
// A busy-time staged notification is equally retained behind the fence.
stage_pending_notifications(&pending, &buffer);
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
None
);
assert!(buffer.has_notification_pending());
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
Some(Duration::ZERO)
);
}
#[test]
fn restored_mixed_activations_preserve_global_fifo_order() {
let temp = TempDir::new().unwrap();
+56
View File
@@ -20,6 +20,7 @@ use agen::Engine;
use agen::llm_client::client::LlmClient;
use agen::state::Mutable;
use agen::tool::ToolDefinition;
use protocol::FeatureInvocationDescriptor;
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -623,6 +624,9 @@ pub struct FeatureDescriptor {
pub provides_services: Vec<ServiceDeclaration>,
pub requires_services: Vec<ServiceRequirement>,
pub protocol_providers: Vec<ProtocolProviderDeclaration>,
/// Declarative, user-selected chat invocations contributed by this Feature.
/// Metadata is public; handlers are registered separately during install.
pub chat_invocations: Vec<FeatureInvocationDescriptor>,
}
impl FeatureDescriptor {
@@ -640,6 +644,7 @@ impl FeatureDescriptor {
provides_services: Vec::new(),
requires_services: Vec::new(),
protocol_providers: Vec::new(),
chat_invocations: Vec::new(),
}
}
@@ -682,6 +687,11 @@ impl FeatureDescriptor {
self.protocol_providers.push(provider);
self
}
pub fn with_chat_invocation(mut self, invocation: FeatureInvocationDescriptor) -> Self {
self.chat_invocations.push(invocation);
self
}
}
/// Feature module contribution boundary.
@@ -739,6 +749,7 @@ pub enum FeatureContributionKind {
BackgroundTask,
Service,
ProtocolProvider,
ChatInvocation,
Notification,
Alert,
Diagnostic,
@@ -765,6 +776,7 @@ pub struct FeatureInstallReport {
pub provided_services: Vec<ServiceDeclaration>,
pub resolved_service_requirements: Vec<ServiceRequirement>,
pub protocol_providers: Vec<ProtocolProviderLifecycleDiagnostic>,
pub installed_chat_invocations: Vec<FeatureInvocationDescriptor>,
pub skipped: Vec<SkippedContribution>,
pub diagnostics: Vec<FeatureDiagnostic>,
}
@@ -782,6 +794,7 @@ impl FeatureInstallReport {
provided_services: Vec::new(),
resolved_service_requirements: Vec::new(),
protocol_providers: Vec::new(),
installed_chat_invocations: Vec::new(),
skipped: Vec::new(),
diagnostics: Vec::new(),
}
@@ -794,6 +807,7 @@ impl FeatureInstallReport {
self.installed_instructions.clear();
self.declared_background_tasks.clear();
self.provided_services.clear();
self.installed_chat_invocations.clear();
}
fn mark_skipped(
@@ -819,6 +833,7 @@ struct FeatureContributionDeclarations {
provided_services: HashSet<(ServiceId, String)>,
required_services: HashSet<ServiceId>,
protocol_providers: HashSet<ProviderId>,
chat_invocations: Vec<FeatureInvocationDescriptor>,
}
impl FeatureContributionDeclarations {
@@ -859,6 +874,7 @@ impl FeatureContributionDeclarations {
.iter()
.map(|provider| provider.id.clone())
.collect(),
chat_invocations: descriptor.chat_invocations.clone(),
}
}
@@ -1527,6 +1543,7 @@ pub struct FeatureInstallContext<'a> {
hook_builder: &'a mut HookRegistryBuilder,
background_task_builder: &'a mut FeatureBackgroundTaskRegistryBuilder,
service_registry: &'a mut FeatureServiceRegistry,
invocation_registry: &'a mut FeatureInvocationRegistry,
report: &'a mut FeatureInstallReport,
}
@@ -1591,6 +1608,15 @@ impl FeatureInstallContext<'_> {
}
}
pub fn chat_invocations(&mut self) -> InvocationContributionRegistrar<'_> {
InvocationContributionRegistrar {
feature_id: self.feature_id,
declarations: &self.declarations.chat_invocations,
registry: self.invocation_registry,
installed: &mut self.report.installed_chat_invocations,
}
}
pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> {
FeatureDiagnosticSink {
report: self.report,
@@ -1604,6 +1630,7 @@ pub struct FeatureRegistryInstallReport {
pub reports: Vec<FeatureInstallReport>,
pub services: FeatureServiceRegistry,
pub background_tasks: FeatureBackgroundTaskRegistry,
pub chat_invocations: FeatureInvocationRegistry,
pub plan_error: Option<FeaturePlanError>,
}
@@ -2026,11 +2053,13 @@ impl FeatureRegistryBuilder {
reports,
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
chat_invocations: FeatureInvocationRegistry::default(),
plan_error: Some(error),
};
}
};
let mut service_registry = FeatureServiceRegistry::default();
let mut invocation_registry = FeatureInvocationRegistry::default();
let mut background_task_builder = FeatureBackgroundTaskRegistryBuilder::default();
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
let install_hook_checkpoint = hook_builder.checkpoint();
@@ -2055,6 +2084,7 @@ impl FeatureRegistryBuilder {
let hook_checkpoint = hook_builder.checkpoint();
let background_checkpoint = background_task_builder.checkpoint();
let service_checkpoint = service_registry.clone();
let invocation_checkpoint = invocation_registry.clone();
let tool_checkpoint = pending_tools.len();
let installed_tool_checkpoint = installed_tool_names.clone();
@@ -2102,6 +2132,7 @@ impl FeatureRegistryBuilder {
hook_builder,
background_task_builder: &mut background_task_builder,
service_registry: &mut service_registry,
invocation_registry: &mut invocation_registry,
report: &mut report,
};
module.install(&mut context)
@@ -2113,6 +2144,7 @@ impl FeatureRegistryBuilder {
hook_builder.rollback_to(hook_checkpoint);
background_task_builder.rollback_to(&background_checkpoint);
service_registry = service_checkpoint.clone();
invocation_registry = invocation_checkpoint.clone();
pending_tools.truncate(tool_checkpoint);
installed_tool_names = installed_tool_checkpoint.clone();
report.clear_installed_contributions();
@@ -2138,6 +2170,14 @@ impl FeatureRegistryBuilder {
)));
}
}
for invocation in &descriptor.chat_invocations {
if !report.installed_chat_invocations.contains(invocation) {
report.diagnostics.push(FeatureDiagnostic::error(format!(
"feature `{}` declared chat invocation `{}` but did not register an executable handler",
descriptor.id, invocation.identity
)));
}
}
if report
.diagnostics
.iter()
@@ -2146,6 +2186,7 @@ impl FeatureRegistryBuilder {
hook_builder.rollback_to(hook_checkpoint);
background_task_builder.rollback_to(&background_checkpoint);
service_registry = service_checkpoint.clone();
invocation_registry = invocation_checkpoint.clone();
pending_tools.truncate(tool_checkpoint);
installed_tool_names = installed_tool_checkpoint.clone();
report.clear_installed_contributions();
@@ -2176,6 +2217,7 @@ impl FeatureRegistryBuilder {
reports,
services: FeatureServiceRegistry::default(),
background_tasks: FeatureBackgroundTaskRegistry::default(),
chat_invocations: FeatureInvocationRegistry::default(),
plan_error: None,
}
} else {
@@ -2183,6 +2225,7 @@ impl FeatureRegistryBuilder {
reports,
services: service_registry,
background_tasks: background_task_builder.build(),
chat_invocations: invocation_registry,
plan_error: None,
}
}
@@ -2225,11 +2268,24 @@ pub enum FeatureInstallError {
first_feature: String,
duplicate_feature: String,
},
#[error("duplicate invocation identity `{identity}`")]
DuplicateInvocationIdentity { identity: String },
#[error("duplicate invocation name `{name}` from `{duplicate}`; first registered by `{first}`")]
DuplicateInvocationName {
name: String,
first: String,
duplicate: String,
},
#[error("feature install failed: {0}")]
Install(String),
}
pub mod background;
pub mod invocation;
pub use invocation::{
FeatureInvocationCompletionProvider, FeatureInvocationContext, FeatureInvocationHandler,
FeatureInvocationHandlerError, FeatureInvocationRegistry, InvocationContributionRegistrar,
};
pub mod builtin;
pub mod mcp;
pub(crate) mod session;
+1 -1
View File
@@ -190,7 +190,7 @@ impl BackgroundTaskCancellation {
}
}
fn cancel(&self) {
pub(crate) fn cancel(&self) {
self.sender.send_replace(true);
}
}
+2
View File
@@ -5,6 +5,7 @@
//! an external plugin-loading surface.
pub mod backend_job_result;
pub mod chat_invocation;
pub mod flow_transition;
pub mod manage_workdir;
pub mod manage_worker;
@@ -23,6 +24,7 @@ pub(crate) mod subjektiv_session;
pub mod task;
pub mod ticket;
pub mod worker_observation;
pub mod workspace_config;
pub mod workspace_worker_discovery;
pub use task::{TaskFeature, task_tools_feature};
@@ -4,7 +4,9 @@
//! identity come from the authenticated Workspace client, while Job, attempt,
//! and input revision are resolved from the Backend-owned binding.
use std::sync::Arc;
use crate::feature::background::BackgroundTaskCancellation;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use async_trait::async_trait;
@@ -12,6 +14,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use crate::feature::builtin::memory_surface_lifecycle::SubjektivSurfaceLifecycleFeature;
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration, ToolDefinition,
@@ -27,9 +30,61 @@ struct SubmitResultInput {
result: Value,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ConsolidationResult {
subject_id: String,
candidate_ids: Vec<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SubmitConsolidationResultInput {
/// Closed candidate ids only. Host awaits and adds the actual surface outcome.
result: ConsolidationResult,
}
#[derive(Default)]
struct ResultSubmissionState {
sealed: Option<SealedResult>,
reusable_surface: Option<Value>,
}
struct SealedResult {
original: Value,
completed: Value,
}
#[derive(Clone)]
struct SubmitResultTool {
client: Arc<dyn WorkspaceClient>,
surface: Option<SubjektivSurfaceLifecycleFeature>,
submission: Arc<tokio::sync::Mutex<ResultSubmissionState>>,
active: Arc<Mutex<HashMap<String, BackgroundTaskCancellation>>>,
}
struct ActiveResultGuard {
id: String,
active: Arc<Mutex<HashMap<String, BackgroundTaskCancellation>>>,
cancellation: BackgroundTaskCancellation,
}
impl Drop for ActiveResultGuard {
fn drop(&mut self) {
self.cancellation.cancel();
self.active
.lock()
.expect("Job result cancellation state poisoned")
.remove(&self.id);
}
}
fn cancelled_output() -> ToolError {
ToolError::Cancelled(ToolOutput {
summary: "Job result submission cancelled; committed candidate decisions are preserved"
.into(),
content: None,
attachments: Vec::new(),
})
}
#[async_trait]
@@ -37,27 +92,128 @@ impl Tool for SubmitResultTool {
async fn execute(
&self,
input_json: &str,
_context: ToolExecutionContext,
context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let input: SubmitResultInput = serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let id = context.execution_id();
let cancellation = self
.active
.lock()
.expect("Job result cancellation state poisoned")
.entry(id.clone())
.or_default()
.clone();
let _active = ActiveResultGuard {
id,
active: self.active.clone(),
cancellation: cancellation.clone(),
};
// Serialize result calls and retain the exact lifecycle outcome for ambiguous retries.
let mut submission = tokio::select! {
guard = self.submission.lock() => guard,
_ = cancellation.cancelled() => return Err(cancelled_output()),
};
// An existing seal may already have had an effect, even if this replay
// is now definitively rejected. It must never be reopened.
let was_previously_sealed = submission.sealed.is_some();
let result = if let Some(sealed) = submission.sealed.as_ref() {
if sealed.original != input.result {
return Err(ToolError::InvalidArgument(
"Job result is already sealed; retry the identical result".into(),
));
}
sealed.completed.clone()
} else {
let mut result = input.result.clone();
if let Some(surface) = &self.surface {
let checked = serde_json::from_str::<SubmitConsolidationResultInput>(input_json)
.map_err(|error| {
ToolError::InvalidArgument(format!(
"invalid consolidation result (surface is Host-generated): {error}"
))
})?
.result;
if checked.subject_id.trim().is_empty()
|| checked.candidate_ids.iter().any(|id| id.trim().is_empty())
{
return Err(ToolError::InvalidArgument(
"subject_id and candidate ids must not be empty".into(),
));
}
let object = result
.as_object_mut()
.expect("typed consolidation result is an object");
let outcome = surface
.complete_for_job(cancellation.clone(), submission.reusable_surface.as_ref())
.await
.map_err(|error| {
if cancellation.is_cancelled() {
cancelled_output()
} else {
ToolError::ExecutionFailed(error.to_string())
}
})?;
submission.reusable_surface = Some(outcome.clone());
object.insert("surface".into(), outcome);
}
result
};
if cancellation.is_cancelled() {
return Err(cancelled_output());
}
let workspace_id = self.client.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed(
"Backend Job result submission requires Workspace identity".to_string(),
)
})?;
let response = self
.client
.execute(WorkspaceRequest::json(
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/workers/self/backend-job-result"),
serde_json::to_string(&serde_json::json!({ "result": input.result }))
serde_json::to_string(&serde_json::json!({ "result": result }))
.map_err(|error| ToolError::Internal(error.to_string()))?,
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
);
// Seal before dispatch: a transport failure may follow durable acceptance.
if submission.sealed.is_none() {
submission.sealed = Some(SealedResult {
original: input.result,
completed: result,
});
submission.reusable_surface = None;
}
let response = match self.client.execute(request) {
Ok(response) => response,
Err(error) => {
return Err(ToolError::ExecutionFailed(format!(
"Backend Job result submission was rejected with HTTP {}: {}",
"Backend Job result acceptance is unknown; retry the identical result: {error}"
)));
}
};
let definitive_rejection = matches!(response.status, 400 | 403 | 409 | 422);
if !response.is_success() {
let sealed = submission.sealed.as_mut().expect("sealed before dispatch");
if definitive_rejection && !was_previously_sealed {
// Only a definitively unaccepted request can be corrected. Reuse
// a ready snapshot after a new preparation confirms its revision;
// a conflict invalidates even that snapshot. Failed outcomes are
// rebuilt, since another generation can replace their marker.
submission.reusable_surface = if response.status != 409 {
sealed
.completed
.get("surface")
.filter(|surface| surface["availability"] == "ready")
.cloned()
} else {
None
};
submission.sealed = None;
return Err(ToolError::ExecutionFailed(format!(
"Backend Job result was definitively rejected with HTTP {}; correct result fields or retry to rebuild a stale surface: {}",
response.status, response.body
)));
}
return Err(ToolError::ExecutionFailed(format!(
"Backend Job result acceptance remains sealed; retry the identical result (HTTP {}): {}",
response.status, response.body
)));
}
@@ -67,10 +223,29 @@ impl Tool for SubmitResultTool {
attachments: Vec::new(),
})
}
async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> {
self.active
.lock()
.expect("Job result cancellation state poisoned")
.entry(context.execution_id())
.or_default()
.cancel();
Ok(())
}
}
fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
let schema = serde_json::to_value(schemars::schema_for!(SubmitResultInput))
fn definition(
client: Arc<dyn WorkspaceClient>,
surface: Option<SubjektivSurfaceLifecycleFeature>,
) -> ToolDefinition {
let submission = Arc::new(tokio::sync::Mutex::new(ResultSubmissionState::default()));
let active = Arc::new(Mutex::new(HashMap::new()));
let schema = if surface.is_some() {
serde_json::to_value(schemars::schema_for!(SubmitConsolidationResultInput))
} else {
serde_json::to_value(schemars::schema_for!(SubmitResultInput))
}
.expect("Backend Job result schema must serialize");
Arc::new(move || {
(
@@ -79,6 +254,9 @@ fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
.input_schema(schema.clone()),
Arc::new(SubmitResultTool {
client: client.clone(),
surface: surface.clone(),
submission: submission.clone(),
active: active.clone(),
}) as Arc<dyn Tool>,
)
})
@@ -87,11 +265,22 @@ fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
#[derive(Clone)]
pub struct BackendJobResultFeature {
client: Arc<dyn WorkspaceClient>,
surface: Option<SubjektivSurfaceLifecycleFeature>,
}
impl BackendJobResultFeature {
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
Self { client }
Self {
client,
surface: None,
}
}
}
impl BackendJobResultFeature {
pub(crate) fn with_surface(mut self, surface: SubjektivSurfaceLifecycleFeature) -> Self {
self.surface = Some(surface);
self
}
}
@@ -105,7 +294,7 @@ impl FeatureModule for BackendJobResultFeature {
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.tools().register(ToolContribution::new(
TOOL_NAME,
definition(self.client.clone()),
definition(self.client.clone(), self.surface.clone()),
))
}
}
@@ -172,4 +361,14 @@ mod tests {
.ends_with("/workers/self/backend-job-result")
);
}
#[tokio::test]
async fn cancellation_before_execution_never_submits_a_result() {
let client = Arc::new(RecordingClient::default());
let (_, tool) = definition(client.clone(), None)();
let context = ToolExecutionContext::new("result-call", "batch", 0);
tool.cancel_execution(&context).await.unwrap();
let outcome = tool.execute(r#"{"result":{"valid":true}}"#, context).await;
assert!(matches!(outcome, Err(ToolError::Cancelled(_))));
assert!(client.requests.lock().unwrap().is_empty());
}
}
@@ -0,0 +1,72 @@
//! Builtin metadata for client-local attachment staging.
//!
//! `/attach` uses the common invocation declaration and parser, but its path is
//! intentionally consumed by the client adapter. The Worker accepts only the
//! resulting immutable `UploadedFile` segment and never resolves a client path.
use async_trait::async_trait;
use protocol::{
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, FeatureInvocationSyntax, InvocationArgumentDescriptor,
InvocationArgumentType, InvocationClientAdapter, InvocationCompletion,
};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext,
FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule,
};
pub const ATTACH_FEATURE_ID: &str = "attachments";
pub const ATTACH_INVOCATION_ID: &str = "builtin:attachments/attach";
pub fn attach_invocation_descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity(ATTACH_INVOCATION_ID.into()),
name: "attach".into(),
aliases: Vec::new(),
display_name: "Attach file".into(),
description: "Stage a client-local file as an editable attachment chip.".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "file".into(),
position: Some(0),
required: true,
value_type: InvocationArgumentType::ClientFile,
completion: InvocationCompletion::ClientFile,
description: Some("File available to this client".into()),
}],
client_adapter: Some(InvocationClientAdapter::Attachment),
}
}
pub struct AttachmentInvocationFeature;
impl FeatureModule for AttachmentInvocationFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin(ATTACH_FEATURE_ID, "Attachments")
.with_description("Client-local attachment staging and typed uploaded-file input")
.with_chat_invocation(attach_invocation_descriptor())
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.chat_invocations().register(
attach_invocation_descriptor(),
AttachmentMustBeStagedByClient,
)
}
}
struct AttachmentMustBeStagedByClient;
#[async_trait]
impl FeatureInvocationHandler for AttachmentMustBeStagedByClient {
async fn invoke(
&self,
_context: FeatureInvocationContext,
_invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
Err(FeatureInvocationHandlerError::failed(
"attachment invocation reached the Worker without client-local upload staging",
))
}
}
@@ -5,6 +5,8 @@
//! endpoints, credentials, materializer handles, and operation sessions stay
//! behind [`WorkspaceClient`].
pub mod wip;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
@@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature {
session_router: Arc<workdir::WorkdirSessionRouter>,
before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
}
impl std::fmt::Debug for ManageWorkdirFeature {
@@ -86,6 +89,7 @@ impl ManageWorkdirFeature {
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
before_workdir_release: None,
after_workdir_attach: None,
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
@@ -100,8 +104,19 @@ impl ManageWorkdirFeature {
session_router,
before_workdir_release: Some(before_workdir_release),
after_workdir_attach: Some(after_workdir_attach),
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
fn backend(&self) -> WorkspaceHttpWorkdirBackend {
let mut backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
.with_session_router(self.session_router.clone())
.with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
backend.attachment_mutation_lock = self.attachment_mutation_lock.clone();
backend
}
}
pub fn manage_workdir_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkdirFeature {
@@ -120,12 +135,7 @@ impl FeatureModule for ManageWorkdirFeature {
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
.with_session_router(self.session_router.clone())
.with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
let backend = self.backend();
for (name, definition) in [
(
LIST_TOOL,
@@ -343,6 +353,102 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
}
}
async fn checkout_observe(
&self,
path: workdir::WorkdirPath,
) -> Result<workdir::CheckoutObservation, WorkdirError> {
match self.operate(WorkdirSessionOperation::CheckoutObserve(path.clone()))? {
WorkdirSessionOperationResult::CheckoutObserve(mut result)
if result.path == path
&& !result.validator.is_empty()
&& result.validator.len() <= 256 =>
{
result.capabilities = result.capabilities.intersection(self.capabilities);
Ok(result)
}
_ => Err(Self::mismatch("checkout_observe")),
}
}
async fn checkout_execute(
&self,
request: workdir::CheckoutRequest,
) -> Result<workdir::CheckoutResult, WorkdirError> {
let cap = request.operation.capability();
if !self.capabilities.supports(cap) {
return Err(WorkdirError::Unsupported(cap));
}
let target = request.target.clone();
let mutation = cap != workdir::WorkdirSessionCapability::Read;
match self.operate(WorkdirSessionOperation::CheckoutExecute(request)) {
Ok(WorkdirSessionOperationResult::CheckoutExecute(mut result))
if result.observation.path == target
&& !result.observation.validator.is_empty()
&& result.observation.validator.len() <= 256
&& matches!(
(&result.output, cap),
(
workdir::CheckoutOutput::Read(_),
workdir::WorkdirSessionCapability::Read
) | (
workdir::CheckoutOutput::Write(_),
workdir::WorkdirSessionCapability::Write
) | (
workdir::CheckoutOutput::Edit(_),
workdir::WorkdirSessionCapability::Edit
)
) =>
{
result.observation.capabilities = result
.observation
.capabilities
.intersection(self.capabilities);
Ok(result)
}
Ok(_) if mutation => Err(WorkdirError::OutcomeUnknown(
"mismatched checkout mutation response".into(),
)),
Ok(_) => Err(Self::mismatch("checkout_execute")),
Err(WorkdirError::Transport(_) | WorkdirError::Unavailable(_)) if mutation => {
Err(WorkdirError::OutcomeUnknown(
"checkout mutation response lost; inspect effects before retry".into(),
))
}
Err(error) => Err(error),
}
}
async fn checkout_search(
&self,
request: workdir::CheckoutSearchRequest,
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
use workdir::{
CheckoutSearchOperation as Op, CheckoutSearchResult as SearchResult,
WorkdirSessionCapability as Cap,
};
let cap = match &request.operation {
Op::List(_) => Cap::Read,
Op::Glob(_) => Cap::Glob,
Op::Grep(_) => Cap::Grep,
};
if !self.capabilities.supports(cap) {
return Err(WorkdirError::Unsupported(cap));
}
match self.operate(WorkdirSessionOperation::CheckoutSearch(request))? {
WorkdirSessionOperationResult::CheckoutSearch(result)
if matches!(
(&result, cap),
(SearchResult::List(_), Cap::Read)
| (SearchResult::Glob(_), Cap::Glob)
| (SearchResult::Grep(_), Cap::Grep)
) =>
{
Ok(result)
}
_ => Err(Self::mismatch("checkout_search")),
}
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Stat(request))? {
WorkdirSessionOperationResult::Stat(result) => Ok(result),
@@ -581,11 +687,24 @@ impl WorkspaceHttpWorkdirBackend {
}
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
self.detach_expected(alias, None)
}
fn detach_expected(
&self,
alias: &str,
expected_connection_id: Option<&str>,
) -> Result<ToolOutput, ToolError> {
let workspace_id = encode_path_segment(self.workspace_id()?);
let alias_path = encode_path_segment(alias);
let suffix = expected_connection_id
.map(|id| format!("?expected_connection_id={}", encode_path_segment(id)))
.unwrap_or_default();
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
method: WorkspaceRequestMethod::Delete,
path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"),
path: format!(
"/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}{suffix}"
),
body: None,
})?;
workdir_output(
@@ -622,6 +741,72 @@ impl WorkspaceHttpWorkdirBackend {
)
}
async fn detach_managed(
&self,
alias: &str,
expected_connection_id: Option<&str>,
) -> Result<ToolOutput, ToolError> {
let _mutation_guard = self.attachment_mutation_lock.lock().await;
let alias = validate_identity(alias, DETACH_TOOL, "alias")?;
if let Some(expected) = expected_connection_id {
// Under the shared local mutation lock, check lifetime before any
// local route exclusion or child cleanup. Backend DELETE still performs
// the authoritative conditional mutation under its lifecycle lock.
let workspace = encode_path_segment(self.workspace_id()?);
let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!(
"/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}",
encode_path_segment(expected)
)))?;
let current = page
.get("items")
.and_then(serde_json::Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("connection_id")
.and_then(serde_json::Value::as_str)
== Some(expected)
&& item.get("alias").and_then(serde_json::Value::as_str) == Some(alias)
})
});
if !current {
return Err(ToolError::StructuredConflict {
code: "workspace_http_409".into(),
message: "attachment lifetime changed before local cleanup".into(),
});
}
}
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
self.session_router
.begin_detach(&alias_key)
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Some(before_release) = &self.before_workdir_release
&& let Err(error) = before_release().await
{
self.session_router.cancel_detach(&alias_key);
return Err(ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.detach_expected(alias, expected_connection_id) {
Ok(result) => result,
Err(error) => {
self.session_router.cancel_detach(&alias_key);
return Err(error);
}
};
self.session_router
.finish_detach(&alias_key)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"detach Workdir session route after Backend release: {error}"
))
})?;
Ok(result)
}
fn execute_json<T: for<'de> Deserialize<'de>>(
&self,
request: WorkspaceRequest,
@@ -697,42 +882,10 @@ impl Tool for WorkspaceHttpWorkdirTool {
result
}
WorkdirOperation::Detach => {
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
let input = parse_input::<WorkdirDetachInput>(input_json)?;
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
self.backend
.session_router
.begin_detach(&alias_key)
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Some(before_release) = &self.backend.before_workdir_release
&& let Err(error) = before_release().await
{
self.backend.session_router.cancel_detach(&alias_key);
return Err(ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.backend.detach(alias) {
Ok(result) => result,
Err(error) => {
self.backend.session_router.cancel_detach(&alias_key);
return Err(error);
}
};
self.backend
.session_router
.finish_detach(&alias_key)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"detach Workdir session route after Backend release: {error}"
))
})?;
Ok(result)
self.backend.detach_managed(&input.alias, None).await
}
WorkdirOperation::Delete => self
.backend
.delete(parse_input::<WorkdirDeleteInput>(input_json)?),
@@ -747,6 +900,12 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
fn decode_response<T: for<'de> Deserialize<'de>>(
response: WorkspaceResponse,
) -> Result<T, ToolError> {
if matches!(response.status, 401 | 403 | 404 | 409) {
return Err(ToolError::StructuredConflict {
code: format!("workspace_http_{}", response.status),
message: "Workspace lifecycle authority rejected the request before execution".into(),
});
}
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Workdir API returned HTTP {}: {}",
@@ -1351,6 +1510,56 @@ mod tests {
assert!(body.get("session_id").is_none());
}
#[tokio::test]
async fn attached_checkout_mutations_preserve_known_rejection_and_lost_outcomes() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![
response(json!({"operation":"write","result":{"bytes_written":3,"created":false}})),
WorkspaceResponse {
status: 200,
body: "invalid payload".into(),
},
error_response(
409,
workdir::http::WorkdirTransportErrorCode::Conflict,
"stale",
),
]));
let session = WorkspaceAttachedWorkdirSession::handle(client.clone(), "checkout");
let request = workdir::CheckoutRequest {
target: workdir::WorkdirPath::new("file.txt").unwrap(),
validator: vec![1],
operation: workdir::CheckoutOperation::Write {
content: b"new".to_vec(),
expected_hash: [0; 32],
},
};
for _ in 0..2 {
assert!(matches!(
session.checkout_execute(request.clone()).await,
Err(WorkdirError::OutcomeUnknown(_))
));
}
assert!(matches!(
session.checkout_execute(request.clone()).await,
Err(WorkdirError::Conflict(_))
));
// Missing response simulates the Workspace client losing the provider result.
assert!(matches!(
session.checkout_execute(request).await,
Err(WorkdirError::OutcomeUnknown(_))
));
let calls = client.requests();
assert_eq!(calls.len(), 4);
for call in calls {
let body: serde_json::Value =
serde_json::from_str(call.body.as_deref().unwrap()).unwrap();
assert_eq!(body["target_workdir"], "checkout");
assert_eq!(body["operation"]["operation"], "checkout_execute");
assert_eq!(body["operation"]["request"]["target"], "file.txt");
assert!(body.get("runtime_id").is_none());
}
}
#[tokio::test]
async fn attached_session_does_not_send_worker_local_bash_spill_path() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
File diff suppressed because it is too large Load Diff
@@ -77,8 +77,9 @@ pub trait WorkerControlService: Send + Sync {
&self,
runtime_id: String,
worker_id: String,
request: server_api::WorkerRestoreRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let _ = (runtime_id, worker_id);
let _ = (runtime_id, worker_id, request);
Err(WorkspaceClientError::Unavailable(
"Runtime Worker restore is unavailable".to_string(),
))
@@ -347,6 +348,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
&self,
runtime_id: String,
worker_id: String,
request: server_api::WorkerRestoreRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !self.runtime_worker_control {
return Err(WorkspaceClientError::Unavailable(
@@ -357,6 +359,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
crate::worker::WorkspaceServerOperation::WorkerControlRestore {
runtime_id,
worker_id,
request,
},
)
}
@@ -750,8 +753,13 @@ enum WorkerSubjectInput {
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct WorkerTargetInput {
struct WorkerRestoreInput {
subject: WorkerSubjectInput,
expected_observation_token: String,
/// Reuse the reported ID for explicit unknown-outcome recovery. A new intent
/// defaults to the durable Tool call identity.
#[serde(default)]
request_id: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -864,7 +872,7 @@ impl WorkerOperation {
Self::Cancel => "Cancel the current turn of a known Runtime Worker when allowed.",
Self::Stop => "Stop a known Runtime Worker when allowed.",
Self::Restore => {
"Restore a stopped Backend/Runtime Worker session in the current Workspace."
"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, unassigned, non-internal Worker. Supply a bounded reason; Backend validation and retention are authoritative."
@@ -981,12 +989,41 @@ impl Tool for WorkspaceWorkerTool {
.map_err(control_tool_error)?
}
WorkerOperation::Restore => {
let input = parse::<WorkerTargetInput>(input_json, "WorkerRestore")?;
let input = parse::<WorkerRestoreInput>(input_json, "WorkerRestore")?;
let (runtime_id, worker_id) = runtime_subject_ids(&input.subject, self.operation)?;
self.control
.restore_worker(runtime_id, worker_id)
let request_id = match input.request_id {
Some(id) => non_empty(id, "request_id")?,
None => format!(
"worker-restore:{}",
non_empty(ctx.call_id.clone(), "tool call_id")?
),
};
if request_id.len() > 512 {
return Err(ToolError::InvalidArgument(
"request_id must contain at most 512 bytes".into(),
));
}
let recoverable_error = |error| {
ToolError::ExecutionFailed(format!(
"WorkerRestore request_id={request_id}; reuse this ID with the original observation token only for explicit unknown/pending outcome recovery, not after a stale-intent conflict: {error}"
))
};
let response = self
.control
.restore_worker(
runtime_id,
worker_id,
server_api::WorkerRestoreRequest {
expected_observation_token: non_empty(
input.expected_observation_token,
"expected_observation_token",
)?,
request_id: request_id.clone(),
},
)
.await
.map_err(control_tool_error)?
.map_err(|error| recoverable_error(control_tool_error(error)))?;
return tool_output(self.operation, response).map_err(recoverable_error);
}
WorkerOperation::Remove => {
let input = parse::<WorkerRemoveInput>(input_json, "WorkerRemove")?;
@@ -1058,6 +1095,18 @@ fn tool_output(
response.status, response.body
)));
}
if operation == WorkerOperation::Restore {
let restored: server_api::WorkerRestoreResponse = serde_json::from_str(&response.body)
.map_err(|error| {
ToolError::ExecutionFailed(format!("invalid WorkerRestore response: {error}"))
})?;
if restored.result.state != server_api::WorkerRestoreState::Accepted {
return Err(ToolError::ExecutionFailed(format!(
"WorkerRestore returned {:?}: {}",
restored.result.state, response.body
)));
}
}
if operation == WorkerOperation::Stop
&& let Ok(summary) = serde_json::from_str::<SubWorkerStopSummary>(&response.body)
{
@@ -1150,7 +1199,7 @@ fn worker_tool_contribution(
}
}
WorkerOperation::Restore => {
definition::<WorkerTargetInput>(operation, control, runtime_worker_control)
definition::<WorkerRestoreInput>(operation, control, runtime_worker_control)
}
WorkerOperation::Remove => {
definition::<WorkerRemoveInput>(operation, control, runtime_worker_control)
@@ -1410,6 +1459,95 @@ mod tests {
})
}
#[tokio::test]
async fn worker_restore_forwards_exact_observation_and_request_identity() {
let client = Arc::new(RecordingWorkspaceClient::default());
let control = test_control(client.clone());
for _ in 0..2 {
control
.restore_worker(
"runtime/a".into(),
"worker/b".into(),
server_api::WorkerRestoreRequest {
expected_observation_token: "same-observation".into(),
request_id: "same-request".into(),
},
)
.await
.unwrap();
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(
requests[0].path,
"/api/w/workspace%2Ftest/worker-control/workers/runtime%2Fa/worker%2Fb/restore"
);
assert_eq!(requests[0].body, requests[1].body);
let body: serde_json::Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(
body,
serde_json::json!({"expected_observation_token":"same-observation","request_id":"same-request"})
);
assert!(serde_json::from_str::<WorkerRestoreInput>(r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-a","worker_id":"worker-a"}}"#).is_err());
}
#[tokio::test]
async fn worker_restore_reports_retry_identity_and_reuses_it_across_explicit_tool_calls() {
let client = Arc::new(RecordingWorkspaceClient::default());
let tool = WorkspaceWorkerTool {
operation: WorkerOperation::Restore,
control: test_control(client.clone()),
runtime_worker_control: true,
};
let mut input = serde_json::json!({
"subject": {"kind":"runtime_worker", "runtime_id":"runtime-a", "worker_id":"worker-b"},
"expected_observation_token":"original-observation"
});
// The fixture deliberately returns malformed JSON shape: delivery may
// have succeeded, so retain the exact tuple rather than issuing new intent.
let first = tool
.execute(
&input.to_string(),
ToolExecutionContext::new("original-call", "batch", 0),
)
.await
.unwrap_err();
assert!(
first
.to_string()
.contains("request_id=worker-restore:original-call")
);
input["request_id"] = "worker-restore:original-call".into();
let second = tool
.execute(
&input.to_string(),
ToolExecutionContext::new("explicit-recovery-call", "batch", 1),
)
.await
.unwrap_err();
assert!(
second
.to_string()
.contains("request_id=worker-restore:original-call")
);
{
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0].body, requests[1].body);
}
input["request_id"] = "x".repeat(513).into();
assert!(
tool.execute(
&input.to_string(),
ToolExecutionContext::new("invalid", "batch", 2)
)
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn worker_spawn_forwards_typed_initial_submit_to_workspace_api() {
let client = Arc::new(RecordingWorkspaceClient::default());
+343 -48
View File
@@ -4,7 +4,8 @@
//! authority. In that case model-visible Memory tools must go through the
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use std::sync::Arc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -24,11 +25,15 @@ use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
HookDeclaration, ToolContribution, ToolDeclaration,
};
use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored};
use crate::hook::{
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext,
WorkerRestored,
};
use crate::prompt::catalog::{PromptCatalog, WorkerPrompt};
use crate::worker::{
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
};
#[derive(Clone, Debug)]
@@ -74,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
Decode(#[from] serde_json::Error),
#[error("workspace memory backend rejected operation: {0}")]
Backend(String),
#[error("workspace memory backend returned an invalid response: {0}")]
InvalidResponse(String),
}
impl dyn WorkspaceClient + '_ {
@@ -672,12 +679,17 @@ struct WorkspaceResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
}
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
#[derive(Default)]
struct SubjectBehaviorRefreshState {
applied: Option<(u64, String)>,
pending: HashMap<u64, String>,
}
const READY_EMPTY_RESIDENT_SURFACE: &str =
"The current ready resident memory surface is intentionally empty.";
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>,
}
fn resident_summary_contribution(
output: memory::backend::MemoryResidentSummaryOutput,
@@ -715,37 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
}
}
#[async_trait]
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
async fn load(&self) -> SystemPromptContribution {
impl WorkspaceSubjektivResidentSummarySource {
async fn fetch(
&self,
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
match execute_subjektiv_memory_operation_with(
self.client.as_ref(),
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|client, request| {
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
},
) {
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
match resident_summary_contribution(output) {
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
}
contribution => contribution,
)? {
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
"unexpected subject resident context response: {other:?}"
))),
}
}
Ok(other) => {
tracing::debug!(?other, "unexpected subject resident Memory response");
SystemPromptContribution::Unavailable
fn render(
&self,
output: &server_api::SubjektivResidentContextOutput,
) -> Result<String, String> {
use memory::backend::MemoryResidentSummaryAvailability as Availability;
let availability = match output.memory_surface.availability {
Availability::Ready => "ready",
Availability::Ungenerated => "ungenerated",
Availability::Stale => "stale",
Availability::Failed => "failed",
Availability::Unavailable => "unavailable",
};
self.prompts
.load()
.subjektiv_resident_context(
&output.behavior_md,
availability,
output.memory_surface.content.as_deref(),
)
.map_err(|error| error.to_string())
}
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) {
let mut state = self
.behavior_state
.lock()
.expect("subject behavior state poisoned");
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
state.pending.remove(&output.behavior_revision);
}
}
#[async_trait]
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
async fn load(&self) -> SystemPromptContribution {
match self.fetch().await {
Ok(output) => match self.render(&output) {
Ok(rendered) => {
self.remember_behavior(&output);
SystemPromptContribution::Ready(rendered)
}
Err(error) => {
tracing::debug!(%error, "subject resident Memory unavailable");
SystemPromptContribution::Unavailable
tracing::error!(%error, "subject resident context render failed");
SystemPromptContribution::Ready(
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
}
},
Err(error) => {
tracing::error!(%error, "subject resident context fetch failed");
SystemPromptContribution::Ready(
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
}
}
}
async fn load_changed_resident_context(
&self,
) -> Result<Option<ResidentContextRefresh>, String> {
let output = self.fetch().await.map_err(|error| error.to_string())?;
let changed = self
.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied
.as_ref()
.is_none_or(|(revision, body)| {
*revision != output.behavior_revision || body != &output.behavior_md
});
if !changed {
return Ok(None);
}
let rendered = self.render(&output)?;
let revision = output.behavior_revision;
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.pending
.insert(revision, output.behavior_md);
Ok(Some(ResidentContextRefresh {
body: rendered,
revision,
}))
}
fn confirm_resident_context_revision(&self, revision: u64) {
let mut state = self
.behavior_state
.lock()
.expect("subject behavior state poisoned");
if let Some(body) = state.pending.remove(&revision) {
state.applied = Some((revision, body));
}
}
fn invalidate_resident_context_representation(&self) {
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied = None;
}
}
const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore";
const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request";
const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite";
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
/// shared host facility, while loading and rendering a post-restore refresh is
@@ -793,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
SUBJEKTIV_RESTORE_REFRESH_HOOK,
FeatureHookPoint::WorkerRestored,
))
.with_hook(HookDeclaration::new(
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
FeatureHookPoint::PreLlmRequest,
))
.with_hook(HookDeclaration::new(
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
FeatureHookPoint::BeforeSessionRewrite,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
@@ -804,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
prompts: Arc::clone(&self.prompts),
workspace_id: self.workspace_id.clone(),
},
)?;
context.hooks().add_pre_llm_request(
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
HookExecutionPolicy::fail_closed(),
SubjektivBehaviorRefreshHook {
source: Arc::clone(&self.source),
},
)?;
context.hooks().add_before_session_rewrite(
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
HookExecutionPolicy::fail_closed(),
SubjektivBehaviorRewriteHook {
source: Arc::clone(&self.source),
},
)
}
}
struct SubjektivBehaviorRewriteHook {
source: Arc<dyn SystemPromptContributionSource>,
}
#[async_trait]
impl Hook<BeforeSessionRewrite> for SubjektivBehaviorRewriteHook {
async fn call(
&self,
_context: &BeforeSessionRewriteContext,
) -> Result<BeforeSessionRewriteAction, HookError> {
self.source.invalidate_resident_context_representation();
Ok(BeforeSessionRewriteAction::Continue)
}
}
struct SubjektivBehaviorRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
}
#[async_trait]
impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
async fn call(&self, context: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
let refresh = self
.source
.load_changed_resident_context()
.await
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?;
if let Some(refresh) = refresh {
let system_items = context.system_items().ok_or_else(|| {
HookError::new(
HookErrorCategory::Dependency,
"Subject behavior refresh requires durable session append authority",
)
})?;
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
}
Ok(HookPreRequestAction::Continue)
}
}
struct SubjektivResidentRestoreRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -945,12 +1115,13 @@ pub(crate) fn is_builtin_subjektiv_consolidation_profile(
pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerManifest) -> bool {
manifest.feature.subjektiv.execution_enabled()
&& !is_builtin_subjektiv_consolidation_profile(manifest)
&& !manifest.feature.subjektiv.profile.consolidation_tools
}
pub(crate) fn ordinary_subjektiv_resident_summary_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
if !ordinary_subjektiv_features_enabled(manifest) {
return Ok(None);
@@ -988,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
}
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
client,
prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
})))
}
@@ -1002,7 +1175,7 @@ impl SubjektivConsolidationFeatureInstallPlan {
client: Arc<dyn WorkspaceClient>,
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
) -> std::io::Result<Option<Self>> {
if !is_builtin_subjektiv_consolidation_profile(manifest) {
if !manifest.feature.subjektiv.profile.consolidation_tools {
return Ok(None);
}
let config = &manifest.feature.subjektiv;
@@ -1120,6 +1293,10 @@ mod tests {
use agen::tool::ToolDefinition;
use std::sync::Mutex;
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
}
fn test_client() -> Arc<dyn WorkspaceClient> {
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
@@ -1132,6 +1309,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>,
scope_allowed: bool,
behavior: Mutex<(u64, String)>,
paths: Mutex<Vec<String>>,
timeouts: Mutex<Vec<Duration>>,
}
@@ -1145,6 +1323,7 @@ mod tests {
availability,
content: content.map(str::to_string),
scope_allowed: true,
behavior: Mutex::new((3, "Be deliberate.".to_string())),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
@@ -1155,6 +1334,7 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("must not be injected".into()),
scope_allowed: false,
behavior: Mutex::new((3, "must not be injected".to_string())),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
@@ -1184,7 +1364,7 @@ mod tests {
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
assert!(matches!(
request.operation,
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
));
if !self.scope_allowed {
return Ok(WorkspaceResponse {
@@ -1192,14 +1372,19 @@ mod tests {
body: "subject scope unavailable".into(),
});
}
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
memory::backend::MemoryResidentSummaryOutput {
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md,
behavior_revision,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
},
},
),
)
.unwrap(),
@@ -1511,13 +1696,19 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
Some("current subject surface"),
));
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
let ready = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_client.clone(),
test_prompts(),
)
.unwrap()
.unwrap();
assert_eq!(
ready.load().await,
SystemPromptContribution::Ready("current subject surface".into())
);
let loaded = ready.load().await;
let SystemPromptContribution::Ready(loaded) = loaded else {
panic!("subject resident context must be present");
};
assert!(loaded.contains("Be deliberate."));
assert!(loaded.contains("current subject surface"));
assert_eq!(
ready_client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
@@ -1531,13 +1722,18 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
None,
));
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
let ready_empty = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_empty_client,
test_prompts(),
)
.unwrap()
.unwrap();
assert_eq!(
ready_empty.load().await,
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
);
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
panic!("empty surface must retain Subject behavior");
};
assert!(ready_empty.contains("Be deliberate."));
assert!(ready_empty.contains("intentionally empty"));
for availability in [
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
@@ -1550,22 +1746,107 @@ permission = "write"
availability,
Some("must not be injected"),
)),
test_prompts(),
)
.unwrap()
.unwrap();
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
let SystemPromptContribution::Ready(loaded) = source.load().await else {
panic!("Subject behavior must survive unavailable surfaces");
};
assert!(loaded.contains("Be deliberate."));
assert!(!loaded.contains("must not be injected"));
}
}
#[tokio::test]
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
None,
));
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
let initial = source.load().await;
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
let refresh = source
.load_changed_resident_context()
.await
.unwrap()
.expect("changed behavior must produce one refresh");
assert_eq!(refresh.revision, 4);
assert!(refresh.body.contains("Ask when uncertain."));
source.confirm_resident_context_revision(refresh.revision);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
*client.behavior.lock().unwrap() = (5, String::new());
let cleared = source
.load_changed_resident_context()
.await
.unwrap()
.expect("cleared behavior must produce one refresh");
assert_eq!(cleared.revision, 5);
assert!(cleared.body.contains("No user-managed behavior is set"));
source.confirm_resident_context_revision(cleared.revision);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
source.invalidate_resident_context_representation();
let replay_after_rewrite = source
.load_changed_resident_context()
.await
.unwrap()
.expect("history rewrite must require a durable current representation");
assert_eq!(replay_after_rewrite.revision, 5);
assert!(
replay_after_rewrite
.body
.contains("No user-managed behavior is set")
);
// Initial load and every live update/clear/replay probe retain the
// target-side bounded resident request contract.
assert_eq!(
client.timeouts.lock().unwrap().as_slice(),
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
);
}
#[tokio::test]
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::denied());
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
let SystemPromptContribution::Ready(failure) = source.load().await else {
panic!("fetch failure must be explicit in resident context");
};
assert!(failure.contains("could not fetch"));
assert_eq!(
client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
@@ -1593,7 +1874,7 @@ permission = "write"
)
.unwrap();
assert!(
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
.unwrap()
.is_none()
);
@@ -1605,20 +1886,28 @@ permission = "write"
Some("policy-only surface must not load"),
));
assert!(
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
ordinary_subjektiv_resident_summary_source(
&policy_only,
unattached_client.clone(),
test_prompts()
)
.unwrap()
.is_none()
);
assert!(unattached_client.paths.lock().unwrap().is_empty());
let foreign = subject_manifest("other-workspace");
assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err());
assert!(
ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts())
.is_err()
);
}
#[test]
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
let manifest = subject_manifest("workspace");
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let prompts = Arc::new(ArcSwap::from(
@@ -1634,7 +1923,8 @@ permission = "write"
#[test]
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
let manifest = subject_manifest("workspace");
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let feature = SubjektivResidentRestoreRefreshFeature::new(
@@ -1647,8 +1937,13 @@ permission = "write"
let descriptor = feature.descriptor();
assert!(descriptor.tools.is_empty());
assert_eq!(descriptor.hooks.len(), 1);
assert_eq!(descriptor.hooks.len(), 3);
assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored);
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
assert_eq!(
descriptor.hooks[2].point,
FeatureHookPoint::BeforeSessionRewrite
);
}
#[test]
@@ -104,6 +104,23 @@ impl SubjektivSurfaceLifecycleFeature {
if !lifecycle_enabled || !dedicated {
return Ok(None);
}
Self::for_job(
workspace_client,
manifest,
client,
prompts,
workspace_context,
)
.map(Some)
}
pub(crate) fn for_job(
workspace_client: Arc<dyn WorkspaceClient>,
manifest: WorkerManifest,
client: Box<dyn LlmClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
workspace_context: WorkerWorkspaceContext,
) -> std::io::Result<Self> {
manifest
.feature
.subjektiv
@@ -121,7 +138,7 @@ impl SubjektivSurfaceLifecycleFeature {
"subjektiv surface generation requires Backend Workspace API authority",
));
}
Ok(Some(Self {
Ok(Self {
task: SubjektivSurfaceLifecycleTask {
workspace_client,
manifest,
@@ -129,7 +146,18 @@ impl SubjektivSurfaceLifecycleFeature {
prompts,
workspace_context,
},
}))
})
}
/// The Job tool awaits this clean-context lifecycle before submitting its result.
pub(crate) async fn complete_for_job(
&self,
cancellation: BackgroundTaskCancellation,
reusable_surface: Option<&serde_json::Value>,
) -> Result<serde_json::Value, HookError> {
self.task
.generate(None, cancellation, reusable_surface)
.await
}
}
@@ -182,80 +210,125 @@ impl FeatureBackgroundTask for SubjektivSurfaceLifecycleTask {
context: BackgroundTaskContext,
cancellation: BackgroundTaskCancellation,
) -> Result<(), HookError> {
self.generate(Some(&context), cancellation, None)
.await
.map(|_| ())
}
}
impl SubjektivSurfaceLifecycleTask {
async fn generate(
&self,
context: Option<&BackgroundTaskContext>,
cancellation: BackgroundTaskCancellation,
reusable_surface: Option<&serde_json::Value>,
) -> Result<serde_json::Value, HookError> {
for attempt in 0..MAX_GENERATION_ATTEMPTS {
if let Some(context) = context {
context.generation_fence.ensure_current()?;
}
if cancellation.is_cancelled() {
return Ok(());
return Err(HookError::new(
HookErrorCategory::Cancelled,
"surface generation cancelled",
));
}
let generation = self
.workspace_client
.prepare_subjektiv_memory_surface()
.await
.map_err(surface_hook_error)?;
if context.is_none() {
if let Some(snapshot_id) = &generation.current_snapshot_id {
return Ok(serde_json::json!({
"availability": "ready", "generation_id": generation.generation_id,
"store_revision": generation.store_revision, "snapshot_id": snapshot_id,
}));
}
}
// Preparation is Backend-fenced to the live attempt and immutable batch.
// Only a ready snapshot is immutable within its store revision. Never
// reuse a failed marker, which a later generation can replace.
if let Some(surface) = reusable_surface.filter(|surface| {
surface["availability"] == "ready"
&& surface["store_revision"].as_u64() == Some(generation.store_revision)
}) {
return Ok(surface.clone());
}
if generation.materials.is_empty() && generation.active_memory_count > 0 {
self.record_failure(&generation.generation_id, "input_budget_exhausted")
return self
.record_failure(&generation, "input_budget_exhausted", context.is_none())
.await;
tracing::warn!(
active_memory_count = generation.active_memory_count,
"subject Memory surface materials could not fit the bounded input"
);
return Ok(());
}
let points = if generation.materials.is_empty() {
Vec::new()
} else {
match self.edit_surface(&generation, cancellation.clone()).await {
Ok(points) => points,
Err(failure) => {
self.record_failure(&generation.generation_id, failure.reason_code)
.await;
match tokio::time::timeout(
TASK_TIMEOUT,
self.edit_surface(&generation, cancellation.clone()),
)
.await
{
Ok(Ok(points)) => points,
Ok(Err(failure)) => {
if cancellation.is_cancelled() {
return Err(HookError::new(
HookErrorCategory::Cancelled,
"surface generation cancelled",
));
}
tracing::warn!(error = %failure.error, reason_code = failure.reason_code, "subject Memory surface editor failed");
return Ok(());
return self
.record_failure(&generation, failure.reason_code, context.is_none())
.await;
}
Err(_) => {
return self
.record_failure(&generation, "editor_timeout", context.is_none())
.await;
}
}
};
if let Some(context) = context {
context.generation_fence.ensure_current()?;
if cancellation.is_cancelled() {
return Ok(());
}
let publish = self
if cancellation.is_cancelled() {
return Err(HookError::new(
HookErrorCategory::Cancelled,
"surface generation cancelled",
));
}
match self
.workspace_client
.publish_subjektiv_memory_surface(server_api::SubjektivSurfacePublishRequest {
generation_id: generation.generation_id.clone(),
points,
})
.await;
match publish {
.await
{
Ok(output) => {
tracing::debug!(
snapshot_id = output.snapshot_id,
store_revision = output.built_from_store_revision,
empty = output.empty,
"published subject Memory surface"
);
return Ok(());
return Ok(serde_json::json!({
"availability": "ready", "generation_id": generation.generation_id,
"store_revision": output.built_from_store_revision, "snapshot_id": output.snapshot_id,
}));
}
Err(WorkspaceMemoryBackendError::Http { status, .. })
if status == reqwest::StatusCode::CONFLICT
&& attempt + 1 < MAX_GENERATION_ATTEMPTS =>
{
// Confirmed Memory moved while editing. Discard the stale
// output and rebuild once from a fresh bounded generation.
continue;
}
Err(error) => {
self.record_failure(&generation.generation_id, "publish_failed")
.await;
tracing::warn!(%error, "subject Memory surface publication failed");
return Ok(());
return self
.record_failure(&generation, "publish_failed", context.is_none())
.await;
}
}
}
Ok(())
}
unreachable!("bounded generation loop always returns on its last attempt")
}
impl SubjektivSurfaceLifecycleTask {
async fn edit_surface(
&self,
generation: &server_api::SubjektivSurfacePrepareResponse,
@@ -344,17 +417,39 @@ impl SubjektivSurfaceLifecycleTask {
.map_err(SurfaceEditorFailure::editor)
}
async fn record_failure(&self, generation_id: &str, reason_code: &str) {
if let Err(error) = self
async fn record_failure(
&self,
generation: &server_api::SubjektivSurfacePrepareResponse,
reason_code: &str,
job_confirmation_required: bool,
) -> Result<serde_json::Value, HookError> {
let failure = self
.workspace_client
.fail_subjektiv_memory_surface(server_api::SubjektivSurfaceFailureRequest {
generation_id: generation_id.to_string(),
generation_id: generation.generation_id.clone(),
reason_code: reason_code.to_string(),
})
.await
.map_err(surface_hook_error)?;
// Success must attest the actual persisted marker, not merely receipt of
// a failure notification. A protected ready surface or moved revision
// must yield a Backend rejection/non-failed status, never a failed claim.
let confirmed_status = if job_confirmation_required {
"failed_confirmed"
} else {
"failed"
};
if failure.status != confirmed_status || failure.store_revision != generation.store_revision
{
tracing::debug!(%error, "could not record subject Memory surface failure");
return Err(HookError::new(
HookErrorCategory::Internal,
"Backend did not confirm a failed surface for this generation's revision; no Job result was submitted",
));
}
Ok(serde_json::json!({
"availability": "failed", "generation_id": generation.generation_id,
"store_revision": failure.store_revision, "reason_code": reason_code,
}))
}
}
@@ -537,6 +632,7 @@ mod tests {
) -> server_api::SubjektivSurfacePrepareResponse {
server_api::SubjektivSurfacePrepareResponse {
generation_id: "generation-1".into(),
current_snapshot_id: None,
store_revision: 1,
active_memory_count: materials.len(),
materials,
@@ -561,6 +657,13 @@ mod tests {
prepare_calls: AtomicUsize,
publish_calls: AtomicUsize,
failure_reasons: Mutex<Vec<String>>,
job_results: Mutex<Vec<serde_json::Value>>,
result_responses:
Mutex<std::collections::VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
current_revision: AtomicUsize,
failure_status: Mutex<String>,
failure_revision: AtomicUsize,
ready_snapshot: Mutex<Option<String>>,
}
impl SurfaceWorkspaceClient {
@@ -571,6 +674,12 @@ mod tests {
prepare_calls: AtomicUsize::new(0),
publish_calls: AtomicUsize::new(0),
failure_reasons: Mutex::new(Vec::new()),
job_results: Mutex::new(Vec::new()),
result_responses: Mutex::new(Default::default()),
current_revision: AtomicUsize::new(0),
failure_status: Mutex::new("failed".into()),
failure_revision: AtomicUsize::new(0),
ready_snapshot: Mutex::new(None),
}
}
@@ -581,6 +690,15 @@ mod tests {
}
}
fn revision(&self, fallback: usize) -> u64 {
let current = self.current_revision.load(Ordering::SeqCst);
if current == 0 {
fallback as u64
} else {
current as u64
}
}
fn response(value: server_api::SubjektivMemoryBackendResponse) -> WorkspaceResponse {
WorkspaceResponse {
status: 200,
@@ -606,6 +724,28 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/workers/self/backend-job-result") {
assert!(
self.publish_calls.load(Ordering::SeqCst) > 0
|| !self.failure_reasons.lock().unwrap().is_empty(),
"result must follow publication or recorded failure"
);
self.job_results
.lock()
.unwrap()
.push(serde_json::from_str(request.body.as_deref().unwrap()).unwrap());
return self
.result_responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
Ok(WorkspaceResponse {
status: 200,
body: "{\"replayed\":false}".into(),
})
});
}
let request: server_api::SubjektivMemoryBackendRequest =
serde_json::from_str(request.body.as_deref().unwrap_or_default())
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
@@ -615,7 +755,8 @@ mod tests {
let mut prepared =
generation(self.input_token_budget, vec![material("memory-1", 32)]);
prepared.generation_id = format!("generation-{call}");
prepared.store_revision = call as u64;
prepared.store_revision = self.revision(call);
prepared.current_snapshot_id = self.ready_snapshot.lock().unwrap().clone();
Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfacePrepared(prepared),
))
@@ -633,7 +774,7 @@ mod tests {
server_api::SubjektivMemoryBackendResponse::SurfacePublished(
server_api::SubjektivSurfacePublishResponse {
snapshot_id: format!("surface-{call}"),
built_from_store_revision: call as u64,
built_from_store_revision: self.revision(call),
empty: false,
},
),
@@ -644,8 +785,13 @@ mod tests {
Ok(Self::response(
server_api::SubjektivMemoryBackendResponse::SurfaceFailed(
server_api::SubjektivSurfaceFailureResponse {
store_revision: self.prepare_calls.load(Ordering::SeqCst) as u64,
status: "failed".into(),
store_revision: if self.failure_revision.load(Ordering::SeqCst) > 0
{
self.failure_revision.load(Ordering::SeqCst) as u64
} else {
self.revision(self.prepare_calls.load(Ordering::SeqCst))
},
status: self.failure_status.lock().unwrap().clone(),
},
),
))
@@ -974,4 +1120,340 @@ permission = "write"
let error = bounded_editor_input("system policy", &generation).unwrap_err();
assert!(error.to_string().contains("input budget"));
}
#[tokio::test]
async fn job_result_waits_for_surface_and_retries_the_exact_outcome() {
for failed in [false, true] {
let workspace = Arc::new(if failed {
SurfaceWorkspaceClient::with_input_token_budget(1)
} else {
SurfaceWorkspaceClient::new(0)
});
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
let lifecycle = SubjektivSurfaceLifecycleFeature {
task: test_task(workspace.clone(), Arc::new(AtomicUsize::new(0))),
};
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::default();
FeatureRegistryBuilder::new()
.with_module(
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
workspace.clone(),
)
.with_surface(lifecycle),
)
.install_into_pending(&mut pending, &mut hooks);
let (_, tool) = pending.remove(0)();
let input = r#"{"result":{"subject_id":"subject-1","candidate_ids":["candidate-1"]}}"#;
for _ in 0..2 {
tool.execute(input, agen::tool::ToolExecutionContext::direct())
.await
.unwrap();
}
let results = workspace.job_results.lock().unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0], results[1]);
assert_eq!(
results[0]["result"]["surface"]["availability"],
if failed { "failed" } else { "ready" }
);
assert_eq!(
results[0]["result"]["surface"]["generation_id"],
"generation-1"
);
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
assert!(
tool.execute(
r#"{"result":{"subject_id":"subject-1","candidate_ids":[]}}"#,
agen::tool::ToolExecutionContext::direct()
)
.await
.is_err()
);
}
}
#[tokio::test]
async fn job_uses_backend_confirmed_current_surface_without_regeneration() {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
*workspace.ready_snapshot.lock().unwrap() = Some("already-current".into());
let calls = Arc::new(AtomicUsize::new(0));
let task = test_task(workspace.clone(), calls.clone());
let outcome = task
.generate(None, BackgroundTaskCancellation::default(), None)
.await
.unwrap();
assert_eq!(outcome["availability"], "ready");
assert_eq!(outcome["generation_id"], "generation-1");
assert_eq!(outcome["snapshot_id"], "already-current");
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
assert!(workspace.failure_reasons.lock().unwrap().is_empty());
}
#[tokio::test]
async fn job_result_rejects_model_surface_claims_before_generation() {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
let lifecycle = SubjektivSurfaceLifecycleFeature {
task: test_task(workspace.clone(), Arc::new(AtomicUsize::new(0))),
};
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::default();
FeatureRegistryBuilder::new()
.with_module(
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
workspace.clone(),
)
.with_surface(lifecycle),
)
.install_into_pending(&mut pending, &mut hooks);
let (_, tool) = pending.remove(0)();
assert!(tool.execute(r#"{"result":{"subject_id":"subject-1","candidate_ids":[],"surface":{"availability":"ready"}}}"#, agen::tool::ToolExecutionContext::direct()).await.is_err());
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 0);
assert!(workspace.job_results.lock().unwrap().is_empty());
}
#[derive(Clone)]
struct PendingSurfaceEditorClient(Arc<tokio::sync::Notify>);
#[async_trait]
impl LlmClient for PendingSurfaceEditorClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.0.notify_one();
Ok(Box::pin(futures::stream::pending()))
}
}
#[tokio::test]
async fn cancelling_job_surface_waits_for_editor_and_never_submits_result() {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
let entered = Arc::new(tokio::sync::Notify::new());
let mut task = test_task(workspace.clone(), Arc::new(AtomicUsize::new(0)));
task.client = Box::new(PendingSurfaceEditorClient(entered.clone()));
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::default();
FeatureRegistryBuilder::new()
.with_module(
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
workspace.clone(),
)
.with_surface(SubjektivSurfaceLifecycleFeature { task }),
)
.install_into_pending(&mut pending, &mut hooks);
let (_, tool) = pending.remove(0)();
let running_tool = tool.clone();
let context = agen::tool::ToolExecutionContext::new("result-call", "batch", 0);
let running_context = context.clone();
let running = tokio::spawn(async move {
running_tool
.execute(
r#"{"result":{"subject_id":"subject-1","candidate_ids":[]}}"#,
running_context,
)
.await
});
tokio::time::timeout(Duration::from_secs(5), entered.notified())
.await
.unwrap();
tool.cancel_execution(&context).await.unwrap();
let outcome = tokio::time::timeout(Duration::from_secs(5), running)
.await
.unwrap()
.unwrap();
assert!(matches!(outcome, Err(agen::tool::ToolError::Cancelled(_))));
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
assert!(workspace.job_results.lock().unwrap().is_empty());
assert!(workspace.failure_reasons.lock().unwrap().is_empty());
}
fn job_result_tool(workspace: Arc<SurfaceWorkspaceClient>) -> Arc<dyn agen::tool::Tool> {
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::default();
FeatureRegistryBuilder::new()
.with_module(
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
workspace.clone(),
)
.with_surface(SubjektivSurfaceLifecycleFeature {
task: test_task(workspace, Arc::new(AtomicUsize::new(0))),
}),
)
.install_into_pending(&mut pending, &mut hooks);
pending.remove(0)().1
}
const CORRECT_RESULT: &str =
r#"{"result":{"subject_id":"subject-1","candidate_ids":["candidate-1"]}}"#;
const WRONG_RESULT: &str = r#"{"result":{"subject_id":"wrong-subject","candidate_ids":[]}}"#;
fn rejection(status: u16) -> Result<WorkspaceResponse, WorkspaceClientError> {
Ok(WorkspaceResponse {
status,
body: "explicit rejection before acceptance".into(),
})
}
#[tokio::test]
async fn job_result_definitive_rejection_allows_correction_and_reuses_current_ready_surface() {
for status in [400, 403, 422] {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
workspace.current_revision.store(1, Ordering::SeqCst);
workspace
.result_responses
.lock()
.unwrap()
.push_back(rejection(status));
let tool = job_result_tool(workspace.clone());
assert!(
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.is_err()
);
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.unwrap();
let results = workspace.job_results.lock().unwrap();
assert_eq!(results.len(), 2);
assert_ne!(
results[0]["result"]["subject_id"],
results[1]["result"]["subject_id"]
);
assert_eq!(
results[0]["result"]["surface"],
results[1]["result"]["surface"]
);
assert_eq!(
workspace.prepare_calls.load(Ordering::SeqCst),
2,
"reuse must verify current revision"
);
assert_eq!(
workspace.publish_calls.load(Ordering::SeqCst),
1,
"correction must not duplicate publication"
);
drop(results);
assert!(
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.is_err(),
"accepted results stay sealed"
);
}
}
#[tokio::test]
async fn job_result_rejection_rebuilds_conflicting_or_stale_surfaces() {
for status in [400, 409] {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
workspace.current_revision.store(1, Ordering::SeqCst);
workspace
.result_responses
.lock()
.unwrap()
.push_back(rejection(status));
let tool = job_result_tool(workspace.clone());
assert!(
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.is_err()
);
workspace.current_revision.store(2, Ordering::SeqCst);
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.unwrap();
let results = workspace.job_results.lock().unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0]["result"]["surface"]["store_revision"], 1);
assert_eq!(results[1]["result"]["surface"]["store_revision"], 2);
assert_ne!(
results[0]["result"]["surface"]["generation_id"],
results[1]["result"]["surface"]["generation_id"]
);
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 2);
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 2);
}
}
#[tokio::test]
async fn job_result_ambiguous_acceptance_never_reopens_even_after_later_rejection() {
for first in [
Err(WorkspaceClientError::Request(
"accepted but response lost".into(),
)),
rejection(500),
] {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
workspace
.result_responses
.lock()
.unwrap()
.extend([first, rejection(403)]);
let tool = job_result_tool(workspace.clone());
assert!(
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.is_err()
);
assert!(
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.is_err()
);
assert!(
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.is_err()
);
assert!(
tool.execute(WRONG_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.is_err()
);
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.unwrap();
let results = workspace.job_results.lock().unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0], results[1]);
assert_eq!(results[1], results[2]);
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 1);
}
}
#[tokio::test]
async fn job_result_never_submits_unconfirmed_failure_or_wrong_revision() {
for (status, revision) in [
("failed", 0),
("ready", 0),
("stale", 0),
("failed_confirmed", 2),
] {
let workspace = Arc::new(SurfaceWorkspaceClient::with_input_token_budget(1));
workspace.current_revision.store(1, Ordering::SeqCst);
let tool = job_result_tool(workspace.clone());
*workspace.failure_status.lock().unwrap() = status.into();
workspace.failure_revision.store(revision, Ordering::SeqCst);
assert!(
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.is_err()
);
assert!(workspace.job_results.lock().unwrap().is_empty());
// Nothing was dispatched/sealed: a real confirmed outcome remains retryable.
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
workspace.failure_revision.store(0, Ordering::SeqCst);
tool.execute(CORRECT_RESULT, agen::tool::ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(workspace.job_results.lock().unwrap().len(), 1);
}
}
}
@@ -5,9 +5,9 @@ use crate::feature::{
};
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -529,7 +529,7 @@ fn native_merge_request_tools(
let (meta, tool) = definition(client.clone(), kind)();
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
WipMountError::InvalidProjection {
route: "/features/merge-request/merge-requests".into(),
route: "/merge-requests".into(),
message: format!("Merge Request tool schema cannot be retained: {error}"),
}
})?;
@@ -562,9 +562,9 @@ pub fn mount_workspace_http_merge_request_wip(
client: Arc<dyn WorkspaceClient>,
config: MergeRequestFeatureConfig,
permissions: Option<ToolPermissionConfig>,
feature_route: &WipFeatureRoute,
namespace_route: &WipNamespaceRoute,
) -> Result<(), WipMountError> {
let collection_route = feature_route.child("merge-requests")?;
let collection_route = namespace_route.root().to_string();
let tools = native_merge_request_tools(client, config)?;
let claimed_tools = tools
.iter()
@@ -1049,7 +1049,7 @@ fn merge_request_operation_declaration(
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| WipMountError::InvalidProjection {
route: "/features/merge-request/merge-requests".into(),
route: "/merge-requests".into(),
message: format!(
"Merge Request tool `{}` schema has no properties",
tool.name
@@ -1474,7 +1474,9 @@ mod tests {
#[test]
fn native_mount_uses_host_route_and_accepts_only_one_canonical_item_segment() {
let mut registry = WipMountRegistry::new();
let feature_route = registry.allocate_feature_route("merge-request").unwrap();
let namespace_route = registry
.allocate_namespace("merge-request", "merge-requests")
.unwrap();
mount_workspace_http_merge_request_wip(
&mut registry,
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")),
@@ -1484,17 +1486,14 @@ mod tests {
..Default::default()
},
None,
&feature_route,
&namespace_route,
)
.unwrap();
assert_eq!(
registry.routes().collect::<Vec<_>>(),
["/features/merge-request/merge-requests"]
);
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/merge-requests"]);
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/merge-request/merge-requests".into(),
collection_route: "/merge-requests".into(),
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
};
assert!(
@@ -1645,7 +1644,7 @@ mod tests {
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/merge-request/merge-requests".into(),
collection_route: "/merge-requests".into(),
revisions: Arc::clone(&revisions),
};
let first = resolver.resolve("MR-1").unwrap().object.validator;
+21 -24
View File
@@ -21,9 +21,9 @@ use wip_protocol::{
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
@@ -551,9 +551,9 @@ pub fn mount_workspace_http_objective_wip(
registry: &mut WipMountRegistry,
client: Arc<dyn WorkspaceClient>,
permissions: Option<ToolPermissionConfig>,
feature_route: &WipFeatureRoute,
namespace_route: &WipNamespaceRoute,
) -> Result<(), WipMountError> {
let collection_route = feature_route.child("objectives")?;
let collection_route = namespace_route.root().to_string();
let backend = WorkspaceHttpObjectiveBackend::new(client);
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let collection_descriptor = objective_collection_descriptor();
@@ -1775,7 +1775,9 @@ mod tests {
}));
let mut registry = WipMountRegistry::new();
let feature_route = registry.allocate_feature_route("objective").unwrap();
let namespace_route = registry
.allocate_namespace("objective", "objectives")
.unwrap();
mount_workspace_http_objective_wip(
&mut registry,
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
@@ -1783,18 +1785,16 @@ mod tests {
"http://backend",
)),
None,
&feature_route,
&namespace_route,
)
.unwrap();
assert_eq!(
registry.routes().collect::<Vec<_>>(),
["/features/objective/objectives"]
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/objectives"]);
assert_eq!(namespace_route.root(), "/objectives");
assert!(
registry
.allocate_namespace("objective", "../tickets")
.is_err()
);
assert_eq!(
feature_route.child("objectives").unwrap(),
"/features/objective/objectives"
);
assert!(feature_route.child("../tickets").is_err());
}
#[test]
@@ -1805,7 +1805,7 @@ mod tests {
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
)),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let initial = resolver.resolve("O-3").unwrap();
@@ -1971,7 +1971,7 @@ mod tests {
let collection = ObjectiveCollectionWipHandler {
backend,
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let result = collection
@@ -2013,7 +2013,7 @@ mod tests {
let collection = ObjectiveCollectionWipHandler {
backend: backend.clone(),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let context = || WipCallContext {
@@ -2170,7 +2170,7 @@ mod tests {
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
)),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let output = match handler
@@ -2191,10 +2191,7 @@ mod tests {
let output = crate::wip::wip_to_json(&output.value).unwrap();
assert_eq!(output["next_cursor"], "next-page");
assert_eq!(output["has_more"], true);
assert_eq!(
output["objectives"][0]["path"],
"/features/objective/objectives/O-3"
);
assert_eq!(output["objectives"][0]["path"], "/objectives/O-3");
assert_eq!(output["objectives"][0]["linked_tickets"][0], "T-7");
assert!(!output.to_string().contains("00001OBJECTIVE"));
assert!(!output.to_string().contains("00001TICKET"));
@@ -2243,7 +2240,7 @@ mod tests {
let resolver = ObjectiveItemResolver {
backend: backend.clone(),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let before = resolver.resolve("O-3").unwrap().object.validator;
+18 -17
View File
@@ -34,9 +34,9 @@ use crate::feature::{
};
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -1377,7 +1377,7 @@ fn native_ticket_tools(
let (meta, tool) = definition();
let projection = native_ticket_operation(&meta.name).ok_or_else(|| {
WipMountError::InvalidProjection {
route: "/features/ticket/tickets".into(),
route: "/tickets".into(),
message: format!(
"enabled Ticket tool `{}` has no native projection",
meta.name
@@ -1386,7 +1386,7 @@ fn native_ticket_tools(
})?;
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
WipMountError::InvalidProjection {
route: "/features/ticket/tickets".into(),
route: "/tickets".into(),
message: format!("Ticket tool schema cannot be retained: {error}"),
}
})?;
@@ -1418,9 +1418,9 @@ pub fn mount_workspace_http_ticket_wip(
client: Arc<dyn WorkspaceClient>,
access: TicketFeatureAccess,
permissions: Option<ToolPermissionConfig>,
feature_route: &WipFeatureRoute,
namespace_route: &WipNamespaceRoute,
) -> Result<(), WipMountError> {
let collection_route = feature_route.child("tickets")?;
let collection_route = namespace_route.root().to_string();
let tools = native_ticket_tools(client, access)?;
let claimed_tools = tools
.iter()
@@ -1920,7 +1920,7 @@ fn ticket_operation_declaration(
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| WipMountError::InvalidProjection {
route: "/features/ticket/tickets".into(),
route: "/tickets".into(),
message: format!("Ticket tool `{}` schema has no properties", tool.name),
})?;
let required = tool
@@ -2103,7 +2103,7 @@ mod tests {
#[test]
fn native_ticket_mount_uses_host_route_and_rejects_non_ticket_children() {
let mut registry = WipMountRegistry::new();
let feature_route = registry.allocate_feature_route("ticket").unwrap();
let namespace_route = registry.allocate_namespace("ticket", "tickets").unwrap();
mount_workspace_http_ticket_wip(
&mut registry,
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
@@ -2112,20 +2112,21 @@ mod tests {
)),
TicketFeatureAccess::review(),
None,
&feature_route,
&namespace_route,
)
.unwrap();
assert_eq!(
registry.routes().collect::<Vec<_>>(),
["/features/ticket/tickets"]
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/tickets"]);
assert!(
registry
.allocate_namespace("ticket", "../objectives")
.is_err()
);
assert!(feature_route.child("../objectives").is_err());
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
collection_route: "/tickets".into(),
revisions,
};
assert!(resolver.resolve("T-42").is_some());
@@ -2189,7 +2190,7 @@ mod tests {
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
};
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
@@ -2243,7 +2244,7 @@ mod tests {
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
};
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
@@ -0,0 +1,333 @@
//! WIP-only, grant-bound Workspace configuration Feature. No local config store
//! or OS Workdir/session is created by this logical attachment.
pub mod backend;
mod content;
pub mod wip;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use protocol::{
FeatureInvocation, FeatureInvocationDescriptor, FeatureInvocationIdentity,
FeatureInvocationResult, FeatureInvocationStatus, FeatureInvocationSyntax,
InvocationArgumentDescriptor, InvocationArgumentType, InvocationCompletion, InvocationValue,
};
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInvocationContext,
FeatureInvocationHandler, FeatureInvocationHandlerError, FeatureModule,
};
use crate::worker::WorkspaceClient;
pub const FEATURE_ID: &str = "workspace-config";
pub const INVOCATION_ID: &str = "builtin:workspace-config/attach";
pub const ATTACHMENT_ALIAS: &str = "workspace-config";
pub const CONTENT_ROOT: &str = "/workspace-config";
const DEADLINE: Duration = Duration::from_secs(30);
/// Requested restriction, not an authorization grant. `None` selects the
/// effective Backend grant. Explicit read_write must never silently downgrade.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigAccess {
ReadOnly,
ReadWrite,
}
impl ConfigAccess {
pub fn as_str(self) -> &'static str {
match self {
Self::ReadOnly => "read_only",
Self::ReadWrite => "read_write",
}
}
}
fn requested_access(value: Option<&str>) -> Result<Option<ConfigAccess>, ConfigAttachError> {
match value {
None | Some("effective") => Ok(None),
Some("read_only") => Ok(Some(ConfigAccess::ReadOnly)),
Some("read_write") => Ok(Some(ConfigAccess::ReadWrite)),
_ => Err(ConfigAttachError::InvalidRequest),
}
}
/// Adapter result from the *existing Backend attachment ledger*. No Worker-local
/// attachment registry or authorization state is created from this result.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConfigAttachment {
pub connection_id: String,
pub access: ConfigAccess,
pub already_attached: bool,
}
/// Safe diagnostics only. Raw Backend/transport errors may contain host paths or
/// secrets and must be classified by the adapter, never copied into these results.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConfigAttachError {
InvalidRequest,
Denied,
Unavailable,
OutcomeUnknown,
}
impl ConfigAttachError {
fn message(self) -> &'static str {
match self {
Self::InvalidRequest => "Invalid Workspace config attachment request",
Self::Denied => "Workspace config access denied; explicit access is not downgraded",
Self::Unavailable => "Workspace config attachment unavailable before effects",
Self::OutcomeUnknown => {
"Workspace config attachment outcome unknown; inspect current attachments before retrying"
}
}
}
fn invocation_error(self) -> FeatureInvocationHandlerError {
if self == Self::OutcomeUnknown {
FeatureInvocationHandlerError::outcome_unknown(self.message())
} else {
FeatureInvocationHandlerError::failed(self.message())
}
}
}
/// Narrow application adapter seam, not a config compatibility Tool/API.
///
/// The production implementation captures an identity-bound WorkspaceClient and
/// uses the shared typed Backend attach operation. Both validation (including
/// T696 replay/resume) and attach must consult current authority. Attach rechecks
/// authorization atomically with ledger mutation, enforces explicit access and
/// alias identity. The execution key identifies the host receipt, not a grant;
/// Backend alias attach is idempotent under its session lock. Never blind retry.
#[async_trait]
pub trait WorkspaceConfigAttachmentBackend: Send + Sync {
fn is_available(&self) -> bool;
fn validate_access(&self, access: Option<ConfigAccess>) -> Result<(), ConfigAttachError>;
async fn attach(
&self,
idempotency_key: &str,
access: Option<ConfigAccess>,
) -> Result<ConfigAttachment, ConfigAttachError>;
}
/// Shared slash/self-attach handler. Possession of Workdir tools is not a grant.
#[derive(Clone)]
pub struct WorkspaceConfigFeature {
client: Arc<dyn WorkspaceClient>,
backend: Arc<dyn WorkspaceConfigAttachmentBackend>,
content_backend: Option<Arc<backend::WorkspaceConfigBackend>>,
wip_mode: bool,
permits: Arc<tokio::sync::Semaphore>,
}
impl WorkspaceConfigFeature {
/// Host supplies the actual mode; a user invocation cannot enable WIP or
/// change the Workspace/security context captured by the adapter.
pub fn new(
client: Arc<dyn WorkspaceClient>,
backend: Arc<dyn WorkspaceConfigAttachmentBackend>,
wip_mode: bool,
) -> Self {
Self {
client,
backend,
content_backend: None,
wip_mode,
permits: Arc::new(tokio::sync::Semaphore::new(16)),
}
}
/// Controller registration gate: a flag cannot create Backend authority or
/// turn a Tools/standalone Worker into a Workspace WIP Worker.
pub fn configured(
client: Arc<dyn WorkspaceClient>,
enabled: bool,
wip_mode: bool,
) -> Option<Self> {
(enabled
&& wip_mode
&& client.is_available()
&& client
.workspace_id()
.is_some_and(|id| !id.is_empty() && !id.chars().any(char::is_control)))
.then(|| Self::for_workspace(client, true))
}
/// Production adapter captures the injected identity-bound Workspace client.
pub fn for_workspace(client: Arc<dyn WorkspaceClient>, wip_mode: bool) -> Self {
let backend = Arc::new(backend::WorkspaceConfigBackend::new(client.clone()));
let mut feature = Self::new(client, backend.clone(), wip_mode);
feature.content_backend = Some(backend);
feature
}
pub fn is_available(&self) -> bool {
self.wip_mode
&& self.client.is_available()
&& self.client.workspace_id().is_some_and(|id| !id.is_empty())
&& self.backend.is_available()
}
fn validate_access(&self, access: Option<ConfigAccess>) -> Result<(), ConfigAttachError> {
if !self.is_available() {
return Err(ConfigAttachError::Denied);
}
self.backend.validate_access(access)
}
/// The only attach execution path used by T696 and native WIP. No router
/// session, command capability, or attachment lifetime is synthesized here.
pub async fn attach(
&self,
idempotency_key: &str,
access: Option<ConfigAccess>,
) -> Result<ConfigAttachment, ConfigAttachError> {
if idempotency_key.is_empty() || idempotency_key.len() > 256 {
return Err(ConfigAttachError::InvalidRequest);
}
self.validate_access(access)?;
let _permit = tokio::time::timeout(DEADLINE, self.permits.acquire())
.await
.map_err(|_| ConfigAttachError::Unavailable)?
.map_err(|_| ConfigAttachError::Unavailable)?;
// Authority can change while queued. Backend still rechecks on execution.
self.validate_access(access)?;
let result = tokio::time::timeout(DEADLINE, self.backend.attach(idempotency_key, access))
.await
.map_err(|_| ConfigAttachError::OutcomeUnknown)??;
// The adapter must map only a safe lifetime identity from a validated
// Backend result. Invalid completion metadata cannot imply no effects.
if result.connection_id.is_empty()
|| result.connection_id.len() > 256
|| !result
.connection_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
|| access.is_some_and(|requested| requested != result.access)
{
return Err(ConfigAttachError::OutcomeUnknown);
}
Ok(result)
}
}
pub fn workspace_config_invocation_descriptor() -> FeatureInvocationDescriptor {
FeatureInvocationDescriptor {
identity: FeatureInvocationIdentity(INVOCATION_ID.into()),
name: FEATURE_ID.into(),
aliases: Vec::new(),
display_name: "Workspace config".into(),
description: "Select this Workspace's logical config attachment before the request reaches the assistant. Backend grants determine access; no host filesystem or command access is added.".into(),
syntax: FeatureInvocationSyntax::Parenthesized,
arguments: vec![InvocationArgumentDescriptor {
name: "access".into(),
position: Some(0),
required: false,
value_type: InvocationArgumentType::Enum {
values: vec!["effective".into(), "read_only".into(), "read_write".into()],
},
completion: InvocationCompletion::None,
description: Some("Default: effective Backend grant. read_only restricts the connection; explicit read_write fails if unavailable.".into()),
}],
client_adapter: None,
}
}
impl FeatureModule for WorkspaceConfigFeature {
fn descriptor(&self) -> FeatureDescriptor {
let descriptor = FeatureDescriptor::builtin(FEATURE_ID, "Workspace config")
.with_description("WIP-only logical Workspace config attachment preparation");
if self.wip_mode && self.client.workspace_id().is_some_and(|id| !id.is_empty()) {
descriptor.with_chat_invocation(workspace_config_invocation_descriptor())
} else {
descriptor
}
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if self.wip_mode && self.client.workspace_id().is_some_and(|id| !id.is_empty()) {
context
.chat_invocations()
.register(workspace_config_invocation_descriptor(), self.clone())?;
}
// No tools are declared/installed, including in non-WIP mode.
Ok(())
}
}
fn invocation_access(
invocation: &FeatureInvocation,
) -> Result<Option<ConfigAccess>, ConfigAttachError> {
if invocation.identity.0 != INVOCATION_ID
|| invocation.name != FEATURE_ID
|| invocation.arguments.len() > 1
{
return Err(ConfigAttachError::InvalidRequest);
}
match invocation.arguments.first() {
None => requested_access(None),
Some(argument) if argument.name == "access" => match &argument.value {
InvocationValue::String(value) => requested_access(Some(value)),
_ => Err(ConfigAttachError::InvalidRequest),
},
_ => Err(ConfigAttachError::InvalidRequest),
}
}
#[async_trait]
impl FeatureInvocationHandler for WorkspaceConfigFeature {
fn is_available(&self) -> bool {
WorkspaceConfigFeature::is_available(self)
}
fn validate(
&self,
invocation: &FeatureInvocation,
) -> Result<(), FeatureInvocationHandlerError> {
let access = invocation_access(invocation).map_err(ConfigAttachError::invocation_error)?;
self.validate_access(access)
.map_err(ConfigAttachError::invocation_error)
}
async fn invoke(
&self,
context: FeatureInvocationContext,
invocation: &FeatureInvocation,
) -> Result<FeatureInvocationResult, FeatureInvocationHandlerError> {
if context.invocation_id != invocation.invocation_id {
return Err(ConfigAttachError::InvalidRequest.invocation_error());
}
let access = invocation_access(invocation).map_err(ConfigAttachError::invocation_error)?;
let result = self
.attach(&context.invocation_id, access)
.await
.map_err(ConfigAttachError::invocation_error)?;
let action = if result.already_attached {
"Already attached"
} else {
"Attached"
};
Ok(FeatureInvocationResult {
invocation_id: context.invocation_id,
identity: invocation.identity.clone(),
status: FeatureInvocationStatus::Succeeded,
message: format!(
"{action}: {ATTACHMENT_ALIAS} (Workspace configuration; {}).",
result.access.as_str()
),
context: Some(format!(
"Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Discover structure through WIP; read and change bodies through published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.",
result.access.as_str()
)),
})
}
}
#[cfg(test)]
#[path = "workspace_config_tests.rs"]
mod tests;
#[cfg(test)]
mod production_tests;
@@ -0,0 +1,302 @@
//! Identity-bound typed Workspace transport. Never retries a dispatched request.
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Serialize, de::DeserializeOwned};
use server_api::{
WorkspaceConfigAccess, WorkspaceConfigApiError, WorkspaceConfigAttachRequest,
WorkspaceConfigAttachment, WorkspaceConfigFailureClassification,
};
use wip_protocol::{ProtocolError, ProtocolErrorCode};
use super::{
ATTACHMENT_ALIAS, CONTENT_ROOT, ConfigAccess, ConfigAttachError, ConfigAttachment, DEADLINE,
WorkspaceConfigAttachmentBackend,
};
use crate::wip::WipOperationError;
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
};
pub const MAX_TEXT_BYTES: usize = 256 * 1024;
pub const MAX_PATH_BYTES: usize = 512;
pub const MAX_NODES: usize = 256;
pub const MAX_DEPTH: u32 = 8;
const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
#[derive(Clone)]
pub struct WorkspaceConfigBackend {
client: Arc<dyn WorkspaceClient>,
}
impl WorkspaceConfigBackend {
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
Self { client }
}
fn base(&self) -> Result<String, WipOperationError> {
let workspace = self
.client
.workspace_id()
.filter(|id| !id.is_empty())
.ok_or_else(|| {
failure(
ProtocolErrorCode::PermissionDenied,
"Workspace config authority unavailable",
)
})?;
let encoded: String = workspace
.bytes()
.map(|byte| {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') {
(byte as char).to_string()
} else {
format!("%{byte:02X}")
}
})
.collect();
Ok(format!("/api/w/{encoded}/workers/self/workspace-config"))
}
fn request<T: DeserializeOwned>(
&self,
request: WorkspaceRequest,
mutation: bool,
) -> Result<T, WipOperationError> {
let response = self
.client
.execute_with_timeout(request, DEADLINE)
.map_err(|error| {
if mutation && !matches!(error, WorkspaceClientError::InvalidPath(_)) {
unknown()
} else {
failure(
ProtocolErrorCode::Internal,
"Workspace config transport unavailable",
)
}
})?;
if response.body.len() > MAX_RESPONSE_BYTES {
return Err(if mutation {
unknown()
} else {
failure(
ProtocolErrorCode::ResourceLimitExceeded,
"Workspace config response exceeds limit",
)
});
}
if !response.is_success() {
if let Ok(error) = serde_json::from_str::<WorkspaceConfigApiError>(&response.body) {
if error.classification == WorkspaceConfigFailureClassification::Unknown {
return Err(unknown());
}
// Do not reflect arbitrary transport/provider text into model output.
let code = match error.status {
401 | 403 | 404 => ProtocolErrorCode::PermissionDenied,
409 => ProtocolErrorCode::ValidatorMismatch,
413 | 429 => ProtocolErrorCode::ResourceLimitExceeded,
400 | 422 => ProtocolErrorCode::InvalidArguments,
_ => ProtocolErrorCode::Internal,
};
return Err(failure(
code,
"Workspace config request rejected without commit",
));
}
return Err(if mutation {
unknown()
} else {
failure(
if matches!(response.status, 401 | 403 | 404) {
ProtocolErrorCode::PermissionDenied
} else {
ProtocolErrorCode::Internal
},
"Workspace config request denied or unavailable",
)
});
}
serde_json::from_str(&response.body).map_err(|_| {
if mutation {
unknown()
} else {
failure(
ProtocolErrorCode::Internal,
"Invalid Workspace config response",
)
}
})
}
/// GET checks the current grant even when the Worker has not attached yet.
pub fn current(&self) -> Result<Option<WorkspaceConfigAttachment>, WipOperationError> {
let result: Option<WorkspaceConfigAttachment> =
self.request(WorkspaceRequest::get(self.base()?), false)?;
if let Some(attachment) = &result {
self.validate_attachment(attachment)?;
}
Ok(result)
}
fn validate_attachment(
&self,
attachment: &WorkspaceConfigAttachment,
) -> Result<(), WipOperationError> {
if Some(attachment.workspace_id.as_str()) != self.client.workspace_id()
|| attachment.alias != ATTACHMENT_ALIAS
|| attachment.content_path != CONTENT_ROOT
|| attachment.connection_id.is_empty()
|| attachment.connection_id.len() > 256
|| !attachment
.connection_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
{
return Err(failure(
ProtocolErrorCode::PermissionDenied,
"Invalid Workspace config attachment identity",
));
}
Ok(())
}
pub async fn post<T: DeserializeOwned + Send + 'static, R: Serialize>(
&self,
suffix: &str,
request: &R,
mutation: bool,
) -> Result<T, WipOperationError> {
let body = serde_json::to_string(request).map_err(|_| {
failure(
ProtocolErrorCode::InvalidArguments,
"Invalid Workspace config request",
)
})?;
if body.len() > MAX_RESPONSE_BYTES {
return Err(failure(
ProtocolErrorCode::ResourceLimitExceeded,
"Workspace config request exceeds limit",
));
}
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("{}{suffix}", self.base()?),
body,
);
let this = self.clone();
tokio::time::timeout(
DEADLINE,
tokio::task::spawn_blocking(move || this.request(request, mutation)),
)
.await
.map_err(|_| {
if mutation {
unknown()
} else {
failure(
ProtocolErrorCode::ResourceLimitExceeded,
"Workspace config request deadline exceeded",
)
}
})?
.map_err(|_| {
if mutation {
unknown()
} else {
failure(
ProtocolErrorCode::Internal,
"Workspace config transport unavailable",
)
}
})?
}
pub async fn current_async(
&self,
) -> Result<Option<WorkspaceConfigAttachment>, WipOperationError> {
let this = self.clone();
tokio::time::timeout(
DEADLINE,
tokio::task::spawn_blocking(move || this.current()),
)
.await
.map_err(|_| {
failure(
ProtocolErrorCode::ResourceLimitExceeded,
"Workspace config request deadline exceeded",
)
})?
.map_err(|_| {
failure(
ProtocolErrorCode::Internal,
"Workspace config transport unavailable",
)
})?
}
}
#[async_trait]
impl WorkspaceConfigAttachmentBackend for WorkspaceConfigBackend {
fn is_available(&self) -> bool {
self.client.is_available()
}
fn validate_access(&self, access: Option<ConfigAccess>) -> Result<(), ConfigAttachError> {
let current = self.current().map_err(attach_error)?;
if access == Some(ConfigAccess::ReadWrite)
&& current.is_some_and(|a| a.access == WorkspaceConfigAccess::ReadOnly)
{
return Err(ConfigAttachError::Denied);
}
Ok(())
}
async fn attach(
&self,
_execution_key: &str,
access: Option<ConfigAccess>,
) -> Result<ConfigAttachment, ConfigAttachError> {
// Alias attach is idempotent under the Backend session lock. The T696
// fenced receipt owns replay; no new idempotency protocol or blind retry.
let request = WorkspaceConfigAttachRequest {
alias: Some(ATTACHMENT_ALIAS.into()),
access: access.map(|a| match a {
ConfigAccess::ReadOnly => WorkspaceConfigAccess::ReadOnly,
ConfigAccess::ReadWrite => WorkspaceConfigAccess::ReadWrite,
}),
};
let attachment: WorkspaceConfigAttachment =
self.post("", &request, true).await.map_err(attach_error)?;
self.validate_attachment(&attachment)
.map_err(|_| ConfigAttachError::OutcomeUnknown)?;
Ok(ConfigAttachment {
connection_id: attachment.connection_id,
access: match attachment.access {
WorkspaceConfigAccess::ReadOnly => ConfigAccess::ReadOnly,
WorkspaceConfigAccess::ReadWrite => ConfigAccess::ReadWrite,
},
already_attached: attachment.already_attached,
})
}
}
fn attach_error(error: WipOperationError) -> ConfigAttachError {
match error {
WipOperationError::OutcomeUnknown(_) => ConfigAttachError::OutcomeUnknown,
WipOperationError::Protocol(error) => match error.code {
ProtocolErrorCode::PermissionDenied => ConfigAttachError::Denied,
ProtocolErrorCode::InvalidArguments => ConfigAttachError::InvalidRequest,
_ => ConfigAttachError::Unavailable,
},
_ => ConfigAttachError::Unavailable,
}
}
pub(super) fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError {
WipOperationError::Protocol(ProtocolError {
code,
message: message.into(),
})
}
pub(super) fn unknown() -> WipOperationError {
WipOperationError::OutcomeUnknown("Workspace config outcome unknown; refresh before any subsequent operation; no automatic retry".into())
}

Some files were not shown because too many files have changed in this diff Show More