use crate::security::AutonomyLevel; use anyhow::{Context, Result}; use directories::UserDirs; use serde::{Deserialize, Serialize}; use std::fs; use std::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, #[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 identity: IdentityConfig, } // ── 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, } } } // ── 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, } fn default_gateway_port() -> u16 { 3000 } fn default_gateway_host() -> String { "127.0.0.1".into() } 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(), } } } // ── 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, Default)] pub struct BrowserConfig { /// Enable `browser_open` tool (opens URLs in Brave without scraping) #[serde(default)] pub enabled: bool, /// Allowed domains for `browser_open` (exact or subdomain match) #[serde(default)] pub allowed_domains: Vec, /// Browser session name (for agent-browser automation) #[serde(default)] pub session_name: Option, } // ── 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, } impl Default for ObservabilityConfig { fn default() -> Self { Self { backend: "none".into(), } } } // ── 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, } 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, } } } // ── Runtime ────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeConfig { /// Runtime kind (currently supported: "native"). /// /// Reserved values (not implemented yet): "docker", "cloudflare". pub kind: String, } impl Default for RuntimeConfig { fn default() -> Self { Self { kind: "native".into(), } } } // ── 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(), } } } // ── 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, } 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, } } } #[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, } #[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 for webhook signature verification (X-Hub-Signature-256) #[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 } // ── 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-20250514".to_string()), default_temperature: 0.7, observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), 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(), identity: IdentityConfig::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"); 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; } } // 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<()> { let toml_str = toml::to_string_pretty(self).context("Failed to serialize config")?; fs::write(&self.config_path, toml_str).context("Failed to write config file")?; 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); } #[test] fn runtime_config_default() { let r = RuntimeConfig::default(); assert_eq!(r.kind, "native"); } #[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(), }, 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, }, runtime: RuntimeConfig { kind: "docker".into(), }, reliability: ReliabilityConfig::default(), 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, }, memory: MemoryConfig::default(), tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), identity: IdentityConfig::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(), 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(), identity: IdentityConfig::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); } // ── 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![], }; 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![], }; 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, }; 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: None, 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, }; 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" ); } #[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()], }; 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"]); } #[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()); } #[test] fn browser_config_serde_roundtrip() { let b = BrowserConfig { enabled: true, allowed_domains: vec!["example.com".into(), "docs.example.com".into()], session_name: None, }; let toml_str = toml::to_string(&b).unwrap(); let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap(); assert!(parsed.enabled); assert_eq!(parsed.allowed_domains.len(), 2); assert_eq!(parsed.allowed_domains[0], "example.com"); } #[test] fn browser_config_backward_compat_missing_section() { let minimal = r#" workspace_dir = "/tmp/ws" config_path = "/tmp/config.toml" default_temperature = 0.7 "#; let parsed: Config = toml::from_str(minimal).unwrap(); assert!(!parsed.browser.enabled); assert!(parsed.browser.allowed_domains.is_empty()); } // ── Environment variable overrides (Docker support) ───────── #[test] fn env_override_api_key() { 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 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 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 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 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 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 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 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 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 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 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 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() { // 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 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()); } }