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 },
751 ))
752 })
753 .collect()
754 }
755
756 pub fn prepare_adapter_homes(&self) -> Result<()> {
757 for name in self.agents.0.keys() {
758 let path = self.instance_home.join("adapters").join(name);
759 std::fs::create_dir_all(&path)
760 .with_context(|| format!("create isolated {name} adapter home"))?;
761 #[cfg(unix)]
762 {
763 use std::os::unix::fs::PermissionsExt;
764 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
765 .with_context(|| format!("secure isolated {name} adapter home"))?;
766 }
767 }
768 Ok(())
769 }
770
771 fn validate(&self) -> Result<()> {
772 if self.provider.kind != "openai-compatible" {
773 bail!("provider.kind must be openai-compatible in v0.1");
774 }
775 if self.provider.model.trim().is_empty()
776 || self.provider.base_url.trim().is_empty()
777 || self
778 .provider
779 .api_key
780 .as_deref()
781 .unwrap_or("")
782 .trim()
783 .is_empty()
784 && self
785 .provider
786 .api_key_env
787 .as_deref()
788 .unwrap_or("")
789 .trim()
790 .is_empty()
791 {
792 bail!(
793 "provider model and base_url must be non-empty; configure api_key or api_key_env"
794 );
795 }
796 for (agent, adapter) in &self.agents.0 {
797 if scv_tools::adapters::adapter(agent).is_none() {
798 let known: Vec<_> = scv_tools::adapters::ADAPTERS
799 .iter()
800 .map(|adapter| adapter.name)
801 .collect();
802 bail!(
803 "unknown agent [agents.{agent}]; known agents are {}",
804 known.join(", ")
805 );
806 }
807 let name = format!("agents.{agent}.command");
808 if adapter.command.trim().is_empty() {
809 bail!("{name} must be non-empty");
810 }
811 for (field, template, placeholder) in [
812 ("model_args", &adapter.model_args, "{model}"),
813 ("effort_args", &adapter.effort_args, "{effort}"),
814 ] {
815 if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
816 let adapter = name.trim_end_matches(".command");
817 bail!("{adapter}.{field} must contain {placeholder} or be empty");
818 }
819 }
820 let adapter_bytes = adapter.command.len()
821 + [
822 &adapter.args,
823 &adapter.prompt_args,
824 &adapter.model_args,
825 &adapter.effort_args,
826 ]
827 .into_iter()
828 .flatten()
829 .map(String::len)
830 .sum::<usize>();
831 if adapter_bytes > 16 * 1024 {
832 bail!("{name} and its fixed arguments exceed 16384 bytes");
833 }
834 }
835 let positives = [
836 (
837 "provider.timeout_seconds",
838 usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
839 ),
840 ("agent.max_steps", self.agent.max_steps),
841 ("agent.max_conversations", self.agent.max_conversations),
842 (
843 "agent.conversation_idle_seconds",
844 usize::try_from(self.agent.conversation_idle_seconds).unwrap_or(usize::MAX),
845 ),
846 ("session.max_history_bytes", self.session.max_history_bytes),
847 ("session.max_messages", self.session.max_messages),
848 ("context.max_tokens", self.context.max_tokens),
849 ("context.bytes_per_token", self.context.bytes_per_token),
850 ("context.summary_max_chars", self.context.summary_max_chars),
851 (
852 "tools.command_timeout_seconds",
853 usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
854 ),
855 (
856 "tools.agent_timeout_seconds",
857 usize::try_from(self.tools.agent_timeout_seconds).unwrap_or(usize::MAX),
858 ),
859 (
860 "tools.max_timeout_seconds",
861 usize::try_from(self.tools.max_timeout_seconds).unwrap_or(usize::MAX),
862 ),
863 ("tools.output_limit_bytes", self.tools.output_limit_bytes),
864 ("tools.max_read_bytes", self.tools.max_read_bytes),
865 ("tools.max_write_bytes", self.tools.max_write_bytes),
866 (
867 "protocol.max_client_frame_bytes",
868 self.protocol.max_client_frame_bytes,
869 ),
870 (
871 "protocol.max_server_frame_bytes",
872 self.protocol.max_server_frame_bytes,
873 ),
874 ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
875 ("tui.max_transcript_items", self.tui.max_transcript_items),
876 (
877 "tui.max_prompt_history_bytes",
878 self.tui.max_prompt_history_bytes,
879 ),
880 (
881 "tui.max_prompt_history_items",
882 self.tui.max_prompt_history_items,
883 ),
884 (
885 "provider_limits.max_sse_event_bytes",
886 self.provider_limits.max_sse_event_bytes,
887 ),
888 (
889 "provider_limits.max_response_bytes",
890 self.provider_limits.max_response_bytes,
891 ),
892 (
893 "provider_limits.max_assistant_bytes",
894 self.provider_limits.max_assistant_bytes,
895 ),
896 (
897 "provider_limits.max_tool_calls",
898 self.provider_limits.max_tool_calls,
899 ),
900 (
901 "provider_limits.max_tool_arguments_bytes",
902 self.provider_limits.max_tool_arguments_bytes,
903 ),
904 ("skills.max_skills", self.skills.max_skills),
905 ("skills.max_skill_bytes", self.skills.max_skill_bytes),
906 ("web.fetch_max_bytes", self.web.fetch_max_bytes),
907 (
908 "web.fetch_timeout_seconds",
909 usize::try_from(self.web.fetch_timeout_seconds).unwrap_or(usize::MAX),
910 ),
911 ("web.max_search_results", self.web.max_search_results),
912 ];
913 if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
914 bail!("{name} must be positive");
915 }
916 for (name, value) in [
917 (
918 "tools.command_timeout_seconds",
919 self.tools.command_timeout_seconds,
920 ),
921 (
922 "tools.agent_timeout_seconds",
923 self.tools.agent_timeout_seconds,
924 ),
925 ] {
926 if value > self.tools.max_timeout_seconds {
927 bail!("{name} exceeds tools.max_timeout_seconds");
928 }
929 }
930 if self.tools.max_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS {
931 bail!("tools.max_timeout_seconds must be at most {MAX_TOOL_TIMEOUT_SECONDS}");
932 }
933 if self
934 .context
935 .reserve_output_tokens
936 .saturating_add(self.context.safety_margin_tokens)
937 >= self.context.max_tokens
938 {
939 bail!("context reserve and safety margin consume max_tokens");
940 }
941 let worst_assistant_frame = self
942 .provider_limits
943 .max_assistant_bytes
944 .saturating_mul(6)
945 .saturating_add(64 * 1024);
946 if worst_assistant_frame > self.protocol.max_server_frame_bytes {
947 bail!(
948 "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
949 );
950 }
951 if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
952 bail!("tool argument limit exceeds provider response limit");
953 }
954 if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
955 bail!("provider SSE event limit exceeds provider response limit");
956 }
957 if self.provider_limits.max_retries > MAX_PROVIDER_RETRIES {
958 bail!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}");
959 }
960 if self.protocol.max_client_frame_bytes < 4096 {
961 bail!("protocol.max_client_frame_bytes must be at least 4096");
962 }
963 if self.protocol.max_server_frame_bytes < 64 * 1024 {
964 bail!("protocol.max_server_frame_bytes must be at least 65536");
965 }
966 let worst_tool_frame = self
967 .tools
968 .output_limit_bytes
969 .max(self.tools.max_read_bytes)
970 .saturating_mul(12)
971 .saturating_add(64 * 1024);
972 let worst_skill_frame = self
973 .skills
974 .max_skill_bytes
975 .saturating_mul(6)
976 .saturating_add(64 * 1024);
977 let worst_arguments_frame = self
978 .provider_limits
979 .max_tool_arguments_bytes
980 .saturating_mul(6)
981 .saturating_add(64 * 1024);
982 if worst_tool_frame
983 .max(worst_skill_frame)
984 .max(worst_arguments_frame)
985 > self.protocol.max_server_frame_bytes
986 {
987 bail!(
988 "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
989 );
990 }
991 self.validate_web()?;
992 if self.skills.project_dir.is_absolute()
993 || self
994 .skills
995 .project_dir
996 .components()
997 .any(|component| matches!(component, std::path::Component::ParentDir))
998 {
999 bail!("skills.project_dir must be a contained relative path");
1000 }
1001 Ok(())
1002 }
1003}
1004
1005impl Config {
1006 fn validate_web(&self) -> Result<()> {
1007 let web = &self.web;
1008 if web.fetch_max_bytes > 64 * 1024 * 1024 {
1009 bail!("web.fetch_max_bytes must be at most 67108864");
1010 }
1011 if web.fetch_timeout_seconds > self.tools.max_timeout_seconds {
1012 bail!("web.fetch_timeout_seconds exceeds tools.max_timeout_seconds");
1013 }
1014 if web.max_redirects > 10 {
1015 bail!("web.max_redirects must be at most 10");
1016 }
1017 if web.max_search_results > 20 {
1018 bail!("web.max_search_results must be at most 20");
1019 }
1020 if web.auto_approve_domains.len() > 256 {
1021 bail!("web.auto_approve_domains may list at most 256 hosts");
1022 }
1023 if let Some(entry) = web
1024 .auto_approve_domains
1025 .iter()
1026 .find(|entry| !valid_domain_pattern(entry))
1027 {
1028 bail!(
1029 "web.auto_approve_domains entry {entry:?} must be a host name such as docs.rs or *.example.com"
1030 );
1031 }
1032 let http_url = |value: &str| value.starts_with("https://") || value.starts_with("http://");
1033 if !http_url(&web.brave_url) {
1034 bail!("web.brave_url must be an http or https URL");
1035 }
1036 match web.search {
1037 WebSearchMode::Searxng if !web.searxng_url.as_deref().is_some_and(http_url) => {
1038 bail!("web.search = \"searxng\" requires web.searxng_url (an http or https URL)");
1039 }
1040 WebSearchMode::Brave
1041 if web.brave_api_key.as_deref().unwrap_or("").trim().is_empty()
1042 && web
1043 .brave_api_key_env
1044 .as_deref()
1045 .unwrap_or("")
1046 .trim()
1047 .is_empty() =>
1048 {
1049 bail!("web.search = \"brave\" requires web.brave_api_key or web.brave_api_key_env");
1050 }
1051 _ => {}
1052 }
1053 Ok(())
1054 }
1055}
1056
1057fn valid_domain_pattern(entry: &str) -> bool {
1059 let host = entry.strip_prefix("*.").unwrap_or(entry);
1060 !host.is_empty()
1061 && host.len() <= 253
1062 && host.split('.').all(|label| {
1063 !label.is_empty()
1064 && label.len() <= 63
1065 && !label.starts_with('-')
1066 && !label.ends_with('-')
1067 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1068 })
1069}
1070
1071fn user_config_path() -> Option<PathBuf> {
1072 user_home_path().map(|path| path.join("config.toml"))
1073}
1074
1075pub fn user_home_path() -> Option<PathBuf> {
1076 let path = std::env::var_os("SCV_HOME")
1077 .map(PathBuf::from)
1078 .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))?;
1079 if path.exists() {
1080 Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
1081 } else if path.is_absolute() {
1082 Some(path)
1083 } else {
1084 std::env::current_dir().ok().map(|cwd| cwd.join(path))
1085 }
1086}
1087
1088fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
1089 #[cfg(unix)]
1090 {
1091 use std::os::unix::fs::PermissionsExt;
1092 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
1093 .with_context(|| format!("secure directory {}", path.display()))?;
1094 }
1095 Ok(())
1096}
1097
1098fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
1099 let size = std::fs::metadata(path)
1100 .with_context(|| format!("stat configuration {}", path.display()))?
1101 .len();
1102 if size > MAX_CONFIG_BYTES {
1103 bail!("configuration {} exceeds 1 MiB", path.display());
1104 }
1105 let content = std::fs::read_to_string(path)
1106 .with_context(|| format!("read configuration {}", path.display()))?;
1107 toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
1108}
1109
1110fn merge(base: &mut toml::Value, overlay: toml::Value) {
1111 match (base, overlay) {
1112 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
1113 for (key, value) in overlay {
1114 match base.get_mut(&key) {
1115 Some(existing) => merge(existing, value),
1116 None => {
1117 base.insert(key, value);
1118 }
1119 }
1120 }
1121 }
1122 (base, overlay) => *base = overlay,
1123 }
1124}
1125
1126fn validate_project_keys(value: &toml::Value) -> Result<()> {
1127 let Some(table) = value.as_table() else {
1128 bail!("project configuration must be a TOML table");
1129 };
1130 for forbidden in [
1131 "provider",
1132 "providers",
1133 "provider_active",
1134 "agents",
1135 "update",
1136 ] {
1137 if table.contains_key(forbidden) {
1138 bail!("project configuration cannot set [{forbidden}]");
1139 }
1140 }
1141 if table
1142 .get("skills")
1143 .and_then(toml::Value::as_table)
1144 .is_some_and(|skills| skills.contains_key("user_dir"))
1145 {
1146 bail!("project configuration cannot set skills.user_dir");
1147 }
1148 if table
1149 .get("agent")
1150 .and_then(toml::Value::as_table)
1151 .is_some_and(|agent| agent.contains_key("system_prompt"))
1152 {
1153 bail!("project configuration cannot replace agent.system_prompt");
1154 }
1155 if let Some(web) = table.get("web").and_then(toml::Value::as_table) {
1156 for key in [
1157 "auto_approve_domains",
1158 "allow_private_addresses",
1159 "searxng_url",
1160 "brave_url",
1161 "brave_api_key",
1162 "brave_api_key_env",
1163 ] {
1164 if web.contains_key(key) {
1165 bail!("project configuration cannot set web.{key}");
1166 }
1167 }
1168 }
1169 Ok(())
1170}
1171
1172fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
1173 macro_rules! no_larger {
1174 ($field:expr, $name:literal) => {
1175 if $field.1 > $field.0 {
1176 bail!(concat!("project configuration cannot raise ", $name));
1177 }
1178 };
1179 }
1180 no_larger!(
1181 (user.agent.max_steps, project.agent.max_steps),
1182 "agent.max_steps"
1183 );
1184 no_larger!(
1185 (
1186 user.agent.max_delegation_depth,
1187 project.agent.max_delegation_depth
1188 ),
1189 "agent.max_delegation_depth"
1190 );
1191 no_larger!(
1192 (
1193 user.agent.max_conversations,
1194 project.agent.max_conversations
1195 ),
1196 "agent.max_conversations"
1197 );
1198 no_larger!(
1199 (
1200 user.agent.conversation_idle_seconds,
1201 project.agent.conversation_idle_seconds
1202 ),
1203 "agent.conversation_idle_seconds"
1204 );
1205 no_larger!(
1206 (
1207 user.session.max_history_bytes,
1208 project.session.max_history_bytes
1209 ),
1210 "session.max_history_bytes"
1211 );
1212 no_larger!(
1213 (user.session.max_messages, project.session.max_messages),
1214 "session.max_messages"
1215 );
1216 no_larger!(
1217 (user.context.max_tokens, project.context.max_tokens),
1218 "context.max_tokens"
1219 );
1220 no_larger!(
1221 (
1222 user.context.summary_max_chars,
1223 project.context.summary_max_chars
1224 ),
1225 "context.summary_max_chars"
1226 );
1227 no_larger!(
1228 (
1229 user.tools.command_timeout_seconds,
1230 project.tools.command_timeout_seconds
1231 ),
1232 "tools.command_timeout_seconds"
1233 );
1234 no_larger!(
1235 (
1236 user.tools.agent_timeout_seconds,
1237 project.tools.agent_timeout_seconds
1238 ),
1239 "tools.agent_timeout_seconds"
1240 );
1241 no_larger!(
1242 (
1243 user.tools.max_timeout_seconds,
1244 project.tools.max_timeout_seconds
1245 ),
1246 "tools.max_timeout_seconds"
1247 );
1248 no_larger!(
1249 (
1250 user.tools.output_limit_bytes,
1251 project.tools.output_limit_bytes
1252 ),
1253 "tools.output_limit_bytes"
1254 );
1255 no_larger!(
1256 (user.tools.max_read_bytes, project.tools.max_read_bytes),
1257 "tools.max_read_bytes"
1258 );
1259 no_larger!(
1260 (user.tools.max_write_bytes, project.tools.max_write_bytes),
1261 "tools.max_write_bytes"
1262 );
1263 no_larger!(
1264 (
1265 user.protocol.max_client_frame_bytes,
1266 project.protocol.max_client_frame_bytes
1267 ),
1268 "protocol.max_client_frame_bytes"
1269 );
1270 no_larger!(
1271 (
1272 user.protocol.max_server_frame_bytes,
1273 project.protocol.max_server_frame_bytes
1274 ),
1275 "protocol.max_server_frame_bytes"
1276 );
1277 no_larger!(
1278 (
1279 user.provider_limits.max_response_bytes,
1280 project.provider_limits.max_response_bytes
1281 ),
1282 "provider_limits.max_response_bytes"
1283 );
1284 no_larger!(
1285 (
1286 user.provider_limits.max_sse_event_bytes,
1287 project.provider_limits.max_sse_event_bytes
1288 ),
1289 "provider_limits.max_sse_event_bytes"
1290 );
1291 no_larger!(
1292 (
1293 user.provider_limits.max_assistant_bytes,
1294 project.provider_limits.max_assistant_bytes
1295 ),
1296 "provider_limits.max_assistant_bytes"
1297 );
1298 no_larger!(
1299 (
1300 user.provider_limits.max_tool_calls,
1301 project.provider_limits.max_tool_calls
1302 ),
1303 "provider_limits.max_tool_calls"
1304 );
1305 no_larger!(
1306 (
1307 user.provider_limits.max_tool_arguments_bytes,
1308 project.provider_limits.max_tool_arguments_bytes
1309 ),
1310 "provider_limits.max_tool_arguments_bytes"
1311 );
1312 no_larger!(
1313 (
1314 user.provider_limits.max_retries,
1315 project.provider_limits.max_retries
1316 ),
1317 "provider_limits.max_retries"
1318 );
1319 no_larger!(
1320 (
1321 user.tui.max_transcript_bytes,
1322 project.tui.max_transcript_bytes
1323 ),
1324 "tui.max_transcript_bytes"
1325 );
1326 no_larger!(
1327 (
1328 user.tui.max_transcript_items,
1329 project.tui.max_transcript_items
1330 ),
1331 "tui.max_transcript_items"
1332 );
1333 no_larger!(
1334 (
1335 user.tui.max_prompt_history_bytes,
1336 project.tui.max_prompt_history_bytes
1337 ),
1338 "tui.max_prompt_history_bytes"
1339 );
1340 no_larger!(
1341 (
1342 user.tui.max_prompt_history_items,
1343 project.tui.max_prompt_history_items
1344 ),
1345 "tui.max_prompt_history_items"
1346 );
1347 no_larger!(
1348 (user.skills.max_skills, project.skills.max_skills),
1349 "skills.max_skills"
1350 );
1351 no_larger!(
1352 (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
1353 "skills.max_skill_bytes"
1354 );
1355 if project.context.reserve_output_tokens < user.context.reserve_output_tokens
1356 || project.context.safety_margin_tokens < user.context.safety_margin_tokens
1357 {
1358 bail!("project configuration cannot lower context reserves");
1359 }
1360 if project.context.bytes_per_token > user.context.bytes_per_token {
1361 bail!("project configuration cannot raise context.bytes_per_token");
1362 }
1363 if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
1364 bail!("project configuration cannot weaken tools.approval_policy");
1365 }
1366 if project.skills.scan_projects && !user.skills.scan_projects {
1367 bail!("project configuration cannot enable skills.scan_projects");
1368 }
1369 if project.web.enabled && !user.web.enabled {
1370 bail!("project configuration cannot enable web");
1371 }
1372 if project.web.search != user.web.search && project.web.search != WebSearchMode::Off {
1373 bail!("project configuration can only turn web.search off");
1374 }
1375 no_larger!(
1376 (user.web.fetch_max_bytes, project.web.fetch_max_bytes),
1377 "web.fetch_max_bytes"
1378 );
1379 no_larger!(
1380 (
1381 user.web.fetch_timeout_seconds,
1382 project.web.fetch_timeout_seconds
1383 ),
1384 "web.fetch_timeout_seconds"
1385 );
1386 no_larger!(
1387 (user.web.max_redirects, project.web.max_redirects),
1388 "web.max_redirects"
1389 );
1390 no_larger!(
1391 (user.web.max_search_results, project.web.max_search_results),
1392 "web.max_search_results"
1393 );
1394 Ok(())
1395}
1396
1397fn expand_home(path: &std::path::Path) -> PathBuf {
1398 let value = path.to_string_lossy();
1399 if value == "~" {
1400 return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
1401 }
1402 if let Some(rest) = value.strip_prefix("~/")
1403 && let Some(home) = dirs::home_dir()
1404 {
1405 return home.join(rest);
1406 }
1407 path.to_path_buf()
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412 use super::*;
1413
1414 #[test]
1415 fn project_cannot_redirect_provider_or_agent() {
1416 let provider: toml::Value = toml::from_str(
1417 r#"[provider]
1418base_url = "https://attacker.invalid"
1419"#,
1420 )
1421 .unwrap();
1422 assert!(validate_project_keys(&provider).is_err());
1423
1424 let agent: toml::Value = toml::from_str(
1425 r#"[agents.codex]
1426command = "/tmp/fake"
1427"#,
1428 )
1429 .unwrap();
1430 assert!(validate_project_keys(&agent).is_err());
1431 }
1432
1433 #[test]
1434 fn project_may_tighten_but_not_weaken_limits() {
1435 let user = Config::default();
1436 let mut tighter = user.clone();
1437 tighter.tools.output_limit_bytes /= 2;
1438 tighter.tools.approval_policy = ApprovalPolicy::Always;
1439 assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1440
1441 let mut weaker = user.clone();
1442 weaker.tools.output_limit_bytes *= 2;
1443 assert!(validate_project_not_weaker(&user, &weaker).is_err());
1444 }
1445
1446 #[test]
1447 fn timeouts_default_below_a_ceiling_that_projects_may_only_lower() {
1448 let user = Config::default();
1449 assert_eq!(
1450 (
1451 user.tools.command_timeout_seconds,
1452 user.tools.agent_timeout_seconds,
1453 user.tools.max_timeout_seconds
1454 ),
1455 (600, 3600, 14400)
1456 );
1457 assert_eq!(user.agent.max_steps, 128);
1458 assert_eq!(user.provider.timeout_seconds, 600);
1459 let tools = user.tools();
1460 assert_eq!(tools.command_timeout, Duration::from_secs(600));
1461 assert_eq!(tools.agent_timeout, Duration::from_secs(3600));
1462 assert_eq!(tools.max_timeout, Duration::from_secs(14400));
1463 assert_eq!(
1465 scv_clawbot::owner_turn_timeout(tools.max_timeout),
1466 Duration::from_secs(4 * 3600 + 5 * 60)
1467 );
1468
1469 for (field, name) in [
1470 (0, "tools.command_timeout_seconds"),
1471 (1, "tools.agent_timeout_seconds"),
1472 ] {
1473 let mut config = Config::default();
1474 let value = if field == 0 {
1475 &mut config.tools.command_timeout_seconds
1476 } else {
1477 &mut config.tools.agent_timeout_seconds
1478 };
1479 *value = config.tools.max_timeout_seconds + 1;
1480 assert_eq!(
1481 config.validate().unwrap_err().to_string(),
1482 format!("{name} exceeds tools.max_timeout_seconds")
1483 );
1484 }
1485 let mut unbounded = Config::default();
1486 unbounded.tools.max_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS + 1;
1487 assert!(unbounded.validate().is_err());
1488 let mut zero = Config::default();
1489 zero.tools.agent_timeout_seconds = 0;
1490 assert!(zero.validate().is_err());
1491
1492 let mut lower = user.clone();
1493 lower.tools.max_timeout_seconds = 900;
1494 lower.tools.agent_timeout_seconds = 300;
1495 assert!(validate_project_not_weaker(&user, &lower).is_ok());
1496 for raise in [
1497 |config: &mut Config| config.tools.max_timeout_seconds += 1,
1498 |config: &mut Config| config.tools.agent_timeout_seconds += 1,
1499 ] {
1500 let mut higher = user.clone();
1501 raise(&mut higher);
1502 assert!(validate_project_not_weaker(&user, &higher).is_err());
1503 }
1504 }
1505
1506 #[test]
1507 fn conversation_limits_are_positive_and_projects_may_only_lower_them() {
1508 let user = Config::default();
1509 assert_eq!(
1510 (
1511 user.agent.max_conversations,
1512 user.agent.conversation_idle_seconds
1513 ),
1514 (8, 86400)
1515 );
1516 let limits = user.tools().conversations;
1517 assert_eq!((limits.max, limits.idle), (8, Duration::from_secs(86400)));
1518 for zero in [
1519 |config: &mut Config| config.agent.max_conversations = 0,
1520 |config: &mut Config| config.agent.conversation_idle_seconds = 0,
1521 ] {
1522 let mut config = Config::default();
1523 zero(&mut config);
1524 assert!(config.validate().is_err());
1525 }
1526 let mut lower = user.clone();
1527 lower.agent.max_conversations = 2;
1528 lower.agent.conversation_idle_seconds = 600;
1529 assert!(validate_project_not_weaker(&user, &lower).is_ok());
1530 for raise in [
1531 |config: &mut Config| config.agent.max_conversations += 1,
1532 |config: &mut Config| config.agent.conversation_idle_seconds += 1,
1533 ] {
1534 let mut higher = user.clone();
1535 raise(&mut higher);
1536 assert!(validate_project_not_weaker(&user, &higher).is_err());
1537 }
1538 }
1539
1540 #[test]
1541 fn provider_retries_are_bounded_and_projects_may_only_lower_them() {
1542 let user = Config::default();
1543 assert_eq!(user.provider_limits.max_retries, 2);
1544 assert_eq!(user.provider_limits().max_retries, 2);
1545 let mut none = user.clone();
1546 none.provider_limits.max_retries = 0;
1547 assert!(none.validate().is_ok());
1548 assert!(validate_project_not_weaker(&user, &none).is_ok());
1549 assert!(validate_project_not_weaker(&none, &user).is_err());
1550 let mut excessive = user.clone();
1551 excessive.provider_limits.max_retries = MAX_PROVIDER_RETRIES + 1;
1552 assert_eq!(
1553 excessive.validate().unwrap_err().to_string(),
1554 format!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}")
1555 );
1556 }
1557
1558 #[test]
1559 fn projects_may_disable_but_not_enable_project_skill_scanning() {
1560 let user = Config::default();
1561 let mut disabled = user.clone();
1562 disabled.skills.scan_projects = false;
1563 assert!(validate_project_not_weaker(&user, &disabled).is_ok());
1564 assert!(validate_project_not_weaker(&disabled, &user).is_err());
1565 }
1566
1567 #[test]
1568 fn web_defaults_offer_fetch_without_search_and_validate_their_settings() {
1569 let config = Config::default();
1570 assert!(config.web.enabled);
1571 assert_eq!(config.web.search, WebSearchMode::Off);
1572 assert!(!config.hosted_web_search());
1573 let tools = config.web_tools().unwrap();
1574 assert!(tools.search.is_none());
1575 assert!(!tools.allow_private_addresses);
1576 assert_eq!(tools.fetch_max_bytes, 2 * 1024 * 1024);
1577 assert_eq!(tools.output_limit, config.tools.output_limit_bytes);
1578 assert!(tools.auto_approve_domains.contains(&"docs.rs".to_owned()));
1579
1580 let mut disabled = Config::default();
1581 disabled.web.enabled = false;
1582 disabled.web.search = WebSearchMode::Provider;
1583 assert!(disabled.web_tools().is_none());
1584 assert!(!disabled.hosted_web_search());
1585
1586 let mut provider = Config::default();
1587 provider.web.search = WebSearchMode::Provider;
1588 assert!(provider.hosted_web_search());
1589 assert!(provider.web_tools().unwrap().search.is_none());
1590
1591 let mut searxng = Config::default();
1592 searxng.web.search = WebSearchMode::Searxng;
1593 assert!(
1594 searxng
1595 .validate()
1596 .unwrap_err()
1597 .to_string()
1598 .contains("web.searxng_url")
1599 );
1600 searxng.web.searxng_url = Some("https://searx.example".into());
1601 assert!(searxng.validate().is_ok());
1602 assert!(matches!(
1603 searxng.web_tools().unwrap().search,
1604 Some(SearchBackend::Searxng { .. })
1605 ));
1606
1607 let mut brave = Config::default();
1608 brave.web.search = WebSearchMode::Brave;
1609 brave.web.brave_api_key_env = None;
1610 assert!(
1611 brave
1612 .validate()
1613 .unwrap_err()
1614 .to_string()
1615 .contains("brave_api_key")
1616 );
1617 brave.web.brave_api_key = Some("inline-test-key".into());
1618 assert!(matches!(
1619 brave.web_tools().unwrap().search,
1620 Some(SearchBackend::Brave { ref api_key, .. }) if api_key == "inline-test-key"
1621 ));
1622 brave.web.brave_api_key = None;
1623 brave.web.brave_api_key_env = Some("SCV_TEST_UNSET_BRAVE_KEY_VARIABLE".into());
1624 assert!(brave.validate().is_ok());
1625 assert!(brave.web_tools().unwrap().search.is_none());
1626
1627 for (mutate, message) in [
1628 (
1629 (|config: &mut Config| {
1630 config.web.auto_approve_domains = vec!["https://docs.rs/".into()]
1631 }) as fn(&mut Config),
1632 "web.auto_approve_domains",
1633 ),
1634 (|config| config.web.max_redirects = 11, "web.max_redirects"),
1635 (
1636 |config| config.web.fetch_max_bytes = 0,
1637 "web.fetch_max_bytes",
1638 ),
1639 (
1640 |config| config.web.fetch_timeout_seconds = config.tools.max_timeout_seconds + 1,
1641 "web.fetch_timeout_seconds",
1642 ),
1643 (
1644 |config| config.web.max_search_results = 21,
1645 "web.max_search_results",
1646 ),
1647 ] {
1648 let mut config = Config::default();
1649 mutate(&mut config);
1650 let error = config.validate().unwrap_err().to_string();
1651 assert!(error.contains(message), "{error}");
1652 }
1653 for valid in ["docs.rs", "*.example.com", "a-b.c1.dev"] {
1654 assert!(valid_domain_pattern(valid), "{valid}");
1655 }
1656 for invalid in ["", "*.", "docs.rs/path", "-a.com", "a..b", "*", "user@host"] {
1657 assert!(!valid_domain_pattern(invalid), "{invalid}");
1658 }
1659 }
1660
1661 #[test]
1662 fn projects_may_narrow_but_not_widen_web_access() {
1663 for key in [
1664 "auto_approve_domains = [\"attacker.test\"]",
1665 "allow_private_addresses = true",
1666 "searxng_url = \"http://attacker.test\"",
1667 "brave_url = \"http://attacker.test\"",
1668 "brave_api_key_env = \"OTHER\"",
1669 ] {
1670 let project: toml::Value = toml::from_str(&format!("[web]\n{key}\n")).unwrap();
1671 assert!(validate_project_keys(&project).is_err(), "{key}");
1672 }
1673 let allowed: toml::Value =
1674 toml::from_str("[web]\nenabled = false\nsearch = \"off\"\nmax_redirects = 1\n")
1675 .unwrap();
1676 assert!(validate_project_keys(&allowed).is_ok());
1677
1678 let mut user = Config::default();
1679 user.web.search = WebSearchMode::Provider;
1680 let mut narrower = user.clone();
1681 narrower.web.enabled = false;
1682 narrower.web.search = WebSearchMode::Off;
1683 narrower.web.fetch_max_bytes = 1024;
1684 narrower.web.max_redirects = 0;
1685 assert!(validate_project_not_weaker(&user, &narrower).is_ok());
1686 assert!(validate_project_not_weaker(&narrower, &user).is_err());
1687 let mut switched = user.clone();
1688 switched.web.search = WebSearchMode::Searxng;
1689 assert!(validate_project_not_weaker(&user, &switched).is_err());
1690 let mut larger = user.clone();
1691 larger.web.fetch_timeout_seconds += 1;
1692 assert!(validate_project_not_weaker(&user, &larger).is_err());
1693 }
1694
1695 #[test]
1696 fn cross_field_validation_accounts_for_json_escaping() {
1697 let mut config = Config::default();
1698 config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
1699 assert!(config.validate().is_err());
1700 }
1701
1702 #[test]
1703 fn adapter_selection_templates_survive_partial_overrides_and_validate() {
1704 let mut value: toml::Value =
1705 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1706 merge(
1707 &mut value,
1708 toml::from_str(
1709 r#"[agents.claude]
1710args = ["-p", "--permission-mode", "acceptEdits"]
1711"#,
1712 )
1713 .unwrap(),
1714 );
1715 let config: Config = value.try_into().unwrap();
1716 let claude = &config.agents.0["claude"];
1717 assert_eq!(claude.args.len(), 3);
1718 assert_eq!(claude.model_args, ["--model", "{model}"]);
1719 assert_eq!(claude.effort_args, ["--effort", "{effort}"]);
1720 assert_eq!(
1721 config.agents.0["pi"].effort_args,
1722 ["--thinking", "{effort}"]
1723 );
1724 assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1725
1726 let mut invalid = Config::default();
1727 invalid.agents.0.get_mut("claude").unwrap().effort_args = vec!["--effort".into()];
1728 assert!(
1729 invalid
1730 .validate()
1731 .unwrap_err()
1732 .to_string()
1733 .contains("agents.claude.effort_args must contain {effort}")
1734 );
1735 }
1736
1737 #[test]
1738 fn adapters_are_bound_to_the_instance_home() {
1739 let config = Config {
1740 instance_home: PathBuf::from("/tmp/scv-instance"),
1741 ..Config::default()
1742 };
1743 let adapters = config.adapters();
1744 let codex = &adapters["agent_codex"];
1745 assert!(codex.environment.contains(&(
1746 OsString::from("CODEX_HOME"),
1747 OsString::from("/tmp/scv-instance/adapters/codex")
1748 )));
1749 assert!(codex.environment.contains(&(
1750 OsString::from("SCV_HOME"),
1751 OsString::from("/tmp/scv-instance/adapters/codex")
1752 )));
1753 for (agent, variable, path) in [
1754 ("grok", "GROK_HOME", "/tmp/scv-instance/adapters/grok/.grok"),
1755 ("dsh", "DSH_HOME", "/tmp/scv-instance/adapters/dsh/.dsh"),
1756 (
1757 "pi",
1758 "PI_CODING_AGENT_DIR",
1759 "/tmp/scv-instance/adapters/pi/.pi/agent",
1760 ),
1761 ] {
1762 let adapter = &adapters[&format!("agent_{agent}")];
1763 assert!(
1764 adapter
1765 .environment
1766 .contains(&(OsString::from(variable), OsString::from(path))),
1767 "{agent}"
1768 );
1769 assert!(adapter.environment.contains(&(
1770 OsString::from("HOME"),
1771 OsString::from(format!("/tmp/scv-instance/adapters/{agent}"))
1772 )));
1773 }
1774 assert!(adapters["agent_grok"].environment.contains(&(
1775 OsString::from("GROK_DISABLE_AUTOUPDATER"),
1776 OsString::from("1")
1777 )));
1778 assert_eq!(adapters["agent_grok"].prompt_args, ["-p"]);
1779 assert!(adapters["agent_pi"].model_hint.contains("provider scv"));
1780 }
1781
1782 #[test]
1783 fn full_permissions_are_opt_in_per_agent_and_combine_with_args() {
1784 let defaults = Config::default().adapters();
1785 for adapter in defaults.values() {
1786 assert_eq!(adapter.full_permission_args, None);
1787 }
1788 let mut value: toml::Value =
1789 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1790 merge(
1791 &mut value,
1792 toml::from_str(
1793 "[agents.claude]\npermissions = \"full\"\n\n\
1794 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\npermissions = \"full\"\n\n\
1795 [agents.grok]\npermissions = \"full\"\n\n\
1796 [agents.dsh]\npermissions = \"full\"\n\n\
1797 [agents.pi]\npermissions = \"full\"\n",
1798 )
1799 .unwrap(),
1800 );
1801 let config: Config = value.try_into().unwrap();
1802 config.validate().unwrap();
1803 let adapters = config.adapters();
1804 let full = |agent: &str| {
1805 adapters[&format!("agent_{agent}")]
1806 .full_permission_args
1807 .clone()
1808 .unwrap()
1809 };
1810 assert_eq!(full("claude"), ["--permission-mode", "bypassPermissions"]);
1811 assert_eq!(
1812 full("codex"),
1813 [
1814 "--dangerously-bypass-approvals-and-sandbox",
1815 "-c",
1816 "web_search=\"live\""
1817 ]
1818 );
1819 assert_eq!(
1820 adapters["agent_codex"].args,
1821 ["exec", "--skip-git-repo-check"]
1822 );
1823 assert_eq!(full("grok"), ["--always-approve"]);
1824 assert!(full("dsh").is_empty());
1825 assert!(adapters["agent_dsh"].environment.contains(&(
1826 OsString::from("DSH_PERMISSION_MODE"),
1827 OsString::from("danger-full-access")
1828 )));
1829 assert!(
1830 !defaults["agent_dsh"]
1831 .environment
1832 .iter()
1833 .any(|(variable, _)| variable == "DSH_PERMISSION_MODE")
1834 );
1835 assert!(full("pi").is_empty());
1837
1838 let mut invalid: toml::Value =
1839 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1840 merge(
1841 &mut invalid,
1842 toml::from_str("[agents.claude]\npermissions = \"yolo\"\n").unwrap(),
1843 );
1844 assert!(invalid.try_into::<Config>().is_err());
1845 }
1846
1847 #[test]
1848 fn user_agent_overrides_merge_over_every_built_in_and_unknown_agents_fail() {
1849 let mut value: toml::Value =
1850 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1851 merge(
1852 &mut value,
1853 toml::from_str(
1854 "[agents.pi]
1855model_args = []
1856
1857[agents.grok]
1858args = [\"--always-approve\"]
1859",
1860 )
1861 .unwrap(),
1862 );
1863 let config: Config = value.clone().try_into().unwrap();
1864 assert!(config.agents.0["pi"].model_args.is_empty());
1865 assert_eq!(config.agents.0["pi"].args, ["-p"]);
1866 assert_eq!(config.agents.0["grok"].args, ["--always-approve"]);
1867 assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1868 assert_eq!(
1869 config.agents.0.keys().collect::<Vec<_>>(),
1870 ["claude", "codex", "dsh", "grok", "pi"]
1871 );
1872
1873 merge(
1874 &mut value,
1875 toml::from_str(
1876 "[agents.zcode]
1877command = \"zcode\"
1878",
1879 )
1880 .unwrap(),
1881 );
1882 let unknown: Config = value.try_into().unwrap();
1883 let error = unknown.validate().unwrap_err().to_string();
1884 assert!(error.contains("unknown agent [agents.zcode]"), "{error}");
1885 }
1886}