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