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_channels::state::AccountSettings;
11use scv_client::Layout;
12use scv_core::{AgentConfig as CoreAgentConfig, ContextConfig, HistoryLimits};
13use scv_provider_openai::ProviderLimits;
14use scv_tools::{
15    AgentAdapterConfig, ToolsConfig,
16    conversation::ConversationLimits,
17    web::{SearchBackend, WebToolsConfig},
18};
19use serde::{Deserialize, Serialize};
20
21const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
22/// A day: long enough for any delegated job, short enough that deadline
23/// arithmetic never overflows.
24const MAX_TOOL_TIMEOUT_SECONDS: u64 = 24 * 60 * 60;
25/// Retries multiply provider load and turn latency, so they stay small.
26const MAX_PROVIDER_RETRIES: usize = 10;
27/// The most background agent jobs `agent.max_background` may allow.
28const MAX_BACKGROUND_JOBS: usize = 16;
29/// Longest `[agents.<name>] use_for` note.
30const MAX_USE_FOR_BYTES: usize = 500;
31
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33#[serde(default, deny_unknown_fields)]
34pub struct Config {
35    pub provider: ProviderConfig,
36    /// Named provider profiles. When non-empty, `provider.active` selects one.
37    pub providers: HashMap<String, ProviderConfig>,
38    pub provider_active: Option<String>,
39    pub agent: AgentConfig,
40    pub session: SessionConfig,
41    pub context: ContextConfigFile,
42    pub tools: ToolConfig,
43    pub protocol: ProtocolConfig,
44    pub tui: TuiConfig,
45    pub update: UpdateConfig,
46    pub notify: NotifyConfig,
47    pub provider_limits: ProviderLimitsFile,
48    pub skills: SkillsConfig,
49    pub agents: AgentsConfig,
50    pub web: WebConfig,
51    /// `[channels.<channel>.<account>]`: each chat account's settings. SCV's
52    /// channel store reads and edits them in the instance's `config.toml`;
53    /// here they are only validated.
54    pub channels: BTreeMap<String, BTreeMap<String, AccountSettings>>,
55    /// The process-owned root: see [`Layout`] for what it holds.
56    #[serde(skip)]
57    pub instance_home: PathBuf,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(default, deny_unknown_fields)]
62pub struct ProviderConfig {
63    pub active: Option<String>,
64    pub kind: String,
65    pub wire_api: String,
66    pub model: String,
67    pub base_url: String,
68    pub api_key: Option<String>,
69    pub api_key_env: Option<String>,
70    pub timeout_seconds: u64,
71    pub headers: HashMap<String, String>,
72    /// Show images users attach to the model as image input. Turn it off
73    /// for a model without vision; SCV also stops for the session after the
74    /// provider rejects an image.
75    pub image_input: bool,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, Default)]
79#[serde(default, deny_unknown_fields)]
80pub struct UpdateConfig {
81    /// Optional Cargo registry index URL used by `scv update`.
82    pub index_url: Option<String>,
83}
84
85/// Where SCV sends notices nobody asked for: an update started from a
86/// terminal, a rollback, a restart after a crash, or a disconnected account.
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88#[serde(default, deny_unknown_fields)]
89pub struct NotifyConfig {
90    /// Accounts as `<channel>:<account>`, such as `feishu:default`. A notice
91    /// goes to the owner of the first one that is connected, on that one
92    /// account only. Empty: the chat the owner last wrote from.
93    pub owner: Vec<String>,
94}
95
96impl Default for ProviderConfig {
97    fn default() -> Self {
98        Self {
99            active: None,
100            kind: "openai-compatible".into(),
101            wire_api: "responses".into(),
102            model: "gpt-4.1-mini".into(),
103            base_url: "https://api.openai.com/v1".into(),
104            api_key: None,
105            api_key_env: Some("OPENAI_API_KEY".into()),
106            timeout_seconds: 600,
107            headers: HashMap::new(),
108            image_input: true,
109        }
110    }
111}
112
113impl Config {
114    pub fn init_user_config() -> Result<PathBuf> {
115        let path = user_config_path()
116            .ok_or_else(|| anyhow::anyhow!("cannot determine user config path"))?;
117        if let Some(parent) = path.parent() {
118            std::fs::create_dir_all(parent).context("create config directory")?;
119            ensure_private_dir(parent)?;
120        }
121        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";
122        if !path.exists() {
123            let parent = path
124                .parent()
125                .ok_or_else(|| anyhow::anyhow!("configuration path has no parent"))?;
126            let mut temporary = tempfile::NamedTempFile::new_in(parent)
127                .context("create temporary example configuration")?;
128            #[cfg(unix)]
129            {
130                use std::os::unix::fs::PermissionsExt;
131                temporary
132                    .as_file()
133                    .set_permissions(std::fs::Permissions::from_mode(0o600))
134                    .context("secure temporary configuration")?;
135            }
136            temporary
137                .write_all(content.as_bytes())
138                .context("write example configuration")?;
139            temporary
140                .as_file()
141                .sync_all()
142                .context("sync example configuration")?;
143            match temporary.persist(&path) {
144                Ok(_) => {}
145                Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {}
146                Err(error) => return Err(error.error).context("install example configuration"),
147            }
148        }
149        Ok(path)
150    }
151    pub fn active_provider(&self) -> Result<ProviderConfig> {
152        if let Some(name) = self
153            .provider_active
154            .as_deref()
155            .or(self.provider.active.as_deref())
156        {
157            return self
158                .providers
159                .get(name)
160                .cloned()
161                .ok_or_else(|| anyhow::anyhow!("active provider profile {name:?} was not found"));
162        }
163        Ok(self.provider.clone())
164    }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(default, deny_unknown_fields)]
169pub struct AgentConfig {
170    pub max_steps: usize,
171    pub system_prompt: String,
172    /// `agent_*` tools are offered only while this SCV's own delegation depth
173    /// is below this, so delegation chains stay bounded. 0 disables them.
174    pub max_delegation_depth: u32,
175    /// Delegated conversations a session remembers; starting another forgets
176    /// the least recently used idle one.
177    pub max_conversations: usize,
178    /// A delegated conversation unused this long is forgotten.
179    pub conversation_idle_seconds: u64,
180    /// Background agent jobs (`background: true`) a session may run at once;
181    /// 0 turns background delegation off.
182    pub max_background: usize,
183    /// Agents the user prefers, in order (such as `["codex", "claude"]`);
184    /// the system prompt names the installed ones. Empty states no preference.
185    pub prefer: Vec<String>,
186}
187
188impl Default for AgentConfig {
189    fn default() -> Self {
190        Self {
191            max_steps: 128,
192            max_delegation_depth: 2,
193            max_conversations: 8,
194            conversation_idle_seconds: 86400,
195            // The main agent hands most work to background jobs and stays
196            // available, so a few may run at once.
197            max_background: 4,
198            prefer: Vec::new(),
199            system_prompt: "You are SCV, a concise and careful coding agent. Use tools to inspect, change, and verify the workspace.".into(),
200        }
201    }
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(default, deny_unknown_fields)]
206pub struct SessionConfig {
207    pub max_history_bytes: usize,
208    pub max_messages: usize,
209}
210
211impl Default for SessionConfig {
212    fn default() -> Self {
213        Self {
214            max_history_bytes: 16 * 1024 * 1024,
215            max_messages: 10_000,
216        }
217    }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(default, deny_unknown_fields)]
222pub struct ContextConfigFile {
223    pub max_tokens: usize,
224    pub reserve_output_tokens: usize,
225    pub safety_margin_tokens: usize,
226    pub bytes_per_token: usize,
227    pub summary_max_chars: usize,
228}
229
230impl Default for ContextConfigFile {
231    fn default() -> Self {
232        let value = ContextConfig::default();
233        Self {
234            max_tokens: value.max_tokens,
235            reserve_output_tokens: value.reserve_output_tokens,
236            safety_margin_tokens: value.safety_margin_tokens,
237            bytes_per_token: value.bytes_per_token,
238            summary_max_chars: value.summary_max_chars,
239        }
240    }
241}
242
243impl From<&ContextConfigFile> for ContextConfig {
244    fn from(value: &ContextConfigFile) -> Self {
245        Self {
246            max_tokens: value.max_tokens,
247            reserve_output_tokens: value.reserve_output_tokens,
248            safety_margin_tokens: value.safety_margin_tokens,
249            bytes_per_token: value.bytes_per_token,
250            summary_max_chars: value.summary_max_chars,
251        }
252    }
253}
254
255#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
256#[serde(rename_all = "kebab-case")]
257pub enum ApprovalPolicy {
258    OnRisk,
259    Always,
260    Never,
261}
262
263impl ApprovalPolicy {
264    fn strictness(self) -> u8 {
265        match self {
266            Self::OnRisk => 1,
267            Self::Always => 2,
268            Self::Never => 3,
269        }
270    }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274#[serde(default, deny_unknown_fields)]
275pub struct ToolConfig {
276    pub approval_policy: ApprovalPolicy,
277    /// `bash` timeout when a call does not choose one.
278    pub command_timeout_seconds: u64,
279    /// Native-agent timeout when a call does not choose one.
280    pub agent_timeout_seconds: u64,
281    /// The longest timeout a single tool call may request.
282    pub max_timeout_seconds: u64,
283    pub output_limit_bytes: usize,
284    pub max_read_bytes: usize,
285    pub max_write_bytes: usize,
286}
287
288impl Default for ToolConfig {
289    fn default() -> Self {
290        Self {
291            approval_policy: ApprovalPolicy::OnRisk,
292            command_timeout_seconds: 600,
293            agent_timeout_seconds: 3600,
294            max_timeout_seconds: 14400,
295            output_limit_bytes: 64 * 1024,
296            max_read_bytes: 256 * 1024,
297            max_write_bytes: 1024 * 1024,
298        }
299    }
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
303#[serde(default, deny_unknown_fields)]
304pub struct ProtocolConfig {
305    pub max_client_frame_bytes: usize,
306    pub max_server_frame_bytes: usize,
307}
308
309impl Default for ProtocolConfig {
310    fn default() -> Self {
311        Self {
312            max_client_frame_bytes: 1024 * 1024,
313            max_server_frame_bytes: 8 * 1024 * 1024,
314        }
315    }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(default, deny_unknown_fields)]
320pub struct TuiConfig {
321    pub max_transcript_bytes: usize,
322    pub max_transcript_items: usize,
323    pub max_prompt_history_bytes: usize,
324    pub max_prompt_history_items: usize,
325}
326
327impl Default for TuiConfig {
328    fn default() -> Self {
329        Self {
330            max_transcript_bytes: 8 * 1024 * 1024,
331            max_transcript_items: 10_000,
332            max_prompt_history_bytes: 1024 * 1024,
333            max_prompt_history_items: 200,
334        }
335    }
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(default, deny_unknown_fields)]
340pub struct ProviderLimitsFile {
341    pub max_sse_event_bytes: usize,
342    pub max_response_bytes: usize,
343    pub max_assistant_bytes: usize,
344    pub max_tool_calls: usize,
345    pub max_tool_arguments_bytes: usize,
346    pub max_retries: usize,
347}
348
349impl Default for ProviderLimitsFile {
350    fn default() -> Self {
351        let value = ProviderLimits::default();
352        Self {
353            max_sse_event_bytes: value.max_sse_event_bytes,
354            max_response_bytes: value.max_response_bytes,
355            max_assistant_bytes: value.max_assistant_bytes,
356            max_tool_calls: value.max_tool_calls,
357            max_tool_arguments_bytes: value.max_tool_arguments_bytes,
358            max_retries: value.max_retries,
359        }
360    }
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(default, deny_unknown_fields)]
365pub struct SkillsConfig {
366    pub user_dir: PathBuf,
367    pub project_dir: PathBuf,
368    /// List the agent skills (`.agents/skills`, `.claude/skills`) of the
369    /// workspace and its immediate child projects in tool-enabled sessions.
370    pub scan_projects: bool,
371    pub max_skills: usize,
372    pub max_skill_bytes: usize,
373}
374
375impl Default for SkillsConfig {
376    fn default() -> Self {
377        Self {
378            user_dir: PathBuf::from("~/.scv/skills"),
379            project_dir: PathBuf::from(".scv/skills"),
380            scan_projects: true,
381            max_skills: 128,
382            max_skill_bytes: 256 * 1024,
383        }
384    }
385}
386
387/// Where `web_search` results come from.
388#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
389#[serde(rename_all = "lowercase")]
390pub enum WebSearchMode {
391    Off,
392    /// The provider endpoint's hosted Responses `web_search` tool.
393    Provider,
394    Searxng,
395    Brave,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
399#[serde(default, deny_unknown_fields)]
400pub struct WebConfig {
401    /// Offer `web_fetch` (and search, when configured) to tool-enabled sessions.
402    pub enabled: bool,
403    pub fetch_max_bytes: usize,
404    pub fetch_timeout_seconds: u64,
405    pub max_redirects: usize,
406    /// HTTPS hosts `web_fetch` may read without approval.
407    pub auto_approve_domains: Vec<String>,
408    /// Let `web_fetch` reach loopback, private, and link-local addresses.
409    pub allow_private_addresses: bool,
410    pub search: WebSearchMode,
411    pub searxng_url: Option<String>,
412    pub brave_url: String,
413    pub brave_api_key: Option<String>,
414    pub brave_api_key_env: Option<String>,
415    pub max_search_results: usize,
416}
417
418impl Default for WebConfig {
419    fn default() -> Self {
420        Self {
421            enabled: true,
422            fetch_max_bytes: 2 * 1024 * 1024,
423            fetch_timeout_seconds: 30,
424            max_redirects: 5,
425            auto_approve_domains: [
426                "docs.rs",
427                "crates.io",
428                "doc.rust-lang.org",
429                "docs.python.org",
430                "pypi.org",
431                "developer.mozilla.org",
432            ]
433            .map(String::from)
434            .to_vec(),
435            allow_private_addresses: false,
436            search: WebSearchMode::Off,
437            searxng_url: None,
438            brave_url: "https://api.search.brave.com/res/v1/web/search".into(),
439            brave_api_key: None,
440            brave_api_key_env: Some("BRAVE_SEARCH_API_KEY".into()),
441            max_search_results: 8,
442        }
443    }
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, Default)]
447#[serde(default, deny_unknown_fields)]
448pub struct AdapterConfig {
449    pub command: String,
450    pub args: Vec<String>,
451    /// `full` adds the CLI's own switches for unprompted, unsandboxed work.
452    pub permissions: AgentPermissions,
453    /// Placed immediately before the prompt (`grok -p <prompt>`).
454    pub prompt_args: Vec<String>,
455    /// Appended when a call selects a model; `{model}` is substituted.
456    pub model_args: Vec<String>,
457    /// Appended when a call selects an effort; `{effort}` is substituted.
458    pub effort_args: Vec<String>,
459    /// How SCV talks to the agent: its ACP server or one process per turn.
460    pub transport: AgentTransport,
461    /// When to choose this agent, in the user's words; added to its tool
462    /// description so the model can pick between agents.
463    pub use_for: Option<String>,
464}
465
466/// How SCV talks to a delegated agent that has an ACP server.
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
468#[serde(rename_all = "lowercase")]
469pub enum AgentTransport {
470    /// The agent's ACP server when it is installed, else one process per turn.
471    #[default]
472    Auto,
473    /// Only its ACP server; the agent is not offered while it is missing.
474    Acp,
475    /// One CLI process per turn, continued through the CLI's own resume.
476    Resume,
477}
478
479/// How much a delegated CLI may do without its own prompts.
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
481#[serde(rename_all = "lowercase")]
482pub enum AgentPermissions {
483    /// Add nothing: the CLI's own configuration decides.
484    #[default]
485    Default,
486    /// Add the CLI's full-autonomy switches: no approval prompts, no sandbox,
487    /// and web search where the CLI gates it. An explicit user opt-in.
488    Full,
489}
490
491/// `[agents.<name>]` for every adapter in [`scv_tools::adapters::ADAPTERS`].
492#[derive(Debug, Clone, Serialize, Deserialize)]
493#[serde(transparent)]
494pub struct AgentsConfig(pub BTreeMap<String, AdapterConfig>);
495
496impl Default for AgentsConfig {
497    fn default() -> Self {
498        let strings = |values: &[&str]| values.iter().map(|value| (*value).to_owned()).collect();
499        Self(
500            scv_tools::adapters::ADAPTERS
501                .iter()
502                .map(|adapter| {
503                    (
504                        adapter.name.to_owned(),
505                        AdapterConfig {
506                            command: adapter.command.into(),
507                            args: strings(adapter.args),
508                            permissions: AgentPermissions::Default,
509                            prompt_args: strings(adapter.prompt_args),
510                            model_args: strings(adapter.model_args),
511                            effort_args: strings(adapter.effort_args),
512                            transport: AgentTransport::Auto,
513                            use_for: None,
514                        },
515                    )
516                })
517                .collect(),
518        )
519    }
520}
521
522#[derive(Debug, Clone, Default)]
523pub struct ConfigOverrides {
524    pub provider: Option<String>,
525    pub model: Option<String>,
526    pub base_url: Option<String>,
527    pub approval_policy: Option<ApprovalPolicy>,
528    pub no_tools: bool,
529}
530
531impl Config {
532    pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
533        Self::load_layers(Some(workspace), overrides)
534    }
535
536    /// Load without a project layer, for settings that project configuration
537    /// can never set (such as `[agents]`), so the caller's directory is irrelevant.
538    pub fn load_user(overrides: ConfigOverrides) -> Result<Self> {
539        Self::load_layers(None, overrides)
540    }
541
542    fn load_layers(
543        workspace: Option<&std::path::Path>,
544        overrides: ConfigOverrides,
545    ) -> Result<Self> {
546        let instance_home = user_home_path()
547            .ok_or_else(|| anyhow::anyhow!("cannot determine SCV instance home"))?;
548        std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
549        ensure_private_dir(&instance_home)?;
550        let mut value: toml::Value = toml::from_str(
551            &toml::to_string(&Self::default()).context("serialize default configuration")?,
552        )?;
553
554        if let Some(user_path) = user_config_path()
555            && user_path.is_file()
556        {
557            #[cfg(unix)]
558            {
559                use std::os::unix::fs::PermissionsExt;
560                if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
561                    bail!("user configuration is readable by group or others; run chmod 600");
562                }
563            }
564            merge(&mut value, read_layer(&user_path)?);
565        }
566        let user_baseline: Self = value
567            .clone()
568            .try_into()
569            .context("parse user configuration")?;
570
571        if let Some(workspace) = workspace {
572            let project_path = workspace.join(".scv/config.toml");
573            // A workspace whose `.scv` is the SCV home (such as running from
574            // `~`) has no project layer: that file is the user configuration,
575            // already applied above at full trust.
576            let user_file = user_config_path().and_then(|path| std::fs::canonicalize(path).ok());
577            if project_path.is_file() {
578                let canonical_project = std::fs::canonicalize(&project_path)
579                    .with_context(|| format!("resolve configuration {}", project_path.display()))?;
580                if user_file.as_ref() != Some(&canonical_project) {
581                    if !canonical_project.starts_with(workspace) {
582                        bail!("project configuration escaped workspace");
583                    }
584                    let project = read_layer(&canonical_project)?;
585                    validate_project_keys(&project)?;
586                    let mut candidate_value = value.clone();
587                    merge(&mut candidate_value, project);
588                    let candidate: Self = candidate_value
589                        .clone()
590                        .try_into()
591                        .context("parse project configuration")?;
592                    validate_project_not_weaker(&user_baseline, &candidate)?;
593                    value = candidate_value;
594                }
595            }
596        }
597
598        if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
599            let path = PathBuf::from(explicit);
600            #[cfg(unix)]
601            {
602                use std::os::unix::fs::PermissionsExt;
603                if std::fs::metadata(&path)?.permissions().mode() & 0o077 != 0 {
604                    bail!("explicit configuration is readable by group or others; run chmod 600");
605                }
606            }
607            let explicit = read_layer(&path)?;
608            if explicit.get("channels").is_some() {
609                bail!(
610                    "{} cannot set [channels]; channel accounts belong in the instance's config.toml",
611                    path.display()
612                );
613            }
614            merge(&mut value, explicit);
615        }
616        let mut config: Self = value.try_into().context("parse merged configuration")?;
617        if let Some(name) = overrides.provider.as_deref() {
618            config.provider_active = Some(name.to_owned());
619        }
620        let selected = config.active_provider()?;
621        config.provider = selected;
622        if let Ok(model) = std::env::var("SCV_MODEL") {
623            config.provider.model = model;
624        }
625        if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
626            config.provider.base_url = base_url;
627        }
628        if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
629            config.provider.api_key_env = Some(api_key_env);
630        }
631        if let Some(model) = overrides.model {
632            config.provider.model = model;
633        }
634        if let Some(base_url) = overrides.base_url {
635            config.provider.base_url = base_url;
636        }
637        if let Some(policy) = overrides.approval_policy {
638            config.tools.approval_policy = policy;
639        }
640        if config.skills.user_dir == std::path::Path::new("~/.scv/skills")
641            && let Some(home) = std::env::var_os("SCV_HOME")
642        {
643            config.skills.user_dir = PathBuf::from(home).join("skills");
644        }
645        config.skills.user_dir = expand_home(&config.skills.user_dir);
646        config.instance_home = instance_home;
647        config.validate()?;
648        Ok(config)
649    }
650
651    /// Every leaf setting after layering, as a session started in
652    /// `workspace` would see it, with the layer that set it. Secret values are
653    /// replaced by `<hidden>`. Channel accounts are left out: `scv config
654    /// show` reports them with their credentials.
655    pub fn settings_with_origins(
656        workspace: Option<&std::path::Path>,
657        overrides: &ConfigOverrides,
658    ) -> Result<Vec<Setting>> {
659        let mut settings: BTreeMap<String, (toml::Value, String)> = BTreeMap::new();
660        let mut apply = |value: &toml::Value, origin: &str| {
661            flatten(value, String::new(), &mut |key, value| {
662                settings.insert(key, (value.clone(), origin.to_owned()));
663            });
664        };
665        let defaults: toml::Value = toml::from_str(
666            &toml::to_string(&Self::default()).context("serialize default configuration")?,
667        )?;
668        apply(&defaults, "default");
669        let mut merged = defaults;
670        let user = user_config_path().filter(|path| path.is_file());
671        if let Some(path) = &user {
672            let layer = read_layer(path)?;
673            apply(&layer, "config.toml");
674            merge(&mut merged, layer);
675        }
676        if let Some(workspace) = workspace {
677            let project = workspace.join(".scv/config.toml");
678            let user_file = user
679                .as_ref()
680                .and_then(|path| std::fs::canonicalize(path).ok());
681            if project.is_file() && std::fs::canonicalize(&project).ok() != user_file {
682                let layer = read_layer(&project)?;
683                apply(&layer, "project .scv/config.toml");
684                merge(&mut merged, layer);
685            }
686        }
687        if let Some(path) = std::env::var_os("SCV_CONFIG") {
688            let layer = read_layer(std::path::Path::new(&path))?;
689            apply(&layer, "SCV_CONFIG");
690            merge(&mut merged, layer);
691        }
692        // Environment and flags change the provider in effect: a named
693        // profile's fields when profiles are used, or `[provider]` itself.
694        let active = overrides.provider.clone().or_else(|| {
695            merged
696                .get("provider")?
697                .get("active")?
698                .as_str()
699                .map(ToOwned::to_owned)
700        });
701        let has_profiles = merged
702            .get("providers")
703            .and_then(toml::Value::as_table)
704            .is_some_and(|profiles| !profiles.is_empty());
705        let prefix = match active {
706            Some(name) if has_profiles => format!("providers.{name}"),
707            _ => "provider".into(),
708        };
709        let mut set = |key: String, value: String, origin: &str| {
710            settings.insert(key, (toml::Value::String(value), origin.to_owned()));
711        };
712        if let Some(name) = &overrides.provider {
713            set("provider.active".into(), name.clone(), "--provider flag");
714        }
715        for (field, variable) in [
716            ("model", "SCV_MODEL"),
717            ("base_url", "SCV_BASE_URL"),
718            ("api_key_env", "SCV_API_KEY_ENV"),
719        ] {
720            if let Ok(value) = std::env::var(variable) {
721                set(
722                    format!("{prefix}.{field}"),
723                    value,
724                    &format!("env {variable}"),
725                );
726            }
727        }
728        for (field, value, flag) in [
729            ("model", &overrides.model, "--model flag"),
730            ("base_url", &overrides.base_url, "--base-url flag"),
731        ] {
732            if let Some(value) = value {
733                set(format!("{prefix}.{field}"), value.clone(), flag);
734            }
735        }
736        if let Some(policy) = overrides.approval_policy {
737            let value = toml::Value::try_from(policy).context("serialize approval policy")?;
738            settings.insert(
739                "tools.approval_policy".into(),
740                (value, "--approval-policy flag".into()),
741            );
742        }
743        Ok(settings
744            .into_iter()
745            .filter(|(key, _)| !key.starts_with("channels."))
746            .map(|(key, (value, origin))| Setting {
747                value: if is_secret_key(&key) {
748                    "<hidden>".into()
749                } else {
750                    value.to_string()
751                },
752                key,
753                origin,
754            })
755            .collect())
756    }
757
758    pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
759        CoreAgentConfig {
760            system_prompt,
761            max_steps: self.agent.max_steps,
762            history_limits: HistoryLimits {
763                max_bytes: self.session.max_history_bytes,
764                max_messages: self.session.max_messages,
765                note_max_chars: self.context.summary_max_chars,
766            },
767        }
768    }
769
770    pub fn tools(&self) -> ToolsConfig {
771        ToolsConfig {
772            command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
773            agent_timeout: Duration::from_secs(self.tools.agent_timeout_seconds),
774            max_timeout: Duration::from_secs(self.tools.max_timeout_seconds),
775            output_limit_bytes: self.tools.output_limit_bytes,
776            max_read_bytes: self.tools.max_read_bytes,
777            max_write_bytes: self.tools.max_write_bytes,
778            max_delegation_depth: self.agent.max_delegation_depth,
779            conversations: ConversationLimits {
780                max: self.agent.max_conversations,
781                idle: Duration::from_secs(self.agent.conversation_idle_seconds),
782            },
783            delegation: None,
784            max_background: self.agent.max_background,
785            background: None,
786            chat_attach: None,
787        }
788    }
789
790    /// Web tool settings for a tool-enabled session, or `None` when disabled.
791    /// A Brave backend without a key is left out rather than failing the session.
792    pub fn web_tools(&self) -> Option<WebToolsConfig> {
793        if !self.web.enabled {
794            return None;
795        }
796        let search = match self.web.search {
797            WebSearchMode::Off | WebSearchMode::Provider => None,
798            WebSearchMode::Searxng => self
799                .web
800                .searxng_url
801                .clone()
802                .map(|url| SearchBackend::Searxng { url }),
803            WebSearchMode::Brave => {
804                let api_key = self
805                    .web
806                    .brave_api_key
807                    .clone()
808                    .or_else(|| {
809                        self.web
810                            .brave_api_key_env
811                            .as_deref()
812                            .and_then(|name| std::env::var(name).ok())
813                    })
814                    .filter(|key| !key.trim().is_empty());
815                if api_key.is_none() {
816                    tracing::warn!(
817                        "web.search is \"brave\" but no Brave API key is configured; web_search is unavailable"
818                    );
819                }
820                api_key.map(|api_key| SearchBackend::Brave {
821                    url: self.web.brave_url.clone(),
822                    api_key,
823                })
824            }
825        };
826        Some(WebToolsConfig {
827            fetch_max_bytes: self.web.fetch_max_bytes,
828            fetch_timeout: Duration::from_secs(self.web.fetch_timeout_seconds),
829            max_redirects: self.web.max_redirects,
830            auto_approve_domains: self.web.auto_approve_domains.clone(),
831            allow_private_addresses: self.web.allow_private_addresses,
832            search,
833            max_search_results: self.web.max_search_results,
834            output_limit: self.tools.output_limit_bytes,
835        })
836    }
837
838    /// Whether to offer the provider's hosted web search to tool-enabled sessions.
839    pub fn hosted_web_search(&self) -> bool {
840        self.web.enabled && self.web.search == WebSearchMode::Provider
841    }
842
843    pub fn provider_limits(&self) -> ProviderLimits {
844        ProviderLimits {
845            max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
846            max_response_bytes: self.provider_limits.max_response_bytes,
847            max_assistant_bytes: self.provider_limits.max_assistant_bytes,
848            max_tool_calls: self.provider_limits.max_tool_calls,
849            max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
850            max_retries: self.provider_limits.max_retries,
851            ..ProviderLimits::default()
852        }
853    }
854
855    pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
856        let user_home = dirs::home_dir();
857        self.agents
858            .0
859            .iter()
860            .filter_map(|(name, config)| {
861                let descriptor = scv_tools::adapters::adapter(name)?;
862                let adapter_home = self.layout().agent_home(name);
863                let mut environment = vec![
864                    (OsString::from("SCV_HOME"), adapter_home.clone().into()),
865                    (OsString::from("HOME"), adapter_home.clone().into()),
866                    (
867                        OsString::from("XDG_CONFIG_HOME"),
868                        adapter_home.join("config").into(),
869                    ),
870                    (
871                        OsString::from("XDG_DATA_HOME"),
872                        adapter_home.join("data").into(),
873                    ),
874                    (
875                        OsString::from("XDG_STATE_HOME"),
876                        adapter_home.join("state").into(),
877                    ),
878                ];
879                for (variable, relative) in descriptor.home_environment {
880                    let path = if relative.is_empty() {
881                        adapter_home.clone()
882                    } else {
883                        adapter_home.join(relative)
884                    };
885                    environment.push((OsString::from(variable), path.into()));
886                }
887                let full = config.permissions == AgentPermissions::Full;
888                environment.extend(
889                    descriptor
890                        .fixed_environment
891                        .iter()
892                        .chain(
893                            descriptor
894                                .full_permission_environment
895                                .iter()
896                                .filter(|_| full),
897                        )
898                        .map(|(variable, value)| (OsString::from(variable), OsString::from(value))),
899                );
900                Some((
901                    format!("agent_{name}"),
902                    AgentAdapterConfig {
903                        command: config.command.clone(),
904                        args: config.args.clone(),
905                        prompt_args: config.prompt_args.clone(),
906                        full_permission_args: full.then(|| {
907                            descriptor
908                                .full_permission_args
909                                .iter()
910                                .map(|arg| (*arg).to_owned())
911                                .collect()
912                        }),
913                        model_args: config.model_args.clone(),
914                        effort_args: config.effort_args.clone(),
915                        model_hint: descriptor.model_hint.into(),
916                        environment,
917                        search_dirs: user_home
918                            .as_deref()
919                            .map(|home| scv_tools::adapters::adapter_search_dirs(descriptor, home))
920                            .unwrap_or_default(),
921                        output: descriptor.output,
922                        resume: descriptor.resume,
923                        home: Some(adapter_home),
924                        transport: descriptor.transport,
925                        acp: descriptor
926                            .acp
927                            .filter(|_| match config.transport {
928                                AgentTransport::Acp => true,
929                                AgentTransport::Resume => false,
930                                // A custom `command` points SCV at a specific
931                                // CLI, which the ACP server would not run.
932                                AgentTransport::Auto => config.command == descriptor.command,
933                            })
934                            .map(|launch| scv_tools::AcpAgentLaunch {
935                                command: launch.command.to_owned(),
936                                args: scv_tools::adapters::acp_args(&launch, full),
937                                full_mode: launch.full_mode.filter(|_| full).map(str::to_owned),
938                                environment: launch
939                                    .full_environment
940                                    .iter()
941                                    .filter(|_| full)
942                                    .map(|(variable, value)| {
943                                        (OsString::from(variable), OsString::from(value))
944                                    })
945                                    .collect(),
946                                required: config.transport == AgentTransport::Acp,
947                            }),
948                        use_for: config.use_for.clone(),
949                    },
950                ))
951            })
952            .collect()
953    }
954
955    /// Where this instance keeps everything.
956    pub fn layout(&self) -> Layout {
957        Layout::new(&self.instance_home)
958    }
959
960    pub fn prepare_adapter_homes(&self) -> Result<()> {
961        let layout = self.layout();
962        std::fs::create_dir_all(layout.agents()).context("create the agent homes directory")?;
963        ensure_private_dir(&layout.agents())?;
964        for name in self.agents.0.keys() {
965            let path = layout.agent_home(name);
966            std::fs::create_dir_all(&path)
967                .with_context(|| format!("create isolated {name} agent home"))?;
968            ensure_private_dir(&path)
969                .with_context(|| format!("secure isolated {name} agent home"))?;
970        }
971        Ok(())
972    }
973
974    fn validate(&self) -> Result<()> {
975        for account in &self.notify.owner {
976            let valid = account.split_once(':').is_some_and(|(channel, name)| {
977                !channel.is_empty()
978                    && !name.is_empty()
979                    && account
980                        .chars()
981                        .all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '-' | '_' | '.'))
982            });
983            if !valid {
984                bail!(
985                    "notify.owner entries must look like \"feishu:default\" (<channel>:<account>)"
986                );
987            }
988        }
989        if self.provider.kind != "openai-compatible" {
990            bail!("provider.kind must be openai-compatible in v0.1");
991        }
992        if self.provider.model.trim().is_empty()
993            || self.provider.base_url.trim().is_empty()
994            || self
995                .provider
996                .api_key
997                .as_deref()
998                .unwrap_or("")
999                .trim()
1000                .is_empty()
1001                && self
1002                    .provider
1003                    .api_key_env
1004                    .as_deref()
1005                    .unwrap_or("")
1006                    .trim()
1007                    .is_empty()
1008        {
1009            bail!(
1010                "provider model and base_url must be non-empty; configure api_key or api_key_env"
1011            );
1012        }
1013        for (channel, accounts) in &self.channels {
1014            let known = [scv_clawbot::CHANNEL, scv_feishu::CHANNEL];
1015            if !known.contains(&channel.as_str()) {
1016                bail!(
1017                    "unknown channel [channels.{channel}]; known channels are {}",
1018                    known.join(", ")
1019                );
1020            }
1021            for (account, settings) in accounts {
1022                scv_channels::state::validate_name(account).with_context(|| {
1023                    format!("[channels.{channel}.{account}] has an invalid account name")
1024                })?;
1025                if settings
1026                    .workspace
1027                    .as_ref()
1028                    .is_some_and(|path| !path.is_absolute())
1029                {
1030                    bail!("channels.{channel}.{account}.workspace must be an absolute path");
1031                }
1032            }
1033        }
1034        for (agent, adapter) in &self.agents.0 {
1035            if scv_tools::adapters::adapter(agent).is_none() {
1036                let known: Vec<_> = scv_tools::adapters::ADAPTERS
1037                    .iter()
1038                    .map(|adapter| adapter.name)
1039                    .collect();
1040                bail!(
1041                    "unknown agent [agents.{agent}]; known agents are {}",
1042                    known.join(", ")
1043                );
1044            }
1045            let name = format!("agents.{agent}.command");
1046            if adapter.command.trim().is_empty() {
1047                bail!("{name} must be non-empty");
1048            }
1049            if let Some(use_for) = &adapter.use_for
1050                && (use_for.trim().is_empty()
1051                    || use_for.len() > MAX_USE_FOR_BYTES
1052                    || use_for.chars().any(char::is_control))
1053            {
1054                bail!(
1055                    "agents.{agent}.use_for must be one non-empty line of at most \
1056                     {MAX_USE_FOR_BYTES} bytes"
1057                );
1058            }
1059            if adapter.transport == AgentTransport::Acp
1060                && scv_tools::adapters::adapter(agent)
1061                    .is_some_and(|descriptor| descriptor.acp.is_none())
1062            {
1063                bail!(
1064                    "agents.{agent}.transport = \"acp\" but {agent} has no verified ACP server; \
1065                     use \"auto\" or \"resume\""
1066                );
1067            }
1068            for (field, template, placeholder) in [
1069                ("model_args", &adapter.model_args, "{model}"),
1070                ("effort_args", &adapter.effort_args, "{effort}"),
1071            ] {
1072                if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
1073                    let adapter = name.trim_end_matches(".command");
1074                    bail!("{adapter}.{field} must contain {placeholder} or be empty");
1075                }
1076            }
1077            let adapter_bytes = adapter.command.len()
1078                + [
1079                    &adapter.args,
1080                    &adapter.prompt_args,
1081                    &adapter.model_args,
1082                    &adapter.effort_args,
1083                ]
1084                .into_iter()
1085                .flatten()
1086                .map(String::len)
1087                .sum::<usize>();
1088            if adapter_bytes > 16 * 1024 {
1089                bail!("{name} and its fixed arguments exceed 16384 bytes");
1090            }
1091        }
1092        let positives = [
1093            (
1094                "provider.timeout_seconds",
1095                usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
1096            ),
1097            ("agent.max_steps", self.agent.max_steps),
1098            ("agent.max_conversations", self.agent.max_conversations),
1099            (
1100                "agent.conversation_idle_seconds",
1101                usize::try_from(self.agent.conversation_idle_seconds).unwrap_or(usize::MAX),
1102            ),
1103            ("session.max_history_bytes", self.session.max_history_bytes),
1104            ("session.max_messages", self.session.max_messages),
1105            ("context.max_tokens", self.context.max_tokens),
1106            ("context.bytes_per_token", self.context.bytes_per_token),
1107            ("context.summary_max_chars", self.context.summary_max_chars),
1108            (
1109                "tools.command_timeout_seconds",
1110                usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
1111            ),
1112            (
1113                "tools.agent_timeout_seconds",
1114                usize::try_from(self.tools.agent_timeout_seconds).unwrap_or(usize::MAX),
1115            ),
1116            (
1117                "tools.max_timeout_seconds",
1118                usize::try_from(self.tools.max_timeout_seconds).unwrap_or(usize::MAX),
1119            ),
1120            ("tools.output_limit_bytes", self.tools.output_limit_bytes),
1121            ("tools.max_read_bytes", self.tools.max_read_bytes),
1122            ("tools.max_write_bytes", self.tools.max_write_bytes),
1123            (
1124                "protocol.max_client_frame_bytes",
1125                self.protocol.max_client_frame_bytes,
1126            ),
1127            (
1128                "protocol.max_server_frame_bytes",
1129                self.protocol.max_server_frame_bytes,
1130            ),
1131            ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
1132            ("tui.max_transcript_items", self.tui.max_transcript_items),
1133            (
1134                "tui.max_prompt_history_bytes",
1135                self.tui.max_prompt_history_bytes,
1136            ),
1137            (
1138                "tui.max_prompt_history_items",
1139                self.tui.max_prompt_history_items,
1140            ),
1141            (
1142                "provider_limits.max_sse_event_bytes",
1143                self.provider_limits.max_sse_event_bytes,
1144            ),
1145            (
1146                "provider_limits.max_response_bytes",
1147                self.provider_limits.max_response_bytes,
1148            ),
1149            (
1150                "provider_limits.max_assistant_bytes",
1151                self.provider_limits.max_assistant_bytes,
1152            ),
1153            (
1154                "provider_limits.max_tool_calls",
1155                self.provider_limits.max_tool_calls,
1156            ),
1157            (
1158                "provider_limits.max_tool_arguments_bytes",
1159                self.provider_limits.max_tool_arguments_bytes,
1160            ),
1161            ("skills.max_skills", self.skills.max_skills),
1162            ("skills.max_skill_bytes", self.skills.max_skill_bytes),
1163            ("web.fetch_max_bytes", self.web.fetch_max_bytes),
1164            (
1165                "web.fetch_timeout_seconds",
1166                usize::try_from(self.web.fetch_timeout_seconds).unwrap_or(usize::MAX),
1167            ),
1168            ("web.max_search_results", self.web.max_search_results),
1169        ];
1170        if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
1171            bail!("{name} must be positive");
1172        }
1173        for (name, value) in [
1174            (
1175                "tools.command_timeout_seconds",
1176                self.tools.command_timeout_seconds,
1177            ),
1178            (
1179                "tools.agent_timeout_seconds",
1180                self.tools.agent_timeout_seconds,
1181            ),
1182        ] {
1183            if value > self.tools.max_timeout_seconds {
1184                bail!("{name} exceeds tools.max_timeout_seconds");
1185            }
1186        }
1187        if self.tools.max_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS {
1188            bail!("tools.max_timeout_seconds must be at most {MAX_TOOL_TIMEOUT_SECONDS}");
1189        }
1190        if self
1191            .context
1192            .reserve_output_tokens
1193            .saturating_add(self.context.safety_margin_tokens)
1194            >= self.context.max_tokens
1195        {
1196            bail!("context reserve and safety margin consume max_tokens");
1197        }
1198        let worst_assistant_frame = self
1199            .provider_limits
1200            .max_assistant_bytes
1201            .saturating_mul(6)
1202            .saturating_add(64 * 1024);
1203        if worst_assistant_frame > self.protocol.max_server_frame_bytes {
1204            bail!(
1205                "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
1206            );
1207        }
1208        if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
1209            bail!("tool argument limit exceeds provider response limit");
1210        }
1211        if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
1212            bail!("provider SSE event limit exceeds provider response limit");
1213        }
1214        if self.agent.max_background > MAX_BACKGROUND_JOBS {
1215            bail!("agent.max_background must be at most {MAX_BACKGROUND_JOBS}");
1216        }
1217        for agent in &self.agent.prefer {
1218            if scv_tools::adapters::adapter(agent).is_none() {
1219                bail!("agent.prefer names unknown agent {agent:?}");
1220            }
1221        }
1222        if self.provider_limits.max_retries > MAX_PROVIDER_RETRIES {
1223            bail!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}");
1224        }
1225        if self.protocol.max_client_frame_bytes < 4096 {
1226            bail!("protocol.max_client_frame_bytes must be at least 4096");
1227        }
1228        if self.protocol.max_server_frame_bytes < 64 * 1024 {
1229            bail!("protocol.max_server_frame_bytes must be at least 65536");
1230        }
1231        let worst_tool_frame = self
1232            .tools
1233            .output_limit_bytes
1234            .max(self.tools.max_read_bytes)
1235            .saturating_mul(12)
1236            .saturating_add(64 * 1024);
1237        let worst_skill_frame = self
1238            .skills
1239            .max_skill_bytes
1240            .saturating_mul(6)
1241            .saturating_add(64 * 1024);
1242        let worst_arguments_frame = self
1243            .provider_limits
1244            .max_tool_arguments_bytes
1245            .saturating_mul(6)
1246            .saturating_add(64 * 1024);
1247        if worst_tool_frame
1248            .max(worst_skill_frame)
1249            .max(worst_arguments_frame)
1250            > self.protocol.max_server_frame_bytes
1251        {
1252            bail!(
1253                "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
1254            );
1255        }
1256        self.validate_web()?;
1257        if self.skills.project_dir.is_absolute()
1258            || self
1259                .skills
1260                .project_dir
1261                .components()
1262                .any(|component| matches!(component, std::path::Component::ParentDir))
1263        {
1264            bail!("skills.project_dir must be a contained relative path");
1265        }
1266        Ok(())
1267    }
1268}
1269
1270impl Config {
1271    fn validate_web(&self) -> Result<()> {
1272        let web = &self.web;
1273        if web.fetch_max_bytes > 64 * 1024 * 1024 {
1274            bail!("web.fetch_max_bytes must be at most 67108864");
1275        }
1276        if web.fetch_timeout_seconds > self.tools.max_timeout_seconds {
1277            bail!("web.fetch_timeout_seconds exceeds tools.max_timeout_seconds");
1278        }
1279        if web.max_redirects > 10 {
1280            bail!("web.max_redirects must be at most 10");
1281        }
1282        if web.max_search_results > 20 {
1283            bail!("web.max_search_results must be at most 20");
1284        }
1285        if web.auto_approve_domains.len() > 256 {
1286            bail!("web.auto_approve_domains may list at most 256 hosts");
1287        }
1288        if let Some(entry) = web
1289            .auto_approve_domains
1290            .iter()
1291            .find(|entry| !valid_domain_pattern(entry))
1292        {
1293            bail!(
1294                "web.auto_approve_domains entry {entry:?} must be a host name such as docs.rs or *.example.com"
1295            );
1296        }
1297        let http_url = |value: &str| value.starts_with("https://") || value.starts_with("http://");
1298        if !http_url(&web.brave_url) {
1299            bail!("web.brave_url must be an http or https URL");
1300        }
1301        match web.search {
1302            WebSearchMode::Searxng if !web.searxng_url.as_deref().is_some_and(http_url) => {
1303                bail!("web.search = \"searxng\" requires web.searxng_url (an http or https URL)");
1304            }
1305            WebSearchMode::Brave
1306                if web.brave_api_key.as_deref().unwrap_or("").trim().is_empty()
1307                    && web
1308                        .brave_api_key_env
1309                        .as_deref()
1310                        .unwrap_or("")
1311                        .trim()
1312                        .is_empty() =>
1313            {
1314                bail!("web.search = \"brave\" requires web.brave_api_key or web.brave_api_key_env");
1315            }
1316            _ => {}
1317        }
1318        Ok(())
1319    }
1320}
1321
1322/// A host name, optionally prefixed with `*.` to match its subdomains.
1323fn valid_domain_pattern(entry: &str) -> bool {
1324    let host = entry.strip_prefix("*.").unwrap_or(entry);
1325    !host.is_empty()
1326        && host.len() <= 253
1327        && host.split('.').all(|label| {
1328            !label.is_empty()
1329                && label.len() <= 63
1330                && !label.starts_with('-')
1331                && !label.ends_with('-')
1332                && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1333        })
1334}
1335
1336fn user_config_path() -> Option<PathBuf> {
1337    user_home_path().map(|path| Layout::new(path).config())
1338}
1339
1340pub fn user_home_path() -> Option<PathBuf> {
1341    let path = Layout::from_env().ok()?.home().to_owned();
1342    if path.exists() {
1343        Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
1344    } else if path.is_absolute() {
1345        Some(path)
1346    } else {
1347        std::env::current_dir().ok().map(|cwd| cwd.join(path))
1348    }
1349}
1350
1351fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
1352    #[cfg(unix)]
1353    {
1354        use std::os::unix::fs::PermissionsExt;
1355        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
1356            .with_context(|| format!("secure directory {}", path.display()))?;
1357    }
1358    Ok(())
1359}
1360
1361fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
1362    let size = std::fs::metadata(path)
1363        .with_context(|| format!("stat configuration {}", path.display()))?
1364        .len();
1365    if size > MAX_CONFIG_BYTES {
1366        bail!("configuration {} exceeds 1 MiB", path.display());
1367    }
1368    let content = std::fs::read_to_string(path)
1369        .with_context(|| format!("read configuration {}", path.display()))?;
1370    // The parser's own display quotes the offending line, which may hold a
1371    // key, so only its message and line number are kept.
1372    toml::from_str(&content).map_err(|error: toml::de::Error| {
1373        let line = error.span().map_or_else(String::new, |span| {
1374            format!(
1375                " line {}",
1376                content[..span.start.min(content.len())]
1377                    .matches('\n')
1378                    .count()
1379                    + 1
1380            )
1381        });
1382        anyhow::anyhow!(
1383            "parse configuration {}{line}: {}",
1384            path.display(),
1385            error.message()
1386        )
1387    })
1388}
1389
1390fn merge(base: &mut toml::Value, overlay: toml::Value) {
1391    match (base, overlay) {
1392        (toml::Value::Table(base), toml::Value::Table(overlay)) => {
1393            for (key, value) in overlay {
1394                match base.get_mut(&key) {
1395                    Some(existing) => merge(existing, value),
1396                    None => {
1397                        base.insert(key, value);
1398                    }
1399                }
1400            }
1401        }
1402        (base, overlay) => *base = overlay,
1403    }
1404}
1405
1406/// A configuration value in effect and the layer that set it.
1407#[derive(Debug, Clone, PartialEq, Eq)]
1408pub struct Setting {
1409    /// Dotted key, such as `tools.approval_policy`.
1410    pub key: String,
1411    /// The value as TOML, or `<hidden>` for a secret.
1412    pub value: String,
1413    /// `default`, `config.toml`, `project .scv/config.toml`, `SCV_CONFIG`,
1414    /// `env <VARIABLE>`, or `--<name> flag`.
1415    pub origin: String,
1416}
1417
1418/// Call `visit` with every leaf of `value` under its dotted key.
1419fn flatten(value: &toml::Value, prefix: String, visit: &mut impl FnMut(String, &toml::Value)) {
1420    match value {
1421        toml::Value::Table(table) => {
1422            for (key, value) in table {
1423                let key = if prefix.is_empty() {
1424                    key.clone()
1425                } else {
1426                    format!("{prefix}.{key}")
1427                };
1428                flatten(value, key, visit);
1429            }
1430        }
1431        leaf => visit(prefix, leaf),
1432    }
1433}
1434
1435/// Keys whose values are credentials: API keys, secrets, passwords, and
1436/// provider headers, which commonly carry authorization.
1437fn is_secret_key(key: &str) -> bool {
1438    let last = key.rsplit('.').next().unwrap_or(key);
1439    last == "api_key"
1440        || last.ends_with("_api_key")
1441        || last.contains("secret")
1442        || last.contains("password")
1443        || key.split('.').any(|segment| segment == "headers")
1444}
1445
1446fn validate_project_keys(value: &toml::Value) -> Result<()> {
1447    let Some(table) = value.as_table() else {
1448        bail!("project configuration must be a TOML table");
1449    };
1450    for forbidden in [
1451        "provider",
1452        "providers",
1453        "provider_active",
1454        "agents",
1455        "update",
1456        "channels",
1457        "notify",
1458    ] {
1459        if table.contains_key(forbidden) {
1460            bail!("project configuration cannot set [{forbidden}]");
1461        }
1462    }
1463    if table
1464        .get("skills")
1465        .and_then(toml::Value::as_table)
1466        .is_some_and(|skills| skills.contains_key("user_dir"))
1467    {
1468        bail!("project configuration cannot set skills.user_dir");
1469    }
1470    if table
1471        .get("agent")
1472        .and_then(toml::Value::as_table)
1473        .is_some_and(|agent| agent.contains_key("system_prompt"))
1474    {
1475        bail!("project configuration cannot replace agent.system_prompt");
1476    }
1477    if table
1478        .get("agent")
1479        .and_then(toml::Value::as_table)
1480        .is_some_and(|agent| agent.contains_key("prefer"))
1481    {
1482        bail!("project configuration cannot set agent.prefer");
1483    }
1484    if let Some(web) = table.get("web").and_then(toml::Value::as_table) {
1485        for key in [
1486            "auto_approve_domains",
1487            "allow_private_addresses",
1488            "searxng_url",
1489            "brave_url",
1490            "brave_api_key",
1491            "brave_api_key_env",
1492        ] {
1493            if web.contains_key(key) {
1494                bail!("project configuration cannot set web.{key}");
1495            }
1496        }
1497    }
1498    Ok(())
1499}
1500
1501fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
1502    macro_rules! no_larger {
1503        ($field:expr, $name:literal) => {
1504            if $field.1 > $field.0 {
1505                bail!(concat!("project configuration cannot raise ", $name));
1506            }
1507        };
1508    }
1509    no_larger!(
1510        (user.agent.max_steps, project.agent.max_steps),
1511        "agent.max_steps"
1512    );
1513    no_larger!(
1514        (
1515            user.agent.max_delegation_depth,
1516            project.agent.max_delegation_depth
1517        ),
1518        "agent.max_delegation_depth"
1519    );
1520    no_larger!(
1521        (
1522            user.agent.max_conversations,
1523            project.agent.max_conversations
1524        ),
1525        "agent.max_conversations"
1526    );
1527    no_larger!(
1528        (
1529            user.agent.conversation_idle_seconds,
1530            project.agent.conversation_idle_seconds
1531        ),
1532        "agent.conversation_idle_seconds"
1533    );
1534    no_larger!(
1535        (user.agent.max_background, project.agent.max_background),
1536        "agent.max_background"
1537    );
1538    no_larger!(
1539        (
1540            user.session.max_history_bytes,
1541            project.session.max_history_bytes
1542        ),
1543        "session.max_history_bytes"
1544    );
1545    no_larger!(
1546        (user.session.max_messages, project.session.max_messages),
1547        "session.max_messages"
1548    );
1549    no_larger!(
1550        (user.context.max_tokens, project.context.max_tokens),
1551        "context.max_tokens"
1552    );
1553    no_larger!(
1554        (
1555            user.context.summary_max_chars,
1556            project.context.summary_max_chars
1557        ),
1558        "context.summary_max_chars"
1559    );
1560    no_larger!(
1561        (
1562            user.tools.command_timeout_seconds,
1563            project.tools.command_timeout_seconds
1564        ),
1565        "tools.command_timeout_seconds"
1566    );
1567    no_larger!(
1568        (
1569            user.tools.agent_timeout_seconds,
1570            project.tools.agent_timeout_seconds
1571        ),
1572        "tools.agent_timeout_seconds"
1573    );
1574    no_larger!(
1575        (
1576            user.tools.max_timeout_seconds,
1577            project.tools.max_timeout_seconds
1578        ),
1579        "tools.max_timeout_seconds"
1580    );
1581    no_larger!(
1582        (
1583            user.tools.output_limit_bytes,
1584            project.tools.output_limit_bytes
1585        ),
1586        "tools.output_limit_bytes"
1587    );
1588    no_larger!(
1589        (user.tools.max_read_bytes, project.tools.max_read_bytes),
1590        "tools.max_read_bytes"
1591    );
1592    no_larger!(
1593        (user.tools.max_write_bytes, project.tools.max_write_bytes),
1594        "tools.max_write_bytes"
1595    );
1596    no_larger!(
1597        (
1598            user.protocol.max_client_frame_bytes,
1599            project.protocol.max_client_frame_bytes
1600        ),
1601        "protocol.max_client_frame_bytes"
1602    );
1603    no_larger!(
1604        (
1605            user.protocol.max_server_frame_bytes,
1606            project.protocol.max_server_frame_bytes
1607        ),
1608        "protocol.max_server_frame_bytes"
1609    );
1610    no_larger!(
1611        (
1612            user.provider_limits.max_response_bytes,
1613            project.provider_limits.max_response_bytes
1614        ),
1615        "provider_limits.max_response_bytes"
1616    );
1617    no_larger!(
1618        (
1619            user.provider_limits.max_sse_event_bytes,
1620            project.provider_limits.max_sse_event_bytes
1621        ),
1622        "provider_limits.max_sse_event_bytes"
1623    );
1624    no_larger!(
1625        (
1626            user.provider_limits.max_assistant_bytes,
1627            project.provider_limits.max_assistant_bytes
1628        ),
1629        "provider_limits.max_assistant_bytes"
1630    );
1631    no_larger!(
1632        (
1633            user.provider_limits.max_tool_calls,
1634            project.provider_limits.max_tool_calls
1635        ),
1636        "provider_limits.max_tool_calls"
1637    );
1638    no_larger!(
1639        (
1640            user.provider_limits.max_tool_arguments_bytes,
1641            project.provider_limits.max_tool_arguments_bytes
1642        ),
1643        "provider_limits.max_tool_arguments_bytes"
1644    );
1645    no_larger!(
1646        (
1647            user.provider_limits.max_retries,
1648            project.provider_limits.max_retries
1649        ),
1650        "provider_limits.max_retries"
1651    );
1652    no_larger!(
1653        (
1654            user.tui.max_transcript_bytes,
1655            project.tui.max_transcript_bytes
1656        ),
1657        "tui.max_transcript_bytes"
1658    );
1659    no_larger!(
1660        (
1661            user.tui.max_transcript_items,
1662            project.tui.max_transcript_items
1663        ),
1664        "tui.max_transcript_items"
1665    );
1666    no_larger!(
1667        (
1668            user.tui.max_prompt_history_bytes,
1669            project.tui.max_prompt_history_bytes
1670        ),
1671        "tui.max_prompt_history_bytes"
1672    );
1673    no_larger!(
1674        (
1675            user.tui.max_prompt_history_items,
1676            project.tui.max_prompt_history_items
1677        ),
1678        "tui.max_prompt_history_items"
1679    );
1680    no_larger!(
1681        (user.skills.max_skills, project.skills.max_skills),
1682        "skills.max_skills"
1683    );
1684    no_larger!(
1685        (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
1686        "skills.max_skill_bytes"
1687    );
1688    if project.context.reserve_output_tokens < user.context.reserve_output_tokens
1689        || project.context.safety_margin_tokens < user.context.safety_margin_tokens
1690    {
1691        bail!("project configuration cannot lower context reserves");
1692    }
1693    if project.context.bytes_per_token > user.context.bytes_per_token {
1694        bail!("project configuration cannot raise context.bytes_per_token");
1695    }
1696    if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
1697        bail!("project configuration cannot weaken tools.approval_policy");
1698    }
1699    if project.skills.scan_projects && !user.skills.scan_projects {
1700        bail!("project configuration cannot enable skills.scan_projects");
1701    }
1702    if project.web.enabled && !user.web.enabled {
1703        bail!("project configuration cannot enable web");
1704    }
1705    if project.web.search != user.web.search && project.web.search != WebSearchMode::Off {
1706        bail!("project configuration can only turn web.search off");
1707    }
1708    no_larger!(
1709        (user.web.fetch_max_bytes, project.web.fetch_max_bytes),
1710        "web.fetch_max_bytes"
1711    );
1712    no_larger!(
1713        (
1714            user.web.fetch_timeout_seconds,
1715            project.web.fetch_timeout_seconds
1716        ),
1717        "web.fetch_timeout_seconds"
1718    );
1719    no_larger!(
1720        (user.web.max_redirects, project.web.max_redirects),
1721        "web.max_redirects"
1722    );
1723    no_larger!(
1724        (user.web.max_search_results, project.web.max_search_results),
1725        "web.max_search_results"
1726    );
1727    Ok(())
1728}
1729
1730fn expand_home(path: &std::path::Path) -> PathBuf {
1731    let value = path.to_string_lossy();
1732    if value == "~" {
1733        return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
1734    }
1735    if let Some(rest) = value.strip_prefix("~/")
1736        && let Some(home) = dirs::home_dir()
1737    {
1738        return home.join(rest);
1739    }
1740    path.to_path_buf()
1741}
1742
1743#[cfg(test)]
1744mod tests {
1745    use super::*;
1746
1747    #[test]
1748    fn project_cannot_redirect_provider_or_agent() {
1749        let provider: toml::Value = toml::from_str(
1750            r#"[provider]
1751base_url = "https://attacker.invalid"
1752"#,
1753        )
1754        .unwrap();
1755        assert!(validate_project_keys(&provider).is_err());
1756
1757        let agent: toml::Value = toml::from_str(
1758            r#"[agents.codex]
1759command = "/tmp/fake"
1760"#,
1761        )
1762        .unwrap();
1763        assert!(validate_project_keys(&agent).is_err());
1764    }
1765
1766    #[test]
1767    fn channel_accounts_are_user_only_and_validated() {
1768        let project: toml::Value =
1769            toml::from_str("[channels.wechat.default]\nenabled = false\n").unwrap();
1770        assert!(validate_project_keys(&project).is_err());
1771
1772        let settings = |workspace: Option<&str>| AccountSettings {
1773            workspace: workspace.map(PathBuf::from),
1774            ..AccountSettings::default()
1775        };
1776        let with = |channel: &str, account: &str, workspace: Option<&str>| Config {
1777            channels: BTreeMap::from([(
1778                channel.to_owned(),
1779                BTreeMap::from([(account.to_owned(), settings(workspace))]),
1780            )]),
1781            ..Config::default()
1782        };
1783        assert!(
1784            with("wechat", "default", Some("/srv/work"))
1785                .validate()
1786                .is_ok()
1787        );
1788        assert!(with("feishu", "team-2", None).validate().is_ok());
1789        let unknown = with("irc", "default", None).validate().unwrap_err();
1790        assert!(unknown.to_string().contains("wechat, feishu"), "{unknown}");
1791        assert!(with("wechat", "a.b", None).validate().is_err());
1792        assert!(with("wechat", "default", Some("work")).validate().is_err());
1793        let parsed: Config =
1794            toml::from_str("[channels.wechat.default]\nenabled = true\nremote_tools = \"owner\"\n")
1795                .unwrap();
1796        assert_eq!(
1797            parsed.channels["wechat"]["default"].remote_tools,
1798            scv_channels::state::RemoteTools::Owner
1799        );
1800        assert!(toml::from_str::<Config>("[channels.wechat.default]\nenabeld = true\n").is_err());
1801    }
1802
1803    #[test]
1804    fn keys_holding_credentials_are_hidden_but_limits_are_not() {
1805        for secret in [
1806            "providers.openai.api_key",
1807            "provider.api_key",
1808            "web.brave_api_key",
1809            "providers.x.headers.Authorization",
1810            "anything.client_secret",
1811        ] {
1812            assert!(is_secret_key(secret), "{secret}");
1813        }
1814        for plain in [
1815            "provider.api_key_env",
1816            "web.brave_api_key_env",
1817            "context.max_tokens",
1818            "context.reserve_output_tokens",
1819            "providers.openai.base_url",
1820        ] {
1821            assert!(!is_secret_key(plain), "{plain}");
1822        }
1823    }
1824
1825    #[test]
1826    fn project_may_tighten_but_not_weaken_limits() {
1827        let user = Config::default();
1828        let mut tighter = user.clone();
1829        tighter.tools.output_limit_bytes /= 2;
1830        tighter.tools.approval_policy = ApprovalPolicy::Always;
1831        assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1832
1833        let mut weaker = user.clone();
1834        weaker.tools.output_limit_bytes *= 2;
1835        assert!(validate_project_not_weaker(&user, &weaker).is_err());
1836    }
1837
1838    #[test]
1839    fn timeouts_default_below_a_ceiling_that_projects_may_only_lower() {
1840        let user = Config::default();
1841        assert_eq!(
1842            (
1843                user.tools.command_timeout_seconds,
1844                user.tools.agent_timeout_seconds,
1845                user.tools.max_timeout_seconds
1846            ),
1847            (600, 3600, 14400)
1848        );
1849        assert_eq!(user.agent.max_steps, 128);
1850        assert_eq!(user.provider.timeout_seconds, 600);
1851        let tools = user.tools();
1852        assert_eq!(tools.command_timeout, Duration::from_secs(600));
1853        assert_eq!(tools.agent_timeout, Duration::from_secs(3600));
1854        assert_eq!(tools.max_timeout, Duration::from_secs(14400));
1855        // A ClawBot owner turn outlasts the ceiling by five minutes: 4h05m.
1856        assert_eq!(
1857            scv_clawbot::owner_turn_timeout(tools.max_timeout),
1858            Duration::from_secs(4 * 3600 + 5 * 60)
1859        );
1860
1861        for (field, name) in [
1862            (0, "tools.command_timeout_seconds"),
1863            (1, "tools.agent_timeout_seconds"),
1864        ] {
1865            let mut config = Config::default();
1866            let value = if field == 0 {
1867                &mut config.tools.command_timeout_seconds
1868            } else {
1869                &mut config.tools.agent_timeout_seconds
1870            };
1871            *value = config.tools.max_timeout_seconds + 1;
1872            assert_eq!(
1873                config.validate().unwrap_err().to_string(),
1874                format!("{name} exceeds tools.max_timeout_seconds")
1875            );
1876        }
1877        let mut unbounded = Config::default();
1878        unbounded.tools.max_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS + 1;
1879        assert!(unbounded.validate().is_err());
1880        let mut zero = Config::default();
1881        zero.tools.agent_timeout_seconds = 0;
1882        assert!(zero.validate().is_err());
1883
1884        let mut lower = user.clone();
1885        lower.tools.max_timeout_seconds = 900;
1886        lower.tools.agent_timeout_seconds = 300;
1887        assert!(validate_project_not_weaker(&user, &lower).is_ok());
1888        for raise in [
1889            |config: &mut Config| config.tools.max_timeout_seconds += 1,
1890            |config: &mut Config| config.tools.agent_timeout_seconds += 1,
1891        ] {
1892            let mut higher = user.clone();
1893            raise(&mut higher);
1894            assert!(validate_project_not_weaker(&user, &higher).is_err());
1895        }
1896    }
1897
1898    #[test]
1899    fn conversation_limits_are_positive_and_projects_may_only_lower_them() {
1900        let user = Config::default();
1901        assert_eq!(
1902            (
1903                user.agent.max_conversations,
1904                user.agent.conversation_idle_seconds
1905            ),
1906            (8, 86400)
1907        );
1908        let limits = user.tools().conversations;
1909        assert_eq!((limits.max, limits.idle), (8, Duration::from_secs(86400)));
1910        for zero in [
1911            |config: &mut Config| config.agent.max_conversations = 0,
1912            |config: &mut Config| config.agent.conversation_idle_seconds = 0,
1913        ] {
1914            let mut config = Config::default();
1915            zero(&mut config);
1916            assert!(config.validate().is_err());
1917        }
1918        let mut lower = user.clone();
1919        lower.agent.max_conversations = 2;
1920        lower.agent.conversation_idle_seconds = 600;
1921        assert!(validate_project_not_weaker(&user, &lower).is_ok());
1922        for raise in [
1923            |config: &mut Config| config.agent.max_conversations += 1,
1924            |config: &mut Config| config.agent.conversation_idle_seconds += 1,
1925        ] {
1926            let mut higher = user.clone();
1927            raise(&mut higher);
1928            assert!(validate_project_not_weaker(&user, &higher).is_err());
1929        }
1930    }
1931
1932    #[test]
1933    fn agent_choice_settings_are_validated_and_user_only() {
1934        let mut config = Config::default();
1935        config.agent.prefer = vec!["codex".into(), "claude".into()];
1936        config.agents.0.get_mut("grok").unwrap().use_for =
1937            Some("current events and X posts".into());
1938        assert!(config.validate().is_ok());
1939        let adapters = config.adapters();
1940        assert_eq!(
1941            adapters["agent_grok"].use_for.as_deref(),
1942            Some("current events and X posts")
1943        );
1944        assert_eq!(adapters["agent_codex"].use_for, None);
1945        let mut unknown = Config::default();
1946        unknown.agent.prefer = vec!["zcode".into()];
1947        assert!(unknown.validate().is_err());
1948        for bad in ["", "two\nlines", &"x".repeat(MAX_USE_FOR_BYTES + 1)] {
1949            let mut config = Config::default();
1950            config.agents.0.get_mut("codex").unwrap().use_for = Some(bad.to_owned());
1951            assert!(config.validate().is_err(), "{bad:?}");
1952        }
1953        let project: toml::Value = toml::from_str("[agent]\nprefer = [\"pi\"]\n").unwrap();
1954        assert!(validate_project_keys(&project).is_err());
1955    }
1956
1957    #[test]
1958    fn background_jobs_are_bounded_and_projects_may_only_lower_them() {
1959        let user = Config::default();
1960        assert_eq!(user.agent.max_background, 4);
1961        assert_eq!(user.tools().max_background, 4);
1962        let mut off = Config::default();
1963        off.agent.max_background = 0;
1964        assert!(off.validate().is_ok(), "0 turns background delegation off");
1965        let mut many = Config::default();
1966        many.agent.max_background = MAX_BACKGROUND_JOBS + 1;
1967        assert!(many.validate().is_err());
1968        let mut lower = user.clone();
1969        lower.agent.max_background = 1;
1970        assert!(validate_project_not_weaker(&user, &lower).is_ok());
1971        let mut higher = user.clone();
1972        higher.agent.max_background = 5;
1973        assert!(validate_project_not_weaker(&user, &higher).is_err());
1974    }
1975
1976    #[test]
1977    fn provider_retries_are_bounded_and_projects_may_only_lower_them() {
1978        let user = Config::default();
1979        assert_eq!(user.provider_limits.max_retries, 2);
1980        assert_eq!(user.provider_limits().max_retries, 2);
1981        let mut none = user.clone();
1982        none.provider_limits.max_retries = 0;
1983        assert!(none.validate().is_ok());
1984        assert!(validate_project_not_weaker(&user, &none).is_ok());
1985        assert!(validate_project_not_weaker(&none, &user).is_err());
1986        let mut excessive = user.clone();
1987        excessive.provider_limits.max_retries = MAX_PROVIDER_RETRIES + 1;
1988        assert_eq!(
1989            excessive.validate().unwrap_err().to_string(),
1990            format!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}")
1991        );
1992    }
1993
1994    #[test]
1995    fn projects_may_disable_but_not_enable_project_skill_scanning() {
1996        let user = Config::default();
1997        let mut disabled = user.clone();
1998        disabled.skills.scan_projects = false;
1999        assert!(validate_project_not_weaker(&user, &disabled).is_ok());
2000        assert!(validate_project_not_weaker(&disabled, &user).is_err());
2001    }
2002
2003    #[test]
2004    fn web_defaults_offer_fetch_without_search_and_validate_their_settings() {
2005        let config = Config::default();
2006        assert!(config.web.enabled);
2007        assert_eq!(config.web.search, WebSearchMode::Off);
2008        assert!(!config.hosted_web_search());
2009        let tools = config.web_tools().unwrap();
2010        assert!(tools.search.is_none());
2011        assert!(!tools.allow_private_addresses);
2012        assert_eq!(tools.fetch_max_bytes, 2 * 1024 * 1024);
2013        assert_eq!(tools.output_limit, config.tools.output_limit_bytes);
2014        assert!(tools.auto_approve_domains.contains(&"docs.rs".to_owned()));
2015
2016        let mut disabled = Config::default();
2017        disabled.web.enabled = false;
2018        disabled.web.search = WebSearchMode::Provider;
2019        assert!(disabled.web_tools().is_none());
2020        assert!(!disabled.hosted_web_search());
2021
2022        let mut provider = Config::default();
2023        provider.web.search = WebSearchMode::Provider;
2024        assert!(provider.hosted_web_search());
2025        assert!(provider.web_tools().unwrap().search.is_none());
2026
2027        let mut searxng = Config::default();
2028        searxng.web.search = WebSearchMode::Searxng;
2029        assert!(
2030            searxng
2031                .validate()
2032                .unwrap_err()
2033                .to_string()
2034                .contains("web.searxng_url")
2035        );
2036        searxng.web.searxng_url = Some("https://searx.example".into());
2037        assert!(searxng.validate().is_ok());
2038        assert!(matches!(
2039            searxng.web_tools().unwrap().search,
2040            Some(SearchBackend::Searxng { .. })
2041        ));
2042
2043        let mut brave = Config::default();
2044        brave.web.search = WebSearchMode::Brave;
2045        brave.web.brave_api_key_env = None;
2046        assert!(
2047            brave
2048                .validate()
2049                .unwrap_err()
2050                .to_string()
2051                .contains("brave_api_key")
2052        );
2053        brave.web.brave_api_key = Some("inline-test-key".into());
2054        assert!(matches!(
2055            brave.web_tools().unwrap().search,
2056            Some(SearchBackend::Brave { ref api_key, .. }) if api_key == "inline-test-key"
2057        ));
2058        brave.web.brave_api_key = None;
2059        brave.web.brave_api_key_env = Some("SCV_TEST_UNSET_BRAVE_KEY_VARIABLE".into());
2060        assert!(brave.validate().is_ok());
2061        assert!(brave.web_tools().unwrap().search.is_none());
2062
2063        for (mutate, message) in [
2064            (
2065                (|config: &mut Config| {
2066                    config.web.auto_approve_domains = vec!["https://docs.rs/".into()]
2067                }) as fn(&mut Config),
2068                "web.auto_approve_domains",
2069            ),
2070            (|config| config.web.max_redirects = 11, "web.max_redirects"),
2071            (
2072                |config| config.web.fetch_max_bytes = 0,
2073                "web.fetch_max_bytes",
2074            ),
2075            (
2076                |config| config.web.fetch_timeout_seconds = config.tools.max_timeout_seconds + 1,
2077                "web.fetch_timeout_seconds",
2078            ),
2079            (
2080                |config| config.web.max_search_results = 21,
2081                "web.max_search_results",
2082            ),
2083        ] {
2084            let mut config = Config::default();
2085            mutate(&mut config);
2086            let error = config.validate().unwrap_err().to_string();
2087            assert!(error.contains(message), "{error}");
2088        }
2089        for valid in ["docs.rs", "*.example.com", "a-b.c1.dev"] {
2090            assert!(valid_domain_pattern(valid), "{valid}");
2091        }
2092        for invalid in ["", "*.", "docs.rs/path", "-a.com", "a..b", "*", "user@host"] {
2093            assert!(!valid_domain_pattern(invalid), "{invalid}");
2094        }
2095    }
2096
2097    #[test]
2098    fn projects_may_narrow_but_not_widen_web_access() {
2099        for key in [
2100            "auto_approve_domains = [\"attacker.test\"]",
2101            "allow_private_addresses = true",
2102            "searxng_url = \"http://attacker.test\"",
2103            "brave_url = \"http://attacker.test\"",
2104            "brave_api_key_env = \"OTHER\"",
2105        ] {
2106            let project: toml::Value = toml::from_str(&format!("[web]\n{key}\n")).unwrap();
2107            assert!(validate_project_keys(&project).is_err(), "{key}");
2108        }
2109        let allowed: toml::Value =
2110            toml::from_str("[web]\nenabled = false\nsearch = \"off\"\nmax_redirects = 1\n")
2111                .unwrap();
2112        assert!(validate_project_keys(&allowed).is_ok());
2113
2114        let mut user = Config::default();
2115        user.web.search = WebSearchMode::Provider;
2116        let mut narrower = user.clone();
2117        narrower.web.enabled = false;
2118        narrower.web.search = WebSearchMode::Off;
2119        narrower.web.fetch_max_bytes = 1024;
2120        narrower.web.max_redirects = 0;
2121        assert!(validate_project_not_weaker(&user, &narrower).is_ok());
2122        assert!(validate_project_not_weaker(&narrower, &user).is_err());
2123        let mut switched = user.clone();
2124        switched.web.search = WebSearchMode::Searxng;
2125        assert!(validate_project_not_weaker(&user, &switched).is_err());
2126        let mut larger = user.clone();
2127        larger.web.fetch_timeout_seconds += 1;
2128        assert!(validate_project_not_weaker(&user, &larger).is_err());
2129    }
2130
2131    #[test]
2132    fn cross_field_validation_accounts_for_json_escaping() {
2133        let mut config = Config::default();
2134        config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
2135        assert!(config.validate().is_err());
2136    }
2137
2138    #[test]
2139    fn adapter_selection_templates_survive_partial_overrides_and_validate() {
2140        let mut value: toml::Value =
2141            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2142        merge(
2143            &mut value,
2144            toml::from_str(
2145                r#"[agents.claude]
2146args = ["-p", "--permission-mode", "acceptEdits"]
2147"#,
2148            )
2149            .unwrap(),
2150        );
2151        let config: Config = value.try_into().unwrap();
2152        let claude = &config.agents.0["claude"];
2153        assert_eq!(claude.args.len(), 3);
2154        assert_eq!(claude.model_args, ["--model", "{model}"]);
2155        assert_eq!(claude.effort_args, ["--effort", "{effort}"]);
2156        assert_eq!(
2157            config.agents.0["pi"].effort_args,
2158            ["--thinking", "{effort}"]
2159        );
2160        assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
2161
2162        let mut invalid = Config::default();
2163        invalid.agents.0.get_mut("claude").unwrap().effort_args = vec!["--effort".into()];
2164        assert!(
2165            invalid
2166                .validate()
2167                .unwrap_err()
2168                .to_string()
2169                .contains("agents.claude.effort_args must contain {effort}")
2170        );
2171    }
2172
2173    #[test]
2174    fn adapters_are_bound_to_the_instance_home() {
2175        let config = Config {
2176            instance_home: PathBuf::from("/tmp/scv-instance"),
2177            ..Config::default()
2178        };
2179        let adapters = config.adapters();
2180        let codex = &adapters["agent_codex"];
2181        assert!(codex.environment.contains(&(
2182            OsString::from("CODEX_HOME"),
2183            OsString::from("/tmp/scv-instance/agents/codex")
2184        )));
2185        assert!(codex.environment.contains(&(
2186            OsString::from("SCV_HOME"),
2187            OsString::from("/tmp/scv-instance/agents/codex")
2188        )));
2189        for (agent, variable, path) in [
2190            ("grok", "GROK_HOME", "/tmp/scv-instance/agents/grok/.grok"),
2191            ("dsh", "DSH_HOME", "/tmp/scv-instance/agents/dsh/.dsh"),
2192            (
2193                "pi",
2194                "PI_CODING_AGENT_DIR",
2195                "/tmp/scv-instance/agents/pi/.pi/agent",
2196            ),
2197        ] {
2198            let adapter = &adapters[&format!("agent_{agent}")];
2199            assert!(
2200                adapter
2201                    .environment
2202                    .contains(&(OsString::from(variable), OsString::from(path))),
2203                "{agent}"
2204            );
2205            assert!(adapter.environment.contains(&(
2206                OsString::from("HOME"),
2207                OsString::from(format!("/tmp/scv-instance/agents/{agent}"))
2208            )));
2209        }
2210        assert!(adapters["agent_grok"].environment.contains(&(
2211            OsString::from("GROK_DISABLE_AUTOUPDATER"),
2212            OsString::from("1")
2213        )));
2214        assert_eq!(adapters["agent_grok"].prompt_args, ["-p"]);
2215        assert!(adapters["agent_pi"].model_hint.contains("provider scv"));
2216    }
2217
2218    #[test]
2219    fn full_codex_over_acp_keeps_live_web_search() {
2220        let codex_acp = |permissions: &str| {
2221            let mut value: toml::Value =
2222                toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2223            merge(
2224                &mut value,
2225                toml::from_str(&format!(
2226                    "[agents.codex]\npermissions = \"{permissions}\"\n"
2227                ))
2228                .unwrap(),
2229            );
2230            let config: Config = value.try_into().unwrap();
2231            config.validate().unwrap();
2232            config.adapters()["agent_codex"].acp.clone().unwrap()
2233        };
2234        let full = codex_acp("full");
2235        assert_eq!(full.full_mode.as_deref(), Some("agent-full-access"));
2236        let [(variable, value)] = full.environment.as_slice() else {
2237            panic!("expected one ACP variable: {:?}", full.environment);
2238        };
2239        assert_eq!(variable, "CODEX_CONFIG");
2240        let overrides: serde_json::Value = serde_json::from_str(value.to_str().unwrap()).unwrap();
2241        assert_eq!(overrides, serde_json::json!({"web_search": "live"}));
2242        assert!(
2243            codex_acp("default").environment.is_empty(),
2244            "default permissions leave web search to the Codex config"
2245        );
2246        assert!(
2247            scv_tools::adapters::is_removed_agent_variable(std::ffi::OsStr::new("CODEX_CONFIG")),
2248            "an inherited CODEX_CONFIG never reaches a delegated Codex"
2249        );
2250    }
2251
2252    #[test]
2253    fn agents_prefer_their_acp_server_unless_configured_otherwise() {
2254        let defaults = Config::default().adapters();
2255        let launch = |adapters: &HashMap<String, scv_tools::AgentAdapterConfig>, agent: &str| {
2256            adapters[&format!("agent_{agent}")].acp.clone()
2257        };
2258        for agent in ["claude", "codex", "grok", "dsh"] {
2259            let acp = launch(&defaults, agent).unwrap();
2260            assert!(!acp.required, "{agent}: auto falls back to resume");
2261            assert_eq!(acp.full_mode, None, "{agent}: no full mode by default");
2262        }
2263        assert_eq!(
2264            launch(&defaults, "claude").unwrap().command,
2265            "claude-agent-acp"
2266        );
2267        assert_eq!(launch(&defaults, "codex").unwrap().command, "codex-acp");
2268        assert_eq!(launch(&defaults, "grok").unwrap().args, ["agent", "stdio"]);
2269        assert_eq!(launch(&defaults, "dsh").unwrap().args, ["--profile", "acp"]);
2270        assert!(launch(&defaults, "pi").is_none());
2271        assert!(launch(&defaults, "scv").is_none());
2272
2273        let mut value: toml::Value =
2274            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2275        merge(
2276            &mut value,
2277            toml::from_str(
2278                "[agents.claude]\npermissions = \"full\"\ntransport = \"acp\"\n\n\
2279                 [agents.codex]\ntransport = \"resume\"\n\n\
2280                 [agents.grok]\npermissions = \"full\"\n",
2281            )
2282            .unwrap(),
2283        );
2284        let config: Config = value.try_into().unwrap();
2285        config.validate().unwrap();
2286        let adapters = config.adapters();
2287        let claude = launch(&adapters, "claude").unwrap();
2288        assert!(claude.required);
2289        assert_eq!(claude.full_mode.as_deref(), Some("bypassPermissions"));
2290        assert!(launch(&adapters, "codex").is_none(), "resume turns ACP off");
2291
2292        let mut custom: toml::Value =
2293            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2294        merge(
2295            &mut custom,
2296            toml::from_str(
2297                "[agents.claude]\ncommand = \"/opt/claude-wrapper\"\n\n\
2298                 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\n",
2299            )
2300            .unwrap(),
2301        );
2302        let custom: Config = custom.try_into().unwrap();
2303        let custom = custom.adapters();
2304        assert!(
2305            launch(&custom, "claude").is_none(),
2306            "a custom command keeps one process per turn"
2307        );
2308        assert!(launch(&custom, "codex").is_some(), "custom args keep ACP");
2309        assert_eq!(
2310            launch(&adapters, "grok").unwrap().args,
2311            ["agent", "--always-approve", "stdio"]
2312        );
2313
2314        let mut pi: toml::Value =
2315            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2316        merge(
2317            &mut pi,
2318            toml::from_str("[agents.pi]\ntransport = \"acp\"\n").unwrap(),
2319        );
2320        let pi: Config = pi.try_into().unwrap();
2321        let error = pi.validate().unwrap_err().to_string();
2322        assert!(error.contains("no verified ACP server"), "{error}");
2323
2324        let mut invalid: toml::Value =
2325            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2326        merge(
2327            &mut invalid,
2328            toml::from_str("[agents.claude]\ntransport = \"rpc\"\n").unwrap(),
2329        );
2330        assert!(invalid.try_into::<Config>().is_err());
2331    }
2332
2333    #[test]
2334    fn full_permissions_are_opt_in_per_agent_and_combine_with_args() {
2335        let defaults = Config::default().adapters();
2336        for adapter in defaults.values() {
2337            assert_eq!(adapter.full_permission_args, None);
2338        }
2339        let mut value: toml::Value =
2340            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2341        merge(
2342            &mut value,
2343            toml::from_str(
2344                "[agents.claude]\npermissions = \"full\"\n\n\
2345                 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\npermissions = \"full\"\n\n\
2346                 [agents.grok]\npermissions = \"full\"\n\n\
2347                 [agents.dsh]\npermissions = \"full\"\n\n\
2348                 [agents.pi]\npermissions = \"full\"\n",
2349            )
2350            .unwrap(),
2351        );
2352        let config: Config = value.try_into().unwrap();
2353        config.validate().unwrap();
2354        let adapters = config.adapters();
2355        let full = |agent: &str| {
2356            adapters[&format!("agent_{agent}")]
2357                .full_permission_args
2358                .clone()
2359                .unwrap()
2360        };
2361        assert_eq!(full("claude"), ["--permission-mode", "bypassPermissions"]);
2362        assert_eq!(
2363            full("codex"),
2364            [
2365                "--dangerously-bypass-approvals-and-sandbox",
2366                "-c",
2367                "web_search=\"live\""
2368            ]
2369        );
2370        assert_eq!(
2371            adapters["agent_codex"].args,
2372            ["exec", "--skip-git-repo-check"]
2373        );
2374        assert_eq!(full("grok"), ["--always-approve"]);
2375        assert!(full("dsh").is_empty());
2376        assert!(adapters["agent_dsh"].environment.contains(&(
2377            OsString::from("DSH_PERMISSION_MODE"),
2378            OsString::from("danger-full-access")
2379        )));
2380        assert!(
2381            !defaults["agent_dsh"]
2382                .environment
2383                .iter()
2384                .any(|(variable, _)| variable == "DSH_PERMISSION_MODE")
2385        );
2386        // pi has no permission system: `full` is accepted and adds nothing.
2387        assert!(full("pi").is_empty());
2388
2389        let mut invalid: toml::Value =
2390            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2391        merge(
2392            &mut invalid,
2393            toml::from_str("[agents.claude]\npermissions = \"yolo\"\n").unwrap(),
2394        );
2395        assert!(invalid.try_into::<Config>().is_err());
2396    }
2397
2398    #[test]
2399    fn user_agent_overrides_merge_over_every_built_in_and_unknown_agents_fail() {
2400        let mut value: toml::Value =
2401            toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
2402        merge(
2403            &mut value,
2404            toml::from_str(
2405                "[agents.pi]
2406model_args = []
2407
2408[agents.grok]
2409args = [\"--always-approve\"]
2410",
2411            )
2412            .unwrap(),
2413        );
2414        let config: Config = value.clone().try_into().unwrap();
2415        assert!(config.agents.0["pi"].model_args.is_empty());
2416        assert_eq!(config.agents.0["pi"].args, ["-p"]);
2417        assert_eq!(config.agents.0["grok"].args, ["--always-approve"]);
2418        assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
2419        assert_eq!(
2420            config.agents.0.keys().collect::<Vec<_>>(),
2421            ["claude", "codex", "dsh", "grok", "pi", "scv"]
2422        );
2423
2424        merge(
2425            &mut value,
2426            toml::from_str(
2427                "[agents.zcode]
2428command = \"zcode\"
2429",
2430            )
2431            .unwrap(),
2432        );
2433        let unknown: Config = value.try_into().unwrap();
2434        let error = unknown.validate().unwrap_err().to_string();
2435        assert!(error.contains("unknown agent [agents.zcode]"), "{error}");
2436    }
2437}