diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 24eb0e4ba..ec5911a4b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -846,12 +846,10 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha2", "shlex", "tempfile", "tokio", "toml 0.9.5", - "toml_edit", "tracing", "tracing-subscriber", "uuid", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 948facdea..cb3f51bb8 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -31,11 +31,9 @@ codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha2 = { workspace = true } mcp-types = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } -toml_edit = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9f4227b2d..53fabd22e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -117,10 +117,9 @@ use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::config::ConfigToml; +use codex_core::config::ConfigService; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::McpServerTransportConfig; -use codex_core::config_loader::load_config_as_toml; use codex_core::default_client::get_codex_user_agent; use codex_core::exec::ExecParams; use codex_core::exec_env::create_env; @@ -1108,12 +1107,13 @@ impl CodexMessageProcessor { } async fn get_user_saved_config(&self, request_id: RequestId) { - let toml_value = match load_config_as_toml(&self.config.codex_home).await { - Ok(val) => val, + let service = ConfigService::new(self.config.codex_home.clone(), Vec::new()); + let user_saved_config: UserSavedConfig = match service.load_user_saved_config().await { + Ok(config) => config, Err(err) => { let error = JSONRPCErrorError { code: INTERNAL_ERROR_CODE, - message: format!("failed to load config.toml: {err}"), + message: err.to_string(), data: None, }; self.outgoing.send_error(request_id, error).await; @@ -1121,21 +1121,6 @@ impl CodexMessageProcessor { } }; - let cfg: ConfigToml = match toml_value.try_into() { - Ok(cfg) => cfg, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to parse config.toml: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let user_saved_config: UserSavedConfig = cfg.into(); - let response = GetUserSavedConfigResponse { config: user_saved_config, }; diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index c1eaf62d2..98e0f108e 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -1,66 +1,27 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; -use codex_app_server_protocol::Config; use codex_app_server_protocol::ConfigBatchWriteParams; -use codex_app_server_protocol::ConfigLayer; -use codex_app_server_protocol::ConfigLayerMetadata; -use codex_app_server_protocol::ConfigLayerName; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteErrorCode; use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::MergeStrategy; -use codex_app_server_protocol::OverriddenMetadata; -use codex_app_server_protocol::WriteStatus; -use codex_core::config::ConfigToml; -use codex_core::config::edit::ConfigEdit; -use codex_core::config::edit::ConfigEditsBuilder; -use codex_core::config_loader::LoadedConfigLayers; -use codex_core::config_loader::LoaderOverrides; -use codex_core::config_loader::load_config_layers_with_overrides; -use codex_core::config_loader::merge_toml_values; -use serde_json::Value as JsonValue; +use codex_core::config::ConfigService; +use codex_core::config::ConfigServiceError; use serde_json::json; -use sha2::Digest; -use sha2::Sha256; -use std::collections::HashMap; -use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; -use toml_edit::Item as TomlItem; - -const SESSION_FLAGS_SOURCE: &str = "--config"; -const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64"; -const CONFIG_FILE_NAME: &str = "config.toml"; #[derive(Clone)] pub(crate) struct ConfigApi { - codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, - loader_overrides: LoaderOverrides, + service: ConfigService, } impl ConfigApi { pub(crate) fn new(codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>) -> Self { Self { - codex_home, - cli_overrides, - loader_overrides: LoaderOverrides::default(), - } - } - - #[cfg(test)] - fn with_overrides( - codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, - loader_overrides: LoaderOverrides, - ) -> Self { - Self { - codex_home, - cli_overrides, - loader_overrides, + service: ConfigService::new(codex_home, cli_overrides), } } @@ -68,696 +29,32 @@ impl ConfigApi { &self, params: ConfigReadParams, ) -> Result { - let layers = self - .load_layers_state() - .await - .map_err(|err| internal_error("failed to read configuration layers", err))?; - - let effective = layers.effective_config(); - validate_config(&effective).map_err(|err| internal_error("invalid configuration", err))?; - - let config: Config = serde_json::from_value(to_json_value(&effective)) - .map_err(|err| internal_error("failed to deserialize configuration", err))?; - let response = ConfigReadResponse { - config, - origins: layers.origins(), - layers: params.include_layers.then(|| layers.layers_high_to_low()), - }; - - Ok(response) + self.service.read(params).await.map_err(map_error) } pub(crate) async fn write_value( &self, params: ConfigValueWriteParams, ) -> Result { - let edits = vec![(params.key_path, params.value, params.merge_strategy)]; - self.apply_edits(params.file_path, params.expected_version, edits) - .await + self.service.write_value(params).await.map_err(map_error) } pub(crate) async fn batch_write( &self, params: ConfigBatchWriteParams, ) -> Result { - let edits = params - .edits - .into_iter() - .map(|edit| (edit.key_path, edit.value, edit.merge_strategy)) - .collect(); - - self.apply_edits(params.file_path, params.expected_version, edits) - .await - } - - async fn apply_edits( - &self, - file_path: Option, - expected_version: Option, - edits: Vec<(String, JsonValue, MergeStrategy)>, - ) -> Result { - let allowed_path = self.codex_home.join(CONFIG_FILE_NAME); - let provided_path = file_path - .as_ref() - .map(PathBuf::from) - .unwrap_or_else(|| allowed_path.clone()); - - if !paths_match(&allowed_path, &provided_path) { - return Err(config_write_error( - ConfigWriteErrorCode::ConfigLayerReadonly, - "Only writes to the user config are allowed", - )); - } - - let layers = self - .load_layers_state() - .await - .map_err(|err| internal_error("failed to load configuration", err))?; - - if let Some(expected) = expected_version.as_deref() - && expected != layers.user.version - { - return Err(config_write_error( - ConfigWriteErrorCode::ConfigVersionConflict, - "Configuration was modified since last read. Fetch latest version and retry.", - )); - } - - let mut user_config = layers.user.config.clone(); - let mut parsed_segments = Vec::new(); - let mut config_edits = Vec::new(); - - for (key_path, value, strategy) in edits.into_iter() { - let segments = parse_key_path(&key_path).map_err(|message| { - config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) - })?; - let original_value = value_at_path(&user_config, &segments).cloned(); - let parsed_value = parse_value(value).map_err(|message| { - config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) - })?; - - apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err( - |err| match err { - MergeError::PathNotFound => config_write_error( - ConfigWriteErrorCode::ConfigPathNotFound, - "Path not found", - ), - MergeError::Validation(message) => { - config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) - } - }, - )?; - - let updated_value = value_at_path(&user_config, &segments).cloned(); - if original_value != updated_value { - let edit = match updated_value { - Some(value) => ConfigEdit::SetPath { - segments: segments.clone(), - value: toml_value_to_item(&value) - .map_err(|err| internal_error("failed to build config edits", err))?, - }, - None => ConfigEdit::ClearPath { - segments: segments.clone(), - }, - }; - config_edits.push(edit); - } - - parsed_segments.push(segments); - } - - validate_config(&user_config).map_err(|err| { - config_write_error( - ConfigWriteErrorCode::ConfigValidationError, - format!("Invalid configuration: {err}"), - ) - })?; - - let updated_layers = layers.with_user_config(user_config.clone()); - let effective = updated_layers.effective_config(); - validate_config(&effective).map_err(|err| { - config_write_error( - ConfigWriteErrorCode::ConfigValidationError, - format!("Invalid configuration: {err}"), - ) - })?; - - if !config_edits.is_empty() { - ConfigEditsBuilder::new(&self.codex_home) - .with_edits(config_edits) - .apply() - .await - .map_err(|err| internal_error("failed to persist config.toml", err))?; - } - - let overridden = first_overridden_edit(&updated_layers, &effective, &parsed_segments); - let status = overridden - .as_ref() - .map(|_| WriteStatus::OkOverridden) - .unwrap_or(WriteStatus::Ok); - - let file_path = provided_path - .canonicalize() - .unwrap_or(provided_path.clone()) - .display() - .to_string(); - - Ok(ConfigWriteResponse { - status, - version: updated_layers.user.version.clone(), - file_path, - overridden_metadata: overridden, - }) - } - - async fn load_layers_state(&self) -> std::io::Result { - let LoadedConfigLayers { - base, - managed_config, - managed_preferences, - } = load_config_layers_with_overrides(&self.codex_home, self.loader_overrides.clone()) - .await?; - - let user = LayerState::new( - ConfigLayerName::User, - self.codex_home.join(CONFIG_FILE_NAME), - base, - ); - - let session_flags = LayerState::new( - ConfigLayerName::SessionFlags, - PathBuf::from(SESSION_FLAGS_SOURCE), - { - let mut root = TomlValue::Table(toml::map::Map::new()); - for (path, value) in self.cli_overrides.iter() { - apply_override(&mut root, path, value.clone()); - } - root - }, - ); - - let system = managed_config.map(|cfg| { - LayerState::new( - ConfigLayerName::System, - system_config_path(&self.codex_home), - cfg, - ) - }); - - let mdm = managed_preferences - .map(|cfg| LayerState::new(ConfigLayerName::Mdm, PathBuf::from(MDM_SOURCE), cfg)); - - Ok(LayersState { - user, - session_flags, - system, - mdm, - }) + self.service.batch_write(params).await.map_err(map_error) } } -fn parse_value(value: JsonValue) -> Result, String> { - if value.is_null() { - return Ok(None); +fn map_error(err: ConfigServiceError) -> JSONRPCErrorError { + if let Some(code) = err.write_error_code() { + return config_write_error(code, err.to_string()); } - serde_json::from_value::(value) - .map(Some) - .map_err(|err| format!("invalid value: {err}")) -} - -fn parse_key_path(path: &str) -> Result, String> { - if path.trim().is_empty() { - return Err("keyPath must not be empty".to_string()); - } - Ok(path - .split('.') - .map(std::string::ToString::to_string) - .collect()) -} - -fn apply_override(target: &mut TomlValue, path: &str, value: TomlValue) { - use toml::value::Table; - - let segments: Vec<&str> = path.split('.').collect(); - let mut current = target; - - for (idx, segment) in segments.iter().enumerate() { - let is_last = idx == segments.len() - 1; - - if is_last { - match current { - TomlValue::Table(table) => { - table.insert(segment.to_string(), value); - } - _ => { - let mut table = Table::new(); - table.insert(segment.to_string(), value); - *current = TomlValue::Table(table); - } - } - return; - } - - match current { - TomlValue::Table(table) => { - current = table - .entry((*segment).to_string()) - .or_insert_with(|| TomlValue::Table(Table::new())); - } - _ => { - *current = TomlValue::Table(Table::new()); - if let TomlValue::Table(tbl) = current { - current = tbl - .entry((*segment).to_string()) - .or_insert_with(|| TomlValue::Table(Table::new())); - } - } - } - } -} - -#[derive(Debug)] -enum MergeError { - PathNotFound, - Validation(String), -} - -fn apply_merge( - root: &mut TomlValue, - segments: &[String], - value: Option<&TomlValue>, - strategy: MergeStrategy, -) -> Result { - let Some(value) = value else { - return clear_path(root, segments); - }; - - let Some((last, parents)) = segments.split_last() else { - return Err(MergeError::Validation( - "keyPath must not be empty".to_string(), - )); - }; - - let mut current = root; - - for segment in parents { - match current { - TomlValue::Table(table) => { - current = table - .entry(segment.clone()) - .or_insert_with(|| TomlValue::Table(toml::map::Map::new())); - } - _ => { - *current = TomlValue::Table(toml::map::Map::new()); - if let TomlValue::Table(table) = current { - current = table - .entry(segment.clone()) - .or_insert_with(|| TomlValue::Table(toml::map::Map::new())); - } - } - } - } - - let table = current.as_table_mut().ok_or_else(|| { - MergeError::Validation("cannot set value on non-table parent".to_string()) - })?; - - if matches!(strategy, MergeStrategy::Upsert) - && let Some(existing) = table.get_mut(last) - && matches!(existing, TomlValue::Table(_)) - && matches!(value, TomlValue::Table(_)) - { - merge_toml_values(existing, value); - return Ok(true); - } - - let changed = table - .get(last) - .map(|existing| Some(existing) != Some(value)) - .unwrap_or(true); - table.insert(last.clone(), value.clone()); - Ok(changed) -} - -fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result { - let Some((last, parents)) = segments.split_last() else { - return Err(MergeError::Validation( - "keyPath must not be empty".to_string(), - )); - }; - - let mut current = root; - for segment in parents { - match current { - TomlValue::Table(table) => { - current = table.get_mut(segment).ok_or(MergeError::PathNotFound)?; - } - _ => return Err(MergeError::PathNotFound), - } - } - - let Some(parent) = current.as_table_mut() else { - return Err(MergeError::PathNotFound); - }; - - Ok(parent.remove(last).is_some()) -} - -fn toml_value_to_item(value: &TomlValue) -> anyhow::Result { - match value { - TomlValue::Table(table) => { - let mut table_item = toml_edit::Table::new(); - table_item.set_implicit(false); - for (key, val) in table { - table_item.insert(key, toml_value_to_item(val)?); - } - Ok(TomlItem::Table(table_item)) - } - other => Ok(TomlItem::Value(toml_value_to_value(other)?)), - } -} - -fn toml_value_to_value(value: &TomlValue) -> anyhow::Result { - match value { - TomlValue::String(val) => Ok(toml_edit::Value::from(val.clone())), - TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)), - TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)), - TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)), - TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)), - TomlValue::Array(items) => { - let mut array = toml_edit::Array::new(); - for item in items { - array.push(toml_value_to_value(item)?); - } - Ok(toml_edit::Value::Array(array)) - } - TomlValue::Table(table) => { - let mut inline = toml_edit::InlineTable::new(); - for (key, val) in table { - inline.insert(key, toml_value_to_value(val)?); - } - Ok(toml_edit::Value::InlineTable(inline)) - } - } -} - -#[derive(Clone)] -struct LayerState { - name: ConfigLayerName, - source: PathBuf, - config: TomlValue, - version: String, -} - -impl LayerState { - fn new(name: ConfigLayerName, source: PathBuf, config: TomlValue) -> Self { - let version = version_for_toml(&config); - Self { - name, - source, - config, - version, - } - } - - fn metadata(&self) -> ConfigLayerMetadata { - ConfigLayerMetadata { - name: self.name.clone(), - source: self.source.display().to_string(), - version: self.version.clone(), - } - } - - fn as_layer(&self) -> ConfigLayer { - ConfigLayer { - name: self.name.clone(), - source: self.source.display().to_string(), - version: self.version.clone(), - config: to_json_value(&self.config), - } - } -} - -#[derive(Clone)] -struct LayersState { - user: LayerState, - session_flags: LayerState, - system: Option, - mdm: Option, -} - -impl LayersState { - fn with_user_config(self, user_config: TomlValue) -> Self { - Self { - user: LayerState::new(self.user.name, self.user.source, user_config), - session_flags: self.session_flags, - system: self.system, - mdm: self.mdm, - } - } - - fn effective_config(&self) -> TomlValue { - let mut merged = self.user.config.clone(); - merge_toml_values(&mut merged, &self.session_flags.config); - if let Some(system) = &self.system { - merge_toml_values(&mut merged, &system.config); - } - if let Some(mdm) = &self.mdm { - merge_toml_values(&mut merged, &mdm.config); - } - merged - } - - fn origins(&self) -> HashMap { - let mut origins = HashMap::new(); - let mut path = Vec::new(); - - record_origins( - &self.user.config, - &self.user.metadata(), - &mut path, - &mut origins, - ); - record_origins( - &self.session_flags.config, - &self.session_flags.metadata(), - &mut path, - &mut origins, - ); - if let Some(system) = &self.system { - record_origins(&system.config, &system.metadata(), &mut path, &mut origins); - } - if let Some(mdm) = &self.mdm { - record_origins(&mdm.config, &mdm.metadata(), &mut path, &mut origins); - } - - origins - } - - fn layers_high_to_low(&self) -> Vec { - let mut layers = Vec::new(); - if let Some(mdm) = &self.mdm { - layers.push(mdm.as_layer()); - } - if let Some(system) = &self.system { - layers.push(system.as_layer()); - } - layers.push(self.session_flags.as_layer()); - layers.push(self.user.as_layer()); - layers - } -} - -fn record_origins( - value: &TomlValue, - meta: &ConfigLayerMetadata, - path: &mut Vec, - origins: &mut HashMap, -) { - match value { - TomlValue::Table(table) => { - for (key, val) in table { - path.push(key.clone()); - record_origins(val, meta, path, origins); - path.pop(); - } - } - TomlValue::Array(items) => { - for (idx, item) in items.iter().enumerate() { - path.push(idx.to_string()); - record_origins(item, meta, path, origins); - path.pop(); - } - } - _ => { - if !path.is_empty() { - origins.insert(path.join("."), meta.clone()); - } - } - } -} - -fn to_json_value(value: &TomlValue) -> JsonValue { - serde_json::to_value(value).unwrap_or(JsonValue::Null) -} - -fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { - let _: ConfigToml = value.clone().try_into()?; - Ok(()) -} - -fn version_for_toml(value: &TomlValue) -> String { - let json = to_json_value(value); - let canonical = canonical_json(&json); - let serialized = serde_json::to_vec(&canonical).unwrap_or_default(); - let mut hasher = Sha256::new(); - hasher.update(serialized); - let hash = hasher.finalize(); - let hex = hash - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - format!("sha256:{hex}") -} - -fn canonical_json(value: &JsonValue) -> JsonValue { - match value { - JsonValue::Object(map) => { - let mut sorted = serde_json::Map::new(); - let mut keys = map.keys().cloned().collect::>(); - keys.sort(); - for key in keys { - if let Some(val) = map.get(&key) { - sorted.insert(key, canonical_json(val)); - } - } - JsonValue::Object(sorted) - } - JsonValue::Array(items) => JsonValue::Array(items.iter().map(canonical_json).collect()), - other => other.clone(), - } -} - -fn paths_match(expected: &Path, provided: &Path) -> bool { - if let (Ok(expanded_expected), Ok(expanded_provided)) = - (expected.canonicalize(), provided.canonicalize()) - { - return expanded_expected == expanded_provided; - } - - expected == provided -} - -fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> { - let mut current = root; - for segment in segments { - match current { - TomlValue::Table(table) => { - current = table.get(segment)?; - } - TomlValue::Array(items) => { - let idx: usize = segment.parse().ok()?; - current = items.get(idx)?; - } - _ => return None, - } - } - Some(current) -} - -fn override_message(layer: &ConfigLayerName) -> String { - match layer { - ConfigLayerName::Mdm => "Overridden by managed policy (mdm)".to_string(), - ConfigLayerName::System => "Overridden by managed config (system)".to_string(), - ConfigLayerName::SessionFlags => "Overridden by session flags".to_string(), - ConfigLayerName::User => "Overridden by user config".to_string(), - } -} - -fn compute_override_metadata( - layers: &LayersState, - effective: &TomlValue, - segments: &[String], -) -> Option { - let user_value = value_at_path(&layers.user.config, segments); - let effective_value = value_at_path(effective, segments); - - if user_value.is_some() && user_value == effective_value { - return None; - } - - if user_value.is_none() && effective_value.is_none() { - return None; - } - - let effective_layer = find_effective_layer(layers, segments); - let overriding_layer = effective_layer.unwrap_or_else(|| layers.user.metadata()); - let message = override_message(&overriding_layer.name); - - Some(OverriddenMetadata { - message, - overriding_layer, - effective_value: effective_value - .map(to_json_value) - .unwrap_or(JsonValue::Null), - }) -} - -fn first_overridden_edit( - layers: &LayersState, - effective: &TomlValue, - edits: &[Vec], -) -> Option { - for segments in edits { - if let Some(meta) = compute_override_metadata(layers, effective, segments) { - return Some(meta); - } - } - None -} - -fn find_effective_layer(layers: &LayersState, segments: &[String]) -> Option { - let check = - |state: &LayerState| value_at_path(&state.config, segments).map(|_| state.metadata()); - - if let Some(mdm) = &layers.mdm - && let Some(meta) = check(mdm) - { - return Some(meta); - } - if let Some(system) = &layers.system - && let Some(meta) = check(system) - { - return Some(meta); - } - if let Some(meta) = check(&layers.session_flags) { - return Some(meta); - } - check(&layers.user) -} - -fn system_config_path(codex_home: &Path) -> PathBuf { - if let Ok(path) = std::env::var("CODEX_MANAGED_CONFIG_PATH") { - return PathBuf::from(path); - } - - #[cfg(unix)] - { - let _ = codex_home; - PathBuf::from("/etc/codex/managed_config.toml") - } - - #[cfg(not(unix))] - { - codex_home.join("managed_config.toml") - } -} - -fn internal_error(context: &str, err: E) -> JSONRPCErrorError { JSONRPCErrorError { code: INTERNAL_ERROR_CODE, - message: format!("{context}: {err}"), + message: err.to_string(), data: None, } } @@ -771,374 +68,3 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> })), } } - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::Result; - use codex_app_server_protocol::AskForApproval; - use pretty_assertions::assert_eq; - use tempfile::tempdir; - - #[test] - fn toml_value_to_item_handles_nested_config_tables() { - let config = r#" -[mcp_servers.docs] -command = "docs-server" - -[mcp_servers.docs.http_headers] -X-Doc = "42" -"#; - - let value: TomlValue = toml::from_str(config).expect("parse config example"); - let item = toml_value_to_item(&value).expect("convert to toml_edit item"); - - let root = item.as_table().expect("root table"); - assert!(!root.is_implicit(), "root table should be explicit"); - - let mcp_servers = root - .get("mcp_servers") - .and_then(TomlItem::as_table) - .expect("mcp_servers table"); - assert!( - !mcp_servers.is_implicit(), - "mcp_servers table should be explicit" - ); - - let docs = mcp_servers - .get("docs") - .and_then(TomlItem::as_table) - .expect("docs table"); - assert_eq!( - docs.get("command") - .and_then(TomlItem::as_value) - .and_then(toml_edit::Value::as_str), - Some("docs-server") - ); - - let http_headers = docs - .get("http_headers") - .and_then(TomlItem::as_table) - .expect("http_headers table"); - assert_eq!( - http_headers - .get("X-Doc") - .and_then(TomlItem::as_value) - .and_then(toml_edit::Value::as_str), - Some("42") - ); - } - - #[tokio::test] - async fn write_value_preserves_comments_and_order() -> Result<()> { - let tmp = tempdir().expect("tempdir"); - let original = r#"# Codex user configuration -model = "gpt-5" -approval_policy = "on-request" - -[notice] -# Preserve this comment -hide_full_access_warning = true - -[features] -unified_exec = true -"#; - std::fs::write(tmp.path().join(CONFIG_FILE_NAME), original)?; - - let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); - api.write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), - key_path: "features.remote_compaction".to_string(), - value: json!(true), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("write succeeds"); - - let updated = - std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); - let expected = r#"# Codex user configuration -model = "gpt-5" -approval_policy = "on-request" - -[notice] -# Preserve this comment -hide_full_access_warning = true - -[features] -unified_exec = true -remote_compaction = true -"#; - assert_eq!(updated, expected); - Ok(()) - } - - #[tokio::test] - async fn read_includes_origins_and_layers() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "model = \"user\"").unwrap(); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); - - let api = ConfigApi::with_overrides( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - }, - ); - - let response = api - .read(ConfigReadParams { - include_layers: true, - }) - .await - .expect("response"); - - assert_eq!(response.config.approval_policy, Some(AskForApproval::Never)); - - assert_eq!( - response - .origins - .get("approval_policy") - .expect("origin") - .name, - ConfigLayerName::System - ); - let layers = response.layers.expect("layers present"); - assert_eq!(layers.first().unwrap().name, ConfigLayerName::System); - assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags); - assert_eq!(layers.last().unwrap().name, ConfigLayerName::User); - } - - #[tokio::test] - async fn write_value_reports_override() { - let tmp = tempdir().expect("tempdir"); - std::fs::write( - tmp.path().join(CONFIG_FILE_NAME), - "approval_policy = \"on-request\"", - ) - .unwrap(); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); - - let api = ConfigApi::with_overrides( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - }, - ); - - let result = api - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), - key_path: "approval_policy".to_string(), - value: json!("never"), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("result"); - - let read_after = api - .read(ConfigReadParams { - include_layers: true, - }) - .await - .expect("read"); - assert_eq!( - read_after.config.approval_policy, - Some(AskForApproval::Never) - ); - assert_eq!( - read_after - .origins - .get("approval_policy") - .expect("origin") - .name, - ConfigLayerName::System - ); - assert_eq!(result.status, WriteStatus::Ok); - assert!(result.overridden_metadata.is_none()); - } - - #[tokio::test] - async fn version_conflict_rejected() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "model = \"user\"").unwrap(); - - let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); - let error = api - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), - key_path: "model".to_string(), - value: json!("gpt-5"), - merge_strategy: MergeStrategy::Replace, - expected_version: Some("sha256:bogus".to_string()), - }) - .await - .expect_err("should fail"); - - assert_eq!(error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!( - error - .data - .as_ref() - .and_then(|d| d.get("config_write_error_code")) - .and_then(serde_json::Value::as_str), - Some("configVersionConflict") - ); - } - - #[tokio::test] - async fn write_value_defaults_to_user_config_path() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "").unwrap(); - - let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); - api.write_value(ConfigValueWriteParams { - file_path: None, - key_path: "model".to_string(), - value: json!("gpt-new"), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("write succeeds"); - - let contents = - std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); - assert!( - contents.contains("model = \"gpt-new\""), - "config.toml should be updated even when file_path is omitted" - ); - } - - #[tokio::test] - async fn invalid_user_value_rejected_even_if_overridden_by_managed() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "model = \"user\"").unwrap(); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); - - let api = ConfigApi::with_overrides( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - }, - ); - - let error = api - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), - key_path: "approval_policy".to_string(), - value: json!("bogus"), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect_err("should fail validation"); - - assert_eq!(error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!( - error - .data - .as_ref() - .and_then(|d| d.get("config_write_error_code")) - .and_then(serde_json::Value::as_str), - Some("configValidationError") - ); - - let contents = - std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); - assert_eq!(contents.trim(), "model = \"user\""); - } - - #[tokio::test] - async fn read_reports_managed_overrides_user_and_session_flags() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "model = \"user\"").unwrap(); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "model = \"system\"").unwrap(); - - let cli_overrides = vec![( - "model".to_string(), - TomlValue::String("session".to_string()), - )]; - - let api = ConfigApi::with_overrides( - tmp.path().to_path_buf(), - cli_overrides, - LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - }, - ); - - let response = api - .read(ConfigReadParams { - include_layers: true, - }) - .await - .expect("response"); - - assert_eq!(response.config.model.as_deref(), Some("system")); - assert_eq!( - response.origins.get("model").expect("origin").name, - ConfigLayerName::System - ); - let layers = response.layers.expect("layers"); - assert_eq!(layers.first().unwrap().name, ConfigLayerName::System); - assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags); - assert_eq!(layers.get(2).unwrap().name, ConfigLayerName::User); - } - - #[tokio::test] - async fn write_value_reports_managed_override() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "").unwrap(); - - let managed_path = tmp.path().join("managed_config.toml"); - std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); - - let api = ConfigApi::with_overrides( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - }, - ); - - let result = api - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), - key_path: "approval_policy".to_string(), - value: json!("on-request"), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect("result"); - - assert_eq!(result.status, WriteStatus::OkOverridden); - let overridden = result.overridden_metadata.expect("overridden metadata"); - assert_eq!(overridden.overriding_layer.name, ConfigLayerName::System); - assert_eq!(overridden.effective_value, json!("never")); - } -} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 4c1a073c5..584708156 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -12,10 +12,7 @@ use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::Tui; use crate::config::types::UriBasedFileOpener; -use crate::config_loader::LoadedConfigLayers; -use crate::config_loader::load_config_as_toml; -use crate::config_loader::load_config_layers_with_overrides; -use crate::config_loader::merge_toml_values; +use crate::config_loader::load_config_layers_state; use crate::features::Feature; use crate::features::FeatureOverrides; use crate::features::Features; @@ -59,8 +56,12 @@ use toml_edit::DocumentMut; pub mod edit; pub mod profile; +pub mod service; pub mod types; +pub use service::ConfigService; +pub use service::ConfigServiceError; + const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max"; /// Maximum number of bytes of the documentation that will be embedded. Larger @@ -342,29 +343,8 @@ async fn load_resolved_config( cli_overrides: Vec<(String, TomlValue)>, overrides: crate::config_loader::LoaderOverrides, ) -> std::io::Result { - let layers = load_config_layers_with_overrides(codex_home, overrides).await?; - Ok(apply_overlays(layers, cli_overrides)) -} - -fn apply_overlays( - layers: LoadedConfigLayers, - cli_overrides: Vec<(String, TomlValue)>, -) -> TomlValue { - let LoadedConfigLayers { - mut base, - managed_config, - managed_preferences, - } = layers; - - for (path, value) in cli_overrides.into_iter() { - apply_toml_override(&mut base, &path, value); - } - - for overlay in [managed_config, managed_preferences].into_iter().flatten() { - merge_toml_values(&mut base, &overlay); - } - - base + let layers = load_config_layers_state(codex_home, &cli_overrides, overrides).await?; + Ok(layers.effective_config()) } fn deserialize_config_toml_with_base( @@ -382,7 +362,12 @@ fn deserialize_config_toml_with_base( pub async fn load_global_mcp_servers( codex_home: &Path, ) -> std::io::Result> { - let root_value = load_config_as_toml(codex_home).await?; + let root_value = load_resolved_config( + codex_home, + Vec::new(), + crate::config_loader::LoaderOverrides::default(), + ) + .await?; let Some(servers_value) = root_value.get("mcp_servers") else { return Ok(BTreeMap::new()); }; @@ -541,49 +526,6 @@ pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::R Ok(()) } -/// Apply a single dotted-path override onto a TOML value. -fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) { - use toml::value::Table; - - let segments: Vec<&str> = path.split('.').collect(); - let mut current = root; - - for (idx, segment) in segments.iter().enumerate() { - let is_last = idx == segments.len() - 1; - - if is_last { - match current { - TomlValue::Table(table) => { - table.insert(segment.to_string(), value); - } - _ => { - let mut table = Table::new(); - table.insert(segment.to_string(), value); - *current = TomlValue::Table(table); - } - } - return; - } - - // Traverse or create intermediate object. - match current { - TomlValue::Table(table) => { - current = table - .entry(segment.to_string()) - .or_insert_with(|| TomlValue::Table(Table::new())); - } - _ => { - *current = TomlValue::Table(Table::new()); - if let TomlValue::Table(tbl) = current { - current = tbl - .entry(segment.to_string()) - .or_insert_with(|| TomlValue::Table(Table::new())); - } - } - } - } -} - /// Base config deserialized from ~/.codex/config.toml. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigToml { diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs new file mode 100644 index 000000000..6f9d0e4d6 --- /dev/null +++ b/codex-rs/core/src/config/service.rs @@ -0,0 +1,935 @@ +use super::CONFIG_TOML_FILE; +use super::ConfigToml; +use crate::config::edit::ConfigEdit; +use crate::config::edit::ConfigEditsBuilder; +use crate::config_loader::ConfigLayerEntry; +use crate::config_loader::ConfigLayerStack; +use crate::config_loader::LoaderOverrides; +use crate::config_loader::load_config_layers_state; +use crate::config_loader::merge_toml_values; +use codex_app_server_protocol::Config as ApiConfig; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigLayerMetadata; +use codex_app_server_protocol::ConfigLayerName; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteErrorCode; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::OverriddenMetadata; +use codex_app_server_protocol::WriteStatus; +use serde_json::Value as JsonValue; +use std::path::Path; +use std::path::PathBuf; +use thiserror::Error; +use toml::Value as TomlValue; +use toml_edit::Item as TomlItem; + +#[derive(Debug, Error)] +pub enum ConfigServiceError { + #[error("{message}")] + Write { + code: ConfigWriteErrorCode, + message: String, + }, + + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: std::io::Error, + }, + + #[error("{context}: {source}")] + Json { + context: &'static str, + #[source] + source: serde_json::Error, + }, + + #[error("{context}: {source}")] + Toml { + context: &'static str, + #[source] + source: toml::de::Error, + }, + + #[error("{context}: {source}")] + Anyhow { + context: &'static str, + #[source] + source: anyhow::Error, + }, +} + +impl ConfigServiceError { + fn write(code: ConfigWriteErrorCode, message: impl Into) -> Self { + Self::Write { + code, + message: message.into(), + } + } + + fn io(context: &'static str, source: std::io::Error) -> Self { + Self::Io { context, source } + } + + fn json(context: &'static str, source: serde_json::Error) -> Self { + Self::Json { context, source } + } + + fn toml(context: &'static str, source: toml::de::Error) -> Self { + Self::Toml { context, source } + } + + fn anyhow(context: &'static str, source: anyhow::Error) -> Self { + Self::Anyhow { context, source } + } + + pub fn write_error_code(&self) -> Option { + match self { + Self::Write { code, .. } => Some(code.clone()), + _ => None, + } + } +} + +#[derive(Clone)] +pub struct ConfigService { + codex_home: PathBuf, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, +} + +impl ConfigService { + pub fn new(codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>) -> Self { + Self { + codex_home, + cli_overrides, + loader_overrides: LoaderOverrides::default(), + } + } + + #[cfg(test)] + fn with_overrides( + codex_home: PathBuf, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + ) -> Self { + Self { + codex_home, + cli_overrides, + loader_overrides, + } + } + + pub async fn read( + &self, + params: ConfigReadParams, + ) -> Result { + let layers = self + .load_layers_state() + .await + .map_err(|err| ConfigServiceError::io("failed to read configuration layers", err))?; + + let effective = layers.effective_config(); + validate_config(&effective) + .map_err(|err| ConfigServiceError::toml("invalid configuration", err))?; + + let json_value = serde_json::to_value(&effective) + .map_err(|err| ConfigServiceError::json("failed to serialize configuration", err))?; + let config: ApiConfig = serde_json::from_value(json_value) + .map_err(|err| ConfigServiceError::json("failed to deserialize configuration", err))?; + + Ok(ConfigReadResponse { + config, + origins: layers.origins(), + layers: params.include_layers.then(|| layers.layers_high_to_low()), + }) + } + + pub async fn write_value( + &self, + params: ConfigValueWriteParams, + ) -> Result { + let edits = vec![(params.key_path, params.value, params.merge_strategy)]; + self.apply_edits(params.file_path, params.expected_version, edits) + .await + } + + pub async fn batch_write( + &self, + params: ConfigBatchWriteParams, + ) -> Result { + let edits = params + .edits + .into_iter() + .map(|edit| (edit.key_path, edit.value, edit.merge_strategy)) + .collect(); + + self.apply_edits(params.file_path, params.expected_version, edits) + .await + } + + pub async fn load_user_saved_config( + &self, + ) -> Result { + let layers = self + .load_layers_state() + .await + .map_err(|err| ConfigServiceError::io("failed to load configuration", err))?; + + let toml_value = layers.effective_config(); + let cfg: ConfigToml = toml_value + .try_into() + .map_err(|err| ConfigServiceError::toml("failed to parse config.toml", err))?; + Ok(cfg.into()) + } + + async fn apply_edits( + &self, + file_path: Option, + expected_version: Option, + edits: Vec<(String, JsonValue, MergeStrategy)>, + ) -> Result { + let allowed_path = self.codex_home.join(CONFIG_TOML_FILE); + let provided_path = file_path + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| allowed_path.clone()); + + if !paths_match(&allowed_path, &provided_path) { + return Err(ConfigServiceError::write( + ConfigWriteErrorCode::ConfigLayerReadonly, + "Only writes to the user config are allowed", + )); + } + + let layers = self + .load_layers_state() + .await + .map_err(|err| ConfigServiceError::io("failed to load configuration", err))?; + + if let Some(expected) = expected_version.as_deref() + && expected != layers.user.version + { + return Err(ConfigServiceError::write( + ConfigWriteErrorCode::ConfigVersionConflict, + "Configuration was modified since last read. Fetch latest version and retry.", + )); + } + + let mut user_config = layers.user.config.clone(); + let mut parsed_segments = Vec::new(); + let mut config_edits = Vec::new(); + + for (key_path, value, strategy) in edits.into_iter() { + let segments = parse_key_path(&key_path).map_err(|message| { + ConfigServiceError::write(ConfigWriteErrorCode::ConfigValidationError, message) + })?; + let original_value = value_at_path(&user_config, &segments).cloned(); + let parsed_value = parse_value(value).map_err(|message| { + ConfigServiceError::write(ConfigWriteErrorCode::ConfigValidationError, message) + })?; + + apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err( + |err| match err { + MergeError::PathNotFound => ConfigServiceError::write( + ConfigWriteErrorCode::ConfigPathNotFound, + "Path not found", + ), + MergeError::Validation(message) => ConfigServiceError::write( + ConfigWriteErrorCode::ConfigValidationError, + message, + ), + }, + )?; + + let updated_value = value_at_path(&user_config, &segments).cloned(); + if original_value != updated_value { + let edit = match updated_value { + Some(value) => ConfigEdit::SetPath { + segments: segments.clone(), + value: toml_value_to_item(&value).map_err(|err| { + ConfigServiceError::anyhow("failed to build config edits", err) + })?, + }, + None => ConfigEdit::ClearPath { + segments: segments.clone(), + }, + }; + config_edits.push(edit); + } + + parsed_segments.push(segments); + } + + validate_config(&user_config).map_err(|err| { + ConfigServiceError::write( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + })?; + + let updated_layers = layers.with_user_config(user_config.clone()); + let effective = updated_layers.effective_config(); + validate_config(&effective).map_err(|err| { + ConfigServiceError::write( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + })?; + + if !config_edits.is_empty() { + ConfigEditsBuilder::new(&self.codex_home) + .with_edits(config_edits) + .apply() + .await + .map_err(|err| ConfigServiceError::anyhow("failed to persist config.toml", err))?; + } + + let overridden = first_overridden_edit(&updated_layers, &effective, &parsed_segments); + let status = overridden + .as_ref() + .map(|_| WriteStatus::OkOverridden) + .unwrap_or(WriteStatus::Ok); + + let file_path = provided_path + .canonicalize() + .unwrap_or(provided_path.clone()) + .display() + .to_string(); + + Ok(ConfigWriteResponse { + status, + version: updated_layers.user.version.clone(), + file_path, + overridden_metadata: overridden, + }) + } + + async fn load_layers_state(&self) -> std::io::Result { + load_config_layers_state( + &self.codex_home, + &self.cli_overrides, + self.loader_overrides.clone(), + ) + .await + } +} + +fn parse_value(value: JsonValue) -> Result, String> { + if value.is_null() { + return Ok(None); + } + + serde_json::from_value::(value) + .map(Some) + .map_err(|err| format!("invalid value: {err}")) +} + +fn parse_key_path(path: &str) -> Result, String> { + if path.trim().is_empty() { + return Err("keyPath must not be empty".to_string()); + } + Ok(path + .split('.') + .map(std::string::ToString::to_string) + .collect()) +} + +#[derive(Debug)] +enum MergeError { + PathNotFound, + Validation(String), +} + +fn apply_merge( + root: &mut TomlValue, + segments: &[String], + value: Option<&TomlValue>, + strategy: MergeStrategy, +) -> Result { + let Some(value) = value else { + return clear_path(root, segments); + }; + + let Some((last, parents)) = segments.split_last() else { + return Err(MergeError::Validation( + "keyPath must not be empty".to_string(), + )); + }; + + let mut current = root; + + for segment in parents { + match current { + TomlValue::Table(table) => { + current = table + .entry(segment.clone()) + .or_insert_with(|| TomlValue::Table(toml::map::Map::new())); + } + _ => { + *current = TomlValue::Table(toml::map::Map::new()); + if let TomlValue::Table(table) = current { + current = table + .entry(segment.clone()) + .or_insert_with(|| TomlValue::Table(toml::map::Map::new())); + } + } + } + } + + let table = current.as_table_mut().ok_or_else(|| { + MergeError::Validation("cannot set value on non-table parent".to_string()) + })?; + + if matches!(strategy, MergeStrategy::Upsert) + && let Some(existing) = table.get_mut(last) + && matches!(existing, TomlValue::Table(_)) + && matches!(value, TomlValue::Table(_)) + { + merge_toml_values(existing, value); + return Ok(true); + } + + let changed = table + .get(last) + .map(|existing| Some(existing) != Some(value)) + .unwrap_or(true); + table.insert(last.clone(), value.clone()); + Ok(changed) +} + +fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result { + let Some((last, parents)) = segments.split_last() else { + return Err(MergeError::Validation( + "keyPath must not be empty".to_string(), + )); + }; + + let mut current = root; + for segment in parents { + match current { + TomlValue::Table(table) => { + current = table.get_mut(segment).ok_or(MergeError::PathNotFound)?; + } + _ => return Err(MergeError::PathNotFound), + } + } + + let Some(parent) = current.as_table_mut() else { + return Err(MergeError::PathNotFound); + }; + + Ok(parent.remove(last).is_some()) +} + +fn toml_value_to_item(value: &TomlValue) -> anyhow::Result { + match value { + TomlValue::Table(table) => { + let mut table_item = toml_edit::Table::new(); + table_item.set_implicit(false); + for (key, val) in table { + table_item.insert(key, toml_value_to_item(val)?); + } + Ok(TomlItem::Table(table_item)) + } + other => Ok(TomlItem::Value(toml_value_to_value(other)?)), + } +} + +fn toml_value_to_value(value: &TomlValue) -> anyhow::Result { + match value { + TomlValue::String(val) => Ok(toml_edit::Value::from(val.clone())), + TomlValue::Integer(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Float(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Boolean(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Datetime(val) => Ok(toml_edit::Value::from(*val)), + TomlValue::Array(items) => { + let mut array = toml_edit::Array::new(); + for item in items { + array.push(toml_value_to_value(item)?); + } + Ok(toml_edit::Value::Array(array)) + } + TomlValue::Table(table) => { + let mut inline = toml_edit::InlineTable::new(); + for (key, val) in table { + inline.insert(key, toml_value_to_value(val)?); + } + Ok(toml_edit::Value::InlineTable(inline)) + } + } +} + +fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { + let _: ConfigToml = value.clone().try_into()?; + Ok(()) +} + +fn paths_match(expected: &Path, provided: &Path) -> bool { + if let (Ok(expanded_expected), Ok(expanded_provided)) = + (expected.canonicalize(), provided.canonicalize()) + { + return expanded_expected == expanded_provided; + } + + expected == provided +} + +fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> { + let mut current = root; + for segment in segments { + match current { + TomlValue::Table(table) => { + current = table.get(segment)?; + } + TomlValue::Array(items) => { + let idx = segment.parse::().ok()?; + let idx = usize::try_from(idx).ok()?; + current = items.get(idx)?; + } + _ => return None, + } + } + Some(current) +} + +fn override_message(layer: &ConfigLayerName) -> String { + match layer { + ConfigLayerName::Mdm => "Overridden by managed policy (mdm)".to_string(), + ConfigLayerName::System => "Overridden by managed config (system)".to_string(), + ConfigLayerName::SessionFlags => "Overridden by session flags".to_string(), + ConfigLayerName::User => "Overridden by user config".to_string(), + } +} + +fn compute_override_metadata( + layers: &ConfigLayerStack, + effective: &TomlValue, + segments: &[String], +) -> Option { + let user_value = value_at_path(&layers.user.config, segments); + let effective_value = value_at_path(effective, segments); + + if user_value.is_some() && user_value == effective_value { + return None; + } + + if user_value.is_none() && effective_value.is_none() { + return None; + } + + let effective_layer = find_effective_layer(layers, segments); + let overriding_layer = effective_layer.unwrap_or_else(|| layers.user.metadata()); + let message = override_message(&overriding_layer.name); + + Some(OverriddenMetadata { + message, + overriding_layer, + effective_value: effective_value + .and_then(|value| serde_json::to_value(value).ok()) + .unwrap_or(JsonValue::Null), + }) +} + +fn first_overridden_edit( + layers: &ConfigLayerStack, + effective: &TomlValue, + edits: &[Vec], +) -> Option { + for segments in edits { + if let Some(meta) = compute_override_metadata(layers, effective, segments) { + return Some(meta); + } + } + None +} + +fn find_effective_layer( + layers: &ConfigLayerStack, + segments: &[String], +) -> Option { + let check = + |state: &ConfigLayerEntry| value_at_path(&state.config, segments).map(|_| state.metadata()); + + if let Some(mdm) = &layers.mdm + && let Some(meta) = check(mdm) + { + return Some(meta); + } + if let Some(system) = &layers.system + && let Some(meta) = check(system) + { + return Some(meta); + } + if let Some(meta) = check(&layers.session_flags) { + return Some(meta); + } + check(&layers.user) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use codex_app_server_protocol::AskForApproval; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[test] + fn toml_value_to_item_handles_nested_config_tables() { + let config = r#" +[mcp_servers.docs] +command = "docs-server" + +[mcp_servers.docs.http_headers] +X-Doc = "42" +"#; + + let value: TomlValue = toml::from_str(config).expect("parse config example"); + let item = toml_value_to_item(&value).expect("convert to toml_edit item"); + + let root = item.as_table().expect("root table"); + assert!(!root.is_implicit(), "root table should be explicit"); + + let mcp_servers = root + .get("mcp_servers") + .and_then(TomlItem::as_table) + .expect("mcp_servers table"); + assert!( + !mcp_servers.is_implicit(), + "mcp_servers table should be explicit" + ); + + let docs = mcp_servers + .get("docs") + .and_then(TomlItem::as_table) + .expect("docs table"); + assert_eq!( + docs.get("command") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("docs-server") + ); + + let http_headers = docs + .get("http_headers") + .and_then(TomlItem::as_table) + .expect("http_headers table"); + assert_eq!( + http_headers + .get("X-Doc") + .and_then(TomlItem::as_value) + .and_then(toml_edit::Value::as_str), + Some("42") + ); + } + + #[tokio::test] + async fn write_value_preserves_comments_and_order() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let original = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +"#; + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?; + + let service = ConfigService::new(tmp.path().to_path_buf(), vec![]); + service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "features.remote_compaction".to_string(), + value: serde_json::json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let updated = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"# Codex user configuration +model = "gpt-5" +approval_policy = "on-request" + +[notice] +# Preserve this comment +hide_full_access_warning = true + +[features] +unified_exec = true +remote_compaction = true +"#; + assert_eq!(updated, expected); + Ok(()) + } + + #[tokio::test] + async fn read_includes_origins_and_layers() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let service = ConfigService::with_overrides( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let response = service + .read(ConfigReadParams { + include_layers: true, + }) + .await + .expect("response"); + + assert_eq!(response.config.approval_policy, Some(AskForApproval::Never)); + + assert_eq!( + response + .origins + .get("approval_policy") + .expect("origin") + .name, + ConfigLayerName::System + ); + let layers = response.layers.expect("layers present"); + assert_eq!(layers.first().unwrap().name, ConfigLayerName::System); + assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags); + assert_eq!(layers.last().unwrap().name, ConfigLayerName::User); + } + + #[tokio::test] + async fn write_value_reports_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "approval_policy = \"on-request\"", + ) + .unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let service = ConfigService::with_overrides( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let result = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "approval_policy".to_string(), + value: serde_json::json!("never"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("result"); + + let read_after = service + .read(ConfigReadParams { + include_layers: true, + }) + .await + .expect("read"); + assert_eq!( + read_after.config.approval_policy, + Some(AskForApproval::Never) + ); + assert_eq!( + read_after + .origins + .get("approval_policy") + .expect("origin") + .name, + ConfigLayerName::System + ); + assert_eq!(result.status, WriteStatus::Ok); + assert!(result.overridden_metadata.is_none()); + } + + #[tokio::test] + async fn version_conflict_rejected() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); + + let service = ConfigService::new(tmp.path().to_path_buf(), vec![]); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "model".to_string(), + value: serde_json::json!("gpt-5"), + merge_strategy: MergeStrategy::Replace, + expected_version: Some("sha256:bogus".to_string()), + }) + .await + .expect_err("should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigVersionConflict) + ); + } + + #[tokio::test] + async fn write_value_defaults_to_user_config_path() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let service = ConfigService::new(tmp.path().to_path_buf(), vec![]); + service + .write_value(ConfigValueWriteParams { + file_path: None, + key_path: "model".to_string(), + value: serde_json::json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert!( + contents.contains("model = \"gpt-new\""), + "config.toml should be updated even when file_path is omitted" + ); + } + + #[tokio::test] + async fn invalid_user_value_rejected_even_if_overridden_by_managed() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let service = ConfigService::with_overrides( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "approval_policy".to_string(), + value: serde_json::json!("bogus"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("should fail validation"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents.trim(), "model = \"user\""); + } + + #[tokio::test] + async fn read_reports_managed_overrides_user_and_session_flags() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "model = \"system\"").unwrap(); + + let cli_overrides = vec![( + "model".to_string(), + TomlValue::String("session".to_string()), + )]; + + let service = ConfigService::with_overrides( + tmp.path().to_path_buf(), + cli_overrides, + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let response = service + .read(ConfigReadParams { + include_layers: true, + }) + .await + .expect("response"); + + assert_eq!(response.config.model.as_deref(), Some("system")); + assert_eq!( + response.origins.get("model").expect("origin").name, + ConfigLayerName::System + ); + let layers = response.layers.expect("layers"); + assert_eq!(layers.first().unwrap().name, ConfigLayerName::System); + assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags); + assert_eq!(layers.get(2).unwrap().name, ConfigLayerName::User); + } + + #[tokio::test] + async fn write_value_reports_managed_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let service = ConfigService::with_overrides( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let result = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "approval_policy".to_string(), + value: serde_json::json!("on-request"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("result"); + + assert_eq!(result.status, WriteStatus::OkOverridden); + let overridden = result.overridden_metadata.expect("overridden metadata"); + assert_eq!(overridden.overriding_layer.name, ConfigLayerName::System); + assert_eq!(overridden.effective_value, serde_json::json!("never")); + } +} diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md new file mode 100644 index 000000000..da427c454 --- /dev/null +++ b/codex-rs/core/src/config_loader/README.md @@ -0,0 +1,64 @@ +# `codex-core` config loader + +This module is the canonical place to **load and describe Codex configuration layers** (user config, CLI/session overrides, managed config, and MDM-managed preferences) and to produce: + +- An **effective merged** TOML config. +- **Per-key origins** metadata (which layer “wins” for a given key). +- **Per-layer versions** (stable fingerprints) used for optimistic concurrency / conflict detection. + +## Public surface + +Exported from `codex_core::config_loader`: + +- `load_config_layers_state(codex_home, cli_overrides, overrides) -> ConfigLayerStack` +- `ConfigLayerStack` + - `effective_config() -> toml::Value` + - `origins() -> HashMap` + - `layers_high_to_low() -> Vec` + - `with_user_config(user_config) -> ConfigLayerStack` +- `ConfigLayerEntry` (one layer’s `{name, source, config, version}`) +- `LoaderOverrides` (test/override hooks for managed config sources) +- `merge_toml_values(base, overlay)` (public helper used elsewhere) + +## Layering model + +Precedence is **top overrides bottom**: + +1. **MDM** managed preferences (macOS only) +2. **System** managed config (e.g. `managed_config.toml`) +3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) +4. **User** config (`config.toml`) + +This is what `ConfigLayerStack::effective_config()` implements. + +## Typical usage + +Most callers want the effective config plus metadata: + +```rust +use codex_core::config_loader::{load_config_layers_state, LoaderOverrides}; +use toml::Value as TomlValue; + +let cli_overrides: Vec<(String, TomlValue)> = Vec::new(); +let layers = load_config_layers_state( + &codex_home, + &cli_overrides, + LoaderOverrides::default(), +).await?; + +let effective = layers.effective_config(); +let origins = layers.origins(); +let layers_for_ui = layers.layers_high_to_low(); +``` + +## Internal layout + +Implementation is split by concern: + +- `state.rs`: public types (`ConfigLayerEntry`, `ConfigLayerStack`) + merge/origins convenience methods. +- `layer_io.rs`: reading `config.toml`, managed config, and managed preferences inputs. +- `overrides.rs`: CLI dotted-path overrides → TOML “session flags” layer. +- `merge.rs`: recursive TOML merge. +- `fingerprint.rs`: stable per-layer hashing and per-key origins traversal. +- `macos.rs`: managed preferences integration (macOS only). + diff --git a/codex-rs/core/src/config_loader/fingerprint.rs b/codex-rs/core/src/config_loader/fingerprint.rs new file mode 100644 index 000000000..c072134fd --- /dev/null +++ b/codex-rs/core/src/config_loader/fingerprint.rs @@ -0,0 +1,67 @@ +use codex_app_server_protocol::ConfigLayerMetadata; +use serde_json::Value as JsonValue; +use sha2::Digest; +use sha2::Sha256; +use std::collections::HashMap; +use toml::Value as TomlValue; + +pub(super) fn record_origins( + value: &TomlValue, + meta: &ConfigLayerMetadata, + path: &mut Vec, + origins: &mut HashMap, +) { + match value { + TomlValue::Table(table) => { + for (key, val) in table { + path.push(key.clone()); + record_origins(val, meta, path, origins); + path.pop(); + } + } + TomlValue::Array(items) => { + for (idx, item) in (0_i32..).zip(items.iter()) { + path.push(idx.to_string()); + record_origins(item, meta, path, origins); + path.pop(); + } + } + _ => { + if !path.is_empty() { + origins.insert(path.join("."), meta.clone()); + } + } + } +} + +pub(super) fn version_for_toml(value: &TomlValue) -> String { + let json = serde_json::to_value(value).unwrap_or(JsonValue::Null); + let canonical = canonical_json(&json); + let serialized = serde_json::to_vec(&canonical).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(serialized); + let hash = hasher.finalize(); + let hex = hash + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("sha256:{hex}") +} + +fn canonical_json(value: &JsonValue) -> JsonValue { + match value { + JsonValue::Object(map) => { + let mut sorted = serde_json::Map::new(); + let mut keys = map.keys().cloned().collect::>(); + keys.sort(); + for key in keys { + if let Some(val) = map.get(&key) { + sorted.insert(key, canonical_json(val)); + } + } + JsonValue::Object(sorted) + } + JsonValue::Array(items) => JsonValue::Array(items.iter().map(canonical_json).collect()), + other => other.clone(), + } +} diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/core/src/config_loader/layer_io.rs new file mode 100644 index 000000000..1e9052f21 --- /dev/null +++ b/codex-rs/core/src/config_loader/layer_io.rs @@ -0,0 +1,100 @@ +use super::LoaderOverrides; +use super::macos::load_managed_admin_config_layer; +use super::overrides::default_empty_table; +use crate::config::CONFIG_TOML_FILE; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use tokio::fs; +use toml::Value as TomlValue; + +#[cfg(unix)] +const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml"; + +#[derive(Debug, Clone)] +pub(super) struct LoadedConfigLayers { + pub base: TomlValue, + pub managed_config: Option, + pub managed_preferences: Option, +} + +pub(super) async fn load_config_layers_internal( + codex_home: &Path, + overrides: LoaderOverrides, +) -> io::Result { + #[cfg(target_os = "macos")] + let LoaderOverrides { + managed_config_path, + managed_preferences_base64, + } = overrides; + + #[cfg(not(target_os = "macos"))] + let LoaderOverrides { + managed_config_path, + } = overrides; + + let managed_config_path = + managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)); + + let user_config_path = codex_home.join(CONFIG_TOML_FILE); + let user_config = read_config_from_path(&user_config_path, true).await?; + let managed_config = read_config_from_path(&managed_config_path, false).await?; + + #[cfg(target_os = "macos")] + let managed_preferences = + load_managed_admin_config_layer(managed_preferences_base64.as_deref()).await?; + + #[cfg(not(target_os = "macos"))] + let managed_preferences = load_managed_admin_config_layer(None).await?; + + Ok(LoadedConfigLayers { + base: user_config.unwrap_or_else(default_empty_table), + managed_config, + managed_preferences, + }) +} + +pub(super) async fn read_config_from_path( + path: &Path, + log_missing_as_info: bool, +) -> io::Result> { + match fs::read_to_string(path).await { + Ok(contents) => match toml::from_str::(&contents) { + Ok(value) => Ok(Some(value)), + Err(err) => { + tracing::error!("Failed to parse {}: {err}", path.display()); + Err(io::Error::new(io::ErrorKind::InvalidData, err)) + } + }, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + if log_missing_as_info { + tracing::info!("{} not found, using defaults", path.display()); + } else { + tracing::debug!("{} not found", path.display()); + } + Ok(None) + } + Err(err) => { + tracing::error!("Failed to read {}: {err}", path.display()); + Err(err) + } + } +} + +/// Return the default managed config path (honoring `CODEX_MANAGED_CONFIG_PATH`). +pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf { + if let Ok(path) = std::env::var("CODEX_MANAGED_CONFIG_PATH") { + return PathBuf::from(path); + } + + #[cfg(unix)] + { + let _ = codex_home; + PathBuf::from(CODEX_MANAGED_CONFIG_SYSTEM_PATH) + } + + #[cfg(not(unix))] + { + codex_home.join("managed_config.toml") + } +} diff --git a/codex-rs/core/src/config_loader/merge.rs b/codex-rs/core/src/config_loader/merge.rs new file mode 100644 index 000000000..eacbbcb78 --- /dev/null +++ b/codex-rs/core/src/config_loader/merge.rs @@ -0,0 +1,18 @@ +use toml::Value as TomlValue; + +/// Merge config `overlay` into `base`, giving `overlay` precedence. +pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) { + if let TomlValue::Table(overlay_table) = overlay + && let TomlValue::Table(base_table) = base + { + for (key, value) in overlay_table { + if let Some(existing) = base_table.get_mut(key) { + merge_toml_values(existing, value); + } else { + base_table.insert(key.clone(), value.clone()); + } + } + } else { + *base = overlay.clone(); + } +} diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 4cd541d3e..c7c522008 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -1,319 +1,74 @@ +mod fingerprint; +mod layer_io; mod macos; +mod merge; +mod overrides; +mod state; + +#[cfg(test)] +mod tests; use crate::config::CONFIG_TOML_FILE; -use macos::load_managed_admin_config_layer; +use codex_app_server_protocol::ConfigLayerName; use std::io; use std::path::Path; use std::path::PathBuf; -use tokio::fs; use toml::Value as TomlValue; -#[cfg(unix)] -const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml"; +pub use merge::merge_toml_values; +pub use state::ConfigLayerEntry; +pub use state::ConfigLayerStack; +pub use state::LoaderOverrides; -#[derive(Debug, Clone)] -pub struct LoadedConfigLayers { - pub base: TomlValue, - pub managed_config: Option, - pub managed_preferences: Option, -} +const SESSION_FLAGS_SOURCE: &str = "--config"; +const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64"; -#[derive(Debug, Default, Clone)] -pub struct LoaderOverrides { - pub managed_config_path: Option, - #[cfg(target_os = "macos")] - pub managed_preferences_base64: Option, -} - -// Configuration layering pipeline (top overrides bottom): -// -// +-------------------------+ -// | Managed preferences (*) | -// +-------------------------+ -// ^ -// | -// +-------------------------+ -// | managed_config.toml | -// +-------------------------+ -// ^ -// | -// +-------------------------+ -// | config.toml (base) | -// +-------------------------+ -// -// (*) Only available on macOS via managed device profiles. - -pub async fn load_config_as_toml(codex_home: &Path) -> io::Result { - load_config_as_toml_with_overrides(codex_home, LoaderOverrides::default()).await -} - -pub async fn load_config_layers(codex_home: &Path) -> io::Result { - load_config_layers_with_overrides(codex_home, LoaderOverrides::default()).await -} - -fn default_empty_table() -> TomlValue { - TomlValue::Table(Default::default()) -} - -pub async fn load_config_layers_with_overrides( +/// Configuration layering pipeline (top overrides bottom): +/// +/// +-------------------------+ +/// | Managed preferences (*) | +/// +-------------------------+ +/// ^ +/// | +/// +-------------------------+ +/// | managed_config.toml | +/// +-------------------------+ +/// ^ +/// | +/// +-------------------------+ +/// | config.toml (base) | +/// +-------------------------+ +/// +/// (*) Only available on macOS via managed device profiles. +pub async fn load_config_layers_state( codex_home: &Path, + cli_overrides: &[(String, TomlValue)], overrides: LoaderOverrides, -) -> io::Result { - load_config_layers_internal(codex_home, overrides).await -} +) -> io::Result { + let managed_config_path = overrides + .managed_config_path + .clone() + .unwrap_or_else(|| layer_io::managed_config_default_path(codex_home)); -async fn load_config_as_toml_with_overrides( - codex_home: &Path, - overrides: LoaderOverrides, -) -> io::Result { - let layers = load_config_layers_internal(codex_home, overrides).await?; - Ok(apply_managed_layers(layers)) -} + let layers = layer_io::load_config_layers_internal(codex_home, overrides).await?; + let cli_overrides = overrides::build_cli_overrides_layer(cli_overrides); -async fn load_config_layers_internal( - codex_home: &Path, - overrides: LoaderOverrides, -) -> io::Result { - #[cfg(target_os = "macos")] - let LoaderOverrides { - managed_config_path, - managed_preferences_base64, - } = overrides; - - #[cfg(not(target_os = "macos"))] - let LoaderOverrides { - managed_config_path, - } = overrides; - - let managed_config_path = - managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)); - - let user_config_path = codex_home.join(CONFIG_TOML_FILE); - let user_config = read_config_from_path(&user_config_path, true).await?; - let managed_config = read_config_from_path(&managed_config_path, false).await?; - - #[cfg(target_os = "macos")] - let managed_preferences = - load_managed_admin_config_layer(managed_preferences_base64.as_deref()).await?; - - #[cfg(not(target_os = "macos"))] - let managed_preferences = load_managed_admin_config_layer(None).await?; - - Ok(LoadedConfigLayers { - base: user_config.unwrap_or_else(default_empty_table), - managed_config, - managed_preferences, + Ok(ConfigLayerStack { + user: ConfigLayerEntry::new( + ConfigLayerName::User, + codex_home.join(CONFIG_TOML_FILE), + layers.base, + ), + session_flags: ConfigLayerEntry::new( + ConfigLayerName::SessionFlags, + PathBuf::from(SESSION_FLAGS_SOURCE), + cli_overrides, + ), + system: layers.managed_config.map(|cfg| { + ConfigLayerEntry::new(ConfigLayerName::System, managed_config_path.clone(), cfg) + }), + mdm: layers + .managed_preferences + .map(|cfg| ConfigLayerEntry::new(ConfigLayerName::Mdm, PathBuf::from(MDM_SOURCE), cfg)), }) } - -async fn read_config_from_path( - path: &Path, - log_missing_as_info: bool, -) -> io::Result> { - match fs::read_to_string(path).await { - Ok(contents) => match toml::from_str::(&contents) { - Ok(value) => Ok(Some(value)), - Err(err) => { - tracing::error!("Failed to parse {}: {err}", path.display()); - Err(io::Error::new(io::ErrorKind::InvalidData, err)) - } - }, - Err(err) if err.kind() == io::ErrorKind::NotFound => { - if log_missing_as_info { - tracing::info!("{} not found, using defaults", path.display()); - } else { - tracing::debug!("{} not found", path.display()); - } - Ok(None) - } - Err(err) => { - tracing::error!("Failed to read {}: {err}", path.display()); - Err(err) - } - } -} - -/// Merge config `overlay` into `base`, giving `overlay` precedence. -pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) { - if let TomlValue::Table(overlay_table) = overlay - && let TomlValue::Table(base_table) = base - { - for (key, value) in overlay_table { - if let Some(existing) = base_table.get_mut(key) { - merge_toml_values(existing, value); - } else { - base_table.insert(key.clone(), value.clone()); - } - } - } else { - *base = overlay.clone(); - } -} - -fn managed_config_default_path(codex_home: &Path) -> PathBuf { - if let Ok(path) = std::env::var("CODEX_MANAGED_CONFIG_PATH") { - return PathBuf::from(path); - } - - #[cfg(unix)] - { - let _ = codex_home; - PathBuf::from(CODEX_MANAGED_CONFIG_SYSTEM_PATH) - } - - #[cfg(not(unix))] - { - codex_home.join("managed_config.toml") - } -} - -fn apply_managed_layers(layers: LoadedConfigLayers) -> TomlValue { - let LoadedConfigLayers { - mut base, - managed_config, - managed_preferences, - } = layers; - - for overlay in [managed_config, managed_preferences].into_iter().flatten() { - merge_toml_values(&mut base, &overlay); - } - - base -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[tokio::test] - async fn merges_managed_config_layer_on_top() { - let tmp = tempdir().expect("tempdir"); - let managed_path = tmp.path().join("managed_config.toml"); - - std::fs::write( - tmp.path().join(CONFIG_TOML_FILE), - r#"foo = 1 - -[nested] -value = "base" -"#, - ) - .expect("write base"); - std::fs::write( - &managed_path, - r#"foo = 2 - -[nested] -value = "managed_config" -extra = true -"#, - ) - .expect("write managed config"); - - let overrides = LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - }; - - let loaded = load_config_as_toml_with_overrides(tmp.path(), overrides) - .await - .expect("load config"); - let table = loaded.as_table().expect("top-level table expected"); - - assert_eq!(table.get("foo"), Some(&TomlValue::Integer(2))); - let nested = table - .get("nested") - .and_then(|v| v.as_table()) - .expect("nested"); - assert_eq!( - nested.get("value"), - Some(&TomlValue::String("managed_config".to_string())) - ); - assert_eq!(nested.get("extra"), Some(&TomlValue::Boolean(true))); - } - - #[tokio::test] - async fn returns_empty_when_all_layers_missing() { - let tmp = tempdir().expect("tempdir"); - let managed_path = tmp.path().join("managed_config.toml"); - let overrides = LoaderOverrides { - managed_config_path: Some(managed_path), - #[cfg(target_os = "macos")] - managed_preferences_base64: None, - }; - - let layers = load_config_layers_with_overrides(tmp.path(), overrides) - .await - .expect("load layers"); - let base_table = layers.base.as_table().expect("base table expected"); - assert!( - base_table.is_empty(), - "expected empty base layer when configs missing" - ); - assert!( - layers.managed_config.is_none(), - "managed config layer should be absent when file missing" - ); - - #[cfg(not(target_os = "macos"))] - { - let loaded = load_config_as_toml(tmp.path()).await.expect("load config"); - let table = loaded.as_table().expect("top-level table expected"); - assert!( - table.is_empty(), - "expected empty table when configs missing" - ); - } - } - - #[cfg(target_os = "macos")] - #[tokio::test] - async fn managed_preferences_take_highest_precedence() { - use base64::Engine; - - let managed_payload = r#" -[nested] -value = "managed" -flag = false -"#; - let encoded = base64::prelude::BASE64_STANDARD.encode(managed_payload.as_bytes()); - let tmp = tempdir().expect("tempdir"); - let managed_path = tmp.path().join("managed_config.toml"); - - std::fs::write( - tmp.path().join(CONFIG_TOML_FILE), - r#"[nested] -value = "base" -"#, - ) - .expect("write base"); - std::fs::write( - &managed_path, - r#"[nested] -value = "managed_config" -flag = true -"#, - ) - .expect("write managed config"); - - let overrides = LoaderOverrides { - managed_config_path: Some(managed_path), - managed_preferences_base64: Some(encoded), - }; - - let loaded = load_config_as_toml_with_overrides(tmp.path(), overrides) - .await - .expect("load config"); - let nested = loaded - .get("nested") - .and_then(|v| v.as_table()) - .expect("nested table"); - assert_eq!( - nested.get("value"), - Some(&TomlValue::String("managed".to_string())) - ); - assert_eq!(nested.get("flag"), Some(&TomlValue::Boolean(false))); - } -} diff --git a/codex-rs/core/src/config_loader/overrides.rs b/codex-rs/core/src/config_loader/overrides.rs new file mode 100644 index 000000000..e2ae6375a --- /dev/null +++ b/codex-rs/core/src/config_loader/overrides.rs @@ -0,0 +1,55 @@ +use toml::Value as TomlValue; + +pub(super) fn default_empty_table() -> TomlValue { + TomlValue::Table(Default::default()) +} + +pub(super) fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue { + let mut root = default_empty_table(); + for (path, value) in cli_overrides { + apply_toml_override(&mut root, path, value.clone()); + } + root +} + +/// Apply a single dotted-path override onto a TOML value. +fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) { + use toml::value::Table; + + let mut current = root; + let mut segments_iter = path.split('.').peekable(); + + while let Some(segment) = segments_iter.next() { + let is_last = segments_iter.peek().is_none(); + + if is_last { + match current { + TomlValue::Table(table) => { + table.insert(segment.to_string(), value); + } + _ => { + let mut table = Table::new(); + table.insert(segment.to_string(), value); + *current = TomlValue::Table(table); + } + } + return; + } + + match current { + TomlValue::Table(table) => { + current = table + .entry(segment.to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + } + _ => { + *current = TomlValue::Table(Table::new()); + if let TomlValue::Table(tbl) = current { + current = tbl + .entry(segment.to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + } + } + } + } +} diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs new file mode 100644 index 000000000..e23cc243c --- /dev/null +++ b/codex-rs/core/src/config_loader/state.rs @@ -0,0 +1,128 @@ +use super::fingerprint::record_origins; +use super::fingerprint::version_for_toml; +use super::merge::merge_toml_values; +use codex_app_server_protocol::ConfigLayer; +use codex_app_server_protocol::ConfigLayerMetadata; +use codex_app_server_protocol::ConfigLayerName; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use toml::Value as TomlValue; + +#[derive(Debug, Default, Clone)] +pub struct LoaderOverrides { + pub managed_config_path: Option, + #[cfg(target_os = "macos")] + pub managed_preferences_base64: Option, +} + +#[derive(Debug, Clone)] +pub struct ConfigLayerEntry { + pub name: ConfigLayerName, + pub source: PathBuf, + pub config: TomlValue, + pub version: String, +} + +impl ConfigLayerEntry { + pub fn new(name: ConfigLayerName, source: PathBuf, config: TomlValue) -> Self { + let version = version_for_toml(&config); + Self { + name, + source, + config, + version, + } + } + + pub fn metadata(&self) -> ConfigLayerMetadata { + ConfigLayerMetadata { + name: self.name.clone(), + source: self.source.display().to_string(), + version: self.version.clone(), + } + } + + pub fn as_layer(&self) -> ConfigLayer { + ConfigLayer { + name: self.name.clone(), + source: self.source.display().to_string(), + version: self.version.clone(), + config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null), + } + } +} + +#[derive(Debug, Clone)] +pub struct ConfigLayerStack { + pub user: ConfigLayerEntry, + pub session_flags: ConfigLayerEntry, + pub system: Option, + pub mdm: Option, +} + +impl ConfigLayerStack { + pub fn with_user_config(&self, user_config: TomlValue) -> Self { + Self { + user: ConfigLayerEntry::new( + self.user.name.clone(), + self.user.source.clone(), + user_config, + ), + session_flags: self.session_flags.clone(), + system: self.system.clone(), + mdm: self.mdm.clone(), + } + } + + pub fn effective_config(&self) -> TomlValue { + let mut merged = self.user.config.clone(); + merge_toml_values(&mut merged, &self.session_flags.config); + if let Some(system) = &self.system { + merge_toml_values(&mut merged, &system.config); + } + if let Some(mdm) = &self.mdm { + merge_toml_values(&mut merged, &mdm.config); + } + merged + } + + pub fn origins(&self) -> HashMap { + let mut origins = HashMap::new(); + let mut path = Vec::new(); + + record_origins( + &self.user.config, + &self.user.metadata(), + &mut path, + &mut origins, + ); + record_origins( + &self.session_flags.config, + &self.session_flags.metadata(), + &mut path, + &mut origins, + ); + if let Some(system) = &self.system { + record_origins(&system.config, &system.metadata(), &mut path, &mut origins); + } + if let Some(mdm) = &self.mdm { + record_origins(&mdm.config, &mdm.metadata(), &mut path, &mut origins); + } + + origins + } + + pub fn layers_high_to_low(&self) -> Vec { + let mut layers = Vec::new(); + if let Some(mdm) = &self.mdm { + layers.push(mdm.as_layer()); + } + if let Some(system) = &self.system { + layers.push(system.as_layer()); + } + layers.push(self.session_flags.as_layer()); + layers.push(self.user.as_layer()); + layers + } +} diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs new file mode 100644 index 000000000..121ef736d --- /dev/null +++ b/codex-rs/core/src/config_loader/tests.rs @@ -0,0 +1,138 @@ +use super::LoaderOverrides; +use super::load_config_layers_state; +use crate::config::CONFIG_TOML_FILE; +use tempfile::tempdir; +use toml::Value as TomlValue; + +#[tokio::test] +async fn merges_managed_config_layer_on_top() { + let tmp = tempdir().expect("tempdir"); + let managed_path = tmp.path().join("managed_config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#"foo = 1 + +[nested] +value = "base" +"#, + ) + .expect("write base"); + std::fs::write( + &managed_path, + r#"foo = 2 + +[nested] +value = "managed_config" +extra = true +"#, + ) + .expect("write managed config"); + + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }; + + let state = load_config_layers_state(tmp.path(), &[] as &[(String, TomlValue)], overrides) + .await + .expect("load config"); + let loaded = state.effective_config(); + let table = loaded.as_table().expect("top-level table expected"); + + assert_eq!(table.get("foo"), Some(&TomlValue::Integer(2))); + let nested = table + .get("nested") + .and_then(|v| v.as_table()) + .expect("nested"); + assert_eq!( + nested.get("value"), + Some(&TomlValue::String("managed_config".to_string())) + ); + assert_eq!(nested.get("extra"), Some(&TomlValue::Boolean(true))); +} + +#[tokio::test] +async fn returns_empty_when_all_layers_missing() { + let tmp = tempdir().expect("tempdir"); + let managed_path = tmp.path().join("managed_config.toml"); + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }; + + let layers = load_config_layers_state(tmp.path(), &[] as &[(String, TomlValue)], overrides) + .await + .expect("load layers"); + let base_table = layers.user.config.as_table().expect("base table expected"); + assert!( + base_table.is_empty(), + "expected empty base layer when configs missing" + ); + assert!( + layers.system.is_none(), + "managed config layer should be absent when file missing" + ); + + #[cfg(not(target_os = "macos"))] + { + let effective = layers.effective_config(); + let table = effective.as_table().expect("top-level table expected"); + assert!( + table.is_empty(), + "expected empty table when configs missing" + ); + } +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn managed_preferences_take_highest_precedence() { + use base64::Engine; + + let managed_payload = r#" +[nested] +value = "managed" +flag = false +"#; + let encoded = base64::prelude::BASE64_STANDARD.encode(managed_payload.as_bytes()); + let tmp = tempdir().expect("tempdir"); + let managed_path = tmp.path().join("managed_config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#"[nested] +value = "base" +"#, + ) + .expect("write base"); + std::fs::write( + &managed_path, + r#"[nested] +value = "managed_config" +flag = true +"#, + ) + .expect("write managed config"); + + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path), + managed_preferences_base64: Some(encoded), + }; + + let state = load_config_layers_state(tmp.path(), &[] as &[(String, TomlValue)], overrides) + .await + .expect("load config"); + let loaded = state.effective_config(); + let nested = loaded + .get("nested") + .and_then(|v| v.as_table()) + .expect("nested table"); + assert_eq!( + nested.get("value"), + Some(&TomlValue::String("managed".to_string())) + ); + assert_eq!(nested.get("flag"), Some(&TomlValue::Boolean(false))); +}