use crate::security::AutonomyLevel; use anyhow::{Context, Result}; use directories::UserDirs; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; // ── Top-level config ────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { /// Workspace directory - computed from home, not serialized #[serde(skip)] pub workspace_dir: PathBuf, /// Path to config.toml - computed from home, not serialized #[serde(skip)] pub config_path: PathBuf, pub api_key: Option, pub default_provider: Option, pub default_model: Option, pub default_temperature: f64, #[serde(default)] pub observability: ObservabilityConfig, #[serde(default)] pub autonomy: AutonomyConfig, #[serde(default)] pub runtime: RuntimeConfig, #[serde(default)] pub reliability: ReliabilityConfig, /// Model routing rules — route `hint:` to specific provider+model combos. #[serde(default)] pub model_routes: Vec, #[serde(default)] pub heartbeat: HeartbeatConfig, #[serde(default)] pub channels_config: ChannelsConfig, #[serde(default)] pub memory: MemoryConfig, #[serde(default)] pub tunnel: TunnelConfig, #[serde(default)] pub gateway: GatewayConfig, #[serde(default)] pub composio: ComposioConfig, #[serde(default)] pub secrets: SecretsConfig, #[serde(default)] pub browser: BrowserConfig, #[serde(default)] pub http_request: HttpRequestConfig, #[serde(default)] pub identity: IdentityConfig, /// Hardware Abstraction Layer (HAL) configuration. /// Controls how ZeroClaw interfaces with physical hardware /// (GPIO, serial, debug probes). #[serde(default)] pub hardware: crate::hardware::HardwareConfig, /// Named delegate agents for agent-to-agent handoff. /// /// ```toml /// [agents.researcher] /// provider = "gemini" /// model = "gemini-2.0-flash" /// system_prompt = "You are a research assistant..." /// /// [agents.coder] /// provider = "openrouter" /// model = "anthropic/claude-sonnet-4-20250514" /// system_prompt = "You are a coding assistant..." /// ``` #[serde(default)] pub agents: HashMap, /// Security configuration (sandboxing, resource limits, audit logging) #[serde(default)] pub security: SecurityConfig, } // ── Identity (AIEOS / OpenClaw format) ────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdentityConfig { /// Identity format: "openclaw" (default) or "aieos" #[serde(default = "default_identity_format")] pub format: String, /// Path to AIEOS JSON file (relative to workspace) #[serde(default)] pub aieos_path: Option, /// Inline AIEOS JSON (alternative to file path) #[serde(default)] pub aieos_inline: Option, } fn default_identity_format() -> String { "openclaw".into() } impl Default for IdentityConfig { fn default() -> Self { Self { format: default_identity_format(), aieos_path: None, aieos_inline: None, } } } // ── Agent delegation ───────────────────────────────────────────── /// Configuration for a named delegate agent that can be invoked via the /// `delegate` tool. Each agent uses its own provider/model combination /// and system prompt, enabling multi-agent workflows with specialization. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DelegateAgentConfig { /// Provider name (e.g. "gemini", "openrouter", "ollama") pub provider: String, /// Model identifier for the provider pub model: String, /// System prompt defining the agent's role and capabilities #[serde(default)] pub system_prompt: Option, /// Optional API key override (uses default if not set). /// Stored encrypted when `secrets.encrypt = true`. #[serde(default)] pub api_key: Option, /// Temperature override (uses 0.7 if not set) #[serde(default)] pub temperature: Option, /// Maximum delegation depth to prevent infinite recursion (default: 3) #[serde(default = "default_max_delegation_depth")] pub max_depth: u32, } fn default_max_delegation_depth() -> u32 { 3 } // ── Gateway security ───────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GatewayConfig { /// Gateway port (default: 8080) #[serde(default = "default_gateway_port")] pub port: u16, /// Gateway host (default: 127.0.0.1) #[serde(default = "default_gateway_host")] pub host: String, /// Require pairing before accepting requests (default: true) #[serde(default = "default_true")] pub require_pairing: bool, /// Allow binding to non-localhost without a tunnel (default: false) #[serde(default)] pub allow_public_bind: bool, /// Paired bearer tokens (managed automatically, not user-edited) #[serde(default)] pub paired_tokens: Vec, /// Max `/pair` requests per minute per client key. #[serde(default = "default_pair_rate_limit")] pub pair_rate_limit_per_minute: u32, /// Max `/webhook` requests per minute per client key. #[serde(default = "default_webhook_rate_limit")] pub webhook_rate_limit_per_minute: u32, /// TTL for webhook idempotency keys. #[serde(default = "default_idempotency_ttl_secs")] pub idempotency_ttl_secs: u64, } fn default_gateway_port() -> u16 { 3000 } fn default_gateway_host() -> String { "127.0.0.1".into() } fn default_pair_rate_limit() -> u32 { 10 } fn default_webhook_rate_limit() -> u32 { 60 } fn default_idempotency_ttl_secs() -> u64 { 300 } fn default_true() -> bool { true } impl Default for GatewayConfig { fn default() -> Self { Self { port: default_gateway_port(), host: default_gateway_host(), require_pairing: true, allow_public_bind: false, paired_tokens: Vec::new(), pair_rate_limit_per_minute: default_pair_rate_limit(), webhook_rate_limit_per_minute: default_webhook_rate_limit(), idempotency_ttl_secs: default_idempotency_ttl_secs(), } } } // ── Composio (managed tool surface) ───────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ComposioConfig { /// Enable Composio integration for 1000+ OAuth tools #[serde(default)] pub enabled: bool, /// Composio API key (stored encrypted when secrets.encrypt = true) #[serde(default)] pub api_key: Option, /// Default entity ID for multi-user setups #[serde(default = "default_entity_id")] pub entity_id: String, } fn default_entity_id() -> String { "default".into() } impl Default for ComposioConfig { fn default() -> Self { Self { enabled: false, api_key: None, entity_id: default_entity_id(), } } } // ── Secrets (encrypted credential store) ──────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecretsConfig { /// Enable encryption for API keys and tokens in config.toml #[serde(default = "default_true")] pub encrypt: bool, } impl Default for SecretsConfig { fn default() -> Self { Self { encrypt: true } } } // ── Browser (friendly-service browsing only) ─────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] 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, /// Browser session name (for agent-browser automation) #[serde(default)] pub session_name: Option, /// Browser automation backend: "agent_browser" | "rust_native" | "auto" #[serde(default = "default_browser_backend")] pub backend: String, /// Headless mode for rust-native backend #[serde(default = "default_true")] pub native_headless: bool, /// WebDriver endpoint URL for rust-native backend (e.g. http://127.0.0.1:9515) #[serde(default = "default_browser_webdriver_url")] pub native_webdriver_url: String, /// Optional Chrome/Chromium executable path for rust-native backend #[serde(default)] pub native_chrome_path: Option, } fn default_browser_backend() -> String { "agent_browser".into() } fn default_browser_webdriver_url() -> String { "http://127.0.0.1:9515".into() } impl Default for BrowserConfig { fn default() -> Self { Self { enabled: false, allowed_domains: Vec::new(), session_name: None, backend: default_browser_backend(), native_headless: default_true(), native_webdriver_url: default_browser_webdriver_url(), native_chrome_path: None, } } } // ── HTTP request tool ─────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HttpRequestConfig { /// Enable `http_request` tool for API interactions #[serde(default)] pub enabled: bool, /// Allowed domains for HTTP requests (exact or subdomain match) #[serde(default)] pub allowed_domains: Vec, /// Maximum response size in bytes (default: 1MB) #[serde(default = "default_http_max_response_size")] pub max_response_size: usize, /// Request timeout in seconds (default: 30) #[serde(default = "default_http_timeout_secs")] pub timeout_secs: u64, } fn default_http_max_response_size() -> usize { 1_000_000 // 1MB } fn default_http_timeout_secs() -> u64 { 30 } // ── Memory ─────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryConfig { /// "sqlite" | "markdown" | "none" pub backend: String, /// Auto-save conversation context to memory pub auto_save: bool, /// Run memory/session hygiene (archiving + retention cleanup) #[serde(default = "default_hygiene_enabled")] pub hygiene_enabled: bool, /// Archive daily/session files older than this many days #[serde(default = "default_archive_after_days")] pub archive_after_days: u32, /// Purge archived files older than this many days #[serde(default = "default_purge_after_days")] pub purge_after_days: u32, /// For sqlite backend: prune conversation rows older than this many days #[serde(default = "default_conversation_retention_days")] pub conversation_retention_days: u32, /// Embedding provider: "none" | "openai" | "custom:URL" #[serde(default = "default_embedding_provider")] pub embedding_provider: String, /// Embedding model name (e.g. "text-embedding-3-small") #[serde(default = "default_embedding_model")] pub embedding_model: String, /// Embedding vector dimensions #[serde(default = "default_embedding_dims")] pub embedding_dimensions: usize, /// Weight for vector similarity in hybrid search (0.0–1.0) #[serde(default = "default_vector_weight")] pub vector_weight: f64, /// Weight for keyword BM25 in hybrid search (0.0–1.0) #[serde(default = "default_keyword_weight")] pub keyword_weight: f64, /// Max embedding cache entries before LRU eviction #[serde(default = "default_cache_size")] pub embedding_cache_size: usize, /// Max tokens per chunk for document splitting #[serde(default = "default_chunk_size")] pub chunk_max_tokens: usize, } fn default_embedding_provider() -> String { "none".into() } fn default_hygiene_enabled() -> bool { true } fn default_archive_after_days() -> u32 { 7 } fn default_purge_after_days() -> u32 { 30 } fn default_conversation_retention_days() -> u32 { 30 } fn default_embedding_model() -> String { "text-embedding-3-small".into() } fn default_embedding_dims() -> usize { 1536 } fn default_vector_weight() -> f64 { 0.7 } fn default_keyword_weight() -> f64 { 0.3 } fn default_cache_size() -> usize { 10_000 } fn default_chunk_size() -> usize { 512 } impl Default for MemoryConfig { fn default() -> Self { Self { backend: "sqlite".into(), auto_save: true, hygiene_enabled: default_hygiene_enabled(), archive_after_days: default_archive_after_days(), purge_after_days: default_purge_after_days(), conversation_retention_days: default_conversation_retention_days(), embedding_provider: default_embedding_provider(), embedding_model: default_embedding_model(), embedding_dimensions: default_embedding_dims(), vector_weight: default_vector_weight(), keyword_weight: default_keyword_weight(), embedding_cache_size: default_cache_size(), chunk_max_tokens: default_chunk_size(), } } } // ── Observability ───────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObservabilityConfig { /// "none" | "log" | "prometheus" | "otel" pub backend: String, /// OTLP endpoint (e.g. "http://localhost:4318"). Only used when backend = "otel". #[serde(default)] pub otel_endpoint: Option, /// Service name reported to the OTel collector. Defaults to "zeroclaw". #[serde(default)] pub otel_service_name: Option, } impl Default for ObservabilityConfig { fn default() -> Self { Self { backend: "none".into(), otel_endpoint: None, otel_service_name: None, } } } // ── Autonomy / Security ────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutonomyConfig { pub level: AutonomyLevel, pub workspace_only: bool, pub allowed_commands: Vec, pub forbidden_paths: Vec, pub max_actions_per_hour: u32, pub max_cost_per_day_cents: u32, /// Require explicit approval for medium-risk shell commands. #[serde(default = "default_true")] pub require_approval_for_medium_risk: bool, /// Block high-risk shell commands even if allowlisted. #[serde(default = "default_true")] pub block_high_risk_commands: bool, } impl Default for AutonomyConfig { fn default() -> Self { Self { level: AutonomyLevel::Supervised, workspace_only: true, allowed_commands: vec![ "git".into(), "npm".into(), "cargo".into(), "ls".into(), "cat".into(), "grep".into(), "find".into(), "echo".into(), "pwd".into(), "wc".into(), "head".into(), "tail".into(), ], forbidden_paths: vec![ "/etc".into(), "/root".into(), "/home".into(), "/usr".into(), "/bin".into(), "/sbin".into(), "/lib".into(), "/opt".into(), "/boot".into(), "/dev".into(), "/proc".into(), "/sys".into(), "/var".into(), "/tmp".into(), "~/.ssh".into(), "~/.gnupg".into(), "~/.aws".into(), "~/.config".into(), ], max_actions_per_hour: 20, max_cost_per_day_cents: 500, require_approval_for_medium_risk: true, block_high_risk_commands: true, } } } // ── Runtime ────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeConfig { /// Runtime kind (`native` | `docker`). #[serde(default = "default_runtime_kind")] pub kind: String, /// Docker runtime settings (used when `kind = "docker"`). #[serde(default)] pub docker: DockerRuntimeConfig, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DockerRuntimeConfig { /// Runtime image used to execute shell commands. #[serde(default = "default_docker_image")] pub image: String, /// Docker network mode (`none`, `bridge`, etc.). #[serde(default = "default_docker_network")] pub network: String, /// Optional memory limit in MB (`None` = no explicit limit). #[serde(default = "default_docker_memory_limit_mb")] pub memory_limit_mb: Option, /// Optional CPU limit (`None` = no explicit limit). #[serde(default = "default_docker_cpu_limit")] pub cpu_limit: Option, /// Mount root filesystem as read-only. #[serde(default = "default_true")] pub read_only_rootfs: bool, /// Mount configured workspace into `/workspace`. #[serde(default = "default_true")] pub mount_workspace: bool, /// Optional workspace root allowlist for Docker mount validation. #[serde(default)] pub allowed_workspace_roots: Vec, } fn default_runtime_kind() -> String { "native".into() } fn default_docker_image() -> String { "alpine:3.20".into() } fn default_docker_network() -> String { "none".into() } fn default_docker_memory_limit_mb() -> Option { Some(512) } fn default_docker_cpu_limit() -> Option { Some(1.0) } impl Default for DockerRuntimeConfig { fn default() -> Self { Self { image: default_docker_image(), network: default_docker_network(), memory_limit_mb: default_docker_memory_limit_mb(), cpu_limit: default_docker_cpu_limit(), read_only_rootfs: true, mount_workspace: true, allowed_workspace_roots: Vec::new(), } } } impl Default for RuntimeConfig { fn default() -> Self { Self { kind: default_runtime_kind(), docker: DockerRuntimeConfig::default(), } } } // ── Reliability / supervision ──────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReliabilityConfig { /// Retries per provider before failing over. #[serde(default = "default_provider_retries")] pub provider_retries: u32, /// Base backoff (ms) for provider retry delay. #[serde(default = "default_provider_backoff_ms")] pub provider_backoff_ms: u64, /// Fallback provider chain (e.g. `["anthropic", "openai"]`). #[serde(default)] pub fallback_providers: Vec, /// Initial backoff for channel/daemon restarts. #[serde(default = "default_channel_backoff_secs")] pub channel_initial_backoff_secs: u64, /// Max backoff for channel/daemon restarts. #[serde(default = "default_channel_backoff_max_secs")] pub channel_max_backoff_secs: u64, /// Scheduler polling cadence in seconds. #[serde(default = "default_scheduler_poll_secs")] pub scheduler_poll_secs: u64, /// Max retries for cron job execution attempts. #[serde(default = "default_scheduler_retries")] pub scheduler_retries: u32, } fn default_provider_retries() -> u32 { 2 } fn default_provider_backoff_ms() -> u64 { 500 } fn default_channel_backoff_secs() -> u64 { 2 } fn default_channel_backoff_max_secs() -> u64 { 60 } fn default_scheduler_poll_secs() -> u64 { 15 } fn default_scheduler_retries() -> u32 { 2 } impl Default for ReliabilityConfig { fn default() -> Self { Self { provider_retries: default_provider_retries(), provider_backoff_ms: default_provider_backoff_ms(), fallback_providers: Vec::new(), channel_initial_backoff_secs: default_channel_backoff_secs(), channel_max_backoff_secs: default_channel_backoff_max_secs(), scheduler_poll_secs: default_scheduler_poll_secs(), scheduler_retries: default_scheduler_retries(), } } } // ── Model routing ──────────────────────────────────────────────── /// Route a task hint to a specific provider + model. /// /// ```toml /// [[model_routes]] /// hint = "reasoning" /// provider = "openrouter" /// model = "anthropic/claude-opus-4-20250514" /// /// [[model_routes]] /// hint = "fast" /// provider = "groq" /// model = "llama-3.3-70b-versatile" /// ``` /// /// Usage: pass `hint:reasoning` as the model parameter to route the request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelRouteConfig { /// Task hint name (e.g. "reasoning", "fast", "code", "summarize") pub hint: String, /// Provider to route to (must match a known provider name) pub provider: String, /// Model to use with that provider pub model: String, /// Optional API key override for this route's provider #[serde(default)] pub api_key: Option, } // ── Heartbeat ──────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HeartbeatConfig { pub enabled: bool, pub interval_minutes: u32, } impl Default for HeartbeatConfig { fn default() -> Self { Self { enabled: false, interval_minutes: 30, } } } // ── Tunnel ────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TunnelConfig { /// "none", "cloudflare", "tailscale", "ngrok", "custom" pub provider: String, #[serde(default)] pub cloudflare: Option, #[serde(default)] pub tailscale: Option, #[serde(default)] pub ngrok: Option, #[serde(default)] pub custom: Option, } impl Default for TunnelConfig { fn default() -> Self { Self { provider: "none".into(), cloudflare: None, tailscale: None, ngrok: None, custom: None, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CloudflareTunnelConfig { /// Cloudflare Tunnel token (from Zero Trust dashboard) pub token: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TailscaleTunnelConfig { /// Use Tailscale Funnel (public internet) vs Serve (tailnet only) #[serde(default)] pub funnel: bool, /// Optional hostname override pub hostname: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NgrokTunnelConfig { /// ngrok auth token pub auth_token: String, /// Optional custom domain pub domain: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CustomTunnelConfig { /// Command template to start the tunnel. Use {port} and {host} placeholders. /// Example: "bore local {port} --to bore.pub" pub start_command: String, /// Optional URL to check tunnel health pub health_url: Option, /// Optional regex to extract public URL from command stdout pub url_pattern: Option, } // ── Channels ───────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChannelsConfig { pub cli: bool, pub telegram: Option, pub discord: Option, pub slack: Option, pub webhook: Option, pub imessage: Option, pub matrix: Option, pub whatsapp: Option, pub email: Option, pub irc: Option, pub lark: Option, } impl Default for ChannelsConfig { fn default() -> Self { Self { cli: true, telegram: None, discord: None, slack: None, webhook: None, imessage: None, matrix: None, whatsapp: None, email: None, irc: None, lark: None, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TelegramConfig { pub bot_token: String, pub allowed_users: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscordConfig { pub bot_token: String, pub guild_id: Option, #[serde(default)] pub allowed_users: Vec, /// When true, process messages from other bots (not just humans). /// The bot still ignores its own messages to prevent feedback loops. #[serde(default)] pub listen_to_bots: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlackConfig { pub bot_token: String, pub app_token: Option, pub channel_id: Option, #[serde(default)] pub allowed_users: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookConfig { pub port: u16, pub secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IMessageConfig { pub allowed_contacts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MatrixConfig { pub homeserver: String, pub access_token: String, pub room_id: String, pub allowed_users: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WhatsAppConfig { /// Access token from Meta Business Suite pub access_token: String, /// Phone number ID from Meta Business API pub phone_number_id: String, /// Webhook verify token (you define this, Meta sends it back for verification) pub verify_token: String, /// App secret from Meta Business Suite (for webhook signature verification) /// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable #[serde(default)] pub app_secret: Option, /// Allowed phone numbers (E.164 format: +1234567890) or "*" for all #[serde(default)] pub allowed_numbers: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IrcConfig { /// IRC server hostname pub server: String, /// IRC server port (default: 6697 for TLS) #[serde(default = "default_irc_port")] pub port: u16, /// Bot nickname pub nickname: String, /// Username (defaults to nickname if not set) pub username: Option, /// Channels to join on connect #[serde(default)] pub channels: Vec, /// Allowed nicknames (case-insensitive) or "*" for all #[serde(default)] pub allowed_users: Vec, /// Server password (for bouncers like ZNC) pub server_password: Option, /// NickServ IDENTIFY password pub nickserv_password: Option, /// SASL PLAIN password (IRCv3) pub sasl_password: Option, /// Verify TLS certificate (default: true) pub verify_tls: Option, } fn default_irc_port() -> u16 { 6697 } /// Lark/Feishu configuration for messaging integration /// Lark is the international version, Feishu is the Chinese version #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LarkConfig { /// App ID from Lark/Feishu developer console pub app_id: String, /// App Secret from Lark/Feishu developer console pub app_secret: String, /// Encrypt key for webhook message decryption (optional) #[serde(default)] pub encrypt_key: Option, /// Verification token for webhook validation (optional) #[serde(default)] pub verification_token: Option, /// Allowed user IDs or union IDs (empty = deny all, "*" = allow all) #[serde(default)] pub allowed_users: Vec, /// Whether to use the Feishu (Chinese) endpoint instead of Lark (International) #[serde(default)] pub use_feishu: bool, } // ── Security Config ───────────────────────────────────────────────── /// Security configuration for sandboxing, resource limits, and audit logging #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SecurityConfig { /// Sandbox configuration #[serde(default)] pub sandbox: SandboxConfig, /// Resource limits #[serde(default)] pub resources: ResourceLimitsConfig, /// Audit logging configuration #[serde(default)] pub audit: AuditConfig, } impl Default for SecurityConfig { fn default() -> Self { Self { sandbox: SandboxConfig::default(), resources: ResourceLimitsConfig::default(), audit: AuditConfig::default(), } } } /// Sandbox configuration for OS-level isolation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SandboxConfig { /// Enable sandboxing (None = auto-detect, Some = explicit) #[serde(default)] pub enabled: Option, /// Sandbox backend to use #[serde(default)] pub backend: SandboxBackend, /// Custom Firejail arguments (when backend = firejail) #[serde(default)] pub firejail_args: Vec, } impl Default for SandboxConfig { fn default() -> Self { Self { enabled: None, // Auto-detect backend: SandboxBackend::Auto, firejail_args: Vec::new(), } } } /// Sandbox backend selection #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SandboxBackend { /// Auto-detect best available (default) Auto, /// Landlock (Linux kernel LSM, native) Landlock, /// Firejail (user-space sandbox) Firejail, /// Bubblewrap (user namespaces) Bubblewrap, /// Docker container isolation Docker, /// No sandboxing (application-layer only) None, } impl Default for SandboxBackend { fn default() -> Self { Self::Auto } } /// Resource limits for command execution #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceLimitsConfig { /// Maximum memory in MB per command #[serde(default = "default_max_memory_mb")] pub max_memory_mb: u32, /// Maximum CPU time in seconds per command #[serde(default = "default_max_cpu_time_seconds")] pub max_cpu_time_seconds: u64, /// Maximum number of subprocesses #[serde(default = "default_max_subprocesses")] pub max_subprocesses: u32, /// Enable memory monitoring #[serde(default = "default_memory_monitoring_enabled")] pub memory_monitoring: bool, } fn default_max_memory_mb() -> u32 { 512 } fn default_max_cpu_time_seconds() -> u64 { 60 } fn default_max_subprocesses() -> u32 { 10 } fn default_memory_monitoring_enabled() -> bool { true } impl Default for ResourceLimitsConfig { fn default() -> Self { Self { max_memory_mb: default_max_memory_mb(), max_cpu_time_seconds: default_max_cpu_time_seconds(), max_subprocesses: default_max_subprocesses(), memory_monitoring: default_memory_monitoring_enabled(), } } } /// Audit logging configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditConfig { /// Enable audit logging #[serde(default = "default_audit_enabled")] pub enabled: bool, /// Path to audit log file (relative to zeroclaw dir) #[serde(default = "default_audit_log_path")] pub log_path: String, /// Maximum log size in MB before rotation #[serde(default = "default_audit_max_size_mb")] pub max_size_mb: u32, /// Sign events with HMAC for tamper evidence #[serde(default)] pub sign_events: bool, } fn default_audit_enabled() -> bool { true } fn default_audit_log_path() -> String { "audit.log".to_string() } fn default_audit_max_size_mb() -> u32 { 100 } impl Default for AuditConfig { fn default() -> Self { Self { enabled: default_audit_enabled(), log_path: default_audit_log_path(), max_size_mb: default_audit_max_size_mb(), sign_events: false, } } } // ── Config impl ────────────────────────────────────────────────── impl Default for Config { fn default() -> Self { let home = UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf()); let zeroclaw_dir = home.join(".zeroclaw"); Self { workspace_dir: zeroclaw_dir.join("workspace"), config_path: zeroclaw_dir.join("config.toml"), api_key: None, default_provider: Some("openrouter".to_string()), default_model: Some("anthropic/claude-sonnet-4".to_string()), default_temperature: 0.7, observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), hardware: crate::hardware::HardwareConfig::default(), agents: HashMap::new(), security: SecurityConfig::default(), } } } impl Config { pub fn load_or_init() -> Result { let home = UserDirs::new() .map(|u| u.home_dir().to_path_buf()) .context("Could not find home directory")?; let zeroclaw_dir = home.join(".zeroclaw"); let config_path = zeroclaw_dir.join("config.toml"); if !zeroclaw_dir.exists() { fs::create_dir_all(&zeroclaw_dir).context("Failed to create .zeroclaw directory")?; fs::create_dir_all(zeroclaw_dir.join("workspace")) .context("Failed to create workspace directory")?; } if config_path.exists() { let contents = fs::read_to_string(&config_path).context("Failed to read config file")?; let mut config: Config = toml::from_str(&contents).context("Failed to parse config file")?; // Set computed paths that are skipped during serialization config.config_path = config_path.clone(); config.workspace_dir = zeroclaw_dir.join("workspace"); // Decrypt agent API keys if encryption is enabled let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt); for agent in config.agents.values_mut() { if let Some(ref encrypted_key) = agent.api_key { agent.api_key = Some( store .decrypt(encrypted_key) .context("Failed to decrypt agent API key")?, ); } } Ok(config) } else { let mut config = Config::default(); config.config_path = config_path.clone(); config.workspace_dir = zeroclaw_dir.join("workspace"); config.save()?; Ok(config) } } /// Apply environment variable overrides to config pub fn apply_env_overrides(&mut self) { // API Key: ZEROCLAW_API_KEY or API_KEY if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) { if !key.is_empty() { self.api_key = Some(key); } } // Provider: ZEROCLAW_PROVIDER or PROVIDER if let Ok(provider) = std::env::var("ZEROCLAW_PROVIDER").or_else(|_| std::env::var("PROVIDER")) { if !provider.is_empty() { self.default_provider = Some(provider); } } // Model: ZEROCLAW_MODEL if let Ok(model) = std::env::var("ZEROCLAW_MODEL") { if !model.is_empty() { self.default_model = Some(model); } } // Workspace directory: ZEROCLAW_WORKSPACE if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") { if !workspace.is_empty() { self.workspace_dir = PathBuf::from(workspace); } } // Gateway port: ZEROCLAW_GATEWAY_PORT or PORT if let Ok(port_str) = std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT")) { if let Ok(port) = port_str.parse::() { self.gateway.port = port; } } // Gateway host: ZEROCLAW_GATEWAY_HOST or HOST if let Ok(host) = std::env::var("ZEROCLAW_GATEWAY_HOST").or_else(|_| std::env::var("HOST")) { if !host.is_empty() { self.gateway.host = host; } } // Allow public bind: ZEROCLAW_ALLOW_PUBLIC_BIND if let Ok(val) = std::env::var("ZEROCLAW_ALLOW_PUBLIC_BIND") { self.gateway.allow_public_bind = val == "1" || val.eq_ignore_ascii_case("true"); } // Temperature: ZEROCLAW_TEMPERATURE if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") { if let Ok(temp) = temp_str.parse::() { if (0.0..=2.0).contains(&temp) { self.default_temperature = temp; } } } } pub fn save(&self) -> Result<()> { // Encrypt agent API keys before serialization let mut config_to_save = self.clone(); let zeroclaw_dir = self .config_path .parent() .context("Config path must have a parent directory")?; let store = crate::security::SecretStore::new(zeroclaw_dir, self.secrets.encrypt); for agent in config_to_save.agents.values_mut() { if let Some(ref plaintext_key) = agent.api_key { if !crate::security::SecretStore::is_encrypted(plaintext_key) { agent.api_key = Some( store .encrypt(plaintext_key) .context("Failed to encrypt agent API key")?, ); } } } let toml_str = toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?; let parent_dir = self .config_path .parent() .context("Config path must have a parent directory")?; fs::create_dir_all(parent_dir).with_context(|| { format!( "Failed to create config directory: {}", parent_dir.display() ) })?; let file_name = self .config_path .file_name() .and_then(|v| v.to_str()) .unwrap_or("config.toml"); let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4())); let backup_path = parent_dir.join(format!("{file_name}.bak")); let mut temp_file = OpenOptions::new() .create_new(true) .write(true) .open(&temp_path) .with_context(|| { format!( "Failed to create temporary config file: {}", temp_path.display() ) })?; temp_file .write_all(toml_str.as_bytes()) .context("Failed to write temporary config contents")?; temp_file .sync_all() .context("Failed to fsync temporary config file")?; drop(temp_file); let had_existing_config = self.config_path.exists(); if had_existing_config { fs::copy(&self.config_path, &backup_path).with_context(|| { format!( "Failed to create config backup before atomic replace: {}", backup_path.display() ) })?; } if let Err(e) = fs::rename(&temp_path, &self.config_path) { let _ = fs::remove_file(&temp_path); if had_existing_config && backup_path.exists() { let _ = fs::copy(&backup_path, &self.config_path); } anyhow::bail!("Failed to atomically replace config file: {e}"); } sync_directory(parent_dir)?; if had_existing_config { let _ = fs::remove_file(&backup_path); } Ok(()) } } #[cfg(unix)] fn sync_directory(path: &Path) -> Result<()> { let dir = File::open(path) .with_context(|| format!("Failed to open directory for fsync: {}", path.display()))?; dir.sync_all() .with_context(|| format!("Failed to fsync directory metadata: {}", path.display()))?; Ok(()) } #[cfg(not(unix))] fn sync_directory(_path: &Path) -> Result<()> { Ok(()) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use tempfile::TempDir; // ── Defaults ───────────────────────────────────────────── #[test] fn config_default_has_sane_values() { let c = Config::default(); assert_eq!(c.default_provider.as_deref(), Some("openrouter")); assert!(c.default_model.as_deref().unwrap().contains("claude")); assert!((c.default_temperature - 0.7).abs() < f64::EPSILON); assert!(c.api_key.is_none()); assert!(c.workspace_dir.to_string_lossy().contains("workspace")); assert!(c.config_path.to_string_lossy().contains("config.toml")); } #[test] fn observability_config_default() { let o = ObservabilityConfig::default(); assert_eq!(o.backend, "none"); } #[test] fn autonomy_config_default() { let a = AutonomyConfig::default(); assert_eq!(a.level, AutonomyLevel::Supervised); assert!(a.workspace_only); assert!(a.allowed_commands.contains(&"git".to_string())); assert!(a.allowed_commands.contains(&"cargo".to_string())); assert!(a.forbidden_paths.contains(&"/etc".to_string())); assert_eq!(a.max_actions_per_hour, 20); assert_eq!(a.max_cost_per_day_cents, 500); assert!(a.require_approval_for_medium_risk); assert!(a.block_high_risk_commands); } #[test] fn runtime_config_default() { let r = RuntimeConfig::default(); assert_eq!(r.kind, "native"); assert_eq!(r.docker.image, "alpine:3.20"); assert_eq!(r.docker.network, "none"); assert_eq!(r.docker.memory_limit_mb, Some(512)); assert_eq!(r.docker.cpu_limit, Some(1.0)); assert!(r.docker.read_only_rootfs); assert!(r.docker.mount_workspace); } #[test] fn heartbeat_config_default() { let h = HeartbeatConfig::default(); assert!(!h.enabled); assert_eq!(h.interval_minutes, 30); } #[test] fn memory_config_default_hygiene_settings() { let m = MemoryConfig::default(); assert_eq!(m.backend, "sqlite"); assert!(m.auto_save); assert!(m.hygiene_enabled); assert_eq!(m.archive_after_days, 7); assert_eq!(m.purge_after_days, 30); assert_eq!(m.conversation_retention_days, 30); } #[test] fn channels_config_default() { let c = ChannelsConfig::default(); assert!(c.cli); assert!(c.telegram.is_none()); assert!(c.discord.is_none()); } // ── Serde round-trip ───────────────────────────────────── #[test] fn config_toml_roundtrip() { let config = Config { workspace_dir: PathBuf::from("/tmp/test/workspace"), config_path: PathBuf::from("/tmp/test/config.toml"), api_key: Some("sk-test-key".into()), default_provider: Some("openrouter".into()), default_model: Some("gpt-4o".into()), default_temperature: 0.5, observability: ObservabilityConfig { backend: "log".into(), ..ObservabilityConfig::default() }, autonomy: AutonomyConfig { level: AutonomyLevel::Full, workspace_only: false, allowed_commands: vec!["docker".into()], forbidden_paths: vec!["/secret".into()], max_actions_per_hour: 50, max_cost_per_day_cents: 1000, require_approval_for_medium_risk: false, block_high_risk_commands: true, }, runtime: RuntimeConfig { kind: "docker".into(), ..RuntimeConfig::default() }, reliability: ReliabilityConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig { enabled: true, interval_minutes: 15, }, channels_config: ChannelsConfig { cli: true, telegram: Some(TelegramConfig { bot_token: "123:ABC".into(), allowed_users: vec!["user1".into()], }), discord: None, slack: None, webhook: None, imessage: None, matrix: None, whatsapp: None, email: None, irc: None, lark: None, }, memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), hardware: crate::hardware::HardwareConfig::default(), agents: HashMap::new(), security: SecurityConfig::default(), }; let toml_str = toml::to_string_pretty(&config).unwrap(); let parsed: Config = toml::from_str(&toml_str).unwrap(); assert_eq!(parsed.api_key, config.api_key); assert_eq!(parsed.default_provider, config.default_provider); assert_eq!(parsed.default_model, config.default_model); assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON); assert_eq!(parsed.observability.backend, "log"); assert_eq!(parsed.autonomy.level, AutonomyLevel::Full); assert!(!parsed.autonomy.workspace_only); assert_eq!(parsed.runtime.kind, "docker"); assert!(parsed.heartbeat.enabled); assert_eq!(parsed.heartbeat.interval_minutes, 15); assert!(parsed.channels_config.telegram.is_some()); assert_eq!( parsed.channels_config.telegram.unwrap().bot_token, "123:ABC" ); } #[test] fn config_minimal_toml_uses_defaults() { 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.api_key.is_none()); assert!(parsed.default_provider.is_none()); assert_eq!(parsed.observability.backend, "none"); assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised); assert_eq!(parsed.runtime.kind, "native"); assert!(!parsed.heartbeat.enabled); assert!(parsed.channels_config.cli); assert!(parsed.memory.hygiene_enabled); assert_eq!(parsed.memory.archive_after_days, 7); assert_eq!(parsed.memory.purge_after_days, 30); assert_eq!(parsed.memory.conversation_retention_days, 30); } #[test] fn config_save_and_load_tmpdir() { let dir = std::env::temp_dir().join("zeroclaw_test_config"); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); let config_path = dir.join("config.toml"); let config = Config { workspace_dir: dir.join("workspace"), config_path: config_path.clone(), api_key: Some("sk-roundtrip".into()), default_provider: Some("openrouter".into()), default_model: Some("test-model".into()), default_temperature: 0.9, observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), identity: IdentityConfig::default(), hardware: crate::hardware::HardwareConfig::default(), agents: HashMap::new(), security: SecurityConfig::default(), }; config.save().unwrap(); assert!(config_path.exists()); let contents = fs::read_to_string(&config_path).unwrap(); let loaded: Config = toml::from_str(&contents).unwrap(); assert_eq!(loaded.api_key.as_deref(), Some("sk-roundtrip")); assert_eq!(loaded.default_model.as_deref(), Some("test-model")); assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON); let _ = fs::remove_dir_all(&dir); } #[test] fn config_save_atomic_cleanup() { let dir = std::env::temp_dir().join(format!("zeroclaw_test_config_{}", uuid::Uuid::new_v4())); fs::create_dir_all(&dir).unwrap(); let config_path = dir.join("config.toml"); let mut config = Config::default(); config.workspace_dir = dir.join("workspace"); config.config_path = config_path.clone(); config.default_model = Some("model-a".into()); config.save().unwrap(); assert!(config_path.exists()); config.default_model = Some("model-b".into()); config.save().unwrap(); let contents = fs::read_to_string(&config_path).unwrap(); assert!(contents.contains("model-b")); let names: Vec = fs::read_dir(&dir) .unwrap() .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string()) .collect(); assert!(!names.iter().any(|name| name.contains(".tmp-"))); assert!(!names.iter().any(|name| name.ends_with(".bak"))); let _ = fs::remove_dir_all(&dir); } // ── Telegram / Discord config ──────────────────────────── #[test] fn telegram_config_serde() { let tc = TelegramConfig { bot_token: "123:XYZ".into(), allowed_users: vec!["alice".into(), "bob".into()], }; let json = serde_json::to_string(&tc).unwrap(); let parsed: TelegramConfig = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.bot_token, "123:XYZ"); assert_eq!(parsed.allowed_users.len(), 2); } #[test] fn discord_config_serde() { let dc = DiscordConfig { bot_token: "discord-token".into(), guild_id: Some("12345".into()), allowed_users: vec![], listen_to_bots: false, }; let json = serde_json::to_string(&dc).unwrap(); let parsed: DiscordConfig = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.bot_token, "discord-token"); assert_eq!(parsed.guild_id.as_deref(), Some("12345")); } #[test] fn discord_config_optional_guild() { let dc = DiscordConfig { bot_token: "tok".into(), guild_id: None, allowed_users: vec![], listen_to_bots: false, }; let json = serde_json::to_string(&dc).unwrap(); let parsed: DiscordConfig = serde_json::from_str(&json).unwrap(); assert!(parsed.guild_id.is_none()); } // ── iMessage / Matrix config ──────────────────────────── #[test] fn imessage_config_serde() { let ic = IMessageConfig { allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()], }; let json = serde_json::to_string(&ic).unwrap(); let parsed: IMessageConfig = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.allowed_contacts.len(), 2); assert_eq!(parsed.allowed_contacts[0], "+1234567890"); } #[test] fn imessage_config_empty_contacts() { let ic = IMessageConfig { allowed_contacts: vec![], }; let json = serde_json::to_string(&ic).unwrap(); let parsed: IMessageConfig = serde_json::from_str(&json).unwrap(); assert!(parsed.allowed_contacts.is_empty()); } #[test] fn imessage_config_wildcard() { let ic = IMessageConfig { allowed_contacts: vec!["*".into()], }; let toml_str = toml::to_string(&ic).unwrap(); let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap(); assert_eq!(parsed.allowed_contacts, vec!["*"]); } #[test] fn matrix_config_serde() { let mc = MatrixConfig { homeserver: "https://matrix.org".into(), access_token: "syt_token_abc".into(), room_id: "!room123:matrix.org".into(), allowed_users: vec!["@user:matrix.org".into()], }; let json = serde_json::to_string(&mc).unwrap(); let parsed: MatrixConfig = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.homeserver, "https://matrix.org"); assert_eq!(parsed.access_token, "syt_token_abc"); assert_eq!(parsed.room_id, "!room123:matrix.org"); assert_eq!(parsed.allowed_users.len(), 1); } #[test] fn matrix_config_toml_roundtrip() { let mc = MatrixConfig { homeserver: "https://synapse.local:8448".into(), access_token: "tok".into(), room_id: "!abc:synapse.local".into(), allowed_users: vec!["@admin:synapse.local".into(), "*".into()], }; let toml_str = toml::to_string(&mc).unwrap(); let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap(); assert_eq!(parsed.homeserver, "https://synapse.local:8448"); assert_eq!(parsed.allowed_users.len(), 2); } #[test] fn channels_config_with_imessage_and_matrix() { let c = ChannelsConfig { cli: true, telegram: None, discord: None, slack: None, webhook: None, imessage: Some(IMessageConfig { allowed_contacts: vec!["+1".into()], }), matrix: Some(MatrixConfig { homeserver: "https://m.org".into(), access_token: "tok".into(), room_id: "!r:m".into(), allowed_users: vec!["@u:m".into()], }), whatsapp: None, email: None, irc: None, lark: None, }; let toml_str = toml::to_string_pretty(&c).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); assert!(parsed.imessage.is_some()); assert!(parsed.matrix.is_some()); assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]); assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org"); } #[test] fn channels_config_default_has_no_imessage_matrix() { let c = ChannelsConfig::default(); assert!(c.imessage.is_none()); assert!(c.matrix.is_none()); } // ── Edge cases: serde(default) for allowed_users ───────── #[test] fn discord_config_deserializes_without_allowed_users() { // Old configs won't have allowed_users — serde(default) should fill vec![] let json = r#"{"bot_token":"tok","guild_id":"123"}"#; let parsed: DiscordConfig = serde_json::from_str(json).unwrap(); assert!(parsed.allowed_users.is_empty()); } #[test] fn discord_config_deserializes_with_allowed_users() { let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#; let parsed: DiscordConfig = serde_json::from_str(json).unwrap(); assert_eq!(parsed.allowed_users, vec!["111", "222"]); } #[test] fn slack_config_deserializes_without_allowed_users() { let json = r#"{"bot_token":"xoxb-tok"}"#; let parsed: SlackConfig = serde_json::from_str(json).unwrap(); assert!(parsed.allowed_users.is_empty()); } #[test] fn slack_config_deserializes_with_allowed_users() { let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#; let parsed: SlackConfig = serde_json::from_str(json).unwrap(); assert_eq!(parsed.allowed_users, vec!["U111"]); } #[test] fn discord_config_toml_backward_compat() { let toml_str = r#" bot_token = "tok" guild_id = "123" "#; let parsed: DiscordConfig = toml::from_str(toml_str).unwrap(); assert!(parsed.allowed_users.is_empty()); assert_eq!(parsed.bot_token, "tok"); } #[test] fn slack_config_toml_backward_compat() { let toml_str = r#" bot_token = "xoxb-tok" channel_id = "C123" "#; let parsed: SlackConfig = toml::from_str(toml_str).unwrap(); assert!(parsed.allowed_users.is_empty()); assert_eq!(parsed.channel_id.as_deref(), Some("C123")); } #[test] fn webhook_config_with_secret() { let json = r#"{"port":8080,"secret":"my-secret-key"}"#; let parsed: WebhookConfig = serde_json::from_str(json).unwrap(); assert_eq!(parsed.secret.as_deref(), Some("my-secret-key")); } #[test] fn webhook_config_without_secret() { let json = r#"{"port":8080}"#; let parsed: WebhookConfig = serde_json::from_str(json).unwrap(); assert!(parsed.secret.is_none()); assert_eq!(parsed.port, 8080); } // ── WhatsApp config ────────────────────────────────────── #[test] fn whatsapp_config_serde() { let wc = WhatsAppConfig { access_token: "EAABx...".into(), phone_number_id: "123456789".into(), verify_token: "my-verify-token".into(), app_secret: None, allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()], }; let json = serde_json::to_string(&wc).unwrap(); let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.access_token, "EAABx..."); assert_eq!(parsed.phone_number_id, "123456789"); assert_eq!(parsed.verify_token, "my-verify-token"); assert_eq!(parsed.allowed_numbers.len(), 2); } #[test] fn whatsapp_config_toml_roundtrip() { let wc = WhatsAppConfig { access_token: "tok".into(), phone_number_id: "12345".into(), verify_token: "verify".into(), app_secret: Some("secret123".into()), allowed_numbers: vec!["+1".into()], }; let toml_str = toml::to_string(&wc).unwrap(); let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap(); assert_eq!(parsed.phone_number_id, "12345"); assert_eq!(parsed.allowed_numbers, vec!["+1"]); } #[test] fn whatsapp_config_deserializes_without_allowed_numbers() { let json = r#"{"access_token":"tok","phone_number_id":"123","verify_token":"ver"}"#; let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap(); assert!(parsed.allowed_numbers.is_empty()); } #[test] fn whatsapp_config_wildcard_allowed() { let wc = WhatsAppConfig { access_token: "tok".into(), phone_number_id: "123".into(), verify_token: "ver".into(), app_secret: None, allowed_numbers: vec!["*".into()], }; let toml_str = toml::to_string(&wc).unwrap(); let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap(); assert_eq!(parsed.allowed_numbers, vec!["*"]); } #[test] fn channels_config_with_whatsapp() { let c = ChannelsConfig { cli: true, telegram: None, discord: None, slack: None, webhook: None, imessage: None, matrix: None, whatsapp: Some(WhatsAppConfig { access_token: "tok".into(), phone_number_id: "123".into(), verify_token: "ver".into(), app_secret: None, allowed_numbers: vec!["+1".into()], }), email: None, irc: None, lark: None, }; let toml_str = toml::to_string_pretty(&c).unwrap(); let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap(); assert!(parsed.whatsapp.is_some()); let wa = parsed.whatsapp.unwrap(); assert_eq!(wa.phone_number_id, "123"); assert_eq!(wa.allowed_numbers, vec!["+1"]); } #[test] fn channels_config_default_has_no_whatsapp() { let c = ChannelsConfig::default(); assert!(c.whatsapp.is_none()); } // ══════════════════════════════════════════════════════════ // SECURITY CHECKLIST TESTS — Gateway config // ══════════════════════════════════════════════════════════ #[test] fn checklist_gateway_default_requires_pairing() { let g = GatewayConfig::default(); assert!(g.require_pairing, "Pairing must be required by default"); } #[test] fn checklist_gateway_default_blocks_public_bind() { let g = GatewayConfig::default(); assert!( !g.allow_public_bind, "Public bind must be blocked by default" ); } #[test] fn checklist_gateway_default_no_tokens() { let g = GatewayConfig::default(); assert!( g.paired_tokens.is_empty(), "No pre-paired tokens by default" ); assert_eq!(g.pair_rate_limit_per_minute, 10); assert_eq!(g.webhook_rate_limit_per_minute, 60); assert_eq!(g.idempotency_ttl_secs, 300); } #[test] fn checklist_gateway_cli_default_host_is_localhost() { // The CLI default for --host is 127.0.0.1 (checked in main.rs) // Here we verify the config default matches let c = Config::default(); assert!( c.gateway.require_pairing, "Config default must require pairing" ); assert!( !c.gateway.allow_public_bind, "Config default must block public bind" ); } #[test] fn checklist_gateway_serde_roundtrip() { let g = GatewayConfig { port: 3000, host: "127.0.0.1".into(), require_pairing: true, allow_public_bind: false, paired_tokens: vec!["zc_test_token".into()], pair_rate_limit_per_minute: 12, webhook_rate_limit_per_minute: 80, idempotency_ttl_secs: 600, }; let toml_str = toml::to_string(&g).unwrap(); let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap(); assert!(parsed.require_pairing); assert!(!parsed.allow_public_bind); assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]); assert_eq!(parsed.pair_rate_limit_per_minute, 12); assert_eq!(parsed.webhook_rate_limit_per_minute, 80); assert_eq!(parsed.idempotency_ttl_secs, 600); } #[test] fn checklist_gateway_backward_compat_no_gateway_section() { // Old configs without [gateway] should get secure defaults 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.gateway.require_pairing, "Missing [gateway] must default to require_pairing=true" ); assert!( !parsed.gateway.allow_public_bind, "Missing [gateway] must default to allow_public_bind=false" ); } #[test] fn checklist_autonomy_default_is_workspace_scoped() { let a = AutonomyConfig::default(); assert!(a.workspace_only, "Default autonomy must be workspace_only"); assert!( a.forbidden_paths.contains(&"/etc".to_string()), "Must block /etc" ); assert!( a.forbidden_paths.contains(&"/proc".to_string()), "Must block /proc" ); assert!( a.forbidden_paths.contains(&"~/.ssh".to_string()), "Must block ~/.ssh" ); } // ══════════════════════════════════════════════════════════ // COMPOSIO CONFIG TESTS // ══════════════════════════════════════════════════════════ #[test] fn composio_config_default_disabled() { let c = ComposioConfig::default(); assert!(!c.enabled, "Composio must be disabled by default"); assert!(c.api_key.is_none(), "No API key by default"); assert_eq!(c.entity_id, "default"); } #[test] fn composio_config_serde_roundtrip() { let c = ComposioConfig { enabled: true, api_key: Some("comp-key-123".into()), entity_id: "user42".into(), }; let toml_str = toml::to_string(&c).unwrap(); let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap(); assert!(parsed.enabled); assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123")); assert_eq!(parsed.entity_id, "user42"); } #[test] fn composio_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.composio.enabled, "Missing [composio] must default to disabled" ); assert!(parsed.composio.api_key.is_none()); } #[test] fn composio_config_partial_toml() { let toml_str = r" enabled = true "; let parsed: ComposioConfig = toml::from_str(toml_str).unwrap(); assert!(parsed.enabled); assert!(parsed.api_key.is_none()); assert_eq!(parsed.entity_id, "default"); } // ══════════════════════════════════════════════════════════ // SECRETS CONFIG TESTS // ══════════════════════════════════════════════════════════ #[test] fn secrets_config_default_encrypts() { let s = SecretsConfig::default(); assert!(s.encrypt, "Encryption must be enabled by default"); } #[test] fn secrets_config_serde_roundtrip() { let s = SecretsConfig { encrypt: false }; let toml_str = toml::to_string(&s).unwrap(); let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap(); assert!(!parsed.encrypt); } #[test] fn secrets_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.secrets.encrypt, "Missing [secrets] must default to encrypt=true" ); } #[test] fn config_default_has_composio_and_secrets() { let c = Config::default(); 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()); assert_eq!(b.backend, "agent_browser"); assert!(b.native_headless); assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515"); assert!(b.native_chrome_path.is_none()); } #[test] fn browser_config_serde_roundtrip() { let b = BrowserConfig { enabled: true, allowed_domains: vec!["example.com".into(), "docs.example.com".into()], session_name: None, backend: "auto".into(), native_headless: false, native_webdriver_url: "http://localhost:4444".into(), native_chrome_path: Some("/usr/bin/chromium".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"); assert_eq!(parsed.backend, "auto"); assert!(!parsed.native_headless); assert_eq!(parsed.native_webdriver_url, "http://localhost:4444"); assert_eq!( parsed.native_chrome_path.as_deref(), Some("/usr/bin/chromium") ); } #[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()); } fn env_override_lock() -> std::sync::MutexGuard<'static, ()> { static ENV_LOCK: OnceLock> = OnceLock::new(); ENV_LOCK .get_or_init(|| Mutex::new(())) .lock() .expect("env override test lock poisoned") } // ── Environment variable overrides (Docker support) ───────── #[test] fn env_override_api_key() { let _guard = env_override_lock(); let mut config = Config::default(); assert!(config.api_key.is_none()); std::env::set_var("ZEROCLAW_API_KEY", "sk-test-env-key"); config.apply_env_overrides(); assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key")); std::env::remove_var("ZEROCLAW_API_KEY"); } #[test] fn env_override_api_key_fallback() { let _guard = env_override_lock(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_API_KEY"); std::env::set_var("API_KEY", "sk-fallback-key"); config.apply_env_overrides(); assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key")); std::env::remove_var("API_KEY"); } #[test] fn env_override_provider() { let _guard = env_override_lock(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_PROVIDER", "anthropic"); config.apply_env_overrides(); assert_eq!(config.default_provider.as_deref(), Some("anthropic")); std::env::remove_var("ZEROCLAW_PROVIDER"); } #[test] fn env_override_provider_fallback() { let _guard = env_override_lock(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_PROVIDER"); std::env::set_var("PROVIDER", "openai"); config.apply_env_overrides(); assert_eq!(config.default_provider.as_deref(), Some("openai")); std::env::remove_var("PROVIDER"); } #[test] fn env_override_model() { let _guard = env_override_lock(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_MODEL", "gpt-4o"); config.apply_env_overrides(); assert_eq!(config.default_model.as_deref(), Some("gpt-4o")); std::env::remove_var("ZEROCLAW_MODEL"); } #[test] fn env_override_workspace() { let _guard = env_override_lock(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_WORKSPACE", "/custom/workspace"); config.apply_env_overrides(); assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace")); std::env::remove_var("ZEROCLAW_WORKSPACE"); } #[test] fn env_override_empty_values_ignored() { let _guard = env_override_lock(); let mut config = Config::default(); let original_provider = config.default_provider.clone(); std::env::set_var("ZEROCLAW_PROVIDER", ""); config.apply_env_overrides(); assert_eq!(config.default_provider, original_provider); std::env::remove_var("ZEROCLAW_PROVIDER"); } #[test] fn env_override_gateway_port() { let _guard = env_override_lock(); let mut config = Config::default(); assert_eq!(config.gateway.port, 3000); std::env::set_var("ZEROCLAW_GATEWAY_PORT", "8080"); config.apply_env_overrides(); assert_eq!(config.gateway.port, 8080); std::env::remove_var("ZEROCLAW_GATEWAY_PORT"); } #[test] fn env_override_port_fallback() { let _guard = env_override_lock(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_GATEWAY_PORT"); std::env::set_var("PORT", "9000"); config.apply_env_overrides(); assert_eq!(config.gateway.port, 9000); std::env::remove_var("PORT"); } #[test] fn env_override_gateway_host() { let _guard = env_override_lock(); let mut config = Config::default(); assert_eq!(config.gateway.host, "127.0.0.1"); std::env::set_var("ZEROCLAW_GATEWAY_HOST", "0.0.0.0"); config.apply_env_overrides(); assert_eq!(config.gateway.host, "0.0.0.0"); std::env::remove_var("ZEROCLAW_GATEWAY_HOST"); } #[test] fn env_override_host_fallback() { let _guard = env_override_lock(); let mut config = Config::default(); std::env::remove_var("ZEROCLAW_GATEWAY_HOST"); std::env::set_var("HOST", "0.0.0.0"); config.apply_env_overrides(); assert_eq!(config.gateway.host, "0.0.0.0"); std::env::remove_var("HOST"); } #[test] fn env_override_temperature() { let _guard = env_override_lock(); let mut config = Config::default(); std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5"); config.apply_env_overrides(); assert!((config.default_temperature - 0.5).abs() < f64::EPSILON); std::env::remove_var("ZEROCLAW_TEMPERATURE"); } #[test] fn env_override_temperature_out_of_range_ignored() { let _guard = env_override_lock(); // Clean up any leftover env vars from other tests std::env::remove_var("ZEROCLAW_TEMPERATURE"); let mut config = Config::default(); let original_temp = config.default_temperature; // Temperature > 2.0 should be ignored std::env::set_var("ZEROCLAW_TEMPERATURE", "3.0"); config.apply_env_overrides(); assert!( (config.default_temperature - original_temp).abs() < f64::EPSILON, "Temperature 3.0 should be ignored (out of range)" ); std::env::remove_var("ZEROCLAW_TEMPERATURE"); } #[test] fn env_override_invalid_port_ignored() { let _guard = env_override_lock(); let mut config = Config::default(); let original_port = config.gateway.port; std::env::set_var("PORT", "not_a_number"); config.apply_env_overrides(); assert_eq!(config.gateway.port, original_port); std::env::remove_var("PORT"); } #[test] fn gateway_config_default_values() { let g = GatewayConfig::default(); assert_eq!(g.port, 3000); assert_eq!(g.host, "127.0.0.1"); assert!(g.require_pairing); assert!(!g.allow_public_bind); assert!(g.paired_tokens.is_empty()); } // ── Lark config ─────────────────────────────────────────────── #[test] fn lark_config_serde() { let lc = LarkConfig { app_id: "cli_123456".into(), app_secret: "secret_abc".into(), encrypt_key: Some("encrypt_key".into()), verification_token: Some("verify_token".into()), allowed_users: vec!["user_123".into(), "user_456".into()], use_feishu: true, }; let json = serde_json::to_string(&lc).unwrap(); let parsed: LarkConfig = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.app_id, "cli_123456"); assert_eq!(parsed.app_secret, "secret_abc"); assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key")); assert_eq!(parsed.verification_token.as_deref(), Some("verify_token")); assert_eq!(parsed.allowed_users.len(), 2); assert!(parsed.use_feishu); } #[test] fn lark_config_toml_roundtrip() { let lc = LarkConfig { app_id: "cli_123456".into(), app_secret: "secret_abc".into(), encrypt_key: Some("encrypt_key".into()), verification_token: Some("verify_token".into()), allowed_users: vec!["*".into()], use_feishu: false, }; let toml_str = toml::to_string(&lc).unwrap(); let parsed: LarkConfig = toml::from_str(&toml_str).unwrap(); assert_eq!(parsed.app_id, "cli_123456"); assert_eq!(parsed.app_secret, "secret_abc"); assert!(!parsed.use_feishu); } #[test] fn lark_config_deserializes_without_optional_fields() { let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#; let parsed: LarkConfig = serde_json::from_str(json).unwrap(); assert!(parsed.encrypt_key.is_none()); assert!(parsed.verification_token.is_none()); assert!(parsed.allowed_users.is_empty()); assert!(!parsed.use_feishu); } #[test] fn lark_config_defaults_to_lark_endpoint() { let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#; let parsed: LarkConfig = serde_json::from_str(json).unwrap(); assert!( !parsed.use_feishu, "use_feishu should default to false (Lark)" ); } #[test] fn lark_config_with_wildcard_allowed_users() { let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#; let parsed: LarkConfig = serde_json::from_str(json).unwrap(); assert_eq!(parsed.allowed_users, vec!["*"]); } // ══════════════════════════════════════════════════════════ // AGENT DELEGATION CONFIG TESTS // ══════════════════════════════════════════════════════════ #[test] fn agents_config_default_empty() { let c = Config::default(); assert!(c.agents.is_empty()); } #[test] fn agents_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.agents.is_empty()); } #[test] fn agents_config_toml_roundtrip() { let toml_str = r#" default_temperature = 0.7 [agents.researcher] provider = "gemini" model = "gemini-2.0-flash" system_prompt = "You are a research assistant." max_depth = 2 [agents.coder] provider = "openrouter" model = "anthropic/claude-sonnet-4-20250514" "#; let parsed: Config = toml::from_str(toml_str).unwrap(); assert_eq!(parsed.agents.len(), 2); let researcher = &parsed.agents["researcher"]; assert_eq!(researcher.provider, "gemini"); assert_eq!(researcher.model, "gemini-2.0-flash"); assert_eq!( researcher.system_prompt.as_deref(), Some("You are a research assistant.") ); assert_eq!(researcher.max_depth, 2); assert!(researcher.api_key.is_none()); assert!(researcher.temperature.is_none()); let coder = &parsed.agents["coder"]; assert_eq!(coder.provider, "openrouter"); assert_eq!(coder.model, "anthropic/claude-sonnet-4-20250514"); assert!(coder.system_prompt.is_none()); assert_eq!(coder.max_depth, 3); // default } #[test] fn agents_config_with_api_key_and_temperature() { let toml_str = r#" [agents.fast] provider = "groq" model = "llama-3.3-70b-versatile" api_key = "gsk-test-key" temperature = 0.3 "#; let parsed: HashMap = toml::from_str::(toml_str) .unwrap()["agents"] .clone() .try_into() .unwrap(); let fast = &parsed["fast"]; assert_eq!(fast.api_key.as_deref(), Some("gsk-test-key")); assert!((fast.temperature.unwrap() - 0.3).abs() < f64::EPSILON); } #[test] fn agent_api_key_encrypted_on_save_and_decrypted_on_load() { let tmp = TempDir::new().unwrap(); let zeroclaw_dir = tmp.path(); let config_path = zeroclaw_dir.join("config.toml"); // Create a config with a plaintext agent API key let mut agents = HashMap::new(); agents.insert( "test_agent".to_string(), DelegateAgentConfig { provider: "openrouter".to_string(), model: "test-model".to_string(), system_prompt: None, api_key: Some("sk-super-secret".to_string()), temperature: None, max_depth: 3, }, ); let config = Config { config_path: config_path.clone(), workspace_dir: zeroclaw_dir.join("workspace"), secrets: SecretsConfig { encrypt: true }, agents, ..Config::default() }; std::fs::create_dir_all(&config.workspace_dir).unwrap(); config.save().unwrap(); // Read the raw TOML and verify the key is encrypted (not plaintext) let raw = std::fs::read_to_string(&config_path).unwrap(); assert!( !raw.contains("sk-super-secret"), "Plaintext API key should not appear in saved config" ); assert!( raw.contains("enc2:"), "Encrypted key should use enc2: prefix" ); // Parse and decrypt — simulate load_or_init by reading + decrypting let store = crate::security::SecretStore::new(zeroclaw_dir, true); let mut loaded: Config = toml::from_str(&raw).unwrap(); for agent in loaded.agents.values_mut() { if let Some(ref encrypted_key) = agent.api_key { agent.api_key = Some(store.decrypt(encrypted_key).unwrap()); } } assert_eq!( loaded.agents["test_agent"].api_key.as_deref(), Some("sk-super-secret"), "Decrypted key should match original" ); } #[test] fn agent_api_key_not_encrypted_when_disabled() { let tmp = TempDir::new().unwrap(); let zeroclaw_dir = tmp.path(); let config_path = zeroclaw_dir.join("config.toml"); let mut agents = HashMap::new(); agents.insert( "test_agent".to_string(), DelegateAgentConfig { provider: "openrouter".to_string(), model: "test-model".to_string(), system_prompt: None, api_key: Some("sk-plaintext-ok".to_string()), temperature: None, max_depth: 3, }, ); let config = Config { config_path: config_path.clone(), workspace_dir: zeroclaw_dir.join("workspace"), secrets: SecretsConfig { encrypt: false }, agents, ..Config::default() }; std::fs::create_dir_all(&config.workspace_dir).unwrap(); config.save().unwrap(); let raw = std::fs::read_to_string(&config_path).unwrap(); assert!( raw.contains("sk-plaintext-ok"), "With encryption disabled, key should remain plaintext" ); assert!(!raw.contains("enc2:"), "No encryption prefix when disabled"); } }