Skip to main content

oxios_kernel/
config.rs

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