1#![allow(missing_docs)]
2use cron::Schedule;
8use serde::{Deserialize, Serialize};
9use std::str::FromStr;
10
11use crate::email::{SmtpProvider, SmtpTls};
12use crate::scheduler::Priority;
13
14#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct CronConfig {
17 #[serde(default)]
19 pub enabled: bool,
20 #[serde(default = "default_tick_interval")]
22 pub tick_interval_secs: u64,
23 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
44pub struct InlineCronJob {
45 pub schedule: String,
47 pub goal: String,
49 #[serde(default)]
51 pub constraints: Vec<String>,
52 #[serde(default)]
54 pub acceptance_criteria: Vec<String>,
55 #[serde(default = "default_toolchain_inline")]
57 pub toolchain: String,
58 #[serde(default)]
60 pub priority: Priority,
61 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct MemoryConfig {
77 #[serde(default = "default_true")]
79 pub enabled: bool,
80 #[serde(default = "default_max_recall")]
82 pub max_recall: usize,
83 #[serde(default = "default_true")]
85 pub auto_summarize: bool,
86 #[serde(default = "default_true")]
88 pub capture_compaction: bool,
89 #[serde(default)]
91 pub retention_days: u32,
92 #[serde(default = "default_true")]
94 pub cache_enabled: bool,
95 #[serde(default = "default_cache_ttl")]
97 pub cache_ttl_secs: u64,
98 #[serde(default = "default_cache_max_entries")]
100 pub cache_max_entries: usize,
101 #[serde(default)]
103 pub consolidation: ConsolidationConfig,
104 #[serde(default)]
106 pub sqlite: SqliteMemoryConfig,
107 #[serde(default)]
109 pub embedding: EmbeddingConfig,
110 #[serde(default)]
112 pub learning: LearningConfig,
113 #[serde(default)]
115 pub knowledge_dream: crate::knowledge_dream::KnowledgeDreamConfig,
116 #[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 }
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#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct SqliteMemoryConfig {
169 #[serde(default = "default_true")]
171 pub enabled: bool,
172 #[serde(default)]
175 pub path: String,
176 #[serde(default = "default_embedding_dim")]
180 pub embedding_dim: usize,
181 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct EmbeddingConfig {
213 #[serde(default = "default_embedding_provider")]
215 pub provider: String,
216 #[serde(default = "default_embedding_dim")]
219 pub dimension: usize,
220 #[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 }
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#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct LearningConfig {
253 #[serde(default = "default_true")]
255 pub enabled: bool,
256 #[serde(default = "default_sona_mode")]
258 pub sona_mode: String,
259 #[serde(default = "default_distill_interval")]
261 pub distill_interval_hours: u64,
262 #[serde(default = "default_auto_promote_quality")]
264 pub auto_promote_quality: f32,
265 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct MemoryBridgeConfig {
308 #[serde(default)]
310 pub sync_enabled: bool,
311 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct ConsolidationConfig {
337 #[serde(default = "default_preset")]
341 pub preset: String,
342
343 #[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 #[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 #[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 #[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 #[serde(default = "default_true")]
387 pub auto_classification: bool,
388 #[serde(default = "default_type_promotion_threshold")]
389 pub type_promotion_repetitions: u32,
390
391 #[serde(default = "default_compaction_threshold")]
393 pub compaction_line_threshold: usize,
394 #[serde(default = "default_true")]
395 pub llm_compaction: bool,
396
397 #[serde(default)]
400 pub dream_model: Option<String>,
401
402 #[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 #[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 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(), };
531 *self = resolved;
532 }
533
534 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 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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
607pub struct ChannelsConfig {
608 #[serde(default)]
611 pub enabled: Vec<String>,
612
613 #[serde(default)]
615 pub telegram: TelegramChannelConfig,
616}
617
618#[derive(Debug, Clone, Deserialize, Serialize)]
623pub struct SurfacesConfig {
624 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
644pub struct TelegramChannelConfig {
645 #[serde(default = "default_telegram_token_env")]
647 pub bot_token_env: String,
648 #[serde(default)]
650 pub allowed_users: Vec<i64>,
651 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
672#[allow(clippy::derivable_impls)]
673pub struct EngineConfig {
674 #[serde(default)]
677 pub default_model: String,
678 #[serde(default, skip_serializing)]
682 pub api_key: Option<String>,
683 #[serde(default)]
686 pub provider_options: Option<oxi_sdk::ProviderOptions>,
687 #[serde(default)]
691 pub routing_enabled: bool,
692 #[serde(default)]
694 pub prefer_cost_efficient: bool,
695 #[serde(default)]
697 pub fallback_models: Vec<String>,
698 #[serde(default)]
700 pub excluded_models: Vec<String>,
701}
702
703#[allow(clippy::derivable_impls)]
704impl Default for EngineConfig {
705 fn default() -> Self {
706 Self {
707 default_model: String::new(),
708 api_key: None,
709 provider_options: None,
710 routing_enabled: false,
711 prefer_cost_efficient: false,
712 fallback_models: Vec::new(),
713 excluded_models: Vec::new(),
714 }
715 }
716}
717
718#[derive(Debug, Clone, Deserialize, Serialize)]
720pub struct DaemonConfig {
721 #[serde(default = "default_pid_file")]
723 pub pid_file: String,
724 #[serde(default = "default_daemon_log_dir")]
726 pub log_dir: String,
727}
728
729fn default_pid_file() -> String {
730 dirs::home_dir()
731 .map(|h| format!("{}/.oxios/oxios.pid", h.display()))
732 .unwrap_or_else(|| "./oxios.pid".into())
733}
734
735fn default_daemon_log_dir() -> String {
736 dirs::home_dir()
737 .map(|h| format!("{}/.oxios/logs", h.display()))
738 .unwrap_or_else(|| "./logs".into())
739}
740
741impl Default for DaemonConfig {
742 fn default() -> Self {
743 Self {
744 pid_file: default_pid_file(),
745 log_dir: default_daemon_log_dir(),
746 }
747 }
748}
749
750#[derive(Debug, Clone, Deserialize, Serialize)]
752pub struct SessionConfig {
753 #[serde(default = "default_max_sessions")]
757 pub max_sessions: usize,
758
759 #[serde(default = "default_session_ttl_hours")]
763 pub ttl_hours: u64,
764
765 #[serde(default = "default_true")]
767 pub auto_prune: bool,
768}
769
770fn default_max_sessions() -> usize {
771 100
772}
773
774fn default_session_ttl_hours() -> u64 {
775 168 }
777
778impl Default for SessionConfig {
779 fn default() -> Self {
780 Self {
781 max_sessions: default_max_sessions(),
782 ttl_hours: default_session_ttl_hours(),
783 auto_prune: true,
784 }
785 }
786}
787
788#[derive(Debug, Clone, Deserialize, Serialize)]
792pub struct MountsConfig {
793 #[serde(default = "default_true")]
795 pub auto_promote_enabled: bool,
796 #[serde(default = "default_promote_threshold")]
798 pub auto_promote_threshold: usize,
799 #[serde(default = "default_promote_window_days")]
801 pub auto_promote_window_days: i64,
802 #[serde(default = "default_promote_interval_secs")]
804 pub auto_promote_interval_secs: u64,
805}
806
807fn default_promote_threshold() -> usize {
808 3
809}
810
811fn default_promote_window_days() -> i64 {
812 14
813}
814
815fn default_promote_interval_secs() -> u64 {
816 3600 }
818
819impl Default for MountsConfig {
820 fn default() -> Self {
821 Self {
822 auto_promote_enabled: true,
823 auto_promote_threshold: default_promote_threshold(),
824 auto_promote_window_days: default_promote_window_days(),
825 auto_promote_interval_secs: default_promote_interval_secs(),
826 }
827 }
828}
829
830#[derive(Debug, Clone, Deserialize, Serialize)]
832pub struct TelegramSessionConfig {
833 #[serde(default = "default_telegram_session_rotation_hours")]
836 pub rotation_hours: u64,
837
838 #[serde(default = "default_telegram_session_max_messages")]
841 pub max_messages: usize,
842}
843
844fn default_telegram_session_rotation_hours() -> u64 {
845 2 }
847
848fn default_telegram_session_max_messages() -> usize {
849 0 }
851
852impl Default for TelegramSessionConfig {
853 fn default() -> Self {
854 Self {
855 rotation_hours: default_telegram_session_rotation_hours(),
856 max_messages: default_telegram_session_max_messages(),
857 }
858 }
859}
860
861#[derive(Debug, Clone, Deserialize, Serialize, Default)]
863pub struct OxiosConfig {
864 pub kernel: KernelConfig,
866 #[serde(default)]
868 pub engine: EngineConfig,
869 #[serde(default)]
871 pub daemon: DaemonConfig,
872 #[serde(default)]
874 pub gateway: GatewayConfig,
875 #[serde(default)]
877 pub scheduler: SchedulerConfig,
878 #[serde(default)]
880 pub orchestrator: OrchestratorConfig,
881 #[serde(default)]
883 pub context: ContextConfig,
884 #[serde(default)]
886 pub security: SecurityConfig,
887 #[serde(default)]
889 pub persona: PersonaConfig,
890 #[serde(default)]
892 pub memory: MemoryConfig,
893 #[serde(default)]
895 pub cron: CronConfig,
896 #[serde(default)]
898 pub mcp: McpConfig,
899 #[serde(default)]
901 pub git: GitConfig,
902 #[serde(default)]
904 pub audit: AuditConfig,
905 #[serde(default)]
907 pub budget: BudgetConfig,
908 #[serde(default)]
910 pub exec: ExecConfig,
911 #[serde(default)]
913 pub resource_monitor: ResourceMonitorConfig,
914 #[serde(default)]
916 pub otel: OtelConfig,
917 #[serde(default)]
919 pub logging: LoggingConfig,
920 #[serde(default)]
922 pub channels: ChannelsConfig,
923 #[serde(default)]
925 pub surfaces: Option<SurfacesConfig>,
926 #[serde(default)]
928 pub browser: BrowserConfig,
929 #[serde(default)]
931 pub session: SessionConfig,
932 #[serde(default)]
934 pub mounts: MountsConfig,
935 #[serde(default)]
937 pub marketplace: MarketplaceConfig,
938 #[serde(default)]
940 pub calendar: CalendarConfig,
941 #[serde(default)]
943 pub email: EmailConfig,
944 #[serde(default)]
946 pub agent_log: AgentLogConfig,
947}
948
949#[derive(Debug, Clone, Deserialize, Serialize)]
951pub struct KernelConfig {
952 #[serde(default = "default_workspace")]
954 pub workspace: String,
955 #[serde(default = "default_event_bus_capacity")]
957 pub event_bus_capacity: usize,
958 #[serde(default = "default_max_agents")]
960 pub max_agents: usize,
961}
962
963fn default_workspace() -> String {
964 dirs_home().unwrap_or_else(|| ".".into())
965}
966
967fn dirs_home() -> Option<String> {
968 dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
969}
970
971fn default_event_bus_capacity() -> usize {
972 256
973}
974
975fn default_max_agents() -> usize {
976 10
977}
978
979impl Default for KernelConfig {
980 fn default() -> Self {
981 Self {
982 workspace: default_workspace(),
983 event_bus_capacity: default_event_bus_capacity(),
984 max_agents: 10,
985 }
986 }
987}
988
989#[derive(Debug, Clone, Deserialize, Serialize)]
991pub struct GatewayConfig {
992 #[serde(default = "default_gateway_host")]
994 pub host: String,
995 #[serde(default = "default_gateway_port")]
997 pub port: u16,
998 #[serde(default)]
1008 pub expose_api_docs: bool,
1009 #[serde(default = "default_response_timeout_secs")]
1013 pub response_timeout_secs: u64,
1014 #[serde(default)]
1016 pub reliability: GatewayReliabilityConfig,
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize)]
1021pub struct GatewayReliabilityConfig {
1022 #[serde(default = "default_replay_buffer_size")]
1025 pub replay_buffer_size: usize,
1026 #[serde(default = "default_replay_ttl_secs")]
1028 pub replay_ttl_secs: u64,
1029}
1030
1031impl Default for GatewayReliabilityConfig {
1032 fn default() -> Self {
1033 Self {
1034 replay_buffer_size: default_replay_buffer_size(),
1035 replay_ttl_secs: default_replay_ttl_secs(),
1036 }
1037 }
1038}
1039
1040fn default_response_timeout_secs() -> u64 {
1041 120
1042}
1043fn default_replay_buffer_size() -> usize {
1044 512
1045}
1046fn default_replay_ttl_secs() -> u64 {
1047 60
1048}
1049
1050impl GatewayConfig {
1051 pub fn should_expose_api_docs(&self) -> bool {
1057 if !self.expose_api_docs {
1058 return false;
1059 }
1060 let h = self.host.trim();
1061 h == "127.0.0.1" || h == "::1" || h == "localhost" || h.starts_with("127.")
1062 }
1063}
1064
1065#[derive(Debug, Clone, Deserialize, Serialize)]
1067pub struct MarketplaceConfig {
1068 #[serde(default)]
1071 pub base_url: Option<String>,
1072 #[serde(default = "default_true")]
1074 pub enabled: bool,
1075 #[serde(default)]
1077 pub skills_sh: SkillsShConfig,
1078}
1079
1080#[derive(Debug, Clone, Deserialize, Serialize)]
1082pub struct SkillsShConfig {
1083 #[serde(default)]
1086 pub base_url: Option<String>,
1087 #[serde(default)]
1090 pub api_key: Option<String>,
1091 #[serde(default = "default_true")]
1093 pub enabled: bool,
1094}
1095
1096impl Default for MarketplaceConfig {
1097 fn default() -> Self {
1098 Self {
1099 base_url: Some("https://clawhub.ai".to_string()),
1100 enabled: true,
1101 skills_sh: SkillsShConfig::default(),
1102 }
1103 }
1104}
1105
1106impl Default for SkillsShConfig {
1107 fn default() -> Self {
1108 Self {
1109 base_url: None,
1110 api_key: None,
1111 enabled: true,
1112 }
1113 }
1114}
1115
1116#[derive(Debug, Clone, Deserialize, Serialize)]
1118pub struct CalendarConfig {
1119 #[serde(default)]
1121 pub enabled: bool,
1122 #[serde(default = "default_calendar_timezone")]
1124 pub timezone: String,
1125 #[serde(default = "default_reminder_minutes")]
1127 pub default_reminder_minutes: Vec<u32>,
1128 #[serde(default)]
1130 pub alarm_channels: Vec<String>,
1131 #[serde(default = "default_journal_sync")]
1133 pub journal_sync: String,
1134 #[serde(default = "default_true")]
1136 pub system_calendar: bool,
1137 #[serde(default = "default_archive_days")]
1139 pub archive_after_days: u32,
1140}
1141
1142fn default_calendar_timezone() -> String {
1143 "Asia/Seoul".to_string()
1144}
1145
1146fn default_reminder_minutes() -> Vec<u32> {
1147 vec![15]
1148}
1149
1150fn default_journal_sync() -> String {
1151 "on_open".to_string()
1152}
1153
1154fn default_archive_days() -> u32 {
1155 365
1156}
1157
1158impl Default for CalendarConfig {
1159 fn default() -> Self {
1160 Self {
1161 enabled: false,
1162 timezone: default_calendar_timezone(),
1163 default_reminder_minutes: default_reminder_minutes(),
1164 alarm_channels: vec![],
1165 journal_sync: default_journal_sync(),
1166 system_calendar: true,
1167 archive_after_days: default_archive_days(),
1168 }
1169 }
1170}
1171
1172#[derive(Debug, Clone, Deserialize, Serialize)]
1177pub struct EmailConfig {
1178 #[serde(default)]
1180 pub enabled: bool,
1181 #[serde(default)]
1183 pub my_email: String,
1184 #[serde(default = "default_email_provider")]
1186 pub provider: SmtpProvider,
1187 #[serde(default)]
1189 pub host: String,
1190 #[serde(default)]
1192 pub port: u16,
1193 #[serde(default)]
1195 pub tls: Option<SmtpTls>,
1196 #[serde(default)]
1198 pub user: String,
1199 #[serde(default = "default_email_secret_ref")]
1202 pub secret_ref: String,
1203 #[serde(default = "default_rate_limit_emails")]
1205 pub rate_limit_per_hour: usize,
1206}
1207
1208fn default_email_provider() -> SmtpProvider {
1209 SmtpProvider::Gmail
1210}
1211
1212fn default_email_secret_ref() -> String {
1213 "email_smtp".to_string()
1214}
1215
1216fn default_rate_limit_emails() -> usize {
1217 10
1218}
1219
1220impl Default for EmailConfig {
1221 fn default() -> Self {
1222 Self {
1223 enabled: false,
1224 my_email: String::new(),
1225 provider: default_email_provider(),
1226 host: String::new(),
1227 port: 0,
1228 tls: None,
1229 user: String::new(),
1230 secret_ref: default_email_secret_ref(),
1231 rate_limit_per_hour: default_rate_limit_emails(),
1232 }
1233 }
1234}
1235
1236impl EmailConfig {
1237 pub fn provider(&self) -> SmtpProvider {
1239 self.provider
1240 }
1241}
1242
1243fn default_gateway_host() -> String {
1244 "127.0.0.1".into()
1245}
1246
1247fn default_gateway_port() -> u16 {
1248 4200
1249}
1250
1251impl Default for GatewayConfig {
1252 fn default() -> Self {
1253 Self {
1254 host: default_gateway_host(),
1255 port: default_gateway_port(),
1256 expose_api_docs: false,
1257 response_timeout_secs: default_response_timeout_secs(),
1258 reliability: GatewayReliabilityConfig::default(),
1259 }
1260 }
1261}
1262
1263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1268#[serde(rename_all = "lowercase")]
1269pub enum ExecMode {
1270 #[default]
1272 Structured,
1273 Shell,
1275}
1276
1277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1279#[serde(rename_all = "snake_case")]
1280#[derive(Default)]
1281pub enum AllowlistMode {
1282 Permissive,
1284 #[default]
1286 Enforced,
1287}
1288
1289#[derive(Debug, Clone, Deserialize, Serialize)]
1293pub struct ExecConfig {
1294 #[serde(default)]
1296 pub default_mode: ExecMode,
1297 #[serde(default = "default_false")]
1299 pub allow_shell_mode: bool,
1300 #[serde(default)]
1303 pub allowed_commands: Vec<String>,
1304 #[serde(default)]
1308 pub allowlist_mode: AllowlistMode,
1309 #[serde(default = "default_exec_timeout")]
1311 pub default_timeout_secs: u64,
1312 #[serde(default = "default_exec_max_timeout")]
1314 pub max_timeout_secs: u64,
1315}
1316
1317fn default_false() -> bool {
1318 false
1319}
1320
1321fn default_exec_timeout() -> u64 {
1322 120
1323}
1324
1325fn default_exec_max_timeout() -> u64 {
1326 600
1327}
1328
1329impl ExecConfig {
1330 pub fn is_binary_allowed(&self, name: &str) -> bool {
1337 match self.allowlist_mode {
1338 AllowlistMode::Permissive => {
1339 self.allowed_commands.is_empty() || self.allowed_commands.iter().any(|c| c == name)
1340 }
1341 AllowlistMode::Enforced => self.allowed_commands.iter().any(|c| c == name),
1342 }
1343 }
1344}
1345
1346impl Default for ExecConfig {
1347 fn default() -> Self {
1348 Self {
1349 default_mode: ExecMode::default(),
1350 allow_shell_mode: default_false(),
1351 allowed_commands: Vec::new(),
1352 allowlist_mode: AllowlistMode::default(),
1353 default_timeout_secs: default_exec_timeout(),
1354 max_timeout_secs: default_exec_max_timeout(),
1355 }
1356 }
1357}
1358
1359#[derive(Debug, Clone, Deserialize, Serialize)]
1361pub struct SchedulerConfig {
1362 #[serde(default = "default_max_concurrent")]
1364 pub max_concurrent: usize,
1365 #[serde(default = "default_rate_limit")]
1367 pub rate_limit_per_minute: u32,
1368 #[serde(default = "default_zombie_timeout")]
1370 pub zombie_timeout_secs: u64,
1371}
1372
1373fn default_max_concurrent() -> usize {
1374 5
1375}
1376
1377fn default_rate_limit() -> u32 {
1378 60
1379}
1380
1381fn default_zombie_timeout() -> u64 {
1382 300
1383}
1384
1385impl Default for SchedulerConfig {
1386 fn default() -> Self {
1387 Self {
1388 max_concurrent: default_max_concurrent(),
1389 rate_limit_per_minute: default_rate_limit(),
1390 zombie_timeout_secs: default_zombie_timeout(),
1391 }
1392 }
1393}
1394
1395#[derive(Debug, Clone, Deserialize, Serialize)]
1397pub struct OrchestratorConfig {
1398 #[serde(default = "default_max_evolution_iterations")]
1401 pub max_evolution_iterations: u32,
1402
1403 #[serde(default = "default_min_evaluation_score")]
1406 pub min_evaluation_score: f64,
1407}
1408
1409fn default_max_evolution_iterations() -> u32 {
1410 3
1411}
1412
1413fn default_min_evaluation_score() -> f64 {
1414 0.8
1415}
1416
1417impl Default for OrchestratorConfig {
1418 fn default() -> Self {
1419 Self {
1420 max_evolution_iterations: default_max_evolution_iterations(),
1421 min_evaluation_score: default_min_evaluation_score(),
1422 }
1423 }
1424}
1425
1426#[derive(Debug, Clone, Serialize, Deserialize)]
1431pub struct IntentConfig {
1432 #[serde(default = "default_intent_max_retries")]
1436 pub max_retries: u32,
1437
1438 #[serde(default = "default_intent_score_threshold")]
1442 pub score_threshold: f64,
1443
1444 #[serde(default = "default_intent_max_clarify_rounds")]
1448 pub max_clarify_rounds: u32,
1449
1450 #[serde(default = "default_intent_enable_retry")]
1454 pub enable_retry: bool,
1455
1456 #[serde(default)]
1460 pub lightweight_model: Option<String>,
1461}
1462
1463fn default_intent_max_retries() -> u32 {
1464 2
1465}
1466
1467fn default_intent_score_threshold() -> f64 {
1468 0.7
1469}
1470
1471fn default_intent_max_clarify_rounds() -> u32 {
1472 3
1473}
1474
1475fn default_intent_enable_retry() -> bool {
1476 true
1477}
1478
1479impl Default for IntentConfig {
1480 fn default() -> Self {
1481 Self {
1482 max_retries: default_intent_max_retries(),
1483 score_threshold: default_intent_score_threshold(),
1484 max_clarify_rounds: default_intent_max_clarify_rounds(),
1485 enable_retry: default_intent_enable_retry(),
1486 lightweight_model: None,
1487 }
1488 }
1489}
1490
1491#[derive(Debug, Clone, Deserialize, Serialize)]
1493pub struct ContextConfig {
1494 #[serde(default = "default_active_limit")]
1496 pub active_limit_tokens: usize,
1497 #[serde(default = "default_cache_limit")]
1499 pub cache_limit_entries: usize,
1500}
1501
1502fn default_active_limit() -> usize {
1503 100_000
1504}
1505
1506fn default_cache_limit() -> usize {
1507 50
1508}
1509
1510impl Default for ContextConfig {
1511 fn default() -> Self {
1512 Self {
1513 active_limit_tokens: default_active_limit(),
1514 cache_limit_entries: default_cache_limit(),
1515 }
1516 }
1517}
1518
1519#[derive(Debug, Clone, Deserialize, Serialize)]
1521pub struct SecurityConfig {
1522 #[serde(default = "default_allowed_tools")]
1524 pub allowed_tools: Vec<String>,
1525 #[serde(default)]
1527 pub network_access: bool,
1528 #[serde(default = "default_max_exec_time")]
1530 pub max_execution_time_secs: u64,
1531 #[serde(default = "default_max_memory")]
1533 pub max_memory_mb: u64,
1534 #[serde(default)]
1536 pub can_fork: bool,
1537 #[serde(default = "default_max_audit")]
1539 pub max_audit_entries: usize,
1540 #[serde(default)]
1542 pub auth_enabled: bool,
1543 #[serde(default = "default_cors_origins")]
1545 pub cors_origins: Vec<String>,
1546 #[serde(default)]
1548 pub audit_log_path: Option<String>,
1549 #[serde(default = "default_rate_limit_per_minute")]
1551 pub rate_limit_per_minute: u32,
1552}
1553
1554fn default_allowed_tools() -> Vec<String> {
1555 vec![
1556 "read".to_string(),
1557 "write".to_string(),
1558 "edit".to_string(),
1559 "bash".to_string(),
1560 "grep".to_string(),
1561 "find".to_string(),
1562 "exec".to_string(),
1563 ]
1564}
1565
1566fn default_max_exec_time() -> u64 {
1567 300
1568}
1569
1570fn default_max_memory() -> u64 {
1571 512
1572}
1573
1574fn default_max_audit() -> usize {
1575 10_000
1576}
1577
1578fn default_rate_limit_per_minute() -> u32 {
1579 120
1580}
1581
1582fn default_cors_origins() -> Vec<String> {
1583 vec![
1588 "http://localhost:4200".to_string(),
1589 "http://127.0.0.1:4200".to_string(),
1590 "http://localhost:5173".to_string(),
1591 "http://127.0.0.1:5173".to_string(),
1592 ]
1593}
1594
1595impl Default for SecurityConfig {
1596 fn default() -> Self {
1597 Self {
1598 allowed_tools: default_allowed_tools(),
1599 network_access: false,
1600 max_execution_time_secs: default_max_exec_time(),
1601 max_memory_mb: default_max_memory(),
1602 can_fork: false,
1603 max_audit_entries: default_max_audit(),
1604 auth_enabled: false,
1605 cors_origins: default_cors_origins(),
1606 audit_log_path: None,
1607 rate_limit_per_minute: default_rate_limit_per_minute(),
1608 }
1609 }
1610}
1611
1612#[derive(Debug, Clone, Deserialize, Serialize)]
1614pub struct PersonaConfig {
1615 #[serde(default)]
1617 pub default_persona_id: Option<String>,
1618 #[serde(default = "default_max_concurrent_personas")]
1620 pub max_concurrent_personas: usize,
1621}
1622
1623fn default_max_concurrent_personas() -> usize {
1624 5
1625}
1626
1627impl Default for PersonaConfig {
1628 fn default() -> Self {
1629 Self {
1630 default_persona_id: Some("dev".to_string()),
1631 max_concurrent_personas: default_max_concurrent_personas(),
1632 }
1633 }
1634}
1635
1636#[derive(Debug, Clone, Deserialize, Serialize, Default)]
1644pub struct McpConfig {
1645 #[serde(default)]
1647 pub servers: std::collections::HashMap<String, McpServerDef>,
1648}
1649
1650#[derive(Debug, Clone, Deserialize, Serialize)]
1652pub struct McpServerDef {
1653 pub command: String,
1655 #[serde(default)]
1657 pub args: Vec<String>,
1658 #[serde(default)]
1660 pub env: std::collections::HashMap<String, String>,
1661 #[serde(default = "default_mcp_enabled")]
1663 pub enabled: bool,
1664}
1665
1666fn default_mcp_enabled() -> bool {
1667 true
1668}
1669
1670#[derive(Debug, Clone, Deserialize, Serialize)]
1672pub struct GitConfig {
1673 #[serde(default = "default_true")]
1675 pub auto_commit: bool,
1676}
1677
1678impl Default for GitConfig {
1679 fn default() -> Self {
1680 Self { auto_commit: true }
1681 }
1682}
1683
1684#[derive(Debug, Clone, Deserialize, Serialize)]
1686pub struct AuditConfig {
1687 #[serde(default = "default_audit_max_entries")]
1689 pub max_entries: usize,
1690 #[serde(default = "default_true")]
1692 pub enabled: bool,
1693}
1694
1695fn default_audit_max_entries() -> usize {
1696 100_000
1697}
1698
1699impl Default for AuditConfig {
1700 fn default() -> Self {
1701 Self {
1702 max_entries: default_audit_max_entries(),
1703 enabled: true,
1704 }
1705 }
1706}
1707
1708#[derive(Debug, Clone, Deserialize, Serialize)]
1710pub struct BudgetConfig {
1711 #[serde(default)]
1713 pub default_token_budget: u64,
1714 #[serde(default)]
1716 pub default_calls_budget: u64,
1717 #[serde(default = "default_budget_window")]
1719 pub default_window_secs: u64,
1720 #[serde(default = "default_true")]
1722 pub enabled: bool,
1723}
1724
1725fn default_budget_window() -> u64 {
1726 3600
1727}
1728
1729impl Default for BudgetConfig {
1730 fn default() -> Self {
1731 Self {
1732 default_token_budget: 0,
1733 default_calls_budget: 0,
1734 default_window_secs: default_budget_window(),
1735 enabled: true,
1736 }
1737 }
1738}
1739
1740#[derive(Debug, Clone, Deserialize, Serialize)]
1742pub struct ResourceMonitorConfig {
1743 #[serde(default = "default_rm_interval")]
1745 pub interval_secs: u64,
1746 #[serde(default = "default_rm_history_max")]
1748 pub history_max: usize,
1749 #[serde(default = "default_rm_cpu_threshold")]
1751 pub cpu_threshold: f32,
1752 #[serde(default = "default_rm_mem_threshold")]
1754 pub memory_threshold: f32,
1755 #[serde(default = "default_rm_load_threshold")]
1757 pub load_threshold: f32,
1758}
1759
1760fn default_rm_interval() -> u64 {
1761 60
1762}
1763
1764fn default_rm_history_max() -> usize {
1765 60
1766}
1767
1768fn default_rm_cpu_threshold() -> f32 {
1769 90.0
1770}
1771
1772fn default_rm_mem_threshold() -> f32 {
1773 90.0
1774}
1775
1776fn default_rm_load_threshold() -> f32 {
1777 8.0
1778}
1779
1780impl Default for ResourceMonitorConfig {
1781 fn default() -> Self {
1782 Self {
1783 interval_secs: default_rm_interval(),
1784 history_max: default_rm_history_max(),
1785 cpu_threshold: default_rm_cpu_threshold(),
1786 memory_threshold: default_rm_mem_threshold(),
1787 load_threshold: default_rm_load_threshold(),
1788 }
1789 }
1790}
1791
1792#[derive(Debug, Clone, Deserialize, Serialize)]
1794pub struct OtelConfig {
1795 #[serde(default)]
1797 pub enabled: bool,
1798 #[serde(default = "default_otel_endpoint")]
1800 pub endpoint: String,
1801 #[serde(default = "default_otel_service_name")]
1803 pub service_name: String,
1804 #[serde(default = "default_otel_sampling_ratio")]
1806 pub sampling_ratio: f64,
1807}
1808
1809fn default_otel_endpoint() -> String {
1810 "http://localhost:4317".into()
1811}
1812
1813fn default_otel_service_name() -> String {
1814 "oxios".into()
1815}
1816
1817fn default_otel_sampling_ratio() -> f64 {
1818 1.0
1819}
1820
1821impl Default for OtelConfig {
1822 fn default() -> Self {
1823 Self {
1824 enabled: false,
1825 endpoint: default_otel_endpoint(),
1826 service_name: default_otel_service_name(),
1827 sampling_ratio: default_otel_sampling_ratio(),
1828 }
1829 }
1830}
1831
1832#[derive(Debug, Clone, Serialize, Deserialize)]
1834pub struct AgentLogConfig {
1835 #[serde(default = "default_agent_log_max_entries")]
1837 pub max_entries: usize,
1838 #[serde(default = "default_agent_log_ttl_hours")]
1840 pub ttl_hours: u64,
1841 #[serde(default = "default_agent_log_max_tool_calls")]
1843 pub max_tool_calls_per_agent: usize,
1844 #[serde(default = "default_agent_log_prune_batch")]
1846 pub prune_batch_size: usize,
1847 #[serde(default)]
1849 pub db_path: String,
1850}
1851
1852fn default_agent_log_max_entries() -> usize {
1853 10_000
1854}
1855fn default_agent_log_ttl_hours() -> u64 {
1856 720
1857}
1858fn default_agent_log_max_tool_calls() -> usize {
1859 500
1860}
1861fn default_agent_log_prune_batch() -> usize {
1862 100
1863}
1864
1865impl Default for AgentLogConfig {
1866 fn default() -> Self {
1867 Self {
1868 max_entries: 10_000,
1869 ttl_hours: 720,
1870 max_tool_calls_per_agent: 500,
1871 prune_batch_size: 100,
1872 db_path: String::new(),
1873 }
1874 }
1875}
1876
1877#[derive(Debug, Clone, Deserialize, Serialize)]
1879pub struct LoggingConfig {
1880 #[serde(default = "default_log_format")]
1882 pub format: String,
1883 #[serde(default)]
1885 pub level: Option<String>,
1886}
1887
1888fn default_log_format() -> String {
1889 "pretty".into()
1890}
1891
1892impl Default for LoggingConfig {
1893 fn default() -> Self {
1894 Self {
1895 format: default_log_format(),
1896 level: None,
1897 }
1898 }
1899}
1900
1901#[derive(Debug, Clone, Deserialize, Serialize)]
1907pub struct BrowserConfig {
1908 #[serde(default = "default_browser_enabled")]
1910 pub enabled: bool,
1911
1912 #[serde(default)]
1923 pub engine: serde_json::Value,
1924}
1925
1926fn default_browser_enabled() -> bool {
1927 true
1928}
1929
1930impl Default for BrowserConfig {
1931 fn default() -> Self {
1932 Self {
1933 enabled: true,
1934 engine: serde_json::json!({}),
1935 }
1936 }
1937}
1938
1939pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
1941 let content = std::fs::read_to_string(path)?;
1942 let config: OxiosConfig = toml::from_str(&content)?;
1943 let (errors, warnings) = config.validate();
1944 for w in warnings {
1945 tracing::warn!("config: {}", w);
1946 }
1947 if !errors.is_empty() {
1948 let msg = errors.join("; ");
1949 anyhow::bail!("Configuration validation failed: {msg}");
1950 }
1951 Ok(config)
1952}
1953
1954impl OxiosConfig {
1955 pub fn api_key(&self) -> Option<String> {
1957 self.engine.api_key.clone().filter(|k| !k.is_empty())
1958 }
1959
1960 pub fn validate(&self) -> (Vec<String>, Vec<String>) {
1963 let mut errors = Vec::new();
1964 let mut warnings = Vec::new();
1965
1966 if self.kernel.max_agents == 0 {
1968 errors.push("kernel.max_agents must be > 0".into());
1969 }
1970 if self.kernel.workspace.is_empty() {
1971 errors.push("kernel.workspace must not be empty".into());
1972 }
1973
1974 if self.gateway.port == 0 {
1976 errors.push("gateway.port must be > 0".into());
1977 }
1978 if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
1979 warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
1980 }
1981
1982 if self.scheduler.max_concurrent == 0 {
1984 warnings.push("scheduler.max_concurrent is 0 — no tasks will run".into());
1985 }
1986 if self.scheduler.zombie_timeout_secs == 0 {
1987 errors.push("scheduler.zombie_timeout_secs must be > 0".into());
1988 }
1989
1990 for (name, job) in &self.cron.jobs {
1992 if job.schedule.is_empty() {
1993 errors.push(format!("cron.jobs.{name}: schedule is empty"));
1994 } else {
1995 let normalized = {
1997 let fields: Vec<&str> = job.schedule.split_whitespace().collect();
1998 match fields.len() {
1999 5 => format!("0 {}", job.schedule),
2000 _ => job.schedule.clone(),
2001 }
2002 };
2003 if Schedule::from_str(&normalized).is_err() {
2004 errors.push(format!(
2005 "cron.jobs.{}: invalid cron expression '{}'",
2006 name, job.schedule
2007 ));
2008 }
2009 }
2010 if job.goal.is_empty() {
2011 errors.push(format!("cron.jobs.{name}: goal is empty"));
2012 }
2013 }
2014
2015 if self.security.max_execution_time_secs == 0 {
2017 warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
2018 }
2019
2020 if self.audit.max_entries == 0 {
2022 warnings.push("audit.max_entries is 0 — audit will never prune".into());
2023 }
2024
2025 if self.budget.default_window_secs == 0 {
2027 warnings.push("budget.default_window_secs is 0 — no time window".into());
2028 }
2029
2030 if self.gateway.response_timeout_secs == 0 {
2032 errors.push("gateway.response_timeout_secs must be > 0".into());
2033 }
2034
2035 if self.engine.api_key.as_ref().is_some_and(|k| !k.is_empty()) {
2038 warnings.push(
2039 "engine.api_key is set in config — prefer the oxi auth store or env var to avoid storing a secret on disk"
2040 .into(),
2041 );
2042 }
2043
2044 for (name, server) in &self.mcp.servers {
2046 if server.command.trim().is_empty() {
2047 errors.push(format!("mcp.servers.{name}: command must not be empty"));
2048 }
2049 }
2050
2051 if self.session.max_sessions == 0 && self.session.ttl_hours == 0 && self.session.auto_prune
2053 {
2054 warnings.push("session: auto_prune is enabled but both max_sessions and ttl_hours are 0 — nothing will be pruned".into());
2055 }
2056
2057 if self.exec.default_timeout_secs == 0 {
2059 errors.push("exec.default_timeout_secs must be > 0".into());
2060 }
2061 if self.exec.max_timeout_secs == 0 {
2062 errors.push("exec.max_timeout_secs must be > 0".into());
2063 }
2064 if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
2065 errors.push(format!(
2066 "exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
2067 self.exec.default_timeout_secs, self.exec.max_timeout_secs
2068 ));
2069 }
2070
2071 if self.resource_monitor.cpu_threshold > 100.0 {
2073 errors.push("resource_monitor.cpu_threshold must be <= 100".into());
2074 }
2075 if self.resource_monitor.memory_threshold > 100.0 {
2076 errors.push("resource_monitor.memory_threshold must be <= 100".into());
2077 }
2078
2079 for name in &self.channels.enabled {
2081 let valid = ["cli", "telegram"];
2082 if !valid.contains(&name.as_str()) {
2083 warnings.push(format!("channels.enabled: unknown channel '{name}'"));
2084 }
2085 }
2086 if self.channels.enabled.iter().any(|c| c == "web") {
2088 warnings.push(
2089 "channels.enabled: 'web' should be listed under [surfaces], not [channels]".into(),
2090 );
2091 }
2092 if self.channels.enabled.iter().any(|c| c == "telegram")
2093 && std::env::var(&self.channels.telegram.bot_token_env).is_err()
2094 {
2095 warnings.push(format!(
2096 "channels.telegram: {} env var not set — telegram channel will fail",
2097 self.channels.telegram.bot_token_env
2098 ));
2099 }
2100
2101 (errors, warnings)
2102 }
2103}
2104
2105pub fn expand_home(path: &str) -> std::path::PathBuf {
2117 if let Some(rest) = path.strip_prefix("~/") {
2118 if let Ok(home) = std::env::var("HOME") {
2119 return std::path::PathBuf::from(format!("{home}/{rest}"));
2120 }
2121 if let Some(home) = dirs::home_dir() {
2122 return home.join(rest);
2123 }
2124 }
2125 std::path::PathBuf::from(path)
2126}
2127
2128#[cfg(test)]
2129mod tests {
2130 use super::*;
2131
2132 #[test]
2133 fn test_default_config_validates() {
2134 let config = OxiosConfig::default();
2135 let (errors, _warnings) = config.validate();
2136 assert!(
2137 errors.is_empty(),
2138 "Default config should have no errors: {:?}",
2139 errors
2140 );
2141 }
2142
2143 #[test]
2144 fn test_exec_config_default_allowed_commands() {
2145 let config = ExecConfig::default();
2146 assert!(config.allowed_commands.is_empty());
2148 assert_eq!(config.allowlist_mode, AllowlistMode::Enforced);
2149 assert!(!config.is_binary_allowed("anything"));
2150 assert!(!config.is_binary_allowed("bash"));
2151 }
2152
2153 #[test]
2154 fn test_exec_config_permissive_mode() {
2155 let config = ExecConfig {
2156 allowlist_mode: AllowlistMode::Permissive,
2157 ..Default::default()
2158 };
2159 assert!(config.is_binary_allowed("anything"));
2161 assert!(config.is_binary_allowed("bash"));
2162 }
2163
2164 #[test]
2165 fn test_is_binary_allowed_with_allowlist() {
2166 let config = ExecConfig {
2167 allowed_commands: vec!["git".into(), "echo".into()],
2168 ..Default::default()
2169 };
2170 assert!(config.is_binary_allowed("git"));
2171 assert!(config.is_binary_allowed("echo"));
2172 assert!(!config.is_binary_allowed("bash"));
2173 assert!(!config.is_binary_allowed("rm"));
2174 assert!(!config.is_binary_allowed("sudo"));
2175 }
2176
2177 #[test]
2178 fn test_expand_home() {
2179 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
2181 let expanded = expand_home("~/projects/test");
2182 assert_eq!(
2183 expanded.to_str().unwrap(),
2184 format!("{}/projects/test", home)
2185 );
2186
2187 let abs = expand_home("/absolute/path");
2189 assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
2190
2191 let bare = expand_home("~something");
2193 assert_eq!(bare, std::path::PathBuf::from("~something"));
2194 }
2195
2196 #[test]
2197 fn test_invalid_cron_expression() {
2198 let mut config = OxiosConfig::default();
2199 config.cron.enabled = true;
2200 config.cron.jobs.insert(
2201 "bad-job".to_string(),
2202 InlineCronJob {
2203 schedule: "not a valid cron".to_string(),
2204 goal: "Test goal".to_string(),
2205 constraints: vec![],
2206 acceptance_criteria: vec![],
2207 toolchain: "default".to_string(),
2208 priority: Priority::Normal,
2209 enabled: true,
2210 },
2211 );
2212
2213 let (errors, _warnings) = config.validate();
2214 assert!(
2215 !errors.is_empty(),
2216 "Expected validation error for invalid cron"
2217 );
2218 let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
2219 assert!(
2220 has_cron_error,
2221 "Expected 'invalid cron expression' error, got: {:?}",
2222 errors
2223 );
2224 }
2225
2226 #[test]
2227 fn test_config_serialization_roundtrip() {
2228 let config = OxiosConfig::default();
2229
2230 let toml_str = toml::to_string(&config).expect("serialization should succeed");
2232
2233 let deserialized: OxiosConfig =
2235 toml::from_str(&toml_str).expect("deserialization should succeed");
2236
2237 assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
2239 assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
2240 assert_eq!(config.gateway.host, deserialized.gateway.host);
2241 assert_eq!(config.gateway.port, deserialized.gateway.port);
2242 assert_eq!(
2243 config.exec.default_timeout_secs,
2244 deserialized.exec.default_timeout_secs
2245 );
2246 assert_eq!(
2247 config.exec.max_timeout_secs,
2248 deserialized.exec.max_timeout_secs
2249 );
2250 }
2251
2252 #[test]
2253 fn test_exec_timeout_validation() {
2254 let mut config = OxiosConfig::default();
2255 config.exec.default_timeout_secs = 999;
2257 config.exec.max_timeout_secs = 100;
2258 let (errors, _warnings) = config.validate();
2259 let has_error = errors.iter().any(|e| e.contains("must not exceed"));
2260 assert!(
2261 has_error,
2262 "Expected timeout ordering error, got: {:?}",
2263 errors
2264 );
2265 }
2266
2267 #[test]
2268 fn test_zero_max_agents_error() {
2269 let mut config = OxiosConfig::default();
2270 config.kernel.max_agents = 0;
2271 let (errors, _warnings) = config.validate();
2272 assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
2273 }
2274
2275 #[test]
2280 fn test_default_config_matches_toml() {
2281 let from_rust = OxiosConfig::default();
2282
2283 let toml_str = include_str!("../../../share/default-config.toml");
2284 let from_toml: OxiosConfig =
2285 toml::from_str(toml_str).expect("share/default-config.toml이 유효하지 않습니다");
2286
2287 assert_eq!(
2289 from_rust.kernel.max_agents, from_toml.kernel.max_agents,
2290 "kernel.max_agents 불일치: Rust={}, TOML={}",
2291 from_rust.kernel.max_agents, from_toml.kernel.max_agents
2292 );
2293 assert_eq!(
2294 from_rust.gateway.host, from_toml.gateway.host,
2295 "gateway.host 불일치: Rust={}, TOML={}",
2296 from_rust.gateway.host, from_toml.gateway.host
2297 );
2298 assert_eq!(
2299 from_rust.gateway.port, from_toml.gateway.port,
2300 "gateway.port 불일치: Rust={}, TOML={}",
2301 from_rust.gateway.port, from_toml.gateway.port
2302 );
2303 assert_eq!(
2304 from_rust.kernel.event_bus_capacity, from_toml.kernel.event_bus_capacity,
2305 "kernel.event_bus_capacity 불일치"
2306 );
2307 assert_eq!(
2308 from_rust.scheduler.max_concurrent, from_toml.scheduler.max_concurrent,
2309 "scheduler.max_concurrent 불일치"
2310 );
2311 assert_eq!(
2312 from_rust.memory.consolidation.preset, from_toml.memory.consolidation.preset,
2313 "memory.consolidation.preset 불일치"
2314 );
2315
2316 let (_, warnings) = from_toml.validate();
2318 for w in &warnings {
2319 eprintln!("default-config.toml 경고: {}", w);
2320 }
2321 }
2322
2323 #[test]
2326 fn test_gateway_should_expose_api_docs() {
2327 let cfg = GatewayConfig::default();
2329 assert!(!cfg.should_expose_api_docs());
2330
2331 let cfg = GatewayConfig {
2333 host: "0.0.0.0".into(),
2334 port: 4200,
2335 expose_api_docs: true,
2336 ..Default::default()
2337 };
2338 assert!(
2339 !cfg.should_expose_api_docs(),
2340 "public bind must not expose api docs even when opt-in is true"
2341 );
2342
2343 let cfg = GatewayConfig {
2345 host: "127.0.0.1".into(),
2346 port: 4200,
2347 expose_api_docs: true,
2348 ..Default::default()
2349 };
2350 assert!(cfg.should_expose_api_docs());
2351
2352 let cfg = GatewayConfig {
2354 host: "::1".into(),
2355 port: 4200,
2356 expose_api_docs: true,
2357 ..Default::default()
2358 };
2359 assert!(cfg.should_expose_api_docs());
2360
2361 let cfg = GatewayConfig {
2363 host: "localhost".into(),
2364 port: 4200,
2365 expose_api_docs: true,
2366 ..Default::default()
2367 };
2368 assert!(cfg.should_expose_api_docs());
2369
2370 let cfg = GatewayConfig {
2372 host: "127.0.0.1".into(),
2373 port: 4200,
2374 expose_api_docs: false,
2375 ..Default::default()
2376 };
2377 assert!(!cfg.should_expose_api_docs());
2378 }
2379}