1use std::{
2 collections::{BTreeMap, HashMap},
3 ffi::OsString,
4 io::Write,
5 path::PathBuf,
6 time::Duration,
7};
8
9use anyhow::{Context, Result, bail};
10use scv_core::{AgentConfig as CoreAgentConfig, ContextConfig, HistoryLimits};
11use scv_provider_openai::ProviderLimits;
12use scv_tools::{AgentAdapterConfig, ToolsConfig};
13use serde::{Deserialize, Serialize};
14
15const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
16const MAX_TOOL_TIMEOUT_SECONDS: u64 = 24 * 60 * 60;
19const MAX_PROVIDER_RETRIES: usize = 10;
21
22#[derive(Debug, Clone, Serialize, Deserialize, Default)]
23#[serde(default, deny_unknown_fields)]
24pub struct Config {
25 pub provider: ProviderConfig,
26 pub providers: HashMap<String, ProviderConfig>,
28 pub provider_active: Option<String>,
29 pub agent: AgentConfig,
30 pub session: SessionConfig,
31 pub context: ContextConfigFile,
32 pub tools: ToolConfig,
33 pub protocol: ProtocolConfig,
34 pub tui: TuiConfig,
35 pub update: UpdateConfig,
36 pub provider_limits: ProviderLimitsFile,
37 pub skills: SkillsConfig,
38 pub agents: AgentsConfig,
39 #[serde(skip)]
41 pub instance_home: PathBuf,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct ProviderConfig {
47 pub active: Option<String>,
48 pub kind: String,
49 pub wire_api: String,
50 pub model: String,
51 pub base_url: String,
52 pub api_key: Option<String>,
53 pub api_key_env: Option<String>,
54 pub timeout_seconds: u64,
55 pub headers: HashMap<String, String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Default)]
59#[serde(default, deny_unknown_fields)]
60pub struct UpdateConfig {
61 pub index_url: Option<String>,
63}
64
65impl Default for ProviderConfig {
66 fn default() -> Self {
67 Self {
68 active: None,
69 kind: "openai-compatible".into(),
70 wire_api: "responses".into(),
71 model: "gpt-4.1-mini".into(),
72 base_url: "https://api.openai.com/v1".into(),
73 api_key: None,
74 api_key_env: Some("OPENAI_API_KEY".into()),
75 timeout_seconds: 600,
76 headers: HashMap::new(),
77 }
78 }
79}
80
81impl Config {
82 pub fn init_user_config() -> Result<PathBuf> {
83 let path = user_config_path()
84 .ok_or_else(|| anyhow::anyhow!("cannot determine user config path"))?;
85 if let Some(parent) = path.parent() {
86 std::fs::create_dir_all(parent).context("create config directory")?;
87 ensure_private_dir(parent)?;
88 }
89 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";
90 if !path.exists() {
91 let parent = path
92 .parent()
93 .ok_or_else(|| anyhow::anyhow!("configuration path has no parent"))?;
94 let mut temporary = tempfile::NamedTempFile::new_in(parent)
95 .context("create temporary example configuration")?;
96 #[cfg(unix)]
97 {
98 use std::os::unix::fs::PermissionsExt;
99 temporary
100 .as_file()
101 .set_permissions(std::fs::Permissions::from_mode(0o600))
102 .context("secure temporary configuration")?;
103 }
104 temporary
105 .write_all(content.as_bytes())
106 .context("write example configuration")?;
107 temporary
108 .as_file()
109 .sync_all()
110 .context("sync example configuration")?;
111 match temporary.persist(&path) {
112 Ok(_) => {}
113 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {}
114 Err(error) => return Err(error.error).context("install example configuration"),
115 }
116 }
117 Ok(path)
118 }
119 pub fn active_provider(&self) -> Result<ProviderConfig> {
120 if let Some(name) = self
121 .provider_active
122 .as_deref()
123 .or(self.provider.active.as_deref())
124 {
125 return self
126 .providers
127 .get(name)
128 .cloned()
129 .ok_or_else(|| anyhow::anyhow!("active provider profile {name:?} was not found"));
130 }
131 Ok(self.provider.clone())
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(default, deny_unknown_fields)]
137pub struct AgentConfig {
138 pub max_steps: usize,
139 pub system_prompt: String,
140}
141
142impl Default for AgentConfig {
143 fn default() -> Self {
144 Self {
145 max_steps: 128,
146 system_prompt: "You are SCV, a concise and careful coding agent. Use tools to inspect, change, and verify the workspace.".into(),
147 }
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(default, deny_unknown_fields)]
153pub struct SessionConfig {
154 pub max_history_bytes: usize,
155 pub max_messages: usize,
156}
157
158impl Default for SessionConfig {
159 fn default() -> Self {
160 Self {
161 max_history_bytes: 16 * 1024 * 1024,
162 max_messages: 10_000,
163 }
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(default, deny_unknown_fields)]
169pub struct ContextConfigFile {
170 pub max_tokens: usize,
171 pub reserve_output_tokens: usize,
172 pub safety_margin_tokens: usize,
173 pub bytes_per_token: usize,
174 pub summary_max_chars: usize,
175}
176
177impl Default for ContextConfigFile {
178 fn default() -> Self {
179 let value = ContextConfig::default();
180 Self {
181 max_tokens: value.max_tokens,
182 reserve_output_tokens: value.reserve_output_tokens,
183 safety_margin_tokens: value.safety_margin_tokens,
184 bytes_per_token: value.bytes_per_token,
185 summary_max_chars: value.summary_max_chars,
186 }
187 }
188}
189
190impl From<&ContextConfigFile> for ContextConfig {
191 fn from(value: &ContextConfigFile) -> Self {
192 Self {
193 max_tokens: value.max_tokens,
194 reserve_output_tokens: value.reserve_output_tokens,
195 safety_margin_tokens: value.safety_margin_tokens,
196 bytes_per_token: value.bytes_per_token,
197 summary_max_chars: value.summary_max_chars,
198 }
199 }
200}
201
202#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
203#[serde(rename_all = "kebab-case")]
204pub enum ApprovalPolicy {
205 OnRisk,
206 Always,
207 Never,
208}
209
210impl ApprovalPolicy {
211 fn strictness(self) -> u8 {
212 match self {
213 Self::OnRisk => 1,
214 Self::Always => 2,
215 Self::Never => 3,
216 }
217 }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(default, deny_unknown_fields)]
222pub struct ToolConfig {
223 pub approval_policy: ApprovalPolicy,
224 pub command_timeout_seconds: u64,
226 pub agent_timeout_seconds: u64,
228 pub max_timeout_seconds: u64,
230 pub output_limit_bytes: usize,
231 pub max_read_bytes: usize,
232 pub max_write_bytes: usize,
233}
234
235impl Default for ToolConfig {
236 fn default() -> Self {
237 Self {
238 approval_policy: ApprovalPolicy::OnRisk,
239 command_timeout_seconds: 600,
240 agent_timeout_seconds: 3600,
241 max_timeout_seconds: 14400,
242 output_limit_bytes: 64 * 1024,
243 max_read_bytes: 256 * 1024,
244 max_write_bytes: 1024 * 1024,
245 }
246 }
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(default, deny_unknown_fields)]
251pub struct ProtocolConfig {
252 pub max_client_frame_bytes: usize,
253 pub max_server_frame_bytes: usize,
254}
255
256impl Default for ProtocolConfig {
257 fn default() -> Self {
258 Self {
259 max_client_frame_bytes: 1024 * 1024,
260 max_server_frame_bytes: 8 * 1024 * 1024,
261 }
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(default, deny_unknown_fields)]
267pub struct TuiConfig {
268 pub max_transcript_bytes: usize,
269 pub max_transcript_items: usize,
270 pub max_prompt_history_bytes: usize,
271 pub max_prompt_history_items: usize,
272}
273
274impl Default for TuiConfig {
275 fn default() -> Self {
276 Self {
277 max_transcript_bytes: 8 * 1024 * 1024,
278 max_transcript_items: 10_000,
279 max_prompt_history_bytes: 1024 * 1024,
280 max_prompt_history_items: 200,
281 }
282 }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(default, deny_unknown_fields)]
287pub struct ProviderLimitsFile {
288 pub max_sse_event_bytes: usize,
289 pub max_response_bytes: usize,
290 pub max_assistant_bytes: usize,
291 pub max_tool_calls: usize,
292 pub max_tool_arguments_bytes: usize,
293 pub max_retries: usize,
294}
295
296impl Default for ProviderLimitsFile {
297 fn default() -> Self {
298 let value = ProviderLimits::default();
299 Self {
300 max_sse_event_bytes: value.max_sse_event_bytes,
301 max_response_bytes: value.max_response_bytes,
302 max_assistant_bytes: value.max_assistant_bytes,
303 max_tool_calls: value.max_tool_calls,
304 max_tool_arguments_bytes: value.max_tool_arguments_bytes,
305 max_retries: value.max_retries,
306 }
307 }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
311#[serde(default, deny_unknown_fields)]
312pub struct SkillsConfig {
313 pub user_dir: PathBuf,
314 pub project_dir: PathBuf,
315 pub scan_projects: bool,
318 pub max_skills: usize,
319 pub max_skill_bytes: usize,
320}
321
322impl Default for SkillsConfig {
323 fn default() -> Self {
324 Self {
325 user_dir: PathBuf::from("~/.scv/skills"),
326 project_dir: PathBuf::from(".scv/skills"),
327 scan_projects: true,
328 max_skills: 128,
329 max_skill_bytes: 256 * 1024,
330 }
331 }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize, Default)]
335#[serde(default, deny_unknown_fields)]
336pub struct AdapterConfig {
337 pub command: String,
338 pub args: Vec<String>,
339 pub permissions: AgentPermissions,
341 pub prompt_args: Vec<String>,
343 pub model_args: Vec<String>,
345 pub effort_args: Vec<String>,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
351#[serde(rename_all = "lowercase")]
352pub enum AgentPermissions {
353 #[default]
355 Default,
356 Full,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363#[serde(transparent)]
364pub struct AgentsConfig(pub BTreeMap<String, AdapterConfig>);
365
366impl Default for AgentsConfig {
367 fn default() -> Self {
368 let strings = |values: &[&str]| values.iter().map(|value| (*value).to_owned()).collect();
369 Self(
370 scv_tools::adapters::ADAPTERS
371 .iter()
372 .map(|adapter| {
373 (
374 adapter.name.to_owned(),
375 AdapterConfig {
376 command: adapter.command.into(),
377 args: strings(adapter.args),
378 permissions: AgentPermissions::Default,
379 prompt_args: strings(adapter.prompt_args),
380 model_args: strings(adapter.model_args),
381 effort_args: strings(adapter.effort_args),
382 },
383 )
384 })
385 .collect(),
386 )
387 }
388}
389
390#[derive(Debug, Clone, Default)]
391pub struct ConfigOverrides {
392 pub provider: Option<String>,
393 pub model: Option<String>,
394 pub base_url: Option<String>,
395 pub approval_policy: Option<ApprovalPolicy>,
396 pub no_tools: bool,
397}
398
399impl Config {
400 pub fn load(workspace: &std::path::Path, overrides: ConfigOverrides) -> Result<Self> {
401 Self::load_layers(Some(workspace), overrides)
402 }
403
404 pub fn load_user(overrides: ConfigOverrides) -> Result<Self> {
407 Self::load_layers(None, overrides)
408 }
409
410 fn load_layers(
411 workspace: Option<&std::path::Path>,
412 overrides: ConfigOverrides,
413 ) -> Result<Self> {
414 let instance_home = user_home_path()
415 .ok_or_else(|| anyhow::anyhow!("cannot determine SCV instance home"))?;
416 std::fs::create_dir_all(&instance_home).context("create SCV instance home")?;
417 ensure_private_dir(&instance_home)?;
418 let mut value: toml::Value = toml::from_str(
419 &toml::to_string(&Self::default()).context("serialize default configuration")?,
420 )?;
421
422 if let Some(user_path) = user_config_path()
423 && user_path.is_file()
424 {
425 #[cfg(unix)]
426 {
427 use std::os::unix::fs::PermissionsExt;
428 if std::fs::metadata(&user_path)?.permissions().mode() & 0o077 != 0 {
429 bail!("user configuration is readable by group or others; run chmod 600");
430 }
431 }
432 merge(&mut value, read_layer(&user_path)?);
433 }
434 let user_baseline: Self = value
435 .clone()
436 .try_into()
437 .context("parse user configuration")?;
438
439 if let Some(workspace) = workspace {
440 let project_path = workspace.join(".scv/config.toml");
441 let user_file = user_config_path().and_then(|path| std::fs::canonicalize(path).ok());
445 if project_path.is_file() {
446 let canonical_project = std::fs::canonicalize(&project_path)
447 .with_context(|| format!("resolve configuration {}", project_path.display()))?;
448 if user_file.as_ref() != Some(&canonical_project) {
449 if !canonical_project.starts_with(workspace) {
450 bail!("project configuration escaped workspace");
451 }
452 let project = read_layer(&canonical_project)?;
453 validate_project_keys(&project)?;
454 let mut candidate_value = value.clone();
455 merge(&mut candidate_value, project);
456 let candidate: Self = candidate_value
457 .clone()
458 .try_into()
459 .context("parse project configuration")?;
460 validate_project_not_weaker(&user_baseline, &candidate)?;
461 value = candidate_value;
462 }
463 }
464 }
465
466 if let Some(explicit) = std::env::var_os("SCV_CONFIG") {
467 let path = PathBuf::from(explicit);
468 #[cfg(unix)]
469 {
470 use std::os::unix::fs::PermissionsExt;
471 if std::fs::metadata(&path)?.permissions().mode() & 0o077 != 0 {
472 bail!("explicit configuration is readable by group or others; run chmod 600");
473 }
474 }
475 merge(&mut value, read_layer(&path)?);
476 }
477 let mut config: Self = value.try_into().context("parse merged configuration")?;
478 if let Some(name) = overrides.provider.as_deref() {
479 config.provider_active = Some(name.to_owned());
480 }
481 let selected = config.active_provider()?;
482 config.provider = selected;
483 if let Ok(model) = std::env::var("SCV_MODEL") {
484 config.provider.model = model;
485 }
486 if let Ok(base_url) = std::env::var("SCV_BASE_URL") {
487 config.provider.base_url = base_url;
488 }
489 if let Ok(api_key_env) = std::env::var("SCV_API_KEY_ENV") {
490 config.provider.api_key_env = Some(api_key_env);
491 }
492 if let Some(model) = overrides.model {
493 config.provider.model = model;
494 }
495 if let Some(base_url) = overrides.base_url {
496 config.provider.base_url = base_url;
497 }
498 if let Some(policy) = overrides.approval_policy {
499 config.tools.approval_policy = policy;
500 }
501 if config.skills.user_dir == std::path::Path::new("~/.scv/skills")
502 && let Some(home) = std::env::var_os("SCV_HOME")
503 {
504 config.skills.user_dir = PathBuf::from(home).join("skills");
505 }
506 config.skills.user_dir = expand_home(&config.skills.user_dir);
507 config.instance_home = instance_home;
508 config.validate()?;
509 Ok(config)
510 }
511
512 pub fn core_agent(&self, system_prompt: String) -> CoreAgentConfig {
513 CoreAgentConfig {
514 system_prompt,
515 max_steps: self.agent.max_steps,
516 history_limits: HistoryLimits {
517 max_bytes: self.session.max_history_bytes,
518 max_messages: self.session.max_messages,
519 note_max_chars: self.context.summary_max_chars,
520 },
521 }
522 }
523
524 pub fn tools(&self) -> ToolsConfig {
525 ToolsConfig {
526 command_timeout: Duration::from_secs(self.tools.command_timeout_seconds),
527 agent_timeout: Duration::from_secs(self.tools.agent_timeout_seconds),
528 max_timeout: Duration::from_secs(self.tools.max_timeout_seconds),
529 output_limit_bytes: self.tools.output_limit_bytes,
530 max_read_bytes: self.tools.max_read_bytes,
531 max_write_bytes: self.tools.max_write_bytes,
532 }
533 }
534
535 pub fn provider_limits(&self) -> ProviderLimits {
536 ProviderLimits {
537 max_sse_event_bytes: self.provider_limits.max_sse_event_bytes,
538 max_response_bytes: self.provider_limits.max_response_bytes,
539 max_assistant_bytes: self.provider_limits.max_assistant_bytes,
540 max_tool_calls: self.provider_limits.max_tool_calls,
541 max_tool_arguments_bytes: self.provider_limits.max_tool_arguments_bytes,
542 max_retries: self.provider_limits.max_retries,
543 ..ProviderLimits::default()
544 }
545 }
546
547 pub fn adapters(&self) -> HashMap<String, AgentAdapterConfig> {
548 let user_home = dirs::home_dir();
549 self.agents
550 .0
551 .iter()
552 .filter_map(|(name, config)| {
553 let descriptor = scv_tools::adapters::adapter(name)?;
554 let adapter_home = self.instance_home.join("adapters").join(name);
555 let mut environment = vec![
556 (OsString::from("SCV_HOME"), adapter_home.clone().into()),
557 (OsString::from("HOME"), adapter_home.clone().into()),
558 (
559 OsString::from("XDG_CONFIG_HOME"),
560 adapter_home.join("config").into(),
561 ),
562 (
563 OsString::from("XDG_DATA_HOME"),
564 adapter_home.join("data").into(),
565 ),
566 (
567 OsString::from("XDG_STATE_HOME"),
568 adapter_home.join("state").into(),
569 ),
570 ];
571 for (variable, relative) in descriptor.home_environment {
572 let path = if relative.is_empty() {
573 adapter_home.clone()
574 } else {
575 adapter_home.join(relative)
576 };
577 environment.push((OsString::from(variable), path.into()));
578 }
579 let full = config.permissions == AgentPermissions::Full;
580 environment.extend(
581 descriptor
582 .fixed_environment
583 .iter()
584 .chain(
585 descriptor
586 .full_permission_environment
587 .iter()
588 .filter(|_| full),
589 )
590 .map(|(variable, value)| (OsString::from(variable), OsString::from(value))),
591 );
592 Some((
593 format!("agent_{name}"),
594 AgentAdapterConfig {
595 command: config.command.clone(),
596 args: config.args.clone(),
597 prompt_args: config.prompt_args.clone(),
598 full_permission_args: full.then(|| {
599 descriptor
600 .full_permission_args
601 .iter()
602 .map(|arg| (*arg).to_owned())
603 .collect()
604 }),
605 model_args: config.model_args.clone(),
606 effort_args: config.effort_args.clone(),
607 model_hint: descriptor.model_hint.into(),
608 environment,
609 search_dirs: user_home
610 .as_deref()
611 .map(|home| scv_tools::adapters::adapter_search_dirs(descriptor, home))
612 .unwrap_or_default(),
613 },
614 ))
615 })
616 .collect()
617 }
618
619 pub fn prepare_adapter_homes(&self) -> Result<()> {
620 for name in self.agents.0.keys() {
621 let path = self.instance_home.join("adapters").join(name);
622 std::fs::create_dir_all(&path)
623 .with_context(|| format!("create isolated {name} adapter home"))?;
624 #[cfg(unix)]
625 {
626 use std::os::unix::fs::PermissionsExt;
627 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700))
628 .with_context(|| format!("secure isolated {name} adapter home"))?;
629 }
630 }
631 Ok(())
632 }
633
634 fn validate(&self) -> Result<()> {
635 if self.provider.kind != "openai-compatible" {
636 bail!("provider.kind must be openai-compatible in v0.1");
637 }
638 if self.provider.model.trim().is_empty()
639 || self.provider.base_url.trim().is_empty()
640 || self
641 .provider
642 .api_key
643 .as_deref()
644 .unwrap_or("")
645 .trim()
646 .is_empty()
647 && self
648 .provider
649 .api_key_env
650 .as_deref()
651 .unwrap_or("")
652 .trim()
653 .is_empty()
654 {
655 bail!(
656 "provider model and base_url must be non-empty; configure api_key or api_key_env"
657 );
658 }
659 for (agent, adapter) in &self.agents.0 {
660 if scv_tools::adapters::adapter(agent).is_none() {
661 let known: Vec<_> = scv_tools::adapters::ADAPTERS
662 .iter()
663 .map(|adapter| adapter.name)
664 .collect();
665 bail!(
666 "unknown agent [agents.{agent}]; known agents are {}",
667 known.join(", ")
668 );
669 }
670 let name = format!("agents.{agent}.command");
671 if adapter.command.trim().is_empty() {
672 bail!("{name} must be non-empty");
673 }
674 for (field, template, placeholder) in [
675 ("model_args", &adapter.model_args, "{model}"),
676 ("effort_args", &adapter.effort_args, "{effort}"),
677 ] {
678 if !template.is_empty() && !template.iter().any(|arg| arg.contains(placeholder)) {
679 let adapter = name.trim_end_matches(".command");
680 bail!("{adapter}.{field} must contain {placeholder} or be empty");
681 }
682 }
683 let adapter_bytes = adapter.command.len()
684 + [
685 &adapter.args,
686 &adapter.prompt_args,
687 &adapter.model_args,
688 &adapter.effort_args,
689 ]
690 .into_iter()
691 .flatten()
692 .map(String::len)
693 .sum::<usize>();
694 if adapter_bytes > 16 * 1024 {
695 bail!("{name} and its fixed arguments exceed 16384 bytes");
696 }
697 }
698 let positives = [
699 (
700 "provider.timeout_seconds",
701 usize::try_from(self.provider.timeout_seconds).unwrap_or(usize::MAX),
702 ),
703 ("agent.max_steps", self.agent.max_steps),
704 ("session.max_history_bytes", self.session.max_history_bytes),
705 ("session.max_messages", self.session.max_messages),
706 ("context.max_tokens", self.context.max_tokens),
707 ("context.bytes_per_token", self.context.bytes_per_token),
708 ("context.summary_max_chars", self.context.summary_max_chars),
709 (
710 "tools.command_timeout_seconds",
711 usize::try_from(self.tools.command_timeout_seconds).unwrap_or(usize::MAX),
712 ),
713 (
714 "tools.agent_timeout_seconds",
715 usize::try_from(self.tools.agent_timeout_seconds).unwrap_or(usize::MAX),
716 ),
717 (
718 "tools.max_timeout_seconds",
719 usize::try_from(self.tools.max_timeout_seconds).unwrap_or(usize::MAX),
720 ),
721 ("tools.output_limit_bytes", self.tools.output_limit_bytes),
722 ("tools.max_read_bytes", self.tools.max_read_bytes),
723 ("tools.max_write_bytes", self.tools.max_write_bytes),
724 (
725 "protocol.max_client_frame_bytes",
726 self.protocol.max_client_frame_bytes,
727 ),
728 (
729 "protocol.max_server_frame_bytes",
730 self.protocol.max_server_frame_bytes,
731 ),
732 ("tui.max_transcript_bytes", self.tui.max_transcript_bytes),
733 ("tui.max_transcript_items", self.tui.max_transcript_items),
734 (
735 "tui.max_prompt_history_bytes",
736 self.tui.max_prompt_history_bytes,
737 ),
738 (
739 "tui.max_prompt_history_items",
740 self.tui.max_prompt_history_items,
741 ),
742 (
743 "provider_limits.max_sse_event_bytes",
744 self.provider_limits.max_sse_event_bytes,
745 ),
746 (
747 "provider_limits.max_response_bytes",
748 self.provider_limits.max_response_bytes,
749 ),
750 (
751 "provider_limits.max_assistant_bytes",
752 self.provider_limits.max_assistant_bytes,
753 ),
754 (
755 "provider_limits.max_tool_calls",
756 self.provider_limits.max_tool_calls,
757 ),
758 (
759 "provider_limits.max_tool_arguments_bytes",
760 self.provider_limits.max_tool_arguments_bytes,
761 ),
762 ("skills.max_skills", self.skills.max_skills),
763 ("skills.max_skill_bytes", self.skills.max_skill_bytes),
764 ];
765 if let Some((name, _)) = positives.into_iter().find(|(_, value)| *value == 0) {
766 bail!("{name} must be positive");
767 }
768 for (name, value) in [
769 (
770 "tools.command_timeout_seconds",
771 self.tools.command_timeout_seconds,
772 ),
773 (
774 "tools.agent_timeout_seconds",
775 self.tools.agent_timeout_seconds,
776 ),
777 ] {
778 if value > self.tools.max_timeout_seconds {
779 bail!("{name} exceeds tools.max_timeout_seconds");
780 }
781 }
782 if self.tools.max_timeout_seconds > MAX_TOOL_TIMEOUT_SECONDS {
783 bail!("tools.max_timeout_seconds must be at most {MAX_TOOL_TIMEOUT_SECONDS}");
784 }
785 if self
786 .context
787 .reserve_output_tokens
788 .saturating_add(self.context.safety_margin_tokens)
789 >= self.context.max_tokens
790 {
791 bail!("context reserve and safety margin consume max_tokens");
792 }
793 let worst_assistant_frame = self
794 .provider_limits
795 .max_assistant_bytes
796 .saturating_mul(6)
797 .saturating_add(64 * 1024);
798 if worst_assistant_frame > self.protocol.max_server_frame_bytes {
799 bail!(
800 "provider_limits.max_assistant_bytes can exceed protocol.max_server_frame_bytes after JSON escaping"
801 );
802 }
803 if self.provider_limits.max_tool_arguments_bytes > self.provider_limits.max_response_bytes {
804 bail!("tool argument limit exceeds provider response limit");
805 }
806 if self.provider_limits.max_sse_event_bytes > self.provider_limits.max_response_bytes {
807 bail!("provider SSE event limit exceeds provider response limit");
808 }
809 if self.provider_limits.max_retries > MAX_PROVIDER_RETRIES {
810 bail!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}");
811 }
812 if self.protocol.max_client_frame_bytes < 4096 {
813 bail!("protocol.max_client_frame_bytes must be at least 4096");
814 }
815 if self.protocol.max_server_frame_bytes < 64 * 1024 {
816 bail!("protocol.max_server_frame_bytes must be at least 65536");
817 }
818 let worst_tool_frame = self
819 .tools
820 .output_limit_bytes
821 .max(self.tools.max_read_bytes)
822 .saturating_mul(12)
823 .saturating_add(64 * 1024);
824 let worst_skill_frame = self
825 .skills
826 .max_skill_bytes
827 .saturating_mul(6)
828 .saturating_add(64 * 1024);
829 let worst_arguments_frame = self
830 .provider_limits
831 .max_tool_arguments_bytes
832 .saturating_mul(6)
833 .saturating_add(64 * 1024);
834 if worst_tool_frame
835 .max(worst_skill_frame)
836 .max(worst_arguments_frame)
837 > self.protocol.max_server_frame_bytes
838 {
839 bail!(
840 "tool or skill limits can exceed protocol.max_server_frame_bytes after JSON escaping"
841 );
842 }
843 if self.skills.project_dir.is_absolute()
844 || self
845 .skills
846 .project_dir
847 .components()
848 .any(|component| matches!(component, std::path::Component::ParentDir))
849 {
850 bail!("skills.project_dir must be a contained relative path");
851 }
852 Ok(())
853 }
854}
855
856fn user_config_path() -> Option<PathBuf> {
857 user_home_path().map(|path| path.join("config.toml"))
858}
859
860pub fn user_home_path() -> Option<PathBuf> {
861 let path = std::env::var_os("SCV_HOME")
862 .map(PathBuf::from)
863 .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))?;
864 if path.exists() {
865 Some(std::fs::canonicalize(path.clone()).unwrap_or(path))
866 } else if path.is_absolute() {
867 Some(path)
868 } else {
869 std::env::current_dir().ok().map(|cwd| cwd.join(path))
870 }
871}
872
873fn ensure_private_dir(path: &std::path::Path) -> Result<()> {
874 #[cfg(unix)]
875 {
876 use std::os::unix::fs::PermissionsExt;
877 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
878 .with_context(|| format!("secure directory {}", path.display()))?;
879 }
880 Ok(())
881}
882
883fn read_layer(path: &std::path::Path) -> Result<toml::Value> {
884 let size = std::fs::metadata(path)
885 .with_context(|| format!("stat configuration {}", path.display()))?
886 .len();
887 if size > MAX_CONFIG_BYTES {
888 bail!("configuration {} exceeds 1 MiB", path.display());
889 }
890 let content = std::fs::read_to_string(path)
891 .with_context(|| format!("read configuration {}", path.display()))?;
892 toml::from_str(&content).with_context(|| format!("parse configuration {}", path.display()))
893}
894
895fn merge(base: &mut toml::Value, overlay: toml::Value) {
896 match (base, overlay) {
897 (toml::Value::Table(base), toml::Value::Table(overlay)) => {
898 for (key, value) in overlay {
899 match base.get_mut(&key) {
900 Some(existing) => merge(existing, value),
901 None => {
902 base.insert(key, value);
903 }
904 }
905 }
906 }
907 (base, overlay) => *base = overlay,
908 }
909}
910
911fn validate_project_keys(value: &toml::Value) -> Result<()> {
912 let Some(table) = value.as_table() else {
913 bail!("project configuration must be a TOML table");
914 };
915 for forbidden in [
916 "provider",
917 "providers",
918 "provider_active",
919 "agents",
920 "update",
921 ] {
922 if table.contains_key(forbidden) {
923 bail!("project configuration cannot set [{forbidden}]");
924 }
925 }
926 if table
927 .get("skills")
928 .and_then(toml::Value::as_table)
929 .is_some_and(|skills| skills.contains_key("user_dir"))
930 {
931 bail!("project configuration cannot set skills.user_dir");
932 }
933 if table
934 .get("agent")
935 .and_then(toml::Value::as_table)
936 .is_some_and(|agent| agent.contains_key("system_prompt"))
937 {
938 bail!("project configuration cannot replace agent.system_prompt");
939 }
940 Ok(())
941}
942
943fn validate_project_not_weaker(user: &Config, project: &Config) -> Result<()> {
944 macro_rules! no_larger {
945 ($field:expr, $name:literal) => {
946 if $field.1 > $field.0 {
947 bail!(concat!("project configuration cannot raise ", $name));
948 }
949 };
950 }
951 no_larger!(
952 (user.agent.max_steps, project.agent.max_steps),
953 "agent.max_steps"
954 );
955 no_larger!(
956 (
957 user.session.max_history_bytes,
958 project.session.max_history_bytes
959 ),
960 "session.max_history_bytes"
961 );
962 no_larger!(
963 (user.session.max_messages, project.session.max_messages),
964 "session.max_messages"
965 );
966 no_larger!(
967 (user.context.max_tokens, project.context.max_tokens),
968 "context.max_tokens"
969 );
970 no_larger!(
971 (
972 user.context.summary_max_chars,
973 project.context.summary_max_chars
974 ),
975 "context.summary_max_chars"
976 );
977 no_larger!(
978 (
979 user.tools.command_timeout_seconds,
980 project.tools.command_timeout_seconds
981 ),
982 "tools.command_timeout_seconds"
983 );
984 no_larger!(
985 (
986 user.tools.agent_timeout_seconds,
987 project.tools.agent_timeout_seconds
988 ),
989 "tools.agent_timeout_seconds"
990 );
991 no_larger!(
992 (
993 user.tools.max_timeout_seconds,
994 project.tools.max_timeout_seconds
995 ),
996 "tools.max_timeout_seconds"
997 );
998 no_larger!(
999 (
1000 user.tools.output_limit_bytes,
1001 project.tools.output_limit_bytes
1002 ),
1003 "tools.output_limit_bytes"
1004 );
1005 no_larger!(
1006 (user.tools.max_read_bytes, project.tools.max_read_bytes),
1007 "tools.max_read_bytes"
1008 );
1009 no_larger!(
1010 (user.tools.max_write_bytes, project.tools.max_write_bytes),
1011 "tools.max_write_bytes"
1012 );
1013 no_larger!(
1014 (
1015 user.protocol.max_client_frame_bytes,
1016 project.protocol.max_client_frame_bytes
1017 ),
1018 "protocol.max_client_frame_bytes"
1019 );
1020 no_larger!(
1021 (
1022 user.protocol.max_server_frame_bytes,
1023 project.protocol.max_server_frame_bytes
1024 ),
1025 "protocol.max_server_frame_bytes"
1026 );
1027 no_larger!(
1028 (
1029 user.provider_limits.max_response_bytes,
1030 project.provider_limits.max_response_bytes
1031 ),
1032 "provider_limits.max_response_bytes"
1033 );
1034 no_larger!(
1035 (
1036 user.provider_limits.max_sse_event_bytes,
1037 project.provider_limits.max_sse_event_bytes
1038 ),
1039 "provider_limits.max_sse_event_bytes"
1040 );
1041 no_larger!(
1042 (
1043 user.provider_limits.max_assistant_bytes,
1044 project.provider_limits.max_assistant_bytes
1045 ),
1046 "provider_limits.max_assistant_bytes"
1047 );
1048 no_larger!(
1049 (
1050 user.provider_limits.max_tool_calls,
1051 project.provider_limits.max_tool_calls
1052 ),
1053 "provider_limits.max_tool_calls"
1054 );
1055 no_larger!(
1056 (
1057 user.provider_limits.max_tool_arguments_bytes,
1058 project.provider_limits.max_tool_arguments_bytes
1059 ),
1060 "provider_limits.max_tool_arguments_bytes"
1061 );
1062 no_larger!(
1063 (
1064 user.provider_limits.max_retries,
1065 project.provider_limits.max_retries
1066 ),
1067 "provider_limits.max_retries"
1068 );
1069 no_larger!(
1070 (
1071 user.tui.max_transcript_bytes,
1072 project.tui.max_transcript_bytes
1073 ),
1074 "tui.max_transcript_bytes"
1075 );
1076 no_larger!(
1077 (
1078 user.tui.max_transcript_items,
1079 project.tui.max_transcript_items
1080 ),
1081 "tui.max_transcript_items"
1082 );
1083 no_larger!(
1084 (
1085 user.tui.max_prompt_history_bytes,
1086 project.tui.max_prompt_history_bytes
1087 ),
1088 "tui.max_prompt_history_bytes"
1089 );
1090 no_larger!(
1091 (
1092 user.tui.max_prompt_history_items,
1093 project.tui.max_prompt_history_items
1094 ),
1095 "tui.max_prompt_history_items"
1096 );
1097 no_larger!(
1098 (user.skills.max_skills, project.skills.max_skills),
1099 "skills.max_skills"
1100 );
1101 no_larger!(
1102 (user.skills.max_skill_bytes, project.skills.max_skill_bytes),
1103 "skills.max_skill_bytes"
1104 );
1105 if project.context.reserve_output_tokens < user.context.reserve_output_tokens
1106 || project.context.safety_margin_tokens < user.context.safety_margin_tokens
1107 {
1108 bail!("project configuration cannot lower context reserves");
1109 }
1110 if project.context.bytes_per_token > user.context.bytes_per_token {
1111 bail!("project configuration cannot raise context.bytes_per_token");
1112 }
1113 if project.tools.approval_policy.strictness() < user.tools.approval_policy.strictness() {
1114 bail!("project configuration cannot weaken tools.approval_policy");
1115 }
1116 if project.skills.scan_projects && !user.skills.scan_projects {
1117 bail!("project configuration cannot enable skills.scan_projects");
1118 }
1119 Ok(())
1120}
1121
1122fn expand_home(path: &std::path::Path) -> PathBuf {
1123 let value = path.to_string_lossy();
1124 if value == "~" {
1125 return dirs::home_dir().unwrap_or_else(|| path.to_path_buf());
1126 }
1127 if let Some(rest) = value.strip_prefix("~/")
1128 && let Some(home) = dirs::home_dir()
1129 {
1130 return home.join(rest);
1131 }
1132 path.to_path_buf()
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137 use super::*;
1138
1139 #[test]
1140 fn project_cannot_redirect_provider_or_agent() {
1141 let provider: toml::Value = toml::from_str(
1142 r#"[provider]
1143base_url = "https://attacker.invalid"
1144"#,
1145 )
1146 .unwrap();
1147 assert!(validate_project_keys(&provider).is_err());
1148
1149 let agent: toml::Value = toml::from_str(
1150 r#"[agents.codex]
1151command = "/tmp/fake"
1152"#,
1153 )
1154 .unwrap();
1155 assert!(validate_project_keys(&agent).is_err());
1156 }
1157
1158 #[test]
1159 fn project_may_tighten_but_not_weaken_limits() {
1160 let user = Config::default();
1161 let mut tighter = user.clone();
1162 tighter.tools.output_limit_bytes /= 2;
1163 tighter.tools.approval_policy = ApprovalPolicy::Always;
1164 assert!(validate_project_not_weaker(&user, &tighter).is_ok());
1165
1166 let mut weaker = user.clone();
1167 weaker.tools.output_limit_bytes *= 2;
1168 assert!(validate_project_not_weaker(&user, &weaker).is_err());
1169 }
1170
1171 #[test]
1172 fn timeouts_default_below_a_ceiling_that_projects_may_only_lower() {
1173 let user = Config::default();
1174 assert_eq!(
1175 (
1176 user.tools.command_timeout_seconds,
1177 user.tools.agent_timeout_seconds,
1178 user.tools.max_timeout_seconds
1179 ),
1180 (600, 3600, 14400)
1181 );
1182 assert_eq!(user.agent.max_steps, 128);
1183 assert_eq!(user.provider.timeout_seconds, 600);
1184 let tools = user.tools();
1185 assert_eq!(tools.command_timeout, Duration::from_secs(600));
1186 assert_eq!(tools.agent_timeout, Duration::from_secs(3600));
1187 assert_eq!(tools.max_timeout, Duration::from_secs(14400));
1188 assert_eq!(
1190 scv_clawbot::owner_turn_timeout(tools.max_timeout),
1191 Duration::from_secs(4 * 3600 + 5 * 60)
1192 );
1193
1194 for (field, name) in [
1195 (0, "tools.command_timeout_seconds"),
1196 (1, "tools.agent_timeout_seconds"),
1197 ] {
1198 let mut config = Config::default();
1199 let value = if field == 0 {
1200 &mut config.tools.command_timeout_seconds
1201 } else {
1202 &mut config.tools.agent_timeout_seconds
1203 };
1204 *value = config.tools.max_timeout_seconds + 1;
1205 assert_eq!(
1206 config.validate().unwrap_err().to_string(),
1207 format!("{name} exceeds tools.max_timeout_seconds")
1208 );
1209 }
1210 let mut unbounded = Config::default();
1211 unbounded.tools.max_timeout_seconds = MAX_TOOL_TIMEOUT_SECONDS + 1;
1212 assert!(unbounded.validate().is_err());
1213 let mut zero = Config::default();
1214 zero.tools.agent_timeout_seconds = 0;
1215 assert!(zero.validate().is_err());
1216
1217 let mut lower = user.clone();
1218 lower.tools.max_timeout_seconds = 900;
1219 lower.tools.agent_timeout_seconds = 300;
1220 assert!(validate_project_not_weaker(&user, &lower).is_ok());
1221 for raise in [
1222 |config: &mut Config| config.tools.max_timeout_seconds += 1,
1223 |config: &mut Config| config.tools.agent_timeout_seconds += 1,
1224 ] {
1225 let mut higher = user.clone();
1226 raise(&mut higher);
1227 assert!(validate_project_not_weaker(&user, &higher).is_err());
1228 }
1229 }
1230
1231 #[test]
1232 fn provider_retries_are_bounded_and_projects_may_only_lower_them() {
1233 let user = Config::default();
1234 assert_eq!(user.provider_limits.max_retries, 2);
1235 assert_eq!(user.provider_limits().max_retries, 2);
1236 let mut none = user.clone();
1237 none.provider_limits.max_retries = 0;
1238 assert!(none.validate().is_ok());
1239 assert!(validate_project_not_weaker(&user, &none).is_ok());
1240 assert!(validate_project_not_weaker(&none, &user).is_err());
1241 let mut excessive = user.clone();
1242 excessive.provider_limits.max_retries = MAX_PROVIDER_RETRIES + 1;
1243 assert_eq!(
1244 excessive.validate().unwrap_err().to_string(),
1245 format!("provider_limits.max_retries must be at most {MAX_PROVIDER_RETRIES}")
1246 );
1247 }
1248
1249 #[test]
1250 fn projects_may_disable_but_not_enable_project_skill_scanning() {
1251 let user = Config::default();
1252 let mut disabled = user.clone();
1253 disabled.skills.scan_projects = false;
1254 assert!(validate_project_not_weaker(&user, &disabled).is_ok());
1255 assert!(validate_project_not_weaker(&disabled, &user).is_err());
1256 }
1257
1258 #[test]
1259 fn cross_field_validation_accounts_for_json_escaping() {
1260 let mut config = Config::default();
1261 config.protocol.max_server_frame_bytes = config.provider_limits.max_assistant_bytes;
1262 assert!(config.validate().is_err());
1263 }
1264
1265 #[test]
1266 fn adapter_selection_templates_survive_partial_overrides_and_validate() {
1267 let mut value: toml::Value =
1268 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1269 merge(
1270 &mut value,
1271 toml::from_str(
1272 r#"[agents.claude]
1273args = ["-p", "--permission-mode", "acceptEdits"]
1274"#,
1275 )
1276 .unwrap(),
1277 );
1278 let config: Config = value.try_into().unwrap();
1279 let claude = &config.agents.0["claude"];
1280 assert_eq!(claude.args.len(), 3);
1281 assert_eq!(claude.model_args, ["--model", "{model}"]);
1282 assert_eq!(claude.effort_args, ["--effort", "{effort}"]);
1283 assert_eq!(
1284 config.agents.0["pi"].effort_args,
1285 ["--thinking", "{effort}"]
1286 );
1287 assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1288
1289 let mut invalid = Config::default();
1290 invalid.agents.0.get_mut("claude").unwrap().effort_args = vec!["--effort".into()];
1291 assert!(
1292 invalid
1293 .validate()
1294 .unwrap_err()
1295 .to_string()
1296 .contains("agents.claude.effort_args must contain {effort}")
1297 );
1298 }
1299
1300 #[test]
1301 fn adapters_are_bound_to_the_instance_home() {
1302 let config = Config {
1303 instance_home: PathBuf::from("/tmp/scv-instance"),
1304 ..Config::default()
1305 };
1306 let adapters = config.adapters();
1307 let codex = &adapters["agent_codex"];
1308 assert!(codex.environment.contains(&(
1309 OsString::from("CODEX_HOME"),
1310 OsString::from("/tmp/scv-instance/adapters/codex")
1311 )));
1312 assert!(codex.environment.contains(&(
1313 OsString::from("SCV_HOME"),
1314 OsString::from("/tmp/scv-instance/adapters/codex")
1315 )));
1316 for (agent, variable, path) in [
1317 ("grok", "GROK_HOME", "/tmp/scv-instance/adapters/grok/.grok"),
1318 ("dsh", "DSH_HOME", "/tmp/scv-instance/adapters/dsh/.dsh"),
1319 (
1320 "pi",
1321 "PI_CODING_AGENT_DIR",
1322 "/tmp/scv-instance/adapters/pi/.pi/agent",
1323 ),
1324 ] {
1325 let adapter = &adapters[&format!("agent_{agent}")];
1326 assert!(
1327 adapter
1328 .environment
1329 .contains(&(OsString::from(variable), OsString::from(path))),
1330 "{agent}"
1331 );
1332 assert!(adapter.environment.contains(&(
1333 OsString::from("HOME"),
1334 OsString::from(format!("/tmp/scv-instance/adapters/{agent}"))
1335 )));
1336 }
1337 assert!(adapters["agent_grok"].environment.contains(&(
1338 OsString::from("GROK_DISABLE_AUTOUPDATER"),
1339 OsString::from("1")
1340 )));
1341 assert_eq!(adapters["agent_grok"].prompt_args, ["-p"]);
1342 assert!(adapters["agent_pi"].model_hint.contains("provider scv"));
1343 }
1344
1345 #[test]
1346 fn full_permissions_are_opt_in_per_agent_and_combine_with_args() {
1347 let defaults = Config::default().adapters();
1348 for adapter in defaults.values() {
1349 assert_eq!(adapter.full_permission_args, None);
1350 }
1351 let mut value: toml::Value =
1352 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1353 merge(
1354 &mut value,
1355 toml::from_str(
1356 "[agents.claude]\npermissions = \"full\"\n\n\
1357 [agents.codex]\nargs = [\"exec\", \"--skip-git-repo-check\"]\npermissions = \"full\"\n\n\
1358 [agents.grok]\npermissions = \"full\"\n\n\
1359 [agents.dsh]\npermissions = \"full\"\n\n\
1360 [agents.pi]\npermissions = \"full\"\n",
1361 )
1362 .unwrap(),
1363 );
1364 let config: Config = value.try_into().unwrap();
1365 config.validate().unwrap();
1366 let adapters = config.adapters();
1367 let full = |agent: &str| {
1368 adapters[&format!("agent_{agent}")]
1369 .full_permission_args
1370 .clone()
1371 .unwrap()
1372 };
1373 assert_eq!(full("claude"), ["--permission-mode", "bypassPermissions"]);
1374 assert_eq!(
1375 full("codex"),
1376 [
1377 "--dangerously-bypass-approvals-and-sandbox",
1378 "-c",
1379 "web_search=\"live\""
1380 ]
1381 );
1382 assert_eq!(
1383 adapters["agent_codex"].args,
1384 ["exec", "--skip-git-repo-check"]
1385 );
1386 assert_eq!(full("grok"), ["--always-approve"]);
1387 assert!(full("dsh").is_empty());
1388 assert!(adapters["agent_dsh"].environment.contains(&(
1389 OsString::from("DSH_PERMISSION_MODE"),
1390 OsString::from("danger-full-access")
1391 )));
1392 assert!(
1393 !defaults["agent_dsh"]
1394 .environment
1395 .iter()
1396 .any(|(variable, _)| variable == "DSH_PERMISSION_MODE")
1397 );
1398 assert!(full("pi").is_empty());
1400
1401 let mut invalid: toml::Value =
1402 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1403 merge(
1404 &mut invalid,
1405 toml::from_str("[agents.claude]\npermissions = \"yolo\"\n").unwrap(),
1406 );
1407 assert!(invalid.try_into::<Config>().is_err());
1408 }
1409
1410 #[test]
1411 fn user_agent_overrides_merge_over_every_built_in_and_unknown_agents_fail() {
1412 let mut value: toml::Value =
1413 toml::from_str(&toml::to_string(&Config::default()).unwrap()).unwrap();
1414 merge(
1415 &mut value,
1416 toml::from_str(
1417 "[agents.pi]
1418model_args = []
1419
1420[agents.grok]
1421args = [\"--always-approve\"]
1422",
1423 )
1424 .unwrap(),
1425 );
1426 let config: Config = value.clone().try_into().unwrap();
1427 assert!(config.agents.0["pi"].model_args.is_empty());
1428 assert_eq!(config.agents.0["pi"].args, ["-p"]);
1429 assert_eq!(config.agents.0["grok"].args, ["--always-approve"]);
1430 assert_eq!(config.agents.0["grok"].prompt_args, ["-p"]);
1431 assert_eq!(
1432 config.agents.0.keys().collect::<Vec<_>>(),
1433 ["claude", "codex", "dsh", "grok", "pi"]
1434 );
1435
1436 merge(
1437 &mut value,
1438 toml::from_str(
1439 "[agents.zcode]
1440command = \"zcode\"
1441",
1442 )
1443 .unwrap(),
1444 );
1445 let unknown: Config = value.try_into().unwrap();
1446 let error = unknown.validate().unwrap_err().to_string();
1447 assert!(error.contains("unknown agent [agents.zcode]"), "{error}");
1448 }
1449}