Skip to main content

limit_cli/tools/browser/
executor.rs

1//! Browser executor abstraction
2//!
3//! Provides the trait for browser execution and CLI implementation.
4
5use super::config::BrowserConfig;
6use async_trait::async_trait;
7use limit_agent::error::AgentError;
8use std::process::Command;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use tokio::process::Command as AsyncCommand;
12use tokio::time::{timeout, Duration};
13
14/// Result from browser operations
15#[derive(Debug, Clone)]
16pub struct BrowserOutput {
17    /// stdout from the command
18    pub stdout: String,
19    /// stderr from the command
20    pub stderr: String,
21    /// Exit code (None if killed)
22    pub exit_code: Option<i32>,
23    /// Whether the operation succeeded
24    pub success: bool,
25}
26
27impl BrowserOutput {
28    /// Create a successful output
29    pub fn success(stdout: String) -> Self {
30        Self {
31            stdout,
32            stderr: String::new(),
33            exit_code: Some(0),
34            success: true,
35        }
36    }
37
38    /// Create a failed output
39    pub fn failure(stderr: String, exit_code: Option<i32>) -> Self {
40        Self {
41            stdout: String::new(),
42            stderr,
43            exit_code,
44            success: false,
45        }
46    }
47}
48
49/// Error type for browser operations
50#[derive(Debug, thiserror::Error)]
51pub enum BrowserError {
52    /// Failed to execute the browser command
53    #[error("Failed to execute browser command: {0}")]
54    ExecutionFailed(String),
55
56    /// Operation timed out
57    #[error("Browser operation timed out after {0}ms")]
58    Timeout(u64),
59
60    /// Invalid arguments provided
61    #[error("Invalid arguments: {0}")]
62    InvalidArguments(String),
63
64    /// Browser not installed or not found
65    #[error("Browser binary not found: {0}")]
66    NotFound(String),
67
68    /// Parse error for output
69    #[error("Failed to parse output: {0}")]
70    ParseError(String),
71
72    /// Generic error
73    #[error("{0}")]
74    Other(String),
75}
76
77impl From<BrowserError> for AgentError {
78    fn from(err: BrowserError) -> Self {
79        AgentError::ToolError(err.to_string())
80    }
81}
82
83/// Trait for browser execution backends
84#[async_trait]
85pub trait BrowserExecutor: Send + Sync {
86    /// Execute a browser command with the given arguments
87    async fn execute(&self, args: &[&str]) -> Result<BrowserOutput, BrowserError>;
88
89    /// Check if the browser daemon is running
90    fn is_daemon_running(&self) -> bool;
91
92    /// Get the configuration
93    fn config(&self) -> &BrowserConfig;
94}
95
96/// CLI-based executor using agent-browser binary
97pub struct CliExecutor {
98    config: BrowserConfig,
99    /// Track if we have an open session
100    is_open: Arc<AtomicBool>,
101}
102
103impl CliExecutor {
104    /// Create a new CLI executor with the given configuration
105    pub fn new(config: BrowserConfig) -> Self {
106        Self {
107            config,
108            is_open: Arc::new(AtomicBool::new(false)),
109        }
110    }
111
112    /// Build a command with common configuration
113    fn build_command(&self, args: &[&str]) -> AsyncCommand {
114        let mut cmd: AsyncCommand = AsyncCommand::new(self.config.binary());
115
116        // Add engine flag if not Chrome (default)
117        if self.config.engine.as_arg() != "chrome" {
118            cmd.arg("--engine").arg(self.config.engine.as_arg());
119        }
120
121        // agent-browser runs headless by default
122        // Use --headed flag to show browser window when headless is false
123        if !self.config.headless {
124            cmd.arg("--headed");
125        }
126
127        cmd.args(args);
128        cmd
129    }
130
131    /// Check if agent-browser is installed
132    pub fn is_installed(&self) -> bool {
133        Command::new(self.config.binary())
134            .arg("--version")
135            .output()
136            .map(|o| o.status.success())
137            .unwrap_or(false)
138    }
139}
140
141#[async_trait]
142impl BrowserExecutor for CliExecutor {
143    async fn execute(&self, args: &[&str]) -> Result<BrowserOutput, BrowserError> {
144        let mut cmd = self.build_command(args);
145
146        let result = timeout(Duration::from_millis(self.config.timeout_ms), cmd.output())
147            .await
148            .map_err(|_| BrowserError::Timeout(self.config.timeout_ms))?
149            .map_err(|e| BrowserError::ExecutionFailed(e.to_string()))?;
150
151        let stdout = String::from_utf8_lossy(&result.stdout).to_string();
152        let stderr = String::from_utf8_lossy(&result.stderr).to_string();
153        let success = result.status.success();
154        let exit_code = result.status.code();
155
156        // Track open state for specific commands
157        if !args.is_empty() {
158            match args[0] {
159                "open" => {
160                    self.is_open.store(true, Ordering::SeqCst);
161                }
162                "close" => {
163                    self.is_open.store(false, Ordering::SeqCst);
164                }
165                _ => {}
166            }
167        }
168
169        Ok(BrowserOutput {
170            stdout,
171            stderr,
172            exit_code,
173            success,
174        })
175    }
176
177    fn is_daemon_running(&self) -> bool {
178        self.is_open.load(Ordering::SeqCst)
179    }
180
181    fn config(&self) -> &BrowserConfig {
182        &self.config
183    }
184}
185
186impl Drop for CliExecutor {
187    fn drop(&mut self) {
188        // Close browser on drop if still open
189        if self.is_open.load(Ordering::SeqCst) {
190            let _ = Command::new(self.config.binary()).arg("close").output();
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_browser_output_success() {
201        let output = BrowserOutput::success("test output".to_string());
202        assert!(output.success);
203        assert_eq!(output.stdout, "test output");
204        assert_eq!(output.exit_code, Some(0));
205    }
206
207    #[test]
208    fn test_browser_output_failure() {
209        let output = BrowserOutput::failure("error message".to_string(), Some(1));
210        assert!(!output.success);
211        assert_eq!(output.stderr, "error message");
212        assert_eq!(output.exit_code, Some(1));
213    }
214
215    #[test]
216    fn test_browser_error_display() {
217        let err = BrowserError::Timeout(5000);
218        assert!(err.to_string().contains("5000ms"));
219
220        let err = BrowserError::NotFound("agent-browser".to_string());
221        assert!(err.to_string().contains("not found"));
222    }
223
224    #[test]
225    fn test_cli_executor_creation() {
226        let config = BrowserConfig::default();
227        let executor = CliExecutor::new(config);
228        assert!(!executor.is_daemon_running());
229    }
230}