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