microclaw_engine/tools/
browser.rs1use 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 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}