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