Skip to main content

roboticus_browser/
agent_browser_backend.rs

1//! External `agent-browser` CLI backend.
2//!
3//! Executes browser actions by spawning the `agent-browser` CLI with `--json`
4//! mode and parsing structured output. Preserves policy controls and
5//! provenance from the Roboticus side.
6
7use async_trait::async_trait;
8use serde_json::json;
9use tracing::{debug, warn};
10
11use crate::actions::{ActionResult, BrowserAction};
12use crate::backend::BrowserBackend;
13
14/// Configuration for the external agent-browser backend.
15#[derive(Debug, Clone)]
16pub struct AgentBrowserConfig {
17    /// Path to the `agent-browser` binary. Resolved at startup via `which` if not absolute.
18    pub binary_path: String,
19    /// Timeout for each CLI invocation in seconds.
20    pub timeout_seconds: u64,
21}
22
23impl Default for AgentBrowserConfig {
24    fn default() -> Self {
25        Self {
26            binary_path: "agent-browser".into(),
27            timeout_seconds: 30,
28        }
29    }
30}
31
32/// Backend that delegates browser actions to an external `agent-browser` CLI.
33pub struct AgentBrowserBackend {
34    config: AgentBrowserConfig,
35    available: bool,
36}
37
38impl AgentBrowserBackend {
39    /// Create a new backend, checking if the binary is available on PATH.
40    pub fn new(config: AgentBrowserConfig) -> Self {
41        let available = std::process::Command::new("which")
42            .arg(&config.binary_path)
43            .output()
44            .map(|o| o.status.success())
45            .unwrap_or(false);
46
47        if !available {
48            warn!(
49                binary = %config.binary_path,
50                "agent-browser binary not found — backend will be unavailable"
51            );
52        }
53
54        Self { config, available }
55    }
56
57    /// Map a `BrowserAction` to agent-browser CLI arguments.
58    fn action_to_args(action: &BrowserAction) -> Vec<String> {
59        match action {
60            BrowserAction::Navigate { url } => vec!["navigate".into(), url.clone()],
61            BrowserAction::Click { selector } => vec!["click".into(), selector.clone()],
62            BrowserAction::Type { selector, text } => {
63                vec!["type".into(), selector.clone(), text.clone()]
64            }
65            BrowserAction::Screenshot => vec!["screenshot".into()],
66            BrowserAction::Pdf => vec!["pdf".into()],
67            BrowserAction::Evaluate { expression } => {
68                vec!["evaluate".into(), expression.clone()]
69            }
70            BrowserAction::GetCookies => vec!["get-cookies".into()],
71            BrowserAction::ClearCookies => vec!["clear-cookies".into()],
72            BrowserAction::ReadPage => vec!["read-page".into()],
73            BrowserAction::GoBack => vec!["go-back".into()],
74            BrowserAction::GoForward => vec!["go-forward".into()],
75            BrowserAction::Reload => vec!["reload".into()],
76        }
77    }
78}
79
80#[async_trait]
81impl BrowserBackend for AgentBrowserBackend {
82    async fn execute(&self, action: &BrowserAction) -> ActionResult {
83        if !self.available {
84            return ActionResult::err("agent-browser", "agent-browser binary not available".into());
85        }
86
87        let args = Self::action_to_args(action);
88        let action_name = args.first().cloned().unwrap_or_default();
89        debug!(backend = "agent-browser", action = %action_name, "executing");
90
91        let timeout = std::time::Duration::from_secs(self.config.timeout_seconds);
92        let result = tokio::time::timeout(timeout, async {
93            tokio::process::Command::new(&self.config.binary_path)
94                .args(&args)
95                .arg("--json")
96                .output()
97                .await
98        })
99        .await;
100
101        match result {
102            Ok(Ok(output)) => {
103                if output.status.success() {
104                    let stdout = String::from_utf8_lossy(&output.stdout);
105                    match serde_json::from_str::<serde_json::Value>(&stdout) {
106                        Ok(data) => ActionResult::ok(&action_name, data),
107                        Err(_) => ActionResult::ok(&action_name, json!({ "raw": stdout.trim() })),
108                    }
109                } else {
110                    let stderr = String::from_utf8_lossy(&output.stderr);
111                    ActionResult::err(
112                        &action_name,
113                        format!("exit code {}: {}", output.status, stderr.trim()),
114                    )
115                }
116            }
117            Ok(Err(e)) => ActionResult::err(&action_name, format!("spawn failed: {e}")),
118            Err(_) => ActionResult::err(
119                &action_name,
120                format!("timeout after {}s", self.config.timeout_seconds),
121            ),
122        }
123    }
124
125    fn name(&self) -> &str {
126        "agent-browser"
127    }
128
129    fn is_available(&self) -> bool {
130        self.available
131    }
132}