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 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    /// Let the provider run web search on its own infrastructure instead of
35    /// apollo's `web_search` tool. Anthropic only; billed by the provider.
36    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    /// Hard circuit breaker (absolute max execution rounds)
53    pub max_rounds: usize,
54    /// Max conversation history (prevents context overflow)
55    pub max_history_messages: usize,
56    /// Max chars for a single tool result
57    pub max_tool_result_chars: usize,
58    /// Max context chars before triggering mid-loop compaction
59    pub max_context_chars: usize,
60    /// Fast/cheap model for planning + summarization
61    pub fast_model: String,
62    /// Heavy model for complex coding/reasoning
63    pub heavy_model: String,
64    /// Per-tool allow/deny rules
65    pub permissions: PermissionRulesConfig,
66    /// Initial safety profile: `full`, `auto`, `prompt`, or `tools_only` (drives default `AgentMode`).
67    pub permission_profile: String,
68    /// Agent loop implementation: `legacy` (apollo's built-in state machine)
69    /// or `rx4` (the rotary harness engine). apollo keeps ownership of context
70    /// assembly and tools either way; `rx4` hands the loop itself to rx4.
71    pub engine: String,
72}
73
74/// Which agent loop executes a turn.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum AgentEngine {
77    /// apollo's built-in Planning → Executing → Summarizing state machine.
78    #[default]
79    Legacy,
80    /// The rx4 (rotary) harness engine, via `agent::rotary_bridge`.
81    Rx4,
82}
83
84impl AgentConfig {
85    /// Parse the configured engine. Unknown values fall back to `Legacy` with
86    /// a warning rather than failing startup.
87    pub fn engine(&self) -> AgentEngine {
88        match self.engine.trim().to_ascii_lowercase().as_str() {
89            "rx4" | "rotary" => AgentEngine::Rx4,
90            "legacy" | "" => AgentEngine::Legacy,
91            other => {
92                tracing::warn!("unknown agent.engine {other:?}, using legacy");
93                AgentEngine::Legacy
94            }
95        }
96    }
97}
98
99impl Default for AgentConfig {
100    fn default() -> Self {
101        Self {
102            max_rounds: 50,
103            max_history_messages: 10,
104            max_tool_result_chars: 20_000,
105            max_context_chars: 150_000,
106            fast_model: "claude-haiku-4-5-20251001".to_string(),
107            heavy_model: "claude-sonnet-4-6".to_string(),
108            permissions: PermissionRulesConfig::default(),
109            permission_profile: "auto".to_string(),
110            engine: "legacy".to_string(),
111        }
112    }
113}
114
115/// Per-tool permission rules.
116///
117/// `deny` blocks matching tools outright (checked first).
118/// `allow` restricts to only those tools when non-empty (allowlist mode).
119/// Supports exact tool names and glob-style `*` wildcards.
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121#[serde(default)]
122pub struct PermissionRulesConfig {
123    /// Tools that are always blocked (e.g. `["exec", "shell"]`).
124    pub deny: Vec<String>,
125    /// If non-empty, only these tools are allowed (allowlist mode).
126    pub allow: Vec<String>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(default)]
131pub struct RuntimeConfig {
132    pub kind: String, // "native", "docker"
133    pub docker_image: Option<String>,
134    pub memory_limit_mb: Option<u64>,
135    pub state_path: Option<PathBuf>,
136    pub self_update: SelfUpdateConfig,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(default)]
141pub struct SelfUpdateConfig {
142    pub enabled: bool,
143    pub interval_secs: u64,
144    pub remote: String,
145    pub branch: String,
146    pub restart_service: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(default)]
151pub struct StorageConfig {
152    pub backend: String, // "surreal"
153    pub root: PathBuf,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(default)]
158pub struct ObservabilityConfig {
159    pub service_name: String,
160    pub environment: String,
161    pub json_logs: bool,
162    pub trace_header_name: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(default)]
167pub struct ChannelConfig {
168    pub kind: String, // "cli", "telegram", "discord", "websocket"
169    pub token: Option<String>,
170    /// When non-empty, only these Telegram (or channel) chat IDs may send inbound messages.
171    pub allowed_chat_ids: Vec<String>,
172    /// When non-empty, only these sender user IDs may send inbound messages.
173    pub allowed_sender_ids: Vec<String>,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(default)]
178pub struct PolicyConfig {
179    pub allow_shell: bool,
180    pub allow_dynamic_tools: bool,
181    pub allow_plugin_shell: bool,
182    pub allow_plugin_git: bool,
183    pub allow_computer_use: bool,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(default)]
188pub struct PluginLayerConfig {
189    pub enabled: bool,
190    #[serde(default = "default_manifest_path")]
191    pub manifest_path: PathBuf,
192    /// Extra directories to scan for OpenClaw SKILL.md and Hermes plugin.json
193    #[serde(default)]
194    pub host_plugin_roots: Vec<PathBuf>,
195    pub hook_events: Vec<String>,
196    pub allow_core_fallback: bool,
197    pub layered_overrides: Vec<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(default)]
202pub struct GroupChatConfig {
203    pub enable_ambient_questions: bool,
204    pub rolling_memory_namespace: String,
205    pub rolling_memory_max_chars: usize,
206    pub rolling_memory_recent_turns: usize,
207    pub ambient_question_window: usize,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, Default)]
211#[serde(default)]
212pub struct ToolsetConfig {
213    pub enabled: Vec<String>,
214    pub disabled: Vec<String>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(default)]
219pub struct MemoryIdeasConfig {
220    /// One logical user across channels (merged history when set).
221    pub principal_id: Option<String>,
222    /// Inject brief + recall gate + idea graph context each turn.
223    pub inject_context: bool,
224    pub graph_recall_limit: usize,
225    /// Route heartbeat synthetic messages to this chat_id (e.g. telegram chat id).
226    pub heartbeat_chat_id: Option<String>,
227    /// After idle, expand open loops into dream nodes (graph).
228    pub dream_on_heartbeat: bool,
229}
230
231impl Default for MemoryIdeasConfig {
232    fn default() -> Self {
233        Self {
234            principal_id: None,
235            inject_context: true,
236            graph_recall_limit: 5,
237            heartbeat_chat_id: None,
238            dream_on_heartbeat: false,
239        }
240    }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(default)]
245pub struct ZkrConfig {
246    pub enabled: bool,
247    pub database: PathBuf,
248    pub tenant_id: String,
249    pub person_id: String,
250    pub auto_capture: bool,
251    pub inject_recall: bool,
252    pub recall_limit: u32,
253    pub self_improve: bool,
254}
255
256impl Default for ZkrConfig {
257    fn default() -> Self {
258        Self {
259            enabled: true,
260            database: PathBuf::from(".apollo/zkr.db"),
261            tenant_id: "apollo".to_string(),
262            person_id: "local".to_string(),
263            auto_capture: true,
264            inject_recall: true,
265            recall_limit: 5,
266            self_improve: true,
267        }
268    }
269}
270
271/// Apply a named onboarding permission profile to `cfg` (policy, toolsets, and `permission_profile`).
272pub fn apply_permission_profile(cfg: &mut Config, profile: &str) {
273    let p = profile.trim().to_ascii_lowercase().replace(['-', ' '], "_");
274    match p.as_str() {
275        "full" => {
276            cfg.agent.permission_profile = "full".to_string();
277            cfg.policy.allow_shell = true;
278            cfg.policy.allow_dynamic_tools = true;
279            cfg.policy.allow_computer_use = true;
280            cfg.toolsets = ToolsetConfig::default();
281            cfg.agent.permissions = PermissionRulesConfig::default();
282        }
283        "auto" => {
284            cfg.agent.permission_profile = "auto".to_string();
285            cfg.policy = PolicyConfig::default();
286            cfg.toolsets = ToolsetConfig::default();
287            cfg.agent.permissions = PermissionRulesConfig::default();
288        }
289        "prompt" => {
290            cfg.agent.permission_profile = "prompt".to_string();
291            cfg.policy = PolicyConfig::default();
292            cfg.toolsets = ToolsetConfig::default();
293            cfg.agent.permissions = PermissionRulesConfig::default();
294        }
295        "tools_only" | "tools" => {
296            cfg.agent.permission_profile = "tools_only".to_string();
297            cfg.policy.allow_shell = false;
298            cfg.policy.allow_dynamic_tools = false;
299            cfg.policy.allow_computer_use = false;
300            cfg.toolsets.enabled = vec![
301                "web".to_string(),
302                "memory".to_string(),
303                "sessions".to_string(),
304            ];
305            cfg.toolsets.disabled = vec![
306                "browser".to_string(),
307                "vibemania".to_string(),
308                "create_tool".to_string(),
309                "mcp".to_string(),
310            ];
311            cfg.agent.permissions = PermissionRulesConfig::default();
312        }
313        _ => {
314            cfg.agent.permission_profile = "auto".to_string();
315            cfg.policy = PolicyConfig::default();
316            cfg.toolsets = ToolsetConfig::default();
317            cfg.agent.permissions = PermissionRulesConfig::default();
318        }
319    }
320}
321
322/// Leaf field names whose values must never be printed.
323const SECRET_LEAF_KEYS: &[&str] = &["api_key", "token", "secret", "password"];
324
325/// Whether a leaf field name holds a credential.
326pub fn is_secret_key(leaf: &str) -> bool {
327    let leaf = leaf.to_ascii_lowercase();
328    SECRET_LEAF_KEYS
329        .iter()
330        .any(|s| leaf == *s || leaf.ends_with(&format!("_{s}")))
331}
332
333/// Replace every credential-bearing leaf with a fixed mask, in place.
334///
335/// Empty strings and nulls stay as they are: "unset" is not a secret, and
336/// showing a mask for one would be a lie.
337pub fn mask_secrets(value: &mut serde_json::Value) {
338    match value {
339        serde_json::Value::Object(map) => {
340            for (key, child) in map.iter_mut() {
341                if is_secret_key(key) {
342                    if let serde_json::Value::String(s) = child {
343                        if !s.is_empty() {
344                            *child = serde_json::Value::String("********".to_string());
345                            continue;
346                        }
347                    }
348                }
349                mask_secrets(child);
350            }
351        }
352        serde_json::Value::Array(items) => {
353            for item in items {
354                mask_secrets(item);
355            }
356        }
357        _ => {}
358    }
359}
360
361fn lookup<'a>(root: &'a serde_json::Value, key: &str) -> anyhow::Result<&'a serde_json::Value> {
362    let mut current = root;
363    let mut walked: Vec<&str> = Vec::new();
364    for segment in key.split('.') {
365        let object = current.as_object().ok_or_else(|| {
366            anyhow::anyhow!(
367                "unknown config key `{key}`: `{}` is not a section",
368                walked.join(".")
369            )
370        })?;
371        current = object.get(segment).ok_or_else(|| {
372            let mut names: Vec<&str> = object.keys().map(|k| k.as_str()).collect();
373            names.sort_unstable();
374            anyhow::anyhow!(
375                "unknown config key `{key}`. Available under `{}`: {}",
376                if walked.is_empty() {
377                    "<root>".to_string()
378                } else {
379                    walked.join(".")
380                },
381                names.join(", ")
382            )
383        })?;
384        walked.push(segment);
385    }
386    Ok(current)
387}
388
389fn coerce(existing: &serde_json::Value, input: &str) -> anyhow::Result<serde_json::Value> {
390    use serde_json::Value;
391    match existing {
392        Value::String(_) => Ok(Value::String(input.to_string())),
393        Value::Bool(_) => input
394            .parse::<bool>()
395            .map(Value::Bool)
396            .map_err(|_| anyhow::anyhow!("expected `true` or `false`, got `{input}`")),
397        Value::Number(n) => {
398            if n.is_f64() {
399                input
400                    .parse::<f64>()
401                    .ok()
402                    .and_then(serde_json::Number::from_f64)
403                    .map(Value::Number)
404                    .ok_or_else(|| anyhow::anyhow!("expected a number, got `{input}`"))
405            } else {
406                input
407                    .parse::<i64>()
408                    .map(|v| Value::Number(v.into()))
409                    .map_err(|_| anyhow::anyhow!("expected an integer, got `{input}`"))
410            }
411        }
412        Value::Array(_) | Value::Object(_) => serde_json::from_str(input)
413            .map_err(|e| anyhow::anyhow!("expected JSON matching the existing value: {e}")),
414        Value::Null => Ok(serde_json::from_str(input).unwrap_or(Value::String(input.to_string()))),
415    }
416}
417
418fn assign(root: &mut serde_json::Value, key: &str, value: serde_json::Value) {
419    let segments: Vec<&str> = key.split('.').collect();
420    let mut current = root;
421    for segment in &segments[..segments.len() - 1] {
422        if !current.is_object() {
423            *current = serde_json::Value::Object(serde_json::Map::new());
424        }
425        current = current
426            .as_object_mut()
427            .expect("object")
428            .entry((*segment).to_string())
429            .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
430    }
431    if !current.is_object() {
432        *current = serde_json::Value::Object(serde_json::Map::new());
433    }
434    current
435        .as_object_mut()
436        .expect("object")
437        .insert(segments[segments.len() - 1].to_string(), value);
438}
439
440impl Config {
441    /// Read a dotted path (`agent.engine`, `provider.name`, `model`) out of the
442    /// effective configuration.
443    pub fn get_path(&self, key: &str) -> anyhow::Result<serde_json::Value> {
444        if key.trim().is_empty() {
445            anyhow::bail!("empty config key");
446        }
447        let root = serde_json::to_value(self)?;
448        lookup(&root, key).cloned()
449    }
450
451    /// Coerce `value` to the type the schema already uses at `key`, apply it,
452    /// and round-trip the result through `Config` so an invalid write is
453    /// rejected before it can reach disk.
454    ///
455    /// Returns the updated config and the JSON value that was written, so a
456    /// caller can splice just that path into the on-disk file.
457    pub fn set_path(&self, key: &str, value: &str) -> anyhow::Result<(Config, serde_json::Value)> {
458        if key.trim().is_empty() {
459            anyhow::bail!("empty config key");
460        }
461        let mut root = serde_json::to_value(self)?;
462        let existing = lookup(&root, key)?;
463        let coerced = coerce(existing, value)
464            .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
465        assign(&mut root, key, coerced.clone());
466        let updated: Config = serde_json::from_value(root)
467            .map_err(|e| anyhow::anyhow!("invalid value for `{key}`: {e}"))?;
468        Ok((updated, coerced))
469    }
470
471    /// Splice a validated value into a raw config document, leaving every other
472    /// key in the file untouched.
473    pub fn splice_into_raw(raw: &mut serde_json::Value, key: &str, value: serde_json::Value) {
474        assign(raw, key, value);
475    }
476
477    pub fn load(path: &str) -> anyhow::Result<Self> {
478        let content = std::fs::read_to_string(path)?;
479        let config: Config = serde_json::from_str(&content)?;
480        Ok(config)
481    }
482
483    pub fn default_config() -> Self {
484        Self {
485            provider: ProviderConfig::default(),
486            embeddings: EmbeddingsConfig::default(),
487            agent: AgentConfig::default(),
488            model: "claude-sonnet-4-6".to_string(),
489            system_prompt: "You are a helpful AI assistant.".to_string(),
490            workspace: PathBuf::from("."),
491            storage: StorageConfig::default(),
492            runtime: RuntimeConfig::default(),
493            observability: ObservabilityConfig::default(),
494            channel: ChannelConfig::default(),
495            policy: PolicyConfig::default(),
496            plugin_layer: PluginLayerConfig::default(),
497            group_chat: GroupChatConfig::default(),
498            toolsets: ToolsetConfig::default(),
499            memory: MemoryIdeasConfig::default(),
500            zkr: ZkrConfig::default(),
501        }
502    }
503}
504
505impl Default for Config {
506    fn default() -> Self {
507        Self::default_config()
508    }
509}
510
511impl Default for ProviderConfig {
512    fn default() -> Self {
513        Self {
514            name: "anthropic".to_string(),
515            api_key: None,
516            base_url: None,
517            native_web_search: false,
518        }
519    }
520}
521
522impl Default for EmbeddingsConfig {
523    fn default() -> Self {
524        Self {
525            enabled: false,
526            provider: "noop".to_string(),
527            api_key: None,
528            model: None,
529            base_url: None,
530        }
531    }
532}
533
534impl Default for RuntimeConfig {
535    fn default() -> Self {
536        Self {
537            kind: "native".to_string(),
538            docker_image: None,
539            memory_limit_mb: None,
540            state_path: None,
541            self_update: SelfUpdateConfig::default(),
542        }
543    }
544}
545
546impl Default for SelfUpdateConfig {
547    fn default() -> Self {
548        Self {
549            enabled: false,
550            interval_secs: 900,
551            remote: "origin".to_string(),
552            branch: "main".to_string(),
553            restart_service: Some("apollo".to_string()),
554        }
555    }
556}
557
558impl Default for StorageConfig {
559    fn default() -> Self {
560        Self {
561            backend: "surreal".to_string(),
562            root: PathBuf::from(".apollo"),
563        }
564    }
565}
566
567impl Default for ObservabilityConfig {
568    fn default() -> Self {
569        Self {
570            service_name: "apollo".to_string(),
571            environment: "development".to_string(),
572            json_logs: false,
573            trace_header_name: "traceparent".to_string(),
574        }
575    }
576}
577
578impl Default for ChannelConfig {
579    fn default() -> Self {
580        Self {
581            kind: "cli".to_string(),
582            token: None,
583            allowed_chat_ids: Vec::new(),
584            allowed_sender_ids: Vec::new(),
585        }
586    }
587}
588
589impl Default for PolicyConfig {
590    fn default() -> Self {
591        Self {
592            allow_shell: true,
593            allow_dynamic_tools: true,
594            allow_plugin_shell: true,
595            allow_plugin_git: true,
596            allow_computer_use: true,
597        }
598    }
599}
600
601fn default_manifest_path() -> PathBuf {
602    PathBuf::from("plugins/manifest.json")
603}
604
605impl Default for PluginLayerConfig {
606    fn default() -> Self {
607        Self {
608            enabled: true,
609            manifest_path: default_manifest_path(),
610            host_plugin_roots: Vec::new(),
611            hook_events: vec![
612                "before_message".to_string(),
613                "after_message".to_string(),
614                "before_tool".to_string(),
615                "after_tool".to_string(),
616            ],
617            allow_core_fallback: true,
618            layered_overrides: vec!["system_prompt".to_string(), "toolsets".to_string()],
619        }
620    }
621}
622
623impl Default for GroupChatConfig {
624    fn default() -> Self {
625        Self {
626            enable_ambient_questions: true,
627            rolling_memory_namespace: "group_memory".to_string(),
628            rolling_memory_max_chars: 6_000,
629            rolling_memory_recent_turns: 16,
630            ambient_question_window: 24,
631        }
632    }
633}
634
635#[cfg(test)]
636mod config_path_tests {
637    use super::*;
638
639    #[test]
640    fn get_reads_nested_and_top_level_keys() {
641        let cfg = Config::default_config();
642        assert_eq!(cfg.get_path("model").unwrap(), cfg.model.as_str());
643        assert_eq!(cfg.get_path("agent.engine").unwrap(), "legacy");
644        assert_eq!(cfg.get_path("provider.name").unwrap(), "anthropic");
645        assert_eq!(cfg.get_path("agent.max_rounds").unwrap(), 50);
646    }
647
648    #[test]
649    fn set_updates_strings_bools_and_numbers() {
650        let cfg = Config::default_config();
651        let (cfg, _) = cfg.set_path("agent.engine", "rx4").unwrap();
652        assert_eq!(cfg.agent.engine, "rx4");
653        let (cfg, _) = cfg.set_path("policy.allow_shell", "false").unwrap();
654        assert!(!cfg.policy.allow_shell);
655        let (cfg, written) = cfg.set_path("agent.max_rounds", "12").unwrap();
656        assert_eq!(cfg.agent.max_rounds, 12);
657        assert_eq!(written, 12);
658    }
659
660    #[test]
661    fn set_fills_an_optional_field_that_was_null() {
662        let cfg = Config::default_config();
663        let (cfg, _) = cfg
664            .set_path("provider.base_url", "http://localhost:11434")
665            .unwrap();
666        assert_eq!(
667            cfg.provider.base_url.as_deref(),
668            Some("http://localhost:11434")
669        );
670    }
671
672    #[test]
673    fn unknown_keys_are_rejected_with_the_available_names() {
674        let cfg = Config::default_config();
675        let err = cfg.set_path("agent.nope", "1").unwrap_err().to_string();
676        assert!(err.contains("unknown config key `agent.nope`"), "{err}");
677        assert!(err.contains("engine"), "{err}");
678
679        let err = cfg.get_path("not_a_section.x").unwrap_err().to_string();
680        assert!(err.contains("unknown config key"), "{err}");
681    }
682
683    #[test]
684    fn wrong_typed_values_are_rejected_before_they_reach_disk() {
685        let cfg = Config::default_config();
686        let err = cfg
687            .set_path("agent.max_rounds", "abc")
688            .unwrap_err()
689            .to_string();
690        assert!(err.contains("expected an integer"), "{err}");
691
692        let err = cfg
693            .set_path("policy.allow_shell", "yes-please")
694            .unwrap_err()
695            .to_string();
696        assert!(err.contains("expected `true` or `false`"), "{err}");
697    }
698
699    #[test]
700    fn secrets_are_masked_and_other_values_are_not() {
701        let mut cfg = Config::default_config();
702        cfg.provider.api_key = Some("sk-ant-secret-value".to_string());
703        cfg.channel.token = Some("123:telegram-secret".to_string());
704        let mut value = serde_json::to_value(&cfg).unwrap();
705        mask_secrets(&mut value);
706        let rendered = serde_json::to_string(&value).unwrap();
707        assert!(!rendered.contains("sk-ant-secret-value"), "{rendered}");
708        assert!(!rendered.contains("telegram-secret"), "{rendered}");
709        assert_eq!(value["provider"]["api_key"], "********");
710        assert_eq!(value["channel"]["token"], "********");
711        assert_eq!(value["provider"]["name"], "anthropic");
712    }
713
714    #[test]
715    fn splicing_preserves_unrelated_keys_in_the_file() {
716        let mut raw: serde_json::Value =
717            serde_json::from_str(r#"{"model":"m","custom":{"kept":true}}"#).unwrap();
718        Config::splice_into_raw(&mut raw, "agent.engine", serde_json::json!("rx4"));
719        assert_eq!(raw["custom"]["kept"], true);
720        assert_eq!(raw["model"], "m");
721        assert_eq!(raw["agent"]["engine"], "rx4");
722    }
723}
724
725#[cfg(test)]
726mod permission_profile_tests {
727    use super::*;
728
729    #[test]
730    fn full_enables_shell_and_resets_toolsets() {
731        let mut cfg = Config::default();
732        cfg.policy.allow_shell = false;
733        cfg.toolsets.enabled = vec!["browser".into()];
734        apply_permission_profile(&mut cfg, "full");
735        assert_eq!(cfg.agent.permission_profile, "full");
736        assert!(cfg.policy.allow_shell);
737        assert!(cfg.policy.allow_dynamic_tools);
738        assert!(cfg.toolsets.enabled.is_empty());
739    }
740
741    #[test]
742    fn tools_only_disables_shell_and_limits_toolsets() {
743        let mut cfg = Config::default();
744        apply_permission_profile(&mut cfg, "tools-only");
745        assert_eq!(cfg.agent.permission_profile, "tools_only");
746        assert!(!cfg.policy.allow_shell);
747        assert!(!cfg.policy.allow_dynamic_tools);
748        assert_eq!(
749            cfg.toolsets.enabled,
750            vec![
751                "web".to_string(),
752                "memory".to_string(),
753                "sessions".to_string()
754            ]
755        );
756        assert!(cfg.toolsets.disabled.contains(&"browser".to_string()));
757    }
758
759    #[test]
760    fn engine_defaults_to_legacy() {
761        assert_eq!(AgentConfig::default().engine(), AgentEngine::Legacy);
762    }
763
764    #[test]
765    fn engine_parses_rx4_aliases() {
766        for value in ["rx4", "RX4", " rotary "] {
767            let cfg = AgentConfig {
768                engine: value.to_string(),
769                ..AgentConfig::default()
770            };
771            assert_eq!(cfg.engine(), AgentEngine::Rx4, "value: {value:?}");
772        }
773    }
774
775    #[test]
776    fn unknown_engine_falls_back_to_legacy() {
777        for value in ["nope", ""] {
778            let cfg = AgentConfig {
779                engine: value.to_string(),
780                ..AgentConfig::default()
781            };
782            assert_eq!(cfg.engine(), AgentEngine::Legacy, "value: {value:?}");
783        }
784    }
785
786    #[test]
787    fn unknown_profile_falls_back_to_auto_defaults() {
788        let mut cfg = Config::default();
789        cfg.policy.allow_shell = false;
790        apply_permission_profile(&mut cfg, "nope");
791        assert_eq!(cfg.agent.permission_profile, "auto");
792        assert!(cfg.policy.allow_shell);
793    }
794}