Skip to main content

apollo/config/
mod.rs

1//! Configuration management.
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(default)]
8pub struct Config {
9    pub provider: ProviderConfig,
10    pub embeddings: EmbeddingsConfig,
11    pub agent: AgentConfig,
12    pub model: String,
13    pub system_prompt: String,
14    pub workspace: PathBuf,
15    pub storage: StorageConfig,
16    pub runtime: RuntimeConfig,
17    pub hosting: HostingConfig,
18    pub observability: ObservabilityConfig,
19    pub channel: ChannelConfig,
20    pub gateway: GatewayConfig,
21    pub policy: PolicyConfig,
22    pub plugin_layer: PluginLayerConfig,
23    pub group_chat: GroupChatConfig,
24    pub toolsets: ToolsetConfig,
25    pub memory: MemoryIdeasConfig,
26    #[serde(default)]
27    pub zkr: ZkrConfig,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct ProviderConfig {
33    pub name: String,
34    pub api_key: Option<String>,
35    pub base_url: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default)]
40pub struct EmbeddingsConfig {
41    pub enabled: bool,
42    pub provider: String,
43    pub api_key: Option<String>,
44    pub model: Option<String>,
45    pub base_url: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(default)]
50pub struct AgentConfig {
51    /// Hard circuit breaker (absolute max execution rounds)
52    pub max_rounds: usize,
53    /// Max conversation history (prevents context overflow)
54    pub max_history_messages: usize,
55    /// Max chars for a single tool result
56    pub max_tool_result_chars: usize,
57    /// Max context chars before triggering mid-loop compaction
58    pub max_context_chars: usize,
59    /// Fast/cheap model for planning + summarization
60    pub fast_model: String,
61    /// Heavy model for complex coding/reasoning
62    pub heavy_model: String,
63    /// Per-tool allow/deny rules
64    pub permissions: PermissionRulesConfig,
65    /// Initial safety profile: `full`, `auto`, `prompt`, or `tools_only` (drives default `AgentMode`).
66    pub permission_profile: String,
67}
68
69impl Default for AgentConfig {
70    fn default() -> Self {
71        Self {
72            max_rounds: 50,
73            max_history_messages: 10,
74            max_tool_result_chars: 20_000,
75            max_context_chars: 150_000,
76            fast_model: "claude-haiku-4-5-20251001".to_string(),
77            heavy_model: "claude-sonnet-4-6".to_string(),
78            permissions: PermissionRulesConfig::default(),
79            permission_profile: "auto".to_string(),
80        }
81    }
82}
83
84/// Per-tool permission rules.
85///
86/// `deny` blocks matching tools outright (checked first).
87/// `allow` restricts to only those tools when non-empty (allowlist mode).
88/// Supports exact tool names and glob-style `*` wildcards.
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
90#[serde(default)]
91pub struct PermissionRulesConfig {
92    /// Tools that are always blocked (e.g. `["exec", "shell"]`).
93    pub deny: Vec<String>,
94    /// If non-empty, only these tools are allowed (allowlist mode).
95    pub allow: Vec<String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(default)]
100pub struct RuntimeConfig {
101    pub kind: String, // "native", "docker"
102    pub docker_image: Option<String>,
103    pub memory_limit_mb: Option<u64>,
104    pub state_path: Option<PathBuf>,
105    pub self_update: SelfUpdateConfig,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(default)]
110pub struct SelfUpdateConfig {
111    pub enabled: bool,
112    pub interval_secs: u64,
113    pub remote: String,
114    pub branch: String,
115    pub restart_service: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(default)]
120pub struct StorageConfig {
121    pub backend: String, // "surreal"
122    pub root: PathBuf,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(default)]
127pub struct HostingConfig {
128    pub enabled: bool,
129    pub tenant_root: PathBuf,
130    pub session_timeout_minutes: u64,
131    pub default_channel: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(default)]
136pub struct ObservabilityConfig {
137    pub service_name: String,
138    pub environment: String,
139    pub json_logs: bool,
140    pub trace_header_name: String,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144#[serde(default)]
145pub struct ChannelConfig {
146    pub kind: String, // "cli", "telegram", "discord", "websocket"
147    pub token: Option<String>,
148    /// When non-empty, only these Telegram (or channel) chat IDs may send inbound messages.
149    pub allowed_chat_ids: Vec<String>,
150    /// When non-empty, only these sender user IDs may send inbound messages.
151    pub allowed_sender_ids: Vec<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(default)]
156pub struct GatewayConfig {
157    pub bind: String,
158    pub auth_token: Option<String>,
159    pub enable_admin_api: bool,
160    pub request_body_limit_kb: usize,
161    pub request_timeout_secs: u64,
162    pub rate_limit_per_minute: usize,
163    pub trusted_proxies: Vec<String>,
164    pub allowed_origins: Vec<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(default)]
169pub struct PolicyConfig {
170    pub allow_shell: bool,
171    pub allow_dynamic_tools: bool,
172    pub allow_plugin_shell: bool,
173    pub allow_plugin_git: bool,
174    pub allow_computer_use: bool,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(default)]
179pub struct PluginLayerConfig {
180    pub enabled: bool,
181    #[serde(default = "default_manifest_path")]
182    pub manifest_path: PathBuf,
183    /// Extra directories to scan for OpenClaw SKILL.md and Hermes plugin.json
184    #[serde(default)]
185    pub host_plugin_roots: Vec<PathBuf>,
186    pub hook_events: Vec<String>,
187    pub allow_core_fallback: bool,
188    pub layered_overrides: Vec<String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192#[serde(default)]
193pub struct GroupChatConfig {
194    pub enable_ambient_questions: bool,
195    pub rolling_memory_namespace: String,
196    pub rolling_memory_max_chars: usize,
197    pub rolling_memory_recent_turns: usize,
198    pub ambient_question_window: usize,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, Default)]
202#[serde(default)]
203pub struct ToolsetConfig {
204    pub enabled: Vec<String>,
205    pub disabled: Vec<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(default)]
210pub struct MemoryIdeasConfig {
211    /// One logical user across channels (merged history when set).
212    pub principal_id: Option<String>,
213    /// Inject brief + recall gate + idea graph context each turn.
214    pub inject_context: bool,
215    pub graph_recall_limit: usize,
216    /// Route heartbeat synthetic messages to this chat_id (e.g. telegram chat id).
217    pub heartbeat_chat_id: Option<String>,
218    /// After idle, expand open loops into dream nodes (graph).
219    pub dream_on_heartbeat: bool,
220}
221
222impl Default for MemoryIdeasConfig {
223    fn default() -> Self {
224        Self {
225            principal_id: None,
226            inject_context: true,
227            graph_recall_limit: 5,
228            heartbeat_chat_id: None,
229            dream_on_heartbeat: false,
230        }
231    }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235#[serde(default)]
236pub struct ZkrConfig {
237    pub enabled: bool,
238    pub database: PathBuf,
239    pub tenant_id: String,
240    pub person_id: String,
241    pub auto_capture: bool,
242    pub inject_recall: bool,
243    pub recall_limit: u32,
244    pub self_improve: bool,
245}
246
247impl Default for ZkrConfig {
248    fn default() -> Self {
249        Self {
250            enabled: true,
251            database: PathBuf::from(".apollo/zkr.db"),
252            tenant_id: "apollo".to_string(),
253            person_id: "local".to_string(),
254            auto_capture: true,
255            inject_recall: true,
256            recall_limit: 5,
257            self_improve: true,
258        }
259    }
260}
261
262/// Apply a named onboarding permission profile to `cfg` (policy, toolsets, and `permission_profile`).
263pub fn apply_permission_profile(cfg: &mut Config, profile: &str) {
264    let p = profile.trim().to_ascii_lowercase().replace(['-', ' '], "_");
265    match p.as_str() {
266        "full" => {
267            cfg.agent.permission_profile = "full".to_string();
268            cfg.policy.allow_shell = true;
269            cfg.policy.allow_dynamic_tools = true;
270            cfg.policy.allow_computer_use = true;
271            cfg.toolsets = ToolsetConfig::default();
272            cfg.agent.permissions = PermissionRulesConfig::default();
273        }
274        "auto" => {
275            cfg.agent.permission_profile = "auto".to_string();
276            cfg.policy = PolicyConfig::default();
277            cfg.toolsets = ToolsetConfig::default();
278            cfg.agent.permissions = PermissionRulesConfig::default();
279        }
280        "prompt" => {
281            cfg.agent.permission_profile = "prompt".to_string();
282            cfg.policy = PolicyConfig::default();
283            cfg.toolsets = ToolsetConfig::default();
284            cfg.agent.permissions = PermissionRulesConfig::default();
285        }
286        "tools_only" | "tools" => {
287            cfg.agent.permission_profile = "tools_only".to_string();
288            cfg.policy.allow_shell = false;
289            cfg.policy.allow_dynamic_tools = false;
290            cfg.policy.allow_computer_use = false;
291            cfg.toolsets.enabled = vec![
292                "web".to_string(),
293                "memory".to_string(),
294                "sessions".to_string(),
295            ];
296            cfg.toolsets.disabled = vec![
297                "browser".to_string(),
298                "vibemania".to_string(),
299                "create_tool".to_string(),
300                "mcp".to_string(),
301            ];
302            cfg.agent.permissions = PermissionRulesConfig::default();
303        }
304        _ => {
305            cfg.agent.permission_profile = "auto".to_string();
306            cfg.policy = PolicyConfig::default();
307            cfg.toolsets = ToolsetConfig::default();
308            cfg.agent.permissions = PermissionRulesConfig::default();
309        }
310    }
311}
312
313impl Config {
314    pub fn load(path: &str) -> anyhow::Result<Self> {
315        let content = std::fs::read_to_string(path)?;
316        let config: Config = serde_json::from_str(&content)?;
317        Ok(config)
318    }
319
320    pub fn default_config() -> Self {
321        Self {
322            provider: ProviderConfig::default(),
323            embeddings: EmbeddingsConfig::default(),
324            agent: AgentConfig::default(),
325            model: "claude-sonnet-4-6".to_string(),
326            system_prompt: "You are a helpful AI assistant.".to_string(),
327            workspace: PathBuf::from("."),
328            storage: StorageConfig::default(),
329            runtime: RuntimeConfig::default(),
330            hosting: HostingConfig::default(),
331            observability: ObservabilityConfig::default(),
332            channel: ChannelConfig::default(),
333            gateway: GatewayConfig::default(),
334            policy: PolicyConfig::default(),
335            plugin_layer: PluginLayerConfig::default(),
336            group_chat: GroupChatConfig::default(),
337            toolsets: ToolsetConfig::default(),
338            memory: MemoryIdeasConfig::default(),
339            zkr: ZkrConfig::default(),
340        }
341    }
342}
343
344impl Default for Config {
345    fn default() -> Self {
346        Self::default_config()
347    }
348}
349
350impl Default for ProviderConfig {
351    fn default() -> Self {
352        Self {
353            name: "anthropic".to_string(),
354            api_key: None,
355            base_url: None,
356        }
357    }
358}
359
360impl Default for EmbeddingsConfig {
361    fn default() -> Self {
362        Self {
363            enabled: false,
364            provider: "noop".to_string(),
365            api_key: None,
366            model: None,
367            base_url: None,
368        }
369    }
370}
371
372impl Default for RuntimeConfig {
373    fn default() -> Self {
374        Self {
375            kind: "native".to_string(),
376            docker_image: None,
377            memory_limit_mb: None,
378            state_path: None,
379            self_update: SelfUpdateConfig::default(),
380        }
381    }
382}
383
384impl Default for SelfUpdateConfig {
385    fn default() -> Self {
386        Self {
387            enabled: false,
388            interval_secs: 900,
389            remote: "origin".to_string(),
390            branch: "main".to_string(),
391            restart_service: Some("apollo".to_string()),
392        }
393    }
394}
395
396impl Default for StorageConfig {
397    fn default() -> Self {
398        Self {
399            backend: "surreal".to_string(),
400            root: PathBuf::from(".apollo"),
401        }
402    }
403}
404
405impl Default for HostingConfig {
406    fn default() -> Self {
407        Self {
408            enabled: true,
409            tenant_root: PathBuf::from(".apollo/tenants"),
410            session_timeout_minutes: 120,
411            default_channel: "gateway".to_string(),
412        }
413    }
414}
415
416impl Default for ObservabilityConfig {
417    fn default() -> Self {
418        Self {
419            service_name: "apollo".to_string(),
420            environment: "development".to_string(),
421            json_logs: false,
422            trace_header_name: "traceparent".to_string(),
423        }
424    }
425}
426
427impl Default for ChannelConfig {
428    fn default() -> Self {
429        Self {
430            kind: "cli".to_string(),
431            token: None,
432            allowed_chat_ids: Vec::new(),
433            allowed_sender_ids: Vec::new(),
434        }
435    }
436}
437
438impl Default for GatewayConfig {
439    fn default() -> Self {
440        Self {
441            bind: "127.0.0.1:8080".to_string(),
442            auth_token: None,
443            enable_admin_api: false,
444            request_body_limit_kb: 512,
445            request_timeout_secs: 60,
446            rate_limit_per_minute: 120,
447            trusted_proxies: Vec::new(),
448            allowed_origins: Vec::new(),
449        }
450    }
451}
452
453impl Default for PolicyConfig {
454    fn default() -> Self {
455        Self {
456            allow_shell: true,
457            allow_dynamic_tools: true,
458            allow_plugin_shell: true,
459            allow_plugin_git: true,
460            allow_computer_use: true,
461        }
462    }
463}
464
465fn default_manifest_path() -> PathBuf {
466    PathBuf::from("plugins/manifest.json")
467}
468
469impl Default for PluginLayerConfig {
470    fn default() -> Self {
471        Self {
472            enabled: true,
473            manifest_path: default_manifest_path(),
474            host_plugin_roots: Vec::new(),
475            hook_events: vec![
476                "before_message".to_string(),
477                "after_message".to_string(),
478                "before_tool".to_string(),
479                "after_tool".to_string(),
480            ],
481            allow_core_fallback: true,
482            layered_overrides: vec!["system_prompt".to_string(), "toolsets".to_string()],
483        }
484    }
485}
486
487impl Default for GroupChatConfig {
488    fn default() -> Self {
489        Self {
490            enable_ambient_questions: true,
491            rolling_memory_namespace: "group_memory".to_string(),
492            rolling_memory_max_chars: 6_000,
493            rolling_memory_recent_turns: 16,
494            ambient_question_window: 24,
495        }
496    }
497}
498
499#[cfg(test)]
500mod permission_profile_tests {
501    use super::*;
502
503    #[test]
504    fn full_enables_shell_and_resets_toolsets() {
505        let mut cfg = Config::default();
506        cfg.policy.allow_shell = false;
507        cfg.toolsets.enabled = vec!["browser".into()];
508        apply_permission_profile(&mut cfg, "full");
509        assert_eq!(cfg.agent.permission_profile, "full");
510        assert!(cfg.policy.allow_shell);
511        assert!(cfg.policy.allow_dynamic_tools);
512        assert!(cfg.toolsets.enabled.is_empty());
513    }
514
515    #[test]
516    fn tools_only_disables_shell_and_limits_toolsets() {
517        let mut cfg = Config::default();
518        apply_permission_profile(&mut cfg, "tools-only");
519        assert_eq!(cfg.agent.permission_profile, "tools_only");
520        assert!(!cfg.policy.allow_shell);
521        assert!(!cfg.policy.allow_dynamic_tools);
522        assert_eq!(
523            cfg.toolsets.enabled,
524            vec![
525                "web".to_string(),
526                "memory".to_string(),
527                "sessions".to_string()
528            ]
529        );
530        assert!(cfg.toolsets.disabled.contains(&"browser".to_string()));
531    }
532
533    #[test]
534    fn unknown_profile_falls_back_to_auto_defaults() {
535        let mut cfg = Config::default();
536        cfg.policy.allow_shell = false;
537        apply_permission_profile(&mut cfg, "nope");
538        assert_eq!(cfg.agent.permission_profile, "auto");
539        assert!(cfg.policy.allow_shell);
540    }
541}