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