Skip to main content

scv_server/
config.rs

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