diff --git a/crates/worker/src/checkout.rs b/crates/worker/src/checkout.rs index c84afae30..74666982a 100644 --- a/crates/worker/src/checkout.rs +++ b/crates/worker/src/checkout.rs @@ -122,6 +122,7 @@ impl Provider { generation: u64, workdir: &str, target: &WorkdirPath, + capabilities: workdir::WorkdirSessionCapabilities, raw: &[u8], ) -> Vec { 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, ), ), diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index b3d85bfc6..a7871239b 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -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 + ); +} diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index dd3da478c..9460924f1 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -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>, + version: Arc, + 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, + _: WipCallContext, + ) -> Result { + 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, 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, 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 { .. }) + )); +} diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index d8be6da1a..994a8f665 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -4139,7 +4139,14 @@ mod tests { request: crate::worker::WorkspaceRequest, ) -> Result { - // 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"}, diff --git a/crates/worker/src/file_operation.rs b/crates/worker/src/file_operation.rs index 308647f5c..00f91eb08 100644 --- a/crates/worker/src/file_operation.rs +++ b/crates/worker/src/file_operation.rs @@ -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 { 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) + ); + } +} diff --git a/crates/workspace-server/src/server/tests/drive_api_tests.rs b/crates/workspace-server/src/server/tests/drive_api_tests.rs index a7ef41151..4b994fbd5 100644 --- a/crates/workspace-server/src/server/tests/drive_api_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_api_tests.rs @@ -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; + } +} diff --git a/crates/workspace-server/src/server/tests/worker_drive_tests.rs b/crates/workspace-server/src/server/tests/worker_drive_tests.rs index 609cbd4ab..af3bf4ab2 100644 --- a/crates/workspace-server/src/server/tests/worker_drive_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_drive_tests.rs @@ -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> { + native_tools_with_checkouts(feature, None) +} +fn native_tools_with_checkouts( + feature: &DriveFeature, + router: Option>, ) -> std::collections::BTreeMap> { 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" + ); +} diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index 1e989edd8..f6f407943 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -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(), diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 146d0ce28..8f09d3f79 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 138ed0fd1..a07539f4d 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 2dbcf6109..aeef96d93 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -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 diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 8fff5d2ea..446685098 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -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