1use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10use super::browser_state::*;
11use super::manifest::Manifest;
12use super::validator;
13
14pub struct BrowserExecutor {
16 sessions: Arc<Mutex<HashMap<String, BrowserSessionState>>>,
17 manifests: HashMap<String, Manifest>,
18}
19
20struct BrowserSessionState {
22 #[allow(dead_code)]
23 page_state: PageState,
24 #[allow(dead_code)]
25 scope_checker: BrowserScopeChecker,
26 interaction_count: u32,
27 #[allow(dead_code)]
28 manifest_name: String,
29 #[allow(dead_code)]
30 session_id: String,
31 #[allow(dead_code)]
32 status: BrowserStatus,
33}
34
35impl BrowserExecutor {
36 pub fn new(manifests: Vec<(String, Manifest)>) -> Self {
37 let browser_manifests: HashMap<String, Manifest> = manifests
38 .into_iter()
39 .filter(|(_, m)| m.tool.mode == "browser")
40 .collect();
41 Self {
42 sessions: Arc::new(Mutex::new(HashMap::new())),
43 manifests: browser_manifests,
44 }
45 }
46
47 pub fn handles(&self, tool_name: &str) -> bool {
48 if let Some(base) = tool_name.split('.').next() {
49 if let Some(m) = self.manifests.get(base) {
50 if let Some(browser) = &m.browser {
51 let cmd = tool_name
52 .strip_prefix(base)
53 .unwrap_or("")
54 .trim_start_matches('.');
55 return !cmd.is_empty() && browser.commands.contains_key(cmd);
56 }
57 }
58 }
59 false
60 }
61
62 pub fn execute_browser_command(
64 &self,
65 tool_name: &str,
66 args_json: &str,
67 ) -> Result<serde_json::Value, String> {
68 let (manifest_name, command_name) = parse_browser_tool_name(tool_name)?;
69
70 let manifest = self
71 .manifests
72 .get(&manifest_name)
73 .ok_or_else(|| format!("No browser manifest for '{}'", manifest_name))?;
74 let browser_def = manifest
75 .browser
76 .as_ref()
77 .ok_or("Manifest has no [browser] section")?;
78 let cmd_def = browser_def
79 .commands
80 .get(&command_name)
81 .ok_or_else(|| format!("Unknown browser command: {}", command_name))?;
82
83 let args: HashMap<String, serde_json::Value> =
84 serde_json::from_str(args_json).map_err(|e| format!("Invalid arguments: {}", e))?;
85
86 for (arg_name, arg_def) in &cmd_def.args {
88 if let Some(value) = args.get(arg_name) {
89 let val_str = match value {
90 serde_json::Value::String(s) => s.clone(),
91 other => other.to_string(),
92 };
93 validator::validate_arg(arg_def, &val_str)
94 .map_err(|e| format!("Arg '{}' validation: {}", arg_name, e))?;
95 } else if arg_def.required {
96 return Err(format!("Missing required arg: {}", arg_name));
97 }
98 }
99
100 if command_name == "navigate" {
102 if let Some(url_val) = args.get("url") {
103 let url = url_val.as_str().unwrap_or("");
104 if let Some(scope) = &browser_def.scope {
105 let checker = BrowserScopeChecker::new(scope);
106 checker.check_url(url)?;
107 }
108 }
109 }
110
111 {
113 let sessions = self.sessions.lock().map_err(|e| e.to_string())?;
114 if let Some(session) = sessions.get(&manifest_name) {
115 if session.interaction_count >= browser_def.max_interactions {
116 return Err(format!(
117 "Browser session exceeded max interactions ({})",
118 browser_def.max_interactions
119 ));
120 }
121 }
122 }
123
124 let result = match command_name.as_str() {
126 "navigate" => {
127 let url = args.get("url").and_then(|v| v.as_str()).unwrap_or("");
128 serde_json::json!({
129 "url": url,
130 "title": "",
131 "domain": extract_domain(url).unwrap_or_default(),
132 "page_state": { "page_loaded": true },
133 "note": "CDP execution requires 'toolclad-browser' feature"
134 })
135 }
136 "snapshot" => {
137 let selector = args.get("selector").and_then(|v| v.as_str());
138 serde_json::json!({
139 "content": format!("Accessibility tree snapshot{}",
140 selector.map(|s| format!(" (scoped to '{}')", s)).unwrap_or_default()),
141 "extract_mode": "accessibility_tree",
142 "note": "CDP execution requires 'toolclad-browser' feature"
143 })
144 }
145 "click" | "type_text" | "submit_form" | "extract" | "extract_html" | "screenshot"
146 | "execute_js" | "wait_for" | "go_back" | "list_tabs" | "network_timing" => {
147 serde_json::json!({
148 "command": command_name,
149 "args": args,
150 "note": "CDP execution requires 'toolclad-browser' feature"
151 })
152 }
153 _ => {
154 return Err(format!("Unknown browser command: {}", command_name));
155 }
156 };
157
158 {
160 let mut sessions = self.sessions.lock().map_err(|e| e.to_string())?;
161 let session = sessions.entry(manifest_name.clone()).or_insert_with(|| {
162 let scope_checker = browser_def
163 .scope
164 .as_ref()
165 .map(BrowserScopeChecker::new)
166 .unwrap_or(BrowserScopeChecker {
167 allowed_domains: vec![],
168 blocked_domains: vec![],
169 allow_external: true,
170 });
171 BrowserSessionState {
172 page_state: PageState::default(),
173 scope_checker,
174 interaction_count: 0,
175 manifest_name: manifest_name.clone(),
176 session_id: format!(
177 "browser-{}-{}",
178 manifest_name,
179 uuid::Uuid::new_v4().as_fields().0
180 ),
181 status: BrowserStatus::Ready,
182 }
183 });
184 session.interaction_count += 1;
185 }
186
187 let scan_id = format!(
188 "{}-{}",
189 chrono::Utc::now().timestamp(),
190 uuid::Uuid::new_v4().as_fields().0
191 );
192
193 Ok(serde_json::json!({
194 "status": "success",
195 "scan_id": scan_id,
196 "tool": tool_name,
197 "command": command_name,
198 "duration_ms": 0,
199 "timestamp": chrono::Utc::now().to_rfc3339(),
200 "exit_code": 0,
201 "stderr": "",
202 "results": result
203 }))
204 }
205
206 pub fn cleanup(&self) {
207 if let Ok(mut sessions) = self.sessions.lock() {
208 sessions.clear();
209 }
210 }
211}
212
213fn parse_browser_tool_name(name: &str) -> Result<(String, String), String> {
214 let parts: Vec<&str> = name.splitn(2, '.').collect();
215 if parts.len() != 2 {
216 return Err(format!(
217 "Invalid browser tool name: '{}' (expected 'browser.command')",
218 name
219 ));
220 }
221 Ok((parts[0].to_string(), parts[1].to_string()))
222}
223
224fn extract_domain(url: &str) -> Option<String> {
226 let after_scheme = url.split("://").nth(1)?;
227 let domain = after_scheme.split('/').next()?;
228 let domain = domain.split(':').next()?;
229 Some(domain.to_string())
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 fn make_browser_manifest() -> Manifest {
237 let toml_str = r#"
238[tool]
239name = "test_browser"
240mode = "browser"
241version = "1.0.0"
242description = "Test browser"
243
244[browser]
245engine = "cdp"
246connect = "launch"
247extract_mode = "accessibility_tree"
248
249[browser.scope]
250allowed_domains = ["*.example.com"]
251
252[browser.commands.navigate]
253description = "Navigate to URL"
254risk_tier = "medium"
255
256[browser.commands.navigate.args.url]
257position = 0
258type = "url"
259required = true
260schemes = ["https"]
261description = "URL to navigate to"
262
263[browser.commands.snapshot]
264description = "Get accessibility tree"
265risk_tier = "low"
266
267[browser.commands.click]
268description = "Click element"
269risk_tier = "low"
270
271[browser.commands.click.args.selector]
272position = 0
273type = "string"
274required = true
275description = "CSS selector"
276
277[output]
278format = "json"
279
280[output.schema]
281type = "object"
282"#;
283 toml::from_str(toml_str).unwrap()
284 }
285
286 #[test]
287 fn test_browser_executor_handles() {
288 let manifest = make_browser_manifest();
289 let executor = BrowserExecutor::new(vec![("test_browser".to_string(), manifest)]);
290 assert!(executor.handles("test_browser.navigate"));
291 assert!(executor.handles("test_browser.snapshot"));
292 assert!(executor.handles("test_browser.click"));
293 assert!(!executor.handles("test_browser.unknown"));
294 assert!(!executor.handles("other.navigate"));
295 }
296
297 #[test]
298 fn test_navigate_scope_check() {
299 let manifest = make_browser_manifest();
300 let executor = BrowserExecutor::new(vec![("test_browser".to_string(), manifest)]);
301
302 let result = executor.execute_browser_command(
304 "test_browser.navigate",
305 r#"{"url": "https://app.example.com/page"}"#,
306 );
307 assert!(result.is_ok());
308
309 let result = executor.execute_browser_command(
311 "test_browser.navigate",
312 r#"{"url": "https://evil.com/page"}"#,
313 );
314 assert!(result.is_err());
315 assert!(result.unwrap_err().contains("not in allowed domains"));
316 }
317
318 #[test]
319 fn test_snapshot_command() {
320 let manifest = make_browser_manifest();
321 let executor = BrowserExecutor::new(vec![("test_browser".to_string(), manifest)]);
322
323 let result = executor.execute_browser_command("test_browser.snapshot", "{}");
324 assert!(result.is_ok());
325 let envelope = result.unwrap();
326 assert_eq!(envelope["status"], "success");
327 assert!(envelope["results"]["content"]
328 .as_str()
329 .unwrap()
330 .contains("Accessibility tree"));
331 }
332
333 #[test]
334 fn test_click_requires_selector() {
335 let manifest = make_browser_manifest();
336 let executor = BrowserExecutor::new(vec![("test_browser".to_string(), manifest)]);
337
338 let result = executor.execute_browser_command("test_browser.click", "{}");
339 assert!(result.is_err());
340 assert!(result.unwrap_err().contains("Missing required arg"));
341 }
342
343 #[test]
344 fn test_interaction_count() {
345 let manifest = make_browser_manifest();
346 let executor = BrowserExecutor::new(vec![("test_browser".to_string(), manifest)]);
347
348 for _ in 0..5 {
350 executor
351 .execute_browser_command("test_browser.snapshot", "{}")
352 .unwrap();
353 }
354
355 let sessions = executor.sessions.lock().unwrap();
356 assert_eq!(sessions["test_browser"].interaction_count, 5);
357 }
358
359 #[test]
360 fn test_parse_browser_tool_name() {
361 let (base, cmd) = parse_browser_tool_name("my_browser.navigate").unwrap();
362 assert_eq!(base, "my_browser");
363 assert_eq!(cmd, "navigate");
364 assert!(parse_browser_tool_name("no_dot").is_err());
365 }
366}