fix: bind capability changes to Object freshness and update contract consumers
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user