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