Skip to main content

oxios_kernel/
config.rs

1#![allow(missing_docs)]
2//! Configuration loading from TOML files.
3//!
4//! Configuration is stored at `~/.oxios/config.toml` and controls
5//! kernel, gateway, and execution settings.
6
7use cron::Schedule;
8use serde::{Deserialize, Serialize};
9use std::str::FromStr;
10
11use crate::email::{SmtpProvider, SmtpTls};
12use crate::scheduler::Priority;
13
14/// Cron scheduler configuration.
15#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct CronConfig {
17    /// Enable the cron scheduler.
18    #[serde(default)]
19    pub enabled: bool,
20    /// Tick interval in seconds.
21    #[serde(default = "default_tick_interval")]
22    pub tick_interval_secs: u64,
23    /// Inline job definitions from config.toml.
24    #[serde(default)]
25    pub jobs: std::collections::HashMap<String, InlineCronJob>,
26}
27
28impl Default for CronConfig {
29    fn default() -> Self {
30        Self {
31            enabled: false,
32            tick_interval_secs: default_tick_interval(),
33            jobs: std::collections::HashMap::new(),
34        }
35    }
36}
37
38fn default_tick_interval() -> u64 {
39    60
40}
41
42/// Inline cron job definition in config.toml.
43#[derive(Debug, Clone, Deserialize, Serialize)]
44pub struct InlineCronJob {
45    /// Cron expression (e.g. "0 */6 * * *").
46    pub schedule: String,
47    /// Goal description for the agent.
48    pub goal: String,
49    /// Constraints on agent behavior.
50    #[serde(default)]
51    pub constraints: Vec<String>,
52    /// Criteria that must be met for the job to be considered successful.
53    #[serde(default)]
54    pub acceptance_criteria: Vec<String>,
55    /// Toolchain preset name.
56    #[serde(default = "default_toolchain_inline")]
57    pub toolchain: String,
58    /// Job priority.
59    #[serde(default)]
60    pub priority: Priority,
61    /// Whether the job is active.
62    #[serde(default = "default_true_inline")]
63    pub enabled: bool,
64}
65
66fn default_toolchain_inline() -> String {
67    "default".into()
68}
69
70fn default_true_inline() -> bool {
71    true
72}
73
74/// Memory system configuration.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct MemoryConfig {
77    /// Enable the memory system.
78    #[serde(default = "default_true")]
79    pub enabled: bool,
80    /// Maximum memories returned by recall.
81    #[serde(default = "default_max_recall")]
82    pub max_recall: usize,
83    /// Auto-summarize sessions on completion.
84    #[serde(default = "default_true")]
85    pub auto_summarize: bool,
86    /// Capture compaction summaries as conversation memory.
87    #[serde(default = "default_true")]
88    pub capture_compaction: bool,
89    /// Memory retention in days (0 = unlimited).
90    #[serde(default)]
91    pub retention_days: u32,
92    /// Enable embedding cache.
93    #[serde(default = "default_true")]
94    pub cache_enabled: bool,
95    /// Embedding cache TTL in seconds.
96    #[serde(default = "default_cache_ttl")]
97    pub cache_ttl_secs: u64,
98    /// Maximum embedding cache entries.
99    #[serde(default = "default_cache_max_entries")]
100    pub cache_max_entries: usize,
101    /// Consolidation configuration (RFC-008).
102    #[serde(default)]
103    pub consolidation: ConsolidationConfig,
104    /// SQLite memory storage configuration (RFC-012).
105    #[serde(default)]
106    pub sqlite: SqliteMemoryConfig,
107    /// Embedding provider configuration (RFC-012).
108    #[serde(default)]
109    pub embedding: EmbeddingConfig,
110    /// Learning configuration (RFC-012 Phase 4: SONA).
111    #[serde(default)]
112    pub learning: LearningConfig,
113    /// Knowledge dream configuration (RFC-022).
114    #[serde(default)]
115    pub knowledge_dream: crate::knowledge_dream::KnowledgeDreamConfig,
116    /// AutoMemoryBridge configuration (RFC-012 Phase 7: SQLite ↔ MEMORY.md sync).
117    #[serde(default)]
118    pub bridge: MemoryBridgeConfig,
119}
120
121fn default_true() -> bool {
122    true
123}
124
125fn default_max_recall() -> usize {
126    10
127}
128
129fn default_cache_ttl() -> u64 {
130    3600 // 1 hour
131}
132
133fn default_cache_max_entries() -> usize {
134    10000
135}
136
137impl Default for MemoryConfig {
138    fn default() -> Self {
139        Self {
140            enabled: true,
141            max_recall: 10,
142            auto_summarize: true,
143            capture_compaction: true,
144            retention_days: 0,
145            cache_enabled: true,
146            cache_ttl_secs: 3600,
147            cache_max_entries: 10000,
148            consolidation: ConsolidationConfig::default(),
149            sqlite: SqliteMemoryConfig::default(),
150            embedding: EmbeddingConfig::default(),
151            learning: LearningConfig::default(),
152            knowledge_dream: crate::knowledge_dream::KnowledgeDreamConfig::default(),
153            bridge: MemoryBridgeConfig::default(),
154        }
155    }
156}
157
158// ---------------------------------------------------------------------------
159// SqliteMemoryConfig (RFC-012: SQLite Memory Storage)
160// ---------------------------------------------------------------------------
161
162/// SQLite-backed memory storage configuration (RFC-012).
163///
164/// When enabled, memories are stored in a single `memory.db` file with
165/// FTS5 BM25 + sqlite-vec KNN search. Falls back to the existing JSON
166/// + TF-IDF approach when disabled.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SqliteMemoryConfig {
169    /// Enable SQLite-backed memory storage.
170    #[serde(default = "default_true")]
171    pub enabled: bool,
172    /// Path to the SQLite database file.
173    /// Empty string means default: `~/.oxios/workspace/memory.db`
174    #[serde(default)]
175    pub path: String,
176    /// Embedding vector dimension.
177    /// Controls the `vec0` virtual table dimension.
178    /// Common values: 128 (fast), 256 (balanced), 768 (full Gemma).
179    #[serde(default = "default_embedding_dim")]
180    pub embedding_dim: usize,
181    /// Enable WAL mode for concurrent reads.
182    #[serde(default = "default_true")]
183    pub wal_mode: bool,
184}
185
186fn default_embedding_dim() -> usize {
187    256
188}
189
190impl Default for SqliteMemoryConfig {
191    fn default() -> Self {
192        Self {
193            enabled: true,
194            path: String::new(),
195            embedding_dim: 256,
196            wal_mode: true,
197        }
198    }
199}
200
201// ---------------------------------------------------------------------------
202// EmbeddingConfig (RFC-012: Embedding Provider)
203// ---------------------------------------------------------------------------
204
205/// Embedding provider configuration (RFC-012).
206///
207/// Controls which embedding model is used for semantic search.
208/// When `embedding-mlx` feature is enabled and `provider = "mlx"`,
209/// uses EmbeddingGemma-300m via MLX on Apple Silicon.
210/// Otherwise falls back to TF-IDF.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct EmbeddingConfig {
213    /// Embedding provider: "tfidf" (default) or "mlx" (Apple Silicon).
214    #[serde(default = "default_embedding_provider")]
215    pub provider: String,
216    /// Matryoshka dimension: 128, 256, 512, or 768.
217    /// Only used when provider = "mlx".
218    #[serde(default = "default_embedding_dim")]
219    pub dimension: usize,
220    /// Model TTL in seconds. Unloaded after this duration of inactivity.
221    /// Only used when provider = "mlx".
222    #[serde(default = "default_model_ttl")]
223    pub model_ttl_secs: u64,
224}
225
226fn default_embedding_provider() -> String {
227    "gguf".to_string()
228}
229
230fn default_model_ttl() -> u64 {
231    300 // 5 minutes
232}
233
234impl Default for EmbeddingConfig {
235    fn default() -> Self {
236        Self {
237            provider: default_embedding_provider(),
238            dimension: default_embedding_dim(),
239            model_ttl_secs: default_model_ttl(),
240        }
241    }
242}
243
244// ---------------------------------------------------------------------------
245// LearningConfig (RFC-012 Phase 4: SONA)
246// ---------------------------------------------------------------------------
247
248/// Learning engine configuration (RFC-012 Phase 4).
249///
250/// Controls SONA self-learning persistence.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct LearningConfig {
253    /// Enable the learning subsystem (SONA).
254    #[serde(default = "default_true")]
255    pub enabled: bool,
256    /// SONA operating mode: "realtime", "balanced", "research", "edge".
257    #[serde(default = "default_sona_mode")]
258    pub sona_mode: String,
259    /// Interval between automatic distillation runs (hours).
260    #[serde(default = "default_distill_interval")]
261    pub distill_interval_hours: u64,
262    /// Minimum quality score for auto-promoting patterns to long-term.
263    #[serde(default = "default_auto_promote_quality")]
264    pub auto_promote_quality: f32,
265    /// Minimum usage count before auto-promotion is considered.
266    #[serde(default = "default_auto_promote_min_usage")]
267    pub auto_promote_min_usage: u32,
268}
269
270fn default_sona_mode() -> String {
271    "balanced".to_string()
272}
273
274fn default_distill_interval() -> u64 {
275    6
276}
277
278fn default_auto_promote_quality() -> f32 {
279    0.8
280}
281
282fn default_auto_promote_min_usage() -> u32 {
283    3
284}
285
286impl Default for LearningConfig {
287    fn default() -> Self {
288        Self {
289            enabled: true,
290            sona_mode: default_sona_mode(),
291            distill_interval_hours: default_distill_interval(),
292            auto_promote_quality: default_auto_promote_quality(),
293            auto_promote_min_usage: default_auto_promote_min_usage(),
294        }
295    }
296}
297
298// ---------------------------------------------------------------------------
299// MemoryBridgeConfig (RFC-012 Phase 7: SQLite ↔ MEMORY.md)
300// ---------------------------------------------------------------------------
301
302/// AutoMemoryBridge configuration (RFC-012 Phase 7).
303///
304/// Controls bidirectional sync between SQLite memory store
305/// and external MEMORY.md files.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct MemoryBridgeConfig {
308    /// Enable bidirectional sync with MEMORY.md.
309    #[serde(default)]
310    pub sync_enabled: bool,
311    /// Sync interval in seconds.
312    #[serde(default = "default_bridge_interval")]
313    pub interval_secs: u64,
314}
315
316fn default_bridge_interval() -> u64 {
317    3600
318}
319
320impl Default for MemoryBridgeConfig {
321    fn default() -> Self {
322        Self {
323            sync_enabled: false,
324            interval_secs: default_bridge_interval(),
325        }
326    }
327}
328
329// ---------------------------------------------------------------------------
330// ConsolidationConfig (RFC-008: Memory Consolidation)
331// ---------------------------------------------------------------------------
332
333/// Memory consolidation configuration (RFC-008).
334/// All values have sensible defaults — users never need to configure these.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct ConsolidationConfig {
337    /// Preset: "conservative" | "balanced" | "aggressive" | "custom".
338    /// When not "custom", all other fields are overridden by the preset values.
339    /// Call `apply_preset()` once during kernel init to resolve.
340    #[serde(default = "default_preset")]
341    pub preset: String,
342
343    // ── Dream Process ─────────────────────────────────
344    #[serde(default = "default_true")]
345    pub dream_enabled: bool,
346    #[serde(default = "default_dream_interval")]
347    pub dream_interval_hours: u64,
348    #[serde(default = "default_dream_min_sessions")]
349    pub dream_min_sessions: u32,
350
351    // ── Tier Budgets ──────────────────────────────────
352    #[serde(default = "default_hot_max")]
353    pub hot_max_entries: usize,
354    #[serde(default = "default_warm_max")]
355    pub warm_max_entries: usize,
356    #[serde(default = "default_cold_max")]
357    pub cold_max_entries: usize,
358    #[serde(default = "default_hot_token_budget")]
359    pub hot_token_budget: usize,
360
361    // ── Decay ─────────────────────────────────────────
362    #[serde(default = "default_true")]
363    pub decay_enabled: bool,
364    #[serde(default = "default_one")]
365    pub decay_multiplier: f32,
366    #[serde(default = "default_decay_threshold")]
367    pub decay_threshold: f32,
368    #[serde(default = "default_retention_days")]
369    pub retention_days: u32,
370
371    // ── Auto-Protection ───────────────────────────────
372    #[serde(default = "default_true")]
373    pub auto_protection: bool,
374    #[serde(default = "default_protection_low_access")]
375    pub protection_low_access: u32,
376    #[serde(default = "default_protection_medium_access")]
377    pub protection_medium_access: u32,
378    #[serde(default = "default_protection_high_access")]
379    pub protection_high_access: u32,
380    #[serde(default = "default_protection_medium_sessions")]
381    pub protection_medium_sessions: u32,
382    #[serde(default = "default_protection_high_sessions")]
383    pub protection_high_sessions: u32,
384
385    // ── Auto-Classification ───────────────────────────
386    #[serde(default = "default_true")]
387    pub auto_classification: bool,
388    #[serde(default = "default_type_promotion_threshold")]
389    pub type_promotion_repetitions: u32,
390
391    // ── Compaction ────────────────────────────────────
392    #[serde(default = "default_compaction_threshold")]
393    pub compaction_line_threshold: usize,
394    #[serde(default = "default_true")]
395    pub llm_compaction: bool,
396
397    // ── Dream LLM ──────────────────────────────────────
398    /// Optional model for Dream LLM operations (None = rule-based fallback).
399    #[serde(default)]
400    pub dream_model: Option<String>,
401
402    // ── Protection Demotion ────────────────────────────
403    #[serde(default = "default_true")]
404    pub protection_demotion_enabled: bool,
405    #[serde(default = "default_demotion_stale_days")]
406    pub protection_demotion_stale_days: u32,
407    #[serde(default = "default_demotion_max_step")]
408    pub protection_demotion_max_step: u32,
409
410    // ── Proactive Recall ──────────────────────────────
411    #[serde(default = "default_true")]
412    pub proactive_recall: bool,
413    #[serde(default = "default_proactive_limit")]
414    pub proactive_recall_limit: usize,
415    #[serde(default = "default_proactive_threshold")]
416    pub proactive_recall_threshold: f32,
417}
418
419fn default_dream_interval() -> u64 {
420    24
421}
422fn default_dream_min_sessions() -> u32 {
423    5
424}
425fn default_hot_max() -> usize {
426    50
427}
428fn default_warm_max() -> usize {
429    500
430}
431fn default_cold_max() -> usize {
432    10_000
433}
434fn default_hot_token_budget() -> usize {
435    3_000
436}
437fn default_one() -> f32 {
438    1.0
439}
440fn default_decay_threshold() -> f32 {
441    0.05
442}
443fn default_retention_days() -> u32 {
444    90
445}
446fn default_protection_low_access() -> u32 {
447    2
448}
449fn default_protection_medium_access() -> u32 {
450    3
451}
452fn default_protection_high_access() -> u32 {
453    5
454}
455fn default_protection_medium_sessions() -> u32 {
456    2
457}
458fn default_protection_high_sessions() -> u32 {
459    3
460}
461fn default_type_promotion_threshold() -> u32 {
462    3
463}
464fn default_compaction_threshold() -> usize {
465    200
466}
467fn default_proactive_limit() -> usize {
468    5
469}
470fn default_proactive_threshold() -> f32 {
471    0.6
472}
473fn default_demotion_stale_days() -> u32 {
474    30
475}
476fn default_demotion_max_step() -> u32 {
477    1
478}
479
480fn default_preset() -> String {
481    "balanced".into()
482}
483
484impl Default for ConsolidationConfig {
485    fn default() -> Self {
486        Self {
487            preset: default_preset(),
488            dream_enabled: true,
489            dream_interval_hours: 24,
490            dream_min_sessions: 5,
491            hot_max_entries: 50,
492            warm_max_entries: 500,
493            cold_max_entries: 10_000,
494            hot_token_budget: 3_000,
495            decay_enabled: true,
496            decay_multiplier: 1.0,
497            decay_threshold: 0.05,
498            retention_days: 90,
499            auto_protection: true,
500            protection_low_access: 2,
501            protection_medium_access: 3,
502            protection_high_access: 5,
503            protection_medium_sessions: 2,
504            protection_high_sessions: 3,
505            auto_classification: true,
506            type_promotion_repetitions: 3,
507            compaction_line_threshold: 200,
508            llm_compaction: true,
509            dream_model: None,
510            protection_demotion_enabled: true,
511            protection_demotion_stale_days: 30,
512            protection_demotion_max_step: 1,
513            proactive_recall: true,
514            proactive_recall_limit: 5,
515            proactive_recall_threshold: 0.6,
516        }
517    }
518}
519
520impl ConsolidationConfig {
521    /// Apply the preset to all fields.
522    /// Call once during kernel initialization.
523    /// When `preset` is "custom", individual fields are left untouched.
524    pub fn apply_preset(&mut self) {
525        let resolved = match self.preset.as_str() {
526            "conservative" => Self::conservative(),
527            "aggressive" => Self::aggressive(),
528            "custom" => return,
529            _ => Self::default(), // "balanced" 및 알 수 없는 값
530        };
531        *self = resolved;
532    }
533
534    /// Conservative preset: slow decay, long retention, larger capacities.
535    fn conservative() -> Self {
536        Self {
537            preset: "conservative".into(),
538            dream_enabled: true,
539            dream_interval_hours: 48,
540            dream_min_sessions: 10,
541            hot_max_entries: 100,
542            warm_max_entries: 1000,
543            cold_max_entries: 50_000,
544            hot_token_budget: 5_000,
545            decay_enabled: true,
546            decay_multiplier: 0.8,
547            decay_threshold: 0.05,
548            retention_days: 365,
549            auto_protection: true,
550            protection_low_access: 3,
551            protection_medium_access: 5,
552            protection_high_access: 10,
553            protection_medium_sessions: 3,
554            protection_high_sessions: 5,
555            auto_classification: true,
556            type_promotion_repetitions: 5,
557            compaction_line_threshold: 300,
558            llm_compaction: true,
559            dream_model: None,
560            protection_demotion_enabled: true,
561            protection_demotion_stale_days: 90,
562            protection_demotion_max_step: 1,
563            proactive_recall: true,
564            proactive_recall_limit: 8,
565            proactive_recall_threshold: 0.5,
566        }
567    }
568
569    /// Aggressive preset: fast decay, short retention, smaller capacities.
570    fn aggressive() -> Self {
571        Self {
572            preset: "aggressive".into(),
573            dream_enabled: true,
574            dream_interval_hours: 4,
575            dream_min_sessions: 2,
576            hot_max_entries: 20,
577            warm_max_entries: 100,
578            cold_max_entries: 1_000,
579            hot_token_budget: 2_000,
580            decay_enabled: true,
581            decay_multiplier: 1.0,
582            decay_threshold: 0.1,
583            retention_days: 30,
584            auto_protection: true,
585            protection_low_access: 1,
586            protection_medium_access: 2,
587            protection_high_access: 3,
588            protection_medium_sessions: 1,
589            protection_high_sessions: 2,
590            auto_classification: true,
591            type_promotion_repetitions: 2,
592            compaction_line_threshold: 150,
593            llm_compaction: true,
594            dream_model: None,
595            protection_demotion_enabled: true,
596            protection_demotion_stale_days: 14,
597            protection_demotion_max_step: 2,
598            proactive_recall: true,
599            proactive_recall_limit: 3,
600            proactive_recall_threshold: 0.7,
601        }
602    }
603}
604
605/// Channel activation configuration.
606#[derive(Debug, Clone, Deserialize, Serialize, Default)]
607pub struct ChannelsConfig {
608    /// List of channel names to activate on startup.
609    /// Channels are message-only interfaces (CLI, Telegram).
610    #[serde(default)]
611    pub enabled: Vec<String>,
612
613    /// Telegram-specific configuration.
614    #[serde(default)]
615    pub telegram: TelegramChannelConfig,
616}
617
618/// Surface activation configuration.
619///
620/// Surfaces are kernel-connected control interfaces (Web dashboard, future desktop apps).
621/// They have direct kernel access for management, monitoring, and configuration.
622#[derive(Debug, Clone, Deserialize, Serialize)]
623pub struct SurfacesConfig {
624    /// List of surface names to activate on startup.
625    /// Default: ["web"] if the web feature is compiled in.
626    #[serde(default = "default_surfaces_enabled")]
627    pub enabled: Vec<String>,
628}
629
630fn default_surfaces_enabled() -> Vec<String> {
631    vec!["web".to_string()]
632}
633
634impl Default for SurfacesConfig {
635    fn default() -> Self {
636        Self {
637            enabled: default_surfaces_enabled(),
638        }
639    }
640}
641
642/// Telegram channel configuration.
643#[derive(Debug, Clone, Deserialize, Serialize)]
644pub struct TelegramChannelConfig {
645    /// Environment variable name holding the bot token.
646    #[serde(default = "default_telegram_token_env")]
647    pub bot_token_env: String,
648    /// List of allowed Telegram user IDs (empty = allow all).
649    #[serde(default)]
650    pub allowed_users: Vec<i64>,
651    /// Telegram session management settings.
652    #[serde(default)]
653    pub session: TelegramSessionConfig,
654}
655
656fn default_telegram_token_env() -> String {
657    "TELEGRAM_BOT_TOKEN".to_string()
658}
659
660impl Default for TelegramChannelConfig {
661    fn default() -> Self {
662        Self {
663            bot_token_env: default_telegram_token_env(),
664            allowed_users: Vec::new(),
665            session: TelegramSessionConfig::default(),
666        }
667    }
668}
669
670/// LLM engine configuration.
671#[derive(Debug, Clone, Deserialize, Serialize)]
672#[allow(clippy::derivable_impls)]
673pub struct EngineConfig {
674    /// Default model in "provider/model" format.
675    /// Empty string means no model configured — onboarding required.
676    #[serde(default)]
677    pub default_model: String,
678    /// Explicit API key override (highest priority).
679    /// If empty/None, falls back to oxi auth store, then env vars.
680    /// Masked when serialized to API responses.
681    #[serde(default, skip_serializing)]
682    pub api_key: Option<String>,
683    /// Per-provider options for fine-grained control (thinking mode, etc.).
684    /// Passed through to `AgentLoopConfig::provider_options`.
685    #[serde(default)]
686    pub provider_options: Option<oxi_sdk::ProviderOptions>,
687    /// Enable complexity-based model routing.
688    /// When enabled, the engine can route simple tasks to cheaper models
689    /// and complex tasks to more capable ones.
690    #[serde(default)]
691    pub routing_enabled: bool,
692    /// Prefer cost-efficient models when routing.
693    #[serde(default)]
694    pub prefer_cost_efficient: bool,
695    /// Fallback models to try when the primary model fails.
696    #[serde(default)]
697    pub fallback_models: Vec<String>,
698    /// Models excluded from automatic routing.
699    #[serde(default)]
700    pub excluded_models: Vec<String>,
701}
702
703#[allow(clippy::derivable_impls)]
704impl Default for EngineConfig {
705    fn default() -> Self {
706        Self {
707            default_model: String::new(),
708            api_key: None,
709            provider_options: None,
710            routing_enabled: false,
711            prefer_cost_efficient: false,
712            fallback_models: Vec::new(),
713            excluded_models: Vec::new(),
714        }
715    }
716}
717
718/// Daemon mode configuration.
719#[derive(Debug, Clone, Deserialize, Serialize)]
720pub struct DaemonConfig {
721    /// PID file path.
722    #[serde(default = "default_pid_file")]
723    pub pid_file: String,
724    /// Log directory.
725    #[serde(default = "default_daemon_log_dir")]
726    pub log_dir: String,
727}
728
729fn default_pid_file() -> String {
730    dirs::home_dir()
731        .map(|h| format!("{}/.oxios/oxios.pid", h.display()))
732        .unwrap_or_else(|| "./oxios.pid".into())
733}
734
735fn default_daemon_log_dir() -> String {
736    dirs::home_dir()
737        .map(|h| format!("{}/.oxios/logs", h.display()))
738        .unwrap_or_else(|| "./logs".into())
739}
740
741impl Default for DaemonConfig {
742    fn default() -> Self {
743        Self {
744            pid_file: default_pid_file(),
745            log_dir: default_daemon_log_dir(),
746        }
747    }
748}
749
750/// Session management configuration.
751#[derive(Debug, Clone, Deserialize, Serialize)]
752pub struct SessionConfig {
753    /// Maximum number of sessions to retain.
754    /// When exceeded, oldest sessions (by `updated_at`) are pruned.
755    /// Set to 0 for unlimited.
756    #[serde(default = "default_max_sessions")]
757    pub max_sessions: usize,
758
759    /// Time-to-live for sessions in hours.
760    /// Sessions older than this are automatically pruned.
761    /// Set to 0 for unlimited (no TTL-based pruning).
762    #[serde(default = "default_session_ttl_hours")]
763    pub ttl_hours: u64,
764
765    /// Enable automatic session pruning on every session save.
766    #[serde(default = "default_true")]
767    pub auto_prune: bool,
768}
769
770fn default_max_sessions() -> usize {
771    100
772}
773
774fn default_session_ttl_hours() -> u64 {
775    168 // 7 days
776}
777
778impl Default for SessionConfig {
779    fn default() -> Self {
780        Self {
781            max_sessions: default_max_sessions(),
782            ttl_hours: default_session_ttl_hours(),
783            auto_prune: true,
784        }
785    }
786}
787
788/// Telegram session management configuration.
789#[derive(Debug, Clone, Deserialize, Serialize)]
790pub struct TelegramSessionConfig {
791    /// Automatically rotate to a new session after this many hours of inactivity.
792    /// Set to 0 to disable time-based rotation.
793    #[serde(default = "default_telegram_session_rotation_hours")]
794    pub rotation_hours: u64,
795
796    /// Maximum number of messages per session before auto-rotating.
797    /// Set to 0 for unlimited.
798    #[serde(default = "default_telegram_session_max_messages")]
799    pub max_messages: usize,
800}
801
802fn default_telegram_session_rotation_hours() -> u64 {
803    2 // 2 hours
804}
805
806fn default_telegram_session_max_messages() -> usize {
807    0 // unlimited by default
808}
809
810impl Default for TelegramSessionConfig {
811    fn default() -> Self {
812        Self {
813            rotation_hours: default_telegram_session_rotation_hours(),
814            max_messages: default_telegram_session_max_messages(),
815        }
816    }
817}
818
819/// Top-level Oxios configuration.
820#[derive(Debug, Clone, Deserialize, Serialize, Default)]
821pub struct OxiosConfig {
822    /// Kernel settings.
823    pub kernel: KernelConfig,
824    /// LLM engine settings.
825    #[serde(default)]
826    pub engine: EngineConfig,
827    /// Daemon mode settings.
828    #[serde(default)]
829    pub daemon: DaemonConfig,
830    /// Gateway settings.
831    #[serde(default)]
832    pub gateway: GatewayConfig,
833    /// Scheduler settings (AIOS-inspired task scheduling).
834    #[serde(default)]
835    pub scheduler: SchedulerConfig,
836    /// Orchestrator settings (Ouroboros protocol execution).
837    #[serde(default)]
838    pub orchestrator: OrchestratorConfig,
839    /// Context manager settings (LLM context window management).
840    #[serde(default)]
841    pub context: ContextConfig,
842    /// Security/access control settings.
843    #[serde(default)]
844    pub security: SecurityConfig,
845    /// Persona system settings.
846    #[serde(default)]
847    pub persona: PersonaConfig,
848    /// Memory system settings.
849    #[serde(default)]
850    pub memory: MemoryConfig,
851    /// Cron scheduler settings.
852    #[serde(default)]
853    pub cron: CronConfig,
854    /// MCP server configurations.
855    #[serde(default)]
856    pub mcp: McpConfig,
857    /// Git version control settings.
858    #[serde(default)]
859    pub git: GitConfig,
860    /// Audit trail configuration.
861    #[serde(default)]
862    pub audit: AuditConfig,
863    /// Budget enforcement configuration.
864    #[serde(default)]
865    pub budget: BudgetConfig,
866    /// Exec configuration (host command execution bridge).
867    #[serde(default)]
868    pub exec: ExecConfig,
869    /// Resource monitor configuration.
870    #[serde(default)]
871    pub resource_monitor: ResourceMonitorConfig,
872    /// OpenTelemetry tracing configuration.
873    #[serde(default)]
874    pub otel: OtelConfig,
875    /// Logging configuration.
876    #[serde(default)]
877    pub logging: LoggingConfig,
878    /// Channel activation configuration (message interfaces: CLI, Telegram).
879    #[serde(default)]
880    pub channels: ChannelsConfig,
881    /// Surface activation configuration (control interfaces: Web dashboard).
882    #[serde(default)]
883    pub surfaces: Option<SurfacesConfig>,
884    /// Headless browser configuration.
885    #[serde(default)]
886    pub browser: BrowserConfig,
887    /// Session management configuration.
888    #[serde(default)]
889    pub session: SessionConfig,
890    /// ClawHub marketplace configuration.
891    #[serde(default)]
892    pub marketplace: MarketplaceConfig,
893    /// Calendar configuration.
894    #[serde(default)]
895    pub calendar: CalendarConfig,
896    /// Email configuration.
897    #[serde(default)]
898    pub email: EmailConfig,
899    /// Agent history log configuration.
900    #[serde(default)]
901    pub agent_log: AgentLogConfig,
902}
903
904/// Kernel configuration.
905#[derive(Debug, Clone, Deserialize, Serialize)]
906pub struct KernelConfig {
907    /// Path to the workspace directory.
908    #[serde(default = "default_workspace")]
909    pub workspace: String,
910    /// Broadcast capacity for the event bus.
911    #[serde(default = "default_event_bus_capacity")]
912    pub event_bus_capacity: usize,
913    /// Maximum number of concurrent agents.
914    #[serde(default = "default_max_agents")]
915    pub max_agents: usize,
916}
917
918fn default_workspace() -> String {
919    dirs_home().unwrap_or_else(|| ".".into())
920}
921
922fn dirs_home() -> Option<String> {
923    dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
924}
925
926fn default_event_bus_capacity() -> usize {
927    256
928}
929
930fn default_max_agents() -> usize {
931    10
932}
933
934impl Default for KernelConfig {
935    fn default() -> Self {
936        Self {
937            workspace: default_workspace(),
938            event_bus_capacity: default_event_bus_capacity(),
939            max_agents: 10,
940        }
941    }
942}
943
944/// Gateway configuration.
945#[derive(Debug, Clone, Deserialize, Serialize)]
946pub struct GatewayConfig {
947    /// Host to bind the gateway to.
948    #[serde(default = "default_gateway_host")]
949    pub host: String,
950    /// Port for the gateway server.
951    #[serde(default = "default_gateway_port")]
952    pub port: u16,
953    /// Expose `/api-docs` (Swagger UI) and `/openapi.json`.
954    ///
955    /// For safety this is gated to localhost-only binds (127.0.0.0/8, ::1,
956    /// "localhost"). Setting this to `true` while binding to a public address
957    /// is a no-op. Default: `false`.
958    ///
959    /// Why: Swagger UI + the full OpenAPI schema expand the attack surface
960    /// (route discovery, parameter names, security scheme details). Local
961    /// dev typically wants them; production typically does not.
962    #[serde(default)]
963    pub expose_api_docs: bool,
964}
965
966impl GatewayConfig {
967    /// Whether the gateway may expose `/api-docs` and `/openapi.json`.
968    ///
969    /// Returns `true` only when both:
970    /// - `expose_api_docs` is explicitly enabled, AND
971    /// - the bind address is a loopback address.
972    pub fn should_expose_api_docs(&self) -> bool {
973        if !self.expose_api_docs {
974            return false;
975        }
976        let h = self.host.trim();
977        h == "127.0.0.1" || h == "::1" || h == "localhost" || h.starts_with("127.")
978    }
979}
980
981/// ClawHub marketplace configuration.
982#[derive(Debug, Clone, Deserialize, Serialize)]
983pub struct MarketplaceConfig {
984    /// Base URL for the ClawHub registry.
985    /// Defaults to `https://clawhub.ai`.
986    #[serde(default)]
987    pub base_url: Option<String>,
988    /// Whether the marketplace is enabled.
989    #[serde(default = "default_true")]
990    pub enabled: bool,
991    /// Skills.sh (Vercel Labs ecosystem) configuration.
992    #[serde(default)]
993    pub skills_sh: SkillsShConfig,
994}
995
996/// Skills.sh registry configuration.
997#[derive(Debug, Clone, Deserialize, Serialize)]
998pub struct SkillsShConfig {
999    /// Base URL for the Skills.sh API.
1000    /// Defaults to `https://skills.sh`.
1001    #[serde(default)]
1002    pub base_url: Option<String>,
1003    /// API key for Skills.sh authentication.
1004    /// Falls back to `SKILLS_SH_TOKEN` env var if not set.
1005    #[serde(default)]
1006    pub api_key: Option<String>,
1007    /// Whether Skills.sh integration is enabled.
1008    #[serde(default = "default_true")]
1009    pub enabled: bool,
1010}
1011
1012impl Default for MarketplaceConfig {
1013    fn default() -> Self {
1014        Self {
1015            base_url: Some("https://clawhub.ai".to_string()),
1016            enabled: true,
1017            skills_sh: SkillsShConfig::default(),
1018        }
1019    }
1020}
1021
1022impl Default for SkillsShConfig {
1023    fn default() -> Self {
1024        Self {
1025            base_url: None,
1026            api_key: None,
1027            enabled: true,
1028        }
1029    }
1030}
1031
1032/// Calendar configuration.
1033#[derive(Debug, Clone, Deserialize, Serialize)]
1034pub struct CalendarConfig {
1035    /// Enable the calendar system.
1036    #[serde(default)]
1037    pub enabled: bool,
1038    /// Default timezone for events.
1039    #[serde(default = "default_calendar_timezone")]
1040    pub timezone: String,
1041    /// Default reminder minutes for new events.
1042    #[serde(default = "default_reminder_minutes")]
1043    pub default_reminder_minutes: Vec<u32>,
1044    /// Alarm dispatch channels.
1045    #[serde(default)]
1046    pub alarm_channels: Vec<String>,
1047    /// Journal sync mode: "on_open", "midnight", "both".
1048    #[serde(default = "default_journal_sync")]
1049    pub journal_sync: String,
1050    /// Show cron jobs on the calendar.
1051    #[serde(default = "default_true")]
1052    pub system_calendar: bool,
1053    /// Days after which old events are archived.
1054    #[serde(default = "default_archive_days")]
1055    pub archive_after_days: u32,
1056}
1057
1058fn default_calendar_timezone() -> String {
1059    "Asia/Seoul".to_string()
1060}
1061
1062fn default_reminder_minutes() -> Vec<u32> {
1063    vec![15]
1064}
1065
1066fn default_journal_sync() -> String {
1067    "on_open".to_string()
1068}
1069
1070fn default_archive_days() -> u32 {
1071    365
1072}
1073
1074impl Default for CalendarConfig {
1075    fn default() -> Self {
1076        Self {
1077            enabled: false,
1078            timezone: default_calendar_timezone(),
1079            default_reminder_minutes: default_reminder_minutes(),
1080            alarm_channels: vec![],
1081            journal_sync: default_journal_sync(),
1082            system_calendar: true,
1083            archive_after_days: default_archive_days(),
1084        }
1085    }
1086}
1087
1088/// Email configuration.
1089///
1090/// Controls SMTP email sending. When enabled, agents gain the `send_email` tool.
1091/// v1 sends to the user's own email only.
1092#[derive(Debug, Clone, Deserialize, Serialize)]
1093pub struct EmailConfig {
1094    /// Enable the email system.
1095    #[serde(default)]
1096    pub enabled: bool,
1097    /// The user's email address (used as both sender and default recipient).
1098    #[serde(default)]
1099    pub my_email: String,
1100    /// SMTP provider preset ("gmail", "icloud", "fastmail", "custom").
1101    #[serde(default = "default_email_provider")]
1102    pub provider: SmtpProvider,
1103    /// SMTP host (auto-filled from provider if empty).
1104    #[serde(default)]
1105    pub host: String,
1106    /// SMTP port (auto-filled from provider if 0).
1107    #[serde(default)]
1108    pub port: u16,
1109    /// TLS mode (auto-filled from provider if None).
1110    #[serde(default)]
1111    pub tls: Option<SmtpTls>,
1112    /// SMTP auth username (defaults to `my_email` if empty).
1113    #[serde(default)]
1114    pub user: String,
1115    /// Credential store key for the SMTP password.
1116    /// Falls back to `OXIOS_EMAIL_PASSWORD` env var.
1117    #[serde(default = "default_email_secret_ref")]
1118    pub secret_ref: String,
1119    /// Maximum emails per hour (rate limit, default: 10).
1120    #[serde(default = "default_rate_limit_emails")]
1121    pub rate_limit_per_hour: usize,
1122}
1123
1124fn default_email_provider() -> SmtpProvider {
1125    SmtpProvider::Gmail
1126}
1127
1128fn default_email_secret_ref() -> String {
1129    "email_smtp".to_string()
1130}
1131
1132fn default_rate_limit_emails() -> usize {
1133    10
1134}
1135
1136impl Default for EmailConfig {
1137    fn default() -> Self {
1138        Self {
1139            enabled: false,
1140            my_email: String::new(),
1141            provider: default_email_provider(),
1142            host: String::new(),
1143            port: 0,
1144            tls: None,
1145            user: String::new(),
1146            secret_ref: default_email_secret_ref(),
1147            rate_limit_per_hour: default_rate_limit_emails(),
1148        }
1149    }
1150}
1151
1152impl EmailConfig {
1153    /// Resolve the effective provider, falling back to Gmail.
1154    pub fn provider(&self) -> SmtpProvider {
1155        self.provider
1156    }
1157}
1158
1159fn default_gateway_host() -> String {
1160    "127.0.0.1".into()
1161}
1162
1163fn default_gateway_port() -> u16 {
1164    4200
1165}
1166
1167impl Default for GatewayConfig {
1168    fn default() -> Self {
1169        Self {
1170            host: default_gateway_host(),
1171            port: default_gateway_port(),
1172            expose_api_docs: false,
1173        }
1174    }
1175}
1176
1177/// Execution mode for commands.
1178///
1179/// - `Structured`: Binary allowlist + metacharacter blocking (recommended)
1180/// - `Shell`: Raw bash execution (dangerous, requires `allow_shell_mode=true`)
1181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1182#[serde(rename_all = "lowercase")]
1183pub enum ExecMode {
1184    /// Structured binary execution with allowlist and metacharacter blocking.
1185    #[default]
1186    Structured,
1187    /// Shell execution via `bash -c`. DANGEROUS — requires explicit enable.
1188    Shell,
1189}
1190
1191/// Execution allowlist behavior mode.
1192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1193#[serde(rename_all = "snake_case")]
1194#[derive(Default)]
1195pub enum AllowlistMode {
1196    /// All binaries are permitted (development only).
1197    Permissive,
1198    /// Only binaries in `allowed_commands` may execute.
1199    #[default]
1200    Enforced,
1201}
1202
1203/// Exec configuration.
1204///
1205/// Governs how the kernel dispatches commands for execution.
1206#[derive(Debug, Clone, Deserialize, Serialize)]
1207pub struct ExecConfig {
1208    /// Default execution mode.
1209    #[serde(default)]
1210    pub default_mode: ExecMode,
1211    /// Allow shell mode. DANGEROUS — should be false in production.
1212    #[serde(default = "default_false")]
1213    pub allow_shell_mode: bool,
1214    /// Commands allowed to run on the host.
1215    /// If empty, *all* bare-name commands are permitted (development mode).
1216    #[serde(default)]
1217    pub allowed_commands: Vec<String>,
1218    /// Allowlist enforcement mode.
1219    /// `Permissive` = empty list means all allowed (dev mode).
1220    /// `Enforced` = only listed commands allowed (production).
1221    #[serde(default)]
1222    pub allowlist_mode: AllowlistMode,
1223    /// Default timeout for an exec call in seconds.
1224    #[serde(default = "default_exec_timeout")]
1225    pub default_timeout_secs: u64,
1226    /// Maximum allowed timeout for an exec call in seconds.
1227    #[serde(default = "default_exec_max_timeout")]
1228    pub max_timeout_secs: u64,
1229}
1230
1231fn default_false() -> bool {
1232    false
1233}
1234
1235fn default_exec_timeout() -> u64 {
1236    120
1237}
1238
1239fn default_exec_max_timeout() -> u64 {
1240    600
1241}
1242
1243impl ExecConfig {
1244    /// Check whether a binary / command name is allowed to execute.
1245    ///
1246    /// In `Permissive` mode, returns `true` when `allowed_commands` is empty
1247    /// (all allowed) **or** when the name is present in the allow-list.
1248    ///
1249    /// In `Enforced` mode, only names present in the allow-list are permitted.
1250    pub fn is_binary_allowed(&self, name: &str) -> bool {
1251        match self.allowlist_mode {
1252            AllowlistMode::Permissive => {
1253                self.allowed_commands.is_empty() || self.allowed_commands.iter().any(|c| c == name)
1254            }
1255            AllowlistMode::Enforced => self.allowed_commands.iter().any(|c| c == name),
1256        }
1257    }
1258}
1259
1260impl Default for ExecConfig {
1261    fn default() -> Self {
1262        Self {
1263            default_mode: ExecMode::default(),
1264            allow_shell_mode: default_false(),
1265            allowed_commands: Vec::new(),
1266            allowlist_mode: AllowlistMode::default(),
1267            default_timeout_secs: default_exec_timeout(),
1268            max_timeout_secs: default_exec_max_timeout(),
1269        }
1270    }
1271}
1272
1273/// Scheduler configuration (inspired by AIOS / AgentRM).
1274#[derive(Debug, Clone, Deserialize, Serialize)]
1275pub struct SchedulerConfig {
1276    /// Maximum number of concurrent agent tasks.
1277    #[serde(default = "default_max_concurrent")]
1278    pub max_concurrent: usize,
1279    /// Maximum LLM API calls per minute (rate limiting).
1280    #[serde(default = "default_rate_limit")]
1281    pub rate_limit_per_minute: u32,
1282    /// Timeout in seconds before a running task is considered a zombie.
1283    #[serde(default = "default_zombie_timeout")]
1284    pub zombie_timeout_secs: u64,
1285}
1286
1287fn default_max_concurrent() -> usize {
1288    5
1289}
1290
1291fn default_rate_limit() -> u32 {
1292    60
1293}
1294
1295fn default_zombie_timeout() -> u64 {
1296    300
1297}
1298
1299impl Default for SchedulerConfig {
1300    fn default() -> Self {
1301        Self {
1302            max_concurrent: default_max_concurrent(),
1303            rate_limit_per_minute: default_rate_limit(),
1304            zombie_timeout_secs: default_zombie_timeout(),
1305        }
1306    }
1307}
1308
1309/// Orchestrator configuration (Ouroboros protocol execution).
1310#[derive(Debug, Clone, Deserialize, Serialize)]
1311pub struct OrchestratorConfig {
1312    /// Maximum evolution iterations (0 = evaluate only, no evolution).
1313    /// Default: 3.
1314    #[serde(default = "default_max_evolution_iterations")]
1315    pub max_evolution_iterations: u32,
1316
1317    /// Minimum evaluation score for task to be considered passed (0.0–1.0).
1318    /// Default: 0.8.
1319    #[serde(default = "default_min_evaluation_score")]
1320    pub min_evaluation_score: f64,
1321
1322    /// Enable evaluation result caching.
1323    #[serde(default = "default_true")]
1324    pub eval_cache_enabled: bool,
1325
1326    /// Keywords that trigger spec (Ouroboros) mode. Prefix-only match.
1327    #[serde(default = "default_spec_keywords")]
1328    pub spec_keywords: Vec<String>,
1329
1330    /// Default execution mode: "chat" (agent) or "spec" (Ouroboros pipeline).
1331    #[serde(default = "default_mode")]
1332    pub default_mode: String,
1333}
1334
1335fn default_spec_keywords() -> Vec<String> {
1336    vec!["#spec".into(), "#plan".into()]
1337}
1338
1339fn default_mode() -> String {
1340    "spec".into() // v1: backward compat
1341}
1342
1343fn default_max_evolution_iterations() -> u32 {
1344    3
1345}
1346
1347fn default_min_evaluation_score() -> f64 {
1348    0.8
1349}
1350
1351impl Default for OrchestratorConfig {
1352    fn default() -> Self {
1353        Self {
1354            max_evolution_iterations: default_max_evolution_iterations(),
1355            min_evaluation_score: default_min_evaluation_score(),
1356            eval_cache_enabled: true,
1357            spec_keywords: default_spec_keywords(),
1358            default_mode: default_mode(),
1359        }
1360    }
1361}
1362
1363/// Context manager configuration (inspired by AIOS).
1364#[derive(Debug, Clone, Deserialize, Serialize)]
1365pub struct ContextConfig {
1366    /// Maximum tokens in the active (in-context) tier.
1367    #[serde(default = "default_active_limit")]
1368    pub active_limit_tokens: usize,
1369    /// Maximum entries in the cache tier.
1370    #[serde(default = "default_cache_limit")]
1371    pub cache_limit_entries: usize,
1372}
1373
1374fn default_active_limit() -> usize {
1375    100_000
1376}
1377
1378fn default_cache_limit() -> usize {
1379    50
1380}
1381
1382impl Default for ContextConfig {
1383    fn default() -> Self {
1384        Self {
1385            active_limit_tokens: default_active_limit(),
1386            cache_limit_entries: default_cache_limit(),
1387        }
1388    }
1389}
1390
1391/// Security/access control configuration (inspired by OWASP Agentic AI).
1392#[derive(Debug, Clone, Deserialize, Serialize)]
1393pub struct SecurityConfig {
1394    /// Default allowed tools for agents (least privilege).
1395    #[serde(default = "default_allowed_tools")]
1396    pub allowed_tools: Vec<String>,
1397    /// Whether agents can make network requests by default.
1398    #[serde(default)]
1399    pub network_access: bool,
1400    /// Maximum execution time in seconds for agent tasks.
1401    #[serde(default = "default_max_exec_time")]
1402    pub max_execution_time_secs: u64,
1403    /// Maximum memory in MB for agent tasks.
1404    #[serde(default = "default_max_memory")]
1405    pub max_memory_mb: u64,
1406    /// Whether agents can fork sub-agents by default.
1407    #[serde(default)]
1408    pub can_fork: bool,
1409    /// Maximum audit log entries to retain.
1410    #[serde(default = "default_max_audit")]
1411    pub max_audit_entries: usize,
1412    /// Enable API key authentication.
1413    #[serde(default)]
1414    pub auth_enabled: bool,
1415    /// Allowed CORS origins.
1416    #[serde(default = "default_cors_origins")]
1417    pub cors_origins: Vec<String>,
1418    /// Path for audit log file (optional, enables file-based persistence).
1419    #[serde(default)]
1420    pub audit_log_path: Option<String>,
1421    /// Rate limit for API endpoints (requests per minute).
1422    #[serde(default = "default_rate_limit_per_minute")]
1423    pub rate_limit_per_minute: u32,
1424}
1425
1426fn default_allowed_tools() -> Vec<String> {
1427    vec![
1428        "read".to_string(),
1429        "write".to_string(),
1430        "edit".to_string(),
1431        "bash".to_string(),
1432        "grep".to_string(),
1433        "find".to_string(),
1434        "exec".to_string(),
1435    ]
1436}
1437
1438fn default_max_exec_time() -> u64 {
1439    300
1440}
1441
1442fn default_max_memory() -> u64 {
1443    512
1444}
1445
1446fn default_max_audit() -> usize {
1447    10_000
1448}
1449
1450fn default_rate_limit_per_minute() -> u32 {
1451    120
1452}
1453
1454fn default_cors_origins() -> Vec<String> {
1455    vec!["http://localhost:4200".to_string()]
1456}
1457
1458impl Default for SecurityConfig {
1459    fn default() -> Self {
1460        Self {
1461            allowed_tools: default_allowed_tools(),
1462            network_access: false,
1463            max_execution_time_secs: default_max_exec_time(),
1464            max_memory_mb: default_max_memory(),
1465            can_fork: false,
1466            max_audit_entries: default_max_audit(),
1467            auth_enabled: false,
1468            cors_origins: default_cors_origins(),
1469            audit_log_path: None,
1470            rate_limit_per_minute: default_rate_limit_per_minute(),
1471        }
1472    }
1473}
1474
1475/// Persona system configuration.
1476#[derive(Debug, Clone, Deserialize, Serialize)]
1477pub struct PersonaConfig {
1478    /// Default persona ID to activate on startup.
1479    #[serde(default)]
1480    pub default_persona_id: Option<String>,
1481    /// Maximum concurrent personas.
1482    #[serde(default = "default_max_concurrent_personas")]
1483    pub max_concurrent_personas: usize,
1484}
1485
1486fn default_max_concurrent_personas() -> usize {
1487    5
1488}
1489
1490impl Default for PersonaConfig {
1491    fn default() -> Self {
1492        Self {
1493            default_persona_id: Some("dev".to_string()),
1494            max_concurrent_personas: default_max_concurrent_personas(),
1495        }
1496    }
1497}
1498
1499/// MCP server configuration loaded from config.toml.
1500///
1501/// Each key is a server name; the value is a table with:
1502/// - `command`: executable to run (e.g. "npx", "python")
1503/// - `args`: arguments array
1504/// - `env`: optional map of environment variables
1505/// - `enabled`: whether to start this server on boot (default: true)
1506#[derive(Debug, Clone, Deserialize, Serialize, Default)]
1507pub struct McpConfig {
1508    /// Map of server-name → server definition.
1509    #[serde(default)]
1510    pub servers: std::collections::HashMap<String, McpServerDef>,
1511}
1512
1513/// A single MCP server definition in config.toml.
1514#[derive(Debug, Clone, Deserialize, Serialize)]
1515pub struct McpServerDef {
1516    /// Command to execute.
1517    pub command: String,
1518    /// Arguments passed to the command.
1519    #[serde(default)]
1520    pub args: Vec<String>,
1521    /// Environment variables.
1522    #[serde(default)]
1523    pub env: std::collections::HashMap<String, String>,
1524    /// Whether this server is enabled (default: true).
1525    #[serde(default = "default_mcp_enabled")]
1526    pub enabled: bool,
1527}
1528
1529fn default_mcp_enabled() -> bool {
1530    true
1531}
1532
1533/// Git version control configuration.
1534#[derive(Debug, Clone, Deserialize, Serialize)]
1535pub struct GitConfig {
1536    /// Enable automatic commits for state changes.
1537    #[serde(default = "default_true")]
1538    pub auto_commit: bool,
1539}
1540
1541impl Default for GitConfig {
1542    fn default() -> Self {
1543        Self { auto_commit: true }
1544    }
1545}
1546
1547/// Audit trail configuration.
1548#[derive(Debug, Clone, Deserialize, Serialize)]
1549pub struct AuditConfig {
1550    /// Maximum audit entries before pruning.
1551    #[serde(default = "default_audit_max_entries")]
1552    pub max_entries: usize,
1553    /// Enable audit trail.
1554    #[serde(default = "default_true")]
1555    pub enabled: bool,
1556}
1557
1558fn default_audit_max_entries() -> usize {
1559    100_000
1560}
1561
1562impl Default for AuditConfig {
1563    fn default() -> Self {
1564        Self {
1565            max_entries: default_audit_max_entries(),
1566            enabled: true,
1567        }
1568    }
1569}
1570
1571/// Budget enforcement configuration.
1572#[derive(Debug, Clone, Deserialize, Serialize)]
1573pub struct BudgetConfig {
1574    /// Default token budget per agent (0 = unlimited).
1575    #[serde(default)]
1576    pub default_token_budget: u64,
1577    /// Default call budget per agent (0 = unlimited).
1578    #[serde(default)]
1579    pub default_calls_budget: u64,
1580    /// Default budget window in seconds.
1581    #[serde(default = "default_budget_window")]
1582    pub default_window_secs: u64,
1583    /// Enable budget enforcement.
1584    #[serde(default = "default_true")]
1585    pub enabled: bool,
1586}
1587
1588fn default_budget_window() -> u64 {
1589    3600
1590}
1591
1592impl Default for BudgetConfig {
1593    fn default() -> Self {
1594        Self {
1595            default_token_budget: 0,
1596            default_calls_budget: 0,
1597            default_window_secs: default_budget_window(),
1598            enabled: true,
1599        }
1600    }
1601}
1602
1603/// Resource monitor configuration.
1604#[derive(Debug, Clone, Deserialize, Serialize)]
1605pub struct ResourceMonitorConfig {
1606    /// Snapshot interval in seconds.
1607    #[serde(default = "default_rm_interval")]
1608    pub interval_secs: u64,
1609    /// Maximum history entries.
1610    #[serde(default = "default_rm_history_max")]
1611    pub history_max: usize,
1612    /// CPU threshold for overload.
1613    #[serde(default = "default_rm_cpu_threshold")]
1614    pub cpu_threshold: f32,
1615    /// Memory threshold for overload (percentage).
1616    #[serde(default = "default_rm_mem_threshold")]
1617    pub memory_threshold: f32,
1618    /// Load average threshold for overload.
1619    #[serde(default = "default_rm_load_threshold")]
1620    pub load_threshold: f32,
1621}
1622
1623fn default_rm_interval() -> u64 {
1624    60
1625}
1626
1627fn default_rm_history_max() -> usize {
1628    60
1629}
1630
1631fn default_rm_cpu_threshold() -> f32 {
1632    90.0
1633}
1634
1635fn default_rm_mem_threshold() -> f32 {
1636    90.0
1637}
1638
1639fn default_rm_load_threshold() -> f32 {
1640    8.0
1641}
1642
1643impl Default for ResourceMonitorConfig {
1644    fn default() -> Self {
1645        Self {
1646            interval_secs: default_rm_interval(),
1647            history_max: default_rm_history_max(),
1648            cpu_threshold: default_rm_cpu_threshold(),
1649            memory_threshold: default_rm_mem_threshold(),
1650            load_threshold: default_rm_load_threshold(),
1651        }
1652    }
1653}
1654
1655/// OpenTelemetry tracing configuration.
1656#[derive(Debug, Clone, Deserialize, Serialize)]
1657pub struct OtelConfig {
1658    /// Enable OTLP export (default: false).
1659    #[serde(default)]
1660    pub enabled: bool,
1661    /// OTLP gRPC endpoint.
1662    #[serde(default = "default_otel_endpoint")]
1663    pub endpoint: String,
1664    /// Service name for traces.
1665    #[serde(default = "default_otel_service_name")]
1666    pub service_name: String,
1667    /// Sampling ratio (0.0 to 1.0).
1668    #[serde(default = "default_otel_sampling_ratio")]
1669    pub sampling_ratio: f64,
1670}
1671
1672fn default_otel_endpoint() -> String {
1673    "http://localhost:4317".into()
1674}
1675
1676fn default_otel_service_name() -> String {
1677    "oxios".into()
1678}
1679
1680fn default_otel_sampling_ratio() -> f64 {
1681    1.0
1682}
1683
1684impl Default for OtelConfig {
1685    fn default() -> Self {
1686        Self {
1687            enabled: false,
1688            endpoint: default_otel_endpoint(),
1689            service_name: default_otel_service_name(),
1690            sampling_ratio: default_otel_sampling_ratio(),
1691        }
1692    }
1693}
1694
1695/// Agent history log configuration.
1696#[derive(Debug, Clone, Serialize, Deserialize)]
1697pub struct AgentLogConfig {
1698    /// Maximum number of agent records to keep (0 = unlimited).
1699    #[serde(default = "default_agent_log_max_entries")]
1700    pub max_entries: usize,
1701    /// TTL for agent records in hours (0 = unlimited).
1702    #[serde(default = "default_agent_log_ttl_hours")]
1703    pub ttl_hours: u64,
1704    /// Max tool_calls per agent to persist (0 = unlimited).
1705    #[serde(default = "default_agent_log_max_tool_calls")]
1706    pub max_tool_calls_per_agent: usize,
1707    /// How many agents to prune per cycle.
1708    #[serde(default = "default_agent_log_prune_batch")]
1709    pub prune_batch_size: usize,
1710    /// Path to the SQLite database file (empty = default).
1711    #[serde(default)]
1712    pub db_path: String,
1713}
1714
1715fn default_agent_log_max_entries() -> usize {
1716    10_000
1717}
1718fn default_agent_log_ttl_hours() -> u64 {
1719    720
1720}
1721fn default_agent_log_max_tool_calls() -> usize {
1722    500
1723}
1724fn default_agent_log_prune_batch() -> usize {
1725    100
1726}
1727
1728impl Default for AgentLogConfig {
1729    fn default() -> Self {
1730        Self {
1731            max_entries: 10_000,
1732            ttl_hours: 720,
1733            max_tool_calls_per_agent: 500,
1734            prune_batch_size: 100,
1735            db_path: String::new(),
1736        }
1737    }
1738}
1739
1740/// Logging configuration.
1741#[derive(Debug, Clone, Deserialize, Serialize)]
1742pub struct LoggingConfig {
1743    /// Log format: "pretty", "json", or "compact".
1744    #[serde(default = "default_log_format")]
1745    pub format: String,
1746    /// Log level override (e.g. "info", "debug"). Falls back to RUST_LOG env var.
1747    #[serde(default)]
1748    pub level: Option<String>,
1749}
1750
1751fn default_log_format() -> String {
1752    "pretty".into()
1753}
1754
1755impl Default for LoggingConfig {
1756    fn default() -> Self {
1757        Self {
1758            format: default_log_format(),
1759            level: None,
1760        }
1761    }
1762}
1763
1764/// Headless browser configuration.
1765///
1766/// Engine configuration. Passes through to `oxi-sdk` browser tools.
1767/// with an `enabled` toggle. The engine config is passed through directly
1768/// to the browser — no field-by-field duplication.
1769#[derive(Debug, Clone, Deserialize, Serialize)]
1770pub struct BrowserConfig {
1771    /// Enable the browser integration.
1772    #[serde(default = "default_browser_enabled")]
1773    pub enabled: bool,
1774
1775    /// Engine configuration — passed to oxi-sdk's `native_browser_tools_with_config()`.
1776    ///
1777    /// All fields have sensible defaults; override only what you need:
1778    ///
1779    /// ```toml
1780    /// [browser.engine]
1781    /// user_agent = "MyBot/1.0"
1782    /// obey_robots = false
1783    /// js_timeout_ms = 10000
1784    /// ```
1785    #[serde(default)]
1786    pub engine: serde_json::Value,
1787}
1788
1789fn default_browser_enabled() -> bool {
1790    true
1791}
1792
1793impl Default for BrowserConfig {
1794    fn default() -> Self {
1795        Self {
1796            enabled: true,
1797            engine: serde_json::json!({}),
1798        }
1799    }
1800}
1801
1802/// Loads configuration from a TOML file.
1803pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
1804    let content = std::fs::read_to_string(path)?;
1805    let config: OxiosConfig = toml::from_str(&content)?;
1806    let (errors, warnings) = config.validate();
1807    for w in warnings {
1808        tracing::warn!("config: {}", w);
1809    }
1810    if !errors.is_empty() {
1811        let msg = errors.join("; ");
1812        anyhow::bail!("Configuration validation failed: {msg}");
1813    }
1814    Ok(config)
1815}
1816
1817impl OxiosConfig {
1818    /// Returns the effective API key from the engine config.
1819    pub fn api_key(&self) -> Option<String> {
1820        self.engine.api_key.clone().filter(|k| !k.is_empty())
1821    }
1822
1823    /// Validate configuration values and return a list of warnings.
1824    /// Returns (errors, warnings). Empty errors = valid config.
1825    pub fn validate(&self) -> (Vec<String>, Vec<String>) {
1826        let mut errors = Vec::new();
1827        let mut warnings = Vec::new();
1828
1829        // Kernel validation
1830        if self.kernel.max_agents == 0 {
1831            errors.push("kernel.max_agents must be > 0".into());
1832        }
1833        if self.kernel.workspace.is_empty() {
1834            errors.push("kernel.workspace must not be empty".into());
1835        }
1836
1837        // Gateway validation
1838        if self.gateway.port == 0 {
1839            errors.push("gateway.port must be > 0".into());
1840        }
1841        if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
1842            warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
1843        }
1844
1845        // Scheduler validation
1846        if self.scheduler.max_concurrent == 0 {
1847            warnings.push("scheduler.max_concurrent is 0 — no tasks will run".into());
1848        }
1849        if self.scheduler.zombie_timeout_secs == 0 {
1850            errors.push("scheduler.zombie_timeout_secs must be > 0".into());
1851        }
1852
1853        // Cron validation
1854        for (name, job) in &self.cron.jobs {
1855            if job.schedule.is_empty() {
1856                errors.push(format!("cron.jobs.{name}: schedule is empty"));
1857            } else {
1858                // Normalize 5-field to 6-field (prepend "0 " for seconds)
1859                let normalized = {
1860                    let fields: Vec<&str> = job.schedule.split_whitespace().collect();
1861                    match fields.len() {
1862                        5 => format!("0 {}", job.schedule),
1863                        _ => job.schedule.clone(),
1864                    }
1865                };
1866                if Schedule::from_str(&normalized).is_err() {
1867                    errors.push(format!(
1868                        "cron.jobs.{}: invalid cron expression '{}'",
1869                        name, job.schedule
1870                    ));
1871                }
1872            }
1873            if job.goal.is_empty() {
1874                errors.push(format!("cron.jobs.{name}: goal is empty"));
1875            }
1876        }
1877
1878        // Security validation
1879        if self.security.max_execution_time_secs == 0 {
1880            warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
1881        }
1882
1883        // Audit validation
1884        if self.audit.max_entries == 0 {
1885            warnings.push("audit.max_entries is 0 — audit will never prune".into());
1886        }
1887
1888        // Budget validation
1889        if self.budget.default_window_secs == 0 {
1890            warnings.push("budget.default_window_secs is 0 — no time window".into());
1891        }
1892
1893        // Session validation
1894        if self.session.max_sessions == 0 && self.session.ttl_hours == 0 && self.session.auto_prune
1895        {
1896            warnings.push("session: auto_prune is enabled but both max_sessions and ttl_hours are 0 — nothing will be pruned".into());
1897        }
1898
1899        // Exec validation
1900        if self.exec.default_timeout_secs == 0 {
1901            errors.push("exec.default_timeout_secs must be > 0".into());
1902        }
1903        if self.exec.max_timeout_secs == 0 {
1904            errors.push("exec.max_timeout_secs must be > 0".into());
1905        }
1906        if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
1907            errors.push(format!(
1908                "exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
1909                self.exec.default_timeout_secs, self.exec.max_timeout_secs
1910            ));
1911        }
1912
1913        // Resource monitor validation
1914        if self.resource_monitor.cpu_threshold > 100.0 {
1915            errors.push("resource_monitor.cpu_threshold must be <= 100".into());
1916        }
1917        if self.resource_monitor.memory_threshold > 100.0 {
1918            errors.push("resource_monitor.memory_threshold must be <= 100".into());
1919        }
1920
1921        // Channels validation (message interfaces only)
1922        for name in &self.channels.enabled {
1923            let valid = ["cli", "telegram"];
1924            if !valid.contains(&name.as_str()) {
1925                warnings.push(format!("channels.enabled: unknown channel '{name}'"));
1926            }
1927        }
1928        // Warn if 'web' is listed in channels — it should be in surfaces
1929        if self.channels.enabled.iter().any(|c| c == "web") {
1930            warnings.push(
1931                "channels.enabled: 'web' should be listed under [surfaces], not [channels]".into(),
1932            );
1933        }
1934        if self.channels.enabled.iter().any(|c| c == "telegram")
1935            && std::env::var(&self.channels.telegram.bot_token_env).is_err()
1936        {
1937            warnings.push(format!(
1938                "channels.telegram: {} env var not set — telegram channel will fail",
1939                self.channels.telegram.bot_token_env
1940            ));
1941        }
1942
1943        (errors, warnings)
1944    }
1945}
1946
1947/// Expand `~/` in paths to the user's home directory.
1948///
1949/// Shared utility for path expansion across the binary and kernel.
1950pub fn expand_home(path: &str) -> std::path::PathBuf {
1951    if let Some(rest) = path.strip_prefix("~/")
1952        && let Ok(home) = std::env::var("HOME")
1953    {
1954        return std::path::PathBuf::from(format!("{home}/{rest}"));
1955    }
1956    std::path::PathBuf::from(path)
1957}
1958
1959#[cfg(test)]
1960mod tests {
1961    use super::*;
1962
1963    #[test]
1964    fn test_default_config_validates() {
1965        let config = OxiosConfig::default();
1966        let (errors, _warnings) = config.validate();
1967        assert!(
1968            errors.is_empty(),
1969            "Default config should have no errors: {:?}",
1970            errors
1971        );
1972    }
1973
1974    #[test]
1975    fn test_exec_config_default_allowed_commands() {
1976        let config = ExecConfig::default();
1977        // Default is Enforced mode — empty list means NOTHING allowed.
1978        assert!(config.allowed_commands.is_empty());
1979        assert_eq!(config.allowlist_mode, AllowlistMode::Enforced);
1980        assert!(!config.is_binary_allowed("anything"));
1981        assert!(!config.is_binary_allowed("bash"));
1982    }
1983
1984    #[test]
1985    fn test_exec_config_permissive_mode() {
1986        let config = ExecConfig {
1987            allowlist_mode: AllowlistMode::Permissive,
1988            ..Default::default()
1989        };
1990        // Permissive + empty list = all allowed
1991        assert!(config.is_binary_allowed("anything"));
1992        assert!(config.is_binary_allowed("bash"));
1993    }
1994
1995    #[test]
1996    fn test_is_binary_allowed_with_allowlist() {
1997        let config = ExecConfig {
1998            allowed_commands: vec!["git".into(), "echo".into()],
1999            ..Default::default()
2000        };
2001        assert!(config.is_binary_allowed("git"));
2002        assert!(config.is_binary_allowed("echo"));
2003        assert!(!config.is_binary_allowed("bash"));
2004        assert!(!config.is_binary_allowed("rm"));
2005        assert!(!config.is_binary_allowed("sudo"));
2006    }
2007
2008    #[test]
2009    fn test_expand_home() {
2010        // With HOME set.
2011        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
2012        let expanded = expand_home("~/projects/test");
2013        assert_eq!(
2014            expanded.to_str().unwrap(),
2015            format!("{}/projects/test", home)
2016        );
2017
2018        // Non-tilde path should pass through unchanged.
2019        let abs = expand_home("/absolute/path");
2020        assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
2021
2022        // Just ~ without slash should not expand.
2023        let bare = expand_home("~something");
2024        assert_eq!(bare, std::path::PathBuf::from("~something"));
2025    }
2026
2027    #[test]
2028    fn test_invalid_cron_expression() {
2029        let mut config = OxiosConfig::default();
2030        config.cron.enabled = true;
2031        config.cron.jobs.insert(
2032            "bad-job".to_string(),
2033            InlineCronJob {
2034                schedule: "not a valid cron".to_string(),
2035                goal: "Test goal".to_string(),
2036                constraints: vec![],
2037                acceptance_criteria: vec![],
2038                toolchain: "default".to_string(),
2039                priority: Priority::Normal,
2040                enabled: true,
2041            },
2042        );
2043
2044        let (errors, _warnings) = config.validate();
2045        assert!(
2046            !errors.is_empty(),
2047            "Expected validation error for invalid cron"
2048        );
2049        let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
2050        assert!(
2051            has_cron_error,
2052            "Expected 'invalid cron expression' error, got: {:?}",
2053            errors
2054        );
2055    }
2056
2057    #[test]
2058    fn test_config_serialization_roundtrip() {
2059        let config = OxiosConfig::default();
2060
2061        // Serialize to TOML string.
2062        let toml_str = toml::to_string(&config).expect("serialization should succeed");
2063
2064        // Deserialize back.
2065        let deserialized: OxiosConfig =
2066            toml::from_str(&toml_str).expect("deserialization should succeed");
2067
2068        // Key fields should match.
2069        assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
2070        assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
2071        assert_eq!(config.gateway.host, deserialized.gateway.host);
2072        assert_eq!(config.gateway.port, deserialized.gateway.port);
2073        assert_eq!(
2074            config.exec.default_timeout_secs,
2075            deserialized.exec.default_timeout_secs
2076        );
2077        assert_eq!(
2078            config.exec.max_timeout_secs,
2079            deserialized.exec.max_timeout_secs
2080        );
2081    }
2082
2083    #[test]
2084    fn test_exec_timeout_validation() {
2085        let mut config = OxiosConfig::default();
2086        // default_timeout > max_timeout should be an error.
2087        config.exec.default_timeout_secs = 999;
2088        config.exec.max_timeout_secs = 100;
2089        let (errors, _warnings) = config.validate();
2090        let has_error = errors.iter().any(|e| e.contains("must not exceed"));
2091        assert!(
2092            has_error,
2093            "Expected timeout ordering error, got: {:?}",
2094            errors
2095        );
2096    }
2097
2098    #[test]
2099    fn test_zero_max_agents_error() {
2100        let mut config = OxiosConfig::default();
2101        config.kernel.max_agents = 0;
2102        let (errors, _warnings) = config.validate();
2103        assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
2104    }
2105
2106    /// Rust Default와 share/default-config.toml 간 핵심 기본값 일치 확인.
2107    /// TOML 템플릿은 "프로덕션 준비" 기본값을 가지며,
2108    /// Rust Default는 "안전한 최소" 기본값을 가질 수 있음.
2109    /// 핵심 스칼라 값(포트, 호스트, max_agents 등)은 반드시 일치해야 함.
2110    #[test]
2111    fn test_default_config_matches_toml() {
2112        let from_rust = OxiosConfig::default();
2113
2114        let toml_str = include_str!("../../../share/default-config.toml");
2115        let from_toml: OxiosConfig =
2116            toml::from_str(toml_str).expect("share/default-config.toml이 유효하지 않습니다");
2117
2118        // 핵심 스칼라 필드 — Rust와 TOML이 반드시 일치해야 함
2119        assert_eq!(
2120            from_rust.kernel.max_agents, from_toml.kernel.max_agents,
2121            "kernel.max_agents 불일치: Rust={}, TOML={}",
2122            from_rust.kernel.max_agents, from_toml.kernel.max_agents
2123        );
2124        assert_eq!(
2125            from_rust.gateway.host, from_toml.gateway.host,
2126            "gateway.host 불일치: Rust={}, TOML={}",
2127            from_rust.gateway.host, from_toml.gateway.host
2128        );
2129        assert_eq!(
2130            from_rust.gateway.port, from_toml.gateway.port,
2131            "gateway.port 불일치: Rust={}, TOML={}",
2132            from_rust.gateway.port, from_toml.gateway.port
2133        );
2134        assert_eq!(
2135            from_rust.kernel.event_bus_capacity, from_toml.kernel.event_bus_capacity,
2136            "kernel.event_bus_capacity 불일치"
2137        );
2138        assert_eq!(
2139            from_rust.scheduler.max_concurrent, from_toml.scheduler.max_concurrent,
2140            "scheduler.max_concurrent 불일치"
2141        );
2142        assert_eq!(
2143            from_rust.memory.consolidation.preset, from_toml.memory.consolidation.preset,
2144            "memory.consolidation.preset 불일치"
2145        );
2146
2147        // TOML 템플릿이 파싱 가능한지 확인
2148        let (_, warnings) = from_toml.validate();
2149        for w in &warnings {
2150            eprintln!("default-config.toml 경고: {}", w);
2151        }
2152    }
2153
2154    /// `gateway.expose_api_docs` is gated to loopback binds for safety.
2155    /// Verifies all four cases: opt-out, opt-in + public, opt-in + loopback.
2156    #[test]
2157    fn test_gateway_should_expose_api_docs() {
2158        // Default: opt-out — never expose.
2159        let cfg = GatewayConfig::default();
2160        assert!(!cfg.should_expose_api_docs());
2161
2162        // Opt-in + public bind (0.0.0.0) — still NOT exposed.
2163        let cfg = GatewayConfig {
2164            host: "0.0.0.0".into(),
2165            port: 4200,
2166            expose_api_docs: true,
2167        };
2168        assert!(
2169            !cfg.should_expose_api_docs(),
2170            "public bind must not expose api docs even when opt-in is true"
2171        );
2172
2173        // Opt-in + loopback (127.0.0.1) — exposed.
2174        let cfg = GatewayConfig {
2175            host: "127.0.0.1".into(),
2176            port: 4200,
2177            expose_api_docs: true,
2178        };
2179        assert!(cfg.should_expose_api_docs());
2180
2181        // Opt-in + ::1 — exposed.
2182        let cfg = GatewayConfig {
2183            host: "::1".into(),
2184            port: 4200,
2185            expose_api_docs: true,
2186        };
2187        assert!(cfg.should_expose_api_docs());
2188
2189        // Opt-in + "localhost" — exposed.
2190        let cfg = GatewayConfig {
2191            host: "localhost".into(),
2192            port: 4200,
2193            expose_api_docs: true,
2194        };
2195        assert!(cfg.should_expose_api_docs());
2196
2197        // Opt-out (explicit false) + loopback — NOT exposed.
2198        let cfg = GatewayConfig {
2199            host: "127.0.0.1".into(),
2200            port: 4200,
2201            expose_api_docs: false,
2202        };
2203        assert!(!cfg.should_expose_api_docs());
2204    }
2205}