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, /// Base URL override for provider API (e.g. "http://10.0.0.1:11434" for remote Ollama) pub api_url: 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, #[serde(default)] pub scheduler: SchedulerConfig, #[serde(default)] pub agent: AgentConfig, /// Model routing rules — route `hint:` to specific provider+model combos. #[serde(default)] pub model_routes: Vec, #[serde(default)] pub heartbeat: HeartbeatConfig, #[serde(default)] pub cron: CronConfig, #[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, #[serde(default)] pub cost: CostConfig, #[serde(default)] pub peripherals: PeripheralsConfig, /// Delegate agent configurations for multi-agent workflows. #[serde(default)] pub agents: HashMap, /// Hardware configuration (wizard-driven physical world setup). #[serde(default)] pub hardware: HardwareConfig, } // ── Delegate Agents ────────────────────────────────────────────── /// Configuration for a delegate sub-agent used by the `delegate` tool. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DelegateAgentConfig { /// Provider name (e.g. "ollama", "openrouter", "anthropic") pub provider: String, /// Model name pub model: String, /// Optional system prompt for the sub-agent #[serde(default)] pub system_prompt: Option, /// Optional API key override #[serde(default)] pub api_key: Option, /// Temperature override #[serde(default)] pub temperature: Option, /// Max recursion depth for nested delegation #[serde(default = "default_max_depth")] pub max_depth: u32, } fn default_max_depth() -> u32 { 3 } // ── Hardware Config (wizard-driven) ───────────────────────────── /// Hardware transport mode. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum HardwareTransport { None, Native, Serial, Probe, } impl Default for HardwareTransport { fn default() -> Self { Self::None } } impl std::fmt::Display for HardwareTransport { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::None => write!(f, "none"), Self::Native => write!(f, "native"), Self::Serial => write!(f, "serial"), Self::Probe => write!(f, "probe"), } } } /// Wizard-driven hardware configuration for physical world interaction. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HardwareConfig { /// Whether hardware access is enabled #[serde(default)] pub enabled: bool, /// Transport mode #[serde(default)] pub transport: HardwareTransport, /// Serial port path (e.g. "/dev/ttyACM0") #[serde(default)] pub serial_port: Option, /// Serial baud rate #[serde(default = "default_baud_rate")] pub baud_rate: u32, /// Probe target chip (e.g. "STM32F401RE") #[serde(default)] pub probe_target: Option, /// Enable workspace datasheet RAG (index PDF schematics for AI pin lookups) #[serde(default)] pub workspace_datasheets: bool, } fn default_baud_rate() -> u32 { 115_200 } impl HardwareConfig { /// Return the active transport mode. pub fn transport_mode(&self) -> HardwareTransport { self.transport.clone() } } impl Default for HardwareConfig { fn default() -> Self { Self { enabled: false, transport: HardwareTransport::None, serial_port: None, baud_rate: default_baud_rate(), probe_target: None, workspace_datasheets: false, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentConfig { /// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models. #[serde(default)] pub compact_context: bool, #[serde(default = "default_agent_max_tool_iterations")] pub max_tool_iterations: usize, #[serde(default = "default_agent_max_history_messages")] pub max_history_messages: usize, #[serde(default)] pub parallel_tools: bool, #[serde(default = "default_agent_tool_dispatcher")] pub tool_dispatcher: String, } fn default_agent_max_tool_iterations() -> usize { 10 } fn default_agent_max_history_messages() -> usize { 50 } fn default_agent_tool_dispatcher() -> String { "auto".into() } impl Default for AgentConfig { fn default() -> Self { Self { compact_context: false, max_tool_iterations: default_agent_max_tool_iterations(), max_history_messages: default_agent_max_history_messages(), parallel_tools: false, tool_dispatcher: default_agent_tool_dispatcher(), } } } // ── 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, } } } // ── Cost tracking and budget enforcement ─────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CostConfig { /// Enable cost tracking (default: false) #[serde(default)] pub enabled: bool, /// Daily spending limit in USD (default: 10.00) #[serde(default = "default_daily_limit")] pub daily_limit_usd: f64, /// Monthly spending limit in USD (default: 100.00) #[serde(default = "default_monthly_limit")] pub monthly_limit_usd: f64, /// Warn when spending reaches this percentage of limit (default: 80) #[serde(default = "default_warn_percent")] pub warn_at_percent: u8, /// Allow requests to exceed budget with --override flag (default: false) #[serde(default)] pub allow_override: bool, /// Per-model pricing (USD per 1M tokens) #[serde(default)] pub prices: std::collections::HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelPricing { /// Input price per 1M tokens #[serde(default)] pub input: f64, /// Output price per 1M tokens #[serde(default)] pub output: f64, } fn default_daily_limit() -> f64 { 10.0 } fn default_monthly_limit() -> f64 { 100.0 } fn default_warn_percent() -> u8 { 80 } impl Default for CostConfig { fn default() -> Self { Self { enabled: false, daily_limit_usd: default_daily_limit(), monthly_limit_usd: default_monthly_limit(), warn_at_percent: default_warn_percent(), allow_override: false, prices: get_default_pricing(), } } } /// Default pricing for popular models (USD per 1M tokens) fn get_default_pricing() -> std::collections::HashMap { let mut prices = std::collections::HashMap::new(); // Anthropic models prices.insert( "anthropic/claude-sonnet-4-20250514".into(), ModelPricing { input: 3.0, output: 15.0, }, ); prices.insert( "anthropic/claude-opus-4-20250514".into(), ModelPricing { input: 15.0, output: 75.0, }, ); prices.insert( "anthropic/claude-3.5-sonnet".into(), ModelPricing { input: 3.0, output: 15.0, }, ); prices.insert( "anthropic/claude-3-haiku".into(), ModelPricing { input: 0.25, output: 1.25, }, ); // OpenAI models prices.insert( "openai/gpt-4o".into(), ModelPricing { input: 5.0, output: 15.0, }, ); prices.insert( "openai/gpt-4o-mini".into(), ModelPricing { input: 0.15, output: 0.60, }, ); prices.insert( "openai/o1-preview".into(), ModelPricing { input: 15.0, output: 60.0, }, ); // Google models prices.insert( "google/gemini-2.0-flash".into(), ModelPricing { input: 0.10, output: 0.40, }, ); prices.insert( "google/gemini-1.5-pro".into(), ModelPricing { input: 1.25, output: 5.0, }, ); prices } // ── Peripherals (hardware: STM32, RPi GPIO, etc.) ──────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PeripheralsConfig { /// Enable peripheral support (boards become agent tools) #[serde(default)] pub enabled: bool, /// Board configurations (nucleo-f401re, rpi-gpio, etc.) #[serde(default)] pub boards: Vec, /// Path to datasheet docs (relative to workspace) for RAG retrieval. /// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md). #[serde(default)] pub datasheet_dir: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PeripheralBoardConfig { /// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc. pub board: String, /// Transport: "serial", "native", "websocket" #[serde(default = "default_peripheral_transport")] pub transport: String, /// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0" #[serde(default)] pub path: Option, /// Baud rate for serial (default: 115200) #[serde(default = "default_peripheral_baud")] pub baud: u32, } fn default_peripheral_transport() -> String { "serial".into() } fn default_peripheral_baud() -> u32 { 115_200 } impl Default for PeripheralsConfig { fn default() -> Self { Self { enabled: false, boards: Vec::new(), datasheet_dir: None, } } } impl Default for PeripheralBoardConfig { fn default() -> Self { Self { board: String::new(), transport: default_peripheral_transport(), path: None, baud: default_peripheral_baud(), } } } // ── 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 BrowserComputerUseConfig { /// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot) #[serde(default = "default_browser_computer_use_endpoint")] pub endpoint: String, /// Optional bearer token for computer-use sidecar #[serde(default)] pub api_key: Option, /// Per-action request timeout in milliseconds #[serde(default = "default_browser_computer_use_timeout_ms")] pub timeout_ms: u64, /// Allow remote/public endpoint for computer-use sidecar (default: false) #[serde(default)] pub allow_remote_endpoint: bool, /// Optional window title/process allowlist forwarded to sidecar policy #[serde(default)] pub window_allowlist: Vec, /// Optional X-axis boundary for coordinate-based actions #[serde(default)] pub max_coordinate_x: Option, /// Optional Y-axis boundary for coordinate-based actions #[serde(default)] pub max_coordinate_y: Option, } fn default_browser_computer_use_endpoint() -> String { "http://127.0.0.1:8787/v1/actions".into() } fn default_browser_computer_use_timeout_ms() -> u64 { 15_000 } impl Default for BrowserComputerUseConfig { fn default() -> Self { Self { endpoint: default_browser_computer_use_endpoint(), api_key: None, timeout_ms: default_browser_computer_use_timeout_ms(), allow_remote_endpoint: false, window_allowlist: Vec::new(), max_coordinate_x: None, max_coordinate_y: None, } } } #[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" | "computer_use" | "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, /// Computer-use sidecar configuration #[serde(default)] pub computer_use: BrowserComputerUseConfig, } 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, computer_use: BrowserComputerUseConfig::default(), } } } // ── 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" | "lucid" | "markdown" | "none" (`none` = explicit no-op memory) 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, // ── Response Cache (saves tokens on repeated prompts) ────── /// Enable LLM response caching to avoid paying for duplicate prompts #[serde(default)] pub response_cache_enabled: bool, /// TTL in minutes for cached responses (default: 60) #[serde(default = "default_response_cache_ttl")] pub response_cache_ttl_minutes: u32, /// Max number of cached responses before LRU eviction (default: 5000) #[serde(default = "default_response_cache_max")] pub response_cache_max_entries: usize, // ── Memory Snapshot (soul backup to Markdown) ───────────── /// Enable periodic export of core memories to MEMORY_SNAPSHOT.md #[serde(default)] pub snapshot_enabled: bool, /// Run snapshot during hygiene passes (heartbeat-driven) #[serde(default)] pub snapshot_on_hygiene: bool, /// Auto-hydrate from MEMORY_SNAPSHOT.md when brain.db is missing #[serde(default = "default_true")] pub auto_hydrate: bool, } 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 } fn default_response_cache_ttl() -> u32 { 60 } fn default_response_cache_max() -> usize { 5_000 } 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(), response_cache_enabled: false, response_cache_ttl_minutes: default_response_cache_ttl(), response_cache_max_entries: default_response_cache_max(), snapshot_enabled: false, snapshot_on_hygiene: false, auto_hydrate: true, } } } // ── 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, /// Additional API keys for round-robin rotation on rate-limit (429) errors. /// The primary `api_key` is always tried first; these are extras. #[serde(default)] pub api_keys: Vec, /// Per-model fallback chains. When a model fails, try these alternatives in order. /// Example: `{ "claude-opus-4-20250514" = ["claude-sonnet-4-20250514", "gpt-4o"] }` #[serde(default)] pub model_fallbacks: std::collections::HashMap>, /// 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(), api_keys: Vec::new(), model_fallbacks: std::collections::HashMap::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(), } } } // ── Scheduler ──────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchedulerConfig { /// Enable the built-in scheduler loop. #[serde(default = "default_scheduler_enabled")] pub enabled: bool, /// Maximum number of persisted scheduled tasks. #[serde(default = "default_scheduler_max_tasks")] pub max_tasks: usize, /// Maximum tasks executed per scheduler polling cycle. #[serde(default = "default_scheduler_max_concurrent")] pub max_concurrent: usize, } fn default_scheduler_enabled() -> bool { true } fn default_scheduler_max_tasks() -> usize { 64 } fn default_scheduler_max_concurrent() -> usize { 4 } impl Default for SchedulerConfig { fn default() -> Self { Self { enabled: default_scheduler_enabled(), max_tasks: default_scheduler_max_tasks(), max_concurrent: default_scheduler_max_concurrent(), } } } // ── 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, } } } // ── Cron ──────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CronConfig { #[serde(default = "default_true")] pub enabled: bool, #[serde(default = "default_max_run_history")] pub max_run_history: u32, } fn default_max_run_history() -> u32 { 50 } impl Default for CronConfig { fn default() -> Self { Self { enabled: true, max_run_history: default_max_run_history(), } } } // ── 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, pub dingtalk: 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, dingtalk: 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, /// When true, only respond to messages that @-mention the bot. /// Other messages in the guild are silently ignored. #[serde(default)] pub mention_only: 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 } /// How ZeroClaw receives events from Feishu / Lark. /// /// - `websocket` (default) — persistent WSS long-connection; no public URL required. /// - `webhook` — HTTP callback server; requires a public HTTPS endpoint. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum LarkReceiveMode { #[default] Websocket, Webhook, } /// 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, /// Event receive mode: "websocket" (default) or "webhook" #[serde(default)] pub receive_mode: LarkReceiveMode, /// HTTP port for webhook mode only. Must be set when receive_mode = "webhook". /// Not required (and ignored) for websocket mode. #[serde(default)] pub port: Option, } // ── Security Config ───────────────────────────────────────────────── /// Security configuration for sandboxing, resource limits, and audit logging #[derive(Debug, Clone, Serialize, Deserialize, Default)] 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, } /// 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, Default)] #[serde(rename_all = "lowercase")] pub enum SandboxBackend { /// Auto-detect best available (default) #[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, } /// 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, } } } /// DingTalk (钉钉) configuration for Stream Mode messaging #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DingTalkConfig { /// Client ID (AppKey) from DingTalk developer console pub client_id: String, /// Client Secret (AppSecret) from DingTalk developer console pub client_secret: String, /// Allowed user IDs (staff IDs). Empty = deny all, "*" = allow all #[serde(default)] pub allowed_users: Vec, } // ── 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, api_url: 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(), scheduler: SchedulerConfig::default(), agent: AgentConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), cron: CronConfig::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(), cost: CostConfig::default(), peripherals: PeripheralsConfig::default(), agents: HashMap::new(), hardware: HardwareConfig::default(), } } } fn default_config_and_workspace_dirs() -> Result<(PathBuf, PathBuf)> { let home = UserDirs::new() .map(|u| u.home_dir().to_path_buf()) .context("Could not find home directory")?; let config_dir = home.join(".zeroclaw"); Ok((config_dir.clone(), config_dir.join("workspace"))) } fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> PathBuf { let workspace_config_dir = workspace_dir.to_path_buf(); if workspace_config_dir.join("config.toml").exists() { return workspace_config_dir; } let legacy_config_dir = workspace_dir .parent() .map(|parent| parent.join(".zeroclaw")); if let Some(legacy_dir) = legacy_config_dir { if legacy_dir.join("config.toml").exists() { return legacy_dir; } if workspace_dir .file_name() .is_some_and(|name| name == std::ffi::OsStr::new("workspace")) { return legacy_dir; } } workspace_config_dir } fn decrypt_optional_secret( store: &crate::security::SecretStore, value: &mut Option, field_name: &str, ) -> Result<()> { if let Some(raw) = value.clone() { if crate::security::SecretStore::is_encrypted(&raw) { *value = Some( store .decrypt(&raw) .with_context(|| format!("Failed to decrypt {field_name}"))?, ); } } Ok(()) } fn encrypt_optional_secret( store: &crate::security::SecretStore, value: &mut Option, field_name: &str, ) -> Result<()> { if let Some(raw) = value.clone() { if !crate::security::SecretStore::is_encrypted(&raw) { *value = Some( store .encrypt(&raw) .with_context(|| format!("Failed to encrypt {field_name}"))?, ); } } Ok(()) } impl Config { pub fn load_or_init() -> Result { // Resolve workspace first so config loading can follow ZEROCLAW_WORKSPACE. let (zeroclaw_dir, workspace_dir) = match std::env::var("ZEROCLAW_WORKSPACE") { Ok(custom_workspace) if !custom_workspace.is_empty() => { let workspace = PathBuf::from(custom_workspace); (resolve_config_dir_for_workspace(&workspace), workspace) } _ => default_config_and_workspace_dirs()?, }; let config_path = zeroclaw_dir.join("config.toml"); fs::create_dir_all(&zeroclaw_dir).context("Failed to create config directory")?; fs::create_dir_all(&workspace_dir).context("Failed to create workspace directory")?; if config_path.exists() { // Warn if config file is world-readable (may contain API keys) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Ok(meta) = fs::metadata(&config_path) { if meta.permissions().mode() & 0o004 != 0 { tracing::warn!( "Config file {:?} is world-readable (mode {:o}). \ Consider restricting with: chmod 600 {:?}", config_path, meta.permissions().mode() & 0o777, config_path, ); } } } 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 = workspace_dir; let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt); decrypt_optional_secret(&store, &mut config.api_key, "config.api_key")?; decrypt_optional_secret( &store, &mut config.composio.api_key, "config.composio.api_key", )?; decrypt_optional_secret( &store, &mut config.browser.computer_use.api_key, "config.browser.computer_use.api_key", )?; for agent in config.agents.values_mut() { decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?; } config.apply_env_overrides(); Ok(config) } else { let mut config = Config::default(); config.config_path = config_path.clone(); config.workspace_dir = workspace_dir; config.save()?; // Restrict permissions on newly created config file (may contain API keys) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600)); } config.apply_env_overrides(); Ok(config) } } /// Apply environment variable overrides to config pub fn apply_env_overrides(&mut self) { // API Key: ZEROCLAW_API_KEY or API_KEY (generic) 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); } } // API Key: GLM_API_KEY overrides when provider is glm (provider-specific) if self.default_provider.as_deref() == Some("glm") || self.default_provider.as_deref() == Some("zhipu") { if let Ok(key) = std::env::var("GLM_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 secrets 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); encrypt_optional_secret(&store, &mut config_to_save.api_key, "config.api_key")?; encrypt_optional_secret( &store, &mut config_to_save.composio.api_key, "config.composio.api_key", )?; encrypt_optional_secret( &store, &mut config_to_save.browser.computer_use.api_key, "config.browser.computer_use.api_key", )?; for agent in config_to_save.agents.values_mut() { encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.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; // ── 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 cron_config_default() { let c = CronConfig::default(); assert!(c.enabled); assert_eq!(c.max_run_history, 50); } #[test] fn cron_config_serde_roundtrip() { let c = CronConfig { enabled: false, max_run_history: 100, }; let json = serde_json::to_string(&c).unwrap(); let parsed: CronConfig = serde_json::from_str(&json).unwrap(); assert!(!parsed.enabled); assert_eq!(parsed.max_run_history, 100); } #[test] fn config_defaults_cron_when_section_missing() { let toml_str = r#" workspace_dir = "/tmp/workspace" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; let parsed: Config = toml::from_str(toml_str).unwrap(); assert!(parsed.cron.enabled); assert_eq!(parsed.cron.max_run_history, 50); } #[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()), api_url: None, 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(), scheduler: SchedulerConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig { enabled: true, interval_minutes: 15, }, cron: CronConfig::default(), 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, dingtalk: None, }, memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), agent: AgentConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), peripherals: PeripheralsConfig::default(), agents: HashMap::new(), hardware: HardwareConfig::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 agent_config_defaults() { let cfg = AgentConfig::default(); assert!(!cfg.compact_context); assert_eq!(cfg.max_tool_iterations, 10); assert_eq!(cfg.max_history_messages, 50); assert!(!cfg.parallel_tools); assert_eq!(cfg.tool_dispatcher, "auto"); } #[test] fn agent_config_deserializes() { let raw = r#" default_temperature = 0.7 [agent] compact_context = true max_tool_iterations = 20 max_history_messages = 80 parallel_tools = true tool_dispatcher = "xml" "#; let parsed: Config = toml::from_str(raw).unwrap(); assert!(parsed.agent.compact_context); assert_eq!(parsed.agent.max_tool_iterations, 20); assert_eq!(parsed.agent.max_history_messages, 80); assert!(parsed.agent.parallel_tools); assert_eq!(parsed.agent.tool_dispatcher, "xml"); } #[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()), api_url: None, 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(), scheduler: SchedulerConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), cron: CronConfig::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(), agent: AgentConfig::default(), identity: IdentityConfig::default(), cost: CostConfig::default(), peripherals: PeripheralsConfig::default(), agents: HashMap::new(), hardware: HardwareConfig::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!(loaded .api_key .as_deref() .is_some_and(crate::security::SecretStore::is_encrypted)); let store = crate::security::SecretStore::new(&dir, true); let decrypted = store.decrypt(loaded.api_key.as_deref().unwrap()).unwrap(); assert_eq!(decrypted, "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_encrypts_nested_credentials() { let dir = std::env::temp_dir().join(format!( "zeroclaw_test_nested_credentials_{}", uuid::Uuid::new_v4() )); fs::create_dir_all(&dir).unwrap(); let mut config = Config::default(); config.workspace_dir = dir.join("workspace"); config.config_path = dir.join("config.toml"); config.api_key = Some("root-credential".into()); config.composio.api_key = Some("composio-credential".into()); config.browser.computer_use.api_key = Some("browser-credential".into()); config.agents.insert( "worker".into(), DelegateAgentConfig { provider: "openrouter".into(), model: "model-test".into(), system_prompt: None, api_key: Some("agent-credential".into()), temperature: None, max_depth: 3, }, ); config.save().unwrap(); let contents = fs::read_to_string(config.config_path.clone()).unwrap(); let stored: Config = toml::from_str(&contents).unwrap(); let store = crate::security::SecretStore::new(&dir, true); let root_encrypted = stored.api_key.as_deref().unwrap(); assert!(crate::security::SecretStore::is_encrypted(root_encrypted)); assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential"); let composio_encrypted = stored.composio.api_key.as_deref().unwrap(); assert!(crate::security::SecretStore::is_encrypted( composio_encrypted )); assert_eq!( store.decrypt(composio_encrypted).unwrap(), "composio-credential" ); let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap(); assert!(crate::security::SecretStore::is_encrypted( browser_encrypted )); assert_eq!( store.decrypt(browser_encrypted).unwrap(), "browser-credential" ); let worker = stored.agents.get("worker").unwrap(); let worker_encrypted = worker.api_key.as_deref().unwrap(); assert!(crate::security::SecretStore::is_encrypted(worker_encrypted)); assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential"); 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, mention_only: 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, mention_only: 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, dingtalk: 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, dingtalk: 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()); assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions"); assert_eq!(b.computer_use.timeout_ms, 15_000); assert!(!b.computer_use.allow_remote_endpoint); assert!(b.computer_use.window_allowlist.is_empty()); assert!(b.computer_use.max_coordinate_x.is_none()); assert!(b.computer_use.max_coordinate_y.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()), computer_use: BrowserComputerUseConfig { endpoint: "https://computer-use.example.com/v1/actions".into(), api_key: Some("test-token".into()), timeout_ms: 8_000, allow_remote_endpoint: true, window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()], max_coordinate_x: Some(3840), max_coordinate_y: Some(2160), }, }; 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") ); assert_eq!( parsed.computer_use.endpoint, "https://computer-use.example.com/v1/actions" ); assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token")); assert_eq!(parsed.computer_use.timeout_ms, 8_000); assert!(parsed.computer_use.allow_remote_endpoint); assert_eq!(parsed.computer_use.window_allowlist.len(), 2); assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840)); assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160)); } #[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()); } // ── Environment variable overrides (Docker support) ───────── fn env_override_test_guard() -> std::sync::MutexGuard<'static, ()> { static ENV_OVERRIDE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); ENV_OVERRIDE_TEST_LOCK .lock() .expect("env override test lock poisoned") } #[test] fn env_override_api_key() { let _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 load_or_init_workspace_override_uses_workspace_root_for_config() { let _env_guard = env_override_test_guard(); let temp_home = std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4())); let workspace_dir = temp_home.join("profile-a"); let original_home = std::env::var("HOME").ok(); std::env::set_var("HOME", &temp_home); std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir); let config = Config::load_or_init().unwrap(); assert_eq!(config.workspace_dir, workspace_dir); assert_eq!(config.config_path, workspace_dir.join("config.toml")); assert!(workspace_dir.join("config.toml").exists()); std::env::remove_var("ZEROCLAW_WORKSPACE"); if let Some(home) = original_home { std::env::set_var("HOME", home); } else { std::env::remove_var("HOME"); } let _ = fs::remove_dir_all(temp_home); } #[test] fn load_or_init_workspace_suffix_uses_legacy_config_layout() { let _env_guard = env_override_test_guard(); let temp_home = std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4())); let workspace_dir = temp_home.join("workspace"); let legacy_config_path = temp_home.join(".zeroclaw").join("config.toml"); let original_home = std::env::var("HOME").ok(); std::env::set_var("HOME", &temp_home); std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir); let config = Config::load_or_init().unwrap(); assert_eq!(config.workspace_dir, workspace_dir); assert_eq!(config.config_path, legacy_config_path); assert!(config.config_path.exists()); std::env::remove_var("ZEROCLAW_WORKSPACE"); if let Some(home) = original_home { std::env::set_var("HOME", home); } else { std::env::remove_var("HOME"); } let _ = fs::remove_dir_all(temp_home); } #[test] fn load_or_init_workspace_override_keeps_existing_legacy_config() { let _env_guard = env_override_test_guard(); let temp_home = std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4())); let workspace_dir = temp_home.join("custom-workspace"); let legacy_config_dir = temp_home.join(".zeroclaw"); let legacy_config_path = legacy_config_dir.join("config.toml"); fs::create_dir_all(&legacy_config_dir).unwrap(); fs::write( &legacy_config_path, r#"default_temperature = 0.7 default_model = "legacy-model" "#, ) .unwrap(); let original_home = std::env::var("HOME").ok(); std::env::set_var("HOME", &temp_home); std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir); let config = Config::load_or_init().unwrap(); assert_eq!(config.workspace_dir, workspace_dir); assert_eq!(config.config_path, legacy_config_path); assert_eq!(config.default_model.as_deref(), Some("legacy-model")); std::env::remove_var("ZEROCLAW_WORKSPACE"); if let Some(home) = original_home { std::env::set_var("HOME", home); } else { std::env::remove_var("HOME"); } let _ = fs::remove_dir_all(temp_home); } #[test] fn env_override_empty_values_ignored() { let _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); 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 _env_guard = env_override_test_guard(); // 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 _env_guard = env_override_test_guard(); 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()); } // ── Peripherals config ─────────────────────────────────────── #[test] fn peripherals_config_default_disabled() { let p = PeripheralsConfig::default(); assert!(!p.enabled); assert!(p.boards.is_empty()); } #[test] fn peripheral_board_config_defaults() { let b = PeripheralBoardConfig::default(); assert!(b.board.is_empty()); assert_eq!(b.transport, "serial"); assert!(b.path.is_none()); assert_eq!(b.baud, 115_200); } #[test] fn peripherals_config_toml_roundtrip() { let p = PeripheralsConfig { enabled: true, boards: vec![PeripheralBoardConfig { board: "nucleo-f401re".into(), transport: "serial".into(), path: Some("/dev/ttyACM0".into()), baud: 115_200, }], datasheet_dir: None, }; let toml_str = toml::to_string(&p).unwrap(); let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap(); assert!(parsed.enabled); assert_eq!(parsed.boards.len(), 1); assert_eq!(parsed.boards[0].board, "nucleo-f401re"); assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0")); } #[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, receive_mode: LarkReceiveMode::Websocket, port: None, }; 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, receive_mode: LarkReceiveMode::Webhook, port: Some(9898), }; 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!["*"]); } // ── Config file permission hardening (Unix only) ─────────────── #[cfg(unix)] #[test] fn new_config_file_has_restricted_permissions() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::TempDir::new().unwrap(); let config_path = tmp.path().join("config.toml"); // Create a config and save it let mut config = Config::default(); config.config_path = config_path.clone(); config.save().unwrap(); // Apply the same permission logic as load_or_init let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600)); let meta = std::fs::metadata(&config_path).unwrap(); let mode = meta.permissions().mode() & 0o777; assert_eq!( mode, 0o600, "New config file should be owner-only (0600), got {mode:o}" ); } #[cfg(unix)] #[test] fn world_readable_config_is_detectable() { use std::os::unix::fs::PermissionsExt; let tmp = tempfile::TempDir::new().unwrap(); let config_path = tmp.path().join("config.toml"); // Create a config file with intentionally loose permissions std::fs::write(&config_path, "# test config").unwrap(); std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap(); let meta = std::fs::metadata(&config_path).unwrap(); let mode = meta.permissions().mode(); assert!( mode & 0o004 != 0, "Test setup: file should be world-readable (mode {mode:o})" ); } }