From d54427812eb739ba067f997afe41a8bad1b03ede Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 19 Dec 2025 20:11:48 -0800 Subject: [PATCH] feat: support in-repo .codex/config.toml entries as sources of config info --- .../app-server-protocol/src/protocol/v2.rs | 11 +++ codex-rs/core/src/config/service.rs | 5 ++ codex-rs/core/src/config_loader/mod.rs | 88 ++++++++++++++++++- codex-rs/core/src/config_loader/state.rs | 21 +++++ codex-rs/core/src/config_loader/tests.rs | 55 ++++++++++++ 5 files changed, 178 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 0aec959b9a..7ba7cc04b0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -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, diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 27785ff0f9..e1e3d3fdb9 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -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()) diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index c05825db89..ca6e2129b2 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -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 { + 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> { + let mut dirs: Vec = 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 diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs index 864a9c7ed7..55d254b030 100644 --- a/codex-rs/core/src/config_loader/state.rs +++ b/codex-rs/core/src/config_loader/state.rs @@ -171,6 +171,7 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result = 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 = 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"); +}