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    /// rx4 auto-compaction threshold. `0` leaves compaction off (the bridge
69    /// default); a non-zero value is forwarded to `Agent::auto_compact_after`,
70    /// which rx4 interprets as an estimated-token cutoff before each prompt.
71    pub auto_compact_after: usize,
72    /// Record ReAct trajectories per chat for RL export. Defaults to `true`,
73    /// which is the historical behaviour; set to `false` to stop collecting.
74    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/// Per-tool permission rules.
95///
96/// `deny` blocks matching tools outright (checked first).
97/// `allow` restricts to only those tools when non-empty (allowlist mode).
98/// Supports exact tool names and glob-style `*` wildcards.
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100#[serde(default)]
101pub struct PermissionRulesConfig {
102    /// Tools that are always blocked (e.g. `["exec", "shell"]`).
103    pub deny: Vec<String>,
104    /// If non-empty, only these tools are allowed (allowlist mode).
105    pub allow: Vec<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(default)]
110pub struct RuntimeConfig {
111    pub kind: String, // "native", "docker"
112    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, // "surreal"
132    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, // "cli", "telegram", "discord", "websocket"
148    pub token: Option<String>,
149    /// When non-empty, only these Telegram (or channel) chat IDs may send inbound messages.
150    pub allowed_chat_ids: Vec<String>,
151    /// When non-empty, only these sender user IDs may send inbound messages.
152    pub allowed_sender_ids: Vec<String>,
153    /// Free-form per-channel settings (`channel_id`, `space`, `server`, `nick`,
154    /// …). Kept untyped so a channel — including one registered by a plugin —
155    /// can take the parameters it needs without a config schema change.
156    #[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    /// Extra directories to scan for OpenClaw SKILL.md and Hermes plugin.json
177    #[serde(default)]
178    pub host_plugin_roots: Vec<PathBuf>,
179    /// Host plugin ids (`hermes:foo`) whose manifest-declared commands may be
180    /// executed. Empty by default: discovering a plugin grants it nothing,
181    /// because a discovered directory is not a trusted one.
182    #[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    /// One logical user across channels (merged history when set).
210    pub principal_id: Option<String>,
211    /// Inject brief + recall gate + idea graph context each turn.
212    pub inject_context: bool,
213    pub graph_recall_limit: usize,
214    /// Route heartbeat synthetic messages to this chat_id (e.g. telegram chat id).
215    pub heartbeat_chat_id: Option<String>,
216    /// After idle, expand open loops into dream nodes (graph).
217    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
260/// Apply a named onboarding permission profile to `cfg` (policy, toolsets, and `permission_profile`).
261pub 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
311/// Leaf field names whose values must never be printed.
312const SECRET_LEAF_KEYS: &[&str] = &["api_key", "token", "secret", "password"];
313
314/// Whether a leaf field name holds a credential.
315pub 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
322/// Replace every credential-bearing leaf with a fixed mask, in place.
323///
324/// Empty strings and nulls stay as they are: "unset" is not a secret, and
325/// showing a mask for one would be a lie.
326pub 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    /// Read a dotted path (`agent.max_rounds`, `provider.name`, `model`) out of the
431    /// effective configuration.
432    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    /// Coerce `value` to the type the schema already uses at `key`, apply it,
441    /// and round-trip the result through `Config` so an invalid write is
442    /// rejected before it can reach disk.
443    ///
444    /// Returns the updated config and the JSON value that was written, so a
445    /// caller can splice just that path into the on-disk file.
446    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    /// Splice a validated value into a raw config document, leaving every other
461    /// key in the file untouched.
462    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            // Deny-by-default: a discovered plugin executes nothing until it
602            // is named here.
603            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}