Skip to main content

oxios_kernel/
config.rs

1//! Configuration loading from TOML files.
2//!
3//! Configuration is stored at `~/.oxios/config.toml` and controls
4//! kernel, gateway, and execution settings.
5
6use cron::Schedule;
7use serde::{Deserialize, Serialize};
8use std::str::FromStr;
9
10use crate::scheduler::Priority;
11
12/// Cron scheduler configuration.
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct CronConfig {
15    /// Enable the cron scheduler.
16    #[serde(default)]
17    pub enabled: bool,
18    /// Tick interval in seconds.
19    #[serde(default = "default_tick_interval")]
20    pub tick_interval_secs: u64,
21    /// Inline job definitions from config.toml.
22    #[serde(default)]
23    pub jobs: std::collections::HashMap<String, InlineCronJob>,
24}
25
26impl Default for CronConfig {
27    fn default() -> Self {
28        Self {
29            enabled: false,
30            tick_interval_secs: default_tick_interval(),
31            jobs: std::collections::HashMap::new(),
32        }
33    }
34}
35
36fn default_tick_interval() -> u64 {
37    60
38}
39
40/// Inline cron job definition in config.toml.
41#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct InlineCronJob {
43    /// Cron expression (e.g. "0 */6 * * *").
44    pub schedule: String,
45    /// Goal description for the agent.
46    pub goal: String,
47    /// Constraints on agent behavior.
48    #[serde(default)]
49    pub constraints: Vec<String>,
50    /// Criteria that must be met for the job to be considered successful.
51    #[serde(default)]
52    pub acceptance_criteria: Vec<String>,
53    /// Toolchain preset name.
54    #[serde(default = "default_toolchain_inline")]
55    pub toolchain: String,
56    /// Job priority.
57    #[serde(default)]
58    pub priority: Priority,
59    /// Whether the job is active.
60    #[serde(default = "default_true_inline")]
61    pub enabled: bool,
62}
63
64fn default_toolchain_inline() -> String {
65    "default".into()
66}
67
68fn default_true_inline() -> bool {
69    true
70}
71
72/// Memory system configuration.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct MemoryConfig {
75    /// Enable the memory system.
76    #[serde(default = "default_true")]
77    pub enabled: bool,
78    /// Maximum memories returned by recall.
79    #[serde(default = "default_max_recall")]
80    pub max_recall: usize,
81    /// Auto-summarize sessions on completion.
82    #[serde(default = "default_true")]
83    pub auto_summarize: bool,
84    /// Capture compaction summaries as conversation memory.
85    #[serde(default = "default_true")]
86    pub capture_compaction: bool,
87    /// Memory retention in days (0 = unlimited).
88    #[serde(default)]
89    pub retention_days: u32,
90    /// Enable embedding cache.
91    #[serde(default = "default_true")]
92    pub cache_enabled: bool,
93    /// Embedding cache TTL in seconds.
94    #[serde(default = "default_cache_ttl")]
95    pub cache_ttl_secs: u64,
96    /// Maximum embedding cache entries.
97    #[serde(default = "default_cache_max_entries")]
98    pub cache_max_entries: usize,
99}
100
101fn default_true() -> bool {
102    true
103}
104
105fn default_max_recall() -> usize {
106    10
107}
108
109fn default_cache_ttl() -> u64 {
110    3600 // 1 hour
111}
112
113fn default_cache_max_entries() -> usize {
114    10000
115}
116
117impl Default for MemoryConfig {
118    fn default() -> Self {
119        Self {
120            enabled: true,
121            max_recall: 10,
122            auto_summarize: true,
123            capture_compaction: true,
124            retention_days: 0,
125            cache_enabled: true,
126            cache_ttl_secs: 3600,
127            cache_max_entries: 10000,
128        }
129    }
130}
131
132/// Channel activation configuration.
133#[derive(Debug, Clone, Deserialize, Serialize)]
134pub struct ChannelsConfig {
135    /// List of channel names to activate on startup.
136    /// Default: ["web"]
137    #[serde(default = "default_channels_enabled")]
138    pub enabled: Vec<String>,
139
140    /// Telegram-specific configuration.
141    #[serde(default)]
142    pub telegram: TelegramChannelConfig,
143}
144
145fn default_channels_enabled() -> Vec<String> {
146    vec!["web".to_string()]
147}
148
149impl Default for ChannelsConfig {
150    fn default() -> Self {
151        Self {
152            enabled: default_channels_enabled(),
153            telegram: TelegramChannelConfig::default(),
154        }
155    }
156}
157
158/// Telegram channel configuration.
159#[derive(Debug, Clone, Deserialize, Serialize)]
160pub struct TelegramChannelConfig {
161    /// Environment variable name holding the bot token.
162    #[serde(default = "default_telegram_token_env")]
163    pub bot_token_env: String,
164    /// List of allowed Telegram user IDs (empty = allow all).
165    #[serde(default)]
166    pub allowed_users: Vec<i64>,
167}
168
169fn default_telegram_token_env() -> String {
170    "TELEGRAM_BOT_TOKEN".to_string()
171}
172
173impl Default for TelegramChannelConfig {
174    fn default() -> Self {
175        Self {
176            bot_token_env: default_telegram_token_env(),
177            allowed_users: Vec::new(),
178        }
179    }
180}
181
182/// LLM engine configuration.
183#[derive(Debug, Clone, Deserialize, Serialize)]
184#[allow(clippy::derivable_impls)]
185pub struct EngineConfig {
186    /// Default model in "provider/model" format.
187    /// Empty string means no model configured — onboarding required.
188    #[serde(default)]
189    pub default_model: String,
190    /// Explicit API key override (highest priority).
191    /// If empty/None, falls back to oxi auth store, then env vars.
192    /// Masked when serialized to API responses.
193    #[serde(default, skip_serializing)]
194    pub api_key: Option<String>,
195}
196
197#[allow(clippy::derivable_impls)]
198impl Default for EngineConfig {
199    fn default() -> Self {
200        Self {
201            default_model: String::new(),
202            api_key: None,
203        }
204    }
205}
206
207/// Daemon mode configuration.
208#[derive(Debug, Clone, Deserialize, Serialize)]
209pub struct DaemonConfig {
210    /// PID file path.
211    #[serde(default = "default_pid_file")]
212    pub pid_file: String,
213    /// Log directory.
214    #[serde(default = "default_daemon_log_dir")]
215    pub log_dir: String,
216}
217
218fn default_pid_file() -> String {
219    dirs::home_dir()
220        .map(|h| format!("{}/.oxios/oxios.pid", h.display()))
221        .unwrap_or_else(|| "./oxios.pid".into())
222}
223
224fn default_daemon_log_dir() -> String {
225    dirs::home_dir()
226        .map(|h| format!("{}/.oxios/logs", h.display()))
227        .unwrap_or_else(|| "./logs".into())
228}
229
230impl Default for DaemonConfig {
231    fn default() -> Self {
232        Self {
233            pid_file: default_pid_file(),
234            log_dir: default_daemon_log_dir(),
235        }
236    }
237}
238
239/// Top-level Oxios configuration.
240#[derive(Debug, Clone, Deserialize, Serialize, Default)]
241pub struct OxiosConfig {
242    /// Kernel settings.
243    pub kernel: KernelConfig,
244    /// LLM engine settings.
245    #[serde(default)]
246    pub engine: EngineConfig,
247    /// Daemon mode settings.
248    #[serde(default)]
249    pub daemon: DaemonConfig,
250    /// Gateway settings.
251    #[serde(default)]
252    pub gateway: GatewayConfig,
253    /// Scheduler settings (AIOS-inspired task scheduling).
254    #[serde(default)]
255    pub scheduler: SchedulerConfig,
256    /// Orchestrator settings (Ouroboros protocol execution).
257    #[serde(default)]
258    pub orchestrator: OrchestratorConfig,
259    /// Context manager settings (LLM context window management).
260    #[serde(default)]
261    pub context: ContextConfig,
262    /// Security/access control settings.
263    #[serde(default)]
264    pub security: SecurityConfig,
265    /// Persona system settings.
266    #[serde(default)]
267    pub persona: PersonaConfig,
268    /// Memory system settings.
269    #[serde(default)]
270    pub memory: MemoryConfig,
271    /// Cron scheduler settings.
272    #[serde(default)]
273    pub cron: CronConfig,
274    /// MCP server configurations.
275    #[serde(default)]
276    pub mcp: McpConfig,
277    /// Git version control settings.
278    #[serde(default)]
279    pub git: GitConfig,
280    /// Audit trail configuration.
281    #[serde(default)]
282    pub audit: AuditConfig,
283    /// Budget enforcement configuration.
284    #[serde(default)]
285    pub budget: BudgetConfig,
286    /// Exec configuration (host command execution bridge).
287    #[serde(default)]
288    pub exec: ExecConfig,
289    /// Resource monitor configuration.
290    #[serde(default)]
291    pub resource_monitor: ResourceMonitorConfig,
292    /// OpenTelemetry tracing configuration.
293    #[serde(default)]
294    pub otel: OtelConfig,
295    /// Logging configuration.
296    #[serde(default)]
297    pub logging: LoggingConfig,
298    /// Channel activation configuration.
299    #[serde(default)]
300    pub channels: ChannelsConfig,
301    /// Headless browser configuration.
302    #[serde(default)]
303    pub browser: BrowserConfig,
304}
305
306/// Kernel configuration.
307#[derive(Debug, Clone, Deserialize, Serialize)]
308pub struct KernelConfig {
309    /// Path to the workspace directory.
310    #[serde(default = "default_workspace")]
311    pub workspace: String,
312    /// Broadcast capacity for the event bus.
313    #[serde(default = "default_event_bus_capacity")]
314    pub event_bus_capacity: usize,
315    /// Maximum number of concurrent agents.
316    #[serde(default = "default_max_agents")]
317    pub max_agents: usize,
318}
319
320fn default_workspace() -> String {
321    dirs_home().unwrap_or_else(|| ".".into())
322}
323
324fn dirs_home() -> Option<String> {
325    dirs::home_dir().map(|h| format!("{}/.oxios/workspace", h.display()))
326}
327
328fn default_event_bus_capacity() -> usize {
329    256
330}
331
332fn default_max_agents() -> usize {
333    16
334}
335
336impl Default for KernelConfig {
337    fn default() -> Self {
338        Self {
339            workspace: default_workspace(),
340            event_bus_capacity: default_event_bus_capacity(),
341            max_agents: default_max_agents(),
342        }
343    }
344}
345
346/// Gateway configuration.
347#[derive(Debug, Clone, Deserialize, Serialize)]
348pub struct GatewayConfig {
349    /// Host to bind the gateway to.
350    #[serde(default = "default_gateway_host")]
351    pub host: String,
352    /// Port for the gateway server.
353    #[serde(default = "default_gateway_port")]
354    pub port: u16,
355}
356
357fn default_gateway_host() -> String {
358    "127.0.0.1".into()
359}
360
361fn default_gateway_port() -> u16 {
362    4200
363}
364
365impl Default for GatewayConfig {
366    fn default() -> Self {
367        Self {
368            host: default_gateway_host(),
369            port: default_gateway_port(),
370        }
371    }
372}
373
374/// Execution mode for commands.
375///
376/// - `Structured`: Binary allowlist + metacharacter blocking (recommended)
377/// - `Shell`: Raw bash execution (dangerous, requires `allow_shell_mode=true`)
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
379#[serde(rename_all = "lowercase")]
380pub enum ExecMode {
381    /// Structured binary execution with allowlist and metacharacter blocking.
382    #[default]
383    Structured,
384    /// Shell execution via `bash -c`. DANGEROUS — requires explicit enable.
385    Shell,
386}
387
388/// Exec configuration.
389///
390/// Governs how the kernel dispatches commands for execution.
391#[derive(Debug, Clone, Deserialize, Serialize)]
392pub struct ExecConfig {
393    /// Default execution mode.
394    #[serde(default)]
395    pub default_mode: ExecMode,
396    /// Allow shell mode. DANGEROUS — should be false in production.
397    #[serde(default = "default_false")]
398    pub allow_shell_mode: bool,
399    /// Commands allowed to run on the host.
400    /// If empty, *all* bare-name commands are permitted (development mode).
401    #[serde(default)]
402    pub allowed_commands: Vec<String>,
403    /// Default timeout for an exec call in seconds.
404    #[serde(default = "default_exec_timeout")]
405    pub default_timeout_secs: u64,
406    /// Maximum allowed timeout for an exec call in seconds.
407    #[serde(default = "default_exec_max_timeout")]
408    pub max_timeout_secs: u64,
409    /// Host tools that MUST be present (checked on startup).
410    #[serde(default)]
411    pub required_host_tools: Vec<String>,
412    /// Host tools that are optional (checked lazily when needed).
413    #[serde(default)]
414    pub optional_host_tools: Vec<String>,
415}
416
417fn default_false() -> bool {
418    false
419}
420
421fn default_exec_timeout() -> u64 {
422    120
423}
424
425fn default_exec_max_timeout() -> u64 {
426    600
427}
428
429impl ExecConfig {
430    /// Check whether a binary / command name is allowed to execute.
431    ///
432    /// Returns `true` when `allowed_commands` is empty (permissive dev mode)
433    /// **or** when the name is present in the allow-list.
434    pub fn is_binary_allowed(&self, name: &str) -> bool {
435        self.allowed_commands.is_empty() || self.allowed_commands.iter().any(|c| c == name)
436    }
437}
438
439impl Default for ExecConfig {
440    fn default() -> Self {
441        Self {
442            default_mode: ExecMode::default(),
443            allow_shell_mode: default_false(),
444            allowed_commands: Vec::new(),
445            default_timeout_secs: default_exec_timeout(),
446            max_timeout_secs: default_exec_max_timeout(),
447            required_host_tools: Vec::new(),
448            optional_host_tools: Vec::new(),
449        }
450    }
451}
452
453/// Scheduler configuration (inspired by AIOS / AgentRM).
454#[derive(Debug, Clone, Deserialize, Serialize)]
455pub struct SchedulerConfig {
456    /// Maximum number of concurrent agent tasks.
457    #[serde(default = "default_max_concurrent")]
458    pub max_concurrent: usize,
459    /// Maximum LLM API calls per minute (rate limiting).
460    #[serde(default = "default_rate_limit")]
461    pub rate_limit_per_minute: u32,
462    /// Timeout in seconds before a running task is considered a zombie.
463    #[serde(default = "default_zombie_timeout")]
464    pub zombie_timeout_secs: u64,
465}
466
467fn default_max_concurrent() -> usize {
468    5
469}
470
471fn default_rate_limit() -> u32 {
472    60
473}
474
475fn default_zombie_timeout() -> u64 {
476    300
477}
478
479impl Default for SchedulerConfig {
480    fn default() -> Self {
481        Self {
482            max_concurrent: default_max_concurrent(),
483            rate_limit_per_minute: default_rate_limit(),
484            zombie_timeout_secs: default_zombie_timeout(),
485        }
486    }
487}
488
489/// Orchestrator configuration (Ouroboros protocol execution).
490#[derive(Debug, Clone, Deserialize, Serialize)]
491pub struct OrchestratorConfig {}
492
493impl Default for OrchestratorConfig {
494    fn default() -> Self {
495        Self {}
496    }
497}
498
499/// Context manager configuration (inspired by AIOS).
500#[derive(Debug, Clone, Deserialize, Serialize)]
501pub struct ContextConfig {
502    /// Maximum tokens in the active (in-context) tier.
503    #[serde(default = "default_active_limit")]
504    pub active_limit_tokens: usize,
505    /// Maximum entries in the cache tier.
506    #[serde(default = "default_cache_limit")]
507    pub cache_limit_entries: usize,
508}
509
510fn default_active_limit() -> usize {
511    100_000
512}
513
514fn default_cache_limit() -> usize {
515    50
516}
517
518impl Default for ContextConfig {
519    fn default() -> Self {
520        Self {
521            active_limit_tokens: default_active_limit(),
522            cache_limit_entries: default_cache_limit(),
523        }
524    }
525}
526
527/// Security/access control configuration (inspired by OWASP Agentic AI).
528#[derive(Debug, Clone, Deserialize, Serialize)]
529pub struct SecurityConfig {
530    /// Default allowed tools for agents (least privilege).
531    #[serde(default = "default_allowed_tools")]
532    pub allowed_tools: Vec<String>,
533    /// Whether agents can make network requests by default.
534    #[serde(default)]
535    pub network_access: bool,
536    /// Maximum execution time in seconds for agent tasks.
537    #[serde(default = "default_max_exec_time")]
538    pub max_execution_time_secs: u64,
539    /// Maximum memory in MB for agent tasks.
540    #[serde(default = "default_max_memory")]
541    pub max_memory_mb: u64,
542    /// Whether agents can fork sub-agents by default.
543    #[serde(default)]
544    pub can_fork: bool,
545    /// Maximum audit log entries to retain.
546    #[serde(default = "default_max_audit")]
547    pub max_audit_entries: usize,
548    /// Enable API key authentication.
549    #[serde(default)]
550    pub auth_enabled: bool,
551    /// Allowed CORS origins.
552    #[serde(default = "default_cors_origins")]
553    pub cors_origins: Vec<String>,
554    /// Path for audit log file (optional, enables file-based persistence).
555    #[serde(default)]
556    pub audit_log_path: Option<String>,
557    /// Rate limit for API endpoints (requests per minute).
558    #[serde(default = "default_rate_limit_per_minute")]
559    pub rate_limit_per_minute: u32,
560}
561
562fn default_allowed_tools() -> Vec<String> {
563    vec![
564        "read".to_string(),
565        "write".to_string(),
566        "edit".to_string(),
567        "bash".to_string(),
568        "grep".to_string(),
569        "find".to_string(),
570    ]
571}
572
573fn default_max_exec_time() -> u64 {
574    300
575}
576
577fn default_max_memory() -> u64 {
578    512
579}
580
581fn default_max_audit() -> usize {
582    10_000
583}
584
585fn default_rate_limit_per_minute() -> u32 {
586    120
587}
588
589fn default_cors_origins() -> Vec<String> {
590    vec!["http://localhost:4200".to_string()]
591}
592
593impl Default for SecurityConfig {
594    fn default() -> Self {
595        Self {
596            allowed_tools: default_allowed_tools(),
597            network_access: false,
598            max_execution_time_secs: default_max_exec_time(),
599            max_memory_mb: default_max_memory(),
600            can_fork: false,
601            max_audit_entries: default_max_audit(),
602            auth_enabled: false,
603            cors_origins: default_cors_origins(),
604            audit_log_path: None,
605            rate_limit_per_minute: default_rate_limit_per_minute(),
606        }
607    }
608}
609
610/// Persona system configuration.
611#[derive(Debug, Clone, Deserialize, Serialize)]
612pub struct PersonaConfig {
613    /// Default persona ID to activate on startup.
614    #[serde(default)]
615    pub default_persona_id: Option<String>,
616    /// Maximum concurrent personas.
617    #[serde(default = "default_max_concurrent_personas")]
618    pub max_concurrent_personas: usize,
619}
620
621fn default_max_concurrent_personas() -> usize {
622    5
623}
624
625impl Default for PersonaConfig {
626    fn default() -> Self {
627        Self {
628            default_persona_id: Some("dev".to_string()),
629            max_concurrent_personas: default_max_concurrent_personas(),
630        }
631    }
632}
633
634/// MCP server configuration loaded from config.toml.
635///
636/// Each key is a server name; the value is a table with:
637/// - `command`: executable to run (e.g. "npx", "python")
638/// - `args`: arguments array
639/// - `env`: optional map of environment variables
640/// - `enabled`: whether to start this server on boot (default: true)
641#[derive(Debug, Clone, Deserialize, Serialize, Default)]
642pub struct McpConfig {
643    /// Map of server-name → server definition.
644    #[serde(default)]
645    pub servers: std::collections::HashMap<String, McpServerDef>,
646}
647
648/// A single MCP server definition in config.toml.
649#[derive(Debug, Clone, Deserialize, Serialize)]
650pub struct McpServerDef {
651    /// Command to execute.
652    pub command: String,
653    /// Arguments passed to the command.
654    #[serde(default)]
655    pub args: Vec<String>,
656    /// Environment variables.
657    #[serde(default)]
658    pub env: std::collections::HashMap<String, String>,
659    /// Whether this server is enabled (default: true).
660    #[serde(default = "default_mcp_enabled")]
661    pub enabled: bool,
662}
663
664fn default_mcp_enabled() -> bool {
665    true
666}
667
668/// Git version control configuration.
669#[derive(Debug, Clone, Deserialize, Serialize)]
670pub struct GitConfig {
671    /// Enable automatic commits for state changes.
672    #[serde(default = "default_true")]
673    pub auto_commit: bool,
674}
675
676impl Default for GitConfig {
677    fn default() -> Self {
678        Self { auto_commit: true }
679    }
680}
681
682/// Audit trail configuration.
683#[derive(Debug, Clone, Deserialize, Serialize)]
684pub struct AuditConfig {
685    /// Maximum audit entries before pruning.
686    #[serde(default = "default_audit_max_entries")]
687    pub max_entries: usize,
688    /// Enable audit trail.
689    #[serde(default = "default_true")]
690    pub enabled: bool,
691}
692
693fn default_audit_max_entries() -> usize {
694    100_000
695}
696
697impl Default for AuditConfig {
698    fn default() -> Self {
699        Self {
700            max_entries: default_audit_max_entries(),
701            enabled: true,
702        }
703    }
704}
705
706/// Budget enforcement configuration.
707#[derive(Debug, Clone, Deserialize, Serialize)]
708pub struct BudgetConfig {
709    /// Default token budget per agent (0 = unlimited).
710    #[serde(default)]
711    pub default_token_budget: u64,
712    /// Default call budget per agent (0 = unlimited).
713    #[serde(default)]
714    pub default_calls_budget: u64,
715    /// Default budget window in seconds.
716    #[serde(default = "default_budget_window")]
717    pub default_window_secs: u64,
718    /// Enable budget enforcement.
719    #[serde(default = "default_true")]
720    pub enabled: bool,
721}
722
723fn default_budget_window() -> u64 {
724    3600
725}
726
727impl Default for BudgetConfig {
728    fn default() -> Self {
729        Self {
730            default_token_budget: 0,
731            default_calls_budget: 0,
732            default_window_secs: default_budget_window(),
733            enabled: true,
734        }
735    }
736}
737
738/// Resource monitor configuration.
739#[derive(Debug, Clone, Deserialize, Serialize)]
740pub struct ResourceMonitorConfig {
741    /// Snapshot interval in seconds.
742    #[serde(default = "default_rm_interval")]
743    pub interval_secs: u64,
744    /// Maximum history entries.
745    #[serde(default = "default_rm_history_max")]
746    pub history_max: usize,
747    /// CPU threshold for overload.
748    #[serde(default = "default_rm_cpu_threshold")]
749    pub cpu_threshold: f32,
750    /// Memory threshold for overload (percentage).
751    #[serde(default = "default_rm_mem_threshold")]
752    pub memory_threshold: f32,
753    /// Load average threshold for overload.
754    #[serde(default = "default_rm_load_threshold")]
755    pub load_threshold: f32,
756}
757
758fn default_rm_interval() -> u64 {
759    60
760}
761
762fn default_rm_history_max() -> usize {
763    60
764}
765
766fn default_rm_cpu_threshold() -> f32 {
767    90.0
768}
769
770fn default_rm_mem_threshold() -> f32 {
771    90.0
772}
773
774fn default_rm_load_threshold() -> f32 {
775    8.0
776}
777
778impl Default for ResourceMonitorConfig {
779    fn default() -> Self {
780        Self {
781            interval_secs: default_rm_interval(),
782            history_max: default_rm_history_max(),
783            cpu_threshold: default_rm_cpu_threshold(),
784            memory_threshold: default_rm_mem_threshold(),
785            load_threshold: default_rm_load_threshold(),
786        }
787    }
788}
789
790/// OpenTelemetry tracing configuration.
791#[derive(Debug, Clone, Deserialize, Serialize)]
792pub struct OtelConfig {
793    /// Enable OTLP export (default: false).
794    #[serde(default)]
795    pub enabled: bool,
796    /// OTLP gRPC endpoint.
797    #[serde(default = "default_otel_endpoint")]
798    pub endpoint: String,
799    /// Service name for traces.
800    #[serde(default = "default_otel_service_name")]
801    pub service_name: String,
802    /// Sampling ratio (0.0 to 1.0).
803    #[serde(default = "default_otel_sampling_ratio")]
804    pub sampling_ratio: f64,
805}
806
807fn default_otel_endpoint() -> String {
808    "http://localhost:4317".into()
809}
810
811fn default_otel_service_name() -> String {
812    "oxios".into()
813}
814
815fn default_otel_sampling_ratio() -> f64 {
816    1.0
817}
818
819impl Default for OtelConfig {
820    fn default() -> Self {
821        Self {
822            enabled: false,
823            endpoint: default_otel_endpoint(),
824            service_name: default_otel_service_name(),
825            sampling_ratio: default_otel_sampling_ratio(),
826        }
827    }
828}
829
830/// Logging configuration.
831#[derive(Debug, Clone, Deserialize, Serialize)]
832pub struct LoggingConfig {
833    /// Log format: "pretty", "json", or "compact".
834    #[serde(default = "default_log_format")]
835    pub format: String,
836    /// Log level override (e.g. "info", "debug"). Falls back to RUST_LOG env var.
837    #[serde(default)]
838    pub level: Option<String>,
839}
840
841fn default_log_format() -> String {
842    "pretty".into()
843}
844
845impl Default for LoggingConfig {
846    fn default() -> Self {
847        Self {
848            format: default_log_format(),
849            level: None,
850        }
851    }
852}
853
854/// Headless browser configuration.
855///
856/// Wraps `oxibrowser_core::BrowserConfig` (Deserialize/Serialize supported)
857/// with an `enabled` toggle. The engine config is passed through directly
858/// to the browser — no field-by-field duplication.
859#[derive(Debug, Clone, Deserialize, Serialize)]
860pub struct BrowserConfig {
861    /// Enable the browser integration.
862    #[serde(default = "default_browser_enabled")]
863    pub enabled: bool,
864
865    /// Engine configuration — passed directly to `oxibrowser_core::Browser::new()`.
866    ///
867    /// All fields have sensible defaults; override only what you need:
868    ///
869    /// ```toml
870    /// [browser.engine]
871    /// user_agent = "MyBot/1.0"
872    /// obey_robots = false
873    /// js_timeout_ms = 10000
874    /// ```
875    #[serde(default)]
876    pub engine: oxibrowser_core::BrowserConfig,
877}
878
879fn default_browser_enabled() -> bool {
880    true
881}
882
883impl Default for BrowserConfig {
884    fn default() -> Self {
885        Self {
886            enabled: true,
887            engine: oxibrowser_core::BrowserConfig::headless(),
888        }
889    }
890}
891
892/// Loads configuration from a TOML file.
893pub fn load_config(path: &std::path::Path) -> anyhow::Result<OxiosConfig> {
894    let content = std::fs::read_to_string(path)?;
895    let config: OxiosConfig = toml::from_str(&content)?;
896    let (errors, warnings) = config.validate();
897    for w in warnings {
898        tracing::warn!("config: {}", w);
899    }
900    if !errors.is_empty() {
901        let msg = errors.join("; ");
902        anyhow::bail!("Configuration validation failed: {}", msg);
903    }
904    Ok(config)
905}
906
907impl OxiosConfig {
908    /// Returns the effective API key from the engine config.
909    pub fn api_key(&self) -> Option<String> {
910        self.engine.api_key.clone().filter(|k| !k.is_empty())
911    }
912
913    /// Validate configuration values and return a list of warnings.
914    /// Returns (errors, warnings). Empty errors = valid config.
915    pub fn validate(&self) -> (Vec<String>, Vec<String>) {
916        let mut errors = Vec::new();
917        let mut warnings = Vec::new();
918
919        // Kernel validation
920        if self.kernel.max_agents == 0 {
921            errors.push("kernel.max_agents must be > 0".into());
922        }
923        if self.kernel.workspace.is_empty() {
924            errors.push("kernel.workspace must not be empty".into());
925        }
926
927        // Gateway validation
928        if self.gateway.port == 0 {
929            errors.push("gateway.port must be > 0".into());
930        }
931        if self.gateway.port < 1024 && self.gateway.host == "0.0.0.0" {
932            warnings.push("Running on port <1024 as 0.0.0.0 may require root".into());
933        }
934
935        // Scheduler validation
936        if self.scheduler.max_concurrent == 0 {
937            warnings.push("scheduler.max_concurrent is 0 — no tasks will run".into());
938        }
939        if self.scheduler.zombie_timeout_secs == 0 {
940            errors.push("scheduler.zombie_timeout_secs must be > 0".into());
941        }
942
943        // Cron validation
944        for (name, job) in &self.cron.jobs {
945            if job.schedule.is_empty() {
946                errors.push(format!("cron.jobs.{}: schedule is empty", name));
947            } else {
948                // Normalize 5-field to 6-field (prepend "0 " for seconds)
949                let normalized = {
950                    let fields: Vec<&str> = job.schedule.split_whitespace().collect();
951                    match fields.len() {
952                        5 => format!("0 {}", job.schedule),
953                        _ => job.schedule.clone(),
954                    }
955                };
956                if Schedule::from_str(&normalized).is_err() {
957                    errors.push(format!(
958                        "cron.jobs.{}: invalid cron expression '{}'",
959                        name, job.schedule
960                    ));
961                }
962            }
963            if job.goal.is_empty() {
964                errors.push(format!("cron.jobs.{}: goal is empty", name));
965            }
966        }
967
968        // Security validation
969        if self.security.max_execution_time_secs == 0 {
970            warnings.push("security.max_execution_time_secs is 0 — no timeout".into());
971        }
972
973        // Audit validation
974        if self.audit.max_entries == 0 {
975            warnings.push("audit.max_entries is 0 — audit will never prune".into());
976        }
977
978        // Budget validation
979        if self.budget.default_window_secs == 0 {
980            warnings.push("budget.default_window_secs is 0 — no time window".into());
981        }
982
983        // Exec validation
984        if self.exec.default_timeout_secs == 0 {
985            errors.push("exec.default_timeout_secs must be > 0".into());
986        }
987        if self.exec.max_timeout_secs == 0 {
988            errors.push("exec.max_timeout_secs must be > 0".into());
989        }
990        if self.exec.default_timeout_secs > self.exec.max_timeout_secs {
991            errors.push(format!(
992                "exec.default_timeout_secs ({}) must not exceed max_timeout_secs ({})",
993                self.exec.default_timeout_secs, self.exec.max_timeout_secs
994            ));
995        }
996
997        // Resource monitor validation
998        if self.resource_monitor.cpu_threshold > 100.0 {
999            errors.push("resource_monitor.cpu_threshold must be <= 100".into());
1000        }
1001        if self.resource_monitor.memory_threshold > 100.0 {
1002            errors.push("resource_monitor.memory_threshold must be <= 100".into());
1003        }
1004
1005        // Channels validation
1006        for name in &self.channels.enabled {
1007            let valid = ["web", "cli", "telegram"];
1008            if !valid.contains(&name.as_str()) {
1009                warnings.push(format!("channels.enabled: unknown channel '{}'", name));
1010            }
1011        }
1012        if self.channels.enabled.iter().any(|c| c == "telegram")
1013            && std::env::var(&self.channels.telegram.bot_token_env).is_err()
1014        {
1015            warnings.push(format!(
1016                "channels.telegram: {} env var not set — telegram channel will fail",
1017                self.channels.telegram.bot_token_env
1018            ));
1019        }
1020
1021        (errors, warnings)
1022    }
1023}
1024
1025/// Expand `~/` in paths to the user's home directory.
1026///
1027/// Shared utility for path expansion across the binary and kernel.
1028pub fn expand_home(path: &str) -> std::path::PathBuf {
1029    if let Some(rest) = path.strip_prefix("~/") {
1030        if let Ok(home) = std::env::var("HOME") {
1031            return std::path::PathBuf::from(format!("{home}/{rest}"));
1032        }
1033    }
1034    std::path::PathBuf::from(path)
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040
1041    #[test]
1042    fn test_default_config_validates() {
1043        let config = OxiosConfig::default();
1044        let (errors, _warnings) = config.validate();
1045        assert!(
1046            errors.is_empty(),
1047            "Default config should have no errors: {:?}",
1048            errors
1049        );
1050    }
1051
1052    #[test]
1053    fn test_exec_config_default_allowed_commands() {
1054        let config = ExecConfig::default();
1055        // Empty allowed_commands means all commands are permitted.
1056        assert!(config.allowed_commands.is_empty());
1057        assert!(config.is_binary_allowed("anything"));
1058        assert!(config.is_binary_allowed("bash"));
1059        assert!(config.is_binary_allowed("rm"));
1060    }
1061
1062    #[test]
1063    fn test_is_binary_allowed_with_allowlist() {
1064        let config = ExecConfig {
1065            allowed_commands: vec!["git".into(), "echo".into()],
1066            ..Default::default()
1067        };
1068        assert!(config.is_binary_allowed("git"));
1069        assert!(config.is_binary_allowed("echo"));
1070        assert!(!config.is_binary_allowed("bash"));
1071        assert!(!config.is_binary_allowed("rm"));
1072        assert!(!config.is_binary_allowed("sudo"));
1073    }
1074
1075    #[test]
1076    fn test_expand_home() {
1077        // With HOME set.
1078        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp/testhome".into());
1079        let expanded = expand_home("~/projects/test");
1080        assert_eq!(
1081            expanded.to_str().unwrap(),
1082            format!("{}/projects/test", home)
1083        );
1084
1085        // Non-tilde path should pass through unchanged.
1086        let abs = expand_home("/absolute/path");
1087        assert_eq!(abs, std::path::PathBuf::from("/absolute/path"));
1088
1089        // Just ~ without slash should not expand.
1090        let bare = expand_home("~something");
1091        assert_eq!(bare, std::path::PathBuf::from("~something"));
1092    }
1093
1094    #[test]
1095    fn test_invalid_cron_expression() {
1096        let mut config = OxiosConfig::default();
1097        config.cron.enabled = true;
1098        config.cron.jobs.insert(
1099            "bad-job".to_string(),
1100            InlineCronJob {
1101                schedule: "not a valid cron".to_string(),
1102                goal: "Test goal".to_string(),
1103                constraints: vec![],
1104                acceptance_criteria: vec![],
1105                toolchain: "default".to_string(),
1106                priority: Priority::Normal,
1107                enabled: true,
1108            },
1109        );
1110
1111        let (errors, _warnings) = config.validate();
1112        assert!(
1113            !errors.is_empty(),
1114            "Expected validation error for invalid cron"
1115        );
1116        let has_cron_error = errors.iter().any(|e| e.contains("invalid cron expression"));
1117        assert!(
1118            has_cron_error,
1119            "Expected 'invalid cron expression' error, got: {:?}",
1120            errors
1121        );
1122    }
1123
1124    #[test]
1125    fn test_config_serialization_roundtrip() {
1126        let config = OxiosConfig::default();
1127
1128        // Serialize to TOML string.
1129        let toml_str = toml::to_string(&config).expect("serialization should succeed");
1130
1131        // Deserialize back.
1132        let deserialized: OxiosConfig =
1133            toml::from_str(&toml_str).expect("deserialization should succeed");
1134
1135        // Key fields should match.
1136        assert_eq!(config.kernel.max_agents, deserialized.kernel.max_agents);
1137        assert_eq!(config.kernel.workspace, deserialized.kernel.workspace);
1138        assert_eq!(config.gateway.host, deserialized.gateway.host);
1139        assert_eq!(config.gateway.port, deserialized.gateway.port);
1140        assert_eq!(
1141            config.exec.default_timeout_secs,
1142            deserialized.exec.default_timeout_secs
1143        );
1144        assert_eq!(
1145            config.exec.max_timeout_secs,
1146            deserialized.exec.max_timeout_secs
1147        );
1148    }
1149
1150    #[test]
1151    fn test_exec_timeout_validation() {
1152        let mut config = OxiosConfig::default();
1153        // default_timeout > max_timeout should be an error.
1154        config.exec.default_timeout_secs = 999;
1155        config.exec.max_timeout_secs = 100;
1156        let (errors, _warnings) = config.validate();
1157        let has_error = errors.iter().any(|e| e.contains("must not exceed"));
1158        assert!(
1159            has_error,
1160            "Expected timeout ordering error, got: {:?}",
1161            errors
1162        );
1163    }
1164
1165    #[test]
1166    fn test_zero_max_agents_error() {
1167        let mut config = OxiosConfig::default();
1168        config.kernel.max_agents = 0;
1169        let (errors, _warnings) = config.validate();
1170        assert!(errors.iter().any(|e| e.contains("max_agents must be > 0")));
1171    }
1172}