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