1use std::collections::HashMap;
2use std::net::{IpAddr, Ipv6Addr};
3use std::path::{Path, PathBuf};
4use std::process::Stdio;
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::Arc;
7
8use anyhow::{anyhow, Context};
9use async_trait::async_trait;
10use base64::Engine;
11use flate2::read::GzDecoder;
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use tandem_browser::{
15 detect_sidecar_binary_path, run_doctor, BrowserActionResult, BrowserArtifactRef,
16 BrowserBlockingIssue, BrowserCloseParams, BrowserCloseResult, BrowserDoctorOptions,
17 BrowserExtractParams, BrowserExtractResult, BrowserNavigateParams, BrowserNavigateResult,
18 BrowserOpenRequest, BrowserOpenResult, BrowserPressParams, BrowserRpcRequest,
19 BrowserRpcResponse, BrowserScreenshotParams, BrowserScreenshotResult, BrowserSnapshotParams,
20 BrowserSnapshotResult, BrowserStatus, BrowserTypeParams, BrowserViewport, BrowserWaitCondition,
21 BrowserWaitParams, BROWSER_PROTOCOL_VERSION,
22};
23use tandem_core::{resolve_shared_paths, BrowserConfig};
24use tandem_tools::{Tool, ToolRegistry};
25use tandem_types::{EngineEvent, ToolResult, ToolSchema};
26use tokio::fs;
27use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
28use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
29use tokio::sync::{Mutex, RwLock};
30use uuid::Uuid;
31
32use crate::{now_ms, AppState, RoutineRunArtifact, RuntimeState};
33
34const STATUS_CACHE_MAX_AGE_MS: u64 = 30_000;
35const INLINE_EXTRACT_LIMIT_BYTES: usize = 24_000;
36const SNAPSHOT_SCREENSHOT_LABEL: &str = "browser snapshot";
37const RELEASE_REPO: &str = "frumu-ai/tandem";
38const RELEASES_URL_ENV: &str = "TANDEM_BROWSER_RELEASES_URL";
39const BROWSER_INSTALL_USER_AGENT: &str = "tandem-browser-installer";
40
41#[derive(Debug)]
42struct BrowserSidecarClient {
43 _child: Child,
44 stdin: ChildStdin,
45 stdout: BufReader<ChildStdout>,
46 stderr: BufReader<ChildStderr>,
47 next_id: u64,
48}
49
50#[derive(Debug, Clone)]
51struct ManagedBrowserSession {
52 owner_session_id: Option<String>,
53 current_url: String,
54 _created_at_ms: u64,
55 updated_at_ms: u64,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59pub struct BrowserHealthSummary {
60 pub enabled: bool,
61 pub runnable: bool,
62 pub tools_registered: bool,
63 pub sidecar_found: bool,
64 pub browser_found: bool,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub browser_version: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub last_checked_at_ms: Option<u64>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub last_error: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct BrowserSidecarInstallResult {
75 pub version: String,
76 pub asset_name: String,
77 pub installed_path: String,
78 pub downloaded_bytes: u64,
79 pub status: BrowserStatus,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct BrowserSmokeTestResult {
84 pub ok: bool,
85 pub status: BrowserStatus,
86 pub url: String,
87 pub final_url: String,
88 pub title: String,
89 pub load_state: String,
90 pub element_count: usize,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub excerpt: Option<String>,
93 pub closed: bool,
94}
95
96#[derive(Debug, Clone, Deserialize)]
97struct GitHubRelease {
98 tag_name: String,
99 assets: Vec<GitHubAsset>,
100}
101
102#[derive(Debug, Clone, Deserialize)]
103struct GitHubAsset {
104 name: String,
105 browser_download_url: String,
106 size: u64,
107}
108
109#[derive(Clone)]
110pub struct BrowserSubsystem {
111 config: BrowserConfig,
112 status: Arc<RwLock<BrowserStatus>>,
113 tools_registered: Arc<AtomicBool>,
114 client: Arc<Mutex<Option<BrowserSidecarClient>>>,
115 sessions: Arc<RwLock<HashMap<String, ManagedBrowserSession>>>,
116 artifact_root: PathBuf,
117}
118
119#[derive(Clone, Copy)]
120enum BrowserToolKind {
121 Status,
122 Open,
123 Navigate,
124 Snapshot,
125 Click,
126 Type,
127 Press,
128 Wait,
129 Extract,
130 Screenshot,
131 Close,
132}
133
134#[derive(Clone)]
135pub struct BrowserTool {
136 kind: BrowserToolKind,
137 browser: BrowserSubsystem,
138 state: Option<AppState>,
139}
140
141#[derive(Debug, Deserialize)]
142struct BrowserTypeToolArgs {
143 session_id: String,
144 #[serde(default)]
145 element_id: Option<String>,
146 #[serde(default)]
147 selector: Option<String>,
148 #[serde(default)]
149 text: Option<String>,
150 #[serde(default)]
151 secret_ref: Option<String>,
152 #[serde(default)]
153 replace: bool,
154 #[serde(default)]
155 submit: bool,
156 #[serde(default)]
157 timeout_ms: Option<u64>,
158}
159
160#[derive(Debug, Deserialize, Default)]
161struct BrowserWaitConditionArgs {
162 #[serde(default, alias = "type")]
163 kind: Option<String>,
164 #[serde(default)]
165 value: Option<String>,
166 #[serde(default)]
167 selector: Option<String>,
168 #[serde(default)]
169 text: Option<String>,
170 #[serde(default)]
171 url: Option<String>,
172}
173
174#[derive(Debug, Deserialize)]
175struct BrowserWaitToolArgs {
176 #[serde(alias = "sessionId")]
177 session_id: String,
178 #[serde(default, alias = "wait_for", alias = "waitFor")]
179 condition: Option<BrowserWaitConditionArgs>,
180 #[serde(default, alias = "timeoutMs")]
181 timeout_ms: Option<u64>,
182 #[serde(default, alias = "type")]
183 kind: Option<String>,
184 #[serde(default)]
185 value: Option<String>,
186 #[serde(default)]
187 selector: Option<String>,
188 #[serde(default)]
189 text: Option<String>,
190 #[serde(default)]
191 url: Option<String>,
192}
193
194#[derive(Debug, Deserialize)]
195struct BrowserToolContext {
196 #[serde(default, rename = "__session_id")]
197 model_session_id: Option<String>,
198}
199
200impl BrowserSidecarClient {
201 async fn spawn(config: &BrowserConfig) -> anyhow::Result<Self> {
202 let sidecar_path = detect_sidecar_binary_path(config.sidecar_path.as_deref())
203 .ok_or_else(|| anyhow!("browser_sidecar_not_found"))?;
204 let mut cmd = Command::new(&sidecar_path);
205 cmd.arg("serve")
206 .arg("--transport")
207 .arg("stdio")
208 .stdin(Stdio::piped())
209 .stdout(Stdio::piped())
210 .stderr(Stdio::piped());
211 if let Some(path) = config
212 .executable_path
213 .as_deref()
214 .filter(|v| !v.trim().is_empty())
215 {
216 cmd.env("TANDEM_BROWSER_EXECUTABLE", path);
217 }
218 if let Some(path) = config
219 .user_data_root
220 .as_deref()
221 .filter(|v| !v.trim().is_empty())
222 {
223 cmd.env("TANDEM_BROWSER_USER_DATA_ROOT", path);
224 }
225 cmd.env(
226 "TANDEM_BROWSER_ALLOW_NO_SANDBOX",
227 bool_env_value(config.allow_no_sandbox),
228 );
229 cmd.env(
230 "TANDEM_BROWSER_HEADLESS",
231 bool_env_value(config.headless_default),
232 );
233
234 let mut child = cmd.spawn().with_context(|| {
235 format!(
236 "failed to spawn tandem-browser sidecar at `{}`",
237 sidecar_path.display()
238 )
239 })?;
240 let stdin = child
241 .stdin
242 .take()
243 .ok_or_else(|| anyhow!("browser sidecar stdin unavailable"))?;
244 let stdout = child
245 .stdout
246 .take()
247 .ok_or_else(|| anyhow!("browser sidecar stdout unavailable"))?;
248 let stderr = child
249 .stderr
250 .take()
251 .ok_or_else(|| anyhow!("browser sidecar stderr unavailable"))?;
252 let mut client = Self {
253 _child: child,
254 stdin,
255 stdout: BufReader::new(stdout),
256 stderr: BufReader::new(stderr),
257 next_id: 1,
258 };
259 let version: Value = client.call_raw("browser.version", json!({})).await?;
260 let protocol = version
261 .get("protocol_version")
262 .and_then(Value::as_str)
263 .unwrap_or("");
264 if protocol != BROWSER_PROTOCOL_VERSION {
265 anyhow::bail!(
266 "protocol_mismatch: expected browser protocol {}, got {}",
267 BROWSER_PROTOCOL_VERSION,
268 protocol
269 );
270 }
271 Ok(client)
272 }
273
274 async fn call_raw(&mut self, method: &str, params: Value) -> anyhow::Result<Value> {
275 let id = self.next_id;
276 self.next_id = self.next_id.saturating_add(1);
277 let request = BrowserRpcRequest {
278 jsonrpc: "2.0".to_string(),
279 id: json!(id),
280 method: method.to_string(),
281 params,
282 };
283 let raw = serde_json::to_string(&request)?;
284 self.stdin.write_all(raw.as_bytes()).await?;
285 self.stdin.write_all(b"\n").await?;
286 self.stdin.flush().await?;
287
288 let mut line = String::new();
289 let read = self.stdout.read_line(&mut line).await?;
290 if read == 0 {
291 let mut stderr = String::new();
292 let _ = self.stderr.read_to_string(&mut stderr).await;
293 let stderr = stderr.trim();
294 if stderr.is_empty() {
295 anyhow::bail!("browser sidecar closed the stdio connection");
296 }
297 anyhow::bail!(
298 "browser sidecar closed the stdio connection: {}",
299 smoke_excerpt(stderr, 600)
300 );
301 }
302 let response: BrowserRpcResponse =
303 serde_json::from_str(line.trim()).context("invalid browser sidecar response")?;
304 if let Some(error) = response.error {
305 anyhow::bail!("{}", error.message);
306 }
307 response
308 .result
309 .ok_or_else(|| anyhow!("browser sidecar returned an empty result"))
310 }
311
312 async fn call<T: Serialize, R: for<'de> Deserialize<'de>>(
313 &mut self,
314 method: &str,
315 params: T,
316 ) -> anyhow::Result<R> {
317 let value = self.call_raw(method, serde_json::to_value(params)?).await?;
318 serde_json::from_value(value).context("invalid browser sidecar payload")
319 }
320
321 async fn call_value<R: for<'de> Deserialize<'de>>(
322 &mut self,
323 method: &str,
324 params: Value,
325 ) -> anyhow::Result<R> {
326 let value = self.call_raw(method, params).await?;
327 serde_json::from_value(value).context("invalid browser sidecar payload")
328 }
329}
330
331impl BrowserSubsystem {
332 pub fn new(config: BrowserConfig) -> Self {
333 let artifact_root = resolve_shared_paths()
334 .map(|paths| paths.canonical_root.join("browser-artifacts"))
335 .unwrap_or_else(|_| PathBuf::from(".tandem").join("browser-artifacts"));
336 Self {
337 config,
338 status: Arc::new(RwLock::new(BrowserStatus::default())),
339 tools_registered: Arc::new(AtomicBool::new(false)),
340 client: Arc::new(Mutex::new(None)),
341 sessions: Arc::new(RwLock::new(HashMap::new())),
342 artifact_root,
343 }
344 }
345
346 pub fn config(&self) -> &BrowserConfig {
347 &self.config
348 }
349
350 pub async fn install_sidecar(&self) -> anyhow::Result<BrowserSidecarInstallResult> {
351 let mut result = install_browser_sidecar(&self.config).await?;
352 result.status = self.refresh_status().await;
353 Ok(result)
354 }
355
356 pub async fn smoke_test(&self, url: Option<String>) -> anyhow::Result<BrowserSmokeTestResult> {
357 let status = self.status_snapshot().await;
358 if !status.runnable {
359 anyhow::bail!(
360 "browser_not_runnable: run browser doctor first; current status is not runnable"
361 );
362 }
363
364 let target_url = url
365 .map(|value| value.trim().to_string())
366 .filter(|value| !value.is_empty())
367 .unwrap_or_else(|| "https://example.com".to_string());
368 let request = BrowserOpenRequest {
369 url: target_url.clone(),
370 profile_id: None,
371 headless: Some(self.config.headless_default),
372 viewport: Some(BrowserViewport {
373 width: self.config.default_viewport.width,
374 height: self.config.default_viewport.height,
375 }),
376 wait_until: Some("navigation".to_string()),
377 executable_path: self.config.executable_path.clone(),
378 user_data_root: self.config.user_data_root.clone(),
379 allow_no_sandbox: self.config.allow_no_sandbox,
380 headless_default: self.config.headless_default,
381 };
382 let opened: BrowserOpenResult = self.call_sidecar("browser.open", request).await?;
383 let session_id = opened.session_id.clone();
384
385 let result = async {
386 let snapshot: BrowserSnapshotResult = self
387 .call_sidecar(
388 "browser.snapshot",
389 BrowserSnapshotParams {
390 session_id: session_id.clone(),
391 max_elements: Some(25),
392 include_screenshot: false,
393 },
394 )
395 .await?;
396 let extract: BrowserExtractResult = self
397 .call_sidecar(
398 "browser.extract",
399 BrowserExtractParams {
400 session_id: session_id.clone(),
401 format: "visible_text".to_string(),
402 max_bytes: Some(4_000),
403 },
404 )
405 .await?;
406 Ok::<BrowserSmokeTestResult, anyhow::Error>(BrowserSmokeTestResult {
407 ok: true,
408 status,
409 url: target_url,
410 final_url: snapshot.url,
411 title: snapshot.title,
412 load_state: snapshot.load_state,
413 element_count: snapshot.elements.len(),
414 excerpt: Some(smoke_excerpt(&extract.content, 400)),
415 closed: false,
416 })
417 }
418 .await;
419
420 let close_result: BrowserCloseResult = self
421 .call_sidecar(
422 "browser.close",
423 BrowserCloseParams {
424 session_id: session_id.clone(),
425 },
426 )
427 .await
428 .unwrap_or(BrowserCloseResult {
429 session_id,
430 closed: false,
431 });
432
433 let mut smoke = result?;
434 smoke.closed = close_result.closed;
435 Ok(smoke)
436 }
437
438 pub async fn refresh_status(&self) -> BrowserStatus {
439 let config = self.config.clone();
440 let evaluated = tokio::task::spawn_blocking(move || evaluate_browser_status(config))
441 .await
442 .unwrap_or_else(|err| BrowserStatus {
443 enabled: false,
444 runnable: false,
445 headless_default: true,
446 sidecar: Default::default(),
447 browser: Default::default(),
448 blocking_issues: vec![BrowserBlockingIssue {
449 code: "browser_launch_failed".to_string(),
450 message: format!("browser readiness task failed: {}", err),
451 }],
452 recommendations: vec![
453 "Run `tandem-engine browser doctor --json` on the same host.".to_string(),
454 ],
455 install_hints: Vec::new(),
456 last_checked_at_ms: Some(now_ms()),
457 last_error: Some(err.to_string()),
458 });
459 *self.status.write().await = evaluated.clone();
460 evaluated
461 }
462
463 pub async fn status_snapshot(&self) -> BrowserStatus {
464 let current = self.status.read().await.clone();
465 if current
466 .last_checked_at_ms
467 .is_some_and(|ts| now_ms().saturating_sub(ts) <= STATUS_CACHE_MAX_AGE_MS)
468 {
469 current
470 } else {
471 self.refresh_status().await
472 }
473 }
474
475 pub async fn health_summary(&self) -> BrowserHealthSummary {
476 let status = self.status.read().await.clone();
477 BrowserHealthSummary {
478 enabled: status.enabled,
479 runnable: status.runnable,
480 tools_registered: self.tools_registered.load(Ordering::Relaxed),
481 sidecar_found: status.sidecar.found,
482 browser_found: status.browser.found,
483 browser_version: status.browser.version,
484 last_checked_at_ms: status.last_checked_at_ms,
485 last_error: status.last_error,
486 }
487 }
488
489 pub fn set_tools_registered(&self, value: bool) {
490 self.tools_registered.store(value, Ordering::Relaxed);
491 }
492
493 pub async fn register_tools(
494 &self,
495 tools: &ToolRegistry,
496 state: Option<AppState>,
497 ) -> anyhow::Result<()> {
498 tools.unregister_by_prefix("browser_").await;
499 tools
500 .register_tool(
501 "browser_status".to_string(),
502 Arc::new(BrowserTool::new(
503 BrowserToolKind::Status,
504 self.clone(),
505 state.clone(),
506 )),
507 )
508 .await;
509
510 let status = self.status_snapshot().await;
511 if !status.enabled || !status.runnable {
512 self.set_tools_registered(false);
513 return Ok(());
514 }
515
516 for (name, kind) in [
517 ("browser_open", BrowserToolKind::Open),
518 ("browser_navigate", BrowserToolKind::Navigate),
519 ("browser_snapshot", BrowserToolKind::Snapshot),
520 ("browser_click", BrowserToolKind::Click),
521 ("browser_type", BrowserToolKind::Type),
522 ("browser_press", BrowserToolKind::Press),
523 ("browser_wait", BrowserToolKind::Wait),
524 ("browser_extract", BrowserToolKind::Extract),
525 ("browser_screenshot", BrowserToolKind::Screenshot),
526 ("browser_close", BrowserToolKind::Close),
527 ] {
528 tools
529 .register_tool(
530 name.to_string(),
531 Arc::new(BrowserTool::new(kind, self.clone(), state.clone())),
532 )
533 .await;
534 }
535 self.set_tools_registered(true);
536 Ok(())
537 }
538
539 async fn update_last_error(&self, message: impl Into<String>) {
540 let mut status = self.status.write().await;
541 status.last_error = Some(message.into());
542 status.last_checked_at_ms = Some(now_ms());
543 }
544
545 async fn call_sidecar<T: Serialize, R: for<'de> Deserialize<'de>>(
546 &self,
547 method: &str,
548 params: T,
549 ) -> anyhow::Result<R> {
550 let params = serde_json::to_value(params)?;
551 let mut guard = self.client.lock().await;
552 if guard.is_none() {
553 *guard = Some(BrowserSidecarClient::spawn(&self.config).await?);
554 }
555 let result = guard
556 .as_mut()
557 .expect("browser sidecar client initialized")
558 .call_value(method, params.clone())
559 .await;
560 if let Err(err) = &result {
561 *guard = None;
562 self.update_last_error(err.to_string()).await;
563 if err
564 .to_string()
565 .contains("browser sidecar closed the stdio connection")
566 {
567 *guard = Some(BrowserSidecarClient::spawn(&self.config).await?);
568 return guard
569 .as_mut()
570 .expect("browser sidecar client reinitialized")
571 .call_value(method, params)
572 .await;
573 }
574 }
575 result
576 }
577
578 async fn insert_session(
579 &self,
580 browser_session_id: String,
581 owner_session_id: Option<String>,
582 current_url: String,
583 ) {
584 self.sessions.write().await.insert(
585 browser_session_id,
586 ManagedBrowserSession {
587 owner_session_id,
588 current_url,
589 _created_at_ms: now_ms(),
590 updated_at_ms: now_ms(),
591 },
592 );
593 }
594
595 async fn session(&self, browser_session_id: &str) -> Option<ManagedBrowserSession> {
596 self.sessions.read().await.get(browser_session_id).cloned()
597 }
598
599 async fn update_session_url(
600 &self,
601 browser_session_id: &str,
602 current_url: String,
603 ) -> Option<ManagedBrowserSession> {
604 let mut sessions = self.sessions.write().await;
605 let session = sessions.get_mut(browser_session_id)?;
606 session.current_url = current_url;
607 session.updated_at_ms = now_ms();
608 Some(session.clone())
609 }
610
611 async fn remove_session(&self, browser_session_id: &str) -> Option<ManagedBrowserSession> {
612 self.sessions.write().await.remove(browser_session_id)
613 }
614
615 pub async fn close_sessions_for_owner(&self, owner_session_id: &str) -> usize {
616 let session_ids = self
617 .sessions
618 .read()
619 .await
620 .iter()
621 .filter_map(|(session_id, session)| {
622 (session.owner_session_id.as_deref() == Some(owner_session_id))
623 .then_some(session_id.clone())
624 })
625 .collect::<Vec<_>>();
626 self.close_session_ids(session_ids).await
627 }
628
629 pub async fn close_all_sessions(&self) -> usize {
630 let session_ids = self
631 .sessions
632 .read()
633 .await
634 .keys()
635 .cloned()
636 .collect::<Vec<_>>();
637 self.close_session_ids(session_ids).await
638 }
639
640 async fn close_session_ids(&self, session_ids: Vec<String>) -> usize {
641 let mut closed = 0usize;
642 for session_id in session_ids {
643 let _ = self
644 .call_sidecar::<_, BrowserCloseResult>(
645 "browser.close",
646 BrowserCloseParams {
647 session_id: session_id.clone(),
648 },
649 )
650 .await;
651 if self.remove_session(&session_id).await.is_some() {
652 closed += 1;
653 }
654 }
655 closed
656 }
657}
658
659impl BrowserTool {
660 fn new(kind: BrowserToolKind, browser: BrowserSubsystem, state: Option<AppState>) -> Self {
661 Self {
662 kind,
663 browser,
664 state,
665 }
666 }
667
668 async fn execute_impl(&self, args: Value) -> anyhow::Result<ToolResult> {
669 match self.kind {
670 BrowserToolKind::Status => self.execute_status().await,
671 BrowserToolKind::Open => self.execute_open(args).await,
672 BrowserToolKind::Navigate => self.execute_navigate(args).await,
673 BrowserToolKind::Snapshot => self.execute_snapshot(args).await,
674 BrowserToolKind::Click => self.execute_click(args).await,
675 BrowserToolKind::Type => self.execute_type(args).await,
676 BrowserToolKind::Press => self.execute_press(args).await,
677 BrowserToolKind::Wait => self.execute_wait(args).await,
678 BrowserToolKind::Extract => self.execute_extract(args).await,
679 BrowserToolKind::Screenshot => self.execute_screenshot(args).await,
680 BrowserToolKind::Close => self.execute_close(args).await,
681 }
682 }
683
684 async fn execute_status(&self) -> anyhow::Result<ToolResult> {
685 let status = self.browser.status_snapshot().await;
686 ok_tool_result(
687 serde_json::to_value(&status)?,
688 json!({
689 "enabled": status.enabled,
690 "runnable": status.runnable,
691 "sidecar_found": status.sidecar.found,
692 "browser_found": status.browser.found,
693 }),
694 )
695 }
696
697 async fn execute_open(&self, args: Value) -> anyhow::Result<ToolResult> {
698 let ctx = parse_tool_context(&args);
699 let mut request: BrowserOpenRequest =
700 serde_json::from_value(args.clone()).context("invalid browser_open arguments")?;
701 normalize_browser_open_request(&mut request);
702 let status = self.browser.status_snapshot().await;
703 if !status.runnable {
704 return browser_not_runnable_result(&status);
705 }
706 ensure_allowed_browser_url(
707 &request.url,
708 &self
709 .effective_allowed_hosts(ctx.model_session_id.as_deref())
710 .await,
711 )?;
712 request.executable_path = self.browser.config.executable_path.clone();
713 request.user_data_root = self.browser.config.user_data_root.clone();
714 request.allow_no_sandbox = self.browser.config.allow_no_sandbox;
715 request.headless_default = self.browser.config.headless_default;
716 if request.viewport.is_none() {
717 request.viewport = Some(BrowserViewport {
718 width: self.browser.config.default_viewport.width,
719 height: self.browser.config.default_viewport.height,
720 });
721 }
722 let result: BrowserOpenResult = self.browser.call_sidecar("browser.open", request).await?;
723 ensure_allowed_browser_url(
724 &result.final_url,
725 &self
726 .effective_allowed_hosts(ctx.model_session_id.as_deref())
727 .await,
728 )
729 .map_err(|err| anyhow!("host_not_allowed: {}", err))?;
730 self.browser
731 .insert_session(
732 result.session_id.clone(),
733 ctx.model_session_id.clone(),
734 result.final_url.clone(),
735 )
736 .await;
737 ok_tool_result(
738 serde_json::to_value(&result)?,
739 json!({
740 "session_id": result.session_id,
741 "url": result.final_url,
742 "headless": result.headless,
743 }),
744 )
745 }
746
747 async fn execute_navigate(&self, args: Value) -> anyhow::Result<ToolResult> {
748 let ctx = parse_tool_context(&args);
749 let params: BrowserNavigateParams =
750 serde_json::from_value(args.clone()).context("invalid browser_navigate arguments")?;
751 let session = self
752 .load_session(¶ms.session_id, ctx.model_session_id.as_deref())
753 .await?;
754 ensure_allowed_browser_url(
755 ¶ms.url,
756 &self
757 .effective_allowed_hosts(session.owner_session_id.as_deref())
758 .await,
759 )?;
760 let result: BrowserNavigateResult = self
761 .browser
762 .call_sidecar("browser.navigate", params.clone())
763 .await?;
764 self.enforce_post_navigation(
765 ¶ms.session_id,
766 &result.final_url,
767 session.owner_session_id.as_deref(),
768 )
769 .await?;
770 ok_tool_result(
771 serde_json::to_value(&result)?,
772 json!({
773 "session_id": result.session_id,
774 "url": result.final_url,
775 }),
776 )
777 }
778
779 async fn execute_snapshot(&self, args: Value) -> anyhow::Result<ToolResult> {
780 let ctx = parse_tool_context(&args);
781 let params: BrowserSnapshotParams =
782 serde_json::from_value(args.clone()).context("invalid browser_snapshot arguments")?;
783 let session = self
784 .load_session(¶ms.session_id, ctx.model_session_id.as_deref())
785 .await?;
786 self.ensure_page_read_allowed(session.owner_session_id.as_deref(), &session.current_url)
787 .await?;
788 let mut result: BrowserSnapshotResult = self
789 .browser
790 .call_sidecar("browser.snapshot", params.clone())
791 .await?;
792 self.browser
793 .update_session_url(¶ms.session_id, result.url.clone())
794 .await;
795
796 let screenshot_artifact = if let Some(base64) = result.screenshot_base64.take() {
797 Some(
798 self.store_artifact(
799 ctx.model_session_id.as_deref(),
800 ¶ms.session_id,
801 "screenshot",
802 params
803 .include_screenshot
804 .then_some(SNAPSHOT_SCREENSHOT_LABEL.to_string()),
805 "png",
806 &base64::engine::general_purpose::STANDARD
807 .decode(base64.as_bytes())
808 .context("invalid snapshot screenshot payload")?,
809 Some(json!({
810 "source": "browser_snapshot",
811 "url": result.url,
812 })),
813 )
814 .await?,
815 )
816 } else {
817 None
818 };
819 let payload = json!({
820 "session_id": result.session_id,
821 "url": result.url,
822 "title": result.title,
823 "load_state": result.load_state,
824 "viewport": result.viewport,
825 "elements": result.elements,
826 "notices": result.notices,
827 "screenshot_artifact": screenshot_artifact,
828 });
829 ok_tool_result(
830 payload.clone(),
831 json!({
832 "session_id": payload.get("session_id"),
833 "url": payload.get("url"),
834 "element_count": payload.get("elements").and_then(Value::as_array).map(|rows| rows.len()).unwrap_or(0),
835 }),
836 )
837 }
838
839 async fn execute_click(&self, args: Value) -> anyhow::Result<ToolResult> {
840 let ctx = parse_tool_context(&args);
841 let params: tandem_browser::BrowserClickParams =
842 serde_json::from_value(args.clone()).context("invalid browser_click arguments")?;
843 let session = self
844 .load_session(¶ms.session_id, ctx.model_session_id.as_deref())
845 .await?;
846 self.ensure_action_allowed(session.owner_session_id.as_deref(), &session.current_url)
847 .await?;
848 let result: BrowserActionResult = self
849 .browser
850 .call_sidecar("browser.click", params.clone())
851 .await?;
852 self.update_action_url(
853 ¶ms.session_id,
854 result.final_url.as_deref(),
855 session.owner_session_id.as_deref(),
856 )
857 .await?;
858 ok_tool_result(
859 serde_json::to_value(&result)?,
860 json!({
861 "session_id": result.session_id,
862 "success": result.success,
863 "url": result.final_url,
864 }),
865 )
866 }
867
868 async fn execute_type(&self, args: Value) -> anyhow::Result<ToolResult> {
869 let ctx = parse_tool_context(&args);
870 let params: BrowserTypeToolArgs =
871 serde_json::from_value(args.clone()).context("invalid browser_type arguments")?;
872 let session = self
873 .load_session(¶ms.session_id, ctx.model_session_id.as_deref())
874 .await?;
875 self.ensure_action_allowed(session.owner_session_id.as_deref(), &session.current_url)
876 .await?;
877 let text = resolve_text_input(params.text.clone(), params.secret_ref.clone())?;
878 let request = BrowserTypeParams {
879 session_id: params.session_id.clone(),
880 element_id: params.element_id.clone(),
881 selector: params.selector.clone(),
882 text,
883 replace: params.replace,
884 submit: params.submit,
885 timeout_ms: params.timeout_ms,
886 };
887 let result: BrowserActionResult =
888 self.browser.call_sidecar("browser.type", request).await?;
889 self.update_action_url(
890 ¶ms.session_id,
891 result.final_url.as_deref(),
892 session.owner_session_id.as_deref(),
893 )
894 .await?;
895 ok_tool_result(
896 serde_json::to_value(&result)?,
897 json!({
898 "session_id": result.session_id,
899 "success": result.success,
900 "used_secret_ref": params.secret_ref.is_some(),
901 "url": result.final_url,
902 }),
903 )
904 }
905
906 async fn execute_press(&self, args: Value) -> anyhow::Result<ToolResult> {
907 let ctx = parse_tool_context(&args);
908 let params: BrowserPressParams =
909 serde_json::from_value(args.clone()).context("invalid browser_press arguments")?;
910 let session = self
911 .load_session(¶ms.session_id, ctx.model_session_id.as_deref())
912 .await?;
913 self.ensure_action_allowed(session.owner_session_id.as_deref(), &session.current_url)
914 .await?;
915 let result: BrowserActionResult = self
916 .browser
917 .call_sidecar("browser.press", params.clone())
918 .await?;
919 self.update_action_url(
920 ¶ms.session_id,
921 result.final_url.as_deref(),
922 session.owner_session_id.as_deref(),
923 )
924 .await?;
925 ok_tool_result(
926 serde_json::to_value(&result)?,
927 json!({
928 "session_id": result.session_id,
929 "success": result.success,
930 "url": result.final_url,
931 }),
932 )
933 }
934
935 async fn execute_wait(&self, args: Value) -> anyhow::Result<ToolResult> {
936 let ctx = parse_tool_context(&args);
937 let params = parse_browser_wait_args(&args).context("invalid browser_wait arguments")?;
938 let session = self
939 .load_session(¶ms.session_id, ctx.model_session_id.as_deref())
940 .await?;
941 self.ensure_page_read_allowed(session.owner_session_id.as_deref(), &session.current_url)
942 .await?;
943 let result: BrowserActionResult = self
944 .browser
945 .call_sidecar("browser.wait", params.clone())
946 .await?;
947 self.update_action_url(
948 ¶ms.session_id,
949 result.final_url.as_deref(),
950 session.owner_session_id.as_deref(),
951 )
952 .await?;
953 ok_tool_result(
954 serde_json::to_value(&result)?,
955 json!({
956 "session_id": result.session_id,
957 "success": result.success,
958 "url": result.final_url,
959 }),
960 )
961 }
962
963 async fn execute_extract(&self, args: Value) -> anyhow::Result<ToolResult> {
964 let ctx = parse_tool_context(&args);
965 let params: BrowserExtractParams =
966 serde_json::from_value(args.clone()).context("invalid browser_extract arguments")?;
967 let session = self
968 .load_session(¶ms.session_id, ctx.model_session_id.as_deref())
969 .await?;
970 self.ensure_page_read_allowed(session.owner_session_id.as_deref(), &session.current_url)
971 .await?;
972 let result: BrowserExtractResult = self
973 .browser
974 .call_sidecar("browser.extract", params.clone())
975 .await?;
976 let bytes = result.content.as_bytes();
977 let artifact = if bytes.len() > INLINE_EXTRACT_LIMIT_BYTES {
978 Some(
979 self.store_artifact(
980 ctx.model_session_id.as_deref(),
981 ¶ms.session_id,
982 "extract",
983 Some(format!("browser extract ({})", result.format)),
984 extension_for_extract_format(&result.format),
985 bytes,
986 Some(json!({
987 "format": result.format,
988 "truncated": result.truncated,
989 "source": "browser_extract",
990 })),
991 )
992 .await?,
993 )
994 } else {
995 None
996 };
997 let payload = json!({
998 "session_id": result.session_id,
999 "format": result.format,
1000 "content": artifact.is_none().then_some(result.content),
1001 "truncated": result.truncated,
1002 "artifact": artifact,
1003 });
1004 ok_tool_result(
1005 payload.clone(),
1006 json!({
1007 "session_id": payload.get("session_id"),
1008 "format": payload.get("format"),
1009 "artifact": payload.get("artifact").is_some(),
1010 }),
1011 )
1012 }
1013
1014 async fn execute_screenshot(&self, args: Value) -> anyhow::Result<ToolResult> {
1015 let ctx = parse_tool_context(&args);
1016 let params: BrowserScreenshotParams =
1017 serde_json::from_value(args.clone()).context("invalid browser_screenshot arguments")?;
1018 let session = self
1019 .load_session(¶ms.session_id, ctx.model_session_id.as_deref())
1020 .await?;
1021 self.ensure_page_read_allowed(session.owner_session_id.as_deref(), &session.current_url)
1022 .await?;
1023 let result: BrowserScreenshotResult = self
1024 .browser
1025 .call_sidecar("browser.screenshot", params.clone())
1026 .await?;
1027 let bytes = base64::engine::general_purpose::STANDARD
1028 .decode(result.data_base64.as_bytes())
1029 .context("invalid screenshot payload")?;
1030 let artifact = self
1031 .store_artifact(
1032 ctx.model_session_id.as_deref(),
1033 ¶ms.session_id,
1034 "screenshot",
1035 result.label.clone(),
1036 "png",
1037 &bytes,
1038 Some(json!({
1039 "mime_type": result.mime_type,
1040 "bytes": result.bytes,
1041 "source": "browser_screenshot",
1042 })),
1043 )
1044 .await?;
1045 ok_tool_result(
1046 json!({
1047 "session_id": result.session_id,
1048 "artifact": artifact,
1049 "summary": format!("Saved screenshot artifact ({} bytes).", result.bytes),
1050 }),
1051 json!({
1052 "session_id": result.session_id,
1053 "artifact_id": artifact.artifact_id,
1054 }),
1055 )
1056 }
1057
1058 async fn execute_close(&self, args: Value) -> anyhow::Result<ToolResult> {
1059 let ctx = parse_tool_context(&args);
1060 let params: BrowserCloseParams =
1061 serde_json::from_value(args.clone()).context("invalid browser_close arguments")?;
1062 let _ = self
1063 .load_session(¶ms.session_id, ctx.model_session_id.as_deref())
1064 .await?;
1065 let result: BrowserCloseResult = self
1066 .browser
1067 .call_sidecar("browser.close", params.clone())
1068 .await?;
1069 self.browser.remove_session(¶ms.session_id).await;
1070 ok_tool_result(
1071 serde_json::to_value(&result)?,
1072 json!({
1073 "session_id": result.session_id,
1074 "closed": result.closed,
1075 }),
1076 )
1077 }
1078
1079 async fn load_session(
1080 &self,
1081 browser_session_id: &str,
1082 model_session_id: Option<&str>,
1083 ) -> anyhow::Result<ManagedBrowserSession> {
1084 let session = self
1085 .browser
1086 .session(browser_session_id)
1087 .await
1088 .ok_or_else(|| anyhow!("session `{}` not found", browser_session_id))?;
1089 if let (Some(owner), Some(model_session_id)) =
1090 (session.owner_session_id.as_deref(), model_session_id)
1091 {
1092 if owner != model_session_id {
1093 anyhow::bail!(
1094 "browser session `{}` belongs to a different engine session",
1095 browser_session_id
1096 );
1097 }
1098 }
1099 Ok(session)
1100 }
1101
1102 async fn effective_allowed_hosts(&self, model_session_id: Option<&str>) -> Vec<String> {
1103 if let Some(model_session_id) = model_session_id {
1104 if let Some(state) = self.state.as_ref() {
1105 if let Some(instance) = state
1106 .agent_teams
1107 .instance_for_session(model_session_id)
1108 .await
1109 {
1110 if !instance.capabilities.net_scopes.allow_hosts.is_empty() {
1111 return normalize_allowed_hosts(
1112 instance.capabilities.net_scopes.allow_hosts,
1113 );
1114 }
1115 }
1116 }
1117 }
1118 normalize_allowed_hosts(self.browser.config.allowed_hosts.clone())
1119 }
1120
1121 async fn ensure_page_read_allowed(
1122 &self,
1123 model_session_id: Option<&str>,
1124 current_url: &str,
1125 ) -> anyhow::Result<()> {
1126 ensure_allowed_browser_url(
1127 current_url,
1128 &self.effective_allowed_hosts(model_session_id).await,
1129 )?;
1130 Ok(())
1131 }
1132
1133 async fn ensure_action_allowed(
1134 &self,
1135 model_session_id: Option<&str>,
1136 current_url: &str,
1137 ) -> anyhow::Result<()> {
1138 self.ensure_page_read_allowed(model_session_id, current_url)
1139 .await?;
1140 let host = browser_url_host(current_url)?;
1141 if !is_local_or_private_host(&host)
1142 && !self.external_integrations_allowed(model_session_id).await
1143 {
1144 anyhow::bail!(
1145 "external integrations are disabled for this routine session on host `{}`",
1146 host
1147 );
1148 }
1149 Ok(())
1150 }
1151
1152 async fn external_integrations_allowed(&self, model_session_id: Option<&str>) -> bool {
1153 let Some(model_session_id) = model_session_id else {
1154 return true;
1155 };
1156 let Some(state) = self.state.as_ref() else {
1157 return true;
1158 };
1159 let Some(policy) = state.routine_session_policy(model_session_id).await else {
1160 return true;
1161 };
1162 state
1163 .get_routine(&policy.routine_id)
1164 .await
1165 .map(|routine| routine.external_integrations_allowed)
1166 .unwrap_or(true)
1167 }
1168
1169 async fn enforce_post_navigation(
1170 &self,
1171 browser_session_id: &str,
1172 final_url: &str,
1173 model_session_id: Option<&str>,
1174 ) -> anyhow::Result<()> {
1175 if let Err(err) = ensure_allowed_browser_url(
1176 final_url,
1177 &self.effective_allowed_hosts(model_session_id).await,
1178 ) {
1179 let _ = self
1180 .browser
1181 .call_sidecar::<_, BrowserCloseResult>(
1182 "browser.close",
1183 BrowserCloseParams {
1184 session_id: browser_session_id.to_string(),
1185 },
1186 )
1187 .await;
1188 self.browser.remove_session(browser_session_id).await;
1189 return Err(anyhow!("host_not_allowed: {}", err));
1190 }
1191 self.browser
1192 .update_session_url(browser_session_id, final_url.to_string())
1193 .await;
1194 Ok(())
1195 }
1196
1197 async fn update_action_url(
1198 &self,
1199 browser_session_id: &str,
1200 final_url: Option<&str>,
1201 model_session_id: Option<&str>,
1202 ) -> anyhow::Result<()> {
1203 if let Some(final_url) = final_url {
1204 self.enforce_post_navigation(browser_session_id, final_url, model_session_id)
1205 .await?;
1206 }
1207 Ok(())
1208 }
1209
1210 async fn store_artifact(
1211 &self,
1212 model_session_id: Option<&str>,
1213 browser_session_id: &str,
1214 kind: &str,
1215 label: Option<String>,
1216 extension: &str,
1217 bytes: &[u8],
1218 metadata: Option<Value>,
1219 ) -> anyhow::Result<BrowserArtifactRef> {
1220 fs::create_dir_all(&self.browser.artifact_root).await?;
1221 let artifact_id = format!("artifact-{}", Uuid::new_v4());
1222 let file_name = format!("{artifact_id}.{extension}");
1223 let target = self.browser.artifact_root.join(file_name);
1224 fs::write(&target, bytes)
1225 .await
1226 .with_context(|| format!("failed to write browser artifact `{}`", target.display()))?;
1227 let artifact = BrowserArtifactRef {
1228 artifact_id: artifact_id.clone(),
1229 uri: target.to_string_lossy().to_string(),
1230 kind: kind.to_string(),
1231 label,
1232 created_at_ms: now_ms(),
1233 metadata,
1234 };
1235 self.append_routine_artifact_if_needed(
1236 model_session_id,
1237 artifact.clone(),
1238 browser_session_id,
1239 )
1240 .await;
1241 Ok(artifact)
1242 }
1243
1244 async fn append_routine_artifact_if_needed(
1245 &self,
1246 model_session_id: Option<&str>,
1247 artifact: BrowserArtifactRef,
1248 browser_session_id: &str,
1249 ) {
1250 let Some(model_session_id) = model_session_id else {
1251 return;
1252 };
1253 let Some(state) = self.state.as_ref() else {
1254 return;
1255 };
1256 let Some(policy) = state.routine_session_policy(model_session_id).await else {
1257 return;
1258 };
1259 let run_artifact = RoutineRunArtifact {
1260 artifact_id: artifact.artifact_id.clone(),
1261 uri: artifact.uri.clone(),
1262 kind: artifact.kind.clone(),
1263 label: artifact.label.clone(),
1264 created_at_ms: artifact.created_at_ms,
1265 metadata: artifact.metadata.clone(),
1266 };
1267 let _ = state
1268 .append_routine_run_artifact(&policy.run_id, run_artifact.clone())
1269 .await;
1270 state.event_bus.publish(EngineEvent::new(
1271 "routine.run.artifact_added",
1272 json!({
1273 "runID": policy.run_id,
1274 "routineID": policy.routine_id,
1275 "browserSessionID": browser_session_id,
1276 "artifact": run_artifact,
1277 }),
1278 ));
1279 }
1280}
1281
1282#[async_trait]
1283impl Tool for BrowserTool {
1284 fn schema(&self) -> ToolSchema {
1285 tool_schema(self.kind)
1286 }
1287
1288 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
1289 match self.execute_impl(args).await {
1290 Ok(result) => Ok(result),
1291 Err(err) => {
1292 let message = err.to_string();
1293 let (code, detail) = split_error_code(&message);
1294 Ok(error_tool_result(code, detail.to_string(), None))
1295 }
1296 }
1297 }
1298}
1299
1300impl RuntimeState {
1301 pub async fn browser_status(&self) -> BrowserStatus {
1302 self.browser.status_snapshot().await
1303 }
1304
1305 pub async fn browser_smoke_test(
1306 &self,
1307 url: Option<String>,
1308 ) -> anyhow::Result<BrowserSmokeTestResult> {
1309 self.browser.smoke_test(url).await
1310 }
1311
1312 pub async fn install_browser_sidecar(&self) -> anyhow::Result<BrowserSidecarInstallResult> {
1313 self.browser.install_sidecar().await
1314 }
1315
1316 pub async fn browser_health_summary(&self) -> BrowserHealthSummary {
1317 self.browser.health_summary().await
1318 }
1319
1320 pub async fn close_browser_sessions_for_owner(&self, owner_session_id: &str) -> usize {
1321 self.browser
1322 .close_sessions_for_owner(owner_session_id)
1323 .await
1324 }
1325
1326 pub async fn close_all_browser_sessions(&self) -> usize {
1327 self.browser.close_all_sessions().await
1328 }
1329}
1330
1331impl AppState {
1332 pub async fn browser_status(&self) -> BrowserStatus {
1333 match self.runtime.get() {
1334 Some(runtime) => runtime.browser.status_snapshot().await,
1335 None => BrowserStatus::default(),
1336 }
1337 }
1338
1339 pub async fn browser_smoke_test(
1340 &self,
1341 url: Option<String>,
1342 ) -> anyhow::Result<BrowserSmokeTestResult> {
1343 let Some(runtime) = self.runtime.get() else {
1344 anyhow::bail!("runtime not ready");
1345 };
1346 runtime.browser_smoke_test(url).await
1347 }
1348
1349 pub async fn install_browser_sidecar(&self) -> anyhow::Result<BrowserSidecarInstallResult> {
1350 let Some(runtime) = self.runtime.get() else {
1351 anyhow::bail!("runtime not ready");
1352 };
1353 runtime.install_browser_sidecar().await
1354 }
1355
1356 pub async fn browser_health_summary(&self) -> BrowserHealthSummary {
1357 match self.runtime.get() {
1358 Some(runtime) => runtime.browser.health_summary().await,
1359 None => BrowserHealthSummary::default(),
1360 }
1361 }
1362
1363 pub async fn close_browser_sessions_for_owner(&self, owner_session_id: &str) -> usize {
1364 match self.runtime.get() {
1365 Some(runtime) => {
1366 runtime
1367 .close_browser_sessions_for_owner(owner_session_id)
1368 .await
1369 }
1370 None => 0,
1371 }
1372 }
1373
1374 pub async fn close_all_browser_sessions(&self) -> usize {
1375 match self.runtime.get() {
1376 Some(runtime) => runtime.close_all_browser_sessions().await,
1377 None => 0,
1378 }
1379 }
1380
1381 pub async fn register_browser_tools(&self) -> anyhow::Result<()> {
1382 let Some(runtime) = self.runtime.get() else {
1383 anyhow::bail!("runtime not ready");
1384 };
1385 runtime
1386 .browser
1387 .register_tools(&runtime.tools, Some(self.clone()))
1388 .await
1389 }
1390}
1391
1392fn evaluate_browser_status(config: BrowserConfig) -> BrowserStatus {
1393 let mut status = run_doctor(BrowserDoctorOptions {
1394 enabled: config.enabled,
1395 headless_default: config.headless_default,
1396 allow_no_sandbox: config.allow_no_sandbox,
1397 executable_path: config.executable_path.clone(),
1398 user_data_root: config.user_data_root.clone(),
1399 });
1400 status.headless_default = config.headless_default;
1401 status.sidecar = evaluate_sidecar_status(config.sidecar_path.as_deref());
1402 if config.enabled && !status.sidecar.found {
1403 status.blocking_issues.push(BrowserBlockingIssue {
1404 code: "browser_sidecar_not_found".to_string(),
1405 message: "The tandem-browser sidecar binary was not found on this host.".to_string(),
1406 });
1407 status.recommendations.push(
1408 "Install or bundle `tandem-browser`, or set `TANDEM_BROWSER_SIDECAR` / `browser.sidecar_path`."
1409 .to_string(),
1410 );
1411 }
1412 status.runnable = config.enabled
1413 && status.sidecar.found
1414 && status.browser.found
1415 && status.blocking_issues.is_empty();
1416 status
1417}
1418
1419fn evaluate_sidecar_status(explicit: Option<&str>) -> tandem_browser::BrowserSidecarStatus {
1420 let path = detect_sidecar_binary_path(explicit);
1421 let version = path
1422 .as_ref()
1423 .and_then(|candidate| probe_binary_version(candidate).ok());
1424 tandem_browser::BrowserSidecarStatus {
1425 found: path.is_some(),
1426 path: path.map(|row| row.to_string_lossy().to_string()),
1427 version,
1428 }
1429}
1430
1431fn probe_binary_version(path: &Path) -> anyhow::Result<String> {
1432 let output = std::process::Command::new(path)
1433 .arg("--version")
1434 .output()
1435 .with_context(|| format!("failed to query `{}` version", path.display()))?;
1436 if !output.status.success() {
1437 anyhow::bail!(
1438 "version probe failed: {}",
1439 String::from_utf8_lossy(&output.stderr).trim()
1440 );
1441 }
1442 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1443 if stdout.is_empty() {
1444 anyhow::bail!("version probe returned empty stdout");
1445 }
1446 Ok(stdout)
1447}
1448
1449pub async fn install_browser_sidecar(
1450 config: &BrowserConfig,
1451) -> anyhow::Result<BrowserSidecarInstallResult> {
1452 let version = env!("CARGO_PKG_VERSION").to_string();
1453 let release = fetch_release_for_version(&version).await?;
1454 let asset_name = browser_release_asset_name()?;
1455 let asset = release
1456 .assets
1457 .iter()
1458 .find(|candidate| candidate.name == asset_name)
1459 .ok_or_else(|| {
1460 anyhow!(
1461 "release_missing_asset: `{}` not found in {}",
1462 asset_name,
1463 release.tag_name
1464 )
1465 })?;
1466 let install_path = sidecar_install_path(config)?;
1467 let parent = install_path
1468 .parent()
1469 .ok_or_else(|| anyhow!("invalid install path `{}`", install_path.display()))?;
1470 fs::create_dir_all(parent)
1471 .await
1472 .with_context(|| format!("failed to create `{}`", parent.display()))?;
1473
1474 let archive_bytes = download_release_asset(asset).await?;
1475 let downloaded_bytes = archive_bytes.len() as u64;
1476 let install_path_for_unpack = install_path.clone();
1477 let asset_name_for_unpack = asset.name.clone();
1478 let unpacked = tokio::task::spawn_blocking(move || {
1479 unpack_sidecar_archive(
1480 &asset_name_for_unpack,
1481 &archive_bytes,
1482 &install_path_for_unpack,
1483 )
1484 })
1485 .await
1486 .context("browser sidecar install task failed")??;
1487
1488 let status = evaluate_browser_status(config.clone());
1489 Ok(BrowserSidecarInstallResult {
1490 version,
1491 asset_name: asset.name.clone(),
1492 installed_path: unpacked.to_string_lossy().to_string(),
1493 downloaded_bytes: asset.size.max(downloaded_bytes),
1494 status,
1495 })
1496}
1497
1498async fn fetch_release_for_version(version: &str) -> anyhow::Result<GitHubRelease> {
1499 let base = std::env::var(RELEASES_URL_ENV)
1500 .unwrap_or_else(|_| format!("https://api.github.com/repos/{RELEASE_REPO}/releases/tags"));
1501 let url = format!("{}/v{}", base.trim_end_matches('/'), version);
1502 let response = reqwest::Client::new()
1503 .get(&url)
1504 .header(reqwest::header::USER_AGENT, BROWSER_INSTALL_USER_AGENT)
1505 .send()
1506 .await
1507 .with_context(|| format!("failed to fetch release metadata from `{url}`"))?;
1508 let status = response.status();
1509 let body = response.text().await.unwrap_or_default();
1510 if !status.is_success() {
1511 anyhow::bail!("release_lookup_failed: {} {}", status, body.trim());
1512 }
1513 serde_json::from_str::<GitHubRelease>(&body).context("invalid release metadata payload")
1514}
1515
1516async fn download_release_asset(asset: &GitHubAsset) -> anyhow::Result<Vec<u8>> {
1517 let response = reqwest::Client::new()
1518 .get(&asset.browser_download_url)
1519 .header(reqwest::header::USER_AGENT, BROWSER_INSTALL_USER_AGENT)
1520 .send()
1521 .await
1522 .with_context(|| format!("failed to download `{}`", asset.browser_download_url))?;
1523 let status = response.status();
1524 if !status.is_success() {
1525 anyhow::bail!(
1526 "asset_download_failed: {} {}",
1527 status,
1528 asset.browser_download_url
1529 );
1530 }
1531 let bytes = response
1532 .bytes()
1533 .await
1534 .context("failed to read asset bytes")?;
1535 Ok(bytes.to_vec())
1536}
1537
1538fn sidecar_install_path(config: &BrowserConfig) -> anyhow::Result<PathBuf> {
1539 if let Some(explicit) = config
1540 .sidecar_path
1541 .as_deref()
1542 .map(str::trim)
1543 .filter(|value| !value.is_empty())
1544 {
1545 return Ok(PathBuf::from(explicit));
1546 }
1547 managed_sidecar_install_path()
1548}
1549
1550fn managed_sidecar_install_path() -> anyhow::Result<PathBuf> {
1551 let root = resolve_shared_paths()
1552 .map(|paths| paths.canonical_root)
1553 .unwrap_or_else(|_| {
1554 dirs::home_dir()
1555 .map(|home| home.join(".tandem"))
1556 .unwrap_or_else(|| PathBuf::from(".tandem"))
1557 });
1558 Ok(root.join("binaries").join(sidecar_binary_name()))
1559}
1560
1561fn browser_release_asset_name() -> anyhow::Result<String> {
1562 let os = if cfg!(target_os = "windows") {
1563 "windows"
1564 } else if cfg!(target_os = "macos") {
1565 "darwin"
1566 } else if cfg!(target_os = "linux") {
1567 "linux"
1568 } else {
1569 anyhow::bail!("unsupported_os: {}", std::env::consts::OS);
1570 };
1571 let arch = if cfg!(target_arch = "x86_64") {
1572 "x64"
1573 } else if cfg!(target_arch = "aarch64") {
1574 "arm64"
1575 } else {
1576 anyhow::bail!("unsupported_arch: {}", std::env::consts::ARCH);
1577 };
1578 let ext = if cfg!(target_os = "windows") || cfg!(target_os = "macos") {
1579 "zip"
1580 } else {
1581 "tar.gz"
1582 };
1583 Ok(format!("tandem-browser-{os}-{arch}.{ext}"))
1584}
1585
1586fn sidecar_binary_name() -> &'static str {
1587 #[cfg(target_os = "windows")]
1588 {
1589 "tandem-browser.exe"
1590 }
1591 #[cfg(not(target_os = "windows"))]
1592 {
1593 "tandem-browser"
1594 }
1595}
1596
1597fn unpack_sidecar_archive(
1598 asset_name: &str,
1599 archive_bytes: &[u8],
1600 install_path: &Path,
1601) -> anyhow::Result<PathBuf> {
1602 if asset_name.ends_with(".zip") {
1603 let cursor = std::io::Cursor::new(archive_bytes);
1604 let mut archive = zip::ZipArchive::new(cursor).context("invalid zip archive")?;
1605 let binary_present = archive
1606 .file_names()
1607 .any(|name| name == sidecar_binary_name());
1608 let mut file = if binary_present {
1609 archive
1610 .by_name(sidecar_binary_name())
1611 .context("browser binary missing from zip archive")?
1612 } else {
1613 archive
1614 .by_index(0)
1615 .context("browser binary missing from zip archive")?
1616 };
1617 let mut output = std::fs::File::create(install_path)
1618 .with_context(|| format!("failed to create `{}`", install_path.display()))?;
1619 std::io::copy(&mut file, &mut output).context("failed to unpack zip asset")?;
1620 } else if asset_name.ends_with(".tar.gz") {
1621 let cursor = std::io::Cursor::new(archive_bytes);
1622 let decoder = GzDecoder::new(cursor);
1623 let mut archive = tar::Archive::new(decoder);
1624 let mut found = false;
1625 for entry in archive.entries().context("invalid tar archive")? {
1626 let mut entry = entry.context("invalid tar entry")?;
1627 let path = entry.path().context("invalid tar entry path")?;
1628 if path
1629 .file_name()
1630 .and_then(|name| name.to_str())
1631 .is_some_and(|name| name == sidecar_binary_name())
1632 {
1633 entry
1634 .unpack(install_path)
1635 .with_context(|| format!("failed to unpack `{}`", install_path.display()))?;
1636 found = true;
1637 break;
1638 }
1639 }
1640 if !found {
1641 anyhow::bail!("browser binary missing from tar archive");
1642 }
1643 } else {
1644 anyhow::bail!("unsupported archive format `{asset_name}`");
1645 }
1646
1647 #[cfg(not(target_os = "windows"))]
1648 {
1649 use std::os::unix::fs::PermissionsExt;
1650
1651 let mut perms = std::fs::metadata(install_path)
1652 .with_context(|| format!("failed to read `{}` metadata", install_path.display()))?
1653 .permissions();
1654 perms.set_mode(0o755);
1655 std::fs::set_permissions(install_path, perms)
1656 .with_context(|| format!("failed to chmod `{}`", install_path.display()))?;
1657 }
1658
1659 Ok(install_path.to_path_buf())
1660}
1661
1662fn parse_tool_context(args: &Value) -> BrowserToolContext {
1663 serde_json::from_value(args.clone()).unwrap_or(BrowserToolContext {
1664 model_session_id: None,
1665 })
1666}
1667
1668fn ok_tool_result(value: Value, metadata: Value) -> anyhow::Result<ToolResult> {
1669 Ok(ToolResult {
1670 output: serde_json::to_string_pretty(&value)?,
1671 metadata,
1672 })
1673}
1674
1675fn error_tool_result(code: &str, message: String, metadata: Option<Value>) -> ToolResult {
1676 let mut meta = metadata.unwrap_or_else(|| json!({}));
1677 if let Some(obj) = meta.as_object_mut() {
1678 obj.insert("ok".to_string(), Value::Bool(false));
1679 obj.insert("code".to_string(), Value::String(code.to_string()));
1680 obj.insert("message".to_string(), Value::String(message.clone()));
1681 }
1682 ToolResult {
1683 output: message,
1684 metadata: meta,
1685 }
1686}
1687
1688fn split_error_code(message: &str) -> (&str, &str) {
1689 let Some((code, detail)) = message.split_once(':') else {
1690 return ("browser_error", message);
1691 };
1692 let code = code.trim();
1693 if code.is_empty()
1694 || !code
1695 .chars()
1696 .all(|ch| ch.is_ascii_lowercase() || ch == '_' || ch.is_ascii_digit())
1697 {
1698 return ("browser_error", message);
1699 }
1700 (code, detail.trim())
1701}
1702
1703fn smoke_excerpt(content: &str, max_chars: usize) -> String {
1704 let mut excerpt = String::new();
1705 for ch in content.chars().take(max_chars) {
1706 excerpt.push(ch);
1707 }
1708 if content.chars().count() > max_chars {
1709 excerpt.push_str("...");
1710 }
1711 excerpt
1712}
1713
1714fn browser_not_runnable_result(status: &BrowserStatus) -> anyhow::Result<ToolResult> {
1715 ok_tool_result(
1716 serde_json::to_value(status)?,
1717 json!({
1718 "ok": false,
1719 "code": "browser_not_runnable",
1720 "runnable": status.runnable,
1721 "enabled": status.enabled,
1722 }),
1723 )
1724}
1725
1726fn normalize_allowed_hosts(hosts: Vec<String>) -> Vec<String> {
1727 let mut out = Vec::new();
1728 for host in hosts {
1729 let normalized = host.trim().trim_start_matches('.').to_ascii_lowercase();
1730 if normalized.is_empty() {
1731 continue;
1732 }
1733 if !out.iter().any(|existing| existing == &normalized) {
1734 out.push(normalized);
1735 }
1736 }
1737 out
1738}
1739
1740fn browser_url_host(url: &str) -> anyhow::Result<String> {
1741 let parsed =
1742 reqwest::Url::parse(url).with_context(|| format!("invalid browser url `{}`", url))?;
1743 let host = parsed
1744 .host_str()
1745 .ok_or_else(|| anyhow!("url `{}` has no host", url))?;
1746 Ok(host.to_ascii_lowercase())
1747}
1748
1749fn ensure_allowed_browser_url(url: &str, allow_hosts: &[String]) -> anyhow::Result<()> {
1750 let parsed =
1751 reqwest::Url::parse(url).with_context(|| format!("invalid browser url `{}`", url))?;
1752 match parsed.scheme() {
1753 "http" | "https" => {}
1754 other => anyhow::bail!("unsupported_url_scheme: `{}` is not allowed", other),
1755 }
1756 if allow_hosts.is_empty() {
1757 return Ok(());
1758 }
1759 let host = parsed
1760 .host_str()
1761 .ok_or_else(|| anyhow!("url `{}` has no host", url))?
1762 .to_ascii_lowercase();
1763 let allowed = allow_hosts
1764 .iter()
1765 .any(|candidate| host == *candidate || host.ends_with(&format!(".{candidate}")));
1766 if !allowed {
1767 anyhow::bail!("host `{}` is not in the browser allowlist", host);
1768 }
1769 Ok(())
1770}
1771
1772fn bool_env_value(enabled: bool) -> &'static str {
1773 if enabled {
1774 "true"
1775 } else {
1776 "false"
1777 }
1778}
1779
1780fn normalize_browser_open_request(request: &mut BrowserOpenRequest) {
1781 request.profile_id = request
1782 .profile_id
1783 .take()
1784 .map(|value| value.trim().to_string())
1785 .filter(|value| !value.is_empty());
1786}
1787
1788fn parse_browser_wait_condition(
1789 input: BrowserWaitConditionArgs,
1790) -> anyhow::Result<BrowserWaitCondition> {
1791 let BrowserWaitConditionArgs {
1792 kind,
1793 value,
1794 selector,
1795 text,
1796 url,
1797 } = input;
1798
1799 let kind = kind
1800 .map(|value| value.trim().to_string())
1801 .filter(|value| !value.is_empty())
1802 .or_else(|| selector.as_ref().map(|_| "selector".to_string()))
1803 .or_else(|| text.as_ref().map(|_| "text".to_string()))
1804 .or_else(|| url.as_ref().map(|_| "url".to_string()))
1805 .ok_or_else(|| anyhow!("browser_wait requires condition.kind"))?;
1806
1807 let value = value
1808 .filter(|value| !value.trim().is_empty())
1809 .or_else(|| match kind.as_str() {
1810 "selector" => selector,
1811 "text" => text,
1812 "url" => url,
1813 _ => None,
1814 });
1815
1816 Ok(BrowserWaitCondition { kind, value })
1817}
1818
1819fn parse_browser_wait_args(args: &Value) -> anyhow::Result<BrowserWaitParams> {
1820 let raw: BrowserWaitToolArgs = serde_json::from_value(args.clone())?;
1821 let condition = if let Some(condition) = raw.condition {
1822 parse_browser_wait_condition(condition)?
1823 } else {
1824 parse_browser_wait_condition(BrowserWaitConditionArgs {
1825 kind: raw.kind,
1826 value: raw.value,
1827 selector: raw.selector,
1828 text: raw.text,
1829 url: raw.url,
1830 })?
1831 };
1832
1833 Ok(BrowserWaitParams {
1834 session_id: raw.session_id,
1835 condition,
1836 timeout_ms: raw.timeout_ms,
1837 })
1838}
1839
1840fn is_local_or_private_host(host: &str) -> bool {
1841 if host.eq_ignore_ascii_case("localhost") {
1842 return true;
1843 }
1844 let Ok(ip) = host.parse::<IpAddr>() else {
1845 return false;
1846 };
1847 match ip {
1848 IpAddr::V4(ip) => {
1849 ip.is_loopback()
1850 || ip.is_private()
1851 || ip.is_link_local()
1852 || ip.octets()[0] == 169 && ip.octets()[1] == 254
1853 }
1854 IpAddr::V6(ip) => {
1855 ip == Ipv6Addr::LOCALHOST || ip.is_unique_local() || ip.is_unicast_link_local()
1856 }
1857 }
1858}
1859
1860fn resolve_text_input(text: Option<String>, secret_ref: Option<String>) -> anyhow::Result<String> {
1861 if let Some(secret_ref) = secret_ref
1862 .map(|v| v.trim().to_string())
1863 .filter(|v| !v.is_empty())
1864 {
1865 let value = std::env::var(&secret_ref).with_context(|| {
1866 format!("secret_ref `{}` is not set in the environment", secret_ref)
1867 })?;
1868 if value.trim().is_empty() {
1869 anyhow::bail!("secret_ref `{}` resolved to an empty value", secret_ref);
1870 }
1871 return Ok(value);
1872 }
1873 let text = text.unwrap_or_default();
1874 if text.is_empty() {
1875 anyhow::bail!("browser_type requires either `text` or `secret_ref`");
1876 }
1877 Ok(text)
1878}
1879
1880fn extension_for_extract_format(format: &str) -> &'static str {
1881 match format {
1882 "html" => "html",
1883 "markdown" => "md",
1884 _ => "txt",
1885 }
1886}
1887
1888fn viewport_schema() -> Value {
1889 json!({
1890 "type": "object",
1891 "properties": {
1892 "width": { "type": "integer", "minimum": 1, "maximum": 10000 },
1893 "height": { "type": "integer", "minimum": 1, "maximum": 10000 }
1894 }
1895 })
1896}
1897
1898fn wait_condition_schema() -> Value {
1899 json!({
1900 "type": "object",
1901 "properties": {
1902 "kind": {
1903 "type": "string",
1904 "enum": ["selector", "text", "url", "network_idle", "navigation"]
1905 },
1906 "value": { "type": "string" }
1907 },
1908 "required": ["kind"]
1909 })
1910}
1911
1912fn tool_schema(kind: BrowserToolKind) -> ToolSchema {
1913 match kind {
1914 BrowserToolKind::Status => ToolSchema {
1915 name: "browser_status".to_string(),
1916 description:
1917 "Check browser automation readiness and install guidance. Call this first when browser tools may be unavailable."
1918 .to_string(),
1919 input_schema: json!({ "type": "object", "properties": {} }),
1920 },
1921 BrowserToolKind::Open => ToolSchema {
1922 name: "browser_open".to_string(),
1923 description:
1924 "Open a URL in a browser session. Only http/https are allowed. Omit profile_id for an ephemeral session."
1925 .to_string(),
1926 input_schema: json!({
1927 "type": "object",
1928 "properties": {
1929 "url": { "type": "string" },
1930 "profile_id": { "type": "string" },
1931 "headless": { "type": "boolean" },
1932 "viewport": viewport_schema(),
1933 "wait_until": { "type": "string", "enum": ["navigation", "network_idle"] }
1934 },
1935 "required": ["url"]
1936 }),
1937 },
1938 BrowserToolKind::Navigate => ToolSchema {
1939 name: "browser_navigate".to_string(),
1940 description: "Navigate an existing browser session to a new URL.".to_string(),
1941 input_schema: json!({
1942 "type": "object",
1943 "properties": {
1944 "session_id": { "type": "string" },
1945 "url": { "type": "string" },
1946 "wait_until": { "type": "string", "enum": ["navigation", "network_idle"] }
1947 },
1948 "required": ["session_id", "url"]
1949 }),
1950 },
1951 BrowserToolKind::Snapshot => ToolSchema {
1952 name: "browser_snapshot".to_string(),
1953 description:
1954 "Capture a bounded page summary with stable element_id values. Call this before click/type on a new page or after navigation."
1955 .to_string(),
1956 input_schema: json!({
1957 "type": "object",
1958 "properties": {
1959 "session_id": { "type": "string" },
1960 "max_elements": { "type": "integer", "minimum": 1, "maximum": 200 },
1961 "include_screenshot": { "type": "boolean" }
1962 },
1963 "required": ["session_id"]
1964 }),
1965 },
1966 BrowserToolKind::Click => ToolSchema {
1967 name: "browser_click".to_string(),
1968 description:
1969 "Click a visible page element by element_id when possible. Use wait_for to make navigation and selector waits race-free."
1970 .to_string(),
1971 input_schema: json!({
1972 "type": "object",
1973 "properties": {
1974 "session_id": { "type": "string" },
1975 "element_id": { "type": "string" },
1976 "selector": { "type": "string" },
1977 "wait_for": wait_condition_schema(),
1978 "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 }
1979 },
1980 "required": ["session_id"]
1981 }),
1982 },
1983 BrowserToolKind::Type => ToolSchema {
1984 name: "browser_type".to_string(),
1985 description:
1986 "Type text into an element. Prefer secret_ref over text for credentials; secret_ref resolves from the host environment and is redacted from logs."
1987 .to_string(),
1988 input_schema: json!({
1989 "type": "object",
1990 "properties": {
1991 "session_id": { "type": "string" },
1992 "element_id": { "type": "string" },
1993 "selector": { "type": "string" },
1994 "text": { "type": "string" },
1995 "secret_ref": { "type": "string" },
1996 "replace": { "type": "boolean" },
1997 "submit": { "type": "boolean" },
1998 "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 }
1999 },
2000 "required": ["session_id"]
2001 }),
2002 },
2003 BrowserToolKind::Press => ToolSchema {
2004 name: "browser_press".to_string(),
2005 description: "Dispatch a key press in the active page context.".to_string(),
2006 input_schema: json!({
2007 "type": "object",
2008 "properties": {
2009 "session_id": { "type": "string" },
2010 "key": { "type": "string" },
2011 "wait_for": wait_condition_schema(),
2012 "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 }
2013 },
2014 "required": ["session_id", "key"]
2015 }),
2016 },
2017 BrowserToolKind::Wait => ToolSchema {
2018 name: "browser_wait".to_string(),
2019 description: "Wait for a selector, text, URL fragment, navigation, or network idle.".to_string(),
2020 input_schema: json!({
2021 "type": "object",
2022 "properties": {
2023 "session_id": { "type": "string" },
2024 "condition": wait_condition_schema(),
2025 "wait_for": wait_condition_schema(),
2026 "waitFor": wait_condition_schema(),
2027 "kind": {
2028 "type": "string",
2029 "enum": ["selector", "text", "url", "network_idle", "navigation"]
2030 },
2031 "type": {
2032 "type": "string",
2033 "enum": ["selector", "text", "url", "network_idle", "navigation"]
2034 },
2035 "value": { "type": "string" },
2036 "selector": { "type": "string" },
2037 "text": { "type": "string" },
2038 "url": { "type": "string" },
2039 "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 },
2040 "timeoutMs": { "type": "integer", "minimum": 250, "maximum": 120000 }
2041 },
2042 "required": ["session_id"],
2043 "anyOf": [
2044 { "required": ["condition"] },
2045 { "required": ["wait_for"] },
2046 { "required": ["waitFor"] },
2047 { "required": ["kind"] },
2048 { "required": ["type"] },
2049 { "required": ["selector"] },
2050 { "required": ["text"] },
2051 { "required": ["url"] }
2052 ]
2053 }),
2054 },
2055 BrowserToolKind::Extract => ToolSchema {
2056 name: "browser_extract".to_string(),
2057 description:
2058 "Extract page content as visible_text, markdown, or html. Prefer this over screenshots when you need text."
2059 .to_string(),
2060 input_schema: json!({
2061 "type": "object",
2062 "properties": {
2063 "session_id": { "type": "string" },
2064 "format": { "type": "string", "enum": ["visible_text", "markdown", "html"] },
2065 "max_bytes": { "type": "integer", "minimum": 1024, "maximum": 2000000 }
2066 },
2067 "required": ["session_id", "format"]
2068 }),
2069 },
2070 BrowserToolKind::Screenshot => ToolSchema {
2071 name: "browser_screenshot".to_string(),
2072 description: "Capture a screenshot and store it as a browser artifact.".to_string(),
2073 input_schema: json!({
2074 "type": "object",
2075 "properties": {
2076 "session_id": { "type": "string" },
2077 "full_page": { "type": "boolean" },
2078 "label": { "type": "string" }
2079 },
2080 "required": ["session_id"]
2081 }),
2082 },
2083 BrowserToolKind::Close => ToolSchema {
2084 name: "browser_close".to_string(),
2085 description: "Close a browser session and release its resources.".to_string(),
2086 input_schema: json!({
2087 "type": "object",
2088 "properties": {
2089 "session_id": { "type": "string" }
2090 },
2091 "required": ["session_id"]
2092 }),
2093 },
2094 }
2095}
2096
2097#[cfg(test)]
2098mod tests {
2099 use super::*;
2100 use tandem_core::BrowserConfig;
2101 use tandem_tools::ToolRegistry;
2102
2103 #[test]
2104 fn local_and_private_hosts_are_detected() {
2105 assert!(is_local_or_private_host("localhost"));
2106 assert!(is_local_or_private_host("127.0.0.1"));
2107 assert!(is_local_or_private_host("10.1.2.3"));
2108 assert!(is_local_or_private_host("192.168.0.10"));
2109 assert!(!is_local_or_private_host("example.com"));
2110 assert!(!is_local_or_private_host("8.8.8.8"));
2111 }
2112
2113 #[test]
2114 fn allow_host_check_accepts_subdomains() {
2115 let allow_hosts = vec!["example.com".to_string()];
2116 ensure_allowed_browser_url("https://example.com/path", &allow_hosts).expect("root host");
2117 ensure_allowed_browser_url("https://app.example.com/path", &allow_hosts)
2118 .expect("subdomain host");
2119 let err =
2120 ensure_allowed_browser_url("https://example.org/path", &allow_hosts).expect_err("deny");
2121 assert!(err.to_string().contains("allowlist"));
2122 }
2123
2124 #[test]
2125 fn browser_release_asset_name_matches_platform() {
2126 let asset = browser_release_asset_name().expect("asset name");
2127 assert!(asset.starts_with("tandem-browser-"));
2128 if cfg!(target_os = "windows") {
2129 assert!(asset.ends_with(".zip"));
2130 assert!(asset.contains("-windows-"));
2131 } else if cfg!(target_os = "macos") {
2132 assert!(asset.ends_with(".zip"));
2133 assert!(asset.contains("-darwin-"));
2134 } else if cfg!(target_os = "linux") {
2135 assert!(asset.ends_with(".tar.gz"));
2136 assert!(asset.contains("-linux-"));
2137 }
2138 }
2139
2140 #[test]
2141 fn managed_sidecar_path_uses_shared_binaries_dir() {
2142 let temp_root =
2143 std::env::temp_dir().join(format!("tandem-browser-test-{}", Uuid::new_v4()));
2144 std::env::set_var("TANDEM_HOME", &temp_root);
2145
2146 let path = managed_sidecar_install_path().expect("managed path");
2147
2148 assert!(path.starts_with(temp_root.join("binaries")));
2149 assert_eq!(
2150 path.file_name().and_then(|value| value.to_str()),
2151 Some(sidecar_binary_name())
2152 );
2153
2154 std::env::remove_var("TANDEM_HOME");
2155 }
2156
2157 #[test]
2158 fn bool_env_value_uses_clap_friendly_literals() {
2159 assert_eq!(bool_env_value(true), "true");
2160 assert_eq!(bool_env_value(false), "false");
2161 }
2162
2163 #[test]
2164 fn normalize_browser_open_request_drops_empty_profile_id() {
2165 let mut request = BrowserOpenRequest {
2166 url: "https://example.com".to_string(),
2167 profile_id: Some(" ".to_string()),
2168 headless: None,
2169 viewport: None,
2170 wait_until: None,
2171 executable_path: None,
2172 user_data_root: None,
2173 allow_no_sandbox: false,
2174 headless_default: true,
2175 };
2176
2177 normalize_browser_open_request(&mut request);
2178
2179 assert_eq!(request.profile_id, None);
2180 }
2181
2182 #[test]
2183 fn parse_browser_wait_args_accepts_canonical_condition_shape() {
2184 let parsed = parse_browser_wait_args(&json!({
2185 "session_id": "browser-1",
2186 "condition": { "kind": "selector", "value": "#login" },
2187 "timeout_ms": 5000
2188 }))
2189 .expect("canonical browser_wait args");
2190
2191 assert_eq!(parsed.session_id, "browser-1");
2192 assert_eq!(parsed.condition.kind, "selector");
2193 assert_eq!(parsed.condition.value.as_deref(), Some("#login"));
2194 assert_eq!(parsed.timeout_ms, Some(5000));
2195 }
2196
2197 #[test]
2198 fn parse_browser_wait_args_accepts_wait_for_alias_and_camel_case() {
2199 let parsed = parse_browser_wait_args(&json!({
2200 "sessionId": "browser-1",
2201 "waitFor": { "type": "text", "value": "Dashboard" },
2202 "timeoutMs": 1500
2203 }))
2204 .expect("aliased browser_wait args");
2205
2206 assert_eq!(parsed.session_id, "browser-1");
2207 assert_eq!(parsed.condition.kind, "text");
2208 assert_eq!(parsed.condition.value.as_deref(), Some("Dashboard"));
2209 assert_eq!(parsed.timeout_ms, Some(1500));
2210 }
2211
2212 #[test]
2213 fn parse_browser_wait_args_accepts_top_level_condition_fields() {
2214 let parsed = parse_browser_wait_args(&json!({
2215 "session_id": "browser-1",
2216 "kind": "url",
2217 "value": "/settings"
2218 }))
2219 .expect("top-level browser_wait args");
2220
2221 assert_eq!(parsed.condition.kind, "url");
2222 assert_eq!(parsed.condition.value.as_deref(), Some("/settings"));
2223 }
2224
2225 #[test]
2226 fn parse_browser_wait_args_infers_selector_alias_without_explicit_kind() {
2227 let parsed = parse_browser_wait_args(&json!({
2228 "session_id": "browser-1",
2229 "condition": { "selector": "[data-testid='save']" }
2230 }))
2231 .expect("selector alias browser_wait args");
2232
2233 assert_eq!(parsed.condition.kind, "selector");
2234 assert_eq!(
2235 parsed.condition.value.as_deref(),
2236 Some("[data-testid='save']")
2237 );
2238 }
2239
2240 #[tokio::test]
2241 async fn register_tools_keeps_browser_status_available_when_disabled() {
2242 let tools = ToolRegistry::new();
2243 let browser = BrowserSubsystem::new(BrowserConfig::default());
2244
2245 browser
2246 .register_tools(&tools, None)
2247 .await
2248 .expect("register browser tools");
2249
2250 let names = tools
2251 .list()
2252 .await
2253 .into_iter()
2254 .map(|schema| schema.name)
2255 .collect::<Vec<_>>();
2256 assert!(names.iter().any(|name| name == "browser_status"));
2257 assert!(!names.iter().any(|name| name == "browser_open"));
2258 assert!(!browser.health_summary().await.tools_registered);
2259 }
2260
2261 #[tokio::test]
2262 async fn close_sessions_for_owner_removes_matching_sessions() {
2263 let browser = BrowserSubsystem::new(BrowserConfig::default());
2264 browser
2265 .insert_session(
2266 "session-1".to_string(),
2267 Some("owner-1".to_string()),
2268 "https://example.com".to_string(),
2269 )
2270 .await;
2271 browser
2272 .insert_session(
2273 "session-2".to_string(),
2274 Some("owner-2".to_string()),
2275 "https://example.org".to_string(),
2276 )
2277 .await;
2278
2279 let closed = browser.close_sessions_for_owner("owner-1").await;
2280
2281 assert_eq!(closed, 1);
2282 assert!(browser.session("session-1").await.is_none());
2283 assert!(browser.session("session-2").await.is_some());
2284 }
2285}