Skip to main content

vtcode_config/loader/
manager.rs

1mod persistence;
2
3use std::collections::HashMap;
4use std::fmt;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, Mutex};
8
9use anyhow::{Context, Result, bail};
10
11use crate::api_keys::{api_key_env_var, credential_metadata_key, store_credential_with_mode};
12use crate::defaults::{self};
13use crate::hooks::{HooksConfig, LifecycleHooksConfig, WorkspaceHookCommand, WorkspaceLifecycleHooks};
14use crate::loader::config::VTCodeConfig;
15use crate::loader::layers::{
16    ConfigLayerEntry, ConfigLayerMetadata, ConfigLayerSource, ConfigLayerStack, LayerDisabledReason,
17};
18use crate::loader::session_override;
19use vtcode_commons::VtCodePaths;
20use vtcode_commons::canonicalize;
21
22type CachedManager = Arc<ConfigManager>;
23
24#[derive(Debug)]
25struct RepositoryProviderSecurityViolation {
26    source: ConfigLayerSource,
27    message: String,
28}
29
30impl fmt::Display for RepositoryProviderSecurityViolation {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        self.message.fmt(formatter)
33    }
34}
35
36impl std::error::Error for RepositoryProviderSecurityViolation {}
37
38#[cfg(not(test))]
39static WORKSPACE_CACHE: Mutex<Option<HashMap<PathBuf, CachedManager>>> = Mutex::new(None);
40
41#[cfg(not(test))]
42fn with_cache_mut(f: impl FnOnce(&mut HashMap<PathBuf, CachedManager>)) {
43    let mut guard = WORKSPACE_CACHE.lock().expect("config cache lock poisoned");
44    guard.get_or_insert_with(HashMap::new);
45    f(guard.as_mut().expect("cache initialized"))
46}
47
48#[cfg(not(test))]
49fn cache_get(workspace: &Path) -> Option<CachedManager> {
50    WORKSPACE_CACHE
51        .lock()
52        .expect("config cache lock poisoned")
53        .as_ref()
54        .and_then(|map| map.get(workspace).cloned())
55}
56
57#[cfg(not(test))]
58fn cache_insert(workspace: PathBuf, manager: CachedManager) {
59    with_cache_mut(move |map| {
60        map.insert(workspace, manager);
61    });
62}
63
64#[cfg(not(test))]
65fn cache_remove(workspace: &Path) {
66    with_cache_mut(|map| {
67        map.remove(workspace);
68    });
69}
70
71pub(crate) fn canonicalize_workspace_root(path: &Path) -> PathBuf {
72    canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
73}
74
75fn push_unique_layer_source(layer_sources: &mut Vec<ConfigLayerSource>, source: ConfigLayerSource) {
76    let file = match &source {
77        ConfigLayerSource::System { file }
78        | ConfigLayerSource::User { file }
79        | ConfigLayerSource::Project { file }
80        | ConfigLayerSource::Workspace { file } => file,
81        ConfigLayerSource::Runtime => {
82            layer_sources.push(source);
83            return;
84        }
85    };
86    if !layer_sources.iter().any(|existing| {
87        matches!(existing,
88            ConfigLayerSource::System { file: existing_file }
89            | ConfigLayerSource::User { file: existing_file }
90            | ConfigLayerSource::Project { file: existing_file }
91            | ConfigLayerSource::Workspace { file: existing_file }
92            if existing_file == file)
93    }) {
94        layer_sources.push(source);
95    }
96}
97
98fn is_optional_global_layer(source: &ConfigLayerSource) -> bool {
99    matches!(source, ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. })
100}
101
102fn should_skip_optional_global_error(source: &ConfigLayerSource, error: &std::io::Error) -> bool {
103    is_optional_global_layer(source) && error.kind() != std::io::ErrorKind::InvalidData
104}
105
106/// Timing metrics (in microseconds) for configuration loading phases
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
108pub struct ConfigPhaseTiming {
109    /// Duration of workspace path resolution (in microseconds)
110    pub path_resolution_us: u64,
111    /// Duration of layer probing and loading (in microseconds)
112    pub layer_loading_us: u64,
113    /// Duration of TOML merging and deserialization (in microseconds)
114    pub merge_and_parse_us: u64,
115    /// Duration of validation and API key migration (in microseconds)
116    pub validation_us: u64,
117}
118
119/// Configuration manager for loading and validating configurations
120#[derive(Clone)]
121pub struct ConfigManager {
122    pub(crate) config: VTCodeConfig,
123    config_path: Option<PathBuf>,
124    canonical_user_config_path: Option<PathBuf>,
125    tracked_user_config_paths: Vec<PathBuf>,
126    write_global_config_to_canonical: bool,
127    /// Whether the associated file was explicitly selected by the user.
128    ///
129    /// Explicit files are trusted even when they are represented as a
130    /// workspace layer internally. Repository and project layers are not.
131    explicit_config_is_trusted: bool,
132    workspace_root: Option<PathBuf>,
133    config_file_name: String,
134    pub(crate) layer_stack: ConfigLayerStack,
135    phase_timing: Option<ConfigPhaseTiming>,
136}
137
138impl ConfigManager {
139    /// Load configuration from the default locations rooted at the current directory.
140    pub fn load() -> Result<Self> {
141        if let Some(override_path) = session_override::explicit_config_path() {
142            return Self::load_for_session(std::env::current_dir()?, &override_path).with_context(|| {
143                format!("Failed to load configuration from explicit path {}", override_path.display())
144            });
145        }
146
147        Self::load_from_workspace_with_repository_repair(std::env::current_dir()?)
148    }
149
150    /// Load configuration for an interactive session with an explicit config file.
151    ///
152    /// The explicit file takes the highest file-layer precedence (the same
153    /// position a workspace root `vtcode.toml` would occupy), while the
154    /// system/user global layers are still loaded underneath it. Unlike
155    /// [`Self::load_from_file`], the manager's `workspace_root` remains the
156    /// session workspace (not the explicit file's parent directory), so
157    /// workspace-relative config writes and project-level paths keep
158    /// resolving against the workspace.
159    pub fn load_for_session(workspace: impl AsRef<Path>, explicit_path: impl AsRef<Path>) -> Result<Self> {
160        let workspace = workspace.as_ref();
161        let explicit_path = explicit_path.as_ref();
162        #[cfg(not(test))]
163        let canonical_workspace = canonicalize_workspace_root(workspace);
164
165        #[cfg(not(test))]
166        if let Some(cached) = cache_get(&canonical_workspace) {
167            return Ok(cached.as_ref().clone());
168        }
169
170        let mut manager = Self::load_from_file_impl(explicit_path, false).with_context(|| {
171            format!(
172                "Failed to load explicit session config file {} (from --config / VTCODE_CONFIG_PATH)",
173                explicit_path.display()
174            )
175        })?;
176        // The session workspace, not the explicit file's parent, anchors
177        // workspace-relative config resolution for the rest of the session.
178        manager.workspace_root = Some(canonicalize_workspace_root(workspace));
179
180        #[cfg(not(test))]
181        cache_insert(canonical_workspace, Arc::new(manager.clone()));
182
183        Ok(manager)
184    }
185
186    /// Load only the system and user configuration layers.
187    ///
188    /// This is used by global configuration commands that must honor legacy
189    /// and XDG search precedence without accidentally importing the current
190    /// workspace's `vtcode.toml`.
191    pub fn load_global() -> Result<Self> {
192        let defaults_provider = defaults::current_config_defaults();
193        let config_file_name = defaults_provider.config_file_name().to_string();
194        let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
195        let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
196        if let Some(path) = &canonical_user_config_path
197            && !tracked_user_config_paths.iter().any(|existing| existing == path)
198        {
199            tracked_user_config_paths.push(path.clone());
200        }
201
202        let mut layer_sources = Vec::new();
203        for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
204            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
205        }
206        for home_config_path in &tracked_user_config_paths {
207            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
208        }
209
210        let mut layer_stack = ConfigLayerStack::default();
211        for source in layer_sources {
212            if let Some(layer) = Self::load_optional_layer(source) {
213                layer_stack.push(layer);
214            }
215        }
216
217        if let Some((layer, error)) = layer_stack.first_layer_error() {
218            bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
219        }
220
221        let (config, config_path) = if layer_stack.layers().is_empty() {
222            let config = VTCodeConfig::default();
223            config.validate().context("Default configuration failed validation")?;
224            (config, None)
225        } else {
226            let (effective_toml, origins) = layer_stack.effective_config_with_origins();
227            let mut config: VTCodeConfig = effective_toml
228                .try_into()
229                .context("Failed to deserialize effective global configuration")?;
230            Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
231            Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
232            config.validate().context("Global configuration failed validation")?;
233            config.workspace_lifecycle_hooks =
234                Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
235            let config_path = layer_stack
236                .layers()
237                .iter()
238                .rev()
239                .find(|layer| layer.is_enabled())
240                .and_then(|layer| match &layer.source {
241                    ConfigLayerSource::System { file } | ConfigLayerSource::User { file } => Some(file.clone()),
242                    _ => None,
243                });
244            (config, config_path)
245        };
246
247        Ok(Self {
248            config,
249            config_path,
250            canonical_user_config_path,
251            tracked_user_config_paths,
252            write_global_config_to_canonical: false,
253            explicit_config_is_trusted: false,
254            workspace_root: None,
255            config_file_name,
256            layer_stack,
257            phase_timing: None,
258        })
259    }
260
261    /// Invalidate the cached configuration for a specific workspace.
262    ///
263    /// Call this when config files may have changed on disk and the next
264    /// `load_from_workspace` call should perform a fresh read instead of
265    /// returning a previously cached result.
266    pub fn invalidate_workspace_cache(workspace: impl AsRef<Path>) {
267        let workspace = workspace.as_ref();
268        #[cfg(not(test))]
269        {
270            let canonical_workspace = canonicalize_workspace_root(workspace);
271            cache_remove(&canonical_workspace);
272        }
273        #[cfg(test)]
274        let _ = workspace;
275    }
276
277    /// Invalidate every cached workspace configuration.
278    ///
279    /// Used when a session-scoped override changes or is cleared: the cache is
280    /// keyed by canonical workspace only, so an override-loaded manager for any
281    /// workspace must not leak into later default loads. This is a rare event
282    /// (startup and tests), so a full sweep is cheap and safe.
283    pub fn invalidate_all_workspace_cache() {
284        #[cfg(not(test))]
285        with_cache_mut(|map| map.clear());
286    }
287
288    /// Load configuration from a specific workspace
289    ///
290    /// When the session has an explicit config-file override (captured at
291    /// startup via [`session_override::set_explicit_config_path`]), the
292    /// override file takes precedence and is loaded as the highest file
293    /// layer above the default global layers.
294    pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
295        if let Some(override_path) = session_override::explicit_config_path() {
296            return Self::load_for_session(workspace, override_path);
297        }
298
299        let workspace = workspace.as_ref();
300        #[cfg(not(test))]
301        let canonical_workspace = canonicalize_workspace_root(workspace);
302
303        #[cfg(not(test))]
304        if let Some(cached) = cache_get(&canonical_workspace) {
305            return Ok(cached.as_ref().clone());
306        }
307
308        let manager = Self::load_from_workspace_impl(workspace)?;
309
310        #[cfg(not(test))]
311        cache_insert(canonical_workspace, Arc::new(manager.clone()));
312
313        Ok(manager)
314    }
315
316    /// Load a workspace configuration and repair provider settings left by
317    /// older versions that persisted the merged configuration into a
318    /// repository-controlled file.
319    ///
320    /// The strict loader still rejects repository-controlled provider
321    /// definitions and endpoint/credential overrides. This entry point only
322    /// handles that specific, already-validated violation by removing the
323    /// prohibited fields from the exact repository layer that introduced it,
324    /// then retrying the normal load. Explicit session files remain trusted
325    /// and are never repaired.
326    pub fn load_from_workspace_with_repository_repair(workspace: impl AsRef<Path>) -> Result<Self> {
327        let workspace = workspace.as_ref();
328        let mut error = match Self::load_from_workspace(workspace) {
329            Ok(manager) => return Ok(manager),
330            Err(error) => error,
331        };
332
333        // More than one repository layer can contain a stale flattened copy.
334        // Repair and retry until the strict loader succeeds or every known
335        // repository layer has had a chance to be cleaned.
336        let repository_paths = Self::repository_config_paths(workspace);
337        for _ in 0..=repository_paths.len() {
338            let Some(violation) = error.downcast_ref::<RepositoryProviderSecurityViolation>() else {
339                return Err(error);
340            };
341
342            let source = violation.source.clone();
343            let Some(path) = repository_paths
344                .iter()
345                .find(|candidate| Self::repository_source_matches_path(&source, candidate))
346            else {
347                return Err(error);
348            };
349
350            if !Self::repair_repository_config_file(path)? {
351                return Err(error);
352            }
353
354            tracing::warn!(
355                path = %path.display(),
356                "Removed prohibited provider settings left by an older repository configuration write"
357            );
358
359            Self::invalidate_workspace_cache(workspace);
360            match Self::load_from_workspace(workspace) {
361                Ok(manager) => return Ok(manager),
362                Err(next_error) => error = next_error,
363            }
364        }
365
366        Err(error).context("Failed to load workspace configuration after repairing prohibited provider settings")
367    }
368
369    fn load_from_workspace_impl(workspace: impl AsRef<Path>) -> Result<Self> {
370        let t0 = std::time::Instant::now();
371        let workspace = workspace.as_ref();
372        let defaults_provider = defaults::current_config_defaults();
373        let workspace_paths = defaults_provider.workspace_paths_for(workspace);
374        let workspace_root = canonicalize_workspace_root(workspace_paths.workspace_root());
375        let config_dir = workspace_paths.config_dir();
376        let config_file_name = defaults_provider.config_file_name().to_string();
377        let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
378        let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
379        if let Some(path) = &canonical_user_config_path
380            && !tracked_user_config_paths.iter().any(|existing| existing == path)
381        {
382            tracked_user_config_paths.push(path.clone());
383        }
384        let path_res_duration = t0.elapsed();
385
386        let t1 = std::time::Instant::now();
387
388        // Collect layer sources in precedence order so we can load them
389        // concurrently and then push results while preserving order.
390        let mut layer_sources: Vec<ConfigLayerSource> = Vec::with_capacity(8);
391
392        // 1. System configuration: /etc compatibility followed by
393        // XDG_CONFIG_DIRS candidates, all low-to-high precedence.
394        for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
395            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
396        }
397
398        // 2. Legacy user config followed by the canonical XDG user config.
399        for home_config_path in &tracked_user_config_paths {
400            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
401        }
402
403        // 3. Project-specific config (.vtcode/projects/<project>/config/vtcode.toml)
404        if let Some(project_config_path) = Self::project_config_path(&config_dir, &workspace_root, &config_file_name) {
405            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Project { file: project_config_path });
406        }
407
408        // 4. Config directory fallback (.vtcode/vtcode.toml)
409        let fallback_path = config_dir.join(&config_file_name);
410        let workspace_config_path = workspace_root.join(&config_file_name);
411        if fallback_path != workspace_config_path {
412            push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Workspace { file: fallback_path });
413        }
414
415        // 5. Workspace config (vtcode.toml in workspace root)
416        push_unique_layer_source(
417            &mut layer_sources,
418            ConfigLayerSource::Workspace { file: workspace_config_path.clone() },
419        );
420
421        // Load all layers concurrently. Each load is independent I/O, so
422        // spawning threads overlaps the disk reads and canonicalization
423        // syscalls. The results are collected in source order to preserve
424        // layer precedence.
425        let raw_layers: Vec<Option<ConfigLayerEntry>> = std::thread::scope(|s| {
426            let handles = layer_sources
427                .into_iter()
428                .map(|source| s.spawn(move || Self::load_optional_layer(source)))
429                .collect::<Vec<_>>();
430            // Join every handle first: `scope` re-panics if any panicked thread is left unjoined.
431            let joined = handles.into_iter().map(|h| h.join()).collect::<Vec<_>>();
432            joined
433                .into_iter()
434                .map(|r| r.map_err(|_payload| anyhow::anyhow!("config layer loader thread panicked")))
435                .collect::<Result<Vec<_>>>()
436        })?;
437
438        let mut layer_stack = ConfigLayerStack::default();
439        for layer in raw_layers.into_iter().flatten() {
440            layer_stack.push(layer);
441        }
442
443        let layer_load_duration = t1.elapsed();
444
445        // If no layers found, use default config
446        if layer_stack.layers().is_empty() {
447            let t_val = std::time::Instant::now();
448            let config = VTCodeConfig::default();
449            config.validate().context("Default configuration failed validation")?;
450            let val_duration = t_val.elapsed();
451
452            let phase_timing = ConfigPhaseTiming {
453                path_resolution_us: path_res_duration.as_micros() as u64,
454                layer_loading_us: layer_load_duration.as_micros() as u64,
455                merge_and_parse_us: 0,
456                validation_us: val_duration.as_micros() as u64,
457            };
458            tracing::debug!(target: "vtcode_config", ?phase_timing, "Default configuration loaded");
459
460            return Ok(Self {
461                config,
462                config_path: None,
463                canonical_user_config_path,
464                tracked_user_config_paths,
465                write_global_config_to_canonical: false,
466                explicit_config_is_trusted: false,
467                workspace_root: Some(workspace_root),
468                config_file_name,
469                layer_stack,
470                phase_timing: Some(phase_timing),
471            });
472        }
473
474        // If the workspace root vtcode.toml sets workspace.use_root_config = true,
475        // discard all lower-precedence layers and re-merge with only the workspace
476        // root and runtime layers.
477        let use_root_config = layer_stack
478            .layers()
479            .iter()
480            .find(|l| {
481                matches!(
482                    &l.source,
483                    ConfigLayerSource::Workspace { file }
484                        if *file == workspace_config_path
485                )
486            })
487            .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
488            .unwrap_or(false);
489        if use_root_config {
490            layer_stack.retain(|layer| {
491                matches!(
492                    &layer.source,
493                    ConfigLayerSource::Workspace { file }
494                        if *file == workspace_config_path
495                ) || matches!(&layer.source, ConfigLayerSource::Runtime)
496            });
497            if layer_stack.layers().is_empty() {
498                bail!(
499                    "workspace.use_root_config is true but no workspace root config was found at {}",
500                    workspace_config_path.display()
501                );
502            }
503        }
504
505        if let Some((layer, error)) = layer_stack.first_layer_error() {
506            bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
507        }
508
509        let t2 = std::time::Instant::now();
510        let (effective_toml, origins) = layer_stack.effective_config_with_origins();
511        let mut config: VTCodeConfig = effective_toml
512            .try_into()
513            .context("Failed to deserialize effective configuration")?;
514        let merge_duration = t2.elapsed();
515
516        let t3 = std::time::Instant::now();
517        Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
518        Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
519
520        config.validate().context("Configuration failed validation")?;
521        config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
522
523        // Migrate any plain-text API keys from config to secure storage
524        migrate_custom_api_keys_if_needed(&mut config)?;
525        let val_duration = t3.elapsed();
526
527        let phase_timing = ConfigPhaseTiming {
528            path_resolution_us: path_res_duration.as_micros() as u64,
529            layer_loading_us: layer_load_duration.as_micros() as u64,
530            merge_and_parse_us: merge_duration.as_micros() as u64,
531            validation_us: val_duration.as_micros() as u64,
532        };
533        tracing::debug!(target: "vtcode_config", ?phase_timing, "Workspace configuration loaded");
534
535        let config_path = layer_stack
536            .layers()
537            .iter()
538            .rev()
539            .find(|layer| layer.is_enabled())
540            .and_then(|l| match &l.source {
541                ConfigLayerSource::User { file } => Some(file.clone()),
542                ConfigLayerSource::Project { file } => Some(file.clone()),
543                ConfigLayerSource::Workspace { file } => Some(file.clone()),
544                ConfigLayerSource::System { file } => Some(file.clone()),
545                ConfigLayerSource::Runtime => None,
546            });
547
548        Ok(Self {
549            config,
550            config_path,
551            canonical_user_config_path,
552            tracked_user_config_paths,
553            write_global_config_to_canonical: false,
554            explicit_config_is_trusted: false,
555            workspace_root: Some(workspace_root),
556            config_file_name,
557            layer_stack,
558            phase_timing: Some(phase_timing),
559        })
560    }
561
562    fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
563        let content =
564            fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
565        let mut value: toml::Value =
566            toml::from_str(&content).with_context(|| format!("Failed to parse config file: {}", path.display()))?;
567        crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut value);
568        Ok(value)
569    }
570
571    fn load_optional_layer(source: ConfigLayerSource) -> Option<ConfigLayerEntry> {
572        let file = match &source {
573            ConfigLayerSource::System { file }
574            | ConfigLayerSource::User { file }
575            | ConfigLayerSource::Project { file }
576            | ConfigLayerSource::Workspace { file } => file,
577            ConfigLayerSource::Runtime => {
578                return Some(ConfigLayerEntry::new(source, toml::Value::Table(toml::Table::new())));
579            }
580        };
581
582        // Read first so missing files return None without a redundant canonicalize.
583        let content = match fs::read_to_string(file) {
584            Ok(content) => content,
585            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
586            Err(err) if should_skip_optional_global_error(&source, &err) => {
587                tracing::debug!(path = %file.display(), "skipping inaccessible optional configuration layer");
588                return None;
589            }
590            Err(err) => {
591                let resolved_file = canonicalize_workspace_root(file);
592                let resolved_source = source.with_file(resolved_file);
593                return Some(Self::disabled_layer_from_error(resolved_source, err.into()));
594            }
595        };
596
597        let resolved_file = canonicalize_workspace_root(file);
598        let resolved_source = source.with_file(resolved_file);
599
600        match toml::from_str::<toml::Value>(&content) {
601            Ok(mut toml) => {
602                crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut toml);
603                Some(ConfigLayerEntry::new(resolved_source, toml))
604            }
605            Err(err) => {
606                let error =
607                    anyhow::Error::from(err).context(format!("Failed to parse config file: {}", file.display()));
608                Some(Self::disabled_layer_from_error(resolved_source, error))
609            }
610        }
611    }
612
613    fn disabled_layer_from_error(source: ConfigLayerSource, error: anyhow::Error) -> ConfigLayerEntry {
614        let reason = if error.is::<toml::de::Error>() {
615            LayerDisabledReason::ParseError
616        } else {
617            LayerDisabledReason::LoadError
618        };
619        ConfigLayerEntry::disabled(source, reason, format!("{error:#}"))
620    }
621
622    /// Check whether a parsed TOML value has `workspace.use_root_config = true`.
623    ///
624    /// This is checked against the already-loaded workspace root layer config
625    /// to avoid a redundant file read.
626    fn workspace_root_wants_root_config_only(config: &toml::Value) -> bool {
627        config
628            .get("workspace")
629            .and_then(|v| v.get("use_root_config"))
630            .and_then(|v| v.as_bool())
631            .unwrap_or(false)
632    }
633
634    /// Load configuration from a specific file
635    pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
636        Self::load_from_file_impl(path, false)
637    }
638
639    fn load_from_file_impl(path: impl AsRef<Path>, write_global_config_to_canonical: bool) -> Result<Self> {
640        let path = path.as_ref();
641        let defaults_provider = defaults::current_config_defaults();
642        // Global layer probing always uses the canonical config file name, never
643        // the basename of the explicit override file. Deriving it from the file
644        // previously caused global layers (e.g. `~/.config/vtcode/vtcode.toml`
645        // with `[[custom_providers]]`) to be skipped whenever the override file
646        // was named something else (e.g. `night.toml`), silently dropping
647        // custom providers and other user-global settings.
648        let config_file_name = defaults_provider.config_file_name().to_string();
649        let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
650        let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
651        if let Some(path) = &canonical_user_config_path
652            && !tracked_user_config_paths.iter().any(|existing| existing == path)
653        {
654            tracked_user_config_paths.push(path.clone());
655        }
656
657        let mut layer_stack = ConfigLayerStack::default();
658        let mut global_sources = Vec::new();
659
660        for system_config in defaults_provider.system_config_paths(&config_file_name)? {
661            push_unique_layer_source(&mut global_sources, ConfigLayerSource::System { file: system_config });
662        }
663        for home_config_path in &tracked_user_config_paths {
664            push_unique_layer_source(&mut global_sources, ConfigLayerSource::User { file: home_config_path.clone() });
665        }
666        for source in global_sources {
667            if let Some(layer) = Self::load_optional_layer(source) {
668                layer_stack.push(layer);
669            }
670        }
671
672        // 3. The specific file provided (Workspace layer)
673        let file = path.to_path_buf();
674        match Self::load_toml_from_file(path) {
675            Ok(toml) => layer_stack.push(ConfigLayerEntry::new(ConfigLayerSource::Workspace { file }, toml)),
676            Err(error) => {
677                layer_stack.push(Self::disabled_layer_from_error(ConfigLayerSource::Workspace { file }, error))
678            }
679        }
680
681        // If the provided file sets workspace.use_root_config = true, discard
682        // lower-precedence layers so only this file and runtime overrides apply.
683        let use_root_config = layer_stack
684            .layers()
685            .iter()
686            .find(|l| {
687                matches!(
688                    &l.source,
689                    ConfigLayerSource::Workspace { file }
690                        if *file == path
691                )
692            })
693            .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
694            .unwrap_or(false);
695        if use_root_config {
696            layer_stack.retain(|layer| {
697                matches!(
698                    &layer.source,
699                    ConfigLayerSource::Workspace { file }
700                        if *file == path
701                ) || matches!(&layer.source, ConfigLayerSource::Runtime)
702            });
703        }
704
705        if let Some((layer, error)) = layer_stack.first_layer_error() {
706            bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
707        }
708
709        let (effective_toml, origins) = layer_stack.effective_config_with_origins();
710        let mut config: VTCodeConfig = effective_toml
711            .try_into()
712            .with_context(|| format!("Failed to parse effective config with file: {}", path.display()))?;
713        Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
714        // A path supplied explicitly by the user is an opt-in trusted layer,
715        // even though it uses the Workspace source variant for precedence and
716        // compatibility with the existing loader.
717        Self::validate_provider_security_fields(&layer_stack, &origins, &config, true)?;
718
719        config
720            .validate()
721            .with_context(|| format!("Failed to validate effective config with file: {}", path.display()))?;
722        config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
723
724        Ok(Self {
725            config,
726            config_path: Some(canonicalize_workspace_root(path)),
727            canonical_user_config_path,
728            tracked_user_config_paths,
729            write_global_config_to_canonical,
730            explicit_config_is_trusted: true,
731            workspace_root: path.parent().map(canonicalize_workspace_root),
732            config_file_name,
733            layer_stack,
734            phase_timing: None,
735        })
736    }
737
738    /// Get the loaded configuration
739    pub fn config(&self) -> &VTCodeConfig {
740        &self.config
741    }
742
743    /// Get the timing metrics recorded during loading, if available.
744    pub fn phase_timing(&self) -> Option<ConfigPhaseTiming> {
745        self.phase_timing
746    }
747
748    /// Get the configuration file path (if loaded from file)
749    pub fn config_path(&self) -> Option<&Path> {
750        self.config_path.as_deref()
751    }
752
753    /// Return whether a path belongs to a repository-controlled config layer.
754    ///
755    /// This keeps callers that persist a full config document from having to
756    /// duplicate layer-origin and explicit-session checks.
757    #[must_use]
758    pub fn is_repository_controlled_path(&self, path: &Path) -> bool {
759        if self.explicit_config_is_trusted {
760            return false;
761        }
762
763        if self
764            .layer_stack
765            .layers()
766            .iter()
767            .any(|layer| layer.is_enabled() && Self::repository_source_matches_path(&layer.source, path))
768        {
769            return true;
770        }
771
772        self.config_path.is_none()
773            && self.workspace_root.as_deref().is_some_and(|workspace| {
774                Self::repository_source_matches_path(
775                    &ConfigLayerSource::Workspace { file: workspace.join(&self.config_file_name) },
776                    path,
777                )
778            })
779    }
780
781    /// Get the active workspace root for this manager.
782    pub fn workspace_root(&self) -> Option<&Path> {
783        self.workspace_root.as_deref()
784    }
785
786    /// Resolve the workspace-level config file this manager reads from, i.e.
787    /// the highest enabled `Workspace` layer.
788    ///
789    /// With a session-explicit config file (`--config` / `VTCODE_CONFIG_PATH`)
790    /// that layer is the override file itself, so config writes land where the
791    /// session actually reads from. Falls back to `<workspace>/<config_file_name>`
792    /// when no workspace layer is present.
793    pub fn preferred_workspace_config_path(&self, workspace: &Path) -> PathBuf {
794        self.layer_stack
795            .layers()
796            .iter()
797            .rev()
798            .find_map(|layer| match &layer.source {
799                ConfigLayerSource::Workspace { file } if layer.is_enabled() => Some(file.clone()),
800                _ => None,
801            })
802            .unwrap_or_else(|| workspace.join(&self.config_file_name))
803    }
804
805    /// Get the config filename used by this manager (usually `vtcode.toml`).
806    pub fn config_file_name(&self) -> &str {
807        &self.config_file_name
808    }
809
810    /// Get the configuration layer stack
811    pub fn layer_stack(&self) -> &ConfigLayerStack {
812        &self.layer_stack
813    }
814
815    /// Resolve the canonical user-level config file VT Code should write to.
816    pub fn preferred_user_config_path(&self) -> Option<PathBuf> {
817        self.canonical_user_config_path.clone()
818    }
819
820    /// Return every supported user-level config path for the loaded config
821    /// filename, including paths that do not exist yet.
822    ///
823    /// Callers that monitor configuration must retain the nonexistent paths:
824    /// a later `None -> Some(mtime)` transition is a real configuration change.
825    pub fn user_config_paths(&self) -> Vec<PathBuf> {
826        let mut paths = self.tracked_user_config_paths.clone();
827
828        for path in self
829            .layer_stack
830            .layers()
831            .iter()
832            .filter_map(|layer| match (&layer.source, layer.is_enabled()) {
833                (ConfigLayerSource::User { file }, true) => Some(file),
834                _ => None,
835            })
836        {
837            if !paths.iter().any(|existing| existing == path) {
838                paths.push(path.clone());
839            }
840        }
841
842        paths
843    }
844
845    /// Return every configuration file location that can affect a workspace.
846    ///
847    /// The list intentionally includes files that do not exist yet. Polling
848    /// callers can therefore observe file creation as well as modification or
849    /// deletion. Paths retain their configured spelling so consumers that
850    /// write a target can still apply the no-follow file policy at the final
851    /// path component.
852    pub fn watched_config_paths(workspace: &Path) -> Vec<PathBuf> {
853        let provider = defaults::current_config_defaults();
854        let config_file_name = provider.config_file_name().to_string();
855        let workspace_paths = provider.workspace_paths_for(workspace);
856        let workspace_root = workspace_paths.workspace_root().to_path_buf();
857        let mut paths = Vec::new();
858
859        let mut push_unique = |path: PathBuf| {
860            if !paths.iter().any(|existing| existing == &path) {
861                paths.push(path);
862            }
863        };
864
865        if let Some(explicit_path) = session_override::explicit_config_path() {
866            push_unique(explicit_path);
867        }
868        if let Ok(system_paths) = provider.system_config_paths(&config_file_name) {
869            for path in system_paths {
870                push_unique(path);
871            }
872        }
873        for path in provider.home_config_paths(&config_file_name) {
874            push_unique(path);
875        }
876        if let Ok(Some(canonical_path)) = provider.canonical_user_config_path(&config_file_name) {
877            push_unique(canonical_path);
878        }
879
880        if let Some(project_name) = Self::current_project_name(&workspace_root) {
881            push_unique(
882                workspace_paths
883                    .config_dir()
884                    .join("projects")
885                    .join(project_name)
886                    .join("config")
887                    .join(&config_file_name),
888            );
889        }
890
891        push_unique(workspace_paths.config_dir().join(&config_file_name));
892        push_unique(workspace_root.join(&config_file_name));
893        push_unique(workspace_root.join(".vtcode").join("theme.toml"));
894        paths
895    }
896
897    fn repository_config_paths(workspace: &Path) -> Vec<PathBuf> {
898        let provider = defaults::current_config_defaults();
899        let config_file_name = provider.config_file_name().to_string();
900        let workspace_paths = provider.workspace_paths_for(workspace);
901        let workspace_root = canonicalize_workspace_root(workspace_paths.workspace_root());
902        let config_dir = workspace_paths.config_dir();
903        let mut paths = Vec::with_capacity(3);
904
905        let mut push_unique = |path: PathBuf| {
906            if !paths.iter().any(|existing| existing == &path) {
907                paths.push(path);
908            }
909        };
910
911        if let Some(project_path) = Self::project_config_path(&config_dir, &workspace_root, &config_file_name) {
912            push_unique(project_path);
913        }
914        push_unique(config_dir.join(&config_file_name));
915        push_unique(workspace_root.join(&config_file_name));
916        paths
917    }
918
919    fn repository_source_matches_path(source: &ConfigLayerSource, path: &Path) -> bool {
920        let Some(source_file) = (match source {
921            ConfigLayerSource::Project { file } | ConfigLayerSource::Workspace { file } => Some(file),
922            ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. } | ConfigLayerSource::Runtime => None,
923        }) else {
924            return false;
925        };
926
927        canonicalize_workspace_root(source_file) == canonicalize_workspace_root(path)
928    }
929
930    /// Get the effective TOML configuration
931    pub fn effective_config(&self) -> toml::Value {
932        self.layer_stack.effective_config()
933    }
934
935    /// Return whether any enabled layer explicitly sets a top-level key.
936    ///
937    /// Borrow-only scan that avoids a full TOML merge for single-key presence
938    /// checks on the startup hot path (e.g. `default_primary_agent`).
939    pub fn has_explicit_top_level_key(&self, key: &str) -> bool {
940        self.layer_stack
941            .layers()
942            .iter()
943            .filter(|layer| layer.is_enabled())
944            .any(|layer| layer.config.as_table().is_some_and(|table| table.contains_key(key)))
945    }
946
947    /// Get session duration from agent config
948    pub fn session_duration(&self) -> std::time::Duration {
949        std::time::Duration::from_secs(60 * 60) // Default 1 hour
950    }
951
952    fn repair_repository_config_file(path: &Path) -> Result<bool> {
953        let metadata = match fs::symlink_metadata(path) {
954            Ok(metadata) if metadata.file_type().is_symlink() => {
955                bail!("Refusing to repair symlinked config file: {}", path.display())
956            }
957            Ok(metadata) if !metadata.is_file() => {
958                bail!("Config path is not a regular file: {}", path.display())
959            }
960            Ok(metadata) => metadata,
961            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
962            Err(error) => {
963                return Err(error).with_context(|| format!("Failed to inspect config: {}", path.display()));
964            }
965        };
966        debug_assert!(metadata.is_file());
967
968        let content = String::from_utf8(VtCodePaths::read_file_no_follow(path)?)
969            .with_context(|| format!("Failed to read existing config: {}", path.display()))?;
970        let mut doc = content
971            .parse::<toml_edit::DocumentMut>()
972            .with_context(|| format!("Failed to parse existing config: {}", path.display()))?;
973        if !Self::remove_repository_provider_settings(&mut doc) {
974            return Ok(false);
975        }
976
977        VtCodePaths::write_private_file_atomic(path, doc.to_string().as_bytes())
978            .with_context(|| format!("Failed to repair config file: {}", path.display()))?;
979        Ok(true)
980    }
981
982    fn remove_repository_provider_settings(doc: &mut toml_edit::DocumentMut) -> bool {
983        let table = doc.as_table_mut();
984        let mut changed = table.remove("custom_providers").is_some();
985
986        let remove_provider_overrides = {
987            let Some(overrides_item) = table.get_mut("provider_overrides") else {
988                return changed;
989            };
990
991            if let Some(overrides) = overrides_item.as_table_mut() {
992                changed |= Self::remove_provider_override_settings_from_table(overrides);
993                overrides.is_empty()
994            } else if let Some(overrides) = overrides_item.as_inline_table_mut() {
995                changed |= Self::remove_provider_override_settings_from_inline_table(overrides);
996                overrides.is_empty()
997            } else {
998                false
999            }
1000        };
1001
1002        if remove_provider_overrides {
1003            changed |= table.remove("provider_overrides").is_some();
1004        }
1005        changed
1006    }
1007
1008    fn remove_provider_override_settings_from_table(overrides: &mut toml_edit::Table) -> bool {
1009        let provider_names = overrides.iter().map(|(name, _)| name.to_string()).collect::<Vec<_>>();
1010        let mut empty_providers = Vec::new();
1011        let mut changed = false;
1012
1013        for provider_name in provider_names {
1014            let Some(provider_item) = overrides.get_mut(&provider_name) else {
1015                continue;
1016            };
1017
1018            let is_empty = if let Some(provider) = provider_item.as_table_mut() {
1019                changed |= provider.remove("base_url").is_some();
1020                changed |= provider.remove("api_key_env").is_some();
1021                provider.is_empty()
1022            } else if let Some(provider) = provider_item.as_inline_table_mut() {
1023                changed |= provider.remove("base_url").is_some();
1024                changed |= provider.remove("api_key_env").is_some();
1025                provider.is_empty()
1026            } else {
1027                false
1028            };
1029
1030            if is_empty {
1031                empty_providers.push(provider_name);
1032            }
1033        }
1034
1035        for provider_name in empty_providers {
1036            changed |= overrides.remove(&provider_name).is_some();
1037        }
1038        changed
1039    }
1040
1041    fn remove_provider_override_settings_from_inline_table(overrides: &mut toml_edit::InlineTable) -> bool {
1042        let provider_names = overrides.iter().map(|(name, _)| name.to_string()).collect::<Vec<_>>();
1043        let mut empty_providers = Vec::new();
1044        let mut changed = false;
1045
1046        for provider_name in provider_names {
1047            let Some(provider) = overrides
1048                .get_mut(&provider_name)
1049                .and_then(toml_edit::Value::as_inline_table_mut)
1050            else {
1051                continue;
1052            };
1053
1054            changed |= provider.remove("base_url").is_some();
1055            changed |= provider.remove("api_key_env").is_some();
1056            if provider.is_empty() {
1057                empty_providers.push(provider_name);
1058            }
1059        }
1060
1061        for provider_name in empty_providers {
1062            changed |= overrides.remove(&provider_name).is_some();
1063        }
1064        changed
1065    }
1066
1067    fn project_config_path(config_dir: &Path, workspace_root: &Path, config_file_name: &str) -> Option<PathBuf> {
1068        let project_name = Self::identify_current_project(workspace_root)?;
1069        Some(
1070            config_dir
1071                .join("projects")
1072                .join(project_name)
1073                .join("config")
1074                .join(config_file_name),
1075        )
1076    }
1077
1078    fn identify_current_project(workspace_root: &Path) -> Option<String> {
1079        let project_file = workspace_root.join(".vtcode-project");
1080        if let Ok(contents) = fs::read_to_string(&project_file) {
1081            let name = contents.trim();
1082            if !name.is_empty() {
1083                return Some(name.to_string());
1084            }
1085        }
1086
1087        workspace_root
1088            .file_name()
1089            .and_then(|name| name.to_str())
1090            .map(|name| name.to_string())
1091    }
1092
1093    /// Resolve the current project name used for project-level config overlays.
1094    pub fn current_project_name(workspace_root: &Path) -> Option<String> {
1095        Self::identify_current_project(workspace_root)
1096    }
1097
1098    fn validate_restricted_agent_fields(
1099        layer_stack: &ConfigLayerStack,
1100        origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
1101    ) -> Result<()> {
1102        if let Some(origin) = origins.get("agent.persistent_memory.directory_override")
1103            && let Some(layer) = layer_stack.layers().iter().find(|layer| layer.metadata == *origin)
1104        {
1105            match layer.source {
1106                ConfigLayerSource::System { .. }
1107                | ConfigLayerSource::User { .. }
1108                | ConfigLayerSource::Project { .. } => {}
1109                ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Runtime => {
1110                    bail!(
1111                        "agent.persistent_memory.directory_override may only be set in system, user, or project-profile configuration layers"
1112                    );
1113                }
1114            }
1115        }
1116
1117        Ok(())
1118    }
1119
1120    /// Reject provider settings from repository-controlled layers before they
1121    /// can reach provider registration or request handling.
1122    ///
1123    /// Workspace and project files are repository-controlled input. They may
1124    /// configure ordinary agent behavior, but must not introduce executable
1125    /// authentication commands or redirect provider requests/credentials.
1126    /// `load_from_file` is an explicit user opt-in and therefore passes
1127    /// `explicit_config_is_trusted = true`.
1128    fn validate_provider_security_fields(
1129        layer_stack: &ConfigLayerStack,
1130        origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
1131        config: &VTCodeConfig,
1132        explicit_config_is_trusted: bool,
1133    ) -> Result<()> {
1134        if explicit_config_is_trusted {
1135            return Ok(());
1136        }
1137
1138        if !config.custom_providers.is_empty()
1139            && let Some(origin) = origins.get("custom_providers")
1140            && let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin)
1141            && Self::is_repository_controlled_source(&layer.source)
1142        {
1143            return Err(RepositoryProviderSecurityViolation {
1144                source: layer.source.clone(),
1145                message: format!(
1146                    "repository-controlled configuration cannot define `custom_providers` (including `auth.command`); move custom provider settings to system, user, or an explicitly selected config file (source: {})",
1147                    layer.source.label()
1148                ),
1149            }
1150            .into());
1151        }
1152
1153        for (path, origin) in origins {
1154            if !Self::is_provider_endpoint_or_credential_path(path) {
1155                continue;
1156            }
1157
1158            let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin) else {
1159                continue;
1160            };
1161            if Self::is_repository_controlled_source(&layer.source) {
1162                return Err(RepositoryProviderSecurityViolation {
1163                    source: layer.source.clone(),
1164                    message: format!(
1165                        "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: {})",
1166                        layer.source.label()
1167                    ),
1168                }
1169                .into());
1170            }
1171        }
1172
1173        Ok(())
1174    }
1175
1176    fn enabled_layer_for_origin<'a>(
1177        layer_stack: &'a ConfigLayerStack,
1178        origin: &ConfigLayerMetadata,
1179    ) -> Option<&'a ConfigLayerEntry> {
1180        layer_stack
1181            .layers()
1182            .iter()
1183            .find(|layer| layer.is_enabled() && layer.metadata == *origin)
1184    }
1185
1186    fn is_repository_controlled_source(source: &ConfigLayerSource) -> bool {
1187        matches!(source, ConfigLayerSource::Project { .. } | ConfigLayerSource::Workspace { .. })
1188    }
1189
1190    fn is_provider_endpoint_or_credential_path(path: &str) -> bool {
1191        let mut segments = path.split('.');
1192        matches!(segments.next(), Some("provider_overrides"))
1193            && segments.next().is_some()
1194            && matches!(segments.next(), Some("base_url" | "api_key_env"))
1195            && segments.next().is_none()
1196    }
1197
1198    /// Collect lifecycle hook commands whose effective origin is a
1199    /// workspace-controlled layer: the workspace-root `vtcode.toml`, the
1200    /// workspace `.vtcode/vtcode.toml` fallback, or a project profile stored
1201    /// inside the workspace. These are attacker-influenceable in an untrusted
1202    /// repository and must be gated behind explicit user approval.
1203    pub(crate) fn collect_workspace_lifecycle_hooks(
1204        layer_stack: &ConfigLayerStack,
1205        hooks: &HooksConfig,
1206    ) -> WorkspaceLifecycleHooks {
1207        let (_, origins) = layer_stack.effective_config_with_origins();
1208        let mut commands = Vec::new();
1209
1210        for event in crate::hooks::LIFECYCLE_HOOK_EVENTS {
1211            let origin_key = format!("hooks.lifecycle.{event}");
1212            let Some(origin) = origins.get(&origin_key) else {
1213                continue;
1214            };
1215            let workspace_controlled = layer_stack.layers().iter().any(|layer| {
1216                layer.is_enabled()
1217                    && layer.metadata == *origin
1218                    && matches!(layer.source, ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Project { .. })
1219            });
1220            if !workspace_controlled {
1221                continue;
1222            }
1223
1224            // Deprecated aliases fold into `stop` at engine normalization.
1225            let tag = match *event {
1226                "task_completion" | "task_completed" => "stop",
1227                other => other,
1228            };
1229            for group in hooks.lifecycle.groups_for_event(event) {
1230                for command in &group.hooks {
1231                    commands.push(WorkspaceHookCommand {
1232                        event: tag.to_string(),
1233                        matcher: group.matcher.clone(),
1234                        command: command.command.clone(),
1235                        timeout_seconds: command.timeout_seconds,
1236                    });
1237                }
1238            }
1239        }
1240
1241        WorkspaceLifecycleHooks { commands }
1242    }
1243}
1244
1245/// Migrate plain-text API keys from config to secure storage.
1246///
1247/// This function checks if there are any API keys stored in plain-text in the config
1248/// and migrates them to secure storage (keyring). After successful migration,
1249/// the key material is cleared and a normalized provider/key metadata marker is
1250/// retained for configuration introspection.
1251///
1252/// # Arguments
1253/// * `config` - The configuration to migrate
1254fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
1255    let storage_mode = config.agent.credential_storage_mode;
1256
1257    // Check if there are any non-empty API keys in the config
1258    let has_plain_text_keys = config.agent.custom_api_keys.values().any(|key| !key.is_empty());
1259
1260    if has_plain_text_keys {
1261        tracing::info!("Detected plain-text API keys in config, migrating to secure storage...");
1262
1263        let mut migrated_count = 0;
1264        let pending = config
1265            .agent
1266            .custom_api_keys
1267            .iter()
1268            .filter(|(_, key)| !key.is_empty())
1269            .map(|(provider, key)| (provider.clone(), key.clone()))
1270            .collect::<Vec<_>>();
1271        for (provider, api_key) in pending {
1272            let key_name = config
1273                .configured_api_key_env(&provider)
1274                .unwrap_or_else(|| api_key_env_var(&provider));
1275            if let Some(identity) = store_credential_with_mode(&provider, &key_name, &api_key, storage_mode)? {
1276                config.agent.custom_api_keys.remove(&provider);
1277                if let Some(metadata_key) = credential_metadata_key(identity.provider(), identity.key_name())? {
1278                    config.agent.custom_api_keys.insert(metadata_key, String::new());
1279                }
1280                migrated_count += 1;
1281            }
1282        }
1283
1284        if migrated_count > 0 {
1285            tracing::info!("Successfully migrated {} API key(s) to secure storage", migrated_count);
1286            tracing::warn!(
1287                "Plain-text API keys have been cleared from config file. \
1288                 Please commit the updated config to remove sensitive data from version control."
1289            );
1290        }
1291    }
1292
1293    Ok(())
1294}
1295
1296#[cfg(test)]
1297mod sparse_config_tests {
1298    use super::*;
1299
1300    #[test]
1301    fn sparse_config_omits_default_primary_agent_when_unmodified() {
1302        let config = VTCodeConfig::default();
1303        let value = ConfigManager::sparse_config_value(&config).expect("sparse config");
1304
1305        assert!(value.get("default_primary_agent").is_none());
1306    }
1307
1308    #[test]
1309    fn sparse_config_persists_non_default_primary_agent() {
1310        let config = VTCodeConfig {
1311            default_primary_agent: "auto".to_string(),
1312            ..Default::default()
1313        };
1314
1315        let value = ConfigManager::sparse_config_value(&config).expect("sparse config");
1316
1317        assert_eq!(value.get("default_primary_agent").and_then(toml::Value::as_str), Some("auto"));
1318    }
1319
1320    #[test]
1321    fn has_explicit_top_level_key_matches_effective_config() {
1322        let temp_dir = tempfile::tempdir().expect("temp dir");
1323        let workspace = temp_dir.path().join("workspace");
1324        fs::create_dir_all(workspace.join(".vtcode")).expect("workspace dot dir");
1325
1326        let manager = crate::loader::ConfigBuilder::new()
1327            .workspace(workspace.clone())
1328            .build()
1329            .expect("manager");
1330        assert!(!manager.has_explicit_top_level_key("default_primary_agent"));
1331        assert!(
1332            manager
1333                .effective_config()
1334                .as_table()
1335                .is_none_or(|table| !table.contains_key("default_primary_agent"))
1336        );
1337
1338        let overridden = crate::loader::ConfigBuilder::new()
1339            .workspace(workspace)
1340            .cli_override("default_primary_agent".to_owned(), toml::Value::String("duck".to_owned()))
1341            .build()
1342            .expect("overridden manager");
1343        assert!(overridden.has_explicit_top_level_key("default_primary_agent"));
1344        assert!(
1345            overridden
1346                .effective_config()
1347                .as_table()
1348                .is_some_and(|table| table.contains_key("default_primary_agent"))
1349        );
1350        assert!(!overridden.has_explicit_top_level_key("definitely_missing_key"));
1351    }
1352
1353    #[test]
1354    fn save_config_migrates_legacy_permissions_auto_permission_table() {
1355        let temp_dir = tempfile::tempdir().expect("temp dir");
1356        let config_path = temp_dir.path().join("vtcode.toml");
1357        fs::write(
1358            &config_path,
1359            r#"
1360[permissions.auto_permission]
1361model = "legacy-reviewer"
1362max_consecutive_denials = 2
1363"#,
1364        )
1365        .expect("write legacy config");
1366
1367        let mut config = VTCodeConfig::default();
1368        config.permissions.auto_permission.model = "legacy-reviewer".to_string();
1369        config.permissions.auto_permission.max_consecutive_denials = 2;
1370
1371        ConfigManager::save_config_to_path(&config_path, &config).expect("save config");
1372
1373        let saved_content = fs::read_to_string(&config_path).expect("read saved config");
1374        assert!(
1375            saved_content.contains("[permissions.auto]"),
1376            "canonical auto permission table should be persisted. Got:\n{saved_content}"
1377        );
1378        assert!(
1379            !saved_content.contains("auto_permission"),
1380            "legacy auto_permission table should be removed. Got:\n{saved_content}"
1381        );
1382
1383        let reloaded = ConfigManager::load_from_file(&config_path).expect("reload saved config");
1384        assert_eq!(reloaded.config().permissions.auto_permission.model, "legacy-reviewer");
1385        assert_eq!(reloaded.config().permissions.auto_permission.max_consecutive_denials, 2);
1386    }
1387
1388    #[test]
1389    fn save_config_removes_legacy_permissions_auto_permission_when_sparse_default() {
1390        let temp_dir = tempfile::tempdir().expect("temp dir");
1391        let config_path = temp_dir.path().join("vtcode.toml");
1392        fs::write(
1393            &config_path,
1394            r#"
1395[permissions.auto_permission]
1396max_consecutive_denials = 3
1397"#,
1398        )
1399        .expect("write legacy config");
1400
1401        ConfigManager::save_config_to_path(&config_path, &VTCodeConfig::default()).expect("save config");
1402
1403        let saved_content = fs::read_to_string(&config_path).expect("read saved config");
1404        assert!(
1405            !saved_content.contains("auto_permission"),
1406            "legacy auto_permission table should be removed. Got:\n{saved_content}"
1407        );
1408        assert!(
1409            !saved_content.contains("[permissions.auto]"),
1410            "default auto permission config should remain sparse. Got:\n{saved_content}"
1411        );
1412
1413        ConfigManager::load_from_file(&config_path).expect("reload saved config");
1414    }
1415
1416    #[test]
1417    fn inaccessible_optional_global_layers_are_skipped() {
1418        let error = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
1419        assert!(should_skip_optional_global_error(
1420            &ConfigLayerSource::System { file: PathBuf::from("/etc/vtcode/vtcode.toml") },
1421            &error
1422        ));
1423        assert!(should_skip_optional_global_error(
1424            &ConfigLayerSource::User {
1425                file: PathBuf::from("/home/user/.config/vtcode/vtcode.toml")
1426            },
1427            &error
1428        ));
1429        assert!(!should_skip_optional_global_error(
1430            &ConfigLayerSource::Workspace { file: PathBuf::from("/workspace/vtcode.toml") },
1431            &error
1432        ));
1433    }
1434
1435    #[test]
1436    fn disabled_layer_reason_tracks_concrete_error_type() {
1437        let temp_dir = tempfile::tempdir().expect("temp dir");
1438        let parse_path = temp_dir.path().join("invalid.toml");
1439        fs::write(&parse_path, "key = [").expect("write invalid TOML");
1440        let parse_error = ConfigManager::load_toml_from_file(&parse_path).expect_err("invalid TOML should fail");
1441        let parse_layer =
1442            ConfigManager::disabled_layer_from_error(ConfigLayerSource::Project { file: parse_path }, parse_error);
1443
1444        assert_eq!(parse_layer.disabled_reason, Some(LayerDisabledReason::ParseError));
1445
1446        let read_path = temp_dir.path().join("parse");
1447        fs::create_dir(&read_path).expect("create directory at config path");
1448        let read_error = ConfigManager::load_toml_from_file(&read_path).expect_err("reading a directory should fail");
1449        assert!(read_error.to_string().contains("parse"));
1450        let read_layer =
1451            ConfigManager::disabled_layer_from_error(ConfigLayerSource::Project { file: read_path }, read_error);
1452
1453        assert_eq!(read_layer.disabled_reason, Some(LayerDisabledReason::LoadError));
1454    }
1455}
1456
1457#[cfg(test)]
1458mod workspace_lifecycle_hooks_tests {
1459    use super::*;
1460    use crate::hooks::WorkspaceLifecycleHooks;
1461
1462    fn layer(source: ConfigLayerSource, content: &str) -> ConfigLayerEntry {
1463        ConfigLayerEntry::new(source, toml::from_str(content).expect("valid test toml"))
1464    }
1465
1466    fn collect(stack: &ConfigLayerStack) -> WorkspaceLifecycleHooks {
1467        let (effective_toml, _) = stack.effective_config_with_origins();
1468        let config: VTCodeConfig = effective_toml.try_into().expect("effective config parses");
1469        ConfigManager::collect_workspace_lifecycle_hooks(stack, &config.hooks)
1470    }
1471
1472    const WS_SESSION_START: &str = r#"
1473[[hooks.lifecycle.session_start]]
1474matcher = "startup"
1475
1476[[hooks.lifecycle.session_start.hooks]]
1477command = "echo workspace-session"
1478timeout_seconds = 30
1479"#;
1480
1481    const USER_SESSION_END: &str = r#"
1482[[hooks.lifecycle.session_end]]
1483[[hooks.lifecycle.session_end.hooks]]
1484command = "echo user-session-end"
1485"#;
1486
1487    #[test]
1488    fn workspace_layer_hooks_are_collected_user_hooks_are_not() {
1489        let mut stack = ConfigLayerStack::default();
1490        stack.push(layer(ConfigLayerSource::User { file: "/home/u/.vtcode/vtcode.toml".into() }, USER_SESSION_END));
1491        stack.push(layer(ConfigLayerSource::Workspace { file: "/ws/vtcode.toml".into() }, WS_SESSION_START));
1492
1493        let collected = collect(&stack);
1494
1495        assert_eq!(collected.commands.len(), 1, "only workspace hooks should be collected");
1496        let command = &collected.commands[0];
1497        assert_eq!(command.event, "session_start");
1498        assert_eq!(command.command, "echo workspace-session");
1499        assert_eq!(command.matcher.as_deref(), Some("startup"));
1500        assert_eq!(command.timeout_seconds, Some(30));
1501        assert!(!collected.is_empty());
1502    }
1503
1504    #[test]
1505    fn user_only_hooks_are_not_workspace_controlled() {
1506        let mut stack = ConfigLayerStack::default();
1507        stack.push(layer(ConfigLayerSource::User { file: "/home/u/.vtcode/vtcode.toml".into() }, USER_SESSION_END));
1508
1509        let collected = collect(&stack);
1510
1511        assert!(collected.is_empty());
1512    }
1513
1514    #[test]
1515    fn deprecated_task_completion_aliases_fold_into_stop() {
1516        let mut stack = ConfigLayerStack::default();
1517        stack.push(layer(
1518            ConfigLayerSource::Workspace { file: "/ws/vtcode.toml".into() },
1519            r#"
1520[[hooks.lifecycle.task_completion]]
1521[[hooks.lifecycle.task_completion.hooks]]
1522command = "echo ws-task-completion"
1523"#,
1524        ));
1525
1526        let collected = collect(&stack);
1527
1528        assert_eq!(collected.commands.len(), 1);
1529        assert_eq!(collected.commands[0].event, "stop");
1530        assert_eq!(collected.commands[0].command, "echo ws-task-completion");
1531    }
1532
1533    #[test]
1534    fn empty_workspace_hook_arrays_do_not_flag_content() {
1535        let mut stack = ConfigLayerStack::default();
1536        stack.push(layer(
1537            ConfigLayerSource::Workspace { file: "/ws/vtcode.toml".into() },
1538            "[hooks.lifecycle]\nsession_start = []\n",
1539        ));
1540
1541        let collected = collect(&stack);
1542
1543        assert!(collected.is_empty(), "an empty workspace hook array is not executable content");
1544    }
1545
1546    #[test]
1547    fn load_from_file_populates_workspace_lifecycle_hooks() {
1548        let temp_dir = tempfile::tempdir().expect("temp dir");
1549        let config_path = temp_dir.path().join("vtcode.toml");
1550        fs::write(&config_path, WS_SESSION_START).expect("write workspace config");
1551
1552        let manager = ConfigManager::load_from_file(&config_path).expect("load config");
1553        let workspace_hooks = manager
1554            .config()
1555            .workspace_lifecycle_hooks
1556            .as_ref()
1557            .expect("workspace hooks populated at load");
1558
1559        assert!(!workspace_hooks.is_empty());
1560        assert_eq!(workspace_hooks.commands.len(), 1);
1561        assert_eq!(workspace_hooks.commands[0].command, "echo workspace-session");
1562    }
1563}