Skip to main content

scv_server/
config.rs

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