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