blob: bdf3189dc25febf7efc52e49a08e6701f7e044ab [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::*;
use maka_agent_facts::{
event::{Fact, ToolOutcome},
interaction::{InteractionRecord, InteractionRequest},
tool_output::DurableToolProjection,
};
async fn pending(
fixture: &Fixture,
session: &str,
step: &std::cell::Cell<&'static str>,
) -> InteractionRecord {
let mut commits = fixture.store.subscribe_commits();
loop {
commits.borrow_and_update();
step.set("pending.query");
if let Some(record) = fixture
.store
.pending_interactions(session)
.await
.unwrap()
.into_iter()
.next()
{
step.set("pending.returned");
return record;
}
step.set("pending.commit_wait");
commits.changed().await.unwrap();
step.set("pending.commit_wait.returned");
}
}
async fn answer(
f: &Fixture,
record: &InteractionRecord,
allow: bool,
step: &std::cell::Cell<&'static str>,
) {
step.set("answer.mutation");
let result = f.mutation(2, "respond", json!({"sessionId":record.session_id,"requestId":record.request_id,"answer":{"kind":"client_capability","decision":if allow { "allow" } else { "deny" }}}), Uuid::new_v4()).await.unwrap();
step.set("answer.assertion");
assert_eq!(result["kind"], "recorded", "{result}");
step.set("answer.returned");
}
async fn start(
listener: &TcpListener,
f: &Fixture,
session: &str,
method: &str,
input: Value,
expected_state: &str,
step: &std::cell::Cell<&'static str>,
) -> String {
step.set("start.send");
let receipt = f.send(session).await.unwrap();
let invocation = receipt["invocationId"].as_str().unwrap().to_owned();
step.set("provider.accept");
let (mut socket, _) = listener.accept().await.unwrap();
step.set("provider.request");
let sent = request(&mut socket).await;
step.set("start.descriptor_assertions");
let tools = sent["tools"].as_array().unwrap();
let tool = tools
.iter()
.find(|tool| tool["function"]["name"] == method)
.expect("actual model tool descriptor");
assert!(
tool["function"]["description"]
.as_str()
.unwrap()
.contains(expected_state),
"{tool}"
);
step.set("start.tool_response");
service_tools::named_tool_response(&mut socket, method, input).await;
step.set("start.returned");
invocation
}
async fn finish(
listener: &TcpListener,
f: &Fixture,
step: &std::cell::Cell<&'static str>,
) -> Value {
step.set("provider.accept");
let (mut socket, _) = listener.accept().await.unwrap();
step.set("provider.request");
let sent = request(&mut socket).await;
step.set("finish.response");
response(&mut socket).await;
step.set("finish.drain");
f.drained().await;
step.set("finish.returned");
sent
}
fn tool_text(sent: &Value) -> String {
sent["messages"]
.as_array()
.unwrap()
.iter()
.rev()
.find(|message| message["role"] == "tool")
.unwrap()["content"]
.to_string()
}
#[tokio::test]
async fn computer_product_tools_preserve_approval_repl_media_and_revocation() {
let scenario = std::cell::Cell::new("initial");
let step = std::cell::Cell::new("initial");
let revocation_trace = std::cell::RefCell::new(Value::Null);
tokio::time::timeout(Duration::from_secs(90), async {
scenario.set("listener.bind");
step.set("direct.await");
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
scenario.set("fixture.setup");
step.set("direct.await");
let f = Fixture::with_services(&format!("http://{}/v1", listener.local_addr().unwrap()), None, None, Some(service_tools::Setup::computer())).await;
scenario.set("vision.enable");
step.set("direct.await");
images::enable_vision(&f).await;
scenario.set("session.create");
step.set("direct.await");
let session = f.create("Computer").await;
scenario.set("other_session.create");
step.set("direct.await");
let other = f.create("Other Computer").await;
scenario.set("denied.start");
step.set("direct.await");
start(&listener, &f, &session, "cua_repl", json!({"code":"globalThis.deniedEffect = true;"}), "fresh", &step).await;
scenario.set("denied.pending");
step.set("direct.await");
let denied = pending(&f, &session, &step).await;
scenario.set("denied.pending.returned");
step.set("returned_or_assertions");
assert!(matches!(&denied.request, InteractionRequest::ClientCapability { target, base_revision: Some(0), .. } if target == &maka_workspace_api::computer::target()));
scenario.set("denied.answer");
step.set("direct.await");
answer(&f, &denied, false, &step).await;
scenario.set("denied.finish_assertion");
step.set("direct.await");
assert!(tool_text(&finish(&listener, &f, &step).await).contains("not granted"));
scenario.set("denied.grant_assertion");
step.set("direct.await");
assert!(f.store.client_capability_grant(&session, &maka_workspace_api::computer::target()).await.unwrap().is_none());
scenario.set("denied.grant_assertion.returned");
step.set("returned_or_assertions");
// Real native facade operations stay journaled under the original parent;
// browser inventory with no configured browser never touches a user app.
// A >1 MiB image crosses the ordinary service through a durable artifact,
// without raising the Host JSON ceiling or losing the exact image bytes.
let png: Vec<u8> = {
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aM7cAAAAASUVORK5CYII=").unwrap()
};
let mut expected = vec![0; 1100 * 1024]; expected[..png.len()].copy_from_slice(&png);
let code = format!("if (globalThis.deniedEffect) throw Error('denied cell ran'); globalThis.counter = 41; await cua.listBrowsers(); const screenshot = new Uint8Array(1100*1024); screenshot.set({}); await nodeRepl.emitImage({{bytes:screenshot,mimeType:'image/png'}}); nodeRepl.write('computer-effect-41');", serde_json::to_string(&png).unwrap());
scenario.set("media.start");
step.set("direct.await");
start(&listener, &f, &session, "cua_repl", json!({"code":code,"title":"Inspect selected browsers"}), "fresh", &step).await;
scenario.set("media.pending");
step.set("direct.await");
let allowed = pending(&f, &session, &step).await;
scenario.set("media.answer");
step.set("direct.await");
answer(&f, &allowed, true, &step).await;
scenario.set("media.finish");
step.set("direct.await");
let sent = finish(&listener, &f, &step).await;
scenario.set("media.finish.returned");
step.set("returned_or_assertions");
assert!(sent.to_string().contains("computer-effect-41"), "Computer effect output was not projected");
assert_eq!(images::model_images(&sent), vec![expected.clone()]);
scenario.set("media.observe");
step.set("direct.await");
let observed = f.query("observe", json!({"sessionId":session})).await.unwrap();
scenario.set("media.observe.returned");
step.set("returned_or_assertions");
let through = observed["through"].as_u64().unwrap();
scenario.set("media.facts");
step.set("direct.await");
let events = service_tools::facts(&f, &session).await;
scenario.set("media.facts.returned");
step.set("returned_or_assertions");
assert!(events.iter().any(|event| matches!(event, Fact::ToolDispatched { name, call, .. } if name == "cua_operation" && matches!(&call.origin, maka_agent_facts::tool_call::ToolOrigin::HostSdk { parent_operation_id: Some(_), .. }))));
assert!(events.iter().any(|event| matches!(event, Fact::ToolDispatched { name, title: Some(title), .. } if name == "cua_repl" && title.fallback == "Inspect selected browsers")));
assert!(events.iter().any(|event| matches!(event, Fact::ToolSettled { outcome: ToolOutcome::Succeeded { model_projection: DurableToolProjection::Content { parts }, .. }, .. } if parts.iter().any(|part| part.media().is_some()))));
assert!(through > 0);
scenario.set("counter.start");
step.set("direct.await");
start(&listener, &f, &session, "cua_repl", json!({"code":"nodeRepl.write(++globalThis.counter);"}), "ready", &step).await;
scenario.set("counter.finish_assertion");
step.set("direct.await");
assert!(tool_text(&finish(&listener, &f, &step).await).contains("42"));
scenario.set("counter.pending_assertion");
step.set("direct.await");
assert!(f.store.pending_interactions(&session).await.unwrap().is_empty());
scenario.set("counter.pending_assertion.returned");
step.set("returned_or_assertions");
// Grants and VM globals never leak into another Session.
scenario.set("other.start");
step.set("direct.await");
start(&listener, &f, &other, "cua_repl", json!({"code":"nodeRepl.write(typeof globalThis.counter);"}), "fresh", &step).await;
scenario.set("other.pending_then_answer");
step.set("direct.await");
answer(&f, &pending(&f, &other, &step).await, true, &step).await;
scenario.set("other.finish_assertion");
step.set("direct.await");
assert!(tool_text(&finish(&listener, &f, &step).await).contains("undefined"));
scenario.set("other.finish_assertion.returned");
step.set("returned_or_assertions");
// Revocation stops the cell; reset joins the VM before replacing it.
scenario.set("revocation.start");
step.set("direct.await");
let revocation_invocation = start(&listener, &f, &session, "cua_repl", json!({"code":"await cua.listBrowsers(); nodeRepl.write('waiting'); await new Promise(() => {});","timeout_ms":120000}), "ready", &step).await;
scenario.set("revocation.start.returned");
step.set("returned_or_assertions");
let mut commits = f.store.subscribe_commits();
loop {
let observed_commit = *commits.borrow_and_update();
scenario.set("revocation.dispatch_facts");
step.set("direct.await");
let stored = service_tools::stored_facts(&f, &session).await;
let events: Vec<_> = stored.iter()
.filter(|event| event.event.invocation.invocation_id == revocation_invocation)
.collect();
scenario.set("revocation.dispatch_facts.returned");
step.set("returned_or_assertions");
*revocation_trace.borrow_mut() = json!({
"factCount": events.len(),
"invocationId": revocation_invocation,
"observedCommit": observed_commit,
"lastSequence": stored.last().map(|event| event.sequence),
"lastFacts": events.iter().rev().take(32).rev().map(|stored| {
let event = &stored.event.fact;
let mut row = match event {
Fact::ToolDispatched { operation_id, call, name, input, .. } => json!({
"kind":event.kind(), "operationId":operation_id, "call":call,
"name":name, "method":input.get("method"),
"revocationCell":name == "cua_repl" && input["code"] == "await cua.listBrowsers(); nodeRepl.write('waiting'); await new Promise(() => {});"
}),
Fact::ToolSettled { operation_id, outcome } => json!({
"kind":event.kind(), "operationId":operation_id,
"outcome":match outcome {
ToolOutcome::Succeeded { .. } => json!({"kind":"succeeded"}),
ToolOutcome::Failed { message } => json!({"kind":"failed", "message":message.chars().take(1024).collect::<String>()}),
ToolOutcome::Unknown { message, .. } => json!({"kind":"unknown", "message":message.chars().take(1024).collect::<String>()}),
}
}),
Fact::ToolRejected { operation_id, name, reason, .. } => json!({
"kind":event.kind(), "operationId":operation_id, "name":name,
"reason":reason.to_string().chars().take(1024).collect::<String>(),
}),
Fact::InvocationEnded { outcome } => json!({
"kind":event.kind(), "status":outcome.status(),
}),
Fact::ModelInterrupted { step_id, status, diagnostic, .. } => json!({
"kind":event.kind(), "stepId":step_id, "status":status,
"diagnostic":diagnostic.as_ref().map(|text| text.chars().take(1024).collect::<String>()),
}),
_ => json!({"kind":event.kind(), "operationId":event.operation_id()}),
};
row["sequence"] = json!(stored.sequence);
row
}).collect::<Vec<_>>()
});
if let Some(operation) = events.iter().find_map(|event| match &event.event.fact {
Fact::ToolDispatched { operation_id, name, input, .. }
if name == "cua_repl" && input["code"] == "await cua.listBrowsers(); nodeRepl.write('waiting'); await new Promise(() => {});" => Some(operation_id),
_ => None,
}) {
if events.iter().any(|event| matches!(&event.event.fact,
Fact::ToolDispatched { input, name, call, .. }
if name == "cua_operation" && input["method"] == "listBrowsers"
&& matches!(&call.origin, maka_agent_facts::tool_call::ToolOrigin::HostSdk {
parent_operation_id: Some(parent), ..
} if parent == operation))) { break; }
assert!(!events.iter().any(|event| matches!(&event.event.fact, Fact::ToolSettled { operation_id, .. } if operation_id == operation)),
"revocation cell settled before its expected child dispatch: {}", revocation_trace.borrow());
}
assert!(!events.iter().any(|event| matches!(&event.event.fact,
Fact::ToolRejected { name, .. } if name == "cua_repl")
|| matches!(&event.event.fact, Fact::ModelInterrupted { .. } | Fact::InvocationEnded { .. })),
"current invocation ended before its expected child dispatch: {}", revocation_trace.borrow());
scenario.set("revocation.commit_wait");
step.set("direct.await");
commits.changed().await.unwrap();
scenario.set("revocation.commit_wait.returned");
step.set("returned_or_assertions");
}
scenario.set("revocation.policy_change");
step.set("direct.await");
assert_eq!(files::change(&f, &session, "workspace_write", true, true).await["kind"], "committed");
scenario.set("revocation.finish");
step.set("direct.await");
let cancelled = finish(&listener, &f, &step).await;
scenario.set("revocation.finish.returned");
step.set("returned_or_assertions");
let stopped = tool_text(&cancelled);
assert!(stopped.contains("changed") || stopped.contains("revoked") || stopped.contains("cancelled"), "{stopped}");
assert!(stopped.contains("reset_required"), "{stopped}");
scenario.set("reset.start");
step.set("direct.await");
start(&listener, &f, &session, "cua_reset", json!({}), "reset_required", &step).await;
scenario.set("reset.finish_assertion");
step.set("direct.await");
assert!(tool_text(&finish(&listener, &f, &step).await).contains("reset"));
scenario.set("renewal.start");
step.set("direct.await");
start(&listener, &f, &session, "cua_repl", json!({"code":"nodeRepl.write(typeof globalThis.counter);"}), "fresh", &step).await;
scenario.set("renewal.pending");
step.set("direct.await");
let renewal = pending(&f, &session, &step).await;
scenario.set("renewal.pending.returned");
step.set("returned_or_assertions");
assert!(matches!(renewal.request, InteractionRequest::ClientCapability { base_revision: Some(1), .. }));
scenario.set("renewal.answer");
step.set("direct.await");
answer(&f, &renewal, true, &step).await;
scenario.set("renewal.finish_assertion");
step.set("direct.await");
assert!(tool_text(&finish(&listener, &f, &step).await).contains("undefined"));
scenario.set("app.shutdown");
step.set("direct.await");
f.app.shutdown().await.unwrap();
scenario.set("config.shutdown");
step.set("direct.await");
f.config.shutdown().await.unwrap();
scenario.set("config.shutdown.returned");
step.set("returned_or_assertions");
}).await.inspect_err(|_| eprintln!("Computer test timed out: scenario={} helper={} revocation={}", scenario.get(), step.get(), revocation_trace.borrow())).unwrap();
}