Skip to main content

tandem_server/
browser.rs

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(&params.session_id, ctx.model_session_id.as_deref())
753            .await?;
754        ensure_allowed_browser_url(
755            &params.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            &params.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(&params.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(&params.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                    &params.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(&params.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            &params.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(&params.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            &params.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(&params.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            &params.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(&params.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            &params.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(&params.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                    &params.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(&params.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                &params.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(&params.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(&params.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}