agentzero_tools/
browser_open.rs1use 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}