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