fix: bind capability changes to Object freshness and update contract consumers

This commit is contained in:
2026-10-20 02:53:21 +09:00
parent 32a409c15d
commit 47b32ef6ab
12 changed files with 711 additions and 20 deletions
+13 -2
View File
@@ -122,6 +122,7 @@ impl Provider {
generation: u64,
workdir: &str,
target: &WorkdirPath,
capabilities: workdir::WorkdirSessionCapabilities,
raw: &[u8],
) -> Vec<u8> {
let mut hash = Sha256::new();
@@ -131,6 +132,7 @@ impl Provider {
&generation.to_be_bytes(),
workdir.as_bytes(),
target.as_str().as_bytes(),
format!("{capabilities:?}").as_bytes(),
raw,
] {
hash.update(value.len().to_be_bytes());
@@ -295,8 +297,14 @@ impl Provider {
"directory"
};
let primary = *contracts.first()?;
let validator =
self.wrap_validator(alias, generation, workdir, &target, &observation.validator);
let validator = self.wrap_validator(
alias,
generation,
workdir,
&target,
observation.capabilities,
&observation.validator,
);
let handler = Arc::new(FileHandler {
provider: self.clone(),
alias: alias.into(),
@@ -525,6 +533,7 @@ impl FileHandler {
self.generation,
&self.workdir,
&self.target,
self.observation.capabilities,
&raw,
),
))
@@ -583,6 +592,7 @@ impl WipOperationHandler for FileHandler {
self.generation,
&self.workdir,
&self.target,
self.observation.capabilities,
&raw,
),
));
@@ -644,6 +654,7 @@ impl WipOperationHandler for FileHandler {
self.generation,
&self.workdir,
&self.target,
self.observation.capabilities,
&raw,
),
),
+41
View File
@@ -800,3 +800,44 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit
"refusal was retried"
);
}
#[tokio::test]
async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() {
use base64::Engine as _;
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
let (runtime, _) = native_runtime(remote);
let path = "/checkouts/main/src/deep/a.txt";
observe_interface(&runtime, path).await;
let output = runtime
.invoke(
path.into(),
crate::file_operation::Contract::ReadBytes.reference(),
"read".into(),
json!({"offset":1,"limit":3}),
Default::default(),
)
.await
.unwrap();
let json: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(json["encoding"], "base64");
assert_eq!(json["offset"], 1);
assert_eq!(json["next_offset"], 4);
let source = fixture
.state
.session
.read_bytes(workdir::ReadBytesRequest {
path: WorkdirPath::new("src/deep/a.txt").unwrap(),
offset: 1,
max_bytes: 3,
expected_hash: None,
})
.await
.unwrap();
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(json["data"].as_str().unwrap())
.unwrap(),
source.bytes
);
}
+296
View File
@@ -1240,3 +1240,299 @@ async fn shared_readonly_contract_rejects_wrong_membership_operation_and_interfa
"雪.*\r\nother\n"
);
}
// The fixture substitutes only a binary source. Real Host/Client/model tools, fixed common
// descriptors, membership, validators and output decoding stay on their production path.
struct ReadonlyBytes {
bytes: Arc<Vec<u8>>,
version: Arc<std::sync::atomic::AtomicU64>,
captured: u64,
}
impl ReadonlyBytes {
fn projection(&self) -> WipProjection {
let contract = crate::file_operation::Contract::ReadBytes;
WipProjection {
route: "/virtual".into(),
capability: "virtual:bytes".into(),
kind: WipProjectionKind::Native,
object: Object {
name: "virtual".into(),
description: None,
interfaces: vec![contract.reference()],
r#ref: None,
validator: Some(self.captured.to_be_bytes().to_vec()),
},
interface: contract.reference(),
descriptor: contract.descriptor(),
interface_validator: Some(contract.validator()),
handler: Arc::new(Self {
bytes: self.bytes.clone(),
version: self.version.clone(),
captured: self.captured,
}),
}
}
}
#[async_trait]
impl WipOperationHandler for ReadonlyBytes {
async fn call(
&self,
op: &str,
args: &BTreeMap<String, Value>,
_: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
if op != "read_bytes" {
return Err(WipOperationError::Protocol(protocol_error(
ProtocolErrorCode::OperationNotFound,
"readonly bytes",
)));
}
if self.captured != self.version.load(Ordering::SeqCst) {
return Err(WipOperationError::Protocol(protocol_error(
ProtocolErrorCode::ValidatorMismatch,
"virtual source replaced",
)));
}
let (offset, limit) = crate::file_operation::byte_range(args)?;
if offset > self.bytes.len() as u64 {
return Err(WipOperationError::Protocol(protocol_error(
ProtocolErrorCode::InvalidArguments,
"past EOF",
)));
}
let end = (offset as usize + limit).min(self.bytes.len());
Ok(WipOperationOutput::native(
crate::file_operation::bytes_result(
args,
&self.bytes[offset as usize..end],
end == self.bytes.len(),
)?,
))
}
}
#[async_trait]
impl WipSubtreeProvider for ReadonlyBytes {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
Ok((path == "/virtual").then(|| {
let current = Self {
bytes: self.bytes.clone(),
version: self.version.clone(),
captured: self.version.load(Ordering::SeqCst),
};
WipPublication {
projection: current.projection(),
scope: None,
}
}))
}
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
Ok(vec![])
}
}
#[tokio::test]
async fn common_bytes_read_long_lines_invalid_utf8_arbitrary_boundaries_and_eof_without_write_authority()
{
use base64::Engine as _;
let mut bytes = "雪".as_bytes().to_vec();
bytes.extend_from_slice(&[0xff, 0, 0xc0]);
bytes.extend(vec![b'x'; 100_000]);
let bytes = Arc::new(bytes);
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("raw"), bytes.as_ref()).unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
WorkdirAttachmentAlias::new("main").unwrap(),
session(&dir, true),
)
.unwrap();
let source = Arc::new(ReadonlyBytes {
bytes: bytes.clone(),
version: Arc::new(std::sync::atomic::AtomicU64::new(1)),
captured: 1,
});
let mut registry = WipMountRegistry::new();
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap();
registry.allocate_namespace("virtual", "virtual").unwrap();
registry.mount(source.projection()).unwrap();
registry
.mount_subtree(WipSubtreeMount {
root: "/virtual".into(),
provider: source.clone(),
})
.unwrap();
let r = Arc::new(WipRuntime::from_mounts(registry, "bytes-test".into()).unwrap());
let virtual_inspect = model_inspect(r.clone(), "/virtual").await;
let reference = virtual_inspect["interfaces"][0]["reference"].clone();
let checkout = model_inspect(r.clone(), "/checkouts/main/raw").await;
assert!(
checkout["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|i| i["reference"] == reference)
);
for path in ["/virtual", "/checkouts/main/raw"] {
for offset in [0, 1, 2, 3, 5, 80_000, bytes.len()] {
let output=json_content(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":{"offset":offset,"limit":17}})).await.unwrap());
let end = (offset + 17).min(bytes.len());
assert_eq!(output["encoding"], "base64");
assert_eq!(output["offset"], offset);
assert_eq!(output["next_offset"], end);
assert_eq!(output["eof"], end == bytes.len());
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(output["data"].as_str().unwrap())
.unwrap(),
bytes[offset..end]
);
}
for arguments in [
json!({"limit":0}),
json!({"limit":32769}),
json!({"offset":-1}),
json!({"offset":9_007_199_254_740_991i64}),
json!({"offset":bytes.len()+1}),
json!({"file_path":"other"}),
] {
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":arguments})).await.is_err());
}
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":{"scope":"/","name":"yoi.file/write-text/v1"},"operation":"write","arguments":{"content":"lost"}})).await.is_err());
}
let fetched = r
.host
.fetch_interface_live(&crate::file_operation::Contract::ReadBytes.reference())
.await
.unwrap();
let mut request = observed_common_request(
"/virtual",
Some(1u64.to_be_bytes().to_vec()),
&fetched,
"read",
);
source.version.store(2, Ordering::SeqCst);
assert!(
matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
);
request.target.validator = Some(2u64.to_be_bytes().to_vec());
assert!(r.host.call(request, file_context()).await.is_ok());
assert_eq!(
std::fs::read(dir.path().join("raw")).unwrap(),
bytes.as_ref().clone()
);
}
#[tokio::test]
async fn scope_downgrade_changes_object_membership_not_common_contract_and_read_remains_available()
{
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("a"), "same").unwrap();
let scope = manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap());
let session = Arc::new(LocalWorkdirSession::materialized(
dir.path().into(),
dir.path().into(),
scope.clone(),
WorkdirSessionCapabilities::READ_WRITE,
));
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(WorkdirAttachmentAlias::new("main").unwrap(), session)
.unwrap();
let r = Arc::new(runtime(router, None));
let path = "/checkouts/main/a";
let old = model_inspect(r.clone(), path).await;
let current = r.host.projection_live(path).await.unwrap().unwrap();
let definition = r
.host
.fetch_interface_live(&contract("read").reference())
.await
.unwrap();
scope
.update(|_| {
manifest::Scope::from_config(&manifest::ScopeConfig {
allow: vec![manifest::ScopeRule {
target: dir.path().into(),
permission: manifest::Permission::Read,
recursive: true,
symlink_policy: manifest::SymlinkPolicy::Resolved,
}],
deny: vec![],
})
})
.unwrap();
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":contract_json("write"),"operation":"write","arguments":{"content":"lost"}})).await.is_err());
let new = json_content(
model_operation(r.clone(), "Inspect", json!({"path":path,"refresh":true}))
.await
.unwrap(),
);
assert_ne!(old["object_signature"], new["object_signature"]);
assert!(
!new["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
);
assert_eq!(
r.host
.fetch_interface_live(&contract("read").reference())
.await
.unwrap()
.validator,
definition.validator
);
assert!(
matches!(r.host.call(observed_common_request(path,current.object.validator,&definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
);
assert_eq!(
json_content(
model_invoke(r.clone(), &new, "read", json!({}))
.await
.unwrap()
)["content"],
" 1\tsame\n"
);
}
fn contract_json(operation: &str) -> Json {
let r = contract(operation).reference();
json!({"scope":r.scope,"name":r.name})
}
#[test]
fn common_contract_registration_rejects_descriptor_redefinition_and_domain_operation_injection() {
let source = ReadonlyBytes {
bytes: Arc::new(vec![]),
version: Arc::new(std::sync::atomic::AtomicU64::new(1)),
captured: 1,
};
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("virtual", "virtual").unwrap();
let mut invalid = source.projection();
invalid.descriptor.operations[0]
.parameters
.push(ParameterDeclaration {
name: "target_override".into(),
required: false,
documentation: None,
r#type: TypeExpr::String,
});
assert!(matches!(
registry.mount(invalid),
Err(WipMountError::InvalidProjection { .. })
));
let projection = source.projection();
registry.mount(projection.clone()).unwrap();
let mut extension = projection.descriptor.clone();
extension.operations[0].name = "erase".into();
assert!(matches!(
registry.contribute_operations(WipOperationContribution {
route: "/virtual".into(),
contributor: "other".into(),
interface: projection.interface,
descriptor: extension,
handler: projection.handler
}),
Err(WipMountError::InterfaceCollision { .. })
));
}
+8 -1
View File
@@ -4139,7 +4139,14 @@ mod tests {
request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
{
// Native discovery must not enumerate or perform any mutation.
// Native discovery reads metadata and the current caller's ceiling, never inventory or mutations.
assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Get);
if request.path == "/api/w/workspace-1/drive/capabilities" {
return Ok(crate::worker::WorkspaceResponse {
status: 200,
body: serde_json::json!({"write":true}).to_string(),
});
}
assert_eq!(request.path, "/api/w/workspace-1/drive/root");
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
+61 -8
View File
@@ -1,5 +1,15 @@
//! WIP presentation of the provider-neutral file argument contracts. Providers
//! choose which read surface to publish; routing and preconditions are not args.
//! Stable root file contracts, independent of provider and target lifetimes.
//!
//! Each capability has one versioned reference and complete descriptor. Providers publish only
//! supported combinations. Root scope is a namespace, never a file grant. Current Object
//! membership selects the provider handler; target observations, checked operations, prior Read,
//! CAS and grants remain provider-owned. Contract validators contain no target or connection state.
//!
//! Line windows, byte ranges and whole-text domain reads are different meanings, not aliases.
//! Settings atomic commits and Drive management/cursor pagination remain domain extensions.
//! Common mutations return one metadata envelope while providers retain their commit details.
//! Old contextual references in history are neither rewritten nor accepted as aliases; callers
//! reobserve Objects and common definitions after migration or restore.
use fs_operation::text::TextOperation;
use wip_protocol::{ParameterDeclaration, TypeExpr};
@@ -185,12 +195,37 @@ impl Contract {
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}}]}
let details = match self {
Self::ReadBytes => {
"Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry."
}
Self::ReadLines | Self::SearchText => {
"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry."
}
Self::Edit | Self::Write | Self::CreateFile => {
"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry."
}
_ => {
"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry."
}
};
InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
types: vec![],
documentation: Some(Documentation {
summary: summary.into(),
details: Some(details.into()),
}),
operations: vec![OperationDeclaration {
name: self.operation().into(),
documentation: None,
parameters,
returns: ReturnDeclaration {
documentation: None,
r#type: result,
},
}],
}
}
pub(crate) fn validator(self) -> Vec<u8> {
Sha256::digest(format!("{:?}", self.descriptor())).to_vec()
@@ -646,3 +681,21 @@ pub(crate) fn bytes_result(
}
Ok(value)
}
#[cfg(test)]
mod byte_tests {
use super::*;
#[test]
fn maximum_raw_byte_page_reserves_base64_and_complete_json_budget() {
let args = BTreeMap::from([("limit".into(), Value::Integer(32768))]);
let result = bytes_result(&args, &vec![0xff; 32768], false)
.unwrap_or_else(|_| panic!("valid bounded bytes"));
let json = crate::wip::wip_to_json(&result).unwrap();
assert!(serde_json::to_vec_pretty(&json).unwrap().len() <= MAX_BYTES);
assert_eq!(json["next_offset"], 32768);
assert_eq!(json["eof"], false);
assert!(
matches!(bytes_result(&args,&vec![0;32769],false),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::Internal)
);
}
}
@@ -777,6 +777,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse
configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id);
let routes = [
format!("{}/root", base()),
format!("{}/capabilities", base()),
format!(
"{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}",
base()
@@ -1007,3 +1008,106 @@ async fn drive_download_stream_expires_instead_of_mixing_last_mutation_ids() {
"next chunk must expire, not deliver generation b"
);
}
#[tokio::test]
async fn drive_capabilities_expose_only_authenticated_current_caller_ceiling() {
let temp = tempfile::tempdir().unwrap();
let mut api = test_api(temp.path()).await;
let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
let (_runtime, workers) = workers(&api).await;
let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap();
configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id);
let path = format!("{}/capabilities", base());
let unsigned = build_router(api.clone())
.oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap())
.await
.unwrap();
assert!(matches!(
unsigned.status(),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
));
signed(
&api,
&identity,
None,
"GET",
&path,
None,
StatusCode::FORBIDDEN,
)
.await;
let mut grants = vec![];
for (worker, access, write) in [
(&workers[0], "read_only", false),
(&workers[1], "read_write", true),
] {
grants.push(user_call(&api,&owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":worker.runtime_id,"worker_id":worker.worker_id,"access":access})),StatusCode::OK).await);
assert_eq!(
signed(
&api,
&identity,
Some(&worker.worker_id),
"GET",
&path,
None,
StatusCode::OK
)
.await,
json!({"write":write})
);
}
// Altering a signed source cannot acquire the other Worker's larger ceiling.
let mut forged =
runtime_source_request(&identity, Some(&workers[0].worker_id), "GET", &path, vec![]);
forged
.headers_mut()
.insert("x-yoi-worker-id", workers[1].worker_id.parse().unwrap());
let response = build_router(api.clone()).oneshot(forged).await.unwrap();
assert!(matches!(
response.status(),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
));
let foreign = path.replace(&format!("/w/{TEST_WORKSPACE_ID}/"), "/w/other-workspace/");
let response = build_router(api.clone())
.oneshot(runtime_source_request(
&identity,
Some(&workers[1].worker_id),
"GET",
&foreign,
vec![],
))
.await
.unwrap();
assert!(!response.status().is_success());
signed(
&api,
&identity,
Some("nonexistent-worker"),
"GET",
&path,
None,
StatusCode::UNAUTHORIZED,
)
.await;
for (worker, grant) in workers.iter().zip(&grants) {
user_call(
&api,
&owner,
"DELETE",
&format!("{}/grants/{}", base(), grant["grant_id"].as_str().unwrap()),
None,
StatusCode::OK,
)
.await;
signed(
&api,
&identity,
Some(&worker.worker_id),
"GET",
&path,
None,
StatusCode::FORBIDDEN,
)
.await;
}
}
@@ -517,7 +517,7 @@ async fn user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_
read["entry"]["metadata"]["last_mutation_id"],
uploaded["entry"]["last_mutation_id"]
);
value(native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":updated_text}})).await.unwrap())
value(native_tool(&tools, "Invoke", json!({"path":path,"interface":file_write_reference(&inspected),"operation":"write","arguments":{"content":updated_text}})).await.unwrap())["metadata"].clone()
} else {
let read = value(
tool(&f.features[0], "DriveRead", json!({"entry":id}))
@@ -733,6 +733,19 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace
.await
.unwrap();
assert!(f.api.runtime.worker(old).is_err());
let client = f.clients[0].clone();
let capability = tokio::task::spawn_blocking(move || {
client.request(
WorkspaceRequestMethod::Get,
format!("{}/capabilities", base()),
vec![],
65536,
)
})
.await
.unwrap()
.unwrap();
assert!(!(200..300).contains(&capability.status));
assert!(
f.api
.store
@@ -1571,11 +1584,30 @@ impl agen::llm_client::LlmClient for NoLlm {
panic!("native tool fixture must not call an LLM")
}
}
fn file_write_reference(observed: &Value) -> Value {
observed["interfaces"]
.as_array()
.unwrap()
.iter()
.find(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
.expect("write capability")["reference"]
.clone()
}
fn native_tools(
feature: &DriveFeature,
) -> std::collections::BTreeMap<String, Arc<dyn agen::tool::Tool>> {
native_tools_with_checkouts(feature, None)
}
fn native_tools_with_checkouts(
feature: &DriveFeature,
router: Option<Arc<WorkdirSessionRouter>>,
) -> std::collections::BTreeMap<String, Arc<dyn agen::tool::Tool>> {
let mut mounts = worker::wip::WipMountRegistry::new();
worker::feature::builtin::drive::wip::mount_drive_wip(&mut mounts, feature).unwrap();
if let Some(router) = router {
worker::checkout::mount_checkouts(&mut mounts, router, tools::Tracker::new(), None)
.unwrap();
}
let mut engine = agen::Engine::<_, agen::state::Mutable, ()>::new_annotated(NoLlm);
engine.register_tools(
feature
@@ -1651,7 +1683,14 @@ async fn native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_cu
);
assert_eq!(read["text"], "native content");
let denied_write = native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":"lost"}})).await;
denied(denied_write);
assert!(denied_write.is_err());
assert!(
!inspected["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
);
f.revoke(1).await;
assert!(
native_tool(
@@ -1705,12 +1744,12 @@ async fn native_create_and_write_use_client_managed_committed_requests_and_rejec
.await
.unwrap(),
);
let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":observed_first["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"committed"}})).await.unwrap());
let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_first),"operation":"write","arguments":{"content":"committed"}})).await.unwrap())["metadata"].clone();
assert_ne!(
updated["entry"]["metadata"]["last_mutation_id"],
created["entry"]["metadata"]["last_mutation_id"]
);
let error = native_tool(&second, "Invoke", json!({"path":path,"interface":observed_second["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err();
let error = native_tool(&second, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_second),"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err();
assert!(
error.to_string().to_lowercase().contains("validator"),
"stale native mutation must report validator conflict: {error:?}"
@@ -1833,3 +1872,143 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati
"history attachment must not fetch latest Drive content"
);
}
#[tokio::test]
async fn common_file_inspect_invoke_crosses_drive_and_checkout_without_transferring_authority() {
let f = Fixture::new(["read_write", "read_only"]).await;
let created = f.create("common.md", "first\n雪 needle\nlast\n").await;
let path = created["entry"]["path"].as_str().unwrap();
let checkout = f._temp.path().join("checkout");
fs::create_dir(&checkout).unwrap();
fs::write(checkout.join("file"), "first\n雪 needle\nlast\n").unwrap();
let router = Arc::new(WorkdirSessionRouter::new());
router
.attach(
workdir::WorkdirAttachmentAlias::new("main").unwrap(),
Arc::new(workdir::LocalWorkdirSession::materialized(
checkout.clone(),
checkout.clone(),
manifest::SharedScope::new(manifest::Scope::writable(&checkout).unwrap()),
workdir::WorkdirSessionCapabilities::READ_WRITE,
)),
)
.unwrap();
let tools = native_tools_with_checkouts(&f.features[0], Some(router));
let drive = value(
native_tool(&tools, "Inspect", json!({"path":path}))
.await
.unwrap(),
);
let local = value(
native_tool(&tools, "Inspect", json!({"path":"/checkouts/main/file"}))
.await
.unwrap(),
);
for name in [
"yoi.file/read-lines/v1",
"yoi.file/search-text/v1",
"yoi.file/edit-text/v1",
"yoi.file/write-text/v1",
] {
let selected = drive["interfaces"]
.as_array()
.unwrap()
.iter()
.find(|i| i["reference"]["name"] == name)
.unwrap();
let reference = &selected["reference"];
assert_eq!(reference["scope"], "/");
assert!(
local["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|i| i["reference"] == *reference)
);
let signature = value(
native_tool(&tools, "Inspect", json!({"path":selected["path"]}))
.await
.unwrap(),
);
assert!(
signature["interface_signature"]
.as_str()
.unwrap()
.contains(name)
);
let (operation, arguments) = match name {
"yoi.file/read-lines/v1" => ("read", json!({"offset":1,"limit":1})),
"yoi.file/search-text/v1" => ("search", json!({"pattern":"needle","limit":2})),
"yoi.file/edit-text/v1" => {
("edit", json!({"old_string":"needle","new_string":"found"}))
}
_ => ("write", json!({"content":"saved\n"})),
};
let mut results = vec![];
for target in [path, "/checkouts/main/file"] {
results.push(value(native_tool(&tools,"Invoke",json!({"path":target,"interface":reference,"operation":operation,"arguments":arguments})).await.unwrap()));
}
if matches!(operation, "read" | "search") {
assert_eq!(results[0], results[1]);
} else {
assert!(results.iter().all(|v| v.get("metadata").is_some()));
}
}
assert_eq!(
fs::read_to_string(checkout.join("file")).unwrap(),
"saved\n"
);
assert_eq!(
value(
tool(
&f.features[1],
"DriveRead",
json!({"entry":entry(&created)})
)
.await
.unwrap()
)["text"],
"saved\n"
);
// A capability query is not a grant: drop the writer to readonly after the common write was cached.
f.revoke(0).await;
owner_call(&f.api,&f.owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":f.clients[0].worker.runtime_id,"worker_id":f.clients[0].worker.worker_id,"access":"read_only"}))).await;
assert!(native_tool(&tools,"Invoke",json!({"path":path,"interface":file_write_reference(&drive),"operation":"write","arguments":{"content":"lost"}})).await.is_err());
let refreshed = value(
native_tool(&tools, "Inspect", json!({"path":path,"refresh":true}))
.await
.unwrap(),
);
assert!(
!refreshed["interfaces"]
.as_array()
.unwrap()
.iter()
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
);
let common_read = json!({"scope":"/","name":"yoi.file/read-lines/v1"});
assert_eq!(
value(
native_tool(
&tools,
"Invoke",
json!({"path":path,"interface":common_read,"operation":"read","arguments":{}})
)
.await
.unwrap()
)["content"],
" 1\tsaved\n"
);
assert_eq!(
value(
tool(
&f.features[1],
"DriveRead",
json!({"entry":entry(&created)})
)
.await
.unwrap()
)["text"],
"saved\n"
);
}
@@ -187,7 +187,7 @@ async fn config_call(
runtime
.invoke(
path.into(),
config_interface(path),
if matches!(operation,"edit"|"write") {wip_protocol::InterfaceReference {scope:"/".into(),name:format!("yoi.file/{operation}-text/v1")}} else {config_interface(path)},
operation.into(),
args,
agen::tool::ToolExecutionContext::direct(),
@@ -1,6 +1,6 @@
// @generated by yoi-companion-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: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-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: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-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: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-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: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check