Files
clawmates/crates/tc-runtime/tests/browser_tool.rs
T
Omar SobhandClaude Fable 5 4f253bec93 P6: browser.goto — real Chromium browsing with live web taint
- SandboxSpec gains an egress flag (default false — the kernel suite
  still proves zero-network for agent sandboxes). Egress-enabled
  containers exist ONLY for the browser: no credentials, no broker
  route, bridge network with host-gateway alias for local test pages
- images/agent-browser: Alpine Chromium, uid 10001, setuid bits
  stripped — same non-root hardening as agent-base
- browser.goto tool: headless chromium --dump-dom in the agent's
  browser container; HTML stripped to readable text (4k cap) and
  returned with output_taint=web; viewport screenshot captured,
  base64'd out of the container, stored in the blob store
- Taint semantics tightened: the step that PRODUCED untrusted output
  now carries its own taint (recorded before the step row), not just
  later steps — chat.inbox test updated to the stricter §15 reading
- GET /api/claws/{id}/browser/viewport.png serves the latest capture;
  BrowserApp polls it and renders the live viewport (spec §7.1),
  keeping the empty state until the agent has browsed
- Proven end to end with REAL Chromium against a REAL local page:
  content 'Revenue up 14 percent' returned tainted web; the gated
  email.send that follows carries 'web' in its approval taint_sources
  (untrusted content can never quietly reach outward); screenshot
  verified by PNG magic bytes

152 Rust tests + 63 frontend + 27 Playwright journeys.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 09:41:40 -05:00

191 lines
6.2 KiB
Rust

//! The browser tool, end to end with REAL Chromium in the egress-enabled
//! browser container: navigate to a real local page, return its text
//! tainted `web`, store a viewport screenshot — and prove the §15 chain:
//! a gated action AFTER browsing carries the web taint into its approval.
use std::process::Command;
use std::sync::Arc;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
};
use tc_llm::ScriptedProvider;
use tc_runtime::{RunEventBody, Runtime, RuntimeConfig, SandboxManager};
use tc_sandbox::DockerDriver;
const BROWSER_IMAGE: &str = "teamclaw/agent-browser:dev";
fn scenario(url: &str) -> String {
format!(
r#"
[[scenario]]
marker = "[[scenario:research]]"
[[scenario.turns]]
events = [
{{ type = "tool_use", name = "browser.goto", input = {{ url = "{url}" }} }},
]
[[scenario.turns]]
events = [
{{ type = "tool_use", name = "email.send", input = {{ to = "[email protected]", subject = "Findings", body = "Summary of the page." }} }},
]
[[scenario.turns]]
events = [
{{ type = "text", text = "Done." }},
]
"#
)
}
fn ensure_browser_image() {
let exists = Command::new("docker")
.args(["image", "inspect", BROWSER_IMAGE])
.output()
.expect("docker available")
.status
.success();
if !exists {
let root = env!("CARGO_MANIFEST_DIR");
let status = Command::new("docker")
.args([
"build",
"-t",
BROWSER_IMAGE,
&format!("{root}/../../images/agent-browser"),
])
.status()
.expect("docker build runs");
assert!(status.success(), "agent-browser image build failed");
}
}
/// A real page served on all interfaces so the container can reach it.
async fn spawn_page_server() -> String {
let app = axum::Router::new().route(
"/page",
axum::routing::get(|| async {
axum::response::Html(
"<html><head><title>Q2 Numbers</title></head>\
<body><h1>Quarterly report</h1><p>Revenue up 14 percent.</p></body></html>",
)
}),
);
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://host.docker.internal:{port}/page")
}
#[tokio::test]
async fn browsing_returns_web_tainted_content_and_taints_later_gated_actions() {
ensure_browser_image();
let pool = tc_testkit::test_pool().await;
let url = spawn_page_server().await;
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
tc_db::repo::users::insert(&pool, &owner).await.unwrap();
let agent = Agent {
id: AgentId::new(),
workspace_id: ws.id,
name: "Scout".into(),
job_title: "Analyst".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: String::new(),
wallpaper: String::new(),
managed_by: owner.id,
status: AgentStatus::Online,
};
tc_db::repo::agents::insert(&pool, &agent, &AccessPolicy::default())
.await
.unwrap();
let driver: Arc<dyn tc_sandbox::SandboxDriver> =
Arc::new(DockerDriver::connect().expect("docker reachable"));
let browser = Arc::new(SandboxManager::new(driver, BROWSER_IMAGE).with_egress());
let blob = Arc::new(tc_files::LocalBlobStore::new(
std::env::temp_dir().join(format!("tc-brw-{}", uuid::Uuid::now_v7())),
));
let rt = Runtime::with_blob_store(
pool.clone(),
Arc::new(ScriptedProvider::from_toml(&scenario(&url)).unwrap()),
RuntimeConfig::basic("scripted", 1024).with_browser(browser.clone()),
blob.clone(),
);
let session = tc_db::repo::sessions::create(&pool, agent.id, ws.id, "Research")
.await
.unwrap();
let started = rt
.send_message(session.id, "research it [[scenario:research]]")
.await
.unwrap();
let mut rx = started.events;
let mut suspended = false;
while let Ok(envelope) = rx.recv().await {
match envelope.event {
RunEventBody::RunSuspended { .. } => {
suspended = true;
break;
}
RunEventBody::Error { .. } => break,
_ => {}
}
}
assert!(suspended, "email.send must gate after browsing");
// The browse step returned real page text, tainted web.
let (output, taint): (serde_json::Value, Vec<String>) = sqlx::query_as(
"SELECT s.output, s.taint FROM steps s
JOIN messages m ON m.id = s.message_id
WHERE m.session_id = $1 AND s.tool_name = 'browser.goto'",
)
.bind(session.id.as_uuid())
.fetch_one(&pool)
.await
.unwrap();
let text = output["content"].as_str().unwrap();
assert!(
text.contains("Revenue up 14 percent"),
"real chromium fetched the real page: {text}"
);
assert!(taint.contains(&"web".to_owned()), "taint: {taint:?}");
// The §15 chain: the approval for the LATER gated action carries the
// web taint — untrusted content can never quietly reach outward.
let pending = tc_safety::approvals::list_pending(&pool, ws.id)
.await
.unwrap();
assert_eq!(pending.len(), 1);
assert!(
pending[0].taint_sources.contains(&"web".to_owned()),
"approval taint: {:?}",
pending[0].taint_sources
);
// The viewport screenshot landed in the blob store as a real PNG.
use tc_files::BlobStore;
let key = format!("{}/browser/{}/viewport.png", ws.id, agent.id);
let png = blob.get(&key).await.expect("screenshot stored");
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n", "PNG magic");
browser.shutdown().await;
}