1mod cache;
2mod inspect;
3mod migrate;
4mod persistence;
5mod repository;
6
7use cache::{
8 RepositoryProviderSecurityViolation, canonicalize_workspace_root, push_unique_layer_source,
9 should_skip_optional_global_error,
10};
11#[cfg(not(test))]
12use cache::{cache_get, cache_insert, cache_remove, with_cache_mut};
13use migrate::migrate_custom_api_keys_if_needed;
14
15use std::collections::HashMap;
16use std::fmt;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex};
20
21use anyhow::{Context, Result, bail};
22
23use crate::api_keys::{api_key_env_var, credential_metadata_key, store_credential_with_mode};
24use crate::defaults::{self};
25use crate::hooks::{HooksConfig, LifecycleHooksConfig, WorkspaceHookCommand, WorkspaceLifecycleHooks};
26use crate::loader::config::VTCodeConfig;
27use crate::loader::layers::{
28 ConfigLayerEntry, ConfigLayerMetadata, ConfigLayerSource, ConfigLayerStack, LayerDisabledReason,
29};
30use crate::loader::session_override;
31use vtcode_commons::VtCodePaths;
32use vtcode_commons::canonicalize;
33
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct ConfigPhaseTiming {
37 pub path_resolution_us: u64,
39 pub layer_loading_us: u64,
41 pub merge_and_parse_us: u64,
43 pub validation_us: u64,
45}
46
47#[derive(Clone)]
49pub struct ConfigManager {
50 pub(crate) config: VTCodeConfig,
51 pub(super) config_path: Option<PathBuf>,
52 pub(super) canonical_user_config_path: Option<PathBuf>,
53 pub(super) tracked_user_config_paths: Vec<PathBuf>,
54 write_global_config_to_canonical: bool,
55 pub(super) explicit_config_is_trusted: bool,
60 pub(super) workspace_root: Option<PathBuf>,
61 pub(super) config_file_name: String,
62 pub(crate) layer_stack: ConfigLayerStack,
63 pub(super) phase_timing: Option<ConfigPhaseTiming>,
64}
65
66impl ConfigManager {
67 pub fn load() -> Result<Self> {
69 if let Some(override_path) = session_override::explicit_config_path() {
70 return Self::load_for_session(std::env::current_dir()?, &override_path).with_context(|| {
71 format!("Failed to load configuration from explicit path {}", override_path.display())
72 });
73 }
74
75 Self::load_from_workspace_with_repository_repair(std::env::current_dir()?)
76 }
77
78 pub fn load_for_session(workspace: impl AsRef<Path>, explicit_path: impl AsRef<Path>) -> Result<Self> {
88 let workspace = workspace.as_ref();
89 let explicit_path = explicit_path.as_ref();
90 #[cfg(not(test))]
91 let canonical_workspace = canonicalize_workspace_root(workspace);
92
93 #[cfg(not(test))]
94 if let Some(cached) = cache_get(&canonical_workspace) {
95 return Ok(cached.as_ref().clone());
96 }
97
98 let mut manager = Self::load_from_file_impl(explicit_path, false).with_context(|| {
99 format!(
100 "Failed to load explicit session config file {} (from --config / VTCODE_CONFIG_PATH)",
101 explicit_path.display()
102 )
103 })?;
104 manager.workspace_root = Some(canonicalize_workspace_root(workspace));
107
108 #[cfg(not(test))]
109 cache_insert(canonical_workspace, Arc::new(manager.clone()));
110
111 Ok(manager)
112 }
113
114 pub fn load_global() -> Result<Self> {
120 let defaults_provider = defaults::current_config_defaults();
121 let config_file_name = defaults_provider.config_file_name().to_string();
122 let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
123 let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
124 if let Some(path) = &canonical_user_config_path
125 && !tracked_user_config_paths.iter().any(|existing| existing == path)
126 {
127 tracked_user_config_paths.push(path.clone());
128 }
129
130 let mut layer_sources = Vec::new();
131 for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
132 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
133 }
134 for home_config_path in &tracked_user_config_paths {
135 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
136 }
137
138 let mut layer_stack = ConfigLayerStack::default();
139 for source in layer_sources {
140 if let Some(layer) = Self::load_optional_layer(source) {
141 layer_stack.push(layer);
142 }
143 }
144
145 if let Some((layer, error)) = layer_stack.first_layer_error() {
146 bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
147 }
148
149 let (config, config_path) = if layer_stack.layers().is_empty() {
150 let config = VTCodeConfig::default();
151 config.validate().context("Default configuration failed validation")?;
152 (config, None)
153 } else {
154 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
155 let mut config: VTCodeConfig = effective_toml
156 .try_into()
157 .context("Failed to deserialize effective global configuration")?;
158 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
159 Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
160 config.validate().context("Global configuration failed validation")?;
161 config.workspace_lifecycle_hooks =
162 Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
163 let config_path = layer_stack
164 .layers()
165 .iter()
166 .rev()
167 .find(|layer| layer.is_enabled())
168 .and_then(|layer| match &layer.source {
169 ConfigLayerSource::System { file } | ConfigLayerSource::User { file } => Some(file.clone()),
170 _ => None,
171 });
172 (config, config_path)
173 };
174
175 Ok(Self {
176 config,
177 config_path,
178 canonical_user_config_path,
179 tracked_user_config_paths,
180 write_global_config_to_canonical: false,
181 explicit_config_is_trusted: false,
182 workspace_root: None,
183 config_file_name,
184 layer_stack,
185 phase_timing: None,
186 })
187 }
188
189 pub fn invalidate_workspace_cache(workspace: impl AsRef<Path>) {
195 let workspace = workspace.as_ref();
196 #[cfg(not(test))]
197 {
198 let canonical_workspace = canonicalize_workspace_root(workspace);
199 cache_remove(&canonical_workspace);
200 }
201 #[cfg(test)]
202 let _ = workspace;
203 }
204
205 pub fn invalidate_all_workspace_cache() {
212 #[cfg(not(test))]
213 with_cache_mut(|map| map.clear());
214 }
215
216 pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
223 if let Some(override_path) = session_override::explicit_config_path() {
224 return Self::load_for_session(workspace, override_path);
225 }
226
227 let workspace = workspace.as_ref();
228 #[cfg(not(test))]
229 let canonical_workspace = canonicalize_workspace_root(workspace);
230
231 #[cfg(not(test))]
232 if let Some(cached) = cache_get(&canonical_workspace) {
233 return Ok(cached.as_ref().clone());
234 }
235
236 let manager = Self::load_from_workspace_impl(workspace)?;
237
238 #[cfg(not(test))]
239 cache_insert(canonical_workspace, Arc::new(manager.clone()));
240
241 Ok(manager)
242 }
243
244 pub fn load_from_workspace_with_repository_repair(workspace: impl AsRef<Path>) -> Result<Self> {
255 let workspace = workspace.as_ref();
256 let mut error = match Self::load_from_workspace(workspace) {
257 Ok(manager) => return Ok(manager),
258 Err(error) => error,
259 };
260
261 let repository_paths = Self::repository_config_paths(workspace);
265 for _ in 0..=repository_paths.len() {
266 let Some(violation) = error.downcast_ref::<RepositoryProviderSecurityViolation>() else {
267 return Err(error);
268 };
269
270 let source = violation.source.clone();
271 let Some(path) = repository_paths
272 .iter()
273 .find(|candidate| Self::repository_source_matches_path(&source, candidate))
274 else {
275 return Err(error);
276 };
277
278 if !Self::repair_repository_config_file(path)? {
279 return Err(error);
280 }
281
282 tracing::warn!(
283 path = %path.display(),
284 "Removed prohibited provider settings left by an older repository configuration write"
285 );
286
287 Self::invalidate_workspace_cache(workspace);
288 match Self::load_from_workspace(workspace) {
289 Ok(manager) => return Ok(manager),
290 Err(next_error) => error = next_error,
291 }
292 }
293
294 Err(error).context("Failed to load workspace configuration after repairing prohibited provider settings")
295 }
296
297 fn load_from_workspace_impl(workspace: impl AsRef<Path>) -> Result<Self> {
298 let t0 = std::time::Instant::now();
299 let workspace = workspace.as_ref();
300 let defaults_provider = defaults::current_config_defaults();
301 let workspace_paths = defaults_provider.workspace_paths_for(workspace);
302 let workspace_root = canonicalize_workspace_root(workspace_paths.workspace_root());
303 let config_dir = workspace_paths.config_dir();
304 let config_file_name = defaults_provider.config_file_name().to_string();
305 let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
306 let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
307 if let Some(path) = &canonical_user_config_path
308 && !tracked_user_config_paths.iter().any(|existing| existing == path)
309 {
310 tracked_user_config_paths.push(path.clone());
311 }
312 let path_res_duration = t0.elapsed();
313
314 let t1 = std::time::Instant::now();
315
316 let mut layer_sources: Vec<ConfigLayerSource> = Vec::with_capacity(8);
319
320 for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
323 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
324 }
325
326 for home_config_path in &tracked_user_config_paths {
328 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
329 }
330
331 if let Some(project_config_path) = Self::project_config_path(&config_dir, &workspace_root, &config_file_name) {
333 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Project { file: project_config_path });
334 }
335
336 let fallback_path = config_dir.join(&config_file_name);
338 let workspace_config_path = workspace_root.join(&config_file_name);
339 if fallback_path != workspace_config_path {
340 push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Workspace { file: fallback_path });
341 }
342
343 push_unique_layer_source(
345 &mut layer_sources,
346 ConfigLayerSource::Workspace { file: workspace_config_path.clone() },
347 );
348
349 let raw_layers: Vec<Option<ConfigLayerEntry>> = std::thread::scope(|s| {
354 let handles = layer_sources
355 .into_iter()
356 .map(|source| s.spawn(move || Self::load_optional_layer(source)))
357 .collect::<Vec<_>>();
358 let joined = handles.into_iter().map(|h| h.join()).collect::<Vec<_>>();
360 joined
361 .into_iter()
362 .map(|r| r.map_err(|_payload| anyhow::anyhow!("config layer loader thread panicked")))
363 .collect::<Result<Vec<_>>>()
364 })?;
365
366 let mut layer_stack = ConfigLayerStack::default();
367 for layer in raw_layers.into_iter().flatten() {
368 layer_stack.push(layer);
369 }
370
371 let layer_load_duration = t1.elapsed();
372
373 if layer_stack.layers().is_empty() {
375 let t_val = std::time::Instant::now();
376 let config = VTCodeConfig::default();
377 config.validate().context("Default configuration failed validation")?;
378 let val_duration = t_val.elapsed();
379
380 let phase_timing = ConfigPhaseTiming {
381 path_resolution_us: path_res_duration.as_micros() as u64,
382 layer_loading_us: layer_load_duration.as_micros() as u64,
383 merge_and_parse_us: 0,
384 validation_us: val_duration.as_micros() as u64,
385 };
386 tracing::debug!(target: "vtcode_config", ?phase_timing, "Default configuration loaded");
387
388 return Ok(Self {
389 config,
390 config_path: None,
391 canonical_user_config_path,
392 tracked_user_config_paths,
393 write_global_config_to_canonical: false,
394 explicit_config_is_trusted: false,
395 workspace_root: Some(workspace_root),
396 config_file_name,
397 layer_stack,
398 phase_timing: Some(phase_timing),
399 });
400 }
401
402 let use_root_config = layer_stack
406 .layers()
407 .iter()
408 .find(|l| {
409 matches!(
410 &l.source,
411 ConfigLayerSource::Workspace { file }
412 if *file == workspace_config_path
413 )
414 })
415 .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
416 .unwrap_or(false);
417 if use_root_config {
418 layer_stack.retain(|layer| {
419 matches!(
420 &layer.source,
421 ConfigLayerSource::Workspace { file }
422 if *file == workspace_config_path
423 ) || matches!(&layer.source, ConfigLayerSource::Runtime)
424 });
425 if layer_stack.layers().is_empty() {
426 bail!(
427 "workspace.use_root_config is true but no workspace root config was found at {}",
428 workspace_config_path.display()
429 );
430 }
431 }
432
433 if let Some((layer, error)) = layer_stack.first_layer_error() {
434 bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
435 }
436
437 let t2 = std::time::Instant::now();
438 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
439 let mut config: VTCodeConfig = effective_toml
440 .try_into()
441 .context("Failed to deserialize effective configuration")?;
442 let merge_duration = t2.elapsed();
443
444 let t3 = std::time::Instant::now();
445 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
446 Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
447
448 config.validate().context("Configuration failed validation")?;
449 config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
450
451 migrate_custom_api_keys_if_needed(&mut config)?;
453 let val_duration = t3.elapsed();
454
455 let phase_timing = ConfigPhaseTiming {
456 path_resolution_us: path_res_duration.as_micros() as u64,
457 layer_loading_us: layer_load_duration.as_micros() as u64,
458 merge_and_parse_us: merge_duration.as_micros() as u64,
459 validation_us: val_duration.as_micros() as u64,
460 };
461 tracing::debug!(target: "vtcode_config", ?phase_timing, "Workspace configuration loaded");
462
463 let config_path = layer_stack
464 .layers()
465 .iter()
466 .rev()
467 .find(|layer| layer.is_enabled())
468 .and_then(|l| match &l.source {
469 ConfigLayerSource::User { file } => Some(file.clone()),
470 ConfigLayerSource::Project { file } => Some(file.clone()),
471 ConfigLayerSource::Workspace { file } => Some(file.clone()),
472 ConfigLayerSource::System { file } => Some(file.clone()),
473 ConfigLayerSource::Runtime => None,
474 });
475
476 Ok(Self {
477 config,
478 config_path,
479 canonical_user_config_path,
480 tracked_user_config_paths,
481 write_global_config_to_canonical: false,
482 explicit_config_is_trusted: false,
483 workspace_root: Some(workspace_root),
484 config_file_name,
485 layer_stack,
486 phase_timing: Some(phase_timing),
487 })
488 }
489
490 fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
491 let content =
492 fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
493 let mut value: toml::Value =
494 toml::from_str(&content).with_context(|| format!("Failed to parse config file: {}", path.display()))?;
495 crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut value);
496 Ok(value)
497 }
498
499 fn load_optional_layer(source: ConfigLayerSource) -> Option<ConfigLayerEntry> {
500 let file = match &source {
501 ConfigLayerSource::System { file }
502 | ConfigLayerSource::User { file }
503 | ConfigLayerSource::Project { file }
504 | ConfigLayerSource::Workspace { file } => file,
505 ConfigLayerSource::Runtime => {
506 return Some(ConfigLayerEntry::new(source, toml::Value::Table(toml::Table::new())));
507 }
508 };
509
510 let content = match fs::read_to_string(file) {
512 Ok(content) => content,
513 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
514 Err(err) if should_skip_optional_global_error(&source, &err) => {
515 tracing::debug!(path = %file.display(), "skipping inaccessible optional configuration layer");
516 return None;
517 }
518 Err(err) => {
519 let resolved_file = canonicalize_workspace_root(file);
520 let resolved_source = source.with_file(resolved_file);
521 return Some(Self::disabled_layer_from_error(resolved_source, err.into()));
522 }
523 };
524
525 let resolved_file = canonicalize_workspace_root(file);
526 let resolved_source = source.with_file(resolved_file);
527
528 match toml::from_str::<toml::Value>(&content) {
529 Ok(mut toml) => {
530 crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut toml);
531 Some(ConfigLayerEntry::new(resolved_source, toml))
532 }
533 Err(err) => {
534 let error =
535 anyhow::Error::from(err).context(format!("Failed to parse config file: {}", file.display()));
536 Some(Self::disabled_layer_from_error(resolved_source, error))
537 }
538 }
539 }
540
541 fn disabled_layer_from_error(source: ConfigLayerSource, error: anyhow::Error) -> ConfigLayerEntry {
542 let reason = if error.is::<toml::de::Error>() {
543 LayerDisabledReason::ParseError
544 } else {
545 LayerDisabledReason::LoadError
546 };
547 ConfigLayerEntry::disabled(source, reason, format!("{error:#}"))
548 }
549
550 fn workspace_root_wants_root_config_only(config: &toml::Value) -> bool {
555 config
556 .get("workspace")
557 .and_then(|v| v.get("use_root_config"))
558 .and_then(|v| v.as_bool())
559 .unwrap_or(false)
560 }
561
562 pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
564 Self::load_from_file_impl(path, false)
565 }
566
567 fn load_from_file_impl(path: impl AsRef<Path>, write_global_config_to_canonical: bool) -> Result<Self> {
568 let path = path.as_ref();
569 let defaults_provider = defaults::current_config_defaults();
570 let config_file_name = defaults_provider.config_file_name().to_string();
577 let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
578 let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
579 if let Some(path) = &canonical_user_config_path
580 && !tracked_user_config_paths.iter().any(|existing| existing == path)
581 {
582 tracked_user_config_paths.push(path.clone());
583 }
584
585 let mut layer_stack = ConfigLayerStack::default();
586 let mut global_sources = Vec::new();
587
588 for system_config in defaults_provider.system_config_paths(&config_file_name)? {
589 push_unique_layer_source(&mut global_sources, ConfigLayerSource::System { file: system_config });
590 }
591 for home_config_path in &tracked_user_config_paths {
592 push_unique_layer_source(&mut global_sources, ConfigLayerSource::User { file: home_config_path.clone() });
593 }
594 for source in global_sources {
595 if let Some(layer) = Self::load_optional_layer(source) {
596 layer_stack.push(layer);
597 }
598 }
599
600 let file = path.to_path_buf();
602 match Self::load_toml_from_file(path) {
603 Ok(toml) => layer_stack.push(ConfigLayerEntry::new(ConfigLayerSource::Workspace { file }, toml)),
604 Err(error) => {
605 layer_stack.push(Self::disabled_layer_from_error(ConfigLayerSource::Workspace { file }, error))
606 }
607 }
608
609 let use_root_config = layer_stack
612 .layers()
613 .iter()
614 .find(|l| {
615 matches!(
616 &l.source,
617 ConfigLayerSource::Workspace { file }
618 if *file == path
619 )
620 })
621 .map(|l| Self::workspace_root_wants_root_config_only(&l.config))
622 .unwrap_or(false);
623 if use_root_config {
624 layer_stack.retain(|layer| {
625 matches!(
626 &layer.source,
627 ConfigLayerSource::Workspace { file }
628 if *file == path
629 ) || matches!(&layer.source, ConfigLayerSource::Runtime)
630 });
631 }
632
633 if let Some((layer, error)) = layer_stack.first_layer_error() {
634 bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
635 }
636
637 let (effective_toml, origins) = layer_stack.effective_config_with_origins();
638 let mut config: VTCodeConfig = effective_toml
639 .try_into()
640 .with_context(|| format!("Failed to parse effective config with file: {}", path.display()))?;
641 Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
642 Self::validate_provider_security_fields(&layer_stack, &origins, &config, true)?;
646
647 config
648 .validate()
649 .with_context(|| format!("Failed to validate effective config with file: {}", path.display()))?;
650 config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
651
652 Ok(Self {
653 config,
654 config_path: Some(canonicalize_workspace_root(path)),
655 canonical_user_config_path,
656 tracked_user_config_paths,
657 write_global_config_to_canonical,
658 explicit_config_is_trusted: true,
659 workspace_root: path.parent().map(canonicalize_workspace_root),
660 config_file_name,
661 layer_stack,
662 phase_timing: None,
663 })
664 }
665
666 pub fn current_project_name(workspace_root: &Path) -> Option<String> {
668 Self::identify_current_project(workspace_root)
669 }
670
671 fn validate_restricted_agent_fields(
672 layer_stack: &ConfigLayerStack,
673 origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
674 ) -> Result<()> {
675 if let Some(origin) = origins.get("agent.persistent_memory.directory_override")
676 && let Some(layer) = layer_stack.layers().iter().find(|layer| layer.metadata == *origin)
677 {
678 match layer.source {
679 ConfigLayerSource::System { .. }
680 | ConfigLayerSource::User { .. }
681 | ConfigLayerSource::Project { .. } => {}
682 ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Runtime => {
683 bail!(
684 "agent.persistent_memory.directory_override may only be set in system, user, or project-profile configuration layers"
685 );
686 }
687 }
688 }
689
690 Ok(())
691 }
692
693 fn validate_provider_security_fields(
702 layer_stack: &ConfigLayerStack,
703 origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
704 config: &VTCodeConfig,
705 explicit_config_is_trusted: bool,
706 ) -> Result<()> {
707 if explicit_config_is_trusted {
708 return Ok(());
709 }
710
711 if !config.custom_providers.is_empty()
712 && let Some(origin) = origins.get("custom_providers")
713 && let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin)
714 && Self::is_repository_controlled_source(&layer.source)
715 {
716 return Err(RepositoryProviderSecurityViolation {
717 source: layer.source.clone(),
718 message: format!(
719 "repository-controlled configuration cannot define `custom_providers` (including `auth.command`); move custom provider settings to system, user, or an explicitly selected config file (source: {})",
720 layer.source.label()
721 ),
722 }
723 .into());
724 }
725
726 for (path, origin) in origins {
727 if path == "auth.copilot.command"
728 && config
729 .auth
730 .copilot
731 .command
732 .as_deref()
733 .is_some_and(|command| !command.trim().is_empty())
734 && let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin)
735 && Self::is_repository_controlled_source(&layer.source)
736 {
737 bail!(
738 "repository-controlled configuration cannot set `auth.copilot.command`; configure it in system, user, or an explicitly selected config file (source: {})",
739 layer.source.label()
740 );
741 }
742 if !Self::is_provider_endpoint_or_credential_path(path) {
743 continue;
744 }
745
746 let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin) else {
747 continue;
748 };
749 if Self::is_repository_controlled_source(&layer.source) {
750 return Err(RepositoryProviderSecurityViolation {
751 source: layer.source.clone(),
752 message: format!(
753 "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: {})",
754 layer.source.label()
755 ),
756 }
757 .into());
758 }
759 }
760
761 Ok(())
762 }
763
764 fn enabled_layer_for_origin<'a>(
765 layer_stack: &'a ConfigLayerStack,
766 origin: &ConfigLayerMetadata,
767 ) -> Option<&'a ConfigLayerEntry> {
768 layer_stack
769 .layers()
770 .iter()
771 .find(|layer| layer.is_enabled() && layer.metadata == *origin)
772 }
773
774 fn is_repository_controlled_source(source: &ConfigLayerSource) -> bool {
775 matches!(source, ConfigLayerSource::Project { .. } | ConfigLayerSource::Workspace { .. })
776 }
777
778 fn is_provider_endpoint_or_credential_path(path: &str) -> bool {
779 let mut segments = path.split('.');
780 matches!(segments.next(), Some("provider_overrides"))
781 && segments.next().is_some()
782 && matches!(segments.next(), Some("base_url" | "api_key_env"))
783 && segments.next().is_none()
784 }
785
786 pub(crate) fn collect_workspace_lifecycle_hooks(
792 layer_stack: &ConfigLayerStack,
793 hooks: &HooksConfig,
794 ) -> WorkspaceLifecycleHooks {
795 let (_, origins) = layer_stack.effective_config_with_origins();
796 let mut commands = Vec::new();
797
798 for event in crate::hooks::LIFECYCLE_HOOK_EVENTS {
799 let origin_key = format!("hooks.lifecycle.{event}");
800 let Some(origin) = origins.get(&origin_key) else {
801 continue;
802 };
803 let workspace_controlled = layer_stack.layers().iter().any(|layer| {
804 layer.is_enabled()
805 && layer.metadata == *origin
806 && matches!(layer.source, ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Project { .. })
807 });
808 if !workspace_controlled {
809 continue;
810 }
811
812 let tag = match *event {
814 "task_completion" | "task_completed" => "stop",
815 other => other,
816 };
817 for group in hooks.lifecycle.groups_for_event(event) {
818 for command in &group.hooks {
819 commands.push(WorkspaceHookCommand {
820 event: tag.to_string(),
821 matcher: group.matcher.clone(),
822 command: command.command.clone(),
823 timeout_seconds: command.timeout_seconds,
824 });
825 }
826 }
827 }
828
829 WorkspaceLifecycleHooks { commands }
830 }
831}
832
833#[cfg(test)]
834mod sparse_config_tests;
835#[cfg(test)]
836mod workspace_lifecycle_hooks_tests;