1use 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 observability: ObservabilityConfig,
18 pub channel: ChannelConfig,
19 pub policy: PolicyConfig,
20 pub plugin_layer: PluginLayerConfig,
21 pub group_chat: GroupChatConfig,
22 pub toolsets: ToolsetConfig,
23 pub memory: MemoryIdeasConfig,
24 #[serde(default)]
25 pub zkr: ZkrConfig,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(default)]
30pub struct ProviderConfig {
31 pub name: String,
32 pub api_key: Option<String>,
33 pub base_url: Option<String>,
34 pub native_web_search: bool,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(default)]
41pub struct EmbeddingsConfig {
42 pub enabled: bool,
43 pub provider: String,
44 pub api_key: Option<String>,
45 pub model: Option<String>,
46 pub base_url: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(default)]
51pub struct AgentConfig {
52 pub max_rounds: usize,
54 pub max_history_messages: usize,
56 pub max_tool_result_chars: usize,
58 pub max_context_chars: usize,
60 pub fast_model: String,
62 pub heavy_model: String,
64 pub permissions: PermissionRulesConfig,
66 pub permission_profile: String,
68 pub auto_compact_after: usize,
72}
73
74impl Default for AgentConfig {
75 fn default() -> Self {
76 Self {
77 max_rounds: 50,
78 max_history_messages: 10,
79 max_tool_result_chars: 20_000,
80 max_context_chars: 150_000,
81 fast_model: "gpt-5.4-mini".to_string(),
82 heavy_model: "gpt-5.5".to_string(),
83 permissions: PermissionRulesConfig::default(),
84 permission_profile: "auto".to_string(),
85 auto_compact_after: 0,
86 }
87 }
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
96#[serde(default)]
97pub struct PermissionRulesConfig {
98 pub deny: Vec<String>,
100 pub allow: Vec<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105#[serde(default)]
106pub struct RuntimeConfig {
107 pub kind: String, pub docker_image: Option<String>,
109 pub memory_limit_mb: Option<u64>,
110 pub state_path: Option<PathBuf>,
111 pub self_update: SelfUpdateConfig,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(default)]
116pub struct SelfUpdateConfig {
117 pub enabled: bool,
118 pub interval_secs: u64,
119 pub remote: String,
120 pub branch: String,
121 pub restart_service: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(default)]
126pub struct StorageConfig {
127 pub backend: String, pub root: PathBuf,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(default)]
133pub struct ObservabilityConfig {
134 pub service_name: String,
135 pub environment: String,
136 pub json_logs: bool,
137 pub trace_header_name: String,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct ChannelConfig {
143 pub kind: String, pub token: Option<String>,
145 pub allowed_chat_ids: Vec<String>,
147 pub allowed_sender_ids: Vec<String>,
149 #[serde(default)]
153 pub settings: std::collections::HashMap<String, String>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(default)]
158pub struct PolicyConfig {
159 pub allow_shell: bool,
160 pub allow_dynamic_tools: bool,
161 pub allow_plugin_shell: bool,
162 pub allow_plugin_git: bool,
163 pub allow_computer_use: bool,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(default)]
168pub struct PluginLayerConfig {
169 pub enabled: bool,
170 #[serde(default = "default_manifest_path")]
171 pub manifest_path: PathBuf,
172 #[serde(default)]
174 pub host_plugin_roots: Vec<PathBuf>,
175 #[serde(default)]
179 pub trusted_host_plugins: Vec<String>,
180 pub hook_events: Vec<String>,
181 pub allow_core_fallback: bool,
182 pub layered_overrides: Vec<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(default)]
187pub struct GroupChatConfig {
188 pub enable_ambient_questions: bool,
189 pub rolling_memory_namespace: String,
190 pub rolling_memory_max_chars: usize,
191 pub rolling_memory_recent_turns: usize,
192 pub ambient_question_window: usize,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, Default)]
196#[serde(default)]
197pub struct ToolsetConfig {
198 pub enabled: Vec<String>,
199 pub disabled: Vec<String>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203#[serde(default)]
204pub struct MemoryIdeasConfig {
205 pub principal_id: Option<String>,
207 pub inject_context: bool,
209 pub graph_recall_limit: usize,
210 pub heartbeat_chat_id: Option<String>,
212 pub dream_on_heartbeat: bool,
214}
215
216impl Default for MemoryIdeasConfig {
217 fn default() -> Self {
218 Self {
219 principal_id: None,
220 inject_context: true,
221 graph_recall_limit: 5,
222 heartbeat_chat_id: None,
223 dream_on_heartbeat: false,
224 }
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(default)]
230pub struct ZkrConfig {
231 pub enabled: bool,
232 pub database: PathBuf,
233 pub tenant_id: String,
234 pub person_id: String,
235 pub auto_capture: bool,
236 pub inject_recall: bool,
237 pub recall_limit: u32,
238 pub self_improve: bool,
239}
240
241impl Default for ZkrConfig {
242 fn default() -> Self {
243 Self {
244 enabled: true,
245 database: PathBuf::from(".apollo/zkr.db"),
246 tenant_id: "apollo".to_string(),
247 person_id: "local".to_string(),
248 auto_capture: true,
249 inject_recall: true,
250 recall_limit: 5,
251 self_improve: true,
252 }
253 }
254}
255
256pub fn apply_permission_profile(cfg: &mut Config, profile: &str) {
258 let p = profile.trim().to_ascii_lowercase().replace(['-', ' '], "_");
259 match p.as_str() {
260 "full" => {
261 cfg.agent.permission_profile = "full".to_string();
262 cfg.policy.allow_shell = true;
263 cfg.policy.allow_dynamic_tools = true;
264 cfg.policy.allow_computer_use = true;
265 cfg.toolsets = ToolsetConfig::default();
266 cfg.agent.permissions = PermissionRulesConfig::default();
267 }
268 "auto" => {
269 cfg.agent.permission_profile = "auto".to_string();
270 cfg.policy = PolicyConfig::default();
271 cfg.toolsets = ToolsetConfig::default();
272 cfg.agent.permissions = PermissionRulesConfig::default();
273 }
274 "prompt" => {
275 cfg.agent.permission_profile = "prompt".to_string();
276 cfg.policy = PolicyConfig::default();
277 cfg.toolsets = ToolsetConfig::default();
278 cfg.agent.permissions = PermissionRulesConfig::default();
279 }
280 "tools_only" | "tools" => {
281 cfg.agent.permission_profile = "tools_only".to_string();
282 cfg.policy.allow_shell = false;
283 cfg.policy.allow_dynamic_tools = false;
284 cfg.policy.allow_computer_use = false;
285 cfg.toolsets.enabled = vec![
286 "web".to_string(),
287 "memory".to_string(),
288 "sessions".to_string(),
289 ];
290 cfg.toolsets.disabled = vec![
291 "browser".to_string(),
292 "vibemania".to_string(),
293 "create_tool".to_string(),
294 "mcp".to_string(),
295 ];
296 cfg.agent.permissions = PermissionRulesConfig::default();
297 }
298 _ => {
299 cfg.agent.permission_profile = "auto".to_string();
300 cfg.policy = PolicyConfig::default();
301 cfg.toolsets = ToolsetConfig::default();
302 cfg.agent.permissions = PermissionRulesConfig::default();
303 }
304 }
305}
306
307const SECRET_LEAF_KEYS: &[&str] = &["api_key", "token", "secret", "password"];
309
310pub fn is_secret_key(leaf: &str) -> bool {
312 let leaf = leaf.to_ascii_lowercase();
313 SECRET_LEAF_KEYS
314 .iter()
315 .any(|s| leaf == *s || leaf.ends_with(&format!("_{s}")))
316}
317
318pub fn mask_secrets(value: &mut serde_json::Value) {
323 match value {
324 serde_json::Value::Object(map) => {
325 for (key, child) in map.iter_mut() {
326 if is_secret_key(key) {
327 if let serde_json::Value::String(s) = child {
328 if !s.is_empty() {
329 *child = serde_json::Value::String("********".to_string());
330 continue;
331 }
332 }
333 }
334 mask_secrets(child);
335 }
336 }
337 serde_json::Value::Array(items) => {
338 for item in items {
339 mask_secrets(item);
340 }
341 }
342 _ => {}
343 }
344}
345
346fn lookup<'a>(root: &'a serde_json::Value, key: &str) -> anyhow::Result<&'a serde_json::Value> {
347 let mut current = root;
348 let mut walked: Vec<&str> = Vec::new();
349 for segment in key.split('.') {
350 let object = current.as_object().ok_or_else(|| {
351 anyhow::anyhow!(
352 "unknown config key `{key}`: `{}` is not a section",
353 walked.join(".")
354 )
355 })?;
356 current = object.get(segment).ok_or_else(|| {
357 let mut names: Vec<&str> = object.keys().map(|k| k.as_str()).collect();
358 names.sort_unstable();
359 anyhow::anyhow!(
360 "unknown config key `{key}`. Available under `{}`: {}",
361 if walked.is_empty() {
362 "<root>".to_string()
363 } else {
364 walked.join(".")
365 },
366 names.join(", ")
367 )
368 })?;
369 walked.push(segment);
370 }
371 Ok(current)
372}
373
374fn coerce(existing: &serde_json::Value, input: &str) -> anyhow::Result<serde_json::Value> {
375 use serde_json::Value;
376 match existing {
377 Value::String(_) => Ok(Value::String(input.to_string())),
378 Value::Bool(_) => input
379 .parse::<bool>()
380 .map(Value::Bool)
381 .map_err(|_| anyhow::anyhow!("expected `true` or `false`, got `{input}`")),
382 Value::Number(n) => {
383 if n.is_f64() {
384 input
385 .parse::<f64>()
386 .ok()
387 .and_then(serde_json::Number::from_f64)
388 .map(Value::Number)
389 .ok_or_else(|| anyhow::anyhow!("expected a number, got `{input}`"))
390 } else {
391 input
392 .parse::<i64>()
393 .map(|v| Value::Number(v.into()))
394 .map_err(|_| anyhow::anyhow!("expected an integer, got `{input}`"))
395 }
396 }
397 Value::Array(_) | Value::Object(_) => serde_json::from_str(input)
398 .map_err(|e| anyhow::anyhow!("expected JSON matching the existing value: {e}")),
399 Value::Null => Ok(serde_json::from_str(input).unwrap_or(Value::String(input.to_string()))),
400 }
401}
402
403fn assign(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
404 let segments: Vec<&str> = key.split('.').collect();
405 let mut current = root;
406 for segment in &segments[..segments.len() - 1] {
407 if !current.is_object() {
408 *current = serde_json::Value::Object(serde_json::Map::new());
409 }
410 current = current
411 .as_object_mut()
412 .expect("object")
413 .entry((*segment).to_string())
414 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
415 }
416 if !current.is_object() {
417 *current = serde_json::Value::Object(serde_json::Map::new());
418 }
419 current
420 .as_object_mut()
421 .expect("object")
422 .insert(segments[segments.len() - 1].to_string(), value);
423}
424
425impl Config {
426 pub fn get_path(&self, key: &str) -> anyhow::Result<serde_json::Value> {
429 if key.trim().is_empty() {
430 anyhow::bail!("empty config key");
431 }
432 let root = serde_json::to_value(self)?;
433 lookup(&root, key).cloned()
434 }
435
436 pub fn set_path(&self, key: &str, value: &str) -> anyhow::Result<(Config, serde_json::Value)> {
443 if key.trim().is_empty() {
444 anyhow::bail!("empty config key");
445 }
446 let mut root = serde_json::to_value(self)?;
447 let existing = lookup(&root, key)?;
448 let coerced = coerce(existing, value)
449 .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
450 assign(&mut root, key, coerced.clone());
451 let updated: Config = serde_json::from_value(root)
452 .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
453 Ok((updated, coerced))
454 }
455
456 pub fn splice_into_raw(raw: &mut serde_json::Value, key: &str, value: serde_json::Value) {
459 assign(raw, key, value);
460 }
461
462 pub fn load(path: &str) -> anyhow::Result<Self> {
463 let content = std::fs::read_to_string(path)?;
464 let config: Config = serde_json::from_str(&content)?;
465 Ok(config)
466 }
467
468 pub fn default_config() -> Self {
469 Self {
470 provider: ProviderConfig::default(),
471 embeddings: EmbeddingsConfig::default(),
472 agent: AgentConfig::default(),
473 model: "gpt-5.5".to_string(),
474 system_prompt: "You are a helpful AI assistant.".to_string(),
475 workspace: PathBuf::from("."),
476 storage: StorageConfig::default(),
477 runtime: RuntimeConfig::default(),
478 observability: ObservabilityConfig::default(),
479 channel: ChannelConfig::default(),
480 policy: PolicyConfig::default(),
481 plugin_layer: PluginLayerConfig::default(),
482 group_chat: GroupChatConfig::default(),
483 toolsets: ToolsetConfig::default(),
484 memory: MemoryIdeasConfig::default(),
485 zkr: ZkrConfig::default(),
486 }
487 }
488}
489
490impl Default for Config {
491 fn default() -> Self {
492 Self::default_config()
493 }
494}
495
496impl Default for ProviderConfig {
497 fn default() -> Self {
498 Self {
499 name: "chatgpt".to_string(),
500 api_key: None,
501 base_url: None,
502 native_web_search: false,
503 }
504 }
505}
506
507impl Default for EmbeddingsConfig {
508 fn default() -> Self {
509 Self {
510 enabled: false,
511 provider: "noop".to_string(),
512 api_key: None,
513 model: None,
514 base_url: None,
515 }
516 }
517}
518
519impl Default for RuntimeConfig {
520 fn default() -> Self {
521 Self {
522 kind: "native".to_string(),
523 docker_image: None,
524 memory_limit_mb: None,
525 state_path: None,
526 self_update: SelfUpdateConfig::default(),
527 }
528 }
529}
530
531impl Default for SelfUpdateConfig {
532 fn default() -> Self {
533 Self {
534 enabled: false,
535 interval_secs: 900,
536 remote: "origin".to_string(),
537 branch: "main".to_string(),
538 restart_service: Some("apollo".to_string()),
539 }
540 }
541}
542
543impl Default for StorageConfig {
544 fn default() -> Self {
545 Self {
546 backend: "surreal".to_string(),
547 root: PathBuf::from(".apollo"),
548 }
549 }
550}
551
552impl Default for ObservabilityConfig {
553 fn default() -> Self {
554 Self {
555 service_name: "apollo".to_string(),
556 environment: "development".to_string(),
557 json_logs: false,
558 trace_header_name: "traceparent".to_string(),
559 }
560 }
561}
562
563impl Default for ChannelConfig {
564 fn default() -> Self {
565 Self {
566 kind: "cli".to_string(),
567 token: None,
568 allowed_chat_ids: Vec::new(),
569 allowed_sender_ids: Vec::new(),
570 settings: std::collections::HashMap::new(),
571 }
572 }
573}
574
575impl Default for PolicyConfig {
576 fn default() -> Self {
577 Self {
578 allow_shell: true,
579 allow_dynamic_tools: true,
580 allow_plugin_shell: true,
581 allow_plugin_git: true,
582 allow_computer_use: true,
583 }
584 }
585}
586
587fn default_manifest_path() -> PathBuf {
588 PathBuf::from("plugins/manifest.json")
589}
590
591impl Default for PluginLayerConfig {
592 fn default() -> Self {
593 Self {
594 enabled: true,
595 manifest_path: default_manifest_path(),
596 host_plugin_roots: Vec::new(),
597 trusted_host_plugins: Vec::new(),
600 hook_events: vec![
601 "before_message".to_string(),
602 "after_message".to_string(),
603 "before_tool".to_string(),
604 "after_tool".to_string(),
605 ],
606 allow_core_fallback: true,
607 layered_overrides: vec!["system_prompt".to_string(), "toolsets".to_string()],
608 }
609 }
610}
611
612impl Default for GroupChatConfig {
613 fn default() -> Self {
614 Self {
615 enable_ambient_questions: true,
616 rolling_memory_namespace: "group_memory".to_string(),
617 rolling_memory_max_chars: 6_000,
618 rolling_memory_recent_turns: 16,
619 ambient_question_window: 24,
620 }
621 }
622}
623
624#[cfg(test)]
625mod config_path_tests {
626 use super::*;
627
628 #[test]
629 fn get_reads_nested_and_top_level_keys() {
630 let cfg = Config::default_config();
631 assert_eq!(cfg.get_path("model").unwrap(), cfg.model.as_str());
632 assert_eq!(cfg.get_path("provider.name").unwrap(), "chatgpt");
633 assert_eq!(cfg.get_path("agent.max_rounds").unwrap(), 50);
634 }
635
636 #[test]
637 fn set_updates_strings_bools_and_numbers() {
638 let cfg = Config::default_config();
639 let (cfg, _) = cfg.set_path("policy.allow_shell", "false").unwrap();
640 assert!(!cfg.policy.allow_shell);
641 let (cfg, written) = cfg.set_path("agent.max_rounds", "12").unwrap();
642 assert_eq!(cfg.agent.max_rounds, 12);
643 assert_eq!(written, 12);
644 }
645
646 #[test]
647 fn set_fills_an_optional_field_that_was_null() {
648 let cfg = Config::default_config();
649 let (cfg, _) = cfg
650 .set_path("provider.base_url", "http://localhost:11434")
651 .unwrap();
652 assert_eq!(
653 cfg.provider.base_url.as_deref(),
654 Some("http://localhost:11434")
655 );
656 }
657
658 #[test]
659 fn unknown_keys_are_rejected_with_the_available_names() {
660 let cfg = Config::default_config();
661 let err = cfg.set_path("agent.nope", "1").unwrap_err().to_string();
662 assert!(err.contains("unknown config key `agent.nope`"), "{err}");
663 assert!(err.contains("max_rounds"), "{err}");
664
665 let err = cfg.get_path("not_a_section.x").unwrap_err().to_string();
666 assert!(err.contains("unknown config key"), "{err}");
667 }
668
669 #[test]
670 fn wrong_typed_values_are_rejected_before_they_reach_disk() {
671 let cfg = Config::default_config();
672 let err = cfg
673 .set_path("agent.max_rounds", "abc")
674 .unwrap_err()
675 .to_string();
676 assert!(err.contains("expected an integer"), "{err}");
677
678 let err = cfg
679 .set_path("policy.allow_shell", "yes-please")
680 .unwrap_err()
681 .to_string();
682 assert!(err.contains("expected `true` or `false`"), "{err}");
683 }
684
685 #[test]
686 fn secrets_are_masked_and_other_values_are_not() {
687 let mut cfg = Config::default_config();
688 cfg.provider.api_key = Some("sk-ant-secret-value".to_string());
689 cfg.channel.token = Some("123:telegram-secret".to_string());
690 let mut value = serde_json::to_value(&cfg).unwrap();
691 mask_secrets(&mut value);
692 let rendered = serde_json::to_string(&value).unwrap();
693 assert!(!rendered.contains("sk-ant-secret-value"), "{rendered}");
694 assert!(!rendered.contains("telegram-secret"), "{rendered}");
695 assert_eq!(value["provider"]["api_key"], "********");
696 assert_eq!(value["channel"]["token"], "********");
697 assert_eq!(value["provider"]["name"], "chatgpt");
698 }
699
700 #[test]
701 fn splicing_preserves_unrelated_keys_in_the_file() {
702 let mut raw: serde_json::Value =
703 serde_json::from_str(r#"{"model":"m","custom":{"kept":true}}"#).unwrap();
704 Config::splice_into_raw(&mut raw, "agent.max_rounds", serde_json::json!(12));
705 assert_eq!(raw["custom"]["kept"], true);
706 assert_eq!(raw["model"], "m");
707 assert_eq!(raw["agent"]["max_rounds"], 12);
708 }
709}
710
711#[cfg(test)]
712mod permission_profile_tests {
713 use super::*;
714
715 #[test]
716 fn full_enables_shell_and_resets_toolsets() {
717 let mut cfg = Config::default();
718 cfg.policy.allow_shell = false;
719 cfg.toolsets.enabled = vec!["browser".into()];
720 apply_permission_profile(&mut cfg, "full");
721 assert_eq!(cfg.agent.permission_profile, "full");
722 assert!(cfg.policy.allow_shell);
723 assert!(cfg.policy.allow_dynamic_tools);
724 assert!(cfg.toolsets.enabled.is_empty());
725 }
726
727 #[test]
728 fn tools_only_disables_shell_and_limits_toolsets() {
729 let mut cfg = Config::default();
730 apply_permission_profile(&mut cfg, "tools-only");
731 assert_eq!(cfg.agent.permission_profile, "tools_only");
732 assert!(!cfg.policy.allow_shell);
733 assert!(!cfg.policy.allow_dynamic_tools);
734 assert_eq!(
735 cfg.toolsets.enabled,
736 vec![
737 "web".to_string(),
738 "memory".to_string(),
739 "sessions".to_string()
740 ]
741 );
742 assert!(cfg.toolsets.disabled.contains(&"browser".to_string()));
743 }
744
745 #[test]
746 fn unknown_profile_falls_back_to_auto_defaults() {
747 let mut cfg = Config::default();
748 cfg.policy.allow_shell = false;
749 apply_permission_profile(&mut cfg, "nope");
750 assert_eq!(cfg.agent.permission_profile, "auto");
751 assert!(cfg.policy.allow_shell);
752 }
753}