Skip to main content

agentzero_tools/
browser_open.rs

1use agentzero_core::common::url_policy::UrlAccessPolicy;
2use agentzero_core::common::util::parse_http_url_with_policy;
3use agentzero_core::{Tool, ToolContext, ToolResult};
4use anyhow::{anyhow, Context};
5use async_trait::async_trait;
6use serde::Deserialize;
7use std::process::Stdio;
8use tokio::process::Command;
9
10#[derive(Debug, Deserialize)]
11struct BrowserOpenInput {
12    url: String,
13}
14
15#[derive(Default)]
16pub struct BrowserOpenTool {
17    url_policy: UrlAccessPolicy,
18}
19
20impl BrowserOpenTool {
21    pub fn with_url_policy(mut self, policy: UrlAccessPolicy) -> Self {
22        self.url_policy = policy;
23        self
24    }
25
26    fn open_command() -> &'static str {
27        if cfg!(target_os = "macos") {
28            "open"
29        } else if cfg!(target_os = "windows") {
30            "cmd"
31        } else {
32            "xdg-open"
33        }
34    }
35
36    fn open_args(url: &str) -> Vec<String> {
37        if cfg!(target_os = "windows") {
38            vec![
39                "/C".to_string(),
40                "start".to_string(),
41                String::new(),
42                url.to_string(),
43            ]
44        } else {
45            vec![url.to_string()]
46        }
47    }
48}
49
50#[async_trait]
51impl Tool for BrowserOpenTool {
52    fn name(&self) -> &'static str {
53        "browser_open"
54    }
55
56    fn description(&self) -> &'static str {
57        "Open a URL in the user's default web browser."
58    }
59
60    fn input_schema(&self) -> Option<serde_json::Value> {
61        Some(serde_json::json!({
62            "type": "object",
63            "properties": {
64                "url": { "type": "string", "description": "The URL to open" }
65            },
66            "required": ["url"]
67        }))
68    }
69
70    async fn execute(&self, input: &str, _ctx: &ToolContext) -> anyhow::Result<ToolResult> {
71        let req: BrowserOpenInput =
72            serde_json::from_str(input).context("browser_open expects JSON: {\"url\": \"...\"}")?;
73
74        if req.url.trim().is_empty() {
75            return Err(anyhow!("url must not be empty"));
76        }
77
78        let parsed = parse_http_url_with_policy(&req.url, &self.url_policy)?;
79
80        let cmd = Self::open_command();
81        let args = Self::open_args(parsed.as_str());
82
83        let status = Command::new(cmd)
84            .args(&args)
85            .stdout(Stdio::null())
86            .stderr(Stdio::null())
87            .status()
88            .await
89            .with_context(|| format!("failed to run {cmd}"))?;
90
91        if status.success() {
92            Ok(ToolResult {
93                output: format!("opened {} in default browser", parsed),
94            })
95        } else {
96            Err(anyhow!(
97                "browser_open failed with exit code {}",
98                status.code().unwrap_or(-1)
99            ))
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[tokio::test]
109    async fn browser_open_rejects_empty_url() {
110        let tool = BrowserOpenTool::default();
111        let err = tool
112            .execute(r#"{"url": ""}"#, &ToolContext::new(".".to_string()))
113            .await
114            .expect_err("empty url should fail");
115        assert!(err.to_string().contains("url must not be empty"));
116    }
117
118    #[tokio::test]
119    async fn browser_open_blocks_private_ip() {
120        let tool = BrowserOpenTool::default();
121        let err = tool
122            .execute(
123                r#"{"url": "http://10.0.0.1/internal"}"#,
124                &ToolContext::new(".".to_string()),
125            )
126            .await
127            .expect_err("private IP should be blocked");
128        assert!(err.to_string().contains("URL access denied"));
129    }
130
131    #[tokio::test]
132    async fn browser_open_blocks_blocklisted_domain() {
133        let tool = BrowserOpenTool::default().with_url_policy(UrlAccessPolicy {
134            domain_blocklist: vec!["evil.example".to_string()],
135            ..Default::default()
136        });
137        let err = tool
138            .execute(
139                r#"{"url": "https://evil.example/phish"}"#,
140                &ToolContext::new(".".to_string()),
141            )
142            .await
143            .expect_err("blocklisted domain should be blocked");
144        assert!(err.to_string().contains("URL access denied"));
145    }
146
147    #[test]
148    fn open_command_returns_platform_binary() {
149        let cmd = BrowserOpenTool::open_command();
150        assert!(!cmd.is_empty());
151    }
152}