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)
This commit is contained in:
parent
a74a774ad5
commit
3d91c40970
14 changed files with 886 additions and 244 deletions
44
README.md
44
README.md
|
|
@ -26,10 +26,10 @@ cd zeroclaw
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
# Quick setup (no prompts)
|
# 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
|
# Or interactive wizard
|
||||||
cargo run --release -- onboard
|
cargo run --release -- onboard --interactive
|
||||||
|
|
||||||
# Chat
|
# Chat
|
||||||
cargo run --release -- agent -m "Hello, ZeroClaw!"
|
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)
|
cargo run --release -- gateway --port 0 # random port (security hardened)
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
cargo run --release -- status --verbose
|
cargo run --release -- status
|
||||||
|
|
||||||
# List tools (includes memory tools)
|
# Check channel health
|
||||||
cargo run --release -- tools list
|
cargo run --release -- channel doctor
|
||||||
|
|
||||||
# Test a tool directly
|
# Get integration setup details
|
||||||
cargo run --release -- tools test memory_store '{"key": "lang", "content": "User prefers Rust"}'
|
cargo run --release -- integrations info Telegram
|
||||||
cargo run --release -- tools test memory_recall '{"query": "Rust"}'
|
|
||||||
|
|
||||||
# List integrations
|
|
||||||
cargo run --release -- integrations list
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Tip:** Run `cargo install --path .` to install `zeroclaw` globally, then use `zeroclaw` instead of `cargo run --release --`.
|
> **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
|
||||||
| **Runtime** | `RuntimeAdapter` | Native (Mac/Linux/Pi) | Docker, WASM |
|
| **Runtime** | `RuntimeAdapter` | Native (Mac/Linux/Pi) | Docker, WASM |
|
||||||
| **Security** | `SecurityPolicy` | Gateway pairing, sandbox, allowlists, rate limits, filesystem scoping, encrypted secrets | — |
|
| **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 <your-host>` — ZeroClaw binds to localhost only, so nothing is exposed unless you explicitly configure a tunnel.
|
> **Run your own nmap:** `nmap -p 1-65535 <your-host>` — 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
|
## Configuration
|
||||||
|
|
||||||
Config: `~/.zeroclaw/config.toml` (created by `onboard`)
|
Config: `~/.zeroclaw/config.toml` (created by `onboard`)
|
||||||
|
|
@ -156,6 +162,10 @@ provider = "none" # "none", "cloudflare", "tailscale", "ngrok", "c
|
||||||
[secrets]
|
[secrets]
|
||||||
encrypt = true # API keys encrypted with local key file
|
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]
|
[composio]
|
||||||
enabled = false # opt-in: 1000+ OAuth apps via composio.dev
|
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 |
|
| 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 -m "..."` | Single message mode |
|
||||||
| `agent` | Interactive chat mode |
|
| `agent` | Interactive chat mode |
|
||||||
| `gateway` | Start webhook server (default: `127.0.0.1:8080`) |
|
| `gateway` | Start webhook server (default: `127.0.0.1:8080`) |
|
||||||
| `gateway --port 0` | Random port mode |
|
| `gateway --port 0` | Random port mode |
|
||||||
| `status -v` | Show full system status |
|
| `status` | Show full system status |
|
||||||
| `tools list` | List available tools |
|
| `channel doctor` | Run health checks for configured channels |
|
||||||
| `tools test <name> <json>` | Test a tool directly |
|
| `integrations info <name>` | Show setup/status details for one integration |
|
||||||
| `integrations list` | List all 50+ integrations |
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
<!-- Auth Gate -->
|
<!-- Auth Gate -->
|
||||||
<rect x="213" y="178" width="165" height="45" rx="8" fill="#292524" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,2"/>
|
<rect x="213" y="178" width="165" height="45" rx="8" fill="#292524" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,2"/>
|
||||||
<text x="295" y="196" text-anchor="middle" fill="#fbbf24" font-size="10" font-weight="600">Auth Gate</text>
|
<text x="295" y="196" text-anchor="middle" fill="#fbbf24" font-size="10" font-weight="600">Auth Gate</text>
|
||||||
<text x="295" y="210" text-anchor="middle" fill="#a8a29e" font-size="8">allowed_users + webhook_secret</text>
|
<text x="295" y="210" text-anchor="middle" fill="#a8a29e" font-size="8">Channel allowlists + webhook_secret</text>
|
||||||
|
|
||||||
<!-- Rate Limiter -->
|
<!-- Rate Limiter -->
|
||||||
<rect x="213" y="230" width="165" height="45" rx="8" fill="#292524" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,2"/>
|
<rect x="213" y="230" width="165" height="45" rx="8" fill="#292524" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,2"/>
|
||||||
|
|
@ -237,9 +237,9 @@
|
||||||
|
|
||||||
<text x="695" y="618" text-anchor="middle" fill="#ef4444" font-size="9">Command allowlist</text>
|
<text x="695" y="618" text-anchor="middle" fill="#ef4444" font-size="9">Command allowlist</text>
|
||||||
<text x="695" y="635" text-anchor="middle" fill="#ef4444" font-size="9">Path jail + traversal block</text>
|
<text x="695" y="635" text-anchor="middle" fill="#ef4444" font-size="9">Path jail + traversal block</text>
|
||||||
<text x="695" y="652" text-anchor="middle" fill="#ef4444" font-size="9">Null byte injection blocked</text>
|
<text x="695" y="652" text-anchor="middle" fill="#ef4444" font-size="9">Browser domain allowlist</text>
|
||||||
<text x="695" y="669" text-anchor="middle" fill="#ef4444" font-size="9">Symlink escape detection</text>
|
<text x="695" y="669" text-anchor="middle" fill="#ef4444" font-size="9">Null byte + Symlink escape block</text>
|
||||||
<text x="695" y="686" text-anchor="middle" fill="#ef4444" font-size="9">14 system dirs + 4 dotfiles blocked</text>
|
<text x="695" y="686" text-anchor="middle" fill="#ef4444" font-size="9">System dirs + Dotfiles blocked</text>
|
||||||
<text x="695" y="706" text-anchor="middle" fill="#ef4444" font-size="9">Default: Supervised + workspace-only</text>
|
<text x="695" y="706" text-anchor="middle" fill="#ef4444" font-size="9">Default: Supervised + workspace-only</text>
|
||||||
<text x="695" y="726" text-anchor="middle" fill="#ef4444" font-size="9">Levels: ReadOnly / Supervised / Full</text>
|
<text x="695" y="726" text-anchor="middle" fill="#ef4444" font-size="9">Levels: ReadOnly / Supervised / Full</text>
|
||||||
|
|
||||||
|
|
@ -260,7 +260,7 @@
|
||||||
|
|
||||||
<!-- ═══════════ BOTTOM: Setup Wizard ═══════════ -->
|
<!-- ═══════════ BOTTOM: Setup Wizard ═══════════ -->
|
||||||
<rect x="20" y="760" width="1060" height="170" rx="10" fill="#1c2333" stroke="#f472b6" stroke-width="1.5" filter="url(#shadow)"/>
|
<rect x="20" y="760" width="1060" height="170" rx="10" fill="#1c2333" stroke="#f472b6" stroke-width="1.5" filter="url(#shadow)"/>
|
||||||
<text x="550" y="785" text-anchor="middle" fill="#f9a8d4" font-size="14" font-weight="700">Setup Wizard -- zeroclaw onboard (--quick for instant setup)</text>
|
<text x="550" y="785" text-anchor="middle" fill="#f9a8d4" font-size="14" font-weight="700">Setup Wizard -- zeroclaw onboard (quick default | --interactive for full wizard)</text>
|
||||||
<text x="550" y="802" text-anchor="middle" fill="#7d8590" font-size="10">7 steps, under 60 seconds | Live connection testing | Secure defaults</text>
|
<text x="550" y="802" text-anchor="middle" fill="#7d8590" font-size="10">7 steps, under 60 seconds | Live connection testing | Secure defaults</text>
|
||||||
|
|
||||||
<!-- Wizard steps: 7 -->
|
<!-- Wizard steps: 7 -->
|
||||||
|
|
@ -308,5 +308,5 @@
|
||||||
<text x="550" y="907" text-anchor="middle" fill="#4ade80" font-size="12" font-weight="600">Ready -- zeroclaw agent</text>
|
<text x="550" y="907" text-anchor="middle" fill="#4ade80" font-size="12" font-weight="600">Ready -- zeroclaw agent</text>
|
||||||
|
|
||||||
<!-- Footer stats -->
|
<!-- Footer stats -->
|
||||||
<text x="550" y="942" text-anchor="middle" fill="#7d8590" font-size="10">~3.4MB binary | <10ms startup | 1,017 tests | 22+ providers | 8 traits | 17,800+ lines of Rust | 0 clippy warnings</text>
|
<text x="550" y="942" text-anchor="middle" fill="#7d8590" font-size="10">~3.4MB binary | <10ms startup | 1,050 tests | 22+ providers | 8 traits | 18,900+ lines of Rust | 0 clippy warnings</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
|
@ -59,7 +59,7 @@ pub async fn run(
|
||||||
} else {
|
} else {
|
||||||
None
|
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 ─────────────────────────────────────────
|
// ── Resolve provider ─────────────────────────────────────────
|
||||||
let provider_name = provider_override
|
let provider_name = provider_override
|
||||||
|
|
@ -82,7 +82,7 @@ pub async fn run(
|
||||||
|
|
||||||
// ── Build system prompt from workspace MD files (OpenClaw framework) ──
|
// ── Build system prompt from workspace MD files (OpenClaw framework) ──
|
||||||
let skills = crate::skills::load_skills(&config.workspace_dir);
|
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"),
|
("shell", "Execute terminal commands"),
|
||||||
("file_read", "Read file contents"),
|
("file_read", "Read file contents"),
|
||||||
("file_write", "Write file contents"),
|
("file_write", "Write file contents"),
|
||||||
|
|
@ -90,6 +90,12 @@ pub async fn run(
|
||||||
("memory_recall", "Search memory"),
|
("memory_recall", "Search memory"),
|
||||||
("memory_forget", "Delete a memory entry"),
|
("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(
|
let system_prompt = crate::channels::build_system_prompt(
|
||||||
&config.workspace_dir,
|
&config.workspace_dir,
|
||||||
model_name,
|
model_name,
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,9 @@ impl DiscordChannel {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a Discord user ID is in the allowlist.
|
/// 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 {
|
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)
|
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -285,10 +283,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_allowlist_allows_everyone() {
|
fn empty_allowlist_denies_everyone() {
|
||||||
let ch = DiscordChannel::new("fake".into(), None, vec![]);
|
let ch = DiscordChannel::new("fake".into(), None, vec![]);
|
||||||
assert!(ch.is_user_allowed("12345"));
|
assert!(!ch.is_user_allowed("12345"));
|
||||||
assert!(ch.is_user_allowed("anyone"));
|
assert!(!ch.is_user_allowed("anyone"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ use crate::memory::{self, Memory};
|
||||||
use crate::providers::{self, Provider};
|
use crate::providers::{self, Provider};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Maximum characters per injected workspace file (matches `OpenClaw` default).
|
/// Maximum characters per injected workspace file (matches `OpenClaw` default).
|
||||||
const BOOTSTRAP_MAX_CHARS: usize = 20_000;
|
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
|
// Handled in main.rs (needs async), this is unreachable
|
||||||
unreachable!("Start is handled in main.rs")
|
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 => {
|
super::ChannelCommands::List => {
|
||||||
println!("Channels:");
|
println!("Channels:");
|
||||||
println!(" ✅ CLI (always available)");
|
println!(" ✅ CLI (always available)");
|
||||||
|
|
@ -195,6 +200,7 @@ pub fn handle_command(command: super::ChannelCommands, config: &Config) -> Resul
|
||||||
println!(" {} {name}", if configured { "✅" } else { "❌" });
|
println!(" {} {name}", if configured { "✅" } else { "❌" });
|
||||||
}
|
}
|
||||||
println!("\nTo start channels: zeroclaw channel start");
|
println!("\nTo start channels: zeroclaw channel start");
|
||||||
|
println!("To check health: zeroclaw channel doctor");
|
||||||
println!("To configure: zeroclaw onboard");
|
println!("To configure: zeroclaw onboard");
|
||||||
Ok(())
|
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<bool, tokio::time::error::Elapsed>,
|
||||||
|
) -> 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<dyn Channel>)> = 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
|
/// Start all configured channels and route messages to the agent
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub async fn start_channels(config: Config) -> Result<()> {
|
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);
|
let skills = crate::skills::load_skills(&workspace);
|
||||||
|
|
||||||
// Collect tool descriptions for the prompt
|
// 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"),
|
("shell", "Execute terminal commands"),
|
||||||
("file_read", "Read file contents"),
|
("file_read", "Read file contents"),
|
||||||
("file_write", "Write 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"),
|
("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);
|
let system_prompt = build_system_prompt(&workspace, &model, &tool_descs, &skills);
|
||||||
|
|
||||||
if !skills.is_empty() {
|
if !skills.is_empty() {
|
||||||
|
|
@ -628,4 +754,27 @@ mod tests {
|
||||||
|
|
||||||
assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,9 @@ impl SlackChannel {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a Slack user ID is in the allowlist.
|
/// 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 {
|
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)
|
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,10 +185,10 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_allowlist_allows_everyone() {
|
fn empty_allowlist_denies_everyone() {
|
||||||
let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]);
|
let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]);
|
||||||
assert!(ch.is_user_allowed("U12345"));
|
assert!(!ch.is_user_allowed("U12345"));
|
||||||
assert!(ch.is_user_allowed("anyone"));
|
assert!(!ch.is_user_allowed("anyone"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
|
||||||
pub use schema::{
|
pub use schema::{
|
||||||
AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, GatewayConfig,
|
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
||||||
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
|
GatewayConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig,
|
||||||
RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, TunnelConfig, WebhookConfig,
|
ObservabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, TunnelConfig,
|
||||||
|
WebhookConfig,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ pub struct Config {
|
||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub secrets: SecretsConfig,
|
pub secrets: SecretsConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub browser: BrowserConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gateway security ─────────────────────────────────────────────
|
// ── 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Memory ───────────────────────────────────────────────────
|
// ── Memory ───────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -455,6 +470,7 @@ impl Default for Config {
|
||||||
gateway: GatewayConfig::default(),
|
gateway: GatewayConfig::default(),
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
|
browser: BrowserConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -596,6 +612,7 @@ mod tests {
|
||||||
gateway: GatewayConfig::default(),
|
gateway: GatewayConfig::default(),
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
|
browser: BrowserConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
|
@ -659,6 +676,7 @@ default_temperature = 0.7
|
||||||
gateway: GatewayConfig::default(),
|
gateway: GatewayConfig::default(),
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
|
browser: BrowserConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.save().unwrap();
|
config.save().unwrap();
|
||||||
|
|
@ -1060,5 +1078,39 @@ default_temperature = 0.7
|
||||||
assert!(!c.composio.enabled);
|
assert!(!c.composio.enabled);
|
||||||
assert!(c.composio.api_key.is_none());
|
assert!(c.composio.api_key.is_none());
|
||||||
assert!(c.secrets.encrypt);
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,84 +69,18 @@ pub struct IntegrationEntry {
|
||||||
/// Handle the `integrations` CLI command
|
/// Handle the `integrations` CLI command
|
||||||
pub fn handle_command(command: super::IntegrationCommands, config: &Config) -> Result<()> {
|
pub fn handle_command(command: super::IntegrationCommands, config: &Config) -> Result<()> {
|
||||||
match command {
|
match command {
|
||||||
super::IntegrationCommands::List { category } => {
|
|
||||||
list_integrations(config, category.as_deref())
|
|
||||||
}
|
|
||||||
super::IntegrationCommands::Info { name } => show_integration_info(config, &name),
|
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 <name>");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_integration_info(config: &Config, name: &str) -> Result<()> {
|
fn show_integration_info(config: &Config, name: &str) -> Result<()> {
|
||||||
let entries = registry::all_integrations();
|
let entries = registry::all_integrations();
|
||||||
let name_lower = name.to_lowercase();
|
let name_lower = name.to_lowercase();
|
||||||
|
|
||||||
let Some(entry) = entries.iter().find(|e| e.name.to_lowercase() == name_lower) else {
|
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);
|
let status = (entry.status_fn)(config);
|
||||||
|
|
|
||||||
60
src/main.rs
60
src/main.rs
|
|
@ -47,15 +47,15 @@ struct Cli {
|
||||||
enum Commands {
|
enum Commands {
|
||||||
/// Initialize your workspace and configuration
|
/// Initialize your workspace and configuration
|
||||||
Onboard {
|
Onboard {
|
||||||
/// Skip interactive prompts — generate config with sensible defaults
|
/// Run the full interactive wizard (default is quick setup)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
quick: bool,
|
interactive: bool,
|
||||||
|
|
||||||
/// API key (used with --quick)
|
/// API key (used in quick mode, ignored with --interactive)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
|
|
||||||
/// Provider name (used with --quick, default: openrouter)
|
/// Provider name (used in quick mode, default: openrouter)
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
provider: Option<String>,
|
provider: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
@ -90,12 +90,8 @@ enum Commands {
|
||||||
host: String,
|
host: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Show system status
|
/// Show system status (full details)
|
||||||
Status {
|
Status,
|
||||||
/// Show detailed status
|
|
||||||
#[arg(short, long)]
|
|
||||||
verbose: bool,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Configure and manage scheduled tasks
|
/// Configure and manage scheduled tasks
|
||||||
Cron {
|
Cron {
|
||||||
|
|
@ -109,12 +105,6 @@ enum Commands {
|
||||||
channel_command: ChannelCommands,
|
channel_command: ChannelCommands,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Tool utilities
|
|
||||||
Tools {
|
|
||||||
#[command(subcommand)]
|
|
||||||
tool_command: ToolCommands,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Browse 50+ integrations
|
/// Browse 50+ integrations
|
||||||
Integrations {
|
Integrations {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|
@ -152,6 +142,8 @@ enum ChannelCommands {
|
||||||
List,
|
List,
|
||||||
/// Start all configured channels (Telegram, Discord, Slack)
|
/// Start all configured channels (Telegram, Discord, Slack)
|
||||||
Start,
|
Start,
|
||||||
|
/// Run health checks for configured channels
|
||||||
|
Doctor,
|
||||||
/// Add a new channel
|
/// Add a new channel
|
||||||
Add {
|
Add {
|
||||||
/// Channel type
|
/// Channel type
|
||||||
|
|
@ -184,12 +176,6 @@ enum SkillCommands {
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
enum IntegrationCommands {
|
enum IntegrationCommands {
|
||||||
/// List all integrations and their status
|
|
||||||
List {
|
|
||||||
/// Filter by category (e.g. "chat", "ai", "productivity")
|
|
||||||
#[arg(short, long)]
|
|
||||||
category: Option<String>,
|
|
||||||
},
|
|
||||||
/// Show details about a specific integration
|
/// Show details about a specific integration
|
||||||
Info {
|
Info {
|
||||||
/// Integration name
|
/// 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]
|
#[tokio::main]
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
|
@ -222,17 +195,17 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
|
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 {
|
if let Commands::Onboard {
|
||||||
quick,
|
interactive,
|
||||||
api_key,
|
api_key,
|
||||||
provider,
|
provider,
|
||||||
} = &cli.command
|
} = &cli.command
|
||||||
{
|
{
|
||||||
let config = if *quick {
|
let config = if *interactive {
|
||||||
onboard::run_quick_setup(api_key.as_deref(), provider.as_deref())?
|
|
||||||
} else {
|
|
||||||
onboard::run_wizard()?
|
onboard::run_wizard()?
|
||||||
|
} else {
|
||||||
|
onboard::run_quick_setup(api_key.as_deref(), provider.as_deref())?
|
||||||
};
|
};
|
||||||
// Auto-start channels if user said yes during wizard
|
// Auto-start channels if user said yes during wizard
|
||||||
if std::env::var("ZEROCLAW_AUTOSTART_CHANNELS").as_deref() == Ok("1") {
|
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
|
gateway::run_gateway(&host, port, config).await
|
||||||
}
|
}
|
||||||
|
|
||||||
Commands::Status { verbose } => {
|
Commands::Status => {
|
||||||
println!("🦀 ZeroClaw Status");
|
println!("🦀 ZeroClaw Status");
|
||||||
println!();
|
println!();
|
||||||
println!("Version: {}", env!("CARGO_PKG_VERSION"));
|
println!("Version: {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
@ -295,7 +268,6 @@ async fn main() -> Result<()> {
|
||||||
if config.memory.auto_save { "on" } else { "off" }
|
if config.memory.auto_save { "on" } else { "off" }
|
||||||
);
|
);
|
||||||
|
|
||||||
if verbose {
|
|
||||||
println!();
|
println!();
|
||||||
println!("Security:");
|
println!("Security:");
|
||||||
println!(" Workspace only: {}", config.autonomy.workspace_only);
|
println!(" Workspace only: {}", config.autonomy.workspace_only);
|
||||||
|
|
@ -329,7 +301,6 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -338,11 +309,10 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
Commands::Channel { channel_command } => match channel_command {
|
Commands::Channel { channel_command } => match channel_command {
|
||||||
ChannelCommands::Start => channels::start_channels(config).await,
|
ChannelCommands::Start => channels::start_channels(config).await,
|
||||||
|
ChannelCommands::Doctor => channels::doctor_channels(config).await,
|
||||||
other => channels::handle_command(other, &config),
|
other => channels::handle_command(other, &config),
|
||||||
},
|
},
|
||||||
|
|
||||||
Commands::Tools { tool_command } => tools::handle_command(tool_command, config).await,
|
|
||||||
|
|
||||||
Commands::Integrations {
|
Commands::Integrations {
|
||||||
integration_command,
|
integration_command,
|
||||||
} => integrations::handle_command(integration_command, &config),
|
} => integrations::handle_command(integration_command, &config),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, HeartbeatConfig,
|
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
||||||
IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig,
|
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
|
||||||
SlackConfig, TelegramConfig, WebhookConfig,
|
RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig,
|
||||||
};
|
};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use console::style;
|
use console::style;
|
||||||
|
|
@ -98,6 +98,7 @@ pub fn run_wizard() -> Result<Config> {
|
||||||
gateway: crate::config::GatewayConfig::default(),
|
gateway: crate::config::GatewayConfig::default(),
|
||||||
composio: composio_config,
|
composio: composio_config,
|
||||||
secrets: secrets_config,
|
secrets: secrets_config,
|
||||||
|
browser: BrowserConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -151,7 +152,8 @@ pub fn run_wizard() -> Result<Config> {
|
||||||
// ── Quick setup (zero prompts) ───────────────────────────────────
|
// ── Quick setup (zero prompts) ───────────────────────────────────
|
||||||
|
|
||||||
/// Non-interactive setup: generates a sensible default config instantly.
|
/// 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)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn run_quick_setup(api_key: Option<&str>, provider: Option<&str>) -> Result<Config> {
|
pub fn run_quick_setup(api_key: Option<&str>, provider: Option<&str>) -> Result<Config> {
|
||||||
println!("{}", style(BANNER).cyan().bold());
|
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(),
|
gateway: crate::config::GatewayConfig::default(),
|
||||||
composio: ComposioConfig::default(),
|
composio: ComposioConfig::default(),
|
||||||
secrets: SecretsConfig::default(),
|
secrets: SecretsConfig::default(),
|
||||||
|
browser: BrowserConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.save()?;
|
config.save()?;
|
||||||
|
|
@ -275,7 +278,7 @@ pub fn run_quick_setup(api_key: Option<&str>, provider: Option<&str>) -> Result<
|
||||||
} else {
|
} else {
|
||||||
println!(" 1. Chat: zeroclaw agent -m \"Hello!\"");
|
println!(" 1. Chat: zeroclaw agent -m \"Hello!\"");
|
||||||
println!(" 2. Gateway: zeroclaw gateway");
|
println!(" 2. Gateway: zeroclaw gateway");
|
||||||
println!(" 3. Status: zeroclaw status --verbose");
|
println!(" 3. Status: zeroclaw status");
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
|
|
@ -1054,10 +1057,34 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
.allow_empty(true)
|
.allow_empty(true)
|
||||||
.interact_text()?;
|
.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 {
|
config.discord = Some(DiscordConfig {
|
||||||
bot_token: token,
|
bot_token: token,
|
||||||
guild_id: if guild.is_empty() { None } else { Some(guild) },
|
guild_id: if guild.is_empty() { None } else { Some(guild) },
|
||||||
allowed_users: vec![],
|
allowed_users,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
2 => {
|
2 => {
|
||||||
|
|
@ -1133,6 +1160,30 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
.allow_empty(true)
|
.allow_empty(true)
|
||||||
.interact_text()?;
|
.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 {
|
config.slack = Some(SlackConfig {
|
||||||
bot_token: token,
|
bot_token: token,
|
||||||
app_token: if app_token.is_empty() {
|
app_token: if app_token.is_empty() {
|
||||||
|
|
@ -1145,7 +1196,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
} else {
|
} else {
|
||||||
Some(channel)
|
Some(channel)
|
||||||
},
|
},
|
||||||
allowed_users: vec![],
|
allowed_users,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
3 => {
|
3 => {
|
||||||
|
|
@ -1936,7 +1987,7 @@ fn print_summary(config: &Config) {
|
||||||
" {} Check full status:",
|
" {} Check full status:",
|
||||||
style(format!("{step}.")).cyan().bold()
|
style(format!("{step}.")).cyan().bold()
|
||||||
);
|
);
|
||||||
println!(" {}", style("zeroclaw status --verbose").yellow());
|
println!(" {}", style("zeroclaw status").yellow());
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => anyhow::bail!(
|
_ => 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."
|
Tip: Use \"custom:https://your-api.com\" for any OpenAI-compatible endpoint."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
465
src/tools/browser_open.rs
Normal file
465
src/tools/browser_open.rs
Normal file
|
|
@ -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<SecurityPolicy>,
|
||||||
|
allowed_domains: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserOpenTool {
|
||||||
|
pub fn new(security: Arc<SecurityPolicy>, allowed_domains: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
security,
|
||||||
|
allowed_domains: normalize_allowed_domains(allowed_domains),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_url(&self, raw_url: &str) -> anyhow::Result<String> {
|
||||||
|
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<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_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<String>) -> Vec<String> {
|
||||||
|
let mut normalized = domains
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|d| normalize_domain(&d))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
normalized.sort_unstable();
|
||||||
|
normalized.dedup();
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_domain(raw: &str) -> Option<String> {
|
||||||
|
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<String> {
|
||||||
|
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::<u8>().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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/tools/mod.rs
104
src/tools/mod.rs
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod browser_open;
|
||||||
pub mod composio;
|
pub mod composio;
|
||||||
pub mod file_read;
|
pub mod file_read;
|
||||||
pub mod file_write;
|
pub mod file_write;
|
||||||
|
|
@ -7,6 +8,7 @@ pub mod memory_store;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
|
||||||
|
pub use browser_open::BrowserOpenTool;
|
||||||
pub use composio::ComposioTool;
|
pub use composio::ComposioTool;
|
||||||
pub use file_read::FileReadTool;
|
pub use file_read::FileReadTool;
|
||||||
pub use file_write::FileWriteTool;
|
pub use file_write::FileWriteTool;
|
||||||
|
|
@ -18,10 +20,8 @@ pub use traits::Tool;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use traits::{ToolResult, ToolSpec};
|
pub use traits::{ToolResult, ToolSpec};
|
||||||
|
|
||||||
use crate::config::Config;
|
|
||||||
use crate::memory::Memory;
|
use crate::memory::Memory;
|
||||||
use crate::security::SecurityPolicy;
|
use crate::security::SecurityPolicy;
|
||||||
use anyhow::Result;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Create the default tool registry
|
/// Create the default tool registry
|
||||||
|
|
@ -35,19 +35,27 @@ pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
|
||||||
|
|
||||||
/// Create full tool registry including memory tools and optional Composio
|
/// Create full tool registry including memory tools and optional Composio
|
||||||
pub fn all_tools(
|
pub fn all_tools(
|
||||||
security: Arc<SecurityPolicy>,
|
security: &Arc<SecurityPolicy>,
|
||||||
memory: Arc<dyn Memory>,
|
memory: Arc<dyn Memory>,
|
||||||
composio_key: Option<&str>,
|
composio_key: Option<&str>,
|
||||||
|
browser_config: &crate::config::BrowserConfig,
|
||||||
) -> Vec<Box<dyn Tool>> {
|
) -> Vec<Box<dyn Tool>> {
|
||||||
let mut tools: Vec<Box<dyn Tool>> = vec![
|
let mut tools: Vec<Box<dyn Tool>> = vec![
|
||||||
Box::new(ShellTool::new(security.clone())),
|
Box::new(ShellTool::new(security.clone())),
|
||||||
Box::new(FileReadTool::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(MemoryStoreTool::new(memory.clone())),
|
||||||
Box::new(MemoryRecallTool::new(memory.clone())),
|
Box::new(MemoryRecallTool::new(memory.clone())),
|
||||||
Box::new(MemoryForgetTool::new(memory)),
|
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 let Some(key) = composio_key {
|
||||||
if !key.is_empty() {
|
if !key.is_empty() {
|
||||||
tools.push(Box::new(ComposioTool::new(key)));
|
tools.push(Box::new(ComposioTool::new(key)));
|
||||||
|
|
@ -57,53 +65,11 @@ pub fn all_tools(
|
||||||
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<dyn Memory> = 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::{BrowserConfig, MemoryConfig};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_tools_has_three() {
|
fn default_tools_has_three() {
|
||||||
|
|
@ -112,6 +78,48 @@ mod tests {
|
||||||
assert_eq!(tools.len(), 3);
|
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<dyn Memory> =
|
||||||
|
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<dyn Memory> =
|
||||||
|
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]
|
#[test]
|
||||||
fn default_tools_names() {
|
fn default_tools_names() {
|
||||||
let security = Arc::new(SecurityPolicy::default());
|
let security = Arc::new(SecurityPolicy::default());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue