Skip to main content

ravenclaws/
config.rs

1//! RavenClaws
2//!
3//! Secure by default: no credentials in config files, use environment variables.
4//! Supports multiple LLM providers: LiteLLM, OpenRouter, Ollama, OpenAI.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use zeroize::Zeroize;
9
10/// Configuration error type.
11///
12/// # Stability
13/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
14#[derive(Error, Debug)]
15#[non_exhaustive]
16pub enum ConfigError {
17    #[error("Failed to load config: {0}")]
18    LoadError(String),
19    #[error("Invalid configuration: {0}")]
20    ValidationError(String),
21    #[error("Missing required environment variable: {0}")]
22    #[allow(dead_code)]
23    MissingEnvVar(String),
24}
25
26/// LLM Provider type — determines which backend to use
27///
28/// # Stability
29/// This enum is `#[non_exhaustive]` — new variants may be added in minor releases.
30/// Match with a wildcard arm to handle future variants.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
32#[serde(rename_all = "lowercase")]
33#[non_exhaustive]
34pub enum LLMProvider {
35    #[default]
36    LiteLLM,
37    OpenRouter,
38    Ollama,
39    OpenAI,
40    Anthropic,
41    /// Generic OpenAI-compatible provider (vLLM, llama.cpp, LM Studio, TGI, Groq, Together AI, etc.)
42    #[serde(rename = "openai-compatible")]
43    OpenAICompatible,
44    /// Azure OpenAI Service
45    #[serde(rename = "azure")]
46    Azure,
47}
48
49/// Top-level configuration for RavenClaws.
50///
51/// # Stability
52/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
53/// Construct using `Config::load()` or use `..Default::default()` for updates.
54#[derive(Debug, Clone, Deserialize, Default)]
55#[non_exhaustive]
56pub struct Config {
57    /// LiteLLM configuration (single provider mode)
58    #[serde(default)]
59    pub llm: LLMConfig,
60
61    /// Multiple LLM configurations (multi-model mode)
62    #[serde(default)]
63    pub llms: Vec<LLMConfig>,
64
65    /// RavenFabric configuration
66    #[serde(default)]
67    pub ravenfabric: RavenFabricConfig,
68
69    /// Security settings
70    #[serde(default)]
71    pub security: SecurityConfig,
72
73    /// Runtime settings
74    #[serde(default)]
75    pub runtime: RuntimeConfig,
76
77    /// Telemetry / OpenTelemetry settings (v0.7.2)
78    #[serde(default)]
79    pub telemetry: TelemetryConfig,
80
81    /// Scheduler / triggers configuration (v0.8)
82    #[serde(default)]
83    pub scheduler: SchedulerConfig,
84
85    /// Web search configuration (v0.8)
86    #[serde(default)]
87    pub web_search: WebSearchConfig,
88
89    /// Heartbeat / autonomous agent configuration (v0.9)
90    #[serde(default)]
91    pub heartbeat: crate::heartbeat::HeartbeatConfig,
92
93    /// Swarm orchestration configuration (v0.9)
94    #[serde(default)]
95    pub swarm: crate::swarm::SwarmConfig,
96
97    /// MCP server connections configuration (v0.9.6)
98    #[serde(default)]
99    pub mcp: McpConfig,
100
101    /// Browser automation configuration (v1.1.0)
102    #[serde(default)]
103    pub browser: BrowserConfig,
104
105    /// Load management / graceful degradation configuration (v1.1.0)
106    #[serde(default)]
107    pub load: crate::load::LoadConfig,
108
109    /// Web access (domain-level) policy configuration (v1.4.0)
110    #[serde(default = "default_web_policy")]
111    pub web_policy: crate::web_policy::WebAccessPolicy,
112}
113
114/// MCP server connections configuration (v0.9.6)
115///
116/// Defines one or more MCP servers to connect to at startup.
117/// Each server is a subprocess that communicates via JSON-RPC 2.0 over stdio.
118///
119/// # Example (TOML)
120///
121/// ```toml
122/// [mcp]
123/// servers = [
124///   { name = "filesystem", command = "npx", args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] },
125///   { name = "database", command = "python", args = ["mcp-server.py"], env = { DB_URL = "postgres://..." } },
126/// ]
127/// ```
128///
129/// # Stability
130/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
131#[derive(Debug, Clone, Deserialize, Default)]
132#[non_exhaustive]
133pub struct McpConfig {
134    /// List of MCP servers to connect to
135    #[serde(default)]
136    pub servers: Vec<McpServerConfig>,
137}
138
139/// Configuration for a single MCP server connection (v0.9.6)
140///
141/// # Stability
142/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
143#[derive(Debug, Clone, Deserialize)]
144#[non_exhaustive]
145pub struct McpServerConfig {
146    /// Human-readable name for this MCP server (used in logs and tool namespacing)
147    pub name: String,
148    /// Command to launch the MCP server process (stdio transport)
149    /// Either `command` or `url` must be set, but not both.
150    #[serde(default)]
151    pub command: String,
152    /// Arguments to pass to the command
153    #[serde(default)]
154    pub args: Vec<String>,
155    /// Environment variables to set for the MCP server process
156    #[serde(default)]
157    pub env: std::collections::HashMap<String, String>,
158    /// SSE endpoint URL for SSE transport (e.g., "http://localhost:8080/sse")
159    /// Either `command` or `url` must be set, but not both.
160    #[serde(default)]
161    pub url: String,
162}
163
164/// Web search configuration (v0.8)
165///
166/// # Stability
167/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
168#[derive(Debug, Clone, Deserialize)]
169#[non_exhaustive]
170pub struct WebSearchConfig {
171    /// Search API endpoint (SearXNG or compatible)
172    #[serde(default = "default_search_endpoint")]
173    pub endpoint: String,
174
175    /// Search engine to use (e.g., "duckduckgo", "google", "brave")
176    #[serde(default = "default_search_engine")]
177    pub engine: String,
178
179    /// Maximum number of search results to return
180    #[serde(default = "default_search_max_results")]
181    pub max_results: usize,
182
183    /// Whether to fetch and extract content from each search result
184    #[serde(default = "default_true")]
185    pub fetch_content: bool,
186}
187
188impl Default for WebSearchConfig {
189    fn default() -> Self {
190        Self {
191            endpoint: default_search_endpoint(),
192            engine: default_search_engine(),
193            max_results: default_search_max_results(),
194            fetch_content: default_true(),
195        }
196    }
197}
198
199/// Browser automation configuration (v1.1.0)
200///
201/// Configures the CDP (Chrome DevTools Protocol) endpoint for browser automation.
202/// Requires Chrome/Chromium running with `--remote-debugging-port=9222`.
203///
204/// # Stability
205/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
206#[derive(Debug, Clone, Deserialize)]
207#[non_exhaustive]
208pub struct BrowserConfig {
209    /// CDP endpoint URL (e.g., "http://127.0.0.1:9222")
210    #[serde(default = "default_browser_cdp_url")]
211    pub cdp_url: String,
212
213    /// Request timeout in milliseconds for CDP commands
214    #[serde(default = "default_browser_timeout")]
215    pub request_timeout: u64,
216}
217
218impl Default for BrowserConfig {
219    fn default() -> Self {
220        Self {
221            cdp_url: default_browser_cdp_url(),
222            request_timeout: default_browser_timeout(),
223        }
224    }
225}
226
227fn default_browser_cdp_url() -> String {
228    "http://127.0.0.1:9222".to_string()
229}
230
231fn default_browser_timeout() -> u64 {
232    30000
233}
234
235fn default_web_policy() -> crate::web_policy::WebAccessPolicy {
236    // Disabled by default — existing deployments are unaffected until they
237    // explicitly enable domain-level filtering.
238    crate::web_policy::WebAccessPolicy::disabled()
239}
240
241fn default_search_endpoint() -> String {
242    "https://searx.be".to_string()
243}
244
245fn default_search_engine() -> String {
246    "duckduckgo".to_string()
247}
248
249fn default_search_max_results() -> usize {
250    5
251}
252
253fn default_otel_disabled() -> bool {
254    true
255}
256
257/// Scheduler / triggers configuration (v0.8)
258///
259/// # Stability
260/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
261#[derive(Debug, Clone, Deserialize, Default)]
262#[non_exhaustive]
263pub struct SchedulerConfig {
264    /// List of trigger configurations
265    #[serde(default)]
266    pub triggers: Vec<crate::scheduler::TriggerConfig>,
267}
268
269/// Telemetry / OpenTelemetry configuration (v0.7.2)
270///
271/// # Stability
272/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
273#[derive(Debug, Clone, Deserialize, Default)]
274#[non_exhaustive]
275pub struct TelemetryConfig {
276    /// OTLP gRPC endpoint for OpenTelemetry (e.g., "http://jaeger:4317")
277    #[serde(default)]
278    pub otel_endpoint: Option<String>,
279
280    /// Service name for OpenTelemetry traces
281    #[serde(default)]
282    pub otel_service_name: Option<String>,
283
284    /// Disable OpenTelemetry tracing (default: true — opt-in)
285    #[serde(default = "default_otel_disabled")]
286    pub otel_disabled: bool,
287}
288
289/// LLM provider configuration.
290///
291/// # Stability
292/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
293/// Construct using `LLMConfig::default()` or use `..Default::default()` for updates.
294#[derive(Debug, Clone, Deserialize)]
295#[non_exhaustive]
296pub struct LLMConfig {
297    /// Provider type: litellm, openrouter, ollama, openai
298    #[serde(default)]
299    pub provider: LLMProvider,
300
301    /// Endpoint URL (e.g., http://litellm:4000, http://localhost:11434, https://api.openai.com)
302    #[serde(default)]
303    pub endpoint: String,
304
305    /// Default model to use
306    #[serde(default = "default_model")]
307    pub model: String,
308
309    /// API key (prefer env var)
310    #[serde(default)]
311    pub api_key: Option<String>,
312
313    /// Request timeout in seconds
314    #[serde(default = "default_timeout")]
315    pub timeout_secs: u64,
316
317    /// System prompt / persona for the agent
318    #[serde(default = "default_system_prompt")]
319    pub system_prompt: String,
320
321    /// Token budget (v0.5) — maximum tokens per run
322    #[serde(default)]
323    pub token_budget: Option<u32>,
324
325    /// Retry max attempts (v0.5) — default 3
326    #[serde(default = "default_retry_max")]
327    pub retry_max: u32,
328
329    /// Retry base delay in ms (v0.5) — default 100
330    #[serde(default = "default_retry_base_delay")]
331    pub retry_base_delay_ms: u64,
332
333    /// Retry max delay in ms (v0.5) — default 10000
334    #[serde(default = "default_retry_max_delay")]
335    pub retry_max_delay_ms: u64,
336}
337
338pub fn default_retry_max() -> u32 {
339    3
340}
341pub fn default_retry_base_delay() -> u64 {
342    100
343}
344pub fn default_retry_max_delay() -> u64 {
345    10000
346}
347
348pub fn default_system_prompt() -> String {
349    "You are RavenClaws, a lightweight autonomous agent. \
350        Be concise, efficient, and secure. Always validate inputs and outputs. \
351        When you have completed the task, prefix your final answer with FINAL: \
352        so the system knows the task is done."
353        .to_string()
354}
355
356/// RavenFabric mesh client configuration.
357///
358/// # Stability
359/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
360#[derive(Debug, Clone, Deserialize)]
361#[non_exhaustive]
362pub struct RavenFabricConfig {
363    /// RavenFabric endpoint
364    #[serde(default)]
365    pub endpoint: Option<String>,
366
367    /// Agent ID for identification
368    #[serde(default)]
369    pub agent_id: Option<String>,
370
371    /// Enable remote command execution
372    #[serde(default = "default_true")]
373    pub remote_exec: bool,
374
375    /// Allowed remote hosts (whitelist)
376    #[serde(default)]
377    #[allow(dead_code)]
378    pub allowed_hosts: Vec<String>,
379}
380
381impl Default for RavenFabricConfig {
382    fn default() -> Self {
383        Self {
384            endpoint: None,
385            agent_id: None,
386            remote_exec: default_true(),
387            allowed_hosts: Vec::new(),
388        }
389    }
390}
391
392/// Security configuration.
393///
394/// # Stability
395/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
396#[derive(Debug, Clone, Deserialize)]
397#[non_exhaustive]
398pub struct SecurityConfig {
399    /// Require TLS for all connections
400    #[serde(default = "default_true")]
401    pub require_tls: bool,
402
403    /// Maximum token lifetime in seconds (0 = unlimited)
404    /// When non-zero, agent sessions automatically terminate after this duration.
405    /// Enforced in the agent loop — checked before each iteration.
406    #[serde(default = "default_token_lifetime")]
407    pub token_lifetime_secs: u64,
408
409    /// Enable audit logging
410    #[serde(default = "default_true")]
411    #[allow(dead_code)]
412    pub audit_log: bool,
413
414    /// Enable prompt-injection defense
415    /// When true, LLM responses are scanned for injection patterns
416    /// and output schema violations before processing.
417    #[serde(default = "default_true")]
418    #[allow(dead_code)]
419    pub prompt_injection_protection: bool,
420}
421
422impl Default for SecurityConfig {
423    fn default() -> Self {
424        Self {
425            require_tls: default_true(),
426            token_lifetime_secs: default_token_lifetime(),
427            audit_log: default_true(),
428            prompt_injection_protection: default_true(),
429        }
430    }
431}
432
433/// Runtime configuration.
434///
435/// # Stability
436/// This struct is `#[non_exhaustive]` — new fields may be added in minor releases.
437#[derive(Debug, Clone, Deserialize)]
438#[non_exhaustive]
439pub struct RuntimeConfig {
440    /// Working directory
441    #[serde(default = "default_workdir")]
442    #[allow(dead_code)]
443    pub workdir: String,
444
445    /// Maximum concurrent agents
446    #[serde(default = "default_max_agents")]
447    #[allow(dead_code)]
448    pub max_agents: usize,
449
450    /// Health check interval in seconds
451    #[serde(default = "default_health_interval")]
452    #[allow(dead_code)]
453    pub health_interval_secs: u64,
454
455    /// HTTP server host (v0.7)
456    #[serde(default)]
457    pub host: Option<String>,
458
459    /// HTTP server port (v0.7)
460    #[serde(default = "default_server_port")]
461    pub port: u16,
462
463    /// Checkpoint directory for durable execution (v0.9.12+)
464    /// When set, the agent loop saves state after each iteration to this directory.
465    /// On restart, the agent loop can resume from the latest checkpoint.
466    /// Defaults to `{workdir}/checkpoints/` if not set.
467    #[serde(default)]
468    #[allow(dead_code)]
469    pub checkpoint_dir: Option<String>,
470
471    /// Checkpoint interval in iterations (v0.9.12+)
472    /// Save a checkpoint every N iterations. Default: 1 (every iteration).
473    /// Higher values reduce I/O but lose more work on crash.
474    #[serde(default = "default_checkpoint_interval")]
475    #[allow(dead_code)]
476    pub checkpoint_interval: usize,
477}
478
479fn default_checkpoint_interval() -> usize {
480    1
481}
482
483impl Default for RuntimeConfig {
484    fn default() -> Self {
485        Self {
486            workdir: default_workdir(),
487            max_agents: default_max_agents(),
488            health_interval_secs: default_health_interval(),
489            host: None,
490            port: default_server_port(),
491            checkpoint_dir: None,
492            checkpoint_interval: 1,
493        }
494    }
495}
496
497fn default_model() -> String {
498    "gpt-4o-mini".to_string()
499}
500
501fn default_timeout() -> u64 {
502    30
503}
504
505fn default_true() -> bool {
506    true
507}
508
509fn default_token_lifetime() -> u64 {
510    3600
511}
512
513fn default_workdir() -> String {
514    "/tmp/ravenclaws-workdir".to_string()
515}
516
517fn default_max_agents() -> usize {
518    10
519}
520
521fn default_health_interval() -> u64 {
522    60
523}
524
525fn default_server_port() -> u16 {
526    8080
527}
528
529impl Default for LLMConfig {
530    fn default() -> Self {
531        Self {
532            provider: LLMProvider::LiteLLM,
533            endpoint: String::new(),
534            model: default_model(),
535            api_key: None,
536            timeout_secs: default_timeout(),
537            system_prompt: default_system_prompt(),
538            token_budget: None,
539            retry_max: default_retry_max(),
540            retry_base_delay_ms: default_retry_base_delay(),
541            retry_max_delay_ms: default_retry_max_delay(),
542        }
543    }
544}
545
546/// Zeroize sensitive fields on drop — API keys are cleared from memory
547impl Drop for LLMConfig {
548    fn drop(&mut self) {
549        if let Some(ref mut key) = self.api_key {
550            key.zeroize();
551        }
552    }
553}
554
555impl Config {
556    /// Load configuration from file and environment
557    pub fn load(config_path: Option<&str>) -> Result<Self, ConfigError> {
558        // Start with defaults from environment
559        dotenvy::dotenv().ok();
560
561        let mut config_builder = config::Config::builder();
562
563        // Load from file if provided
564        if let Some(path) = config_path {
565            config_builder =
566                config_builder.add_source(config::File::with_name(path).required(false));
567        }
568
569        // Load from environment (RAVENCLAW_* prefix)
570        // Save and remove RAVENCLAWS__LLMS before serde deserialization because
571        // config::Environment passes it as a raw string, which serde can't
572        // deserialize into Vec<LLMConfig>. We restore and parse it manually below.
573        let ravenclaws_llms = std::env::var("RAVENCLAWS__LLMS").ok();
574        if ravenclaws_llms.is_some() {
575            std::env::remove_var("RAVENCLAWS__LLMS");
576        }
577
578        config_builder = config_builder
579            .add_source(config::Environment::with_prefix("RAVENCLAW").separator("__"));
580
581        let config = config_builder
582            .build()
583            .map_err(|e| ConfigError::LoadError(e.to_string()))?;
584
585        let mut cfg: Config = config
586            .try_deserialize()
587            .map_err(|e| ConfigError::LoadError(e.to_string()))?;
588
589        // Restore RAVENCLAWS__LLMS if it was set
590        if let Some(ref val) = ravenclaws_llms {
591            std::env::set_var("RAVENCLAWS__LLMS", val);
592        }
593
594        // Override sensitive values from environment
595        // Single provider mode
596        if let Ok(key) = std::env::var("LITELLM_API_KEY") {
597            cfg.llm.api_key = Some(key);
598        }
599        if let Ok(provider) = std::env::var("RAVENCLAWS__LLM__PROVIDER") {
600            cfg.llm.provider = match provider.to_lowercase().as_str() {
601                "openrouter" => LLMProvider::OpenRouter,
602                "ollama" => LLMProvider::Ollama,
603                "openai" => LLMProvider::OpenAI,
604                "anthropic" => LLMProvider::Anthropic,
605                _ => LLMProvider::LiteLLM,
606            };
607        }
608        if let Ok(endpoint) = std::env::var("RAVENCLAWS__LLM__ENDPOINT") {
609            cfg.llm.endpoint = endpoint;
610        }
611        if let Ok(model) = std::env::var("RAVENCLAWS__LLM__MODEL") {
612            cfg.llm.model = model;
613        }
614
615        // Multi-provider mode
616        // Note: RAVENCLAWS__LLMS is handled manually (not via config::Environment)
617        // because it's a JSON string that serde can't deserialize into Vec<LLMConfig>.
618        if let Ok(keys) = std::env::var("RAVENCLAWS__LLMS") {
619            // Parse JSON array of LLM configs from env
620            if let Ok(llms) = serde_json::from_str::<Vec<LLMConfig>>(&keys) {
621                cfg.llms = llms;
622            }
623        }
624
625        if let Ok(endpoint) = std::env::var("RAVENFABRIC_ENDPOINT") {
626            cfg.ravenfabric.endpoint = Some(endpoint);
627        }
628
629        // Validate
630        cfg.validate()?;
631
632        Ok(cfg)
633    }
634
635    /// Validate configuration
636    fn validate(&self) -> Result<(), ConfigError> {
637        // Validate single provider config
638        if !self.llm.endpoint.is_empty() {
639            self.validate_llm_config(&self.llm)?;
640        }
641
642        // Validate multi-provider configs
643        for (i, llm) in self.llms.iter().enumerate() {
644            self.validate_llm_config(llm)
645                .map_err(|e| ConfigError::ValidationError(format!("LLM[{}]: {}", i, e)))?;
646        }
647
648        // At least one provider must be configured
649        if self.llm.endpoint.is_empty() && self.llms.is_empty() {
650            return Err(ConfigError::ValidationError(
651                "At least one LLM provider must be configured (llm or llms)".to_string(),
652            ));
653        }
654
655        Ok(())
656    }
657
658    fn validate_llm_config(&self, llm: &LLMConfig) -> Result<(), ConfigError> {
659        if llm.endpoint.is_empty()
660            && llm.provider != LLMProvider::OpenAI
661            && llm.provider != LLMProvider::OpenRouter
662            && llm.provider != LLMProvider::Anthropic
663        {
664            // OpenAI, OpenRouter, and Anthropic have fixed endpoints
665            return Err(ConfigError::ValidationError(
666                "LLM endpoint is required for this provider".to_string(),
667            ));
668        }
669
670        if self.security.require_tls
671            && !llm.endpoint.is_empty()
672            && !llm.endpoint.starts_with("https://")
673            && !llm.endpoint.contains("localhost")
674            && !llm.endpoint.contains("127.0.0.1")
675            && !llm.endpoint.contains("0.0.0.0")
676        {
677            return Err(ConfigError::ValidationError(
678                "TLS required but endpoint is not HTTPS".to_string(),
679            ));
680        }
681
682        Ok(())
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use serial_test::serial;
690
691    #[test]
692    #[serial(env_test)]
693    fn test_default_config() {
694        std::env::set_var("LITELLM_API_KEY", "test-key");
695        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
696
697        let config = Config::load(None).unwrap();
698        assert_eq!(config.llm.model, "gpt-4o-mini");
699        assert_eq!(config.llm.timeout_secs, 30);
700        // require_tls defaults to true via serde(default = "default_true")
701        // but only when deserialized, not via #[derive(Default)]
702        // Since we load via serde, it should be true
703        assert!(config.security.require_tls);
704
705        // Clean up env vars set by this test
706        std::env::remove_var("LITELLM_API_KEY");
707        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
708    }
709
710    #[test]
711    fn test_llm_provider_default() {
712        assert_eq!(LLMProvider::default(), LLMProvider::LiteLLM);
713    }
714
715    #[test]
716    fn test_llm_provider_serde() {
717        let json = r#""litellm""#;
718        let provider: LLMProvider = serde_json::from_str(json).unwrap();
719        assert_eq!(provider, LLMProvider::LiteLLM);
720
721        let json = r#""openai""#;
722        let provider: LLMProvider = serde_json::from_str(json).unwrap();
723        assert_eq!(provider, LLMProvider::OpenAI);
724
725        let json = r#""ollama""#;
726        let provider: LLMProvider = serde_json::from_str(json).unwrap();
727        assert_eq!(provider, LLMProvider::Ollama);
728
729        let json = r#""openrouter""#;
730        let provider: LLMProvider = serde_json::from_str(json).unwrap();
731        assert_eq!(provider, LLMProvider::OpenRouter);
732    }
733
734    #[test]
735    fn test_llm_config_default() {
736        let config = LLMConfig::default();
737        assert_eq!(config.provider, LLMProvider::LiteLLM);
738        assert_eq!(config.model, "gpt-4o-mini");
739        assert_eq!(config.timeout_secs, 30);
740        assert!(config.api_key.is_none());
741        assert!(config.endpoint.is_empty());
742        assert!(config.system_prompt.contains("RavenClaws"));
743    }
744
745    #[test]
746    fn test_system_prompt_custom() {
747        let mut config = LLMConfig::default();
748        config.system_prompt = "You are a helpful coding assistant.".to_string();
749        assert_eq!(config.system_prompt, "You are a helpful coding assistant.");
750    }
751
752    #[test]
753    fn test_validate_missing_endpoint() {
754        let config = Config {
755            llm: LLMConfig {
756                provider: LLMProvider::LiteLLM,
757                endpoint: String::new(),
758                model: "gpt-4o-mini".to_string(),
759                api_key: None,
760                timeout_secs: 30,
761                system_prompt: default_system_prompt(),
762                token_budget: None,
763                retry_max: 3,
764                retry_base_delay_ms: 100,
765                retry_max_delay_ms: 10000,
766            },
767            llms: vec![],
768            ravenfabric: RavenFabricConfig::default(),
769            security: SecurityConfig {
770                require_tls: false,
771                token_lifetime_secs: 3600,
772                audit_log: false,
773                prompt_injection_protection: false,
774            },
775            runtime: RuntimeConfig::default(),
776            telemetry: TelemetryConfig::default(),
777            scheduler: SchedulerConfig::default(),
778            web_search: WebSearchConfig::default(),
779            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
780            mcp: McpConfig::default(),
781            swarm: crate::swarm::SwarmConfig::default(),
782            browser: BrowserConfig::default(),
783            load: crate::load::LoadConfig::default(),
784            web_policy: crate::web_policy::WebAccessPolicy::default(),
785        };
786
787        let result = config.validate();
788        assert!(result.is_err());
789        assert!(result
790            .unwrap_err()
791            .to_string()
792            .contains("At least one LLM provider"));
793    }
794
795    #[test]
796    fn test_validate_tls_required() {
797        let config = Config {
798            llm: LLMConfig {
799                provider: LLMProvider::LiteLLM,
800                endpoint: "http://example.com:4000".to_string(),
801                model: "gpt-4o-mini".to_string(),
802                api_key: Some("key".to_string()),
803                timeout_secs: 30,
804                system_prompt: default_system_prompt(),
805                token_budget: None,
806                retry_max: 3,
807                retry_base_delay_ms: 100,
808                retry_max_delay_ms: 10000,
809            },
810            llms: vec![],
811            ravenfabric: RavenFabricConfig::default(),
812            security: SecurityConfig {
813                require_tls: true,
814                token_lifetime_secs: 3600,
815                audit_log: false,
816                prompt_injection_protection: false,
817            },
818            runtime: RuntimeConfig::default(),
819            telemetry: TelemetryConfig::default(),
820            scheduler: SchedulerConfig::default(),
821            web_search: WebSearchConfig::default(),
822            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
823            mcp: McpConfig::default(),
824            swarm: crate::swarm::SwarmConfig::default(),
825            browser: BrowserConfig::default(),
826            load: crate::load::LoadConfig::default(),
827            web_policy: crate::web_policy::WebAccessPolicy::default(),
828        };
829
830        let result = config.validate();
831        assert!(result.is_err());
832        let err = result.unwrap_err().to_string();
833        assert!(err.contains("TLS required"));
834    }
835
836    #[test]
837    fn test_validate_tls_localhost_allowed() {
838        let config = Config {
839            llm: LLMConfig {
840                provider: LLMProvider::LiteLLM,
841                endpoint: "http://localhost:4000".to_string(),
842                model: "gpt-4o-mini".to_string(),
843                api_key: Some("key".to_string()),
844                timeout_secs: 30,
845                system_prompt: default_system_prompt(),
846                token_budget: None,
847                retry_max: 3,
848                retry_base_delay_ms: 100,
849                retry_max_delay_ms: 10000,
850            },
851            llms: vec![],
852            ravenfabric: RavenFabricConfig::default(),
853            security: SecurityConfig {
854                require_tls: true,
855                token_lifetime_secs: 3600,
856                audit_log: false,
857                prompt_injection_protection: false,
858            },
859            runtime: RuntimeConfig::default(),
860            telemetry: TelemetryConfig::default(),
861            scheduler: SchedulerConfig::default(),
862            web_search: WebSearchConfig::default(),
863            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
864            mcp: McpConfig::default(),
865            swarm: crate::swarm::SwarmConfig::default(),
866            browser: BrowserConfig::default(),
867            load: crate::load::LoadConfig::default(),
868            web_policy: crate::web_policy::WebAccessPolicy::default(),
869        };
870
871        let result = config.validate();
872        assert!(result.is_ok());
873    }
874
875    #[test]
876    fn test_validate_openai_no_endpoint_needed() {
877        let config = Config {
878            llm: LLMConfig {
879                provider: LLMProvider::OpenAI,
880                endpoint: String::new(),
881                model: "gpt-4o".to_string(),
882                api_key: Some("sk-key".to_string()),
883                timeout_secs: 30,
884                system_prompt: default_system_prompt(),
885                token_budget: None,
886                retry_max: 3,
887                retry_base_delay_ms: 100,
888                retry_max_delay_ms: 10000,
889            },
890            llms: vec![],
891            ravenfabric: RavenFabricConfig::default(),
892            security: SecurityConfig {
893                require_tls: false,
894                token_lifetime_secs: 3600,
895                audit_log: false,
896                prompt_injection_protection: false,
897            },
898            runtime: RuntimeConfig::default(),
899            telemetry: TelemetryConfig::default(),
900            scheduler: SchedulerConfig::default(),
901            web_search: WebSearchConfig::default(),
902            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
903            mcp: McpConfig::default(),
904            swarm: crate::swarm::SwarmConfig::default(),
905            browser: BrowserConfig::default(),
906            load: crate::load::LoadConfig::default(),
907            web_policy: crate::web_policy::WebAccessPolicy::default(),
908        };
909
910        // OpenAI doesn't need an endpoint, but the llm.endpoint is empty
911        // and the llm section is checked. Since llm.endpoint is empty,
912        // validate() skips the llm check but then fails because no providers.
913        // Fix: set llm.endpoint to something or add llms
914        let result = config.validate();
915        assert!(result.is_err()); // No endpoint set for llm, and no llms
916    }
917
918    #[test]
919    fn test_validate_multi_provider() {
920        let config = Config {
921            llm: LLMConfig::default(),
922            llms: vec![LLMConfig {
923                provider: LLMProvider::Ollama,
924                endpoint: "http://localhost:11434".to_string(),
925                model: "llama3.1".to_string(),
926                api_key: None,
927                timeout_secs: 60,
928                system_prompt: default_system_prompt(),
929                token_budget: None,
930                retry_max: 3,
931                retry_base_delay_ms: 100,
932                retry_max_delay_ms: 10000,
933            }],
934            ravenfabric: RavenFabricConfig::default(),
935            security: SecurityConfig {
936                require_tls: false,
937                token_lifetime_secs: 3600,
938                audit_log: false,
939                prompt_injection_protection: false,
940            },
941            runtime: RuntimeConfig::default(),
942            telemetry: TelemetryConfig::default(),
943            scheduler: SchedulerConfig::default(),
944            web_search: WebSearchConfig::default(),
945            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
946            mcp: McpConfig::default(),
947            swarm: crate::swarm::SwarmConfig::default(),
948            browser: BrowserConfig::default(),
949            load: crate::load::LoadConfig::default(),
950            web_policy: crate::web_policy::WebAccessPolicy::default(),
951        };
952
953        let result = config.validate();
954        assert!(result.is_ok());
955    }
956
957    #[test]
958    fn test_ravenfabric_config_default() {
959        let config = RavenFabricConfig::default();
960        assert!(config.endpoint.is_none());
961        assert!(config.agent_id.is_none());
962        assert!(config.remote_exec);
963        assert!(config.allowed_hosts.is_empty());
964    }
965
966    #[test]
967    fn test_security_config_default() {
968        let config = SecurityConfig::default();
969        assert!(config.require_tls);
970        assert_eq!(config.token_lifetime_secs, 3600);
971        assert!(config.audit_log);
972    }
973
974    #[test]
975    fn test_runtime_config_default() {
976        let config = RuntimeConfig::default();
977        assert_eq!(config.workdir, "/tmp/ravenclaws-workdir");
978        assert_eq!(config.max_agents, 10);
979        assert_eq!(config.health_interval_secs, 60);
980    }
981
982    #[test]
983    fn test_config_error_display() {
984        let err = ConfigError::LoadError("file not found".to_string());
985        assert_eq!(format!("{}", err), "Failed to load config: file not found");
986
987        let err = ConfigError::ValidationError("bad field".to_string());
988        assert_eq!(format!("{}", err), "Invalid configuration: bad field");
989
990        let err = ConfigError::MissingEnvVar("API_KEY".to_string());
991        assert_eq!(
992            format!("{}", err),
993            "Missing required environment variable: API_KEY"
994        );
995    }
996
997    #[test]
998    fn test_validate_openrouter_no_endpoint_needed() {
999        let config = Config {
1000            llm: LLMConfig {
1001                provider: LLMProvider::OpenRouter,
1002                endpoint: String::new(),
1003                model: "anthropic/claude-sonnet-4-20250514".to_string(),
1004                api_key: Some("or-key".to_string()),
1005                timeout_secs: 30,
1006                system_prompt: default_system_prompt(),
1007                token_budget: None,
1008                retry_max: 3,
1009                retry_base_delay_ms: 100,
1010                retry_max_delay_ms: 10000,
1011            },
1012            llms: vec![],
1013            ravenfabric: RavenFabricConfig::default(),
1014            security: SecurityConfig {
1015                require_tls: false,
1016                token_lifetime_secs: 3600,
1017                audit_log: false,
1018                prompt_injection_protection: false,
1019            },
1020            runtime: RuntimeConfig::default(),
1021            telemetry: TelemetryConfig::default(),
1022            scheduler: SchedulerConfig::default(),
1023            web_search: WebSearchConfig::default(),
1024            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1025            mcp: McpConfig::default(),
1026            swarm: crate::swarm::SwarmConfig::default(),
1027            browser: BrowserConfig::default(),
1028            load: crate::load::LoadConfig::default(),
1029            web_policy: crate::web_policy::WebAccessPolicy::default(),
1030        };
1031
1032        // OpenRouter doesn't need an endpoint, but llm.endpoint is empty
1033        // so validate() skips llm check and fails because no providers
1034        let result = config.validate();
1035        assert!(result.is_err());
1036    }
1037
1038    #[test]
1039    fn test_validate_ollama_needs_endpoint() {
1040        let config = Config {
1041            llm: LLMConfig {
1042                provider: LLMProvider::Ollama,
1043                endpoint: String::new(),
1044                model: "llama3.1".to_string(),
1045                api_key: None,
1046                timeout_secs: 30,
1047                system_prompt: default_system_prompt(),
1048                token_budget: None,
1049                retry_max: 3,
1050                retry_base_delay_ms: 100,
1051                retry_max_delay_ms: 10000,
1052            },
1053            llms: vec![],
1054            ravenfabric: RavenFabricConfig::default(),
1055            security: SecurityConfig {
1056                require_tls: false,
1057                token_lifetime_secs: 3600,
1058                audit_log: false,
1059                prompt_injection_protection: false,
1060            },
1061            runtime: RuntimeConfig::default(),
1062            telemetry: TelemetryConfig::default(),
1063            scheduler: SchedulerConfig::default(),
1064            web_search: WebSearchConfig::default(),
1065            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1066            mcp: McpConfig::default(),
1067            swarm: crate::swarm::SwarmConfig::default(),
1068            browser: BrowserConfig::default(),
1069            load: crate::load::LoadConfig::default(),
1070            web_policy: crate::web_policy::WebAccessPolicy::default(),
1071        };
1072
1073        let result = config.validate();
1074        assert!(result.is_err());
1075        let err = result.unwrap_err().to_string();
1076        assert!(err.contains("At least one LLM provider"));
1077    }
1078
1079    #[test]
1080    fn test_validate_tls_localhost_ip_allowed() {
1081        let config = Config {
1082            llm: LLMConfig {
1083                provider: LLMProvider::LiteLLM,
1084                endpoint: "http://127.0.0.1:4000".to_string(),
1085                model: "gpt-4o-mini".to_string(),
1086                api_key: Some("key".to_string()),
1087                timeout_secs: 30,
1088                system_prompt: default_system_prompt(),
1089                token_budget: None,
1090                retry_max: 3,
1091                retry_base_delay_ms: 100,
1092                retry_max_delay_ms: 10000,
1093            },
1094            llms: vec![],
1095            ravenfabric: RavenFabricConfig::default(),
1096            security: SecurityConfig {
1097                require_tls: true,
1098                token_lifetime_secs: 3600,
1099                audit_log: false,
1100                prompt_injection_protection: false,
1101            },
1102            runtime: RuntimeConfig::default(),
1103            telemetry: TelemetryConfig::default(),
1104            scheduler: SchedulerConfig::default(),
1105            web_search: WebSearchConfig::default(),
1106            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1107            mcp: McpConfig::default(),
1108            swarm: crate::swarm::SwarmConfig::default(),
1109            browser: BrowserConfig::default(),
1110            load: crate::load::LoadConfig::default(),
1111            web_policy: crate::web_policy::WebAccessPolicy::default(),
1112        };
1113
1114        let result = config.validate();
1115        assert!(result.is_ok());
1116    }
1117
1118    #[test]
1119    fn test_validate_tls_wildcard_allowed() {
1120        let config = Config {
1121            llm: LLMConfig {
1122                provider: LLMProvider::LiteLLM,
1123                endpoint: "http://0.0.0.0:4000".to_string(),
1124                model: "gpt-4o-mini".to_string(),
1125                api_key: Some("key".to_string()),
1126                timeout_secs: 30,
1127                system_prompt: default_system_prompt(),
1128                token_budget: None,
1129                retry_max: 3,
1130                retry_base_delay_ms: 100,
1131                retry_max_delay_ms: 10000,
1132            },
1133            llms: vec![],
1134            ravenfabric: RavenFabricConfig::default(),
1135            security: SecurityConfig {
1136                require_tls: true,
1137                token_lifetime_secs: 3600,
1138                audit_log: false,
1139                prompt_injection_protection: false,
1140            },
1141            runtime: RuntimeConfig::default(),
1142            telemetry: TelemetryConfig::default(),
1143            scheduler: SchedulerConfig::default(),
1144            web_search: WebSearchConfig::default(),
1145            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1146            mcp: McpConfig::default(),
1147            swarm: crate::swarm::SwarmConfig::default(),
1148            browser: BrowserConfig::default(),
1149            load: crate::load::LoadConfig::default(),
1150            web_policy: crate::web_policy::WebAccessPolicy::default(),
1151        };
1152
1153        let result = config.validate();
1154        assert!(result.is_ok());
1155    }
1156
1157    #[test]
1158    fn test_validate_multi_provider_with_tls() {
1159        let config = Config {
1160            llm: LLMConfig::default(),
1161            llms: vec![
1162                LLMConfig {
1163                    provider: LLMProvider::Ollama,
1164                    endpoint: "http://localhost:11434".to_string(),
1165                    model: "llama3.1".to_string(),
1166                    api_key: None,
1167                    timeout_secs: 60,
1168                    system_prompt: default_system_prompt(),
1169                    token_budget: None,
1170                    retry_max: 3,
1171                    retry_base_delay_ms: 100,
1172                    retry_max_delay_ms: 10000,
1173                },
1174                LLMConfig {
1175                    provider: LLMProvider::LiteLLM,
1176                    endpoint: "https://litellm.example.com:4000".to_string(),
1177                    model: "gpt-4o-mini".to_string(),
1178                    api_key: Some("key".to_string()),
1179                    timeout_secs: 30,
1180                    system_prompt: default_system_prompt(),
1181                    token_budget: None,
1182                    retry_max: 3,
1183                    retry_base_delay_ms: 100,
1184                    retry_max_delay_ms: 10000,
1185                },
1186            ],
1187            ravenfabric: RavenFabricConfig::default(),
1188            security: SecurityConfig {
1189                require_tls: true,
1190                token_lifetime_secs: 3600,
1191                audit_log: false,
1192                prompt_injection_protection: false,
1193            },
1194            runtime: RuntimeConfig::default(),
1195            telemetry: TelemetryConfig::default(),
1196            scheduler: SchedulerConfig::default(),
1197            web_search: WebSearchConfig::default(),
1198            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1199            mcp: McpConfig::default(),
1200            swarm: crate::swarm::SwarmConfig::default(),
1201            browser: BrowserConfig::default(),
1202            load: crate::load::LoadConfig::default(),
1203            web_policy: crate::web_policy::WebAccessPolicy::default(),
1204        };
1205
1206        let result = config.validate();
1207        assert!(result.is_ok());
1208    }
1209
1210    #[test]
1211    fn test_validate_multi_provider_tls_failure() {
1212        let config = Config {
1213            llm: LLMConfig::default(),
1214            llms: vec![LLMConfig {
1215                provider: LLMProvider::LiteLLM,
1216                endpoint: "http://example.com:4000".to_string(),
1217                model: "gpt-4o-mini".to_string(),
1218                api_key: Some("key".to_string()),
1219                timeout_secs: 30,
1220                system_prompt: default_system_prompt(),
1221                token_budget: None,
1222                retry_max: 3,
1223                retry_base_delay_ms: 100,
1224                retry_max_delay_ms: 10000,
1225            }],
1226            ravenfabric: RavenFabricConfig::default(),
1227            security: SecurityConfig {
1228                require_tls: true,
1229                token_lifetime_secs: 3600,
1230                audit_log: false,
1231                prompt_injection_protection: false,
1232            },
1233            runtime: RuntimeConfig::default(),
1234            telemetry: TelemetryConfig::default(),
1235            scheduler: SchedulerConfig::default(),
1236            web_search: WebSearchConfig::default(),
1237            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1238            mcp: McpConfig::default(),
1239            swarm: crate::swarm::SwarmConfig::default(),
1240            browser: BrowserConfig::default(),
1241            load: crate::load::LoadConfig::default(),
1242            web_policy: crate::web_policy::WebAccessPolicy::default(),
1243        };
1244
1245        let result = config.validate();
1246        assert!(result.is_err());
1247        let err = result.unwrap_err().to_string();
1248        assert!(err.contains("TLS required"));
1249    }
1250
1251    #[test]
1252    fn test_ravenfabric_config_custom() {
1253        let config = RavenFabricConfig {
1254            endpoint: Some("https://fabric.example.com:8443".to_string()),
1255            agent_id: Some("agent-01".to_string()),
1256            remote_exec: false,
1257            allowed_hosts: vec!["10.0.0.0/8".to_string()],
1258        };
1259        assert_eq!(config.endpoint.unwrap(), "https://fabric.example.com:8443");
1260        assert_eq!(config.agent_id.unwrap(), "agent-01");
1261        assert!(!config.remote_exec);
1262        assert_eq!(config.allowed_hosts.len(), 1);
1263    }
1264
1265    #[test]
1266    fn test_security_config_custom() {
1267        let config = SecurityConfig {
1268            require_tls: false,
1269            token_lifetime_secs: 7200,
1270            audit_log: false,
1271            prompt_injection_protection: false,
1272        };
1273        assert!(!config.require_tls);
1274        assert_eq!(config.token_lifetime_secs, 7200);
1275        assert!(!config.audit_log);
1276    }
1277
1278    #[test]
1279    fn test_runtime_config_custom() {
1280        let config = RuntimeConfig {
1281            workdir: "/data".to_string(),
1282            max_agents: 5,
1283            health_interval_secs: 120,
1284            host: Some("127.0.0.1".to_string()),
1285            port: 9090,
1286            checkpoint_dir: None,
1287            checkpoint_interval: 1,
1288        };
1289        assert_eq!(config.workdir, "/data");
1290        assert_eq!(config.max_agents, 5);
1291        assert_eq!(config.health_interval_secs, 120);
1292        assert_eq!(config.host, Some("127.0.0.1".to_string()));
1293        assert_eq!(config.port, 9090);
1294    }
1295
1296    #[test]
1297    fn test_llm_config_custom() {
1298        let config = LLMConfig {
1299            provider: LLMProvider::OpenAI,
1300            endpoint: String::new(),
1301            model: "gpt-4o".to_string(),
1302            api_key: Some("sk-test".to_string()),
1303            timeout_secs: 120,
1304            system_prompt: default_system_prompt(),
1305            token_budget: None,
1306            retry_max: 3,
1307            retry_base_delay_ms: 100,
1308            retry_max_delay_ms: 10000,
1309        };
1310        assert_eq!(config.provider, LLMProvider::OpenAI);
1311        assert_eq!(config.model, "gpt-4o");
1312        assert_eq!(config.timeout_secs, 120);
1313        assert_eq!(config.api_key.clone().unwrap(), "sk-test");
1314    }
1315
1316    #[test]
1317    fn test_llm_provider_serde_invalid_fallback() {
1318        // Unknown provider values should fall back to default (LiteLLM)
1319        let json = r#""unknown_provider""#;
1320        let provider: LLMProvider = serde_json::from_str(json).unwrap_or_default();
1321        assert_eq!(provider, LLMProvider::LiteLLM);
1322    }
1323
1324    #[test]
1325    #[serial(env_test)]
1326    fn test_config_load_with_env_overrides() {
1327        // Set up env vars for single-provider mode
1328        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1329        std::env::set_var("RAVENCLAWS__LLM__MODEL", "gpt-4o");
1330        std::env::set_var("LITELLM_API_KEY", "env-key");
1331
1332        let config = Config::load(None).unwrap();
1333        assert_eq!(config.llm.endpoint, "http://localhost:4000");
1334        assert_eq!(config.llm.model, "gpt-4o");
1335        assert_eq!(config.llm.api_key.clone().unwrap(), "env-key");
1336
1337        // Clean up env vars set by this test
1338        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1339        std::env::remove_var("RAVENCLAWS__LLM__MODEL");
1340        std::env::remove_var("LITELLM_API_KEY");
1341    }
1342
1343    #[test]
1344    #[serial(env_test)]
1345    fn test_config_load_with_llms_json_env() {
1346        let llms_json = r#"[{"provider":"ollama","endpoint":"http://localhost:11434","model":"llama3.1","timeout_secs":60}]"#;
1347        std::env::set_var("RAVENCLAWS__LLMS", llms_json);
1348        std::env::set_var("LITELLM_API_KEY", "dummy");
1349        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1350
1351        let config = Config::load(None).unwrap();
1352        assert_eq!(config.llms.len(), 1);
1353        assert_eq!(config.llms[0].provider, LLMProvider::Ollama);
1354        assert_eq!(config.llms[0].endpoint, "http://localhost:11434");
1355        assert_eq!(config.llms[0].model, "llama3.1");
1356        assert_eq!(config.llms[0].timeout_secs, 60);
1357
1358        // Clean up env vars set by this test
1359        std::env::remove_var("RAVENCLAWS__LLMS");
1360        std::env::remove_var("LITELLM_API_KEY");
1361        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1362    }
1363
1364    #[test]
1365    #[serial(env_test)]
1366    fn test_config_load_with_ravenfabric_env() {
1367        std::env::set_var("RAVENFABRIC_ENDPOINT", "https://fabric.example.com:8443");
1368        std::env::set_var("LITELLM_API_KEY", "dummy");
1369        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1370
1371        let config = Config::load(None).unwrap();
1372        assert_eq!(
1373            config.ravenfabric.endpoint.unwrap(),
1374            "https://fabric.example.com:8443"
1375        );
1376
1377        // Clean up env vars set by this test
1378        std::env::remove_var("RAVENFABRIC_ENDPOINT");
1379        std::env::remove_var("LITELLM_API_KEY");
1380        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1381    }
1382
1383    #[test]
1384    #[serial(env_test)]
1385    fn test_config_load_with_provider_env() {
1386        // Test provider override via env var — use a valid serde value
1387        std::env::set_var("RAVENCLAWS__LLM__PROVIDER", "openai");
1388        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "https://api.openai.com");
1389        std::env::set_var("LITELLM_API_KEY", "dummy");
1390
1391        let config = Config::load(None).unwrap();
1392        assert_eq!(config.llm.provider, LLMProvider::OpenAI);
1393
1394        // Clean up env vars set by this test
1395        std::env::remove_var("RAVENCLAWS__LLM__PROVIDER");
1396        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1397        std::env::remove_var("LITELLM_API_KEY");
1398    }
1399
1400    #[test]
1401    fn test_config_load_with_provider_env_fallback() {
1402        // Test that the manual override code handles unknown providers
1403        // We test the override logic directly to avoid serde deserialization failure
1404        let mapped = match "unknown" {
1405            "openrouter" => LLMProvider::OpenRouter,
1406            "ollama" => LLMProvider::Ollama,
1407            "openai" => LLMProvider::OpenAI,
1408            _ => LLMProvider::LiteLLM,
1409        };
1410        assert_eq!(mapped, LLMProvider::LiteLLM);
1411
1412        // Test with empty string
1413        let mapped = match "" {
1414            "openrouter" => LLMProvider::OpenRouter,
1415            "ollama" => LLMProvider::Ollama,
1416            "openai" => LLMProvider::OpenAI,
1417            _ => LLMProvider::LiteLLM,
1418        };
1419        assert_eq!(mapped, LLMProvider::LiteLLM);
1420    }
1421
1422    #[test]
1423    fn test_validate_openai_with_endpoint() {
1424        let config = Config {
1425            llm: LLMConfig {
1426                provider: LLMProvider::OpenAI,
1427                endpoint: "https://api.openai.com".to_string(),
1428                model: "gpt-4o".to_string(),
1429                api_key: Some("sk-key".to_string()),
1430                timeout_secs: 30,
1431                system_prompt: default_system_prompt(),
1432                token_budget: None,
1433                retry_max: 3,
1434                retry_base_delay_ms: 100,
1435                retry_max_delay_ms: 10000,
1436            },
1437            llms: vec![],
1438            ravenfabric: RavenFabricConfig::default(),
1439            security: SecurityConfig {
1440                require_tls: false,
1441                token_lifetime_secs: 3600,
1442                audit_log: false,
1443                prompt_injection_protection: false,
1444            },
1445            runtime: RuntimeConfig::default(),
1446            telemetry: TelemetryConfig::default(),
1447            scheduler: SchedulerConfig::default(),
1448            web_search: WebSearchConfig::default(),
1449            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1450            mcp: McpConfig::default(),
1451            swarm: crate::swarm::SwarmConfig::default(),
1452            browser: BrowserConfig::default(),
1453            load: crate::load::LoadConfig::default(),
1454            web_policy: crate::web_policy::WebAccessPolicy::default(),
1455        };
1456
1457        let result = config.validate();
1458        assert!(result.is_ok());
1459    }
1460
1461    #[test]
1462    fn test_validate_openrouter_with_endpoint() {
1463        let config = Config {
1464            llm: LLMConfig {
1465                provider: LLMProvider::OpenRouter,
1466                endpoint: "https://openrouter.ai/api".to_string(),
1467                model: "anthropic/claude-sonnet-4-20250514".to_string(),
1468                api_key: Some("or-key".to_string()),
1469                timeout_secs: 30,
1470                system_prompt: default_system_prompt(),
1471                token_budget: None,
1472                retry_max: 3,
1473                retry_base_delay_ms: 100,
1474                retry_max_delay_ms: 10000,
1475            },
1476            llms: vec![],
1477            ravenfabric: RavenFabricConfig::default(),
1478            security: SecurityConfig {
1479                require_tls: false,
1480                token_lifetime_secs: 3600,
1481                audit_log: false,
1482                prompt_injection_protection: false,
1483            },
1484            runtime: RuntimeConfig::default(),
1485            telemetry: TelemetryConfig::default(),
1486            scheduler: SchedulerConfig::default(),
1487            web_search: WebSearchConfig::default(),
1488            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1489            mcp: McpConfig::default(),
1490            swarm: crate::swarm::SwarmConfig::default(),
1491            browser: BrowserConfig::default(),
1492            load: crate::load::LoadConfig::default(),
1493            web_policy: crate::web_policy::WebAccessPolicy::default(),
1494        };
1495
1496        let result = config.validate();
1497        assert!(result.is_ok());
1498    }
1499
1500    #[test]
1501    fn test_validate_https_endpoint_with_tls() {
1502        let config = Config {
1503            llm: LLMConfig {
1504                provider: LLMProvider::LiteLLM,
1505                endpoint: "https://api.example.com:4000".to_string(),
1506                model: "gpt-4o-mini".to_string(),
1507                api_key: Some("key".to_string()),
1508                timeout_secs: 30,
1509                system_prompt: default_system_prompt(),
1510                token_budget: None,
1511                retry_max: 3,
1512                retry_base_delay_ms: 100,
1513                retry_max_delay_ms: 10000,
1514            },
1515            llms: vec![],
1516            ravenfabric: RavenFabricConfig::default(),
1517            security: SecurityConfig {
1518                require_tls: true,
1519                token_lifetime_secs: 3600,
1520                audit_log: false,
1521                prompt_injection_protection: false,
1522            },
1523            runtime: RuntimeConfig::default(),
1524            telemetry: TelemetryConfig::default(),
1525            scheduler: SchedulerConfig::default(),
1526            web_search: WebSearchConfig::default(),
1527            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1528            mcp: McpConfig::default(),
1529            swarm: crate::swarm::SwarmConfig::default(),
1530            browser: BrowserConfig::default(),
1531            load: crate::load::LoadConfig::default(),
1532            web_policy: crate::web_policy::WebAccessPolicy::default(),
1533        };
1534
1535        let result = config.validate();
1536        assert!(result.is_ok());
1537    }
1538
1539    #[test]
1540    #[serial(env_test)]
1541    fn test_config_load_with_nonexistent_file() {
1542        // Loading with a non-existent file path should still succeed
1543        // (the File source is created with required(false))
1544        std::env::set_var("LITELLM_API_KEY", "test-key");
1545        std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1546
1547        let result = Config::load(Some("/tmp/nonexistent/ravenclaws.toml"));
1548        assert!(result.is_ok());
1549
1550        std::env::remove_var("LITELLM_API_KEY");
1551        std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1552    }
1553
1554    #[test]
1555    fn test_config_error_missing_env_var_display() {
1556        let err = ConfigError::MissingEnvVar("DATABASE_URL".to_string());
1557        assert_eq!(
1558            format!("{}", err),
1559            "Missing required environment variable: DATABASE_URL"
1560        );
1561    }
1562
1563    #[test]
1564    fn test_llm_config_deserialize() {
1565        let json = r#"{
1566            "provider": "openai",
1567            "endpoint": "https://api.openai.com",
1568            "model": "gpt-4o",
1569            "api_key": "sk-test",
1570            "timeout_secs": 120
1571        }"#;
1572        let config: LLMConfig = serde_json::from_str(json).unwrap();
1573
1574        assert_eq!(config.provider, LLMProvider::OpenAI);
1575        assert_eq!(config.endpoint, "https://api.openai.com");
1576        assert_eq!(config.model, "gpt-4o");
1577        assert_eq!(config.timeout_secs, 120);
1578    }
1579
1580    #[test]
1581    fn test_security_config_serde_defaults() {
1582        // When deserializing from an empty JSON object, serde should use defaults
1583        let json = r#"{}"#;
1584        let config: SecurityConfig = serde_json::from_str(json).unwrap();
1585        assert!(config.require_tls);
1586        assert_eq!(config.token_lifetime_secs, 3600);
1587        assert!(config.audit_log);
1588    }
1589
1590    #[test]
1591    fn test_runtime_config_serde_defaults() {
1592        let json = r#"{}"#;
1593        let config: RuntimeConfig = serde_json::from_str(json).unwrap();
1594        assert_eq!(config.workdir, "/tmp/ravenclaws-workdir");
1595        assert_eq!(config.max_agents, 10);
1596        assert_eq!(config.health_interval_secs, 60);
1597    }
1598
1599    #[test]
1600    fn test_ravenfabric_config_serde_defaults() {
1601        let json = r#"{}"#;
1602        let config: RavenFabricConfig = serde_json::from_str(json).unwrap();
1603        assert!(config.endpoint.is_none());
1604        assert!(config.agent_id.is_none());
1605        assert!(config.remote_exec);
1606        assert!(config.allowed_hosts.is_empty());
1607    }
1608
1609    #[test]
1610    fn test_validate_ollama_with_endpoint_succeeds() {
1611        let config = Config {
1612            llm: LLMConfig {
1613                provider: LLMProvider::Ollama,
1614                endpoint: "http://localhost:11434".to_string(),
1615                model: "llama3.1".to_string(),
1616                api_key: None,
1617                timeout_secs: 60,
1618                system_prompt: default_system_prompt(),
1619                token_budget: None,
1620                retry_max: 3,
1621                retry_base_delay_ms: 100,
1622                retry_max_delay_ms: 10000,
1623            },
1624            llms: vec![],
1625            ravenfabric: RavenFabricConfig::default(),
1626            security: SecurityConfig {
1627                require_tls: false,
1628                token_lifetime_secs: 3600,
1629                audit_log: false,
1630                prompt_injection_protection: false,
1631            },
1632            runtime: RuntimeConfig::default(),
1633            telemetry: TelemetryConfig::default(),
1634            scheduler: SchedulerConfig::default(),
1635            web_search: WebSearchConfig::default(),
1636            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1637            mcp: McpConfig::default(),
1638            swarm: crate::swarm::SwarmConfig::default(),
1639            browser: BrowserConfig::default(),
1640            load: crate::load::LoadConfig::default(),
1641            web_policy: crate::web_policy::WebAccessPolicy::default(),
1642        };
1643
1644        let result = config.validate();
1645        assert!(result.is_ok());
1646    }
1647
1648    #[test]
1649    fn test_validate_openrouter_with_endpoint_succeeds() {
1650        let config = Config {
1651            llm: LLMConfig {
1652                provider: LLMProvider::OpenRouter,
1653                endpoint: "https://openrouter.ai/api".to_string(),
1654                model: "anthropic/claude-sonnet-4-20250514".to_string(),
1655                api_key: Some("or-key".to_string()),
1656                timeout_secs: 30,
1657                system_prompt: default_system_prompt(),
1658                token_budget: None,
1659                retry_max: 3,
1660                retry_base_delay_ms: 100,
1661                retry_max_delay_ms: 10000,
1662            },
1663            llms: vec![],
1664            ravenfabric: RavenFabricConfig::default(),
1665            security: SecurityConfig {
1666                require_tls: false,
1667                token_lifetime_secs: 3600,
1668                audit_log: false,
1669                prompt_injection_protection: false,
1670            },
1671            runtime: RuntimeConfig::default(),
1672            telemetry: TelemetryConfig::default(),
1673            scheduler: SchedulerConfig::default(),
1674            web_search: WebSearchConfig::default(),
1675            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1676            mcp: McpConfig::default(),
1677            swarm: crate::swarm::SwarmConfig::default(),
1678            browser: BrowserConfig::default(),
1679            load: crate::load::LoadConfig::default(),
1680            web_policy: crate::web_policy::WebAccessPolicy::default(),
1681        };
1682
1683        let result = config.validate();
1684        assert!(result.is_ok());
1685    }
1686
1687    #[test]
1688    fn test_validate_litellm_with_empty_endpoint_fails() {
1689        let config = Config {
1690            llm: LLMConfig {
1691                provider: LLMProvider::LiteLLM,
1692                endpoint: String::new(),
1693                model: "gpt-4o-mini".to_string(),
1694                api_key: Some("key".to_string()),
1695                timeout_secs: 30,
1696                system_prompt: default_system_prompt(),
1697                token_budget: None,
1698                retry_max: 3,
1699                retry_base_delay_ms: 100,
1700                retry_max_delay_ms: 10000,
1701            },
1702            llms: vec![],
1703            ravenfabric: RavenFabricConfig::default(),
1704            security: SecurityConfig {
1705                require_tls: false,
1706                token_lifetime_secs: 3600,
1707                audit_log: false,
1708                prompt_injection_protection: false,
1709            },
1710            runtime: RuntimeConfig::default(),
1711            telemetry: TelemetryConfig::default(),
1712            scheduler: SchedulerConfig::default(),
1713            web_search: WebSearchConfig::default(),
1714            heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1715            mcp: McpConfig::default(),
1716            swarm: crate::swarm::SwarmConfig::default(),
1717            browser: BrowserConfig::default(),
1718            load: crate::load::LoadConfig::default(),
1719            web_policy: crate::web_policy::WebAccessPolicy::default(),
1720        };
1721
1722        let result = config.validate();
1723        assert!(result.is_err());
1724        assert!(result
1725            .unwrap_err()
1726            .to_string()
1727            .contains("At least one LLM provider"));
1728    }
1729
1730    #[test]
1731    fn test_llm_provider_serde_serialize() {
1732        let provider = LLMProvider::OpenAI;
1733        let json = serde_json::to_string(&provider).unwrap();
1734        assert_eq!(json, r#""openai""#);
1735
1736        let provider = LLMProvider::Ollama;
1737        let json = serde_json::to_string(&provider).unwrap();
1738        assert_eq!(json, r#""ollama""#);
1739
1740        let provider = LLMProvider::OpenRouter;
1741        let json = serde_json::to_string(&provider).unwrap();
1742        assert_eq!(json, r#""openrouter""#);
1743
1744        let provider = LLMProvider::LiteLLM;
1745        let json = serde_json::to_string(&provider).unwrap();
1746        assert_eq!(json, r#""litellm""#);
1747    }
1748}