Add variable expansion in config.toml

This commit is contained in:
Brian Mgrdichian
2026-01-29 14:01:44 -08:00
parent 28051d18c6
commit bb98df8c4d
10 changed files with 1486 additions and 56 deletions

View File

@@ -0,0 +1,358 @@
use std::collections::BTreeSet;
use std::collections::HashMap;
use dirs::home_dir;
use serde_json::json;
use toml::Value as TomlValue;
pub const KEY_COLLISION_SENTINEL: &str = "KEY_COLLISION";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigExpansionWarning {
pub var: String,
pub path: String,
}
impl ConfigExpansionWarning {
pub fn new(var: impl Into<String>, path: impl Into<String>) -> Self {
Self {
var: var.into(),
path: path.into(),
}
}
}
pub(crate) trait EnvProvider {
fn get(&self, key: &str) -> Option<String>;
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct RealEnv;
impl EnvProvider for RealEnv {
fn get(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PathSegment {
Key(String),
Index(usize),
}
pub(crate) struct ExpansionResult {
pub value: TomlValue,
pub warnings: Vec<ConfigExpansionWarning>,
}
pub(crate) fn expand_config_toml(value: TomlValue) -> ExpansionResult {
expand_config_toml_with_env(value, &RealEnv)
}
pub(crate) fn expand_key_for_matching_with_env(key: &str, env: &impl EnvProvider) -> String {
if key == "~" {
let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
if let Some(home) = env.get(home_var) {
return home;
}
if let Some(home) = home_dir() {
return home.to_string_lossy().to_string();
}
return key.to_string();
}
let mut warnings = Vec::new();
expand_string(key, "<projects>", env, &mut warnings)
}
pub(crate) fn expand_config_toml_with_env(
value: TomlValue,
env: &impl EnvProvider,
) -> ExpansionResult {
let mut warnings = Vec::new();
let mut path = Vec::new();
let value = expand_value(value, &mut path, env, &mut warnings);
ExpansionResult { value, warnings }
}
fn expand_value(
value: TomlValue,
path: &mut Vec<PathSegment>,
env: &impl EnvProvider,
warnings: &mut Vec<ConfigExpansionWarning>,
) -> TomlValue {
match value {
TomlValue::String(value) => {
let path_display = format_path(path);
TomlValue::String(expand_string(&value, &path_display, env, warnings))
}
TomlValue::Array(values) => {
let mut expanded = Vec::with_capacity(values.len());
for (index, value) in values.into_iter().enumerate() {
path.push(PathSegment::Index(index));
expanded.push(expand_value(value, path, env, warnings));
path.pop();
}
TomlValue::Array(expanded)
}
TomlValue::Table(values) => {
let mut expanded = toml::map::Map::new();
let mut original_key_by_expanded_key: HashMap<String, String> = HashMap::new();
for (key, value) in values {
path.push(PathSegment::Key(key.clone()));
let parent_path_display = format_path(&path[..path.len().saturating_sub(1)]);
let key_path_display = format_path(path);
let expanded_key = expand_string(&key, &key_path_display, env, warnings);
let expanded_value = expand_value(value, path, env, warnings);
path.pop();
if let Some(previous_original_key) =
original_key_by_expanded_key.get(&expanded_key).cloned()
{
warnings.push(new_key_collision_warning(
parent_path_display,
expanded_key,
previous_original_key,
key,
));
continue;
}
original_key_by_expanded_key.insert(expanded_key.clone(), key);
expanded.insert(expanded_key, expanded_value);
}
TomlValue::Table(expanded)
}
value => value,
}
}
fn new_key_collision_warning(
parent_path: String,
expanded_key: String,
original_key_a: String,
original_key_b: String,
) -> ConfigExpansionWarning {
// Encode collision details into the existing warning shape to avoid a breaking API change.
let path = json!({
"path": parent_path,
"expanded_key": expanded_key,
"original_keys": [original_key_a, original_key_b],
})
.to_string();
ConfigExpansionWarning::new(KEY_COLLISION_SENTINEL, path)
}
fn expand_string(
input: &str,
path: &str,
env: &impl EnvProvider,
warnings: &mut Vec<ConfigExpansionWarning>,
) -> String {
let mut missing_vars = BTreeSet::new();
let input = expand_tilde_prefix(input, env, &mut missing_vars);
let mut output = String::with_capacity(input.len());
let mut iter = input.chars().peekable();
while let Some(ch) = iter.next() {
if ch != '$' {
output.push(ch);
continue;
}
let Some(&next) = iter.peek() else {
output.push('$');
continue;
};
if next == '$' {
output.push('$');
iter.next();
continue;
}
if next == '{' {
iter.next();
let mut name = String::new();
let mut closed = false;
while let Some(&ch) = iter.peek() {
iter.next();
if ch == '}' {
closed = true;
break;
}
name.push(ch);
}
if !closed || !is_valid_env_name(&name) {
output.push_str("${");
output.push_str(&name);
if closed {
output.push('}');
}
continue;
}
if let Some(value) = env.get(&name) {
output.push_str(&value);
} else {
missing_vars.insert(name.clone());
output.push_str("${");
output.push_str(&name);
output.push('}');
}
continue;
}
if !is_valid_env_start(next) {
output.push('$');
continue;
}
let mut name = String::new();
name.push(next);
iter.next();
while let Some(&ch) = iter.peek() {
if is_valid_env_continue(ch) {
name.push(ch);
iter.next();
} else {
break;
}
}
if let Some(value) = env.get(&name) {
output.push_str(&value);
} else {
missing_vars.insert(name.clone());
output.push('$');
output.push_str(&name);
}
}
for var in missing_vars {
warnings.push(ConfigExpansionWarning::new(var, path.to_string()));
}
output
}
fn expand_tilde_prefix(
input: &str,
env: &impl EnvProvider,
missing_vars: &mut BTreeSet<String>,
) -> String {
if !input.starts_with("~/") && !input.starts_with("~\\") {
return input.to_string();
}
let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
match env.get(home_var) {
Some(home) => {
let mut expanded = String::with_capacity(home.len() + input.len().saturating_sub(1));
expanded.push_str(&home);
expanded.push_str(&input[1..]);
expanded
}
None => {
missing_vars.insert(home_var.to_string());
input.to_string()
}
}
}
fn is_valid_env_start(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphabetic()
}
fn is_valid_env_continue(ch: char) -> bool {
ch == '_' || ch.is_ascii_alphanumeric()
}
fn is_valid_env_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !is_valid_env_start(first) {
return false;
}
chars.all(is_valid_env_continue)
}
fn format_path(segments: &[PathSegment]) -> String {
if segments.is_empty() {
return "<root>".to_string();
}
let mut output = String::new();
for segment in segments {
match segment {
PathSegment::Key(key) => {
if is_simple_key(key) {
if !output.is_empty() {
output.push('.');
}
output.push_str(key);
} else {
output.push_str("[\"");
output.push_str(&escape_key(key));
output.push_str("\"]");
}
}
PathSegment::Index(index) => {
output.push('[');
output.push_str(&index.to_string());
output.push(']');
}
}
}
output
}
fn is_simple_key(key: &str) -> bool {
let mut chars = key.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}
fn escape_key(key: &str) -> String {
let mut escaped = String::with_capacity(key.len());
for ch in key.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
_ => escaped.push(ch),
}
}
escaped
}
#[cfg(test)]
pub(crate) struct FakeEnv {
vars: std::collections::HashMap<String, String>,
}
#[cfg(test)]
impl FakeEnv {
pub(crate) fn new(
vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
) -> Self {
Self {
vars: vars
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
}
}
}
#[cfg(test)]
impl EnvProvider for FakeEnv {
fn get(&self, key: &str) -> Option<String> {
self.vars.get(key).cloned()
}
}

View File

@@ -1,5 +1,6 @@
mod config_requirements;
mod diagnostics;
mod expansion;
mod fingerprint;
mod layer_io;
#[cfg(target_os = "macos")]
@@ -24,8 +25,10 @@ use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use serde::Deserialize;
use std::collections::HashMap;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
pub use config_requirements::ConfigRequirements;
@@ -45,8 +48,19 @@ pub(crate) use diagnostics::first_layer_config_error_from_entries;
pub use diagnostics::format_config_error;
pub use diagnostics::format_config_error_with_source;
pub(crate) use diagnostics::io_error_from_config_error;
pub use expansion::ConfigExpansionWarning;
pub(crate) use expansion::EnvProvider;
#[cfg(test)]
pub(crate) use expansion::FakeEnv;
use expansion::KEY_COLLISION_SENTINEL;
pub(crate) use expansion::RealEnv;
pub(crate) use expansion::expand_config_toml;
#[cfg(test)]
pub(crate) use expansion::expand_config_toml_with_env;
pub(crate) use expansion::expand_key_for_matching_with_env;
pub use merge::merge_toml_values;
pub(crate) use overrides::build_cli_overrides_layer;
pub use state::ConfigExpansionWarningInfo;
pub use state::ConfigLayerEntry;
pub use state::ConfigLayerStack;
pub use state::ConfigLayerStackOrdering;
@@ -62,6 +76,78 @@ pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
pub fn config_layer_source_display(source: &ConfigLayerSource) -> String {
match source {
ConfigLayerSource::System { file } => file.as_path().display().to_string(),
ConfigLayerSource::User { file } => file.as_path().display().to_string(),
ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder
.as_path()
.join(CONFIG_TOML_FILE)
.display()
.to_string(),
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
file.as_path().display().to_string()
}
ConfigLayerSource::Mdm { domain, key } => format!("MDM {domain}:{key}"),
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => "MDM managed_config.toml".to_string(),
ConfigLayerSource::SessionFlags => "session flags".to_string(),
}
}
pub fn format_expansion_warnings(warnings: &[ConfigExpansionWarningInfo]) -> String {
let mut message =
"Some config values could not expand environment variables and were left unchanged.\n"
.to_string();
for (index, warning) in warnings.iter().enumerate() {
let display_index = index + 1;
let source = config_layer_source_display(&warning.source);
if warning.warning.var == KEY_COLLISION_SENTINEL {
message.push_str(&format_collision_warning(
display_index,
source.as_str(),
warning.warning.path.as_str(),
));
continue;
}
let path = warning.warning.path.as_str();
let var = warning.warning.var.as_str();
message.push_str(&format!(
" {display_index}. {source}: {path} references ${var} which is not set\n"
));
}
message
}
fn format_collision_warning(display_index: usize, source: &str, path: &str) -> String {
let parsed = serde_json::from_str::<serde_json::Value>(path);
if let Ok(value) = parsed {
let parent_path = value
.get("path")
.and_then(serde_json::Value::as_str)
.unwrap_or("<unknown>");
let expanded_key = value
.get("expanded_key")
.and_then(serde_json::Value::as_str)
.unwrap_or("<unknown>");
let original_keys = value
.get("original_keys")
.and_then(serde_json::Value::as_array)
.map(|keys| {
keys.iter()
.filter_map(serde_json::Value::as_str)
.map(|key| format!("\"{key}\""))
.collect::<Vec<_>>()
.join(" and ")
})
.filter(|keys| !keys.is_empty())
.unwrap_or_else(|| "\"<unknown>\"".to_string());
return format!(
" {display_index}. {source}: {parent_path} has duplicate keys after expansion: {original_keys} both expand to \"{expanded_key}\" (kept first)\n"
);
}
format!(" {display_index}. {source}: duplicate keys after expansion (details unavailable)\n")
}
/// To build up the set of admin-enforced constraints, we build up from multiple
/// configuration layers in the following order, but a constraint defined in an
/// earlier layer cannot be overridden by a later layer:
@@ -96,6 +182,16 @@ pub async fn load_config_layers_state(
cwd: Option<AbsolutePathBuf>,
cli_overrides: &[(String, TomlValue)],
overrides: LoaderOverrides,
) -> io::Result<ConfigLayerStack> {
load_config_layers_state_with_env(codex_home, cwd, cli_overrides, overrides, &RealEnv).await
}
async fn load_config_layers_state_with_env(
codex_home: &Path,
cwd: Option<AbsolutePathBuf>,
cli_overrides: &[(String, TomlValue)],
overrides: LoaderOverrides,
env: &impl EnvProvider,
) -> io::Result<ConfigLayerStack> {
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
@@ -201,6 +297,7 @@ pub async fn load_config_layers_state(
&project_root_markers,
codex_home,
&user_file,
env,
)
.await
{
@@ -231,10 +328,13 @@ pub async fn load_config_layers_state(
// Add a layer for runtime overrides from the CLI or UI, if any exist.
if let Some(cli_overrides_layer) = cli_overrides_layer {
layers.push(ConfigLayerEntry::new(
let expanded = expand_config_toml(cli_overrides_layer);
let entry = ConfigLayerEntry::new_with_warnings(
ConfigLayerSource::SessionFlags,
cli_overrides_layer,
));
expanded.value,
expanded.warnings,
);
layers.push(entry);
}
// Make a best-effort to support the legacy `managed_config.toml` as a
@@ -256,18 +356,23 @@ pub async fn load_config_layers_state(
),
)
})?;
let managed_config =
resolve_relative_paths_in_config_toml(config.managed_config, managed_parent)?;
layers.push(ConfigLayerEntry::new(
let expanded = expand_config_toml(config.managed_config);
let managed_config = resolve_relative_paths_in_config_toml(expanded.value, managed_parent)?;
let entry = ConfigLayerEntry::new_with_warnings(
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file },
managed_config,
));
expanded.warnings,
);
layers.push(entry);
}
if let Some(config) = managed_config_from_mdm {
layers.push(ConfigLayerEntry::new(
let expanded = expand_config_toml(config);
let entry = ConfigLayerEntry::new_with_warnings(
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
config,
));
expanded.value,
expanded.warnings,
);
layers.push(entry);
}
ConfigLayerStack::new(
@@ -277,6 +382,17 @@ pub async fn load_config_layers_state(
)
}
#[cfg(test)]
pub(crate) async fn load_config_layers_state_with_env_for_tests(
codex_home: &Path,
cwd: Option<AbsolutePathBuf>,
cli_overrides: &[(String, TomlValue)],
overrides: LoaderOverrides,
env: &impl EnvProvider,
) -> io::Result<ConfigLayerStack> {
load_config_layers_state_with_env(codex_home, cwd, cli_overrides, overrides, env).await
}
/// Attempts to load a config.toml file from `config_toml`.
/// - If the file exists and is valid TOML, passes the parsed `toml::Value` to
/// `create_entry` and returns the resulting layer entry.
@@ -289,7 +405,7 @@ async fn load_config_toml_for_required_layer(
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
) -> io::Result<ConfigLayerEntry> {
let toml_file = config_toml.as_ref();
let toml_value = match tokio::fs::read_to_string(toml_file).await {
let (toml_value, warnings) = match tokio::fs::read_to_string(toml_file).await {
Ok(contents) => {
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
let config_error = config_error_from_toml(toml_file, &contents, err.clone());
@@ -304,21 +420,24 @@ async fn load_config_toml_for_required_layer(
),
)
})?;
resolve_relative_paths_in_config_toml(config, config_parent)
let expanded = expand_config_toml(config);
let resolved = resolve_relative_paths_in_config_toml(expanded.value, config_parent)?;
(resolved, expanded.warnings)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
(TomlValue::Table(toml::map::Map::new()), Vec::new())
}
Err(e) => {
if e.kind() == io::ErrorKind::NotFound {
Ok(TomlValue::Table(toml::map::Map::new()))
} else {
Err(io::Error::new(
e.kind(),
format!("Failed to read config file {}: {e}", toml_file.display()),
))
}
return Err(io::Error::new(
e.kind(),
format!("Failed to read config file {}: {e}", toml_file.display()),
));
}
}?;
};
Ok(create_entry(toml_value))
let mut entry = create_entry(toml_value);
entry.expansion_warnings = warnings;
Ok(entry)
}
/// If available, apply requirements from `/etc/codex/requirements.toml` to
@@ -451,11 +570,57 @@ fn default_project_root_markers() -> Vec<String> {
struct ProjectTrustContext {
project_root: AbsolutePathBuf,
project_root_key: String,
project_root_display_key: String,
repo_root_key: Option<String>,
projects_trust: std::collections::HashMap<String, TrustLevel>,
repo_root_display_key: Option<String>,
projects_trust: HashMap<String, TrustLevel>,
trust_key_by_normalized: HashMap<String, String>,
user_config_file: AbsolutePathBuf,
}
#[derive(Clone)]
struct TrustEntry {
trust_level: TrustLevel,
original_key: String,
is_symbolic: bool,
}
fn is_symbolic_project_key(key: &str) -> bool {
key.starts_with('~') || key.contains('$')
}
fn normalize_path_for_matching(path: &str) -> String {
let path_buf = PathBuf::from(path);
std::fs::canonicalize(&path_buf)
.unwrap_or(path_buf)
.to_string_lossy()
.to_string()
}
fn expand_and_normalize_project_key(key: &str, env: &impl EnvProvider) -> String {
let expanded = expand_key_for_matching_with_env(key, env);
normalize_path_for_matching(&expanded)
}
fn trust_rank(level: TrustLevel) -> u8 {
match level {
TrustLevel::Untrusted => 2,
TrustLevel::Trusted => 1,
}
}
fn should_replace_trust_entry(existing: &TrustEntry, candidate: &TrustEntry) -> bool {
let existing_rank = trust_rank(existing.trust_level);
let candidate_rank = trust_rank(candidate.trust_level);
if existing_rank != candidate_rank {
return candidate_rank > existing_rank;
}
if existing.is_symbolic != candidate.is_symbolic {
return candidate.is_symbolic;
}
candidate.original_key < existing.original_key
}
struct ProjectTrustDecision {
trust_level: Option<TrustLevel>,
trust_key: String,
@@ -469,36 +634,53 @@ impl ProjectTrustDecision {
impl ProjectTrustContext {
fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision {
let dir_key = dir.as_path().to_string_lossy().to_string();
let dir_display_key = dir.as_path().to_string_lossy().to_string();
let dir_key = normalize_path_for_matching(&dir_display_key);
if let Some(trust_level) = self.projects_trust.get(&dir_key).copied() {
let trust_key = self
.trust_key_by_normalized
.get(&dir_key)
.cloned()
.unwrap_or(dir_display_key);
return ProjectTrustDecision {
trust_level: Some(trust_level),
trust_key: dir_key,
trust_key,
};
}
if let Some(trust_level) = self.projects_trust.get(&self.project_root_key).copied() {
let trust_key = self
.trust_key_by_normalized
.get(&self.project_root_key)
.cloned()
.unwrap_or_else(|| self.project_root_display_key.clone());
return ProjectTrustDecision {
trust_level: Some(trust_level),
trust_key: self.project_root_key.clone(),
trust_key,
};
}
if let Some(repo_root_key) = self.repo_root_key.as_ref()
&& let Some(trust_level) = self.projects_trust.get(repo_root_key).copied()
{
let trust_key = self
.trust_key_by_normalized
.get(repo_root_key)
.cloned()
.or_else(|| self.repo_root_display_key.clone())
.unwrap_or_else(|| repo_root_key.clone());
return ProjectTrustDecision {
trust_level: Some(trust_level),
trust_key: repo_root_key.clone(),
trust_key,
};
}
ProjectTrustDecision {
trust_level: None,
trust_key: self
.repo_root_key
.repo_root_display_key
.clone()
.unwrap_or_else(|| self.project_root_key.clone()),
.unwrap_or_else(|| self.project_root_display_key.clone()),
}
}
@@ -545,28 +727,59 @@ async fn project_trust_context(
project_root_markers: &[String],
config_base_dir: &Path,
user_config_file: &AbsolutePathBuf,
env: &impl EnvProvider,
) -> io::Result<ProjectTrustContext> {
let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?;
let project_root = find_project_root(cwd, project_root_markers).await?;
let projects = config_toml.projects.unwrap_or_default();
let project_root_key = project_root.as_path().to_string_lossy().to_string();
let project_root_display_key = project_root.as_path().to_string_lossy().to_string();
let project_root_key = normalize_path_for_matching(&project_root_display_key);
let repo_root = resolve_root_git_project_for_trust(cwd.as_path());
let repo_root_key = repo_root
let repo_root_display_key = repo_root
.as_ref()
.map(|root| root.to_string_lossy().to_string());
let repo_root_key = repo_root_display_key
.as_deref()
.map(normalize_path_for_matching);
let projects_trust = projects
.into_iter()
.filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level)))
.collect();
let mut trust_entries: HashMap<String, TrustEntry> = HashMap::new();
for (key, project) in projects {
let Some(trust_level) = project.trust_level else {
continue;
};
let normalized_key = expand_and_normalize_project_key(&key, env);
let candidate = TrustEntry {
trust_level,
original_key: key.clone(),
is_symbolic: is_symbolic_project_key(&key),
};
trust_entries
.entry(normalized_key)
.and_modify(|existing| {
if should_replace_trust_entry(existing, &candidate) {
*existing = candidate.clone();
}
})
.or_insert(candidate);
}
let mut projects_trust = HashMap::new();
let mut trust_key_by_normalized = HashMap::new();
for (normalized_key, entry) in trust_entries {
projects_trust.insert(normalized_key.clone(), entry.trust_level);
trust_key_by_normalized.insert(normalized_key, entry.original_key);
}
Ok(ProjectTrustContext {
project_root,
project_root_key,
project_root_display_key,
repo_root_key,
repo_root_display_key,
projects_trust,
trust_key_by_normalized,
user_config_file: user_config_file.clone(),
})
}
@@ -715,10 +928,12 @@ async fn load_project_layers(
continue;
}
};
let expanded = expand_config_toml(config);
let config =
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
let entry =
resolve_relative_paths_in_config_toml(expanded.value, dot_codex_abs.as_path())?;
let mut entry =
project_layer_entry(trust_context, &dot_codex_abs, &layer_dir, config, true);
entry.expansion_warnings = expanded.warnings;
layers.push(entry);
}
Err(err) => {

View File

@@ -1,5 +1,6 @@
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use super::ConfigExpansionWarning;
use super::ConfigRequirements;
use super::ConfigRequirementsToml;
use super::fingerprint::record_origins;
use super::fingerprint::version_for_toml;
@@ -29,23 +30,42 @@ pub struct ConfigLayerEntry {
pub config: TomlValue,
pub version: String,
pub disabled_reason: Option<String>,
pub expansion_warnings: Vec<ConfigExpansionWarning>,
}
impl ConfigLayerEntry {
pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self {
let version = version_for_toml(&config);
Self {
name,
config,
version,
disabled_reason: None,
}
Self::new_with_warnings(name, config, Vec::new())
}
pub fn new_disabled(
name: ConfigLayerSource,
config: TomlValue,
disabled_reason: impl Into<String>,
) -> Self {
Self::new_disabled_with_warnings(name, config, disabled_reason, Vec::new())
}
pub fn new_with_warnings(
name: ConfigLayerSource,
config: TomlValue,
expansion_warnings: Vec<ConfigExpansionWarning>,
) -> Self {
let version = version_for_toml(&config);
Self {
name,
config,
version,
disabled_reason: None,
expansion_warnings,
}
}
pub fn new_disabled_with_warnings(
name: ConfigLayerSource,
config: TomlValue,
disabled_reason: impl Into<String>,
expansion_warnings: Vec<ConfigExpansionWarning>,
) -> Self {
let version = version_for_toml(&config);
Self {
@@ -53,6 +73,7 @@ impl ConfigLayerEntry {
config,
version,
disabled_reason: Some(disabled_reason.into()),
expansion_warnings,
}
}
@@ -90,6 +111,12 @@ impl ConfigLayerEntry {
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigExpansionWarningInfo {
pub source: ConfigLayerSource,
pub warning: ConfigExpansionWarning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigLayerStackOrdering {
LowestPrecedenceFirst,
@@ -144,6 +171,22 @@ impl ConfigLayerStack {
&self.requirements_toml
}
pub fn expansion_warnings(&self) -> Vec<ConfigExpansionWarningInfo> {
let mut warnings = Vec::new();
for layer in &self.layers {
if layer.expansion_warnings.is_empty() {
continue;
}
for warning in &layer.expansion_warnings {
warnings.push(ConfigExpansionWarningInfo {
source: layer.name.clone(),
warning: warning.clone(),
});
}
}
warnings
}
/// Creates a new [ConfigLayerStack] using the specified values to inject a
/// "user layer" into the stack. If such a layer already exists, it is
/// replaced; otherwise, it is inserted into the stack at the appropriate

View File

@@ -1,11 +1,18 @@
use super::LoaderOverrides;
use super::expand_config_toml_with_env;
use super::expansion::FakeEnv;
use super::expansion::KEY_COLLISION_SENTINEL;
use super::format_expansion_warnings;
use super::load_config_layers_state;
use super::load_config_layers_state_with_env_for_tests;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use crate::config::ProjectConfig;
use crate::config_loader::ConfigExpansionWarningInfo;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerSource;
use crate::config_loader::ConfigLoadError;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::config_requirements::ConfigRequirementsWithSources;
@@ -233,6 +240,7 @@ async fn returns_empty_when_all_layers_missing() {
config: TomlValue::Table(toml::map::Map::new()),
version: version_for_toml(&TomlValue::Table(toml::map::Map::new())),
disabled_reason: None,
expansion_warnings: Vec::new(),
},
user_layer,
);
@@ -388,6 +396,196 @@ allowed_sandbox_modes = ["read-only"]
Ok(())
}
#[test]
fn expands_env_vars_in_keys_and_values() {
let config: TomlValue = toml::from_str(
r#"[projects."$PROJECTS/gpt5test"]
trust_level = "trusted"
notes = "path=$PROJECTS/gpt5test model=${MODEL} $$MODEL"
"#,
)
.expect("parse toml");
let env = FakeEnv::new([("PROJECTS", "/Users/tester/projects"), ("MODEL", "gpt-5")]);
let result = expand_config_toml_with_env(config, &env);
assert_eq!(result.warnings, Vec::new());
let expected: TomlValue = toml::from_str(
r#"[projects."/Users/tester/projects/gpt5test"]
trust_level = "trusted"
notes = "path=/Users/tester/projects/gpt5test model=gpt-5 $MODEL"
"#,
)
.expect("parse toml");
assert_eq!(result.value, expected);
}
#[test]
fn expands_tilde_prefix_only_at_start() {
let config: TomlValue = toml::from_str(
r#"
path = "~/skills/skill.md"
no_expand = "docs/~user/skill.md"
"#,
)
.expect("parse toml");
let env = if cfg!(windows) {
FakeEnv::new([("USERPROFILE", "C:\\Users\\tester")])
} else {
FakeEnv::new([("HOME", "/Users/tester")])
};
let result = expand_config_toml_with_env(config, &env);
assert_eq!(result.warnings, Vec::new());
let expected_path = if cfg!(windows) {
"C:\\Users\\tester\\skills\\skill.md"
} else {
"/Users/tester/skills/skill.md"
};
let expected: TomlValue = toml::from_str(&format!(
r#"
path = "{expected_path}"
no_expand = "docs/~user/skill.md"
"#
))
.expect("parse toml");
assert_eq!(result.value, expected);
}
#[test]
fn warns_when_env_var_missing() {
let config: TomlValue = toml::from_str(
r#"
path = "$MISSING/bin"
[projects."$PROJECTS/demo"]
trust_level = "trusted"
"#,
)
.expect("parse toml");
let env = FakeEnv::new([("PROJECTS", "/Users/tester/projects")]);
let result = expand_config_toml_with_env(config, &env);
assert_eq!(
result.warnings,
vec![super::ConfigExpansionWarning::new("MISSING", "path")]
);
let expected: TomlValue = toml::from_str(
r#"
path = "$MISSING/bin"
[projects."/Users/tester/projects/demo"]
trust_level = "trusted"
"#,
)
.expect("parse toml");
assert_eq!(result.value, expected);
}
#[test]
fn warns_when_env_var_missing_in_key() {
let config: TomlValue = toml::from_str(
r#"[projects."$PROJECTS/demo"]
trust_level = "trusted"
"#,
)
.expect("parse toml");
let env = FakeEnv::new(Vec::<(String, String)>::new());
let result = expand_config_toml_with_env(config, &env);
assert_eq!(
result.warnings,
vec![super::ConfigExpansionWarning::new(
"PROJECTS",
"projects[\"$PROJECTS/demo\"]"
)]
);
}
#[test]
fn warns_when_home_missing_for_tilde() {
let config: TomlValue = toml::from_str(
r#"
path = "~/skills/skill.md"
"#,
)
.expect("parse toml");
let env = FakeEnv::new(Vec::<(String, String)>::new());
let result = expand_config_toml_with_env(config, &env);
let expected_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
assert_eq!(
result.warnings,
vec![super::ConfigExpansionWarning::new(expected_var, "path")]
);
let expected: TomlValue = toml::from_str(
r#"
path = "~/skills/skill.md"
"#,
)
.expect("parse toml");
assert_eq!(result.value, expected);
}
#[test]
fn warns_on_duplicate_keys_after_expansion_and_keeps_first() {
let config: TomlValue = toml::from_str(
r#"
[projects."$ROOT/demo"]
trust_level = "trusted"
[projects."${ROOT}/demo"]
trust_level = "untrusted"
"#,
)
.expect("parse toml");
let env = FakeEnv::new([("ROOT", "/tmp/root")]);
let result = expand_config_toml_with_env(config, &env);
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].var, KEY_COLLISION_SENTINEL);
let expected: TomlValue = toml::from_str(
r#"
[projects."/tmp/root/demo"]
trust_level = "trusted"
"#,
)
.expect("parse toml");
assert_eq!(result.value, expected);
}
#[test]
fn formats_duplicate_key_collision_warnings() {
let config: TomlValue = toml::from_str(
r#"
[projects."$ROOT/demo"]
trust_level = "trusted"
[projects."${ROOT}/demo"]
trust_level = "untrusted"
"#,
)
.expect("parse toml");
let env = FakeEnv::new([("ROOT", "/tmp/root")]);
let result = expand_config_toml_with_env(config, &env);
assert_eq!(result.warnings.len(), 1);
let warnings = vec![ConfigExpansionWarningInfo {
source: ConfigLayerSource::SessionFlags,
warning: result.warnings[0].clone(),
}];
let formatted = format_expansion_warnings(&warnings);
assert!(formatted.contains("duplicate keys after expansion"));
assert!(formatted.contains("\"$ROOT/demo\" and \"${ROOT}/demo\""));
assert!(formatted.contains("\"/tmp/root/demo\""));
assert!(formatted.contains("session flags: projects"));
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<()> {
@@ -648,6 +846,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
config: TomlValue::Table(toml::map::Map::new()),
version: version_for_toml(&TomlValue::Table(toml::map::Map::new())),
disabled_reason: None,
expansion_warnings: Vec::new(),
}],
project_layers
);
@@ -755,6 +954,59 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
Ok(())
}
#[tokio::test]
async fn project_layers_enabled_when_trusted_via_symbolic_tilde_key() -> std::io::Result<()> {
let tmp = tempdir()?;
let home_dir = tmp.path().join("home");
let project_root = home_dir.clone();
let nested = project_root.join("child");
tokio::fs::create_dir_all(project_root.join(".git")).await?;
tokio::fs::create_dir_all(project_root.join(".codex")).await?;
tokio::fs::create_dir_all(&nested).await?;
tokio::fs::write(
project_root.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"project\"\n",
)
.await?;
let codex_home = tmp.path().join("codex_home");
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
"[projects.\"~\"]\ntrust_level = \"trusted\"\n",
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
let env = FakeEnv::new([("HOME", home_dir.to_string_lossy().to_string())]);
let layers = load_config_layers_state_with_env_for_tests(
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
&env,
)
.await?;
let project_layers: Vec<_> = layers
.get_layers(
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
true,
)
.into_iter()
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
.collect();
assert_eq!(project_layers.len(), 1);
assert_eq!(project_layers[0].disabled_reason, None);
assert_eq!(
layers.effective_config().get("foo"),
Some(&TomlValue::String("project".to_string()))
);
Ok(())
}
#[tokio::test]
async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::Result<()> {
let tmp = tempdir()?;