Compare commits

...

5 Commits

Author SHA1 Message Date
Ahmed Ibrahim
b9ee961fe2 Simplify LenientEnum unknown values
Store unknown enum inputs as the original TOML value and let the sanitizer decide whether to warn and remove string values.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 02:44:42 +03:00
Ahmed Ibrahim
12165aeb42 Use LenientEnum for config enum sanitization
Route enum value classification through a LenientEnum wrapper so known values, unknown string values, and non-string shapes are handled explicitly before ConfigToml deserialization.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 02:42:28 +03:00
Ahmed Ibrahim
f166592739 Use explicit registry for lenient config enums
Replace the schema-derived config enum sanitizer with a small generic sanitizer and explicit path registry. Unknown string values still produce collected startup warnings and are removed before ConfigToml deserialization.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 02:40:02 +03:00
Ahmed Ibrahim
a7259ae3a8 Derive config enum sanitizer from schema
Avoid maintaining a hand-written list of enum-typed config paths by walking the ConfigToml JSON schema and discovering string enum fields. Tagged enum fields remove their parent table so invalid variants still behave as absent config.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 02:21:28 +03:00
Ahmed Ibrahim
4b2cd0c149 Tolerate unknown config enum values
Unknown string enum values in config.toml now behave like absent values and produce startup warnings instead of failing config load. The sanitizer runs per config layer before merge so lower-precedence valid values can still apply.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 02:16:26 +03:00
6 changed files with 413 additions and 3 deletions

View File

@@ -24,6 +24,7 @@ mod state;
mod thread_config;
mod tui_keymap;
pub mod types;
mod unknown_enum_values;
pub const CONFIG_TOML_FILE: &str = "config.toml";
@@ -126,3 +127,4 @@ pub use thread_config::ThreadConfigLoader;
pub use thread_config::ThreadConfigSource;
pub use thread_config::UserThreadConfig;
pub use toml::Value as TomlValue;
pub use unknown_enum_values::sanitize_unknown_enum_values;

View File

@@ -1,5 +1,6 @@
use crate::config_requirements::ConfigRequirements;
use crate::config_requirements::ConfigRequirementsToml;
use crate::unknown_enum_values::sanitize_unknown_enum_values;
use super::fingerprint::record_origins;
use super::fingerprint::version_for_toml;
@@ -216,6 +217,18 @@ impl ConfigLayerStack {
self.startup_warnings.as_deref()
}
pub fn sanitize_unknown_enum_values(&mut self) -> Vec<String> {
let mut warnings = Vec::new();
for layer in &mut self.layers {
let layer_warnings = sanitize_unknown_enum_values(&mut layer.config);
if !layer_warnings.is_empty() {
layer.version = version_for_toml(&layer.config);
warnings.extend(layer_warnings);
}
}
warnings
}
/// Returns the raw user config layer, if any.
///
/// This does not merge other config layers or apply any requirements.

View File

@@ -0,0 +1,347 @@
use crate::config_toml::RealtimeTransport;
use crate::config_toml::RealtimeWsMode;
use crate::config_toml::ThreadStoreToml;
use crate::types::ApprovalsReviewer;
use crate::types::AuthCredentialsStoreMode;
use crate::types::HistoryPersistence;
use crate::types::NotificationCondition;
use crate::types::NotificationMethod;
use crate::types::OAuthCredentialsStoreMode;
use crate::types::UriBasedFileOpener;
use crate::types::WindowsSandboxModeToml;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use toml::Value as TomlValue;
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LenientEnum<T> {
Known(T),
Unknown(TomlValue),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PathSegment {
Key(&'static str),
MapValue,
}
macro_rules! sanitize_config_enums {
($root:expr, $warnings:expr, {$($ty:ty => [$($path:tt).+],)+}) => {
$(
sanitize_enum::<$ty>($root, &path_segments!($($path).+), $warnings);
)+
};
}
macro_rules! path_segments {
($($path:tt).+) => {
vec![$(path_segment!($path)),+]
};
}
macro_rules! path_segment {
(*) => {
PathSegment::MapValue
};
(r#type) => {
PathSegment::Key("type")
};
($key:ident) => {
PathSegment::Key(stringify!($key))
};
}
/// Removes unrecognized string values from enum-typed config fields.
///
/// This keeps older clients from failing to load a config written by a newer
/// client that knows about a newly added enum variant. The field is treated as
/// unset, so the normal default/resolution path applies. Non-string shape
/// errors are left intact and still fail during typed deserialization.
pub fn sanitize_unknown_enum_values(root: &mut TomlValue) -> Vec<String> {
let mut warnings = Vec::new();
sanitize_config_enums!(root, &mut warnings, {
AskForApproval => [approval_policy],
ApprovalsReviewer => [approvals_reviewer],
SandboxMode => [sandbox_mode],
ForcedLoginMethod => [forced_login_method],
AuthCredentialsStoreMode => [cli_auth_credentials_store],
OAuthCredentialsStoreMode => [mcp_oauth_credentials_store],
UriBasedFileOpener => [file_opener],
ReasoningEffort => [model_reasoning_effort],
ReasoningEffort => [plan_mode_reasoning_effort],
ReasoningSummary => [model_reasoning_summary],
Verbosity => [model_verbosity],
Personality => [personality],
ServiceTier => [service_tier],
WebSearchMode => [web_search],
HistoryPersistence => [history.persistence],
NotificationMethod => [tui.notification_method],
NotificationCondition => [tui.notification_condition],
AltScreenMode => [tui.alternate_screen],
WebSearchContextSize => [tools.web_search.context_size],
TrustLevel => [projects.*.trust_level],
RealtimeWsMode => [realtime.r#type],
RealtimeTransport => [realtime.transport],
WindowsSandboxModeToml => [windows.sandbox],
AskForApproval => [profiles.*.approval_policy],
ApprovalsReviewer => [profiles.*.approvals_reviewer],
SandboxMode => [profiles.*.sandbox_mode],
ReasoningEffort => [profiles.*.model_reasoning_effort],
ReasoningEffort => [profiles.*.plan_mode_reasoning_effort],
ReasoningSummary => [profiles.*.model_reasoning_summary],
Verbosity => [profiles.*.model_verbosity],
Personality => [profiles.*.personality],
ServiceTier => [profiles.*.service_tier],
WebSearchMode => [profiles.*.web_search],
WebSearchContextSize => [profiles.*.tools.web_search.context_size],
});
sanitize_tagged_enum::<ThreadStoreToml>(
root,
&path_segments!(experimental_thread_store),
"type",
&mut warnings,
);
warnings
}
fn sanitize_enum<T>(root: &mut TomlValue, path: &[PathSegment], warnings: &mut Vec<String>)
where
T: DeserializeOwned,
{
let paths = matching_paths(root, path);
for value_path in paths {
let Some(value) = value_at_path(root, &value_path).cloned() else {
continue;
};
match value.try_into::<LenientEnum<T>>() {
Ok(LenientEnum::Known(_)) => {}
Ok(LenientEnum::Unknown(TomlValue::String(raw_value))) => {
warn_and_remove(root, &value_path, &value_path, &raw_value, warnings);
}
Ok(LenientEnum::Unknown(_)) | Err(_) => {}
};
}
}
fn sanitize_tagged_enum<T>(
root: &mut TomlValue,
path: &[PathSegment],
tag_key: &'static str,
warnings: &mut Vec<String>,
) where
T: DeserializeOwned,
{
let parent_paths = matching_paths(root, path);
for parent_path in parent_paths {
let mut tag_path = parent_path.clone();
tag_path.push(tag_key.to_string());
let Some(value) = value_at_path(root, &parent_path).cloned() else {
continue;
};
match value.try_into::<LenientEnum<T>>() {
Ok(LenientEnum::Known(_)) => {}
Ok(LenientEnum::Unknown(table_value)) => {
let Some(raw_value) = table_value
.get(tag_key)
.and_then(TomlValue::as_str)
.map(str::to_string)
else {
continue;
};
warn_and_remove(root, &tag_path, &parent_path, &raw_value, warnings);
}
Err(_) => {}
};
}
}
fn warn_and_remove(
root: &mut TomlValue,
value_path: &[String],
remove_path: &[String],
raw_value: &str,
warnings: &mut Vec<String>,
) {
let field_path = value_path.join(".");
warnings.push(format!(
"Ignoring unrecognized config value `{raw_value}` for `{field_path}`; using the default for this setting."
));
tracing::warn!(
field = field_path,
value = raw_value,
"ignoring unrecognized config enum value"
);
remove_value_at_path(root, remove_path);
}
fn matching_paths(root: &TomlValue, path: &[PathSegment]) -> Vec<Vec<String>> {
let mut matches = Vec::new();
collect_matching_paths(root, path, &mut Vec::new(), &mut matches);
matches
}
fn collect_matching_paths(
current: &TomlValue,
remaining_path: &[PathSegment],
matched_path: &mut Vec<String>,
matches: &mut Vec<Vec<String>>,
) {
let Some((segment, remaining_path)) = remaining_path.split_first() else {
matches.push(matched_path.clone());
return;
};
match segment {
PathSegment::Key(key) => {
let Some(next) = current.get(*key) else {
return;
};
matched_path.push((*key).to_string());
collect_matching_paths(next, remaining_path, matched_path, matches);
matched_path.pop();
}
PathSegment::MapValue => {
let Some(table) = current.as_table() else {
return;
};
for (key, next) in table {
matched_path.push(key.clone());
collect_matching_paths(next, remaining_path, matched_path, matches);
matched_path.pop();
}
}
}
}
fn value_at_path<'a>(root: &'a TomlValue, path: &[String]) -> Option<&'a TomlValue> {
let mut current = root;
for segment in path {
current = current.get(segment)?;
}
Some(current)
}
fn remove_value_at_path(root: &mut TomlValue, path: &[String]) {
let Some((last_segment, parent_path)) = path.split_last() else {
return;
};
let Some(parent) = value_at_path_mut(root, parent_path) else {
return;
};
let Some(table) = parent.as_table_mut() else {
return;
};
table.remove(last_segment);
}
fn value_at_path_mut<'a>(root: &'a mut TomlValue, path: &[String]) -> Option<&'a mut TomlValue> {
let mut current = root;
for segment in path {
current = current.get_mut(segment)?;
}
Some(current)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config_toml::ConfigToml;
use pretty_assertions::assert_eq;
#[test]
fn unknown_config_enum_values_are_removed_with_warnings() {
let mut value = r#"
approval_policy = "maybe"
model = "gpt-5"
service_tier = "ultrafast"
[profiles.work]
model = "gpt-5-codex"
model_reasoning_effort = "maximum"
[projects."/tmp/project"]
trust_level = "somewhat"
"#
.parse::<TomlValue>()
.expect("config should parse as toml");
let warnings = sanitize_unknown_enum_values(&mut value);
let expected_value = r#"
model = "gpt-5"
[profiles.work]
model = "gpt-5-codex"
[projects."/tmp/project"]
"#
.parse::<TomlValue>()
.expect("expected config should parse as toml");
let expected_warnings = vec![
"Ignoring unrecognized config value `maybe` for `approval_policy`; using the default for this setting.".to_string(),
"Ignoring unrecognized config value `ultrafast` for `service_tier`; using the default for this setting.".to_string(),
"Ignoring unrecognized config value `somewhat` for `projects./tmp/project.trust_level`; using the default for this setting.".to_string(),
"Ignoring unrecognized config value `maximum` for `profiles.work.model_reasoning_effort`; using the default for this setting.".to_string(),
];
assert_eq!((value, warnings), (expected_value, expected_warnings));
}
#[test]
fn unknown_config_enum_values_allow_config_toml_deserialization() {
let mut value = r#"
model = "gpt-5"
service_tier = "ultrafast"
"#
.parse::<TomlValue>()
.expect("config should parse as toml");
let warnings = sanitize_unknown_enum_values(&mut value);
let config: ConfigToml = value.try_into().expect("config should deserialize");
let expected_warnings = vec![
"Ignoring unrecognized config value `ultrafast` for `service_tier`; using the default for this setting.".to_string(),
];
assert_eq!((config.service_tier, warnings), (None, expected_warnings));
}
#[test]
fn unknown_tagged_enum_removes_the_parent_field() {
let mut value = r#"
model = "gpt-5"
[experimental_thread_store]
type = "future_store"
endpoint = "https://example.com"
"#
.parse::<TomlValue>()
.expect("config should parse as toml");
let warnings = sanitize_unknown_enum_values(&mut value);
let expected_value = r#"
model = "gpt-5"
"#
.parse::<TomlValue>()
.expect("expected config should parse as toml");
let expected_warnings = vec![
"Ignoring unrecognized config value `future_store` for `experimental_thread_store.type`; using the default for this setting.".to_string(),
];
assert_eq!((value, warnings), (expected_value, expected_warnings));
}
}

View File

@@ -35,6 +35,7 @@ use codex_config::loader::load_config_layers_state;
use codex_config::loader::project_trust_key;
use codex_config::profile_toml::ConfigProfile;
use codex_config::sandbox_mode_requirement_for_permission_profile;
use codex_config::sanitize_unknown_enum_values;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::AuthCredentialsStoreMode;
use codex_config::types::DEFAULT_OTEL_ENVIRONMENT;
@@ -951,7 +952,7 @@ impl ConfigBuilder {
None => AbsolutePathBuf::current_dir()?,
};
harness_overrides.cwd = Some(cwd.to_path_buf());
let config_layer_stack = load_config_layers_state(
let mut config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(cwd),
@@ -963,6 +964,7 @@ impl ConfigBuilder {
.unwrap_or(&codex_config::NoopThreadConfigLoader),
)
.await?;
let unknown_enum_warnings = config_layer_stack.sanitize_unknown_enum_values();
let merged_toml = config_layer_stack.effective_config();
// Note that each layer in ConfigLayerStack should have resolved
@@ -1017,20 +1019,25 @@ impl ConfigBuilder {
lock_config_layer_stack,
)
.await?;
config
.startup_warnings
.splice(0..0, unknown_enum_warnings.clone());
config.config_lock_toml = Some(Arc::new(expected_lock_config));
config.config_lock_allow_codex_version_mismatch = allow_codex_version_mismatch;
config.config_lock_save_fields_resolved_from_model_catalog =
save_fields_resolved_from_model_catalog;
return Ok(config);
}
Config::load_config_with_layer_stack(
let mut config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
config_toml,
harness_overrides,
codex_home,
config_layer_stack,
)
.await
.await?;
config.startup_warnings.splice(0..0, unknown_enum_warnings);
Ok(config)
}
#[cfg(test)]
@@ -1159,6 +1166,7 @@ impl Config {
})?;
let cli_layer = codex_config::build_cli_overrides_layer(&cli_overrides);
codex_config::merge_toml_values(&mut merged, &cli_layer);
sanitize_unknown_enum_values(&mut merged);
let codex_home = AbsolutePathBuf::from_absolute_path_checked(codex_home)?;
let config_toml = deserialize_config_toml_with_base(merged, &codex_home)?;
Self::load_config_with_layer_stack(
@@ -1225,6 +1233,8 @@ pub async fn load_config_as_toml_with_cli_and_loader_overrides(
)
.await?;
let mut config_layer_stack = config_layer_stack;
config_layer_stack.sanitize_unknown_enum_values();
let merged_toml = config_layer_stack.effective_config();
let cfg = deserialize_config_toml_with_base(merged_toml, codex_home).map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
@@ -1241,6 +1251,8 @@ pub fn deserialize_config_toml_with_base(
// This guard ensures that any relative paths that is deserialized into an
// [AbsolutePathBuf] is resolved against `config_base_dir`.
let _guard = AbsolutePathBufGuard::new(config_base_dir);
let mut root_value = root_value;
sanitize_unknown_enum_values(&mut root_value);
root_value
.try_into()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))

View File

@@ -0,0 +1,35 @@
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::WarningEvent;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
const CONFIG_TOML: &str = "config.toml";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unknown_config_enum_value_emits_startup_warning_and_uses_default() {
let server = start_mock_server().await;
let mut builder = test_codex().with_pre_build_hook(|home| {
std::fs::write(home.join(CONFIG_TOML), "service_tier = \"ultrafast\"\n")
.expect("seed config.toml");
});
let test = builder.build(&server).await.expect("create conversation");
let warning = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::Warning(WarningEvent { message })
if message.contains("service_tier") && message.contains("ultrafast")
)
})
.await;
assert_eq!(None, test.config.service_tier);
assert_eq!(
EventMsg::Warning(WarningEvent {
message: "Ignoring unrecognized config value `ultrafast` for `service_tier`; using the default for this setting.".to_string(),
}),
warning
);
}

View File

@@ -44,6 +44,7 @@ mod collaboration_instructions;
mod compact;
mod compact_remote;
mod compact_resume_fork;
mod config_unknown_enum_values;
mod deprecation_notice;
mod exec;
mod exec_policy;