feat: publish stable root file contracts and target-owned dispatch

This commit is contained in:
2026-10-20 01:46:30 +09:00
parent e5749b9be0
commit 38d9069632
4 changed files with 589 additions and 179 deletions
+33
View File
@@ -403,3 +403,36 @@ 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))
}
+86 -171
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,
@@ -236,45 +235,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,15 +257,36 @@ 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]);
}
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 primary = *contracts.first()?;
let validator = self.wrap_validator(alias, generation, workdir, &observation.validator);
let handler = Arc::new(FileHandler {
provider: self.clone(),
@@ -304,86 +296,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
@@ -604,6 +527,54 @@ impl WipOperationHandler for FileHandler {
arguments: &BTreeMap<String, Value>,
context: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
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 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, &raw),
),
None => WipOperationOutput::native(value),
});
}
let tool = match operation {
"list" => "List",
"read" => "Read",
@@ -774,53 +745,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 +769,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,
+418
View File
@@ -41,3 +41,421 @@ 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 {
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::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::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 => "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::SearchText => "search_text",
_ => self.operation(),
}
}
pub(crate) fn descriptor(self) -> InterfaceDescriptor {
let (summary, parameters, result) = match self {
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::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 > i64::MAX as usize - MAX_LINES {
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));
}
Ok(Value::Record(fields))
}
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
}
+52 -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
}
@@ -1188,10 +1197,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 +1327,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 +1360,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 +1537,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;