Skip to main content

opendev_tools_impl/
browser.rs

1//! Browser automation tool — headless browser interaction.
2//!
3//! Provides browser automation actions (navigate, click, type, fill,
4//! screenshot, get_text, evaluate JS, etc.) using a headless browser.
5//!
6//! This implementation uses `reqwest` for simple page fetching and
7//! JavaScript-free DOM extraction. For full Playwright-equivalent
8//! functionality, a Playwright/Chrome DevTools Protocol (CDP) bridge
9//! would be needed. This tool degrades gracefully when full browser
10//! automation is not available, offering HTTP-based fallbacks.
11
12use std::collections::HashMap;
13
14use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
15
16/// Maximum page body size to process (5 MB).
17const MAX_PAGE_SIZE: usize = 5 * 1024 * 1024;
18
19/// Maximum text content length to return.
20const MAX_TEXT_LENGTH: usize = 5000;
21
22/// Default action timeout in milliseconds.
23const DEFAULT_TIMEOUT_MS: u64 = 10_000;
24
25/// Available browser actions.
26const AVAILABLE_ACTIONS: &[&str] = &[
27    "navigate",
28    "get_text",
29    "screenshot",
30    "evaluate",
31    "back",
32    "forward",
33    "reload",
34    "tabs_list",
35    "tab_close",
36    "click",
37    "type",
38    "fill",
39    "wait",
40];
41
42/// Tool for browser automation.
43#[derive(Debug)]
44pub struct BrowserTool;
45
46#[async_trait::async_trait]
47impl BaseTool for BrowserTool {
48    fn name(&self) -> &str {
49        "browser"
50    }
51
52    fn description(&self) -> &str {
53        "Interactive browser automation. Supports actions: navigate, click, type, fill, \
54         screenshot, get_text, wait, evaluate, tabs_list, tab_close, back, forward, reload."
55    }
56
57    fn parameter_schema(&self) -> serde_json::Value {
58        serde_json::json!({
59            "type": "object",
60            "properties": {
61                "action": {
62                    "type": "string",
63                    "description": "Browser action to perform",
64                    "enum": AVAILABLE_ACTIONS
65                },
66                "target": {
67                    "type": "string",
68                    "description": "Target for the action (URL, CSS selector, JS expression)"
69                },
70                "value": {
71                    "type": "string",
72                    "description": "Value for the action (text to type, JS to evaluate)"
73                },
74                "timeout": {
75                    "type": "integer",
76                    "description": "Action timeout in milliseconds (default: 10000)"
77                }
78            },
79            "required": ["action"]
80        })
81    }
82
83    async fn execute(
84        &self,
85        args: HashMap<String, serde_json::Value>,
86        ctx: &ToolContext,
87    ) -> ToolResult {
88        let action = match args.get("action").and_then(|v| v.as_str()) {
89            Some(a) => a,
90            None => return ToolResult::fail("action is required"),
91        };
92
93        let target = args.get("target").and_then(|v| v.as_str());
94        let value = args.get("value").and_then(|v| v.as_str());
95        let _timeout = args
96            .get("timeout")
97            .and_then(|v| v.as_u64())
98            .unwrap_or(DEFAULT_TIMEOUT_MS);
99
100        match action {
101            "navigate" => self.navigate(target, ctx).await,
102            "get_text" => self.get_text(target, ctx).await,
103            "screenshot" => self.screenshot(target, ctx).await,
104            "click" => self.click(target).await,
105            "type" => self.type_text(target, value).await,
106            "fill" => self.fill(target, value).await,
107            "wait" => self.wait(target).await,
108            "evaluate" => self.evaluate(target, value).await,
109            "tabs_list" => self.tabs_list().await,
110            "tab_close" => self.tab_close(target).await,
111            "back" => self.back().await,
112            "forward" => self.forward().await,
113            "reload" => self.reload().await,
114            other => ToolResult::fail(format!(
115                "Unknown browser action: {other}. Available: {}",
116                AVAILABLE_ACTIONS.join(", ")
117            )),
118        }
119    }
120
121    fn display_meta(&self) -> Option<ToolDisplayMeta> {
122        Some(ToolDisplayMeta {
123            verb: "Browse",
124            label: "page",
125            category: "Web",
126            primary_arg_keys: &["action", "target"],
127        })
128    }
129}
130
131impl BrowserTool {
132    /// Navigate to a URL and return page info.
133    ///
134    /// Uses HTTP GET to fetch the page. For JavaScript-rendered pages,
135    /// a real browser engine would be needed.
136    async fn navigate(&self, target: Option<&str>, _ctx: &ToolContext) -> ToolResult {
137        let url = match target {
138            Some(u) if !u.is_empty() => u,
139            _ => return ToolResult::fail("URL is required for navigate"),
140        };
141
142        // Normalize URL
143        let url = normalize_url(url);
144
145        let client = match build_client() {
146            Ok(c) => c,
147            Err(e) => return ToolResult::fail(format!("Failed to create HTTP client: {e}")),
148        };
149
150        let response = match client.get(&url).send().await {
151            Ok(r) => r,
152            Err(e) => return ToolResult::fail(format!("Navigation failed: {e}")),
153        };
154
155        let status = response.status().as_u16();
156        let final_url = response.url().to_string();
157
158        let body = match response.text().await {
159            Ok(t) => t,
160            Err(e) => return ToolResult::fail(format!("Failed to read page: {e}")),
161        };
162
163        let title = extract_title(&body).unwrap_or_else(|| "Untitled".to_string());
164
165        let mut metadata = HashMap::new();
166        metadata.insert("status".into(), serde_json::json!(status));
167        metadata.insert("url".into(), serde_json::json!(final_url));
168        metadata.insert("title".into(), serde_json::json!(title));
169
170        ToolResult::ok_with_metadata(
171            format!("Navigated to: {url}\nTitle: {title}\nURL: {final_url}"),
172            metadata,
173        )
174    }
175
176    /// Get text content from the page or a specific element.
177    async fn get_text(&self, target: Option<&str>, _ctx: &ToolContext) -> ToolResult {
178        // Without a real browser session, we need a URL to fetch
179        let url = match target {
180            Some(t) if t.starts_with("http://") || t.starts_with("https://") => t,
181            Some(selector) => {
182                return ToolResult::fail(format!(
183                    "CSS selector '{selector}' requires an active browser session. \
184                     Use 'navigate' action first, then provide a URL for get_text, or \
185                     use the web_fetch tool instead."
186                ));
187            }
188            None => {
189                return ToolResult::fail("Target (URL or CSS selector) is required for get_text");
190            }
191        };
192
193        let client = match build_client() {
194            Ok(c) => c,
195            Err(e) => return ToolResult::fail(format!("Failed to create HTTP client: {e}")),
196        };
197
198        let response = match client.get(url).send().await {
199            Ok(r) => r,
200            Err(e) => return ToolResult::fail(format!("Request failed: {e}")),
201        };
202
203        let body = match response.text().await {
204            Ok(t) => t,
205            Err(e) => return ToolResult::fail(format!("Failed to read response: {e}")),
206        };
207
208        // Extract visible text from HTML
209        let text = extract_visible_text(&body);
210
211        let truncated = text.len() > MAX_TEXT_LENGTH;
212        let text = if truncated {
213            format!("{}...\n[truncated]", &text[..MAX_TEXT_LENGTH])
214        } else {
215            text
216        };
217
218        ToolResult::ok(text)
219    }
220
221    /// Capture a screenshot — saves as HTML snapshot since we don't have a real browser.
222    async fn screenshot(&self, target: Option<&str>, _ctx: &ToolContext) -> ToolResult {
223        let url = match target {
224            Some(u) if u.starts_with("http://") || u.starts_with("https://") => u,
225            Some(_) | None => {
226                return ToolResult::fail(
227                    "URL is required for screenshot. Use web_screenshot tool for full \
228                     browser screenshots with JavaScript rendering.",
229                );
230            }
231        };
232
233        let client = match build_client() {
234            Ok(c) => c,
235            Err(e) => return ToolResult::fail(format!("Failed to create HTTP client: {e}")),
236        };
237
238        let response = match client.get(url).send().await {
239            Ok(r) => r,
240            Err(e) => return ToolResult::fail(format!("Request failed: {e}")),
241        };
242
243        let body = match response.text().await {
244            Ok(t) => {
245                if t.len() > MAX_PAGE_SIZE {
246                    t[..MAX_PAGE_SIZE].to_string()
247                } else {
248                    t
249                }
250            }
251            Err(e) => return ToolResult::fail(format!("Failed to read page: {e}")),
252        };
253
254        // Save HTML snapshot
255        let screenshot_dir = std::env::temp_dir().join("opendev-screenshots");
256        std::fs::create_dir_all(&screenshot_dir).ok();
257        let filename = format!("browser_{}.html", std::process::id());
258        let path = screenshot_dir.join(&filename);
259
260        match std::fs::write(&path, &body) {
261            Ok(_) => {
262                let mut metadata = HashMap::new();
263                metadata.insert(
264                    "screenshot_path".into(),
265                    serde_json::json!(path.to_string_lossy()),
266                );
267                metadata.insert("format".into(), serde_json::json!("html"));
268                metadata.insert(
269                    "note".into(),
270                    serde_json::json!(
271                        "HTML snapshot saved. For rendered screenshots, use the web_screenshot tool."
272                    ),
273                );
274
275                ToolResult::ok_with_metadata(
276                    format!(
277                        "HTML snapshot saved: {}\nPage: {url}\n\
278                         Note: For visual screenshots, use the web_screenshot tool.",
279                        path.display()
280                    ),
281                    metadata,
282                )
283            }
284            Err(e) => ToolResult::fail(format!("Failed to save snapshot: {e}")),
285        }
286    }
287
288    /// Click action — requires active browser session.
289    async fn click(&self, target: Option<&str>) -> ToolResult {
290        let selector = match target {
291            Some(s) if !s.is_empty() => s,
292            _ => return ToolResult::fail("CSS selector is required for click"),
293        };
294        ToolResult::fail(format!(
295            "Click on '{selector}' requires a browser session with JavaScript support. \
296             Consider using the web_fetch tool for content retrieval, or the bash tool \
297             to run a headless browser script."
298        ))
299    }
300
301    /// Type text into an element.
302    async fn type_text(&self, target: Option<&str>, value: Option<&str>) -> ToolResult {
303        let selector = match target {
304            Some(s) if !s.is_empty() => s,
305            _ => return ToolResult::fail("CSS selector is required for type"),
306        };
307        let _text = match value {
308            Some(v) => v,
309            None => return ToolResult::fail("value (text) is required for type"),
310        };
311        ToolResult::fail(format!(
312            "Typing into '{selector}' requires a browser session with JavaScript support. \
313             Consider using curl/wget via the bash tool for form submission."
314        ))
315    }
316
317    /// Fill a form field.
318    async fn fill(&self, target: Option<&str>, value: Option<&str>) -> ToolResult {
319        let selector = match target {
320            Some(s) if !s.is_empty() => s,
321            _ => return ToolResult::fail("CSS selector is required for fill"),
322        };
323        let _text = match value {
324            Some(v) => v,
325            None => return ToolResult::fail("value (text) is required for fill"),
326        };
327        ToolResult::fail(format!(
328            "Filling '{selector}' requires a browser session with JavaScript support."
329        ))
330    }
331
332    /// Wait for an element — requires active browser session.
333    async fn wait(&self, target: Option<&str>) -> ToolResult {
334        let selector = match target {
335            Some(s) if !s.is_empty() => s,
336            _ => return ToolResult::fail("CSS selector is required for wait"),
337        };
338        ToolResult::fail(format!(
339            "Waiting for '{selector}' requires a browser session with JavaScript support."
340        ))
341    }
342
343    /// Evaluate JavaScript — requires active browser session.
344    async fn evaluate(&self, target: Option<&str>, value: Option<&str>) -> ToolResult {
345        let _js_code = value.or(target);
346        if _js_code.is_none() || _js_code.unwrap().is_empty() {
347            return ToolResult::fail("JavaScript expression is required for evaluate");
348        }
349        ToolResult::fail(
350            "JavaScript evaluation requires a browser session. \
351             Consider using the bash tool to run Node.js scripts."
352                .to_string(),
353        )
354    }
355
356    /// List open tabs — no persistent browser state in HTTP mode.
357    async fn tabs_list(&self) -> ToolResult {
358        ToolResult::ok(
359            "No browser context open (HTTP-only mode). \
360                        Use 'navigate' to fetch a page.",
361        )
362    }
363
364    /// Close a tab.
365    async fn tab_close(&self, _target: Option<&str>) -> ToolResult {
366        ToolResult::ok("No browser context open (HTTP-only mode).")
367    }
368
369    /// Navigate back.
370    async fn back(&self) -> ToolResult {
371        ToolResult::fail("Browser history navigation requires a persistent browser session.")
372    }
373
374    /// Navigate forward.
375    async fn forward(&self) -> ToolResult {
376        ToolResult::fail("Browser history navigation requires a persistent browser session.")
377    }
378
379    /// Reload the current page.
380    async fn reload(&self) -> ToolResult {
381        ToolResult::fail(
382            "Reload requires a persistent browser session. Use 'navigate' to re-fetch a URL.",
383        )
384    }
385}
386
387/// Build an HTTP client with browser-like settings.
388fn build_client() -> Result<reqwest::Client, reqwest::Error> {
389    reqwest::Client::builder()
390        .timeout(std::time::Duration::from_secs(30))
391        .redirect(reqwest::redirect::Policy::limited(10))
392        .user_agent(
393            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
394             AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
395        )
396        .build()
397}
398
399/// Normalize a URL, adding `https://` if no scheme is present.
400fn normalize_url(url: &str) -> String {
401    let url = url.trim();
402    if url.starts_with("https://") || url.starts_with("http://") {
403        return url.to_string();
404    }
405    if url.starts_with("https:/") && !url.starts_with("https://") {
406        return url.replacen("https:/", "https://", 1);
407    }
408    if url.starts_with("http:/") && !url.starts_with("http://") {
409        return url.replacen("http:/", "http://", 1);
410    }
411    format!("https://{url}")
412}
413
414/// Extract the `<title>` from HTML.
415fn extract_title(html: &str) -> Option<String> {
416    let lower = html.to_lowercase();
417    let start = lower.find("<title")?;
418    let rest = &html[start..];
419    let tag_end = rest.find('>')?;
420    let after_tag = &rest[tag_end + 1..];
421    let end = after_tag.find('<')?;
422    let title = after_tag[..end].trim().to_string();
423    if title.is_empty() {
424        None
425    } else {
426        Some(html_decode(&title))
427    }
428}
429
430/// Extract visible text from HTML, stripping tags, scripts, and styles.
431fn extract_visible_text(html: &str) -> String {
432    let mut result = String::with_capacity(html.len() / 2);
433    let mut in_tag = false;
434    let mut in_script = false;
435    let mut in_style = false;
436    let lower = html.to_lowercase();
437    let chars: Vec<char> = html.chars().collect();
438    let lower_chars: Vec<char> = lower.chars().collect();
439
440    let mut i = 0;
441    while i < chars.len() {
442        if !in_tag && chars[i] == '<' {
443            in_tag = true;
444            // Check if entering script or style
445            let remaining: String = lower_chars[i..].iter().take(20).collect();
446            if remaining.starts_with("<script") {
447                in_script = true;
448            } else if remaining.starts_with("<style") {
449                in_style = true;
450            } else if remaining.starts_with("</script") {
451                in_script = false;
452            } else if remaining.starts_with("</style") {
453                in_style = false;
454            }
455        } else if in_tag && chars[i] == '>' {
456            in_tag = false;
457            // Add space to separate content from different tags
458            if !result.ends_with(' ') && !result.ends_with('\n') {
459                result.push(' ');
460            }
461        } else if !in_tag && !in_script && !in_style {
462            result.push(chars[i]);
463        }
464        i += 1;
465    }
466
467    // Decode HTML entities and collapse whitespace
468    let decoded = html_decode(&result);
469    let lines: Vec<&str> = decoded
470        .lines()
471        .map(|l| l.trim())
472        .filter(|l| !l.is_empty())
473        .collect();
474    lines.join("\n")
475}
476
477/// Decode common HTML entities.
478fn html_decode(s: &str) -> String {
479    s.replace("&amp;", "&")
480        .replace("&lt;", "<")
481        .replace("&gt;", ">")
482        .replace("&quot;", "\"")
483        .replace("&#39;", "'")
484        .replace("&apos;", "'")
485        .replace("&nbsp;", " ")
486}
487
488#[cfg(test)]
489#[path = "browser_tests.rs"]
490mod tests;