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
@@ -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(),