zeroclaw/src/config/schema.rs
fettpl 65a5c3c1e8 fix: consolidate env-var override tests to eliminate parallel races
Tests that set/remove the same environment variables can race when
cargo test runs them in parallel. Merges each racing pair into a
single test function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:26:39 +01:00

1635 lines
55 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String>,
pub default_provider: Option<String>,
pub default_model: Option<String>,
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<String>,
/// Inline AIEOS JSON (alternative to file path)
#[serde(default)]
pub aieos_inline: Option<String>,
}
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: 3000)
#[serde(default = "default_gateway_port")]
pub port: u16,
/// Gateway host/bind address (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<String>,
}
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<String>,
/// 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 tools (`browser_open` and browser automation)
#[serde(default)]
pub enabled: bool,
/// Allowed domains for browser tools (exact or subdomain match)
#[serde(default)]
pub allowed_domains: Vec<String>,
/// Session name for agent-browser (persists state across commands)
#[serde(default)]
pub session_name: Option<String>,
}
// ── 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.01.0)
#[serde(default = "default_vector_weight")]
pub vector_weight: f64,
/// Weight for keyword BM25 in hybrid search (0.01.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<String>,
pub forbidden_paths: Vec<String>,
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<String>,
/// 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<CloudflareTunnelConfig>,
#[serde(default)]
pub tailscale: Option<TailscaleTunnelConfig>,
#[serde(default)]
pub ngrok: Option<NgrokTunnelConfig>,
#[serde(default)]
pub custom: Option<CustomTunnelConfig>,
}
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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NgrokTunnelConfig {
/// ngrok auth token
pub auth_token: String,
/// Optional custom domain
pub domain: Option<String>,
}
#[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<String>,
/// Optional regex to extract public URL from command stdout
pub url_pattern: Option<String>,
}
// ── Channels ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelsConfig {
pub cli: bool,
pub telegram: Option<TelegramConfig>,
pub discord: Option<DiscordConfig>,
pub slack: Option<SlackConfig>,
pub webhook: Option<WebhookConfig>,
pub imessage: Option<IMessageConfig>,
pub matrix: Option<MatrixConfig>,
pub whatsapp: Option<WhatsAppConfig>,
}
impl Default for ChannelsConfig {
fn default() -> Self {
Self {
cli: true,
telegram: None,
discord: None,
slack: None,
webhook: None,
imessage: None,
matrix: None,
whatsapp: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelegramConfig {
pub bot_token: String,
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscordConfig {
pub bot_token: String,
pub guild_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlackConfig {
pub bot_token: String,
pub app_token: Option<String>,
pub channel_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
pub port: u16,
pub secret: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IMessageConfig {
pub allowed_contacts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixConfig {
pub homeserver: String,
pub access_token: String,
pub room_id: String,
pub allowed_users: Vec<String>,
}
#[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,
/// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
#[serde(default)]
pub allowed_numbers: Vec<String>,
}
// ── 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<Self> {
// Check for workspace override from environment (Docker support)
let zeroclaw_dir = if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
let ws_path = PathBuf::from(&workspace);
ws_path
.parent()
.map_or_else(|| PathBuf::from(&workspace), PathBuf::from)
} else {
let home = UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.context("Could not find home directory")?;
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")?;
}
let mut config = if config_path.exists() {
let contents =
fs::read_to_string(&config_path).context("Failed to read config file")?;
toml::from_str(&contents).context("Failed to parse config file")?
} else {
Config::default()
};
// Apply environment variable overrides (Docker/container support)
config.apply_env_overrides();
// Save config if it didn't exist (creates default config with env overrides)
if !config_path.exists() {
config.save()?;
}
Ok(config)
}
/// Apply environment variable overrides to config.
///
/// Supports:
/// - `ZEROCLAW_API_KEY` or `API_KEY` - LLM provider API key
/// - `ZEROCLAW_PROVIDER` or `PROVIDER` - Provider name (openrouter, openai, anthropic, ollama)
/// - `ZEROCLAW_MODEL` - Model name/ID
/// - `ZEROCLAW_WORKSPACE` - Workspace directory path
/// - `ZEROCLAW_GATEWAY_PORT` or `PORT` - Gateway server port
/// - `ZEROCLAW_GATEWAY_HOST` or `HOST` - Gateway bind address
/// - `ZEROCLAW_TEMPERATURE` - Default temperature (0.0-2.0)
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);
}
}
// Temperature: ZEROCLAW_TEMPERATURE
if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") {
if let Ok(temp) = temp_str.parse::<f64>() {
if (0.0..=2.0).contains(&temp) {
self.default_temperature = temp;
}
}
}
// 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::<u16>() {
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;
}
}
}
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,
},
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,
};
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(),
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(),
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(),
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(),
allowed_numbers: vec!["+1".into()],
}),
};
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() {
// Primary and fallback tested together to avoid env-var races.
std::env::remove_var("ZEROCLAW_API_KEY");
std::env::remove_var("API_KEY");
// Primary: ZEROCLAW_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");
// Fallback: API_KEY
let mut config2 = Config::default();
std::env::set_var("API_KEY", "sk-fallback-key");
config2.apply_env_overrides();
assert_eq!(config2.api_key.as_deref(), Some("sk-fallback-key"));
std::env::remove_var("API_KEY");
}
#[test]
fn env_override_provider() {
// Primary, fallback, and empty-value tested together to avoid env-var races.
std::env::remove_var("ZEROCLAW_PROVIDER");
std::env::remove_var("PROVIDER");
// Primary: ZEROCLAW_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");
// Fallback: PROVIDER
let mut config2 = Config::default();
std::env::set_var("PROVIDER", "openai");
config2.apply_env_overrides();
assert_eq!(config2.default_provider.as_deref(), Some("openai"));
std::env::remove_var("PROVIDER");
// Empty value should not override
let mut config3 = Config::default();
let original_provider = config3.default_provider.clone();
std::env::set_var("ZEROCLAW_PROVIDER", "");
config3.apply_env_overrides();
assert_eq!(config3.default_provider, original_provider);
std::env::remove_var("ZEROCLAW_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"));
// Clean up
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"));
// Clean up
std::env::remove_var("ZEROCLAW_WORKSPACE");
}
#[test]
fn env_override_gateway_port() {
// Port, fallback, and invalid tested together to avoid env-var races.
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
std::env::remove_var("PORT");
// Primary: ZEROCLAW_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");
// Fallback: PORT
let mut config2 = Config::default();
std::env::set_var("PORT", "9000");
config2.apply_env_overrides();
assert_eq!(config2.gateway.port, 9000);
// Invalid PORT is ignored
let mut config3 = Config::default();
let original_port = config3.gateway.port;
std::env::set_var("PORT", "not_a_number");
config3.apply_env_overrides();
assert_eq!(config3.gateway.port, original_port);
std::env::remove_var("PORT");
}
#[test]
fn env_override_gateway_host() {
// Primary and fallback tested together to avoid env-var races.
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
std::env::remove_var("HOST");
// Primary: ZEROCLAW_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");
// Fallback: HOST
let mut config2 = Config::default();
std::env::set_var("HOST", "0.0.0.0");
config2.apply_env_overrides();
assert_eq!(config2.gateway.host, "0.0.0.0");
std::env::remove_var("HOST");
}
#[test]
fn env_override_temperature() {
// Valid and out-of-range tested together to avoid env-var races.
std::env::remove_var("ZEROCLAW_TEMPERATURE");
// Valid temperature is applied
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);
// Out-of-range temperature is ignored
let mut config2 = Config::default();
let original_temp = config2.default_temperature;
std::env::set_var("ZEROCLAW_TEMPERATURE", "3.0");
config2.apply_env_overrides();
assert!(
(config2.default_temperature - original_temp).abs() < f64::EPSILON,
"Temperature 3.0 should be ignored (out of range)"
);
std::env::remove_var("ZEROCLAW_TEMPERATURE");
}
#[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());
}
}