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                                required: config.transport == AgentTransport::Acp,
781                            }),
782                    },
783                ))
784            })
785            .collect()
786    }
787
788    pub fn prepare_adapter_homes(&self) -> Result<()> {
789        for name in self.agents.0.keys() {
790            let path = self.instance_home.join("adapters").join(name);
791            std::fs::create_dir_all(&path)
792                .with_context(|| format!("create isolated {name} adapter home"))?;
793            #[cfg(unix)]
794            {
795                use std::os::unix::fs::PermissionsExt;
796                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
797                    .with_context(|| format!("secure isolated {name} adapter home"))?;
798            }
799        }
800        Ok(())
801    }
802
803    fn validate(&self) -> Result<()> {
804        if self.provider.kind != "openai-compatible" {
805            bail!("provider.kind must be openai-compatible in v0.1");
806        }
807        if self.provider.model.trim().is_empty()
808            || self.provider.base_url.trim().is_empty()
809            || self
810                .provider
811                .api_key
812                .as_deref()
813                .unwrap_or("")
814                .trim()
815                .is_empty()
816                && self
817                    .provider
818                    .api_key_env
819                    .as_deref()
820                    .unwrap_or("")
821                    .trim()
822                    .is_empty()
823        {
824            bail!(
825                "provider model and base_url must be non-empty; configure api_key or api_key_env"
826            );
827        }
828        for (agent, adapter) in &self.agents.0 {
829            if scv_tools::adapters::adapter(agent).is_none() {
830                let known: Vec<_> = scv_tools::adapters::ADAPTERS
831                    .iter()
832                    .map(|adapter| adapter.name)
833                    .collect();
834                bail!(
835                    "unknown agent [agents.{agent}]; known agents are {}",
836                    known.join(", ")
837                );
838            }
839            let name = format!("agents.{agent}.command");
840            if adapter.command.trim().is_empty() {
841                bail!("{name} must be non-empty");
842            }
843            if adapter.transport == AgentTransport::Acp
844                && scv_tools::adapters::adapter(agent)
845                    .is_some_and(|descriptor| descriptor.acp.is_none())
846            {
847                bail!(
848                    "agents.{agent}.transport = \"acp\" but {agent} has no verified ACP server; \
849                     use \"auto\" or \"resume\""
850                );
851            }
852            for (field, template, placeholder) in [
853                ("model_args", &adapter.model_args, "{model}"),
854                ("effort_args", &adapter.effort_args, "{effort}"),
855            ] {
856                if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
857                    let adapter = name.trim_end_matches(".command");
858                    bail!("{adapter}.{field} must contain {placeholder} or be empty");
859                }
860            }
861            let adapter_bytes = adapter.command.len()
862                + [
863                    &adapter.args,
864                    &adapter.prompt_args,
865                    &adapter.model_args,
866                    &adapter.effort_args,
867                ]
868                .into_iter()
869                .flatten()
870                .map(String::len)
871                .sum::<usize>();
872            if adapter_bytes > 16 * 1024 {
873                bail!("{name} and its fixed arguments exceed 16384 bytes");
874            }
875        }
876        let positives = [
877            (
878                "provider.timeout_seconds",
879                usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
880            ),
881            ("agent.max_steps", self.agent.max_steps),
882            ("agent.max_conversations", self.agent.max_conversations),
883            (
884                "agent.conversation_idle_seconds",
885                usize::try_from(self.agent.conversation_idle_seconds).unwrap_or(usize::MAX),
886            ),
887            ("session.max_history_bytes", self.session.max_history_bytes),
888            ("session.max_messages", self.session.max_messages),
889            ("context.max_tokens", self.context.max_tokens),
890            ("context.bytes_per_token", self.context.bytes_per_token),
891            ("context.summary_max_chars", self.context.summary_max_chars),
892            (
893                "tools.command_timeout_seconds",
894                usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
895            ),
896            (
897                "tools.agent_timeout_seconds",
898                usize::try_from(self.tools.agent_timeout_seconds).unwrap_or(usize::MAX),
899            ),
900            (
901                "tools.max_timeout_seconds",
902                usize::try_from(self.tools.max_timeout_seconds).unwrap_or(usize::MAX),
903            ),
904            ("tools.output_limit_bytes", self.tools.output_limit_bytes),
905            ("tools.max_read_bytes", self.tools.max_read_bytes),
906            ("tools.max_write_bytes", self.tools.max_write_bytes),
907            (
908                "protocol.max_client_frame_bytes",
909                self.protocol.max_client_frame_bytes,
910            ),
911            (
912                "protocol.max_server_frame_bytes",
913                self.protocol.max_server_frame_bytes,
914            ),
915            ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
916            ("tui.max_transcript_items", self.tui.max_transcript_items),
917            (
918                "tui.max_prompt_history_bytes",
919                self.tui.max_prompt_history_bytes,
920            ),
921            (
922                "tui.max_prompt_history_items",
923                self.tui.max_prompt_history_items,
924            ),
925            (
926                "provider_limits.max_sse_event_bytes",
927                self.provider_limits.max_sse_event_bytes,
928            ),
929            (
930                "provider_limits.max_response_bytes",
931                self.provider_limits.max_response_bytes,
932            ),
933            (
934                "provider_limits.max_assistant_bytes",
935                self.provider_limits.max_assistant_bytes,
936            ),
937            (
938                "provider_limits.max_tool_calls",
939                self.provider_limits.max_tool_calls,
940            ),
941            (
942                "provider_limits.max_tool_arguments_bytes",
943                self.provider_limits.max_tool_arguments_bytes,
944            ),
945            ("skills.max_skills", self.skills.max_skills),
946            ("skills.max_skill_bytes", self.skills.max_skill_bytes),
947            ("web.fetch_max_bytes", self.web.fetch_max_bytes),
948            (
949                "web.fetch_timeout_seconds",
950                usize::try_from(self.web.fetch_timeout_seconds).unwrap_or(usize::MAX),
951            ),
952            ("web.max_search_results", self.web.max_search_results),
953        ];
954        if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
955            bail!("{name} must be positive");
956        }
957        for (name, value) in [
958            (
959                "tools.command_timeout_seconds",
960                self.tools.command_timeout_seconds,
961            ),
962            (
963                "tools.agent_timeout_seconds",
964                self.tools.agent_timeout_seconds,
965            ),
966        ] {
967            if value > self.tools.max_timeout_seconds {
968                bail!("{name} exceeds tools.max_timeout_seconds");
969            }
970        }
971        if self.tools.max_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS {
972            bail!("tools.max_timeout_seconds must be at most {MAX_TOOL_TIMEOUT_SECONDS}");
973        }
974        if self
975            .context
976            .reserve_output_tokens
977            .saturating_add(self.context.safety_margin_tokens)
978            >= self.context.max_tokens
979        {
980            bail!("context reserve and safety margin consume max_tokens");
981        }
982        let worst_assistant_frame = self
983            .provider_limits
984            .max_assistant_bytes
985            .saturating_mul(6)
986            .saturating_add(64 * 1024);
987        if worst_assistant_frame > self.protocol.max_server_frame_bytes {
988            bail!(
989                "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
990            );
991        }
992        if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
993            bail!("tool argument limit exceeds provider response limit");
994        }
995        if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
996            bail!("provider SSE event limit exceeds provider response limit");
997        }
998        if self.provider_limits.max_retries > MAX_PROVIDER_RETRIES {
999            bail!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}");
1000        }
1001        if self.protocol.max_client_frame_bytes < 4096 {
1002            bail!("protocol.max_client_frame_bytes must be at least 4096");
1003        }
1004        if self.protocol.max_server_frame_bytes < 64 * 1024 {
1005            bail!("protocol.max_server_frame_bytes must be at least 65536");
1006        }
1007        let worst_tool_frame = self
1008            .tools
1009            .output_limit_bytes
1010            .max(self.tools.max_read_bytes)
1011            .saturating_mul(12)
1012            .saturating_add(64 * 1024);
1013        let worst_skill_frame = self
1014            .skills
1015            .max_skill_bytes
1016            .saturating_mul(6)
1017            .saturating_add(64 * 1024);
1018        let worst_arguments_frame = self
1019            .provider_limits
1020            .max_tool_arguments_bytes
1021            .saturating_mul(6)
1022            .saturating_add(64 * 1024);
1023        if worst_tool_frame
1024            .max(worst_skill_frame)
1025            .max(worst_arguments_frame)
1026            > self.protocol.max_server_frame_bytes
1027        {
1028            bail!(
1029                "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
1030            );
1031        }
1032        self.validate_web()?;
1033        if self.skills.project_dir.is_absolute()
1034            || self
1035                .skills
1036                .project_dir
1037                .components()
1038                .any(|component| matches!(component, std::path::Component::ParentDir))
1039        {
1040            bail!("skills.project_dir must be a contained relative path");
1041        }
1042        Ok(())
1043    }
1044}
1045
1046impl Config {
1047    fn validate_web(&self) -> Result<()> {
1048        let web = &self.web;
1049        if web.fetch_max_bytes > 64 * 1024 * 1024 {
1050            bail!("web.fetch_max_bytes must be at most 67108864");
1051        }
1052        if web.fetch_timeout_seconds > self.tools.max_timeout_seconds {
1053            bail!("web.fetch_timeout_seconds exceeds tools.max_timeout_seconds");
1054        }
1055        if web.max_redirects > 10 {
1056            bail!("web.max_redirects must be at most 10");
1057        }
1058        if web.max_search_results > 20 {
1059            bail!("web.max_search_results must be at most 20");
1060        }
1061        if web.auto_approve_domains.len() > 256 {
1062            bail!("web.auto_approve_domains may list at most 256 hosts");
1063        }
1064        if let Some(entry) = web
1065            .auto_approve_domains
1066            .iter()
1067            .find(|entry| !valid_domain_pattern(entry))
1068        {
1069            bail!(
1070                "web.auto_approve_domains entry {entry:?} must be a host name such as docs.rs or *.example.com"
1071            );
1072        }
1073        let http_url = |value: &str| value.starts_with("https://") || value.starts_with("http://");
1074        if !http_url(&web.brave_url) {
1075            bail!("web.brave_url must be an http or https URL");
1076        }
1077        match web.search {
1078            WebSearchMode::Searxng if !web.searxng_url.as_deref().is_some_and(http_url) => {
1079                bail!("web.search = \"searxng\" requires web.searxng_url (an http or https URL)");
1080            }
1081            WebSearchMode::Brave
1082                if web.brave_api_key.as_deref().unwrap_or("").trim().is_empty()
1083                    && web
1084                        .brave_api_key_env
1085                        .as_deref()
1086                        .unwrap_or("")
1087                        .trim()
1088                        .is_empty() =>
1089            {
1090                bail!("web.search = \"brave\" requires web.brave_api_key or web.brave_api_key_env");
1091            }
1092            _ => {}
1093        }
1094        Ok(())
1095    }
1096}
1097
1098/// A host name, optionally prefixed with `*.` to match its subdomains.
1099fn valid_domain_pattern(entry: &str) -> bool {
1100    let host = entry.strip_prefix("*.").unwrap_or(entry);
1101    !host.is_empty()
1102        && host.len() <= 253
1103        && host.split('.').all(|label| {
1104            !label.is_empty()
1105                && label.len() <= 63
1106                && !label.starts_with('-')
1107                && !label.ends_with('-')
1108                && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1109        })
1110}
1111
1112fn user_config_path() -> Option<PathBuf> {
1113    user_home_path().map(|path| path.join("config.toml"))
1114}
1115
1116pub fn user_home_path() -> Option<PathBuf> {
1117    let path = std::env::var_os("SCV_HOME")
1118        .map(PathBuf::from)
1119        .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))?;
1120    if path.exists() {
1121        Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
1122    } else if path.is_absolute() {
1123        Some(path)
1124    } else {
1125        std::env::current_dir().ok().map(|cwd| cwd.join(path))
1126    }
1127}
1128
1129fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
1130    #[cfg(unix)]
1131    {
1132        use std::os::unix::fs::PermissionsExt;
1133        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
1134            .with_context(|| format!("secure directory {}", path.display()))?;
1135    }
1136    Ok(())
1137}
1138
1139fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
1140    let size = std::fs::metadata(path)
1141        .with_context(|| format!("stat configuration {}", path.display()))?
1142        .len();
1143    if size > MAX_CONFIG_BYTES {
1144        bail!("configuration {} exceeds 1 MiB", path.display());
1145    }
1146    let content = std::fs::read_to_string(path)
1147        .with_context(|| format!("read configuration {}", path.display()))?;
1148    toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
1149}
1150
1151fn merge(base: &mut toml::Value, overlay: toml::Value) {
1152    match (base, overlay) {
1153        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
1154            for (key, value) in overlay {
1155                match base.get_mut(&key) {
1156                    Some(existing) => merge(existing, value),
1157                    None => {
1158                        base.insert(key, value);
1159                    }
1160                }
1161            }
1162        }
1163        (base, overlay) => *base = overlay,
1164    }
1165}
1166
1167fn validate_project_keys(value: &toml::Value) -> Result<()> {
1168    let Some(table) = value.as_table() else {
1169        bail!("project configuration must be a TOML table");
1170    };
1171    for forbidden in [
1172        "provider",
1173        "providers",
1174        "provider_active",
1175        "agents",
1176        "update",
1177    ] {
1178        if table.contains_key(forbidden) {
1179            bail!("project configuration cannot set [{forbidden}]");
1180        }
1181    }
1182    if table
1183        .get("skills")
1184        .and_then(toml::Value::as_table)
1185        .is_some_and(|skills| skills.contains_key("user_dir"))
1186    {
1187        bail!("project configuration cannot set skills.user_dir");
1188    }
1189    if table
1190        .get("agent")
1191        .and_then(toml::Value::as_table)
1192        .is_some_and(|agent| agent.contains_key("system_prompt"))
1193    {
1194        bail!("project configuration cannot replace agent.system_prompt");
1195    }
1196    if let Some(web) = table.get("web").and_then(toml::Value::as_table) {
1197        for key in [
1198            "auto_approve_domains",
1199            "allow_private_addresses",
1200            "searxng_url",
1201            "brave_url",
1202            "brave_api_key",
1203            "brave_api_key_env",
1204        ] {
1205            if web.contains_key(key) {
1206                bail!("project configuration cannot set web.{key}");
1207            }
1208        }
1209    }
1210    Ok(())
1211}
1212
1213fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
1214    macro_rules! no_larger {
1215        ($field:expr, $name:literal) => {
1216            if $field.1 > $field.0 {
1217                bail!(concat!("project configuration cannot raise ", $name));
1218            }
1219        };
1220    }
1221    no_larger!(
1222        (user.agent.max_steps, project.agent.max_steps),
1223        "agent.max_steps"
1224    );
1225    no_larger!(
1226        (
1227            user.agent.max_delegation_depth,
1228            project.agent.max_delegation_depth
1229        ),
1230        "agent.max_delegation_depth"
1231    );
1232    no_larger!(
1233        (
1234            user.agent.max_conversations,
1235            project.agent.max_conversations
1236        ),
1237        "agent.max_conversations"
1238    );
1239    no_larger!(
1240        (
1241            user.agent.conversation_idle_seconds,
1242            project.agent.conversation_idle_seconds
1243        ),
1244        "agent.conversation_idle_seconds"
1245    );
1246    no_larger!(
1247        (
1248            user.session.max_history_bytes,
1249            project.session.max_history_bytes
1250        ),
1251        "session.max_history_bytes"
1252    );
1253    no_larger!(
1254        (user.session.max_messages, project.session.max_messages),
1255        "session.max_messages"
1256    );
1257    no_larger!(
1258        (user.context.max_tokens, project.context.max_tokens),
1259        "context.max_tokens"
1260    );
1261    no_larger!(
1262        (
1263            user.context.summary_max_chars,
1264            project.context.summary_max_chars
1265        ),
1266        "context.summary_max_chars"
1267    );
1268    no_larger!(
1269        (
1270            user.tools.command_timeout_seconds,
1271            project.tools.command_timeout_seconds
1272        ),
1273        "tools.command_timeout_seconds"
1274    );
1275    no_larger!(
1276        (
1277            user.tools.agent_timeout_seconds,
1278            project.tools.agent_timeout_seconds
1279        ),
1280        "tools.agent_timeout_seconds"
1281    );
1282    no_larger!(
1283        (
1284            user.tools.max_timeout_seconds,
1285            project.tools.max_timeout_seconds
1286        ),
1287        "tools.max_timeout_seconds"
1288    );
1289    no_larger!(
1290        (
1291            user.tools.output_limit_bytes,
1292            project.tools.output_limit_bytes
1293        ),
1294        "tools.output_limit_bytes"
1295    );
1296    no_larger!(
1297        (user.tools.max_read_bytes, project.tools.max_read_bytes),
1298        "tools.max_read_bytes"
1299    );
1300    no_larger!(
1301        (user.tools.max_write_bytes, project.tools.max_write_bytes),
1302        "tools.max_write_bytes"
1303    );
1304    no_larger!(
1305        (
1306            user.protocol.max_client_frame_bytes,
1307            project.protocol.max_client_frame_bytes
1308        ),
1309        "protocol.max_client_frame_bytes"
1310    );
1311    no_larger!(
1312        (
1313            user.protocol.max_server_frame_bytes,
1314            project.protocol.max_server_frame_bytes
1315        ),
1316        "protocol.max_server_frame_bytes"
1317    );
1318    no_larger!(
1319        (
1320            user.provider_limits.max_response_bytes,
1321            project.provider_limits.max_response_bytes
1322        ),
1323        "provider_limits.max_response_bytes"
1324    );
1325    no_larger!(
1326        (
1327            user.provider_limits.max_sse_event_bytes,
1328            project.provider_limits.max_sse_event_bytes
1329        ),
1330        "provider_limits.max_sse_event_bytes"
1331    );
1332    no_larger!(
1333        (
1334            user.provider_limits.max_assistant_bytes,
1335            project.provider_limits.max_assistant_bytes
1336        ),
1337        "provider_limits.max_assistant_bytes"
1338    );
1339    no_larger!(
1340        (
1341            user.provider_limits.max_tool_calls,
1342            project.provider_limits.max_tool_calls
1343        ),
1344        "provider_limits.max_tool_calls"
1345    );
1346    no_larger!(
1347        (
1348            user.provider_limits.max_tool_arguments_bytes,
1349            project.provider_limits.max_tool_arguments_bytes
1350        ),
1351        "provider_limits.max_tool_arguments_bytes"
1352    );
1353    no_larger!(
1354        (
1355            user.provider_limits.max_retries,
1356            project.provider_limits.max_retries
1357        ),
1358        "provider_limits.max_retries"
1359    );
1360    no_larger!(
1361        (
1362            user.tui.max_transcript_bytes,
1363            project.tui.max_transcript_bytes
1364        ),
1365        "tui.max_transcript_bytes"
1366    );
1367    no_larger!(
1368        (
1369            user.tui.max_transcript_items,
1370            project.tui.max_transcript_items
1371        ),
1372        "tui.max_transcript_items"
1373    );
1374    no_larger!(
1375        (
1376            user.tui.max_prompt_history_bytes,
1377            project.tui.max_prompt_history_bytes
1378        ),
1379        "tui.max_prompt_history_bytes"
1380    );
1381    no_larger!(
1382        (
1383            user.tui.max_prompt_history_items,
1384            project.tui.max_prompt_history_items
1385        ),
1386        "tui.max_prompt_history_items"
1387    );
1388    no_larger!(
1389        (user.skills.max_skills, project.skills.max_skills),
1390        "skills.max_skills"
1391    );
1392    no_larger!(
1393        (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
1394        "skills.max_skill_bytes"
1395    );
1396    if project.context.reserve_output_tokens < user.context.reserve_output_tokens
1397        || project.context.safety_margin_tokens < user.context.safety_margin_tokens
1398    {
1399        bail!("project configuration cannot lower context reserves");
1400    }
1401    if project.context.bytes_per_token > user.context.bytes_per_token {
1402        bail!("project configuration cannot raise context.bytes_per_token");
1403    }
1404    if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
1405        bail!("project configuration cannot weaken tools.approval_policy");
1406    }
1407    if project.skills.scan_projects && !user.skills.scan_projects {
1408        bail!("project configuration cannot enable skills.scan_projects");
1409    }
1410    if project.web.enabled && !user.web.enabled {
1411        bail!("project configuration cannot enable web");
1412    }
1413    if project.web.search != user.web.search && project.web.search != WebSearchMode::Off {
1414        bail!("project configuration can only turn web.search off");
1415    }
1416    no_larger!(
1417        (user.web.fetch_max_bytes, project.web.fetch_max_bytes),
1418        "web.fetch_max_bytes"
1419    );
1420    no_larger!(
1421        (
1422            user.web.fetch_timeout_seconds,
1423            project.web.fetch_timeout_seconds
1424        ),
1425        "web.fetch_timeout_seconds"
1426    );
1427    no_larger!(
1428        (user.web.max_redirects, project.web.max_redirects),
1429        "web.max_redirects"
1430    );
1431    no_larger!(
1432        (user.web.max_search_results, project.web.max_search_results),
1433        "web.max_search_results"
1434    );
1435    Ok(())
1436}
1437
1438fn expand_home(path: &std::path::Path) -> PathBuf {
1439    let value = path.to_string_lossy();
1440    if value == "~" {
1441        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
1442    }
1443    if let Some(rest) = value.strip_prefix("~/")
1444        && let Some(home) = dirs::home_dir()
1445    {
1446        return home.join(rest);
1447    }
1448    path.to_path_buf()
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453    use super::*;
1454
1455    #[test]
1456    fn project_cannot_redirect_provider_or_agent() {
1457        let provider: toml::Value = toml::from_str(
1458            r#"[provider]
1459base_url = "https://attacker.invalid"
1460"#,
1461        )
1462        .unwrap();
1463        assert!(validate_project_keys(&provider).is_err());
1464
1465        let agent: toml::Value = toml::from_str(
1466            r#"[agents.codex]
1467command = "/tmp/fake"
1468"#,
1469        )
1470        .unwrap();
1471        assert!(validate_project_keys(&agent).is_err());
1472    }
1473
1474    #[test]
1475    fn project_may_tighten_but_not_weaken_limits() {
1476        let user = Config::default();
1477        let mut tighter = user.clone();
1478        tighter.tools.output_limit_bytes /= 2;
1479        tighter.tools.approval_policy = ApprovalPolicy::Always;
1480        assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1481
1482        let mut weaker = user.clone();
1483        weaker.tools.output_limit_bytes *= 2;
1484        assert!(validate_project_not_weaker(&user, &weaker).is_err());
1485    }
1486
1487    #[test]
1488    fn timeouts_default_below_a_ceiling_that_projects_may_only_lower() {
1489        let user = Config::default();
1490        assert_eq!(
1491            (
1492                user.tools.command_timeout_seconds,
1493                user.tools.agent_timeout_seconds,
1494                user.tools.max_timeout_seconds
1495            ),
1496            (600, 3600, 14400)
1497        );
1498        assert_eq!(user.agent.max_steps, 128);
1499        assert_eq!(user.provider.timeout_seconds, 600);
1500        let tools = user.tools();
1501        assert_eq!(tools.command_timeout, Duration::from_secs(600));
1502        assert_eq!(tools.agent_timeout, Duration::from_secs(3600));
1503        assert_eq!(tools.max_timeout, Duration::from_secs(14400));
1504        // A ClawBot owner turn outlasts the ceiling by five minutes: 4h05m.
1505        assert_eq!(
1506            scv_clawbot::owner_turn_timeout(tools.max_timeout),
1507            Duration::from_secs(4 * 3600 + 5 * 60)
1508        );
1509
1510        for (field, name) in [
1511            (0, "tools.command_timeout_seconds"),
1512            (1, "tools.agent_timeout_seconds"),
1513        ] {
1514            let mut config = Config::default();
1515            let value = if field == 0 {
1516                &mut config.tools.command_timeout_seconds
1517            } else {
1518                &mut config.tools.agent_timeout_seconds
1519            };
1520            *value = config.tools.max_timeout_seconds + 1;
1521            assert_eq!(
1522                config.validate().unwrap_err().to_string(),
1523                format!("{name} exceeds tools.max_timeout_seconds")
1524            );
1525        }
1526        let mut unbounded = Config::default();
1527        unbounded.tools.max_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS + 1;
1528        assert!(unbounded.validate().is_err());
1529        let mut zero = Config::default();
1530        zero.tools.agent_timeout_seconds = 0;
1531        assert!(zero.validate().is_err());
1532
1533        let mut lower = user.clone();
1534        lower.tools.max_timeout_seconds = 900;
1535        lower.tools.agent_timeout_seconds = 300;
1536        assert!(validate_project_not_weaker(&user, &lower).is_ok());
1537        for raise in [
1538            |config: &mut Config| config.tools.max_timeout_seconds += 1,
1539            |config: &mut Config| config.tools.agent_timeout_seconds += 1,
1540        ] {
1541            let mut higher = user.clone();
1542            raise(&mut higher);
1543            assert!(validate_project_not_weaker(&user, &higher).is_err());
1544        }
1545    }
1546
1547    #[test]
1548    fn conversation_limits_are_positive_and_projects_may_only_lower_them() {
1549        let user = Config::default();
1550        assert_eq!(
1551            (
1552                user.agent.max_conversations,
1553                user.agent.conversation_idle_seconds
1554            ),
1555            (8, 86400)
1556        );
1557        let limits = user.tools().conversations;
1558        assert_eq!((limits.max, limits.idle), (8, Duration::from_secs(86400)));
1559        for zero in [
1560            |config: &mut Config| config.agent.max_conversations = 0,
1561            |config: &mut Config| config.agent.conversation_idle_seconds = 0,
1562        ] {
1563            let mut config = Config::default();
1564            zero(&mut config);
1565            assert!(config.validate().is_err());
1566        }
1567        let mut lower = user.clone();
1568        lower.agent.max_conversations = 2;
1569        lower.agent.conversation_idle_seconds = 600;
1570        assert!(validate_project_not_weaker(&user, &lower).is_ok());
1571        for raise in [
1572            |config: &mut Config| config.agent.max_conversations += 1,
1573            |config: &mut Config| config.agent.conversation_idle_seconds += 1,
1574        ] {
1575            let mut higher = user.clone();
1576            raise(&mut higher);
1577            assert!(validate_project_not_weaker(&user, &higher).is_err());
1578        }
1579    }
1580
1581    #[test]
1582    fn provider_retries_are_bounded_and_projects_may_only_lower_them() {
1583        let user = Config::default();
1584        assert_eq!(user.provider_limits.max_retries, 2);
1585        assert_eq!(user.provider_limits().max_retries, 2);
1586        let mut none = user.clone();
1587        none.provider_limits.max_retries = 0;
1588        assert!(none.validate().is_ok());
1589        assert!(validate_project_not_weaker(&user, &none).is_ok());
1590        assert!(validate_project_not_weaker(&none, &user).is_err());
1591        let mut excessive = user.clone();
1592        excessive.provider_limits.max_retries = MAX_PROVIDER_RETRIES + 1;
1593        assert_eq!(
1594            excessive.validate().unwrap_err().to_string(),
1595            format!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}")
1596        );
1597    }
1598
1599    #[test]
1600    fn projects_may_disable_but_not_enable_project_skill_scanning() {
1601        let user = Config::default();
1602        let mut disabled = user.clone();
1603        disabled.skills.scan_projects = false;
1604        assert!(validate_project_not_weaker(&user, &disabled).is_ok());
1605        assert!(validate_project_not_weaker(&disabled, &user).is_err());
1606    }
1607
1608    #[test]
1609    fn web_defaults_offer_fetch_without_search_and_validate_their_settings() {
1610        let config = Config::default();
1611        assert!(config.web.enabled);
1612        assert_eq!(config.web.search, WebSearchMode::Off);
1613        assert!(!config.hosted_web_search());
1614        let tools = config.web_tools().unwrap();
1615        assert!(tools.search.is_none());
1616        assert!(!tools.allow_private_addresses);
1617        assert_eq!(tools.fetch_max_bytes, 2 * 1024 * 1024);
1618        assert_eq!(tools.output_limit, config.tools.output_limit_bytes);
1619        assert!(tools.auto_approve_domains.contains(&"docs.rs".to_owned()));
1620
1621        let mut disabled = Config::default();
1622        disabled.web.enabled = false;
1623        disabled.web.search = WebSearchMode::Provider;
1624        assert!(disabled.web_tools().is_none());
1625        assert!(!disabled.hosted_web_search());
1626
1627        let mut provider = Config::default();
1628        provider.web.search = WebSearchMode::Provider;
1629        assert!(provider.hosted_web_search());
1630        assert!(provider.web_tools().unwrap().search.is_none());
1631
1632        let mut searxng = Config::default();
1633        searxng.web.search = WebSearchMode::Searxng;
1634        assert!(
1635            searxng
1636                .validate()
1637                .unwrap_err()
1638                .to_string()
1639                .contains("web.searxng_url")
1640        );
1641        searxng.web.searxng_url = Some("https://searx.example".into());
1642        assert!(searxng.validate().is_ok());
1643        assert!(matches!(
1644            searxng.web_tools().unwrap().search,
1645            Some(SearchBackend::Searxng { .. })
1646        ));
1647
1648        let mut brave = Config::default();
1649        brave.web.search = WebSearchMode::Brave;
1650        brave.web.brave_api_key_env = None;
1651        assert!(
1652            brave
1653                .validate()
1654                .unwrap_err()
1655                .to_string()
1656                .contains("brave_api_key")
1657        );
1658        brave.web.brave_api_key = Some("inline-test-key".into());
1659        assert!(matches!(
1660            brave.web_tools().unwrap().search,
1661            Some(SearchBackend::Brave { ref api_key, .. }) if api_key == "inline-test-key"
1662        ));
1663        brave.web.brave_api_key = None;
1664        brave.web.brave_api_key_env = Some("SCV_TEST_UNSET_BRAVE_KEY_VARIABLE".into());
1665        assert!(brave.validate().is_ok());
1666        assert!(brave.web_tools().unwrap().search.is_none());
1667
1668        for (mutate, message) in [
1669            (
1670                (|config: &mut Config| {
1671                    config.web.auto_approve_domains = vec!["https://docs.rs/".into()]
1672                }) as fn(&mut Config),
1673                "web.auto_approve_domains",
1674            ),
1675            (|config| config.web.max_redirects = 11, "web.max_redirects"),
1676            (
1677                |config| config.web.fetch_max_bytes = 0,
1678                "web.fetch_max_bytes",
1679            ),
1680            (
1681                |config| config.web.fetch_timeout_seconds = config.tools.max_timeout_seconds + 1,
1682                "web.fetch_timeout_seconds",
1683            ),
1684            (
1685                |config| config.web.max_search_results = 21,
1686                "web.max_search_results",
1687            ),
1688        ] {
1689            let mut config = Config::default();
1690            mutate(&mut config);
1691            let error = config.validate().unwrap_err().to_string();
1692            assert!(error.contains(message), "{error}");
1693        }
1694        for valid in ["docs.rs", "*.example.com", "a-b.c1.dev"] {
1695            assert!(valid_domain_pattern(valid), "{valid}");
1696        }
1697        for invalid in ["", "*.", "docs.rs/path", "-a.com", "a..b", "*", "user@host"] {
1698            assert!(!valid_domain_pattern(invalid), "{invalid}");
1699        }
1700    }
1701
1702    #[test]
1703    fn projects_may_narrow_but_not_widen_web_access() {
1704        for key in [
1705            "auto_approve_domains = [\"attacker.test\"]",
1706            "allow_private_addresses = true",
1707            "searxng_url = \"http://attacker.test\"",
1708            "brave_url = \"http://attacker.test\"",
1709            "brave_api_key_env = \"OTHER\"",
1710        ] {
1711            let project: toml::Value = toml::from_str(&format!("[web]\n{key}\n")).unwrap();
1712            assert!(validate_project_keys(&project).is_err(), "{key}");
1713        }
1714        let allowed: toml::Value =
1715            toml::from_str("[web]\nenabled = false\nsearch = \"off\"\nmax_redirects = 1\n")
1716                .unwrap();
1717        assert!(validate_project_keys(&allowed).is_ok());
1718
1719        let mut user = Config::default();
1720        user.web.search = WebSearchMode::Provider;
1721        let mut narrower = user.clone();
1722        narrower.web.enabled = false;
1723        narrower.web.search = WebSearchMode::Off;
1724        narrower.web.fetch_max_bytes = 1024;
1725        narrower.web.max_redirects = 0;
1726        assert!(validate_project_not_weaker(&user, &narrower).is_ok());
1727        assert!(validate_project_not_weaker(&narrower, &user).is_err());
1728        let mut switched = user.clone();
1729        switched.web.search = WebSearchMode::Searxng;
1730        assert!(validate_project_not_weaker(&user, &switched).is_err());
1731        let mut larger = user.clone();
1732        larger.web.fetch_timeout_seconds += 1;
1733        assert!(validate_project_not_weaker(&user, &larger).is_err());
1734    }
1735
1736    #[test]
1737    fn cross_field_validation_accounts_for_json_escaping() {
1738        let mut config = Config::default();
1739        config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
1740        assert!(config.validate().is_err());
1741    }
1742
1743    #[test]
1744    fn adapter_selection_templates_survive_partial_overrides_and_validate() {
1745        let mut value: toml::Value =
1746            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1747        merge(
1748            &mut value,
1749            toml::from_str(
1750                r#"[agents.claude]
1751args = ["-p", "--permission-mode", "acceptEdits"]
1752"#,
1753            )
1754            .unwrap(),
1755        );
1756        let config: Config = value.try_into().unwrap();
1757        let claude = &config.agents.0["claude"];
1758        assert_eq!(claude.args.len(), 3);
1759        assert_eq!(claude.model_args, ["--model", "{model}"]);
1760        assert_eq!(claude.effort_args, ["--effort", "{effort}"]);
1761        assert_eq!(
1762            config.agents.0["pi"].effort_args,
1763            ["--thinking", "{effort}"]
1764        );
1765        assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1766
1767        let mut invalid = Config::default();
1768        invalid.agents.0.get_mut("claude").unwrap().effort_args = vec!["--effort".into()];
1769        assert!(
1770            invalid
1771                .validate()
1772                .unwrap_err()
1773                .to_string()
1774                .contains("agents.claude.effort_args must contain {effort}")
1775        );
1776    }
1777
1778    #[test]
1779    fn adapters_are_bound_to_the_instance_home() {
1780        let config = Config {
1781            instance_home: PathBuf::from("/tmp/scv-instance"),
1782            ..Config::default()
1783        };
1784        let adapters = config.adapters();
1785        let codex = &adapters["agent_codex"];
1786        assert!(codex.environment.contains(&(
1787            OsString::from("CODEX_HOME"),
1788            OsString::from("/tmp/scv-instance/adapters/codex")
1789        )));
1790        assert!(codex.environment.contains(&(
1791            OsString::from("SCV_HOME"),
1792            OsString::from("/tmp/scv-instance/adapters/codex")
1793        )));
1794        for (agent, variable, path) in [
1795            ("grok", "GROK_HOME", "/tmp/scv-instance/adapters/grok/.grok"),
1796            ("dsh", "DSH_HOME", "/tmp/scv-instance/adapters/dsh/.dsh"),
1797            (
1798                "pi",
1799                "PI_CODING_AGENT_DIR",
1800                "/tmp/scv-instance/adapters/pi/.pi/agent",
1801            ),
1802        ] {
1803            let adapter = &adapters[&format!("agent_{agent}")];
1804            assert!(
1805                adapter
1806                    .environment
1807                    .contains(&(OsString::from(variable), OsString::from(path))),
1808                "{agent}"
1809            );
1810            assert!(adapter.environment.contains(&(
1811                OsString::from("HOME"),
1812                OsString::from(format!("/tmp/scv-instance/adapters/{agent}"))
1813            )));
1814        }
1815        assert!(adapters["agent_grok"].environment.contains(&(
1816            OsString::from("GROK_DISABLE_AUTOUPDATER"),
1817            OsString::from("1")
1818        )));
1819        assert_eq!(adapters["agent_grok"].prompt_args, ["-p"]);
1820        assert!(adapters["agent_pi"].model_hint.contains("provider scv"));
1821    }
1822
1823    #[test]
1824    fn agents_prefer_their_acp_server_unless_configured_otherwise() {
1825        let defaults = Config::default().adapters();
1826        let launch = |adapters: &HashMap<String, scv_tools::AgentAdapterConfig>, agent: &str| {
1827            adapters[&format!("agent_{agent}")].acp.clone()
1828        };
1829        for agent in ["claude", "codex", "grok", "dsh"] {
1830            let acp = launch(&defaults, agent).unwrap();
1831            assert!(!acp.required, "{agent}: auto falls back to resume");
1832            assert_eq!(acp.full_mode, None, "{agent}: no full mode by default");
1833        }
1834        assert_eq!(
1835            launch(&defaults, "claude").unwrap().command,
1836            "claude-agent-acp"
1837        );
1838        assert_eq!(launch(&defaults, "codex").unwrap().command, "codex-acp");
1839        assert_eq!(launch(&defaults, "grok").unwrap().args, ["agent", "stdio"]);
1840        assert_eq!(launch(&defaults, "dsh").unwrap().args, ["--profile", "acp"]);
1841        assert!(launch(&defaults, "pi").is_none());
1842        assert!(launch(&defaults, "scv").is_none());
1843
1844        let mut value: toml::Value =
1845            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1846        merge(
1847            &mut value,
1848            toml::from_str(
1849                "[agents.claude]\npermissions = \"full\"\ntransport = \"acp\"\n\n\
1850                 [agents.codex]\ntransport = \"resume\"\n\n\
1851                 [agents.grok]\npermissions = \"full\"\n",
1852            )
1853            .unwrap(),
1854        );
1855        let config: Config = value.try_into().unwrap();
1856        config.validate().unwrap();
1857        let adapters = config.adapters();
1858        let claude = launch(&adapters, "claude").unwrap();
1859        assert!(claude.required);
1860        assert_eq!(claude.full_mode.as_deref(), Some("bypassPermissions"));
1861        assert!(launch(&adapters, "codex").is_none(), "resume turns ACP off");
1862
1863        let mut custom: toml::Value =
1864            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1865        merge(
1866            &mut custom,
1867            toml::from_str(
1868                "[agents.claude]\ncommand = \"/opt/claude-wrapper\"\n\n\
1869                 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\n",
1870            )
1871            .unwrap(),
1872        );
1873        let custom: Config = custom.try_into().unwrap();
1874        let custom = custom.adapters();
1875        assert!(
1876            launch(&custom, "claude").is_none(),
1877            "a custom command keeps one process per turn"
1878        );
1879        assert!(launch(&custom, "codex").is_some(), "custom args keep ACP");
1880        assert_eq!(
1881            launch(&adapters, "grok").unwrap().args,
1882            ["agent", "--always-approve", "stdio"]
1883        );
1884
1885        let mut pi: toml::Value =
1886            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1887        merge(
1888            &mut pi,
1889            toml::from_str("[agents.pi]\ntransport = \"acp\"\n").unwrap(),
1890        );
1891        let pi: Config = pi.try_into().unwrap();
1892        let error = pi.validate().unwrap_err().to_string();
1893        assert!(error.contains("no verified ACP server"), "{error}");
1894
1895        let mut invalid: toml::Value =
1896            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1897        merge(
1898            &mut invalid,
1899            toml::from_str("[agents.claude]\ntransport = \"rpc\"\n").unwrap(),
1900        );
1901        assert!(invalid.try_into::<Config>().is_err());
1902    }
1903
1904    #[test]
1905    fn full_permissions_are_opt_in_per_agent_and_combine_with_args() {
1906        let defaults = Config::default().adapters();
1907        for adapter in defaults.values() {
1908            assert_eq!(adapter.full_permission_args, None);
1909        }
1910        let mut value: toml::Value =
1911            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1912        merge(
1913            &mut value,
1914            toml::from_str(
1915                "[agents.claude]\npermissions = \"full\"\n\n\
1916                 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\npermissions = \"full\"\n\n\
1917                 [agents.grok]\npermissions = \"full\"\n\n\
1918                 [agents.dsh]\npermissions = \"full\"\n\n\
1919                 [agents.pi]\npermissions = \"full\"\n",
1920            )
1921            .unwrap(),
1922        );
1923        let config: Config = value.try_into().unwrap();
1924        config.validate().unwrap();
1925        let adapters = config.adapters();
1926        let full = |agent: &str| {
1927            adapters[&format!("agent_{agent}")]
1928                .full_permission_args
1929                .clone()
1930                .unwrap()
1931        };
1932        assert_eq!(full("claude"), ["--permission-mode", "bypassPermissions"]);
1933        assert_eq!(
1934            full("codex"),
1935            [
1936                "--dangerously-bypass-approvals-and-sandbox",
1937                "-c",
1938                "web_search=\"live\""
1939            ]
1940        );
1941        assert_eq!(
1942            adapters["agent_codex"].args,
1943            ["exec", "--skip-git-repo-check"]
1944        );
1945        assert_eq!(full("grok"), ["--always-approve"]);
1946        assert!(full("dsh").is_empty());
1947        assert!(adapters["agent_dsh"].environment.contains(&(
1948            OsString::from("DSH_PERMISSION_MODE"),
1949            OsString::from("danger-full-access")
1950        )));
1951        assert!(
1952            !defaults["agent_dsh"]
1953                .environment
1954                .iter()
1955                .any(|(variable, _)| variable == "DSH_PERMISSION_MODE")
1956        );
1957        // pi has no permission system: `full` is accepted and adds nothing.
1958        assert!(full("pi").is_empty());
1959
1960        let mut invalid: toml::Value =
1961            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1962        merge(
1963            &mut invalid,
1964            toml::from_str("[agents.claude]\npermissions = \"yolo\"\n").unwrap(),
1965        );
1966        assert!(invalid.try_into::<Config>().is_err());
1967    }
1968
1969    #[test]
1970    fn user_agent_overrides_merge_over_every_built_in_and_unknown_agents_fail() {
1971        let mut value: toml::Value =
1972            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1973        merge(
1974            &mut value,
1975            toml::from_str(
1976                "[agents.pi]
1977model_args = []
1978
1979[agents.grok]
1980args = [\"--always-approve\"]
1981",
1982            )
1983            .unwrap(),
1984        );
1985        let config: Config = value.clone().try_into().unwrap();
1986        assert!(config.agents.0["pi"].model_args.is_empty());
1987        assert_eq!(config.agents.0["pi"].args, ["-p"]);
1988        assert_eq!(config.agents.0["grok"].args, ["--always-approve"]);
1989        assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1990        assert_eq!(
1991            config.agents.0.keys().collect::<Vec<_>>(),
1992            ["claude", "codex", "dsh", "grok", "pi", "scv"]
1993        );
1994
1995        merge(
1996            &mut value,
1997            toml::from_str(
1998                "[agents.zcode]
1999command = \"zcode\"
2000",
2001            )
2002            .unwrap(),
2003        );
2004        let unknown: Config = value.try_into().unwrap();
2005        let error = unknown.validate().unwrap_err().to_string();
2006        assert!(error.contains("unknown agent [agents.zcode]"), "{error}");
2007    }
2008}