mod cache;
mod inspect;
mod migrate;
mod persistence;
mod repository;
use cache::{
RepositoryProviderSecurityViolation, canonicalize_workspace_root, push_unique_layer_source,
should_skip_optional_global_error,
};
#[cfg(not(test))]
use cache::{cache_get, cache_insert, cache_remove, with_cache_mut};
use migrate::migrate_custom_api_keys_if_needed;
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result, bail};
use crate::api_keys::{api_key_env_var, credential_metadata_key, store_credential_with_mode};
use crate::defaults::{self};
use crate::hooks::{HooksConfig, LifecycleHooksConfig, WorkspaceHookCommand, WorkspaceLifecycleHooks};
use crate::loader::config::VTCodeConfig;
use crate::loader::layers::{
ConfigLayerEntry, ConfigLayerMetadata, ConfigLayerSource, ConfigLayerStack, LayerDisabledReason,
};
use crate::loader::session_override;
use vtcode_commons::VtCodePaths;
use vtcode_commons::canonicalize;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ConfigPhaseTiming {
pub path_resolution_us: u64,
pub layer_loading_us: u64,
pub merge_and_parse_us: u64,
pub validation_us: u64,
}
#[derive(Clone)]
pub struct ConfigManager {
pub(crate) config: VTCodeConfig,
pub(super) config_path: Option<PathBuf>,
pub(super) canonical_user_config_path: Option<PathBuf>,
pub(super) tracked_user_config_paths: Vec<PathBuf>,
write_global_config_to_canonical: bool,
pub(super) explicit_config_is_trusted: bool,
pub(super) workspace_root: Option<PathBuf>,
pub(super) config_file_name: String,
pub(crate) layer_stack: ConfigLayerStack,
pub(super) phase_timing: Option<ConfigPhaseTiming>,
}
impl ConfigManager {
pub fn load() -> Result<Self> {
if let Some(override_path) = session_override::explicit_config_path() {
return Self::load_for_session(std::env::current_dir()?, &override_path).with_context(|| {
format!("Failed to load configuration from explicit path {}", override_path.display())
});
}
Self::load_from_workspace_with_repository_repair(std::env::current_dir()?)
}
pub fn load_for_session(workspace: impl AsRef<Path>, explicit_path: impl AsRef<Path>) -> Result<Self> {
let workspace = workspace.as_ref();
let explicit_path = explicit_path.as_ref();
#[cfg(not(test))]
let canonical_workspace = canonicalize_workspace_root(workspace);
#[cfg(not(test))]
if let Some(cached) = cache_get(&canonical_workspace) {
return Ok(cached.as_ref().clone());
}
let mut manager = Self::load_from_file_impl(explicit_path, false).with_context(|| {
format!(
"Failed to load explicit session config file {} (from --config / VTCODE_CONFIG_PATH)",
explicit_path.display()
)
})?;
manager.workspace_root = Some(canonicalize_workspace_root(workspace));
#[cfg(not(test))]
cache_insert(canonical_workspace, Arc::new(manager.clone()));
Ok(manager)
}
pub fn load_global() -> Result<Self> {
let defaults_provider = defaults::current_config_defaults();
let config_file_name = defaults_provider.config_file_name().to_string();
let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
if let Some(path) = &canonical_user_config_path
&& !tracked_user_config_paths.iter().any(|existing| existing == path)
{
tracked_user_config_paths.push(path.clone());
}
let mut layer_sources = Vec::new();
for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
}
for home_config_path in &tracked_user_config_paths {
push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
}
let mut layer_stack = ConfigLayerStack::default();
for source in layer_sources {
if let Some(layer) = Self::load_optional_layer(source) {
layer_stack.push(layer);
}
}
if let Some((layer, error)) = layer_stack.first_layer_error() {
bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
}
let (config, config_path) = if layer_stack.layers().is_empty() {
let config = VTCodeConfig::default();
config.validate().context("Default configuration failed validation")?;
(config, None)
} else {
let (effective_toml, origins) = layer_stack.effective_config_with_origins();
let mut config: VTCodeConfig = effective_toml
.try_into()
.context("Failed to deserialize effective global configuration")?;
Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
config.validate().context("Global configuration failed validation")?;
config.workspace_lifecycle_hooks =
Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
let config_path = layer_stack
.layers()
.iter()
.rev()
.find(|layer| layer.is_enabled())
.and_then(|layer| match &layer.source {
ConfigLayerSource::System { file } | ConfigLayerSource::User { file } => Some(file.clone()),
_ => None,
});
(config, config_path)
};
Ok(Self {
config,
config_path,
canonical_user_config_path,
tracked_user_config_paths,
write_global_config_to_canonical: false,
explicit_config_is_trusted: false,
workspace_root: None,
config_file_name,
layer_stack,
phase_timing: None,
})
}
pub fn invalidate_workspace_cache(workspace: impl AsRef<Path>) {
let workspace = workspace.as_ref();
#[cfg(not(test))]
{
let canonical_workspace = canonicalize_workspace_root(workspace);
cache_remove(&canonical_workspace);
}
#[cfg(test)]
let _ = workspace;
}
pub fn invalidate_all_workspace_cache() {
#[cfg(not(test))]
with_cache_mut(|map| map.clear());
}
pub fn load_from_workspace(workspace: impl AsRef<Path>) -> Result<Self> {
if let Some(override_path) = session_override::explicit_config_path() {
return Self::load_for_session(workspace, override_path);
}
let workspace = workspace.as_ref();
#[cfg(not(test))]
let canonical_workspace = canonicalize_workspace_root(workspace);
#[cfg(not(test))]
if let Some(cached) = cache_get(&canonical_workspace) {
return Ok(cached.as_ref().clone());
}
let manager = Self::load_from_workspace_impl(workspace)?;
#[cfg(not(test))]
cache_insert(canonical_workspace, Arc::new(manager.clone()));
Ok(manager)
}
pub fn load_from_workspace_with_repository_repair(workspace: impl AsRef<Path>) -> Result<Self> {
let workspace = workspace.as_ref();
let mut error = match Self::load_from_workspace(workspace) {
Ok(manager) => return Ok(manager),
Err(error) => error,
};
let repository_paths = Self::repository_config_paths(workspace);
for _ in 0..=repository_paths.len() {
let Some(violation) = error.downcast_ref::<RepositoryProviderSecurityViolation>() else {
return Err(error);
};
let source = violation.source.clone();
let Some(path) = repository_paths
.iter()
.find(|candidate| Self::repository_source_matches_path(&source, candidate))
else {
return Err(error);
};
if !Self::repair_repository_config_file(path)? {
return Err(error);
}
tracing::warn!(
path = %path.display(),
"Removed prohibited provider settings left by an older repository configuration write"
);
Self::invalidate_workspace_cache(workspace);
match Self::load_from_workspace(workspace) {
Ok(manager) => return Ok(manager),
Err(next_error) => error = next_error,
}
}
Err(error).context("Failed to load workspace configuration after repairing prohibited provider settings")
}
fn load_from_workspace_impl(workspace: impl AsRef<Path>) -> Result<Self> {
let t0 = std::time::Instant::now();
let workspace = workspace.as_ref();
let defaults_provider = defaults::current_config_defaults();
let workspace_paths = defaults_provider.workspace_paths_for(workspace);
let workspace_root = canonicalize_workspace_root(workspace_paths.workspace_root());
let config_dir = workspace_paths.config_dir();
let config_file_name = defaults_provider.config_file_name().to_string();
let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
if let Some(path) = &canonical_user_config_path
&& !tracked_user_config_paths.iter().any(|existing| existing == path)
{
tracked_user_config_paths.push(path.clone());
}
let path_res_duration = t0.elapsed();
let t1 = std::time::Instant::now();
let mut layer_sources: Vec<ConfigLayerSource> = Vec::with_capacity(8);
for system_config_path in defaults_provider.system_config_paths(&config_file_name)? {
push_unique_layer_source(&mut layer_sources, ConfigLayerSource::System { file: system_config_path });
}
for home_config_path in &tracked_user_config_paths {
push_unique_layer_source(&mut layer_sources, ConfigLayerSource::User { file: home_config_path.clone() });
}
if let Some(project_config_path) = Self::project_config_path(&config_dir, &workspace_root, &config_file_name) {
push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Project { file: project_config_path });
}
let fallback_path = config_dir.join(&config_file_name);
let workspace_config_path = workspace_root.join(&config_file_name);
if fallback_path != workspace_config_path {
push_unique_layer_source(&mut layer_sources, ConfigLayerSource::Workspace { file: fallback_path });
}
push_unique_layer_source(
&mut layer_sources,
ConfigLayerSource::Workspace { file: workspace_config_path.clone() },
);
let raw_layers: Vec<Option<ConfigLayerEntry>> = std::thread::scope(|s| {
let handles = layer_sources
.into_iter()
.map(|source| s.spawn(move || Self::load_optional_layer(source)))
.collect::<Vec<_>>();
let joined = handles.into_iter().map(|h| h.join()).collect::<Vec<_>>();
joined
.into_iter()
.map(|r| r.map_err(|_payload| anyhow::anyhow!("config layer loader thread panicked")))
.collect::<Result<Vec<_>>>()
})?;
let mut layer_stack = ConfigLayerStack::default();
for layer in raw_layers.into_iter().flatten() {
layer_stack.push(layer);
}
let layer_load_duration = t1.elapsed();
if layer_stack.layers().is_empty() {
let t_val = std::time::Instant::now();
let config = VTCodeConfig::default();
config.validate().context("Default configuration failed validation")?;
let val_duration = t_val.elapsed();
let phase_timing = ConfigPhaseTiming {
path_resolution_us: path_res_duration.as_micros() as u64,
layer_loading_us: layer_load_duration.as_micros() as u64,
merge_and_parse_us: 0,
validation_us: val_duration.as_micros() as u64,
};
tracing::debug!(target: "vtcode_config", ?phase_timing, "Default configuration loaded");
return Ok(Self {
config,
config_path: None,
canonical_user_config_path,
tracked_user_config_paths,
write_global_config_to_canonical: false,
explicit_config_is_trusted: false,
workspace_root: Some(workspace_root),
config_file_name,
layer_stack,
phase_timing: Some(phase_timing),
});
}
let use_root_config = layer_stack
.layers()
.iter()
.find(|l| {
matches!(
&l.source,
ConfigLayerSource::Workspace { file }
if *file == workspace_config_path
)
})
.map(|l| Self::workspace_root_wants_root_config_only(&l.config))
.unwrap_or(false);
if use_root_config {
layer_stack.retain(|layer| {
matches!(
&layer.source,
ConfigLayerSource::Workspace { file }
if *file == workspace_config_path
) || matches!(&layer.source, ConfigLayerSource::Runtime)
});
if layer_stack.layers().is_empty() {
bail!(
"workspace.use_root_config is true but no workspace root config was found at {}",
workspace_config_path.display()
);
}
}
if let Some((layer, error)) = layer_stack.first_layer_error() {
bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
}
let t2 = std::time::Instant::now();
let (effective_toml, origins) = layer_stack.effective_config_with_origins();
let mut config: VTCodeConfig = effective_toml
.try_into()
.context("Failed to deserialize effective configuration")?;
let merge_duration = t2.elapsed();
let t3 = std::time::Instant::now();
Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
Self::validate_provider_security_fields(&layer_stack, &origins, &config, false)?;
config.validate().context("Configuration failed validation")?;
config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
migrate_custom_api_keys_if_needed(&mut config)?;
let val_duration = t3.elapsed();
let phase_timing = ConfigPhaseTiming {
path_resolution_us: path_res_duration.as_micros() as u64,
layer_loading_us: layer_load_duration.as_micros() as u64,
merge_and_parse_us: merge_duration.as_micros() as u64,
validation_us: val_duration.as_micros() as u64,
};
tracing::debug!(target: "vtcode_config", ?phase_timing, "Workspace configuration loaded");
let config_path = layer_stack
.layers()
.iter()
.rev()
.find(|layer| layer.is_enabled())
.and_then(|l| match &l.source {
ConfigLayerSource::User { file } => Some(file.clone()),
ConfigLayerSource::Project { file } => Some(file.clone()),
ConfigLayerSource::Workspace { file } => Some(file.clone()),
ConfigLayerSource::System { file } => Some(file.clone()),
ConfigLayerSource::Runtime => None,
});
Ok(Self {
config,
config_path,
canonical_user_config_path,
tracked_user_config_paths,
write_global_config_to_canonical: false,
explicit_config_is_trusted: false,
workspace_root: Some(workspace_root),
config_file_name,
layer_stack,
phase_timing: Some(phase_timing),
})
}
fn load_toml_from_file(path: &Path) -> Result<toml::Value> {
let content =
fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
let mut value: toml::Value =
toml::from_str(&content).with_context(|| format!("Failed to parse config file: {}", path.display()))?;
crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut value);
Ok(value)
}
fn load_optional_layer(source: ConfigLayerSource) -> Option<ConfigLayerEntry> {
let file = match &source {
ConfigLayerSource::System { file }
| ConfigLayerSource::User { file }
| ConfigLayerSource::Project { file }
| ConfigLayerSource::Workspace { file } => file,
ConfigLayerSource::Runtime => {
return Some(ConfigLayerEntry::new(source, toml::Value::Table(toml::Table::new())));
}
};
let content = match fs::read_to_string(file) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
Err(err) if should_skip_optional_global_error(&source, &err) => {
tracing::debug!(path = %file.display(), "skipping inaccessible optional configuration layer");
return None;
}
Err(err) => {
let resolved_file = canonicalize_workspace_root(file);
let resolved_source = source.with_file(resolved_file);
return Some(Self::disabled_layer_from_error(resolved_source, err.into()));
}
};
let resolved_file = canonicalize_workspace_root(file);
let resolved_source = source.with_file(resolved_file);
match toml::from_str::<toml::Value>(&content) {
Ok(mut toml) => {
crate::loader::merge::normalize_legacy_top_level_provider_aliases(&mut toml);
Some(ConfigLayerEntry::new(resolved_source, toml))
}
Err(err) => {
let error =
anyhow::Error::from(err).context(format!("Failed to parse config file: {}", file.display()));
Some(Self::disabled_layer_from_error(resolved_source, error))
}
}
}
fn disabled_layer_from_error(source: ConfigLayerSource, error: anyhow::Error) -> ConfigLayerEntry {
let reason = if error.is::<toml::de::Error>() {
LayerDisabledReason::ParseError
} else {
LayerDisabledReason::LoadError
};
ConfigLayerEntry::disabled(source, reason, format!("{error:#}"))
}
fn workspace_root_wants_root_config_only(config: &toml::Value) -> bool {
config
.get("workspace")
.and_then(|v| v.get("use_root_config"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
Self::load_from_file_impl(path, false)
}
fn load_from_file_impl(path: impl AsRef<Path>, write_global_config_to_canonical: bool) -> Result<Self> {
let path = path.as_ref();
let defaults_provider = defaults::current_config_defaults();
let config_file_name = defaults_provider.config_file_name().to_string();
let canonical_user_config_path = defaults_provider.canonical_user_config_path(&config_file_name)?;
let mut tracked_user_config_paths = defaults_provider.home_config_paths(&config_file_name);
if let Some(path) = &canonical_user_config_path
&& !tracked_user_config_paths.iter().any(|existing| existing == path)
{
tracked_user_config_paths.push(path.clone());
}
let mut layer_stack = ConfigLayerStack::default();
let mut global_sources = Vec::new();
for system_config in defaults_provider.system_config_paths(&config_file_name)? {
push_unique_layer_source(&mut global_sources, ConfigLayerSource::System { file: system_config });
}
for home_config_path in &tracked_user_config_paths {
push_unique_layer_source(&mut global_sources, ConfigLayerSource::User { file: home_config_path.clone() });
}
for source in global_sources {
if let Some(layer) = Self::load_optional_layer(source) {
layer_stack.push(layer);
}
}
let file = path.to_path_buf();
match Self::load_toml_from_file(path) {
Ok(toml) => layer_stack.push(ConfigLayerEntry::new(ConfigLayerSource::Workspace { file }, toml)),
Err(error) => {
layer_stack.push(Self::disabled_layer_from_error(ConfigLayerSource::Workspace { file }, error))
}
}
let use_root_config = layer_stack
.layers()
.iter()
.find(|l| {
matches!(
&l.source,
ConfigLayerSource::Workspace { file }
if *file == path
)
})
.map(|l| Self::workspace_root_wants_root_config_only(&l.config))
.unwrap_or(false);
if use_root_config {
layer_stack.retain(|layer| {
matches!(
&layer.source,
ConfigLayerSource::Workspace { file }
if *file == path
) || matches!(&layer.source, ConfigLayerSource::Runtime)
});
}
if let Some((layer, error)) = layer_stack.first_layer_error() {
bail!("Configuration layer '{}' failed to load: {}", layer.source.label(), error.message);
}
let (effective_toml, origins) = layer_stack.effective_config_with_origins();
let mut config: VTCodeConfig = effective_toml
.try_into()
.with_context(|| format!("Failed to parse effective config with file: {}", path.display()))?;
Self::validate_restricted_agent_fields(&layer_stack, &origins)?;
Self::validate_provider_security_fields(&layer_stack, &origins, &config, true)?;
config
.validate()
.with_context(|| format!("Failed to validate effective config with file: {}", path.display()))?;
config.workspace_lifecycle_hooks = Some(Self::collect_workspace_lifecycle_hooks(&layer_stack, &config.hooks));
Ok(Self {
config,
config_path: Some(canonicalize_workspace_root(path)),
canonical_user_config_path,
tracked_user_config_paths,
write_global_config_to_canonical,
explicit_config_is_trusted: true,
workspace_root: path.parent().map(canonicalize_workspace_root),
config_file_name,
layer_stack,
phase_timing: None,
})
}
pub fn current_project_name(workspace_root: &Path) -> Option<String> {
Self::identify_current_project(workspace_root)
}
fn validate_restricted_agent_fields(
layer_stack: &ConfigLayerStack,
origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
) -> Result<()> {
if let Some(origin) = origins.get("agent.persistent_memory.directory_override")
&& let Some(layer) = layer_stack.layers().iter().find(|layer| layer.metadata == *origin)
{
match layer.source {
ConfigLayerSource::System { .. }
| ConfigLayerSource::User { .. }
| ConfigLayerSource::Project { .. } => {}
ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Runtime => {
bail!(
"agent.persistent_memory.directory_override may only be set in system, user, or project-profile configuration layers"
);
}
}
}
Ok(())
}
fn validate_provider_security_fields(
layer_stack: &ConfigLayerStack,
origins: &hashbrown::HashMap<String, ConfigLayerMetadata>,
config: &VTCodeConfig,
explicit_config_is_trusted: bool,
) -> Result<()> {
if explicit_config_is_trusted {
return Ok(());
}
if !config.custom_providers.is_empty()
&& let Some(origin) = origins.get("custom_providers")
&& let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin)
&& Self::is_repository_controlled_source(&layer.source)
{
return Err(RepositoryProviderSecurityViolation {
source: layer.source.clone(),
message: format!(
"repository-controlled configuration cannot define `custom_providers` (including `auth.command`); move custom provider settings to system, user, or an explicitly selected config file (source: {})",
layer.source.label()
),
}
.into());
}
for (path, origin) in origins {
if path == "auth.copilot.command"
&& config
.auth
.copilot
.command
.as_deref()
.is_some_and(|command| !command.trim().is_empty())
&& let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin)
&& Self::is_repository_controlled_source(&layer.source)
{
bail!(
"repository-controlled configuration cannot set `auth.copilot.command`; configure it in system, user, or an explicitly selected config file (source: {})",
layer.source.label()
);
}
if !Self::is_provider_endpoint_or_credential_path(path) {
continue;
}
let Some(layer) = Self::enabled_layer_for_origin(layer_stack, origin) else {
continue;
};
if Self::is_repository_controlled_source(&layer.source) {
return Err(RepositoryProviderSecurityViolation {
source: layer.source.clone(),
message: format!(
"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: {})",
layer.source.label()
),
}
.into());
}
}
Ok(())
}
fn enabled_layer_for_origin<'a>(
layer_stack: &'a ConfigLayerStack,
origin: &ConfigLayerMetadata,
) -> Option<&'a ConfigLayerEntry> {
layer_stack
.layers()
.iter()
.find(|layer| layer.is_enabled() && layer.metadata == *origin)
}
fn is_repository_controlled_source(source: &ConfigLayerSource) -> bool {
matches!(source, ConfigLayerSource::Project { .. } | ConfigLayerSource::Workspace { .. })
}
fn is_provider_endpoint_or_credential_path(path: &str) -> bool {
let mut segments = path.split('.');
matches!(segments.next(), Some("provider_overrides"))
&& segments.next().is_some()
&& matches!(segments.next(), Some("base_url" | "api_key_env"))
&& segments.next().is_none()
}
pub(crate) fn collect_workspace_lifecycle_hooks(
layer_stack: &ConfigLayerStack,
hooks: &HooksConfig,
) -> WorkspaceLifecycleHooks {
let (_, origins) = layer_stack.effective_config_with_origins();
let mut commands = Vec::new();
for event in crate::hooks::LIFECYCLE_HOOK_EVENTS {
let origin_key = format!("hooks.lifecycle.{event}");
let Some(origin) = origins.get(&origin_key) else {
continue;
};
let workspace_controlled = layer_stack.layers().iter().any(|layer| {
layer.is_enabled()
&& layer.metadata == *origin
&& matches!(layer.source, ConfigLayerSource::Workspace { .. } | ConfigLayerSource::Project { .. })
});
if !workspace_controlled {
continue;
}
let tag = match *event {
"task_completion" | "task_completed" => "stop",
other => other,
};
for group in hooks.lifecycle.groups_for_event(event) {
for command in &group.hooks {
commands.push(WorkspaceHookCommand {
event: tag.to_string(),
matcher: group.matcher.clone(),
command: command.command.clone(),
timeout_seconds: command.timeout_seconds,
});
}
}
}
WorkspaceLifecycleHooks { commands }
}
}
#[cfg(test)]
mod sparse_config_tests;
#[cfg(test)]
mod workspace_lifecycle_hooks_tests;