Skip to main content

microclaw_engine/tools/
browser.rs

1use std::path::PathBuf;
2
3use async_trait::async_trait;
4use serde_json::json;
5use tracing::info;
6
7use crate::internal::tool_runtime::command_runner::agent_browser_program;
8use microclaw_core::llm_types::ToolDefinition;
9use microclaw_core::text::floor_char_boundary;
10
11use super::{auth_context_from_input, schema_object, Tool, ToolResult};
12
13pub struct BrowserTool {
14    data_dir: PathBuf,
15    default_timeout_secs: u64,
16}
17
18fn split_browser_command(command: &str) -> Result<Vec<String>, String> {
19    let mut args = Vec::new();
20    let mut current = String::new();
21    let mut quote: Option<char> = None;
22    let mut escaped = false;
23
24    for ch in command.chars() {
25        if escaped {
26            current.push(ch);
27            escaped = false;
28            continue;
29        }
30
31        if ch == '\\' {
32            escaped = true;
33            continue;
34        }
35
36        if let Some(q) = quote {
37            if ch == q {
38                quote = None;
39            } else {
40                current.push(ch);
41            }
42            continue;
43        }
44
45        if ch == '"' || ch == '\'' {
46            quote = Some(ch);
47            continue;
48        }
49
50        if ch.is_whitespace() {
51            if !current.is_empty() {
52                args.push(current.clone());
53                current.clear();
54            }
55            continue;
56        }
57
58        current.push(ch);
59    }
60
61    if escaped {
62        current.push('\\');
63    }
64    if quote.is_some() {
65        return Err("unclosed quote".into());
66    }
67    if !current.is_empty() {
68        args.push(current);
69    }
70    Ok(args)
71}
72
73impl BrowserTool {
74    pub fn new(data_dir: &str) -> Self {
75        BrowserTool {
76            data_dir: PathBuf::from(data_dir).join("groups"),
77            default_timeout_secs: 30,
78        }
79    }
80
81    pub fn with_default_timeout_secs(mut self, timeout_secs: u64) -> Self {
82        self.default_timeout_secs = timeout_secs;
83        self
84    }
85
86    fn profile_path(&self, chat_id: i64) -> PathBuf {
87        self.data_dir
88            .join(chat_id.to_string())
89            .join("browser-profile")
90    }
91
92    fn session_name_for_chat(chat_id: i64) -> String {
93        let normalized = if chat_id < 0 {
94            format!("neg{}", chat_id.unsigned_abs())
95        } else {
96            chat_id.to_string()
97        };
98        format!("microclaw-chat-{normalized}")
99    }
100}
101
102#[async_trait]
103impl Tool for BrowserTool {
104    fn name(&self) -> &str {
105        "browser"
106    }
107
108    fn definition(&self) -> ToolDefinition {
109        ToolDefinition {
110            name: "browser".into(),
111            description: "Headless browser automation via agent-browser CLI. Browser state (cookies, localStorage, login sessions) persists across calls and across conversations.\n\n\
112                ## Basic workflow\n\
113                1. `open <url>` — navigate to a URL\n\
114                2. `snapshot -i` — get interactive elements with refs (@e1, @e2, ...)\n\
115                3. `click @e1` / `fill @e2 \"text\"` — interact with elements\n\
116                4. `get text @e3` — extract text content\n\
117                5. Always run `snapshot -i` after navigation or interaction to see updated state\n\n\
118                ## All available commands\n\
119                **Navigation**: open, back, forward, reload, close\n\
120                **Interaction**: click, dblclick, fill, type, press, hover, select, check, uncheck, upload, drag\n\
121                **Scrolling**: scroll <dir> [px], scrollintoview <sel>\n\
122                **Data extraction**: get text/html/value/attr/title/url/count/box <sel>\n\
123                **State checks**: is visible/enabled/checked <sel>\n\
124                **Snapshot**: snapshot (-i for interactive only, -c for compact)\n\
125                **Screenshot/PDF**: screenshot [path] (--full for full page), pdf <path>\n\
126                **JavaScript**: eval <js>\n\
127                **Cookies**: cookies, cookies set <name> <val>, cookies clear\n\
128                **Storage**: storage local [key], storage local set <k> <v>, storage local clear (same for session)\n\
129                **Tabs**: tab, tab new [url], tab <n>, tab close [n]\n\
130                **Frames**: frame <sel>, frame main\n\
131                **Dialogs**: dialog accept [text], dialog dismiss\n\
132                **Viewport**: set viewport <w> <h>, set device <name>, set media dark/light\n\
133                **Network**: network route <url> [--abort|--body <json>], network requests\n\
134                **Wait**: wait <sel|ms|--text|--url|--load|--fn>\n\
135                **Auth state**: state save <path>, state load <path>\n\
136                **Semantic find**: find role/text/label/placeholder <value> <action> [input]".into(),
137            input_schema: schema_object(
138                json!({
139                    "command": {
140                        "type": "string",
141                        "description": "The agent-browser command to run (e.g. `open https://example.com`, `snapshot -i`, `fill @e2 \"hello\"`)"
142                    },
143                    "timeout_secs": {
144                        "type": "integer",
145                        "description": "Timeout in seconds (defaults to configured tool timeout budget)"
146                    }
147                }),
148                &["command"],
149            ),
150        }
151    }
152
153    async fn execute(&self, input: serde_json::Value) -> ToolResult {
154        let command = match input.get("command").and_then(|v| v.as_str()) {
155            Some(c) => c,
156            None => return ToolResult::error("Missing 'command' parameter".into()),
157        };
158
159        let timeout_secs = input
160            .get("timeout_secs")
161            .and_then(|v| v.as_u64())
162            .unwrap_or(self.default_timeout_secs);
163
164        let auth = auth_context_from_input(&input);
165
166        let session_name = auth
167            .as_ref()
168            .map(|auth| Self::session_name_for_chat(auth.caller_chat_id))
169            .unwrap_or_else(|| "microclaw".to_string());
170
171        let mut args = vec!["--session".to_string(), session_name];
172        if let Some(auth) = auth.as_ref() {
173            let path = self.profile_path(auth.caller_chat_id);
174            args.push("--profile".to_string());
175            args.push(path.to_string_lossy().to_string());
176        }
177
178        let command_args = match split_browser_command(command) {
179            Ok(parts) if !parts.is_empty() => parts,
180            Ok(_) => return ToolResult::error("Empty browser command".into()),
181            Err(e) => {
182                return ToolResult::error(format!(
183                    "Invalid browser command syntax (quote parsing failed): {e}"
184                ));
185            }
186        };
187        args.extend(command_args);
188
189        let program = agent_browser_program();
190        info!("Executing browser command via '{}'", program);
191
192        let result = tokio::time::timeout(
193            std::time::Duration::from_secs(timeout_secs),
194            tokio::process::Command::new(&program).args(&args).output(),
195        )
196        .await;
197
198        match result {
199            Ok(Ok(output)) => {
200                let stdout = String::from_utf8_lossy(&output.stdout);
201                let stderr = String::from_utf8_lossy(&output.stderr);
202                let exit_code = output.status.code().unwrap_or(-1);
203
204                let mut result_text = String::new();
205                if !stdout.is_empty() {
206                    result_text.push_str(&stdout);
207                }
208                if !stderr.is_empty() {
209                    if !result_text.is_empty() {
210                        result_text.push('\n');
211                    }
212                    result_text.push_str("STDERR:\n");
213                    result_text.push_str(&stderr);
214                }
215                if result_text.is_empty() {
216                    result_text = format!("Command completed with exit code {exit_code}");
217                }
218
219                // Truncate very long output
220                if result_text.len() > 30000 {
221                    let cutoff = floor_char_boundary(&result_text, 30000);
222                    result_text.truncate(cutoff);
223                    result_text.push_str("\n... (output truncated)");
224                }
225
226                if exit_code == 0 {
227                    ToolResult::success(result_text).with_status_code(exit_code)
228                } else {
229                    ToolResult::error(format!("Exit code {exit_code}\n{result_text}"))
230                        .with_status_code(exit_code)
231                        .with_error_type("process_exit")
232                }
233            }
234            Ok(Err(e)) => ToolResult::error(format!("Failed to execute agent-browser: {e}"))
235                .with_error_type("spawn_error"),
236            Err(_) => ToolResult::error(format!(
237                "Browser command timed out after {timeout_secs} seconds"
238            ))
239            .with_error_type("timeout"),
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use serde_json::json;
248
249    #[test]
250    fn test_split_browser_command() {
251        let args = split_browser_command("fill @e2 \"hello world\"").unwrap();
252        assert_eq!(args, vec!["fill", "@e2", "hello world"]);
253    }
254
255    #[test]
256    fn test_split_browser_command_unclosed_quote() {
257        let err = split_browser_command("open \"https://example.com").unwrap_err();
258        assert!(err.contains("unclosed quote"));
259    }
260
261    #[test]
262    fn test_browser_tool_name_and_definition() {
263        let tool = BrowserTool::new("/tmp/test-data");
264        assert_eq!(tool.name(), "browser");
265        let def = tool.definition();
266        assert_eq!(def.name, "browser");
267        assert!(def.description.contains("agent-browser"));
268        assert!(def.description.contains("cookies"));
269        assert!(def.description.contains("eval"));
270        assert!(def.description.contains("pdf"));
271        assert!(def.input_schema["properties"]["command"].is_object());
272        assert!(def.input_schema["properties"]["timeout_secs"].is_object());
273    }
274
275    #[test]
276    fn test_browser_profile_path() {
277        let tool = BrowserTool::new("/tmp/test-data");
278        let path = tool.profile_path(12345);
279        assert_eq!(
280            path,
281            PathBuf::from("/tmp/test-data/groups/12345/browser-profile")
282        );
283    }
284
285    #[test]
286    fn test_browser_session_name_for_chat() {
287        assert_eq!(
288            BrowserTool::session_name_for_chat(12345),
289            "microclaw-chat-12345"
290        );
291        assert_eq!(
292            BrowserTool::session_name_for_chat(-100987),
293            "microclaw-chat-neg100987"
294        );
295    }
296
297    #[tokio::test]
298    async fn test_browser_missing_command() {
299        let tool = BrowserTool::new("/tmp/test-data");
300        let result = tool.execute(json!({})).await;
301        assert!(result.is_error);
302        assert!(result.content.contains("Missing 'command'"));
303    }
304}