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