From fe14df10a7ba9fc10dc1aaf7e9c30e304500db9c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 19 Dec 2025 22:04:01 -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/mod.rs | 9 +- codex-rs/core/src/config/profile.rs | 3 +- codex-rs/core/src/config/service.rs | 5 + codex-rs/core/src/config/types.rs | 26 +-- codex-rs/core/src/config_loader/mod.rs | 151 +++++++++++++++++- codex-rs/core/src/config_loader/state.rs | 23 +++ codex-rs/core/src/config_loader/tests.rs | 107 +++++++++++++ codex-rs/core/src/features.rs | 3 +- 9 files changed, 313 insertions(+), 25 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/mod.rs b/codex-rs/core/src/config/mod.rs index da94f76cb0..1b41500779 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -42,6 +42,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use dirs::home_dir; use serde::Deserialize; +use serde::Serialize; use similar::DiffableStr; use std::collections::BTreeMap; use std::collections::HashMap; @@ -615,7 +616,7 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R } /// Base config deserialized from ~/.codex/config.toml. -#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigToml { /// Optional override of model selection. pub model: Option, @@ -805,7 +806,7 @@ impl From for UserSavedConfig { } } -#[derive(Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct ProjectConfig { pub trust_level: Option, } @@ -820,7 +821,7 @@ impl ProjectConfig { } } -#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] pub struct ToolsToml { #[serde(default, alias = "web_search_request")] pub web_search: Option, @@ -839,7 +840,7 @@ impl From for Tools { } } -#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct GhostSnapshotToml { /// Exclude untracked files larger than this many bytes from ghost snapshots. #[serde(alias = "ignore_untracked_files_over_bytes")] diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index b74b70887d..026badfeed 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -1,5 +1,6 @@ use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; +use serde::Serialize; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningSummary; @@ -9,7 +10,7 @@ use codex_protocol::openai_models::ReasoningEffort; /// Collection of common configuration options that a user can define as a unit /// in `config.toml`. -#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ConfigProfile { pub model: Option, /// The key in the `model_providers` map identifying the 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/types.rs b/codex-rs/core/src/config/types.rs index 8fa43a6772..e55b4ee442 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -221,7 +221,7 @@ mod option_duration_secs { } } -#[derive(Deserialize, Debug, Copy, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)] pub enum UriBasedFileOpener { #[serde(rename = "vscode")] VsCode, @@ -253,7 +253,7 @@ impl UriBasedFileOpener { } /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct History { /// If true, history entries will not be written to disk. pub persistence: HistoryPersistence, @@ -263,7 +263,7 @@ pub struct History { pub max_bytes: Option, } -#[derive(Deserialize, Debug, Copy, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub enum HistoryPersistence { /// Save all history entries to disk. @@ -275,7 +275,7 @@ pub enum HistoryPersistence { // ===== OTEL configuration ===== -#[derive(Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum OtelHttpProtocol { /// Binary payload @@ -284,7 +284,7 @@ pub enum OtelHttpProtocol { Json, } -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub struct OtelTlsConfig { pub ca_certificate: Option, @@ -293,7 +293,7 @@ pub struct OtelTlsConfig { } /// Which OTEL exporter to use. -#[derive(Deserialize, Debug, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "kebab-case")] pub enum OtelExporterKind { None, @@ -315,7 +315,7 @@ pub enum OtelExporterKind { } /// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct OtelConfigToml { /// Log user prompt in traces pub log_user_prompt: Option, @@ -350,7 +350,7 @@ impl Default for OtelConfig { } } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(untagged)] pub enum Notifications { Enabled(bool), @@ -364,7 +364,7 @@ impl Default for Notifications { } /// Collection of settings that are specific to the TUI. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct Tui { /// Enable desktop notifications from the TUI when the terminal is unfocused. /// Defaults to `true`. @@ -389,7 +389,7 @@ const fn default_true() -> bool { /// Settings for notices we display to users via the tui and app-server clients /// (primarily the Codex IDE extension). NOTE: these are different from /// notifications - notices are warnings, NUX screens, acknowledgements, etc. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct Notice { /// Tracks whether the user has acknowledged the full access warning prompt. pub hide_full_access_warning: Option, @@ -412,7 +412,7 @@ impl Notice { pub(crate) const TABLE_KEY: &'static str = "notice"; } -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct SandboxWorkspaceWrite { #[serde(default)] pub writable_roots: Vec, @@ -435,7 +435,7 @@ impl From for codex_app_server_protocol::SandboxSettings } } -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] #[serde(rename_all = "kebab-case")] pub enum ShellEnvironmentPolicyInherit { /// "Core" environment variables for the platform. On UNIX, this would @@ -452,7 +452,7 @@ pub enum ShellEnvironmentPolicyInherit { /// Policy for building the `env` when spawning a process via either the /// `shell` or `local_shell` tool. -#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub struct ShellEnvironmentPolicyToml { pub inherit: Option, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index c05825db89..e6f5086bc1 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -11,15 +11,18 @@ mod state; mod tests; use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigToml; use crate::config_loader::config_requirements::ConfigRequirementsToml; use crate::config_loader::layer_io::LoadedConfigLayers; 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; @@ -109,6 +112,13 @@ pub async fn load_config_layers_state( ), ) })?; + let user_config = resolve_config_paths( + user_config, + user_file + .as_path() + .parent() + .unwrap_or_else(|| user_file.as_path()), + )?; layers.push(ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, user_config, @@ -127,8 +137,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() { @@ -149,11 +162,17 @@ pub async fn load_config_layers_state( managed_config_from_mdm, } = loaded_config_layers; if let Some(config) = managed_config { - layers.push(ConfigLayerEntry::new( - ConfigLayerSource::LegacyManagedConfigTomlFromFile { - file: config.file.clone(), - }, + let managed_config = resolve_config_paths( config.managed_config, + config + .file + .as_path() + .parent() + .unwrap_or(config.file.as_path()), + )?; + layers.push(ConfigLayerEntry::new( + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file }, + managed_config, )); } if let Some(config) = managed_config_from_mdm { @@ -235,6 +254,126 @@ async fn load_requirements_from_legacy_scheme( Ok(()) } +fn resolve_config_paths(value: TomlValue, base_dir: &Path) -> io::Result { + let _guard = AbsolutePathBufGuard::new(base_dir); + let Ok(resolved) = value.clone().try_into::() else { + return Ok(value); + }; + drop(_guard); + let resolved_value = TomlValue::try_from(resolved).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to serialize resolved config: {e}"), + ) + })?; + + Ok(copy_shape_from_original(&value, &resolved_value)) +} + +fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlValue { + match (original, resolved) { + (TomlValue::Table(original_table), TomlValue::Table(resolved_table)) => { + let mut table = toml::map::Map::new(); + for (key, original_value) in original_table { + let resolved_value = resolved_table.get(key).unwrap_or(original_value); + table.insert( + key.clone(), + copy_shape_from_original(original_value, resolved_value), + ); + } + TomlValue::Table(table) + } + (TomlValue::Array(original_array), TomlValue::Array(resolved_array)) => { + let mut items = Vec::new(); + for (index, original_value) in original_array.iter().enumerate() { + let resolved_value = resolved_array.get(index).unwrap_or(original_value); + items.push(copy_shape_from_original(original_value, resolved_value)); + } + TomlValue::Array(items) + } + (_, resolved_value) => resolved_value.clone(), + } +} + +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 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(), + ), + ) + })?; + let config = resolve_config_paths(config, dot_codex_abs.as_path())?; + 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..3287ac74b2 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,28 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::create_dir_all(project_root.join(".codex")).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + + tokio::fs::write( + project_root.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"root\"\n", + ) + .await?; + tokio::fs::write( + nested.join(".codex").join(CONFIG_TOML_FILE), + "foo = \"child\"\n", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let layers = load_config_layers_state( + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + + 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"); + Ok(()) +} + +#[tokio::test] +async fn project_paths_resolve_relative_to_dot_codex_and_override_in_order() -> std::io::Result<()> +{ + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(project_root.join(".codex")).await?; + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + + let root_cfg = r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["./a"] +"#; + let nested_cfg = r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["./b"] +"#; + tokio::fs::write(project_root.join(".codex").join(CONFIG_TOML_FILE), root_cfg).await?; + tokio::fs::write(nested.join(".codex").join(CONFIG_TOML_FILE), nested_cfg).await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .harness_overrides(ConfigOverrides { + cwd: Some(nested.clone()), + ..ConfigOverrides::default() + }) + .build() + .await?; + + let expected_root = AbsolutePathBuf::from_absolute_path(nested.join(".codex").join("b"))?; + assert_eq!( + config.sandbox_policy.get(), + &SandboxPolicy::WorkspaceWrite { + writable_roots: vec![expected_root], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + ); + + Ok(()) +} diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 22fd310b99..88ea9160ae 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -8,6 +8,7 @@ use crate::config::ConfigToml; use crate::config::profile::ConfigProfile; use serde::Deserialize; +use serde::Serialize; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -274,7 +275,7 @@ pub fn is_known_feature_key(key: &str) -> bool { } /// Deserializable features table for TOML. -#[derive(Deserialize, Debug, Clone, Default, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] pub struct FeaturesToml { #[serde(flatten)] pub entries: BTreeMap,