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 { pub workspace_dir: PathBuf, 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 heartbeat: HeartbeatConfig, #[serde(default)] pub channels_config: ChannelsConfig, #[serde(default)] pub memory: MemoryConfig, #[serde(default)] pub tunnel: TunnelConfig, } // ── 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, } impl Default for MemoryConfig { fn default() -> Self { Self { backend: "sqlite".into(), auto_save: true, } } } // ── 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(), "~/.ssh".into(), "~/.gnupg".into(), ], max_actions_per_hour: 20, max_cost_per_day_cents: 500, } } } // ── Runtime ────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeConfig { /// "native" | "docker" | "cloudflare" pub kind: String, } impl Default for RuntimeConfig { fn default() -> Self { Self { kind: "native".into(), } } } // ── 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, } impl Default for ChannelsConfig { fn default() -> Self { Self { cli: true, telegram: None, discord: None, slack: None, webhook: None, imessage: None, matrix: 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, } // ── 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(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), memory: MemoryConfig::default(), tunnel: TunnelConfig::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 config: Config = toml::from_str(&contents).context("Failed to parse config file")?; Ok(config) } else { let config = Config::default(); config.save()?; Ok(config) } } 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 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(), }, 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, }, memory: MemoryConfig::default(), tunnel: TunnelConfig::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); } #[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(), heartbeat: HeartbeatConfig::default(), channels_config: ChannelsConfig::default(), memory: MemoryConfig::default(), tunnel: TunnelConfig::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()], }), }; 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); } }