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