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::types::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 `provider = "api"`, uses an OpenAI-compatible remote embedding
209/// endpoint. When `provider = "gguf"` and the `embedding-gguf` feature is
210/// enabled on aarch64, uses EmbeddingGemma-300m locally. Otherwise
211/// falls back to TF-IDF (sparse vectors; no sqlite-vec KNN).
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct EmbeddingConfig {
214    /// Embedding provider: "tfidf" (default), "gguf", or "api".
215    #[serde(default = "default_embedding_provider")]
216    pub provider: String,
217    /// Matryoshka dimension: 128, 256, 512, or 768 (gguf).
218    /// For "api", defaults to the model's known dimensionality
219    /// (text-embedding-3-small=1536, text-embedding-3-large=3072).
220    #[serde(default = "default_embedding_dim")]
221    pub dimension: usize,
222    /// Model TTL in seconds. Unloaded after this duration of inactivity.
223    /// Only used when provider = "gguf".
224    #[serde(default = "default_model_ttl")]
225    pub model_ttl_secs: u64,
226    /// API endpoint URL (provider = "api"). E.g.
227    /// `https://api.openai.com/v1/embeddings`.
228    #[serde(default)]
229    pub api_endpoint: String,
230    /// API bearer key (provider = "api"). Empty → inherit from active
231    /// LLM provider's api_key at boot.
232    #[serde(default)]
233    pub api_key: String,
234    /// Embedding model name (provider = "api"). E.g.
235    /// `text-embedding-3-small`.
236    #[serde(default)]
237    pub api_model: String,
238}
239
240fn default_embedding_provider() -> String {
241    // Default to TF-IDF; users opt into "api" or "gguf" via config.
242    // GGUF/MLX feature gating happens at runtime in `kernel.rs`.
243    "tfidf".to_string()
244}
245
246fn default_model_ttl() -> u64 {
247    300 // 5 minutes
248}
249
250impl Default for EmbeddingConfig {
251    fn default() -> Self {
252        Self {
253            provider: default_embedding_provider(),
254            dimension: default_embedding_dim(),
255            model_ttl_secs: default_model_ttl(),
256            api_endpoint: String::new(),
257            api_key: String::new(),
258            api_model: String::new(),
259        }
260    }
261}
262
263// ---------------------------------------------------------------------------
264// LearningConfig (RFC-012 Phase 4: SONA)
265// ---------------------------------------------------------------------------
266
267/// Learning engine configuration (RFC-012 Phase 4).
268///
269/// Controls SONA self-learning persistence.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct LearningConfig {
272    /// Enable the learning subsystem (SONA).
273    #[serde(default = "default_true")]
274    pub enabled: bool,
275    /// SONA operating mode: "realtime", "balanced", "research", "edge".
276    #[serde(default = "default_sona_mode")]
277    pub sona_mode: String,
278    /// Interval between automatic distillation runs (hours).
279    #[serde(default = "default_distill_interval")]
280    pub distill_interval_hours: u64,
281    /// Minimum quality score for auto-promoting patterns to long-term.
282    #[serde(default = "default_auto_promote_quality")]
283    pub auto_promote_quality: f32,
284    /// Minimum usage count before auto-promotion is considered.
285    #[serde(default = "default_auto_promote_min_usage")]
286    pub auto_promote_min_usage: u32,
287}
288
289fn default_sona_mode() -> String {
290    "balanced".to_string()
291}
292
293fn default_distill_interval() -> u64 {
294    6
295}
296
297fn default_auto_promote_quality() -> f32 {
298    0.8
299}
300
301fn default_auto_promote_min_usage() -> u32 {
302    3
303}
304
305impl Default for LearningConfig {
306    fn default() -> Self {
307        Self {
308            enabled: true,
309            sona_mode: default_sona_mode(),
310            distill_interval_hours: default_distill_interval(),
311            auto_promote_quality: default_auto_promote_quality(),
312            auto_promote_min_usage: default_auto_promote_min_usage(),
313        }
314    }
315}
316
317// ---------------------------------------------------------------------------
318// MemoryBridgeConfig (RFC-012 Phase 7: SQLite ↔ MEMORY.md)
319// ---------------------------------------------------------------------------
320
321/// AutoMemoryBridge configuration (RFC-012 Phase 7).
322///
323/// Controls bidirectional sync between SQLite memory store
324/// and external MEMORY.md files.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct MemoryBridgeConfig {
327    /// Enable bidirectional sync with MEMORY.md.
328    #[serde(default)]
329    pub sync_enabled: bool,
330    /// Sync interval in seconds.
331    #[serde(default = "default_bridge_interval")]
332    pub interval_secs: u64,
333}
334
335fn default_bridge_interval() -> u64 {
336    3600
337}
338
339impl Default for MemoryBridgeConfig {
340    fn default() -> Self {
341        Self {
342            sync_enabled: false,
343            interval_secs: default_bridge_interval(),
344        }
345    }
346}
347
348// ---------------------------------------------------------------------------
349// ConsolidationConfig (RFC-008: Memory Consolidation)
350// ---------------------------------------------------------------------------
351
352/// Memory consolidation configuration (RFC-008).
353/// All values have sensible defaults — users never need to configure these.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct ConsolidationConfig {
356    /// Preset: "conservative" | "balanced" | "aggressive" | "custom".
357    /// When not "custom", all other fields are overridden by the preset values.
358    /// Call `apply_preset()` once during kernel init to resolve.
359    #[serde(default = "default_preset")]
360    pub preset: String,
361
362    // ── Dream Process ─────────────────────────────────
363    #[serde(default = "default_true")]
364    pub dream_enabled: bool,
365    #[serde(default = "default_dream_interval")]
366    pub dream_interval_hours: u64,
367    #[serde(default = "default_dream_min_sessions")]
368    pub dream_min_sessions: u32,
369
370    // ── Tier Budgets ──────────────────────────────────
371    #[serde(default = "default_hot_max")]
372    pub hot_max_entries: usize,
373    #[serde(default = "default_warm_max")]
374    pub warm_max_entries: usize,
375    #[serde(default = "default_cold_max")]
376    pub cold_max_entries: usize,
377    #[serde(default = "default_hot_token_budget")]
378    pub hot_token_budget: usize,
379
380    // ── Decay ─────────────────────────────────────────
381    #[serde(default = "default_true")]
382    pub decay_enabled: bool,
383    #[serde(default = "default_one")]
384    pub decay_multiplier: f32,
385    #[serde(default = "default_decay_threshold")]
386    pub decay_threshold: f32,
387    #[serde(default = "default_retention_days")]
388    pub retention_days: u32,
389
390    // ── Auto-Protection ───────────────────────────────
391    #[serde(default = "default_true")]
392    pub auto_protection: bool,
393    #[serde(default = "default_protection_low_access")]
394    pub protection_low_access: u32,
395    #[serde(default = "default_protection_medium_access")]
396    pub protection_medium_access: u32,
397    #[serde(default = "default_protection_high_access")]
398    pub protection_high_access: u32,
399    #[serde(default = "default_protection_medium_sessions")]
400    pub protection_medium_sessions: u32,
401    #[serde(default = "default_protection_high_sessions")]
402    pub protection_high_sessions: u32,
403
404    // ── Auto-Classification ───────────────────────────
405    #[serde(default = "default_true")]
406    pub auto_classification: bool,
407    #[serde(default = "default_type_promotion_threshold")]
408    pub type_promotion_repetitions: u32,
409
410    // ── Compaction ────────────────────────────────────
411    #[serde(default = "default_compaction_threshold")]
412    pub compaction_line_threshold: usize,
413    #[serde(default = "default_true")]
414    pub llm_compaction: bool,
415
416    // ── Dream LLM ──────────────────────────────────────
417    /// Optional model for Dream LLM operations (None = rule-based fallback).
418    #[serde(default)]
419    pub dream_model: Option<String>,
420
421    // ── Protection Demotion ────────────────────────────
422    #[serde(default = "default_true")]
423    pub protection_demotion_enabled: bool,
424    #[serde(default = "default_demotion_stale_days")]
425    pub protection_demotion_stale_days: u32,
426    #[serde(default = "default_demotion_max_step")]
427    pub protection_demotion_max_step: u32,
428
429    // ── Proactive Recall ──────────────────────────────
430    #[serde(default = "default_true")]
431    pub proactive_recall: bool,
432    #[serde(default = "default_proactive_limit")]
433    pub proactive_recall_limit: usize,
434    #[serde(default = "default_proactive_threshold")]
435    pub proactive_recall_threshold: f32,
436}
437
438fn default_dream_interval() -> u64 {
439    24
440}
441fn default_dream_min_sessions() -> u32 {
442    5
443}
444fn default_hot_max() -> usize {
445    50
446}
447fn default_warm_max() -> usize {
448    500
449}
450fn default_cold_max() -> usize {
451    10_000
452}
453fn default_hot_token_budget() -> usize {
454    3_000
455}
456fn default_one() -> f32 {
457    1.0
458}
459fn default_decay_threshold() -> f32 {
460    0.05
461}
462fn default_retention_days() -> u32 {
463    90
464}
465fn default_protection_low_access() -> u32 {
466    2
467}
468fn default_protection_medium_access() -> u32 {
469    3
470}
471fn default_protection_high_access() -> u32 {
472    5
473}
474fn default_protection_medium_sessions() -> u32 {
475    2
476}
477fn default_protection_high_sessions() -> u32 {
478    3
479}
480fn default_type_promotion_threshold() -> u32 {
481    3
482}
483fn default_compaction_threshold() -> usize {
484    200
485}
486fn default_proactive_limit() -> usize {
487    5
488}
489fn default_proactive_threshold() -> f32 {
490    0.6
491}
492fn default_demotion_stale_days() -> u32 {
493    30
494}
495fn default_demotion_max_step() -> u32 {
496    1
497}
498
499fn default_preset() -> String {
500    "balanced".into()
501}
502
503impl Default for ConsolidationConfig {
504    fn default() -> Self {
505        Self {
506            preset: default_preset(),
507            dream_enabled: true,
508            dream_interval_hours: 24,
509            dream_min_sessions: 5,
510            hot_max_entries: 50,
511            warm_max_entries: 500,
512            cold_max_entries: 10_000,
513            hot_token_budget: 3_000,
514            decay_enabled: true,
515            decay_multiplier: 1.0,
516            decay_threshold: 0.05,
517            retention_days: 90,
518            auto_protection: true,
519            protection_low_access: 2,
520            protection_medium_access: 3,
521            protection_high_access: 5,
522            protection_medium_sessions: 2,
523            protection_high_sessions: 3,
524            auto_classification: true,
525            type_promotion_repetitions: 3,
526            compaction_line_threshold: 200,
527            llm_compaction: true,
528            dream_model: None,
529            protection_demotion_enabled: true,
530            protection_demotion_stale_days: 30,
531            protection_demotion_max_step: 1,
532            proactive_recall: true,
533            proactive_recall_limit: 5,
534            proactive_recall_threshold: 0.6,
535        }
536    }
537}
538
539impl ConsolidationConfig {
540    /// Apply the preset to all fields.
541    /// Call once during kernel initialization.
542    /// When `preset` is "custom", individual fields are left untouched.
543    pub fn apply_preset(&mut self) {
544        let resolved = match self.preset.as_str() {
545            "conservative" => Self::conservative(),
546            "aggressive" => Self::aggressive(),
547            "custom" => return,
548            _ => Self::default(), // "balanced" 및 알 수 없는 값
549        };
550        *self = resolved;
551    }
552
553    /// Conservative preset: slow decay, long retention, larger capacities.
554    fn conservative() -> Self {
555        Self {
556            preset: "conservative".into(),
557            dream_enabled: true,
558            dream_interval_hours: 48,
559            dream_min_sessions: 10,
560            hot_max_entries: 100,
561            warm_max_entries: 1000,
562            cold_max_entries: 50_000,
563            hot_token_budget: 5_000,
564            decay_enabled: true,
565            decay_multiplier: 0.8,
566            decay_threshold: 0.05,
567            retention_days: 365,
568            auto_protection: true,
569            protection_low_access: 3,
570            protection_medium_access: 5,
571            protection_high_access: 10,
572            protection_medium_sessions: 3,
573            protection_high_sessions: 5,
574            auto_classification: true,
575            type_promotion_repetitions: 5,
576            compaction_line_threshold: 300,
577            llm_compaction: true,
578            dream_model: None,
579            protection_demotion_enabled: true,
580            protection_demotion_stale_days: 90,
581            protection_demotion_max_step: 1,
582            proactive_recall: true,
583            proactive_recall_limit: 8,
584            proactive_recall_threshold: 0.5,
585        }
586    }
587
588    /// Aggressive preset: fast decay, short retention, smaller capacities.
589    fn aggressive() -> Self {
590        Self {
591            preset: "aggressive".into(),
592            dream_enabled: true,
593            dream_interval_hours: 4,
594            dream_min_sessions: 2,
595            hot_max_entries: 20,
596            warm_max_entries: 100,
597            cold_max_entries: 1_000,
598            hot_token_budget: 2_000,
599            decay_enabled: true,
600            decay_multiplier: 1.0,
601            decay_threshold: 0.1,
602            retention_days: 30,
603            auto_protection: true,
604            protection_low_access: 1,
605            protection_medium_access: 2,
606            protection_high_access: 3,
607            protection_medium_sessions: 1,
608            protection_high_sessions: 2,
609            auto_classification: true,
610            type_promotion_repetitions: 2,
611            compaction_line_threshold: 150,
612            llm_compaction: true,
613            dream_model: None,
614            protection_demotion_enabled: true,
615            protection_demotion_stale_days: 14,
616            protection_demotion_max_step: 2,
617            proactive_recall: true,
618            proactive_recall_limit: 3,
619            proactive_recall_threshold: 0.7,
620        }
621    }
622}
623
624/// Channel activation configuration.
625#[derive(Debug, Clone, Deserialize, Serialize, Default)]
626pub struct ChannelsConfig {
627    /// List of channel names to activate on startup.
628    /// Channels are message-only interfaces (CLI, Telegram).
629    #[serde(default)]
630    pub enabled: Vec<String>,
631
632    /// Telegram-specific configuration.
633    #[serde(default)]
634    pub telegram: TelegramChannelConfig,
635}
636
637/// Surface activation configuration.
638///
639/// Surfaces are kernel-connected control interfaces (Web dashboard, future desktop apps).
640/// They have direct kernel access for management, monitoring, and configuration.
641#[derive(Debug, Clone, Deserialize, Serialize)]
642pub struct SurfacesConfig {
643    /// List of surface names to activate on startup.
644    /// Default: ["web"] if the web feature is compiled in.
645    #[serde(default = "default_surfaces_enabled")]
646    pub enabled: Vec<String>,
647}
648
649fn default_surfaces_enabled() -> Vec<String> {
650    vec!["web".to_string()]
651}
652
653impl Default for SurfacesConfig {
654    fn default() -> Self {
655        Self {
656            enabled: default_surfaces_enabled(),
657        }
658    }
659}
660
661/// Telegram channel configuration.
662#[derive(Debug, Clone, Deserialize, Serialize)]
663pub struct TelegramChannelConfig {
664    /// Environment variable name holding the bot token.
665    #[serde(default = "default_telegram_token_env")]
666    pub bot_token_env: String,
667    /// List of allowed Telegram user IDs (empty = allow all).
668    #[serde(default)]
669    pub allowed_users: Vec<i64>,
670    /// Telegram session management settings.
671    #[serde(default)]
672    pub session: TelegramSessionConfig,
673}
674
675fn default_telegram_token_env() -> String {
676    "TELEGRAM_BOT_TOKEN".to_string()
677}
678
679impl Default for TelegramChannelConfig {
680    fn default() -> Self {
681        Self {
682            bot_token_env: default_telegram_token_env(),
683            allowed_users: Vec::new(),
684            session: TelegramSessionConfig::default(),
685        }
686    }
687}
688///
689/// Role-to-model routing configuration (RFC-032).
690/// Maps role names to model IDs in "provider/model" format.
691#[derive(Debug, Clone, Serialize, Deserialize, Default)]
692pub struct RoleRoutingConfig {
693    /// Role name → model ID mapping (e.g. "coder" → "anthropic/claude-sonnet-4-20250514").
694    #[serde(default)]
695    pub roles: std::collections::HashMap<String, String>,
696}
697
698/// LLM engine configuration.
699#[derive(Debug, Clone, Deserialize, Serialize)]
700#[allow(clippy::derivable_impls)]
701pub struct EngineConfig {
702    /// Default model in "provider/model" format.
703    /// Empty string means no model configured — onboarding required.
704    #[serde(default)]
705    pub default_model: String,
706    /// Explicit API key override (highest priority).
707    /// If empty/None, falls back to oxi auth store, then env vars.
708    /// Masked when serialized to API responses.
709    #[serde(default, skip_serializing)]
710    pub api_key: Option<String>,
711    /// Per-provider options for fine-grained control (thinking mode, etc.).
712    /// Passed through to `AgentLoopConfig::provider_options`.
713    #[serde(default)]
714    pub provider_options: Option<oxi_sdk::ProviderOptions>,
715    /// Enable complexity-based model routing.
716    /// When enabled, the engine can route simple tasks to cheaper models
717    /// and complex tasks to more capable ones.
718    #[serde(default)]
719    pub routing_enabled: bool,
720    /// Prefer cost-efficient models when routing.
721    #[serde(default)]
722    pub prefer_cost_efficient: bool,
723    /// Fallback models to try when the primary model fails.
724    #[serde(default)]
725    pub fallback_models: Vec<String>,
726    /// Models excluded from automatic routing.
727    #[serde(default)]
728    pub excluded_models: Vec<String>,
729    /// Role-based model routing (RFC-032).
730    /// Maps role names (e.g. "coder", "writer") to model IDs.
731    /// When present, messages with a matching role will use the mapped model.
732    #[serde(default)]
733    pub role_routing: RoleRoutingConfig,
734    /// Default model for one-shot (QuickAsk) requests in "provider/model"
735    /// format. When None, one-shot falls back to `default_model`. Lets the
736    /// user point throwaway questions at a cheaper/faster model.
737    #[serde(default)]
738    pub quick_ask_model: Option<String>,
739}
740
741#[allow(clippy::derivable_impls)]
742impl Default for EngineConfig {
743    fn default() -> Self {
744        Self {
745            default_model: String::new(),
746            api_key: None,
747            provider_options: None,
748            routing_enabled: false,
749            prefer_cost_efficient: false,
750            fallback_models: Vec::new(),
751            excluded_models: Vec::new(),
752            role_routing: RoleRoutingConfig::default(),
753            quick_ask_model: None,
754        }
755    }
756}
757
758/// Daemon mode configuration.
759#[derive(Debug, Clone, Deserialize, Serialize)]
760pub struct DaemonConfig {
761    /// PID file path.
762    #[serde(default = "default_pid_file")]
763    pub pid_file: String,
764    /// Log directory.
765    #[serde(default = "default_daemon_log_dir")]
766    pub log_dir: String,
767}
768
769fn default_pid_file() -> String {
770    dirs::home_dir()
771        .map(|h| format!("{}/.oxios/oxios.pid", h.display()))
772        .unwrap_or_else(|| "./oxios.pid".into())
773}
774
775fn default_daemon_log_dir() -> String {
776    dirs::home_dir()
777        .map(|h| format!("{}/.oxios/logs", h.display()))
778        .unwrap_or_else(|| "./logs".into())
779}
780
781impl Default for DaemonConfig {
782    fn default() -> Self {
783        Self {
784            pid_file: default_pid_file(),
785            log_dir: default_daemon_log_dir(),
786        }
787    }
788}
789
790/// Session management configuration.
791#[derive(Debug, Clone, Deserialize, Serialize)]
792pub struct SessionConfig {
793    /// Maximum number of sessions to retain.
794    /// When exceeded, oldest sessions (by `updated_at`) are pruned.
795    /// Set to 0 for unlimited.
796    #[serde(default = "default_max_sessions")]
797    pub max_sessions: usize,
798
799    /// Time-to-live for sessions in hours.
800    /// Sessions older than this are automatically pruned.
801    /// Set to 0 for unlimited (no TTL-based pruning).
802    #[serde(default = "default_session_ttl_hours")]
803    pub ttl_hours: u64,
804
805    /// Enable automatic session pruning on every session save.
806    #[serde(default = "default_true")]
807    pub auto_prune: bool,
808}
809
810fn default_max_sessions() -> usize {
811    100
812}
813
814fn default_session_ttl_hours() -> u64 {
815    168 // 7 days
816}
817
818impl Default for SessionConfig {
819    fn default() -> Self {
820        Self {
821            max_sessions: default_max_sessions(),
822            ttl_hours: default_session_ttl_hours(),
823            auto_prune: true,
824        }
825    }
826}
827
828/// RFC-025 Phase 5: Mount auto-promotion configuration.
829/// Controls the background scanner that promotes frequently-used paths into
830/// Mounts. See `mount::path_promotion`.
831#[derive(Debug, Clone, Deserialize, Serialize)]
832pub struct MountsConfig {
833    /// Enable the auto-promotion scanner.
834    #[serde(default = "default_true")]
835    pub auto_promote_enabled: bool,
836    /// Minimum distinct touches within the window to trigger promotion.
837    #[serde(default = "default_promote_threshold")]
838    pub auto_promote_threshold: usize,
839    /// How far back to look, in days.
840    #[serde(default = "default_promote_window_days")]
841    pub auto_promote_window_days: i64,
842    /// Seconds between promotion scans (background cadence).
843    #[serde(default = "default_promote_interval_secs")]
844    pub auto_promote_interval_secs: u64,
845}
846
847fn default_promote_threshold() -> usize {
848    3
849}
850
851fn default_promote_window_days() -> i64 {
852    14
853}
854
855fn default_promote_interval_secs() -> u64 {
856    3600 // hourly
857}
858
859impl Default for MountsConfig {
860    fn default() -> Self {
861        Self {
862            auto_promote_enabled: true,
863            auto_promote_threshold: default_promote_threshold(),
864            auto_promote_window_days: default_promote_window_days(),
865            auto_promote_interval_secs: default_promote_interval_secs(),
866        }
867    }
868}
869
870/// Telegram session management configuration.
871#[derive(Debug, Clone, Deserialize, Serialize)]
872pub struct TelegramSessionConfig {
873    /// Automatically rotate to a new session after this many hours of inactivity.
874    /// Set to 0 to disable time-based rotation.
875    #[serde(default = "default_telegram_session_rotation_hours")]
876    pub rotation_hours: u64,
877
878    /// Maximum number of messages per session before auto-rotating.
879    /// Set to 0 for unlimited.
880    #[serde(default = "default_telegram_session_max_messages")]
881    pub max_messages: usize,
882}
883
884fn default_telegram_session_rotation_hours() -> u64 {
885    2 // 2 hours
886}
887
888fn default_telegram_session_max_messages() -> usize {
889    0 // unlimited by default
890}
891
892impl Default for TelegramSessionConfig {
893    fn default() -> Self {
894        Self {
895            rotation_hours: default_telegram_session_rotation_hours(),
896            max_messages: default_telegram_session_max_messages(),
897        }
898    }
899}
900
901/// Top-level Oxios configuration.
902/// A single system agent model assignment.
903/// Lets users pick a different model for each system task.
904#[derive(Debug, Clone, Deserialize, Serialize, Default)]
905pub struct SystemAgentItem {
906    /// Model id in "provider/model" format. Empty = inherit default.
907    #[serde(default)]
908    pub model: String,
909    /// Whether this system task is enabled.
910    #[serde(default = "default_true")]
911    pub enabled: bool,
912    /// Token cap for this task.
913    #[serde(default)]
914    pub context_limit: Option<u32>,
915    /// Override system prompt.
916    #[serde(default)]
917    pub custom_prompt: Option<String>,
918}
919
920/// System agent model assignments (ported from LobeHub).
921/// Each field controls which model is used for a specific background task.
922#[derive(Debug, Clone, Deserialize, Serialize, Default)]
923pub struct SystemAgentsConfig {
924    /// Auto topic naming.
925    #[serde(default)]
926    pub topic: SystemAgentItem,
927    /// AI image topic naming.
928    #[serde(default)]
929    pub generation_topic: SystemAgentItem,
930    /// Message translation.
931    #[serde(default)]
932    pub translation: SystemAgentItem,
933    /// Conversation history compression.
934    #[serde(default)]
935    pub history_compress: SystemAgentItem,
936    /// Agent metadata generation (name, description, avatar, tags).
937    #[serde(default)]
938    pub agent_meta: SystemAgentItem,
939    /// Follow-up suggestion chips.
940    #[serde(default)]
941    pub follow_up_action: SystemAgentItem,
942    /// Input auto-complete (ghost text).
943    #[serde(default)]
944    pub input_completion: SystemAgentItem,
945    /// Prompt rewriting.
946    #[serde(default)]
947    pub prompt_rewrite: SystemAgentItem,
948    /// Memory analysis — extract identity, preferences, context, etc.
949    #[serde(default)]
950    pub memory_analysis: SystemAgentItem,
951    /// Memory embedding model.
952    #[serde(default)]
953    pub memory_embedding: SystemAgentItem,
954    /// Memory persona summary writer.
955    #[serde(default)]
956    pub memory_persona_writer: SystemAgentItem,
957}
958
959impl SystemAgentsConfig {
960    /// Resolve the model for a given system task.
961    pub fn model_for_task(&self, task: &str) -> Option<String> {
962        let item = match task {
963            "topic" => &self.topic,
964            "generation_topic" => &self.generation_topic,
965            "translation" => &self.translation,
966            "history_compress" => &self.history_compress,
967            "agent_meta" => &self.agent_meta,
968            "follow_up_action" => &self.follow_up_action,
969            "input_completion" => &self.input_completion,
970            "prompt_rewrite" => &self.prompt_rewrite,
971            "memory_analysis" => &self.memory_analysis,
972            "memory_embedding" => &self.memory_embedding,
973            "memory_persona_writer" => &self.memory_persona_writer,
974            _ => return None,
975        };
976        if !item.enabled || item.model.is_empty() {
977            None
978        } else {
979            Some(item.model.clone())
980        }
981    }
982
983    /// Check if a system task is enabled.
984    pub fn is_enabled(&self, task: &str) -> bool {
985        self.model_for_task(task).is_some()
986            || match task {
987                "topic" => self.topic.enabled,
988                "generation_topic" => self.generation_topic.enabled,
989                "translation" => self.translation.enabled,
990                "history_compress" => self.history_compress.enabled,
991                "agent_meta" => self.agent_meta.enabled,
992                "follow_up_action" => self.follow_up_action.enabled,
993                "input_completion" => self.input_completion.enabled,
994                "prompt_rewrite" => self.prompt_rewrite.enabled,
995                "memory_analysis" => self.memory_analysis.enabled,
996                "memory_embedding" => self.memory_embedding.enabled,
997                "memory_persona_writer" => self.memory_persona_writer.enabled,
998                _ => false,
999            }
1000    }
1001}
1002
1003#[derive(Debug, Clone, Deserialize, Serialize, Default)]
1004pub struct OxiosConfig {
1005    /// Kernel settings.
1006    pub kernel: KernelConfig,
1007    /// LLM engine settings.
1008    #[serde(default)]
1009    pub engine: EngineConfig,
1010    /// Daemon mode settings.
1011    #[serde(default)]
1012    pub daemon: DaemonConfig,
1013    /// Gateway settings.
1014    #[serde(default)]
1015    pub gateway: GatewayConfig,
1016    /// Orchestrator settings (Ouroboros protocol execution).
1017    #[serde(default)]
1018    pub orchestrator: OrchestratorConfig,
1019    /// Intent engine settings (assess/crystallize/review model + retry).
1020    #[serde(default)]
1021    pub intent: IntentConfig,
1022    /// System agent model assignments (LobeHub-inspired).
1023    #[serde(default)]
1024    pub system_agents: SystemAgentsConfig,
1025    /// Context manager settings (LLM context window management).
1026    #[serde(default)]
1027    pub context: ContextConfig,
1028    /// Security/access control settings.
1029    #[serde(default)]
1030    pub security: SecurityConfig,
1031    /// Persona system settings.
1032    #[serde(default)]
1033    pub persona: PersonaConfig,
1034    /// Memory system settings.
1035    #[serde(default)]
1036    pub memory: MemoryConfig,
1037    /// Cron scheduler settings.
1038    #[serde(default)]
1039    pub cron: CronConfig,
1040    /// MCP server configurations.
1041    #[serde(default)]
1042    pub mcp: McpConfig,
1043    /// Git version control settings.
1044    #[serde(default)]
1045    pub git: GitConfig,
1046    /// Audit trail configuration.
1047    #[serde(default)]
1048    pub audit: AuditConfig,
1049    /// Budget enforcement configuration.
1050    #[serde(default)]
1051    pub budget: BudgetConfig,
1052    /// Exec configuration (host command execution bridge).
1053    #[serde(default)]
1054    pub exec: ExecConfig,
1055    /// Resource monitor configuration.
1056    #[serde(default)]
1057    pub resource_monitor: ResourceMonitorConfig,
1058    /// Logging configuration.
1059    #[serde(default)]
1060    pub logging: LoggingConfig,
1061    /// Channel activation configuration (message interfaces: CLI, Telegram).
1062    #[serde(default)]
1063    pub channels: ChannelsConfig,
1064    /// Surface activation configuration (control interfaces: Web dashboard).
1065    #[serde(default)]
1066    pub surfaces: Option<SurfacesConfig>,
1067    /// Headless browser configuration.
1068    #[serde(default)]
1069    pub browser: BrowserConfig,
1070    /// Session management configuration.
1071    #[serde(default)]
1072    pub session: SessionConfig,
1073    /// RFC-025: Mount system configuration (auto-promotion scanner).
1074    #[serde(default)]
1075    pub mounts: MountsConfig,
1076    /// ClawHub marketplace configuration.
1077    #[serde(default)]
1078    pub marketplace: MarketplaceConfig,
1079    /// Calendar configuration.
1080    #[serde(default)]
1081    pub calendar: CalendarConfig,
1082    /// Email configuration.
1083    #[serde(default)]
1084    pub email: EmailConfig,
1085    /// Agent history log configuration.
1086    #[serde(default)]
1087    pub agent_log: AgentLogConfig,
1088    /// Token Maxing mode configuration (RFC-031).
1089    #[serde(default)]
1090    pub token_maxing: crate::token_maxing::TokenMaxingConfig,
1091    /// Image generation configuration (OpenAI-compatible providers).
1092    #[serde(default)]
1093    pub image_gen: ImageGenConfig,
1094}
1095
1096/// Image generation configuration.
1097///
1098/// Opt-in (`enabled = false` by default). When enabled, the
1099/// `image_generation` tool is registered and agents can generate images via
1100/// an OpenAI-compatible `/v1/images/generations` endpoint. The API key is
1101/// resolved via [`CredentialStore`](crate::credential::CredentialStore) — the
1102/// same provider key used for chat — so no separate credential is needed.
1103#[derive(Debug, Clone, Deserialize, Serialize)]
1104pub struct ImageGenConfig {
1105    /// Enable the image generation tool.
1106    #[serde(default)]
1107    pub enabled: bool,
1108    /// Provider id. Currently only `"openai"` (OpenAI-compatible).
1109    #[serde(default = "default_image_gen_provider")]
1110    pub provider: String,
1111    /// Base URL for the image API. Defaults to the OpenAI endpoint.
1112    #[serde(default = "default_image_gen_base_url")]
1113    pub base_url: String,
1114    /// Default model when the agent doesn't specify one. Empty = error
1115    /// (the provider requires a model; set one in config).
1116    #[serde(default)]
1117    pub default_model: String,
1118    /// Default number of images per call (1-8).
1119    #[serde(default = "default_image_gen_num")]
1120    pub default_num: u8,
1121}
1122
1123impl Default for ImageGenConfig {
1124    fn default() -> Self {
1125        Self {
1126            enabled: false,
1127            provider: default_image_gen_provider(),
1128            base_url: default_image_gen_base_url(),
1129            default_model: String::new(),
1130            default_num: default_image_gen_num(),
1131        }
1132    }
1133}
1134
1135fn default_image_gen_provider() -> String {
1136    "openai".into()
1137}
1138
1139fn default_image_gen_base_url() -> String {
1140    "https://api.openai.com/v1".into()
1141}
1142
1143fn default_image_gen_num() -> u8 {
1144    1
1145}
1146
1147/// Kernel configuration.
1148#[derive(Debug, Clone, Deserialize, Serialize)]
1149pub struct KernelConfig {
1150    /// Path to the workspace directory.
1151    #[serde(default = "default_workspace")]
1152    pub workspace: String,
1153    /// Broadcast capacity for the event bus.
1154    #[serde(default = "default_event_bus_capacity")]
1155    pub event_bus_capacity: usize,
1156    /// Maximum number of concurrent agents.
1157    #[serde(default = "default_max_agents")]
1158    pub max_agents: usize,
1159}
1160
1161fn default_workspace() -> String {
1162    dirs_home().unwrap_or_else(|| ".".into())
1163}
1164
1165fn dirs_home() -> Option<String> {
1166    dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
1167}
1168
1169fn default_event_bus_capacity() -> usize {
1170    256
1171}
1172
1173fn default_max_agents() -> usize {
1174    10
1175}
1176
1177impl Default for KernelConfig {
1178    fn default() -> Self {
1179        Self {
1180            workspace: default_workspace(),
1181            event_bus_capacity: default_event_bus_capacity(),
1182            max_agents: 10,
1183        }
1184    }
1185}
1186
1187/// Gateway configuration.
1188#[derive(Debug, Clone, Deserialize, Serialize)]
1189pub struct GatewayConfig {
1190    /// Host to bind the gateway to.
1191    #[serde(default = "default_gateway_host")]
1192    pub host: String,
1193    /// Port for the gateway server.
1194    #[serde(default = "default_gateway_port")]
1195    pub port: u16,
1196    /// Expose `/api-docs` (Swagger UI) and `/openapi.json`.
1197    ///
1198    /// For safety this is gated to localhost-only binds (127.0.0.0/8, ::1,
1199    /// "localhost"). Setting this to `true` while binding to a public address
1200    /// is a no-op. Default: `false`.
1201    ///
1202    /// Why: Swagger UI + the full OpenAPI schema expand the attack surface
1203    /// (route discovery, parameter names, security scheme details). Local
1204    /// dev typically wants them; production typically does not.
1205    #[serde(default)]
1206    pub expose_api_docs: bool,
1207    /// RFC-024 SP1: ceiling on `send_and_wait` for HTTP request-response
1208    /// matching. The HTTP layer returns 504 Gateway Timeout when the
1209    /// orchestrator does not respond within this duration.
1210    #[serde(default = "default_response_timeout_secs")]
1211    pub response_timeout_secs: u64,
1212    /// RFC-024 SP1: in-memory replay buffer tuning (per channel).
1213    #[serde(default)]
1214    pub reliability: GatewayReliabilityConfig,
1215}
1216
1217/// RFC-024 SP1: in-memory replay buffer tuning.
1218#[derive(Debug, Clone, Serialize, Deserialize)]
1219pub struct GatewayReliabilityConfig {
1220    /// Per-channel replay buffer size. Older messages are evicted when
1221    /// the buffer is full.
1222    #[serde(default = "default_replay_buffer_size")]
1223    pub replay_buffer_size: usize,
1224    /// How long a message stays in the replay buffer.
1225    #[serde(default = "default_replay_ttl_secs")]
1226    pub replay_ttl_secs: u64,
1227}
1228
1229impl Default for GatewayReliabilityConfig {
1230    fn default() -> Self {
1231        Self {
1232            replay_buffer_size: default_replay_buffer_size(),
1233            replay_ttl_secs: default_replay_ttl_secs(),
1234        }
1235    }
1236}
1237
1238fn default_response_timeout_secs() -> u64 {
1239    120
1240}
1241fn default_replay_buffer_size() -> usize {
1242    512
1243}
1244fn default_replay_ttl_secs() -> u64 {
1245    60
1246}
1247
1248impl GatewayConfig {
1249    /// Whether the gateway may expose `/api-docs` and `/openapi.json`.
1250    ///
1251    /// Returns `true` only when both:
1252    /// - `expose_api_docs` is explicitly enabled, AND
1253    /// - the bind address is a loopback address.
1254    pub fn should_expose_api_docs(&self) -> bool {
1255        if !self.expose_api_docs {
1256            return false;
1257        }
1258        let h = self.host.trim();
1259        h == "127.0.0.1" || h == "::1" || h == "localhost" || h.starts_with("127.")
1260    }
1261}
1262
1263/// ClawHub marketplace configuration.
1264#[derive(Debug, Clone, Deserialize, Serialize)]
1265pub struct MarketplaceConfig {
1266    /// Base URL for the ClawHub registry.
1267    /// Defaults to `https://clawhub.ai`.
1268    #[serde(default)]
1269    pub base_url: Option<String>,
1270    /// Whether the marketplace is enabled.
1271    #[serde(default = "default_true")]
1272    pub enabled: bool,
1273    /// Skills.sh (Vercel Labs ecosystem) configuration.
1274    #[serde(default)]
1275    pub skills_sh: SkillsShConfig,
1276}
1277
1278/// Skills.sh registry configuration.
1279#[derive(Debug, Clone, Deserialize, Serialize)]
1280pub struct SkillsShConfig {
1281    /// Base URL for the Skills.sh API.
1282    /// Defaults to `https://skills.sh`.
1283    #[serde(default)]
1284    pub base_url: Option<String>,
1285    /// API key for Skills.sh authentication.
1286    /// Falls back to `SKILLS_SH_TOKEN` env var if not set.
1287    #[serde(default)]
1288    pub api_key: Option<String>,
1289    /// Whether Skills.sh integration is enabled.
1290    #[serde(default = "default_true")]
1291    pub enabled: bool,
1292}
1293
1294impl Default for MarketplaceConfig {
1295    fn default() -> Self {
1296        Self {
1297            base_url: Some("https://clawhub.ai".to_string()),
1298            enabled: true,
1299            skills_sh: SkillsShConfig::default(),
1300        }
1301    }
1302}
1303
1304impl Default for SkillsShConfig {
1305    fn default() -> Self {
1306        Self {
1307            base_url: None,
1308            api_key: None,
1309            enabled: true,
1310        }
1311    }
1312}
1313
1314/// Calendar configuration.
1315#[derive(Debug, Clone, Deserialize, Serialize)]
1316pub struct CalendarConfig {
1317    /// Enable the calendar system.
1318    #[serde(default = "default_true")]
1319    pub enabled: bool,
1320    /// Default timezone for events.
1321    #[serde(default = "default_calendar_timezone")]
1322    pub timezone: String,
1323    /// Default reminder minutes for new events.
1324    #[serde(default = "default_reminder_minutes")]
1325    pub default_reminder_minutes: Vec<u32>,
1326    /// Alarm dispatch channels.
1327    #[serde(default)]
1328    pub alarm_channels: Vec<String>,
1329    /// Journal sync mode: "on_open", "midnight", "both".
1330    #[serde(default = "default_journal_sync")]
1331    pub journal_sync: String,
1332    /// Show cron jobs on the calendar.
1333    #[serde(default = "default_true")]
1334    pub system_calendar: bool,
1335    /// Days after which old events are archived.
1336    #[serde(default = "default_archive_days")]
1337    pub archive_after_days: u32,
1338}
1339
1340fn default_calendar_timezone() -> String {
1341    "Asia/Seoul".to_string()
1342}
1343
1344fn default_reminder_minutes() -> Vec<u32> {
1345    vec![15]
1346}
1347
1348fn default_journal_sync() -> String {
1349    "on_open".to_string()
1350}
1351
1352fn default_archive_days() -> u32 {
1353    365
1354}
1355
1356impl Default for CalendarConfig {
1357    fn default() -> Self {
1358        Self {
1359            enabled: true,
1360            timezone: default_calendar_timezone(),
1361            default_reminder_minutes: default_reminder_minutes(),
1362            alarm_channels: vec![],
1363            journal_sync: default_journal_sync(),
1364            system_calendar: true,
1365            archive_after_days: default_archive_days(),
1366        }
1367    }
1368}
1369
1370/// Email configuration.
1371///
1372/// Controls SMTP email sending. When enabled, agents gain the `send_email` tool.
1373/// v1 sends to the user's own email only.
1374#[derive(Debug, Clone, Deserialize, Serialize)]
1375pub struct EmailConfig {
1376    /// Enable the email system.
1377    #[serde(default)]
1378    pub enabled: bool,
1379    /// The user's email address (used as both sender and default recipient).
1380    #[serde(default)]
1381    pub my_email: String,
1382    /// SMTP provider preset ("gmail", "icloud", "fastmail", "custom").
1383    #[serde(default = "default_email_provider")]
1384    pub provider: SmtpProvider,
1385    /// SMTP host (auto-filled from provider if empty).
1386    #[serde(default)]
1387    pub host: String,
1388    /// SMTP port (auto-filled from provider if 0).
1389    #[serde(default)]
1390    pub port: u16,
1391    /// TLS mode (auto-filled from provider if None).
1392    #[serde(default)]
1393    pub tls: Option<SmtpTls>,
1394    /// SMTP auth username (defaults to `my_email` if empty).
1395    #[serde(default)]
1396    pub user: String,
1397    /// Credential store key for the SMTP password.
1398    /// Falls back to `OXIOS_EMAIL_PASSWORD` env var.
1399    #[serde(default = "default_email_secret_ref")]
1400    pub secret_ref: String,
1401    /// Maximum emails per hour (rate limit, default: 10).
1402    #[serde(default = "default_rate_limit_emails")]
1403    pub rate_limit_per_hour: usize,
1404}
1405
1406fn default_email_provider() -> SmtpProvider {
1407    SmtpProvider::Gmail
1408}
1409
1410fn default_email_secret_ref() -> String {
1411    "email_smtp".to_string()
1412}
1413
1414fn default_rate_limit_emails() -> usize {
1415    10
1416}
1417
1418impl Default for EmailConfig {
1419    fn default() -> Self {
1420        Self {
1421            enabled: false,
1422            my_email: String::new(),
1423            provider: default_email_provider(),
1424            host: String::new(),
1425            port: 0,
1426            tls: None,
1427            user: String::new(),
1428            secret_ref: default_email_secret_ref(),
1429            rate_limit_per_hour: default_rate_limit_emails(),
1430        }
1431    }
1432}
1433
1434impl EmailConfig {
1435    /// Resolve the effective provider, falling back to Gmail.
1436    pub fn provider(&self) -> SmtpProvider {
1437        self.provider
1438    }
1439}
1440
1441fn default_gateway_host() -> String {
1442    "127.0.0.1".into()
1443}
1444
1445fn default_gateway_port() -> u16 {
1446    4200
1447}
1448
1449impl Default for GatewayConfig {
1450    fn default() -> Self {
1451        Self {
1452            host: default_gateway_host(),
1453            port: default_gateway_port(),
1454            expose_api_docs: false,
1455            response_timeout_secs: default_response_timeout_secs(),
1456            reliability: GatewayReliabilityConfig::default(),
1457        }
1458    }
1459}
1460
1461/// Execution mode for commands.
1462///
1463/// - `Structured`: Binary allowlist + metacharacter blocking (recommended)
1464/// - `Shell`: Raw bash execution (dangerous, requires `allow_shell_mode=true`)
1465#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1466#[serde(rename_all = "lowercase")]
1467pub enum ExecMode {
1468    /// Structured binary execution with allowlist and metacharacter blocking.
1469    #[default]
1470    Structured,
1471    /// Shell execution via `bash -c`. DANGEROUS — requires explicit enable.
1472    Shell,
1473}
1474
1475/// Execution allowlist behavior mode.
1476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1477#[serde(rename_all = "snake_case")]
1478#[derive(Default)]
1479pub enum AllowlistMode {
1480    /// All binaries are permitted (development only).
1481    Permissive,
1482    /// Only binaries in `allowed_commands` may execute.
1483    #[default]
1484    Enforced,
1485}
1486
1487/// Exec configuration.
1488///
1489/// Governs how the kernel dispatches commands for execution.
1490#[derive(Debug, Clone, Deserialize, Serialize)]
1491pub struct ExecConfig {
1492    /// Default execution mode.
1493    #[serde(default)]
1494    pub default_mode: ExecMode,
1495    /// Allow shell mode. DANGEROUS — should be false in production.
1496    #[serde(default = "default_false")]
1497    pub allow_shell_mode: bool,
1498    /// Commands allowed to run on the host.
1499    /// If empty, *all* bare-name commands are permitted (development mode).
1500    #[serde(default)]
1501    pub allowed_commands: Vec<String>,
1502    /// Allowlist enforcement mode.
1503    /// `Permissive` = empty list means all allowed (dev mode).
1504    /// `Enforced` = only listed commands allowed (production).
1505    #[serde(default)]
1506    pub allowlist_mode: AllowlistMode,
1507    /// Default timeout for an exec call in seconds.
1508    #[serde(default = "default_exec_timeout")]
1509    pub default_timeout_secs: u64,
1510    /// Maximum allowed timeout for an exec call in seconds.
1511    #[serde(default = "default_exec_max_timeout")]
1512    pub max_timeout_secs: u64,
1513}
1514
1515fn default_false() -> bool {
1516    false
1517}
1518
1519fn default_exec_timeout() -> u64 {
1520    120
1521}
1522
1523fn default_exec_max_timeout() -> u64 {
1524    600
1525}
1526
1527impl ExecConfig {
1528    /// Check whether a binary / command name is allowed to execute.
1529    ///
1530    /// In `Permissive` mode, returns `true` when `allowed_commands` is empty
1531    /// (all allowed) **or** when the name is present in the allow-list.
1532    ///
1533    /// In `Enforced` mode, only names present in the allow-list are permitted.
1534    pub fn is_binary_allowed(&self, name: &str) -> bool {
1535        match self.allowlist_mode {
1536            AllowlistMode::Permissive => {
1537                self.allowed_commands.is_empty() || self.allowed_commands.iter().any(|c| c == name)
1538            }
1539            AllowlistMode::Enforced => self.allowed_commands.iter().any(|c| c == name),
1540        }
1541    }
1542}
1543
1544impl Default for ExecConfig {
1545    fn default() -> Self {
1546        Self {
1547            default_mode: ExecMode::default(),
1548            allow_shell_mode: default_false(),
1549            allowed_commands: Vec::new(),
1550            allowlist_mode: AllowlistMode::default(),
1551            default_timeout_secs: default_exec_timeout(),
1552            max_timeout_secs: default_exec_max_timeout(),
1553        }
1554    }
1555}
1556
1557/// Orchestrator configuration (Ouroboros protocol execution).
1558#[derive(Debug, Clone, Deserialize, Serialize)]
1559pub struct OrchestratorConfig {
1560    /// Maximum evolution iterations (0 = evaluate only, no evolution).
1561    /// Default: 3.
1562    #[serde(default = "default_max_evolution_iterations")]
1563    pub max_evolution_iterations: u32,
1564
1565    /// Minimum evaluation score for task to be considered passed (0.0–1.0).
1566    /// Default: 0.8.
1567    #[serde(default = "default_min_evaluation_score")]
1568    pub min_evaluation_score: f64,
1569}
1570
1571fn default_max_evolution_iterations() -> u32 {
1572    3
1573}
1574
1575fn default_min_evaluation_score() -> f64 {
1576    0.8
1577}
1578
1579impl Default for OrchestratorConfig {
1580    fn default() -> Self {
1581        Self {
1582            max_evolution_iterations: default_max_evolution_iterations(),
1583            min_evaluation_score: default_min_evaluation_score(),
1584        }
1585    }
1586}
1587
1588/// Intent engine configuration (RFC-027 unified intent handling).
1589///
1590/// Controls the unified intent engine that replaces the legacy Ouroboros
1591/// five-phase protocol: `assess` → `crystallize` → `execute` → `review` → `retry`.
1592#[derive(Debug, Clone, Serialize, Deserialize)]
1593pub struct IntentConfig {
1594    /// Maximum retry attempts when a Substantial task fails review.
1595    /// Set to 0 to disable retries entirely.
1596    /// Default: 2.
1597    #[serde(default = "default_intent_max_retries")]
1598    pub max_retries: u32,
1599
1600    /// Minimum review score (0.0–1.0) required for a verdict to pass.
1601    /// Reviews below this threshold trigger a retry.
1602    /// Default: 0.7.
1603    #[serde(default = "default_intent_score_threshold")]
1604    pub score_threshold: f64,
1605
1606    /// Maximum clarification rounds before forcing the task to proceed
1607    /// with the system's best-guess understanding.
1608    /// Default: 3.
1609    #[serde(default = "default_intent_max_clarify_rounds")]
1610    pub max_clarify_rounds: u32,
1611
1612    /// Whether to retry Substantial tasks whose review verdict fails.
1613    /// When false, a failing review is reported back to the user directly.
1614    /// Default: true.
1615    #[serde(default = "default_intent_enable_retry")]
1616    pub enable_retry: bool,
1617
1618    /// Optional lightweight model ID for `assess`/`crystallize`/`review` calls.
1619    /// When None, the engine uses the resolver's default model.
1620    /// Default: None.
1621    #[serde(default)]
1622    pub lightweight_model: Option<String>,
1623}
1624
1625fn default_intent_max_retries() -> u32 {
1626    2
1627}
1628
1629fn default_intent_score_threshold() -> f64 {
1630    0.7
1631}
1632
1633fn default_intent_max_clarify_rounds() -> u32 {
1634    3
1635}
1636
1637fn default_intent_enable_retry() -> bool {
1638    true
1639}
1640
1641impl Default for IntentConfig {
1642    fn default() -> Self {
1643        Self {
1644            max_retries: default_intent_max_retries(),
1645            score_threshold: default_intent_score_threshold(),
1646            max_clarify_rounds: default_intent_max_clarify_rounds(),
1647            enable_retry: default_intent_enable_retry(),
1648            lightweight_model: None,
1649        }
1650    }
1651}
1652
1653/// Context manager configuration (inspired by AIOS).
1654#[derive(Debug, Clone, Deserialize, Serialize)]
1655pub struct ContextConfig {
1656    /// Maximum tokens in the active (in-context) tier.
1657    #[serde(default = "default_active_limit")]
1658    pub active_limit_tokens: usize,
1659    /// Maximum entries in the cache tier.
1660    #[serde(default = "default_cache_limit")]
1661    pub cache_limit_entries: usize,
1662}
1663
1664fn default_active_limit() -> usize {
1665    100_000
1666}
1667
1668fn default_cache_limit() -> usize {
1669    50
1670}
1671
1672impl Default for ContextConfig {
1673    fn default() -> Self {
1674        Self {
1675            active_limit_tokens: default_active_limit(),
1676            cache_limit_entries: default_cache_limit(),
1677        }
1678    }
1679}
1680
1681/// Security/access control configuration (inspired by OWASP Agentic AI).
1682#[derive(Debug, Clone, Deserialize, Serialize)]
1683pub struct SecurityConfig {
1684    /// Default allowed tools for agents (least privilege).
1685    #[serde(default = "default_allowed_tools")]
1686    pub allowed_tools: Vec<String>,
1687    /// Whether agents can make network requests by default.
1688    #[serde(default)]
1689    pub network_access: bool,
1690    /// Maximum execution time in seconds for agent tasks.
1691    #[serde(default = "default_max_exec_time")]
1692    pub max_execution_time_secs: u64,
1693    /// Maximum memory in MB for agent tasks.
1694    #[serde(default = "default_max_memory")]
1695    pub max_memory_mb: u64,
1696    /// Whether agents can fork sub-agents by default.
1697    #[serde(default)]
1698    pub can_fork: bool,
1699    /// Maximum audit log entries to retain.
1700    #[serde(default = "default_max_audit")]
1701    pub max_audit_entries: usize,
1702    /// Enable API key authentication.
1703    #[serde(default)]
1704    pub auth_enabled: bool,
1705    /// Allowed CORS origins.
1706    #[serde(default = "default_cors_origins")]
1707    pub cors_origins: Vec<String>,
1708    /// Path for audit log file (optional, enables file-based persistence).
1709    #[serde(default)]
1710    pub audit_log_path: Option<String>,
1711    /// Rate limit for API endpoints (requests per minute).
1712    #[serde(default = "default_rate_limit_per_minute")]
1713    pub rate_limit_per_minute: u32,
1714}
1715
1716fn default_allowed_tools() -> Vec<String> {
1717    vec![
1718        "read".to_string(),
1719        "write".to_string(),
1720        "edit".to_string(),
1721        "bash".to_string(),
1722        "grep".to_string(),
1723        "find".to_string(),
1724        "exec".to_string(),
1725    ]
1726}
1727
1728fn default_max_exec_time() -> u64 {
1729    300
1730}
1731
1732fn default_max_memory() -> u64 {
1733    512
1734}
1735
1736fn default_max_audit() -> usize {
1737    10_000
1738}
1739
1740fn default_rate_limit_per_minute() -> u32 {
1741    // Local-first single-user server — 600/min (10 req/s) gives ample headroom
1742    // for the ~20 frontend polling queries without throttling legitimate use.
1743    // 0 = unlimited (see RateLimiter::new).
1744    600
1745}
1746
1747fn default_cors_origins() -> Vec<String> {
1748    // Browsers treat `localhost` and `127.0.0.1` as distinct origins, so both
1749    // must be allow-listed or cross-origin requests silently fail CORS checks.
1750    // 4200 = backend that also serves the production SPA (same origin).
1751    // 5173 = Vite dev server (`bun dev` in web/).
1752    vec![
1753        "http://localhost:4200".to_string(),
1754        "http://127.0.0.1:4200".to_string(),
1755        "http://localhost:5173".to_string(),
1756        "http://127.0.0.1:5173".to_string(),
1757    ]
1758}
1759
1760impl Default for SecurityConfig {
1761    fn default() -> Self {
1762        Self {
1763            allowed_tools: default_allowed_tools(),
1764            network_access: false,
1765            max_execution_time_secs: default_max_exec_time(),
1766            max_memory_mb: default_max_memory(),
1767            can_fork: false,
1768            max_audit_entries: default_max_audit(),
1769            auth_enabled: false,
1770            cors_origins: default_cors_origins(),
1771            audit_log_path: None,
1772            rate_limit_per_minute: default_rate_limit_per_minute(),
1773        }
1774    }
1775}
1776
1777/// Persona system configuration.
1778///
1779/// Only one persona is active at a time (single slot in `PersonaManager`).
1780/// See `docs/rfc-039-persona-completion.md` for the rationale.
1781#[derive(Debug, Clone, Deserialize, Serialize, Default)]
1782pub struct PersonaConfig {
1783    /// Default persona ID to activate on startup.
1784    #[serde(default)]
1785    pub default_persona_id: Option<String>,
1786}
1787
1788/// MCP server configuration loaded from config.toml.
1789///
1790/// Each key is a server name; the value is a table with:
1791/// - `command`: executable to run (e.g. "npx", "python")
1792/// - `args`: arguments array
1793/// - `env`: optional map of environment variables
1794/// - `enabled`: whether to start this server on boot (default: true)
1795#[derive(Debug, Clone, Deserialize, Serialize, Default)]
1796pub struct McpConfig {
1797    /// Map of server-name → server definition.
1798    #[serde(default)]
1799    pub servers: std::collections::HashMap<String, McpServerDef>,
1800}
1801
1802/// A single MCP server definition in config.toml.
1803#[derive(Debug, Clone, Deserialize, Serialize)]
1804pub struct McpServerDef {
1805    /// Command to execute.
1806    pub command: String,
1807    /// Arguments passed to the command.
1808    #[serde(default)]
1809    pub args: Vec<String>,
1810    /// Environment variables.
1811    #[serde(default)]
1812    pub env: std::collections::HashMap<String, String>,
1813    /// Whether this server is enabled (default: true).
1814    #[serde(default = "default_mcp_enabled")]
1815    pub enabled: bool,
1816}
1817
1818fn default_mcp_enabled() -> bool {
1819    true
1820}
1821
1822/// Git version control configuration.
1823#[derive(Debug, Clone, Deserialize, Serialize)]
1824pub struct GitConfig {
1825    /// Enable automatic commits for state changes.
1826    #[serde(default = "default_true")]
1827    pub auto_commit: bool,
1828}
1829
1830impl Default for GitConfig {
1831    fn default() -> Self {
1832        Self { auto_commit: true }
1833    }
1834}
1835
1836/// Audit trail configuration.
1837#[derive(Debug, Clone, Deserialize, Serialize)]
1838pub struct AuditConfig {
1839    /// Maximum audit entries before pruning.
1840    #[serde(default = "default_audit_max_entries")]
1841    pub max_entries: usize,
1842    /// Enable audit trail.
1843    #[serde(default = "default_true")]
1844    pub enabled: bool,
1845}
1846
1847fn default_audit_max_entries() -> usize {
1848    100_000
1849}
1850
1851impl Default for AuditConfig {
1852    fn default() -> Self {
1853        Self {
1854            max_entries: default_audit_max_entries(),
1855            enabled: true,
1856        }
1857    }
1858}
1859
1860/// Budget enforcement configuration.
1861#[derive(Debug, Clone, Deserialize, Serialize)]
1862pub struct BudgetConfig {
1863    /// Default token budget per agent (0 = unlimited).
1864    #[serde(default)]
1865    pub default_token_budget: u64,
1866    /// Default call budget per agent (0 = unlimited).
1867    #[serde(default)]
1868    pub default_calls_budget: u64,
1869    /// Default budget window in seconds.
1870    #[serde(default = "default_budget_window")]
1871    pub default_window_secs: u64,
1872    /// Enable budget enforcement.
1873    #[serde(default = "default_true")]
1874    pub enabled: bool,
1875    /// Monthly spend limit in USD. When set, the cost summary includes
1876    /// month-to-date spend and remaining budget. Phase 1: monitoring +
1877    /// alerts only. Phase 2: pre-execution enforcement.
1878    #[serde(default)]
1879    pub monthly_spend_limit_usd: Option<f64>,
1880}
1881
1882fn default_budget_window() -> u64 {
1883    3600
1884}
1885
1886impl Default for BudgetConfig {
1887    fn default() -> Self {
1888        Self {
1889            default_token_budget: 0,
1890            default_calls_budget: 0,
1891            default_window_secs: default_budget_window(),
1892            enabled: true,
1893            monthly_spend_limit_usd: None,
1894        }
1895    }
1896}
1897
1898/// Resource monitor configuration.
1899#[derive(Debug, Clone, Deserialize, Serialize)]
1900pub struct ResourceMonitorConfig {
1901    /// Snapshot interval in seconds.
1902    #[serde(default = "default_rm_interval")]
1903    pub interval_secs: u64,
1904    /// Maximum history entries.
1905    #[serde(default = "default_rm_history_max")]
1906    pub history_max: usize,
1907    /// CPU threshold for overload.
1908    #[serde(default = "default_rm_cpu_threshold")]
1909    pub cpu_threshold: f32,
1910    /// Memory threshold for overload (percentage).
1911    #[serde(default = "default_rm_mem_threshold")]
1912    pub memory_threshold: f32,
1913    /// Load average threshold for overload.
1914    #[serde(default = "default_rm_load_threshold")]
1915    pub load_threshold: f32,
1916}
1917
1918fn default_rm_interval() -> u64 {
1919    60
1920}
1921
1922fn default_rm_history_max() -> usize {
1923    60
1924}
1925
1926fn default_rm_cpu_threshold() -> f32 {
1927    90.0
1928}
1929
1930fn default_rm_mem_threshold() -> f32 {
1931    90.0
1932}
1933
1934fn default_rm_load_threshold() -> f32 {
1935    8.0
1936}
1937
1938impl Default for ResourceMonitorConfig {
1939    fn default() -> Self {
1940        Self {
1941            interval_secs: default_rm_interval(),
1942            history_max: default_rm_history_max(),
1943            cpu_threshold: default_rm_cpu_threshold(),
1944            memory_threshold: default_rm_mem_threshold(),
1945            load_threshold: default_rm_load_threshold(),
1946        }
1947    }
1948}
1949
1950/// Agent history log configuration.
1951#[derive(Debug, Clone, Serialize, Deserialize)]
1952pub struct AgentLogConfig {
1953    /// Maximum number of agent records to keep (0 = unlimited).
1954    #[serde(default = "default_agent_log_max_entries")]
1955    pub max_entries: usize,
1956    /// TTL for agent records in hours (0 = unlimited).
1957    #[serde(default = "default_agent_log_ttl_hours")]
1958    pub ttl_hours: u64,
1959    /// Max tool_calls per agent to persist (0 = unlimited).
1960    #[serde(default = "default_agent_log_max_tool_calls")]
1961    pub max_tool_calls_per_agent: usize,
1962    /// How many agents to prune per cycle.
1963    #[serde(default = "default_agent_log_prune_batch")]
1964    pub prune_batch_size: usize,
1965    /// Path to the SQLite database file (empty = default).
1966    #[serde(default)]
1967    pub db_path: String,
1968}
1969
1970fn default_agent_log_max_entries() -> usize {
1971    10_000
1972}
1973fn default_agent_log_ttl_hours() -> u64 {
1974    720
1975}
1976fn default_agent_log_max_tool_calls() -> usize {
1977    500
1978}
1979fn default_agent_log_prune_batch() -> usize {
1980    100
1981}
1982
1983impl Default for AgentLogConfig {
1984    fn default() -> Self {
1985        Self {
1986            max_entries: 10_000,
1987            ttl_hours: 720,
1988            max_tool_calls_per_agent: 500,
1989            prune_batch_size: 100,
1990            db_path: String::new(),
1991        }
1992    }
1993}
1994
1995/// Logging configuration.
1996#[derive(Debug, Clone, Deserialize, Serialize)]
1997pub struct LoggingConfig {
1998    /// Log format: "pretty", "json", or "compact".
1999    #[serde(default = "default_log_format")]
2000    pub format: String,
2001    /// Log level override (e.g. "info", "debug"). Falls back to RUST_LOG env var.
2002    #[serde(default)]
2003    pub level: Option<String>,
2004}
2005
2006fn default_log_format() -> String {
2007    "pretty".into()
2008}
2009
2010impl Default for LoggingConfig {
2011    fn default() -> Self {
2012        Self {
2013            format: default_log_format(),
2014            level: None,
2015        }
2016    }
2017}
2018
2019/// Headless browser configuration.
2020///
2021/// Engine configuration. Passes through to `oxi-sdk` browser tools.
2022/// with an `enabled` toggle. The engine config is passed through directly
2023/// to the browser — no field-by-field duplication.
2024#[derive(Debug, Clone, Deserialize, Serialize)]
2025pub struct BrowserConfig {
2026    /// Enable the browser integration.
2027    #[serde(default = "default_browser_enabled")]
2028    pub enabled: bool,
2029
2030    /// Engine configuration — passed to oxi-sdk's `native_browser_tools_with_config()`.
2031    ///
2032    /// All fields have sensible defaults; override only what you need:
2033    ///
2034    /// ```toml
2035    /// [browser.engine]
2036    /// user_agent = "MyBot/1.0"
2037    /// obey_robots = false
2038    /// js_timeout_ms = 10000
2039    /// ```
2040    #[serde(default)]
2041    pub engine: serde_json::Value,
2042}
2043
2044fn default_browser_enabled() -> bool {
2045    true
2046}
2047
2048impl Default for BrowserConfig {
2049    fn default() -> Self {
2050        Self {
2051            enabled: true,
2052            engine: serde_json::json!({}),
2053        }
2054    }
2055}
2056
2057/// Loads configuration from a TOML file.
2058pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
2059    let content = std::fs::read_to_string(path)?;
2060    let config: OxiosConfig = toml::from_str(&content)?;
2061    let (errors, warnings) = config.validate();
2062    for w in warnings {
2063        tracing::warn!("config: {}", w);
2064    }
2065    if !errors.is_empty() {
2066        let msg = errors.join("; ");
2067        anyhow::bail!("Configuration validation failed: {msg}");
2068    }
2069    Ok(config)
2070}
2071
2072impl OxiosConfig {
2073    /// Returns the effective API key from the engine config.
2074    pub fn api_key(&self) -> Option<String> {
2075        self.engine.api_key.clone().filter(|k| !k.is_empty())
2076    }
2077
2078    /// Validate configuration values and return a list of warnings.
2079    /// Returns (errors, warnings). Empty errors = valid config.
2080    pub fn validate(&self) -> (Vec<String>, Vec<String>) {
2081        let mut errors = Vec::new();
2082        let mut warnings = Vec::new();
2083
2084        // Kernel validation
2085        if self.kernel.max_agents == 0 {
2086            errors.push("kernel.max_agents must be > 0".into());
2087        }
2088        if self.kernel.workspace.is_empty() {
2089            errors.push("kernel.workspace must not be empty".into());
2090        }
2091
2092        // Gateway validation
2093        if self.gateway.port == 0 {
2094            errors.push("gateway.port must be > 0".into());
2095        }
2096        if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
2097            warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
2098        }
2099
2100        // Cron validation
2101        for (name, job) in &self.cron.jobs {
2102            if job.schedule.is_empty() {
2103                errors.push(format!("cron.jobs.{name}: schedule is empty"));
2104            } else {
2105                // Normalize 5-field to 6-field (prepend "0 " for seconds)
2106                let normalized = {
2107                    let fields: Vec<&str> = job.schedule.split_whitespace().collect();
2108                    match fields.len() {
2109                        5 => format!("0 {}", job.schedule),
2110                        _ => job.schedule.clone(),
2111                    }
2112                };
2113                if Schedule::from_str(&normalized).is_err() {
2114                    errors.push(format!(
2115                        "cron.jobs.{}: invalid cron expression '{}'",
2116                        name, job.schedule
2117                    ));
2118                }
2119            }
2120            if job.goal.is_empty() {
2121                errors.push(format!("cron.jobs.{name}: goal is empty"));
2122            }
2123        }
2124
2125        // Security validation
2126        if self.security.max_execution_time_secs == 0 {
2127            warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
2128        }
2129
2130        // Audit validation
2131        if self.audit.max_entries == 0 {
2132            warnings.push("audit.max_entries is 0 — audit will never prune".into());
2133        }
2134
2135        // Budget validation
2136        if self.budget.default_window_secs == 0 {
2137            warnings.push("budget.default_window_secs is 0 — no time window".into());
2138        }
2139
2140        // Gateway field-level validation
2141        if self.gateway.response_timeout_secs == 0 {
2142            errors.push("gateway.response_timeout_secs must be > 0".into());
2143        }
2144
2145        // Engine: warn when an API key is committed to config in plaintext.
2146        // The auth store and env-var fallback are preferred for secret hygiene.
2147        if self.engine.api_key.as_ref().is_some_and(|k| !k.is_empty()) {
2148            warnings.push(
2149                "engine.api_key is set in config — prefer the oxi auth store or env var to avoid storing a secret on disk"
2150                    .into(),
2151            );
2152        }
2153
2154        // MCP server validation: reject empty commands (would spawn a no-op).
2155        for (name, server) in &self.mcp.servers {
2156            if server.command.trim().is_empty() {
2157                errors.push(format!("mcp.servers.{name}: command must not be empty"));
2158            }
2159        }
2160
2161        // Session validation
2162        if self.session.max_sessions == 0 && self.session.ttl_hours == 0 && self.session.auto_prune
2163        {
2164            warnings.push("session: auto_prune is enabled but both max_sessions and ttl_hours are 0 — nothing will be pruned".into());
2165        }
2166
2167        // Exec validation
2168        if self.exec.default_timeout_secs == 0 {
2169            errors.push("exec.default_timeout_secs must be > 0".into());
2170        }
2171        if self.exec.max_timeout_secs == 0 {
2172            errors.push("exec.max_timeout_secs must be > 0".into());
2173        }
2174        if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
2175            errors.push(format!(
2176                "exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
2177                self.exec.default_timeout_secs, self.exec.max_timeout_secs
2178            ));
2179        }
2180
2181        // Resource monitor validation
2182        if self.resource_monitor.cpu_threshold > 100.0 {
2183            errors.push("resource_monitor.cpu_threshold must be <= 100".into());
2184        }
2185        if self.resource_monitor.memory_threshold > 100.0 {
2186            errors.push("resource_monitor.memory_threshold must be <= 100".into());
2187        }
2188
2189        // Channels validation (message interfaces only)
2190        for name in &self.channels.enabled {
2191            let valid = ["cli", "telegram"];
2192            if !valid.contains(&name.as_str()) {
2193                warnings.push(format!("channels.enabled: unknown channel '{name}'"));
2194            }
2195        }
2196        // Warn if 'web' is listed in channels — it should be in surfaces
2197        if self.channels.enabled.iter().any(|c| c == "web") {
2198            warnings.push(
2199                "channels.enabled: 'web' should be listed under [surfaces], not [channels]".into(),
2200            );
2201        }
2202        if self.channels.enabled.iter().any(|c| c == "telegram")
2203            && std::env::var(&self.channels.telegram.bot_token_env).is_err()
2204        {
2205            warnings.push(format!(
2206                "channels.telegram: {} env var not set — telegram channel will fail",
2207                self.channels.telegram.bot_token_env
2208            ));
2209        }
2210        // Token Maxing (RFC-031) — only fail-closed at startup if the
2211        // user explicitly opted in but the entry is broken. A valid
2212        // empty/disabled config never errors.
2213        for err in self.token_maxing.validate() {
2214            errors.push(err);
2215        }
2216
2217        (errors, warnings)
2218    }
2219}
2220
2221/// Expand `~/` in paths to the user's home directory.
2222///
2223/// Shared utility for path expansion across the binary and kernel.
2224///
2225/// Resolution order for the home directory:
2226/// 1. `$HOME` environment variable (preserves existing behavior).
2227/// 2. `dirs::home_dir()` (works in environments where HOME is unset, e.g.
2228///    systemd units, containers, cron jobs).
2229/// 3. If neither is available, the literal path is returned unchanged so the
2230///    caller still gets a usable `PathBuf` rather than a panic — the failure
2231///    will surface as a normal "path not found" downstream.
2232pub fn expand_home(path: &str) -> std::path::PathBuf {
2233    if let Some(rest) = path.strip_prefix("~/") {
2234        if let Ok(home) = std::env::var("HOME") {
2235            return std::path::PathBuf::from(format!("{home}/{rest}"));
2236        }
2237        if let Some(home) = dirs::home_dir() {
2238            return home.join(rest);
2239        }
2240    }
2241    std::path::PathBuf::from(path)
2242}
2243
2244#[cfg(test)]
2245mod tests {
2246    use super::*;
2247
2248    #[test]
2249    fn test_default_config_validates() {
2250        let config = OxiosConfig::default();
2251        let (errors, _warnings) = config.validate();
2252        assert!(
2253            errors.is_empty(),
2254            "Default config should have no errors: {:?}",
2255            errors
2256        );
2257    }
2258
2259    #[test]
2260    fn test_exec_config_default_allowed_commands() {
2261        let config = ExecConfig::default();
2262        // Default is Enforced mode — empty list means NOTHING allowed.
2263        assert!(config.allowed_commands.is_empty());
2264        assert_eq!(config.allowlist_mode, AllowlistMode::Enforced);
2265        assert!(!config.is_binary_allowed("anything"));
2266        assert!(!config.is_binary_allowed("bash"));
2267    }
2268
2269    #[test]
2270    fn test_exec_config_permissive_mode() {
2271        let config = ExecConfig {
2272            allowlist_mode: AllowlistMode::Permissive,
2273            ..Default::default()
2274        };
2275        // Permissive + empty list = all allowed
2276        assert!(config.is_binary_allowed("anything"));
2277        assert!(config.is_binary_allowed("bash"));
2278    }
2279
2280    #[test]
2281    fn test_is_binary_allowed_with_allowlist() {
2282        let config = ExecConfig {
2283            allowed_commands: vec!["git".into(), "echo".into()],
2284            ..Default::default()
2285        };
2286        assert!(config.is_binary_allowed("git"));
2287        assert!(config.is_binary_allowed("echo"));
2288        assert!(!config.is_binary_allowed("bash"));
2289        assert!(!config.is_binary_allowed("rm"));
2290        assert!(!config.is_binary_allowed("sudo"));
2291    }
2292
2293    #[test]
2294    fn test_expand_home() {
2295        // With HOME set.
2296        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
2297        let expanded = expand_home("~/projects/test");
2298        assert_eq!(
2299            expanded.to_str().unwrap(),
2300            format!("{}/projects/test", home)
2301        );
2302
2303        // Non-tilde path should pass through unchanged.
2304        let abs = expand_home("/absolute/path");
2305        assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
2306
2307        // Just ~ without slash should not expand.
2308        let bare = expand_home("~something");
2309        assert_eq!(bare, std::path::PathBuf::from("~something"));
2310    }
2311
2312    #[test]
2313    fn test_invalid_cron_expression() {
2314        let mut config = OxiosConfig::default();
2315        config.cron.enabled = true;
2316        config.cron.jobs.insert(
2317            "bad-job".to_string(),
2318            InlineCronJob {
2319                schedule: "not a valid cron".to_string(),
2320                goal: "Test goal".to_string(),
2321                constraints: vec![],
2322                acceptance_criteria: vec![],
2323                toolchain: "default".to_string(),
2324                priority: Priority::Normal,
2325                enabled: true,
2326            },
2327        );
2328
2329        let (errors, _warnings) = config.validate();
2330        assert!(
2331            !errors.is_empty(),
2332            "Expected validation error for invalid cron"
2333        );
2334        let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
2335        assert!(
2336            has_cron_error,
2337            "Expected 'invalid cron expression' error, got: {:?}",
2338            errors
2339        );
2340    }
2341
2342    #[test]
2343    fn test_config_serialization_roundtrip() {
2344        let config = OxiosConfig::default();
2345
2346        // Serialize to TOML string.
2347        let toml_str = toml::to_string(&config).expect("serialization should succeed");
2348
2349        // Deserialize back.
2350        let deserialized: OxiosConfig =
2351            toml::from_str(&toml_str).expect("deserialization should succeed");
2352
2353        // Key fields should match.
2354        assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
2355        assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
2356        assert_eq!(config.gateway.host, deserialized.gateway.host);
2357        assert_eq!(config.gateway.port, deserialized.gateway.port);
2358        assert_eq!(
2359            config.exec.default_timeout_secs,
2360            deserialized.exec.default_timeout_secs
2361        );
2362        assert_eq!(
2363            config.exec.max_timeout_secs,
2364            deserialized.exec.max_timeout_secs
2365        );
2366    }
2367
2368    #[test]
2369    fn test_exec_timeout_validation() {
2370        let mut config = OxiosConfig::default();
2371        // default_timeout > max_timeout should be an error.
2372        config.exec.default_timeout_secs = 999;
2373        config.exec.max_timeout_secs = 100;
2374        let (errors, _warnings) = config.validate();
2375        let has_error = errors.iter().any(|e| e.contains("must not exceed"));
2376        assert!(
2377            has_error,
2378            "Expected timeout ordering error, got: {:?}",
2379            errors
2380        );
2381    }
2382
2383    #[test]
2384    fn test_zero_max_agents_error() {
2385        let mut config = OxiosConfig::default();
2386        config.kernel.max_agents = 0;
2387        let (errors, _warnings) = config.validate();
2388        assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
2389    }
2390
2391    /// Rust Default와 share/default-config.toml 간 핵심 기본값 일치 확인.
2392    /// TOML 템플릿은 "프로덕션 준비" 기본값을 가지며,
2393    /// Rust Default는 "안전한 최소" 기본값을 가질 수 있음.
2394    /// 핵심 스칼라 값(포트, 호스트, max_agents 등)은 반드시 일치해야 함.
2395    #[test]
2396    fn test_default_config_matches_toml() {
2397        let from_rust = OxiosConfig::default();
2398
2399        let toml_str = include_str!("../../../share/default-config.toml");
2400        let from_toml: OxiosConfig =
2401            toml::from_str(toml_str).expect("share/default-config.toml이 유효하지 않습니다");
2402
2403        // 핵심 스칼라 필드 — Rust와 TOML이 반드시 일치해야 함
2404        assert_eq!(
2405            from_rust.kernel.max_agents, from_toml.kernel.max_agents,
2406            "kernel.max_agents 불일치: Rust={}, TOML={}",
2407            from_rust.kernel.max_agents, from_toml.kernel.max_agents
2408        );
2409        assert_eq!(
2410            from_rust.gateway.host, from_toml.gateway.host,
2411            "gateway.host 불일치: Rust={}, TOML={}",
2412            from_rust.gateway.host, from_toml.gateway.host
2413        );
2414        assert_eq!(
2415            from_rust.gateway.port, from_toml.gateway.port,
2416            "gateway.port 불일치: Rust={}, TOML={}",
2417            from_rust.gateway.port, from_toml.gateway.port
2418        );
2419        assert_eq!(
2420            from_rust.kernel.event_bus_capacity, from_toml.kernel.event_bus_capacity,
2421            "kernel.event_bus_capacity 불일치"
2422        );
2423        assert_eq!(
2424            from_rust.memory.consolidation.preset, from_toml.memory.consolidation.preset,
2425            "memory.consolidation.preset 불일치"
2426        );
2427
2428        // TOML 템플릿이 파싱 가능한지 확인
2429        let (_, warnings) = from_toml.validate();
2430        for w in &warnings {
2431            eprintln!("default-config.toml 경고: {}", w);
2432        }
2433    }
2434
2435    /// `gateway.expose_api_docs` is gated to loopback binds for safety.
2436    /// Verifies all four cases: opt-out, opt-in + public, opt-in + loopback.
2437    #[test]
2438    fn test_gateway_should_expose_api_docs() {
2439        // Default: opt-out — never expose.
2440        let cfg = GatewayConfig::default();
2441        assert!(!cfg.should_expose_api_docs());
2442
2443        // Opt-in + public bind (0.0.0.0) — still NOT exposed.
2444        let cfg = GatewayConfig {
2445            host: "0.0.0.0".into(),
2446            port: 4200,
2447            expose_api_docs: true,
2448            ..Default::default()
2449        };
2450        assert!(
2451            !cfg.should_expose_api_docs(),
2452            "public bind must not expose api docs even when opt-in is true"
2453        );
2454
2455        // Opt-in + loopback (127.0.0.1) — exposed.
2456        let cfg = GatewayConfig {
2457            host: "127.0.0.1".into(),
2458            port: 4200,
2459            expose_api_docs: true,
2460            ..Default::default()
2461        };
2462        assert!(cfg.should_expose_api_docs());
2463
2464        // Opt-in + ::1 — exposed.
2465        let cfg = GatewayConfig {
2466            host: "::1".into(),
2467            port: 4200,
2468            expose_api_docs: true,
2469            ..Default::default()
2470        };
2471        assert!(cfg.should_expose_api_docs());
2472
2473        // Opt-in + "localhost" — exposed.
2474        let cfg = GatewayConfig {
2475            host: "localhost".into(),
2476            port: 4200,
2477            expose_api_docs: true,
2478            ..Default::default()
2479        };
2480        assert!(cfg.should_expose_api_docs());
2481
2482        // Opt-out (explicit false) + loopback — NOT exposed.
2483        let cfg = GatewayConfig {
2484            host: "127.0.0.1".into(),
2485            port: 4200,
2486            expose_api_docs: false,
2487            ..Default::default()
2488        };
2489        assert!(!cfg.should_expose_api_docs());
2490    }
2491}