core: reload override config at runtime

This commit is contained in:
Brent Traut
2026-05-19 15:08:01 -07:00
parent 5caa94cb76
commit 78da3fdc38
4 changed files with 213 additions and 18 deletions
+35 -8
View File
@@ -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<LayerMtime
}
fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec<LayerMtime> {
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::<Vec<_>>();
let watched_paths = layer_mtimes
.iter()
.map(|layer| layer.path.clone())
.collect::<std::collections::HashSet<_>>();
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
)
}
@@ -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() {
+43 -10
View File
@@ -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::<Vec<_>>();
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::<toml::Value>(&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);
+106
View File
@@ -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| {