Skip to main content

symbi_runtime/toolclad/
browser_executor.rs

1//! BrowserExecutor -- CDP-based headless/live browser session manager.
2//!
3//! Manages browser sessions via Chrome DevTools Protocol. Validates navigation
4//! against URL scope rules, executes typed browser commands, and captures
5//! accessibility tree snapshots and screenshots as evidence.
6
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10use super::browser_state::*;
11use super::manifest::Manifest;
12use super::validator;
13
14/// Manages browser sessions.
15pub struct BrowserExecutor {
16    sessions: Arc<Mutex<HashMap<String, BrowserSessionState>>>,
17    manifests: HashMap<String, Manifest>,
18}
19
20/// Browser session state (without actual CDP connection for now).
21struct 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    /// Execute a browser command.
63    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        // Validate command-specific args
87        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        // Scope check for navigation commands
101        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        // Check max interactions
112        {
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        // Build result based on command type
125        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        // Update interaction count
159        {
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
224/// Extract domain from a URL (reused from browser_state).
225fn 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        // Allowed domain
303        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        // Blocked domain
310        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        // Multiple commands should increment count
349        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}