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