Merge commit '00ad02d04728a8e32a82a4e72747fefcf8a82d4e' into refactor/T-765-session-feature-boundary
# Conflicts: # crates/worker/src/feature/builtin.rs
This commit is contained in:
@@ -47,9 +47,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
|
||||
|
||||
let runtime_tmp = tempfile::tempdir()?;
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, _shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?;
|
||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?;
|
||||
|
||||
// Check initial status via shared state
|
||||
println!("[shared_state] {}", handle.shared_state.status_json());
|
||||
|
||||
@@ -17,28 +17,9 @@ use manifest::WorkerManifest;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkerBootstrapLayout {
|
||||
/// A direct Worker rooted below the supplied runtime base directory.
|
||||
Direct {
|
||||
runtime_base: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
},
|
||||
Direct { runtime_base: PathBuf },
|
||||
/// A runtime-managed Worker with an exact persisted run directory.
|
||||
RuntimeManagedRun {
|
||||
run_dir: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// Return the temporary Bash spill directory owned by a stable Worker identity.
|
||||
///
|
||||
/// The directory deliberately lives outside session/run-generation storage so a
|
||||
/// restarted controller for the same Worker keeps the same readable artifact
|
||||
/// boundary.
|
||||
pub fn bash_output_dir_for_worker_id(worker_id: impl std::fmt::Display) -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
.join("yoi")
|
||||
.join("workers")
|
||||
.join(worker_id.to_string())
|
||||
.join("bash-output")
|
||||
RuntimeManagedRun { run_dir: PathBuf },
|
||||
}
|
||||
|
||||
/// Construction and controller inputs that are stable for one Worker launch.
|
||||
@@ -272,27 +253,12 @@ where
|
||||
{
|
||||
let cleanup_sessions = worker.workdir_sessions();
|
||||
let controller = match layout {
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
} => {
|
||||
WorkerController::spawn_with_transport_owned(
|
||||
worker,
|
||||
&runtime_base,
|
||||
&bash_output_dir,
|
||||
transport,
|
||||
)
|
||||
.await
|
||||
WorkerBootstrapLayout::Direct { runtime_base } => {
|
||||
WorkerController::spawn_with_transport_owned(worker, &runtime_base, transport).await
|
||||
}
|
||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir,
|
||||
bash_output_dir,
|
||||
} => {
|
||||
WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => {
|
||||
WorkerController::spawn_runtime_managed_run_with_transport_owned(
|
||||
worker,
|
||||
&run_dir,
|
||||
&bash_output_dir,
|
||||
transport,
|
||||
worker, &run_dir, transport,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -313,22 +279,3 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::bash_output_dir_for_worker_id;
|
||||
|
||||
#[test]
|
||||
fn bash_output_directory_is_stable_per_worker_below_system_temp() {
|
||||
let path = bash_output_dir_for_worker_id("019c1234-worker");
|
||||
|
||||
assert_eq!(
|
||||
path,
|
||||
std::env::temp_dir()
|
||||
.join("yoi")
|
||||
.join("workers")
|
||||
.join("019c1234-worker")
|
||||
.join("bash-output")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+186
-183
@@ -7,7 +7,7 @@
|
||||
//! empty children mean "no indexable children", not "empty directory". List/Glob/Grep discover
|
||||
//! content; Inspect checks selected coordinates.
|
||||
//!
|
||||
//! Interfaces/validators bind the projection incarnation, attachment generation, and provider state
|
||||
//! Object validators bind the projection incarnation, attachment generation, and provider state
|
||||
//! so alias reuse after restore or detach cannot transfer old observations to a new connection.
|
||||
//! Publication also checks the generation recorded at acquisition. Possessing an Interface grants
|
||||
//! no authority: route-reconstructed permission inputs and current provider capability/scope are
|
||||
@@ -28,9 +28,8 @@ use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||
use serde_json::{Value as Json, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use wip_protocol::{
|
||||
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ReturnDeclaration, TypeExpr, Value,
|
||||
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
|
||||
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
use workdir::{
|
||||
CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||
@@ -117,13 +116,23 @@ impl Provider {
|
||||
}
|
||||
Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?))
|
||||
}
|
||||
fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec<u8> {
|
||||
fn wrap_validator(
|
||||
&self,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
workdir: &str,
|
||||
target: &WorkdirPath,
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
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(),
|
||||
target.as_str().as_bytes(),
|
||||
format!("{capabilities:?}").as_bytes(),
|
||||
raw,
|
||||
] {
|
||||
hash.update(value.len().to_be_bytes());
|
||||
@@ -236,45 +245,17 @@ impl Provider {
|
||||
return Ok(None);
|
||||
}
|
||||
let workdir = selected.session.workdir().id().as_str().to_owned();
|
||||
let root = checkout_root(&alias);
|
||||
// The router guard pins this connection across both observations. Root
|
||||
// lifetime is attachment lifetime, not directory-content validator state.
|
||||
let scope = if target.is_root() {
|
||||
None
|
||||
} else {
|
||||
let root_observation =
|
||||
match selected.session.checkout_observe(WorkdirPath::root()).await {
|
||||
Ok(o) => o,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
self.entry_projection(
|
||||
&root,
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
WorkdirPath::root(),
|
||||
root_observation,
|
||||
)
|
||||
};
|
||||
let Some(projection) = self.entry_projection(
|
||||
let projection = self.entry_projection(
|
||||
path,
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
target,
|
||||
observation,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path == root {
|
||||
return WipPublication::self_scoped(projection).map(Some);
|
||||
}
|
||||
let Some(scope) = scope else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(WipPublication {
|
||||
);
|
||||
Ok(projection.map(|projection| WipPublication {
|
||||
projection,
|
||||
scope: Some(scope.object),
|
||||
scope: None,
|
||||
}))
|
||||
}
|
||||
fn entry_projection(
|
||||
@@ -286,16 +267,44 @@ impl Provider {
|
||||
target: WorkdirPath,
|
||||
observation: CheckoutObservation,
|
||||
) -> Option<WipProjection> {
|
||||
let ops = self.operations(&observation);
|
||||
if ops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
use crate::file_operation::Contract as C;
|
||||
let has = |cap, name| observation.capabilities.supports(cap) && self.may_allow(name);
|
||||
let mut contracts = Vec::new();
|
||||
let kind = if observation.kind == EntryKind::File {
|
||||
if has(Cap::Read, "Read") {
|
||||
contracts.extend([C::ReadLines, C::SearchText, C::ReadBytes]);
|
||||
}
|
||||
if has(Cap::Edit, "Edit") {
|
||||
contracts.push(C::Edit);
|
||||
}
|
||||
if has(Cap::Write, "Write") {
|
||||
contracts.push(C::Write);
|
||||
}
|
||||
"file"
|
||||
} else {
|
||||
if has(Cap::Read, "List") {
|
||||
contracts.push(C::List);
|
||||
}
|
||||
if has(Cap::Glob, "Glob") {
|
||||
contracts.push(C::Glob);
|
||||
}
|
||||
if has(Cap::Grep, "Grep") {
|
||||
contracts.push(C::Grep);
|
||||
}
|
||||
if has(Cap::Write, "Write") {
|
||||
contracts.push(C::CreateFile);
|
||||
}
|
||||
"directory"
|
||||
};
|
||||
let validator = self.wrap_validator(alias, generation, workdir, &observation.validator);
|
||||
let primary = *contracts.first()?;
|
||||
let validator = self.wrap_validator(
|
||||
alias,
|
||||
generation,
|
||||
workdir,
|
||||
&target,
|
||||
observation.capabilities,
|
||||
&observation.validator,
|
||||
);
|
||||
let handler = Arc::new(FileHandler {
|
||||
provider: self.clone(),
|
||||
alias: alias.into(),
|
||||
@@ -304,86 +313,17 @@ impl Provider {
|
||||
target,
|
||||
observation,
|
||||
});
|
||||
let family = format!("{kind}-g{generation}");
|
||||
let mut projection = self.project(path, descriptor(ops), validator, &family, handler);
|
||||
// Keep path-qualified local names (permissions/capabilities may differ
|
||||
// per target) while binding every content Interface to its live entrance.
|
||||
projection.interface.scope = checkout_root(alias);
|
||||
projection.interface.name.push_str("#target/");
|
||||
projection.interface.name.push_str(&encode_identity(path));
|
||||
projection.object.interfaces = vec![projection.interface.clone()];
|
||||
let mut projection = self.project(path, primary.descriptor(), validator, kind, handler);
|
||||
projection.interface = primary.reference();
|
||||
projection.interface_validator = Some(primary.validator());
|
||||
projection.object.interfaces = Vec::new();
|
||||
crate::file_operation::expose(&mut projection, contracts);
|
||||
Some(projection)
|
||||
}
|
||||
|
||||
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
|
||||
let mut ops = Vec::new();
|
||||
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
|
||||
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::Read, "List") {
|
||||
ops.push(operation(
|
||||
"list",
|
||||
"List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; pass returned after to continue. Limit defaults to 100, maximum 1000. List permission and provider Read/enumeration authority apply. Includes hidden/ignored names per provider List policy, unlike Glob/Grep.",
|
||||
vec![parameter("limit", false, TypeExpr::Integer), parameter("after", false, cursor_type())],
|
||||
));
|
||||
}
|
||||
if has(Cap::Glob, "Glob") {
|
||||
ops.push(operation(
|
||||
"glob",
|
||||
"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> {
|
||||
fn interface_target(&self, reference: &wip_protocol::InterfaceReference) -> Option<String> {
|
||||
if reference.scope == ROOT {
|
||||
return Some(ROOT.into());
|
||||
}
|
||||
let (_, encoded) = reference.name.rsplit_once("#target/")?;
|
||||
let path = decode_identity(encoded)?;
|
||||
let (alias, _) = self.parse(&path)?;
|
||||
(reference.scope == checkout_root(&alias)).then_some(path)
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
.await
|
||||
@@ -588,8 +528,14 @@ impl FileHandler {
|
||||
if let Some(raw) = result.validator {
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider
|
||||
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
|
||||
self.provider.wrap_validator(
|
||||
&self.alias,
|
||||
self.generation,
|
||||
&self.workdir,
|
||||
&self.target,
|
||||
self.observation.capabilities,
|
||||
&raw,
|
||||
),
|
||||
))
|
||||
} else {
|
||||
Ok(WipOperationOutput::native(value))
|
||||
@@ -604,6 +550,127 @@ impl WipOperationHandler for FileHandler {
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if operation == "read_bytes" {
|
||||
let (offset, limit) = crate::file_operation::byte_range(arguments)?;
|
||||
if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) {return Err(protocol(ProtocolErrorCode::PermissionDenied,"byte read denied"));}
|
||||
let _permit =
|
||||
tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"byte read capacity deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| protocol(ProtocolErrorCode::Internal, "byte read unavailable"))?;
|
||||
let (read, raw) = tokio::time::timeout(
|
||||
self.provider.deadline,
|
||||
tools::checkout::read_checkout_bytes(
|
||||
self.provider.router.clone(),
|
||||
&self.alias,
|
||||
self.generation,
|
||||
self.target.clone(),
|
||||
self.observation.validator.clone(),
|
||||
offset,
|
||||
limit,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"byte read deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(map_tool_error)?;
|
||||
self.check_connection()?;
|
||||
let value = crate::file_operation::bytes_result(arguments, &read.bytes, read.eof)?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider.wrap_validator(
|
||||
&self.alias,
|
||||
self.generation,
|
||||
&self.workdir,
|
||||
&self.target,
|
||||
self.observation.capabilities,
|
||||
&raw,
|
||||
),
|
||||
));
|
||||
}
|
||||
if matches!(operation, "read_lines" | "search_text") {
|
||||
let (offset, limit) =
|
||||
crate::file_operation::window(arguments, operation == "search_text")?;
|
||||
if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) {
|
||||
return Err(protocol(ProtocolErrorCode::PermissionDenied,"text read denied"));
|
||||
}
|
||||
let _permit =
|
||||
tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text read capacity deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| protocol(ProtocolErrorCode::Internal, "text read unavailable"))?;
|
||||
let result = tokio::time::timeout(
|
||||
self.provider.deadline,
|
||||
tools::checkout::read_checkout_text(
|
||||
self.provider.router.clone(),
|
||||
self.provider.tracker.clone(),
|
||||
&self.alias,
|
||||
self.generation,
|
||||
self.target.clone(),
|
||||
self.observation.validator.clone(),
|
||||
offset,
|
||||
limit,
|
||||
crate::file_operation::MAX_BYTES,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text read deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(map_tool_error)?;
|
||||
let (read, validator, record_read) = result;
|
||||
self.check_connection()?;
|
||||
if read.byte_truncated {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text line window exceeds the provider byte budget; use byte ranges",
|
||||
));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&read.bytes);
|
||||
let value = crate::file_operation::page_result(
|
||||
operation,
|
||||
arguments,
|
||||
&text,
|
||||
offset,
|
||||
read.truncated,
|
||||
)?;
|
||||
// Only a successfully rendered Read can establish mutation authority.
|
||||
// Search and failed result budgets must leave prior-Read history unchanged.
|
||||
if operation == "read_lines" {
|
||||
record_read();
|
||||
}
|
||||
return Ok(match validator {
|
||||
Some(raw) => WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider.wrap_validator(
|
||||
&self.alias,
|
||||
self.generation,
|
||||
&self.workdir,
|
||||
&self.target,
|
||||
self.observation.capabilities,
|
||||
&raw,
|
||||
),
|
||||
),
|
||||
None => WipOperationOutput::native(value),
|
||||
});
|
||||
}
|
||||
let tool = match operation {
|
||||
"list" => "List",
|
||||
"read" => "Read",
|
||||
@@ -752,14 +819,6 @@ fn join_under(parent: &WorkdirPath, relative: &str) -> Result<String, WipOperati
|
||||
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,
|
||||
@@ -774,53 +833,10 @@ fn operation(
|
||||
parameters,
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: result_type(name == "list"),
|
||||
r#type: TypeExpr::Json,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration {
|
||||
FieldDeclaration {
|
||||
name: name.into(),
|
||||
required,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
fn cursor_type() -> TypeExpr {
|
||||
TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("entry", true, TypeExpr::Entry),
|
||||
],
|
||||
}
|
||||
}
|
||||
fn result_type(list: bool) -> TypeExpr {
|
||||
let mut item = vec![field("entry", true, TypeExpr::Entry)];
|
||||
if list {
|
||||
item.extend([
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("size", true, TypeExpr::Integer),
|
||||
]);
|
||||
}
|
||||
let mut fields = vec![
|
||||
field("summary", true, TypeExpr::String),
|
||||
field("content", false, TypeExpr::String),
|
||||
field(
|
||||
"items",
|
||||
true,
|
||||
TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Record { fields: item }),
|
||||
},
|
||||
),
|
||||
];
|
||||
if list {
|
||||
fields.extend([
|
||||
field("truncated", true, TypeExpr::Boolean),
|
||||
field("after", false, cursor_type()),
|
||||
]);
|
||||
}
|
||||
TypeExpr::Record { fields }
|
||||
}
|
||||
fn kind_name(kind: EntryKind) -> &'static str {
|
||||
match kind {
|
||||
EntryKind::Directory => "directory",
|
||||
@@ -841,19 +857,6 @@ fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value {
|
||||
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,
|
||||
|
||||
@@ -31,11 +31,18 @@ enum WriteResponseFault {
|
||||
LostBody,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum CommandResponseFault {
|
||||
LostTerminal,
|
||||
EmptyWaiting,
|
||||
}
|
||||
|
||||
struct ProviderState {
|
||||
session: WorkdirSessionHandle,
|
||||
requests: Mutex<Vec<(String, String)>>,
|
||||
operations: Mutex<Vec<WorkdirSessionOperation>>,
|
||||
write_fault: Mutex<Option<WriteResponseFault>>,
|
||||
command_fault: Mutex<Option<CommandResponseFault>>,
|
||||
observe_refusal: Mutex<bool>,
|
||||
}
|
||||
|
||||
@@ -152,6 +159,21 @@ async fn operate(
|
||||
source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())),
|
||||
});
|
||||
}
|
||||
if let WorkdirSessionOperation::CommandOutput(output) = &request.operation {
|
||||
if matches!(
|
||||
*state.command_fault.lock().unwrap(),
|
||||
Some(CommandResponseFault::EmptyWaiting)
|
||||
) {
|
||||
return AxumJson(workdir::http::WorkdirSessionOperationResult::CommandOutput(
|
||||
workdir::CommandOutput {
|
||||
chunks: vec![],
|
||||
next_cursor: output.cursor,
|
||||
terminal: None,
|
||||
},
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let result =
|
||||
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
|
||||
match result {
|
||||
@@ -159,6 +181,15 @@ async fn operate(
|
||||
Ok(result) => {
|
||||
let fault = if is_write {
|
||||
state.write_fault.lock().unwrap().take()
|
||||
} else if matches!(&result, workdir::http::WorkdirSessionOperationResult::CommandOutput(output) if output.terminal.is_some())
|
||||
{
|
||||
let mut command_fault = state.command_fault.lock().unwrap();
|
||||
if matches!(*command_fault, Some(CommandResponseFault::LostTerminal)) {
|
||||
command_fault.take();
|
||||
Some(WriteResponseFault::LostBody)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -237,6 +268,7 @@ impl HttpFixture {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
operations: Mutex::new(Vec::new()),
|
||||
write_fault: Mutex::new(None),
|
||||
command_fault: Mutex::new(None),
|
||||
observe_refusal: Mutex::new(false),
|
||||
});
|
||||
let app = Router::new()
|
||||
@@ -358,7 +390,8 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::In
|
||||
}
|
||||
|
||||
async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value {
|
||||
let interface = observe_interface(runtime, path).await;
|
||||
observe_interface(runtime, path).await;
|
||||
let interface = super::checkout_wip_tests::contract(operation).reference();
|
||||
runtime
|
||||
.call(
|
||||
path.into(),
|
||||
@@ -620,7 +653,8 @@ async fn committed_write_unknown(fault: WriteResponseFault) {
|
||||
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;
|
||||
observe_interface(&runtime, file).await;
|
||||
let interface = super::checkout_wip_tests::contract("write").reference();
|
||||
*fixture.state.write_fault.lock().unwrap() = Some(fault);
|
||||
let error = runtime
|
||||
.call(
|
||||
@@ -798,3 +832,175 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit
|
||||
"refusal was retried"
|
||||
);
|
||||
}
|
||||
|
||||
// Full collection/projection integration: production HTTP client, local process pipes,
|
||||
// Bash collector/store, WIP codec and common file dispatch. Only HTTP response loss is injected.
|
||||
#[tokio::test]
|
||||
async fn remote_native_bash_retains_streams_quota_and_replays_lost_terminal_through_common_files() {
|
||||
watchdog(async {
|
||||
let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await;
|
||||
let remote = Arc::new(f.open(TOKEN).await.unwrap());
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote.clone()).unwrap();
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None).unwrap();
|
||||
let view = tools::bash_output::BashOutputView::new();
|
||||
crate::feature::builtin::bash::mount(&mut registry, router.clone(), view, None).unwrap();
|
||||
let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("remote-bash"), 0).unwrap();
|
||||
*f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::LostTerminal);
|
||||
let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":r"printf 'first\nneedle\n'; printf '%200000s' ''; printf '\377€' >&2; exit 7","cwd":"."}}), Default::default()).await.unwrap();
|
||||
f.public_output(run.content.as_ref().unwrap());
|
||||
let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap();
|
||||
assert_eq!(result["metadata"]["stdout_saved"], 200013);
|
||||
assert_eq!(result["metadata"]["stderr_saved"], 4);
|
||||
assert_eq!(result["metadata"]["terminal"]["exit_code"], 7);
|
||||
assert_eq!(result["metadata"]["terminal"]["transport_complete"], true);
|
||||
assert_eq!(result["metadata"]["storage_truncated"], false);
|
||||
assert_eq!(result["preview_omitted"], true);
|
||||
for name in ["stdout_entry", "stderr_entry", "order_entry"] {
|
||||
runtime.inspect(result[name].as_str().unwrap().into(), false).await.unwrap();
|
||||
}
|
||||
let stdout = result["stdout_entry"].as_str().unwrap();
|
||||
let search = runtime.invoke(stdout.into(), crate::file_operation::Contract::SearchText.reference(), "search".into(), json!({"pattern":"needle","limit":2}), Default::default()).await.unwrap();
|
||||
assert!(search.content.unwrap().contains("needle"));
|
||||
let stderr = runtime.invoke(result["stderr_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"limit":4}), Default::default()).await.unwrap();
|
||||
let stderr: Json = serde_json::from_str(&stderr.content.unwrap()).unwrap();
|
||||
use base64::Engine as _;
|
||||
assert_eq!(base64::engine::general_purpose::STANDARD.decode(stderr["data"].as_str().unwrap()).unwrap(), [vec![255], "€".as_bytes().to_vec()].concat());
|
||||
let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf '%4194303s' ''; printf '€'","cwd":"."}}), Default::default()).await.unwrap();
|
||||
let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap();
|
||||
assert_eq!(result["metadata"]["stdout_saved"], 4194304);
|
||||
assert_eq!(result["metadata"]["stdout_seen"], 4194306);
|
||||
assert_eq!(result["metadata"]["transport_truncation"], "byte_budget");
|
||||
assert_eq!(result["metadata"]["storage_truncated"], true);
|
||||
let tail = runtime.invoke(result["stdout_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"offset":4194303,"limit":4}), Default::default()).await.unwrap();
|
||||
let tail: Json = serde_json::from_str(&tail.content.unwrap()).unwrap();
|
||||
assert_eq!(base64::engine::general_purpose::STANDARD.decode(tail["data"].as_str().unwrap()).unwrap(), vec![0xe2]);
|
||||
assert_eq!(tail["eof"], true);
|
||||
assert_eq!(f.state.operations.lock().unwrap().iter().filter(|op| matches!(op, WorkdirSessionOperation::CommandStart(_))).count(), 2);
|
||||
assert!(f.state.command_fault.lock().unwrap().is_none());
|
||||
router.close_all().await.unwrap();
|
||||
f.assert_endpoints();
|
||||
}).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_remote_command_rejects_empty_response_without_http_busy_loop() {
|
||||
watchdog(async {
|
||||
let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await;
|
||||
let remote = f.open(TOKEN).await.unwrap();
|
||||
*f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::EmptyWaiting);
|
||||
let error = remote
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: workdir::CommandHandle("unused".into()),
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::Unavailable(_)));
|
||||
assert_eq!(
|
||||
f.state
|
||||
.operations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|op| matches!(op, WorkdirSessionOperation::CommandOutput(_)))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
remote.close().await.unwrap();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() {
|
||||
use base64::Engine as _;
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
|
||||
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
|
||||
let (runtime, _) = native_runtime(remote);
|
||||
let path = "/checkouts/main/src/deep/a.txt";
|
||||
observe_interface(&runtime, path).await;
|
||||
let output = runtime
|
||||
.invoke(
|
||||
path.into(),
|
||||
crate::file_operation::Contract::ReadBytes.reference(),
|
||||
"read".into(),
|
||||
json!({"offset":1,"limit":3}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(json["encoding"], "base64");
|
||||
assert_eq!(json["offset"], 1);
|
||||
assert_eq!(json["next_offset"], 4);
|
||||
let source = fixture
|
||||
.state
|
||||
.session
|
||||
.read_bytes(workdir::ReadBytesRequest {
|
||||
path: WorkdirPath::new("src/deep/a.txt").unwrap(),
|
||||
offset: 1,
|
||||
max_bytes: 3,
|
||||
expected_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(json["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
source.bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_failed_text_read_does_not_authorize_mutations() {
|
||||
for operation in ["write", "edit"] {
|
||||
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
|
||||
let remote = fixture.open(TOKEN).await.unwrap();
|
||||
checkout_wip_tests::failed_text_read_cannot_authorize_mutation(
|
||||
Arc::new(remote),
|
||||
&fixture.dir.path().join("text"),
|
||||
&oversized,
|
||||
operation,
|
||||
)
|
||||
.await;
|
||||
let mutations = fixture
|
||||
.state
|
||||
.operations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|op| {
|
||||
matches!(
|
||||
op,
|
||||
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
|
||||
operation: CheckoutOperation::Write { .. }
|
||||
| CheckoutOperation::Edit { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
mutations, 1,
|
||||
"only the mutation after a successful Read may reach the provider"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() {
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
|
||||
let remote = fixture.open(TOKEN).await.unwrap();
|
||||
checkout_wip_tests::assert_long_utf8_search_refuses_incomplete_line(
|
||||
Arc::new(remote),
|
||||
fixture.dir.path(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -683,7 +683,12 @@ async fn committed_checkout_mutation_losing_connection_is_unknown_not_rejected_o
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let route = object_path("main", &file.target);
|
||||
let mut projection = p.node(&route).await.unwrap().unwrap();
|
||||
let interface = projection.interface.clone();
|
||||
let interface = match operation {
|
||||
"write" => crate::file_operation::Contract::Write,
|
||||
"edit" => crate::file_operation::Contract::Edit,
|
||||
_ => crate::file_operation::Contract::CreateFile,
|
||||
}
|
||||
.reference();
|
||||
projection.handler = Arc::new(DetachBeforePublication {
|
||||
file,
|
||||
calls: calls.clone(),
|
||||
|
||||
@@ -41,13 +41,28 @@ async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReferen
|
||||
.unwrap();
|
||||
p.interface
|
||||
}
|
||||
pub(super) fn contract(operation: &str) -> crate::file_operation::Contract {
|
||||
use crate::file_operation::Contract as C;
|
||||
match operation {
|
||||
"read" => C::ReadLines,
|
||||
"search" => C::SearchText,
|
||||
"edit" => C::Edit,
|
||||
"write" => C::Write,
|
||||
"list" => C::List,
|
||||
"glob" => C::Glob,
|
||||
"grep" => C::Grep,
|
||||
"create_file" => C::CreateFile,
|
||||
_ => panic!("unknown file operation {operation}"),
|
||||
}
|
||||
}
|
||||
async fn call(
|
||||
r: &WipRuntime,
|
||||
path: &str,
|
||||
operation: &str,
|
||||
args: Json,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let i = interface(r, path).await;
|
||||
interface(r, path).await;
|
||||
let i = contract(operation).reference();
|
||||
r.call(path.into(), i, operation.into(), args, Default::default())
|
||||
.await
|
||||
}
|
||||
@@ -336,12 +351,26 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
|
||||
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());
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&old.interface)
|
||||
.await
|
||||
.unwrap()
|
||||
.validator,
|
||||
old.interface_validator
|
||||
);
|
||||
router.attach(alias, session(&b, false)).unwrap();
|
||||
let new = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
assert_ne!(old.interface, new.interface);
|
||||
assert_eq!(old.interface, new.interface);
|
||||
assert_ne!(old.object.validator, new.object.validator);
|
||||
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&old.interface)
|
||||
.await
|
||||
.unwrap()
|
||||
.validator,
|
||||
old.interface_validator
|
||||
);
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
@@ -354,14 +383,16 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
|
||||
);
|
||||
let restored = runtime(router, None);
|
||||
let next = restored.host.projection_live(path).await.unwrap().unwrap();
|
||||
assert_ne!(new.interface, next.interface);
|
||||
assert_eq!(new.interface, next.interface);
|
||||
assert_ne!(new.object.validator, next.object.validator);
|
||||
assert!(
|
||||
assert_eq!(
|
||||
restored
|
||||
.host
|
||||
.fetch_interface_live(&new.interface)
|
||||
.await
|
||||
.is_err()
|
||||
.unwrap()
|
||||
.validator,
|
||||
new.interface_validator
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(b.path().join("file.txt")).unwrap(),
|
||||
@@ -446,7 +477,11 @@ async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_repla
|
||||
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")) {
|
||||
for def in tools::routed_builtin_tools(
|
||||
router,
|
||||
tools::Tracker::new(),
|
||||
tools::bash_output::BashOutputView::new(),
|
||||
) {
|
||||
let (meta, tool) = def();
|
||||
let p = compatibility_projection(meta, tool, None).unwrap();
|
||||
registry.mount(p).unwrap();
|
||||
@@ -497,7 +532,7 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
|
||||
.unwrap();
|
||||
let r = runtime(router, None);
|
||||
let path = "/checkouts/main/file.txt";
|
||||
let i = interface(&r, path).await;
|
||||
interface(&r, path).await;
|
||||
// No rediscovery or Interface refresh between any of these operations.
|
||||
for (op, args) in [
|
||||
("read", json!({})),
|
||||
@@ -505,16 +540,23 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
|
||||
("write", json!({"content":"saved\n"})),
|
||||
("read", json!({})),
|
||||
] {
|
||||
r.call(path.into(), i.clone(), op.into(), args, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
r.call(
|
||||
path.into(),
|
||||
contract(op).reference(),
|
||||
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;
|
||||
interface(&r, parent).await;
|
||||
let i = contract("create_file").reference();
|
||||
for name in ["one.txt", "nested/two.txt"] {
|
||||
r.call(
|
||||
parent.into(),
|
||||
@@ -617,11 +659,12 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||
.is_none()
|
||||
);
|
||||
let file = "/checkouts/child/a.txt";
|
||||
let i = interface(&child, file).await;
|
||||
interface(&child, file).await;
|
||||
let i = contract("write").reference();
|
||||
child
|
||||
.call(
|
||||
file.into(),
|
||||
i.clone(),
|
||||
contract("read").reference(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
Default::default(),
|
||||
@@ -686,7 +729,21 @@ pub(super) async fn model_invoke(
|
||||
operation: &str,
|
||||
arguments: Json,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":inspected["interfaces"][0]["reference"], "operation":operation, "arguments":arguments})).await
|
||||
let reference = inspected["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| &item["reference"])
|
||||
.find(|r| {
|
||||
Some(wip_protocol::InterfaceReference {
|
||||
scope: r["scope"].as_str().unwrap().into(),
|
||||
name: r["name"].as_str().unwrap().into(),
|
||||
})
|
||||
.and_then(|r| crate::file_operation::Contract::from_reference(&r))
|
||||
.is_some_and(|c| c.operation() == operation)
|
||||
})
|
||||
.unwrap_or(&inspected["interfaces"][0]["reference"]);
|
||||
model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":reference, "operation":operation, "arguments":arguments})).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -752,10 +809,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let entrance = model_inspect(r.clone(), "/checkouts/main").await;
|
||||
assert_eq!(entrance["path"], "/checkouts/main");
|
||||
assert_eq!(
|
||||
entrance["interfaces"][0]["reference"]["scope"],
|
||||
"/checkouts/main"
|
||||
);
|
||||
assert_eq!(entrance["interfaces"][0]["reference"]["scope"], "/");
|
||||
let projection = r
|
||||
.host
|
||||
.projection_live("/checkouts/main")
|
||||
@@ -829,10 +883,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed
|
||||
json!([])
|
||||
);
|
||||
let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await;
|
||||
assert_eq!(
|
||||
deep["interfaces"][0]["reference"]["scope"],
|
||||
"/checkouts/main"
|
||||
);
|
||||
assert_eq!(deep["interfaces"][0]["reference"]["scope"], "/");
|
||||
for (operation, args) in [
|
||||
("list", json!({})),
|
||||
("glob", json!({"pattern":"*.txt"})),
|
||||
@@ -926,7 +977,7 @@ async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identi
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() {
|
||||
async fn common_scope_rejects_wrong_namespace_scope_ref_and_old_target_authority() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "same\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
@@ -943,8 +994,8 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap();
|
||||
assert!(
|
||||
fetched.scope_ref.is_none(),
|
||||
"ref-less publication is legitimate"
|
||||
fetched.scope_ref.is_some(),
|
||||
"common namespace uses the Host root scope"
|
||||
);
|
||||
// Ordinary content changes do not terminate the entrance's Interface lifetime.
|
||||
std::fs::write(dir.path().join("new.txt"), "fixture").unwrap();
|
||||
@@ -959,12 +1010,12 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
|
||||
let request = |reference, scope_ref| CallOperationRequest {
|
||||
target: wip_protocol::Target {
|
||||
path: path.into(),
|
||||
validator: None,
|
||||
validator: p.object.validator.clone(),
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference,
|
||||
scope_ref,
|
||||
validator: None,
|
||||
validator: fetched.validator.clone(),
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
@@ -996,6 +1047,654 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
|
||||
router.attach(main, session(&dir, true)).unwrap();
|
||||
let forged = request(p.interface.clone(), None);
|
||||
assert!(
|
||||
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
fn observed_common_request(
|
||||
path: &str,
|
||||
validator: Option<Vec<u8>>,
|
||||
fetched: &FetchInterfaceResponse,
|
||||
operation: &str,
|
||||
) -> CallOperationRequest {
|
||||
CallOperationRequest {
|
||||
target: wip_protocol::Target {
|
||||
path: path.into(),
|
||||
validator,
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference: fetched.interface.clone(),
|
||||
scope_ref: fetched.scope_ref.clone(),
|
||||
validator: fetched.validator.clone(),
|
||||
},
|
||||
operation: operation.into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
fn file_context() -> WipCallContext {
|
||||
WipCallContext {
|
||||
execution: Default::default(),
|
||||
security_context: "file-contract-test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_contract_survives_one_attachment_update_but_old_object_authority_never_transfers() {
|
||||
let left = TempDir::new().unwrap();
|
||||
let right = TempDir::new().unwrap();
|
||||
for root in [&left, &right] {
|
||||
std::fs::write(root.path().join("a.txt"), "same\n").unwrap();
|
||||
}
|
||||
std::fs::write(left.path().join("b.txt"), "same\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let alias = WorkdirAttachmentAlias::new("left").unwrap();
|
||||
router.attach(alias.clone(), session(&left, false)).unwrap();
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("right").unwrap(),
|
||||
session(&right, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(router.clone(), None);
|
||||
let path = "/checkouts/left/a.txt";
|
||||
let other = "/checkouts/right/a.txt";
|
||||
let first = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let same_bytes = r
|
||||
.host
|
||||
.projection_live("/checkouts/left/b.txt")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let independent = r.host.projection_live(other).await.unwrap().unwrap();
|
||||
assert_eq!(first.interface, independent.interface);
|
||||
assert_eq!(first.descriptor, independent.descriptor);
|
||||
assert_ne!(first.object.validator, same_bytes.object.validator);
|
||||
let definition = r.host.fetch_interface_live(&first.interface).await.unwrap();
|
||||
let request =
|
||||
observed_common_request(path, first.object.validator.clone(), &definition, "read");
|
||||
assert!(r.host.call(request.clone(), file_context()).await.is_ok());
|
||||
// Replace the same path with a different inode containing exactly the same bytes.
|
||||
std::fs::write(left.path().join("replacement"), "same\n").unwrap();
|
||||
std::fs::rename(left.path().join("replacement"), left.path().join("a.txt")).unwrap();
|
||||
assert!(
|
||||
matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&first.interface)
|
||||
.await
|
||||
.unwrap()
|
||||
.validator,
|
||||
definition.validator
|
||||
);
|
||||
assert!(
|
||||
r.host
|
||||
.call(
|
||||
observed_common_request(
|
||||
other,
|
||||
independent.object.validator.clone(),
|
||||
&definition,
|
||||
"read"
|
||||
),
|
||||
file_context()
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
call(&r, path, "read", json!({})).await.unwrap();
|
||||
router.detach(&alias).await.unwrap();
|
||||
router.attach(alias, session(&left, false)).unwrap();
|
||||
assert!(
|
||||
matches!(r.host.call(request,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
assert!(
|
||||
r.host
|
||||
.call(
|
||||
observed_common_request(other, independent.object.validator, &definition, "read"),
|
||||
file_context()
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
call(&r, path, "read", json!({})).await.unwrap();
|
||||
let before_restore = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let restored = runtime(router, None);
|
||||
let restored_definition = restored
|
||||
.host
|
||||
.fetch_interface_live(&first.interface)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(definition.descriptor, restored_definition.descriptor);
|
||||
assert_eq!(definition.validator, restored_definition.validator);
|
||||
assert!(
|
||||
matches!(restored.host.call(observed_common_request(path,before_restore.object.validator,&restored_definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
call(&restored, path, "read", json!({})).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_readonly_contract_rejects_wrong_membership_operation_and_interface_validator() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "雪.*\r\nother\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let path = "/checkouts/main/a.txt";
|
||||
let observed = model_inspect(r.clone(), path).await;
|
||||
let read = json_content(
|
||||
model_invoke(r.clone(), &observed, "read", json!({"limit":1}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
read,
|
||||
json!({"content":" 1\t雪.*\n","next_offset":1,"truncated":true})
|
||||
);
|
||||
let searched = json_content(
|
||||
model_invoke(
|
||||
r.clone(),
|
||||
&observed,
|
||||
"search",
|
||||
json!({"pattern":".*","limit":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
searched,
|
||||
json!({"matches":[{"line":1,"text":"雪.*"}],"next_offset":1,"truncated":true})
|
||||
);
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let read = r
|
||||
.host
|
||||
.fetch_interface_live(&contract("read").reference())
|
||||
.await
|
||||
.unwrap();
|
||||
let write = r
|
||||
.host
|
||||
.fetch_interface_live(&contract("write").reference())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&write,"write"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
assert!(
|
||||
matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&read,"missing"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::OperationNotFound)
|
||||
);
|
||||
let mut stale = observed_common_request(path, p.object.validator.clone(), &read, "read");
|
||||
stale.interface.validator = Some(vec![0]);
|
||||
assert!(
|
||||
matches!(r.host.call(stale,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceValidatorMismatch)
|
||||
);
|
||||
let mut legacy = observed_common_request(path, p.object.validator, &read, "read");
|
||||
legacy.interface.reference = InterfaceReference {
|
||||
scope: "/checkouts/main".into(),
|
||||
name: "yoi.checkout/old/file/v1#target/old".into(),
|
||||
};
|
||||
assert!(
|
||||
matches!(r.host.call(legacy,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
|
||||
"雪.*\r\nother\n"
|
||||
);
|
||||
}
|
||||
|
||||
// The fixture substitutes only a binary source. Real Host/Client/model tools, fixed common
|
||||
// descriptors, membership, validators and output decoding stay on their production path.
|
||||
struct ReadonlyBytes {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
version: Arc<std::sync::atomic::AtomicU64>,
|
||||
captured: u64,
|
||||
}
|
||||
impl ReadonlyBytes {
|
||||
fn projection(&self) -> WipProjection {
|
||||
let contract = crate::file_operation::Contract::ReadBytes;
|
||||
WipProjection {
|
||||
route: "/virtual".into(),
|
||||
capability: "virtual:bytes".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: "virtual".into(),
|
||||
description: None,
|
||||
interfaces: vec![contract.reference()],
|
||||
r#ref: None,
|
||||
validator: Some(self.captured.to_be_bytes().to_vec()),
|
||||
},
|
||||
interface: contract.reference(),
|
||||
descriptor: contract.descriptor(),
|
||||
interface_validator: Some(contract.validator()),
|
||||
handler: Arc::new(Self {
|
||||
bytes: self.bytes.clone(),
|
||||
version: self.version.clone(),
|
||||
captured: self.captured,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for ReadonlyBytes {
|
||||
async fn call(
|
||||
&self,
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if op != "read_bytes" {
|
||||
return Err(WipOperationError::Protocol(protocol_error(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"readonly bytes",
|
||||
)));
|
||||
}
|
||||
if self.captured != self.version.load(Ordering::SeqCst) {
|
||||
return Err(WipOperationError::Protocol(protocol_error(
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
"virtual source replaced",
|
||||
)));
|
||||
}
|
||||
let (offset, limit) = crate::file_operation::byte_range(args)?;
|
||||
if offset > self.bytes.len() as u64 {
|
||||
return Err(WipOperationError::Protocol(protocol_error(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"past EOF",
|
||||
)));
|
||||
}
|
||||
let end = (offset as usize + limit).min(self.bytes.len());
|
||||
Ok(WipOperationOutput::native(
|
||||
crate::file_operation::bytes_result(
|
||||
args,
|
||||
&self.bytes[offset as usize..end],
|
||||
end == self.bytes.len(),
|
||||
)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ReadonlyBytes {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
Ok((path == "/virtual").then(|| {
|
||||
let current = Self {
|
||||
bytes: self.bytes.clone(),
|
||||
version: self.version.clone(),
|
||||
captured: self.version.load(Ordering::SeqCst),
|
||||
};
|
||||
WipPublication {
|
||||
projection: current.projection(),
|
||||
scope: None,
|
||||
}
|
||||
}))
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn common_bytes_read_long_lines_invalid_utf8_arbitrary_boundaries_and_eof_without_write_authority()
|
||||
{
|
||||
use base64::Engine as _;
|
||||
let mut bytes = "雪".as_bytes().to_vec();
|
||||
bytes.extend_from_slice(&[0xff, 0, 0xc0]);
|
||||
bytes.extend(vec![b'x'; 100_000]);
|
||||
let bytes = Arc::new(bytes);
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("raw"), bytes.as_ref()).unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let source = Arc::new(ReadonlyBytes {
|
||||
bytes: bytes.clone(),
|
||||
version: Arc::new(std::sync::atomic::AtomicU64::new(1)),
|
||||
captured: 1,
|
||||
});
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap();
|
||||
registry.allocate_namespace("virtual", "virtual").unwrap();
|
||||
registry.mount(source.projection()).unwrap();
|
||||
registry
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: "/virtual".into(),
|
||||
provider: source.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
let r = Arc::new(WipRuntime::from_mounts(registry, "bytes-test".into()).unwrap());
|
||||
let virtual_inspect = model_inspect(r.clone(), "/virtual").await;
|
||||
let reference = virtual_inspect["interfaces"][0]["reference"].clone();
|
||||
let checkout = model_inspect(r.clone(), "/checkouts/main/raw").await;
|
||||
assert!(
|
||||
checkout["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"] == reference)
|
||||
);
|
||||
for path in ["/virtual", "/checkouts/main/raw"] {
|
||||
for offset in [0, 1, 2, 3, 5, 80_000, bytes.len()] {
|
||||
let output=json_content(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":{"offset":offset,"limit":17}})).await.unwrap());
|
||||
let end = (offset + 17).min(bytes.len());
|
||||
assert_eq!(output["encoding"], "base64");
|
||||
assert_eq!(output["offset"], offset);
|
||||
assert_eq!(output["next_offset"], end);
|
||||
assert_eq!(output["eof"], end == bytes.len());
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(output["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
bytes[offset..end]
|
||||
);
|
||||
}
|
||||
for arguments in [
|
||||
json!({"limit":0}),
|
||||
json!({"limit":32769}),
|
||||
json!({"offset":-1}),
|
||||
json!({"offset":9_007_199_254_740_991i64}),
|
||||
json!({"offset":bytes.len()+1}),
|
||||
json!({"file_path":"other"}),
|
||||
] {
|
||||
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":arguments})).await.is_err());
|
||||
}
|
||||
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":{"scope":"/","name":"yoi.file/write-text/v1"},"operation":"write","arguments":{"content":"lost"}})).await.is_err());
|
||||
}
|
||||
let fetched = r
|
||||
.host
|
||||
.fetch_interface_live(&crate::file_operation::Contract::ReadBytes.reference())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut request = observed_common_request(
|
||||
"/virtual",
|
||||
Some(1u64.to_be_bytes().to_vec()),
|
||||
&fetched,
|
||||
"read",
|
||||
);
|
||||
source.version.store(2, Ordering::SeqCst);
|
||||
assert!(
|
||||
matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
request.target.validator = Some(2u64.to_be_bytes().to_vec());
|
||||
assert!(r.host.call(request, file_context()).await.is_ok());
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join("raw")).unwrap(),
|
||||
bytes.as_ref().clone()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scope_downgrade_changes_object_membership_not_common_contract_and_read_remains_available()
|
||||
{
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a"), "same").unwrap();
|
||||
let scope = manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap());
|
||||
let session = Arc::new(LocalWorkdirSession::materialized(
|
||||
dir.path().into(),
|
||||
dir.path().into(),
|
||||
scope.clone(),
|
||||
WorkdirSessionCapabilities::READ_WRITE,
|
||||
));
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), session)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let path = "/checkouts/main/a";
|
||||
let old = model_inspect(r.clone(), path).await;
|
||||
let current = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let definition = r
|
||||
.host
|
||||
.fetch_interface_live(&contract("read").reference())
|
||||
.await
|
||||
.unwrap();
|
||||
scope
|
||||
.update(|_| {
|
||||
manifest::Scope::from_config(&manifest::ScopeConfig {
|
||||
allow: vec![manifest::ScopeRule {
|
||||
target: dir.path().into(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: manifest::SymlinkPolicy::Resolved,
|
||||
}],
|
||||
deny: vec![],
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":contract_json("write"),"operation":"write","arguments":{"content":"lost"}})).await.is_err());
|
||||
let new = json_content(
|
||||
model_operation(r.clone(), "Inspect", json!({"path":path,"refresh":true}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_ne!(old["object_signature"], new["object_signature"]);
|
||||
assert!(
|
||||
!new["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&contract("read").reference())
|
||||
.await
|
||||
.unwrap()
|
||||
.validator,
|
||||
definition.validator
|
||||
);
|
||||
assert!(
|
||||
matches!(r.host.call(observed_common_request(path,current.object.validator,&definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
json_content(
|
||||
model_invoke(r.clone(), &new, "read", json!({}))
|
||||
.await
|
||||
.unwrap()
|
||||
)["content"],
|
||||
" 1\tsame\n"
|
||||
);
|
||||
}
|
||||
fn contract_json(operation: &str) -> Json {
|
||||
let r = contract(operation).reference();
|
||||
json!({"scope":r.scope,"name":r.name})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_contract_registration_rejects_descriptor_redefinition_and_domain_operation_injection() {
|
||||
let source = ReadonlyBytes {
|
||||
bytes: Arc::new(vec![]),
|
||||
version: Arc::new(std::sync::atomic::AtomicU64::new(1)),
|
||||
captured: 1,
|
||||
};
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("virtual", "virtual").unwrap();
|
||||
let mut invalid = source.projection();
|
||||
invalid.descriptor.operations[0]
|
||||
.parameters
|
||||
.push(ParameterDeclaration {
|
||||
name: "target_override".into(),
|
||||
required: false,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
});
|
||||
assert!(matches!(
|
||||
registry.mount(invalid),
|
||||
Err(WipMountError::InvalidProjection { .. })
|
||||
));
|
||||
let projection = source.projection();
|
||||
registry.mount(projection.clone()).unwrap();
|
||||
let mut extension = projection.descriptor.clone();
|
||||
extension.operations[0].name = "erase".into();
|
||||
assert!(matches!(
|
||||
registry.contribute_operations(WipOperationContribution {
|
||||
route: "/virtual".into(),
|
||||
contributor: "other".into(),
|
||||
interface: projection.interface,
|
||||
descriptor: extension,
|
||||
handler: projection.handler
|
||||
}),
|
||||
Err(WipMountError::InterfaceCollision { .. })
|
||||
));
|
||||
}
|
||||
|
||||
pub(super) async fn failed_text_read_cannot_authorize_mutation(
|
||||
source: WorkdirSessionHandle,
|
||||
disk_file: &std::path::Path,
|
||||
oversized: &str,
|
||||
operation: &str,
|
||||
) {
|
||||
let original = format!("{oversized}\nknown\n");
|
||||
std::fs::write(disk_file, &original).unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), source)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let inspected = model_inspect(r.clone(), "/checkouts/main/text").await;
|
||||
let arguments = if operation == "write" {
|
||||
json!({"content":"changed"})
|
||||
} else {
|
||||
json!({"old_string":"known","new_string":"changed"})
|
||||
};
|
||||
let failure = model_invoke(r.clone(), &inspected, "read", json!({"limit":1}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
failure.to_string().contains("ResourceLimitExceeded"),
|
||||
"{failure}"
|
||||
);
|
||||
let denied = model_invoke(r.clone(), &inspected, operation, arguments.clone()).await;
|
||||
assert!(denied.is_err(), "failed read authorized {operation}");
|
||||
let denied = denied.unwrap_err();
|
||||
assert!(denied.to_string().contains("InvalidArguments"), "{denied}");
|
||||
assert_eq!(std::fs::read_to_string(disk_file).unwrap(), original);
|
||||
model_invoke(r.clone(), &inspected, "read", json!({"offset":1,"limit":1}))
|
||||
.await
|
||||
.unwrap();
|
||||
// A failed read must neither grant new authority nor erase an earlier successful Read.
|
||||
assert!(
|
||||
model_invoke(r.clone(), &inspected, "read", json!({"limit":1}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
model_invoke(r, &inspected, operation, arguments)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = if operation == "write" {
|
||||
"changed".to_string()
|
||||
} else {
|
||||
original.replace("known", "changed")
|
||||
};
|
||||
assert_eq!(std::fs::read_to_string(disk_file).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_text_read_does_not_authorize_write() {
|
||||
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
failed_text_read_cannot_authorize_mutation(
|
||||
session(&dir, false),
|
||||
&dir.path().join("text"),
|
||||
&oversized,
|
||||
"write",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_text_read_does_not_authorize_edit() {
|
||||
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
failed_text_read_cannot_authorize_mutation(
|
||||
session(&dir, false),
|
||||
&dir.path().join("text"),
|
||||
&oversized,
|
||||
"edit",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn assert_long_utf8_search_refuses_incomplete_line(
|
||||
source: WorkdirSessionHandle,
|
||||
directory: &std::path::Path,
|
||||
) {
|
||||
let original = format!("{}needle\nnext\n", "雪".repeat(22_000));
|
||||
std::fs::write(directory.join("text"), &original).unwrap();
|
||||
for (name, text) in [
|
||||
("normal", "雪\nneedle\nnext\n"),
|
||||
("unterminated", "雪\nneedle\nnext"),
|
||||
] {
|
||||
std::fs::write(directory.join(name), text).unwrap();
|
||||
}
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), source)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let inspected = model_inspect(r.clone(), "/checkouts/main/text").await;
|
||||
let result = model_invoke(
|
||||
r.clone(),
|
||||
&inspected,
|
||||
"search",
|
||||
json!({"pattern":"needle","limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"search advanced over a byte-truncated UTF-8 line: {result:?}"
|
||||
);
|
||||
let failure = result.unwrap_err();
|
||||
assert!(
|
||||
failure.to_string().contains("ResourceLimitExceeded"),
|
||||
"{failure}"
|
||||
);
|
||||
let read = model_invoke(r.clone(), &inspected, "read", json!({"limit":1}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(read.to_string().contains("ResourceLimitExceeded"), "{read}");
|
||||
let write = model_invoke(r.clone(), &inspected, "write", json!({"content":"lost"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(write.to_string().contains("InvalidArguments"), "{write}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(directory.join("text")).unwrap(),
|
||||
original
|
||||
);
|
||||
for name in ["normal", "unterminated"] {
|
||||
let inspected = model_inspect(r.clone(), &format!("/checkouts/main/{name}")).await;
|
||||
for offset in 0..=3 {
|
||||
let page = json_content(
|
||||
model_invoke(
|
||||
r.clone(),
|
||||
&inspected,
|
||||
"search",
|
||||
json!({"pattern":"needle","offset":offset,"limit":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(page["next_offset"], (offset + 1).min(3));
|
||||
assert_eq!(page["truncated"], offset < 2);
|
||||
assert_eq!(
|
||||
page["matches"],
|
||||
if offset == 1 {
|
||||
json!([{"line":2,"text":"needle"}])
|
||||
} else {
|
||||
json!([])
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert_long_utf8_search_refuses_incomplete_line(session(&dir, false), dir.path()).await;
|
||||
}
|
||||
|
||||
@@ -692,7 +692,6 @@ impl WorkerController {
|
||||
pub async fn spawn<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
@@ -701,7 +700,6 @@ impl WorkerController {
|
||||
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
false,
|
||||
None,
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
@@ -715,7 +713,7 @@ impl WorkerController {
|
||||
pub async fn spawn_with_transport<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
@@ -723,42 +721,32 @@ impl WorkerController {
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let (handle, shutdown, _task) =
|
||||
Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport)
|
||||
.await?;
|
||||
Self::spawn_with_transport_owned(worker, runtime_base, transport).await?;
|
||||
Ok((handle, shutdown))
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_with_transport_owned<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Self::spawn_inner(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
false,
|
||||
None,
|
||||
transport,
|
||||
)
|
||||
.await
|
||||
Self::spawn_inner(worker, runtime_base, false, None, transport).await
|
||||
}
|
||||
|
||||
/// Spawn a Worker owned by `worker-runtime`.
|
||||
///
|
||||
/// The controller uses an ephemeral directory for Unix sockets while tool
|
||||
/// spill artifacts use the separately supplied Worker-owned temporary path.
|
||||
/// The controller uses an ephemeral directory for Unix sockets. Bash output is
|
||||
/// owned by its live Feature instance, independently of filesystem layout.
|
||||
/// Runtime-managed Workers do not write legacy pid/status/manifest liveness
|
||||
/// projections.
|
||||
pub async fn spawn_runtime_managed<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
@@ -767,7 +755,6 @@ impl WorkerController {
|
||||
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
true,
|
||||
None,
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
@@ -780,7 +767,6 @@ impl WorkerController {
|
||||
pub async fn spawn_runtime_managed_run<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
run_dir: &Path,
|
||||
bash_output_dir: &Path,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
@@ -789,7 +775,6 @@ impl WorkerController {
|
||||
Self::spawn_runtime_managed_run_with_transport(
|
||||
worker,
|
||||
run_dir,
|
||||
bash_output_dir,
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
)
|
||||
.await
|
||||
@@ -800,27 +785,23 @@ impl WorkerController {
|
||||
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
run_dir: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned(
|
||||
worker,
|
||||
run_dir,
|
||||
bash_output_dir,
|
||||
transport,
|
||||
)
|
||||
.await?;
|
||||
let (handle, shutdown, _task) =
|
||||
Self::spawn_runtime_managed_run_with_transport_owned(worker, run_dir, transport)
|
||||
.await?;
|
||||
Ok((handle, shutdown))
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_runtime_managed_run_with_transport_owned<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
run_dir: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||
where
|
||||
@@ -830,21 +811,13 @@ impl WorkerController {
|
||||
let parent = run_dir
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
|
||||
Self::spawn_inner(
|
||||
worker,
|
||||
parent,
|
||||
bash_output_dir,
|
||||
true,
|
||||
Some(run_dir),
|
||||
transport,
|
||||
)
|
||||
.await
|
||||
Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await
|
||||
}
|
||||
|
||||
async fn spawn_inner<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
runtime_managed: bool,
|
||||
runtime_run: Option<&Path>,
|
||||
transport: WorkerControllerTransport,
|
||||
@@ -857,7 +830,6 @@ impl WorkerController {
|
||||
let result = Self::spawn_initialized(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
runtime_managed,
|
||||
runtime_run,
|
||||
transport,
|
||||
@@ -874,7 +846,7 @@ impl WorkerController {
|
||||
async fn spawn_initialized<C, St>(
|
||||
mut worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
runtime_managed: bool,
|
||||
runtime_run: Option<&Path>,
|
||||
transport: WorkerControllerTransport,
|
||||
@@ -888,8 +860,7 @@ impl WorkerController {
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
// === 1. Initialization (channels / RuntimeDir / worker-immutable
|
||||
// snapshots / SpawnedWorkerRegistry / alerter attach /
|
||||
// bash-output scope) ===
|
||||
// snapshots / SpawnedWorkerRegistry / alerter attach) ===
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
|
||||
let (working_event_tx, _) = broadcast::channel::<Event>(256);
|
||||
@@ -897,8 +868,8 @@ impl WorkerController {
|
||||
let in_flight = InFlightEvents::new(working_event_tx.clone());
|
||||
worker.attach_in_flight_events(in_flight.clone());
|
||||
|
||||
// Runtime directory is created before tool registration because it owns
|
||||
// bounded tool artifacts, and before initial status/history writes consume
|
||||
// Runtime directory is created before tool registration and before
|
||||
// initial status/history writes consume
|
||||
// the greeting we build after registration is complete.
|
||||
let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run {
|
||||
RuntimeDir::create_worker_run(run_dir).await?
|
||||
@@ -935,26 +906,6 @@ impl WorkerController {
|
||||
worker.attach_internal_worker_registry(spawned_registry.clone());
|
||||
worker.attach_working_event_tx(working_event_tx.clone());
|
||||
|
||||
// Bash spill artifacts are owned by the stable Worker identity rather
|
||||
// than a controller session/run generation. Push a recursive
|
||||
// `allow(Read)` for the exact tool output path into the Worker's shared
|
||||
// runtime scope so the Workdir session and system prompt stay aligned.
|
||||
let bash_output_dir = bash_output_dir.to_path_buf();
|
||||
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
||||
std::io::Error::other(format!(
|
||||
"create bash output dir {}: {e}",
|
||||
bash_output_dir.display()
|
||||
))
|
||||
})?;
|
||||
worker
|
||||
.add_scope_rules([manifest::ScopeRule {
|
||||
target: bash_output_dir.clone(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
// === 1.5. Direct writer wiring ===
|
||||
//
|
||||
// Engine callbacks fire `on_history_append` for each assistant
|
||||
@@ -974,7 +925,6 @@ impl WorkerController {
|
||||
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
||||
let fs_for_view = register_worker_tools(
|
||||
&mut worker,
|
||||
bash_output_dir,
|
||||
runtime_base.to_path_buf(),
|
||||
spawned_registry.clone(),
|
||||
Some(method_tx.downgrade()),
|
||||
@@ -1440,7 +1390,7 @@ fn validate_memory_lifecycle_targets(
|
||||
/// the shared state.
|
||||
pub(crate) async fn register_worker_tools<C, St>(
|
||||
worker: &mut Worker<C, St>,
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
runtime_base: PathBuf,
|
||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||
parent_method_tx: Option<mpsc::WeakSender<Method>>,
|
||||
@@ -1485,6 +1435,7 @@ where
|
||||
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
|
||||
let prompts = worker.prompts().clone();
|
||||
let tracker = tools::Tracker::new();
|
||||
let bash_output = spawned_registry.bash_output();
|
||||
{
|
||||
let paste_store = worker.store().clone();
|
||||
let paste_session_id = worker.session_id();
|
||||
@@ -1507,13 +1458,13 @@ where
|
||||
tools::routed_builtin_tools_for_current_capabilities(
|
||||
workdir_sessions.clone(),
|
||||
tracker.clone(),
|
||||
bash_output_dir.clone(),
|
||||
bash_output.clone(),
|
||||
)
|
||||
} else {
|
||||
tools::routed_builtin_tools(
|
||||
workdir_sessions.clone(),
|
||||
tracker.clone(),
|
||||
bash_output_dir.clone(),
|
||||
bash_output.clone(),
|
||||
)
|
||||
};
|
||||
worker.engine_mut().register_tools(workdir_tools);
|
||||
@@ -1910,6 +1861,20 @@ where
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if wip_mode {
|
||||
crate::feature::builtin::bash::mount(
|
||||
&mut wip_mount_registry,
|
||||
worker.workdir_sessions(),
|
||||
bash_output.clone(),
|
||||
wip_permissions.clone(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Bash output 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,
|
||||
@@ -2040,7 +2005,6 @@ where
|
||||
spawner_workspace_context,
|
||||
parent_notifications,
|
||||
runtime_base.clone(),
|
||||
bash_output_dir.clone(),
|
||||
spawner_workspace_root,
|
||||
workdir_tool_broker,
|
||||
spawned_registry.clone(),
|
||||
@@ -3326,6 +3290,8 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
}
|
||||
|
||||
spawned_registry.bash_output().revoke();
|
||||
|
||||
if let Some(observer) = command_observer.take() {
|
||||
observer.abort();
|
||||
let _ = observer.await;
|
||||
@@ -4139,7 +4105,14 @@ mod tests {
|
||||
request: crate::worker::WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
|
||||
{
|
||||
// Native discovery must not enumerate or perform any mutation.
|
||||
// Native discovery reads metadata and the current caller's ceiling, never inventory or mutations.
|
||||
assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Get);
|
||||
if request.path == "/api/w/workspace-1/drive/capabilities" {
|
||||
return Ok(crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::json!({"write":true}).to_string(),
|
||||
});
|
||||
}
|
||||
assert_eq!(request.path, "/api/w/workspace-1/drive/root");
|
||||
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
|
||||
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
|
||||
@@ -4212,7 +4185,6 @@ mod tests {
|
||||
.descriptor();
|
||||
register_worker_tools(
|
||||
&mut worker,
|
||||
root.path().join("bash-output"),
|
||||
root.path().join("runtime"),
|
||||
SpawnedWorkerRegistry::new_for_internal_services(),
|
||||
None,
|
||||
|
||||
@@ -64,7 +64,6 @@ async fn prepared(
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
|
||||
@@ -635,12 +635,11 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name);
|
||||
|
||||
let started = match start_worker_controller(
|
||||
worker,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: runtime_base.clone(),
|
||||
bash_output_dir,
|
||||
},
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! an external plugin-loading surface.
|
||||
|
||||
pub mod backend_job_result;
|
||||
pub(crate) mod bash;
|
||||
|
||||
/// Host metadata participants are independent of model-facing Tool enablement.
|
||||
/// Disabling a Feature must not preserve stale Session-bound authority evidence.
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Bash Feature projection. The view is instance-owned and shared only by explicit parent
|
||||
//! routing. No output is indexable; committed result entries directly address immutable files.
|
||||
//! Root namespace knowledge grants nothing: every publication and read checks the current view.
|
||||
|
||||
use crate::{
|
||||
file_operation::{self, Contract},
|
||||
permission::permission_action_for,
|
||||
wip::{
|
||||
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount,
|
||||
WipSubtreeProvider, contextual_reference, json_to_wip, wip_to_json,
|
||||
},
|
||||
};
|
||||
use agen::tool::{Tool, ToolError};
|
||||
use async_trait::async_trait;
|
||||
use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||
use serde_json::{Value as Json, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use tools::{BashTool, bash_output::BashOutputView};
|
||||
use wip_protocol::{
|
||||
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
use workdir::{CommandStream, WorkdirSessionRouter};
|
||||
|
||||
const ROOT: &str = "/bash-output";
|
||||
|
||||
pub(crate) fn mount(
|
||||
registry: &mut WipMountRegistry,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
view: BashOutputView,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
) -> Result<(), WipMountError> {
|
||||
registry.allocate_namespace("bash", "bash-output")?;
|
||||
let provider = Arc::new(Provider {
|
||||
service: BashTool::new(router, view.clone()),
|
||||
view,
|
||||
permissions,
|
||||
incarnation: uuid::Uuid::now_v7().to_string(),
|
||||
});
|
||||
registry.mount(provider.root())?;
|
||||
registry.replace_compatibility_tools(ROOT, ["Bash"])?;
|
||||
registry.mount_subtree(WipSubtreeMount {
|
||||
root: ROOT.into(),
|
||||
provider: Arc::new(provider),
|
||||
})
|
||||
}
|
||||
|
||||
struct Provider {
|
||||
service: BashTool,
|
||||
view: BashOutputView,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
incarnation: String,
|
||||
}
|
||||
impl Provider {
|
||||
fn allowed(&self, tool: &str, args: &Json) -> bool {
|
||||
self.view.is_active()
|
||||
&& self
|
||||
.permissions
|
||||
.as_ref()
|
||||
.is_none_or(|p| permission_action_for(p, tool, args) == ToolPermissionAction::Allow)
|
||||
}
|
||||
fn may_allow(&self, tool: &str) -> bool {
|
||||
if !self.view.is_active() {
|
||||
return false;
|
||||
}
|
||||
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 root(self: &Arc<Self>) -> WipProjection {
|
||||
let interface = contextual_reference("yoi.bash/command/v1", ROOT);
|
||||
let descriptor = InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: Some(Documentation {
|
||||
summary: "Run Bash with bounded ephemeral output; retained bytes are accessed via result entries, not listed".into(),
|
||||
details: None,
|
||||
}),
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "run".into(),
|
||||
documentation: None,
|
||||
parameters: vec![ParameterDeclaration {
|
||||
name: "input".into(),
|
||||
required: true,
|
||||
documentation: Some(Documentation {
|
||||
summary: "Bash input, validated by the existing Tool argument contract".into(),
|
||||
details: Some(BashTool::input_schema().to_string()),
|
||||
}),
|
||||
r#type: TypeExpr::Json,
|
||||
}],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("summary", TypeExpr::String),
|
||||
field("stdout_preview", TypeExpr::String),
|
||||
field("stderr_preview", TypeExpr::String),
|
||||
field("preview_omitted", TypeExpr::Boolean),
|
||||
field("metadata", TypeExpr::Json),
|
||||
field("stdout_entry", TypeExpr::Entry),
|
||||
field("stderr_entry", TypeExpr::Entry),
|
||||
field("order_entry", TypeExpr::Entry),
|
||||
],
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
WipProjection {
|
||||
route: ROOT.into(),
|
||||
capability: "bash:command".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: "bash-output".into(),
|
||||
description: Some("Bash command and nonindexable retained output".into()),
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: Some(format!("bash:{}", self.incarnation)),
|
||||
validator: Some(self.incarnation.as_bytes().to_vec()),
|
||||
},
|
||||
interface,
|
||||
interface_validator: Some(Sha256::digest(format!("{descriptor:?}")).to_vec()),
|
||||
descriptor,
|
||||
handler: Arc::new(Run {
|
||||
provider: self.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn parse(&self, path: &str) -> Option<(String, Option<CommandStream>)> {
|
||||
let rest = path.strip_prefix("/bash-output/")?;
|
||||
let (id, stream) = rest.split_once('/')?;
|
||||
if uuid::Uuid::parse_str(id).ok()?.to_string() != id {
|
||||
return None;
|
||||
}
|
||||
let stream = match stream {
|
||||
"stdout" => Some(CommandStream::Stdout),
|
||||
"stderr" => Some(CommandStream::Stderr),
|
||||
"order" => None,
|
||||
_ => return None,
|
||||
};
|
||||
Some((id.into(), stream))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for Arc<Provider> {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
if !self.view.is_active() {
|
||||
return Ok(None);
|
||||
}
|
||||
if path == ROOT {
|
||||
let projection = self.root();
|
||||
return Ok(Some(WipPublication {
|
||||
scope: Some(projection.object.clone()),
|
||||
projection,
|
||||
}));
|
||||
}
|
||||
let Some((id, stream)) = self.parse(path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(metadata) = self.view.metadata(&id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !self.may_allow("Read") && !self.may_allow("Grep") {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut contracts = vec![];
|
||||
if self.may_allow("Read") {
|
||||
contracts.extend([Contract::ReadLines, Contract::ReadBytes]);
|
||||
}
|
||||
if self.may_allow("Grep") {
|
||||
contracts.push(Contract::SearchText);
|
||||
}
|
||||
let primary = contracts[0];
|
||||
let interface = primary.reference();
|
||||
let descriptor = primary.descriptor();
|
||||
let validator = Sha256::digest(format!("{}:{id}:{stream:?}", self.incarnation)).to_vec();
|
||||
let mut projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "bash:output".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: path.rsplit('/').next().unwrap().into(),
|
||||
description: Some(format!(
|
||||
"Read-only output component (stdout={} bytes, stderr={} bytes); text is UTF-8 lossy; storage_truncated={}",
|
||||
metadata.stdout_saved, metadata.stderr_saved, metadata.storage_truncated
|
||||
)),
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: Some(format!("bash:{}:{id}:{stream:?}", self.incarnation)),
|
||||
validator: Some(validator.clone()),
|
||||
},
|
||||
interface,
|
||||
descriptor: descriptor.clone(),
|
||||
interface_validator: Some(primary.validator()),
|
||||
handler: Arc::new(File {
|
||||
provider: self.clone(),
|
||||
id,
|
||||
stream,
|
||||
path: path.into(),
|
||||
validator,
|
||||
}),
|
||||
};
|
||||
file_operation::expose(&mut projection, contracts);
|
||||
Ok(Some(WipPublication {
|
||||
projection,
|
||||
scope: None,
|
||||
}))
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
provider: Arc<Provider>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for Run {
|
||||
fn is_visible(&self) -> bool {
|
||||
self.provider.view.is_active()
|
||||
}
|
||||
fn operation_available(&self, op: &str) -> bool {
|
||||
op == "run" && self.provider.may_allow("Bash")
|
||||
}
|
||||
async fn call(
|
||||
&self,
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
ctx: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if op != "run" {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"unknown Bash operation",
|
||||
));
|
||||
}
|
||||
let input = args
|
||||
.get("input")
|
||||
.ok_or_else(|| fail(ProtocolErrorCode::InvalidArguments, "input is required"))?;
|
||||
let input =
|
||||
wip_to_json(input).map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?;
|
||||
if !self.provider.allowed("Bash", &input) {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"Bash not permitted",
|
||||
));
|
||||
}
|
||||
let result = self
|
||||
.provider
|
||||
.service
|
||||
.execute_result(&input.to_string(), ctx.execution)
|
||||
.await
|
||||
.map_err(tool_error)?;
|
||||
if result.cancelled {
|
||||
return Err(WipOperationError::Cancelled(result.tool_output()));
|
||||
}
|
||||
if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) {
|
||||
return Err(WipOperationError::Interrupted(result.tool_output()));
|
||||
}
|
||||
// Entry fields are typed by the native Descriptor. No display-string parsing is involved.
|
||||
Ok(WipOperationOutput::native(Value::Record(BTreeMap::from([
|
||||
("summary".into(), Value::String(result.summary.clone())),
|
||||
(
|
||||
"stdout_preview".into(),
|
||||
Value::String(result.stdout_preview.clone()),
|
||||
),
|
||||
(
|
||||
"stderr_preview".into(),
|
||||
Value::String(result.stderr_preview.clone()),
|
||||
),
|
||||
(
|
||||
"preview_omitted".into(),
|
||||
Value::Boolean(result.preview_omitted),
|
||||
),
|
||||
("stdout_entry".into(), Value::String(result.stdout_entry())),
|
||||
("stderr_entry".into(), Value::String(result.stderr_entry())),
|
||||
("order_entry".into(), Value::String(result.order_entry())),
|
||||
(
|
||||
"metadata".into(),
|
||||
json_to_wip(&serde_json::to_value(result.metadata).unwrap())
|
||||
.map_err(|e| fail(ProtocolErrorCode::Internal, &e))?,
|
||||
),
|
||||
]))))
|
||||
}
|
||||
async fn cancel(&self, ctx: &agen::tool::ToolExecutionContext) -> Result<(), ToolError> {
|
||||
self.provider.service.cancel_execution(ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
struct File {
|
||||
provider: Arc<Provider>,
|
||||
id: String,
|
||||
stream: Option<CommandStream>,
|
||||
path: String,
|
||||
validator: Vec<u8>,
|
||||
}
|
||||
impl File {
|
||||
fn order_text(&self) -> Result<String, WipOperationError> {
|
||||
use std::fmt::Write;
|
||||
let ranges = self
|
||||
.provider
|
||||
.view
|
||||
.order(&self.id)
|
||||
.ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?;
|
||||
let mut text = String::new();
|
||||
for r in ranges {
|
||||
writeln!(&mut text, "{:?} {} {}", r.stream, r.start, r.end).unwrap();
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for File {
|
||||
fn is_visible(&self) -> bool {
|
||||
self.provider.view.metadata(&self.id).is_some()
|
||||
}
|
||||
fn object_validator(&self) -> Option<Vec<u8>> {
|
||||
self.is_visible().then(|| self.validator.clone())
|
||||
}
|
||||
fn operation_available(&self, op: &str) -> bool {
|
||||
match op {
|
||||
"read_lines" | "read_bytes" | "read" => self.provider.may_allow("Read"),
|
||||
"search_text" | "search" => self.provider.may_allow("Grep"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
async fn call(
|
||||
&self,
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if !self.operation_available(op) {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"output operation not permitted",
|
||||
));
|
||||
}
|
||||
let mut input = wip_to_json(&Value::Record(args.clone()))
|
||||
.map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?;
|
||||
let tool = if op == "search_text" {
|
||||
input["path"] = json!(self.path);
|
||||
"Grep"
|
||||
} else {
|
||||
input["file_path"] = json!(self.path);
|
||||
"Read"
|
||||
};
|
||||
if !self.provider.allowed(tool, &input) {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"output operation not permitted",
|
||||
));
|
||||
}
|
||||
let metadata = self
|
||||
.provider
|
||||
.view
|
||||
.metadata(&self.id)
|
||||
.ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?;
|
||||
if op == "read_bytes" {
|
||||
let (offset, limit) = file_operation::byte_range(args)?;
|
||||
let (bytes, total) = if let Some(stream) = self.stream {
|
||||
let total = if stream == CommandStream::Stdout {
|
||||
metadata.stdout_saved
|
||||
} else {
|
||||
metadata.stderr_saved
|
||||
};
|
||||
if offset > total as u64 {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"byte offset exceeds output length",
|
||||
));
|
||||
}
|
||||
let bytes = self
|
||||
.provider
|
||||
.view
|
||||
.read(&self.id, stream, offset as usize, limit)
|
||||
.ok_or_else(|| {
|
||||
fail(ProtocolErrorCode::NotFound, "output expired or revoked")
|
||||
})?;
|
||||
(bytes, total)
|
||||
} else {
|
||||
let text = self.order_text()?;
|
||||
if offset > text.len() as u64 {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"byte offset exceeds output length",
|
||||
));
|
||||
}
|
||||
(
|
||||
text.as_bytes()[offset as usize..(offset as usize + limit).min(text.len())]
|
||||
.to_vec(),
|
||||
text.len(),
|
||||
)
|
||||
};
|
||||
let eof = offset + bytes.len() as u64 == total as u64;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
file_operation::bytes_result(args, &bytes, eof)?,
|
||||
self.validator.clone(),
|
||||
));
|
||||
}
|
||||
let (offset, limit) = file_operation::window(args, op == "search_text")?;
|
||||
let value = if let Some(stream) = self.stream {
|
||||
let (text, more) = self
|
||||
.provider
|
||||
.view
|
||||
.line_window(&self.id, stream, offset, limit)
|
||||
.ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?
|
||||
.map_err(|e| fail(ProtocolErrorCode::ResourceLimitExceeded, &e))?;
|
||||
file_operation::page_result(op, args, &text, offset, more)?
|
||||
} else {
|
||||
file_operation::text_result(op, args, &self.order_text()?)?
|
||||
};
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.validator.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
}
|
||||
fn tool_error(error: ToolError) -> WipOperationError {
|
||||
match error {
|
||||
ToolError::InvalidArgument(_) => fail(ProtocolErrorCode::InvalidArguments, "invalid Bash arguments"),
|
||||
ToolError::Cancelled(output) => WipOperationError::Cancelled(output),
|
||||
ToolError::Interrupted(output) => WipOperationError::Interrupted(output),
|
||||
_ => WipOperationError::OutcomeUnknown("Bash execution failed; effects or process termination may be unconfirmed; do not automatically restart the command".into()),
|
||||
}
|
||||
}
|
||||
fn field(name: &str, r#type: TypeExpr) -> FieldDeclaration {
|
||||
FieldDeclaration {
|
||||
name: name.into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,9 @@ impl DriveBackend {
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn capabilities(&self) -> Result<DriveCapabilities, DriveError> {
|
||||
self.get("/capabilities", &serde_json::json!({})).await
|
||||
}
|
||||
pub async fn root(&self) -> Result<DriveEntry, DriveError> {
|
||||
let entry: DriveEntry = self
|
||||
.json(
|
||||
|
||||
@@ -7,12 +7,14 @@ use std::collections::VecDeque;
|
||||
struct Client {
|
||||
responses: Mutex<VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
capabilities: Mutex<WorkspaceResponse>,
|
||||
}
|
||||
impl Client {
|
||||
fn new(responses: Vec<WorkspaceResponse>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
responses: Mutex::new(responses.into_iter().map(Ok).collect()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
capabilities: Mutex::new(response(DriveCapabilities { write: false })),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,7 +32,15 @@ impl WorkspaceClient for Client {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let capabilities = request.path == "/api/w/workspace/drive/capabilities";
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if capabilities {
|
||||
let response = self.capabilities.lock().unwrap();
|
||||
return Ok(WorkspaceResponse {
|
||||
status: response.status,
|
||||
body: response.body.clone(),
|
||||
});
|
||||
}
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -479,7 +489,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
let feature = feature(client.clone());
|
||||
let mut mounts = crate::wip::WipMountRegistry::new();
|
||||
wip::mount_drive_wip(&mut mounts, &feature).unwrap();
|
||||
let provider = wip::DriveProvider(feature);
|
||||
let provider = wip::DriveProvider::new(feature);
|
||||
let path = entry_path(&entry.entry);
|
||||
let publication = provider.publication(&path).await.unwrap().unwrap();
|
||||
assert_eq!(publication.projection.route, path);
|
||||
@@ -487,7 +497,8 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
publication.scope.as_ref().unwrap().r#ref,
|
||||
publication.projection.object.r#ref
|
||||
);
|
||||
assert_eq!(publication.projection.object.validator, Some(b"3".to_vec()));
|
||||
assert!(publication.projection.object.validator.is_some());
|
||||
assert_ne!(publication.projection.object.validator, Some(b"3".to_vec()));
|
||||
assert!(provider.children("/drive").await.unwrap().is_empty());
|
||||
assert!(
|
||||
provider
|
||||
@@ -504,7 +515,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
.flat_map(|op| &op.parameters)
|
||||
.any(|parameter| parameter.name == "expected_mutation_id")
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -656,3 +667,662 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() {
|
||||
);
|
||||
assert_eq!(client.json.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
fn native_context() -> crate::wip::WipCallContext {
|
||||
crate::wip::WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "drive-test".into(),
|
||||
}
|
||||
}
|
||||
fn native_args(value: Value) -> std::collections::BTreeMap<String, wip_protocol::Value> {
|
||||
let wip_protocol::Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else {
|
||||
panic!("named arguments required")
|
||||
};
|
||||
args
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_reads_use_bounded_complete_source_and_literal_window_search() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let text = "α\r\nneedle.*\nNeedle.*\nlast\n";
|
||||
let mut entry = entry("2", "3");
|
||||
entry.size = Some(text.len() as u32);
|
||||
let read = DriveReadTextResponse {
|
||||
entry: entry.clone(),
|
||||
text: text.into(),
|
||||
truncated: false,
|
||||
};
|
||||
let client = Client::new(vec![
|
||||
response(entry.clone()),
|
||||
response(&read),
|
||||
response(&read),
|
||||
]);
|
||||
let feature = feature(client.clone());
|
||||
let publication = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut projection = publication.projection;
|
||||
assert!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::ReadLines.reference())
|
||||
);
|
||||
assert!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::SearchText.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Edit.reference())
|
||||
);
|
||||
assert!(
|
||||
projection
|
||||
.descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.any(|op| op.name == "read" && op.parameters.iter().any(|p| p.name == "max_bytes"))
|
||||
);
|
||||
let original_handler = projection.handler.clone();
|
||||
assert!(crate::file_operation::select(
|
||||
&mut projection,
|
||||
&Contract::ReadLines.reference()
|
||||
));
|
||||
let read = projection
|
||||
.handler
|
||||
.call(
|
||||
"read",
|
||||
&native_args(json!({"offset":1,"limit":2})),
|
||||
native_context(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("common read failed"));
|
||||
let value = crate::wip::wip_to_json(&read.value).unwrap();
|
||||
assert_eq!(value["next_offset"], 3);
|
||||
assert_eq!(value["truncated"], true);
|
||||
assert!(value["content"].as_str().unwrap().contains("needle.*"));
|
||||
// The common selector wraps a provider handler once; reuse the original
|
||||
// publication for the independent SearchText contract.
|
||||
projection.handler = original_handler;
|
||||
let request = client.requests.lock().unwrap().last().unwrap().clone();
|
||||
assert!(request.path.contains("max_bytes=65536"));
|
||||
assert!(crate::file_operation::select(
|
||||
&mut projection,
|
||||
&Contract::SearchText.reference()
|
||||
));
|
||||
let search = projection
|
||||
.handler
|
||||
.call(
|
||||
"search",
|
||||
&native_args(json!({"pattern":"needle.*","limit":3})),
|
||||
native_context(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("common search failed"));
|
||||
let value = crate::wip::wip_to_json(&search.value).unwrap();
|
||||
assert_eq!(value["matches"], json!([{"line":2,"text":"needle.*"}]));
|
||||
assert_eq!(value["next_offset"], 3);
|
||||
assert_eq!(value["truncated"], true);
|
||||
}
|
||||
|
||||
fn read_response(entry: &DriveEntry, text: &str) -> DriveReadTextResponse {
|
||||
DriveReadTextResponse {
|
||||
entry: entry.clone(),
|
||||
text: text.into(),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_refuses_truncated_mismatched_stale_and_denied_reads() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
use wip_protocol::ProtocolErrorCode;
|
||||
for (case, expected) in [
|
||||
("truncated", ProtocolErrorCode::ResourceLimitExceeded),
|
||||
("size", ProtocolErrorCode::ResourceLimitExceeded),
|
||||
("stale", ProtocolErrorCode::ValidatorMismatch),
|
||||
("denied", ProtocolErrorCode::PermissionDenied),
|
||||
] {
|
||||
let entry = entry("2", "3");
|
||||
let mut read = read_response(&entry, "hello");
|
||||
match case {
|
||||
"truncated" => read.truncated = true,
|
||||
"size" => read.text = "hell".into(),
|
||||
"stale" => read.entry.last_mutation_id = "4".into(),
|
||||
_ => {}
|
||||
}
|
||||
let read = if case == "denied" {
|
||||
WorkspaceResponse {
|
||||
status: 403,
|
||||
body: "denied".into(),
|
||||
}
|
||||
} else {
|
||||
response(read)
|
||||
};
|
||||
let client = Client::new(vec![response(entry.clone()), read]);
|
||||
let projection = wip::DriveProvider::new(feature(client.clone()))
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
let result = projection
|
||||
.handler
|
||||
.call("read_lines", &native_args(json!({})), native_context())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(ref error)) if error.code == expected),
|
||||
"{case}"
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3, "{case}: no retries");
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| request.method == crate::worker::WorkspaceRequestMethod::Get)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_rejects_oversized_source_before_read_and_remount_changes_object_validator()
|
||||
{
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let mut entry = entry("2", "3");
|
||||
entry.size = Some(server_api::DRIVE_CHUNK_MAX_BYTES + 1);
|
||||
let client = Client::new(vec![response(entry.clone()), response(entry.clone())]);
|
||||
let first = feature(client.clone());
|
||||
let second = first.clone();
|
||||
let path = entry_path(&entry.entry);
|
||||
let first = wip::DriveProvider::new(first)
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
let second = wip::DriveProvider::new(second)
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert_ne!(first.object.validator, second.object.validator);
|
||||
assert_eq!(first.object.interfaces, second.object.interfaces);
|
||||
let result = first
|
||||
.handler
|
||||
.call("read_lines", &native_args(json!({})), native_context())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::ResourceLimitExceeded)
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_reads_keep_tool_permission_denial_before_backend_read() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
let feature = feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig {
|
||||
default_action: manifest::ToolPermissionAction::Deny,
|
||||
rules: Vec::new(),
|
||||
}));
|
||||
let projection = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
let result = projection
|
||||
.handler
|
||||
.call("read_lines", &native_args(json!({})), native_context())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::PermissionDenied)
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_read_only_capability_omits_file_and_folder_mutation_contracts() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
for (kind, root) in [
|
||||
(DriveEntryKind::File, false),
|
||||
(DriveEntryKind::Folder, false),
|
||||
(DriveEntryKind::Folder, true),
|
||||
] {
|
||||
let mut entry = entry("2", "3");
|
||||
entry.kind = kind;
|
||||
if root {
|
||||
entry.parent = None;
|
||||
}
|
||||
let path = if root {
|
||||
"/drive".into()
|
||||
} else {
|
||||
entry_path(&entry.entry)
|
||||
};
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
let projection = wip::DriveProvider::new(feature(client.clone()))
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Edit.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference())
|
||||
);
|
||||
assert!(!projection.descriptor.operations.iter().any(|op| matches!(
|
||||
op.name.as_str(),
|
||||
"write"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "relocate"
|
||||
| "create_folder"
|
||||
| "create_text"
|
||||
| "save_workdir"
|
||||
)));
|
||||
let result = projection
|
||||
.handler
|
||||
.call(
|
||||
"write",
|
||||
&native_args(json!({"content":"denied"})),
|
||||
native_context(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::OperationNotFound)
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1].path, "/api/w/workspace/drive/capabilities");
|
||||
assert_eq!(
|
||||
requests[1].method,
|
||||
crate::worker::WorkspaceRequestMethod::Get
|
||||
);
|
||||
assert!(requests[1].body.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_capability_failure_never_publishes_an_assumed_write_ceiling() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
for (status, body) in [
|
||||
(403, "denied"),
|
||||
(500, "unavailable"),
|
||||
(200, "{}"),
|
||||
(200, "{\"write\":true,\"grant_id\":\"spoof\"}"),
|
||||
] {
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
*client.capabilities.lock().unwrap() = WorkspaceResponse {
|
||||
status,
|
||||
body: body.into(),
|
||||
};
|
||||
let result = wip::DriveProvider::new(feature(client.clone()))
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await;
|
||||
assert!(!matches!(result, Ok(Some(_))), "{status}: {body}");
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_mutation_publication_intersects_tool_permission_ceiling() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
*client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true });
|
||||
let feature = feature(client).with_permissions(Some(manifest::ToolPermissionConfig {
|
||||
default_action: manifest::ToolPermissionAction::Allow,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "DriveWrite".into(),
|
||||
pattern: "*".into(),
|
||||
action: manifest::ToolPermissionAction::Deny,
|
||||
}],
|
||||
}));
|
||||
let projection = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference())
|
||||
);
|
||||
assert!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Edit.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.any(|op| matches!(op.name.as_str(), "write" | "edit"))
|
||||
);
|
||||
}
|
||||
|
||||
/// Narrow transport fixture for native mutation chaining. The real provider,
|
||||
/// Client/Host, permission checks and Backend CAS adapter remain in the path;
|
||||
/// only canonical Drive persistence is replaced by an entry and text value.
|
||||
#[derive(Debug)]
|
||||
struct NativeMutations(Mutex<(DriveEntry, String, Vec<DriveMutationRequest>)>);
|
||||
impl WorkspaceClient for NativeMutations {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-native-mutations"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let mut state = self.0.lock().unwrap();
|
||||
let suffix = request
|
||||
.path
|
||||
.strip_prefix("/api/w/workspace/drive/")
|
||||
.unwrap();
|
||||
Ok(if suffix == "capabilities" {
|
||||
response(DriveCapabilities { write: true })
|
||||
} else if suffix.starts_with("metadata?") {
|
||||
response(&state.0)
|
||||
} else if suffix.starts_with("read-text?") {
|
||||
response(read_response(&state.0, &state.1))
|
||||
} else if suffix == "mutate" {
|
||||
let request: DriveMutationRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
let DriveMutation::UpdateText {
|
||||
id,
|
||||
expected_mutation_id,
|
||||
text,
|
||||
..
|
||||
} = &request.mutation
|
||||
else {
|
||||
panic!("unexpected mutation")
|
||||
};
|
||||
assert_eq!(id, &state.0.entry);
|
||||
assert_eq!(expected_mutation_id, &state.0.last_mutation_id);
|
||||
state.1 = text.clone();
|
||||
state.0.size = Some(text.len() as u32);
|
||||
state.0.last_mutation_id = request.request_id.clone();
|
||||
let output = response(DriveMutationResponse {
|
||||
request_id: request.request_id.clone(),
|
||||
entry: Some(state.0.clone()),
|
||||
});
|
||||
state.2.push(request);
|
||||
output
|
||||
} else {
|
||||
panic!("unexpected request: {}", request.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_edit_then_write_reuses_exact_post_operation_object_validator() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::{WipMountRegistry, WipRuntime};
|
||||
let entry = entry("2", "3");
|
||||
let path = entry_path(&entry.entry);
|
||||
let client = Arc::new(NativeMutations(Mutex::new((
|
||||
entry,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
))));
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
let mut mounts = WipMountRegistry::new();
|
||||
wip::mount_drive_wip(&mut mounts, &feature).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(mounts, "drive-test".into()).unwrap();
|
||||
runtime
|
||||
.prepare_call_observations(path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
let edit = runtime
|
||||
.call(
|
||||
path.clone(),
|
||||
Contract::Edit.reference(),
|
||||
"edit".into(),
|
||||
json!({"old_string":"hello","new_string":"updated"}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let edit: Value = serde_json::from_str(edit.content.as_deref().unwrap()).unwrap();
|
||||
assert!(edit["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string());
|
||||
assert_eq!(client.0.lock().unwrap().1, "updated");
|
||||
// No intervening Inspect/prepare_call_observations: success depends on the
|
||||
// exact nonce-bound post-commit validator returned by the first operation.
|
||||
let write = runtime
|
||||
.call(
|
||||
path,
|
||||
Contract::Write.reference(),
|
||||
"write".into(),
|
||||
json!({"content":"final"}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let write: Value = serde_json::from_str(write.content.as_deref().unwrap()).unwrap();
|
||||
assert!(write["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string());
|
||||
let state = client.0.lock().unwrap();
|
||||
assert_eq!(state.1, "final");
|
||||
assert_eq!(state.2.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_argument_dependent_write_policy_is_rechecked_and_ask_is_not_published() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
for (default_action, pattern, action, published, code) in [
|
||||
(
|
||||
manifest::ToolPermissionAction::Deny,
|
||||
"*permitted*",
|
||||
manifest::ToolPermissionAction::Allow,
|
||||
true,
|
||||
wip_protocol::ProtocolErrorCode::PermissionDenied,
|
||||
),
|
||||
(
|
||||
manifest::ToolPermissionAction::Allow,
|
||||
"*",
|
||||
manifest::ToolPermissionAction::Ask,
|
||||
false,
|
||||
wip_protocol::ProtocolErrorCode::OperationNotFound,
|
||||
),
|
||||
] {
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
*client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true });
|
||||
let feature =
|
||||
feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig {
|
||||
default_action,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "DriveWrite".into(),
|
||||
pattern: pattern.into(),
|
||||
action,
|
||||
}],
|
||||
}));
|
||||
let projection = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert_eq!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference()),
|
||||
published
|
||||
);
|
||||
let result = projection
|
||||
.handler
|
||||
.call(
|
||||
"write",
|
||||
&native_args(json!({"content":"denied"})),
|
||||
native_context(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == code)
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
// A single retained Object observation models a restored Client cache. All later
|
||||
// publication and execution use the real new-mount provider; the new Host supplies
|
||||
// the common contract's root scope independently, without replaying its old ref.
|
||||
struct RetainedObjectOnce {
|
||||
current: wip::DriveProvider,
|
||||
retained: Mutex<Option<wip_protocol::Object>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl crate::wip::WipSubtreeProvider for RetainedObjectOnce {
|
||||
async fn publication(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<crate::wip::WipPublication>, wip_protocol::ProtocolError> {
|
||||
let Some(mut publication) = self.current.publication(path).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(retained) = self.retained.lock().unwrap().take() {
|
||||
publication.projection.object = retained;
|
||||
publication = crate::wip::WipPublication::self_scoped(publication.projection)?;
|
||||
}
|
||||
Ok(Some(publication))
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, wip_protocol::ProtocolError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_same_feature_remount_rejects_retained_object_with_fresh_common_scope_then_reobservation_succeeds()
|
||||
{
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::{WipMountRegistry, WipRuntime, WipSubtreeMount, WipSubtreeProvider};
|
||||
let entry = entry("2", "3");
|
||||
let path = entry_path(&entry.entry);
|
||||
let client = Arc::new(NativeMutations(Mutex::new((
|
||||
entry,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
))));
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
// mount_drive_wip constructs exactly one of these providers per mount. Reuse
|
||||
// the same Feature, whose mutation request identity deliberately does not rotate.
|
||||
let first = wip::DriveProvider::new(feature.clone());
|
||||
let old = first.publication(&path).await.unwrap().unwrap().projection;
|
||||
let cloned = first
|
||||
.clone()
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert_eq!(old.object.validator, cloned.object.validator);
|
||||
let second = wip::DriveProvider::new(feature.clone());
|
||||
let current = second.publication(&path).await.unwrap().unwrap().projection;
|
||||
assert_ne!(old.object.validator, current.object.validator);
|
||||
let mut old_contract = old.clone();
|
||||
let mut current_contract = current.clone();
|
||||
assert!(crate::file_operation::select(
|
||||
&mut old_contract,
|
||||
&Contract::ReadLines.reference()
|
||||
));
|
||||
assert!(crate::file_operation::select(
|
||||
&mut current_contract,
|
||||
&Contract::ReadLines.reference()
|
||||
));
|
||||
assert_eq!(old_contract.interface, current_contract.interface);
|
||||
assert_eq!(old_contract.descriptor, current_contract.descriptor);
|
||||
assert_eq!(
|
||||
old_contract.interface_validator,
|
||||
current_contract.interface_validator
|
||||
);
|
||||
|
||||
// Mount only the selected entry for this cache-restoration fixture. The real
|
||||
// Host still selects the root common contract and validates target membership,
|
||||
// scope_ref and Object/Interface validators before running the Drive handler.
|
||||
let mut mounts = WipMountRegistry::new();
|
||||
mounts.allocate_namespace("drive", "drive").unwrap();
|
||||
mounts.mount(current).unwrap();
|
||||
mounts
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: path.clone(),
|
||||
provider: Arc::new(RetainedObjectOnce {
|
||||
current: second,
|
||||
retained: Mutex::new(Some(old.object)),
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(mounts, "drive-remount".into()).unwrap();
|
||||
runtime.inspect(path.clone(), true).await.unwrap();
|
||||
let interface_path = format!(
|
||||
"{}::{}",
|
||||
serde_json::to_string("/").unwrap(),
|
||||
serde_json::to_string(&Contract::ReadLines.reference().name).unwrap()
|
||||
);
|
||||
// This fetch obtains the *new* Host's root scope_ref and the unchanged common
|
||||
// Interface validator; only the retained target observation remains stale.
|
||||
runtime.inspect(interface_path, true).await.unwrap();
|
||||
let error = runtime
|
||||
.call(
|
||||
path.clone(),
|
||||
Contract::ReadLines.reference(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("ValidatorMismatch"), "{error}");
|
||||
assert!(!error.contains("InterfaceMismatch"), "{error}");
|
||||
runtime.inspect(path.clone(), true).await.unwrap();
|
||||
let result = runtime
|
||||
.call(
|
||||
path,
|
||||
Contract::ReadLines.reference(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap();
|
||||
assert!(result["content"].as_str().unwrap().contains("hello"));
|
||||
assert!(client.0.lock().unwrap().2.is_empty());
|
||||
}
|
||||
|
||||
@@ -40,7 +40,11 @@ pub fn mount_drive_wip(
|
||||
registry.allocate_namespace("drive", "drive")?;
|
||||
// Registration is only a namespace entrance. The subtree provides the
|
||||
// current target/scope/descriptor/handler in a single self-scoped snapshot.
|
||||
let names = root_specs();
|
||||
// This unresolved entrance has no live capability evidence.
|
||||
let names: Vec<_> = root_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| !mutation(spec))
|
||||
.collect();
|
||||
let descriptor = descriptor(&names, true);
|
||||
registry.mount(WipProjection {
|
||||
route: ROOT.into(),
|
||||
@@ -63,7 +67,7 @@ pub fn mount_drive_wip(
|
||||
})?;
|
||||
registry.mount_subtree(WipSubtreeMount {
|
||||
root: ROOT.into(),
|
||||
provider: Arc::new(DriveProvider(feature.clone())),
|
||||
provider: Arc::new(DriveProvider::new(feature.clone())),
|
||||
})?;
|
||||
registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?;
|
||||
Ok(())
|
||||
@@ -80,7 +84,19 @@ impl WipOperationHandler for Unresolved {
|
||||
Err(map_error(DriveError::Denied))
|
||||
}
|
||||
}
|
||||
pub(super) struct DriveProvider(pub(super) DriveFeature);
|
||||
#[derive(Clone)]
|
||||
pub(super) struct DriveProvider {
|
||||
feature: DriveFeature,
|
||||
observation_nonce: Arc<str>,
|
||||
}
|
||||
impl DriveProvider {
|
||||
pub(super) fn new(feature: DriveFeature) -> Self {
|
||||
Self {
|
||||
feature,
|
||||
observation_nonce: uuid::Uuid::now_v7().to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for DriveProvider {
|
||||
// Arbitrary tree depth is safe: there are deliberately no indexable entries.
|
||||
@@ -92,27 +108,50 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let result = if path == ROOT {
|
||||
self.0.backend.root().await
|
||||
self.feature.backend.root().await
|
||||
} else {
|
||||
let Some(entry) = parse_entry(&self.0, path).map_err(protocol)? else {
|
||||
let Some(entry) = parse_entry(&self.feature, path).map_err(protocol)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.0.backend.metadata(&entry).await
|
||||
self.feature.backend.metadata(&entry).await
|
||||
};
|
||||
let entry = match result {
|
||||
Ok(entry) => entry,
|
||||
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
|
||||
Err(error) => return Err(protocol(error)),
|
||||
};
|
||||
let capabilities = match self.feature.backend.capabilities().await {
|
||||
Ok(capabilities) => capabilities,
|
||||
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
|
||||
Err(error) => return Err(protocol(error)),
|
||||
};
|
||||
let root = path == ROOT;
|
||||
let specs = if root {
|
||||
let specs: Vec<_> = if root {
|
||||
root_specs()
|
||||
} else {
|
||||
entry_specs(&entry)
|
||||
};
|
||||
}
|
||||
.into_iter()
|
||||
.filter(|spec| {
|
||||
!mutation(spec) || (capabilities.write && publication_may_allow(&self.feature, spec))
|
||||
})
|
||||
.collect();
|
||||
let common_mutations: Vec<_> = specs
|
||||
.iter()
|
||||
.filter_map(|spec| match spec.operation {
|
||||
"edit" => Some(crate::file_operation::Contract::Edit),
|
||||
"write" => Some(crate::file_operation::Contract::Write),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let descriptor = descriptor(&specs, root);
|
||||
let reference = contextual_reference(INTERFACE, path);
|
||||
let validator = entry.last_mutation_id.as_bytes().to_vec();
|
||||
let validator = object_validator(
|
||||
&self.observation_nonce,
|
||||
&entry,
|
||||
&entry.last_mutation_id,
|
||||
&specs,
|
||||
);
|
||||
let object = Object {
|
||||
name: if root {
|
||||
"drive".into()
|
||||
@@ -130,7 +169,8 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
)),
|
||||
validator: Some(validator),
|
||||
};
|
||||
let projection = WipProjection {
|
||||
let common_read = entry.kind == DriveEntryKind::File;
|
||||
let mut projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "drive:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
@@ -139,18 +179,80 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()),
|
||||
descriptor,
|
||||
handler: Arc::new(DriveHandler {
|
||||
feature: self.0.clone(),
|
||||
feature: self.feature.clone(),
|
||||
observation_nonce: self.observation_nonce.clone(),
|
||||
entry,
|
||||
specs,
|
||||
root,
|
||||
}),
|
||||
};
|
||||
// This is publication evidence only. Backend execution rechecks current
|
||||
// authority and the captured commit precondition on every mutation.
|
||||
crate::file_operation::expose(&mut projection, common_mutations);
|
||||
if common_read {
|
||||
crate::file_operation::expose(
|
||||
&mut projection,
|
||||
[
|
||||
crate::file_operation::Contract::ReadLines,
|
||||
crate::file_operation::Contract::SearchText,
|
||||
],
|
||||
);
|
||||
}
|
||||
WipPublication::self_scoped(projection).map(Some)
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
fn object_validator(
|
||||
observation_nonce: &str,
|
||||
entry: &DriveEntry,
|
||||
mutation: &str,
|
||||
specs: &[Spec],
|
||||
) -> Vec<u8> {
|
||||
// Object observations belong to one mount, even when a restored Host reuses
|
||||
// the same Feature instance. Feature.nonce separately identifies mutations.
|
||||
// Current membership is part of Object representation, not the shared Interface lifetime.
|
||||
let operations: Vec<_> = specs.iter().map(|spec| spec.operation).collect();
|
||||
digest(
|
||||
format!(
|
||||
"{observation_nonce:?}:{:?}:{:?}:{mutation:?}:{operations:?}",
|
||||
entry.entry.workspace_id, entry.entry.node_id
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn mutation(spec: &Spec) -> bool {
|
||||
matches!(
|
||||
spec.operation,
|
||||
"write" | "edit" | "delete" | "relocate" | "create_folder" | "create_text" | "save_workdir"
|
||||
)
|
||||
}
|
||||
|
||||
fn publication_may_allow(feature: &DriveFeature, spec: &Spec) -> bool {
|
||||
let Some(permissions) = &feature.permissions else {
|
||||
return true;
|
||||
};
|
||||
// Like checkout publication, retain potentially allowed argument-dependent
|
||||
// operations but hide an unconditional Deny/Ask ceiling. This is not an
|
||||
// authorization decision: exact arguments are still checked at execution.
|
||||
for rule in permissions
|
||||
.rules
|
||||
.iter()
|
||||
.filter(|rule| rule.tool.eq_ignore_ascii_case(spec.name))
|
||||
{
|
||||
if rule.action == manifest::ToolPermissionAction::Allow {
|
||||
return true;
|
||||
}
|
||||
if rule.pattern == "*" {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
permissions.default_action == manifest::ToolPermissionAction::Allow
|
||||
}
|
||||
|
||||
fn root_specs() -> Vec<Spec> {
|
||||
SPECS
|
||||
.iter()
|
||||
@@ -194,11 +296,10 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
|
||||
}
|
||||
}
|
||||
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().filter(|spec| !matches!(spec.operation,"edit"|"write")).map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)],
|
||||
"write"|"edit"=>crate::file_operation::parameters(spec.operation,false).expect("shared text arguments"),
|
||||
"create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
|
||||
"create_folder"=>vec![parameter("name",true,TypeExpr::String)],
|
||||
"relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)],
|
||||
@@ -209,6 +310,7 @@ fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
|
||||
}
|
||||
struct DriveHandler {
|
||||
feature: DriveFeature,
|
||||
observation_nonce: Arc<str>,
|
||||
entry: DriveEntry,
|
||||
specs: Vec<Spec>,
|
||||
root: bool,
|
||||
@@ -221,21 +323,34 @@ impl WipOperationHandler for DriveHandler {
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let common_text = matches!(operation, "read_lines" | "search_text");
|
||||
let backend_operation = if common_text { "read" } else { operation };
|
||||
let spec = self
|
||||
.specs
|
||||
.iter()
|
||||
.find(|spec| spec.operation == operation)
|
||||
.find(|spec| spec.operation == backend_operation)
|
||||
.ok_or_else(|| {
|
||||
failure(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"Drive operation is not published",
|
||||
)
|
||||
})?;
|
||||
let declaration = descriptor(&self.specs, self.root)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.unwrap();
|
||||
let contract = match operation {
|
||||
"read_lines" => Some(crate::file_operation::Contract::ReadLines),
|
||||
"search_text" => Some(crate::file_operation::Contract::SearchText),
|
||||
"edit" => Some(crate::file_operation::Contract::Edit),
|
||||
"write" => Some(crate::file_operation::Contract::Write),
|
||||
_ => None,
|
||||
};
|
||||
let declaration = if let Some(contract) = contract {
|
||||
contract.descriptor().operations.into_iter().next().unwrap()
|
||||
} else {
|
||||
descriptor(&self.specs, self.root)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.unwrap()
|
||||
};
|
||||
if arguments
|
||||
.keys()
|
||||
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
|
||||
@@ -244,8 +359,22 @@ impl WipOperationHandler for DriveHandler {
|
||||
"unrecognized arguments; committed-request observations are Client-managed".into(),
|
||||
)));
|
||||
}
|
||||
let mut input = wip_to_json(&Value::Record(arguments.clone()))
|
||||
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?;
|
||||
let mut input = if common_text {
|
||||
// Validate the common input before performing any source read. Its
|
||||
// line window is evaluated only after the complete bounded read.
|
||||
crate::file_operation::text_result(operation, arguments, "")?;
|
||||
if self
|
||||
.entry
|
||||
.size
|
||||
.is_some_and(|size| size > server_api::DRIVE_CHUNK_MAX_BYTES)
|
||||
{
|
||||
return Err(map_error(DriveError::Limit));
|
||||
}
|
||||
json!({"entry": self.entry.entry, "max_bytes": server_api::DRIVE_CHUNK_MAX_BYTES})
|
||||
} else {
|
||||
wip_to_json(&Value::Record(arguments.clone()))
|
||||
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?
|
||||
};
|
||||
let fields = input
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?;
|
||||
@@ -298,6 +427,19 @@ impl WipOperationHandler for DriveHandler {
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
if common_text {
|
||||
let content = complete_text(&output.value)?;
|
||||
let value = crate::file_operation::text_result(operation, arguments, content)?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.entry,
|
||||
&self.entry.last_mutation_id,
|
||||
&self.specs,
|
||||
),
|
||||
));
|
||||
}
|
||||
let value = json_to_wip(&output.value).map_err(|_| {
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"Completed Drive result unavailable; query request_status before another mutation"
|
||||
@@ -314,13 +456,39 @@ impl WipOperationHandler for DriveHandler {
|
||||
"Committed Drive request identity unavailable".into(),
|
||||
)
|
||||
})?;
|
||||
WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec())
|
||||
WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.entry,
|
||||
last_mutation_id,
|
||||
&self.specs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
WipOperationOutput::native(value)
|
||||
};
|
||||
Ok(result.with_attachments(output.attachments))
|
||||
}
|
||||
}
|
||||
/// A bounded line/search contract cannot claim that a partial blob is the full
|
||||
/// source. Retain Drive's separate whole-text read with its truncation flag.
|
||||
fn complete_text(value: &serde_json::Value) -> Result<&str, WipOperationError> {
|
||||
let content = value["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| map_error(DriveError::Unavailable))?;
|
||||
let size = value["entry"]["metadata"]["size"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| map_error(DriveError::Unavailable))?;
|
||||
if value["truncated"].as_bool() != Some(false)
|
||||
|| size != content.len() as u64
|
||||
|| content.len() > server_api::DRIVE_CHUNK_MAX_BYTES as usize
|
||||
{
|
||||
return Err(map_error(DriveError::Limit));
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn parse_entry(feature: &DriveFeature, path: &str) -> Result<Option<DriveEntryRef>, DriveError> {
|
||||
let workspace = feature.backend.workspace()?;
|
||||
let prefix = format!(
|
||||
|
||||
@@ -505,13 +505,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_command(
|
||||
&self,
|
||||
mut request: CommandRequest,
|
||||
) -> Result<CommandHandle, WorkdirError> {
|
||||
// `spill_dir` belongs to the Worker host running BashTool. This remote
|
||||
// WorkdirSession provider cannot safely resolve or write that host path.
|
||||
request.spill_dir = None;
|
||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::CommandStart(request))? {
|
||||
WorkdirSessionOperationResult::CommandStart(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("command_start")),
|
||||
@@ -1660,9 +1654,9 @@ mod tests {
|
||||
.start_command(CommandRequest {
|
||||
command: "true".to_string(),
|
||||
timeout_secs: 120,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: Some("/worker-local/bash-output".into()),
|
||||
tool_call_id: Some("call-1".to_string()),
|
||||
})
|
||||
.await
|
||||
@@ -1675,7 +1669,7 @@ mod tests {
|
||||
assert_eq!(body["operation"]["operation"], "command_start");
|
||||
assert_eq!(body["operation"]["request"]["command"], "true");
|
||||
assert_eq!(body["operation"]["request"]["cwd"], "");
|
||||
assert!(body["operation"]["request"]["spill_dir"].is_null());
|
||||
assert!(body["operation"]["request"].get("spill_dir").is_none());
|
||||
assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1");
|
||||
assert!(
|
||||
!requests[0]
|
||||
@@ -1942,9 +1936,9 @@ mod tests {
|
||||
.start_command(CommandRequest {
|
||||
command: "printf done".to_string(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -47,6 +47,12 @@ use crate::wip::{
|
||||
pub(super) struct ConfigProvider {
|
||||
pub feature: WorkspaceConfigFeature,
|
||||
pub backend: Arc<WorkspaceConfigBackend>,
|
||||
// A fresh mount after restore invalidates observations from its predecessor.
|
||||
pub observation_nonce: Arc<str>,
|
||||
}
|
||||
|
||||
fn object_validator(nonce: &str, connection: &str, validator: &str) -> Vec<u8> {
|
||||
Sha256::digest(format!("{nonce}:{connection}:{validator}")).to_vec()
|
||||
}
|
||||
|
||||
/// Backend tree root is empty; source names are canonical relative config paths.
|
||||
@@ -240,7 +246,7 @@ impl ConfigProvider {
|
||||
let descriptor = descriptor(&operations);
|
||||
let interface = contextual_reference("yoi.workspace-config/node/v1", path);
|
||||
let interface_validator = Some(Sha256::digest(format!("{descriptor:?}")).to_vec());
|
||||
Ok(Some(WipProjection {
|
||||
let mut projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "workspace-config:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
@@ -252,7 +258,11 @@ impl ConfigProvider {
|
||||
)),
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: None,
|
||||
validator: Some(node.validator.as_bytes().to_vec()),
|
||||
validator: Some(object_validator(
|
||||
&self.observation_nonce,
|
||||
&attachment.connection_id,
|
||||
&node.validator,
|
||||
)),
|
||||
},
|
||||
interface,
|
||||
descriptor,
|
||||
@@ -263,9 +273,12 @@ impl ConfigProvider {
|
||||
attachment,
|
||||
snapshot,
|
||||
node,
|
||||
operations,
|
||||
operations: operations.clone(),
|
||||
observation_nonce: self.observation_nonce.clone(),
|
||||
}),
|
||||
}))
|
||||
};
|
||||
crate::file_operation::expose(&mut projection, common_contracts(&operations));
|
||||
Ok(Some(projection))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,12 +348,28 @@ fn operations(
|
||||
names.push(name.into());
|
||||
}
|
||||
}
|
||||
if names.iter().any(|name| name == "read") {
|
||||
names.extend(["read_lines".into(), "search_text".into()]);
|
||||
}
|
||||
if root {
|
||||
names.push("attach".into());
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn common_contracts(names: &[String]) -> Vec<crate::file_operation::Contract> {
|
||||
use crate::file_operation::Contract;
|
||||
[
|
||||
("read_lines", Contract::ReadLines),
|
||||
("search_text", Contract::SearchText),
|
||||
("edit", Contract::Edit),
|
||||
("write", Contract::Write),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(name, contract)| names.iter().any(|n| n == name).then_some(contract))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
|
||||
ParameterDeclaration {
|
||||
name: name.into(),
|
||||
@@ -356,11 +385,11 @@ fn descriptor(names: &[String]) -> InterfaceDescriptor {
|
||||
summary: "Logical Workspace configuration file operations".into(),
|
||||
details: Some("Backend grants, semantic validation, atomic whole-tree CAS and activation apply. Validators and change digests are managed automatically. apply_changes atomically accepts create/update/delete/rename changes without validators or expected_digest fields. Saving updates configuration for future operation boundaries, not already-running Worker manifests. No OS or command access.".into()),
|
||||
}),
|
||||
operations: names.iter().map(|name| OperationDeclaration {
|
||||
operations: names.iter().filter(|name| !matches!(name.as_str(), "read_lines" | "search_text" | "edit" | "write")).map(|name| OperationDeclaration {
|
||||
name: name.clone(), documentation: None,
|
||||
parameters: match name.as_str() {
|
||||
"attach" => vec![parameter("access", false, TypeExpr::String)],
|
||||
"read" | "write" | "edit" => crate::file_operation::parameters(name, false).unwrap(),
|
||||
"read" => crate::file_operation::parameters(name, false).unwrap(),
|
||||
"delete" => vec![],
|
||||
"create" => vec![parameter("path", false, TypeExpr::String), parameter("content", true, TypeExpr::String), parameter("content_type", false, TypeExpr::String)],
|
||||
"apply_changes" => vec![parameter("changes", true, TypeExpr::Json)],
|
||||
@@ -378,6 +407,7 @@ struct ConfigHandler {
|
||||
snapshot: WorkspaceConfigObserveResponse,
|
||||
node: WorkspaceConfigNode,
|
||||
operations: Vec<String>,
|
||||
observation_nonce: Arc<str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -476,13 +506,18 @@ impl ConfigHandler {
|
||||
|| response.validator != self.node.validator
|
||||
|| Some(&response.digest) != self.node.digest.as_ref()
|
||||
|| Some(&response.content_type) != self.node.content_type.as_ref()
|
||||
|| response.content.len() > MAX_TEXT_BYTES
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"Invalid Workspace config read response",
|
||||
));
|
||||
}
|
||||
if response.content.len() > MAX_TEXT_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"Workspace config source exceeds text limit",
|
||||
));
|
||||
}
|
||||
let mut response = response;
|
||||
response.content = fs_operation::text::read(response.content, text_limits())
|
||||
.map_err(text_error)?
|
||||
@@ -628,11 +663,33 @@ impl ConfigHandler {
|
||||
));
|
||||
}
|
||||
// Direct handler calls also enforce parameter shape; no model validators accepted.
|
||||
let declaration = descriptor(&self.operations)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.ok_or_else(invalid)?;
|
||||
let declaration = match operation {
|
||||
"read_lines" => crate::file_operation::Contract::ReadLines
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
"search_text" => crate::file_operation::Contract::SearchText
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
"edit" => crate::file_operation::Contract::Edit
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
"write" => crate::file_operation::Contract::Write
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
_ => descriptor(&self.operations)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation),
|
||||
}
|
||||
.ok_or_else(invalid)?;
|
||||
if arguments
|
||||
.keys()
|
||||
.any(|name| !declaration.parameters.iter().any(|p| p.name == *name))
|
||||
@@ -646,6 +703,19 @@ impl ConfigHandler {
|
||||
.call(operation, arguments, context)
|
||||
.await;
|
||||
}
|
||||
if matches!(operation, "read_lines" | "search_text") {
|
||||
crate::file_operation::text_result(operation, arguments, "")?;
|
||||
let result = self.read().await?;
|
||||
let value = crate::file_operation::text_result(operation, arguments, &result.content)?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.attachment.connection_id,
|
||||
&result.validator,
|
||||
),
|
||||
));
|
||||
}
|
||||
if operation == "read" {
|
||||
let _: fs_operation::text::ReadArgs = decode_text(arguments)?;
|
||||
let result = self.read().await?;
|
||||
@@ -657,7 +727,11 @@ impl ConfigHandler {
|
||||
.map_err(|_| invalid())?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.node.validator.as_bytes().to_vec(),
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.attachment.connection_id,
|
||||
&self.node.validator,
|
||||
),
|
||||
));
|
||||
}
|
||||
if self.attachment.access != WorkspaceConfigAccess::ReadWrite {
|
||||
@@ -762,7 +836,11 @@ impl ConfigHandler {
|
||||
"activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?;
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
node.validator.as_bytes().to_vec(),
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.attachment.connection_id,
|
||||
&node.validator,
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,8 +466,19 @@ async fn call(
|
||||
runtime
|
||||
.call(
|
||||
path.into(),
|
||||
interface(path, true),
|
||||
op.into(),
|
||||
match op {
|
||||
"edit" => crate::file_operation::Contract::Edit.reference(),
|
||||
"write" => crate::file_operation::Contract::Write.reference(),
|
||||
"read_lines" => crate::file_operation::Contract::ReadLines.reference(),
|
||||
"search_text" => crate::file_operation::Contract::SearchText.reference(),
|
||||
_ => interface(path, true),
|
||||
},
|
||||
match op {
|
||||
"read_lines" => "read",
|
||||
"search_text" => "search",
|
||||
_ => op,
|
||||
}
|
||||
.into(),
|
||||
args,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
@@ -825,11 +836,11 @@ async fn workspace_config_production_reattach_new_lifetime_rejects_old_same_byte
|
||||
client.state.lock().unwrap().active = None;
|
||||
let second = feature.attach("second", None).await.unwrap();
|
||||
assert_ne!(first.connection_id, second.connection_id);
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"bad"}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let error = call(&runtime, path, "write", json!({"content":"bad"}))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("ValidatorMismatch"), "{error}");
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
observe_interface(&runtime, path, true).await;
|
||||
call(&runtime, path, "write", json!({"content":"new-lifetime"}))
|
||||
@@ -958,3 +969,189 @@ async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_d
|
||||
.all(|r| r.method == WorkspaceRequestMethod::Get)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_read_only_text_windows_search_and_rejections_preserve_backend_authority() {
|
||||
let client = Router::new();
|
||||
{
|
||||
let mut state = client.state.lock().unwrap();
|
||||
state.read_only = true;
|
||||
state.entries.get_mut("main.dcdl").unwrap().1 = "α\r\nneedle.*\nNeedle.*\nlast\n".into();
|
||||
}
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("read-only", None).await.unwrap();
|
||||
let path = "/workspace-config/main.dcdl";
|
||||
observe_interface(&runtime, path, true).await;
|
||||
let object = runtime
|
||||
.inspect(path.into(), true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
assert!(object.contains(&crate::file_operation::Contract::ReadLines.reference().name));
|
||||
assert!(object.contains(&crate::file_operation::Contract::SearchText.reference().name));
|
||||
assert!(!object.contains(&crate::file_operation::Contract::Write.reference().name));
|
||||
assert!(!object.contains(&crate::file_operation::Contract::Edit.reference().name));
|
||||
let read = call(&runtime, path, "read_lines", json!({"offset":1,"limit":2}))
|
||||
.await
|
||||
.unwrap();
|
||||
let read: Json = serde_json::from_str(read.content.as_deref().unwrap()).unwrap();
|
||||
assert!(read["content"].as_str().unwrap().contains("needle.*"));
|
||||
assert!(read["content"].as_str().unwrap().contains("Needle.*"));
|
||||
assert!(!read["content"].as_str().unwrap().contains('α'));
|
||||
assert_eq!(read["next_offset"], 3);
|
||||
assert_eq!(read["truncated"], true);
|
||||
let search = call(
|
||||
&runtime,
|
||||
path,
|
||||
"search_text",
|
||||
json!({"pattern":"needle.*","limit":3}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let search: Json = serde_json::from_str(search.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(search["matches"], json!([{"line":2,"text":"needle.*"}]));
|
||||
assert_eq!(search["next_offset"], 3);
|
||||
assert_eq!(search["truncated"], true);
|
||||
for (op, arguments) in [
|
||||
("read_lines", json!({"limit":0})),
|
||||
("read_lines", json!({"limit":2001})),
|
||||
("search_text", json!({"pattern":""})),
|
||||
(
|
||||
"search_text",
|
||||
json!({"pattern":"x","case_insensitive":true}),
|
||||
),
|
||||
] {
|
||||
let before = client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.path.ends_with("/read"))
|
||||
.count();
|
||||
assert!(call(&runtime, path, op, arguments).await.is_err());
|
||||
let after = client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.path.ends_with("/read"))
|
||||
.count();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"denied"}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
client.state.lock().unwrap().granted = false;
|
||||
assert!(call(&runtime, path, "read_lines", json!({})).await.is_err());
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_text_read_refuses_oversized_rendered_window() {
|
||||
let client = Router::new();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("bounded", None).await.unwrap();
|
||||
let path = "/workspace-config/main.dcdl";
|
||||
client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut("main.dcdl")
|
||||
.unwrap()
|
||||
.1 = "x".repeat(64 * 1024);
|
||||
observe_interface(&runtime, path, true).await;
|
||||
let error = call(&runtime, path, "read_lines", json!({}))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("ResourceLimitExceeded"), "{error}");
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_config_backend_validators_reject_path_and_connection_transplant_even_for_identical_sources()
|
||||
{
|
||||
use crate::wip::WipOperationError;
|
||||
use wip_protocol::ProtocolErrorCode;
|
||||
let client = Router::new();
|
||||
let path = "profiles/team/deep/model.dcdl";
|
||||
client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut(path)
|
||||
.unwrap()
|
||||
.1 = "model = old".into();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
let first = feature.attach("path-binding", None).await.unwrap();
|
||||
let backend = backend::WorkspaceConfigBackend::new(client.clone());
|
||||
let (main_validator, deep_validator) = {
|
||||
let state = client.state.lock().unwrap();
|
||||
(state.validator("main.dcdl"), state.validator(path))
|
||||
};
|
||||
assert_ne!(main_validator, deep_validator);
|
||||
let read = |connection_id: String, validator: String| WorkspaceConfigReadRequest {
|
||||
connection_id,
|
||||
path: path.into(),
|
||||
validator,
|
||||
};
|
||||
let result = backend
|
||||
.post::<WorkspaceConfigReadResponse, _>(
|
||||
"/read",
|
||||
&read(first.connection_id.clone(), main_validator),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
let result: WorkspaceConfigReadResponse = backend
|
||||
.post(
|
||||
"/read",
|
||||
&read(first.connection_id.clone(), deep_validator.clone()),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("correct path validator rejected"));
|
||||
assert_eq!(result.content, "model = old");
|
||||
assert_eq!(result.validator, deep_validator);
|
||||
client.state.lock().unwrap().active = None;
|
||||
let second = feature.attach("connection-binding", None).await.unwrap();
|
||||
assert_ne!(first.connection_id, second.connection_id);
|
||||
for connection in [first.connection_id, second.connection_id.clone()] {
|
||||
let result = backend
|
||||
.post::<WorkspaceConfigReadResponse, _>(
|
||||
"/read",
|
||||
&read(connection, deep_validator.clone()),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
}
|
||||
// Fresh path/connection observation restores native common mutation access,
|
||||
// and the returned exact post-operation validator also supports common reads.
|
||||
let route = format!("/workspace-config/{path}");
|
||||
observe_interface(&runtime, &route, true).await;
|
||||
call(
|
||||
&runtime,
|
||||
&route,
|
||||
"write",
|
||||
json!({"content":"model = updated"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = call(&runtime, &route, "read_lines", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.content.unwrap().contains("model = updated"));
|
||||
assert_eq!(client.state.lock().unwrap().commits.len(), 1);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ pub fn mount_workspace_config_wip(
|
||||
provider: Arc::new(super::content::ConfigProvider {
|
||||
feature: feature.clone(),
|
||||
backend: backend.clone(),
|
||||
observation_nonce: uuid::Uuid::now_v7().to_string().into(),
|
||||
}),
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
//! WIP presentation of the provider-neutral file argument contracts. Providers
|
||||
//! choose which read surface to publish; routing and preconditions are not args.
|
||||
//! Stable root file contracts, independent of provider and target lifetimes.
|
||||
//!
|
||||
//! Each capability has one versioned reference and complete descriptor. Providers publish only
|
||||
//! supported combinations. Root scope is a namespace, never a file grant. Current Object
|
||||
//! membership selects the provider handler; target observations, checked operations, prior Read,
|
||||
//! CAS and grants remain provider-owned. Contract validators contain no target or connection state.
|
||||
//!
|
||||
//! Line windows, byte ranges and whole-text domain reads are different meanings, not aliases.
|
||||
//! Settings atomic commits and Drive management/cursor pagination remain domain extensions.
|
||||
//! Common mutations return one metadata envelope while providers retain their commit details.
|
||||
//! Old contextual references in history are neither rewritten nor accepted as aliases; callers
|
||||
//! reobserve Objects and common definitions after migration or restore.
|
||||
use fs_operation::text::TextOperation;
|
||||
use wip_protocol::{ParameterDeclaration, TypeExpr};
|
||||
|
||||
@@ -41,3 +51,651 @@ fn schema_parameters(schema: &schemars::Schema) -> Vec<ParameterDeclaration> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
use crate::wip::{
|
||||
WipCallContext, WipOperationError, WipOperationHandler, WipOperationOutput, WipProjection,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use wip_protocol::{
|
||||
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference,
|
||||
OperationDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, Value,
|
||||
};
|
||||
|
||||
pub(crate) const MAX_LINES: usize = 2000;
|
||||
pub(crate) const MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Contracts have independent capability membership and no target-dependent descriptor fields.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Contract {
|
||||
ReadBytes,
|
||||
ReadLines,
|
||||
SearchText,
|
||||
Edit,
|
||||
Write,
|
||||
List,
|
||||
Glob,
|
||||
Grep,
|
||||
CreateFile,
|
||||
}
|
||||
impl Contract {
|
||||
pub(crate) fn reference(self) -> InterfaceReference {
|
||||
crate::wip::root_reference(match self {
|
||||
Self::ReadLines => "yoi.file/read-lines/v1",
|
||||
Self::ReadBytes => "yoi.file/read-bytes/v1",
|
||||
Self::SearchText => "yoi.file/search-text/v1",
|
||||
Self::Edit => "yoi.file/edit-text/v1",
|
||||
Self::Write => "yoi.file/write-text/v1",
|
||||
Self::List => "yoi.directory/list/v1",
|
||||
Self::Glob => "yoi.directory/glob/v1",
|
||||
Self::Grep => "yoi.directory/grep/v1",
|
||||
Self::CreateFile => "yoi.directory/create-file/v1",
|
||||
})
|
||||
}
|
||||
pub(crate) fn from_reference(r: &InterfaceReference) -> Option<Self> {
|
||||
[
|
||||
Self::ReadLines,
|
||||
Self::ReadBytes,
|
||||
Self::SearchText,
|
||||
Self::Edit,
|
||||
Self::Write,
|
||||
Self::List,
|
||||
Self::Glob,
|
||||
Self::Grep,
|
||||
Self::CreateFile,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|c| c.reference() == *r)
|
||||
}
|
||||
pub(crate) fn operation(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadLines | Self::ReadBytes => "read",
|
||||
Self::SearchText => "search",
|
||||
Self::Edit => "edit",
|
||||
Self::Write => "write",
|
||||
Self::List => "list",
|
||||
Self::Glob => "glob",
|
||||
Self::Grep => "grep",
|
||||
Self::CreateFile => "create_file",
|
||||
}
|
||||
}
|
||||
pub(crate) fn handler_operation(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadLines => "read_lines",
|
||||
Self::ReadBytes => "read_bytes",
|
||||
Self::SearchText => "search_text",
|
||||
_ => self.operation(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn descriptor(self) -> InterfaceDescriptor {
|
||||
let (summary, parameters, result) = match self {
|
||||
Self::ReadBytes => (
|
||||
"Read a bounded binary byte range",
|
||||
vec![
|
||||
parameter("offset", false, TypeExpr::Integer),
|
||||
parameter("limit", false, TypeExpr::Integer),
|
||||
],
|
||||
TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("encoding", true, TypeExpr::String),
|
||||
field("data", true, TypeExpr::String),
|
||||
field("offset", true, TypeExpr::Integer),
|
||||
field("next_offset", true, TypeExpr::Integer),
|
||||
field("eof", true, TypeExpr::Boolean),
|
||||
],
|
||||
},
|
||||
),
|
||||
Self::ReadLines => (
|
||||
"Read a bounded text line window",
|
||||
parameters("read", true).unwrap(),
|
||||
page_type(false),
|
||||
),
|
||||
Self::SearchText => {
|
||||
let mut p = parameters("read", true).unwrap();
|
||||
p.push(parameter("pattern", true, TypeExpr::String));
|
||||
(
|
||||
"Search a bounded text line window for a literal",
|
||||
p,
|
||||
page_type(true),
|
||||
)
|
||||
}
|
||||
Self::Edit | Self::Write => (
|
||||
"Change existing text under provider preconditions",
|
||||
parameters(self.operation(), true).unwrap(),
|
||||
mutation_type(),
|
||||
),
|
||||
Self::List => (
|
||||
"List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; limit defaults to 100, maximum 1000; after continues by kind/entry. Includes hidden/ignored names under provider List policy, unlike Glob/Grep.",
|
||||
vec![
|
||||
parameter("limit", false, TypeExpr::Integer),
|
||||
parameter("after", false, cursor_type()),
|
||||
],
|
||||
result_type(true),
|
||||
),
|
||||
Self::Glob => (
|
||||
"Glob within this directory",
|
||||
vec![
|
||||
parameter("pattern", true, TypeExpr::String),
|
||||
parameter("path", false, TypeExpr::String),
|
||||
],
|
||||
result_type(false),
|
||||
),
|
||||
Self::Grep => (
|
||||
"Regex search within this directory",
|
||||
grep_parameters(),
|
||||
result_type(false),
|
||||
),
|
||||
Self::CreateFile => (
|
||||
"Create a new relative file and permitted missing parents; never overwrite",
|
||||
vec![
|
||||
parameter("path", true, TypeExpr::String),
|
||||
parameter("content", true, TypeExpr::String),
|
||||
],
|
||||
mutation_type(),
|
||||
),
|
||||
};
|
||||
let details = match self {
|
||||
Self::ReadBytes => {
|
||||
"Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry."
|
||||
}
|
||||
Self::ReadLines | Self::SearchText => {
|
||||
"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry."
|
||||
}
|
||||
Self::Edit | Self::Write | Self::CreateFile => {
|
||||
"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry."
|
||||
}
|
||||
_ => {
|
||||
"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry."
|
||||
}
|
||||
};
|
||||
InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
types: vec![],
|
||||
documentation: Some(Documentation {
|
||||
summary: summary.into(),
|
||||
details: Some(details.into()),
|
||||
}),
|
||||
operations: vec![OperationDeclaration {
|
||||
name: self.operation().into(),
|
||||
documentation: None,
|
||||
parameters,
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: result,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
pub(crate) fn validator(self) -> Vec<u8> {
|
||||
Sha256::digest(format!("{:?}", self.descriptor())).to_vec()
|
||||
}
|
||||
}
|
||||
pub(crate) fn expose(p: &mut WipProjection, contracts: impl IntoIterator<Item = Contract>) {
|
||||
for c in contracts {
|
||||
let r = c.reference();
|
||||
if !p.object.interfaces.contains(&r) {
|
||||
p.object.interfaces.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select only from current provider membership; the selected handler still owns all authority.
|
||||
pub(crate) fn select(p: &mut WipProjection, r: &InterfaceReference) -> bool {
|
||||
let Some(c) = Contract::from_reference(r) else {
|
||||
return false;
|
||||
};
|
||||
if !p.object.interfaces.contains(r) {
|
||||
return false;
|
||||
}
|
||||
p.interface = r.clone();
|
||||
p.descriptor = c.descriptor();
|
||||
p.interface_validator = Some(c.validator());
|
||||
p.handler = Arc::new(Selected {
|
||||
contract: c,
|
||||
inner: p.handler.clone(),
|
||||
});
|
||||
true
|
||||
}
|
||||
struct Selected {
|
||||
contract: Contract,
|
||||
inner: Arc<dyn WipOperationHandler>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for Selected {
|
||||
async fn call(
|
||||
&self,
|
||||
_: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let mut out = self
|
||||
.inner
|
||||
.call(self.contract.handler_operation(), args, context)
|
||||
.await?;
|
||||
if matches!(
|
||||
self.contract,
|
||||
Contract::Edit | Contract::Write | Contract::CreateFile
|
||||
) {
|
||||
out.value = Value::Record(BTreeMap::from([("metadata".into(), out.value)]));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
async fn cancel_operation(
|
||||
&self,
|
||||
_: &str,
|
||||
ctx: &agen::tool::ToolExecutionContext,
|
||||
) -> Result<(), agen::tool::ToolError> {
|
||||
self.inner
|
||||
.cancel_operation(self.contract.handler_operation(), ctx)
|
||||
.await
|
||||
}
|
||||
}
|
||||
fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
}
|
||||
pub(crate) fn window(
|
||||
args: &BTreeMap<String, Value>,
|
||||
search: bool,
|
||||
) -> Result<(usize, usize), WipOperationError> {
|
||||
if args
|
||||
.keys()
|
||||
.any(|k| !["offset", "limit"].contains(&k.as_str()) && !(search && k == "pattern"))
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"unknown text argument",
|
||||
));
|
||||
}
|
||||
let integer = |name: &str, default: usize| match args.get(name) {
|
||||
None => Ok(default),
|
||||
Some(Value::Integer(i)) => usize::try_from(*i)
|
||||
.map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative text range")),
|
||||
_ => Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid text range",
|
||||
)),
|
||||
};
|
||||
let offset = integer("offset", 0)?;
|
||||
let limit = integer("limit", MAX_LINES)?;
|
||||
if !(1..=MAX_LINES).contains(&limit)
|
||||
|| (offset as u64) > 9_007_199_254_740_991u64 - MAX_LINES as u64
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"text range exceeds bounds",
|
||||
));
|
||||
}
|
||||
if search {
|
||||
match args.get("pattern") {
|
||||
Some(Value::String(p)) if !p.is_empty() && p.len() <= 4096 => {}
|
||||
_ => {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"search requires a nonempty literal pattern up to 4096 bytes",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((offset, limit))
|
||||
}
|
||||
pub(crate) fn text_result(
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
content: &str,
|
||||
) -> Result<Value, WipOperationError> {
|
||||
let (offset, limit) = window(args, op == "search_text")?;
|
||||
let mut lines = content.lines().skip(offset);
|
||||
let mut text = String::new();
|
||||
for line in lines.by_ref().take(limit) {
|
||||
if text.len().saturating_add(line.len()).saturating_add(1) >= MAX_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text window exceeds source byte bound",
|
||||
));
|
||||
}
|
||||
text.push_str(line);
|
||||
text.push('\n');
|
||||
}
|
||||
page_result(op, args, &text, offset, lines.next().is_some())
|
||||
}
|
||||
/// A checked provider can supply only the selected window, without copying whole source files.
|
||||
pub(crate) fn page_result(
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
text: &str,
|
||||
offset: usize,
|
||||
more: bool,
|
||||
) -> Result<Value, WipOperationError> {
|
||||
let (_, limit) = window(args, op == "search_text")?;
|
||||
if text.len() >= MAX_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text window exceeds source byte bound",
|
||||
));
|
||||
}
|
||||
let count = text.lines().count();
|
||||
if count > limit {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"provider returned an invalid line window",
|
||||
));
|
||||
}
|
||||
let mut fields = BTreeMap::from([
|
||||
(
|
||||
"next_offset".into(),
|
||||
Value::Integer((offset + count) as i64),
|
||||
),
|
||||
("truncated".into(), Value::Boolean(more)),
|
||||
]);
|
||||
if op == "search_text" {
|
||||
let Some(Value::String(pattern)) = args.get("pattern") else {
|
||||
unreachable!("window checked pattern")
|
||||
};
|
||||
let matches = text
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| l.contains(pattern))
|
||||
.map(|(i, l)| {
|
||||
Value::Record(BTreeMap::from([
|
||||
("line".into(), Value::Integer((offset + i + 1) as i64)),
|
||||
("text".into(), Value::String(l.into())),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
fields.insert("matches".into(), Value::List(matches));
|
||||
} else {
|
||||
let full_len = text
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, l)| format!("{:>6}\t", offset + i + 1).len() + l.len() + 1)
|
||||
.sum::<usize>();
|
||||
if full_len > MAX_BYTES.saturating_sub(64) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"numbered window exceeds output byte bound",
|
||||
));
|
||||
}
|
||||
let rendered =
|
||||
fs_operation::text::render_numbered(text, offset, offset + count, false, MAX_BYTES);
|
||||
fields.insert("content".into(), Value::String(rendered.body));
|
||||
}
|
||||
let value = Value::Record(fields);
|
||||
let json = crate::wip::wip_to_json(&value)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?;
|
||||
// The model receives pretty JSON, so budget escapes and record/indentation overhead too.
|
||||
let encoded = serde_json::to_vec_pretty(&json)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?;
|
||||
if encoded.len() > MAX_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"serialized text window exceeds output byte bound",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn parameter(n: &str, r: bool, t: TypeExpr) -> ParameterDeclaration {
|
||||
ParameterDeclaration {
|
||||
name: n.into(),
|
||||
required: r,
|
||||
documentation: None,
|
||||
r#type: t,
|
||||
}
|
||||
}
|
||||
fn field(n: &str, r: bool, t: TypeExpr) -> FieldDeclaration {
|
||||
FieldDeclaration {
|
||||
name: n.into(),
|
||||
required: r,
|
||||
documentation: None,
|
||||
r#type: t,
|
||||
}
|
||||
}
|
||||
fn mutation_type() -> TypeExpr {
|
||||
TypeExpr::Record {
|
||||
fields: vec![field("metadata", true, TypeExpr::Json)],
|
||||
}
|
||||
}
|
||||
fn page_type(search: bool) -> TypeExpr {
|
||||
let mut fields = vec![
|
||||
field("next_offset", true, TypeExpr::Integer),
|
||||
field("truncated", true, TypeExpr::Boolean),
|
||||
];
|
||||
fields.push(if search {
|
||||
field(
|
||||
"matches",
|
||||
true,
|
||||
TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("line", true, TypeExpr::Integer),
|
||||
field("text", true, TypeExpr::String),
|
||||
],
|
||||
}),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
field("content", true, TypeExpr::String)
|
||||
});
|
||||
TypeExpr::Record { fields }
|
||||
}
|
||||
fn cursor_type() -> TypeExpr {
|
||||
TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("entry", true, TypeExpr::Entry),
|
||||
],
|
||||
}
|
||||
}
|
||||
fn result_type(list: bool) -> TypeExpr {
|
||||
let mut item = vec![field("entry", true, TypeExpr::Entry)];
|
||||
if list {
|
||||
item.extend([
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("size", true, TypeExpr::Integer),
|
||||
]);
|
||||
}
|
||||
let mut fields = vec![
|
||||
field("summary", true, TypeExpr::String),
|
||||
field("content", false, TypeExpr::String),
|
||||
field(
|
||||
"items",
|
||||
true,
|
||||
TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Record { fields: item }),
|
||||
},
|
||||
),
|
||||
];
|
||||
if list {
|
||||
fields.extend([
|
||||
field("truncated", true, TypeExpr::Boolean),
|
||||
field("after", false, cursor_type()),
|
||||
]);
|
||||
}
|
||||
TypeExpr::Record { fields }
|
||||
}
|
||||
fn grep_parameters() -> Vec<ParameterDeclaration> {
|
||||
let mut p = vec![parameter("pattern", true, TypeExpr::String)];
|
||||
for n in ["path", "glob", "type", "output_mode"] {
|
||||
p.push(parameter(n, false, TypeExpr::String));
|
||||
}
|
||||
for n in ["-A", "-B", "-C", "head_limit", "offset"] {
|
||||
p.push(parameter(n, false, TypeExpr::Integer));
|
||||
}
|
||||
for n in ["case_insensitive", "multiline"] {
|
||||
p.push(parameter(n, false, TypeExpr::Boolean));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn args(value: serde_json::Value) -> BTreeMap<String, Value> {
|
||||
let Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else {
|
||||
panic!("record")
|
||||
};
|
||||
args
|
||||
}
|
||||
#[test]
|
||||
fn text_window_preserves_unicode_crlf_offsets_and_literal_search_continuation() {
|
||||
let input = "first\r\n雪.*\r\nnot a match\n雪.*\n";
|
||||
let arguments = args(serde_json::json!({"offset":1,"limit":2,"pattern":".*"}));
|
||||
let whole = text_result("search_text", &arguments, input)
|
||||
.unwrap_or_else(|_| panic!("text result refused"));
|
||||
let partial = page_result("search_text", &arguments, "雪.*\r\nnot a match\n", 1, true)
|
||||
.unwrap_or_else(|_| panic!("page refused"));
|
||||
assert_eq!(whole, partial);
|
||||
let result = crate::wip::wip_to_json(&whole).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!({"matches":[{"line":2,"text":"雪.*"}],"next_offset":3,"truncated":true})
|
||||
);
|
||||
let read = text_result(
|
||||
"read_lines",
|
||||
&args(serde_json::json!({"offset":1,"limit":1})),
|
||||
input,
|
||||
)
|
||||
.unwrap_or_else(|_| panic!("read refused"));
|
||||
assert_eq!(
|
||||
crate::wip::wip_to_json(&read).unwrap(),
|
||||
serde_json::json!({"content":" 2\t雪.*\n","next_offset":2,"truncated":true})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn text_window_refuses_invalid_ranges_patterns_source_and_encoded_output_bounds() {
|
||||
for value in [
|
||||
serde_json::json!({"limit":0}),
|
||||
serde_json::json!({"limit":2001}),
|
||||
serde_json::json!({"offset":-1}),
|
||||
serde_json::json!({"offset":9_007_199_254_740_991i64}),
|
||||
serde_json::json!({"file_path":"elsewhere"}),
|
||||
] {
|
||||
assert!(
|
||||
matches!(text_result("read_lines",&args(value),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments)
|
||||
);
|
||||
}
|
||||
for pattern in [String::new(), "x".repeat(4097)] {
|
||||
assert!(
|
||||
matches!(text_result("search_text",&args(serde_json::json!({"pattern":pattern})),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments)
|
||||
);
|
||||
}
|
||||
for (operation, arguments, source) in [
|
||||
(
|
||||
"read_lines",
|
||||
serde_json::json!({}),
|
||||
"雪".repeat(MAX_BYTES / 3 + 1),
|
||||
),
|
||||
("read_lines", serde_json::json!({}), "\u{1}".repeat(16000)),
|
||||
(
|
||||
"search_text",
|
||||
serde_json::json!({"pattern":"x"}),
|
||||
"x\n".repeat(MAX_LINES),
|
||||
),
|
||||
(
|
||||
"search_text",
|
||||
serde_json::json!({"pattern":"\""}),
|
||||
format!("{}\n", "\"".repeat(40000)),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
matches!(text_result(operation,&args(arguments),&source),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ResourceLimitExceeded),
|
||||
"{operation}: {} source bytes",
|
||||
source.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds reserve room for base64 expansion, JSON encoding, and exact lossless integer offsets.
|
||||
pub(crate) fn byte_range(
|
||||
args: &BTreeMap<String, Value>,
|
||||
) -> Result<(u64, usize), WipOperationError> {
|
||||
if args
|
||||
.keys()
|
||||
.any(|k| !["offset", "limit"].contains(&k.as_str()))
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"unknown byte argument",
|
||||
));
|
||||
}
|
||||
let integer = |name: &str, default: u64| match args.get(name) {
|
||||
None => Ok(default),
|
||||
Some(Value::Integer(i)) => u64::try_from(*i)
|
||||
.map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative byte range")),
|
||||
_ => Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid byte range",
|
||||
)),
|
||||
};
|
||||
let offset = integer("offset", 0)?;
|
||||
let limit = integer("limit", 16384)?;
|
||||
if offset > 9_007_199_254_708_223 || !(1..=32768).contains(&limit) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"byte range exceeds bounds",
|
||||
));
|
||||
}
|
||||
Ok((offset, limit as usize))
|
||||
}
|
||||
pub(crate) fn bytes_result(
|
||||
args: &BTreeMap<String, Value>,
|
||||
bytes: &[u8],
|
||||
eof: bool,
|
||||
) -> Result<Value, WipOperationError> {
|
||||
use base64::Engine as _;
|
||||
let (offset, limit) = byte_range(args)?;
|
||||
if bytes.len() > limit || (bytes.is_empty() && !eof) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"provider returned an invalid byte range",
|
||||
));
|
||||
}
|
||||
let value = Value::Record(BTreeMap::from([
|
||||
("encoding".into(), Value::String("base64".into())),
|
||||
(
|
||||
"data".into(),
|
||||
Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||
),
|
||||
("offset".into(), Value::Integer(offset as i64)),
|
||||
(
|
||||
"next_offset".into(),
|
||||
Value::Integer((offset + bytes.len() as u64) as i64),
|
||||
),
|
||||
("eof".into(), Value::Boolean(eof)),
|
||||
]));
|
||||
let json = crate::wip::wip_to_json(&value)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?;
|
||||
if serde_json::to_vec_pretty(&json)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?
|
||||
.len()
|
||||
> MAX_BYTES
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"encoded byte result exceeds bound",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod byte_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maximum_raw_byte_page_reserves_base64_and_complete_json_budget() {
|
||||
let args = BTreeMap::from([("limit".into(), Value::Integer(32768))]);
|
||||
let result = bytes_result(&args, &vec![0xff; 32768], false)
|
||||
.unwrap_or_else(|_| panic!("valid bounded bytes"));
|
||||
let json = crate::wip::wip_to_json(&result).unwrap();
|
||||
assert!(serde_json::to_vec_pretty(&json).unwrap().len() <= MAX_BYTES);
|
||||
assert_eq!(json["next_offset"], 32768);
|
||||
assert_eq!(json["eof"], false);
|
||||
assert!(
|
||||
matches!(bytes_result(&args,&vec![0;32769],false),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::Internal)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ mod worker;
|
||||
|
||||
pub use bootstrap::{
|
||||
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
|
||||
WorkerBootstrapLayout, bash_output_dir_for_worker_id, start_worker_controller,
|
||||
WorkerBootstrapLayout, start_worker_controller,
|
||||
};
|
||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
||||
|
||||
@@ -88,7 +88,7 @@ impl InternalSpawnedWorkerRecord {
|
||||
pub(crate) fn new(
|
||||
worker_name: String,
|
||||
scope_delegated: Vec<ScopeRule>,
|
||||
workdir_tool_scope: WorkdirScopeLeaseSet,
|
||||
workdir_tool_scope: Arc<WorkdirScopeLeaseSet>,
|
||||
#[cfg(test)] installed_tools: Vec<String>,
|
||||
session: InternalWorkerSessionHandle,
|
||||
child_registry: Arc<SpawnedWorkerRegistry>,
|
||||
@@ -97,7 +97,7 @@ impl InternalSpawnedWorkerRecord {
|
||||
Self {
|
||||
worker_name: worker_name.clone(),
|
||||
scope_delegated,
|
||||
workdir_tool_scope: Arc::new(workdir_tool_scope),
|
||||
workdir_tool_scope,
|
||||
#[cfg(test)]
|
||||
installed_tools: installed_tools.into(),
|
||||
published: Arc::new(super::published::PublishedProtocol::new(
|
||||
@@ -329,6 +329,7 @@ pub struct SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify,
|
||||
internal_spawn_cleanup_failed: AtomicBool,
|
||||
parent_scope: Option<SharedScope>,
|
||||
bash_output: Mutex<tools::bash_output::BashOutputView>,
|
||||
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
||||
protocol_publish_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
@@ -340,6 +341,14 @@ pub struct SpawnedWorkerRegistryLoad {
|
||||
}
|
||||
|
||||
impl SpawnedWorkerRegistry {
|
||||
pub(crate) fn bash_output(&self) -> tools::bash_output::BashOutputView {
|
||||
self.bash_output.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_bash_output(&self, view: tools::bash_output::BashOutputView) {
|
||||
*self.bash_output.lock().unwrap() = view;
|
||||
}
|
||||
|
||||
pub(crate) fn new_for_internal_services() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||
@@ -352,6 +361,7 @@ impl SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify::new(),
|
||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||
parent_scope: None,
|
||||
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||
parent_protocol: Mutex::new(None),
|
||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||
})
|
||||
@@ -370,6 +380,7 @@ impl SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify::new(),
|
||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||
parent_scope: None,
|
||||
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||
parent_protocol: Mutex::new(None),
|
||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||
})
|
||||
@@ -387,6 +398,7 @@ impl SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify::new(),
|
||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||
parent_scope: Some(parent_scope),
|
||||
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||
parent_protocol: Mutex::new(None),
|
||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||
})
|
||||
@@ -473,6 +485,7 @@ impl SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify::new(),
|
||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||
parent_scope,
|
||||
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||
parent_protocol: Mutex::new(None),
|
||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||
}),
|
||||
@@ -966,6 +979,10 @@ impl SpawnedWorkerRegistry {
|
||||
"an in-flight Internal SubWorker failed cleanup during shutdown",
|
||||
));
|
||||
}
|
||||
let output = self.bash_output();
|
||||
if !output.is_root() {
|
||||
output.revoke();
|
||||
}
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
@@ -1111,6 +1128,7 @@ fn internal_worker_live_snapshot(snapshot: InternalWorkerSnapshot) -> Option<Eve
|
||||
|
||||
impl Drop for SpawnedWorkerRegistry {
|
||||
fn drop(&mut self) {
|
||||
self.bash_output.get_mut().unwrap().revoke();
|
||||
let Some(parent_scope) = &self.parent_scope else {
|
||||
return;
|
||||
};
|
||||
@@ -1297,11 +1315,13 @@ mod tests {
|
||||
InternalSpawnedWorkerRecord::new(
|
||||
name.into(),
|
||||
Vec::new(),
|
||||
workdir::WorkdirScopeLeaseSet::from_single(
|
||||
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
|
||||
delegation,
|
||||
)
|
||||
.unwrap(),
|
||||
Arc::new(
|
||||
workdir::WorkdirScopeLeaseSet::from_single(
|
||||
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
|
||||
delegation,
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
Vec::new(),
|
||||
session,
|
||||
registry(),
|
||||
|
||||
@@ -310,7 +310,7 @@ pub struct SubWorkerSpawnTool {
|
||||
/// Runtime-owned root used for Internal Worker controller state.
|
||||
runtime_base: PathBuf,
|
||||
/// Parent Worker-owned temporary root used for bounded Bash spill output.
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
|
||||
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
||||
workspace_root: PathBuf,
|
||||
@@ -345,7 +345,7 @@ impl SubWorkerSpawnTool {
|
||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||
parent_notifications: ParentNotificationTarget,
|
||||
runtime_base: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
workspace_root: PathBuf,
|
||||
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
@@ -358,7 +358,7 @@ impl SubWorkerSpawnTool {
|
||||
workspace_context,
|
||||
parent_notifications,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
|
||||
workspace_root,
|
||||
workdir_tool_broker,
|
||||
registry,
|
||||
@@ -430,15 +430,6 @@ impl Tool for SubWorkerSpawnTool {
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
|
||||
let workdir_rules = parse_workdir_scope(&input.scope)?;
|
||||
let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name);
|
||||
tokio::fs::create_dir_all(&child_bash_output_dir)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"create Internal Worker Bash output directory {}: {error}",
|
||||
child_bash_output_dir.display()
|
||||
))
|
||||
})?;
|
||||
let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?;
|
||||
let tool_scopes = routed_workdir_tool_scopes(
|
||||
workdir_tool_broker,
|
||||
@@ -452,6 +443,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
.map_err(|error| {
|
||||
ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}"))
|
||||
})?;
|
||||
let workdir_scope = Arc::new(workdir_scope);
|
||||
let child_workdir_tool_broker = workdir_scope.broker_router();
|
||||
|
||||
let spawn_selector =
|
||||
@@ -552,24 +544,17 @@ impl Tool for SubWorkerSpawnTool {
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
||||
child
|
||||
.add_scope_rules([ScopeRule {
|
||||
target: child_bash_output_dir.clone(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"grant Internal Worker Bash output scope: {error}"
|
||||
))
|
||||
})?;
|
||||
let child_scope = child.scope().clone();
|
||||
child.bind_workdir_sessions(workdir_scope.session_router());
|
||||
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
|
||||
let output_lease = workdir_scope.clone();
|
||||
child_registry.inherit_bash_output(
|
||||
self.registry
|
||||
.bash_output()
|
||||
.child(Arc::new(move || output_lease.is_active())),
|
||||
);
|
||||
register_worker_tools(
|
||||
&mut child,
|
||||
child_bash_output_dir,
|
||||
self.runtime_base.clone(),
|
||||
child_registry.clone(),
|
||||
None,
|
||||
@@ -1052,7 +1037,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||
parent_notifications: ParentNotificationTarget,
|
||||
runtime_base: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
workspace_root: PathBuf,
|
||||
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
@@ -1064,7 +1049,6 @@ pub(crate) fn sub_worker_spawn_tool(
|
||||
workspace_context,
|
||||
parent_notifications,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
workspace_root,
|
||||
workdir_tool_broker,
|
||||
registry,
|
||||
@@ -1078,7 +1062,7 @@ fn sub_worker_spawn_tool_impl(
|
||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||
parent_notifications: ParentNotificationTarget,
|
||||
runtime_base: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
workspace_root: PathBuf,
|
||||
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
@@ -1110,7 +1094,6 @@ fn sub_worker_spawn_tool_impl(
|
||||
workspace_context.clone(),
|
||||
parent_notifications.clone(),
|
||||
runtime_base.clone(),
|
||||
bash_output_dir.clone(),
|
||||
workspace_root.clone(),
|
||||
workdir_tool_broker.clone(),
|
||||
registry.clone(),
|
||||
@@ -1374,7 +1357,6 @@ enabled = false
|
||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||
),
|
||||
runtime.path().to_path_buf(),
|
||||
bash_output_dir.clone(),
|
||||
workspace_root.clone(),
|
||||
Some(workdir_tool_broker),
|
||||
registry.clone(),
|
||||
@@ -1425,12 +1407,7 @@ enabled = false
|
||||
.await
|
||||
.expect("spawn project reviewer as Internal Worker");
|
||||
assert!(output.summary.contains("internal worker `reviewer-child`"));
|
||||
assert!(
|
||||
bash_output_dir
|
||||
.join("sub-workers")
|
||||
.join("reviewer-child")
|
||||
.is_dir()
|
||||
);
|
||||
assert!(!bash_output_dir.join("sub-workers").exists());
|
||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||
let record = registry
|
||||
.get_internal("reviewer-child")
|
||||
@@ -1460,6 +1437,27 @@ enabled = false
|
||||
record.session.wait_until_idle().await,
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
||||
);
|
||||
let child_output_view = record.child_registry.bash_output();
|
||||
let child_bash = tools::BashTool::new(
|
||||
record.workdir_tool_scope.session_router(),
|
||||
child_output_view.clone(),
|
||||
);
|
||||
let child_output = child_bash
|
||||
.execute_result(
|
||||
r#"{"command":"printf retained-child-evidence","cwd":"."}"#,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let output_id = child_output.metadata.id.clone();
|
||||
assert!(child_output_view.metadata(&output_id).is_some());
|
||||
assert_eq!(
|
||||
registry
|
||||
.bash_output()
|
||||
.read(&output_id, workdir::CommandStream::Stdout, 0, 65536)
|
||||
.unwrap(),
|
||||
b"retained-child-evidence"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(!observed_parent_write_revoked.load(Ordering::SeqCst));
|
||||
assert!(observed_instruction_override.load(Ordering::SeqCst));
|
||||
@@ -1563,6 +1561,8 @@ enabled = false
|
||||
!record.workdir_tool_scope.is_active(),
|
||||
"stopped child must revoke cloned scoped sessions"
|
||||
);
|
||||
assert!(child_output_view.metadata(&output_id).is_none());
|
||||
assert!(registry.bash_output().metadata(&output_id).is_some());
|
||||
assert!(registry.get_internal("reviewer-child").is_some());
|
||||
|
||||
assert!(
|
||||
@@ -1573,6 +1573,7 @@ enabled = false
|
||||
.is_some()
|
||||
);
|
||||
assert!(registry.get_internal("reviewer-child").is_none());
|
||||
assert!(registry.bash_output().metadata(&output_id).is_some());
|
||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||
|
||||
fail_requests.store(false, Ordering::SeqCst);
|
||||
@@ -1672,7 +1673,6 @@ enabled = false
|
||||
),
|
||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||
runtime.path().to_path_buf(),
|
||||
bash_output_dir,
|
||||
workspace_root.clone(),
|
||||
Some(workdir_tool_broker),
|
||||
registry.clone(),
|
||||
@@ -1805,7 +1805,6 @@ enabled = false
|
||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||
),
|
||||
runtime.path().to_path_buf(),
|
||||
bash_output_dir.clone(),
|
||||
workspace_root.clone(),
|
||||
Some(workdir_tool_broker),
|
||||
registry.clone(),
|
||||
|
||||
@@ -943,7 +943,6 @@ enabled = true
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash-output"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
@@ -1137,7 +1136,6 @@ enabled = true
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
@@ -1319,7 +1317,6 @@ enabled = true
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
@@ -1379,7 +1376,6 @@ enabled = true
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash-output"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
|
||||
@@ -418,13 +418,22 @@ struct MountedProjection {
|
||||
impl MountedProjection {
|
||||
fn resolved(&self) -> WipProjection {
|
||||
let mut projection = self.projection.clone();
|
||||
let mut handlers: BTreeMap<_, _> = self
|
||||
.operation_handlers
|
||||
.iter()
|
||||
.map(|(op, (_, h))| (op.clone(), h.clone()))
|
||||
.collect();
|
||||
for reference in &projection.object.interfaces {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(reference) {
|
||||
handlers.insert(
|
||||
contract.handler_operation().into(),
|
||||
self.projection.handler.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
projection.handler = Arc::new(OperationDispatchHandler {
|
||||
owner: Arc::clone(&self.projection.handler),
|
||||
handlers: self
|
||||
.operation_handlers
|
||||
.iter()
|
||||
.map(|(operation, (_, handler))| (operation.clone(), Arc::clone(handler)))
|
||||
.collect(),
|
||||
handlers,
|
||||
});
|
||||
projection
|
||||
}
|
||||
@@ -1017,6 +1026,10 @@ impl WipMountRegistry {
|
||||
descriptor: &InterfaceDescriptor,
|
||||
validator: Option<&[u8]>,
|
||||
) -> bool {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(interface) {
|
||||
return *descriptor != contract.descriptor()
|
||||
|| validator != Some(contract.validator().as_slice());
|
||||
}
|
||||
self.mounts.values().any(|mounted| {
|
||||
mounted.projection.interface == *interface
|
||||
&& (mounted.projection.descriptor != *descriptor
|
||||
@@ -1082,6 +1095,10 @@ impl WipMountRegistry {
|
||||
validator: Option<&[u8]>,
|
||||
replacing_route: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(interface) {
|
||||
return *descriptor != contract.descriptor()
|
||||
|| validator != Some(contract.validator().as_slice());
|
||||
}
|
||||
self.additional_interfaces.iter().any(|(route, all)| {
|
||||
Some(route.as_str()) != replacing_route
|
||||
&& all.iter().any(|p| {
|
||||
@@ -1188,10 +1205,24 @@ fn validate_projection(projection: &WipProjection) -> Result<(), WipMountError>
|
||||
route: projection.route.clone(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
if projection.object.interfaces != [projection.interface.clone()] {
|
||||
if !projection.object.interfaces.contains(&projection.interface)
|
||||
|| projection.object.interfaces.iter().any(|reference| {
|
||||
*reference != projection.interface
|
||||
&& crate::file_operation::Contract::from_reference(reference).is_none()
|
||||
})
|
||||
{
|
||||
return Err(WipMountError::InvalidProjection {
|
||||
route: projection.route.clone(),
|
||||
message: "projected object must expose exactly its declared interface".into(),
|
||||
message: "projected object must expose its declared interface and only known common contracts".into(),
|
||||
});
|
||||
}
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(&projection.interface)
|
||||
&& (projection.descriptor != contract.descriptor()
|
||||
|| projection.interface_validator.as_deref() != Some(contract.validator().as_slice()))
|
||||
{
|
||||
return Err(WipMountError::InvalidProjection {
|
||||
route: projection.route.clone(),
|
||||
message: "common contract descriptor/validator differs from its definition".into(),
|
||||
});
|
||||
}
|
||||
projection
|
||||
@@ -1304,9 +1335,13 @@ impl WipHost {
|
||||
path: &str,
|
||||
reference: &InterfaceReference,
|
||||
) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let Some(owner) = self.publication_live(path).await? else {
|
||||
let Some(mut owner) = self.publication_live(path).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if crate::file_operation::select(&mut owner.projection, reference) {
|
||||
owner.scope = self.object_at(&reference.scope);
|
||||
return Ok(Some(owner));
|
||||
}
|
||||
// A live provider owns all its publication metadata; static additional
|
||||
// Interface registrations must not survive its scope lifetime changes.
|
||||
if owner.projection.interface == *reference || self.subtree(path).is_some() {
|
||||
@@ -1333,6 +1368,20 @@ impl WipHost {
|
||||
reference
|
||||
.validate()
|
||||
.map_err(|e| protocol_error(ProtocolErrorCode::InvalidRequest, e.to_string()))?;
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(reference) {
|
||||
let scope = self.object_at("/").ok_or_else(|| {
|
||||
protocol_error(
|
||||
ProtocolErrorCode::InterfaceNotFound,
|
||||
"root scope unavailable",
|
||||
)
|
||||
})?;
|
||||
return Ok(FetchInterfaceResponse {
|
||||
interface: reference.clone(),
|
||||
scope_ref: scope.r#ref,
|
||||
descriptor: contract.descriptor(),
|
||||
validator: Some(contract.validator()),
|
||||
});
|
||||
}
|
||||
let target = if let Some(mount) = self.subtree(&reference.scope) {
|
||||
let target = mount.provider.interface_target(reference).ok_or_else(|| {
|
||||
protocol_error(
|
||||
@@ -1496,6 +1545,9 @@ impl WipHost {
|
||||
&self,
|
||||
reference: &InterfaceReference,
|
||||
) -> Option<(InterfaceDescriptor, Option<Vec<u8>>)> {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(reference) {
|
||||
return Some((contract.descriptor(), Some(contract.validator())));
|
||||
}
|
||||
for (path, all) in &self.registry.additional_interfaces {
|
||||
if self.projection(path).is_none() {
|
||||
continue;
|
||||
@@ -1560,6 +1612,18 @@ impl WipHost {
|
||||
if let Some(validator) = projection.handler.object_validator() {
|
||||
projection.object.validator = Some(validator);
|
||||
}
|
||||
if let Some(contract) =
|
||||
crate::file_operation::Contract::from_reference(&projection.interface)
|
||||
{
|
||||
if !projection
|
||||
.handler
|
||||
.operation_available(contract.handler_operation())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Common descriptor identity never varies with subject or capability filtering.
|
||||
return Some(projection);
|
||||
}
|
||||
let contextual = contextual_family || projection.handler.contextual_interface();
|
||||
let original_len = projection.descriptor.operations.len();
|
||||
projection
|
||||
@@ -7058,3 +7122,6 @@ mod tests {
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bash_output_tests;
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
//! Native Bash output integration through real WIP Client/Host codec and local process pipes.
|
||||
//! No telemetry/storage fake: only the model client is absent. Runtime HTTP transport is tested
|
||||
//! by worker-runtime's command_transport_tests. Each fixture owns its Workdir and ephemeral view.
|
||||
use super::*;
|
||||
use crate::file_operation::Contract;
|
||||
use tempfile::TempDir;
|
||||
use tools::bash_output::BashOutputView;
|
||||
use workdir::{
|
||||
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
_dir: TempDir,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
view: BashOutputView,
|
||||
runtime: WipRuntime,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("real.txt"), "first\n雪 needle\nlast\n").unwrap();
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().into(),
|
||||
));
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), session)
|
||||
.unwrap();
|
||||
let view = BashOutputView::new();
|
||||
let runtime = runtime(router.clone(), view.clone());
|
||||
Self {
|
||||
_dir: dir,
|
||||
router,
|
||||
view,
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
async fn run(&self, command: &str) -> Json {
|
||||
value(
|
||||
self.runtime
|
||||
.invoke(
|
||||
"/bash-output".into(),
|
||||
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||
"run".into(),
|
||||
json!({"input":{"command":command,"cwd":"."}}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fn runtime(router: Arc<WorkdirSessionRouter>, view: BashOutputView) -> WipRuntime {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
||||
.unwrap();
|
||||
crate::feature::builtin::bash::mount(&mut registry, router, view, None).unwrap();
|
||||
WipRuntime::new(WipHost::new(registry), SecurityContext::new("bash-test"), 0).unwrap()
|
||||
}
|
||||
fn value(output: ToolOutput) -> Json {
|
||||
serde_json::from_str(&output.content.unwrap()).unwrap()
|
||||
}
|
||||
async fn invoke(
|
||||
runtime: &WipRuntime,
|
||||
path: &str,
|
||||
contract: Contract,
|
||||
operation: &str,
|
||||
args: Json,
|
||||
) -> Result<Json, ToolError> {
|
||||
runtime
|
||||
.invoke(
|
||||
path.into(),
|
||||
contract.reference(),
|
||||
operation.into(),
|
||||
args,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.map(value)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_bash_entries_reuse_checkout_contract_for_direct_inspect_read_search() {
|
||||
let f = Fixture::new();
|
||||
let result = f
|
||||
.run("printf 'first\n雪 needle\nlast\n'; printf error >&2; exit 3")
|
||||
.await;
|
||||
assert_eq!(result["metadata"]["terminal"]["exit_code"], 3);
|
||||
let stdout = result["stdout_entry"].as_str().unwrap();
|
||||
assert!(stdout.starts_with("/bash-output/"));
|
||||
// The native result Descriptor gives these coordinates TypeExpr::Entry; Object inspection
|
||||
// contains capabilities and metadata, not the process output body.
|
||||
let projection = f
|
||||
.runtime
|
||||
.host
|
||||
.projection_live("/bash-output")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let TypeExpr::Record { fields } = &projection.descriptor.operations[0].returns.r#type else {
|
||||
panic!("native record");
|
||||
};
|
||||
assert_eq!(
|
||||
fields
|
||||
.iter()
|
||||
.find(|v| v.name == "stdout_entry")
|
||||
.unwrap()
|
||||
.r#type,
|
||||
TypeExpr::Entry
|
||||
);
|
||||
let inspect = f.runtime.inspect(stdout.into(), false).await.unwrap();
|
||||
assert!(!inspect.content.unwrap().contains("雪 needle"));
|
||||
for path in ["/checkouts/main/real.txt", stdout] {
|
||||
let read = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadLines,
|
||||
"read",
|
||||
json!({"offset":1,"limit":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read["content"].as_str().unwrap().contains("雪 needle"));
|
||||
assert_eq!(read["next_offset"], 2);
|
||||
let search = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::SearchText,
|
||||
"search",
|
||||
json!({"offset":0,"limit":2,"pattern":"needle"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(search["matches"][0]["line"], 2);
|
||||
assert_eq!(search["next_offset"], 2);
|
||||
assert_eq!(search["truncated"], true);
|
||||
}
|
||||
for name in ["stdout_entry", "stderr_entry", "order_entry"] {
|
||||
assert_eq!(
|
||||
fields.iter().find(|v| v.name == name).unwrap().r#type,
|
||||
TypeExpr::Entry
|
||||
);
|
||||
f.runtime
|
||||
.inspect(result[name].as_str().unwrap().into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let stderr = invoke(
|
||||
&f.runtime,
|
||||
result["stderr_entry"].as_str().unwrap(),
|
||||
Contract::ReadLines,
|
||||
"read",
|
||||
json!({}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stderr["content"].as_str().unwrap().contains("error"));
|
||||
let order = invoke(
|
||||
&f.runtime,
|
||||
result["order_entry"].as_str().unwrap(),
|
||||
Contract::ReadLines,
|
||||
"read",
|
||||
json!({}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(order["content"].as_str().unwrap().contains("Stdout"));
|
||||
assert!(order["content"].as_str().unwrap().contains("Stderr"));
|
||||
assert_eq!(result["metadata"]["ordering"], "provider_read_observation");
|
||||
let tree = value(
|
||||
f.runtime
|
||||
.tree("/bash-output".into(), 8, true)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(!tree.to_string().contains(stdout));
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
stdout,
|
||||
Contract::Write,
|
||||
"write",
|
||||
json!({"content":"no"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
stdout,
|
||||
Contract::Edit,
|
||||
"edit",
|
||||
json!({"old_string":"first","new_string":"no"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_byte_reader_handles_long_lines_raw_invalid_utf8_boundaries_and_eof() {
|
||||
let f = Fixture::new();
|
||||
let result = f.run("printf '%100000s' ''; printf '€\\377'").await;
|
||||
let path = result["stdout_entry"].as_str().unwrap();
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadLines,
|
||||
"read",
|
||||
json!({"limit":1})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
for (offset, expected) in [
|
||||
(99999, vec![b' ', 0xe2]),
|
||||
(100001, vec![0x82, 0xac]),
|
||||
(100003, vec![0xff]),
|
||||
] {
|
||||
let read = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"offset":offset,"limit":2}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(read["data"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(read["next_offset"], offset + expected.len() as u64);
|
||||
}
|
||||
let eof = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"offset":100004,"limit":2}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(eof["data"], "");
|
||||
assert_eq!(eof["eof"], true);
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"offset":100005})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"limit":32769})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publication_and_invoke_recheck_child_authority_and_root_teardown_invalidates_old_refs() {
|
||||
let f = Fixture::new();
|
||||
let lease = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
let child = f.view.child({
|
||||
let lease = lease.clone();
|
||||
Arc::new(move || lease.load(std::sync::atomic::Ordering::Acquire))
|
||||
});
|
||||
let child_runtime = runtime(f.router.clone(), child.clone());
|
||||
let result = value(
|
||||
child_runtime
|
||||
.invoke(
|
||||
"/bash-output".into(),
|
||||
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||
"run".into(),
|
||||
json!({"input":{"command":"printf child-evidence","cwd":"."}}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let path = result["stdout_entry"].as_str().unwrap();
|
||||
child_runtime.inspect(path.into(), false).await.unwrap();
|
||||
let sibling = runtime(f.router.clone(), f.view.child(Arc::new(|| true)));
|
||||
let unrelated = runtime(f.router.clone(), BashOutputView::new());
|
||||
assert!(sibling.inspect(path.into(), false).await.is_err());
|
||||
assert!(unrelated.inspect(path.into(), false).await.is_err());
|
||||
lease.store(false, std::sync::atomic::Ordering::Release);
|
||||
assert!(
|
||||
invoke(&child_runtime, path, Contract::ReadBytes, "read", json!({}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(
|
||||
&child_runtime,
|
||||
path,
|
||||
Contract::SearchText,
|
||||
"search",
|
||||
json!({"pattern":"child"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({}))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
f.view.revoke();
|
||||
assert!(
|
||||
invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let restored = runtime(f.router.clone(), BashOutputView::new());
|
||||
assert!(restored.inspect(path.into(), false).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_timeout_and_cancellation_keep_terminal_evidence_instead_of_success() {
|
||||
let f = Fixture::new();
|
||||
let session = f.router.only_session().unwrap();
|
||||
let mut events = session.subscribe_command_events().unwrap();
|
||||
let runtime = Arc::new(f.runtime);
|
||||
let context = agen::tool::ToolExecutionContext::new("native-cancel", "native-attempt", 0);
|
||||
let running = runtime.clone();
|
||||
let executing_context = context.clone();
|
||||
let execution = tokio::spawn(async move {
|
||||
running
|
||||
.invoke(
|
||||
"/bash-output".into(),
|
||||
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||
"run".into(),
|
||||
json!({"input":{"command":"printf before; while :; do :; done","cwd":"."}}),
|
||||
executing_context,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if matches!(
|
||||
events.recv().await.unwrap(),
|
||||
workdir::CommandEvent::Output { .. }
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
runtime.cancel(&context).await.unwrap();
|
||||
let ToolError::Cancelled(output) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), execution)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap_err()
|
||||
else {
|
||||
panic!("expected cancelled terminal output")
|
||||
};
|
||||
let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
let id = body["output"]["id"].as_str().unwrap();
|
||||
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||
assert_eq!(body["output"]["terminal"]["status"], "cancelled");
|
||||
assert_eq!(
|
||||
f.view
|
||||
.read(id, workdir::CommandStream::Stdout, 0, 20)
|
||||
.unwrap(),
|
||||
b"before"
|
||||
);
|
||||
assert!(
|
||||
invoke(
|
||||
&runtime,
|
||||
body["stdout_entry"].as_str().unwrap(),
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({})
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
let error = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf timeout; while :; do :; done","cwd":".","timeout":1}}), Default::default()).await.unwrap_err();
|
||||
let ToolError::Interrupted(output) = error else {
|
||||
panic!("expected timeout evidence")
|
||||
};
|
||||
let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
assert_eq!(body["output"]["terminal"]["status"], "timed_out");
|
||||
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||
assert!(
|
||||
f.view
|
||||
.metadata(body["output"]["id"].as_str().unwrap())
|
||||
.is_some()
|
||||
);
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_read_and_search_permissions_are_independent_from_bash_execution() {
|
||||
let f = Fixture::new();
|
||||
let result = f.run("printf needle").await;
|
||||
let path = result["stdout_entry"].as_str().unwrap();
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::feature::builtin::bash::mount(
|
||||
&mut registry,
|
||||
f.router.clone(),
|
||||
f.view.clone(),
|
||||
Some(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Deny,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "Grep".into(),
|
||||
pattern: "*".into(),
|
||||
action: ToolPermissionAction::Allow,
|
||||
}],
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let restricted = WipRuntime::new(
|
||||
WipHost::new(registry),
|
||||
SecurityContext::new("restricted"),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
restricted.inspect(path.into(), false).await.unwrap();
|
||||
assert!(
|
||||
invoke(&restricted, path, Contract::ReadBytes, "read", json!({}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(&restricted, path, Contract::ReadLines, "read", json!({}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let found = invoke(
|
||||
&restricted,
|
||||
path,
|
||||
Contract::SearchText,
|
||||
"search",
|
||||
json!({"pattern":"needle"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(found["matches"].as_array().unwrap().len() == 1);
|
||||
assert!(
|
||||
restricted
|
||||
.invoke(
|
||||
"/bash-output".into(),
|
||||
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||
"run".into(),
|
||||
json!({"input":{"command":"touch denied","cwd":"."}}),
|
||||
Default::default()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!f._dir.path().join("denied").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multibyte_long_line_search_refuses_instead_of_skipping_an_unread_suffix() {
|
||||
let f = Fixture::new();
|
||||
let result = f
|
||||
.run("printf '雪%.0s' {1..22000}; printf 'needle\nnext\n'")
|
||||
.await;
|
||||
let path = result["stdout_entry"].as_str().unwrap();
|
||||
let error = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::SearchText,
|
||||
"search",
|
||||
json!({"pattern":"needle","offset":0,"limit":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("ResourceLimitExceeded"),
|
||||
"{error}"
|
||||
);
|
||||
// No successful page claims to advance past a partial line. Raw bytes can still
|
||||
// retrieve its unread suffix using the same common file contract.
|
||||
let suffix = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"offset":66000,"limit":7}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(suffix["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
b"needle\n"
|
||||
);
|
||||
assert_eq!(suffix["next_offset"], 66007);
|
||||
}
|
||||
@@ -17552,12 +17552,10 @@ permission = "write"
|
||||
let layout = if runtime_managed {
|
||||
crate::WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: temp.path().join("runtime/runs/1"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
}
|
||||
} else {
|
||||
crate::WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
}
|
||||
};
|
||||
crate::WorkerBootstrap::new(
|
||||
|
||||
@@ -1496,11 +1496,9 @@ async fn post_compact_pause_bills_partial_without_rearming_or_consuming_correlat
|
||||
let session_id = worker.session_id();
|
||||
|
||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, shutdown_receiver) =
|
||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
handle
|
||||
.send(Method::submit_text(
|
||||
@@ -1706,11 +1704,9 @@ async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() {
|
||||
let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await;
|
||||
let session_id = worker.session_id();
|
||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, shutdown_receiver) =
|
||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
|
||||
handle
|
||||
@@ -1831,11 +1827,9 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
||||
let source_session = worker.session_id();
|
||||
let source_segment = worker.segment_id();
|
||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, shutdown_receiver) =
|
||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
|
||||
handle
|
||||
@@ -1997,8 +1991,7 @@ async fn controller_compact_method_publishes_progress_and_clear() {
|
||||
]);
|
||||
let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
|
||||
@@ -311,8 +311,7 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> WorkerHandle
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let runtime_base = tmp.path().to_owned();
|
||||
std::mem::forget(tmp);
|
||||
let bash_output_dir = runtime_base.join("bash-output");
|
||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base, &bash_output_dir)
|
||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
|
||||
.await
|
||||
.unwrap();
|
||||
handle
|
||||
@@ -342,13 +341,9 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(
|
||||
worker,
|
||||
&root.path().join("runtime"),
|
||||
&root.path().join("bash-output"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, &root.path().join("runtime"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
let file = handle
|
||||
.upload_file_with_context(
|
||||
@@ -408,24 +403,20 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_grants_read_scope_for_exact_bash_output_directory() {
|
||||
async fn controller_does_not_grant_or_create_a_model_visible_bash_output_directory() {
|
||||
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||
let shared_scope = worker.scope().clone();
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let worker_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = worker_tmp.path().join("worker-1").join("bash-output");
|
||||
let original_rules = shared_scope.snapshot().allow_rules().to_vec();
|
||||
|
||||
let (handle, shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(bash_output_dir.is_dir());
|
||||
assert!(shared_scope.snapshot().allow_rules().iter().any(|rule| {
|
||||
rule.target == bash_output_dir
|
||||
&& rule.permission == manifest::Permission::Read
|
||||
&& rule.recursive
|
||||
}));
|
||||
assert_eq!(
|
||||
shared_scope.snapshot().allow_rules(),
|
||||
original_rules.as_slice()
|
||||
);
|
||||
assert!(!handle.runtime_dir.path().join("bash-output").exists());
|
||||
|
||||
handle
|
||||
@@ -451,9 +442,9 @@ async fn shutdown_closes_bound_workdir_session() {
|
||||
.start_command(CommandRequest {
|
||||
command: "sleep 30".to_owned(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
@@ -461,11 +452,9 @@ async fn shutdown_closes_bound_workdir_session() {
|
||||
worker.bind_single_workdir_session(Some(Arc::clone(&session)));
|
||||
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||
let (handle, shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||
.await
|
||||
.unwrap();
|
||||
handle
|
||||
.send(Method::Shutdown {
|
||||
command: worker_command(&handle),
|
||||
@@ -501,9 +490,9 @@ async fn controller_filters_workdir_command_not_owned_by_worker() {
|
||||
.start_command(CommandRequest {
|
||||
command: "sleep 0.2".to_owned(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: Some("foreign-tool-call".into()),
|
||||
})
|
||||
.await
|
||||
@@ -529,7 +518,10 @@ async fn controller_filters_workdir_command_not_owned_by_worker() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.status, workdir::CommandStatus::Completed);
|
||||
assert_eq!(
|
||||
output.terminal.unwrap().status,
|
||||
workdir::CommandStatus::Completed
|
||||
);
|
||||
handle
|
||||
.send(Method::Shutdown {
|
||||
command: worker_command(&handle),
|
||||
@@ -738,9 +730,8 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
||||
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
|
||||
std::fs::write(&invalid_runtime_base, "file").unwrap();
|
||||
|
||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||
assert!(
|
||||
WorkerController::spawn(worker, &invalid_runtime_base, &bash_output_dir)
|
||||
WorkerController::spawn(worker, &invalid_runtime_base)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -749,9 +740,9 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
||||
.start_command(CommandRequest {
|
||||
command: "printf unreachable".to_owned(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await,
|
||||
@@ -1553,8 +1544,7 @@ permission = "write"
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = tmp.path().join("bash-output");
|
||||
let result = WorkerController::spawn(worker, tmp.path(), &bash_output_dir).await;
|
||||
let result = WorkerController::spawn(worker, tmp.path()).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"feature exposure must not imply delegation authority"
|
||||
@@ -2927,11 +2917,9 @@ async fn status_json_reflects_worker_name() {
|
||||
async fn shutdown_closes_method_admission_before_terminal_confirmation() {
|
||||
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||
let (handle, mut shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, mut shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||
.await
|
||||
.unwrap();
|
||||
handle
|
||||
.send(Method::Shutdown {
|
||||
command: worker_command(&handle),
|
||||
@@ -2966,11 +2954,9 @@ async fn shutdown_joins_socket_server_with_active_connection() {
|
||||
|
||||
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||
let (handle, shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let socket_path = handle.runtime_dir.socket_path();
|
||||
let _connection = UnixStream::connect(&socket_path).await.unwrap();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user