Skip to main content

scv_server/
config.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    ffi::OsString,
4    io::Write,
5    path::PathBuf,
6    time::Duration,
7};
8
9use anyhow::{Context, Result, bail};
10use scv_core::{AgentConfig as CoreAgentConfig, ContextConfig, HistoryLimits};
11use scv_provider_openai::ProviderLimits;
12use scv_tools::{AgentAdapterConfig, ToolsConfig};
13use serde::{Deserialize, Serialize};
14
15const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
16/// A day: long enough for any delegated job, short enough that deadline
17/// arithmetic never overflows.
18const MAX_TOOL_TIMEOUT_SECONDS: u64 = 24 * 60 * 60;
19
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21#[serde(default, deny_unknown_fields)]
22pub struct Config {
23    pub provider: ProviderConfig,
24    /// Named provider profiles. When non-empty, `provider.active` selects one.
25    pub providers: HashMap<String, ProviderConfig>,
26    pub provider_active: Option<String>,
27    pub agent: AgentConfig,
28    pub session: SessionConfig,
29    pub context: ContextConfigFile,
30    pub tools: ToolConfig,
31    pub protocol: ProtocolConfig,
32    pub tui: TuiConfig,
33    pub update: UpdateConfig,
34    pub provider_limits: ProviderLimitsFile,
35    pub skills: SkillsConfig,
36    pub agents: AgentsConfig,
37    /// The process-owned root used for sockets, credentials, skills, and adapters.
38    #[serde(skip)]
39    pub instance_home: PathBuf,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(default, deny_unknown_fields)]
44pub struct ProviderConfig {
45    pub active: Option<String>,
46    pub kind: String,
47    pub wire_api: String,
48    pub model: String,
49    pub base_url: String,
50    pub api_key: Option<String>,
51    pub api_key_env: Option<String>,
52    pub timeout_seconds: u64,
53    pub headers: HashMap<String, String>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57#[serde(default, deny_unknown_fields)]
58pub struct UpdateConfig {
59    /// Optional Cargo registry index URL used by `scv update`.
60    pub index_url: Option<String>,
61}
62
63impl Default for ProviderConfig {
64    fn default() -> Self {
65        Self {
66            active: None,
67            kind: "openai-compatible".into(),
68            wire_api: "responses".into(),
69            model: "gpt-4.1-mini".into(),
70            base_url: "https://api.openai.com/v1".into(),
71            api_key: None,
72            api_key_env: Some("OPENAI_API_KEY".into()),
73            timeout_seconds: 600,
74            headers: HashMap::new(),
75        }
76    }
77}
78
79impl Config {
80    pub fn init_user_config() -> Result<PathBuf> {
81        let path = user_config_path()
82            .ok_or_else(|| anyhow::anyhow!("cannot determine user config path"))?;
83        if let Some(parent) = path.parent() {
84            std::fs::create_dir_all(parent).context("create config directory")?;
85            ensure_private_dir(parent)?;
86        }
87        let content = "[provider]\nactive = \"openai\"\n\n[providers.openai]\nkind = \"openai-compatible\"\nmodel = \"gpt-4.1-mini\"\nbase_url = \"https://api.openai.com/v1\"\napi_key_env = \"OPENAI_API_KEY\"\n";
88        if !path.exists() {
89            let parent = path
90                .parent()
91                .ok_or_else(|| anyhow::anyhow!("configuration path has no parent"))?;
92            let mut temporary = tempfile::NamedTempFile::new_in(parent)
93                .context("create temporary example configuration")?;
94            #[cfg(unix)]
95            {
96                use std::os::unix::fs::PermissionsExt;
97                temporary
98                    .as_file()
99                    .set_permissions(std::fs::Permissions::from_mode(0o600))
100                    .context("secure temporary configuration")?;
101            }
102            temporary
103                .write_all(content.as_bytes())
104                .context("write example configuration")?;
105            temporary
106                .as_file()
107                .sync_all()
108                .context("sync example configuration")?;
109            match temporary.persist(&path) {
110                Ok(_) => {}
111                Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {}
112                Err(error) => return Err(error.error).context("install example configuration"),
113            }
114        }
115        Ok(path)
116    }
117    pub fn active_provider(&self) -> Result<ProviderConfig> {
118        if let Some(name) = self
119            .provider_active
120            .as_deref()
121            .or(self.provider.active.as_deref())
122        {
123            return self
124                .providers
125                .get(name)
126                .cloned()
127                .ok_or_else(|| anyhow::anyhow!("active provider profile {name:?} was not found"));
128        }
129        Ok(self.provider.clone())
130    }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(default, deny_unknown_fields)]
135pub struct AgentConfig {
136    pub max_steps: usize,
137    pub system_prompt: String,
138}
139
140impl Default for AgentConfig {
141    fn default() -> Self {
142        Self {
143            max_steps: 128,
144            system_prompt: "You are SCV, a concise and careful coding agent. Use tools to inspect, change, and verify the workspace.".into(),
145        }
146    }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub struct SessionConfig {
152    pub max_history_bytes: usize,
153    pub max_messages: usize,
154}
155
156impl Default for SessionConfig {
157    fn default() -> Self {
158        Self {
159            max_history_bytes: 16 * 1024 * 1024,
160            max_messages: 10_000,
161        }
162    }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(default, deny_unknown_fields)]
167pub struct ContextConfigFile {
168    pub max_tokens: usize,
169    pub reserve_output_tokens: usize,
170    pub safety_margin_tokens: usize,
171    pub bytes_per_token: usize,
172    pub summary_max_chars: usize,
173}
174
175impl Default for ContextConfigFile {
176    fn default() -> Self {
177        let value = ContextConfig::default();
178        Self {
179            max_tokens: value.max_tokens,
180            reserve_output_tokens: value.reserve_output_tokens,
181            safety_margin_tokens: value.safety_margin_tokens,
182            bytes_per_token: value.bytes_per_token,
183            summary_max_chars: value.summary_max_chars,
184        }
185    }
186}
187
188impl From<&ContextConfigFile> for ContextConfig {
189    fn from(value: &ContextConfigFile) -> Self {
190        Self {
191            max_tokens: value.max_tokens,
192            reserve_output_tokens: value.reserve_output_tokens,
193            safety_margin_tokens: value.safety_margin_tokens,
194            bytes_per_token: value.bytes_per_token,
195            summary_max_chars: value.summary_max_chars,
196        }
197    }
198}
199
200#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
201#[serde(rename_all = "kebab-case")]
202pub enum ApprovalPolicy {
203    OnRisk,
204    Always,
205    Never,
206}
207
208impl ApprovalPolicy {
209    fn strictness(self) -> u8 {
210        match self {
211            Self::OnRisk => 1,
212            Self::Always => 2,
213            Self::Never => 3,
214        }
215    }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(default, deny_unknown_fields)]
220pub struct ToolConfig {
221    pub approval_policy: ApprovalPolicy,
222    /// `bash` timeout when a call does not choose one.
223    pub command_timeout_seconds: u64,
224    /// Native-agent timeout when a call does not choose one.
225    pub agent_timeout_seconds: u64,
226    /// The longest timeout a single tool call may request.
227    pub max_timeout_seconds: u64,
228    pub output_limit_bytes: usize,
229    pub max_read_bytes: usize,
230    pub max_write_bytes: usize,
231}
232
233impl Default for ToolConfig {
234    fn default() -> Self {
235        Self {
236            approval_policy: ApprovalPolicy::OnRisk,
237            command_timeout_seconds: 600,
238            agent_timeout_seconds: 3600,
239            max_timeout_seconds: 14400,
240            output_limit_bytes: 64 * 1024,
241            max_read_bytes: 256 * 1024,
242            max_write_bytes: 1024 * 1024,
243        }
244    }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(default, deny_unknown_fields)]
249pub struct ProtocolConfig {
250    pub max_client_frame_bytes: usize,
251    pub max_server_frame_bytes: usize,
252}
253
254impl Default for ProtocolConfig {
255    fn default() -> Self {
256        Self {
257            max_client_frame_bytes: 1024 * 1024,
258            max_server_frame_bytes: 8 * 1024 * 1024,
259        }
260    }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
264#[serde(default, deny_unknown_fields)]
265pub struct TuiConfig {
266    pub max_transcript_bytes: usize,
267    pub max_transcript_items: usize,
268    pub max_prompt_history_bytes: usize,
269    pub max_prompt_history_items: usize,
270}
271
272impl Default for TuiConfig {
273    fn default() -> Self {
274        Self {
275            max_transcript_bytes: 8 * 1024 * 1024,
276            max_transcript_items: 10_000,
277            max_prompt_history_bytes: 1024 * 1024,
278            max_prompt_history_items: 200,
279        }
280    }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284#[serde(default, deny_unknown_fields)]
285pub struct ProviderLimitsFile {
286    pub max_sse_event_bytes: usize,
287    pub max_response_bytes: usize,
288    pub max_assistant_bytes: usize,
289    pub max_tool_calls: usize,
290    pub max_tool_arguments_bytes: usize,
291}
292
293impl Default for ProviderLimitsFile {
294    fn default() -> Self {
295        let value = ProviderLimits::default();
296        Self {
297            max_sse_event_bytes: value.max_sse_event_bytes,
298            max_response_bytes: value.max_response_bytes,
299            max_assistant_bytes: value.max_assistant_bytes,
300            max_tool_calls: value.max_tool_calls,
301            max_tool_arguments_bytes: value.max_tool_arguments_bytes,
302        }
303    }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(default, deny_unknown_fields)]
308pub struct SkillsConfig {
309    pub user_dir: PathBuf,
310    pub project_dir: PathBuf,
311    /// List the agent skills (`.agents/skills`, `.claude/skills`) of the
312    /// workspace and its immediate child projects in tool-enabled sessions.
313    pub scan_projects: bool,
314    pub max_skills: usize,
315    pub max_skill_bytes: usize,
316}
317
318impl Default for SkillsConfig {
319    fn default() -> Self {
320        Self {
321            user_dir: PathBuf::from("~/.scv/skills"),
322            project_dir: PathBuf::from(".scv/skills"),
323            scan_projects: true,
324            max_skills: 128,
325            max_skill_bytes: 256 * 1024,
326        }
327    }
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, Default)]
331#[serde(default, deny_unknown_fields)]
332pub struct AdapterConfig {
333    pub command: String,
334    pub args: Vec<String>,
335    /// `full` adds the CLI's own switches for unprompted, unsandboxed work.
336    pub permissions: AgentPermissions,
337    /// Placed immediately before the prompt (`grok -p <prompt>`).
338    pub prompt_args: Vec<String>,
339    /// Appended when a call selects a model; `{model}` is substituted.
340    pub model_args: Vec<String>,
341    /// Appended when a call selects an effort; `{effort}` is substituted.
342    pub effort_args: Vec<String>,
343}
344
345/// How much a delegated CLI may do without its own prompts.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
347#[serde(rename_all = "lowercase")]
348pub enum AgentPermissions {
349    /// Add nothing: the CLI's own configuration decides.
350    #[default]
351    Default,
352    /// Add the CLI's full-autonomy switches: no approval prompts, no sandbox,
353    /// and web search where the CLI gates it. An explicit user opt-in.
354    Full,
355}
356
357/// `[agents.<name>]` for every adapter in [`scv_tools::adapters::ADAPTERS`].
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(transparent)]
360pub struct AgentsConfig(pub BTreeMap<String, AdapterConfig>);
361
362impl Default for AgentsConfig {
363    fn default() -> Self {
364        let strings = |values: &[&str]| values.iter().map(|value| (*value).to_owned()).collect();
365        Self(
366            scv_tools::adapters::ADAPTERS
367                .iter()
368                .map(|adapter| {
369                    (
370                        adapter.name.to_owned(),
371                        AdapterConfig {
372                            command: adapter.command.into(),
373                            args: strings(adapter.args),
374                            permissions: AgentPermissions::Default,
375                            prompt_args: strings(adapter.prompt_args),
376                            model_args: strings(adapter.model_args),
377                            effort_args: strings(adapter.effort_args),
378                        },
379                    )
380                })
381                .collect(),
382        )
383    }
384}
385
386#[derive(Debug, Clone, Default)]
387pub struct ConfigOverrides {
388    pub provider: Option<String>,
389    pub model: Option<String>,
390    pub base_url: Option<String>,
391    pub approval_policy: Option<ApprovalPolicy>,
392    pub no_tools: bool,
393}
394
395impl Config {
396    pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
397        Self::load_layers(Some(workspace), overrides)
398    }
399
400    /// Load without a project layer, for settings that project configuration
401    /// can never set (such as `[agents]`), so the caller's directory is irrelevant.
402    pub fn load_user(overrides: ConfigOverrides) -> Result<Self> {
403        Self::load_layers(None, overrides)
404    }
405
406    fn load_layers(
407        workspace: Option<&std::path::Path>,
408        overrides: ConfigOverrides,
409    ) -> Result<Self> {
410        let instance_home = user_home_path()
411            .ok_or_else(|| anyhow::anyhow!("cannot determine SCV instance home"))?;
412        std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
413        ensure_private_dir(&instance_home)?;
414        let mut value: toml::Value = toml::from_str(
415            &toml::to_string(&Self::default()).context("serialize default configuration")?,
416        )?;
417
418        if let Some(user_path) = user_config_path()
419            && user_path.is_file()
420        {
421            #[cfg(unix)]
422            {
423                use std::os::unix::fs::PermissionsExt;
424                if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
425                    bail!("user configuration is readable by group or others; run chmod 600");
426                }
427            }
428            merge(&mut value, read_layer(&user_path)?);
429        }
430        let user_baseline: Self = value
431            .clone()
432            .try_into()
433            .context("parse user configuration")?;
434
435        if let Some(workspace) = workspace {
436            let project_path = workspace.join(".scv/config.toml");
437            // A workspace whose `.scv` is the SCV home (such as running from
438            // `~`) has no project layer: that file is the user configuration,
439            // already applied above at full trust.
440            let user_file = user_config_path().and_then(|path| std::fs::canonicalize(path).ok());
441            if project_path.is_file() {
442                let canonical_project = std::fs::canonicalize(&project_path)
443                    .with_context(|| format!("resolve configuration {}", project_path.display()))?;
444                if user_file.as_ref() != Some(&canonical_project) {
445                    if !canonical_project.starts_with(workspace) {
446                        bail!("project configuration escaped workspace");
447                    }
448                    let project = read_layer(&canonical_project)?;
449                    validate_project_keys(&project)?;
450                    let mut candidate_value = value.clone();
451                    merge(&mut candidate_value, project);
452                    let candidate: Self = candidate_value
453                        .clone()
454                        .try_into()
455                        .context("parse project configuration")?;
456                    validate_project_not_weaker(&user_baseline, &candidate)?;
457                    value = candidate_value;
458                }
459            }
460        }
461
462        if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
463            let path = PathBuf::from(explicit);
464            #[cfg(unix)]
465            {
466                use std::os::unix::fs::PermissionsExt;
467                if std::fs::metadata(&path)?.permissions().mode() & 0o077 != 0 {
468                    bail!("explicit configuration is readable by group or others; run chmod 600");
469                }
470            }
471            merge(&mut value, read_layer(&path)?);
472        }
473        let mut config: Self = value.try_into().context("parse merged configuration")?;
474        if let Some(name) = overrides.provider.as_deref() {
475            config.provider_active = Some(name.to_owned());
476        }
477        let selected = config.active_provider()?;
478        config.provider = selected;
479        if let Ok(model) = std::env::var("SCV_MODEL") {
480            config.provider.model = model;
481        }
482        if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
483            config.provider.base_url = base_url;
484        }
485        if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
486            config.provider.api_key_env = Some(api_key_env);
487        }
488        if let Some(model) = overrides.model {
489            config.provider.model = model;
490        }
491        if let Some(base_url) = overrides.base_url {
492            config.provider.base_url = base_url;
493        }
494        if let Some(policy) = overrides.approval_policy {
495            config.tools.approval_policy = policy;
496        }
497        if config.skills.user_dir == std::path::Path::new("~/.scv/skills")
498            && let Some(home) = std::env::var_os("SCV_HOME")
499        {
500            config.skills.user_dir = PathBuf::from(home).join("skills");
501        }
502        config.skills.user_dir = expand_home(&config.skills.user_dir);
503        config.instance_home = instance_home;
504        config.validate()?;
505        Ok(config)
506    }
507
508    pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
509        CoreAgentConfig {
510            system_prompt,
511            max_steps: self.agent.max_steps,
512            history_limits: HistoryLimits {
513                max_bytes: self.session.max_history_bytes,
514                max_messages: self.session.max_messages,
515                note_max_chars: self.context.summary_max_chars,
516            },
517        }
518    }
519
520    pub fn tools(&self) -> ToolsConfig {
521        ToolsConfig {
522            command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
523            agent_timeout: Duration::from_secs(self.tools.agent_timeout_seconds),
524            max_timeout: Duration::from_secs(self.tools.max_timeout_seconds),
525            output_limit_bytes: self.tools.output_limit_bytes,
526            max_read_bytes: self.tools.max_read_bytes,
527            max_write_bytes: self.tools.max_write_bytes,
528        }
529    }
530
531    pub fn provider_limits(&self) -> ProviderLimits {
532        ProviderLimits {
533            max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
534            max_response_bytes: self.provider_limits.max_response_bytes,
535            max_assistant_bytes: self.provider_limits.max_assistant_bytes,
536            max_tool_calls: self.provider_limits.max_tool_calls,
537            max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
538        }
539    }
540
541    pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
542        let user_home = dirs::home_dir();
543        self.agents
544            .0
545            .iter()
546            .filter_map(|(name, config)| {
547                let descriptor = scv_tools::adapters::adapter(name)?;
548                let adapter_home = self.instance_home.join("adapters").join(name);
549                let mut environment = vec![
550                    (OsString::from("SCV_HOME"), adapter_home.clone().into()),
551                    (OsString::from("HOME"), adapter_home.clone().into()),
552                    (
553                        OsString::from("XDG_CONFIG_HOME"),
554                        adapter_home.join("config").into(),
555                    ),
556                    (
557                        OsString::from("XDG_DATA_HOME"),
558                        adapter_home.join("data").into(),
559                    ),
560                    (
561                        OsString::from("XDG_STATE_HOME"),
562                        adapter_home.join("state").into(),
563                    ),
564                ];
565                for (variable, relative) in descriptor.home_environment {
566                    let path = if relative.is_empty() {
567                        adapter_home.clone()
568                    } else {
569                        adapter_home.join(relative)
570                    };
571                    environment.push((OsString::from(variable), path.into()));
572                }
573                let full = config.permissions == AgentPermissions::Full;
574                environment.extend(
575                    descriptor
576                        .fixed_environment
577                        .iter()
578                        .chain(
579                            descriptor
580                                .full_permission_environment
581                                .iter()
582                                .filter(|_| full),
583                        )
584                        .map(|(variable, value)| (OsString::from(variable), OsString::from(value))),
585                );
586                Some((
587                    format!("agent_{name}"),
588                    AgentAdapterConfig {
589                        command: config.command.clone(),
590                        args: config.args.clone(),
591                        prompt_args: config.prompt_args.clone(),
592                        full_permission_args: full.then(|| {
593                            descriptor
594                                .full_permission_args
595                                .iter()
596                                .map(|arg| (*arg).to_owned())
597                                .collect()
598                        }),
599                        model_args: config.model_args.clone(),
600                        effort_args: config.effort_args.clone(),
601                        model_hint: descriptor.model_hint.into(),
602                        environment,
603                        search_dirs: user_home
604                            .as_deref()
605                            .map(|home| scv_tools::adapters::adapter_search_dirs(descriptor, home))
606                            .unwrap_or_default(),
607                    },
608                ))
609            })
610            .collect()
611    }
612
613    pub fn prepare_adapter_homes(&self) -> Result<()> {
614        for name in self.agents.0.keys() {
615            let path = self.instance_home.join("adapters").join(name);
616            std::fs::create_dir_all(&path)
617                .with_context(|| format!("create isolated {name} adapter home"))?;
618            #[cfg(unix)]
619            {
620                use std::os::unix::fs::PermissionsExt;
621                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
622                    .with_context(|| format!("secure isolated {name} adapter home"))?;
623            }
624        }
625        Ok(())
626    }
627
628    fn validate(&self) -> Result<()> {
629        if self.provider.kind != "openai-compatible" {
630            bail!("provider.kind must be openai-compatible in v0.1");
631        }
632        if self.provider.model.trim().is_empty()
633            || self.provider.base_url.trim().is_empty()
634            || self
635                .provider
636                .api_key
637                .as_deref()
638                .unwrap_or("")
639                .trim()
640                .is_empty()
641                && self
642                    .provider
643                    .api_key_env
644                    .as_deref()
645                    .unwrap_or("")
646                    .trim()
647                    .is_empty()
648        {
649            bail!(
650                "provider model and base_url must be non-empty; configure api_key or api_key_env"
651            );
652        }
653        for (agent, adapter) in &self.agents.0 {
654            if scv_tools::adapters::adapter(agent).is_none() {
655                let known: Vec<_> = scv_tools::adapters::ADAPTERS
656                    .iter()
657                    .map(|adapter| adapter.name)
658                    .collect();
659                bail!(
660                    "unknown agent [agents.{agent}]; known agents are {}",
661                    known.join(", ")
662                );
663            }
664            let name = format!("agents.{agent}.command");
665            if adapter.command.trim().is_empty() {
666                bail!("{name} must be non-empty");
667            }
668            for (field, template, placeholder) in [
669                ("model_args", &adapter.model_args, "{model}"),
670                ("effort_args", &adapter.effort_args, "{effort}"),
671            ] {
672                if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
673                    let adapter = name.trim_end_matches(".command");
674                    bail!("{adapter}.{field} must contain {placeholder} or be empty");
675                }
676            }
677            let adapter_bytes = adapter.command.len()
678                + [
679                    &adapter.args,
680                    &adapter.prompt_args,
681                    &adapter.model_args,
682                    &adapter.effort_args,
683                ]
684                .into_iter()
685                .flatten()
686                .map(String::len)
687                .sum::<usize>();
688            if adapter_bytes > 16 * 1024 {
689                bail!("{name} and its fixed arguments exceed 16384 bytes");
690            }
691        }
692        let positives = [
693            (
694                "provider.timeout_seconds",
695                usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
696            ),
697            ("agent.max_steps", self.agent.max_steps),
698            ("session.max_history_bytes", self.session.max_history_bytes),
699            ("session.max_messages", self.session.max_messages),
700            ("context.max_tokens", self.context.max_tokens),
701            ("context.bytes_per_token", self.context.bytes_per_token),
702            ("context.summary_max_chars", self.context.summary_max_chars),
703            (
704                "tools.command_timeout_seconds",
705                usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
706            ),
707            (
708                "tools.agent_timeout_seconds",
709                usize::try_from(self.tools.agent_timeout_seconds).unwrap_or(usize::MAX),
710            ),
711            (
712                "tools.max_timeout_seconds",
713                usize::try_from(self.tools.max_timeout_seconds).unwrap_or(usize::MAX),
714            ),
715            ("tools.output_limit_bytes", self.tools.output_limit_bytes),
716            ("tools.max_read_bytes", self.tools.max_read_bytes),
717            ("tools.max_write_bytes", self.tools.max_write_bytes),
718            (
719                "protocol.max_client_frame_bytes",
720                self.protocol.max_client_frame_bytes,
721            ),
722            (
723                "protocol.max_server_frame_bytes",
724                self.protocol.max_server_frame_bytes,
725            ),
726            ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
727            ("tui.max_transcript_items", self.tui.max_transcript_items),
728            (
729                "tui.max_prompt_history_bytes",
730                self.tui.max_prompt_history_bytes,
731            ),
732            (
733                "tui.max_prompt_history_items",
734                self.tui.max_prompt_history_items,
735            ),
736            (
737                "provider_limits.max_sse_event_bytes",
738                self.provider_limits.max_sse_event_bytes,
739            ),
740            (
741                "provider_limits.max_response_bytes",
742                self.provider_limits.max_response_bytes,
743            ),
744            (
745                "provider_limits.max_assistant_bytes",
746                self.provider_limits.max_assistant_bytes,
747            ),
748            (
749                "provider_limits.max_tool_calls",
750                self.provider_limits.max_tool_calls,
751            ),
752            (
753                "provider_limits.max_tool_arguments_bytes",
754                self.provider_limits.max_tool_arguments_bytes,
755            ),
756            ("skills.max_skills", self.skills.max_skills),
757            ("skills.max_skill_bytes", self.skills.max_skill_bytes),
758        ];
759        if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
760            bail!("{name} must be positive");
761        }
762        for (name, value) in [
763            (
764                "tools.command_timeout_seconds",
765                self.tools.command_timeout_seconds,
766            ),
767            (
768                "tools.agent_timeout_seconds",
769                self.tools.agent_timeout_seconds,
770            ),
771        ] {
772            if value > self.tools.max_timeout_seconds {
773                bail!("{name} exceeds tools.max_timeout_seconds");
774            }
775        }
776        if self.tools.max_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS {
777            bail!("tools.max_timeout_seconds must be at most {MAX_TOOL_TIMEOUT_SECONDS}");
778        }
779        if self
780            .context
781            .reserve_output_tokens
782            .saturating_add(self.context.safety_margin_tokens)
783            >= self.context.max_tokens
784        {
785            bail!("context reserve and safety margin consume max_tokens");
786        }
787        let worst_assistant_frame = self
788            .provider_limits
789            .max_assistant_bytes
790            .saturating_mul(6)
791            .saturating_add(64 * 1024);
792        if worst_assistant_frame > self.protocol.max_server_frame_bytes {
793            bail!(
794                "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
795            );
796        }
797        if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
798            bail!("tool argument limit exceeds provider response limit");
799        }
800        if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
801            bail!("provider SSE event limit exceeds provider response limit");
802        }
803        if self.protocol.max_client_frame_bytes < 4096 {
804            bail!("protocol.max_client_frame_bytes must be at least 4096");
805        }
806        if self.protocol.max_server_frame_bytes < 64 * 1024 {
807            bail!("protocol.max_server_frame_bytes must be at least 65536");
808        }
809        let worst_tool_frame = self
810            .tools
811            .output_limit_bytes
812            .max(self.tools.max_read_bytes)
813            .saturating_mul(12)
814            .saturating_add(64 * 1024);
815        let worst_skill_frame = self
816            .skills
817            .max_skill_bytes
818            .saturating_mul(6)
819            .saturating_add(64 * 1024);
820        let worst_arguments_frame = self
821            .provider_limits
822            .max_tool_arguments_bytes
823            .saturating_mul(6)
824            .saturating_add(64 * 1024);
825        if worst_tool_frame
826            .max(worst_skill_frame)
827            .max(worst_arguments_frame)
828            > self.protocol.max_server_frame_bytes
829        {
830            bail!(
831                "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
832            );
833        }
834        if self.skills.project_dir.is_absolute()
835            || self
836                .skills
837                .project_dir
838                .components()
839                .any(|component| matches!(component, std::path::Component::ParentDir))
840        {
841            bail!("skills.project_dir must be a contained relative path");
842        }
843        Ok(())
844    }
845}
846
847fn user_config_path() -> Option<PathBuf> {
848    user_home_path().map(|path| path.join("config.toml"))
849}
850
851pub fn user_home_path() -> Option<PathBuf> {
852    let path = std::env::var_os("SCV_HOME")
853        .map(PathBuf::from)
854        .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))?;
855    if path.exists() {
856        Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
857    } else if path.is_absolute() {
858        Some(path)
859    } else {
860        std::env::current_dir().ok().map(|cwd| cwd.join(path))
861    }
862}
863
864fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
865    #[cfg(unix)]
866    {
867        use std::os::unix::fs::PermissionsExt;
868        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
869            .with_context(|| format!("secure directory {}", path.display()))?;
870    }
871    Ok(())
872}
873
874fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
875    let size = std::fs::metadata(path)
876        .with_context(|| format!("stat configuration {}", path.display()))?
877        .len();
878    if size > MAX_CONFIG_BYTES {
879        bail!("configuration {} exceeds 1 MiB", path.display());
880    }
881    let content = std::fs::read_to_string(path)
882        .with_context(|| format!("read configuration {}", path.display()))?;
883    toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
884}
885
886fn merge(base: &mut toml::Value, overlay: toml::Value) {
887    match (base, overlay) {
888        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
889            for (key, value) in overlay {
890                match base.get_mut(&key) {
891                    Some(existing) => merge(existing, value),
892                    None => {
893                        base.insert(key, value);
894                    }
895                }
896            }
897        }
898        (base, overlay) => *base = overlay,
899    }
900}
901
902fn validate_project_keys(value: &toml::Value) -> Result<()> {
903    let Some(table) = value.as_table() else {
904        bail!("project configuration must be a TOML table");
905    };
906    for forbidden in [
907        "provider",
908        "providers",
909        "provider_active",
910        "agents",
911        "update",
912    ] {
913        if table.contains_key(forbidden) {
914            bail!("project configuration cannot set [{forbidden}]");
915        }
916    }
917    if table
918        .get("skills")
919        .and_then(toml::Value::as_table)
920        .is_some_and(|skills| skills.contains_key("user_dir"))
921    {
922        bail!("project configuration cannot set skills.user_dir");
923    }
924    if table
925        .get("agent")
926        .and_then(toml::Value::as_table)
927        .is_some_and(|agent| agent.contains_key("system_prompt"))
928    {
929        bail!("project configuration cannot replace agent.system_prompt");
930    }
931    Ok(())
932}
933
934fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
935    macro_rules! no_larger {
936        ($field:expr, $name:literal) => {
937            if $field.1 > $field.0 {
938                bail!(concat!("project configuration cannot raise ", $name));
939            }
940        };
941    }
942    no_larger!(
943        (user.agent.max_steps, project.agent.max_steps),
944        "agent.max_steps"
945    );
946    no_larger!(
947        (
948            user.session.max_history_bytes,
949            project.session.max_history_bytes
950        ),
951        "session.max_history_bytes"
952    );
953    no_larger!(
954        (user.session.max_messages, project.session.max_messages),
955        "session.max_messages"
956    );
957    no_larger!(
958        (user.context.max_tokens, project.context.max_tokens),
959        "context.max_tokens"
960    );
961    no_larger!(
962        (
963            user.context.summary_max_chars,
964            project.context.summary_max_chars
965        ),
966        "context.summary_max_chars"
967    );
968    no_larger!(
969        (
970            user.tools.command_timeout_seconds,
971            project.tools.command_timeout_seconds
972        ),
973        "tools.command_timeout_seconds"
974    );
975    no_larger!(
976        (
977            user.tools.agent_timeout_seconds,
978            project.tools.agent_timeout_seconds
979        ),
980        "tools.agent_timeout_seconds"
981    );
982    no_larger!(
983        (
984            user.tools.max_timeout_seconds,
985            project.tools.max_timeout_seconds
986        ),
987        "tools.max_timeout_seconds"
988    );
989    no_larger!(
990        (
991            user.tools.output_limit_bytes,
992            project.tools.output_limit_bytes
993        ),
994        "tools.output_limit_bytes"
995    );
996    no_larger!(
997        (user.tools.max_read_bytes, project.tools.max_read_bytes),
998        "tools.max_read_bytes"
999    );
1000    no_larger!(
1001        (user.tools.max_write_bytes, project.tools.max_write_bytes),
1002        "tools.max_write_bytes"
1003    );
1004    no_larger!(
1005        (
1006            user.protocol.max_client_frame_bytes,
1007            project.protocol.max_client_frame_bytes
1008        ),
1009        "protocol.max_client_frame_bytes"
1010    );
1011    no_larger!(
1012        (
1013            user.protocol.max_server_frame_bytes,
1014            project.protocol.max_server_frame_bytes
1015        ),
1016        "protocol.max_server_frame_bytes"
1017    );
1018    no_larger!(
1019        (
1020            user.provider_limits.max_response_bytes,
1021            project.provider_limits.max_response_bytes
1022        ),
1023        "provider_limits.max_response_bytes"
1024    );
1025    no_larger!(
1026        (
1027            user.provider_limits.max_sse_event_bytes,
1028            project.provider_limits.max_sse_event_bytes
1029        ),
1030        "provider_limits.max_sse_event_bytes"
1031    );
1032    no_larger!(
1033        (
1034            user.provider_limits.max_assistant_bytes,
1035            project.provider_limits.max_assistant_bytes
1036        ),
1037        "provider_limits.max_assistant_bytes"
1038    );
1039    no_larger!(
1040        (
1041            user.provider_limits.max_tool_calls,
1042            project.provider_limits.max_tool_calls
1043        ),
1044        "provider_limits.max_tool_calls"
1045    );
1046    no_larger!(
1047        (
1048            user.provider_limits.max_tool_arguments_bytes,
1049            project.provider_limits.max_tool_arguments_bytes
1050        ),
1051        "provider_limits.max_tool_arguments_bytes"
1052    );
1053    no_larger!(
1054        (
1055            user.tui.max_transcript_bytes,
1056            project.tui.max_transcript_bytes
1057        ),
1058        "tui.max_transcript_bytes"
1059    );
1060    no_larger!(
1061        (
1062            user.tui.max_transcript_items,
1063            project.tui.max_transcript_items
1064        ),
1065        "tui.max_transcript_items"
1066    );
1067    no_larger!(
1068        (
1069            user.tui.max_prompt_history_bytes,
1070            project.tui.max_prompt_history_bytes
1071        ),
1072        "tui.max_prompt_history_bytes"
1073    );
1074    no_larger!(
1075        (
1076            user.tui.max_prompt_history_items,
1077            project.tui.max_prompt_history_items
1078        ),
1079        "tui.max_prompt_history_items"
1080    );
1081    no_larger!(
1082        (user.skills.max_skills, project.skills.max_skills),
1083        "skills.max_skills"
1084    );
1085    no_larger!(
1086        (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
1087        "skills.max_skill_bytes"
1088    );
1089    if project.context.reserve_output_tokens < user.context.reserve_output_tokens
1090        || project.context.safety_margin_tokens < user.context.safety_margin_tokens
1091    {
1092        bail!("project configuration cannot lower context reserves");
1093    }
1094    if project.context.bytes_per_token > user.context.bytes_per_token {
1095        bail!("project configuration cannot raise context.bytes_per_token");
1096    }
1097    if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
1098        bail!("project configuration cannot weaken tools.approval_policy");
1099    }
1100    if project.skills.scan_projects && !user.skills.scan_projects {
1101        bail!("project configuration cannot enable skills.scan_projects");
1102    }
1103    Ok(())
1104}
1105
1106fn expand_home(path: &std::path::Path) -> PathBuf {
1107    let value = path.to_string_lossy();
1108    if value == "~" {
1109        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
1110    }
1111    if let Some(rest) = value.strip_prefix("~/")
1112        && let Some(home) = dirs::home_dir()
1113    {
1114        return home.join(rest);
1115    }
1116    path.to_path_buf()
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122
1123    #[test]
1124    fn project_cannot_redirect_provider_or_agent() {
1125        let provider: toml::Value = toml::from_str(
1126            r#"[provider]
1127base_url = "https://attacker.invalid"
1128"#,
1129        )
1130        .unwrap();
1131        assert!(validate_project_keys(&provider).is_err());
1132
1133        let agent: toml::Value = toml::from_str(
1134            r#"[agents.codex]
1135command = "/tmp/fake"
1136"#,
1137        )
1138        .unwrap();
1139        assert!(validate_project_keys(&agent).is_err());
1140    }
1141
1142    #[test]
1143    fn project_may_tighten_but_not_weaken_limits() {
1144        let user = Config::default();
1145        let mut tighter = user.clone();
1146        tighter.tools.output_limit_bytes /= 2;
1147        tighter.tools.approval_policy = ApprovalPolicy::Always;
1148        assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1149
1150        let mut weaker = user.clone();
1151        weaker.tools.output_limit_bytes *= 2;
1152        assert!(validate_project_not_weaker(&user, &weaker).is_err());
1153    }
1154
1155    #[test]
1156    fn timeouts_default_below_a_ceiling_that_projects_may_only_lower() {
1157        let user = Config::default();
1158        assert_eq!(
1159            (
1160                user.tools.command_timeout_seconds,
1161                user.tools.agent_timeout_seconds,
1162                user.tools.max_timeout_seconds
1163            ),
1164            (600, 3600, 14400)
1165        );
1166        assert_eq!(user.agent.max_steps, 128);
1167        assert_eq!(user.provider.timeout_seconds, 600);
1168        let tools = user.tools();
1169        assert_eq!(tools.command_timeout, Duration::from_secs(600));
1170        assert_eq!(tools.agent_timeout, Duration::from_secs(3600));
1171        assert_eq!(tools.max_timeout, Duration::from_secs(14400));
1172        // A ClawBot owner turn outlasts the ceiling by five minutes: 4h05m.
1173        assert_eq!(
1174            scv_clawbot::owner_turn_timeout(tools.max_timeout),
1175            Duration::from_secs(4 * 3600 + 5 * 60)
1176        );
1177
1178        for (field, name) in [
1179            (0, "tools.command_timeout_seconds"),
1180            (1, "tools.agent_timeout_seconds"),
1181        ] {
1182            let mut config = Config::default();
1183            let value = if field == 0 {
1184                &mut config.tools.command_timeout_seconds
1185            } else {
1186                &mut config.tools.agent_timeout_seconds
1187            };
1188            *value = config.tools.max_timeout_seconds + 1;
1189            assert_eq!(
1190                config.validate().unwrap_err().to_string(),
1191                format!("{name} exceeds tools.max_timeout_seconds")
1192            );
1193        }
1194        let mut unbounded = Config::default();
1195        unbounded.tools.max_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS + 1;
1196        assert!(unbounded.validate().is_err());
1197        let mut zero = Config::default();
1198        zero.tools.agent_timeout_seconds = 0;
1199        assert!(zero.validate().is_err());
1200
1201        let mut lower = user.clone();
1202        lower.tools.max_timeout_seconds = 900;
1203        lower.tools.agent_timeout_seconds = 300;
1204        assert!(validate_project_not_weaker(&user, &lower).is_ok());
1205        for raise in [
1206            |config: &mut Config| config.tools.max_timeout_seconds += 1,
1207            |config: &mut Config| config.tools.agent_timeout_seconds += 1,
1208        ] {
1209            let mut higher = user.clone();
1210            raise(&mut higher);
1211            assert!(validate_project_not_weaker(&user, &higher).is_err());
1212        }
1213    }
1214
1215    #[test]
1216    fn projects_may_disable_but_not_enable_project_skill_scanning() {
1217        let user = Config::default();
1218        let mut disabled = user.clone();
1219        disabled.skills.scan_projects = false;
1220        assert!(validate_project_not_weaker(&user, &disabled).is_ok());
1221        assert!(validate_project_not_weaker(&disabled, &user).is_err());
1222    }
1223
1224    #[test]
1225    fn cross_field_validation_accounts_for_json_escaping() {
1226        let mut config = Config::default();
1227        config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
1228        assert!(config.validate().is_err());
1229    }
1230
1231    #[test]
1232    fn adapter_selection_templates_survive_partial_overrides_and_validate() {
1233        let mut value: toml::Value =
1234            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1235        merge(
1236            &mut value,
1237            toml::from_str(
1238                r#"[agents.claude]
1239args = ["-p", "--permission-mode", "acceptEdits"]
1240"#,
1241            )
1242            .unwrap(),
1243        );
1244        let config: Config = value.try_into().unwrap();
1245        let claude = &config.agents.0["claude"];
1246        assert_eq!(claude.args.len(), 3);
1247        assert_eq!(claude.model_args, ["--model", "{model}"]);
1248        assert_eq!(claude.effort_args, ["--effort", "{effort}"]);
1249        assert_eq!(
1250            config.agents.0["pi"].effort_args,
1251            ["--thinking", "{effort}"]
1252        );
1253        assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1254
1255        let mut invalid = Config::default();
1256        invalid.agents.0.get_mut("claude").unwrap().effort_args = vec!["--effort".into()];
1257        assert!(
1258            invalid
1259                .validate()
1260                .unwrap_err()
1261                .to_string()
1262                .contains("agents.claude.effort_args must contain {effort}")
1263        );
1264    }
1265
1266    #[test]
1267    fn adapters_are_bound_to_the_instance_home() {
1268        let config = Config {
1269            instance_home: PathBuf::from("/tmp/scv-instance"),
1270            ..Config::default()
1271        };
1272        let adapters = config.adapters();
1273        let codex = &adapters["agent_codex"];
1274        assert!(codex.environment.contains(&(
1275            OsString::from("CODEX_HOME"),
1276            OsString::from("/tmp/scv-instance/adapters/codex")
1277        )));
1278        assert!(codex.environment.contains(&(
1279            OsString::from("SCV_HOME"),
1280            OsString::from("/tmp/scv-instance/adapters/codex")
1281        )));
1282        for (agent, variable, path) in [
1283            ("grok", "GROK_HOME", "/tmp/scv-instance/adapters/grok/.grok"),
1284            ("dsh", "DSH_HOME", "/tmp/scv-instance/adapters/dsh/.dsh"),
1285            (
1286                "pi",
1287                "PI_CODING_AGENT_DIR",
1288                "/tmp/scv-instance/adapters/pi/.pi/agent",
1289            ),
1290        ] {
1291            let adapter = &adapters[&format!("agent_{agent}")];
1292            assert!(
1293                adapter
1294                    .environment
1295                    .contains(&(OsString::from(variable), OsString::from(path))),
1296                "{agent}"
1297            );
1298            assert!(adapter.environment.contains(&(
1299                OsString::from("HOME"),
1300                OsString::from(format!("/tmp/scv-instance/adapters/{agent}"))
1301            )));
1302        }
1303        assert!(adapters["agent_grok"].environment.contains(&(
1304            OsString::from("GROK_DISABLE_AUTOUPDATER"),
1305            OsString::from("1")
1306        )));
1307        assert_eq!(adapters["agent_grok"].prompt_args, ["-p"]);
1308        assert!(adapters["agent_pi"].model_hint.contains("provider scv"));
1309    }
1310
1311    #[test]
1312    fn full_permissions_are_opt_in_per_agent_and_combine_with_args() {
1313        let defaults = Config::default().adapters();
1314        for adapter in defaults.values() {
1315            assert_eq!(adapter.full_permission_args, None);
1316        }
1317        let mut value: toml::Value =
1318            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1319        merge(
1320            &mut value,
1321            toml::from_str(
1322                "[agents.claude]\npermissions = \"full\"\n\n\
1323                 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\npermissions = \"full\"\n\n\
1324                 [agents.grok]\npermissions = \"full\"\n\n\
1325                 [agents.dsh]\npermissions = \"full\"\n\n\
1326                 [agents.pi]\npermissions = \"full\"\n",
1327            )
1328            .unwrap(),
1329        );
1330        let config: Config = value.try_into().unwrap();
1331        config.validate().unwrap();
1332        let adapters = config.adapters();
1333        let full = |agent: &str| {
1334            adapters[&format!("agent_{agent}")]
1335                .full_permission_args
1336                .clone()
1337                .unwrap()
1338        };
1339        assert_eq!(full("claude"), ["--permission-mode", "bypassPermissions"]);
1340        assert_eq!(
1341            full("codex"),
1342            [
1343                "--dangerously-bypass-approvals-and-sandbox",
1344                "-c",
1345                "web_search=\"live\""
1346            ]
1347        );
1348        assert_eq!(
1349            adapters["agent_codex"].args,
1350            ["exec", "--skip-git-repo-check"]
1351        );
1352        assert_eq!(full("grok"), ["--always-approve"]);
1353        assert!(full("dsh").is_empty());
1354        assert!(adapters["agent_dsh"].environment.contains(&(
1355            OsString::from("DSH_PERMISSION_MODE"),
1356            OsString::from("danger-full-access")
1357        )));
1358        assert!(
1359            !defaults["agent_dsh"]
1360                .environment
1361                .iter()
1362                .any(|(variable, _)| variable == "DSH_PERMISSION_MODE")
1363        );
1364        // pi has no permission system: `full` is accepted and adds nothing.
1365        assert!(full("pi").is_empty());
1366
1367        let mut invalid: toml::Value =
1368            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1369        merge(
1370            &mut invalid,
1371            toml::from_str("[agents.claude]\npermissions = \"yolo\"\n").unwrap(),
1372        );
1373        assert!(invalid.try_into::<Config>().is_err());
1374    }
1375
1376    #[test]
1377    fn user_agent_overrides_merge_over_every_built_in_and_unknown_agents_fail() {
1378        let mut value: toml::Value =
1379            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1380        merge(
1381            &mut value,
1382            toml::from_str(
1383                "[agents.pi]
1384model_args = []
1385
1386[agents.grok]
1387args = [\"--always-approve\"]
1388",
1389            )
1390            .unwrap(),
1391        );
1392        let config: Config = value.clone().try_into().unwrap();
1393        assert!(config.agents.0["pi"].model_args.is_empty());
1394        assert_eq!(config.agents.0["pi"].args, ["-p"]);
1395        assert_eq!(config.agents.0["grok"].args, ["--always-approve"]);
1396        assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1397        assert_eq!(
1398            config.agents.0.keys().collect::<Vec<_>>(),
1399            ["claude", "codex", "dsh", "grok", "pi"]
1400        );
1401
1402        merge(
1403            &mut value,
1404            toml::from_str(
1405                "[agents.zcode]
1406command = \"zcode\"
1407",
1408            )
1409            .unwrap(),
1410        );
1411        let unknown: Config = value.try_into().unwrap();
1412        let error = unknown.validate().unwrap_err().to_string();
1413        assert!(error.contains("unknown agent [agents.zcode]"), "{error}");
1414    }
1415}