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