From 3d91c40970f2761d31bf8a987cbc8d85718614b4 Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Sat, 14 Feb 2026 05:17:16 -0500 Subject: [PATCH] refactor: simplify CLI commands and update architecture docs 1. Simplify CLI: - Make 'onboard' quick setup default (remove --quick) - Add --interactive flag for full wizard - Make 'status' detailed by default (remove --verbose) - Remove 'tools list/test' and 'integrations list' commands - Add 'channel doctor' command 2. Update Docs: - Update architecture.svg with Channel allowlists, Browser allowlist, and latest stats - Update README.md with new command usage and browser/channel config details 3. Polish: - Browser tool integration - Channel allowlist logic (empty = deny all) --- README.md | 44 ++-- docs/architecture.svg | 12 +- src/agent/loop_.rs | 10 +- src/channels/discord.rs | 12 +- src/channels/mod.rs | 151 ++++++++++++- src/channels/slack.rs | 12 +- src/config/mod.rs | 7 +- src/config/schema.rs | 52 +++++ src/integrations/mod.rs | 72 +----- src/main.rs | 120 ++++------ src/onboard/wizard.rs | 67 +++++- src/providers/mod.rs | 2 +- src/tools/browser_open.rs | 465 ++++++++++++++++++++++++++++++++++++++ src/tools/mod.rs | 104 +++++---- 14 files changed, 886 insertions(+), 244 deletions(-) create mode 100644 src/tools/browser_open.rs diff --git a/README.md b/README.md index e9dd23f..64f06cb 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ cd zeroclaw cargo build --release # Quick setup (no prompts) -cargo run --release -- onboard --quick --api-key sk-... --provider openrouter +cargo run --release -- onboard --api-key sk-... --provider openrouter # Or interactive wizard -cargo run --release -- onboard +cargo run --release -- onboard --interactive # Chat cargo run --release -- agent -m "Hello, ZeroClaw!" @@ -42,17 +42,13 @@ cargo run --release -- gateway # default: 127.0.0.1:8080 cargo run --release -- gateway --port 0 # random port (security hardened) # Check status -cargo run --release -- status --verbose +cargo run --release -- status -# List tools (includes memory tools) -cargo run --release -- tools list +# Check channel health +cargo run --release -- channel doctor -# Test a tool directly -cargo run --release -- tools test memory_store '{"key": "lang", "content": "User prefers Rust"}' -cargo run --release -- tools test memory_recall '{"query": "Rust"}' - -# List integrations -cargo run --release -- integrations list +# Get integration setup details +cargo run --release -- integrations info Telegram ``` > **Tip:** Run `cargo install --path .` to install `zeroclaw` globally, then use `zeroclaw` instead of `cargo run --release --`. @@ -70,7 +66,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze | **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API | | **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook | Any messaging API | | **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Markdown | Any persistence backend | -| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, composio (optional) | Any capability | +| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), composio (optional) | Any capability | | **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel | | **Runtime** | `RuntimeAdapter` | Native (Mac/Linux/Pi) | Docker, WASM | | **Security** | `SecurityPolicy` | Gateway pairing, sandbox, allowlists, rate limits, filesystem scoping, encrypted secrets | — | @@ -119,6 +115,16 @@ ZeroClaw enforces security at **every layer** — not just the sandbox. It passe > **Run your own nmap:** `nmap -p 1-65535 ` — ZeroClaw binds to localhost only, so nothing is exposed unless you explicitly configure a tunnel. +### Channel allowlists (Telegram / Discord / Slack) + +Inbound sender policy is now consistent: + +- Empty allowlist = **deny all inbound messages** +- `"*"` = **allow all** (explicit opt-in) +- Otherwise = exact-match allowlist + +This keeps accidental exposure low by default. + ## Configuration Config: `~/.zeroclaw/config.toml` (created by `onboard`) @@ -156,6 +162,10 @@ provider = "none" # "none", "cloudflare", "tailscale", "ngrok", "c [secrets] encrypt = true # API keys encrypted with local key file +[browser] +enabled = false # opt-in browser_open tool +allowed_domains = ["docs.rs"] # required when browser is enabled + [composio] enabled = false # opt-in: 1000+ OAuth apps via composio.dev ``` @@ -172,15 +182,15 @@ enabled = false # opt-in: 1000+ OAuth apps via composio.dev | Command | Description | |---------|-------------| -| `onboard` | Setup wizard (`--quick` for non-interactive) | +| `onboard` | Quick setup (default) | +| `onboard --interactive` | Full interactive 7-step wizard | | `agent -m "..."` | Single message mode | | `agent` | Interactive chat mode | | `gateway` | Start webhook server (default: `127.0.0.1:8080`) | | `gateway --port 0` | Random port mode | -| `status -v` | Show full system status | -| `tools list` | List available tools | -| `tools test ` | Test a tool directly | -| `integrations list` | List all 50+ integrations | +| `status` | Show full system status | +| `channel doctor` | Run health checks for configured channels | +| `integrations info ` | Show setup/status details for one integration | ## Development diff --git a/docs/architecture.svg b/docs/architecture.svg index ec37052..72ea548 100644 --- a/docs/architecture.svg +++ b/docs/architecture.svg @@ -67,7 +67,7 @@ Auth Gate - allowed_users + webhook_secret + Channel allowlists + webhook_secret @@ -237,9 +237,9 @@ Command allowlist Path jail + traversal block - Null byte injection blocked - Symlink escape detection - 14 system dirs + 4 dotfiles blocked + Browser domain allowlist + Null byte + Symlink escape block + System dirs + Dotfiles blocked Default: Supervised + workspace-only Levels: ReadOnly / Supervised / Full @@ -260,7 +260,7 @@ - Setup Wizard -- zeroclaw onboard (--quick for instant setup) + Setup Wizard -- zeroclaw onboard (quick default | --interactive for full wizard) 7 steps, under 60 seconds | Live connection testing | Secure defaults @@ -308,5 +308,5 @@ Ready -- zeroclaw agent - ~3.4MB binary | <10ms startup | 1,017 tests | 22+ providers | 8 traits | 17,800+ lines of Rust | 0 clippy warnings + ~3.4MB binary | <10ms startup | 1,050 tests | 22+ providers | 8 traits | 18,900+ lines of Rust | 0 clippy warnings diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 122f1d9..57e0182 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -59,7 +59,7 @@ pub async fn run( } else { None }; - let _tools = tools::all_tools(security, mem.clone(), composio_key); + let _tools = tools::all_tools(&security, mem.clone(), composio_key, &config.browser); // ── Resolve provider ───────────────────────────────────────── let provider_name = provider_override @@ -82,7 +82,7 @@ pub async fn run( // ── Build system prompt from workspace MD files (OpenClaw framework) ── let skills = crate::skills::load_skills(&config.workspace_dir); - let tool_descs: Vec<(&str, &str)> = vec![ + let mut tool_descs: Vec<(&str, &str)> = vec![ ("shell", "Execute terminal commands"), ("file_read", "Read file contents"), ("file_write", "Write file contents"), @@ -90,6 +90,12 @@ pub async fn run( ("memory_recall", "Search memory"), ("memory_forget", "Delete a memory entry"), ]; + if config.browser.enabled { + tool_descs.push(( + "browser_open", + "Open approved HTTPS URLs in Brave Browser (allowlist-only, no scraping)", + )); + } let system_prompt = crate::channels::build_system_prompt( &config.workspace_dir, model_name, diff --git a/src/channels/discord.rs b/src/channels/discord.rs index c498595..fd5fe37 100644 --- a/src/channels/discord.rs +++ b/src/channels/discord.rs @@ -24,11 +24,9 @@ impl DiscordChannel { } /// Check if a Discord user ID is in the allowlist. - /// Empty list or `["*"]` means allow everyone. + /// Empty list means deny everyone until explicitly configured. + /// `"*"` means allow everyone. fn is_user_allowed(&self, user_id: &str) -> bool { - if self.allowed_users.is_empty() { - return true; - } self.allowed_users.iter().any(|u| u == "*" || u == user_id) } @@ -285,10 +283,10 @@ mod tests { } #[test] - fn empty_allowlist_allows_everyone() { + fn empty_allowlist_denies_everyone() { let ch = DiscordChannel::new("fake".into(), None, vec![]); - assert!(ch.is_user_allowed("12345")); - assert!(ch.is_user_allowed("anyone")); + assert!(!ch.is_user_allowed("12345")); + assert!(!ch.is_user_allowed("anyone")); } #[test] diff --git a/src/channels/mod.rs b/src/channels/mod.rs index f77a4a1..7252f7d 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -19,6 +19,7 @@ use crate::memory::{self, Memory}; use crate::providers::{self, Provider}; use anyhow::Result; use std::sync::Arc; +use std::time::Duration; /// Maximum characters per injected workspace file (matches `OpenClaw` default). const BOOTSTRAP_MAX_CHARS: usize = 20_000; @@ -181,6 +182,10 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul // Handled in main.rs (needs async), this is unreachable unreachable!("Start is handled in main.rs") } + super::ChannelCommands::Doctor => { + // Handled in main.rs (needs async), this is unreachable + unreachable!("Doctor is handled in main.rs") + } super::ChannelCommands::List => { println!("Channels:"); println!(" ✅ CLI (always available)"); @@ -195,6 +200,7 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul println!(" {} {name}", if configured { "✅" } else { "❌" }); } println!("\nTo start channels: zeroclaw channel start"); + println!("To check health: zeroclaw channel doctor"); println!("To configure: zeroclaw onboard"); Ok(()) } @@ -212,6 +218,119 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChannelHealthState { + Healthy, + Unhealthy, + Timeout, +} + +fn classify_health_result( + result: &std::result::Result, +) -> ChannelHealthState { + match result { + Ok(true) => ChannelHealthState::Healthy, + Ok(false) => ChannelHealthState::Unhealthy, + Err(_) => ChannelHealthState::Timeout, + } +} + +/// Run health checks for configured channels. +pub async fn doctor_channels(config: Config) -> Result<()> { + let mut channels: Vec<(&'static str, Arc)> = Vec::new(); + + if let Some(ref tg) = config.channels_config.telegram { + channels.push(( + "Telegram", + Arc::new(TelegramChannel::new( + tg.bot_token.clone(), + tg.allowed_users.clone(), + )), + )); + } + + if let Some(ref dc) = config.channels_config.discord { + channels.push(( + "Discord", + Arc::new(DiscordChannel::new( + dc.bot_token.clone(), + dc.guild_id.clone(), + dc.allowed_users.clone(), + )), + )); + } + + if let Some(ref sl) = config.channels_config.slack { + channels.push(( + "Slack", + Arc::new(SlackChannel::new( + sl.bot_token.clone(), + sl.channel_id.clone(), + sl.allowed_users.clone(), + )), + )); + } + + if let Some(ref im) = config.channels_config.imessage { + channels.push(( + "iMessage", + Arc::new(IMessageChannel::new(im.allowed_contacts.clone())), + )); + } + + if let Some(ref mx) = config.channels_config.matrix { + channels.push(( + "Matrix", + Arc::new(MatrixChannel::new( + mx.homeserver.clone(), + mx.access_token.clone(), + mx.room_id.clone(), + mx.allowed_users.clone(), + )), + )); + } + + if channels.is_empty() { + println!("No real-time channels configured. Run `zeroclaw onboard` first."); + return Ok(()); + } + + println!("🩺 ZeroClaw Channel Doctor"); + println!(); + + let mut healthy = 0_u32; + let mut unhealthy = 0_u32; + let mut timeout = 0_u32; + + for (name, channel) in channels { + let result = tokio::time::timeout(Duration::from_secs(10), channel.health_check()).await; + let state = classify_health_result(&result); + + match state { + ChannelHealthState::Healthy => { + healthy += 1; + println!(" ✅ {name:<9} healthy"); + } + ChannelHealthState::Unhealthy => { + unhealthy += 1; + println!(" ❌ {name:<9} unhealthy (auth/config/network)"); + } + ChannelHealthState::Timeout => { + timeout += 1; + println!(" ⏱️ {name:<9} timed out (>10s)"); + } + } + } + + if config.channels_config.webhook.is_some() { + println!(" ℹ️ Webhook check via `zeroclaw gateway` then GET /health"); + } + + println!(); + println!("Summary: {healthy} healthy, {unhealthy} unhealthy, {timeout} timed out"); + Ok(()) +} + /// Start all configured channels and route messages to the agent #[allow(clippy::too_many_lines)] pub async fn start_channels(config: Config) -> Result<()> { @@ -235,7 +354,7 @@ pub async fn start_channels(config: Config) -> Result<()> { let skills = crate::skills::load_skills(&workspace); // Collect tool descriptions for the prompt - let tool_descs: Vec<(&str, &str)> = vec![ + let mut tool_descs: Vec<(&str, &str)> = vec![ ("shell", "Execute terminal commands"), ("file_read", "Read file contents"), ("file_write", "Write file contents"), @@ -244,6 +363,13 @@ pub async fn start_channels(config: Config) -> Result<()> { ("memory_forget", "Delete a memory entry"), ]; + if config.browser.enabled { + tool_descs.push(( + "browser_open", + "Open approved HTTPS URLs in Brave Browser (allowlist-only, no scraping)", + )); + } + let system_prompt = build_system_prompt(&workspace, &model, &tool_descs, &skills); if !skills.is_empty() { @@ -628,4 +754,27 @@ mod tests { assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display()))); } + + #[test] + fn classify_health_ok_true() { + let state = classify_health_result(&Ok(true)); + assert_eq!(state, ChannelHealthState::Healthy); + } + + #[test] + fn classify_health_ok_false() { + let state = classify_health_result(&Ok(false)); + assert_eq!(state, ChannelHealthState::Unhealthy); + } + + #[tokio::test] + async fn classify_health_timeout() { + let result = tokio::time::timeout(Duration::from_millis(1), async { + tokio::time::sleep(Duration::from_millis(20)).await; + true + }) + .await; + let state = classify_health_result(&result); + assert_eq!(state, ChannelHealthState::Timeout); + } } diff --git a/src/channels/slack.rs b/src/channels/slack.rs index d7f807d..d8b35cb 100644 --- a/src/channels/slack.rs +++ b/src/channels/slack.rs @@ -21,11 +21,9 @@ impl SlackChannel { } /// Check if a Slack user ID is in the allowlist. - /// Empty list or `["*"]` means allow everyone. + /// Empty list means deny everyone until explicitly configured. + /// `"*"` means allow everyone. fn is_user_allowed(&self, user_id: &str) -> bool { - if self.allowed_users.is_empty() { - return true; - } self.allowed_users.iter().any(|u| u == "*" || u == user_id) } @@ -187,10 +185,10 @@ mod tests { } #[test] - fn empty_allowlist_allows_everyone() { + fn empty_allowlist_denies_everyone() { let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]); - assert!(ch.is_user_allowed("U12345")); - assert!(ch.is_user_allowed("anyone")); + assert!(!ch.is_user_allowed("U12345")); + assert!(!ch.is_user_allowed("anyone")); } #[test] diff --git a/src/config/mod.rs b/src/config/mod.rs index 58dbafa..9af098c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,7 +1,8 @@ pub mod schema; pub use schema::{ - AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, GatewayConfig, - HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, - RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, TunnelConfig, WebhookConfig, + AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, + GatewayConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, + ObservabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, TunnelConfig, + WebhookConfig, }; diff --git a/src/config/schema.rs b/src/config/schema.rs index 96813c5..49a9d59 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -45,6 +45,9 @@ pub struct Config { #[serde(default)] pub secrets: SecretsConfig, + + #[serde(default)] + pub browser: BrowserConfig, } // ── Gateway security ───────────────────────────────────────────── @@ -120,6 +123,18 @@ impl Default for SecretsConfig { } } +// ── Browser (friendly-service browsing only) ─────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BrowserConfig { + /// Enable `browser_open` tool (opens URLs in Brave without scraping) + #[serde(default)] + pub enabled: bool, + /// Allowed domains for `browser_open` (exact or subdomain match) + #[serde(default)] + pub allowed_domains: Vec, +} + // ── Memory ─────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] @@ -455,6 +470,7 @@ impl Default for Config { gateway: GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), + browser: BrowserConfig::default(), } } } @@ -596,6 +612,7 @@ mod tests { gateway: GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), + browser: BrowserConfig::default(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); @@ -659,6 +676,7 @@ default_temperature = 0.7 gateway: GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), + browser: BrowserConfig::default(), }; config.save().unwrap(); @@ -1060,5 +1078,39 @@ default_temperature = 0.7 assert!(!c.composio.enabled); assert!(c.composio.api_key.is_none()); assert!(c.secrets.encrypt); + assert!(!c.browser.enabled); + assert!(c.browser.allowed_domains.is_empty()); + } + + #[test] + fn browser_config_default_disabled() { + let b = BrowserConfig::default(); + assert!(!b.enabled); + assert!(b.allowed_domains.is_empty()); + } + + #[test] + fn browser_config_serde_roundtrip() { + let b = BrowserConfig { + enabled: true, + allowed_domains: vec!["example.com".into(), "docs.example.com".into()], + }; + let toml_str = toml::to_string(&b).unwrap(); + let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap(); + assert!(parsed.enabled); + assert_eq!(parsed.allowed_domains.len(), 2); + assert_eq!(parsed.allowed_domains[0], "example.com"); + } + + #[test] + fn browser_config_backward_compat_missing_section() { + let minimal = r#" +workspace_dir = "/tmp/ws" +config_path = "/tmp/config.toml" +default_temperature = 0.7 +"#; + let parsed: Config = toml::from_str(minimal).unwrap(); + assert!(!parsed.browser.enabled); + assert!(parsed.browser.allowed_domains.is_empty()); } } diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 26b14af..8b2b126 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -69,84 +69,18 @@ pub struct IntegrationEntry { /// Handle the `integrations` CLI command pub fn handle_command(command: super::IntegrationCommands, config: &Config) -> Result<()> { match command { - super::IntegrationCommands::List { category } => { - list_integrations(config, category.as_deref()) - } super::IntegrationCommands::Info { name } => show_integration_info(config, &name), } } -#[allow(clippy::unnecessary_wraps)] -fn list_integrations(config: &Config, filter_category: Option<&str>) -> Result<()> { - let entries = registry::all_integrations(); - - let mut available = 0u32; - let mut active = 0u32; - let mut coming = 0u32; - - for &cat in IntegrationCategory::all() { - // Filter by category if specified - if let Some(filter) = filter_category { - let filter_lower = filter.to_lowercase(); - let cat_lower = cat.label().to_lowercase(); - if !cat_lower.contains(&filter_lower) { - continue; - } - } - - let cat_entries: Vec<&IntegrationEntry> = - entries.iter().filter(|e| e.category == cat).collect(); - - if cat_entries.is_empty() { - continue; - } - - println!("\n ⟩ {}", console::style(cat.label()).white().bold()); - - for entry in &cat_entries { - let status = (entry.status_fn)(config); - let (icon, label) = match status { - IntegrationStatus::Active => { - active += 1; - ("✅", console::style("active").green()) - } - IntegrationStatus::Available => { - available += 1; - ("⚪", console::style("available").dim()) - } - IntegrationStatus::ComingSoon => { - coming += 1; - ("🔜", console::style("coming soon").dim()) - } - }; - println!( - " {icon} {:<22} {:<30} {}", - console::style(entry.name).white().bold(), - entry.description, - label - ); - } - } - - let total = available + active + coming; - println!(); - println!( - " {total} integrations: {active} active, {available} available, {coming} coming soon" - ); - println!(); - println!(" Configure: zeroclaw onboard"); - println!(" Details: zeroclaw integrations info "); - println!(); - - Ok(()) -} - fn show_integration_info(config: &Config, name: &str) -> Result<()> { let entries = registry::all_integrations(); let name_lower = name.to_lowercase(); let Some(entry) = entries.iter().find(|e| e.name.to_lowercase() == name_lower) else { - anyhow::bail!("Unknown integration: {name}. Run `zeroclaw integrations list` to see all."); + anyhow::bail!( + "Unknown integration: {name}. Check README for supported integrations or run `zeroclaw onboard --interactive` to configure channels/providers." + ); }; let status = (entry.status_fn)(config); diff --git a/src/main.rs b/src/main.rs index 1dcb99e..dbc2d4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,15 +47,15 @@ struct Cli { enum Commands { /// Initialize your workspace and configuration Onboard { - /// Skip interactive prompts — generate config with sensible defaults + /// Run the full interactive wizard (default is quick setup) #[arg(long)] - quick: bool, + interactive: bool, - /// API key (used with --quick) + /// API key (used in quick mode, ignored with --interactive) #[arg(long)] api_key: Option, - /// Provider name (used with --quick, default: openrouter) + /// Provider name (used in quick mode, default: openrouter) #[arg(long)] provider: Option, }, @@ -90,12 +90,8 @@ enum Commands { host: String, }, - /// Show system status - Status { - /// Show detailed status - #[arg(short, long)] - verbose: bool, - }, + /// Show system status (full details) + Status, /// Configure and manage scheduled tasks Cron { @@ -109,12 +105,6 @@ enum Commands { channel_command: ChannelCommands, }, - /// Tool utilities - Tools { - #[command(subcommand)] - tool_command: ToolCommands, - }, - /// Browse 50+ integrations Integrations { #[command(subcommand)] @@ -152,6 +142,8 @@ enum ChannelCommands { List, /// Start all configured channels (Telegram, Discord, Slack) Start, + /// Run health checks for configured channels + Doctor, /// Add a new channel Add { /// Channel type @@ -184,12 +176,6 @@ enum SkillCommands { #[derive(Subcommand, Debug)] enum IntegrationCommands { - /// List all integrations and their status - List { - /// Filter by category (e.g. "chat", "ai", "productivity") - #[arg(short, long)] - category: Option, - }, /// Show details about a specific integration Info { /// Integration name @@ -197,19 +183,6 @@ enum IntegrationCommands { }, } -#[derive(Subcommand, Debug)] -enum ToolCommands { - /// List available tools - List, - /// Test a tool - Test { - /// Tool name - tool: String, - /// Tool arguments (JSON) - args: String, - }, -} - #[tokio::main] #[allow(clippy::too_many_lines)] async fn main() -> Result<()> { @@ -222,17 +195,17 @@ async fn main() -> Result<()> { tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); - // Onboard runs the wizard or quick setup — no existing config needed + // Onboard runs quick setup by default, or the interactive wizard with --interactive if let Commands::Onboard { - quick, + interactive, api_key, provider, } = &cli.command { - let config = if *quick { - onboard::run_quick_setup(api_key.as_deref(), provider.as_deref())? - } else { + let config = if *interactive { onboard::run_wizard()? + } else { + onboard::run_quick_setup(api_key.as_deref(), provider.as_deref())? }; // Auto-start channels if user said yes during wizard if std::env::var("ZEROCLAW_AUTOSTART_CHANNELS").as_deref() == Ok("1") { @@ -263,7 +236,7 @@ async fn main() -> Result<()> { gateway::run_gateway(&host, port, config).await } - Commands::Status { verbose } => { + Commands::Status => { println!("🦀 ZeroClaw Status"); println!(); println!("Version: {}", env!("CARGO_PKG_VERSION")); @@ -295,40 +268,38 @@ async fn main() -> Result<()> { if config.memory.auto_save { "on" } else { "off" } ); - if verbose { - println!(); - println!("Security:"); - println!(" Workspace only: {}", config.autonomy.workspace_only); + println!(); + println!("Security:"); + println!(" Workspace only: {}", config.autonomy.workspace_only); + println!( + " Allowed commands: {}", + config.autonomy.allowed_commands.join(", ") + ); + println!( + " Max actions/hour: {}", + config.autonomy.max_actions_per_hour + ); + println!( + " Max cost/day: ${:.2}", + f64::from(config.autonomy.max_cost_per_day_cents) / 100.0 + ); + println!(); + println!("Channels:"); + println!(" CLI: ✅ always"); + for (name, configured) in [ + ("Telegram", config.channels_config.telegram.is_some()), + ("Discord", config.channels_config.discord.is_some()), + ("Slack", config.channels_config.slack.is_some()), + ("Webhook", config.channels_config.webhook.is_some()), + ] { println!( - " Allowed commands: {}", - config.autonomy.allowed_commands.join(", ") + " {name:9} {}", + if configured { + "✅ configured" + } else { + "❌ not configured" + } ); - println!( - " Max actions/hour: {}", - config.autonomy.max_actions_per_hour - ); - println!( - " Max cost/day: ${:.2}", - f64::from(config.autonomy.max_cost_per_day_cents) / 100.0 - ); - println!(); - println!("Channels:"); - println!(" CLI: ✅ always"); - for (name, configured) in [ - ("Telegram", config.channels_config.telegram.is_some()), - ("Discord", config.channels_config.discord.is_some()), - ("Slack", config.channels_config.slack.is_some()), - ("Webhook", config.channels_config.webhook.is_some()), - ] { - println!( - " {name:9} {}", - if configured { - "✅ configured" - } else { - "❌ not configured" - } - ); - } } Ok(()) @@ -338,11 +309,10 @@ async fn main() -> Result<()> { Commands::Channel { channel_command } => match channel_command { ChannelCommands::Start => channels::start_channels(config).await, + ChannelCommands::Doctor => channels::doctor_channels(config).await, other => channels::handle_command(other, &config), }, - Commands::Tools { tool_command } => tools::handle_command(tool_command, config).await, - Commands::Integrations { integration_command, } => integrations::handle_command(integration_command, &config), diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index f38a3e4..0153cbd 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -1,7 +1,7 @@ use crate::config::{ - AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, HeartbeatConfig, - IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig, - SlackConfig, TelegramConfig, WebhookConfig, + AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, + HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, + RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig, }; use anyhow::{Context, Result}; use console::style; @@ -98,6 +98,7 @@ pub fn run_wizard() -> Result { gateway: crate::config::GatewayConfig::default(), composio: composio_config, secrets: secrets_config, + browser: BrowserConfig::default(), }; println!( @@ -151,7 +152,8 @@ pub fn run_wizard() -> Result { // ── Quick setup (zero prompts) ─────────────────────────────────── /// Non-interactive setup: generates a sensible default config instantly. -/// Use `zeroclaw onboard --quick` or `zeroclaw onboard --quick --api-key sk-... --provider openrouter` +/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter`. +/// Use `zeroclaw onboard --interactive` for the full wizard. #[allow(clippy::too_many_lines)] pub fn run_quick_setup(api_key: Option<&str>, provider: Option<&str>) -> Result { println!("{}", style(BANNER).cyan().bold()); @@ -192,6 +194,7 @@ pub fn run_quick_setup(api_key: Option<&str>, provider: Option<&str>) -> Result< gateway: crate::config::GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), + browser: BrowserConfig::default(), }; config.save()?; @@ -275,7 +278,7 @@ pub fn run_quick_setup(api_key: Option<&str>, provider: Option<&str>) -> Result< } else { println!(" 1. Chat: zeroclaw agent -m \"Hello!\""); println!(" 2. Gateway: zeroclaw gateway"); - println!(" 3. Status: zeroclaw status --verbose"); + println!(" 3. Status: zeroclaw status"); } println!(); @@ -1054,10 +1057,34 @@ fn setup_channels() -> Result { .allow_empty(true) .interact_text()?; + let allowed_users_str: String = Input::new() + .with_prompt( + " Allowed Discord user IDs (comma-separated, '*' for all, Enter to deny all)", + ) + .allow_empty(true) + .interact_text()?; + + let allowed_users = if allowed_users_str.trim().is_empty() { + vec![] + } else { + allowed_users_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }; + + if allowed_users.is_empty() { + println!( + " {} No users allowlisted — Discord inbound messages will be denied until you add IDs or '*'.", + style("⚠").yellow().bold() + ); + } + config.discord = Some(DiscordConfig { bot_token: token, guild_id: if guild.is_empty() { None } else { Some(guild) }, - allowed_users: vec![], + allowed_users, }); } 2 => { @@ -1133,6 +1160,30 @@ fn setup_channels() -> Result { .allow_empty(true) .interact_text()?; + let allowed_users_str: String = Input::new() + .with_prompt( + " Allowed Slack user IDs (comma-separated, '*' for all, Enter to deny all)", + ) + .allow_empty(true) + .interact_text()?; + + let allowed_users = if allowed_users_str.trim().is_empty() { + vec![] + } else { + allowed_users_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }; + + if allowed_users.is_empty() { + println!( + " {} No users allowlisted — Slack inbound messages will be denied until you add IDs or '*'.", + style("⚠").yellow().bold() + ); + } + config.slack = Some(SlackConfig { bot_token: token, app_token: if app_token.is_empty() { @@ -1145,7 +1196,7 @@ fn setup_channels() -> Result { } else { Some(channel) }, - allowed_users: vec![], + allowed_users, }); } 3 => { @@ -1936,7 +1987,7 @@ fn print_summary(config: &Config) { " {} Check full status:", style(format!("{step}.")).cyan().bold() ); - println!(" {}", style("zeroclaw status --verbose").yellow()); + println!(" {}", style("zeroclaw status").yellow()); println!(); println!( diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 8828a18..83c5392 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -104,7 +104,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result anyhow::bail!( - "Unknown provider: {name}. Run `zeroclaw integrations list -c ai` to see all available providers.\n\ + "Unknown provider: {name}. Check README for supported providers or run `zeroclaw onboard --interactive` to reconfigure.\n\ Tip: Use \"custom:https://your-api.com\" for any OpenAI-compatible endpoint." ), } diff --git a/src/tools/browser_open.rs b/src/tools/browser_open.rs new file mode 100644 index 0000000..c3ca76f --- /dev/null +++ b/src/tools/browser_open.rs @@ -0,0 +1,465 @@ +use super::traits::{Tool, ToolResult}; +use crate::security::SecurityPolicy; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Open approved HTTPS URLs in Brave Browser (no scraping, no DOM automation). +pub struct BrowserOpenTool { + security: Arc, + allowed_domains: Vec, +} + +impl BrowserOpenTool { + pub fn new(security: Arc, allowed_domains: Vec) -> Self { + Self { + security, + allowed_domains: normalize_allowed_domains(allowed_domains), + } + } + + fn validate_url(&self, raw_url: &str) -> anyhow::Result { + let url = raw_url.trim(); + + if url.is_empty() { + anyhow::bail!("URL cannot be empty"); + } + + if url.chars().any(char::is_whitespace) { + anyhow::bail!("URL cannot contain whitespace"); + } + + if !url.starts_with("https://") { + anyhow::bail!("Only https:// URLs are allowed"); + } + + if self.allowed_domains.is_empty() { + anyhow::bail!( + "Browser tool is enabled but no allowed_domains are configured. Add [browser].allowed_domains in config.toml" + ); + } + + let host = extract_host(url)?; + + if is_private_or_local_host(&host) { + anyhow::bail!("Blocked local/private host: {host}"); + } + + if !host_matches_allowlist(&host, &self.allowed_domains) { + anyhow::bail!("Host '{host}' is not in browser.allowed_domains"); + } + + Ok(url.to_string()) + } +} + +#[async_trait] +impl Tool for BrowserOpenTool { + fn name(&self) -> &str { + "browser_open" + } + + fn description(&self) -> &str { + "Open an approved HTTPS URL in Brave 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 Brave Browser" + } + }, + "required": ["url"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + 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_brave(&url).await { + Ok(()) => Ok(ToolResult { + success: true, + output: format!("Opened in Brave: {url}"), + error: None, + }), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Failed to open Brave Browser: {e}")), + }), + } + } +} + +async fn open_in_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"); + } +} + +fn normalize_allowed_domains(domains: Vec) -> Vec { + let mut normalized = domains + .into_iter() + .filter_map(|d| normalize_domain(&d)) + .collect::>(); + normalized.sort_unstable(); + normalized.dedup(); + normalized +} + +fn normalize_domain(raw: &str) -> Option { + let mut d = raw.trim().to_lowercase(); + if d.is_empty() { + return None; + } + + if let Some(stripped) = d.strip_prefix("https://") { + d = stripped.to_string(); + } else if let Some(stripped) = d.strip_prefix("http://") { + d = stripped.to_string(); + } + + if let Some((host, _)) = d.split_once('/') { + d = host.to_string(); + } + + d = d.trim_start_matches('.').trim_end_matches('.').to_string(); + + if let Some((host, _)) = d.split_once(':') { + d = host.to_string(); + } + + if d.is_empty() || d.chars().any(char::is_whitespace) { + return None; + } + + Some(d) +} + +fn extract_host(url: &str) -> anyhow::Result { + let rest = url + .strip_prefix("https://") + .ok_or_else(|| anyhow::anyhow!("Only https:// URLs are allowed"))?; + + let authority = rest + .split(['/', '?', '#']) + .next() + .ok_or_else(|| anyhow::anyhow!("Invalid URL"))?; + + if authority.is_empty() { + anyhow::bail!("URL must include a host"); + } + + if authority.contains('@') { + anyhow::bail!("URL userinfo is not allowed"); + } + + if authority.starts_with('[') { + anyhow::bail!("IPv6 hosts are not supported in browser_open"); + } + + let host = authority + .split(':') + .next() + .unwrap_or_default() + .trim() + .trim_end_matches('.') + .to_lowercase(); + + if host.is_empty() { + anyhow::bail!("URL must include a valid host"); + } + + Ok(host) +} + +fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { + allowed_domains.iter().any(|domain| { + host == domain + || host + .strip_suffix(domain) + .is_some_and(|prefix| prefix.ends_with('.')) + }) +} + +fn is_private_or_local_host(host: &str) -> bool { + let has_local_tld = host + .rsplit('.') + .next() + .is_some_and(|label| label == "local"); + + if host == "localhost" || host.ends_with(".localhost") || has_local_tld || host == "::1" { + return true; + } + + if let Some([a, b, _, _]) = parse_ipv4(host) { + return a == 0 + || a == 10 + || a == 127 + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 168) + || (a == 100 && (64..=127).contains(&b)); + } + + false +} + +fn parse_ipv4(host: &str) -> Option<[u8; 4]> { + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() != 4 { + return None; + } + + let mut octets = [0_u8; 4]; + for (i, part) in parts.iter().enumerate() { + octets[i] = part.parse::().ok()?; + } + Some(octets) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::{AutonomyLevel, SecurityPolicy}; + + 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(), + ) + } + + #[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_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![]); + let err = tool + .validate_url("https://example.com") + .unwrap_err() + .to_string(); + assert!(err.contains("allowed_domains")); + } + + #[test] + fn parse_ipv4_valid() { + assert_eq!(parse_ipv4("1.2.3.4"), Some([1, 2, 3, 4])); + } + + #[test] + fn parse_ipv4_invalid() { + assert_eq!(parse_ipv4("1.2.3"), None); + assert_eq!(parse_ipv4("1.2.3.999"), None); + assert_eq!(parse_ipv4("not-an-ip"), None); + } + + #[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()]); + 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()]); + let result = tool + .execute(json!({"url": "https://example.com"})) + .await + .unwrap(); + assert!(!result.success); + assert!(result.error.unwrap().contains("rate limit")); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 617c0dd..41524f1 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,3 +1,4 @@ +pub mod browser_open; pub mod composio; pub mod file_read; pub mod file_write; @@ -7,6 +8,7 @@ pub mod memory_store; pub mod shell; pub mod traits; +pub use browser_open::BrowserOpenTool; pub use composio::ComposioTool; pub use file_read::FileReadTool; pub use file_write::FileWriteTool; @@ -18,10 +20,8 @@ pub use traits::Tool; #[allow(unused_imports)] pub use traits::{ToolResult, ToolSpec}; -use crate::config::Config; use crate::memory::Memory; use crate::security::SecurityPolicy; -use anyhow::Result; use std::sync::Arc; /// Create the default tool registry @@ -35,19 +35,27 @@ pub fn default_tools(security: Arc) -> Vec> { /// Create full tool registry including memory tools and optional Composio pub fn all_tools( - security: Arc, + security: &Arc, memory: Arc, composio_key: Option<&str>, + browser_config: &crate::config::BrowserConfig, ) -> Vec> { let mut tools: Vec> = vec![ Box::new(ShellTool::new(security.clone())), Box::new(FileReadTool::new(security.clone())), - Box::new(FileWriteTool::new(security)), + Box::new(FileWriteTool::new(security.clone())), Box::new(MemoryStoreTool::new(memory.clone())), Box::new(MemoryRecallTool::new(memory.clone())), Box::new(MemoryForgetTool::new(memory)), ]; + if browser_config.enabled { + tools.push(Box::new(BrowserOpenTool::new( + security.clone(), + browser_config.allowed_domains.clone(), + ))); + } + if let Some(key) = composio_key { if !key.is_empty() { tools.push(Box::new(ComposioTool::new(key))); @@ -57,53 +65,11 @@ pub fn all_tools( tools } -pub async fn handle_command(command: super::ToolCommands, config: Config) -> Result<()> { - let security = Arc::new(SecurityPolicy { - workspace_dir: config.workspace_dir.clone(), - ..SecurityPolicy::default() - }); - let mem: Arc = Arc::from(crate::memory::create_memory( - &config.memory, - &config.workspace_dir, - config.api_key.as_deref(), - )?); - let composio_key = if config.composio.enabled { - config.composio.api_key.as_deref() - } else { - None - }; - let tools_list = all_tools(security, mem, composio_key); - - match command { - super::ToolCommands::List => { - println!("Available tools ({}):", tools_list.len()); - for tool in &tools_list { - println!(" - {}: {}", tool.name(), tool.description()); - } - Ok(()) - } - super::ToolCommands::Test { tool, args } => { - let matched = tools_list.iter().find(|t| t.name() == tool); - match matched { - Some(t) => { - let parsed: serde_json::Value = serde_json::from_str(&args)?; - let result = t.execute(parsed).await?; - println!("Success: {}", result.success); - println!("Output: {}", result.output); - if let Some(err) = result.error { - println!("Error: {err}"); - } - Ok(()) - } - None => anyhow::bail!("Unknown tool: {tool}"), - } - } - } -} - #[cfg(test)] mod tests { use super::*; + use crate::config::{BrowserConfig, MemoryConfig}; + use tempfile::TempDir; #[test] fn default_tools_has_three() { @@ -112,6 +78,48 @@ mod tests { assert_eq!(tools.len(), 3); } + #[test] + fn all_tools_excludes_browser_when_disabled() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem_cfg = MemoryConfig { + backend: "markdown".into(), + ..MemoryConfig::default() + }; + let mem: Arc = + Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap()); + + let browser = BrowserConfig { + enabled: false, + allowed_domains: vec!["example.com".into()], + }; + + let tools = all_tools(&security, mem, None, &browser); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(!names.contains(&"browser_open")); + } + + #[test] + fn all_tools_includes_browser_when_enabled() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem_cfg = MemoryConfig { + backend: "markdown".into(), + ..MemoryConfig::default() + }; + let mem: Arc = + Arc::from(crate::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap()); + + let browser = BrowserConfig { + enabled: true, + allowed_domains: vec!["example.com".into()], + }; + + let tools = all_tools(&security, mem, None, &browser); + let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); + assert!(names.contains(&"browser_open")); + } + #[test] fn default_tools_names() { let security = Arc::new(SecurityPolicy::default());