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