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