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::{
13    AgentAdapterConfig, ToolsConfig,
14    conversation::ConversationLimits,
15    web::{SearchBackend, WebToolsConfig},
16};
17use serde::{Deserialize, Serialize};
18
19const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
20/// A day: long enough for any delegated job, short enough that deadline
21/// arithmetic never overflows.
22const MAX_TOOL_TIMEOUT_SECONDS: u64 = 24 * 60 * 60;
23/// Retries multiply provider load and turn latency, so they stay small.
24const MAX_PROVIDER_RETRIES: usize = 10;
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27#[serde(default, deny_unknown_fields)]
28pub struct Config {
29    pub provider: ProviderConfig,
30    /// Named provider profiles. When non-empty, `provider.active` selects one.
31    pub providers: HashMap<String, ProviderConfig>,
32    pub provider_active: Option<String>,
33    pub agent: AgentConfig,
34    pub session: SessionConfig,
35    pub context: ContextConfigFile,
36    pub tools: ToolConfig,
37    pub protocol: ProtocolConfig,
38    pub tui: TuiConfig,
39    pub update: UpdateConfig,
40    pub provider_limits: ProviderLimitsFile,
41    pub skills: SkillsConfig,
42    pub agents: AgentsConfig,
43    pub web: WebConfig,
44    /// The process-owned root used for sockets, credentials, skills, and adapters.
45    #[serde(skip)]
46    pub instance_home: PathBuf,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(default, deny_unknown_fields)]
51pub struct ProviderConfig {
52    pub active: Option<String>,
53    pub kind: String,
54    pub wire_api: String,
55    pub model: String,
56    pub base_url: String,
57    pub api_key: Option<String>,
58    pub api_key_env: Option<String>,
59    pub timeout_seconds: u64,
60    pub headers: HashMap<String, String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64#[serde(default, deny_unknown_fields)]
65pub struct UpdateConfig {
66    /// Optional Cargo registry index URL used by `scv update`.
67    pub index_url: Option<String>,
68}
69
70impl Default for ProviderConfig {
71    fn default() -> Self {
72        Self {
73            active: None,
74            kind: "openai-compatible".into(),
75            wire_api: "responses".into(),
76            model: "gpt-4.1-mini".into(),
77            base_url: "https://api.openai.com/v1".into(),
78            api_key: None,
79            api_key_env: Some("OPENAI_API_KEY".into()),
80            timeout_seconds: 600,
81            headers: HashMap::new(),
82        }
83    }
84}
85
86impl Config {
87    pub fn init_user_config() -> Result<PathBuf> {
88        let path = user_config_path()
89            .ok_or_else(|| anyhow::anyhow!("cannot determine user config path"))?;
90        if let Some(parent) = path.parent() {
91            std::fs::create_dir_all(parent).context("create config directory")?;
92            ensure_private_dir(parent)?;
93        }
94        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";
95        if !path.exists() {
96            let parent = path
97                .parent()
98                .ok_or_else(|| anyhow::anyhow!("configuration path has no parent"))?;
99            let mut temporary = tempfile::NamedTempFile::new_in(parent)
100                .context("create temporary example configuration")?;
101            #[cfg(unix)]
102            {
103                use std::os::unix::fs::PermissionsExt;
104                temporary
105                    .as_file()
106                    .set_permissions(std::fs::Permissions::from_mode(0o600))
107                    .context("secure temporary configuration")?;
108            }
109            temporary
110                .write_all(content.as_bytes())
111                .context("write example configuration")?;
112            temporary
113                .as_file()
114                .sync_all()
115                .context("sync example configuration")?;
116            match temporary.persist(&path) {
117                Ok(_) => {}
118                Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {}
119                Err(error) => return Err(error.error).context("install example configuration"),
120            }
121        }
122        Ok(path)
123    }
124    pub fn active_provider(&self) -> Result<ProviderConfig> {
125        if let Some(name) = self
126            .provider_active
127            .as_deref()
128            .or(self.provider.active.as_deref())
129        {
130            return self
131                .providers
132                .get(name)
133                .cloned()
134                .ok_or_else(|| anyhow::anyhow!("active provider profile {name:?} was not found"));
135        }
136        Ok(self.provider.clone())
137    }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default, deny_unknown_fields)]
142pub struct AgentConfig {
143    pub max_steps: usize,
144    pub system_prompt: String,
145    /// `agent_*` tools are offered only while this SCV's own delegation depth
146    /// is below this, so delegation chains stay bounded. 0 disables them.
147    pub max_delegation_depth: u32,
148    /// Delegated conversations a session remembers; starting another forgets
149    /// the least recently used idle one.
150    pub max_conversations: usize,
151    /// A delegated conversation unused this long is forgotten.
152    pub conversation_idle_seconds: u64,
153}
154
155impl Default for AgentConfig {
156    fn default() -> Self {
157        Self {
158            max_steps: 128,
159            max_delegation_depth: 2,
160            max_conversations: 8,
161            conversation_idle_seconds: 86400,
162            system_prompt: "You are SCV, a concise and careful coding agent. Use tools to inspect, change, and verify the workspace.".into(),
163        }
164    }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(default, deny_unknown_fields)]
169pub struct SessionConfig {
170    pub max_history_bytes: usize,
171    pub max_messages: usize,
172}
173
174impl Default for SessionConfig {
175    fn default() -> Self {
176        Self {
177            max_history_bytes: 16 * 1024 * 1024,
178            max_messages: 10_000,
179        }
180    }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184#[serde(default, deny_unknown_fields)]
185pub struct ContextConfigFile {
186    pub max_tokens: usize,
187    pub reserve_output_tokens: usize,
188    pub safety_margin_tokens: usize,
189    pub bytes_per_token: usize,
190    pub summary_max_chars: usize,
191}
192
193impl Default for ContextConfigFile {
194    fn default() -> Self {
195        let value = ContextConfig::default();
196        Self {
197            max_tokens: value.max_tokens,
198            reserve_output_tokens: value.reserve_output_tokens,
199            safety_margin_tokens: value.safety_margin_tokens,
200            bytes_per_token: value.bytes_per_token,
201            summary_max_chars: value.summary_max_chars,
202        }
203    }
204}
205
206impl From<&ContextConfigFile> for ContextConfig {
207    fn from(value: &ContextConfigFile) -> Self {
208        Self {
209            max_tokens: value.max_tokens,
210            reserve_output_tokens: value.reserve_output_tokens,
211            safety_margin_tokens: value.safety_margin_tokens,
212            bytes_per_token: value.bytes_per_token,
213            summary_max_chars: value.summary_max_chars,
214        }
215    }
216}
217
218#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
219#[serde(rename_all = "kebab-case")]
220pub enum ApprovalPolicy {
221    OnRisk,
222    Always,
223    Never,
224}
225
226impl ApprovalPolicy {
227    fn strictness(self) -> u8 {
228        match self {
229            Self::OnRisk => 1,
230            Self::Always => 2,
231            Self::Never => 3,
232        }
233    }
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(default, deny_unknown_fields)]
238pub struct ToolConfig {
239    pub approval_policy: ApprovalPolicy,
240    /// `bash` timeout when a call does not choose one.
241    pub command_timeout_seconds: u64,
242    /// Native-agent timeout when a call does not choose one.
243    pub agent_timeout_seconds: u64,
244    /// The longest timeout a single tool call may request.
245    pub max_timeout_seconds: u64,
246    pub output_limit_bytes: usize,
247    pub max_read_bytes: usize,
248    pub max_write_bytes: usize,
249}
250
251impl Default for ToolConfig {
252    fn default() -> Self {
253        Self {
254            approval_policy: ApprovalPolicy::OnRisk,
255            command_timeout_seconds: 600,
256            agent_timeout_seconds: 3600,
257            max_timeout_seconds: 14400,
258            output_limit_bytes: 64 * 1024,
259            max_read_bytes: 256 * 1024,
260            max_write_bytes: 1024 * 1024,
261        }
262    }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(default, deny_unknown_fields)]
267pub struct ProtocolConfig {
268    pub max_client_frame_bytes: usize,
269    pub max_server_frame_bytes: usize,
270}
271
272impl Default for ProtocolConfig {
273    fn default() -> Self {
274        Self {
275            max_client_frame_bytes: 1024 * 1024,
276            max_server_frame_bytes: 8 * 1024 * 1024,
277        }
278    }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
282#[serde(default, deny_unknown_fields)]
283pub struct TuiConfig {
284    pub max_transcript_bytes: usize,
285    pub max_transcript_items: usize,
286    pub max_prompt_history_bytes: usize,
287    pub max_prompt_history_items: usize,
288}
289
290impl Default for TuiConfig {
291    fn default() -> Self {
292        Self {
293            max_transcript_bytes: 8 * 1024 * 1024,
294            max_transcript_items: 10_000,
295            max_prompt_history_bytes: 1024 * 1024,
296            max_prompt_history_items: 200,
297        }
298    }
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[serde(default, deny_unknown_fields)]
303pub struct ProviderLimitsFile {
304    pub max_sse_event_bytes: usize,
305    pub max_response_bytes: usize,
306    pub max_assistant_bytes: usize,
307    pub max_tool_calls: usize,
308    pub max_tool_arguments_bytes: usize,
309    pub max_retries: usize,
310}
311
312impl Default for ProviderLimitsFile {
313    fn default() -> Self {
314        let value = ProviderLimits::default();
315        Self {
316            max_sse_event_bytes: value.max_sse_event_bytes,
317            max_response_bytes: value.max_response_bytes,
318            max_assistant_bytes: value.max_assistant_bytes,
319            max_tool_calls: value.max_tool_calls,
320            max_tool_arguments_bytes: value.max_tool_arguments_bytes,
321            max_retries: value.max_retries,
322        }
323    }
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(default, deny_unknown_fields)]
328pub struct SkillsConfig {
329    pub user_dir: PathBuf,
330    pub project_dir: PathBuf,
331    /// List the agent skills (`.agents/skills`, `.claude/skills`) of the
332    /// workspace and its immediate child projects in tool-enabled sessions.
333    pub scan_projects: bool,
334    pub max_skills: usize,
335    pub max_skill_bytes: usize,
336}
337
338impl Default for SkillsConfig {
339    fn default() -> Self {
340        Self {
341            user_dir: PathBuf::from("~/.scv/skills"),
342            project_dir: PathBuf::from(".scv/skills"),
343            scan_projects: true,
344            max_skills: 128,
345            max_skill_bytes: 256 * 1024,
346        }
347    }
348}
349
350/// Where `web_search` results come from.
351#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
352#[serde(rename_all = "lowercase")]
353pub enum WebSearchMode {
354    Off,
355    /// The provider endpoint's hosted Responses `web_search` tool.
356    Provider,
357    Searxng,
358    Brave,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(default, deny_unknown_fields)]
363pub struct WebConfig {
364    /// Offer `web_fetch` (and search, when configured) to tool-enabled sessions.
365    pub enabled: bool,
366    pub fetch_max_bytes: usize,
367    pub fetch_timeout_seconds: u64,
368    pub max_redirects: usize,
369    /// HTTPS hosts `web_fetch` may read without approval.
370    pub auto_approve_domains: Vec<String>,
371    /// Let `web_fetch` reach loopback, private, and link-local addresses.
372    pub allow_private_addresses: bool,
373    pub search: WebSearchMode,
374    pub searxng_url: Option<String>,
375    pub brave_url: String,
376    pub brave_api_key: Option<String>,
377    pub brave_api_key_env: Option<String>,
378    pub max_search_results: usize,
379}
380
381impl Default for WebConfig {
382    fn default() -> Self {
383        Self {
384            enabled: true,
385            fetch_max_bytes: 2 * 1024 * 1024,
386            fetch_timeout_seconds: 30,
387            max_redirects: 5,
388            auto_approve_domains: [
389                "docs.rs",
390                "crates.io",
391                "doc.rust-lang.org",
392                "docs.python.org",
393                "pypi.org",
394                "developer.mozilla.org",
395            ]
396            .map(String::from)
397            .to_vec(),
398            allow_private_addresses: false,
399            search: WebSearchMode::Off,
400            searxng_url: None,
401            brave_url: "https://api.search.brave.com/res/v1/web/search".into(),
402            brave_api_key: None,
403            brave_api_key_env: Some("BRAVE_SEARCH_API_KEY".into()),
404            max_search_results: 8,
405        }
406    }
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, Default)]
410#[serde(default, deny_unknown_fields)]
411pub struct AdapterConfig {
412    pub command: String,
413    pub args: Vec<String>,
414    /// `full` adds the CLI's own switches for unprompted, unsandboxed work.
415    pub permissions: AgentPermissions,
416    /// Placed immediately before the prompt (`grok -p <prompt>`).
417    pub prompt_args: Vec<String>,
418    /// Appended when a call selects a model; `{model}` is substituted.
419    pub model_args: Vec<String>,
420    /// Appended when a call selects an effort; `{effort}` is substituted.
421    pub effort_args: Vec<String>,
422    /// How SCV talks to the agent: its ACP server or one process per turn.
423    pub transport: AgentTransport,
424}
425
426/// How SCV talks to a delegated agent that has an ACP server.
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
428#[serde(rename_all = "lowercase")]
429pub enum AgentTransport {
430    /// The agent's ACP server when it is installed, else one process per turn.
431    #[default]
432    Auto,
433    /// Only its ACP server; the agent is not offered while it is missing.
434    Acp,
435    /// One CLI process per turn, continued through the CLI's own resume.
436    Resume,
437}
438
439/// How much a delegated CLI may do without its own prompts.
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
441#[serde(rename_all = "lowercase")]
442pub enum AgentPermissions {
443    /// Add nothing: the CLI's own configuration decides.
444    #[default]
445    Default,
446    /// Add the CLI's full-autonomy switches: no approval prompts, no sandbox,
447    /// and web search where the CLI gates it. An explicit user opt-in.
448    Full,
449}
450
451/// `[agents.<name>]` for every adapter in [`scv_tools::adapters::ADAPTERS`].
452#[derive(Debug, Clone, Serialize, Deserialize)]
453#[serde(transparent)]
454pub struct AgentsConfig(pub BTreeMap<String, AdapterConfig>);
455
456impl Default for AgentsConfig {
457    fn default() -> Self {
458        let strings = |values: &[&str]| values.iter().map(|value| (*value).to_owned()).collect();
459        Self(
460            scv_tools::adapters::ADAPTERS
461                .iter()
462                .map(|adapter| {
463                    (
464                        adapter.name.to_owned(),
465                        AdapterConfig {
466                            command: adapter.command.into(),
467                            args: strings(adapter.args),
468                            permissions: AgentPermissions::Default,
469                            prompt_args: strings(adapter.prompt_args),
470                            model_args: strings(adapter.model_args),
471                            effort_args: strings(adapter.effort_args),
472                            transport: AgentTransport::Auto,
473                        },
474                    )
475                })
476                .collect(),
477        )
478    }
479}
480
481#[derive(Debug, Clone, Default)]
482pub struct ConfigOverrides {
483    pub provider: Option<String>,
484    pub model: Option<String>,
485    pub base_url: Option<String>,
486    pub approval_policy: Option<ApprovalPolicy>,
487    pub no_tools: bool,
488}
489
490impl Config {
491    pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
492        Self::load_layers(Some(workspace), overrides)
493    }
494
495    /// Load without a project layer, for settings that project configuration
496    /// can never set (such as `[agents]`), so the caller's directory is irrelevant.
497    pub fn load_user(overrides: ConfigOverrides) -> Result<Self> {
498        Self::load_layers(None, overrides)
499    }
500
501    fn load_layers(
502        workspace: Option<&std::path::Path>,
503        overrides: ConfigOverrides,
504    ) -> Result<Self> {
505        let instance_home = user_home_path()
506            .ok_or_else(|| anyhow::anyhow!("cannot determine SCV instance home"))?;
507        std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
508        ensure_private_dir(&instance_home)?;
509        let mut value: toml::Value = toml::from_str(
510            &toml::to_string(&Self::default()).context("serialize default configuration")?,
511        )?;
512
513        if let Some(user_path) = user_config_path()
514            && user_path.is_file()
515        {
516            #[cfg(unix)]
517            {
518                use std::os::unix::fs::PermissionsExt;
519                if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
520                    bail!("user configuration is readable by group or others; run chmod 600");
521                }
522            }
523            merge(&mut value, read_layer(&user_path)?);
524        }
525        let user_baseline: Self = value
526            .clone()
527            .try_into()
528            .context("parse user configuration")?;
529
530        if let Some(workspace) = workspace {
531            let project_path = workspace.join(".scv/config.toml");
532            // A workspace whose `.scv` is the SCV home (such as running from
533            // `~`) has no project layer: that file is the user configuration,
534            // already applied above at full trust.
535            let user_file = user_config_path().and_then(|path| std::fs::canonicalize(path).ok());
536            if project_path.is_file() {
537                let canonical_project = std::fs::canonicalize(&project_path)
538                    .with_context(|| format!("resolve configuration {}", project_path.display()))?;
539                if user_file.as_ref() != Some(&canonical_project) {
540                    if !canonical_project.starts_with(workspace) {
541                        bail!("project configuration escaped workspace");
542                    }
543                    let project = read_layer(&canonical_project)?;
544                    validate_project_keys(&project)?;
545                    let mut candidate_value = value.clone();
546                    merge(&mut candidate_value, project);
547                    let candidate: Self = candidate_value
548                        .clone()
549                        .try_into()
550                        .context("parse project configuration")?;
551                    validate_project_not_weaker(&user_baseline, &candidate)?;
552                    value = candidate_value;
553                }
554            }
555        }
556
557        if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
558            let path = PathBuf::from(explicit);
559            #[cfg(unix)]
560            {
561                use std::os::unix::fs::PermissionsExt;
562                if std::fs::metadata(&path)?.permissions().mode() & 0o077 != 0 {
563                    bail!("explicit configuration is readable by group or others; run chmod 600");
564                }
565            }
566            merge(&mut value, read_layer(&path)?);
567        }
568        let mut config: Self = value.try_into().context("parse merged configuration")?;
569        if let Some(name) = overrides.provider.as_deref() {
570            config.provider_active = Some(name.to_owned());
571        }
572        let selected = config.active_provider()?;
573        config.provider = selected;
574        if let Ok(model) = std::env::var("SCV_MODEL") {
575            config.provider.model = model;
576        }
577        if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
578            config.provider.base_url = base_url;
579        }
580        if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
581            config.provider.api_key_env = Some(api_key_env);
582        }
583        if let Some(model) = overrides.model {
584            config.provider.model = model;
585        }
586        if let Some(base_url) = overrides.base_url {
587            config.provider.base_url = base_url;
588        }
589        if let Some(policy) = overrides.approval_policy {
590            config.tools.approval_policy = policy;
591        }
592        if config.skills.user_dir == std::path::Path::new("~/.scv/skills")
593            && let Some(home) = std::env::var_os("SCV_HOME")
594        {
595            config.skills.user_dir = PathBuf::from(home).join("skills");
596        }
597        config.skills.user_dir = expand_home(&config.skills.user_dir);
598        config.instance_home = instance_home;
599        config.validate()?;
600        Ok(config)
601    }
602
603    pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
604        CoreAgentConfig {
605            system_prompt,
606            max_steps: self.agent.max_steps,
607            history_limits: HistoryLimits {
608                max_bytes: self.session.max_history_bytes,
609                max_messages: self.session.max_messages,
610                note_max_chars: self.context.summary_max_chars,
611            },
612        }
613    }
614
615    pub fn tools(&self) -> ToolsConfig {
616        ToolsConfig {
617            command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
618            agent_timeout: Duration::from_secs(self.tools.agent_timeout_seconds),
619            max_timeout: Duration::from_secs(self.tools.max_timeout_seconds),
620            output_limit_bytes: self.tools.output_limit_bytes,
621            max_read_bytes: self.tools.max_read_bytes,
622            max_write_bytes: self.tools.max_write_bytes,
623            max_delegation_depth: self.agent.max_delegation_depth,
624            conversations: ConversationLimits {
625                max: self.agent.max_conversations,
626                idle: Duration::from_secs(self.agent.conversation_idle_seconds),
627            },
628            delegation: None,
629        }
630    }
631
632    /// Web tool settings for a tool-enabled session, or `None` when disabled.
633    /// A Brave backend without a key is left out rather than failing the session.
634    pub fn web_tools(&self) -> Option<WebToolsConfig> {
635        if !self.web.enabled {
636            return None;
637        }
638        let search = match self.web.search {
639            WebSearchMode::Off | WebSearchMode::Provider => None,
640            WebSearchMode::Searxng => self
641                .web
642                .searxng_url
643                .clone()
644                .map(|url| SearchBackend::Searxng { url }),
645            WebSearchMode::Brave => {
646                let api_key = self
647                    .web
648                    .brave_api_key
649                    .clone()
650                    .or_else(|| {
651                        self.web
652                            .brave_api_key_env
653                            .as_deref()
654                            .and_then(|name| std::env::var(name).ok())
655                    })
656                    .filter(|key| !key.trim().is_empty());
657                if api_key.is_none() {
658                    tracing::warn!(
659                        "web.search is \"brave\" but no Brave API key is configured; web_search is unavailable"
660                    );
661                }
662                api_key.map(|api_key| SearchBackend::Brave {
663                    url: self.web.brave_url.clone(),
664                    api_key,
665                })
666            }
667        };
668        Some(WebToolsConfig {
669            fetch_max_bytes: self.web.fetch_max_bytes,
670            fetch_timeout: Duration::from_secs(self.web.fetch_timeout_seconds),
671            max_redirects: self.web.max_redirects,
672            auto_approve_domains: self.web.auto_approve_domains.clone(),
673            allow_private_addresses: self.web.allow_private_addresses,
674            search,
675            max_search_results: self.web.max_search_results,
676            output_limit: self.tools.output_limit_bytes,
677        })
678    }
679
680    /// Whether to offer the provider's hosted web search to tool-enabled sessions.
681    pub fn hosted_web_search(&self) -> bool {
682        self.web.enabled && self.web.search == WebSearchMode::Provider
683    }
684
685    pub fn provider_limits(&self) -> ProviderLimits {
686        ProviderLimits {
687            max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
688            max_response_bytes: self.provider_limits.max_response_bytes,
689            max_assistant_bytes: self.provider_limits.max_assistant_bytes,
690            max_tool_calls: self.provider_limits.max_tool_calls,
691            max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
692            max_retries: self.provider_limits.max_retries,
693            ..ProviderLimits::default()
694        }
695    }
696
697    pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
698        let user_home = dirs::home_dir();
699        self.agents
700            .0
701            .iter()
702            .filter_map(|(name, config)| {
703                let descriptor = scv_tools::adapters::adapter(name)?;
704                let adapter_home = self.instance_home.join("adapters").join(name);
705                let mut environment = vec![
706                    (OsString::from("SCV_HOME"), adapter_home.clone().into()),
707                    (OsString::from("HOME"), adapter_home.clone().into()),
708                    (
709                        OsString::from("XDG_CONFIG_HOME"),
710                        adapter_home.join("config").into(),
711                    ),
712                    (
713                        OsString::from("XDG_DATA_HOME"),
714                        adapter_home.join("data").into(),
715                    ),
716                    (
717                        OsString::from("XDG_STATE_HOME"),
718                        adapter_home.join("state").into(),
719                    ),
720                ];
721                for (variable, relative) in descriptor.home_environment {
722                    let path = if relative.is_empty() {
723                        adapter_home.clone()
724                    } else {
725                        adapter_home.join(relative)
726                    };
727                    environment.push((OsString::from(variable), path.into()));
728                }
729                let full = config.permissions == AgentPermissions::Full;
730                environment.extend(
731                    descriptor
732                        .fixed_environment
733                        .iter()
734                        .chain(
735                            descriptor
736                                .full_permission_environment
737                                .iter()
738                                .filter(|_| full),
739                        )
740                        .map(|(variable, value)| (OsString::from(variable), OsString::from(value))),
741                );
742                Some((
743                    format!("agent_{name}"),
744                    AgentAdapterConfig {
745                        command: config.command.clone(),
746                        args: config.args.clone(),
747                        prompt_args: config.prompt_args.clone(),
748                        full_permission_args: full.then(|| {
749                            descriptor
750                                .full_permission_args
751                                .iter()
752                                .map(|arg| (*arg).to_owned())
753                                .collect()
754                        }),
755                        model_args: config.model_args.clone(),
756                        effort_args: config.effort_args.clone(),
757                        model_hint: descriptor.model_hint.into(),
758                        environment,
759                        search_dirs: user_home
760                            .as_deref()
761                            .map(|home| scv_tools::adapters::adapter_search_dirs(descriptor, home))
762                            .unwrap_or_default(),
763                        output: descriptor.output,
764                        resume: descriptor.resume,
765                        home: Some(adapter_home),
766                        transport: descriptor.transport,
767                        acp: descriptor
768                            .acp
769                            .filter(|_| match config.transport {
770                                AgentTransport::Acp => true,
771                                AgentTransport::Resume => false,
772                                // A custom `command` points SCV at a specific
773                                // CLI, which the ACP server would not run.
774                                AgentTransport::Auto => config.command == descriptor.command,
775                            })
776                            .map(|launch| scv_tools::AcpAgentLaunch {
777                                command: launch.command.to_owned(),
778                                args: scv_tools::adapters::acp_args(&launch, full),
779                                full_mode: launch.full_mode.filter(|_| full).map(str::to_owned),
780                                environment: launch
781                                    .full_environment
782                                    .iter()
783                                    .filter(|_| full)
784                                    .map(|(variable, value)| {
785                                        (OsString::from(variable), OsString::from(value))
786                                    })
787                                    .collect(),
788                                required: config.transport == AgentTransport::Acp,
789                            }),
790                    },
791                ))
792            })
793            .collect()
794    }
795
796    pub fn prepare_adapter_homes(&self) -> Result<()> {
797        for name in self.agents.0.keys() {
798            let path = self.instance_home.join("adapters").join(name);
799            std::fs::create_dir_all(&path)
800                .with_context(|| format!("create isolated {name} adapter home"))?;
801            #[cfg(unix)]
802            {
803                use std::os::unix::fs::PermissionsExt;
804                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
805                    .with_context(|| format!("secure isolated {name} adapter home"))?;
806            }
807        }
808        Ok(())
809    }
810
811    fn validate(&self) -> Result<()> {
812        if self.provider.kind != "openai-compatible" {
813            bail!("provider.kind must be openai-compatible in v0.1");
814        }
815        if self.provider.model.trim().is_empty()
816            || self.provider.base_url.trim().is_empty()
817            || self
818                .provider
819                .api_key
820                .as_deref()
821                .unwrap_or("")
822                .trim()
823                .is_empty()
824                && self
825                    .provider
826                    .api_key_env
827                    .as_deref()
828                    .unwrap_or("")
829                    .trim()
830                    .is_empty()
831        {
832            bail!(
833                "provider model and base_url must be non-empty; configure api_key or api_key_env"
834            );
835        }
836        for (agent, adapter) in &self.agents.0 {
837            if scv_tools::adapters::adapter(agent).is_none() {
838                let known: Vec<_> = scv_tools::adapters::ADAPTERS
839                    .iter()
840                    .map(|adapter| adapter.name)
841                    .collect();
842                bail!(
843                    "unknown agent [agents.{agent}]; known agents are {}",
844                    known.join(", ")
845                );
846            }
847            let name = format!("agents.{agent}.command");
848            if adapter.command.trim().is_empty() {
849                bail!("{name} must be non-empty");
850            }
851            if adapter.transport == AgentTransport::Acp
852                && scv_tools::adapters::adapter(agent)
853                    .is_some_and(|descriptor| descriptor.acp.is_none())
854            {
855                bail!(
856                    "agents.{agent}.transport = \"acp\" but {agent} has no verified ACP server; \
857                     use \"auto\" or \"resume\""
858                );
859            }
860            for (field, template, placeholder) in [
861                ("model_args", &adapter.model_args, "{model}"),
862                ("effort_args", &adapter.effort_args, "{effort}"),
863            ] {
864                if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
865                    let adapter = name.trim_end_matches(".command");
866                    bail!("{adapter}.{field} must contain {placeholder} or be empty");
867                }
868            }
869            let adapter_bytes = adapter.command.len()
870                + [
871                    &adapter.args,
872                    &adapter.prompt_args,
873                    &adapter.model_args,
874                    &adapter.effort_args,
875                ]
876                .into_iter()
877                .flatten()
878                .map(String::len)
879                .sum::<usize>();
880            if adapter_bytes > 16 * 1024 {
881                bail!("{name} and its fixed arguments exceed 16384 bytes");
882            }
883        }
884        let positives = [
885            (
886                "provider.timeout_seconds",
887                usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
888            ),
889            ("agent.max_steps", self.agent.max_steps),
890            ("agent.max_conversations", self.agent.max_conversations),
891            (
892                "agent.conversation_idle_seconds",
893                usize::try_from(self.agent.conversation_idle_seconds).unwrap_or(usize::MAX),
894            ),
895            ("session.max_history_bytes", self.session.max_history_bytes),
896            ("session.max_messages", self.session.max_messages),
897            ("context.max_tokens", self.context.max_tokens),
898            ("context.bytes_per_token", self.context.bytes_per_token),
899            ("context.summary_max_chars", self.context.summary_max_chars),
900            (
901                "tools.command_timeout_seconds",
902                usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
903            ),
904            (
905                "tools.agent_timeout_seconds",
906                usize::try_from(self.tools.agent_timeout_seconds).unwrap_or(usize::MAX),
907            ),
908            (
909                "tools.max_timeout_seconds",
910                usize::try_from(self.tools.max_timeout_seconds).unwrap_or(usize::MAX),
911            ),
912            ("tools.output_limit_bytes", self.tools.output_limit_bytes),
913            ("tools.max_read_bytes", self.tools.max_read_bytes),
914            ("tools.max_write_bytes", self.tools.max_write_bytes),
915            (
916                "protocol.max_client_frame_bytes",
917                self.protocol.max_client_frame_bytes,
918            ),
919            (
920                "protocol.max_server_frame_bytes",
921                self.protocol.max_server_frame_bytes,
922            ),
923            ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
924            ("tui.max_transcript_items", self.tui.max_transcript_items),
925            (
926                "tui.max_prompt_history_bytes",
927                self.tui.max_prompt_history_bytes,
928            ),
929            (
930                "tui.max_prompt_history_items",
931                self.tui.max_prompt_history_items,
932            ),
933            (
934                "provider_limits.max_sse_event_bytes",
935                self.provider_limits.max_sse_event_bytes,
936            ),
937            (
938                "provider_limits.max_response_bytes",
939                self.provider_limits.max_response_bytes,
940            ),
941            (
942                "provider_limits.max_assistant_bytes",
943                self.provider_limits.max_assistant_bytes,
944            ),
945            (
946                "provider_limits.max_tool_calls",
947                self.provider_limits.max_tool_calls,
948            ),
949            (
950                "provider_limits.max_tool_arguments_bytes",
951                self.provider_limits.max_tool_arguments_bytes,
952            ),
953            ("skills.max_skills", self.skills.max_skills),
954            ("skills.max_skill_bytes", self.skills.max_skill_bytes),
955            ("web.fetch_max_bytes", self.web.fetch_max_bytes),
956            (
957                "web.fetch_timeout_seconds",
958                usize::try_from(self.web.fetch_timeout_seconds).unwrap_or(usize::MAX),
959            ),
960            ("web.max_search_results", self.web.max_search_results),
961        ];
962        if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
963            bail!("{name} must be positive");
964        }
965        for (name, value) in [
966            (
967                "tools.command_timeout_seconds",
968                self.tools.command_timeout_seconds,
969            ),
970            (
971                "tools.agent_timeout_seconds",
972                self.tools.agent_timeout_seconds,
973            ),
974        ] {
975            if value > self.tools.max_timeout_seconds {
976                bail!("{name} exceeds tools.max_timeout_seconds");
977            }
978        }
979        if self.tools.max_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS {
980            bail!("tools.max_timeout_seconds must be at most {MAX_TOOL_TIMEOUT_SECONDS}");
981        }
982        if self
983            .context
984            .reserve_output_tokens
985            .saturating_add(self.context.safety_margin_tokens)
986            >= self.context.max_tokens
987        {
988            bail!("context reserve and safety margin consume max_tokens");
989        }
990        let worst_assistant_frame = self
991            .provider_limits
992            .max_assistant_bytes
993            .saturating_mul(6)
994            .saturating_add(64 * 1024);
995        if worst_assistant_frame > self.protocol.max_server_frame_bytes {
996            bail!(
997                "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
998            );
999        }
1000        if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
1001            bail!("tool argument limit exceeds provider response limit");
1002        }
1003        if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
1004            bail!("provider SSE event limit exceeds provider response limit");
1005        }
1006        if self.provider_limits.max_retries > MAX_PROVIDER_RETRIES {
1007            bail!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}");
1008        }
1009        if self.protocol.max_client_frame_bytes < 4096 {
1010            bail!("protocol.max_client_frame_bytes must be at least 4096");
1011        }
1012        if self.protocol.max_server_frame_bytes < 64 * 1024 {
1013            bail!("protocol.max_server_frame_bytes must be at least 65536");
1014        }
1015        let worst_tool_frame = self
1016            .tools
1017            .output_limit_bytes
1018            .max(self.tools.max_read_bytes)
1019            .saturating_mul(12)
1020            .saturating_add(64 * 1024);
1021        let worst_skill_frame = self
1022            .skills
1023            .max_skill_bytes
1024            .saturating_mul(6)
1025            .saturating_add(64 * 1024);
1026        let worst_arguments_frame = self
1027            .provider_limits
1028            .max_tool_arguments_bytes
1029            .saturating_mul(6)
1030            .saturating_add(64 * 1024);
1031        if worst_tool_frame
1032            .max(worst_skill_frame)
1033            .max(worst_arguments_frame)
1034            > self.protocol.max_server_frame_bytes
1035        {
1036            bail!(
1037                "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
1038            );
1039        }
1040        self.validate_web()?;
1041        if self.skills.project_dir.is_absolute()
1042            || self
1043                .skills
1044                .project_dir
1045                .components()
1046                .any(|component| matches!(component, std::path::Component::ParentDir))
1047        {
1048            bail!("skills.project_dir must be a contained relative path");
1049        }
1050        Ok(())
1051    }
1052}
1053
1054impl Config {
1055    fn validate_web(&self) -> Result<()> {
1056        let web = &self.web;
1057        if web.fetch_max_bytes > 64 * 1024 * 1024 {
1058            bail!("web.fetch_max_bytes must be at most 67108864");
1059        }
1060        if web.fetch_timeout_seconds > self.tools.max_timeout_seconds {
1061            bail!("web.fetch_timeout_seconds exceeds tools.max_timeout_seconds");
1062        }
1063        if web.max_redirects > 10 {
1064            bail!("web.max_redirects must be at most 10");
1065        }
1066        if web.max_search_results > 20 {
1067            bail!("web.max_search_results must be at most 20");
1068        }
1069        if web.auto_approve_domains.len() > 256 {
1070            bail!("web.auto_approve_domains may list at most 256 hosts");
1071        }
1072        if let Some(entry) = web
1073            .auto_approve_domains
1074            .iter()
1075            .find(|entry| !valid_domain_pattern(entry))
1076        {
1077            bail!(
1078                "web.auto_approve_domains entry {entry:?} must be a host name such as docs.rs or *.example.com"
1079            );
1080        }
1081        let http_url = |value: &str| value.starts_with("https://") || value.starts_with("http://");
1082        if !http_url(&web.brave_url) {
1083            bail!("web.brave_url must be an http or https URL");
1084        }
1085        match web.search {
1086            WebSearchMode::Searxng if !web.searxng_url.as_deref().is_some_and(http_url) => {
1087                bail!("web.search = \"searxng\" requires web.searxng_url (an http or https URL)");
1088            }
1089            WebSearchMode::Brave
1090                if web.brave_api_key.as_deref().unwrap_or("").trim().is_empty()
1091                    && web
1092                        .brave_api_key_env
1093                        .as_deref()
1094                        .unwrap_or("")
1095                        .trim()
1096                        .is_empty() =>
1097            {
1098                bail!("web.search = \"brave\" requires web.brave_api_key or web.brave_api_key_env");
1099            }
1100            _ => {}
1101        }
1102        Ok(())
1103    }
1104}
1105
1106/// A host name, optionally prefixed with `*.` to match its subdomains.
1107fn valid_domain_pattern(entry: &str) -> bool {
1108    let host = entry.strip_prefix("*.").unwrap_or(entry);
1109    !host.is_empty()
1110        && host.len() <= 253
1111        && host.split('.').all(|label| {
1112            !label.is_empty()
1113                && label.len() <= 63
1114                && !label.starts_with('-')
1115                && !label.ends_with('-')
1116                && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1117        })
1118}
1119
1120fn user_config_path() -> Option<PathBuf> {
1121    user_home_path().map(|path| path.join("config.toml"))
1122}
1123
1124pub fn user_home_path() -> Option<PathBuf> {
1125    let path = std::env::var_os("SCV_HOME")
1126        .map(PathBuf::from)
1127        .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))?;
1128    if path.exists() {
1129        Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
1130    } else if path.is_absolute() {
1131        Some(path)
1132    } else {
1133        std::env::current_dir().ok().map(|cwd| cwd.join(path))
1134    }
1135}
1136
1137fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
1138    #[cfg(unix)]
1139    {
1140        use std::os::unix::fs::PermissionsExt;
1141        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
1142            .with_context(|| format!("secure directory {}", path.display()))?;
1143    }
1144    Ok(())
1145}
1146
1147fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
1148    let size = std::fs::metadata(path)
1149        .with_context(|| format!("stat configuration {}", path.display()))?
1150        .len();
1151    if size > MAX_CONFIG_BYTES {
1152        bail!("configuration {} exceeds 1 MiB", path.display());
1153    }
1154    let content = std::fs::read_to_string(path)
1155        .with_context(|| format!("read configuration {}", path.display()))?;
1156    toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
1157}
1158
1159fn merge(base: &mut toml::Value, overlay: toml::Value) {
1160    match (base, overlay) {
1161        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
1162            for (key, value) in overlay {
1163                match base.get_mut(&key) {
1164                    Some(existing) => merge(existing, value),
1165                    None => {
1166                        base.insert(key, value);
1167                    }
1168                }
1169            }
1170        }
1171        (base, overlay) => *base = overlay,
1172    }
1173}
1174
1175fn validate_project_keys(value: &toml::Value) -> Result<()> {
1176    let Some(table) = value.as_table() else {
1177        bail!("project configuration must be a TOML table");
1178    };
1179    for forbidden in [
1180        "provider",
1181        "providers",
1182        "provider_active",
1183        "agents",
1184        "update",
1185    ] {
1186        if table.contains_key(forbidden) {
1187            bail!("project configuration cannot set [{forbidden}]");
1188        }
1189    }
1190    if table
1191        .get("skills")
1192        .and_then(toml::Value::as_table)
1193        .is_some_and(|skills| skills.contains_key("user_dir"))
1194    {
1195        bail!("project configuration cannot set skills.user_dir");
1196    }
1197    if table
1198        .get("agent")
1199        .and_then(toml::Value::as_table)
1200        .is_some_and(|agent| agent.contains_key("system_prompt"))
1201    {
1202        bail!("project configuration cannot replace agent.system_prompt");
1203    }
1204    if let Some(web) = table.get("web").and_then(toml::Value::as_table) {
1205        for key in [
1206            "auto_approve_domains",
1207            "allow_private_addresses",
1208            "searxng_url",
1209            "brave_url",
1210            "brave_api_key",
1211            "brave_api_key_env",
1212        ] {
1213            if web.contains_key(key) {
1214                bail!("project configuration cannot set web.{key}");
1215            }
1216        }
1217    }
1218    Ok(())
1219}
1220
1221fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
1222    macro_rules! no_larger {
1223        ($field:expr, $name:literal) => {
1224            if $field.1 > $field.0 {
1225                bail!(concat!("project configuration cannot raise ", $name));
1226            }
1227        };
1228    }
1229    no_larger!(
1230        (user.agent.max_steps, project.agent.max_steps),
1231        "agent.max_steps"
1232    );
1233    no_larger!(
1234        (
1235            user.agent.max_delegation_depth,
1236            project.agent.max_delegation_depth
1237        ),
1238        "agent.max_delegation_depth"
1239    );
1240    no_larger!(
1241        (
1242            user.agent.max_conversations,
1243            project.agent.max_conversations
1244        ),
1245        "agent.max_conversations"
1246    );
1247    no_larger!(
1248        (
1249            user.agent.conversation_idle_seconds,
1250            project.agent.conversation_idle_seconds
1251        ),
1252        "agent.conversation_idle_seconds"
1253    );
1254    no_larger!(
1255        (
1256            user.session.max_history_bytes,
1257            project.session.max_history_bytes
1258        ),
1259        "session.max_history_bytes"
1260    );
1261    no_larger!(
1262        (user.session.max_messages, project.session.max_messages),
1263        "session.max_messages"
1264    );
1265    no_larger!(
1266        (user.context.max_tokens, project.context.max_tokens),
1267        "context.max_tokens"
1268    );
1269    no_larger!(
1270        (
1271            user.context.summary_max_chars,
1272            project.context.summary_max_chars
1273        ),
1274        "context.summary_max_chars"
1275    );
1276    no_larger!(
1277        (
1278            user.tools.command_timeout_seconds,
1279            project.tools.command_timeout_seconds
1280        ),
1281        "tools.command_timeout_seconds"
1282    );
1283    no_larger!(
1284        (
1285            user.tools.agent_timeout_seconds,
1286            project.tools.agent_timeout_seconds
1287        ),
1288        "tools.agent_timeout_seconds"
1289    );
1290    no_larger!(
1291        (
1292            user.tools.max_timeout_seconds,
1293            project.tools.max_timeout_seconds
1294        ),
1295        "tools.max_timeout_seconds"
1296    );
1297    no_larger!(
1298        (
1299            user.tools.output_limit_bytes,
1300            project.tools.output_limit_bytes
1301        ),
1302        "tools.output_limit_bytes"
1303    );
1304    no_larger!(
1305        (user.tools.max_read_bytes, project.tools.max_read_bytes),
1306        "tools.max_read_bytes"
1307    );
1308    no_larger!(
1309        (user.tools.max_write_bytes, project.tools.max_write_bytes),
1310        "tools.max_write_bytes"
1311    );
1312    no_larger!(
1313        (
1314            user.protocol.max_client_frame_bytes,
1315            project.protocol.max_client_frame_bytes
1316        ),
1317        "protocol.max_client_frame_bytes"
1318    );
1319    no_larger!(
1320        (
1321            user.protocol.max_server_frame_bytes,
1322            project.protocol.max_server_frame_bytes
1323        ),
1324        "protocol.max_server_frame_bytes"
1325    );
1326    no_larger!(
1327        (
1328            user.provider_limits.max_response_bytes,
1329            project.provider_limits.max_response_bytes
1330        ),
1331        "provider_limits.max_response_bytes"
1332    );
1333    no_larger!(
1334        (
1335            user.provider_limits.max_sse_event_bytes,
1336            project.provider_limits.max_sse_event_bytes
1337        ),
1338        "provider_limits.max_sse_event_bytes"
1339    );
1340    no_larger!(
1341        (
1342            user.provider_limits.max_assistant_bytes,
1343            project.provider_limits.max_assistant_bytes
1344        ),
1345        "provider_limits.max_assistant_bytes"
1346    );
1347    no_larger!(
1348        (
1349            user.provider_limits.max_tool_calls,
1350            project.provider_limits.max_tool_calls
1351        ),
1352        "provider_limits.max_tool_calls"
1353    );
1354    no_larger!(
1355        (
1356            user.provider_limits.max_tool_arguments_bytes,
1357            project.provider_limits.max_tool_arguments_bytes
1358        ),
1359        "provider_limits.max_tool_arguments_bytes"
1360    );
1361    no_larger!(
1362        (
1363            user.provider_limits.max_retries,
1364            project.provider_limits.max_retries
1365        ),
1366        "provider_limits.max_retries"
1367    );
1368    no_larger!(
1369        (
1370            user.tui.max_transcript_bytes,
1371            project.tui.max_transcript_bytes
1372        ),
1373        "tui.max_transcript_bytes"
1374    );
1375    no_larger!(
1376        (
1377            user.tui.max_transcript_items,
1378            project.tui.max_transcript_items
1379        ),
1380        "tui.max_transcript_items"
1381    );
1382    no_larger!(
1383        (
1384            user.tui.max_prompt_history_bytes,
1385            project.tui.max_prompt_history_bytes
1386        ),
1387        "tui.max_prompt_history_bytes"
1388    );
1389    no_larger!(
1390        (
1391            user.tui.max_prompt_history_items,
1392            project.tui.max_prompt_history_items
1393        ),
1394        "tui.max_prompt_history_items"
1395    );
1396    no_larger!(
1397        (user.skills.max_skills, project.skills.max_skills),
1398        "skills.max_skills"
1399    );
1400    no_larger!(
1401        (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
1402        "skills.max_skill_bytes"
1403    );
1404    if project.context.reserve_output_tokens < user.context.reserve_output_tokens
1405        || project.context.safety_margin_tokens < user.context.safety_margin_tokens
1406    {
1407        bail!("project configuration cannot lower context reserves");
1408    }
1409    if project.context.bytes_per_token > user.context.bytes_per_token {
1410        bail!("project configuration cannot raise context.bytes_per_token");
1411    }
1412    if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
1413        bail!("project configuration cannot weaken tools.approval_policy");
1414    }
1415    if project.skills.scan_projects && !user.skills.scan_projects {
1416        bail!("project configuration cannot enable skills.scan_projects");
1417    }
1418    if project.web.enabled && !user.web.enabled {
1419        bail!("project configuration cannot enable web");
1420    }
1421    if project.web.search != user.web.search && project.web.search != WebSearchMode::Off {
1422        bail!("project configuration can only turn web.search off");
1423    }
1424    no_larger!(
1425        (user.web.fetch_max_bytes, project.web.fetch_max_bytes),
1426        "web.fetch_max_bytes"
1427    );
1428    no_larger!(
1429        (
1430            user.web.fetch_timeout_seconds,
1431            project.web.fetch_timeout_seconds
1432        ),
1433        "web.fetch_timeout_seconds"
1434    );
1435    no_larger!(
1436        (user.web.max_redirects, project.web.max_redirects),
1437        "web.max_redirects"
1438    );
1439    no_larger!(
1440        (user.web.max_search_results, project.web.max_search_results),
1441        "web.max_search_results"
1442    );
1443    Ok(())
1444}
1445
1446fn expand_home(path: &std::path::Path) -> PathBuf {
1447    let value = path.to_string_lossy();
1448    if value == "~" {
1449        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
1450    }
1451    if let Some(rest) = value.strip_prefix("~/")
1452        && let Some(home) = dirs::home_dir()
1453    {
1454        return home.join(rest);
1455    }
1456    path.to_path_buf()
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461    use super::*;
1462
1463    #[test]
1464    fn project_cannot_redirect_provider_or_agent() {
1465        let provider: toml::Value = toml::from_str(
1466            r#"[provider]
1467base_url = "https://attacker.invalid"
1468"#,
1469        )
1470        .unwrap();
1471        assert!(validate_project_keys(&provider).is_err());
1472
1473        let agent: toml::Value = toml::from_str(
1474            r#"[agents.codex]
1475command = "/tmp/fake"
1476"#,
1477        )
1478        .unwrap();
1479        assert!(validate_project_keys(&agent).is_err());
1480    }
1481
1482    #[test]
1483    fn project_may_tighten_but_not_weaken_limits() {
1484        let user = Config::default();
1485        let mut tighter = user.clone();
1486        tighter.tools.output_limit_bytes /= 2;
1487        tighter.tools.approval_policy = ApprovalPolicy::Always;
1488        assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1489
1490        let mut weaker = user.clone();
1491        weaker.tools.output_limit_bytes *= 2;
1492        assert!(validate_project_not_weaker(&user, &weaker).is_err());
1493    }
1494
1495    #[test]
1496    fn timeouts_default_below_a_ceiling_that_projects_may_only_lower() {
1497        let user = Config::default();
1498        assert_eq!(
1499            (
1500                user.tools.command_timeout_seconds,
1501                user.tools.agent_timeout_seconds,
1502                user.tools.max_timeout_seconds
1503            ),
1504            (600, 3600, 14400)
1505        );
1506        assert_eq!(user.agent.max_steps, 128);
1507        assert_eq!(user.provider.timeout_seconds, 600);
1508        let tools = user.tools();
1509        assert_eq!(tools.command_timeout, Duration::from_secs(600));
1510        assert_eq!(tools.agent_timeout, Duration::from_secs(3600));
1511        assert_eq!(tools.max_timeout, Duration::from_secs(14400));
1512        // A ClawBot owner turn outlasts the ceiling by five minutes: 4h05m.
1513        assert_eq!(
1514            scv_clawbot::owner_turn_timeout(tools.max_timeout),
1515            Duration::from_secs(4 * 3600 + 5 * 60)
1516        );
1517
1518        for (field, name) in [
1519            (0, "tools.command_timeout_seconds"),
1520            (1, "tools.agent_timeout_seconds"),
1521        ] {
1522            let mut config = Config::default();
1523            let value = if field == 0 {
1524                &mut config.tools.command_timeout_seconds
1525            } else {
1526                &mut config.tools.agent_timeout_seconds
1527            };
1528            *value = config.tools.max_timeout_seconds + 1;
1529            assert_eq!(
1530                config.validate().unwrap_err().to_string(),
1531                format!("{name} exceeds tools.max_timeout_seconds")
1532            );
1533        }
1534        let mut unbounded = Config::default();
1535        unbounded.tools.max_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS + 1;
1536        assert!(unbounded.validate().is_err());
1537        let mut zero = Config::default();
1538        zero.tools.agent_timeout_seconds = 0;
1539        assert!(zero.validate().is_err());
1540
1541        let mut lower = user.clone();
1542        lower.tools.max_timeout_seconds = 900;
1543        lower.tools.agent_timeout_seconds = 300;
1544        assert!(validate_project_not_weaker(&user, &lower).is_ok());
1545        for raise in [
1546            |config: &mut Config| config.tools.max_timeout_seconds += 1,
1547            |config: &mut Config| config.tools.agent_timeout_seconds += 1,
1548        ] {
1549            let mut higher = user.clone();
1550            raise(&mut higher);
1551            assert!(validate_project_not_weaker(&user, &higher).is_err());
1552        }
1553    }
1554
1555    #[test]
1556    fn conversation_limits_are_positive_and_projects_may_only_lower_them() {
1557        let user = Config::default();
1558        assert_eq!(
1559            (
1560                user.agent.max_conversations,
1561                user.agent.conversation_idle_seconds
1562            ),
1563            (8, 86400)
1564        );
1565        let limits = user.tools().conversations;
1566        assert_eq!((limits.max, limits.idle), (8, Duration::from_secs(86400)));
1567        for zero in [
1568            |config: &mut Config| config.agent.max_conversations = 0,
1569            |config: &mut Config| config.agent.conversation_idle_seconds = 0,
1570        ] {
1571            let mut config = Config::default();
1572            zero(&mut config);
1573            assert!(config.validate().is_err());
1574        }
1575        let mut lower = user.clone();
1576        lower.agent.max_conversations = 2;
1577        lower.agent.conversation_idle_seconds = 600;
1578        assert!(validate_project_not_weaker(&user, &lower).is_ok());
1579        for raise in [
1580            |config: &mut Config| config.agent.max_conversations += 1,
1581            |config: &mut Config| config.agent.conversation_idle_seconds += 1,
1582        ] {
1583            let mut higher = user.clone();
1584            raise(&mut higher);
1585            assert!(validate_project_not_weaker(&user, &higher).is_err());
1586        }
1587    }
1588
1589    #[test]
1590    fn provider_retries_are_bounded_and_projects_may_only_lower_them() {
1591        let user = Config::default();
1592        assert_eq!(user.provider_limits.max_retries, 2);
1593        assert_eq!(user.provider_limits().max_retries, 2);
1594        let mut none = user.clone();
1595        none.provider_limits.max_retries = 0;
1596        assert!(none.validate().is_ok());
1597        assert!(validate_project_not_weaker(&user, &none).is_ok());
1598        assert!(validate_project_not_weaker(&none, &user).is_err());
1599        let mut excessive = user.clone();
1600        excessive.provider_limits.max_retries = MAX_PROVIDER_RETRIES + 1;
1601        assert_eq!(
1602            excessive.validate().unwrap_err().to_string(),
1603            format!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}")
1604        );
1605    }
1606
1607    #[test]
1608    fn projects_may_disable_but_not_enable_project_skill_scanning() {
1609        let user = Config::default();
1610        let mut disabled = user.clone();
1611        disabled.skills.scan_projects = false;
1612        assert!(validate_project_not_weaker(&user, &disabled).is_ok());
1613        assert!(validate_project_not_weaker(&disabled, &user).is_err());
1614    }
1615
1616    #[test]
1617    fn web_defaults_offer_fetch_without_search_and_validate_their_settings() {
1618        let config = Config::default();
1619        assert!(config.web.enabled);
1620        assert_eq!(config.web.search, WebSearchMode::Off);
1621        assert!(!config.hosted_web_search());
1622        let tools = config.web_tools().unwrap();
1623        assert!(tools.search.is_none());
1624        assert!(!tools.allow_private_addresses);
1625        assert_eq!(tools.fetch_max_bytes, 2 * 1024 * 1024);
1626        assert_eq!(tools.output_limit, config.tools.output_limit_bytes);
1627        assert!(tools.auto_approve_domains.contains(&"docs.rs".to_owned()));
1628
1629        let mut disabled = Config::default();
1630        disabled.web.enabled = false;
1631        disabled.web.search = WebSearchMode::Provider;
1632        assert!(disabled.web_tools().is_none());
1633        assert!(!disabled.hosted_web_search());
1634
1635        let mut provider = Config::default();
1636        provider.web.search = WebSearchMode::Provider;
1637        assert!(provider.hosted_web_search());
1638        assert!(provider.web_tools().unwrap().search.is_none());
1639
1640        let mut searxng = Config::default();
1641        searxng.web.search = WebSearchMode::Searxng;
1642        assert!(
1643            searxng
1644                .validate()
1645                .unwrap_err()
1646                .to_string()
1647                .contains("web.searxng_url")
1648        );
1649        searxng.web.searxng_url = Some("https://searx.example".into());
1650        assert!(searxng.validate().is_ok());
1651        assert!(matches!(
1652            searxng.web_tools().unwrap().search,
1653            Some(SearchBackend::Searxng { .. })
1654        ));
1655
1656        let mut brave = Config::default();
1657        brave.web.search = WebSearchMode::Brave;
1658        brave.web.brave_api_key_env = None;
1659        assert!(
1660            brave
1661                .validate()
1662                .unwrap_err()
1663                .to_string()
1664                .contains("brave_api_key")
1665        );
1666        brave.web.brave_api_key = Some("inline-test-key".into());
1667        assert!(matches!(
1668            brave.web_tools().unwrap().search,
1669            Some(SearchBackend::Brave { ref api_key, .. }) if api_key == "inline-test-key"
1670        ));
1671        brave.web.brave_api_key = None;
1672        brave.web.brave_api_key_env = Some("SCV_TEST_UNSET_BRAVE_KEY_VARIABLE".into());
1673        assert!(brave.validate().is_ok());
1674        assert!(brave.web_tools().unwrap().search.is_none());
1675
1676        for (mutate, message) in [
1677            (
1678                (|config: &mut Config| {
1679                    config.web.auto_approve_domains = vec!["https://docs.rs/".into()]
1680                }) as fn(&mut Config),
1681                "web.auto_approve_domains",
1682            ),
1683            (|config| config.web.max_redirects = 11, "web.max_redirects"),
1684            (
1685                |config| config.web.fetch_max_bytes = 0,
1686                "web.fetch_max_bytes",
1687            ),
1688            (
1689                |config| config.web.fetch_timeout_seconds = config.tools.max_timeout_seconds + 1,
1690                "web.fetch_timeout_seconds",
1691            ),
1692            (
1693                |config| config.web.max_search_results = 21,
1694                "web.max_search_results",
1695            ),
1696        ] {
1697            let mut config = Config::default();
1698            mutate(&mut config);
1699            let error = config.validate().unwrap_err().to_string();
1700            assert!(error.contains(message), "{error}");
1701        }
1702        for valid in ["docs.rs", "*.example.com", "a-b.c1.dev"] {
1703            assert!(valid_domain_pattern(valid), "{valid}");
1704        }
1705        for invalid in ["", "*.", "docs.rs/path", "-a.com", "a..b", "*", "user@host"] {
1706            assert!(!valid_domain_pattern(invalid), "{invalid}");
1707        }
1708    }
1709
1710    #[test]
1711    fn projects_may_narrow_but_not_widen_web_access() {
1712        for key in [
1713            "auto_approve_domains = [\"attacker.test\"]",
1714            "allow_private_addresses = true",
1715            "searxng_url = \"http://attacker.test\"",
1716            "brave_url = \"http://attacker.test\"",
1717            "brave_api_key_env = \"OTHER\"",
1718        ] {
1719            let project: toml::Value = toml::from_str(&format!("[web]\n{key}\n")).unwrap();
1720            assert!(validate_project_keys(&project).is_err(), "{key}");
1721        }
1722        let allowed: toml::Value =
1723            toml::from_str("[web]\nenabled = false\nsearch = \"off\"\nmax_redirects = 1\n")
1724                .unwrap();
1725        assert!(validate_project_keys(&allowed).is_ok());
1726
1727        let mut user = Config::default();
1728        user.web.search = WebSearchMode::Provider;
1729        let mut narrower = user.clone();
1730        narrower.web.enabled = false;
1731        narrower.web.search = WebSearchMode::Off;
1732        narrower.web.fetch_max_bytes = 1024;
1733        narrower.web.max_redirects = 0;
1734        assert!(validate_project_not_weaker(&user, &narrower).is_ok());
1735        assert!(validate_project_not_weaker(&narrower, &user).is_err());
1736        let mut switched = user.clone();
1737        switched.web.search = WebSearchMode::Searxng;
1738        assert!(validate_project_not_weaker(&user, &switched).is_err());
1739        let mut larger = user.clone();
1740        larger.web.fetch_timeout_seconds += 1;
1741        assert!(validate_project_not_weaker(&user, &larger).is_err());
1742    }
1743
1744    #[test]
1745    fn cross_field_validation_accounts_for_json_escaping() {
1746        let mut config = Config::default();
1747        config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
1748        assert!(config.validate().is_err());
1749    }
1750
1751    #[test]
1752    fn adapter_selection_templates_survive_partial_overrides_and_validate() {
1753        let mut value: toml::Value =
1754            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1755        merge(
1756            &mut value,
1757            toml::from_str(
1758                r#"[agents.claude]
1759args = ["-p", "--permission-mode", "acceptEdits"]
1760"#,
1761            )
1762            .unwrap(),
1763        );
1764        let config: Config = value.try_into().unwrap();
1765        let claude = &config.agents.0["claude"];
1766        assert_eq!(claude.args.len(), 3);
1767        assert_eq!(claude.model_args, ["--model", "{model}"]);
1768        assert_eq!(claude.effort_args, ["--effort", "{effort}"]);
1769        assert_eq!(
1770            config.agents.0["pi"].effort_args,
1771            ["--thinking", "{effort}"]
1772        );
1773        assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1774
1775        let mut invalid = Config::default();
1776        invalid.agents.0.get_mut("claude").unwrap().effort_args = vec!["--effort".into()];
1777        assert!(
1778            invalid
1779                .validate()
1780                .unwrap_err()
1781                .to_string()
1782                .contains("agents.claude.effort_args must contain {effort}")
1783        );
1784    }
1785
1786    #[test]
1787    fn adapters_are_bound_to_the_instance_home() {
1788        let config = Config {
1789            instance_home: PathBuf::from("/tmp/scv-instance"),
1790            ..Config::default()
1791        };
1792        let adapters = config.adapters();
1793        let codex = &adapters["agent_codex"];
1794        assert!(codex.environment.contains(&(
1795            OsString::from("CODEX_HOME"),
1796            OsString::from("/tmp/scv-instance/adapters/codex")
1797        )));
1798        assert!(codex.environment.contains(&(
1799            OsString::from("SCV_HOME"),
1800            OsString::from("/tmp/scv-instance/adapters/codex")
1801        )));
1802        for (agent, variable, path) in [
1803            ("grok", "GROK_HOME", "/tmp/scv-instance/adapters/grok/.grok"),
1804            ("dsh", "DSH_HOME", "/tmp/scv-instance/adapters/dsh/.dsh"),
1805            (
1806                "pi",
1807                "PI_CODING_AGENT_DIR",
1808                "/tmp/scv-instance/adapters/pi/.pi/agent",
1809            ),
1810        ] {
1811            let adapter = &adapters[&format!("agent_{agent}")];
1812            assert!(
1813                adapter
1814                    .environment
1815                    .contains(&(OsString::from(variable), OsString::from(path))),
1816                "{agent}"
1817            );
1818            assert!(adapter.environment.contains(&(
1819                OsString::from("HOME"),
1820                OsString::from(format!("/tmp/scv-instance/adapters/{agent}"))
1821            )));
1822        }
1823        assert!(adapters["agent_grok"].environment.contains(&(
1824            OsString::from("GROK_DISABLE_AUTOUPDATER"),
1825            OsString::from("1")
1826        )));
1827        assert_eq!(adapters["agent_grok"].prompt_args, ["-p"]);
1828        assert!(adapters["agent_pi"].model_hint.contains("provider scv"));
1829    }
1830
1831    #[test]
1832    fn full_codex_over_acp_keeps_live_web_search() {
1833        let codex_acp = |permissions: &str| {
1834            let mut value: toml::Value =
1835                toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1836            merge(
1837                &mut value,
1838                toml::from_str(&format!(
1839                    "[agents.codex]\npermissions = \"{permissions}\"\n"
1840                ))
1841                .unwrap(),
1842            );
1843            let config: Config = value.try_into().unwrap();
1844            config.validate().unwrap();
1845            config.adapters()["agent_codex"].acp.clone().unwrap()
1846        };
1847        let full = codex_acp("full");
1848        assert_eq!(full.full_mode.as_deref(), Some("agent-full-access"));
1849        let [(variable, value)] = full.environment.as_slice() else {
1850            panic!("expected one ACP variable: {:?}", full.environment);
1851        };
1852        assert_eq!(variable, "CODEX_CONFIG");
1853        let overrides: serde_json::Value = serde_json::from_str(value.to_str().unwrap()).unwrap();
1854        assert_eq!(overrides, serde_json::json!({"web_search": "live"}));
1855        assert!(
1856            codex_acp("default").environment.is_empty(),
1857            "default permissions leave web search to the Codex config"
1858        );
1859        assert!(
1860            scv_tools::adapters::is_removed_agent_variable(std::ffi::OsStr::new("CODEX_CONFIG")),
1861            "an inherited CODEX_CONFIG never reaches a delegated Codex"
1862        );
1863    }
1864
1865    #[test]
1866    fn agents_prefer_their_acp_server_unless_configured_otherwise() {
1867        let defaults = Config::default().adapters();
1868        let launch = |adapters: &HashMap<String, scv_tools::AgentAdapterConfig>, agent: &str| {
1869            adapters[&format!("agent_{agent}")].acp.clone()
1870        };
1871        for agent in ["claude", "codex", "grok", "dsh"] {
1872            let acp = launch(&defaults, agent).unwrap();
1873            assert!(!acp.required, "{agent}: auto falls back to resume");
1874            assert_eq!(acp.full_mode, None, "{agent}: no full mode by default");
1875        }
1876        assert_eq!(
1877            launch(&defaults, "claude").unwrap().command,
1878            "claude-agent-acp"
1879        );
1880        assert_eq!(launch(&defaults, "codex").unwrap().command, "codex-acp");
1881        assert_eq!(launch(&defaults, "grok").unwrap().args, ["agent", "stdio"]);
1882        assert_eq!(launch(&defaults, "dsh").unwrap().args, ["--profile", "acp"]);
1883        assert!(launch(&defaults, "pi").is_none());
1884        assert!(launch(&defaults, "scv").is_none());
1885
1886        let mut value: toml::Value =
1887            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1888        merge(
1889            &mut value,
1890            toml::from_str(
1891                "[agents.claude]\npermissions = \"full\"\ntransport = \"acp\"\n\n\
1892                 [agents.codex]\ntransport = \"resume\"\n\n\
1893                 [agents.grok]\npermissions = \"full\"\n",
1894            )
1895            .unwrap(),
1896        );
1897        let config: Config = value.try_into().unwrap();
1898        config.validate().unwrap();
1899        let adapters = config.adapters();
1900        let claude = launch(&adapters, "claude").unwrap();
1901        assert!(claude.required);
1902        assert_eq!(claude.full_mode.as_deref(), Some("bypassPermissions"));
1903        assert!(launch(&adapters, "codex").is_none(), "resume turns ACP off");
1904
1905        let mut custom: toml::Value =
1906            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1907        merge(
1908            &mut custom,
1909            toml::from_str(
1910                "[agents.claude]\ncommand = \"/opt/claude-wrapper\"\n\n\
1911                 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\n",
1912            )
1913            .unwrap(),
1914        );
1915        let custom: Config = custom.try_into().unwrap();
1916        let custom = custom.adapters();
1917        assert!(
1918            launch(&custom, "claude").is_none(),
1919            "a custom command keeps one process per turn"
1920        );
1921        assert!(launch(&custom, "codex").is_some(), "custom args keep ACP");
1922        assert_eq!(
1923            launch(&adapters, "grok").unwrap().args,
1924            ["agent", "--always-approve", "stdio"]
1925        );
1926
1927        let mut pi: toml::Value =
1928            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1929        merge(
1930            &mut pi,
1931            toml::from_str("[agents.pi]\ntransport = \"acp\"\n").unwrap(),
1932        );
1933        let pi: Config = pi.try_into().unwrap();
1934        let error = pi.validate().unwrap_err().to_string();
1935        assert!(error.contains("no verified ACP server"), "{error}");
1936
1937        let mut invalid: toml::Value =
1938            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1939        merge(
1940            &mut invalid,
1941            toml::from_str("[agents.claude]\ntransport = \"rpc\"\n").unwrap(),
1942        );
1943        assert!(invalid.try_into::<Config>().is_err());
1944    }
1945
1946    #[test]
1947    fn full_permissions_are_opt_in_per_agent_and_combine_with_args() {
1948        let defaults = Config::default().adapters();
1949        for adapter in defaults.values() {
1950            assert_eq!(adapter.full_permission_args, None);
1951        }
1952        let mut value: toml::Value =
1953            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1954        merge(
1955            &mut value,
1956            toml::from_str(
1957                "[agents.claude]\npermissions = \"full\"\n\n\
1958                 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\npermissions = \"full\"\n\n\
1959                 [agents.grok]\npermissions = \"full\"\n\n\
1960                 [agents.dsh]\npermissions = \"full\"\n\n\
1961                 [agents.pi]\npermissions = \"full\"\n",
1962            )
1963            .unwrap(),
1964        );
1965        let config: Config = value.try_into().unwrap();
1966        config.validate().unwrap();
1967        let adapters = config.adapters();
1968        let full = |agent: &str| {
1969            adapters[&format!("agent_{agent}")]
1970                .full_permission_args
1971                .clone()
1972                .unwrap()
1973        };
1974        assert_eq!(full("claude"), ["--permission-mode", "bypassPermissions"]);
1975        assert_eq!(
1976            full("codex"),
1977            [
1978                "--dangerously-bypass-approvals-and-sandbox",
1979                "-c",
1980                "web_search=\"live\""
1981            ]
1982        );
1983        assert_eq!(
1984            adapters["agent_codex"].args,
1985            ["exec", "--skip-git-repo-check"]
1986        );
1987        assert_eq!(full("grok"), ["--always-approve"]);
1988        assert!(full("dsh").is_empty());
1989        assert!(adapters["agent_dsh"].environment.contains(&(
1990            OsString::from("DSH_PERMISSION_MODE"),
1991            OsString::from("danger-full-access")
1992        )));
1993        assert!(
1994            !defaults["agent_dsh"]
1995                .environment
1996                .iter()
1997                .any(|(variable, _)| variable == "DSH_PERMISSION_MODE")
1998        );
1999        // pi has no permission system: `full` is accepted and adds nothing.
2000        assert!(full("pi").is_empty());
2001
2002        let mut invalid: toml::Value =
2003            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2004        merge(
2005            &mut invalid,
2006            toml::from_str("[agents.claude]\npermissions = \"yolo\"\n").unwrap(),
2007        );
2008        assert!(invalid.try_into::<Config>().is_err());
2009    }
2010
2011    #[test]
2012    fn user_agent_overrides_merge_over_every_built_in_and_unknown_agents_fail() {
2013        let mut value: toml::Value =
2014            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2015        merge(
2016            &mut value,
2017            toml::from_str(
2018                "[agents.pi]
2019model_args = []
2020
2021[agents.grok]
2022args = [\"--always-approve\"]
2023",
2024            )
2025            .unwrap(),
2026        );
2027        let config: Config = value.clone().try_into().unwrap();
2028        assert!(config.agents.0["pi"].model_args.is_empty());
2029        assert_eq!(config.agents.0["pi"].args, ["-p"]);
2030        assert_eq!(config.agents.0["grok"].args, ["--always-approve"]);
2031        assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
2032        assert_eq!(
2033            config.agents.0.keys().collect::<Vec<_>>(),
2034            ["claude", "codex", "dsh", "grok", "pi", "scv"]
2035        );
2036
2037        merge(
2038            &mut value,
2039            toml::from_str(
2040                "[agents.zcode]
2041command = \"zcode\"
2042",
2043            )
2044            .unwrap(),
2045        );
2046        let unknown: Config = value.try_into().unwrap();
2047        let error = unknown.validate().unwrap_err().to_string();
2048        assert!(error.contains("unknown agent [agents.zcode]"), "{error}");
2049    }
2050}