Skip to main content

vtcode_config/loader/
manager.rs

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/// Timing metrics (in microseconds) for configuration loading phases
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
115pub struct ConfigPhaseTiming {
116    /// Duration of workspace path resolution (in microseconds)
117    pub path_resolution_us: u64,
118    /// Duration of layer probing and loading (in microseconds)
119    pub layer_loading_us: u64,
120    /// Duration of TOML merging and deserialization (in microseconds)
121    pub merge_and_parse_us: u64,
122    /// Duration of validation and API key migration (in microseconds)
123    pub validation_us: u64,
124}
125
126/// Configuration manager for loading and validating configurations
127#[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    /// Whether the associated file was explicitly selected by the user.
135    ///
136    /// Explicit files are trusted even when they are represented as a
137    /// workspace layer internally. Repository and project layers are not.
138    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    /// Load configuration from the default locations rooted at the current directory.
147    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    /// Load configuration for an interactive session with an explicit config file.
158    ///
159    /// The explicit file takes the highest file-layer precedence (the same
160    /// position a workspace root `vtcode.toml` would occupy), while the
161    /// system/user global layers are still loaded underneath it. Unlike
162    /// [`Self::load_from_file`], the manager's `workspace_root` remains the
163    /// session workspace (not the explicit file's parent directory), so
164    /// workspace-relative config writes and project-level paths keep
165    /// resolving against the workspace.
166    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        // The session workspace, not the explicit file's parent, anchors
184        // workspace-relative config resolution for the rest of the session.
185        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    /// Load only the system and user configuration layers.
194    ///
195    /// This is used by global configuration commands that must honor legacy
196    /// and XDG search precedence without accidentally importing the current
197    /// workspace's `vtcode.toml`.
198    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    /// Invalidate the cached configuration for a specific workspace.
269    ///
270    /// Call this when config files may have changed on disk and the next
271    /// `load_from_workspace` call should perform a fresh read instead of
272    /// returning a previously cached result.
273    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    /// Invalidate every cached workspace configuration.
285    ///
286    /// Used when a session-scoped override changes or is cleared: the cache is
287    /// keyed by canonical workspace only, so an override-loaded manager for any
288    /// workspace must not leak into later default loads. This is a rare event
289    /// (startup and tests), so a full sweep is cheap and safe.
290    pub fn invalidate_all_workspace_cache() {
291        #[cfg(not(test))]
292        with_cache_mut(|map| map.clear());
293    }
294
295    /// Load configuration from a specific workspace
296    ///
297    /// When the session has an explicit config-file override (captured at
298    /// startup via [`session_override::set_explicit_config_path`]), the
299    /// override file takes precedence and is loaded as the highest file
300    /// layer above the default global layers.
301    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    /// Load a workspace configuration and repair provider settings left by
324    /// older versions that persisted the merged configuration into a
325    /// repository-controlled file.
326    ///
327    /// The strict loader still rejects repository-controlled provider
328    /// definitions and endpoint/credential overrides. This entry point only
329    /// handles that specific, already-validated violation by removing the
330    /// prohibited fields from the exact repository layer that introduced it,
331    /// then retrying the normal load. Explicit session files remain trusted
332    /// and are never repaired.
333    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        // More than one repository layer can contain a stale flattened copy.
341        // Repair and retry until the strict loader succeeds or every known
342        // repository layer has had a chance to be cleaned.
343        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        // Collect layer sources in precedence order so we can load them
396        // concurrently and then push results while preserving order.
397        let mut layer_sources: Vec<ConfigLayerSource> = Vec::with_capacity(8);
398
399        // 1. System configuration: /etc compatibility followed by
400        // XDG_CONFIG_DIRS candidates, all low-to-high precedence.
401        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        // 2. Legacy user config followed by the canonical XDG user config.
406        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        // 3. Project-specific config (.vtcode/projects/<project>/config/vtcode.toml)
411        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        // 4. Config directory fallback (.vtcode/vtcode.toml)
416        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        // 5. Workspace config (vtcode.toml in workspace root)
423        push_unique_layer_source(
424            &mut layer_sources,
425            ConfigLayerSource::Workspace { file: workspace_config_path.clone() },
426        );
427
428        // Load all layers concurrently. Each load is independent I/O, so
429        // spawning threads overlaps the disk reads and canonicalization
430        // syscalls. The results are collected in source order to preserve
431        // layer precedence.
432        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            // Join every handle first: `scope` re-panics if any panicked thread is left unjoined.
438            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 no layers found, use default config
453        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        // If the workspace root vtcode.toml sets workspace.use_root_config = true,
482        // discard all lower-precedence layers and re-merge with only the workspace
483        // root and runtime layers.
484        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 any plain-text API keys from config to secure storage
531        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        // Read first so missing files return None without a redundant canonicalize.
590        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    /// Check whether a parsed TOML value has `workspace.use_root_config = true`.
630    ///
631    /// This is checked against the already-loaded workspace root layer config
632    /// to avoid a redundant file read.
633    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    /// Load configuration from a specific file
642    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        // Global layer probing always uses the canonical config file name, never
650        // the basename of the explicit override file. Deriving it from the file
651        // previously caused global layers (e.g. `~/.config/vtcode/vtcode.toml`
652        // with `[[custom_providers]]`) to be skipped whenever the override file
653        // was named something else (e.g. `night.toml`), silently dropping
654        // custom providers and other user-global settings.
655        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        // 3. The specific file provided (Workspace layer)
680        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        // If the provided file sets workspace.use_root_config = true, discard
689        // lower-precedence layers so only this file and runtime overrides apply.
690        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        // A path supplied explicitly by the user is an opt-in trusted layer,
722        // even though it uses the Workspace source variant for precedence and
723        // compatibility with the existing loader.
724        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    /// Get the loaded configuration
746    pub fn config(&self) -> &VTCodeConfig {
747        &self.config
748    }
749
750    /// Get the timing metrics recorded during loading, if available.
751    pub fn phase_timing(&self) -> Option<ConfigPhaseTiming> {
752        self.phase_timing
753    }
754
755    /// Get the configuration file path (if loaded from file)
756    pub fn config_path(&self) -> Option<&Path> {
757        self.config_path.as_deref()
758    }
759
760    /// Return whether a path belongs to a repository-controlled config layer.
761    ///
762    /// This keeps callers that persist a full config document from having to
763    /// duplicate layer-origin and explicit-session checks.
764    #[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    /// Get the active workspace root for this manager.
789    pub fn workspace_root(&self) -> Option<&Path> {
790        self.workspace_root.as_deref()
791    }
792
793    /// Resolve the workspace-level config file this manager reads from, i.e.
794    /// the highest enabled `Workspace` layer.
795    ///
796    /// With a session-explicit config file (`--config` / `VTCODE_CONFIG_PATH`)
797    /// that layer is the override file itself, so config writes land where the
798    /// session actually reads from. Falls back to `<workspace>/<config_file_name>`
799    /// when no workspace layer is present.
800    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    /// Get the config filename used by this manager (usually `vtcode.toml`).
813    pub fn config_file_name(&self) -> &str {
814        &self.config_file_name
815    }
816
817    /// Get the configuration layer stack
818    pub fn layer_stack(&self) -> &ConfigLayerStack {
819        &self.layer_stack
820    }
821
822    /// Resolve the canonical user-level config file VT Code should write to.
823    pub fn preferred_user_config_path(&self) -> Option<PathBuf> {
824        self.canonical_user_config_path.clone()
825    }
826
827    /// Return every supported user-level config path for the loaded config
828    /// filename, including paths that do not exist yet.
829    ///
830    /// Callers that monitor configuration must retain the nonexistent paths:
831    /// a later `None -> Some(mtime)` transition is a real configuration change.
832    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    /// Return every configuration file location that can affect a workspace.
853    ///
854    /// The list intentionally includes files that do not exist yet. Polling
855    /// callers can therefore observe file creation as well as modification or
856    /// deletion. Paths retain their configured spelling so consumers that
857    /// write a target can still apply the no-follow file policy at the final
858    /// path component.
859    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    /// Get the effective TOML configuration
938    pub fn effective_config(&self) -> toml::Value {
939        self.layer_stack.effective_config()
940    }
941
942    /// Return whether any enabled layer explicitly sets a top-level key.
943    ///
944    /// Borrow-only scan that avoids a full TOML merge for single-key presence
945    /// checks on the startup hot path (e.g. `default_primary_agent`).
946    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    /// Get session duration from agent config
955    pub fn session_duration(&self) -> std::time::Duration {
956        std::time::Duration::from_secs(60 * 60) // Default 1 hour
957    }
958
959    /// Persist configuration to a specific path, preserving comments.
960    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    /// Persist configuration to a repository-controlled path.
965    ///
966    /// Repository and project files may contain ordinary settings, but they
967    /// must never receive trusted provider definitions or provider endpoint and
968    /// credential overrides from the merged configuration. Existing protected
969    /// keys are removed as well so this method can repair files written by an
970    /// older version that flattened the effective configuration.
971    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        // If file exists, preserve comments by using toml_edit. Read and
993        // publish through the shared no-follow/atomic file policy so a
994        // user-controlled symlink cannot redirect configuration writes.
995        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            // Update values while preserving structure and comments
1032            Self::merge_sparse_toml_documents(&mut doc, &new_doc, &default_doc);
1033            // The sparse merge cannot express the deletion of optional fields
1034            // whose default serializes to absent (they appear in neither the
1035            // sparse update nor the defaults), so resets issued through save
1036            // paths (e.g. service_tier back to Project default) would leave
1037            // stale file values behind forever. Reconcile those explicitly.
1038            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    /// Remove file values for optional fields the effective config reset to
1061    /// their absent default (e.g. `provider.openai.service_tier` back to
1062    /// Project default). The sparse merge cannot express these deletions
1063    /// (absent from both the sparse update and the defaults), so each entry
1064    /// pairs a dotted path with a typed cleared-check. Only listed paths are
1065    /// touched; foreign keys are never removed. Extend the list when a new
1066    /// resettable optional reaches a save path.
1067    fn prune_cleared_optional_fields(doc: &mut toml_edit::DocumentMut, config: &VTCodeConfig) {
1068        // Each entry: (dotted path, is_cleared).
1069        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    /// Remove `path` from `table`, then drop ancestor tables left empty.
1079    /// Returns true when `table` itself is now empty.
1080    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    /// Merge TOML documents, preserving comments and structure from original
1250    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    /// Resolve the current project name used for project-level config overlays.
1355    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    /// Reject provider settings from repository-controlled layers before they
1382    /// can reach provider registration or request handling.
1383    ///
1384    /// Workspace and project files are repository-controlled input. They may
1385    /// configure ordinary agent behavior, but must not introduce executable
1386    /// authentication commands or redirect provider requests/credentials.
1387    /// `load_from_file` is an explicit user opt-in and therefore passes
1388    /// `explicit_config_is_trusted = true`.
1389    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    /// Collect lifecycle hook commands whose effective origin is a
1460    /// workspace-controlled layer: the workspace-root `vtcode.toml`, the
1461    /// workspace `.vtcode/vtcode.toml` fallback, or a project profile stored
1462    /// inside the workspace. These are attacker-influenceable in an untrusted
1463    /// repository and must be gated behind explicit user approval.
1464    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            // Deprecated aliases fold into `stop` at engine normalization.
1486            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    /// Persist configuration to the manager's associated path or workspace
1506    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    /// Sync internal config from a saved config
1546    /// Call this after save_config to keep internal state in sync
1547    fn sync_from_config(&mut self, config: &VTCodeConfig) -> Result<()> {
1548        self.config = config.clone();
1549        Ok(())
1550    }
1551}
1552
1553/// Migrate plain-text API keys from config to secure storage.
1554///
1555/// This function checks if there are any API keys stored in plain-text in the config
1556/// and migrates them to secure storage (keyring). After successful migration,
1557/// the key material is cleared and a normalized provider/key metadata marker is
1558/// retained for configuration introspection.
1559///
1560/// # Arguments
1561/// * `config` - The configuration to migrate
1562fn migrate_custom_api_keys_if_needed(config: &mut VTCodeConfig) -> Result<()> {
1563    let storage_mode = config.agent.credential_storage_mode;
1564
1565    // Check if there are any non-empty API keys in the config
1566    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}