zeroclaw/src/tools/browser_open.rs
killf a40b0c09fd feat(tools): add Chrome/Firefox support to browser_open tool
Add support for Chrome and Firefox browsers to the browser_open tool,
which previously only supported Brave. Users can now specify the
browser via the `browser_open` config option.

Changes:
- Add `browser_open` config field: "disable" | "brave" | "chrome" | "firefox" | "default"
- Implement platform-specific launch commands for Chrome and Firefox
- When set to "disable", only the browser automation tool is registered,
  not the browser_open tool
- Update tool descriptions and error messages to reflect browser selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:07:09 +08:00

551 lines
16 KiB
Rust

use super::traits::{Tool, ToolResult};
use super::url_validation::{
normalize_allowed_domains, validate_url, DomainPolicy, UrlSchemePolicy,
};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
/// Open approved HTTPS URLs in a browser (no scraping, no DOM automation).
pub struct BrowserOpenTool {
security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>,
browser_name: String,
}
impl BrowserOpenTool {
pub fn new(
security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>,
browser_name: String,
) -> Self {
Self {
security,
allowed_domains: normalize_allowed_domains(allowed_domains),
browser_name,
}
}
fn validate_url(&self, raw_url: &str) -> anyhow::Result<String> {
validate_url(
raw_url,
&DomainPolicy {
allowed_domains: &self.allowed_domains,
blocked_domains: &[],
allowed_field_name: "browser.allowed_domains",
blocked_field_name: None,
empty_allowed_message: "Browser tool is enabled but no allowed_domains are configured. Add [browser].allowed_domains in config.toml",
scheme_policy: UrlSchemePolicy::HttpsOnly,
ipv6_error_context: "browser_open",
},
)
}
}
#[async_trait]
impl Tool for BrowserOpenTool {
fn name(&self) -> &str {
"browser_open"
}
fn description(&self) -> &str {
"Open an approved HTTPS URL in a browser. Security constraints: allowlist-only domains, no local/private hosts, no scraping."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "HTTPS URL to open in browser"
}
},
"required": ["url"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let url = args
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?;
if !self.security.can_act() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Action blocked: autonomy is read-only".into()),
});
}
if !self.security.record_action() {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some("Action blocked: rate limit exceeded".into()),
});
}
let url = match self.validate_url(url) {
Ok(v) => v,
Err(e) => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(e.to_string()),
})
}
};
match open_in_browser(&url, &self.browser_name).await {
Ok(()) => Ok(ToolResult {
success: true,
output: format!(
"Opened in {}: {}",
display_browser_name(&self.browser_name),
url
),
error: None,
}),
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Failed to open {}: {}",
display_browser_name(&self.browser_name),
e
)),
}),
}
}
}
/// Get display name for browser (user-facing string)
fn display_browser_name(browser_name: &str) -> &str {
match browser_name {
"brave" => "Brave",
"chrome" => "Chrome",
"firefox" => "Firefox",
"default" | "" => "system default browser",
_ => browser_name,
}
}
async fn open_in_browser(url: &str, browser_name: &str) -> anyhow::Result<()> {
let normalized = browser_name.to_ascii_lowercase().replace('-', "_");
match normalized.as_str() {
"default" | "" => open_system_default(url).await,
"brave" => open_brave(url).await,
"chrome" => open_chrome(url).await,
"firefox" => open_firefox(url).await,
_ => anyhow::bail!(
"Unsupported browser '{browser_name}'. Use: default, brave, chrome, or firefox"
),
}
}
async fn open_system_default(url: &str) -> anyhow::Result<()> {
#[cfg(target_os = "macos")]
{
let status = tokio::process::Command::new("open")
.arg(url)
.status()
.await?;
if status.success() {
return Ok(());
}
anyhow::bail!("Failed to open URL with system default browser");
}
#[cfg(target_os = "linux")]
{
// Try xdg-open first, then fallback to common commands
let commands = ["xdg-open", "gio open", "gnome-open", "kde-open"];
let mut last_error = String::new();
for cmd in commands {
match tokio::process::Command::new(cmd).arg(url).status().await {
Ok(status) if status.success() => return Ok(()),
Ok(status) => {
last_error = format!("{cmd} exited with status {status}");
}
Err(e) => {
last_error = format!("{cmd} not runnable: {e}");
}
}
}
anyhow::bail!("{last_error}");
}
#[cfg(target_os = "windows")]
{
let status = tokio::process::Command::new("cmd")
.args(["/C", "start", "", url])
.status()
.await?;
if status.success() {
return Ok(());
}
anyhow::bail!("cmd start exited with non-zero status");
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = url;
anyhow::bail!("browser_open is not supported on this OS");
}
}
async fn open_brave(url: &str) -> anyhow::Result<()> {
#[cfg(target_os = "macos")]
{
for app in ["Brave Browser", "Brave"] {
let status = tokio::process::Command::new("open")
.arg("-a")
.arg(app)
.arg(url)
.status()
.await;
if let Ok(s) = status {
if s.success() {
return Ok(());
}
}
}
anyhow::bail!(
"Brave Browser was not found (tried macOS app names 'Brave Browser' and 'Brave')"
);
}
#[cfg(target_os = "linux")]
{
let mut last_error = String::new();
for cmd in ["brave-browser", "brave"] {
match tokio::process::Command::new(cmd).arg(url).status().await {
Ok(status) if status.success() => return Ok(()),
Ok(status) => {
last_error = format!("{cmd} exited with status {status}");
}
Err(e) => {
last_error = format!("{cmd} not runnable: {e}");
}
}
}
anyhow::bail!("{last_error}");
}
#[cfg(target_os = "windows")]
{
let status = tokio::process::Command::new("cmd")
.args(["/C", "start", "", "brave", url])
.status()
.await?;
if status.success() {
return Ok(());
}
anyhow::bail!("cmd start brave exited with status {status}");
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = url;
anyhow::bail!("browser_open is not supported on this OS");
}
}
async fn open_chrome(url: &str) -> anyhow::Result<()> {
#[cfg(target_os = "macos")]
{
for app in ["Google Chrome", "Chrome"] {
let status = tokio::process::Command::new("open")
.arg("-a")
.arg(app)
.arg(url)
.status()
.await;
if let Ok(s) = status {
if s.success() {
return Ok(());
}
}
}
anyhow::bail!(
"Google Chrome was not found (tried macOS app names 'Google Chrome' and 'Chrome')"
);
}
#[cfg(target_os = "linux")]
{
let mut last_error = String::new();
for cmd in ["google-chrome", "chrome", "chromium", "chromium-browser"] {
match tokio::process::Command::new(cmd).arg(url).status().await {
Ok(status) if status.success() => return Ok(()),
Ok(status) => {
last_error = format!("{cmd} exited with status {status}");
}
Err(e) => {
last_error = format!("{cmd} not runnable: {e}");
}
}
}
anyhow::bail!("{last_error}");
}
#[cfg(target_os = "windows")]
{
let status = tokio::process::Command::new("cmd")
.args(["/C", "start", "", "chrome", url])
.status()
.await?;
if status.success() {
return Ok(());
}
anyhow::bail!("cmd start chrome exited with status {status}");
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = url;
anyhow::bail!("browser_open is not supported on this OS");
}
}
async fn open_firefox(url: &str) -> anyhow::Result<()> {
#[cfg(target_os = "macos")]
{
let status = tokio::process::Command::new("open")
.arg("-a")
.arg("Firefox")
.arg(url)
.status()
.await?;
if status.success() {
return Ok(());
}
anyhow::bail!("Firefox was not found");
}
#[cfg(target_os = "linux")]
{
let mut last_error = String::new();
for cmd in ["firefox", "firefox-esr"] {
match tokio::process::Command::new(cmd).arg(url).status().await {
Ok(status) if status.success() => return Ok(()),
Ok(status) => {
last_error = format!("{cmd} exited with status {status}");
}
Err(e) => {
last_error = format!("{cmd} not runnable: {e}");
}
}
}
anyhow::bail!("{last_error}");
}
#[cfg(target_os = "windows")]
{
let status = tokio::process::Command::new("cmd")
.args(["/C", "start", "", "firefox", url])
.status()
.await?;
if status.success() {
return Ok(());
}
anyhow::bail!("cmd start firefox exited with status {status}");
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = url;
anyhow::bail!("browser_open is not supported on this OS");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::{AutonomyLevel, SecurityPolicy};
use crate::tools::url_validation::normalize_domain;
fn test_tool(allowed_domains: Vec<&str>) -> BrowserOpenTool {
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
..SecurityPolicy::default()
});
BrowserOpenTool::new(
security,
allowed_domains.into_iter().map(String::from).collect(),
"default".into(),
)
}
#[test]
fn normalize_domain_strips_scheme_path_and_case() {
let got = normalize_domain(" HTTPS://Docs.Example.com/path ").unwrap();
assert_eq!(got, "docs.example.com");
}
#[test]
fn normalize_allowed_domains_deduplicates() {
let got = normalize_allowed_domains(vec![
"example.com".into(),
"EXAMPLE.COM".into(),
"https://example.com/".into(),
]);
assert_eq!(got, vec!["example.com".to_string()]);
}
#[test]
fn validate_accepts_exact_domain() {
let tool = test_tool(vec!["example.com"]);
let got = tool.validate_url("https://example.com/docs").unwrap();
assert_eq!(got, "https://example.com/docs");
}
#[test]
fn validate_accepts_subdomain() {
let tool = test_tool(vec!["example.com"]);
assert!(tool.validate_url("https://api.example.com/v1").is_ok());
}
#[test]
fn validate_accepts_wildcard_allowlist_for_public_host() {
let tool = test_tool(vec!["*"]);
assert!(tool.validate_url("https://www.rust-lang.org").is_ok());
}
#[test]
fn validate_wildcard_allowlist_still_rejects_private_host() {
let tool = test_tool(vec!["*"]);
let err = tool
.validate_url("https://localhost:8443")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
}
#[test]
fn validate_accepts_wildcard_subdomain_pattern() {
let tool = test_tool(vec!["*.example.com"]);
assert!(tool.validate_url("https://example.com").is_ok());
assert!(tool.validate_url("https://sub.example.com").is_ok());
assert!(tool.validate_url("https://other.com").is_err());
}
#[test]
fn validate_rejects_http() {
let tool = test_tool(vec!["example.com"]);
let err = tool
.validate_url("http://example.com")
.unwrap_err()
.to_string();
assert!(err.contains("https://"));
}
#[test]
fn validate_rejects_localhost() {
let tool = test_tool(vec!["localhost"]);
let err = tool
.validate_url("https://localhost:8080")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
}
#[test]
fn validate_rejects_private_ipv4() {
let tool = test_tool(vec!["192.168.1.5"]);
let err = tool
.validate_url("https://192.168.1.5")
.unwrap_err()
.to_string();
assert!(err.contains("local/private"));
}
#[test]
fn validate_rejects_allowlist_miss() {
let tool = test_tool(vec!["example.com"]);
let err = tool
.validate_url("https://google.com")
.unwrap_err()
.to_string();
assert!(err.contains("allowed_domains"));
}
#[test]
fn validate_rejects_whitespace() {
let tool = test_tool(vec!["example.com"]);
let err = tool
.validate_url("https://example.com/hello world")
.unwrap_err()
.to_string();
assert!(err.contains("whitespace"));
}
#[test]
fn validate_rejects_userinfo() {
let tool = test_tool(vec!["example.com"]);
let err = tool
.validate_url("https://user@example.com")
.unwrap_err()
.to_string();
assert!(err.contains("userinfo"));
}
#[test]
fn validate_requires_allowlist() {
let security = Arc::new(SecurityPolicy::default());
let tool = BrowserOpenTool::new(security, vec![], "default".into());
let err = tool
.validate_url("https://example.com")
.unwrap_err()
.to_string();
assert!(err.contains("allowed_domains"));
}
#[tokio::test]
async fn execute_blocks_readonly_mode() {
let security = Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::ReadOnly,
..SecurityPolicy::default()
});
let tool = BrowserOpenTool::new(security, vec!["example.com".into()], "default".into());
let result = tool
.execute(json!({"url": "https://example.com"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("read-only"));
}
#[tokio::test]
async fn execute_blocks_when_rate_limited() {
let security = Arc::new(SecurityPolicy {
max_actions_per_hour: 0,
..SecurityPolicy::default()
});
let tool = BrowserOpenTool::new(security, vec!["example.com".into()], "default".into());
let result = tool
.execute(json!({"url": "https://example.com"}))
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("rate limit"));
}
}