chore: connect shared file contracts for Bash validation

This commit is contained in:
2026-10-20 03:03:53 +09:00
26 changed files with 2586 additions and 275 deletions
+10
View File
@@ -134,6 +134,14 @@ pub enum DriveEntryKind {
File,
}
/// Current caller's advisory publication ceiling, never a grant or execution credential.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct DriveCapabilities {
pub write: bool,
}
/// Metadata only: content is returned through bounded text or binary routes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -491,6 +499,7 @@ impl api_macros::HttpRequestError for DriveApiError {
impl api_macros::openapi::OpenApiSchema for DriveApiError {}
impl api_macros::openapi::OpenApiSchema for DriveEntry {}
impl api_macros::openapi::OpenApiSchema for DriveCapabilities {}
impl api_macros::openapi::OpenApiSchema for DriveEntryQuery {}
impl api_macros::openapi::OpenApiSchema for DriveListQuery {}
impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {}
@@ -518,6 +527,7 @@ pub fn drive_api_typescript() -> String {
DriveEntryRef::decl(&config),
DriveEntryKind::decl(&config),
DriveEntry::decl(&config),
DriveCapabilities::decl(&config),
DriveEntryQuery::decl(&config),
DriveListQuery::decl(&config),
DriveSearchQuery::decl(&config),
+6
View File
@@ -2762,6 +2762,12 @@ pub trait ServerApi {
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<DriveEntry, DriveApiError>;
#[get("/api/w/{workspace_id}/drive/capabilities", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
async fn drive_capabilities(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<DriveCapabilities, DriveApiError>;
#[get("/api/w/{workspace_id}/drive/metadata", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
async fn drive_metadata(
&self,
+68
View File
@@ -403,3 +403,71 @@ fn check_pattern(pattern: &str) -> Result<(), ToolError> {
#[cfg(test)]
#[path = "checkout_tests.rs"]
mod tests;
/// Read a bounded native text window through the same checked target and attachment tracker.
/// Search callers do not establish the prior-Read precondition for mutations.
#[allow(clippy::too_many_arguments)]
pub async fn read_checkout_text(
router: Arc<WorkdirSessionRouter>,
tracker: Tracker,
alias: &str,
generation: u64,
path: WorkdirPath,
validator: Vec<u8>,
offset: usize,
limit: usize,
max_bytes: usize,
record_read: bool,
) -> Result<(workdir::ReadResult, Option<Vec<u8>>), ToolError> {
let selected =
crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?;
if selected.generation != generation {
return Err(stale_checkout("checkout connection changed"));
}
let tracker = tracker.scoped_attachment(&selected.alias, selected.generation);
let target = FileTarget {
session: selected.session.clone(),
path: path.clone(),
validator: Some(validator),
};
let (result, validator) = target.read(offset, limit, max_bytes).await?;
if record_read {
tracker.record_workdir_observation(&path, result.content_hash.clone(), result.total_lines);
}
Ok((result, validator))
}
/// Binary-safe checked byte window. This does not establish a text prior-Read observation.
#[allow(clippy::too_many_arguments)]
pub async fn read_checkout_bytes(
router: Arc<WorkdirSessionRouter>,
alias: &str,
generation: u64,
path: WorkdirPath,
validator: Vec<u8>,
offset: u64,
max_bytes: usize,
) -> Result<(workdir::ReadBytesResult, Vec<u8>), ToolError> {
let selected =
crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?;
if selected.generation != generation {
return Err(stale_checkout("checkout connection changed"));
}
let result = selected
.session
.checkout_execute(workdir::CheckoutRequest {
target: path.clone(),
validator,
operation: workdir::CheckoutOperation::ReadBytes { offset, max_bytes },
})
.await
.map_err(crate::file_target::checked_error)?;
match result.output {
workdir::CheckoutOutput::ReadBytes(read)
if read.path == path && read.offset == offset && read.bytes.len() <= max_bytes =>
{
Ok((read, result.observation.validator))
}
_ => Err(crate::file_target::readonly_result_error("ReadBytes")),
}
}
+6 -1
View File
@@ -48,6 +48,10 @@ pub struct CheckoutRequest {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
pub enum CheckoutOperation {
ReadBytes {
offset: u64,
max_bytes: usize,
},
Read {
offset: usize,
limit: usize,
@@ -71,7 +75,7 @@ pub enum CheckoutOperation {
impl CheckoutOperation {
pub fn capability(&self) -> WorkdirSessionCapability {
match self {
Self::Read { .. } => WorkdirSessionCapability::Read,
Self::Read { .. } | Self::ReadBytes { .. } => WorkdirSessionCapability::Read,
Self::Edit { .. } => WorkdirSessionCapability::Edit,
Self::Write { .. } | Self::Create { .. } => WorkdirSessionCapability::Write,
}
@@ -86,6 +90,7 @@ impl CheckoutOperation {
)]
pub enum CheckoutOutput {
Read(ReadResult),
ReadBytes(crate::ReadBytesResult),
Write(WriteResult),
Edit(EditResult),
}
+12
View File
@@ -186,6 +186,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
&& !request.validator.is_empty()
&& request.validator.len() <= 256
&& match &request.operation {
crate::CheckoutOperation::ReadBytes { offset, max_bytes } => {
*offset <= 9_007_199_254_740_991
&& *max_bytes > 0
&& *max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
}
crate::CheckoutOperation::Read {
limit, max_bytes, ..
} => {
@@ -375,6 +380,13 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
WorkdirSessionOperationResult::CheckoutExecute(result) => {
valid_checkout_observation(&result.observation)
&& match &result.output {
crate::CheckoutOutput::ReadBytes(read) => {
read.path == result.observation.path
&& validate_external_result(&WorkdirSessionOperationResult::ReadBytes(
read.clone(),
))
.is_ok()
}
crate::CheckoutOutput::Read(read) => {
read.path == result.observation.path
&& validate_external_result(&WorkdirSessionOperationResult::Read(
+26 -1
View File
@@ -693,6 +693,12 @@ mod client {
let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read;
let expected_path = request.target.clone();
let expected_output = request.operation.capability();
let byte_range = match &request.operation {
crate::CheckoutOperation::ReadBytes { offset, max_bytes } => {
Some((*offset, *max_bytes))
}
_ => None,
};
match self
.operate(WorkdirSessionOperation::CheckoutExecute(request))
.await
@@ -702,11 +708,30 @@ mod client {
&& !result.observation.validator.is_empty()
&& result.observation.validator.len() <= 256
&& match (&result.output, expected_output) {
(
crate::CheckoutOutput::ReadBytes(read),
crate::WorkdirSessionCapability::Read,
) => byte_range.is_some_and(|(offset, max)| {
read.path == expected_path
&& read.offset == offset
&& read.bytes.len() <= max
&& read
.offset
.checked_add(read.bytes.len() as u64)
.is_some_and(|end| {
end <= read.total_bytes
|| (read.bytes.is_empty() && read.eof)
})
&& read.eof
== (read.offset.saturating_add(read.bytes.len() as u64)
>= read.total_bytes)
}),
(
crate::CheckoutOutput::Read(read),
crate::WorkdirSessionCapability::Read,
) => {
read.path == expected_path
byte_range.is_none()
&& read.path == expected_path
&& read.bytes.len()
<= crate::BoundedReadLimits::EXTERNAL_DEFAULT
.max_response_bytes
+15 -1
View File
@@ -1550,6 +1550,20 @@ impl WorkdirSession for LocalWorkdirSession {
}
let is_create = matches!(&request.operation, Op::Create { .. });
let output = match request.operation {
Op::ReadBytes { offset, max_bytes } => fs_operation::run_read_bytes_bounded(
&self.inner.root,
ReadBytesRequest {
path: path.clone(),
offset,
max_bytes,
expected_hash: None,
},
&pinned,
self.inner
.read_limits
.unwrap_or(BoundedReadLimits::EXTERNAL_DEFAULT),
)
.map(Output::ReadBytes),
Op::Read {
offset,
limit,
@@ -1642,7 +1656,7 @@ impl WorkdirSession for LocalWorkdirSession {
checkout_execute_error(&path, WorkdirError::from(error))
}
})?;
if matches!(&output, Output::Read(_)) {
if matches!(&output, Output::Read(_) | Output::ReadBytes(_)) {
pinned.confirm().map_err(|e| checkout_io(&path, e))?;
return Ok(crate::CheckoutResult {
observation: self
+3
View File
@@ -1260,6 +1260,9 @@ impl WorkdirSession for ScopedWorkdirSession {
.intersection(WorkdirSessionCapabilities::READ_ONLY);
}
if let crate::CheckoutOutput::Read(read) = &mut result.output {
read.path = result_path.clone();
}
if let crate::CheckoutOutput::ReadBytes(read) = &mut result.output {
read.path = result_path;
}
Ok(result)
+165 -183
View File
@@ -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,21 @@ 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,
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(),
raw,
] {
hash.update(value.len().to_be_bytes());
@@ -236,45 +243,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 +265,38 @@ 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.validator);
let handler = Arc::new(FileHandler {
provider: self.clone(),
alias: alias.into(),
@@ -304,86 +305,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 +520,13 @@ 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,
&raw,
),
))
} else {
Ok(WipOperationOutput::native(value))
@@ -604,6 +541,115 @@ 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,
&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,
operation == "read_lines",
),
)
.await
.map_err(|_| {
protocol(
ProtocolErrorCode::ResourceLimitExceeded,
"text read deadline",
)
})?
.map_err(map_tool_error)?;
let (read, validator) = result;
self.check_connection()?;
let text = String::from_utf8_lossy(&read.bytes);
let value = crate::file_operation::page_result(
operation,
arguments,
&text,
offset,
read.truncated,
)?;
return Ok(match validator {
Some(raw) => WipOperationOutput::native_with_validator(
value,
self.provider.wrap_validator(
&self.alias,
self.generation,
&self.workdir,
&self.target,
&raw,
),
),
None => WipOperationOutput::native(value),
});
}
let tool = match operation {
"list" => "List",
"read" => "Read",
@@ -752,14 +798,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 +812,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 +836,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,
+4 -2
View File
@@ -358,7 +358,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 +621,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(
+6 -1
View File
@@ -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(),
+270 -29
View File
@@ -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(),
@@ -501,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!({})),
@@ -509,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(),
@@ -621,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(),
@@ -690,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]
@@ -756,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")
@@ -833,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"})),
@@ -930,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());
@@ -947,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();
@@ -963,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(),
@@ -1000,6 +1047,200 @@ 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"
);
}
@@ -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());
}
+190 -22
View File
@@ -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!(
@@ -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(),
}),
})?;
}
+605
View File
@@ -41,3 +41,608 @@ 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(),
),
};
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),types:vec![],documentation:Some(Documentation {summary:summary.into(),details:Some(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.",
}.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)
}
+72 -8
View File
@@ -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
+8
View File
@@ -12694,6 +12694,14 @@ impl server_api::ServerApi for ServerApiContractService {
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
drive::root(api, &context, &workspace_id).await
}
async fn drive_capabilities(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
) -> std::result::Result<server_api::DriveCapabilities, server_api::DriveApiError> {
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
drive::capabilities(api, &context, &workspace_id).await
}
async fn drive_metadata(
&self,
context: server_api::ServerRequestContext,
@@ -238,6 +238,25 @@ pub(super) async fn root(
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
blocking(false, move || drive.root()).await.map(public_node)
}
/// Query only the authenticated caller's current ceiling using the operation authorizer.
/// This is admission/publication evidence, never authority retained for a later mutation.
pub(super) async fn capabilities(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
) -> DriveResult<DriveCapabilities> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
let write = blocking(false, move || match drive.check_authorized(true) {
Ok(()) => Ok(true),
Err(workspace_drive::Error::Denied) => {
drive.check_authorized(false)?;
Ok(false)
}
Err(error) => Err(error),
})
.await?;
Ok(DriveCapabilities { write })
}
pub(super) async fn metadata(
api: &WorkspaceApi,
context: &ServerRequestContext,
@@ -581,6 +600,9 @@ pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
.merge(streaming_router(service.clone()))
.merge(server_api::server_api_axum::drive_root(service.clone()))
.merge(server_api::server_api_axum::drive_metadata(service.clone()))
.merge(server_api::server_api_axum::drive_capabilities(
service.clone(),
))
.merge(server_api::server_api_axum::drive_list(service.clone()))
.merge(server_api::server_api_axum::drive_search(service.clone()))
.merge(server_api::server_api_axum::drive_read_text(
+121 -1
View File
@@ -1951,6 +1951,19 @@
],
"type": "string"
},
"DriveCapabilities": {
"additionalProperties": false,
"description": "Current caller's advisory publication ceiling, never a grant or execution credential.",
"properties": {
"write": {
"type": "boolean"
}
},
"required": [
"write"
],
"type": "object"
},
"DriveDownloadQuery": {
"additionalProperties": false,
"properties": {
@@ -17831,7 +17844,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d"
"x-yoi-source-digest": "sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb"
},
"openapi": "3.1.0",
"paths": {
@@ -22228,6 +22241,113 @@
]
}
},
"/api/w/{workspace_id}/drive/capabilities": {
"get": {
"operationId": "drive_capabilities",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DriveCapabilities"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DriveApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DriveApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DriveApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DriveApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DriveApiError"
}
}
},
"description": "Error response"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DriveApiError"
}
}
},
"description": "Error response"
},
"503": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DriveApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/drive/download": {
"get": {
"operationId": "drive_download",
@@ -31,6 +31,8 @@ export type DriveEntry = {
latest_url: string;
};
export type DriveCapabilities = { write: boolean };
export type DriveEntryQuery = { entry_workspace_id: string; id: string };
export type DriveListQuery = {
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check