* test: add comprehensive recovery tests for agent loop Add recovery test coverage for all edge cases and failure scenarios in the agentic loop, addressing the missing test coverage for recovery use cases. Tool Call Parsing Edge Cases: - Empty tool_result tags - Empty tool_calls arrays - Whitespace-only tool names - Empty string arguments History Management: - Trimming without system prompt - Role ordering consistency after trim - Only system prompt edge case Arguments Parsing: - Invalid JSON string fallback - None arguments handling - Null value handling JSON Extraction: - Empty input handling - Whitespace only input - Multiple JSON objects - JSON arrays Tool Call Value Parsing: - Missing name field - Non-OpenAI format - Empty tool_calls array - Missing tool_calls field fallback - Top-level array format Constants Validation: - MAX_TOOL_ITERATIONS bounds (prevent runaway loops) - MAX_HISTORY_MESSAGES bounds (prevent memory bloat) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(security): Add Phase 1 security features - sandboxing, resource limits, audit logging Phase 1 security enhancements with zero impact on the quick setup wizard: - ✅ Pluggable sandbox trait system (traits.rs) - ✅ Landlock sandbox support (Linux kernel 5.13+) - ✅ Firejail sandbox support (Linux user-space) - ✅ Bubblewrap sandbox support (Linux/macOS user namespaces) - ✅ Docker sandbox support (container isolation) - ✅ No-op fallback (application-layer security only) - ✅ Auto-detection logic (detect.rs) - ✅ Audit logging with HMAC signing support (audit.rs) - ✅ SecurityConfig schema (SandboxConfig, ResourceLimitsConfig, AuditConfig) - ✅ Feature-gated implementation (sandbox-landlock, sandbox-bubblewrap) - ✅ 1,265 tests passing Key design principles: - Silent auto-detection: no new prompts in wizard - Graceful degradation: works on all platforms - Feature flags: zero overhead when disabled - Pluggable architecture: swap sandbox backends via config - Backward compatible: existing configs work unchanged Config usage: ```toml [security.sandbox] enabled = false # Explicitly disable backend = "auto" # auto, landlock, firejail, bubblewrap, docker, none [security.resources] max_memory_mb = 512 max_cpu_time_seconds = 60 [security.audit] enabled = true log_path = "audit.log" sign_events = false ``` Security documentation: - docs/sandboxing.md: Sandbox implementation strategies - docs/resource-limits.md: Resource limit approaches - docs/audit-logging.md: Audit logging specification - docs/security-roadmap.md: 3-phase implementation plan - docs/frictionless-security.md: Zero-impact wizard design - docs/agnostic-security.md: Platform/hardware agnostic approach Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
467 lines
13 KiB
Rust
467 lines
13 KiB
Rust
#![warn(clippy::all, clippy::pedantic)]
|
|
#![allow(
|
|
clippy::assigning_clones,
|
|
clippy::bool_to_int_with_if,
|
|
clippy::case_sensitive_file_extension_comparisons,
|
|
clippy::cast_possible_wrap,
|
|
clippy::doc_markdown,
|
|
clippy::field_reassign_with_default,
|
|
clippy::float_cmp,
|
|
clippy::implicit_clone,
|
|
clippy::items_after_statements,
|
|
clippy::map_unwrap_or,
|
|
clippy::manual_let_else,
|
|
clippy::missing_errors_doc,
|
|
clippy::missing_panics_doc,
|
|
clippy::module_name_repetitions,
|
|
clippy::needless_pass_by_value,
|
|
clippy::needless_raw_string_hashes,
|
|
clippy::redundant_closure_for_method_calls,
|
|
clippy::similar_names,
|
|
clippy::single_match_else,
|
|
clippy::struct_field_names,
|
|
clippy::too_many_lines,
|
|
clippy::uninlined_format_args,
|
|
clippy::unused_self,
|
|
clippy::cast_precision_loss,
|
|
clippy::unnecessary_cast,
|
|
clippy::unnecessary_lazy_evaluations,
|
|
clippy::unnecessary_literal_bound,
|
|
clippy::unnecessary_map_or,
|
|
clippy::unnecessary_wraps,
|
|
dead_code
|
|
)]
|
|
|
|
use anyhow::{bail, Result};
|
|
use clap::{Parser, Subcommand};
|
|
use tracing::{info, Level};
|
|
use tracing_subscriber::FmtSubscriber;
|
|
|
|
mod agent;
|
|
mod channels;
|
|
mod config;
|
|
mod cron;
|
|
mod daemon;
|
|
mod doctor;
|
|
mod gateway;
|
|
mod hardware;
|
|
mod health;
|
|
mod heartbeat;
|
|
mod identity;
|
|
mod integrations;
|
|
mod memory;
|
|
mod migration;
|
|
mod observability;
|
|
mod onboard;
|
|
mod providers;
|
|
mod runtime;
|
|
mod security;
|
|
mod service;
|
|
mod skillforge;
|
|
mod skills;
|
|
mod tools;
|
|
mod tunnel;
|
|
mod util;
|
|
|
|
use config::Config;
|
|
|
|
/// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust.
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "zeroclaw")]
|
|
#[command(author = "theonlyhennygod")]
|
|
#[command(version = "0.1.0")]
|
|
#[command(about = "The fastest, smallest AI assistant.", long_about = None)]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Commands,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum ServiceCommands {
|
|
/// Install daemon service unit for auto-start and restart
|
|
Install,
|
|
/// Start daemon service
|
|
Start,
|
|
/// Stop daemon service
|
|
Stop,
|
|
/// Check daemon service status
|
|
Status,
|
|
/// Uninstall daemon service unit
|
|
Uninstall,
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum Commands {
|
|
/// Initialize your workspace and configuration
|
|
Onboard {
|
|
/// Run the full interactive wizard (default is quick setup)
|
|
#[arg(long)]
|
|
interactive: bool,
|
|
|
|
/// Reconfigure channels only (fast repair flow)
|
|
#[arg(long)]
|
|
channels_only: bool,
|
|
|
|
/// API key (used in quick mode, ignored with --interactive)
|
|
#[arg(long)]
|
|
api_key: Option<String>,
|
|
|
|
/// Provider name (used in quick mode, default: openrouter)
|
|
#[arg(long)]
|
|
provider: Option<String>,
|
|
|
|
/// Memory backend (sqlite, markdown, none) - used in quick mode, default: sqlite
|
|
#[arg(long)]
|
|
memory: Option<String>,
|
|
},
|
|
|
|
/// Start the AI agent loop
|
|
Agent {
|
|
/// Single message mode (don't enter interactive mode)
|
|
#[arg(short, long)]
|
|
message: Option<String>,
|
|
|
|
/// Provider to use (openrouter, anthropic, openai)
|
|
#[arg(short, long)]
|
|
provider: Option<String>,
|
|
|
|
/// Model to use
|
|
#[arg(long)]
|
|
model: Option<String>,
|
|
|
|
/// Temperature (0.0 - 2.0)
|
|
#[arg(short, long, default_value = "0.7")]
|
|
temperature: f64,
|
|
},
|
|
|
|
/// Start the gateway server (webhooks, websockets)
|
|
Gateway {
|
|
/// Port to listen on (use 0 for random available port)
|
|
#[arg(short, long, default_value = "8080")]
|
|
port: u16,
|
|
|
|
/// Host to bind to
|
|
#[arg(long, default_value = "127.0.0.1")]
|
|
host: String,
|
|
},
|
|
|
|
/// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler)
|
|
Daemon {
|
|
/// Port to listen on (use 0 for random available port)
|
|
#[arg(short, long, default_value = "8080")]
|
|
port: u16,
|
|
|
|
/// Host to bind to
|
|
#[arg(long, default_value = "127.0.0.1")]
|
|
host: String,
|
|
},
|
|
|
|
/// Manage OS service lifecycle (launchd/systemd user service)
|
|
Service {
|
|
#[command(subcommand)]
|
|
service_command: ServiceCommands,
|
|
},
|
|
|
|
/// Run diagnostics for daemon/scheduler/channel freshness
|
|
Doctor,
|
|
|
|
/// Show system status (full details)
|
|
Status,
|
|
|
|
/// Configure and manage scheduled tasks
|
|
Cron {
|
|
#[command(subcommand)]
|
|
cron_command: CronCommands,
|
|
},
|
|
|
|
/// Manage channels (telegram, discord, slack)
|
|
Channel {
|
|
#[command(subcommand)]
|
|
channel_command: ChannelCommands,
|
|
},
|
|
|
|
/// Browse 50+ integrations
|
|
Integrations {
|
|
#[command(subcommand)]
|
|
integration_command: IntegrationCommands,
|
|
},
|
|
|
|
/// Manage skills (user-defined capabilities)
|
|
Skills {
|
|
#[command(subcommand)]
|
|
skill_command: SkillCommands,
|
|
},
|
|
|
|
/// Migrate data from other agent runtimes
|
|
Migrate {
|
|
#[command(subcommand)]
|
|
migrate_command: MigrateCommands,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum MigrateCommands {
|
|
/// Import memory from an `OpenClaw` workspace into this `ZeroClaw` workspace
|
|
Openclaw {
|
|
/// Optional path to `OpenClaw` workspace (defaults to ~/.openclaw/workspace)
|
|
#[arg(long)]
|
|
source: Option<std::path::PathBuf>,
|
|
|
|
/// Validate and preview migration without writing any data
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum CronCommands {
|
|
/// List all scheduled tasks
|
|
List,
|
|
/// Add a new scheduled task
|
|
Add {
|
|
/// Cron expression
|
|
expression: String,
|
|
/// Command to run
|
|
command: String,
|
|
},
|
|
/// Remove a scheduled task
|
|
Remove {
|
|
/// Task ID
|
|
id: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum ChannelCommands {
|
|
/// List configured channels
|
|
List,
|
|
/// Start all configured channels (Telegram, Discord, Slack)
|
|
Start,
|
|
/// Run health checks for configured channels
|
|
Doctor,
|
|
/// Add a new channel
|
|
Add {
|
|
/// Channel type
|
|
channel_type: String,
|
|
/// Configuration JSON
|
|
config: String,
|
|
},
|
|
/// Remove a channel
|
|
Remove {
|
|
/// Channel name
|
|
name: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum SkillCommands {
|
|
/// List installed skills
|
|
List,
|
|
/// Install a skill from a GitHub URL or local path
|
|
Install {
|
|
/// GitHub URL or local path
|
|
source: String,
|
|
},
|
|
/// Remove an installed skill
|
|
Remove {
|
|
/// Skill name
|
|
name: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand, Debug)]
|
|
enum IntegrationCommands {
|
|
/// Show details about a specific integration
|
|
Info {
|
|
/// Integration name
|
|
name: String,
|
|
},
|
|
}
|
|
|
|
#[tokio::main]
|
|
#[allow(clippy::too_many_lines)]
|
|
async fn main() -> Result<()> {
|
|
// Install default crypto provider for Rustls TLS.
|
|
// This prevents the error: "could not automatically determine the process-level CryptoProvider"
|
|
// when both aws-lc-rs and ring features are available (or neither is explicitly selected).
|
|
if let Err(e) = rustls::crypto::ring::default_provider().install_default() {
|
|
eprintln!("Warning: Failed to install default crypto provider: {e:?}");
|
|
}
|
|
|
|
let cli = Cli::parse();
|
|
|
|
// Initialize logging
|
|
let subscriber = FmtSubscriber::builder()
|
|
.with_max_level(Level::INFO)
|
|
.finish();
|
|
|
|
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
|
|
|
|
// Onboard runs quick setup by default, or the interactive wizard with --interactive
|
|
if let Commands::Onboard {
|
|
interactive,
|
|
channels_only,
|
|
api_key,
|
|
provider,
|
|
memory,
|
|
} = &cli.command
|
|
{
|
|
if *interactive && *channels_only {
|
|
bail!("Use either --interactive or --channels-only, not both");
|
|
}
|
|
if *channels_only && (api_key.is_some() || provider.is_some() || memory.is_some()) {
|
|
bail!("--channels-only does not accept --api-key, --provider, or --memory");
|
|
}
|
|
|
|
let config = if *channels_only {
|
|
onboard::run_channels_repair_wizard()?
|
|
} else if *interactive {
|
|
onboard::run_wizard()?
|
|
} else {
|
|
onboard::run_quick_setup(api_key.as_deref(), provider.as_deref(), memory.as_deref())?
|
|
};
|
|
// Auto-start channels if user said yes during wizard
|
|
if std::env::var("ZEROCLAW_AUTOSTART_CHANNELS").as_deref() == Ok("1") {
|
|
channels::start_channels(config).await?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
// All other commands need config loaded first
|
|
let mut config = Config::load_or_init()?;
|
|
config.apply_env_overrides();
|
|
|
|
match cli.command {
|
|
Commands::Onboard { .. } => unreachable!(),
|
|
|
|
Commands::Agent {
|
|
message,
|
|
provider,
|
|
model,
|
|
temperature,
|
|
} => agent::run(config, message, provider, model, temperature).await,
|
|
|
|
Commands::Gateway { port, host } => {
|
|
if port == 0 {
|
|
info!("🚀 Starting ZeroClaw Gateway on {host} (random port)");
|
|
} else {
|
|
info!("🚀 Starting ZeroClaw Gateway on {host}:{port}");
|
|
}
|
|
gateway::run_gateway(&host, port, config).await
|
|
}
|
|
|
|
Commands::Daemon { port, host } => {
|
|
if port == 0 {
|
|
info!("🧠 Starting ZeroClaw Daemon on {host} (random port)");
|
|
} else {
|
|
info!("🧠 Starting ZeroClaw Daemon on {host}:{port}");
|
|
}
|
|
daemon::run(config, host, port).await
|
|
}
|
|
|
|
Commands::Status => {
|
|
println!("🦀 ZeroClaw Status");
|
|
println!();
|
|
println!("Version: {}", env!("CARGO_PKG_VERSION"));
|
|
println!("Workspace: {}", config.workspace_dir.display());
|
|
println!("Config: {}", config.config_path.display());
|
|
println!();
|
|
println!(
|
|
"🤖 Provider: {}",
|
|
config.default_provider.as_deref().unwrap_or("openrouter")
|
|
);
|
|
println!(
|
|
" Model: {}",
|
|
config.default_model.as_deref().unwrap_or("(default)")
|
|
);
|
|
println!("📊 Observability: {}", config.observability.backend);
|
|
println!("🛡️ Autonomy: {:?}", config.autonomy.level);
|
|
println!("⚙️ Runtime: {}", config.runtime.kind);
|
|
println!(
|
|
"💓 Heartbeat: {}",
|
|
if config.heartbeat.enabled {
|
|
format!("every {}min", config.heartbeat.interval_minutes)
|
|
} else {
|
|
"disabled".into()
|
|
}
|
|
);
|
|
println!(
|
|
"🧠 Memory: {} (auto-save: {})",
|
|
config.memory.backend,
|
|
if config.memory.auto_save { "on" } else { "off" }
|
|
);
|
|
|
|
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!(
|
|
" {name:9} {}",
|
|
if configured {
|
|
"✅ configured"
|
|
} else {
|
|
"❌ not configured"
|
|
}
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
Commands::Cron { cron_command } => cron::handle_command(cron_command, &config),
|
|
|
|
Commands::Service { service_command } => service::handle_command(&service_command, &config),
|
|
|
|
Commands::Doctor => doctor::run(&config),
|
|
|
|
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::Integrations {
|
|
integration_command,
|
|
} => integrations::handle_command(integration_command, &config),
|
|
|
|
Commands::Skills { skill_command } => {
|
|
skills::handle_command(skill_command, &config.workspace_dir)
|
|
}
|
|
|
|
Commands::Migrate { migrate_command } => {
|
|
migration::handle_command(migrate_command, &config).await
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use clap::CommandFactory;
|
|
|
|
#[test]
|
|
fn cli_definition_has_no_flag_conflicts() {
|
|
Cli::command().debug_assert();
|
|
}
|
|
}
|