From 4c9ed5d6a9f26ee037e52df91f48d01694aaaabc Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 22 Dec 2025 17:31:01 -0800 Subject: [PATCH] feat: honor /etc/codex/config.toml --- .../app-server-protocol/src/protocol/v2.rs | 2 + codex-rs/core/src/config_loader/mod.rs | 140 ++++++++++++------ codex-rs/core/src/config_loader/state.rs | 2 +- 3 files changed, 96 insertions(+), 48 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7a360cee20..85ce73ab3f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -227,6 +227,8 @@ pub enum ConfigLayerSource { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] System { + /// This is the path to the system config.toml file, though it is not + /// guaranteed to exist. file: AbsolutePathBuf, }, diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index be336bd056..6e87a4a0a7 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -33,6 +33,12 @@ pub use state::LoaderOverrides; /// On Unix systems, load requirements from this file path, if present. const DEFAULT_REQUIREMENTS_TOML_FILE_UNIX: &str = "/etc/codex/requirements.toml"; + +/// On Unix systems, load default settings from this file path, if present. +/// Note that /etc/config/ is treated as a "config folder," so subfolders such +/// as skills/ and rules/ will also be honored. +const DEFAULT_SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml"; + const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// To build up the set of admin-enforced constraints, we build up from multiple @@ -72,7 +78,7 @@ pub async fn load_config_layers_state( ) -> io::Result { let mut config_requirements_toml = ConfigRequirementsToml::default(); - // TODO(mbolin): Support an entry in MDM for config requirements and use it + // TODO(gt): Support an entry in MDM for config requirements and use it // with `config_requirements_toml.merge_unset_fields(...)`, if present. // Honor /etc/codex/requirements.toml. @@ -95,57 +101,45 @@ pub async fn load_config_layers_state( let mut layers = Vec::::new(); - // TODO(mbolin): Honor managed preferences (macOS only). - // TODO(mbolin): Honor /etc/codex/config.toml. + // TODO(gt): Honor managed preferences (macOS only). + + // Include an entry for the "system" config folder, loading its config.toml, + // if it exists. + let system_config_toml_file = if cfg!(unix) { + Some(AbsolutePathBuf::from_absolute_path( + DEFAULT_SYSTEM_CONFIG_TOML_FILE_UNIX, + )?) + } else { + // TODO(gt): Determine the path to load on Windows. + None + }; + if let Some(system_config_toml_file) = system_config_toml_file { + let system_layer = + load_config_toml_for_required_layer(&system_config_toml_file, |config_toml| { + ConfigLayerEntry::new( + ConfigLayerSource::System { + file: system_config_toml_file.clone(), + }, + config_toml, + ) + }) + .await?; + layers.push(system_layer); + } // Add a layer for $CODEX_HOME/config.toml if it exists. Note if the file // exists, but is malformed, then this error should be propagated to the // user. let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home)?; - let user_layer = match tokio::fs::read_to_string(&user_file).await { - Ok(contents) => { - let user_config: TomlValue = toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Error parsing user config file {}: {e}", - user_file.as_path().display(), - ), - ) - })?; - let user_config_parent = user_file.as_path().parent().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "User config file {} has no parent directory", - user_file.as_path().display() - ), - ) - })?; - let user_config = - resolve_relative_paths_in_config_toml(user_config, user_config_parent)?; - ConfigLayerEntry::new(ConfigLayerSource::User { file: user_file }, user_config) - } - Err(e) => { - if e.kind() == io::ErrorKind::NotFound { - // If there is no config.toml file, record an empty entry - // for this user layer, as this may still have subfolders - // that are significant in the overall ConfigLayerStack. - ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, - TomlValue::Table(toml::map::Map::new()), - ) - } else { - return Err(io::Error::new( - e.kind(), - format!( - "Failed to read user config file {}: {e}", - user_file.as_path().display(), - ), - )); - } - } - }; + let user_layer = load_config_toml_for_required_layer(&user_file, |config_toml| { + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + }, + config_toml, + ) + }) + .await?; layers.push(user_layer); if let Some(cwd) = cwd { @@ -206,6 +200,58 @@ pub async fn load_config_layers_state( ConfigLayerStack::new(layers, config_requirements_toml.try_into()?) } +/// 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 `Ok(Some(entry))`. +/// - If the file does not exist, uses an empty Table as `toml::Value` with +/// `create_entry` and returns `Ok(Some(entry))`. +/// - If there is an error reading the file or parsing the TOML, returns +/// `Err(...)`. +async fn load_config_toml_for_required_layer( + config_toml: impl AsRef, + create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry, +) -> io::Result { + let toml_file = config_toml.as_ref(); + let toml_value = match tokio::fs::read_to_string(toml_file).await { + Ok(contents) => { + let user_config: TomlValue = toml::from_str(&contents).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing user config file {}: {e}", + toml_file.display(), + ), + ) + })?; + let user_config_parent = toml_file.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "User config file {} has no parent directory", + toml_file.display() + ), + ) + })?; + resolve_relative_paths_in_config_toml(user_config, user_config_parent) + } + 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 user config file {}: {e}", + toml_file.display(), + ), + )) + } + } + }?; + + Ok(create_entry(toml_value)) +} + /// If available, apply requirements from `/etc/codex/requirements.toml` to /// `config_requirements_toml` by filling in any unset fields. async fn load_requirements_toml( diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs index bd00e77247..efb33dfac5 100644 --- a/codex-rs/core/src/config_loader/state.rs +++ b/codex-rs/core/src/config_loader/state.rs @@ -55,7 +55,7 @@ impl ConfigLayerEntry { pub fn config_folder(&self) -> Option { match &self.name { ConfigLayerSource::Mdm { .. } => None, - ConfigLayerSource::System { .. } => None, + ConfigLayerSource::System { file } => file.parent(), ConfigLayerSource::User { file } => file.parent(), ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()), ConfigLayerSource::SessionFlags => None,