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 pub transport: AgentTransport,
424}
425
426#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
428#[serde(rename_all = "lowercase")]
429pub enum AgentTransport {
430 #[default]
432 Auto,
433 Acp,
435 Resume,
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
441#[serde(rename_all = "lowercase")]
442pub enum AgentPermissions {
443 #[default]
445 Default,
446 Full,
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
453#[serde(transparent)]
454pub struct AgentsConfig(pub BTreeMap<String, AdapterConfig>);
455
456impl Default for AgentsConfig {
457 fn default() -> Self {
458 let strings = |values: &[&str]| values.iter().map(|value| (*value).to_owned()).collect();
459 Self(
460 scv_tools::adapters::ADAPTERS
461 .iter()
462 .map(|adapter| {
463 (
464 adapter.name.to_owned(),
465 AdapterConfig {
466 command: adapter.command.into(),
467 args: strings(adapter.args),
468 permissions: AgentPermissions::Default,
469 prompt_args: strings(adapter.prompt_args),
470 model_args: strings(adapter.model_args),
471 effort_args: strings(adapter.effort_args),
472 transport: AgentTransport::Auto,
473 },
474 )
475 })
476 .collect(),
477 )
478 }
479}
480
481#[derive(Debug, Clone, Default)]
482pub struct ConfigOverrides {
483 pub provider: Option<String>,
484 pub model: Option<String>,
485 pub base_url: Option<String>,
486 pub approval_policy: Option<ApprovalPolicy>,
487 pub no_tools: bool,
488}
489
490impl Config {
491 pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
492 Self::load_layers(Some(workspace), overrides)
493 }
494
495 pub fn load_user(overrides: ConfigOverrides) -> Result<Self> {
498 Self::load_layers(None, overrides)
499 }
500
501 fn load_layers(
502 workspace: Option<&std::path::Path>,
503 overrides: ConfigOverrides,
504 ) -> Result<Self> {
505 let instance_home = user_home_path()
506 .ok_or_else(|| anyhow::anyhow!("cannot determine SCV instance home"))?;
507 std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
508 ensure_private_dir(&instance_home)?;
509 let mut value: toml::Value = toml::from_str(
510 &toml::to_string(&Self::default()).context("serialize default configuration")?,
511 )?;
512
513 if let Some(user_path) = user_config_path()
514 && user_path.is_file()
515 {
516 #[cfg(unix)]
517 {
518 use std::os::unix::fs::PermissionsExt;
519 if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
520 bail!("user configuration is readable by group or others; run chmod 600");
521 }
522 }
523 merge(&mut value, read_layer(&user_path)?);
524 }
525 let user_baseline: Self = value
526 .clone()
527 .try_into()
528 .context("parse user configuration")?;
529
530 if let Some(workspace) = workspace {
531 let project_path = workspace.join(".scv/config.toml");
532 let user_file = user_config_path().and_then(|path| std::fs::canonicalize(path).ok());
536 if project_path.is_file() {
537 let canonical_project = std::fs::canonicalize(&project_path)
538 .with_context(|| format!("resolve configuration {}", project_path.display()))?;
539 if user_file.as_ref() != Some(&canonical_project) {
540 if !canonical_project.starts_with(workspace) {
541 bail!("project configuration escaped workspace");
542 }
543 let project = read_layer(&canonical_project)?;
544 validate_project_keys(&project)?;
545 let mut candidate_value = value.clone();
546 merge(&mut candidate_value, project);
547 let candidate: Self = candidate_value
548 .clone()
549 .try_into()
550 .context("parse project configuration")?;
551 validate_project_not_weaker(&user_baseline, &candidate)?;
552 value = candidate_value;
553 }
554 }
555 }
556
557 if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
558 let path = PathBuf::from(explicit);
559 #[cfg(unix)]
560 {
561 use std::os::unix::fs::PermissionsExt;
562 if std::fs::metadata(&path)?.permissions().mode() & 0o077 != 0 {
563 bail!("explicit configuration is readable by group or others; run chmod 600");
564 }
565 }
566 merge(&mut value, read_layer(&path)?);
567 }
568 let mut config: Self = value.try_into().context("parse merged configuration")?;
569 if let Some(name) = overrides.provider.as_deref() {
570 config.provider_active = Some(name.to_owned());
571 }
572 let selected = config.active_provider()?;
573 config.provider = selected;
574 if let Ok(model) = std::env::var("SCV_MODEL") {
575 config.provider.model = model;
576 }
577 if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
578 config.provider.base_url = base_url;
579 }
580 if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
581 config.provider.api_key_env = Some(api_key_env);
582 }
583 if let Some(model) = overrides.model {
584 config.provider.model = model;
585 }
586 if let Some(base_url) = overrides.base_url {
587 config.provider.base_url = base_url;
588 }
589 if let Some(policy) = overrides.approval_policy {
590 config.tools.approval_policy = policy;
591 }
592 if config.skills.user_dir == std::path::Path::new("~/.scv/skills")
593 && let Some(home) = std::env::var_os("SCV_HOME")
594 {
595 config.skills.user_dir = PathBuf::from(home).join("skills");
596 }
597 config.skills.user_dir = expand_home(&config.skills.user_dir);
598 config.instance_home = instance_home;
599 config.validate()?;
600 Ok(config)
601 }
602
603 pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
604 CoreAgentConfig {
605 system_prompt,
606 max_steps: self.agent.max_steps,
607 history_limits: HistoryLimits {
608 max_bytes: self.session.max_history_bytes,
609 max_messages: self.session.max_messages,
610 note_max_chars: self.context.summary_max_chars,
611 },
612 }
613 }
614
615 pub fn tools(&self) -> ToolsConfig {
616 ToolsConfig {
617 command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
618 agent_timeout: Duration::from_secs(self.tools.agent_timeout_seconds),
619 max_timeout: Duration::from_secs(self.tools.max_timeout_seconds),
620 output_limit_bytes: self.tools.output_limit_bytes,
621 max_read_bytes: self.tools.max_read_bytes,
622 max_write_bytes: self.tools.max_write_bytes,
623 max_delegation_depth: self.agent.max_delegation_depth,
624 conversations: ConversationLimits {
625 max: self.agent.max_conversations,
626 idle: Duration::from_secs(self.agent.conversation_idle_seconds),
627 },
628 delegation: None,
629 }
630 }
631
632 pub fn web_tools(&self) -> Option<WebToolsConfig> {
635 if !self.web.enabled {
636 return None;
637 }
638 let search = match self.web.search {
639 WebSearchMode::Off | WebSearchMode::Provider => None,
640 WebSearchMode::Searxng => self
641 .web
642 .searxng_url
643 .clone()
644 .map(|url| SearchBackend::Searxng { url }),
645 WebSearchMode::Brave => {
646 let api_key = self
647 .web
648 .brave_api_key
649 .clone()
650 .or_else(|| {
651 self.web
652 .brave_api_key_env
653 .as_deref()
654 .and_then(|name| std::env::var(name).ok())
655 })
656 .filter(|key| !key.trim().is_empty());
657 if api_key.is_none() {
658 tracing::warn!(
659 "web.search is \"brave\" but no Brave API key is configured; web_search is unavailable"
660 );
661 }
662 api_key.map(|api_key| SearchBackend::Brave {
663 url: self.web.brave_url.clone(),
664 api_key,
665 })
666 }
667 };
668 Some(WebToolsConfig {
669 fetch_max_bytes: self.web.fetch_max_bytes,
670 fetch_timeout: Duration::from_secs(self.web.fetch_timeout_seconds),
671 max_redirects: self.web.max_redirects,
672 auto_approve_domains: self.web.auto_approve_domains.clone(),
673 allow_private_addresses: self.web.allow_private_addresses,
674 search,
675 max_search_results: self.web.max_search_results,
676 output_limit: self.tools.output_limit_bytes,
677 })
678 }
679
680 pub fn hosted_web_search(&self) -> bool {
682 self.web.enabled && self.web.search == WebSearchMode::Provider
683 }
684
685 pub fn provider_limits(&self) -> ProviderLimits {
686 ProviderLimits {
687 max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
688 max_response_bytes: self.provider_limits.max_response_bytes,
689 max_assistant_bytes: self.provider_limits.max_assistant_bytes,
690 max_tool_calls: self.provider_limits.max_tool_calls,
691 max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
692 max_retries: self.provider_limits.max_retries,
693 ..ProviderLimits::default()
694 }
695 }
696
697 pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
698 let user_home = dirs::home_dir();
699 self.agents
700 .0
701 .iter()
702 .filter_map(|(name, config)| {
703 let descriptor = scv_tools::adapters::adapter(name)?;
704 let adapter_home = self.instance_home.join("adapters").join(name);
705 let mut environment = vec![
706 (OsString::from("SCV_HOME"), adapter_home.clone().into()),
707 (OsString::from("HOME"), adapter_home.clone().into()),
708 (
709 OsString::from("XDG_CONFIG_HOME"),
710 adapter_home.join("config").into(),
711 ),
712 (
713 OsString::from("XDG_DATA_HOME"),
714 adapter_home.join("data").into(),
715 ),
716 (
717 OsString::from("XDG_STATE_HOME"),
718 adapter_home.join("state").into(),
719 ),
720 ];
721 for (variable, relative) in descriptor.home_environment {
722 let path = if relative.is_empty() {
723 adapter_home.clone()
724 } else {
725 adapter_home.join(relative)
726 };
727 environment.push((OsString::from(variable), path.into()));
728 }
729 let full = config.permissions == AgentPermissions::Full;
730 environment.extend(
731 descriptor
732 .fixed_environment
733 .iter()
734 .chain(
735 descriptor
736 .full_permission_environment
737 .iter()
738 .filter(|_| full),
739 )
740 .map(|(variable, value)| (OsString::from(variable), OsString::from(value))),
741 );
742 Some((
743 format!("agent_{name}"),
744 AgentAdapterConfig {
745 command: config.command.clone(),
746 args: config.args.clone(),
747 prompt_args: config.prompt_args.clone(),
748 full_permission_args: full.then(|| {
749 descriptor
750 .full_permission_args
751 .iter()
752 .map(|arg| (*arg).to_owned())
753 .collect()
754 }),
755 model_args: config.model_args.clone(),
756 effort_args: config.effort_args.clone(),
757 model_hint: descriptor.model_hint.into(),
758 environment,
759 search_dirs: user_home
760 .as_deref()
761 .map(|home| scv_tools::adapters::adapter_search_dirs(descriptor, home))
762 .unwrap_or_default(),
763 output: descriptor.output,
764 resume: descriptor.resume,
765 home: Some(adapter_home),
766 transport: descriptor.transport,
767 acp: descriptor
768 .acp
769 .filter(|_| match config.transport {
770 AgentTransport::Acp => true,
771 AgentTransport::Resume => false,
772 AgentTransport::Auto => config.command == descriptor.command,
775 })
776 .map(|launch| scv_tools::AcpAgentLaunch {
777 command: launch.command.to_owned(),
778 args: scv_tools::adapters::acp_args(&launch, full),
779 full_mode: launch.full_mode.filter(|_| full).map(str::to_owned),
780 required: config.transport == AgentTransport::Acp,
781 }),
782 },
783 ))
784 })
785 .collect()
786 }
787
788 pub fn prepare_adapter_homes(&self) -> Result<()> {
789 for name in self.agents.0.keys() {
790 let path = self.instance_home.join("adapters").join(name);
791 std::fs::create_dir_all(&path)
792 .with_context(|| format!("create isolated {name} adapter home"))?;
793 #[cfg(unix)]
794 {
795 use std::os::unix::fs::PermissionsExt;
796 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
797 .with_context(|| format!("secure isolated {name} adapter home"))?;
798 }
799 }
800 Ok(())
801 }
802
803 fn validate(&self) -> Result<()> {
804 if self.provider.kind != "openai-compatible" {
805 bail!("provider.kind must be openai-compatible in v0.1");
806 }
807 if self.provider.model.trim().is_empty()
808 || self.provider.base_url.trim().is_empty()
809 || self
810 .provider
811 .api_key
812 .as_deref()
813 .unwrap_or("")
814 .trim()
815 .is_empty()
816 && self
817 .provider
818 .api_key_env
819 .as_deref()
820 .unwrap_or("")
821 .trim()
822 .is_empty()
823 {
824 bail!(
825 "provider model and base_url must be non-empty; configure api_key or api_key_env"
826 );
827 }
828 for (agent, adapter) in &self.agents.0 {
829 if scv_tools::adapters::adapter(agent).is_none() {
830 let known: Vec<_> = scv_tools::adapters::ADAPTERS
831 .iter()
832 .map(|adapter| adapter.name)
833 .collect();
834 bail!(
835 "unknown agent [agents.{agent}]; known agents are {}",
836 known.join(", ")
837 );
838 }
839 let name = format!("agents.{agent}.command");
840 if adapter.command.trim().is_empty() {
841 bail!("{name} must be non-empty");
842 }
843 if adapter.transport == AgentTransport::Acp
844 && scv_tools::adapters::adapter(agent)
845 .is_some_and(|descriptor| descriptor.acp.is_none())
846 {
847 bail!(
848 "agents.{agent}.transport = \"acp\" but {agent} has no verified ACP server; \
849 use \"auto\" or \"resume\""
850 );
851 }
852 for (field, template, placeholder) in [
853 ("model_args", &adapter.model_args, "{model}"),
854 ("effort_args", &adapter.effort_args, "{effort}"),
855 ] {
856 if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
857 let adapter = name.trim_end_matches(".command");
858 bail!("{adapter}.{field} must contain {placeholder} or be empty");
859 }
860 }
861 let adapter_bytes = adapter.command.len()
862 + [
863 &adapter.args,
864 &adapter.prompt_args,
865 &adapter.model_args,
866 &adapter.effort_args,
867 ]
868 .into_iter()
869 .flatten()
870 .map(String::len)
871 .sum::<usize>();
872 if adapter_bytes > 16 * 1024 {
873 bail!("{name} and its fixed arguments exceed 16384 bytes");
874 }
875 }
876 let positives = [
877 (
878 "provider.timeout_seconds",
879 usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
880 ),
881 ("agent.max_steps", self.agent.max_steps),
882 ("agent.max_conversations", self.agent.max_conversations),
883 (
884 "agent.conversation_idle_seconds",
885 usize::try_from(self.agent.conversation_idle_seconds).unwrap_or(usize::MAX),
886 ),
887 ("session.max_history_bytes", self.session.max_history_bytes),
888 ("session.max_messages", self.session.max_messages),
889 ("context.max_tokens", self.context.max_tokens),
890 ("context.bytes_per_token", self.context.bytes_per_token),
891 ("context.summary_max_chars", self.context.summary_max_chars),
892 (
893 "tools.command_timeout_seconds",
894 usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
895 ),
896 (
897 "tools.agent_timeout_seconds",
898 usize::try_from(self.tools.agent_timeout_seconds).unwrap_or(usize::MAX),
899 ),
900 (
901 "tools.max_timeout_seconds",
902 usize::try_from(self.tools.max_timeout_seconds).unwrap_or(usize::MAX),
903 ),
904 ("tools.output_limit_bytes", self.tools.output_limit_bytes),
905 ("tools.max_read_bytes", self.tools.max_read_bytes),
906 ("tools.max_write_bytes", self.tools.max_write_bytes),
907 (
908 "protocol.max_client_frame_bytes",
909 self.protocol.max_client_frame_bytes,
910 ),
911 (
912 "protocol.max_server_frame_bytes",
913 self.protocol.max_server_frame_bytes,
914 ),
915 ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
916 ("tui.max_transcript_items", self.tui.max_transcript_items),
917 (
918 "tui.max_prompt_history_bytes",
919 self.tui.max_prompt_history_bytes,
920 ),
921 (
922 "tui.max_prompt_history_items",
923 self.tui.max_prompt_history_items,
924 ),
925 (
926 "provider_limits.max_sse_event_bytes",
927 self.provider_limits.max_sse_event_bytes,
928 ),
929 (
930 "provider_limits.max_response_bytes",
931 self.provider_limits.max_response_bytes,
932 ),
933 (
934 "provider_limits.max_assistant_bytes",
935 self.provider_limits.max_assistant_bytes,
936 ),
937 (
938 "provider_limits.max_tool_calls",
939 self.provider_limits.max_tool_calls,
940 ),
941 (
942 "provider_limits.max_tool_arguments_bytes",
943 self.provider_limits.max_tool_arguments_bytes,
944 ),
945 ("skills.max_skills", self.skills.max_skills),
946 ("skills.max_skill_bytes", self.skills.max_skill_bytes),
947 ("web.fetch_max_bytes", self.web.fetch_max_bytes),
948 (
949 "web.fetch_timeout_seconds",
950 usize::try_from(self.web.fetch_timeout_seconds).unwrap_or(usize::MAX),
951 ),
952 ("web.max_search_results", self.web.max_search_results),
953 ];
954 if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
955 bail!("{name} must be positive");
956 }
957 for (name, value) in [
958 (
959 "tools.command_timeout_seconds",
960 self.tools.command_timeout_seconds,
961 ),
962 (
963 "tools.agent_timeout_seconds",
964 self.tools.agent_timeout_seconds,
965 ),
966 ] {
967 if value > self.tools.max_timeout_seconds {
968 bail!("{name} exceeds tools.max_timeout_seconds");
969 }
970 }
971 if self.tools.max_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS {
972 bail!("tools.max_timeout_seconds must be at most {MAX_TOOL_TIMEOUT_SECONDS}");
973 }
974 if self
975 .context
976 .reserve_output_tokens
977 .saturating_add(self.context.safety_margin_tokens)
978 >= self.context.max_tokens
979 {
980 bail!("context reserve and safety margin consume max_tokens");
981 }
982 let worst_assistant_frame = self
983 .provider_limits
984 .max_assistant_bytes
985 .saturating_mul(6)
986 .saturating_add(64 * 1024);
987 if worst_assistant_frame > self.protocol.max_server_frame_bytes {
988 bail!(
989 "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
990 );
991 }
992 if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
993 bail!("tool argument limit exceeds provider response limit");
994 }
995 if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
996 bail!("provider SSE event limit exceeds provider response limit");
997 }
998 if self.provider_limits.max_retries > MAX_PROVIDER_RETRIES {
999 bail!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}");
1000 }
1001 if self.protocol.max_client_frame_bytes < 4096 {
1002 bail!("protocol.max_client_frame_bytes must be at least 4096");
1003 }
1004 if self.protocol.max_server_frame_bytes < 64 * 1024 {
1005 bail!("protocol.max_server_frame_bytes must be at least 65536");
1006 }
1007 let worst_tool_frame = self
1008 .tools
1009 .output_limit_bytes
1010 .max(self.tools.max_read_bytes)
1011 .saturating_mul(12)
1012 .saturating_add(64 * 1024);
1013 let worst_skill_frame = self
1014 .skills
1015 .max_skill_bytes
1016 .saturating_mul(6)
1017 .saturating_add(64 * 1024);
1018 let worst_arguments_frame = self
1019 .provider_limits
1020 .max_tool_arguments_bytes
1021 .saturating_mul(6)
1022 .saturating_add(64 * 1024);
1023 if worst_tool_frame
1024 .max(worst_skill_frame)
1025 .max(worst_arguments_frame)
1026 > self.protocol.max_server_frame_bytes
1027 {
1028 bail!(
1029 "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
1030 );
1031 }
1032 self.validate_web()?;
1033 if self.skills.project_dir.is_absolute()
1034 || self
1035 .skills
1036 .project_dir
1037 .components()
1038 .any(|component| matches!(component, std::path::Component::ParentDir))
1039 {
1040 bail!("skills.project_dir must be a contained relative path");
1041 }
1042 Ok(())
1043 }
1044}
1045
1046impl Config {
1047 fn validate_web(&self) -> Result<()> {
1048 let web = &self.web;
1049 if web.fetch_max_bytes > 64 * 1024 * 1024 {
1050 bail!("web.fetch_max_bytes must be at most 67108864");
1051 }
1052 if web.fetch_timeout_seconds > self.tools.max_timeout_seconds {
1053 bail!("web.fetch_timeout_seconds exceeds tools.max_timeout_seconds");
1054 }
1055 if web.max_redirects > 10 {
1056 bail!("web.max_redirects must be at most 10");
1057 }
1058 if web.max_search_results > 20 {
1059 bail!("web.max_search_results must be at most 20");
1060 }
1061 if web.auto_approve_domains.len() > 256 {
1062 bail!("web.auto_approve_domains may list at most 256 hosts");
1063 }
1064 if let Some(entry) = web
1065 .auto_approve_domains
1066 .iter()
1067 .find(|entry| !valid_domain_pattern(entry))
1068 {
1069 bail!(
1070 "web.auto_approve_domains entry {entry:?} must be a host name such as docs.rs or *.example.com"
1071 );
1072 }
1073 let http_url = |value: &str| value.starts_with("https://") || value.starts_with("http://");
1074 if !http_url(&web.brave_url) {
1075 bail!("web.brave_url must be an http or https URL");
1076 }
1077 match web.search {
1078 WebSearchMode::Searxng if !web.searxng_url.as_deref().is_some_and(http_url) => {
1079 bail!("web.search = \"searxng\" requires web.searxng_url (an http or https URL)");
1080 }
1081 WebSearchMode::Brave
1082 if web.brave_api_key.as_deref().unwrap_or("").trim().is_empty()
1083 && web
1084 .brave_api_key_env
1085 .as_deref()
1086 .unwrap_or("")
1087 .trim()
1088 .is_empty() =>
1089 {
1090 bail!("web.search = \"brave\" requires web.brave_api_key or web.brave_api_key_env");
1091 }
1092 _ => {}
1093 }
1094 Ok(())
1095 }
1096}
1097
1098fn valid_domain_pattern(entry: &str) -> bool {
1100 let host = entry.strip_prefix("*.").unwrap_or(entry);
1101 !host.is_empty()
1102 && host.len() <= 253
1103 && host.split('.').all(|label| {
1104 !label.is_empty()
1105 && label.len() <= 63
1106 && !label.starts_with('-')
1107 && !label.ends_with('-')
1108 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1109 })
1110}
1111
1112fn user_config_path() -> Option<PathBuf> {
1113 user_home_path().map(|path| path.join("config.toml"))
1114}
1115
1116pub fn user_home_path() -> Option<PathBuf> {
1117 let path = std::env::var_os("SCV_HOME")
1118 .map(PathBuf::from)
1119 .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))?;
1120 if path.exists() {
1121 Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
1122 } else if path.is_absolute() {
1123 Some(path)
1124 } else {
1125 std::env::current_dir().ok().map(|cwd| cwd.join(path))
1126 }
1127}
1128
1129fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
1130 #[cfg(unix)]
1131 {
1132 use std::os::unix::fs::PermissionsExt;
1133 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
1134 .with_context(|| format!("secure directory {}", path.display()))?;
1135 }
1136 Ok(())
1137}
1138
1139fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
1140 let size = std::fs::metadata(path)
1141 .with_context(|| format!("stat configuration {}", path.display()))?
1142 .len();
1143 if size > MAX_CONFIG_BYTES {
1144 bail!("configuration {} exceeds 1 MiB", path.display());
1145 }
1146 let content = std::fs::read_to_string(path)
1147 .with_context(|| format!("read configuration {}", path.display()))?;
1148 toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
1149}
1150
1151fn merge(base: &mut toml::Value, overlay: toml::Value) {
1152 match (base, overlay) {
1153 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
1154 for (key, value) in overlay {
1155 match base.get_mut(&key) {
1156 Some(existing) => merge(existing, value),
1157 None => {
1158 base.insert(key, value);
1159 }
1160 }
1161 }
1162 }
1163 (base, overlay) => *base = overlay,
1164 }
1165}
1166
1167fn validate_project_keys(value: &toml::Value) -> Result<()> {
1168 let Some(table) = value.as_table() else {
1169 bail!("project configuration must be a TOML table");
1170 };
1171 for forbidden in [
1172 "provider",
1173 "providers",
1174 "provider_active",
1175 "agents",
1176 "update",
1177 ] {
1178 if table.contains_key(forbidden) {
1179 bail!("project configuration cannot set [{forbidden}]");
1180 }
1181 }
1182 if table
1183 .get("skills")
1184 .and_then(toml::Value::as_table)
1185 .is_some_and(|skills| skills.contains_key("user_dir"))
1186 {
1187 bail!("project configuration cannot set skills.user_dir");
1188 }
1189 if table
1190 .get("agent")
1191 .and_then(toml::Value::as_table)
1192 .is_some_and(|agent| agent.contains_key("system_prompt"))
1193 {
1194 bail!("project configuration cannot replace agent.system_prompt");
1195 }
1196 if let Some(web) = table.get("web").and_then(toml::Value::as_table) {
1197 for key in [
1198 "auto_approve_domains",
1199 "allow_private_addresses",
1200 "searxng_url",
1201 "brave_url",
1202 "brave_api_key",
1203 "brave_api_key_env",
1204 ] {
1205 if web.contains_key(key) {
1206 bail!("project configuration cannot set web.{key}");
1207 }
1208 }
1209 }
1210 Ok(())
1211}
1212
1213fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
1214 macro_rules! no_larger {
1215 ($field:expr, $name:literal) => {
1216 if $field.1 > $field.0 {
1217 bail!(concat!("project configuration cannot raise ", $name));
1218 }
1219 };
1220 }
1221 no_larger!(
1222 (user.agent.max_steps, project.agent.max_steps),
1223 "agent.max_steps"
1224 );
1225 no_larger!(
1226 (
1227 user.agent.max_delegation_depth,
1228 project.agent.max_delegation_depth
1229 ),
1230 "agent.max_delegation_depth"
1231 );
1232 no_larger!(
1233 (
1234 user.agent.max_conversations,
1235 project.agent.max_conversations
1236 ),
1237 "agent.max_conversations"
1238 );
1239 no_larger!(
1240 (
1241 user.agent.conversation_idle_seconds,
1242 project.agent.conversation_idle_seconds
1243 ),
1244 "agent.conversation_idle_seconds"
1245 );
1246 no_larger!(
1247 (
1248 user.session.max_history_bytes,
1249 project.session.max_history_bytes
1250 ),
1251 "session.max_history_bytes"
1252 );
1253 no_larger!(
1254 (user.session.max_messages, project.session.max_messages),
1255 "session.max_messages"
1256 );
1257 no_larger!(
1258 (user.context.max_tokens, project.context.max_tokens),
1259 "context.max_tokens"
1260 );
1261 no_larger!(
1262 (
1263 user.context.summary_max_chars,
1264 project.context.summary_max_chars
1265 ),
1266 "context.summary_max_chars"
1267 );
1268 no_larger!(
1269 (
1270 user.tools.command_timeout_seconds,
1271 project.tools.command_timeout_seconds
1272 ),
1273 "tools.command_timeout_seconds"
1274 );
1275 no_larger!(
1276 (
1277 user.tools.agent_timeout_seconds,
1278 project.tools.agent_timeout_seconds
1279 ),
1280 "tools.agent_timeout_seconds"
1281 );
1282 no_larger!(
1283 (
1284 user.tools.max_timeout_seconds,
1285 project.tools.max_timeout_seconds
1286 ),
1287 "tools.max_timeout_seconds"
1288 );
1289 no_larger!(
1290 (
1291 user.tools.output_limit_bytes,
1292 project.tools.output_limit_bytes
1293 ),
1294 "tools.output_limit_bytes"
1295 );
1296 no_larger!(
1297 (user.tools.max_read_bytes, project.tools.max_read_bytes),
1298 "tools.max_read_bytes"
1299 );
1300 no_larger!(
1301 (user.tools.max_write_bytes, project.tools.max_write_bytes),
1302 "tools.max_write_bytes"
1303 );
1304 no_larger!(
1305 (
1306 user.protocol.max_client_frame_bytes,
1307 project.protocol.max_client_frame_bytes
1308 ),
1309 "protocol.max_client_frame_bytes"
1310 );
1311 no_larger!(
1312 (
1313 user.protocol.max_server_frame_bytes,
1314 project.protocol.max_server_frame_bytes
1315 ),
1316 "protocol.max_server_frame_bytes"
1317 );
1318 no_larger!(
1319 (
1320 user.provider_limits.max_response_bytes,
1321 project.provider_limits.max_response_bytes
1322 ),
1323 "provider_limits.max_response_bytes"
1324 );
1325 no_larger!(
1326 (
1327 user.provider_limits.max_sse_event_bytes,
1328 project.provider_limits.max_sse_event_bytes
1329 ),
1330 "provider_limits.max_sse_event_bytes"
1331 );
1332 no_larger!(
1333 (
1334 user.provider_limits.max_assistant_bytes,
1335 project.provider_limits.max_assistant_bytes
1336 ),
1337 "provider_limits.max_assistant_bytes"
1338 );
1339 no_larger!(
1340 (
1341 user.provider_limits.max_tool_calls,
1342 project.provider_limits.max_tool_calls
1343 ),
1344 "provider_limits.max_tool_calls"
1345 );
1346 no_larger!(
1347 (
1348 user.provider_limits.max_tool_arguments_bytes,
1349 project.provider_limits.max_tool_arguments_bytes
1350 ),
1351 "provider_limits.max_tool_arguments_bytes"
1352 );
1353 no_larger!(
1354 (
1355 user.provider_limits.max_retries,
1356 project.provider_limits.max_retries
1357 ),
1358 "provider_limits.max_retries"
1359 );
1360 no_larger!(
1361 (
1362 user.tui.max_transcript_bytes,
1363 project.tui.max_transcript_bytes
1364 ),
1365 "tui.max_transcript_bytes"
1366 );
1367 no_larger!(
1368 (
1369 user.tui.max_transcript_items,
1370 project.tui.max_transcript_items
1371 ),
1372 "tui.max_transcript_items"
1373 );
1374 no_larger!(
1375 (
1376 user.tui.max_prompt_history_bytes,
1377 project.tui.max_prompt_history_bytes
1378 ),
1379 "tui.max_prompt_history_bytes"
1380 );
1381 no_larger!(
1382 (
1383 user.tui.max_prompt_history_items,
1384 project.tui.max_prompt_history_items
1385 ),
1386 "tui.max_prompt_history_items"
1387 );
1388 no_larger!(
1389 (user.skills.max_skills, project.skills.max_skills),
1390 "skills.max_skills"
1391 );
1392 no_larger!(
1393 (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
1394 "skills.max_skill_bytes"
1395 );
1396 if project.context.reserve_output_tokens < user.context.reserve_output_tokens
1397 || project.context.safety_margin_tokens < user.context.safety_margin_tokens
1398 {
1399 bail!("project configuration cannot lower context reserves");
1400 }
1401 if project.context.bytes_per_token > user.context.bytes_per_token {
1402 bail!("project configuration cannot raise context.bytes_per_token");
1403 }
1404 if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
1405 bail!("project configuration cannot weaken tools.approval_policy");
1406 }
1407 if project.skills.scan_projects && !user.skills.scan_projects {
1408 bail!("project configuration cannot enable skills.scan_projects");
1409 }
1410 if project.web.enabled && !user.web.enabled {
1411 bail!("project configuration cannot enable web");
1412 }
1413 if project.web.search != user.web.search && project.web.search != WebSearchMode::Off {
1414 bail!("project configuration can only turn web.search off");
1415 }
1416 no_larger!(
1417 (user.web.fetch_max_bytes, project.web.fetch_max_bytes),
1418 "web.fetch_max_bytes"
1419 );
1420 no_larger!(
1421 (
1422 user.web.fetch_timeout_seconds,
1423 project.web.fetch_timeout_seconds
1424 ),
1425 "web.fetch_timeout_seconds"
1426 );
1427 no_larger!(
1428 (user.web.max_redirects, project.web.max_redirects),
1429 "web.max_redirects"
1430 );
1431 no_larger!(
1432 (user.web.max_search_results, project.web.max_search_results),
1433 "web.max_search_results"
1434 );
1435 Ok(())
1436}
1437
1438fn expand_home(path: &std::path::Path) -> PathBuf {
1439 let value = path.to_string_lossy();
1440 if value == "~" {
1441 return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
1442 }
1443 if let Some(rest) = value.strip_prefix("~/")
1444 && let Some(home) = dirs::home_dir()
1445 {
1446 return home.join(rest);
1447 }
1448 path.to_path_buf()
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::*;
1454
1455 #[test]
1456 fn project_cannot_redirect_provider_or_agent() {
1457 let provider: toml::Value = toml::from_str(
1458 r#"[provider]
1459base_url = "https://attacker.invalid"
1460"#,
1461 )
1462 .unwrap();
1463 assert!(validate_project_keys(&provider).is_err());
1464
1465 let agent: toml::Value = toml::from_str(
1466 r#"[agents.codex]
1467command = "/tmp/fake"
1468"#,
1469 )
1470 .unwrap();
1471 assert!(validate_project_keys(&agent).is_err());
1472 }
1473
1474 #[test]
1475 fn project_may_tighten_but_not_weaken_limits() {
1476 let user = Config::default();
1477 let mut tighter = user.clone();
1478 tighter.tools.output_limit_bytes /= 2;
1479 tighter.tools.approval_policy = ApprovalPolicy::Always;
1480 assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1481
1482 let mut weaker = user.clone();
1483 weaker.tools.output_limit_bytes *= 2;
1484 assert!(validate_project_not_weaker(&user, &weaker).is_err());
1485 }
1486
1487 #[test]
1488 fn timeouts_default_below_a_ceiling_that_projects_may_only_lower() {
1489 let user = Config::default();
1490 assert_eq!(
1491 (
1492 user.tools.command_timeout_seconds,
1493 user.tools.agent_timeout_seconds,
1494 user.tools.max_timeout_seconds
1495 ),
1496 (600, 3600, 14400)
1497 );
1498 assert_eq!(user.agent.max_steps, 128);
1499 assert_eq!(user.provider.timeout_seconds, 600);
1500 let tools = user.tools();
1501 assert_eq!(tools.command_timeout, Duration::from_secs(600));
1502 assert_eq!(tools.agent_timeout, Duration::from_secs(3600));
1503 assert_eq!(tools.max_timeout, Duration::from_secs(14400));
1504 assert_eq!(
1506 scv_clawbot::owner_turn_timeout(tools.max_timeout),
1507 Duration::from_secs(4 * 3600 + 5 * 60)
1508 );
1509
1510 for (field, name) in [
1511 (0, "tools.command_timeout_seconds"),
1512 (1, "tools.agent_timeout_seconds"),
1513 ] {
1514 let mut config = Config::default();
1515 let value = if field == 0 {
1516 &mut config.tools.command_timeout_seconds
1517 } else {
1518 &mut config.tools.agent_timeout_seconds
1519 };
1520 *value = config.tools.max_timeout_seconds + 1;
1521 assert_eq!(
1522 config.validate().unwrap_err().to_string(),
1523 format!("{name} exceeds tools.max_timeout_seconds")
1524 );
1525 }
1526 let mut unbounded = Config::default();
1527 unbounded.tools.max_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS + 1;
1528 assert!(unbounded.validate().is_err());
1529 let mut zero = Config::default();
1530 zero.tools.agent_timeout_seconds = 0;
1531 assert!(zero.validate().is_err());
1532
1533 let mut lower = user.clone();
1534 lower.tools.max_timeout_seconds = 900;
1535 lower.tools.agent_timeout_seconds = 300;
1536 assert!(validate_project_not_weaker(&user, &lower).is_ok());
1537 for raise in [
1538 |config: &mut Config| config.tools.max_timeout_seconds += 1,
1539 |config: &mut Config| config.tools.agent_timeout_seconds += 1,
1540 ] {
1541 let mut higher = user.clone();
1542 raise(&mut higher);
1543 assert!(validate_project_not_weaker(&user, &higher).is_err());
1544 }
1545 }
1546
1547 #[test]
1548 fn conversation_limits_are_positive_and_projects_may_only_lower_them() {
1549 let user = Config::default();
1550 assert_eq!(
1551 (
1552 user.agent.max_conversations,
1553 user.agent.conversation_idle_seconds
1554 ),
1555 (8, 86400)
1556 );
1557 let limits = user.tools().conversations;
1558 assert_eq!((limits.max, limits.idle), (8, Duration::from_secs(86400)));
1559 for zero in [
1560 |config: &mut Config| config.agent.max_conversations = 0,
1561 |config: &mut Config| config.agent.conversation_idle_seconds = 0,
1562 ] {
1563 let mut config = Config::default();
1564 zero(&mut config);
1565 assert!(config.validate().is_err());
1566 }
1567 let mut lower = user.clone();
1568 lower.agent.max_conversations = 2;
1569 lower.agent.conversation_idle_seconds = 600;
1570 assert!(validate_project_not_weaker(&user, &lower).is_ok());
1571 for raise in [
1572 |config: &mut Config| config.agent.max_conversations += 1,
1573 |config: &mut Config| config.agent.conversation_idle_seconds += 1,
1574 ] {
1575 let mut higher = user.clone();
1576 raise(&mut higher);
1577 assert!(validate_project_not_weaker(&user, &higher).is_err());
1578 }
1579 }
1580
1581 #[test]
1582 fn provider_retries_are_bounded_and_projects_may_only_lower_them() {
1583 let user = Config::default();
1584 assert_eq!(user.provider_limits.max_retries, 2);
1585 assert_eq!(user.provider_limits().max_retries, 2);
1586 let mut none = user.clone();
1587 none.provider_limits.max_retries = 0;
1588 assert!(none.validate().is_ok());
1589 assert!(validate_project_not_weaker(&user, &none).is_ok());
1590 assert!(validate_project_not_weaker(&none, &user).is_err());
1591 let mut excessive = user.clone();
1592 excessive.provider_limits.max_retries = MAX_PROVIDER_RETRIES + 1;
1593 assert_eq!(
1594 excessive.validate().unwrap_err().to_string(),
1595 format!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}")
1596 );
1597 }
1598
1599 #[test]
1600 fn projects_may_disable_but_not_enable_project_skill_scanning() {
1601 let user = Config::default();
1602 let mut disabled = user.clone();
1603 disabled.skills.scan_projects = false;
1604 assert!(validate_project_not_weaker(&user, &disabled).is_ok());
1605 assert!(validate_project_not_weaker(&disabled, &user).is_err());
1606 }
1607
1608 #[test]
1609 fn web_defaults_offer_fetch_without_search_and_validate_their_settings() {
1610 let config = Config::default();
1611 assert!(config.web.enabled);
1612 assert_eq!(config.web.search, WebSearchMode::Off);
1613 assert!(!config.hosted_web_search());
1614 let tools = config.web_tools().unwrap();
1615 assert!(tools.search.is_none());
1616 assert!(!tools.allow_private_addresses);
1617 assert_eq!(tools.fetch_max_bytes, 2 * 1024 * 1024);
1618 assert_eq!(tools.output_limit, config.tools.output_limit_bytes);
1619 assert!(tools.auto_approve_domains.contains(&"docs.rs".to_owned()));
1620
1621 let mut disabled = Config::default();
1622 disabled.web.enabled = false;
1623 disabled.web.search = WebSearchMode::Provider;
1624 assert!(disabled.web_tools().is_none());
1625 assert!(!disabled.hosted_web_search());
1626
1627 let mut provider = Config::default();
1628 provider.web.search = WebSearchMode::Provider;
1629 assert!(provider.hosted_web_search());
1630 assert!(provider.web_tools().unwrap().search.is_none());
1631
1632 let mut searxng = Config::default();
1633 searxng.web.search = WebSearchMode::Searxng;
1634 assert!(
1635 searxng
1636 .validate()
1637 .unwrap_err()
1638 .to_string()
1639 .contains("web.searxng_url")
1640 );
1641 searxng.web.searxng_url = Some("https://searx.example".into());
1642 assert!(searxng.validate().is_ok());
1643 assert!(matches!(
1644 searxng.web_tools().unwrap().search,
1645 Some(SearchBackend::Searxng { .. })
1646 ));
1647
1648 let mut brave = Config::default();
1649 brave.web.search = WebSearchMode::Brave;
1650 brave.web.brave_api_key_env = None;
1651 assert!(
1652 brave
1653 .validate()
1654 .unwrap_err()
1655 .to_string()
1656 .contains("brave_api_key")
1657 );
1658 brave.web.brave_api_key = Some("inline-test-key".into());
1659 assert!(matches!(
1660 brave.web_tools().unwrap().search,
1661 Some(SearchBackend::Brave { ref api_key, .. }) if api_key == "inline-test-key"
1662 ));
1663 brave.web.brave_api_key = None;
1664 brave.web.brave_api_key_env = Some("SCV_TEST_UNSET_BRAVE_KEY_VARIABLE".into());
1665 assert!(brave.validate().is_ok());
1666 assert!(brave.web_tools().unwrap().search.is_none());
1667
1668 for (mutate, message) in [
1669 (
1670 (|config: &mut Config| {
1671 config.web.auto_approve_domains = vec!["https://docs.rs/".into()]
1672 }) as fn(&mut Config),
1673 "web.auto_approve_domains",
1674 ),
1675 (|config| config.web.max_redirects = 11, "web.max_redirects"),
1676 (
1677 |config| config.web.fetch_max_bytes = 0,
1678 "web.fetch_max_bytes",
1679 ),
1680 (
1681 |config| config.web.fetch_timeout_seconds = config.tools.max_timeout_seconds + 1,
1682 "web.fetch_timeout_seconds",
1683 ),
1684 (
1685 |config| config.web.max_search_results = 21,
1686 "web.max_search_results",
1687 ),
1688 ] {
1689 let mut config = Config::default();
1690 mutate(&mut config);
1691 let error = config.validate().unwrap_err().to_string();
1692 assert!(error.contains(message), "{error}");
1693 }
1694 for valid in ["docs.rs", "*.example.com", "a-b.c1.dev"] {
1695 assert!(valid_domain_pattern(valid), "{valid}");
1696 }
1697 for invalid in ["", "*.", "docs.rs/path", "-a.com", "a..b", "*", "user@host"] {
1698 assert!(!valid_domain_pattern(invalid), "{invalid}");
1699 }
1700 }
1701
1702 #[test]
1703 fn projects_may_narrow_but_not_widen_web_access() {
1704 for key in [
1705 "auto_approve_domains = [\"attacker.test\"]",
1706 "allow_private_addresses = true",
1707 "searxng_url = \"http://attacker.test\"",
1708 "brave_url = \"http://attacker.test\"",
1709 "brave_api_key_env = \"OTHER\"",
1710 ] {
1711 let project: toml::Value = toml::from_str(&format!("[web]\n{key}\n")).unwrap();
1712 assert!(validate_project_keys(&project).is_err(), "{key}");
1713 }
1714 let allowed: toml::Value =
1715 toml::from_str("[web]\nenabled = false\nsearch = \"off\"\nmax_redirects = 1\n")
1716 .unwrap();
1717 assert!(validate_project_keys(&allowed).is_ok());
1718
1719 let mut user = Config::default();
1720 user.web.search = WebSearchMode::Provider;
1721 let mut narrower = user.clone();
1722 narrower.web.enabled = false;
1723 narrower.web.search = WebSearchMode::Off;
1724 narrower.web.fetch_max_bytes = 1024;
1725 narrower.web.max_redirects = 0;
1726 assert!(validate_project_not_weaker(&user, &narrower).is_ok());
1727 assert!(validate_project_not_weaker(&narrower, &user).is_err());
1728 let mut switched = user.clone();
1729 switched.web.search = WebSearchMode::Searxng;
1730 assert!(validate_project_not_weaker(&user, &switched).is_err());
1731 let mut larger = user.clone();
1732 larger.web.fetch_timeout_seconds += 1;
1733 assert!(validate_project_not_weaker(&user, &larger).is_err());
1734 }
1735
1736 #[test]
1737 fn cross_field_validation_accounts_for_json_escaping() {
1738 let mut config = Config::default();
1739 config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
1740 assert!(config.validate().is_err());
1741 }
1742
1743 #[test]
1744 fn adapter_selection_templates_survive_partial_overrides_and_validate() {
1745 let mut value: toml::Value =
1746 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1747 merge(
1748 &mut value,
1749 toml::from_str(
1750 r#"[agents.claude]
1751args = ["-p", "--permission-mode", "acceptEdits"]
1752"#,
1753 )
1754 .unwrap(),
1755 );
1756 let config: Config = value.try_into().unwrap();
1757 let claude = &config.agents.0["claude"];
1758 assert_eq!(claude.args.len(), 3);
1759 assert_eq!(claude.model_args, ["--model", "{model}"]);
1760 assert_eq!(claude.effort_args, ["--effort", "{effort}"]);
1761 assert_eq!(
1762 config.agents.0["pi"].effort_args,
1763 ["--thinking", "{effort}"]
1764 );
1765 assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1766
1767 let mut invalid = Config::default();
1768 invalid.agents.0.get_mut("claude").unwrap().effort_args = vec!["--effort".into()];
1769 assert!(
1770 invalid
1771 .validate()
1772 .unwrap_err()
1773 .to_string()
1774 .contains("agents.claude.effort_args must contain {effort}")
1775 );
1776 }
1777
1778 #[test]
1779 fn adapters_are_bound_to_the_instance_home() {
1780 let config = Config {
1781 instance_home: PathBuf::from("/tmp/scv-instance"),
1782 ..Config::default()
1783 };
1784 let adapters = config.adapters();
1785 let codex = &adapters["agent_codex"];
1786 assert!(codex.environment.contains(&(
1787 OsString::from("CODEX_HOME"),
1788 OsString::from("/tmp/scv-instance/adapters/codex")
1789 )));
1790 assert!(codex.environment.contains(&(
1791 OsString::from("SCV_HOME"),
1792 OsString::from("/tmp/scv-instance/adapters/codex")
1793 )));
1794 for (agent, variable, path) in [
1795 ("grok", "GROK_HOME", "/tmp/scv-instance/adapters/grok/.grok"),
1796 ("dsh", "DSH_HOME", "/tmp/scv-instance/adapters/dsh/.dsh"),
1797 (
1798 "pi",
1799 "PI_CODING_AGENT_DIR",
1800 "/tmp/scv-instance/adapters/pi/.pi/agent",
1801 ),
1802 ] {
1803 let adapter = &adapters[&format!("agent_{agent}")];
1804 assert!(
1805 adapter
1806 .environment
1807 .contains(&(OsString::from(variable), OsString::from(path))),
1808 "{agent}"
1809 );
1810 assert!(adapter.environment.contains(&(
1811 OsString::from("HOME"),
1812 OsString::from(format!("/tmp/scv-instance/adapters/{agent}"))
1813 )));
1814 }
1815 assert!(adapters["agent_grok"].environment.contains(&(
1816 OsString::from("GROK_DISABLE_AUTOUPDATER"),
1817 OsString::from("1")
1818 )));
1819 assert_eq!(adapters["agent_grok"].prompt_args, ["-p"]);
1820 assert!(adapters["agent_pi"].model_hint.contains("provider scv"));
1821 }
1822
1823 #[test]
1824 fn agents_prefer_their_acp_server_unless_configured_otherwise() {
1825 let defaults = Config::default().adapters();
1826 let launch = |adapters: &HashMap<String, scv_tools::AgentAdapterConfig>, agent: &str| {
1827 adapters[&format!("agent_{agent}")].acp.clone()
1828 };
1829 for agent in ["claude", "codex", "grok", "dsh"] {
1830 let acp = launch(&defaults, agent).unwrap();
1831 assert!(!acp.required, "{agent}: auto falls back to resume");
1832 assert_eq!(acp.full_mode, None, "{agent}: no full mode by default");
1833 }
1834 assert_eq!(
1835 launch(&defaults, "claude").unwrap().command,
1836 "claude-agent-acp"
1837 );
1838 assert_eq!(launch(&defaults, "codex").unwrap().command, "codex-acp");
1839 assert_eq!(launch(&defaults, "grok").unwrap().args, ["agent", "stdio"]);
1840 assert_eq!(launch(&defaults, "dsh").unwrap().args, ["--profile", "acp"]);
1841 assert!(launch(&defaults, "pi").is_none());
1842 assert!(launch(&defaults, "scv").is_none());
1843
1844 let mut value: toml::Value =
1845 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1846 merge(
1847 &mut value,
1848 toml::from_str(
1849 "[agents.claude]\npermissions = \"full\"\ntransport = \"acp\"\n\n\
1850 [agents.codex]\ntransport = \"resume\"\n\n\
1851 [agents.grok]\npermissions = \"full\"\n",
1852 )
1853 .unwrap(),
1854 );
1855 let config: Config = value.try_into().unwrap();
1856 config.validate().unwrap();
1857 let adapters = config.adapters();
1858 let claude = launch(&adapters, "claude").unwrap();
1859 assert!(claude.required);
1860 assert_eq!(claude.full_mode.as_deref(), Some("bypassPermissions"));
1861 assert!(launch(&adapters, "codex").is_none(), "resume turns ACP off");
1862
1863 let mut custom: toml::Value =
1864 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1865 merge(
1866 &mut custom,
1867 toml::from_str(
1868 "[agents.claude]\ncommand = \"/opt/claude-wrapper\"\n\n\
1869 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\n",
1870 )
1871 .unwrap(),
1872 );
1873 let custom: Config = custom.try_into().unwrap();
1874 let custom = custom.adapters();
1875 assert!(
1876 launch(&custom, "claude").is_none(),
1877 "a custom command keeps one process per turn"
1878 );
1879 assert!(launch(&custom, "codex").is_some(), "custom args keep ACP");
1880 assert_eq!(
1881 launch(&adapters, "grok").unwrap().args,
1882 ["agent", "--always-approve", "stdio"]
1883 );
1884
1885 let mut pi: toml::Value =
1886 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1887 merge(
1888 &mut pi,
1889 toml::from_str("[agents.pi]\ntransport = \"acp\"\n").unwrap(),
1890 );
1891 let pi: Config = pi.try_into().unwrap();
1892 let error = pi.validate().unwrap_err().to_string();
1893 assert!(error.contains("no verified ACP server"), "{error}");
1894
1895 let mut invalid: toml::Value =
1896 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1897 merge(
1898 &mut invalid,
1899 toml::from_str("[agents.claude]\ntransport = \"rpc\"\n").unwrap(),
1900 );
1901 assert!(invalid.try_into::<Config>().is_err());
1902 }
1903
1904 #[test]
1905 fn full_permissions_are_opt_in_per_agent_and_combine_with_args() {
1906 let defaults = Config::default().adapters();
1907 for adapter in defaults.values() {
1908 assert_eq!(adapter.full_permission_args, None);
1909 }
1910 let mut value: toml::Value =
1911 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1912 merge(
1913 &mut value,
1914 toml::from_str(
1915 "[agents.claude]\npermissions = \"full\"\n\n\
1916 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\npermissions = \"full\"\n\n\
1917 [agents.grok]\npermissions = \"full\"\n\n\
1918 [agents.dsh]\npermissions = \"full\"\n\n\
1919 [agents.pi]\npermissions = \"full\"\n",
1920 )
1921 .unwrap(),
1922 );
1923 let config: Config = value.try_into().unwrap();
1924 config.validate().unwrap();
1925 let adapters = config.adapters();
1926 let full = |agent: &str| {
1927 adapters[&format!("agent_{agent}")]
1928 .full_permission_args
1929 .clone()
1930 .unwrap()
1931 };
1932 assert_eq!(full("claude"), ["--permission-mode", "bypassPermissions"]);
1933 assert_eq!(
1934 full("codex"),
1935 [
1936 "--dangerously-bypass-approvals-and-sandbox",
1937 "-c",
1938 "web_search=\"live\""
1939 ]
1940 );
1941 assert_eq!(
1942 adapters["agent_codex"].args,
1943 ["exec", "--skip-git-repo-check"]
1944 );
1945 assert_eq!(full("grok"), ["--always-approve"]);
1946 assert!(full("dsh").is_empty());
1947 assert!(adapters["agent_dsh"].environment.contains(&(
1948 OsString::from("DSH_PERMISSION_MODE"),
1949 OsString::from("danger-full-access")
1950 )));
1951 assert!(
1952 !defaults["agent_dsh"]
1953 .environment
1954 .iter()
1955 .any(|(variable, _)| variable == "DSH_PERMISSION_MODE")
1956 );
1957 assert!(full("pi").is_empty());
1959
1960 let mut invalid: toml::Value =
1961 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1962 merge(
1963 &mut invalid,
1964 toml::from_str("[agents.claude]\npermissions = \"yolo\"\n").unwrap(),
1965 );
1966 assert!(invalid.try_into::<Config>().is_err());
1967 }
1968
1969 #[test]
1970 fn user_agent_overrides_merge_over_every_built_in_and_unknown_agents_fail() {
1971 let mut value: toml::Value =
1972 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1973 merge(
1974 &mut value,
1975 toml::from_str(
1976 "[agents.pi]
1977model_args = []
1978
1979[agents.grok]
1980args = [\"--always-approve\"]
1981",
1982 )
1983 .unwrap(),
1984 );
1985 let config: Config = value.clone().try_into().unwrap();
1986 assert!(config.agents.0["pi"].model_args.is_empty());
1987 assert_eq!(config.agents.0["pi"].args, ["-p"]);
1988 assert_eq!(config.agents.0["grok"].args, ["--always-approve"]);
1989 assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1990 assert_eq!(
1991 config.agents.0.keys().collect::<Vec<_>>(),
1992 ["claude", "codex", "dsh", "grok", "pi", "scv"]
1993 );
1994
1995 merge(
1996 &mut value,
1997 toml::from_str(
1998 "[agents.zcode]
1999command = \"zcode\"
2000",
2001 )
2002 .unwrap(),
2003 );
2004 let unknown: Config = value.try_into().unwrap();
2005 let error = unknown.validate().unwrap_err().to_string();
2006 assert!(error.contains("unknown agent [agents.zcode]"), "{error}");
2007 }
2008}