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