feat: support in-repo .codex/config.toml entries as sources of config info

This commit is contained in:
Michael Bolin
2025-12-19 20:11:48 -08:00
parent a6974087e5
commit d54427812e
5 changed files with 178 additions and 2 deletions

View File

@@ -240,6 +240,14 @@ pub enum ConfigLayerSource {
file: AbsolutePathBuf,
},
/// Path to a .codex/ folder within a project. There could be multiple of
/// these between `cwd` and the project/repo root.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Project {
dot_codex_folder: AbsolutePathBuf,
},
/// Session-layer overrides supplied via `-c`/`--config`.
SessionFlags,
@@ -247,6 +255,8 @@ pub enum ConfigLayerSource {
/// as the last layer on top of everything else. This scheme did not quite
/// work out as intended, but we keep this variant as a "best effort" while
/// we phase out `managed_config.toml` in favor of `requirements.toml`.
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
LegacyManagedConfigTomlFromFile {
file: AbsolutePathBuf,
},
@@ -262,6 +272,7 @@ impl ConfigLayerSource {
ConfigLayerSource::Mdm { .. } => 0,
ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::User { .. } => 20,
ConfigLayerSource::Project { .. } => 25,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,

View File

@@ -556,6 +556,11 @@ fn override_message(layer: &ConfigLayerSource) -> String {
ConfigLayerSource::System { file } => {
format!("Overridden by managed config (system): {}", file.display())
}
ConfigLayerSource::Project { dot_codex_folder } => format!(
"Overridden by project config: {}/{}",
dot_codex_folder.display(),
CONFIG_TOML_FILE
),
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
ConfigLayerSource::User { file } => {
format!("Overridden by user config: {}", file.display())

View File

@@ -17,9 +17,11 @@ use codex_app_server_protocol::ConfigLayerSource;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use serde::Deserialize;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
pub use config_requirements::ConfigRequirements;
@@ -127,8 +129,11 @@ pub async fn load_config_layers_state(
}
}
// TODO(mbolin): Add layers for cwd, tree, and repo config files.
let _ = cwd;
if let Some(cwd) = cwd {
let project_root = find_project_root(&cwd).await?;
let project_layers = load_project_layers(&cwd, &project_root).await?;
layers.extend(project_layers);
}
// Add a layer for runtime overrides from the CLI or UI, if any exist.
if !cli_overrides.is_empty() {
@@ -235,6 +240,85 @@ async fn load_requirements_from_legacy_scheme(
Ok(())
}
async fn find_project_root(cwd: &AbsolutePathBuf) -> io::Result<AbsolutePathBuf> {
for ancestor in cwd.as_path().ancestors() {
let git_dir = ancestor.join(".git");
if tokio::fs::metadata(&git_dir).await.is_ok() {
return AbsolutePathBuf::from_absolute_path(ancestor);
}
}
Ok(cwd.clone())
}
/// Return the appropriate list of layers (each with
/// [ConfigLayerSource::Project] as the source) between `cwd` and
/// `project_root`, inclusive. The list is ordered in _increasing_ precdence,
/// starting from folders closest to `project_root` (which is the lowest
/// precedence) to those closest to `cwd` (which si the highest precedence).
async fn load_project_layers(
cwd: &AbsolutePathBuf,
project_root: &AbsolutePathBuf,
) -> io::Result<Vec<ConfigLayerEntry>> {
let mut dirs: Vec<PathBuf> = Vec::new();
let mut current = Some(cwd.as_path());
while let Some(dir) = current {
dirs.push(dir.to_path_buf());
if dir == project_root.as_path() {
break;
}
current = dir.parent();
}
dirs.reverse();
let mut layers = Vec::new();
for dir in dirs {
let dot_codex = dir.join(".codex");
if !tokio::fs::metadata(&dot_codex)
.await
.map(|meta| meta.is_dir())
.unwrap_or(false)
{
continue;
}
let dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?;
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE)?;
match tokio::fs::read_to_string(&config_file).await {
Ok(contents) => {
let _guard = AbsolutePathBufGuard::new(dot_codex_abs.as_path());
let config: TomlValue = toml::from_str(&contents).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"Error parsing project config file {}: {e}",
config_file.as_path().display(),
),
)
})?;
layers.push(ConfigLayerEntry::new(
ConfigLayerSource::Project {
dot_codex_folder: dot_codex_abs,
},
config,
));
}
Err(err) => {
if err.kind() != io::ErrorKind::NotFound {
return Err(io::Error::new(
err.kind(),
format!(
"Failed to read project config file {}: {err}",
config_file.as_path().display(),
),
));
}
}
}
}
Ok(layers)
}
/// The legacy mechanism for specifying admin-enforced configuration is to read
/// from a file like `/etc/codex/managed_config.toml` that has the same
/// structure as `config.toml` where fields like `approval_policy` can specify

View File

@@ -171,6 +171,7 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<
}
let mut user_layer_index: Option<usize> = None;
let mut previous_project: Option<&AbsolutePathBuf> = None;
for (index, layer) in layers.iter().enumerate() {
if matches!(layer.name, ConfigLayerSource::User { .. }) {
if user_layer_index.is_some() {
@@ -181,6 +182,26 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<
}
user_layer_index = Some(index);
}
if let ConfigLayerSource::Project {
dot_codex_folder: current,
} = &layer.name
{
if let Some(previous) = previous_project {
if previous == current
|| !current
.as_path()
.ancestors()
.any(|ancestor| ancestor == previous.as_path().parent().unwrap_or_else(|| previous.as_path()))
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"project layers are not ordered from root to cwd",
));
}
}
previous_project = Some(current);
}
}
Ok(user_layer_index)

View File

@@ -208,3 +208,58 @@ allowed_approval_policies = ["never", "on-request"]
);
Ok(())
}
#[tokio::test]
async fn project_layers_prefer_closest_cwd() {
let tmp = tempdir().expect("tempdir");
let project_root = tmp.path().join("project");
let nested = project_root.join("child");
std::fs::create_dir_all(nested.join(".codex")).expect("dirs");
std::fs::create_dir_all(project_root.join(".codex")).expect("dirs");
std::fs::write(project_root.join(".git"), "gitdir: here").expect("git marker");
std::fs::write(
project_root.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"root\"\n",
)
.expect("root config");
std::fs::write(
nested.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"child\"\n",
)
.expect("nested config");
let codex_home = tmp.path().join("home");
std::fs::create_dir_all(&codex_home).expect("home");
let cwd = AbsolutePathBuf::from_absolute_path(&nested).expect("cwd");
let layers = load_config_layers_state(
&codex_home,
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
)
.await
.expect("load layers");
let project_layers: Vec<_> = layers
.layers_high_to_low()
.into_iter()
.filter_map(|layer| match &layer.name {
super::ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder),
_ => None,
})
.collect();
assert_eq!(project_layers.len(), 2);
assert_eq!(project_layers[0].as_path(), nested.join(".codex").as_path());
assert_eq!(
project_layers[1].as_path(),
project_root.join(".codex").as_path()
);
let config = layers.effective_config();
let foo = config
.get("foo")
.and_then(TomlValue::as_str)
.expect("foo entry");
assert_eq!(foo, "child");
}