- 1
//! Headless-browser DOM render tool: drives a locally installed - 2
//! Chromium-family browser as a child process and returns the JavaScript- - 3
//! rendered DOM (docs/design/29-personal-os.md, sibling of webfetch P4). - 4
//! - 5
//! Permission posture matches webfetch exactly (Ask in restricted modes, - 6
//! Allow under FullAccess or an explicit rule) and is applied downstream by - 7
//! the registry/permission engine; this tool performs no permission checks - 8
//! itself. Credentials are never sent: fresh throwaway profile, no cookie - 9
//! reuse, no auth material. - 10
//! - 11
//! Sandbox posture: unlike Bash, no Seatbelt/Landlock command wrapper is - 12
//! applied — containment comes from spawning a fixed, discovered browser - 13
//! binary with fixed flags, a scrubbed allowlist environment, a fresh - 14
//! profile directory that is deleted afterwards, and its own process group - 15
//! killed on timeout/cancel. - 16
//! - 17
//! v1 SSRF honesty note: the guard below screens only the REQUESTED host. - 18
//! Once navigation is handed to Chromium, redirects and subresource fetches - 19
//! inside the browser resolve and connect on their own and are not - 20
//! re-screenable here. - 21
- 22
use std::path::{Path, PathBuf}; - 23
use std::sync::atomic::{AtomicU64, Ordering}; - 24
use std::time::{Duration, Instant}; - 25
- 26
use async_trait::async_trait; - 27
use serde_json::Value; - 28
- 29
use crate::webfetch::ssrf_guard; - 30
use crate::{ResourceClaims, Tool, ToolContext, ToolOutput}; - 31
- 32
const DEFAULT_WAIT_MS: u64 = 4000; - 33
const MAX_WAIT_MS: u64 = 10_000; - 34
const TOTAL_TIMEOUT_SECS: u64 = 20; - 35
const TOTAL_TIMEOUT: Duration = Duration::from_secs(TOTAL_TIMEOUT_SECS); - 36
const BROWSER_ENV: &str = "VAK_BROWSER"; - 37
- 38
pub struct WebBrowseTool; - 39
- 40
#[cfg(target_os = "macos")] - 41
const MACOS_BUNDLES: &[&str] = &[ - 42
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - 43
"/Applications/Chromium.app/Contents/MacOS/Chromium", - 44
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - 45
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", - 46
]; - 47
- 48
#[cfg(target_os = "linux")] - 49
const LINUX_NAMES: &[&str] = &[ - 50
"google-chrome", - 51
"chromium", - 52
"chromium-browser", - 53
"msedge", - 54
"brave-browser", - 55
]; - 56
- 57
/// Ordered candidate list: an explicit `VAK_BROWSER` override walks - 58
/// first; platform-native locations follow. - 59
fn default_candidates() -> Vec<PathBuf> { - 60
let mut out: Vec<PathBuf> = std::env::var_os(BROWSER_ENV) - 61
.filter(|v| !v.is_empty()) - 62
.map(PathBuf::from) - 63
.into_iter() - 64
.collect(); - 65
#[cfg(target_os = "macos")] - 66
out.extend(MACOS_BUNDLES.iter().map(PathBuf::from)); - 67
#[cfg(target_os = "linux")] - 68
if let Some(path_var) = std::env::var_os("PATH") { - 69
for dir in std::env::split_paths(&path_var) { - 70
for name in LINUX_NAMES { - 71
out.push(dir.join(name)); - 72
} - 73
} - 74
} - 75
out - 76
} - 77
- 78
/// First usable candidate wins; exhaustion fails closed naming every path - 79
/// tried so operators can see exactly what was searched. - 80
fn select_browser(candidates: &[PathBuf]) -> Result<PathBuf, String> { - 81
if candidates.is_empty() { - 82
return Err(format!( - 83
"no {BROWSER_ENV} override set and no browser locations known for this platform" - 84
)); - 85
} - 86
let tried: Vec<String> = candidates.iter().map(|p| p.display().to_string()).collect(); - 87
for path in candidates { - 88
if path.is_file() { - 89
return Ok(path.clone()); - 90
} - 91
} - 92
Err(format!( - 93
"no Chromium-family browser found; tried:\n{}", - 94
tried - 95
.iter() - 96
.map(|p| format!(" - {p}")) - 97
.collect::<Vec<_>>() - 98
.join("\n") - 99
)) - 100
} - 101
- 102
fn discover_browser() -> Result<PathBuf, String> { - 103
select_browser(&default_candidates()) - 104
} - 105
- 106
fn clamp_wait_ms(raw: Option<i64>) -> u64 { - 107
match raw { - 108
Some(n) if n > 0 => u64::try_from(n).unwrap_or(MAX_WAIT_MS).min(MAX_WAIT_MS), - 109
_ => DEFAULT_WAIT_MS, - 110
} - 111
} - 112
- 113
fn assemble_args(wait_ms: u64, url: &str, profile_dir: &Path) -> Vec<String> { - 114
vec![ - 115
"--headless=new".to_string(), - 116
"--disable-gpu".to_string(), - 117
"--no-first-run".to_string(), - 118
"--no-default-browser-check".to_string(), - 119
format!("--user-data-dir={}", profile_dir.display()), - 120
format!("--virtual-time-budget={wait_ms}"), - 121
"--dump-dom".to_string(), - 122
url.to_string(), - 123
] - 124
} - 125
- 126
static PROFILE_SEQ: AtomicU64 = AtomicU64::new(0); - 127
- 128
fn fresh_profile_dir() -> std::io::Result<PathBuf> { - 129
let n = PROFILE_SEQ.fetch_add(1, Ordering::Relaxed); - 130
let dir = std::env::temp_dir().join(format!( - 131
"vak-browse-{}-{}-{n}", - 132
std::process::id(), - 133
std::time::SystemTime::now() - 134
.duration_since(std::time::UNIX_EPOCH) - 135
.map(|d| d.as_nanos()) - 136
.unwrap_or_default() - 137
)); - 138
std::fs::create_dir_all(&dir)?; - 139
Ok(dir) - 140
} - 141
- 142
struct ProfileDir(PathBuf); - 143
- 144
impl Drop for ProfileDir { - 145
fn drop(&mut self) { - 146
let _ = std::fs::remove_dir_all(&self.0); - 147
} - 148
} - 149
- 150
fn parse_target(raw: &str) -> Result<reqwest::Url, String> { - 151
let url = reqwest::Url::parse(raw).map_err(|e| format!("invalid url: {e}"))?; - 152
match url.scheme() { - 153
"http" | "https" => {} - 154
other => return Err(format!("unsupported scheme \"{other}\" (only http/https)")), - 155
} - 156
if url.host_str().is_none_or(str::is_empty) { - 157
return Err("invalid url: missing host".to_string()); - 158
} - 159
Ok(url) - 160
} - 161
- 162
fn stderr_tail(err: &str) -> String { - 163
let trimmed = err.trim(); - 164
if trimmed.chars().count() <= 2000 { - 165
return trimmed.to_string(); - 166
} - 167
let skipped = trimmed.chars().count() - 2000; - 168
let tail: String = trimmed.chars().skip(skipped).collect(); - 169
format!("…{tail}") - 170
} - 171
- 172
fn basename(path: &Path) -> String { - 173
path.file_name() - 174
.map(|n| n.to_string_lossy().into_owned()) - 175
.unwrap_or_else(|| path.display().to_string()) - 176
} - 177
- 178
#[async_trait] - 179
impl Tool for WebBrowseTool { - 180
fn name(&self) -> &str { - 181
"browse" - 182
} - 183
- 184
fn serves(&self) -> &'static [&'static str] { - 185
&["web", "live-data"] - 186
} - 187
- 188
fn description(&self) -> &str { - 189
"Render a URL in a locally installed headless Chromium-family browser and return the JavaScript-rendered DOM. Applies the same private-range blocklist as webfetch to the requested host, runs with a fresh throwaway profile (never sends credentials), caps runtime at 20s, and returns a header line followed by the DOM. In-browser redirects are not re-screened in v1." - 190
} - 191
- 192
fn schema(&self) -> Value { - 193
serde_json::json!({ - 194
"type": "object", - 195
"properties": { - 196
"url": {"type": "string", "description": "Absolute http(s) URL to render"}, - 197
"wait_ms": {"type": "integer", "description": "Virtual-time budget in milliseconds (default 4000, capped at 10000; values <= 0 fall back to the default)"} - 198
}, - 199
"required": ["url"] - 200
}) - 201
} - 202
- 203
fn claims(&self, _args: &Value) -> ResourceClaims { - 204
ResourceClaims { - 205
exclusive: false, - 206
read_only: true, - 207
paths: Vec::new(), - 208
} - 209
} - 210
- 211
async fn execute(&self, args: &Value, ctx: &ToolContext) -> ToolOutput { - 212
if args.get("url").and_then(Value::as_str).is_none() { - 213
return ToolOutput::error("missing required parameter: url"); - 214
} - 215
tokio::select! { - 216
_ = ctx.cancel.cancelled() => ToolOutput::error("browse cancelled"), - 217
out = self.run(args, ctx) => out, - 218
} - 219
} - 220
} - 221
- 222
impl WebBrowseTool { - 223
async fn run(&self, args: &Value, ctx: &ToolContext) -> ToolOutput { - 224
// Unwrap-free re-extraction after the execute() pre-check keeps this - 225
// method total even when called directly in tests. - 226
let raw = match args.get("url").and_then(Value::as_str) { - 227
Some(r) => r, - 228
None => return ToolOutput::error("missing required parameter: url"), - 229
}; - 230
let wait_ms = clamp_wait_ms(args.get("wait_ms").and_then(Value::as_i64)); - 231
- 232
let url = match parse_target(raw) { - 233
Ok(u) => u, - 234
Err(e) => return ToolOutput::error(e), - 235
}; - 236
let Some(host) = url.host_str().map(str::to_string) else { - 237
return ToolOutput::error("invalid url: missing host"); - 238
}; - 239
let screened = match tokio::task::spawn_blocking(move || ssrf_guard(&host)).await { - 240
Ok(r) => r, - 241
Err(e) => return ToolOutput::error(format!("address screening failed: {e}")), - 242
}; - 243
if let Err(rejection) = screened { - 244
return ToolOutput::error(rejection.to_string()); - 245
} - 246
- 247
let browser = match discover_browser() { - 248
Ok(b) => b, - 249
Err(e) => return ToolOutput::error(e), - 250
}; - 251
let profile = match fresh_profile_dir() { - 252
Ok(p) => ProfileDir(p), - 253
Err(e) => return ToolOutput::error(format!("profile dir creation failed: {e}")), - 254
}; - 255
- 256
// Capture DOM/stderr via FILES, not pipes: Chromium helper - 257
// processes inherit pipe fds and never let EOF fire, while file - 258
// redirection makes output inspectable while Chrome still runs. - 259
let out_path = profile.0.join("dom.html"); - 260
let err_path = profile.0.join("stderr.txt"); - 261
let out_file = match std::fs::File::create(&out_path) { - 262
Ok(f) => f, - 263
Err(e) => return ToolOutput::error(format!("dom capture create failed: {e}")), - 264
}; - 265
let err_file = match std::fs::File::create(&err_path) { - 266
Ok(f) => f, - 267
Err(e) => return ToolOutput::error(format!("stderr capture create failed: {e}")), - 268
}; - 269
- 270
let mut cmd = tokio::process::Command::new(&browser); - 271
cmd.args(assemble_args(wait_ms, url.as_str(), &profile.0)) - 272
.stdin(std::process::Stdio::null()) - 273
.stdout(std::process::Stdio::from(out_file)) - 274
.stderr(std::process::Stdio::from(err_file)) - 275
.kill_on_drop(true); - 276
crate::bash::scrub_environment(&mut cmd); - 277
if std::env::var_os(crate::broker::WORKER_ENV).is_none() { - 278
crate::bash::isolate_process_group(&mut cmd); - 279
} - 280
- 281
let started = Instant::now(); - 282
let mut child = match cmd.spawn() { - 283
Ok(c) => c, - 284
Err(e) => { - 285
return ToolOutput::error(format!("failed to launch {}: {e}", basename(&browser))); - 286
} - 287
}; - 288
- 289
// Completion is content-based: --dump-dom writes the document then - 290
// frequently HANGS (headless=new quirk), so poll for a closed-html - 291
// sentinel (or natural exit) and kill the group either way. The - 292
// deadline is checked against elapsed time each cycle — a fresh - 293
// per-iteration timer would always lose to the 250 ms tick. - 294
let header_base = format!("[browse] GET {url} via {}", basename(&browser)); - 295
let started_at = Instant::now(); - 296
loop { - 297
if started_at.elapsed() >= TOTAL_TIMEOUT { - 298
crate::bash::kill_process_group(&child.id()); - 299
// macOS Chrome re-execs into a fresh process group, so the - 300
// group signal can miss the main binary entirely; a - 301
// direct-pid SIGKILL cannot. - 302
let _ = child.start_kill(); - 303
let _ = child.wait().await; - 304
let elapsed_ms = started_at.elapsed().as_millis(); - 305
return ToolOutput::error(format!( - 306
"{header_base} ({elapsed_ms} ms): exceeded its {TOTAL_TIMEOUT_SECS}s total timeout" - 307
)); - 308
} - 309
let wait = std::cmp::min( - 310
std::time::Duration::from_millis(250), - 311
TOTAL_TIMEOUT - started_at.elapsed(), - 312
); - 313
let mut done = false; - 314
tokio::select! { - 315
_ = ctx.cancel.cancelled() => { - 316
crate::bash::kill_process_group(&child.id()); - 317
// Direct-pid SIGKILL: see re-exec note above. - 318
let _ = child.start_kill(); - 319
let _ = child.wait().await; - 320
return ToolOutput::error(format!("{header_base}: browse cancelled")); - 321
} - 322
_ = tokio::time::sleep(wait) => { - 323
if matches!(child.try_wait(), Ok(Some(_))) { - 324
done = true; - 325
} else if let Ok(s) = std::fs::read_to_string(&out_path) - 326
&& s.to_ascii_lowercase().contains("</html>") - 327
{ - 328
done = true; - 329
} - 330
} - 331
} - 332
if done { - 333
break; - 334
} - 335
} - 336
crate::bash::kill_process_group(&child.id()); - 337
// Direct-pid SIGKILL: see re-exec note above. - 338
let _ = child.start_kill(); - 339
let _ = child.wait().await; - 340
- 341
let dom = std::fs::read_to_string(&out_path).unwrap_or_default(); - 342
let err = std::fs::read_to_string(&err_path).unwrap_or_default(); - 343
let elapsed_ms = started.elapsed().as_millis(); - 344
let header = format!("{header_base} ({elapsed_ms} ms, {} bytes)", dom.len()); - 345
let body = if dom.is_empty() { - 346
let tail = stderr_tail(&err); - 347
if tail.is_empty() { - 348
"(no DOM captured)".to_string() - 349
} else { - 350
format!("(no DOM captured)\nbrowser stderr: {tail}") - 351
} - 352
} else { - 353
dom - 354
}; - 355
ToolOutput::ok(format!("{header}\n{body}")) - 356
} - 357
} - 358
- 359
#[cfg(test)] - 360
mod tests { - 361
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 362
- 363
use super::*; - 364
use crate::context::shared_ctx; - 365
- 366
fn fake_binary(dir: &Path, name: &str) -> PathBuf { - 367
let p = dir.join(name); - 368
std::fs::write(&p, "#!/bin/sh\nexit 7\n").expect("write fake binary"); - 369
#[cfg(unix)] - 370
{ - 371
use std::os::unix::fs::PermissionsExt; - 372
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)) - 373
.expect("chmod fake binary"); - 374
} - 375
p - 376
} - 377
- 378
#[test] - 379
fn discovery_matrix_picks_first_usable_candidate_in_order() { - 380
let dir = tempfile::tempdir().unwrap(); - 381
let a = fake_binary(dir.path(), "chrome-a"); - 382
let b = fake_binary(dir.path(), "chrome-b"); - 383
let missing = dir.path().join("does-not-exist"); - 384
- 385
assert_eq!(select_browser(&[missing.clone(), a.clone(), b]).unwrap(), a); - 386
- 387
let none = select_browser(&[dir.path().join("x"), dir.path().join("y")]); - 388
assert!(none.is_err()); - 389
} - 390
- 391
#[test] - 392
fn explicit_override_wins_over_existing_candidates() { - 393
let dir = tempfile::tempdir().unwrap(); - 394
let pinned = fake_binary(dir.path(), "pinned-browser"); - 395
let fallback = fake_binary(dir.path(), "fallback-browser"); - 396
- 397
let chosen = - 398
select_browser(&[pinned.clone(), fallback.clone(), PathBuf::from("/nope")]).unwrap(); - 399
assert_eq!(chosen, pinned, "override must walk first"); - 400
- 401
// The override participates in the ordered walk like any candidate: - 402
// remove it and the next existing entry takes over. - 403
std::fs::remove_file(&pinned).unwrap(); - 404
assert_eq!( - 405
select_browser(&[pinned.clone(), fallback.clone()]).unwrap(), - 406
fallback - 407
); - 408
} - 409
- 410
#[test] - 411
fn missing_browser_error_lists_every_tried_path_and_the_env_hint() { - 412
let dir = tempfile::tempdir().unwrap(); - 413
let c1 = dir.path().join("nowhere-one"); - 414
let c2 = dir.path().join("nowhere-two"); - 415
let err = select_browser(&[c1.clone(), c2.clone()]).unwrap_err(); - 416
assert!(err.contains("no Chromium-family browser found"), "{err}"); - 417
assert!(err.contains(&c1.display().to_string()), "{err}"); - 418
assert!(err.contains(&c2.display().to_string()), "{err}"); - 419
} - 420
- 421
#[test] - 422
fn empty_candidate_list_fails_closed_with_reason() { - 423
let err = select_browser(&[]).unwrap_err(); - 424
assert!(err.contains(BROWSER_ENV), "{err}"); - 425
} - 426
- 427
#[test] - 428
fn wait_ms_clamping_matches_contract() { - 429
assert_eq!(clamp_wait_ms(None), 4000); - 430
assert_eq!(clamp_wait_ms(Some(0)), 4000); - 431
assert_eq!(clamp_wait_ms(Some(-5)), 4000); - 432
assert_eq!(clamp_wait_ms(Some(15000)), 10000); - 433
assert_eq!(clamp_wait_ms(Some(2500)), 2500); - 434
assert_eq!(clamp_wait_ms(Some(i64::MAX)), 10000); - 435
} - 436
- 437
#[test] - 438
fn arg_assembly_carries_fixed_flags_profile_budget_then_url() { - 439
let profile = Path::new("/tmp/fake-profile"); - 440
let args = assemble_args(clamp_wait_ms(Some(15000)), "https://example.com/", profile); - 441
assert_eq!( - 442
args, - 443
vec![ - 444
"--headless=new".to_string(), - 445
"--disable-gpu".to_string(), - 446
"--no-first-run".to_string(), - 447
"--no-default-browser-check".to_string(), - 448
format!("--user-data-dir={}", profile.display()), - 449
"--virtual-time-budget=10000".to_string(), - 450
"--dump-dom".to_string(), - 451
"https://example.com/".to_string(), - 452
] - 453
); - 454
let default_args = assemble_args(clamp_wait_ms(None), "https://example.com/", profile); - 455
assert!(default_args.contains(&"--virtual-time-budget=4000".to_string())); - 456
} - 457
- 458
#[test] - 459
fn fresh_user_data_dir_is_unique_per_call_and_removed_by_guard() { - 460
let first = fresh_profile_dir().unwrap(); - 461
let second = fresh_profile_dir().unwrap(); - 462
assert_ne!(first, second); - 463
assert!(first.is_dir()); - 464
drop(ProfileDir(first.clone())); - 465
assert!(!first.exists()); - 466
let second_path = second.clone(); - 467
drop(ProfileDir(second)); - 468
assert!(!second_path.exists()); - 469
} - 470
- 471
#[tokio::test] - 472
async fn ssrf_screen_blocks_dangerous_targets_before_any_spawn() { - 473
let cases = [ - 474
("http://127.0.0.1/x", "loopback"), - 475
("http://169.254.169.254/latest/meta-data/", "link-local"), - 476
("http://[::ffff:10.0.0.1]/x", "private"), - 477
]; - 478
let ctx = shared_ctx(std::path::Path::new(".")); - 479
for (url, class) in cases { - 480
let out = WebBrowseTool - 481
.execute(&serde_json::json!({"url": url}), &ctx) - 482
.await; - 483
assert!(out.is_error, "expected {url} to be blocked"); - 484
assert!( - 485
out.content.contains("blocked") && out.content.contains(class), - 486
"unexpected message for {url}: {}", - 487
out.content - 488
); - 489
} - 490
} - 491
- 492
#[tokio::test] - 493
async fn unsupported_scheme_rejected_before_any_spawn() { - 494
let ctx = shared_ctx(std::path::Path::new(".")); - 495
let out = WebBrowseTool - 496
.execute(&serde_json::json!({"url": "file:///etc/passwd"}), &ctx) - 497
.await; - 498
assert!(out.is_error); - 499
assert!(out.content.contains("scheme"), "{}", out.content); - 500
- 501
let out = WebBrowseTool.execute(&serde_json::json!({}), &ctx).await; - 502
assert!(out.is_error); - 503
assert!(out.content.contains("missing required parameter")); - 504
} - 505
- 506
#[test] - 507
fn parse_target_accepts_only_http_https_with_host() { - 508
assert!(parse_target("https://example.com/").is_ok()); - 509
assert!(parse_target("http://example.com/x?y=1").is_ok()); - 510
assert!(parse_target("ftp://example.com/").is_err()); - 511
assert!(parse_target("javascript:alert(1)").is_err()); - 512
assert!(parse_target("not a url").is_err()); - 513
} - 514
- 515
#[tokio::test] - 516
#[ignore = "requires a locally installed Chromium-family browser and network access"] - 517
async fn live_browse_returns_header_line_and_dom() { - 518
if discover_browser().is_err() { - 519
eprintln!("skipping: no local browser found"); - 520
return; - 521
} - 522
let ctx = shared_ctx(std::path::Path::new(".")); - 523
let out = WebBrowseTool - 524
.execute( - 525
&serde_json::json!({"url": "https://example.com/", "wait_ms": 2000}), - 526
&ctx, - 527
) - 528
.await; - 529
assert!(!out.is_error, "live browse failed: {}", out.content); - 530
assert!(out.content.starts_with("[browse] GET "), "{}", out.content); - 531
} - 532
} - 533
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.