1use std::collections::HashMap;
13
14use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
15
16const MAX_PAGE_SIZE: usize = 5 * 1024 * 1024;
18
19const MAX_TEXT_LENGTH: usize = 5000;
21
22const DEFAULT_TIMEOUT_MS: u64 = 10_000;
24
25const 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#[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 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 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 async fn get_text(&self, target: Option<&str>, _ctx: &ToolContext) -> ToolResult {
178 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 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 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 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 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 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 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 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 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 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 async fn tab_close(&self, _target: Option<&str>) -> ToolResult {
366 ToolResult::ok("No browser context open (HTTP-only mode).")
367 }
368
369 async fn back(&self) -> ToolResult {
371 ToolResult::fail("Browser history navigation requires a persistent browser session.")
372 }
373
374 async fn forward(&self) -> ToolResult {
376 ToolResult::fail("Browser history navigation requires a persistent browser session.")
377 }
378
379 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
387fn 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
399fn 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
414fn 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
430fn 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 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 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 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
477fn html_decode(s: &str) -> String {
479 s.replace("&", "&")
480 .replace("<", "<")
481 .replace(">", ">")
482 .replace(""", "\"")
483 .replace("'", "'")
484 .replace("'", "'")
485 .replace(" ", " ")
486}
487
488#[cfg(test)]
489#[path = "browser_tests.rs"]
490mod tests;