From 78da3fdc38d1c86fc909c5db4c0fb583cc361f3a Mon Sep 17 00:00:00 2001 From: Brent Traut Date: Tue, 19 May 2026 11:21:43 -0700 Subject: [PATCH] core: reload override config at runtime --- codex-rs/core/src/network_proxy_loader.rs | 43 +++++-- .../core/src/network_proxy_loader_tests.rs | 29 +++++ codex-rs/core/src/session/mod.rs | 53 +++++++-- codex-rs/core/src/session/tests.rs | 106 ++++++++++++++++++ 4 files changed, 213 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 1c49be2daf..738849f1ef 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -7,6 +7,7 @@ use anyhow::Context; use anyhow::Result; use async_trait::async_trait; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::CONFIG_OVERRIDE_TOML_FILE; use codex_config::CONFIG_TOML_FILE; use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; @@ -81,25 +82,49 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec Vec { - stack - .get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ false, - ) + let layers = stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ); + let mut layer_mtimes = layers .iter() .filter_map(|layer| { let path = match &layer.name { - ConfigLayerSource::System { file } => Some(file.clone()), - ConfigLayerSource::User { file, .. } => Some(file.clone()), + ConfigLayerSource::System { file } | ConfigLayerSource::SystemOverride { file } => { + Some(file.clone()) + } + ConfigLayerSource::User { file, .. } | ConfigLayerSource::UserOverride { file } => { + Some(file.clone()) + } ConfigLayerSource::Project { dot_codex_folder } => { Some(dot_codex_folder.join(CONFIG_TOML_FILE)) } + ConfigLayerSource::ProjectOverride { dot_codex_folder } => { + Some(dot_codex_folder.join(CONFIG_OVERRIDE_TOML_FILE)) + } ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.clone()), _ => None, }; path.map(LayerMtime::new) }) - .collect() + .collect::>(); + + let watched_paths = layer_mtimes + .iter() + .map(|layer| layer.path.clone()) + .collect::>(); + let optional_override_paths = layers + .into_iter() + .filter_map(|layer| match &layer.name { + ConfigLayerSource::System { file } | ConfigLayerSource::User { file, .. } => file + .parent() + .map(|parent| parent.join(CONFIG_OVERRIDE_TOML_FILE)), + _ => None, + }) + .filter(|path| !watched_paths.contains(path)) + .map(LayerMtime::new); + layer_mtimes.extend(optional_override_paths); + layer_mtimes } fn enforce_trusted_constraints( @@ -256,7 +281,9 @@ fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool { matches!( layer, ConfigLayerSource::User { .. } + | ConfigLayerSource::UserOverride { .. } | ConfigLayerSource::Project { .. } + | ConfigLayerSource::ProjectOverride { .. } | ConfigLayerSource::SessionFlags ) } diff --git a/codex-rs/core/src/network_proxy_loader_tests.rs b/codex-rs/core/src/network_proxy_loader_tests.rs index b5adb935ec..858e388776 100644 --- a/codex-rs/core/src/network_proxy_loader_tests.rs +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -3,7 +3,36 @@ use super::*; use codex_execpolicy::Decision; use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use tempfile::tempdir; + +#[tokio::test] +async fn network_proxy_reloader_notices_new_user_override_files() { + let temp = tempdir().expect("create temp dir"); + let base_file = + AbsolutePathBuf::try_from(temp.path().join(CONFIG_TOML_FILE)).expect("base path"); + let override_file = AbsolutePathBuf::try_from(temp.path().join(CONFIG_OVERRIDE_TOML_FILE)) + .expect("override path"); + std::fs::write(base_file.as_path(), "").expect("write base config"); + let stack = ConfigLayerStack::new( + vec![codex_config::ConfigLayerEntry::new( + ConfigLayerSource::User { + file: base_file, + profile: None, + }, + toml::Value::Table(Default::default()), + )], + codex_config::ConfigRequirements::default(), + codex_config::ConfigRequirementsToml::default(), + ) + .expect("stack"); + let reloader = MtimeConfigReloader::new(collect_layer_mtimes(&stack)); + + assert!(!reloader.needs_reload().await); + std::fs::write(override_file.as_path(), "[network_proxy]\n").expect("write override"); + assert!(reloader.needs_reload().await); +} #[test] fn higher_precedence_profile_network_overlays_domain_entries() { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8f0d2f1c01..14abea73c4 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -180,6 +180,7 @@ use crate::config::resolve_web_search_mode_for_turn; use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; +use codex_config::CONFIG_OVERRIDE_TOML_FILE; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStackOrdering; @@ -1512,6 +1513,11 @@ impl Session { // // Prefer `refresh_runtime_config()` when the host can already provide a materialized // config snapshot. This file-based path exists for legacy local reload flows. + enum UserConfigReloadLayer { + Base(AbsolutePathBuf), + Override(AbsolutePathBuf), + } + let config_toml_paths = { let state = self.state.lock().await; let config = &state.session_configuration.original_config_do_not_use; @@ -1523,25 +1529,47 @@ impl Session { ) .into_iter() .filter_map(|layer| match &layer.name { - ConfigLayerSource::User { file, .. } => Some(file.clone()), + ConfigLayerSource::User { file, .. } => { + Some(UserConfigReloadLayer::Base(file.clone())) + } + ConfigLayerSource::UserOverride { file } => { + Some(UserConfigReloadLayer::Override(file.clone())) + } _ => None, }) .collect::>(); + let override_path = state + .session_configuration + .codex_home + .join(CONFIG_OVERRIDE_TOML_FILE); + let has_override_path = user_config_paths.iter().any(|layer| { + matches!( + layer, + UserConfigReloadLayer::Override(path) if path == &override_path + ) + }); + let mut user_config_paths = user_config_paths; + if !has_override_path && override_path.as_path().exists() { + user_config_paths.push(UserConfigReloadLayer::Override(override_path)); + } if user_config_paths.is_empty() { - vec![ + vec![UserConfigReloadLayer::Base( state .session_configuration .codex_home .join(CONFIG_TOML_FILE), - ] + )] } else { user_config_paths } }; let mut reloaded_user_configs = Vec::with_capacity(config_toml_paths.len()); - for config_toml_path in config_toml_paths { - let user_config = match std::fs::read_to_string(&config_toml_path) { + for reload_layer in config_toml_paths { + let config_toml_path = match &reload_layer { + UserConfigReloadLayer::Base(path) | UserConfigReloadLayer::Override(path) => path, + }; + let user_config = match std::fs::read_to_string(config_toml_path.as_path()) { Ok(contents) => match toml::from_str::(&contents) { Ok(config) => config, Err(err) => { @@ -1557,16 +1585,21 @@ impl Session { return; } }; - reloaded_user_configs.push((config_toml_path, user_config)); + reloaded_user_configs.push((reload_layer, user_config)); } let next_config = { let state = self.state.lock().await; let mut config = (*state.session_configuration.original_config_do_not_use).clone(); - for (config_toml_path, user_config) in reloaded_user_configs { - config.config_layer_stack = config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); + for (reload_layer, user_config) in reloaded_user_configs { + config.config_layer_stack = match reload_layer { + UserConfigReloadLayer::Base(config_toml_path) => config + .config_layer_stack + .with_user_config(&config_toml_path, user_config), + UserConfigReloadLayer::Override(config_toml_path) => config + .config_layer_stack + .with_user_override_config(&config_toml_path, user_config), + }; } config.tool_suggest = resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 88e9092da3..a45b820691 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1269,6 +1269,112 @@ async fn reload_user_config_layer_updates_base_and_selected_profile_layers() { ); } +#[tokio::test] +async fn reload_user_config_layer_refreshes_user_override_layers() { + let (session, _turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + let config_toml_path = codex_home.join(CONFIG_TOML_FILE); + let override_path = codex_home.join(codex_config::CONFIG_OVERRIDE_TOML_FILE); + std::fs::write(&config_toml_path, "model = \"base\"\n").expect("write base config"); + std::fs::write(&override_path, "model = \"override-old\"\n").expect("write override"); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.to_path_buf()) + .build() + .await + .expect("load override config"); + { + let mut state = session.state.lock().await; + state.session_configuration.original_config_do_not_use = Arc::new(config); + } + std::fs::write(&override_path, "model = \"override-new\"\n").expect("update override"); + + session.reload_user_config_layer().await; + + let config = session.get_config().await; + assert_eq!( + config + .config_layer_stack + .effective_user_config() + .expect("merged user config") + .get("model") + .and_then(toml::Value::as_str), + Some("override-new") + ); +} + +#[tokio::test] +async fn reload_user_config_layer_discovers_new_user_override_layers() { + let (session, _turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + let config_toml_path = codex_home.join(CONFIG_TOML_FILE); + let override_path = codex_home.join(codex_config::CONFIG_OVERRIDE_TOML_FILE); + std::fs::write(&config_toml_path, "model = \"base\"\n").expect("write base config"); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.to_path_buf()) + .build() + .await + .expect("load base config"); + { + let mut state = session.state.lock().await; + state.session_configuration.original_config_do_not_use = Arc::new(config); + } + std::fs::write(&override_path, "model = \"override-new\"\n").expect("write override"); + + session.reload_user_config_layer().await; + + let config = session.get_config().await; + assert_eq!( + config + .config_layer_stack + .effective_user_config() + .expect("merged user config") + .get("model") + .and_then(toml::Value::as_str), + Some("override-new") + ); +} + +#[tokio::test] +async fn reload_user_config_layer_keeps_selected_non_profile_user_above_new_override() { + let (session, _turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + let base_config_path = codex_home.join(CONFIG_TOML_FILE); + let override_path = codex_home.join(codex_config::CONFIG_OVERRIDE_TOML_FILE); + let selected_config_path = codex_home.join("selected.toml"); + std::fs::write(&base_config_path, "model = \"base\"\n").expect("write base config"); + std::fs::write(&selected_config_path, "model = \"selected\"\n").expect("write selected config"); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.to_path_buf()) + .loader_overrides(LoaderOverrides { + user_config_path: Some(selected_config_path.abs()), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .build() + .await + .expect("load selected config"); + { + let mut state = session.state.lock().await; + state.session_configuration.original_config_do_not_use = Arc::new(config); + } + std::fs::write(&override_path, "model = \"override\"\n").expect("write override"); + + session.reload_user_config_layer().await; + + let config = session.get_config().await; + assert_eq!( + config + .config_layer_stack + .effective_user_config() + .expect("merged user config") + .get("model") + .and_then(toml::Value::as_str), + Some("selected") + ); +} + #[tokio::test] async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { let session = make_session_with_config(|config| {