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