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