Skip to main content

tandem_server/browser_parts/
part02.rs

1#[async_trait]
2impl Tool for BrowserTool {
3    fn schema(&self) -> ToolSchema {
4        tool_schema(self.kind)
5    }
6
7    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
8        match self.execute_impl(args).await {
9            Ok(result) => Ok(result),
10            Err(err) => {
11                let message = err.to_string();
12                let (code, detail) = split_error_code(&message);
13                Ok(error_tool_result(code, detail.to_string(), None))
14            }
15        }
16    }
17}
18
19impl RuntimeState {
20    pub async fn browser_status(&self) -> BrowserStatus {
21        self.browser.status_snapshot().await
22    }
23
24    pub async fn browser_smoke_test(
25        &self,
26        url: Option<String>,
27    ) -> anyhow::Result<BrowserSmokeTestResult> {
28        self.browser.smoke_test(url).await
29    }
30
31    pub async fn install_browser_sidecar(&self) -> anyhow::Result<BrowserSidecarInstallResult> {
32        self.browser.install_sidecar().await
33    }
34
35    pub async fn browser_health_summary(&self) -> BrowserHealthSummary {
36        self.browser.health_summary().await
37    }
38
39    pub async fn close_browser_sessions_for_owner(&self, owner_session_id: &str) -> usize {
40        self.browser
41            .close_sessions_for_owner(owner_session_id)
42            .await
43    }
44
45    pub async fn close_all_browser_sessions(&self) -> usize {
46        self.browser.close_all_sessions().await
47    }
48}
49
50impl AppState {
51    pub async fn browser_status(&self) -> BrowserStatus {
52        match self.runtime.get() {
53            Some(runtime) => runtime.browser.status_snapshot().await,
54            None => BrowserStatus::default(),
55        }
56    }
57
58    pub async fn browser_smoke_test(
59        &self,
60        url: Option<String>,
61    ) -> anyhow::Result<BrowserSmokeTestResult> {
62        let Some(runtime) = self.runtime.get() else {
63            anyhow::bail!("runtime not ready");
64        };
65        runtime.browser_smoke_test(url).await
66    }
67
68    pub async fn install_browser_sidecar(&self) -> anyhow::Result<BrowserSidecarInstallResult> {
69        let Some(runtime) = self.runtime.get() else {
70            anyhow::bail!("runtime not ready");
71        };
72        runtime.install_browser_sidecar().await
73    }
74
75    pub async fn browser_health_summary(&self) -> BrowserHealthSummary {
76        match self.runtime.get() {
77            Some(runtime) => runtime.browser.health_summary().await,
78            None => BrowserHealthSummary::default(),
79        }
80    }
81
82    pub async fn close_browser_sessions_for_owner(&self, owner_session_id: &str) -> usize {
83        match self.runtime.get() {
84            Some(runtime) => {
85                runtime
86                    .close_browser_sessions_for_owner(owner_session_id)
87                    .await
88            }
89            None => 0,
90        }
91    }
92
93    pub async fn close_all_browser_sessions(&self) -> usize {
94        match self.runtime.get() {
95            Some(runtime) => runtime.close_all_browser_sessions().await,
96            None => 0,
97        }
98    }
99
100    pub async fn register_browser_tools(&self) -> anyhow::Result<()> {
101        let Some(runtime) = self.runtime.get() else {
102            anyhow::bail!("runtime not ready");
103        };
104        runtime
105            .browser
106            .register_tools(&runtime.tools, Some(self.clone()))
107            .await
108    }
109}
110
111fn evaluate_browser_status(config: BrowserConfig) -> BrowserStatus {
112    let mut status = run_doctor(BrowserDoctorOptions {
113        enabled: config.enabled,
114        headless_default: config.headless_default,
115        allow_no_sandbox: config.allow_no_sandbox,
116        executable_path: config.executable_path.clone(),
117        user_data_root: config.user_data_root.clone(),
118    });
119    status.headless_default = config.headless_default;
120    status.sidecar = evaluate_sidecar_status(config.sidecar_path.as_deref());
121    if config.enabled && !status.sidecar.found {
122        status.blocking_issues.push(BrowserBlockingIssue {
123            code: "browser_sidecar_not_found".to_string(),
124            message: "The tandem-browser sidecar binary was not found on this host.".to_string(),
125        });
126        status.recommendations.push(
127            "Install or bundle `tandem-browser`, or set `TANDEM_BROWSER_SIDECAR` / `browser.sidecar_path`."
128                .to_string(),
129        );
130    }
131    status.runnable = config.enabled
132        && status.sidecar.found
133        && status.browser.found
134        && status.blocking_issues.is_empty();
135    status
136}
137
138fn evaluate_sidecar_status(explicit: Option<&str>) -> tandem_browser::BrowserSidecarStatus {
139    let path = detect_sidecar_binary_path(explicit);
140    let version = path
141        .as_ref()
142        .and_then(|candidate| probe_binary_version(candidate).ok());
143    tandem_browser::BrowserSidecarStatus {
144        found: path.is_some(),
145        path: path.map(|row| row.to_string_lossy().to_string()),
146        version,
147    }
148}
149
150fn probe_binary_version(path: &Path) -> anyhow::Result<String> {
151    let output = std::process::Command::new(path)
152        .arg("--version")
153        .output()
154        .with_context(|| format!("failed to query `{}` version", path.display()))?;
155    if !output.status.success() {
156        anyhow::bail!(
157            "version probe failed: {}",
158            String::from_utf8_lossy(&output.stderr).trim()
159        );
160    }
161    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
162    if stdout.is_empty() {
163        anyhow::bail!("version probe returned empty stdout");
164    }
165    Ok(stdout)
166}
167
168pub async fn install_browser_sidecar(
169    config: &BrowserConfig,
170) -> anyhow::Result<BrowserSidecarInstallResult> {
171    let version = env!("CARGO_PKG_VERSION").to_string();
172    let release = fetch_release_for_version(&version).await?;
173    let asset_name = browser_release_asset_name()?;
174    let asset = release
175        .assets
176        .iter()
177        .find(|candidate| candidate.name == asset_name)
178        .ok_or_else(|| {
179            anyhow!(
180                "release_missing_asset: `{}` not found in {}",
181                asset_name,
182                release.tag_name
183            )
184        })?;
185    let install_path = sidecar_install_path(config)?;
186    let parent = install_path
187        .parent()
188        .ok_or_else(|| anyhow!("invalid install path `{}`", install_path.display()))?;
189    fs::create_dir_all(parent)
190        .await
191        .with_context(|| format!("failed to create `{}`", parent.display()))?;
192
193    let archive_bytes = download_release_asset(asset).await?;
194    let downloaded_bytes = archive_bytes.len() as u64;
195    let install_path_for_unpack = install_path.clone();
196    let asset_name_for_unpack = asset.name.clone();
197    let unpacked = tokio::task::spawn_blocking(move || {
198        unpack_sidecar_archive(
199            &asset_name_for_unpack,
200            &archive_bytes,
201            &install_path_for_unpack,
202        )
203    })
204    .await
205    .context("browser sidecar install task failed")??;
206
207    let status = evaluate_browser_status(config.clone());
208    Ok(BrowserSidecarInstallResult {
209        version,
210        asset_name: asset.name.clone(),
211        installed_path: unpacked.to_string_lossy().to_string(),
212        downloaded_bytes: asset.size.max(downloaded_bytes),
213        status,
214    })
215}
216
217async fn fetch_release_for_version(version: &str) -> anyhow::Result<GitHubRelease> {
218    let base = std::env::var(RELEASES_URL_ENV)
219        .unwrap_or_else(|_| format!("https://api.github.com/repos/{RELEASE_REPO}/releases/tags"));
220    let url = format!("{}/v{}", base.trim_end_matches('/'), version);
221    let response = reqwest::Client::new()
222        .get(&url)
223        .header(reqwest::header::USER_AGENT, BROWSER_INSTALL_USER_AGENT)
224        .send()
225        .await
226        .with_context(|| format!("failed to fetch release metadata from `{url}`"))?;
227    let status = response.status();
228    let body = response.text().await.unwrap_or_default();
229    if !status.is_success() {
230        anyhow::bail!("release_lookup_failed: {} {}", status, body.trim());
231    }
232    serde_json::from_str::<GitHubRelease>(&body).context("invalid release metadata payload")
233}
234
235async fn download_release_asset(asset: &GitHubAsset) -> anyhow::Result<Vec<u8>> {
236    let response = reqwest::Client::new()
237        .get(&asset.browser_download_url)
238        .header(reqwest::header::USER_AGENT, BROWSER_INSTALL_USER_AGENT)
239        .send()
240        .await
241        .with_context(|| format!("failed to download `{}`", asset.browser_download_url))?;
242    let status = response.status();
243    if !status.is_success() {
244        anyhow::bail!(
245            "asset_download_failed: {} {}",
246            status,
247            asset.browser_download_url
248        );
249    }
250    let bytes = response
251        .bytes()
252        .await
253        .context("failed to read asset bytes")?;
254    Ok(bytes.to_vec())
255}
256
257fn sidecar_install_path(config: &BrowserConfig) -> anyhow::Result<PathBuf> {
258    if let Some(explicit) = config
259        .sidecar_path
260        .as_deref()
261        .map(str::trim)
262        .filter(|value| !value.is_empty())
263    {
264        return Ok(PathBuf::from(explicit));
265    }
266    managed_sidecar_install_path()
267}
268
269fn managed_sidecar_install_path() -> anyhow::Result<PathBuf> {
270    let root = resolve_shared_paths()
271        .map(|paths| paths.canonical_root)
272        .unwrap_or_else(|_| {
273            dirs::home_dir()
274                .map(|home| home.join(".tandem"))
275                .unwrap_or_else(|| PathBuf::from(".tandem"))
276        });
277    Ok(root.join("binaries").join(sidecar_binary_name()))
278}
279
280fn browser_release_asset_name() -> anyhow::Result<String> {
281    let os = if cfg!(target_os = "windows") {
282        "windows"
283    } else if cfg!(target_os = "macos") {
284        "darwin"
285    } else if cfg!(target_os = "linux") {
286        "linux"
287    } else {
288        anyhow::bail!("unsupported_os: {}", std::env::consts::OS);
289    };
290    let arch = if cfg!(target_arch = "x86_64") {
291        "x64"
292    } else if cfg!(target_arch = "aarch64") {
293        "arm64"
294    } else {
295        anyhow::bail!("unsupported_arch: {}", std::env::consts::ARCH);
296    };
297    let ext = if cfg!(target_os = "windows") || cfg!(target_os = "macos") {
298        "zip"
299    } else {
300        "tar.gz"
301    };
302    Ok(format!("tandem-browser-{os}-{arch}.{ext}"))
303}
304
305fn sidecar_binary_name() -> &'static str {
306    #[cfg(target_os = "windows")]
307    {
308        "tandem-browser.exe"
309    }
310    #[cfg(not(target_os = "windows"))]
311    {
312        "tandem-browser"
313    }
314}
315
316fn unpack_sidecar_archive(
317    asset_name: &str,
318    archive_bytes: &[u8],
319    install_path: &Path,
320) -> anyhow::Result<PathBuf> {
321    if asset_name.ends_with(".zip") {
322        let cursor = std::io::Cursor::new(archive_bytes);
323        let mut archive = zip::ZipArchive::new(cursor).context("invalid zip archive")?;
324        let binary_present = archive
325            .file_names()
326            .any(|name| name == sidecar_binary_name());
327        let mut file = if binary_present {
328            archive
329                .by_name(sidecar_binary_name())
330                .context("browser binary missing from zip archive")?
331        } else {
332            archive
333                .by_index(0)
334                .context("browser binary missing from zip archive")?
335        };
336        let mut output = std::fs::File::create(install_path)
337            .with_context(|| format!("failed to create `{}`", install_path.display()))?;
338        std::io::copy(&mut file, &mut output).context("failed to unpack zip asset")?;
339    } else if asset_name.ends_with(".tar.gz") {
340        let cursor = std::io::Cursor::new(archive_bytes);
341        let decoder = GzDecoder::new(cursor);
342        let mut archive = tar::Archive::new(decoder);
343        let mut found = false;
344        for entry in archive.entries().context("invalid tar archive")? {
345            let mut entry = entry.context("invalid tar entry")?;
346            let path = entry.path().context("invalid tar entry path")?;
347            if path
348                .file_name()
349                .and_then(|name| name.to_str())
350                .is_some_and(|name| name == sidecar_binary_name())
351            {
352                entry
353                    .unpack(install_path)
354                    .with_context(|| format!("failed to unpack `{}`", install_path.display()))?;
355                found = true;
356                break;
357            }
358        }
359        if !found {
360            anyhow::bail!("browser binary missing from tar archive");
361        }
362    } else {
363        anyhow::bail!("unsupported archive format `{asset_name}`");
364    }
365
366    #[cfg(not(target_os = "windows"))]
367    {
368        use std::os::unix::fs::PermissionsExt;
369
370        let mut perms = std::fs::metadata(install_path)
371            .with_context(|| format!("failed to read `{}` metadata", install_path.display()))?
372            .permissions();
373        perms.set_mode(0o755);
374        std::fs::set_permissions(install_path, perms)
375            .with_context(|| format!("failed to chmod `{}`", install_path.display()))?;
376    }
377
378    Ok(install_path.to_path_buf())
379}
380
381fn parse_tool_context(args: &Value) -> BrowserToolContext {
382    serde_json::from_value(args.clone()).unwrap_or(BrowserToolContext {
383        model_session_id: None,
384    })
385}
386
387fn ok_tool_result(value: Value, metadata: Value) -> anyhow::Result<ToolResult> {
388    Ok(ToolResult {
389        output: serde_json::to_string_pretty(&value)?,
390        metadata,
391    })
392}
393
394fn error_tool_result(code: &str, message: String, metadata: Option<Value>) -> ToolResult {
395    let mut meta = metadata.unwrap_or_else(|| json!({}));
396    if let Some(obj) = meta.as_object_mut() {
397        obj.insert("ok".to_string(), Value::Bool(false));
398        obj.insert("code".to_string(), Value::String(code.to_string()));
399        obj.insert("message".to_string(), Value::String(message.clone()));
400    }
401    ToolResult {
402        output: message,
403        metadata: meta,
404    }
405}
406
407fn split_error_code(message: &str) -> (&str, &str) {
408    let Some((code, detail)) = message.split_once(':') else {
409        return ("browser_error", message);
410    };
411    let code = code.trim();
412    if code.is_empty()
413        || !code
414            .chars()
415            .all(|ch| ch.is_ascii_lowercase() || ch == '_' || ch.is_ascii_digit())
416    {
417        return ("browser_error", message);
418    }
419    (code, detail.trim())
420}
421
422fn smoke_excerpt(content: &str, max_chars: usize) -> String {
423    let mut excerpt = String::new();
424    for ch in content.chars().take(max_chars) {
425        excerpt.push(ch);
426    }
427    if content.chars().count() > max_chars {
428        excerpt.push_str("...");
429    }
430    excerpt
431}
432
433fn browser_not_runnable_result(status: &BrowserStatus) -> anyhow::Result<ToolResult> {
434    ok_tool_result(
435        serde_json::to_value(status)?,
436        json!({
437            "ok": false,
438            "code": "browser_not_runnable",
439            "runnable": status.runnable,
440            "enabled": status.enabled,
441        }),
442    )
443}
444
445fn normalize_allowed_hosts(hosts: Vec<String>) -> Vec<String> {
446    let mut out = Vec::new();
447    for host in hosts {
448        let normalized = host.trim().trim_start_matches('.').to_ascii_lowercase();
449        if normalized.is_empty() {
450            continue;
451        }
452        if !out.iter().any(|existing| existing == &normalized) {
453            out.push(normalized);
454        }
455    }
456    out
457}
458
459fn browser_url_host(url: &str) -> anyhow::Result<String> {
460    let parsed =
461        reqwest::Url::parse(url).with_context(|| format!("invalid browser url `{}`", url))?;
462    let host = parsed
463        .host_str()
464        .ok_or_else(|| anyhow!("url `{}` has no host", url))?;
465    Ok(host.to_ascii_lowercase())
466}
467
468fn ensure_allowed_browser_url(url: &str, allow_hosts: &[String]) -> anyhow::Result<()> {
469    let parsed =
470        reqwest::Url::parse(url).with_context(|| format!("invalid browser url `{}`", url))?;
471    match parsed.scheme() {
472        "http" | "https" => {}
473        other => anyhow::bail!("unsupported_url_scheme: `{}` is not allowed", other),
474    }
475    if allow_hosts.is_empty() {
476        return Ok(());
477    }
478    let host = parsed
479        .host_str()
480        .ok_or_else(|| anyhow!("url `{}` has no host", url))?
481        .to_ascii_lowercase();
482    let allowed = allow_hosts
483        .iter()
484        .any(|candidate| host == *candidate || host.ends_with(&format!(".{candidate}")));
485    if !allowed {
486        anyhow::bail!("host `{}` is not in the browser allowlist", host);
487    }
488    Ok(())
489}
490
491fn bool_env_value(enabled: bool) -> &'static str {
492    if enabled {
493        "true"
494    } else {
495        "false"
496    }
497}
498
499fn normalize_browser_open_request(request: &mut BrowserOpenRequest) {
500    request.profile_id = request
501        .profile_id
502        .take()
503        .map(|value| value.trim().to_string())
504        .filter(|value| !value.is_empty());
505}
506
507fn parse_browser_wait_condition(
508    input: BrowserWaitConditionArgs,
509) -> anyhow::Result<BrowserWaitCondition> {
510    let BrowserWaitConditionArgs {
511        kind,
512        value,
513        selector,
514        text,
515        url,
516    } = input;
517
518    let kind = kind
519        .map(|value| value.trim().to_string())
520        .filter(|value| !value.is_empty())
521        .or_else(|| selector.as_ref().map(|_| "selector".to_string()))
522        .or_else(|| text.as_ref().map(|_| "text".to_string()))
523        .or_else(|| url.as_ref().map(|_| "url".to_string()))
524        .ok_or_else(|| anyhow!("browser_wait requires condition.kind"))?;
525
526    let value = value
527        .filter(|value| !value.trim().is_empty())
528        .or_else(|| match kind.as_str() {
529            "selector" => selector,
530            "text" => text,
531            "url" => url,
532            _ => None,
533        });
534
535    Ok(BrowserWaitCondition { kind, value })
536}
537
538fn parse_browser_wait_args(args: &Value) -> anyhow::Result<BrowserWaitParams> {
539    let raw: BrowserWaitToolArgs = serde_json::from_value(args.clone())?;
540    let condition = if let Some(condition) = raw.condition {
541        parse_browser_wait_condition(condition)?
542    } else {
543        parse_browser_wait_condition(BrowserWaitConditionArgs {
544            kind: raw.kind,
545            value: raw.value,
546            selector: raw.selector,
547            text: raw.text,
548            url: raw.url,
549        })?
550    };
551
552    Ok(BrowserWaitParams {
553        session_id: raw.session_id,
554        condition,
555        timeout_ms: raw.timeout_ms,
556    })
557}
558
559fn is_local_or_private_host(host: &str) -> bool {
560    if host.eq_ignore_ascii_case("localhost") {
561        return true;
562    }
563    let Ok(ip) = host.parse::<IpAddr>() else {
564        return false;
565    };
566    match ip {
567        IpAddr::V4(ip) => {
568            ip.is_loopback()
569                || ip.is_private()
570                || ip.is_link_local()
571                || ip.octets()[0] == 169 && ip.octets()[1] == 254
572        }
573        IpAddr::V6(ip) => {
574            ip == Ipv6Addr::LOCALHOST || ip.is_unique_local() || ip.is_unicast_link_local()
575        }
576    }
577}
578
579fn resolve_text_input(text: Option<String>, secret_ref: Option<String>) -> anyhow::Result<String> {
580    if let Some(secret_ref) = secret_ref
581        .map(|v| v.trim().to_string())
582        .filter(|v| !v.is_empty())
583    {
584        let value = std::env::var(&secret_ref).with_context(|| {
585            format!("secret_ref `{}` is not set in the environment", secret_ref)
586        })?;
587        if value.trim().is_empty() {
588            anyhow::bail!("secret_ref `{}` resolved to an empty value", secret_ref);
589        }
590        return Ok(value);
591    }
592    let text = text.unwrap_or_default();
593    if text.is_empty() {
594        anyhow::bail!("browser_type requires either `text` or `secret_ref`");
595    }
596    Ok(text)
597}
598
599fn extension_for_extract_format(format: &str) -> &'static str {
600    match format {
601        "html" => "html",
602        "markdown" => "md",
603        _ => "txt",
604    }
605}
606
607fn viewport_schema() -> Value {
608    json!({
609        "type": "object",
610        "properties": {
611            "width": { "type": "integer", "minimum": 1, "maximum": 10000 },
612            "height": { "type": "integer", "minimum": 1, "maximum": 10000 }
613        }
614    })
615}
616
617fn wait_condition_schema() -> Value {
618    json!({
619        "type": "object",
620        "properties": {
621            "kind": {
622                "type": "string",
623                "enum": ["selector", "text", "url", "network_idle", "navigation"]
624            },
625            "value": { "type": "string" }
626        },
627        "required": ["kind"]
628    })
629}
630
631fn tool_schema(kind: BrowserToolKind) -> ToolSchema {
632    match kind {
633        BrowserToolKind::Status => ToolSchema::new(
634            "browser_status",
635            "Check browser automation readiness and install guidance. Call this first when browser tools may be unavailable.",
636            json!({ "type": "object", "properties": {} }),
637        ),
638        BrowserToolKind::Open => ToolSchema::new(
639            "browser_open",
640            "Open a URL in a browser session. Only http/https are allowed. Omit profile_id for an ephemeral session.",
641            json!({
642                "type": "object",
643                "properties": {
644                    "url": { "type": "string" },
645                    "profile_id": { "type": "string" },
646                    "headless": { "type": "boolean" },
647                    "viewport": viewport_schema(),
648                    "wait_until": { "type": "string", "enum": ["navigation", "network_idle"] }
649                },
650                "required": ["url"]
651            }),
652        ),
653        BrowserToolKind::Navigate => ToolSchema::new(
654            "browser_navigate",
655            "Navigate an existing browser session to a new URL.",
656            json!({
657                "type": "object",
658                "properties": {
659                    "session_id": { "type": "string" },
660                    "url": { "type": "string" },
661                    "wait_until": { "type": "string", "enum": ["navigation", "network_idle"] }
662                },
663                "required": ["session_id", "url"]
664            }),
665        ),
666        BrowserToolKind::Snapshot => ToolSchema::new(
667            "browser_snapshot",
668            "Capture a bounded page summary with stable element_id values. Call this before click/type on a new page or after navigation.",
669            json!({
670                "type": "object",
671                "properties": {
672                    "session_id": { "type": "string" },
673                    "max_elements": { "type": "integer", "minimum": 1, "maximum": 200 },
674                    "include_screenshot": { "type": "boolean" }
675                },
676                "required": ["session_id"]
677            }),
678        ),
679        BrowserToolKind::Click => ToolSchema::new(
680            "browser_click",
681            "Click a visible page element by element_id when possible. Use wait_for to make navigation and selector waits race-free.",
682            json!({
683                "type": "object",
684                "properties": {
685                    "session_id": { "type": "string" },
686                    "element_id": { "type": "string" },
687                    "selector": { "type": "string" },
688                    "wait_for": wait_condition_schema(),
689                    "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 }
690                },
691                "required": ["session_id"]
692            }),
693        ),
694        BrowserToolKind::Type => ToolSchema::new(
695            "browser_type",
696            "Type text into an element. Prefer secret_ref over text for credentials; secret_ref resolves from the host environment and is redacted from logs.",
697            json!({
698                "type": "object",
699                "properties": {
700                    "session_id": { "type": "string" },
701                    "element_id": { "type": "string" },
702                    "selector": { "type": "string" },
703                    "text": { "type": "string" },
704                    "secret_ref": { "type": "string" },
705                    "replace": { "type": "boolean" },
706                    "submit": { "type": "boolean" },
707                    "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 }
708                },
709                "required": ["session_id"]
710            }),
711        ),
712        BrowserToolKind::Press => ToolSchema::new(
713            "browser_press",
714            "Dispatch a key press in the active page context.",
715            json!({
716                "type": "object",
717                "properties": {
718                    "session_id": { "type": "string" },
719                    "key": { "type": "string" },
720                    "wait_for": wait_condition_schema(),
721                    "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 }
722                },
723                "required": ["session_id", "key"]
724            }),
725        ),
726        BrowserToolKind::Wait => ToolSchema::new(
727            "browser_wait",
728            "Wait for a selector, text, URL fragment, navigation, or network idle.",
729            json!({
730                "type": "object",
731                "properties": {
732                    "session_id": { "type": "string" },
733                    "condition": wait_condition_schema(),
734                    "wait_for": wait_condition_schema(),
735                    "waitFor": wait_condition_schema(),
736                    "kind": {
737                        "type": "string",
738                        "enum": ["selector", "text", "url", "network_idle", "navigation"]
739                    },
740                    "type": {
741                        "type": "string",
742                        "enum": ["selector", "text", "url", "network_idle", "navigation"]
743                    },
744                    "value": { "type": "string" },
745                    "selector": { "type": "string" },
746                    "text": { "type": "string" },
747                    "url": { "type": "string" },
748                    "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 },
749                    "timeoutMs": { "type": "integer", "minimum": 250, "maximum": 120000 }
750                },
751                "required": ["session_id"],
752                "anyOf": [
753                    { "required": ["condition"] },
754                    { "required": ["wait_for"] },
755                    { "required": ["waitFor"] },
756                    { "required": ["kind"] },
757                    { "required": ["type"] },
758                    { "required": ["selector"] },
759                    { "required": ["text"] },
760                    { "required": ["url"] }
761                ]
762            }),
763        ),
764        BrowserToolKind::Extract => ToolSchema::new(
765            "browser_extract",
766            "Extract page content as visible_text, markdown, or html. Prefer this over screenshots when you need text.",
767            json!({
768                "type": "object",
769                "properties": {
770                    "session_id": { "type": "string" },
771                    "format": { "type": "string", "enum": ["visible_text", "markdown", "html"] },
772                    "max_bytes": { "type": "integer", "minimum": 1024, "maximum": 2000000 }
773                },
774                "required": ["session_id", "format"]
775            }),
776        ),
777        BrowserToolKind::Screenshot => ToolSchema::new(
778            "browser_screenshot",
779            "Capture a screenshot and store it as a browser artifact.",
780            json!({
781                "type": "object",
782                "properties": {
783                    "session_id": { "type": "string" },
784                    "full_page": { "type": "boolean" },
785                    "label": { "type": "string" }
786                },
787                "required": ["session_id"]
788            }),
789        ),
790        BrowserToolKind::Close => ToolSchema::new(
791            "browser_close",
792            "Close a browser session and release its resources.",
793            json!({
794                "type": "object",
795                "properties": {
796                    "session_id": { "type": "string" }
797                },
798                "required": ["session_id"]
799            }),
800        ),
801    }
802}