mirror of
https://github.com/openai/codex.git
synced 2026-04-30 01:16:54 +00:00
Add variable expansion in config.toml
This commit is contained in:
358
codex-rs/core/src/config_loader/expansion.rs
Normal file
358
codex-rs/core/src/config_loader/expansion.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
Reference in New Issue
Block a user