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