zeroclaw/src/config/schema.rs
Vernon Stinebaker d5e8fc1652
fix(config): apply env overrides at runtime and fix Docker compose defaults (#279)
- Call apply_env_overrides() after Config::load_or_init() in main.rs so
  environment variables (API_KEY, PROVIDER, ZEROCLAW_GATEWAY_PORT, etc.)
  are actually applied at runtime, not just in tests
- Add ZEROCLAW_ALLOW_PUBLIC_BIND env var support for gateway bind policy
- Fix docker-compose.yml: correct volume path (/zeroclaw-data not /data),
  add ZEROCLAW_ALLOW_PUBLIC_BIND=true for container networking, make host
  port configurable via HOST_PORT env var
- Add docker-compose.override.yml to .gitignore for local dev overrides
2026-02-16 02:12:49 -05:00

2319 lines
77 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::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
// ── Top-level config ──────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Workspace directory - computed from home, not serialized
#[serde(skip)]
pub workspace_dir: PathBuf,
/// Path to config.toml - computed from home, not serialized
#[serde(skip)]
pub config_path: PathBuf,
pub api_key: Option<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,
/// Model routing rules — route `hint:<name>` to specific provider+model combos.
#[serde(default)]
pub model_routes: Vec<ModelRouteConfig>,
#[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,
/// Named delegate agents for agent-to-agent handoff.
///
/// ```toml
/// [agents.researcher]
/// provider = "gemini"
/// model = "gemini-2.0-flash"
/// system_prompt = "You are a research assistant..."
///
/// [agents.coder]
/// provider = "openrouter"
/// model = "anthropic/claude-sonnet-4-20250514"
/// system_prompt = "You are a coding assistant..."
/// ```
#[serde(default)]
pub agents: HashMap<String, DelegateAgentConfig>,
}
// ── 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,
}
}
}
// ── Agent delegation ─────────────────────────────────────────────
/// Configuration for a named delegate agent that can be invoked via the
/// `delegate` tool. Each agent uses its own provider/model combination
/// and system prompt, enabling multi-agent workflows with specialization.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegateAgentConfig {
/// Provider name (e.g. "gemini", "openrouter", "ollama")
pub provider: String,
/// Model identifier for the provider
pub model: String,
/// System prompt defining the agent's role and capabilities
#[serde(default)]
pub system_prompt: Option<String>,
/// Optional API key override (uses default if not set).
/// Stored encrypted when `secrets.encrypt = true`.
#[serde(default)]
pub api_key: Option<String>,
/// Temperature override (uses 0.7 if not set)
#[serde(default)]
pub temperature: Option<f64>,
/// Maximum delegation depth to prevent infinite recursion (default: 3)
#[serde(default = "default_max_delegation_depth")]
pub max_depth: u32,
}
fn default_max_delegation_depth() -> u32 {
3
}
// ── Gateway security ─────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GatewayConfig {
/// Gateway port (default: 8080)
#[serde(default = "default_gateway_port")]
pub port: u16,
/// Gateway host (default: 127.0.0.1)
#[serde(default = "default_gateway_host")]
pub host: String,
/// Require pairing before accepting requests (default: true)
#[serde(default = "default_true")]
pub require_pairing: bool,
/// Allow binding to non-localhost without a tunnel (default: false)
#[serde(default)]
pub allow_public_bind: bool,
/// Paired bearer tokens (managed automatically, not user-edited)
#[serde(default)]
pub paired_tokens: Vec<String>,
/// Max `/pair` requests per minute per client key.
#[serde(default = "default_pair_rate_limit")]
pub pair_rate_limit_per_minute: u32,
/// Max `/webhook` requests per minute per client key.
#[serde(default = "default_webhook_rate_limit")]
pub webhook_rate_limit_per_minute: u32,
/// TTL for webhook idempotency keys.
#[serde(default = "default_idempotency_ttl_secs")]
pub idempotency_ttl_secs: u64,
}
fn default_gateway_port() -> u16 {
3000
}
fn default_gateway_host() -> String {
"127.0.0.1".into()
}
fn default_pair_rate_limit() -> u32 {
10
}
fn default_webhook_rate_limit() -> u32 {
60
}
fn default_idempotency_ttl_secs() -> u64 {
300
}
fn default_true() -> bool {
true
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
port: default_gateway_port(),
host: default_gateway_host(),
require_pairing: true,
allow_public_bind: false,
paired_tokens: Vec::new(),
pair_rate_limit_per_minute: default_pair_rate_limit(),
webhook_rate_limit_per_minute: default_webhook_rate_limit(),
idempotency_ttl_secs: default_idempotency_ttl_secs(),
}
}
}
// ── Composio (managed tool surface) ─────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposioConfig {
/// Enable Composio integration for 1000+ OAuth tools
#[serde(default)]
pub enabled: bool,
/// Composio API key (stored encrypted when secrets.encrypt = true)
#[serde(default)]
pub api_key: Option<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_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<String>,
/// Browser session name (for agent-browser automation)
#[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,
/// OTLP endpoint (e.g. "http://localhost:4318"). Only used when backend = "otel".
#[serde(default)]
pub otel_endpoint: Option<String>,
/// Service name reported to the OTel collector. Defaults to "zeroclaw".
#[serde(default)]
pub otel_service_name: Option<String>,
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
backend: "none".into(),
otel_endpoint: None,
otel_service_name: None,
}
}
}
// ── Autonomy / Security ──────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutonomyConfig {
pub level: AutonomyLevel,
pub workspace_only: bool,
pub allowed_commands: Vec<String>,
pub forbidden_paths: Vec<String>,
pub max_actions_per_hour: u32,
pub max_cost_per_day_cents: u32,
/// Require explicit approval for medium-risk shell commands.
#[serde(default = "default_true")]
pub require_approval_for_medium_risk: bool,
/// Block high-risk shell commands even if allowlisted.
#[serde(default = "default_true")]
pub block_high_risk_commands: bool,
}
impl Default for AutonomyConfig {
fn default() -> Self {
Self {
level: AutonomyLevel::Supervised,
workspace_only: true,
allowed_commands: vec![
"git".into(),
"npm".into(),
"cargo".into(),
"ls".into(),
"cat".into(),
"grep".into(),
"find".into(),
"echo".into(),
"pwd".into(),
"wc".into(),
"head".into(),
"tail".into(),
],
forbidden_paths: vec![
"/etc".into(),
"/root".into(),
"/home".into(),
"/usr".into(),
"/bin".into(),
"/sbin".into(),
"/lib".into(),
"/opt".into(),
"/boot".into(),
"/dev".into(),
"/proc".into(),
"/sys".into(),
"/var".into(),
"/tmp".into(),
"~/.ssh".into(),
"~/.gnupg".into(),
"~/.aws".into(),
"~/.config".into(),
],
max_actions_per_hour: 20,
max_cost_per_day_cents: 500,
require_approval_for_medium_risk: true,
block_high_risk_commands: true,
}
}
}
// ── Runtime ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeConfig {
/// Runtime kind (`native` | `docker`).
#[serde(default = "default_runtime_kind")]
pub kind: String,
/// Docker runtime settings (used when `kind = "docker"`).
#[serde(default)]
pub docker: DockerRuntimeConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerRuntimeConfig {
/// Runtime image used to execute shell commands.
#[serde(default = "default_docker_image")]
pub image: String,
/// Docker network mode (`none`, `bridge`, etc.).
#[serde(default = "default_docker_network")]
pub network: String,
/// Optional memory limit in MB (`None` = no explicit limit).
#[serde(default = "default_docker_memory_limit_mb")]
pub memory_limit_mb: Option<u64>,
/// Optional CPU limit (`None` = no explicit limit).
#[serde(default = "default_docker_cpu_limit")]
pub cpu_limit: Option<f64>,
/// Mount root filesystem as read-only.
#[serde(default = "default_true")]
pub read_only_rootfs: bool,
/// Mount configured workspace into `/workspace`.
#[serde(default = "default_true")]
pub mount_workspace: bool,
/// Optional workspace root allowlist for Docker mount validation.
#[serde(default)]
pub allowed_workspace_roots: Vec<String>,
}
fn default_runtime_kind() -> String {
"native".into()
}
fn default_docker_image() -> String {
"alpine:3.20".into()
}
fn default_docker_network() -> String {
"none".into()
}
fn default_docker_memory_limit_mb() -> Option<u64> {
Some(512)
}
fn default_docker_cpu_limit() -> Option<f64> {
Some(1.0)
}
impl Default for DockerRuntimeConfig {
fn default() -> Self {
Self {
image: default_docker_image(),
network: default_docker_network(),
memory_limit_mb: default_docker_memory_limit_mb(),
cpu_limit: default_docker_cpu_limit(),
read_only_rootfs: true,
mount_workspace: true,
allowed_workspace_roots: Vec::new(),
}
}
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
kind: default_runtime_kind(),
docker: DockerRuntimeConfig::default(),
}
}
}
// ── Reliability / supervision ────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReliabilityConfig {
/// Retries per provider before failing over.
#[serde(default = "default_provider_retries")]
pub provider_retries: u32,
/// Base backoff (ms) for provider retry delay.
#[serde(default = "default_provider_backoff_ms")]
pub provider_backoff_ms: u64,
/// Fallback provider chain (e.g. `["anthropic", "openai"]`).
#[serde(default)]
pub fallback_providers: Vec<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(),
}
}
}
// ── Model routing ────────────────────────────────────────────────
/// Route a task hint to a specific provider + model.
///
/// ```toml
/// [[model_routes]]
/// hint = "reasoning"
/// provider = "openrouter"
/// model = "anthropic/claude-opus-4-20250514"
///
/// [[model_routes]]
/// hint = "fast"
/// provider = "groq"
/// model = "llama-3.3-70b-versatile"
/// ```
///
/// Usage: pass `hint:reasoning` as the model parameter to route the request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRouteConfig {
/// Task hint name (e.g. "reasoning", "fast", "code", "summarize")
pub hint: String,
/// Provider to route to (must match a known provider name)
pub provider: String,
/// Model to use with that provider
pub model: String,
/// Optional API key override for this route's provider
#[serde(default)]
pub api_key: Option<String>,
}
// ── 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>,
pub email: Option<crate::channels::email_channel::EmailConfig>,
pub irc: Option<IrcConfig>,
pub lark: Option<LarkConfig>,
}
impl Default for ChannelsConfig {
fn default() -> Self {
Self {
cli: true,
telegram: None,
discord: None,
slack: None,
webhook: None,
imessage: None,
matrix: None,
whatsapp: None,
email: None,
irc: None,
lark: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelegramConfig {
pub bot_token: String,
pub allowed_users: Vec<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,
/// App secret from Meta Business Suite (for webhook signature verification)
/// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable
#[serde(default)]
pub app_secret: Option<String>,
/// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
#[serde(default)]
pub allowed_numbers: Vec<String>,
}
#[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<String>,
/// Channels to join on connect
#[serde(default)]
pub channels: Vec<String>,
/// Allowed nicknames (case-insensitive) or "*" for all
#[serde(default)]
pub allowed_users: Vec<String>,
/// Server password (for bouncers like ZNC)
pub server_password: Option<String>,
/// NickServ IDENTIFY password
pub nickserv_password: Option<String>,
/// SASL PLAIN password (IRCv3)
pub sasl_password: Option<String>,
/// Verify TLS certificate (default: true)
pub verify_tls: Option<bool>,
}
fn default_irc_port() -> u16 {
6697
}
/// Lark/Feishu configuration for messaging integration
/// Lark is the international version, Feishu is the Chinese version
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LarkConfig {
/// App ID from Lark/Feishu developer console
pub app_id: String,
/// App Secret from Lark/Feishu developer console
pub app_secret: String,
/// Encrypt key for webhook message decryption (optional)
#[serde(default)]
pub encrypt_key: Option<String>,
/// Verification token for webhook validation (optional)
#[serde(default)]
pub verification_token: Option<String>,
/// Allowed user IDs or union IDs (empty = deny all, "*" = allow all)
#[serde(default)]
pub allowed_users: Vec<String>,
/// Whether to use the Feishu (Chinese) endpoint instead of Lark (International)
#[serde(default)]
pub use_feishu: bool,
}
// ── 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(),
model_routes: Vec::new(),
heartbeat: HeartbeatConfig::default(),
channels_config: ChannelsConfig::default(),
memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
identity: IdentityConfig::default(),
agents: HashMap::new(),
}
}
}
impl Config {
pub fn load_or_init() -> Result<Self> {
let home = UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.context("Could not find home directory")?;
let zeroclaw_dir = home.join(".zeroclaw");
let config_path = zeroclaw_dir.join("config.toml");
if !zeroclaw_dir.exists() {
fs::create_dir_all(&zeroclaw_dir).context("Failed to create .zeroclaw directory")?;
fs::create_dir_all(zeroclaw_dir.join("workspace"))
.context("Failed to create workspace directory")?;
}
if config_path.exists() {
let contents =
fs::read_to_string(&config_path).context("Failed to read config file")?;
let mut config: Config =
toml::from_str(&contents).context("Failed to parse config file")?;
// Set computed paths that are skipped during serialization
config.config_path = config_path.clone();
config.workspace_dir = zeroclaw_dir.join("workspace");
// Decrypt agent API keys if encryption is enabled
let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
for agent in config.agents.values_mut() {
if let Some(ref encrypted_key) = agent.api_key {
agent.api_key = Some(
store
.decrypt(encrypted_key)
.context("Failed to decrypt agent API key")?,
);
}
}
Ok(config)
} else {
let mut config = Config::default();
config.config_path = config_path.clone();
config.workspace_dir = zeroclaw_dir.join("workspace");
config.save()?;
Ok(config)
}
}
/// Apply environment variable overrides to config
pub fn apply_env_overrides(&mut self) {
// API Key: ZEROCLAW_API_KEY or API_KEY
if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) {
if !key.is_empty() {
self.api_key = Some(key);
}
}
// Provider: ZEROCLAW_PROVIDER or PROVIDER
if let Ok(provider) =
std::env::var("ZEROCLAW_PROVIDER").or_else(|_| std::env::var("PROVIDER"))
{
if !provider.is_empty() {
self.default_provider = Some(provider);
}
}
// Model: ZEROCLAW_MODEL
if let Ok(model) = std::env::var("ZEROCLAW_MODEL") {
if !model.is_empty() {
self.default_model = Some(model);
}
}
// Workspace directory: ZEROCLAW_WORKSPACE
if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
if !workspace.is_empty() {
self.workspace_dir = PathBuf::from(workspace);
}
}
// Gateway port: ZEROCLAW_GATEWAY_PORT or PORT
if let Ok(port_str) =
std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
{
if let Ok(port) = port_str.parse::<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;
}
}
// Allow public bind: ZEROCLAW_ALLOW_PUBLIC_BIND
if let Ok(val) = std::env::var("ZEROCLAW_ALLOW_PUBLIC_BIND") {
self.gateway.allow_public_bind = val == "1" || val.eq_ignore_ascii_case("true");
}
// Temperature: ZEROCLAW_TEMPERATURE
if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") {
if let Ok(temp) = temp_str.parse::<f64>() {
if (0.0..=2.0).contains(&temp) {
self.default_temperature = temp;
}
}
}
}
pub fn save(&self) -> Result<()> {
// Encrypt agent API keys before serialization
let mut config_to_save = self.clone();
let zeroclaw_dir = self
.config_path
.parent()
.context("Config path must have a parent directory")?;
let store = crate::security::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
for agent in config_to_save.agents.values_mut() {
if let Some(ref plaintext_key) = agent.api_key {
if !crate::security::SecretStore::is_encrypted(plaintext_key) {
agent.api_key = Some(
store
.encrypt(plaintext_key)
.context("Failed to encrypt agent API key")?,
);
}
}
}
let toml_str =
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
let parent_dir = self
.config_path
.parent()
.context("Config path must have a parent directory")?;
fs::create_dir_all(parent_dir).with_context(|| {
format!(
"Failed to create config directory: {}",
parent_dir.display()
)
})?;
let file_name = self
.config_path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("config.toml");
let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
let backup_path = parent_dir.join(format!("{file_name}.bak"));
let mut temp_file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp_path)
.with_context(|| {
format!(
"Failed to create temporary config file: {}",
temp_path.display()
)
})?;
temp_file
.write_all(toml_str.as_bytes())
.context("Failed to write temporary config contents")?;
temp_file
.sync_all()
.context("Failed to fsync temporary config file")?;
drop(temp_file);
let had_existing_config = self.config_path.exists();
if had_existing_config {
fs::copy(&self.config_path, &backup_path).with_context(|| {
format!(
"Failed to create config backup before atomic replace: {}",
backup_path.display()
)
})?;
}
if let Err(e) = fs::rename(&temp_path, &self.config_path) {
let _ = fs::remove_file(&temp_path);
if had_existing_config && backup_path.exists() {
let _ = fs::copy(&backup_path, &self.config_path);
}
anyhow::bail!("Failed to atomically replace config file: {e}");
}
sync_directory(parent_dir)?;
if had_existing_config {
let _ = fs::remove_file(&backup_path);
}
Ok(())
}
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<()> {
let dir = File::open(path)
.with_context(|| format!("Failed to open directory for fsync: {}", path.display()))?;
dir.sync_all()
.with_context(|| format!("Failed to fsync directory metadata: {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
// ── Defaults ─────────────────────────────────────────────
#[test]
fn config_default_has_sane_values() {
let c = Config::default();
assert_eq!(c.default_provider.as_deref(), Some("openrouter"));
assert!(c.default_model.as_deref().unwrap().contains("claude"));
assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
assert!(c.api_key.is_none());
assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
assert!(c.config_path.to_string_lossy().contains("config.toml"));
}
#[test]
fn observability_config_default() {
let o = ObservabilityConfig::default();
assert_eq!(o.backend, "none");
}
#[test]
fn autonomy_config_default() {
let a = AutonomyConfig::default();
assert_eq!(a.level, AutonomyLevel::Supervised);
assert!(a.workspace_only);
assert!(a.allowed_commands.contains(&"git".to_string()));
assert!(a.allowed_commands.contains(&"cargo".to_string()));
assert!(a.forbidden_paths.contains(&"/etc".to_string()));
assert_eq!(a.max_actions_per_hour, 20);
assert_eq!(a.max_cost_per_day_cents, 500);
assert!(a.require_approval_for_medium_risk);
assert!(a.block_high_risk_commands);
}
#[test]
fn runtime_config_default() {
let r = RuntimeConfig::default();
assert_eq!(r.kind, "native");
assert_eq!(r.docker.image, "alpine:3.20");
assert_eq!(r.docker.network, "none");
assert_eq!(r.docker.memory_limit_mb, Some(512));
assert_eq!(r.docker.cpu_limit, Some(1.0));
assert!(r.docker.read_only_rootfs);
assert!(r.docker.mount_workspace);
}
#[test]
fn heartbeat_config_default() {
let h = HeartbeatConfig::default();
assert!(!h.enabled);
assert_eq!(h.interval_minutes, 30);
}
#[test]
fn memory_config_default_hygiene_settings() {
let m = MemoryConfig::default();
assert_eq!(m.backend, "sqlite");
assert!(m.auto_save);
assert!(m.hygiene_enabled);
assert_eq!(m.archive_after_days, 7);
assert_eq!(m.purge_after_days, 30);
assert_eq!(m.conversation_retention_days, 30);
}
#[test]
fn channels_config_default() {
let c = ChannelsConfig::default();
assert!(c.cli);
assert!(c.telegram.is_none());
assert!(c.discord.is_none());
}
// ── Serde round-trip ─────────────────────────────────────
#[test]
fn config_toml_roundtrip() {
let config = Config {
workspace_dir: PathBuf::from("/tmp/test/workspace"),
config_path: PathBuf::from("/tmp/test/config.toml"),
api_key: Some("sk-test-key".into()),
default_provider: Some("openrouter".into()),
default_model: Some("gpt-4o".into()),
default_temperature: 0.5,
observability: ObservabilityConfig {
backend: "log".into(),
..ObservabilityConfig::default()
},
autonomy: AutonomyConfig {
level: AutonomyLevel::Full,
workspace_only: false,
allowed_commands: vec!["docker".into()],
forbidden_paths: vec!["/secret".into()],
max_actions_per_hour: 50,
max_cost_per_day_cents: 1000,
require_approval_for_medium_risk: false,
block_high_risk_commands: true,
},
runtime: RuntimeConfig {
kind: "docker".into(),
..RuntimeConfig::default()
},
reliability: ReliabilityConfig::default(),
model_routes: Vec::new(),
heartbeat: HeartbeatConfig {
enabled: true,
interval_minutes: 15,
},
channels_config: ChannelsConfig {
cli: true,
telegram: Some(TelegramConfig {
bot_token: "123:ABC".into(),
allowed_users: vec!["user1".into()],
}),
discord: None,
slack: None,
webhook: None,
imessage: None,
matrix: None,
whatsapp: None,
email: None,
irc: None,
lark: None,
},
memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
identity: IdentityConfig::default(),
agents: HashMap::new(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.api_key, config.api_key);
assert_eq!(parsed.default_provider, config.default_provider);
assert_eq!(parsed.default_model, config.default_model);
assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
assert_eq!(parsed.observability.backend, "log");
assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
assert!(!parsed.autonomy.workspace_only);
assert_eq!(parsed.runtime.kind, "docker");
assert!(parsed.heartbeat.enabled);
assert_eq!(parsed.heartbeat.interval_minutes, 15);
assert!(parsed.channels_config.telegram.is_some());
assert_eq!(
parsed.channels_config.telegram.unwrap().bot_token,
"123:ABC"
);
}
#[test]
fn config_minimal_toml_uses_defaults() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(parsed.api_key.is_none());
assert!(parsed.default_provider.is_none());
assert_eq!(parsed.observability.backend, "none");
assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
assert_eq!(parsed.runtime.kind, "native");
assert!(!parsed.heartbeat.enabled);
assert!(parsed.channels_config.cli);
assert!(parsed.memory.hygiene_enabled);
assert_eq!(parsed.memory.archive_after_days, 7);
assert_eq!(parsed.memory.purge_after_days, 30);
assert_eq!(parsed.memory.conversation_retention_days, 30);
}
#[test]
fn config_save_and_load_tmpdir() {
let dir = std::env::temp_dir().join("zeroclaw_test_config");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("config.toml");
let config = Config {
workspace_dir: dir.join("workspace"),
config_path: config_path.clone(),
api_key: Some("sk-roundtrip".into()),
default_provider: Some("openrouter".into()),
default_model: Some("test-model".into()),
default_temperature: 0.9,
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
runtime: RuntimeConfig::default(),
reliability: ReliabilityConfig::default(),
model_routes: Vec::new(),
heartbeat: HeartbeatConfig::default(),
channels_config: ChannelsConfig::default(),
memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
identity: IdentityConfig::default(),
agents: HashMap::new(),
};
config.save().unwrap();
assert!(config_path.exists());
let contents = fs::read_to_string(&config_path).unwrap();
let loaded: Config = toml::from_str(&contents).unwrap();
assert_eq!(loaded.api_key.as_deref(), Some("sk-roundtrip"));
assert_eq!(loaded.default_model.as_deref(), Some("test-model"));
assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn config_save_atomic_cleanup() {
let dir =
std::env::temp_dir().join(format!("zeroclaw_test_config_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("config.toml");
let mut config = Config::default();
config.workspace_dir = dir.join("workspace");
config.config_path = config_path.clone();
config.default_model = Some("model-a".into());
config.save().unwrap();
assert!(config_path.exists());
config.default_model = Some("model-b".into());
config.save().unwrap();
let contents = fs::read_to_string(&config_path).unwrap();
assert!(contents.contains("model-b"));
let names: Vec<String> = fs::read_dir(&dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
.collect();
assert!(!names.iter().any(|name| name.contains(".tmp-")));
assert!(!names.iter().any(|name| name.ends_with(".bak")));
let _ = fs::remove_dir_all(&dir);
}
// ── Telegram / Discord config ────────────────────────────
#[test]
fn telegram_config_serde() {
let tc = TelegramConfig {
bot_token: "123:XYZ".into(),
allowed_users: vec!["alice".into(), "bob".into()],
};
let json = serde_json::to_string(&tc).unwrap();
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.bot_token, "123:XYZ");
assert_eq!(parsed.allowed_users.len(), 2);
}
#[test]
fn discord_config_serde() {
let dc = DiscordConfig {
bot_token: "discord-token".into(),
guild_id: Some("12345".into()),
allowed_users: vec![],
};
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,
lark: None,
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.imessage.is_some());
assert!(parsed.matrix.is_some());
assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
}
#[test]
fn channels_config_default_has_no_imessage_matrix() {
let c = ChannelsConfig::default();
assert!(c.imessage.is_none());
assert!(c.matrix.is_none());
}
// ── Edge cases: serde(default) for allowed_users ─────────
#[test]
fn discord_config_deserializes_without_allowed_users() {
// Old configs won't have allowed_users — serde(default) should fill vec![]
let json = r#"{"bot_token":"tok","guild_id":"123"}"#;
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_users.is_empty());
}
#[test]
fn discord_config_deserializes_with_allowed_users() {
let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#;
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["111", "222"]);
}
#[test]
fn slack_config_deserializes_without_allowed_users() {
let json = r#"{"bot_token":"xoxb-tok"}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_users.is_empty());
}
#[test]
fn slack_config_deserializes_with_allowed_users() {
let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["U111"]);
}
#[test]
fn discord_config_toml_backward_compat() {
let toml_str = r#"
bot_token = "tok"
guild_id = "123"
"#;
let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.allowed_users.is_empty());
assert_eq!(parsed.bot_token, "tok");
}
#[test]
fn slack_config_toml_backward_compat() {
let toml_str = r#"
bot_token = "xoxb-tok"
channel_id = "C123"
"#;
let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.allowed_users.is_empty());
assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
}
#[test]
fn webhook_config_with_secret() {
let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
}
#[test]
fn webhook_config_without_secret() {
let json = r#"{"port":8080}"#;
let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
assert!(parsed.secret.is_none());
assert_eq!(parsed.port, 8080);
}
// ── WhatsApp config ──────────────────────────────────────
#[test]
fn whatsapp_config_serde() {
let wc = WhatsAppConfig {
access_token: "EAABx...".into(),
phone_number_id: "123456789".into(),
verify_token: "my-verify-token".into(),
app_secret: None,
allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()],
};
let json = serde_json::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.access_token, "EAABx...");
assert_eq!(parsed.phone_number_id, "123456789");
assert_eq!(parsed.verify_token, "my-verify-token");
assert_eq!(parsed.allowed_numbers.len(), 2);
}
#[test]
fn whatsapp_config_toml_roundtrip() {
let wc = WhatsAppConfig {
access_token: "tok".into(),
phone_number_id: "12345".into(),
verify_token: "verify".into(),
app_secret: Some("secret123".into()),
allowed_numbers: vec!["+1".into()],
};
let toml_str = toml::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.phone_number_id, "12345");
assert_eq!(parsed.allowed_numbers, vec!["+1"]);
}
#[test]
fn whatsapp_config_deserializes_without_allowed_numbers() {
let json = r#"{"access_token":"tok","phone_number_id":"123","verify_token":"ver"}"#;
let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_numbers.is_empty());
}
#[test]
fn whatsapp_config_wildcard_allowed() {
let wc = WhatsAppConfig {
access_token: "tok".into(),
phone_number_id: "123".into(),
verify_token: "ver".into(),
app_secret: None,
allowed_numbers: vec!["*".into()],
};
let toml_str = toml::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.allowed_numbers, vec!["*"]);
}
#[test]
fn channels_config_with_whatsapp() {
let c = ChannelsConfig {
cli: true,
telegram: None,
discord: None,
slack: None,
webhook: None,
imessage: None,
matrix: None,
whatsapp: Some(WhatsAppConfig {
access_token: "tok".into(),
phone_number_id: "123".into(),
verify_token: "ver".into(),
app_secret: None,
allowed_numbers: vec!["+1".into()],
}),
email: None,
irc: None,
lark: None,
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.whatsapp.is_some());
let wa = parsed.whatsapp.unwrap();
assert_eq!(wa.phone_number_id, "123");
assert_eq!(wa.allowed_numbers, vec!["+1"]);
}
#[test]
fn channels_config_default_has_no_whatsapp() {
let c = ChannelsConfig::default();
assert!(c.whatsapp.is_none());
}
// ══════════════════════════════════════════════════════════
// SECURITY CHECKLIST TESTS — Gateway config
// ══════════════════════════════════════════════════════════
#[test]
fn checklist_gateway_default_requires_pairing() {
let g = GatewayConfig::default();
assert!(g.require_pairing, "Pairing must be required by default");
}
#[test]
fn checklist_gateway_default_blocks_public_bind() {
let g = GatewayConfig::default();
assert!(
!g.allow_public_bind,
"Public bind must be blocked by default"
);
}
#[test]
fn checklist_gateway_default_no_tokens() {
let g = GatewayConfig::default();
assert!(
g.paired_tokens.is_empty(),
"No pre-paired tokens by default"
);
assert_eq!(g.pair_rate_limit_per_minute, 10);
assert_eq!(g.webhook_rate_limit_per_minute, 60);
assert_eq!(g.idempotency_ttl_secs, 300);
}
#[test]
fn checklist_gateway_cli_default_host_is_localhost() {
// The CLI default for --host is 127.0.0.1 (checked in main.rs)
// Here we verify the config default matches
let c = Config::default();
assert!(
c.gateway.require_pairing,
"Config default must require pairing"
);
assert!(
!c.gateway.allow_public_bind,
"Config default must block public bind"
);
}
#[test]
fn checklist_gateway_serde_roundtrip() {
let g = GatewayConfig {
port: 3000,
host: "127.0.0.1".into(),
require_pairing: true,
allow_public_bind: false,
paired_tokens: vec!["zc_test_token".into()],
pair_rate_limit_per_minute: 12,
webhook_rate_limit_per_minute: 80,
idempotency_ttl_secs: 600,
};
let toml_str = toml::to_string(&g).unwrap();
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.require_pairing);
assert!(!parsed.allow_public_bind);
assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
assert_eq!(parsed.pair_rate_limit_per_minute, 12);
assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
assert_eq!(parsed.idempotency_ttl_secs, 600);
}
#[test]
fn checklist_gateway_backward_compat_no_gateway_section() {
// Old configs without [gateway] should get secure defaults
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(
parsed.gateway.require_pairing,
"Missing [gateway] must default to require_pairing=true"
);
assert!(
!parsed.gateway.allow_public_bind,
"Missing [gateway] must default to allow_public_bind=false"
);
}
#[test]
fn checklist_autonomy_default_is_workspace_scoped() {
let a = AutonomyConfig::default();
assert!(a.workspace_only, "Default autonomy must be workspace_only");
assert!(
a.forbidden_paths.contains(&"/etc".to_string()),
"Must block /etc"
);
assert!(
a.forbidden_paths.contains(&"/proc".to_string()),
"Must block /proc"
);
assert!(
a.forbidden_paths.contains(&"~/.ssh".to_string()),
"Must block ~/.ssh"
);
}
// ══════════════════════════════════════════════════════════
// COMPOSIO CONFIG TESTS
// ══════════════════════════════════════════════════════════
#[test]
fn composio_config_default_disabled() {
let c = ComposioConfig::default();
assert!(!c.enabled, "Composio must be disabled by default");
assert!(c.api_key.is_none(), "No API key by default");
assert_eq!(c.entity_id, "default");
}
#[test]
fn composio_config_serde_roundtrip() {
let c = ComposioConfig {
enabled: true,
api_key: Some("comp-key-123".into()),
entity_id: "user42".into(),
};
let toml_str = toml::to_string(&c).unwrap();
let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
assert_eq!(parsed.entity_id, "user42");
}
#[test]
fn composio_config_backward_compat_missing_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(
!parsed.composio.enabled,
"Missing [composio] must default to disabled"
);
assert!(parsed.composio.api_key.is_none());
}
#[test]
fn composio_config_partial_toml() {
let toml_str = r"
enabled = true
";
let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.enabled);
assert!(parsed.api_key.is_none());
assert_eq!(parsed.entity_id, "default");
}
// ══════════════════════════════════════════════════════════
// SECRETS CONFIG TESTS
// ══════════════════════════════════════════════════════════
#[test]
fn secrets_config_default_encrypts() {
let s = SecretsConfig::default();
assert!(s.encrypt, "Encryption must be enabled by default");
}
#[test]
fn secrets_config_serde_roundtrip() {
let s = SecretsConfig { encrypt: false };
let toml_str = toml::to_string(&s).unwrap();
let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
assert!(!parsed.encrypt);
}
#[test]
fn secrets_config_backward_compat_missing_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(
parsed.secrets.encrypt,
"Missing [secrets] must default to encrypt=true"
);
}
#[test]
fn config_default_has_composio_and_secrets() {
let c = Config::default();
assert!(!c.composio.enabled);
assert!(c.composio.api_key.is_none());
assert!(c.secrets.encrypt);
assert!(!c.browser.enabled);
assert!(c.browser.allowed_domains.is_empty());
}
#[test]
fn browser_config_default_disabled() {
let b = BrowserConfig::default();
assert!(!b.enabled);
assert!(b.allowed_domains.is_empty());
}
#[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());
}
// ── Lark config ───────────────────────────────────────────────
#[test]
fn lark_config_serde() {
let lc = LarkConfig {
app_id: "cli_123456".into(),
app_secret: "secret_abc".into(),
encrypt_key: Some("encrypt_key".into()),
verification_token: Some("verify_token".into()),
allowed_users: vec!["user_123".into(), "user_456".into()],
use_feishu: true,
};
let json = serde_json::to_string(&lc).unwrap();
let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.app_id, "cli_123456");
assert_eq!(parsed.app_secret, "secret_abc");
assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
assert_eq!(parsed.allowed_users.len(), 2);
assert!(parsed.use_feishu);
}
#[test]
fn lark_config_toml_roundtrip() {
let lc = LarkConfig {
app_id: "cli_123456".into(),
app_secret: "secret_abc".into(),
encrypt_key: Some("encrypt_key".into()),
verification_token: Some("verify_token".into()),
allowed_users: vec!["*".into()],
use_feishu: false,
};
let toml_str = toml::to_string(&lc).unwrap();
let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.app_id, "cli_123456");
assert_eq!(parsed.app_secret, "secret_abc");
assert!(!parsed.use_feishu);
}
#[test]
fn lark_config_deserializes_without_optional_fields() {
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
assert!(parsed.encrypt_key.is_none());
assert!(parsed.verification_token.is_none());
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.use_feishu);
}
#[test]
fn lark_config_defaults_to_lark_endpoint() {
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
assert!(
!parsed.use_feishu,
"use_feishu should default to false (Lark)"
);
}
#[test]
fn lark_config_with_wildcard_allowed_users() {
let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#;
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["*"]);
}
// ══════════════════════════════════════════════════════════
// AGENT DELEGATION CONFIG TESTS
// ══════════════════════════════════════════════════════════
#[test]
fn agents_config_default_empty() {
let c = Config::default();
assert!(c.agents.is_empty());
}
#[test]
fn agents_config_backward_compat_missing_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(parsed.agents.is_empty());
}
#[test]
fn agents_config_toml_roundtrip() {
let toml_str = r#"
default_temperature = 0.7
[agents.researcher]
provider = "gemini"
model = "gemini-2.0-flash"
system_prompt = "You are a research assistant."
max_depth = 2
[agents.coder]
provider = "openrouter"
model = "anthropic/claude-sonnet-4-20250514"
"#;
let parsed: Config = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.agents.len(), 2);
let researcher = &parsed.agents["researcher"];
assert_eq!(researcher.provider, "gemini");
assert_eq!(researcher.model, "gemini-2.0-flash");
assert_eq!(
researcher.system_prompt.as_deref(),
Some("You are a research assistant.")
);
assert_eq!(researcher.max_depth, 2);
assert!(researcher.api_key.is_none());
assert!(researcher.temperature.is_none());
let coder = &parsed.agents["coder"];
assert_eq!(coder.provider, "openrouter");
assert_eq!(coder.model, "anthropic/claude-sonnet-4-20250514");
assert!(coder.system_prompt.is_none());
assert_eq!(coder.max_depth, 3); // default
}
#[test]
fn agents_config_with_api_key_and_temperature() {
let toml_str = r#"
[agents.fast]
provider = "groq"
model = "llama-3.3-70b-versatile"
api_key = "gsk-test-key"
temperature = 0.3
"#;
let parsed: HashMap<String, DelegateAgentConfig> = toml::from_str::<toml::Value>(toml_str)
.unwrap()["agents"]
.clone()
.try_into()
.unwrap();
let fast = &parsed["fast"];
assert_eq!(fast.api_key.as_deref(), Some("gsk-test-key"));
assert!((fast.temperature.unwrap() - 0.3).abs() < f64::EPSILON);
}
#[test]
fn agent_api_key_encrypted_on_save_and_decrypted_on_load() {
let tmp = TempDir::new().unwrap();
let zeroclaw_dir = tmp.path();
let config_path = zeroclaw_dir.join("config.toml");
// Create a config with a plaintext agent API key
let mut agents = HashMap::new();
agents.insert(
"test_agent".to_string(),
DelegateAgentConfig {
provider: "openrouter".to_string(),
model: "test-model".to_string(),
system_prompt: None,
api_key: Some("sk-super-secret".to_string()),
temperature: None,
max_depth: 3,
},
);
let mut config = Config {
config_path: config_path.clone(),
workspace_dir: zeroclaw_dir.join("workspace"),
secrets: SecretsConfig { encrypt: true },
agents,
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config.save().unwrap();
// Read the raw TOML and verify the key is encrypted (not plaintext)
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(
!raw.contains("sk-super-secret"),
"Plaintext API key should not appear in saved config"
);
assert!(
raw.contains("enc2:"),
"Encrypted key should use enc2: prefix"
);
// Parse and decrypt — simulate load_or_init by reading + decrypting
let store = crate::security::SecretStore::new(zeroclaw_dir, true);
let mut loaded: Config = toml::from_str(&raw).unwrap();
for agent in loaded.agents.values_mut() {
if let Some(ref encrypted_key) = agent.api_key {
agent.api_key = Some(store.decrypt(encrypted_key).unwrap());
}
}
assert_eq!(
loaded.agents["test_agent"].api_key.as_deref(),
Some("sk-super-secret"),
"Decrypted key should match original"
);
}
#[test]
fn agent_api_key_not_encrypted_when_disabled() {
let tmp = TempDir::new().unwrap();
let zeroclaw_dir = tmp.path();
let config_path = zeroclaw_dir.join("config.toml");
let mut agents = HashMap::new();
agents.insert(
"test_agent".to_string(),
DelegateAgentConfig {
provider: "openrouter".to_string(),
model: "test-model".to_string(),
system_prompt: None,
api_key: Some("sk-plaintext-ok".to_string()),
temperature: None,
max_depth: 3,
},
);
let config = Config {
config_path: config_path.clone(),
workspace_dir: zeroclaw_dir.join("workspace"),
secrets: SecretsConfig { encrypt: false },
agents,
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config.save().unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(
raw.contains("sk-plaintext-ok"),
"With encryption disabled, key should remain plaintext"
);
assert!(!raw.contains("enc2:"), "No encryption prefix when disabled");
}
}