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