roboticus-browser 0.11.3

Headless browser automation via Chrome DevTools Protocol
Documentation
//! External `agent-browser` CLI backend.
//!
//! Executes browser actions by spawning the `agent-browser` CLI with `--json`
//! mode and parsing structured output. Preserves policy controls and
//! provenance from the Roboticus side.

use async_trait::async_trait;
use serde_json::json;
use tracing::{debug, warn};

use crate::actions::{ActionResult, BrowserAction};
use crate::backend::BrowserBackend;

/// Configuration for the external agent-browser backend.
#[derive(Debug, Clone)]
pub struct AgentBrowserConfig {
    /// Path to the `agent-browser` binary. Resolved at startup via `which` if not absolute.
    pub binary_path: String,
    /// Timeout for each CLI invocation in seconds.
    pub timeout_seconds: u64,
}

impl Default for AgentBrowserConfig {
    fn default() -> Self {
        Self {
            binary_path: "agent-browser".into(),
            timeout_seconds: 30,
        }
    }
}

/// Backend that delegates browser actions to an external `agent-browser` CLI.
pub struct AgentBrowserBackend {
    config: AgentBrowserConfig,
    available: bool,
}

impl AgentBrowserBackend {
    /// Create a new backend, checking if the binary is available on PATH.
    pub fn new(config: AgentBrowserConfig) -> Self {
        let available = std::process::Command::new("which")
            .arg(&config.binary_path)
            .output()
            .map(|o| o.status.success())
            .unwrap_or(false);

        if !available {
            warn!(
                binary = %config.binary_path,
                "agent-browser binary not found — backend will be unavailable"
            );
        }

        Self { config, available }
    }

    /// Map a `BrowserAction` to agent-browser CLI arguments.
    fn action_to_args(action: &BrowserAction) -> Vec<String> {
        match action {
            BrowserAction::Navigate { url } => vec!["navigate".into(), url.clone()],
            BrowserAction::Click { selector } => vec!["click".into(), selector.clone()],
            BrowserAction::Type { selector, text } => {
                vec!["type".into(), selector.clone(), text.clone()]
            }
            BrowserAction::Screenshot => vec!["screenshot".into()],
            BrowserAction::Pdf => vec!["pdf".into()],
            BrowserAction::Evaluate { expression } => {
                vec!["evaluate".into(), expression.clone()]
            }
            BrowserAction::GetCookies => vec!["get-cookies".into()],
            BrowserAction::ClearCookies => vec!["clear-cookies".into()],
            BrowserAction::ReadPage => vec!["read-page".into()],
            BrowserAction::GoBack => vec!["go-back".into()],
            BrowserAction::GoForward => vec!["go-forward".into()],
            BrowserAction::Reload => vec!["reload".into()],
        }
    }
}

#[async_trait]
impl BrowserBackend for AgentBrowserBackend {
    async fn execute(&self, action: &BrowserAction) -> ActionResult {
        if !self.available {
            return ActionResult::err("agent-browser", "agent-browser binary not available".into());
        }

        let args = Self::action_to_args(action);
        let action_name = args.first().cloned().unwrap_or_default();
        debug!(backend = "agent-browser", action = %action_name, "executing");

        let timeout = std::time::Duration::from_secs(self.config.timeout_seconds);
        let result = tokio::time::timeout(timeout, async {
            tokio::process::Command::new(&self.config.binary_path)
                .args(&args)
                .arg("--json")
                .output()
                .await
        })
        .await;

        match result {
            Ok(Ok(output)) => {
                if output.status.success() {
                    let stdout = String::from_utf8_lossy(&output.stdout);
                    match serde_json::from_str::<serde_json::Value>(&stdout) {
                        Ok(data) => ActionResult::ok(&action_name, data),
                        Err(_) => ActionResult::ok(&action_name, json!({ "raw": stdout.trim() })),
                    }
                } else {
                    let stderr = String::from_utf8_lossy(&output.stderr);
                    ActionResult::err(
                        &action_name,
                        format!("exit code {}: {}", output.status, stderr.trim()),
                    )
                }
            }
            Ok(Err(e)) => ActionResult::err(&action_name, format!("spawn failed: {e}")),
            Err(_) => ActionResult::err(
                &action_name,
                format!("timeout after {}s", self.config.timeout_seconds),
            ),
        }
    }

    fn name(&self) -> &str {
        "agent-browser"
    }

    fn is_available(&self) -> bool {
        self.available
    }
}