1use std::collections::HashMap;
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use anyhow::{Context, Result, bail};
8
9use crate::api_keys::{api_key_env_var, credential_metadata_key, store_credential_with_mode};
10use crate::defaults::{self};
11use crate::hooks::{HooksConfig, LifecycleHooksConfig, WorkspaceHookCommand, WorkspaceLifecycleHooks};
12use crate::loader::config::VTCodeConfig;
13use crate::loader::layers::{
14 ConfigLayerEntry, ConfigLayerMetadata, ConfigLayerSource, ConfigLayerStack, LayerDisabledReason,
15};
16use crate::loader::session_override;
17use vtcode_commons::VtCodePaths;
18use vtcode_commons::canonicalize;
19
20type CachedManager = Arc<ConfigManager>;
21
22#[derive(Debug)]
23struct RepositoryProviderSecurityViolation {
24 source: ConfigLayerSource,
25 message: String,
26}
27
28impl fmt::Display for RepositoryProviderSecurityViolation {
29 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30 self.message.fmt(formatter)
31 }
32}
33
34impl std::error::Error for RepositoryProviderSecurityViolation {}
35
36#[cfg(not(test))]
37static WORKSPACE_CACHE: Mutex<Option<HashMap<PathBuf, CachedManager>>> = Mutex::new(None);
38
39#[cfg(not(test))]
40fn with_cache_mut(f: impl FnOnce(&mut HashMap<PathBuf, CachedManager>)) {
41 let mut guard = WORKSPACE_CACHE.lock().expect("config cache lock poisoned");
42 guard.get_or_insert_with(HashMap::new);
43 f(guard.as_mut().expect("cache initialized"))
44}
45
46#[cfg(not(test))]
47fn cache_get(workspace: &Path) -> Option<CachedManager> {
48 WORKSPACE_CACHE
49 .lock()
50 .expect("config cache lock poisoned")
51 .as_ref()
52 .and_then(|map| map.get(workspace).cloned())
53}
54
55#[cfg(not(test))]
56fn cache_insert(workspace: PathBuf, manager: CachedManager) {
57 with_cache_mut(move |map| {
58 map.insert(workspace, manager);
59 });
60}
61
62#[cfg(not(test))]
63fn cache_remove(workspace: &Path) {
64 with_cache_mut(|map| {
65 map.remove(workspace);
66 });
67}
68
69pub(crate) fn canonicalize_workspace_root(path: &Path) -> PathBuf {
70 canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
71}
72
73fn push_unique_layer_source(layer_sources: &mut Vec<ConfigLayerSource>, source: ConfigLayerSource) {
74 let file = match &source {
75 ConfigLayerSource::System { file }
76 | ConfigLayerSource::User { file }
77 | ConfigLayerSource::Project { file }
78 | ConfigLayerSource::Workspace { file } => file,
79 ConfigLayerSource::Runtime => {
80 layer_sources.push(source);
81 return;
82 }
83 };
84 if !layer_sources.iter().any(|existing| {
85 matches!(existing,
86 ConfigLayerSource::System { file: existing_file }
87 | ConfigLayerSource::User { file: existing_file }
88 | ConfigLayerSource::Project { file: existing_file }
89 | ConfigLayerSource::Workspace { file: existing_file }
90 if existing_file == file)
91 }) {
92 layer_sources.push(source);
93 }
94}
95
96fn is_optional_global_layer(source: &ConfigLayerSource) -> bool {
97 matches!(source, ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. })
98}
99
100fn should_skip_optional_global_error(source: &ConfigLayerSource, error: &std::io::Error) -> bool {
101 is_optional_global_layer(source) && error.kind() != std::io::ErrorKind::InvalidData
102}
103
104fn ensure_private_parent_dir(path: &Path) -> Result<()> {
105 let Some(parent) = path.parent() else {
106 return Ok(());
107 };
108 let _ = VtCodePaths::ensure_user_dir(parent)
109 .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
110 Ok(())
111}
112
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
115pub struct ConfigPhaseTiming {
116 pub path_resolution_us: u64,
118 pub layer_loading_us: u64,
120 pub merge_and_parse_us: u64,
122 pub validation_us: u64,
124}
125
126#[derive(Clone)]
128pub struct ConfigManager {
129 pub(crate) config: VTCodeConfig,
130 config_path: Option<PathBuf>,
131 canonical_user_config_path: Option<PathBuf>,
132 tracked_user_config_paths: Vec<PathBuf>,
133 write_global_config_to_canonical: bool,
134 explicit_config_is_trusted: bool,
139 workspace_root: Option<PathBuf>,
140 config_file_name: String,
141 pub(crate) layer_stack: ConfigLayerStack,
142 phase_timing: Option<ConfigPhaseTiming>,
143}
144
145impl ConfigManager {
146 pub fn load() -> Result<Self> {
148 if let Some(override_path) = session_override::explicit_config_path() {
149 return Self::load_for_session(std::env::current_dir()?, &override_path).with_context(|| {
150 format!("Failed to load configuration from explicit path {}", override_path.display())
151 });
152 }
153
154 Self::load_from_workspace_with_repository_repair(std::env::current_dir()?)
155 }
156
157 pub fn load_for_session(workspace: impl AsRef<Path>, explicit_path: impl AsRef<Path>) -> Result<Self> {
167 let workspace = workspace.as_ref();
168 let explicit_path = explicit_path.as_ref();
169 #[cfg(not(test))]
170 let canonical_workspace = canonicalize_workspace_root(workspace);
171
172 #[cfg(not(test))]
173 if let Some(cached) = cache_get(&canonical_workspace) {
174 return Ok(cached.as_ref().clone());
175 }
176
177 let mut manager = Self::load_from_file_impl(explicit_path, false).with_context(|| {
178 format!(
179 "Failed to load explicit session config file {} (from --config / VTCODE_CONFIG_PATH)",
180 explicit_path.display()
181 )
182 })?;
183 manager.workspace_root = Some(canonicalize_workspace_root(workspace));
186
187 #[cfg(not(test))]
188 cache_insert(canonical_workspace, Arc::new(manager.clone()));
189
190 Ok(manager)
191 }
192
193 pub fn load_global() -> Result<Self> {
199 let defaults_provider = defaults::current_config_defaults();
200 let config_file_name = defaults_provider.config_file_name().to_string();
201 let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
202 let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
203 if let Some(path) = &canonical_user_config_path
204 && !tracked_user_config_paths.iter().any(|existing| existing == path)
205 {
206 tracked_user_config_paths.push(path.clone());
207 }
208
209 let mut layer_sources = Vec::new();
210 for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
211 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
212 }
213 for home_config_path in &tracked_user_config_paths {
214 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
215 }
216
217 let mut layer_stack = ConfigLayerStack::default();
218 for source in layer_sources {
219 if let Some(layer) = Self::load_optional_layer(source) {
220 layer_stack.push(layer);
221 }
222 }
223
224 if let Some((layer, error)) = layer_stack.first_layer_error() {
225 bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
226 }
227
228 let (config, config_path) = if layer_stack.layers().is_empty() {
229 let config = VTCodeConfig::default();
230 config.validate().context("Default configuration failed validation")?;
231 (config, None)
232 } else {
233 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
234 let mut config: VTCodeConfig = effective_toml
235 .try_into()
236 .context("Failed to deserialize effective global configuration")?;
237 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
238 Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
239 config.validate().context("Global configuration failed validation")?;
240 config.workspace_lifecycle_hooks =
241 Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
242 let config_path = layer_stack
243 .layers()
244 .iter()
245 .rev()
246 .find(|layer| layer.is_enabled())
247 .and_then(|layer| match &layer.source {
248 ConfigLayerSource::System { file } | ConfigLayerSource::User { file } => Some(file.clone()),
249 _ => None,
250 });
251 (config, config_path)
252 };
253
254 Ok(Self {
255 config,
256 config_path,
257 canonical_user_config_path,
258 tracked_user_config_paths,
259 write_global_config_to_canonical: false,
260 explicit_config_is_trusted: false,
261 workspace_root: None,
262 config_file_name,
263 layer_stack,
264 phase_timing: None,
265 })
266 }
267
268 pub fn invalidate_workspace_cache(workspace: impl AsRef<Path>) {
274 let workspace = workspace.as_ref();
275 #[cfg(not(test))]
276 {
277 let canonical_workspace = canonicalize_workspace_root(workspace);
278 cache_remove(&canonical_workspace);
279 }
280 #[cfg(test)]
281 let _ = workspace;
282 }
283
284 pub fn invalidate_all_workspace_cache() {
291 #[cfg(not(test))]
292 with_cache_mut(|map| map.clear());
293 }
294
295 pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
302 if let Some(override_path) = session_override::explicit_config_path() {
303 return Self::load_for_session(workspace, override_path);
304 }
305
306 let workspace = workspace.as_ref();
307 #[cfg(not(test))]
308 let canonical_workspace = canonicalize_workspace_root(workspace);
309
310 #[cfg(not(test))]
311 if let Some(cached) = cache_get(&canonical_workspace) {
312 return Ok(cached.as_ref().clone());
313 }
314
315 let manager = Self::load_from_workspace_impl(workspace)?;
316
317 #[cfg(not(test))]
318 cache_insert(canonical_workspace, Arc::new(manager.clone()));
319
320 Ok(manager)
321 }
322
323 pub fn load_from_workspace_with_repository_repair(workspace: impl AsRef<Path>) -> Result<Self> {
334 let workspace = workspace.as_ref();
335 let mut error = match Self::load_from_workspace(workspace) {
336 Ok(manager) => return Ok(manager),
337 Err(error) => error,
338 };
339
340 let repository_paths = Self::repository_config_paths(workspace);
344 for _ in 0..=repository_paths.len() {
345 let Some(violation) = error.downcast_ref::<RepositoryProviderSecurityViolation>() else {
346 return Err(error);
347 };
348
349 let source = violation.source.clone();
350 let Some(path) = repository_paths
351 .iter()
352 .find(|candidate| Self::repository_source_matches_path(&source, candidate))
353 else {
354 return Err(error);
355 };
356
357 if !Self::repair_repository_config_file(path)? {
358 return Err(error);
359 }
360
361 tracing::warn!(
362 path = %path.display(),
363 "Removed prohibited provider settings left by an older repository configuration write"
364 );
365
366 Self::invalidate_workspace_cache(workspace);
367 match Self::load_from_workspace(workspace) {
368 Ok(manager) => return Ok(manager),
369 Err(next_error) => error = next_error,
370 }
371 }
372
373 Err(error).context("Failed to load workspace configuration after repairing prohibited provider settings")
374 }
375
376 fn load_from_workspace_impl(workspace: impl AsRef<Path>) -> Result<Self> {
377 let t0 = std::time::Instant::now();
378 let workspace = workspace.as_ref();
379 let defaults_provider = defaults::current_config_defaults();
380 let workspace_paths = defaults_provider.workspace_paths_for(workspace);
381 let workspace_root = canonicalize_workspace_root(workspace_paths.workspace_root());
382 let config_dir = workspace_paths.config_dir();
383 let config_file_name = defaults_provider.config_file_name().to_string();
384 let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
385 let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
386 if let Some(path) = &canonical_user_config_path
387 && !tracked_user_config_paths.iter().any(|existing| existing == path)
388 {
389 tracked_user_config_paths.push(path.clone());
390 }
391 let path_res_duration = t0.elapsed();
392
393 let t1 = std::time::Instant::now();
394
395 let mut layer_sources: Vec<ConfigLayerSource> = Vec::with_capacity(8);
398
399 for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
402 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
403 }
404
405 for home_config_path in &tracked_user_config_paths {
407 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
408 }
409
410 if let Some(project_config_path) = Self::project_config_path(&config_dir, &workspace_root, &config_file_name) {
412 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Project { file: project_config_path });
413 }
414
415 let fallback_path = config_dir.join(&config_file_name);
417 let workspace_config_path = workspace_root.join(&config_file_name);
418 if fallback_path != workspace_config_path {
419 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Workspace { file: fallback_path });
420 }
421
422 push_unique_layer_source(
424 &mut layer_sources,
425 ConfigLayerSource::Workspace { file: workspace_config_path.clone() },
426 );
427
428 let raw_layers: Vec<Option<ConfigLayerEntry>> = std::thread::scope(|s| {
433 let handles = layer_sources
434 .into_iter()
435 .map(|source| s.spawn(move || Self::load_optional_layer(source)))
436 .collect::<Vec<_>>();
437 let joined = handles.into_iter().map(|h| h.join()).collect::<Vec<_>>();
439 joined
440 .into_iter()
441 .map(|r| r.map_err(|_payload| anyhow::anyhow!("config layer loader thread panicked")))
442 .collect::<Result<Vec<_>>>()
443 })?;
444
445 let mut layer_stack = ConfigLayerStack::default();
446 for layer in raw_layers.into_iter().flatten() {
447 layer_stack.push(layer);
448 }
449
450 let layer_load_duration = t1.elapsed();
451
452 if layer_stack.layers().is_empty() {
454 let t_val = std::time::Instant::now();
455 let config = VTCodeConfig::default();
456 config.validate().context("Default configuration failed validation")?;
457 let val_duration = t_val.elapsed();
458
459 let phase_timing = ConfigPhaseTiming {
460 path_resolution_us: path_res_duration.as_micros() as u64,
461 layer_loading_us: layer_load_duration.as_micros() as u64,
462 merge_and_parse_us: 0,
463 validation_us: val_duration.as_micros() as u64,
464 };
465 tracing::debug!(target: "vtcode_config", ?phase_timing, "Default configuration loaded");
466
467 return Ok(Self {
468 config,
469 config_path: None,
470 canonical_user_config_path,
471 tracked_user_config_paths,
472 write_global_config_to_canonical: false,
473 explicit_config_is_trusted: false,
474 workspace_root: Some(workspace_root),
475 config_file_name,
476 layer_stack,
477 phase_timing: Some(phase_timing),
478 });
479 }
480
481 let use_root_config = layer_stack
485 .layers()
486 .iter()
487 .find(|l| {
488 matches!(
489 &l.source,
490 ConfigLayerSource::Workspace { file }
491 if *file == workspace_config_path
492 )
493 })
494 .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
495 .unwrap_or(false);
496 if use_root_config {
497 layer_stack.retain(|layer| {
498 matches!(
499 &layer.source,
500 ConfigLayerSource::Workspace { file }
501 if *file == workspace_config_path
502 ) || matches!(&layer.source, ConfigLayerSource::Runtime)
503 });
504 if layer_stack.layers().is_empty() {
505 bail!(
506 "workspace.use_root_config is true but no workspace root config was found at {}",
507 workspace_config_path.display()
508 );
509 }
510 }
511
512 if let Some((layer, error)) = layer_stack.first_layer_error() {
513 bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
514 }
515
516 let t2 = std::time::Instant::now();
517 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
518 let mut config: VTCodeConfig = effective_toml
519 .try_into()
520 .context("Failed to deserialize effective configuration")?;
521 let merge_duration = t2.elapsed();
522
523 let t3 = std::time::Instant::now();
524 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
525 Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
526
527 config.validate().context("Configuration failed validation")?;
528 config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
529
530 migrate_custom_api_keys_if_needed(&mut config)?;
532 let val_duration = t3.elapsed();
533
534 let phase_timing = ConfigPhaseTiming {
535 path_resolution_us: path_res_duration.as_micros() as u64,
536 layer_loading_us: layer_load_duration.as_micros() as u64,
537 merge_and_parse_us: merge_duration.as_micros() as u64,
538 validation_us: val_duration.as_micros() as u64,
539 };
540 tracing::debug!(target: "vtcode_config", ?phase_timing, "Workspace configuration loaded");
541
542 let config_path = layer_stack
543 .layers()
544 .iter()
545 .rev()
546 .find(|layer| layer.is_enabled())
547 .and_then(|l| match &l.source {
548 ConfigLayerSource::User { file } => Some(file.clone()),
549 ConfigLayerSource::Project { file } => Some(file.clone()),
550 ConfigLayerSource::Workspace { file } => Some(file.clone()),
551 ConfigLayerSource::System { file } => Some(file.clone()),
552 ConfigLayerSource::Runtime => None,
553 });
554
555 Ok(Self {
556 config,
557 config_path,
558 canonical_user_config_path,
559 tracked_user_config_paths,
560 write_global_config_to_canonical: false,
561 explicit_config_is_trusted: false,
562 workspace_root: Some(workspace_root),
563 config_file_name,
564 layer_stack,
565 phase_timing: Some(phase_timing),
566 })
567 }
568
569 fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
570 let content =
571 fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
572 let mut value: toml::Value =
573 toml::from_str(&content).with_context(|| format!("Failed to parse config file: {}", path.display()))?;
574 crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut value);
575 Ok(value)
576 }
577
578 fn load_optional_layer(source: ConfigLayerSource) -> Option<ConfigLayerEntry> {
579 let file = match &source {
580 ConfigLayerSource::System { file }
581 | ConfigLayerSource::User { file }
582 | ConfigLayerSource::Project { file }
583 | ConfigLayerSource::Workspace { file } => file,
584 ConfigLayerSource::Runtime => {
585 return Some(ConfigLayerEntry::new(source, toml::Value::Table(toml::Table::new())));
586 }
587 };
588
589 let content = match fs::read_to_string(file) {
591 Ok(content) => content,
592 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
593 Err(err) if should_skip_optional_global_error(&source, &err) => {
594 tracing::debug!(path = %file.display(), "skipping inaccessible optional configuration layer");
595 return None;
596 }
597 Err(err) => {
598 let resolved_file = canonicalize_workspace_root(file);
599 let resolved_source = source.with_file(resolved_file);
600 return Some(Self::disabled_layer_from_error(resolved_source, err.into()));
601 }
602 };
603
604 let resolved_file = canonicalize_workspace_root(file);
605 let resolved_source = source.with_file(resolved_file);
606
607 match toml::from_str::<toml::Value>(&content) {
608 Ok(mut toml) => {
609 crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut toml);
610 Some(ConfigLayerEntry::new(resolved_source, toml))
611 }
612 Err(err) => {
613 let error =
614 anyhow::Error::from(err).context(format!("Failed to parse config file: {}", file.display()));
615 Some(Self::disabled_layer_from_error(resolved_source, error))
616 }
617 }
618 }
619
620 fn disabled_layer_from_error(source: ConfigLayerSource, error: anyhow::Error) -> ConfigLayerEntry {
621 let reason = if error.is::<toml::de::Error>() {
622 LayerDisabledReason::ParseError
623 } else {
624 LayerDisabledReason::LoadError
625 };
626 ConfigLayerEntry::disabled(source, reason, format!("{error:#}"))
627 }
628
629 fn workspace_root_wants_root_config_only(config: &toml::Value) -> bool {
634 config
635 .get("workspace")
636 .and_then(|v| v.get("use_root_config"))
637 .and_then(|v| v.as_bool())
638 .unwrap_or(false)
639 }
640
641 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
643 Self::load_from_file_impl(path, false)
644 }
645
646 fn load_from_file_impl(path: impl AsRef<Path>, write_global_config_to_canonical: bool) -> Result<Self> {
647 let path = path.as_ref();
648 let defaults_provider = defaults::current_config_defaults();
649 let config_file_name = defaults_provider.config_file_name().to_string();
656 let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
657 let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
658 if let Some(path) = &canonical_user_config_path
659 && !tracked_user_config_paths.iter().any(|existing| existing == path)
660 {
661 tracked_user_config_paths.push(path.clone());
662 }
663
664 let mut layer_stack = ConfigLayerStack::default();
665 let mut global_sources = Vec::new();
666
667 for system_config in defaults_provider.system_config_paths(&config_file_name)? {
668 push_unique_layer_source(&mut global_sources, ConfigLayerSource::System { file: system_config });
669 }
670 for home_config_path in &tracked_user_config_paths {
671 push_unique_layer_source(&mut global_sources, ConfigLayerSource::User { file: home_config_path.clone() });
672 }
673 for source in global_sources {
674 if let Some(layer) = Self::load_optional_layer(source) {
675 layer_stack.push(layer);
676 }
677 }
678
679 let file = path.to_path_buf();
681 match Self::load_toml_from_file(path) {
682 Ok(toml) => layer_stack.push(ConfigLayerEntry::new(ConfigLayerSource::Workspace { file }, toml)),
683 Err(error) => {
684 layer_stack.push(Self::disabled_layer_from_error(ConfigLayerSource::Workspace { file }, error))
685 }
686 }
687
688 let use_root_config = layer_stack
691 .layers()
692 .iter()
693 .find(|l| {
694 matches!(
695 &l.source,
696 ConfigLayerSource::Workspace { file }
697 if *file == path
698 )
699 })
700 .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
701 .unwrap_or(false);
702 if use_root_config {
703 layer_stack.retain(|layer| {
704 matches!(
705 &layer.source,
706 ConfigLayerSource::Workspace { file }
707 if *file == path
708 ) || matches!(&layer.source, ConfigLayerSource::Runtime)
709 });
710 }
711
712 if let Some((layer, error)) = layer_stack.first_layer_error() {
713 bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
714 }
715
716 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
717 let mut config: VTCodeConfig = effective_toml
718 .try_into()
719 .with_context(|| format!("Failed to parse effective config with file: {}", path.display()))?;
720 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
721 Self::validate_provider_security_fields(&layer_stack, &origins, &config, true)?;
725
726 config
727 .validate()
728 .with_context(|| format!("Failed to validate effective config with file: {}", path.display()))?;
729 config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
730
731 Ok(Self {
732 config,
733 config_path: Some(canonicalize_workspace_root(path)),
734 canonical_user_config_path,
735 tracked_user_config_paths,
736 write_global_config_to_canonical,
737 explicit_config_is_trusted: true,
738 workspace_root: path.parent().map(canonicalize_workspace_root),
739 config_file_name,
740 layer_stack,
741 phase_timing: None,
742 })
743 }
744
745 pub fn config(&self) -> &VTCodeConfig {
747 &self.config
748 }
749
750 pub fn phase_timing(&self) -> Option<ConfigPhaseTiming> {
752 self.phase_timing
753 }
754
755 pub fn config_path(&self) -> Option<&Path> {
757 self.config_path.as_deref()
758 }
759
760 #[must_use]
765 pub fn is_repository_controlled_path(&self, path: &Path) -> bool {
766 if self.explicit_config_is_trusted {
767 return false;
768 }
769
770 if self
771 .layer_stack
772 .layers()
773 .iter()
774 .any(|layer| layer.is_enabled() && Self::repository_source_matches_path(&layer.source, path))
775 {
776 return true;
777 }
778
779 self.config_path.is_none()
780 && self.workspace_root.as_deref().is_some_and(|workspace| {
781 Self::repository_source_matches_path(
782 &ConfigLayerSource::Workspace { file: workspace.join(&self.config_file_name) },
783 path,
784 )
785 })
786 }
787
788 pub fn workspace_root(&self) -> Option<&Path> {
790 self.workspace_root.as_deref()
791 }
792
793 pub fn preferred_workspace_config_path(&self, workspace: &Path) -> PathBuf {
801 self.layer_stack
802 .layers()
803 .iter()
804 .rev()
805 .find_map(|layer| match &layer.source {
806 ConfigLayerSource::Workspace { file } if layer.is_enabled() => Some(file.clone()),
807 _ => None,
808 })
809 .unwrap_or_else(|| workspace.join(&self.config_file_name))
810 }
811
812 pub fn config_file_name(&self) -> &str {
814 &self.config_file_name
815 }
816
817 pub fn layer_stack(&self) -> &ConfigLayerStack {
819 &self.layer_stack
820 }
821
822 pub fn preferred_user_config_path(&self) -> Option<PathBuf> {
824 self.canonical_user_config_path.clone()
825 }
826
827 pub fn user_config_paths(&self) -> Vec<PathBuf> {
833 let mut paths = self.tracked_user_config_paths.clone();
834
835 for path in self
836 .layer_stack
837 .layers()
838 .iter()
839 .filter_map(|layer| match (&layer.source, layer.is_enabled()) {
840 (ConfigLayerSource::User { file }, true) => Some(file),
841 _ => None,
842 })
843 {
844 if !paths.iter().any(|existing| existing == path) {
845 paths.push(path.clone());
846 }
847 }
848
849 paths
850 }
851
852 pub fn watched_config_paths(workspace: &Path) -> Vec<PathBuf> {
860 let provider = defaults::current_config_defaults();
861 let config_file_name = provider.config_file_name().to_string();
862 let workspace_paths = provider.workspace_paths_for(workspace);
863 let workspace_root = workspace_paths.workspace_root().to_path_buf();
864 let mut paths = Vec::new();
865
866 let mut push_unique = |path: PathBuf| {
867 if !paths.iter().any(|existing| existing == &path) {
868 paths.push(path);
869 }
870 };
871
872 if let Some(explicit_path) = session_override::explicit_config_path() {
873 push_unique(explicit_path);
874 }
875 if let Ok(system_paths) = provider.system_config_paths(&config_file_name) {
876 for path in system_paths {
877 push_unique(path);
878 }
879 }
880 for path in provider.home_config_paths(&config_file_name) {
881 push_unique(path);
882 }
883 if let Ok(Some(canonical_path)) = provider.canonical_user_config_path(&config_file_name) {
884 push_unique(canonical_path);
885 }
886
887 if let Some(project_name) = Self::current_project_name(&workspace_root) {
888 push_unique(
889 workspace_paths
890 .config_dir()
891 .join("projects")
892 .join(project_name)
893 .join("config")
894 .join(&config_file_name),
895 );
896 }
897
898 push_unique(workspace_paths.config_dir().join(&config_file_name));
899 push_unique(workspace_root.join(&config_file_name));
900 push_unique(workspace_root.join(".vtcode").join("theme.toml"));
901 paths
902 }
903
904 fn repository_config_paths(workspace: &Path) -> Vec<PathBuf> {
905 let provider = defaults::current_config_defaults();
906 let config_file_name = provider.config_file_name().to_string();
907 let workspace_paths = provider.workspace_paths_for(workspace);
908 let workspace_root = canonicalize_workspace_root(workspace_paths.workspace_root());
909 let config_dir = workspace_paths.config_dir();
910 let mut paths = Vec::with_capacity(3);
911
912 let mut push_unique = |path: PathBuf| {
913 if !paths.iter().any(|existing| existing == &path) {
914 paths.push(path);
915 }
916 };
917
918 if let Some(project_path) = Self::project_config_path(&config_dir, &workspace_root, &config_file_name) {
919 push_unique(project_path);
920 }
921 push_unique(config_dir.join(&config_file_name));
922 push_unique(workspace_root.join(&config_file_name));
923 paths
924 }
925
926 fn repository_source_matches_path(source: &ConfigLayerSource, path: &Path) -> bool {
927 let Some(source_file) = (match source {
928 ConfigLayerSource::Project { file } | ConfigLayerSource::Workspace { file } => Some(file),
929 ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. } | ConfigLayerSource::Runtime => None,
930 }) else {
931 return false;
932 };
933
934 canonicalize_workspace_root(source_file) == canonicalize_workspace_root(path)
935 }
936
937 pub fn effective_config(&self) -> toml::Value {
939 self.layer_stack.effective_config()
940 }
941
942 pub fn has_explicit_top_level_key(&self, key: &str) -> bool {
947 self.layer_stack
948 .layers()
949 .iter()
950 .filter(|layer| layer.is_enabled())
951 .any(|layer| layer.config.as_table().is_some_and(|table| table.contains_key(key)))
952 }
953
954 pub fn session_duration(&self) -> std::time::Duration {
956 std::time::Duration::from_secs(60 * 60) }
958
959 pub fn save_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
961 Self::save_config_to_path_internal(path, config, false)
962 }
963
964 pub fn save_repository_config_to_path(path: impl AsRef<Path>, config: &VTCodeConfig) -> Result<()> {
972 Self::save_config_to_path_internal(path, config, true)
973 }
974
975 fn save_config_to_path_internal(
976 path: impl AsRef<Path>,
977 config: &VTCodeConfig,
978 repository_controlled: bool,
979 ) -> Result<()> {
980 let path = path.as_ref();
981 ensure_private_parent_dir(path)?;
982 let config_to_persist = if repository_controlled {
983 Self::repository_safe_config(config)
984 } else {
985 config.clone()
986 };
987 let sparse_value =
988 Self::sparse_config_value(&config_to_persist).context("Failed to prepare sparse configuration")?;
989 let sparse_content =
990 toml::to_string_pretty(&sparse_value).context("Failed to serialize sparse configuration")?;
991
992 let existing_content = match fs::symlink_metadata(path) {
996 Ok(metadata) if metadata.file_type().is_symlink() => {
997 bail!("Refusing to read symlinked config file: {}", path.display())
998 }
999 Ok(metadata) if !metadata.is_file() => {
1000 bail!("Config path is not a regular file: {}", path.display())
1001 }
1002 Ok(_) => Some(
1003 String::from_utf8(VtCodePaths::read_file_no_follow(path)?)
1004 .with_context(|| format!("Failed to read existing config: {}", path.display()))?,
1005 ),
1006 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1007 Err(error) => {
1008 return Err(error).with_context(|| format!("Failed to inspect config: {}", path.display()));
1009 }
1010 };
1011
1012 if let Some(original_content) = existing_content {
1013 let mut doc = original_content
1014 .parse::<toml_edit::DocumentMut>()
1015 .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
1016 Self::remove_deprecated_config_keys(&mut doc);
1017 if repository_controlled {
1018 Self::remove_repository_provider_settings(&mut doc);
1019 }
1020
1021 let new_doc: toml_edit::DocumentMut = sparse_content
1022 .parse()
1023 .context("Failed to parse sparse serialized configuration")?;
1024 let default_value =
1025 toml::Value::try_from(VTCodeConfig::default()).context("Failed to serialize default configuration")?;
1026 let default_doc: toml_edit::DocumentMut = toml::to_string_pretty(&default_value)
1027 .context("Failed to serialize default configuration")?
1028 .parse()
1029 .context("Failed to parse default serialized configuration")?;
1030
1031 Self::merge_sparse_toml_documents(&mut doc, &new_doc, &default_doc);
1033 Self::prune_cleared_optional_fields(&mut doc, &config_to_persist);
1039
1040 VtCodePaths::write_private_file_atomic(path, doc.to_string().as_bytes())
1041 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
1042 } else {
1043 VtCodePaths::write_private_file_atomic(path, sparse_content.as_bytes())
1044 .with_context(|| format!("Failed to write config file: {}", path.display()))?;
1045 }
1046
1047 Ok(())
1048 }
1049
1050 fn repository_safe_config(config: &VTCodeConfig) -> VTCodeConfig {
1051 let mut safe_config = config.clone();
1052 safe_config.custom_providers.clear();
1053 for provider_override in safe_config.provider_overrides.values_mut() {
1054 provider_override.base_url = None;
1055 provider_override.api_key_env = None;
1056 }
1057 safe_config
1058 }
1059
1060 fn prune_cleared_optional_fields(doc: &mut toml_edit::DocumentMut, config: &VTCodeConfig) {
1068 let cleared: &[(&[&str], bool)] =
1070 &[(&["provider", "openai", "service_tier"], config.provider.openai.service_tier.is_none())];
1071 for (path, is_cleared) in cleared {
1072 if *is_cleared {
1073 Self::remove_path_and_prune_empty_parents(doc.as_table_mut(), path);
1074 }
1075 }
1076 }
1077
1078 fn remove_path_and_prune_empty_parents(table: &mut toml_edit::Table, path: &[&str]) -> bool {
1081 let Some((first, rest)) = path.split_first() else {
1082 return table.is_empty();
1083 };
1084 if rest.is_empty() {
1085 table.remove(first);
1086 } else if let Some(child) = table.get_mut(first).and_then(toml_edit::Item::as_table_mut) {
1087 if Self::remove_path_and_prune_empty_parents(child, rest) {
1088 table.remove(first);
1089 }
1090 }
1091 table.is_empty()
1092 }
1093
1094 fn repair_repository_config_file(path: &Path) -> Result<bool> {
1095 let metadata = match fs::symlink_metadata(path) {
1096 Ok(metadata) if metadata.file_type().is_symlink() => {
1097 bail!("Refusing to repair symlinked config file: {}", path.display())
1098 }
1099 Ok(metadata) if !metadata.is_file() => {
1100 bail!("Config path is not a regular file: {}", path.display())
1101 }
1102 Ok(metadata) => metadata,
1103 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
1104 Err(error) => {
1105 return Err(error).with_context(|| format!("Failed to inspect config: {}", path.display()));
1106 }
1107 };
1108 debug_assert!(metadata.is_file());
1109
1110 let content = String::from_utf8(VtCodePaths::read_file_no_follow(path)?)
1111 .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
1112 let mut doc = content
1113 .parse::<toml_edit::DocumentMut>()
1114 .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
1115 if !Self::remove_repository_provider_settings(&mut doc) {
1116 return Ok(false);
1117 }
1118
1119 VtCodePaths::write_private_file_atomic(path, doc.to_string().as_bytes())
1120 .with_context(|| format!("Failed to repair config file: {}", path.display()))?;
1121 Ok(true)
1122 }
1123
1124 fn remove_repository_provider_settings(doc: &mut toml_edit::DocumentMut) -> bool {
1125 let table = doc.as_table_mut();
1126 let mut changed = table.remove("custom_providers").is_some();
1127
1128 let remove_provider_overrides = {
1129 let Some(overrides_item) = table.get_mut("provider_overrides") else {
1130 return changed;
1131 };
1132
1133 if let Some(overrides) = overrides_item.as_table_mut() {
1134 changed |= Self::remove_provider_override_settings_from_table(overrides);
1135 overrides.is_empty()
1136 } else if let Some(overrides) = overrides_item.as_inline_table_mut() {
1137 changed |= Self::remove_provider_override_settings_from_inline_table(overrides);
1138 overrides.is_empty()
1139 } else {
1140 false
1141 }
1142 };
1143
1144 if remove_provider_overrides {
1145 changed |= table.remove("provider_overrides").is_some();
1146 }
1147 changed
1148 }
1149
1150 fn remove_provider_override_settings_from_table(overrides: &mut toml_edit::Table) -> bool {
1151 let provider_names = overrides.iter().map(|(name, _)| name.to_string()).collect::<Vec<_>>();
1152 let mut empty_providers = Vec::new();
1153 let mut changed = false;
1154
1155 for provider_name in provider_names {
1156 let Some(provider_item) = overrides.get_mut(&provider_name) else {
1157 continue;
1158 };
1159
1160 let is_empty = if let Some(provider) = provider_item.as_table_mut() {
1161 changed |= provider.remove("base_url").is_some();
1162 changed |= provider.remove("api_key_env").is_some();
1163 provider.is_empty()
1164 } else if let Some(provider) = provider_item.as_inline_table_mut() {
1165 changed |= provider.remove("base_url").is_some();
1166 changed |= provider.remove("api_key_env").is_some();
1167 provider.is_empty()
1168 } else {
1169 false
1170 };
1171
1172 if is_empty {
1173 empty_providers.push(provider_name);
1174 }
1175 }
1176
1177 for provider_name in empty_providers {
1178 changed |= overrides.remove(&provider_name).is_some();
1179 }
1180 changed
1181 }
1182
1183 fn remove_provider_override_settings_from_inline_table(overrides: &mut toml_edit::InlineTable) -> bool {
1184 let provider_names = overrides.iter().map(|(name, _)| name.to_string()).collect::<Vec<_>>();
1185 let mut empty_providers = Vec::new();
1186 let mut changed = false;
1187
1188 for provider_name in provider_names {
1189 let Some(provider) = overrides
1190 .get_mut(&provider_name)
1191 .and_then(toml_edit::Value::as_inline_table_mut)
1192 else {
1193 continue;
1194 };
1195
1196 changed |= provider.remove("base_url").is_some();
1197 changed |= provider.remove("api_key_env").is_some();
1198 if provider.is_empty() {
1199 empty_providers.push(provider_name);
1200 }
1201 }
1202
1203 for provider_name in empty_providers {
1204 changed |= overrides.remove(&provider_name).is_some();
1205 }
1206 changed
1207 }
1208
1209 fn remove_deprecated_config_keys(doc: &mut toml_edit::DocumentMut) {
1210 let table = doc.as_table_mut();
1211 table.remove("project_doc_max_bytes");
1212 table.remove("project_doc_fallback_filenames");
1213 Self::remove_table_keys(table, "agent", &["autonomous_mode", "default_editing_mode"]);
1214 Self::remove_table_keys(table, "permissions", &["allowed_tools", "disallowed_tools", "auto_permission"]);
1215 }
1216
1217 fn remove_table_keys(table: &mut toml_edit::Table, section: &str, keys: &[&str]) {
1218 let Some(section) = table.get_mut(section).and_then(toml_edit::Item::as_table_mut) else {
1219 return;
1220 };
1221
1222 for key in keys {
1223 section.remove(key);
1224 }
1225 }
1226
1227 pub fn sparse_config_value(config: &VTCodeConfig) -> Result<toml::Value> {
1228 let mut value = toml::Value::try_from(config).context("Failed to serialize configuration")?;
1229 let default_value =
1230 toml::Value::try_from(VTCodeConfig::default()).context("Failed to serialize default configuration")?;
1231 Self::prune_default_values(&mut value, &default_value);
1232 Ok(value)
1233 }
1234
1235 fn prune_default_values(value: &mut toml::Value, default_value: &toml::Value) -> bool {
1236 match (value, default_value) {
1237 (toml::Value::Table(table), toml::Value::Table(default_table)) => {
1238 table.retain(|key, child| {
1239 default_table
1240 .get(key)
1241 .is_none_or(|default_child| !Self::prune_default_values(child, default_child))
1242 });
1243 table.is_empty()
1244 }
1245 (value, default_value) => value == default_value,
1246 }
1247 }
1248
1249 fn merge_sparse_toml_documents(
1251 original: &mut toml_edit::DocumentMut,
1252 new: &toml_edit::DocumentMut,
1253 default_doc: &toml_edit::DocumentMut,
1254 ) {
1255 Self::merge_sparse_tables(original.as_table_mut(), new.as_table(), default_doc.as_table());
1256 }
1257
1258 fn merge_sparse_tables(original: &mut toml_edit::Table, new: &toml_edit::Table, default_table: &toml_edit::Table) {
1259 let mut remove_keys = Vec::with_capacity(default_table.len());
1260
1261 for (key, default_value) in default_table.iter() {
1262 if let Some(new_value) = new.get(key) {
1263 if let Some(original_value) = original.get_mut(key) {
1264 Self::merge_sparse_items(original_value, new_value, default_value);
1265 } else {
1266 original[key] = new_value.clone();
1267 }
1268 } else {
1269 let Some(original_value) = original.get_mut(key) else {
1270 continue;
1271 };
1272 if Self::remove_known_default_item(original_value, default_value) {
1273 remove_keys.push(key.to_string());
1274 }
1275 }
1276 }
1277
1278 for key in remove_keys {
1279 original.remove(&key);
1280 }
1281
1282 for (key, new_value) in new.iter() {
1283 if default_table.contains_key(key) {
1284 continue;
1285 }
1286 if let Some(original_value) = original.get_mut(key) {
1287 *original_value = new_value.clone();
1288 } else {
1289 original[key] = new_value.clone();
1290 }
1291 }
1292 }
1293
1294 fn merge_sparse_items(original: &mut toml_edit::Item, new: &toml_edit::Item, default_value: &toml_edit::Item) {
1295 match (original, new, default_value) {
1296 (
1297 toml_edit::Item::Table(orig_table),
1298 toml_edit::Item::Table(new_table),
1299 toml_edit::Item::Table(default_table),
1300 ) => Self::merge_sparse_tables(orig_table, new_table, default_table),
1301 (orig, new, _) => {
1302 *orig = new.clone();
1303 }
1304 }
1305 }
1306
1307 fn remove_known_default_item(original: &mut toml_edit::Item, default_value: &toml_edit::Item) -> bool {
1308 match (original, default_value) {
1309 (toml_edit::Item::Table(orig_table), toml_edit::Item::Table(default_table)) => {
1310 let mut remove_keys = Vec::new();
1311 for (key, default_child) in default_table.iter() {
1312 let Some(orig_child) = orig_table.get_mut(key) else {
1313 continue;
1314 };
1315 if Self::remove_known_default_item(orig_child, default_child) {
1316 remove_keys.push(key.to_string());
1317 }
1318 }
1319 for key in remove_keys {
1320 orig_table.remove(&key);
1321 }
1322 orig_table.is_empty()
1323 }
1324 _ => true,
1325 }
1326 }
1327
1328 fn project_config_path(config_dir: &Path, workspace_root: &Path, config_file_name: &str) -> Option<PathBuf> {
1329 let project_name = Self::identify_current_project(workspace_root)?;
1330 Some(
1331 config_dir
1332 .join("projects")
1333 .join(project_name)
1334 .join("config")
1335 .join(config_file_name),
1336 )
1337 }
1338
1339 fn identify_current_project(workspace_root: &Path) -> Option<String> {
1340 let project_file = workspace_root.join(".vtcode-project");
1341 if let Ok(contents) = fs::read_to_string(&project_file) {
1342 let name = contents.trim();
1343 if !name.is_empty() {
1344 return Some(name.to_string());
1345 }
1346 }
1347
1348 workspace_root
1349 .file_name()
1350 .and_then(|name| name.to_str())
1351 .map(|name| name.to_string())
1352 }
1353
1354 pub fn current_project_name(workspace_root: &Path) -> Option<String> {
1356 Self::identify_current_project(workspace_root)
1357 }
1358
1359 fn validate_restricted_agent_fields(
1360 layer_stack: &ConfigLayerStack,
1361 origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
1362 ) -> Result<()> {
1363 if let Some(origin) = origins.get("agent.persistent_memory.directory_override")
1364 && let Some(layer) = layer_stack.layers().iter().find(|layer| layer.metadata == *origin)
1365 {
1366 match layer.source {
1367 ConfigLayerSource::System { .. }
1368 | ConfigLayerSource::User { .. }
1369 | ConfigLayerSource::Project { .. } => {}
1370 ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Runtime => {
1371 bail!(
1372 "agent.persistent_memory.directory_override may only be set in system, user, or project-profile configuration layers"
1373 );
1374 }
1375 }
1376 }
1377
1378 Ok(())
1379 }
1380
1381 fn validate_provider_security_fields(
1390 layer_stack: &ConfigLayerStack,
1391 origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
1392 config: &VTCodeConfig,
1393 explicit_config_is_trusted: bool,
1394 ) -> Result<()> {
1395 if explicit_config_is_trusted {
1396 return Ok(());
1397 }
1398
1399 if !config.custom_providers.is_empty()
1400 && let Some(origin) = origins.get("custom_providers")
1401 && let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin)
1402 && Self::is_repository_controlled_source(&layer.source)
1403 {
1404 return Err(RepositoryProviderSecurityViolation {
1405 source: layer.source.clone(),
1406 message: format!(
1407 "repository-controlled configuration cannot define `custom_providers` (including `auth.command`); move custom provider settings to system, user, or an explicitly selected config file (source: {})",
1408 layer.source.label()
1409 ),
1410 }
1411 .into());
1412 }
1413
1414 for (path, origin) in origins {
1415 if !Self::is_provider_endpoint_or_credential_path(path) {
1416 continue;
1417 }
1418
1419 let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin) else {
1420 continue;
1421 };
1422 if Self::is_repository_controlled_source(&layer.source) {
1423 return Err(RepositoryProviderSecurityViolation {
1424 source: layer.source.clone(),
1425 message: format!(
1426 "repository-controlled configuration cannot set `{path}`; provider endpoints and credential environment variables must be configured in system, user, or an explicitly selected config file (source: {})",
1427 layer.source.label()
1428 ),
1429 }
1430 .into());
1431 }
1432 }
1433
1434 Ok(())
1435 }
1436
1437 fn enabled_layer_for_origin<'a>(
1438 layer_stack: &'a ConfigLayerStack,
1439 origin: &ConfigLayerMetadata,
1440 ) -> Option<&'a ConfigLayerEntry> {
1441 layer_stack
1442 .layers()
1443 .iter()
1444 .find(|layer| layer.is_enabled() && layer.metadata == *origin)
1445 }
1446
1447 fn is_repository_controlled_source(source: &ConfigLayerSource) -> bool {
1448 matches!(source, ConfigLayerSource::Project { .. } | ConfigLayerSource::Workspace { .. })
1449 }
1450
1451 fn is_provider_endpoint_or_credential_path(path: &str) -> bool {
1452 let mut segments = path.split('.');
1453 matches!(segments.next(), Some("provider_overrides"))
1454 && segments.next().is_some()
1455 && matches!(segments.next(), Some("base_url" | "api_key_env"))
1456 && segments.next().is_none()
1457 }
1458
1459 pub(crate) fn collect_workspace_lifecycle_hooks(
1465 layer_stack: &ConfigLayerStack,
1466 hooks: &HooksConfig,
1467 ) -> WorkspaceLifecycleHooks {
1468 let (_, origins) = layer_stack.effective_config_with_origins();
1469 let mut commands = Vec::new();
1470
1471 for event in crate::hooks::LIFECYCLE_HOOK_EVENTS {
1472 let origin_key = format!("hooks.lifecycle.{event}");
1473 let Some(origin) = origins.get(&origin_key) else {
1474 continue;
1475 };
1476 let workspace_controlled = layer_stack.layers().iter().any(|layer| {
1477 layer.is_enabled()
1478 && layer.metadata == *origin
1479 && matches!(layer.source, ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Project { .. })
1480 });
1481 if !workspace_controlled {
1482 continue;
1483 }
1484
1485 let tag = match *event {
1487 "task_completion" | "task_completed" => "stop",
1488 other => other,
1489 };
1490 for group in hooks.lifecycle.groups_for_event(event) {
1491 for command in &group.hooks {
1492 commands.push(WorkspaceHookCommand {
1493 event: tag.to_string(),
1494 matcher: group.matcher.clone(),
1495 command: command.command.clone(),
1496 timeout_seconds: command.timeout_seconds,
1497 });
1498 }
1499 }
1500 }
1501
1502 WorkspaceLifecycleHooks { commands }
1503 }
1504
1505 pub fn save_config(&mut self, config: &VTCodeConfig) -> Result<()> {
1507 let (target, repository_controlled) = if let Some(path) = &self.config_path {
1508 if self.write_global_config_to_canonical || self.is_global_config_path(path) {
1509 (self.preferred_user_config_path().unwrap_or_else(|| path.clone()), false)
1510 } else {
1511 (path.clone(), !self.explicit_config_is_trusted)
1512 }
1513 } else if let Some(workspace_root) = &self.workspace_root {
1514 (workspace_root.join(&self.config_file_name), !self.explicit_config_is_trusted)
1515 } else {
1516 let cwd = std::env::current_dir().context("Failed to resolve current directory")?;
1517 (cwd.join(&self.config_file_name), !self.explicit_config_is_trusted)
1518 };
1519
1520 if repository_controlled {
1521 Self::save_repository_config_to_path(target, config)?;
1522 } else {
1523 Self::save_config_to_path(target, config)?;
1524 }
1525
1526 #[cfg(not(test))]
1527 if let Some(workspace) = &self.workspace_root {
1528 Self::invalidate_workspace_cache(workspace);
1529 }
1530
1531 self.sync_from_config(config)
1532 }
1533
1534 fn is_global_config_path(&self, path: &Path) -> bool {
1535 self.layer_stack.layers().iter().any(|layer| {
1536 layer.is_enabled()
1537 && matches!(layer.source, ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. })
1538 && match &layer.source {
1539 ConfigLayerSource::System { file } | ConfigLayerSource::User { file } => file == path,
1540 _ => false,
1541 }
1542 })
1543 }
1544
1545 fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
1548 self.config = config.clone();
1549 Ok(())
1550 }
1551}
1552
1553fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
1563 let storage_mode = config.agent.credential_storage_mode;
1564
1565 let has_plain_text_keys = config.agent.custom_api_keys.values().any(|key| !key.is_empty());
1567
1568 if has_plain_text_keys {
1569 tracing::info!("Detected plain-text API keys in config, migrating to secure storage...");
1570
1571 let mut migrated_count = 0;
1572 let pending = config
1573 .agent
1574 .custom_api_keys
1575 .iter()
1576 .filter(|(_, key)| !key.is_empty())
1577 .map(|(provider, key)| (provider.clone(), key.clone()))
1578 .collect::<Vec<_>>();
1579 for (provider, api_key) in pending {
1580 let key_name = config
1581 .configured_api_key_env(&provider)
1582 .unwrap_or_else(|| api_key_env_var(&provider));
1583 if let Some(identity) = store_credential_with_mode(&provider, &key_name, &api_key, storage_mode)? {
1584 config.agent.custom_api_keys.remove(&provider);
1585 if let Some(metadata_key) = credential_metadata_key(identity.provider(), identity.key_name())? {
1586 config.agent.custom_api_keys.insert(metadata_key, String::new());
1587 }
1588 migrated_count += 1;
1589 }
1590 }
1591
1592 if migrated_count > 0 {
1593 tracing::info!("Successfully migrated {} API key(s) to secure storage", migrated_count);
1594 tracing::warn!(
1595 "Plain-text API keys have been cleared from config file. \
1596 Please commit the updated config to remove sensitive data from version control."
1597 );
1598 }
1599 }
1600
1601 Ok(())
1602}
1603
1604#[cfg(test)]
1605mod sparse_config_tests {
1606 use super::*;
1607
1608 #[test]
1609 fn sparse_config_omits_default_primary_agent_when_unmodified() {
1610 let config = VTCodeConfig::default();
1611 let value = ConfigManager::sparse_config_value(&config).expect("sparse config");
1612
1613 assert!(value.get("default_primary_agent").is_none());
1614 }
1615
1616 #[test]
1617 fn sparse_config_persists_non_default_primary_agent() {
1618 let config = VTCodeConfig {
1619 default_primary_agent: "auto".to_string(),
1620 ..Default::default()
1621 };
1622
1623 let value = ConfigManager::sparse_config_value(&config).expect("sparse config");
1624
1625 assert_eq!(value.get("default_primary_agent").and_then(toml::Value::as_str), Some("auto"));
1626 }
1627
1628 #[test]
1629 fn has_explicit_top_level_key_matches_effective_config() {
1630 let temp_dir = tempfile::tempdir().expect("temp dir");
1631 let workspace = temp_dir.path().join("workspace");
1632 fs::create_dir_all(workspace.join(".vtcode")).expect("workspace dot dir");
1633
1634 let manager = crate::loader::ConfigBuilder::new()
1635 .workspace(workspace.clone())
1636 .build()
1637 .expect("manager");
1638 assert!(!manager.has_explicit_top_level_key("default_primary_agent"));
1639 assert!(
1640 manager
1641 .effective_config()
1642 .as_table()
1643 .is_none_or(|table| !table.contains_key("default_primary_agent"))
1644 );
1645
1646 let overridden = crate::loader::ConfigBuilder::new()
1647 .workspace(workspace)
1648 .cli_override("default_primary_agent".to_owned(), toml::Value::String("duck".to_owned()))
1649 .build()
1650 .expect("overridden manager");
1651 assert!(overridden.has_explicit_top_level_key("default_primary_agent"));
1652 assert!(
1653 overridden
1654 .effective_config()
1655 .as_table()
1656 .is_some_and(|table| table.contains_key("default_primary_agent"))
1657 );
1658 assert!(!overridden.has_explicit_top_level_key("definitely_missing_key"));
1659 }
1660
1661 #[test]
1662 fn save_config_migrates_legacy_permissions_auto_permission_table() {
1663 let temp_dir = tempfile::tempdir().expect("temp dir");
1664 let config_path = temp_dir.path().join("vtcode.toml");
1665 fs::write(
1666 &config_path,
1667 r#"
1668[permissions.auto_permission]
1669model = "legacy-reviewer"
1670max_consecutive_denials = 2
1671"#,
1672 )
1673 .expect("write legacy config");
1674
1675 let mut config = VTCodeConfig::default();
1676 config.permissions.auto_permission.model = "legacy-reviewer".to_string();
1677 config.permissions.auto_permission.max_consecutive_denials = 2;
1678
1679 ConfigManager::save_config_to_path(&config_path, &config).expect("save config");
1680
1681 let saved_content = fs::read_to_string(&config_path).expect("read saved config");
1682 assert!(
1683 saved_content.contains("[permissions.auto]"),
1684 "canonical auto permission table should be persisted. Got:\n{saved_content}"
1685 );
1686 assert!(
1687 !saved_content.contains("auto_permission"),
1688 "legacy auto_permission table should be removed. Got:\n{saved_content}"
1689 );
1690
1691 let reloaded = ConfigManager::load_from_file(&config_path).expect("reload saved config");
1692 assert_eq!(reloaded.config().permissions.auto_permission.model, "legacy-reviewer");
1693 assert_eq!(reloaded.config().permissions.auto_permission.max_consecutive_denials, 2);
1694 }
1695
1696 #[test]
1697 fn save_config_removes_legacy_permissions_auto_permission_when_sparse_default() {
1698 let temp_dir = tempfile::tempdir().expect("temp dir");
1699 let config_path = temp_dir.path().join("vtcode.toml");
1700 fs::write(
1701 &config_path,
1702 r#"
1703[permissions.auto_permission]
1704max_consecutive_denials = 3
1705"#,
1706 )
1707 .expect("write legacy config");
1708
1709 ConfigManager::save_config_to_path(&config_path, &VTCodeConfig::default()).expect("save config");
1710
1711 let saved_content = fs::read_to_string(&config_path).expect("read saved config");
1712 assert!(
1713 !saved_content.contains("auto_permission"),
1714 "legacy auto_permission table should be removed. Got:\n{saved_content}"
1715 );
1716 assert!(
1717 !saved_content.contains("[permissions.auto]"),
1718 "default auto permission config should remain sparse. Got:\n{saved_content}"
1719 );
1720
1721 ConfigManager::load_from_file(&config_path).expect("reload saved config");
1722 }
1723
1724 #[test]
1725 fn inaccessible_optional_global_layers_are_skipped() {
1726 let error = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
1727 assert!(should_skip_optional_global_error(
1728 &ConfigLayerSource::System { file: PathBuf::from("/etc/vtcode/vtcode.toml") },
1729 &error
1730 ));
1731 assert!(should_skip_optional_global_error(
1732 &ConfigLayerSource::User {
1733 file: PathBuf::from("/home/user/.config/vtcode/vtcode.toml")
1734 },
1735 &error
1736 ));
1737 assert!(!should_skip_optional_global_error(
1738 &ConfigLayerSource::Workspace { file: PathBuf::from("/workspace/vtcode.toml") },
1739 &error
1740 ));
1741 }
1742
1743 #[test]
1744 fn disabled_layer_reason_tracks_concrete_error_type() {
1745 let temp_dir = tempfile::tempdir().expect("temp dir");
1746 let parse_path = temp_dir.path().join("invalid.toml");
1747 fs::write(&parse_path, "key = [").expect("write invalid TOML");
1748 let parse_error = ConfigManager::load_toml_from_file(&parse_path).expect_err("invalid TOML should fail");
1749 let parse_layer =
1750 ConfigManager::disabled_layer_from_error(ConfigLayerSource::Project { file: parse_path }, parse_error);
1751
1752 assert_eq!(parse_layer.disabled_reason, Some(LayerDisabledReason::ParseError));
1753
1754 let read_path = temp_dir.path().join("parse");
1755 fs::create_dir(&read_path).expect("create directory at config path");
1756 let read_error = ConfigManager::load_toml_from_file(&read_path).expect_err("reading a directory should fail");
1757 assert!(read_error.to_string().contains("parse"));
1758 let read_layer =
1759 ConfigManager::disabled_layer_from_error(ConfigLayerSource::Project { file: read_path }, read_error);
1760
1761 assert_eq!(read_layer.disabled_reason, Some(LayerDisabledReason::LoadError));
1762 }
1763}
1764
1765#[cfg(test)]
1766mod workspace_lifecycle_hooks_tests {
1767 use super::*;
1768 use crate::hooks::WorkspaceLifecycleHooks;
1769
1770 fn layer(source: ConfigLayerSource, content: &str) -> ConfigLayerEntry {
1771 ConfigLayerEntry::new(source, toml::from_str(content).expect("valid test toml"))
1772 }
1773
1774 fn collect(stack: &ConfigLayerStack) -> WorkspaceLifecycleHooks {
1775 let (effective_toml, _) = stack.effective_config_with_origins();
1776 let config: VTCodeConfig = effective_toml.try_into().expect("effective config parses");
1777 ConfigManager::collect_workspace_lifecycle_hooks(stack, &config.hooks)
1778 }
1779
1780 const WS_SESSION_START: &str = r#"
1781[[hooks.lifecycle.session_start]]
1782matcher = "startup"
1783
1784[[hooks.lifecycle.session_start.hooks]]
1785command = "echo workspace-session"
1786timeout_seconds = 30
1787"#;
1788
1789 const USER_SESSION_END: &str = r#"
1790[[hooks.lifecycle.session_end]]
1791[[hooks.lifecycle.session_end.hooks]]
1792command = "echo user-session-end"
1793"#;
1794
1795 #[test]
1796 fn workspace_layer_hooks_are_collected_user_hooks_are_not() {
1797 let mut stack = ConfigLayerStack::default();
1798 stack.push(layer(ConfigLayerSource::User { file: "/home/u/.vtcode/vtcode.toml".into() }, USER_SESSION_END));
1799 stack.push(layer(ConfigLayerSource::Workspace { file: "/ws/vtcode.toml".into() }, WS_SESSION_START));
1800
1801 let collected = collect(&stack);
1802
1803 assert_eq!(collected.commands.len(), 1, "only workspace hooks should be collected");
1804 let command = &collected.commands[0];
1805 assert_eq!(command.event, "session_start");
1806 assert_eq!(command.command, "echo workspace-session");
1807 assert_eq!(command.matcher.as_deref(), Some("startup"));
1808 assert_eq!(command.timeout_seconds, Some(30));
1809 assert!(!collected.is_empty());
1810 }
1811
1812 #[test]
1813 fn user_only_hooks_are_not_workspace_controlled() {
1814 let mut stack = ConfigLayerStack::default();
1815 stack.push(layer(ConfigLayerSource::User { file: "/home/u/.vtcode/vtcode.toml".into() }, USER_SESSION_END));
1816
1817 let collected = collect(&stack);
1818
1819 assert!(collected.is_empty());
1820 }
1821
1822 #[test]
1823 fn deprecated_task_completion_aliases_fold_into_stop() {
1824 let mut stack = ConfigLayerStack::default();
1825 stack.push(layer(
1826 ConfigLayerSource::Workspace { file: "/ws/vtcode.toml".into() },
1827 r#"
1828[[hooks.lifecycle.task_completion]]
1829[[hooks.lifecycle.task_completion.hooks]]
1830command = "echo ws-task-completion"
1831"#,
1832 ));
1833
1834 let collected = collect(&stack);
1835
1836 assert_eq!(collected.commands.len(), 1);
1837 assert_eq!(collected.commands[0].event, "stop");
1838 assert_eq!(collected.commands[0].command, "echo ws-task-completion");
1839 }
1840
1841 #[test]
1842 fn empty_workspace_hook_arrays_do_not_flag_content() {
1843 let mut stack = ConfigLayerStack::default();
1844 stack.push(layer(
1845 ConfigLayerSource::Workspace { file: "/ws/vtcode.toml".into() },
1846 "[hooks.lifecycle]\nsession_start = []\n",
1847 ));
1848
1849 let collected = collect(&stack);
1850
1851 assert!(collected.is_empty(), "an empty workspace hook array is not executable content");
1852 }
1853
1854 #[test]
1855 fn load_from_file_populates_workspace_lifecycle_hooks() {
1856 let temp_dir = tempfile::tempdir().expect("temp dir");
1857 let config_path = temp_dir.path().join("vtcode.toml");
1858 fs::write(&config_path, WS_SESSION_START).expect("write workspace config");
1859
1860 let manager = ConfigManager::load_from_file(&config_path).expect("load config");
1861 let workspace_hooks = manager
1862 .config()
1863 .workspace_lifecycle_hooks
1864 .as_ref()
1865 .expect("workspace hooks populated at load");
1866
1867 assert!(!workspace_hooks.is_empty());
1868 assert_eq!(workspace_hooks.commands.len(), 1);
1869 assert_eq!(workspace_hooks.commands[0].command, "echo workspace-session");
1870 }
1871}