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