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