Compare commits

...

50 Commits

Author SHA1 Message Date
Ahmed Ibrahim
d98019b0ac Fix config lock warning test expectation 2026-05-07 06:46:31 +03:00
Ahmed Ibrahim
fb66343740 Keep warned enum values non-fatal 2026-05-07 06:40:16 +03:00
Ahmed Ibrahim
3f55561327 Harden invalid enum warning paths 2026-05-07 06:31:43 +03:00
Ahmed Ibrahim
dbb15289c4 Align enum warning naming 2026-05-07 06:26:17 +03:00
Ahmed Ibrahim
a21345c5f9 Make nested enum config warnings non-blocking 2026-05-07 05:50:25 +03:00
Ahmed Ibrahim
f366ac3326 Keep invalid hook types advisory 2026-05-07 05:28:26 +03:00
Ahmed Ibrahim
b487b3592d Handle tagged enum config schema branches 2026-05-07 05:07:48 +03:00
Ahmed Ibrahim
6eb417e76c Export enum config warning helper 2026-05-07 04:39:43 +03:00
Ahmed Ibrahim
e6c51d58b4 Fix enum config validation expectations 2026-05-07 04:36:37 +03:00
Ahmed Ibrahim
b3f8ccf7bb Fix config clippy lint 2026-05-07 04:28:20 +03:00
Ahmed Ibrahim
28739bb664 Add enum warning schema walk tests 2026-05-07 04:23:03 +03:00
Ahmed Ibrahim
bf035fcc0d Simplify enum warning helpers 2026-05-07 04:14:35 +03:00
Ahmed Ibrahim
6c6dfa77fb Document enum config warning walk 2026-05-07 04:12:28 +03:00
Ahmed Ibrahim
c9c16fca70 Make enum config warnings advisory 2026-05-07 04:10:11 +03:00
Ahmed Ibrahim
4d88b9961a Keep enum warning scan nonblocking 2026-05-07 03:46:39 +03:00
Ahmed Ibrahim
0e3631c931 Drive enum warnings from config schema 2026-05-07 03:36:18 +03:00
Ahmed Ibrahim
bf4737b520 Use spec loop for config enum warnings 2026-05-07 02:30:57 +03:00
Ahmed Ibrahim
bd84525f3c Add config enum warning integration test 2026-05-07 02:23:07 +03:00
Ahmed Ibrahim
e3de954486 Use default-on-error config enum warnings 2026-05-07 01:16:54 +03:00
Ahmed Ibrahim
5684d7b5bd Preserve config layer fallback for invalid enums 2026-05-07 00:33:05 +03:00
Ahmed Ibrahim
646f9baf7e Resolve config paths during lenient projection 2026-05-07 00:20:39 +03:00
Ahmed Ibrahim
a81898ca68 Harden lenient config enum warnings 2026-05-07 00:18:18 +03:00
Ahmed Ibrahim
634718a1d3 Merge origin/main into config-lenient-enum-warnings 2026-05-06 17:59:01 +03:00
Ahmed Ibrahim
85bfc885ac Share config field lists with lenient loader 2026-05-06 17:53:21 +03:00
Ahmed Ibrahim
3e4c052664 Generate lenient config mirror from field list 2026-05-06 17:31:09 +03:00
Ahmed Ibrahim
f18c4565ea Use macro for lenient enum warnings 2026-05-06 17:22:55 +03:00
Ahmed Ibrahim
2c8de33e0a Order lenient config loader by visibility 2026-05-06 17:17:22 +03:00
Ahmed Ibrahim
22ca0e68c0 Inline lenient warning wrappers 2026-05-06 17:09:32 +03:00
Ahmed Ibrahim
f4a38f2555 Keep lenient enum state explicit 2026-05-06 17:01:27 +03:00
Ahmed Ibrahim
0abc304330 Simplify intermediate enum warnings 2026-05-06 16:56:13 +03:00
Ahmed Ibrahim
73815250e5 Fix intermediate config enum warnings 2026-05-06 16:29:21 +03:00
Ahmed Ibrahim
a8ce48a160 Use intermediate config enum loader 2026-05-06 16:20:32 +03:00
Ahmed Ibrahim
e4ea70f95f Remove nested invalid config enum leaves 2026-05-06 16:05:58 +03:00
Ahmed Ibrahim
23f42ddeae Keep app server invalid config write rejection 2026-05-06 15:58:41 +03:00
Ahmed Ibrahim
e1d8a67d5d Document lenient config enum loading 2026-05-06 15:54:04 +03:00
Ahmed Ibrahim
c1331e6da6 Fix lenient config CI findings 2026-05-06 15:49:28 +03:00
Ahmed Ibrahim
883c70fcce Remove stale Lenient config references 2026-05-06 15:42:26 +03:00
Ahmed Ibrahim
b1c09a4426 Keep invalid config enums at load boundary 2026-05-06 15:23:59 +03:00
Ahmed Ibrahim
01c513fcaa codex: align tests with lenient config enums
Update test expectations after invalid enum values no longer fail whole config deserialization.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 04:32:32 +03:00
Ahmed Ibrahim
4007c4c1ec codex: fix lenient enum CI fallout
Address clippy and compile failures from wrapping config enums in Lenient.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 04:26:53 +03:00
Ahmed Ibrahim
da01f35b00 codex: unwrap lenient auth store in exec
Fix the exec cloud requirements path after cli_auth_credentials_store became lenient.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 04:19:53 +03:00
Ahmed Ibrahim
d595ef0604 codex: fix CI failure on PR #21111
Update the TUI config consumer to unwrap the lenient CLI auth credential store before passing it to the cloud requirements loader.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 04:18:00 +03:00
Ahmed Ibrahim
9bc30cf95f Call lenient warning unwraps directly
Remove the local warning unwrap helpers so runtime config consumption calls Lenient::into_valid_with_warning directly instead of hiding the API behind wrappers.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 04:11:43 +03:00
Ahmed Ibrahim
2e1b882d19 Split silent and warning lenient unwraps
Keep Lenient::into_valid as the silent projection helper and add into_valid_with_warning for runtime config consumption that should emit startup warnings.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 04:08:08 +03:00
Ahmed Ibrahim
e064d502ae Collect config enum warnings while unwrapping
Remove the explicit invalid_enum_warnings tree walk and have Lenient::into_valid append warning messages when invalid values are consumed. Keep higher-level resolvers returning values instead of accepting active profile and warning sink plumbing.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 03:58:45 +03:00
Ahmed Ibrahim
c49e2318a3 Add config enum warning integration test
Cover the full ConfigBuilder load path for invalid enum values so startup warnings are asserted alongside valid config that still applies.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 03:49:37 +03:00
Ahmed Ibrahim
098f4aa6ef Wrap config enum values leniently
Store selected config enum fields as Lenient<T> so invalid values remain visible after deserialization and can be reported as startup warnings while valid consumers unwrap at runtime. Remove the older retry-loop sanitizer now that warnings come from the typed config tree.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 03:46:52 +03:00
Ahmed Ibrahim
2a9061ba5e Use retry loop for invalid config enums
Replace the explicit config enum sanitizer with a generic deserialize retry loop over the assembled TOML. Unknown enum variant errors remove the offending field, append a warning with the field path and invalid value, and retry deserialization.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 03:27:06 +03:00
Ahmed Ibrahim
d6e2ff811b Sanitize config enums after merging layers
Defer invalid enum handling until after config layers are assembled. This keeps layer loading raw, removes invalid enum values from the final effective config, and reports warnings with the dotted field path and invalid value only.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 03:13:40 +03:00
Ahmed Ibrahim
5e1dbff17e Warn on invalid config enum values
Allow config loading to continue when enum-valued settings contain invalid values. Invalid enum entries are removed from the layer before merging and surfaced through startup config warnings, while unrelated valid settings keep loading normally.

Co-authored-by: Codex <noreply@openai.com>
2026-05-05 03:01:34 +03:00
21 changed files with 1558 additions and 53 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2391,6 +2391,7 @@ dependencies = [
"serde",
"serde_json",
"serde_path_to_error",
"serde_with",
"sha2",
"tempfile",
"thiserror 2.0.18",

View File

@@ -17,6 +17,7 @@ use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ConfigRequirementsToml;
use codex_config::config_toml::ConfigToml;
use codex_config::invalid_enum_warnings;
use codex_config::merge_toml_values;
use codex_core::config::deserialize_config_toml_with_base;
use codex_core::config::edit::ConfigEdit;
@@ -241,6 +242,30 @@ impl ConfigManager {
let parsed_value = parse_value(value).map_err(|message| {
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
})?;
if let Some(value) = parsed_value.as_ref() {
// Reject enum mistakes introduced by this edit only. The full
// config parse below still checks the resulting shape, while
// stale enum values elsewhere remain startup warnings.
let mut edited_config = TomlValue::Table(toml::map::Map::new());
apply_merge(
&mut edited_config,
&segments,
Some(value),
MergeStrategy::Replace,
)
.map_err(|err| match err {
MergeError::Validation(message) => ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
message,
),
})?;
if let Some(warning) = invalid_enum_warnings(&edited_config).into_iter().next() {
return Err(ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {warning}"),
));
}
}
apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err(
|err| match err {

View File

@@ -489,6 +489,75 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
assert_eq!(contents.trim(), "model = \"user\"");
}
#[tokio::test]
async fn write_value_allows_unrelated_edit_when_existing_enum_is_invalid() -> Result<()> {
let tmp = tempdir().expect("tempdir");
let original = "sandbox_mode = \"make-it-so\"\n";
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?;
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
let response = 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-codex"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("unrelated write should not be blocked by stale enum value");
assert_eq!(
(
response.status,
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))?,
),
(
WriteStatus::Ok,
"sandbox_mode = \"make-it-so\"\nmodel = \"gpt-5-codex\"\n".to_string(),
)
);
Ok(())
}
#[tokio::test]
async fn write_value_rejects_new_nested_invalid_enum() -> Result<()> {
let tmp = tempdir().expect("tempdir");
let original = r#"[model_providers.custom]
name = "Custom"
base_url = "https://example.invalid/v1"
"#;
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?;
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
let error = service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
key_path: "model_providers.custom.wire_api".to_string(),
value: serde_json::json!("telegraph"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect_err("new invalid enum should fail validation");
assert_eq!(
(
error.write_error_code(),
error
.to_string()
.contains("model_providers.custom.wire_api"),
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))?,
),
(
Some(ConfigWriteErrorCode::ConfigValidationError),
true,
original.to_string(),
)
);
Ok(())
}
#[tokio::test]
async fn reserved_builtin_provider_override_rejected() {
let tmp = tempdir().expect("tempdir");

View File

@@ -34,6 +34,7 @@ schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_path_to_error = { workspace = true }
serde_with = { workspace = true, features = ["macros"] }
sha2 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] }

View File

@@ -37,6 +37,7 @@ use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
use codex_model_provider_info::OPENAI_PROVIDER_ID;
use codex_model_provider_info::WireApi;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
@@ -44,6 +45,7 @@ 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::config_types::WebSearchToolConfig;
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -57,6 +59,10 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as SerdeError;
use serde_with::DefaultOnError;
use serde_with::serde_as;
use toml::Value as TomlValue;
const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [
AMAZON_BEDROCK_PROVIDER_ID,
@@ -88,6 +94,7 @@ const fn default_hide_agent_reasoning() -> Option<bool> {
}
/// Base config deserialized from ~/.codex/config.toml.
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConfigToml {
@@ -106,11 +113,15 @@ pub struct ConfigToml {
pub model_auto_compact_token_limit: Option<i64>,
/// Default approval policy for executing commands.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub approval_policy: Option<AskForApproval>,
/// Configures who approval requests are routed to for review once they have
/// been escalated. This does not disable separate safety checks such as
/// ARC.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Optional policy instructions for the guardian auto-reviewer.
@@ -132,6 +143,8 @@ pub struct ConfigToml {
pub allow_login_shell: Option<bool>,
/// Sandbox mode to use.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub sandbox_mode: Option<SandboxMode>,
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
@@ -186,6 +199,7 @@ pub struct ConfigToml {
/// When set, restricts the login mechanism users may use.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub forced_login_method: Option<ForcedLoginMethod>,
/// Preferred backend for storing CLI auth credentials.
@@ -193,6 +207,7 @@ pub struct ConfigToml {
/// keyring: Use an OS-specific keyring service.
/// auto: Use the keyring if available, otherwise use a file.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub cli_auth_credentials_store: Option<AuthCredentialsStoreMode>,
/// Definition for MCP servers that Codex can reach out to for tool calls.
@@ -207,6 +222,7 @@ pub struct ConfigToml {
/// file: Use a file in the Codex home directory.
/// auto (default): Use the OS-specific keyring service if available, otherwise use a file.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,
/// Optional fixed port for the local HTTP callback server used during MCP OAuth login.
@@ -274,6 +290,8 @@ pub struct ConfigToml {
/// Optional URI-based file opener. If set, citations to files in the model
/// output will be hyperlinked using the specified URI scheme.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub file_opener: Option<UriBasedFileOpener>,
/// Collection of settings that are specific to the TUI.
@@ -288,10 +306,18 @@ pub struct ConfigToml {
/// Defaults to `false`.
pub show_raw_agent_reasoning: Option<bool>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub model_reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub plan_mode_reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub model_reasoning_summary: Option<ReasoningSummary>,
/// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub model_verbosity: Option<Verbosity>,
/// Override to force-enable reasoning summaries for the configured model.
@@ -302,9 +328,13 @@ pub struct ConfigToml {
pub model_catalog_json: Option<AbsolutePathBuf>,
/// Optionally specify a personality for the model
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub personality: Option<Personality>,
/// Optional explicit service tier preference for new turns (`fast` or `flex`).
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub service_tier: Option<ServiceTier>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
@@ -351,10 +381,14 @@ pub struct ConfigToml {
pub experimental_thread_config_endpoint: Option<String>,
/// Experimental / do not use. Selects the thread store implementation.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub experimental_thread_store: Option<ThreadStoreToml>,
pub projects: Option<HashMap<String, ProjectConfig>>,
/// Controls the web search tool mode: disabled, cached, or live.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub web_search: Option<WebSearchMode>,
/// Nested tools section for feature toggles
@@ -525,9 +559,12 @@ impl From<ConfigToml> for UserSavedConfig {
}
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ProjectConfig {
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub trust_level: Option<TrustLevel>,
}
@@ -577,13 +614,22 @@ pub struct RealtimeConfig {
pub voice: Option<RealtimeVoice>,
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RealtimeToml {
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub version: Option<RealtimeWsVersion>,
#[serde(rename = "type")]
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub session_type: Option<RealtimeWsMode>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub transport: Option<RealtimeTransport>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub voice: Option<RealtimeVoice>,
}
@@ -621,15 +667,35 @@ fn deserialize_optional_web_search_tool_config<'de, D>(
where
D: Deserializer<'de>,
{
let value = Option::<WebSearchToolConfigInput>::deserialize(deserializer)?;
let Some(value) = Option::<TomlValue>::deserialize(deserializer)? else {
return Ok(None);
};
let value = match value.clone().try_into::<WebSearchToolConfigInput>() {
Ok(value) => value,
Err(err) => {
let mut without_context_size = value;
let removed_context_size = without_context_size
.as_table_mut()
.and_then(|table| table.remove("context_size"))
.is_some_and(|context_size| {
context_size.try_into::<WebSearchContextSize>().is_err()
});
if !removed_context_size {
return Err(SerdeError::custom(err.to_string()));
}
without_context_size
.try_into::<WebSearchToolConfigInput>()
.map_err(SerdeError::custom)?
}
};
Ok(match value {
None => None,
Some(WebSearchToolConfigInput::Enabled(enabled)) => {
WebSearchToolConfigInput::Enabled(enabled) => {
let _ = enabled;
None
}
Some(WebSearchToolConfigInput::Config(config)) => Some(config),
WebSearchToolConfigInput::Config(config) => Some(config),
})
}
@@ -940,7 +1006,30 @@ fn deserialize_model_providers<'de, D>(
where
D: serde::Deserializer<'de>,
{
let model_providers = HashMap::<String, ModelProviderInfo>::deserialize(deserializer)?;
let raw_model_providers = HashMap::<String, TomlValue>::deserialize(deserializer)?;
let mut model_providers = HashMap::new();
for (key, value) in raw_model_providers {
let provider = match value.clone().try_into::<ModelProviderInfo>() {
Ok(provider) => provider,
Err(err) => {
// Invalid `wire_api` is already reported by the schema warning
// pass. Remove just that leaf so the provider can fall back to
// its default wire API without hiding unrelated provider errors.
let mut without_wire_api = value;
let removed_wire_api = without_wire_api
.as_table_mut()
.and_then(|table| table.remove("wire_api"))
.is_some_and(|wire_api| wire_api.try_into::<WireApi>().is_err());
if !removed_wire_api {
return Err(serde::de::Error::custom(err.to_string()));
}
without_wire_api
.try_into::<ModelProviderInfo>()
.map_err(serde::de::Error::custom)?
}
};
model_providers.insert(key, provider);
}
validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?;
Ok(model_providers)
}

View File

@@ -0,0 +1,521 @@
use crate::schema::config_schema;
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use std::collections::BTreeSet;
use std::panic::AssertUnwindSafe;
use std::panic::catch_unwind;
use toml::Value as TomlValue;
const SCHEMA_COMPOSITION_KEYS: [&str; 3] = ["allOf", "anyOf", "oneOf"];
/// String enum choices collected from a schema node and anything it references.
///
/// `allows_non_string` matters for untagged/union schemas such as `bool | table`:
/// if a schema accepts a non-string shape, this warning pass should not report a
/// non-string TOML value as an invalid enum.
#[derive(Default)]
struct EnumChoices {
allowed_strings: BTreeSet<String>,
allows_non_string: bool,
}
impl EnumChoices {
fn has_string_enum(&self) -> bool {
!self.allowed_strings.is_empty()
}
fn accepts(&self, value: &TomlValue) -> bool {
if let Some(value) = value.as_str() {
self.allowed_strings.contains(value)
} else {
self.allows_non_string
}
}
}
/// One concrete location in the TOML value being compared with the schema.
enum PathElement {
/// A table key, including keys from `additionalProperties` maps.
Key(String),
/// An array element when the schema has an `items` child.
Index(usize),
}
enum CurrentNodeWarning {
Check,
Skip,
}
/// Return best-effort warnings for raw TOML values that look like invalid enums.
///
/// This pass is intentionally advisory: it walks the final merged TOML value
/// against the generated config schema and reports enum-looking mismatches
/// without changing the TOML that will be deserialized. Narrow typed-config
/// fallbacks keep the reported enum leaves non-blocking for startup loading;
/// config write paths may use the same signal to reject newly provided invalid
/// enum values.
pub fn invalid_enum_warnings(value: &TomlValue) -> Vec<String> {
// Startup warnings should never make config loading fail. If schema
// generation or traversal panics, the typed config load still proceeds.
catch_unwind(AssertUnwindSafe(|| {
match serde_json::to_value(config_schema()) {
Ok(schema) => {
let definitions = schema.get("definitions").and_then(JsonValue::as_object);
let mut path = Vec::new();
let mut ref_stack = Vec::new();
let mut warnings = Vec::new();
collect_warnings(
value,
&schema,
definitions,
&mut path,
&mut ref_stack,
&mut warnings,
CurrentNodeWarning::Check,
);
warnings
}
Err(_) => Vec::new(),
}
}))
.unwrap_or_default()
}
/// Walk a TOML value and a schema node together and append enum warnings.
///
/// The traversal intentionally follows the schema shapes emitted by
/// `config_schema`: object `properties`, map-like `additionalProperties`, array
/// `items`, `$ref`, and schema composition keywords. It never validates the full
/// shape. Serde remains the source of truth for real config parsing.
fn collect_warnings(
value: &TomlValue,
schema: &JsonValue,
definitions: Option<&JsonMap<String, JsonValue>>,
path: &mut Vec<PathElement>,
ref_stack: &mut Vec<String>,
warnings: &mut Vec<String>,
current_node_warning: CurrentNodeWarning,
) {
let mut choices = EnumChoices::default();
collect_enum_choices(schema, definitions, ref_stack, &mut choices);
// Warn only when this schema node has string enum choices. Non-enum shape
// mismatches are left alone so this pass stays small and advisory.
if matches!(current_node_warning, CurrentNodeWarning::Check)
&& choices.has_string_enum()
&& !choices.accepts(value)
{
let warning = format!(
"Ignoring invalid config value at {}: {value}",
display_path(path)
);
if !warnings.contains(&warning) {
warnings.push(warning);
}
}
// Follow references and composition at the same TOML path so nested object
// schemas are still traversed. Current-path warnings are skipped here
// because the enum choices above already aggregate refs and composition
// children; warning inside each oneOf branch would reject valid sibling
// enum variants.
if let Some(definition) = resolve_definition(schema, definitions, ref_stack) {
collect_warnings(
value,
definition,
definitions,
path,
ref_stack,
warnings,
CurrentNodeWarning::Skip,
);
ref_stack.pop();
}
for key in SCHEMA_COMPOSITION_KEYS {
let Some(child_schemas) = schema.get(key).and_then(JsonValue::as_array) else {
continue;
};
// Tagged object unions put the enum on a child property such as
// `type`. First find enum properties accepted by at least one sibling
// branch; invalid values match no branch here, so the fallback below
// still walks every branch and reports the bad leaf.
let mut matched_enum_properties: BTreeSet<&str> = BTreeSet::new();
if key != "allOf"
&& let Some(table) = value.as_table()
{
for child_schema in child_schemas {
let Some(properties) = child_schema
.get("properties")
.and_then(JsonValue::as_object)
else {
continue;
};
for (property_name, property_schema) in properties {
let Some(child_value) = table.get(property_name) else {
continue;
};
let mut property_choices = EnumChoices::default();
let mut choice_ref_stack = ref_stack.clone();
collect_enum_choices(
property_schema,
definitions,
&mut choice_ref_stack,
&mut property_choices,
);
if property_choices.has_string_enum() && property_choices.accepts(child_value) {
matched_enum_properties.insert(property_name.as_str());
}
}
}
}
let mut matching_child_schemas = Vec::new();
for child_schema in child_schemas {
let mut child_matches_value = true;
if !matched_enum_properties.is_empty()
&& let (Some(table), Some(properties)) = (
value.as_table(),
child_schema
.get("properties")
.and_then(JsonValue::as_object),
)
{
for property_name in &matched_enum_properties {
let (Some(child_value), Some(property_schema)) =
(table.get(*property_name), properties.get(*property_name))
else {
continue;
};
let mut property_choices = EnumChoices::default();
let mut choice_ref_stack = ref_stack.clone();
collect_enum_choices(
property_schema,
definitions,
&mut choice_ref_stack,
&mut property_choices,
);
if property_choices.has_string_enum() && !property_choices.accepts(child_value)
{
child_matches_value = false;
break;
}
}
}
if child_matches_value {
matching_child_schemas.push(child_schema);
}
}
let child_schemas_to_walk = if matching_child_schemas.is_empty() {
child_schemas.iter().collect()
} else {
matching_child_schemas
};
for child_schema in child_schemas_to_walk {
collect_warnings(
value,
child_schema,
definitions,
path,
ref_stack,
warnings,
CurrentNodeWarning::Skip,
);
}
}
// For ordinary tables, recurse only into keys present in both the TOML and
// the schema. Unknown keys are not part of enum warning collection.
let properties = schema.get("properties").and_then(JsonValue::as_object);
if let (Some(table), Some(properties)) = (value.as_table(), properties) {
for (key, child_schema) in properties {
let Some(child_value) = table.get(key) else {
continue;
};
path.push(PathElement::Key(key.clone()));
collect_warnings(
child_value,
child_schema,
definitions,
path,
ref_stack,
warnings,
CurrentNodeWarning::Check,
);
path.pop();
}
}
// Dynamic maps such as profiles and MCP servers are represented by
// `additionalProperties`; every unmatched TOML key uses the same child
// schema.
let additional_properties = schema
.get("additionalProperties")
.filter(|additional_properties| additional_properties.is_object());
if let (Some(table), Some(additional_properties)) = (value.as_table(), additional_properties) {
for (key, child_value) in table {
if properties.is_some_and(|properties| properties.contains_key(key)) {
continue;
}
path.push(PathElement::Key(key.clone()));
collect_warnings(
child_value,
additional_properties,
definitions,
path,
ref_stack,
warnings,
CurrentNodeWarning::Check,
);
path.pop();
}
}
// Arrays are rare for enum config today, but following `items` keeps the
// schema walk honest if a future list contains enum-valued entries.
let items = schema.get("items");
if let (Some(array), Some(items)) = (value.as_array(), items) {
for (index, child_value) in array.iter().enumerate() {
path.push(PathElement::Index(index));
collect_warnings(
child_value,
items,
definitions,
path,
ref_stack,
warnings,
CurrentNodeWarning::Check,
);
path.pop();
}
}
}
/// Collect string enum values reachable from one schema node.
///
/// This mirrors the traversal used by `collect_warnings` at a single location:
/// referenced schemas and union/composition children all contribute choices for
/// the same TOML value.
fn collect_enum_choices(
schema: &JsonValue,
definitions: Option<&JsonMap<String, JsonValue>>,
ref_stack: &mut Vec<String>,
choices: &mut EnumChoices,
) {
choices.allowed_strings.extend(
schema
.get("enum")
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter_map(JsonValue::as_str)
.map(str::to_string),
);
choices.allows_non_string |= match schema.get("type") {
Some(JsonValue::String(value)) => value != "string" && value != "null",
Some(JsonValue::Array(values)) => values
.iter()
.filter_map(JsonValue::as_str)
.any(|value| value != "string" && value != "null"),
_ => {
schema.get("properties").is_some()
|| schema.get("additionalProperties").is_some()
|| schema.get("items").is_some()
}
};
if let Some(definition) = resolve_definition(schema, definitions, ref_stack) {
collect_enum_choices(definition, definitions, ref_stack, choices);
ref_stack.pop();
}
for key in SCHEMA_COMPOSITION_KEYS {
let Some(child_schemas) = schema.get(key).and_then(JsonValue::as_array) else {
continue;
};
for child_schema in child_schemas {
collect_enum_choices(child_schema, definitions, ref_stack, choices);
}
}
}
/// Resolve an internal `#/definitions/...` reference while avoiding cycles.
///
/// When this returns `Some`, the resolved name has been pushed onto `ref_stack`;
/// callers must pop after they finish walking that definition.
fn resolve_definition<'a>(
schema: &JsonValue,
definitions: Option<&'a JsonMap<String, JsonValue>>,
ref_stack: &mut Vec<String>,
) -> Option<&'a JsonValue> {
let name = schema
.get("$ref")?
.as_str()?
.strip_prefix("#/definitions/")?;
if ref_stack.iter().any(|entry| entry == name) {
None
} else {
let definition = definitions?.get(name)?;
ref_stack.push(name.to_string());
Some(definition)
}
}
/// Render a warning path using TOML-style dotted keys where possible.
fn display_path(path: &[PathElement]) -> String {
if path.is_empty() {
"<root>".to_string()
} else {
let mut output = String::new();
for element in path {
match element {
PathElement::Key(key) => {
if !output.is_empty() {
output.push('.');
}
if !key.is_empty()
&& key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
{
output.push_str(key);
} else {
let escaped = key.replace('\\', "\\\\").replace('"', "\\\"");
output.push('"');
output.push_str(&escaped);
output.push('"');
}
}
PathElement::Index(index) => {
output.push_str(&format!("[{index}]"));
}
}
}
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeSet;
fn warning_set(contents: &str) -> BTreeSet<String> {
let value = toml::from_str::<TomlValue>(contents).expect("config should parse");
invalid_enum_warnings(&value).into_iter().collect()
}
#[test]
fn enum_warning_walk_follows_refs_composition_and_dynamic_maps() {
let contents = r#"
sandbox_mode = "hyperdrive"
[tui]
notification_method = "shout"
notification_condition = "whenever"
[profiles."alpha.beta"]
sandbox_mode = "moonwalk"
[mcp_servers.local]
command = "server"
default_tools_approval_mode = "ship-it"
[mcp_servers.local.tools."danger.tool"]
approval_mode = "yolo"
"#;
let value = toml::from_str::<TomlValue>(contents).expect("config should parse");
let original = value.clone();
let warnings = invalid_enum_warnings(&value).into_iter().collect();
let expected_warnings = BTreeSet::from([
"Ignoring invalid config value at mcp_servers.local.default_tools_approval_mode: \
\"ship-it\""
.to_string(),
"Ignoring invalid config value at mcp_servers.local.tools.\"danger.tool\".\
approval_mode: \"yolo\""
.to_string(),
"Ignoring invalid config value at profiles.\"alpha.beta\".sandbox_mode: \"moonwalk\""
.to_string(),
"Ignoring invalid config value at sandbox_mode: \"hyperdrive\"".to_string(),
"Ignoring invalid config value at tui.notification_condition: \"whenever\"".to_string(),
"Ignoring invalid config value at tui.notification_method: \"shout\"".to_string(),
]);
assert_eq!((value, warnings), (original, expected_warnings));
}
#[test]
fn valid_one_of_enum_values_do_not_warn_from_sibling_branches() {
let warnings = warning_set(
r#"
[tui]
notification_condition = "always"
"#,
);
assert_eq!(warnings, BTreeSet::new());
}
#[test]
fn tagged_union_sibling_variants_do_not_warn() {
let warnings = warning_set(
r#"
[hooks]
[[hooks.UserPromptSubmit]]
[[hooks.UserPromptSubmit.hooks]]
type = "command"
command = "python3 /tmp/user-prompt.py"
"#,
);
assert_eq!(warnings, BTreeSet::new());
}
#[test]
fn invalid_tagged_union_variant_warns_at_tag_property() {
let warnings = warning_set(
r#"
[hooks]
[[hooks.UserPromptSubmit]]
[[hooks.UserPromptSubmit.hooks]]
type = "python"
command = "python3 /tmp/user-prompt.py"
"#,
);
let expected_warnings = BTreeSet::from([String::from(
"Ignoring invalid config value at hooks.UserPromptSubmit[0].hooks[0].type: \"python\"",
)]);
assert_eq!(warnings, expected_warnings);
}
#[test]
fn non_string_union_branches_do_not_warn() {
let warnings = warning_set(
r#"
[tools]
web_search = true
[tui]
notifications = ["notify-send"]
"#,
);
assert_eq!(warnings, BTreeSet::new());
}
}

View File

@@ -5,7 +5,10 @@ use std::path::PathBuf;
use codex_protocol::protocol::HookEventName;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as SerdeError;
use serde_json::Value as JsonValue;
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct HooksFile {
@@ -102,10 +105,36 @@ impl HookEventsToml {
pub struct MatcherGroup {
#[serde(default)]
pub matcher: Option<String>,
#[serde(default)]
#[serde(default, deserialize_with = "deserialize_hook_handlers")]
pub hooks: Vec<HookHandlerConfig>,
}
/// Deserialize hook handlers while dropping entries with unknown tagged variants.
///
/// The schema warning pass reports invalid `type` values before typed config
/// deserialization. Dropping only those entries keeps startup warnings
/// non-blocking without making unrelated hook shape errors silent.
fn deserialize_hook_handlers<'de, D>(deserializer: D) -> Result<Vec<HookHandlerConfig>, D::Error>
where
D: Deserializer<'de>,
{
let values = Vec::<JsonValue>::deserialize(deserializer)?;
let mut handlers = Vec::new();
for value in values {
let invalid_type = value.get("type").is_some_and(|handler_type| {
!matches!(handler_type.as_str(), Some("command" | "prompt" | "agent"))
});
match serde_json::from_value(value) {
Ok(handler) => handlers.push(handler),
Err(_) if invalid_type => {}
Err(err) => return Err(SerdeError::custom(err)),
}
}
Ok(handlers)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type")]
pub enum HookHandlerConfig {

View File

@@ -128,6 +128,62 @@ command = "python3 /tmp/pre.py"
);
}
#[test]
fn hooks_toml_drops_unknown_handler_type() {
let parsed: HooksToml = toml::from_str(
r#"
[[UserPromptSubmit]]
matcher = "^UserPromptSubmit$"
[[UserPromptSubmit.hooks]]
type = "python"
command = "python3 /tmp/ignored.py"
[[UserPromptSubmit.hooks]]
type = 7
command = "python3 /tmp/also-ignored.py"
[[UserPromptSubmit.hooks]]
type = "command"
command = "python3 /tmp/kept.py"
"#,
)
.expect("unknown hook handler type should be dropped");
assert_eq!(
parsed,
HooksToml {
events: HookEventsToml {
user_prompt_submit: vec![MatcherGroup {
matcher: Some("^UserPromptSubmit$".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "python3 /tmp/kept.py".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
}],
..Default::default()
},
state: BTreeMap::new(),
}
);
}
#[test]
fn hooks_toml_keeps_non_enum_handler_errors_strict() {
let result = toml::from_str::<HooksToml>(
r#"
[[UserPromptSubmit]]
[[UserPromptSubmit.hooks]]
type = "command"
"#,
);
assert!(result.is_err());
}
#[test]
fn managed_hooks_requirements_flatten_hook_events() {
let parsed: ManagedHooksRequirementsToml = toml::from_str(

View File

@@ -3,6 +3,7 @@ mod config_requirements;
pub mod config_toml;
mod constraint;
mod diagnostics;
mod enum_warnings;
mod fingerprint;
mod hook_config;
mod host_name;
@@ -71,6 +72,7 @@ pub use diagnostics::first_layer_config_error_from_entries;
pub use diagnostics::format_config_error;
pub use diagnostics::format_config_error_with_source;
pub use diagnostics::io_error_from_config_error;
pub use enum_warnings::invalid_enum_warnings;
pub use fingerprint::version_for_toml;
pub use hook_config::HookEventsToml;
pub use hook_config::HookHandlerConfig;

View File

@@ -869,8 +869,10 @@ pub fn resolve_relative_paths_in_config_toml(
value_from_config_toml: TomlValue,
base_dir: &Path,
) -> io::Result<TomlValue> {
// Use the serialize/deserialize round-trip to convert the
// `toml::Value` into a `ConfigToml` with `AbsolutePath
// Use the serialize/deserialize round-trip to convert the `toml::Value`
// into a `ConfigToml` with `AbsolutePathBuf` fields resolved by the guard.
// copy_shape_from_original preserves raw non-path leaves so invalid enum
// values can still be reported from the final merged TOML.
let _guard = AbsolutePathBufGuard::new(base_dir);
let Ok(resolved) = value_from_config_toml.clone().try_into::<ConfigToml>() else {
return Ok(value_from_config_toml);
@@ -891,9 +893,9 @@ pub fn resolve_relative_paths_in_config_toml(
}
/// Ensure that every field in `original` is present in the returned
/// `toml::Value`, taking the value from `resolved` where possible. This ensures
/// the fields that we "removed" during the serialize/deserialize round-trip in
/// `resolve_config_paths` are preserved, out of an abundance of caution.
/// `toml::Value`, copying only the absolute path strings produced by the typed
/// round-trip. Other leaves stay raw so enum fallback defaults do not overwrite
/// the value that should produce a startup warning.
fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlValue {
match (original, resolved) {
(TomlValue::Table(original_table), TomlValue::Table(resolved_table)) => {
@@ -915,7 +917,18 @@ fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlV
}
TomlValue::Array(items)
}
(_, resolved_value) => resolved_value.clone(),
(TomlValue::String(original), TomlValue::String(resolved)) => {
// Relative path normalization turns a string into a different
// absolute path string. Enum fallback defaults can also change a
// string, but those default strings are not absolute paths and must
// not replace the user's raw value before warning collection.
if original != resolved && Path::new(resolved).is_absolute() {
TomlValue::String(resolved.clone())
} else {
TomlValue::String(original.clone())
}
}
(original_value, _) => original_value.clone(),
}
}

View File

@@ -10,6 +10,8 @@ use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::de::Error as SerdeError;
use serde_with::DefaultOnError;
use serde_with::serde_as;
use crate::RequirementSource;
@@ -48,11 +50,13 @@ impl fmt::Display for McpServerDisabledReason {
}
/// Per-tool approval settings for a single MCP server tool.
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct McpServerToolConfig {
/// Approval mode for this tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde_as(deserialize_as = "DefaultOnError")]
pub approval_mode: Option<AppToolApproval>,
}
@@ -185,6 +189,7 @@ pub struct McpServerConfig {
/// Keep `TryFrom<RawMcpServerConfig> for McpServerConfig` exhaustively
/// destructuring this struct so new TOML fields cannot be added here without
/// updating the validation/mapping logic that produces [`McpServerConfig`].
#[serde_as]
#[derive(Deserialize, Clone, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RawMcpServerConfig {
@@ -225,6 +230,7 @@ pub struct RawMcpServerConfig {
#[serde(default)]
pub supports_parallel_tool_calls: Option<bool>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub default_tools_approval_mode: Option<AppToolApproval>,
#[serde(default)]
pub enabled_tools: Option<Vec<String>>,

View File

@@ -2,6 +2,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_with::DefaultOnError;
use serde_with::serde_as;
use crate::config_toml::ToolsToml;
use crate::types::AnalyticsConfigToml;
@@ -20,24 +22,43 @@ use codex_protocol::protocol::AskForApproval;
/// Collection of common configuration options that a user can define as a unit
/// in `config.toml`.
#[serde_as]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConfigProfile {
pub model: Option<String>,
/// Optional explicit service tier preference for new turns (`fast` or `flex`).
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub service_tier: Option<ServiceTier>,
/// The key in the `model_providers` map identifying the
/// [`ModelProviderInfo`] to use.
pub model_provider: Option<String>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub approval_policy: Option<AskForApproval>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub approvals_reviewer: Option<ApprovalsReviewer>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub sandbox_mode: Option<SandboxMode>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub model_reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub plan_mode_reasoning_effort: Option<ReasoningEffort>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub model_reasoning_summary: Option<ReasoningSummary>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub model_verbosity: Option<Verbosity>,
/// Optional path to a JSON model catalog (applied on startup only).
pub model_catalog_json: Option<AbsolutePathBuf>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub personality: Option<Personality>,
pub chatgpt_base_url: Option<String>,
/// Optional path to a file containing model instructions.
@@ -62,6 +83,8 @@ pub struct ConfigProfile {
pub experimental_use_freeform_apply_patch: Option<bool>,
pub tools_view_image: Option<bool>,
pub tools: Option<ToolsToml>,
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub web_search: Option<WebSearchMode>,
pub analytics: Option<AnalyticsConfigToml>,
/// TUI settings scoped to this profile.
@@ -78,12 +101,14 @@ pub struct ConfigProfile {
}
/// TUI settings supported inside a named profile.
#[serde_as]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(deny_unknown_fields)]
pub struct ProfileTui {
/// Preferred layout for resume/fork session picker results.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub session_picker_view: Option<SessionPickerViewMode>,
}

View File

@@ -1,10 +1,12 @@
use crate::config_requirements::ConfigRequirements;
use crate::config_requirements::ConfigRequirementsToml;
use super::enum_warnings::invalid_enum_warnings;
use super::fingerprint::record_origins;
use super::fingerprint::version_for_toml;
use super::key_aliases::normalized_with_key_aliases;
use super::merge::merge_toml_values;
use crate::config_toml::ConfigToml;
use codex_app_server_protocol::ConfigLayer;
use codex_app_server_protocol::ConfigLayerMetadata;
use codex_app_server_protocol::ConfigLayerSource;
@@ -212,6 +214,22 @@ impl ConfigLayerStack {
self
}
/// Appends warnings discovered after the raw layers have been assembled.
///
/// Invalid enum warnings are produced while preparing the typed effective
/// config, after the stack has already been constructed. Keeping this merge
/// point on the stack lets callers surface them through the same startup
/// warning channel used by file-level config diagnostics.
pub fn with_additional_startup_warnings(mut self, warnings: Vec<String>) -> Self {
if warnings.is_empty() {
return self;
}
self.startup_warnings
.get_or_insert_with(Vec::new)
.extend(warnings);
self
}
pub fn startup_warnings(&self) -> Option<&[String]> {
self.startup_warnings.as_deref()
}
@@ -300,6 +318,20 @@ impl ConfigLayerStack {
merged
}
/// Deserializes the merged config-layer view and returns any soft warnings.
///
/// Invalid enum-valued settings are reported from the final raw TOML view.
/// Narrow typed-config fallbacks keep those warnings advisory without
/// changing unrelated config semantics.
pub fn deserialize_effective_config_with_warnings(
&self,
) -> Result<(TomlValue, ConfigToml, Vec<String>), toml::de::Error> {
let merged = self.effective_config();
let warnings = invalid_enum_warnings(&merged);
let typed = merged.clone().try_into::<ConfigToml>()?;
Ok((merged, typed, warnings))
}
/// Returns field origins for the merged config-layer view.
///
/// Requirement sources are tracked separately and are not included here.

View File

@@ -27,6 +27,9 @@ use std::fmt;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_with::DefaultOnError;
use serde_with::VecSkipError;
use serde_with::serde_as;
pub use crate::tui_keymap::KeybindingSpec;
pub use crate::tui_keymap::KeybindingsSpec;
@@ -118,9 +121,12 @@ pub enum WindowsSandboxModeToml {
Unelevated,
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct WindowsToml {
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub sandbox: Option<WindowsSandboxModeToml>,
/// Defaults to `true`. Set to `false` to launch the final sandboxed child
/// process on `Winsta0\\Default` instead of a private desktop.
@@ -159,11 +165,13 @@ impl UriBasedFileOpener {
}
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[serde(default)]
#[schemars(deny_unknown_fields)]
pub struct History {
/// If true, history entries will not be written to disk.
#[serde_as(deserialize_as = "DefaultOnError")]
pub persistence: HistoryPersistence,
/// If set, the maximum size of the history file in bytes. The oldest entries
@@ -245,12 +253,15 @@ impl ToolSuggestDisabledTool {
}
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ToolSuggestConfig {
#[serde(default)]
#[serde_as(deserialize_as = "VecSkipError<_>")]
pub discoverables: Vec<ToolSuggestDiscoverable>,
#[serde(default)]
#[serde_as(deserialize_as = "VecSkipError<_>")]
pub disabled_tools: Vec<ToolSuggestDisabledTool>,
}
@@ -389,6 +400,7 @@ pub struct AppsDefaultConfig {
}
/// Per-tool settings for a single app tool.
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppToolConfig {
@@ -398,6 +410,7 @@ pub struct AppToolConfig {
/// Approval mode for this tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde_as(deserialize_as = "DefaultOnError")]
pub approval_mode: Option<AppToolApproval>,
}
@@ -411,6 +424,7 @@ pub struct AppToolsConfig {
}
/// Config values for a single app/connector.
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppConfig {
@@ -428,6 +442,7 @@ pub struct AppConfig {
/// Approval mode for tools in this app unless a tool override exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde_as(deserialize_as = "DefaultOnError")]
pub default_tools_approval_mode: Option<AppToolApproval>,
/// Whether tools are enabled by default for this app.
@@ -497,6 +512,7 @@ pub enum OtelExporterKind {
}
/// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct OtelConfigToml {
@@ -507,12 +523,18 @@ pub struct OtelConfigToml {
pub environment: Option<String>,
/// Optional log exporter
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub exporter: Option<OtelExporterKind>,
/// Optional trace exporter
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub trace_exporter: Option<OtelExporterKind>,
/// Optional metrics exporter
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub metrics_exporter: Option<OtelExporterKind>,
}
@@ -589,22 +611,26 @@ impl fmt::Display for NotificationCondition {
}
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct TuiNotificationSettings {
/// Enable desktop notifications from the TUI.
/// Defaults to `true`.
#[serde(default, rename = "notifications")]
#[serde_as(deserialize_as = "DefaultOnError")]
pub notifications: Notifications,
/// Notification method to use for terminal notifications.
/// Defaults to `auto`.
#[serde(default, rename = "notification_method")]
#[serde_as(deserialize_as = "DefaultOnError")]
pub method: NotificationMethod,
/// Controls whether TUI notifications are delivered only when the terminal is unfocused or
/// regardless of focus. Defaults to `unfocused`.
#[serde(default, rename = "notification_condition")]
#[serde_as(deserialize_as = "DefaultOnError")]
pub condition: NotificationCondition,
}
@@ -620,6 +646,7 @@ pub struct ModelAvailabilityNuxConfig {
pub const DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS: usize = 1_000;
/// Collection of settings that are specific to the TUI.
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct Tui {
@@ -655,6 +682,7 @@ pub struct Tui {
/// Using alternate screen provides a cleaner fullscreen experience but prevents
/// scrollback in terminal multiplexers like Zellij that follow the xterm spec.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub alternate_screen: AltScreenMode,
/// Ordered list of status line item identifiers.
@@ -687,6 +715,7 @@ pub struct Tui {
/// Preferred layout for resume/fork session picker results.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub session_picker_view: Option<SessionPickerViewMode>,
/// Keybinding overrides for the TUI.
@@ -773,6 +802,7 @@ pub struct PluginConfig {
///
/// This intentionally excludes transport settings: plugin manifests own how the
/// MCP server is launched, while user config owns enablement and tool policy.
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PluginMcpServerConfig {
@@ -782,6 +812,7 @@ pub struct PluginMcpServerConfig {
/// Approval mode for tools in this server unless a tool override exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde_as(deserialize_as = "DefaultOnError")]
pub default_tools_approval_mode: Option<AppToolApproval>,
/// Explicit allow-list of tools exposed from this server.
@@ -809,6 +840,7 @@ impl Default for PluginMcpServerConfig {
}
}
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MarketplaceConfig {
@@ -820,6 +852,7 @@ pub struct MarketplaceConfig {
pub last_revision: Option<String>,
/// Source kind used to install this marketplace.
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub source_type: Option<MarketplaceSourceType>,
/// Source location used when the marketplace was added.
#[serde(default)]
@@ -865,9 +898,12 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
/// Policy for building the `env` when spawning a process via either the
/// `shell` or `local_shell` tool.
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ShellEnvironmentPolicyToml {
#[serde(default)]
#[serde_as(deserialize_as = "DefaultOnError")]
pub inherit: Option<ShellEnvironmentPolicyInherit>,
pub ignore_default_excludes: Option<bool>,

View File

@@ -336,10 +336,20 @@
"$ref": "#/definitions/AnalyticsConfigToml"
},
"approval_policy": {
"$ref": "#/definitions/AskForApproval"
"allOf": [
{
"$ref": "#/definitions/AskForApproval"
}
],
"default": null
},
"approvals_reviewer": {
"$ref": "#/definitions/ApprovalsReviewer"
"allOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
}
],
"default": null
},
"chatgpt_base_url": {
"type": "string"
@@ -646,25 +656,55 @@
"type": "string"
},
"model_reasoning_effort": {
"$ref": "#/definitions/ReasoningEffort"
"allOf": [
{
"$ref": "#/definitions/ReasoningEffort"
}
],
"default": null
},
"model_reasoning_summary": {
"$ref": "#/definitions/ReasoningSummary"
"allOf": [
{
"$ref": "#/definitions/ReasoningSummary"
}
],
"default": null
},
"model_verbosity": {
"$ref": "#/definitions/Verbosity"
"allOf": [
{
"$ref": "#/definitions/Verbosity"
}
],
"default": null
},
"oss_provider": {
"type": "string"
},
"personality": {
"$ref": "#/definitions/Personality"
"allOf": [
{
"$ref": "#/definitions/Personality"
}
],
"default": null
},
"plan_mode_reasoning_effort": {
"$ref": "#/definitions/ReasoningEffort"
"allOf": [
{
"$ref": "#/definitions/ReasoningEffort"
}
],
"default": null
},
"sandbox_mode": {
"$ref": "#/definitions/SandboxMode"
"allOf": [
{
"$ref": "#/definitions/SandboxMode"
}
],
"default": null
},
"service_tier": {
"allOf": [
@@ -672,6 +712,7 @@
"$ref": "#/definitions/ServiceTier"
}
],
"default": null,
"description": "Optional explicit service tier preference for new turns (`fast` or `flex`)."
},
"tools": {
@@ -690,7 +731,12 @@
"description": "TUI settings scoped to this profile."
},
"web_search": {
"$ref": "#/definitions/WebSearchMode"
"allOf": [
{
"$ref": "#/definitions/WebSearchMode"
}
],
"default": null
},
"windows": {
"allOf": [
@@ -1664,6 +1710,7 @@
"$ref": "#/definitions/OtelExporterKind"
}
],
"default": null,
"description": "Optional log exporter"
},
"log_user_prompt": {
@@ -1676,6 +1723,7 @@
"$ref": "#/definitions/OtelExporterKind"
}
],
"default": null,
"description": "Optional metrics exporter"
},
"trace_exporter": {
@@ -1684,6 +1732,7 @@
"$ref": "#/definitions/OtelExporterKind"
}
],
"default": null,
"description": "Optional trace exporter"
}
},
@@ -1911,7 +1960,12 @@
"additionalProperties": false,
"properties": {
"trust_level": {
"$ref": "#/definitions/TrustLevel"
"allOf": [
{
"$ref": "#/definitions/TrustLevel"
}
],
"default": null
}
},
"type": "object"
@@ -2070,16 +2124,36 @@
"additionalProperties": false,
"properties": {
"transport": {
"$ref": "#/definitions/RealtimeTransport"
"allOf": [
{
"$ref": "#/definitions/RealtimeTransport"
}
],
"default": null
},
"type": {
"$ref": "#/definitions/RealtimeWsMode"
"allOf": [
{
"$ref": "#/definitions/RealtimeWsMode"
}
],
"default": null
},
"version": {
"$ref": "#/definitions/RealtimeConversationVersion"
"allOf": [
{
"$ref": "#/definitions/RealtimeConversationVersion"
}
],
"default": null
},
"voice": {
"$ref": "#/definitions/RealtimeVoice"
"allOf": [
{
"$ref": "#/definitions/RealtimeVoice"
}
],
"default": null
}
},
"type": "object"
@@ -2252,7 +2326,12 @@
"type": "array"
},
"inherit": {
"$ref": "#/definitions/ShellEnvironmentPolicyInherit"
"allOf": [
{
"$ref": "#/definitions/ShellEnvironmentPolicyInherit"
}
],
"default": null
},
"set": {
"additionalProperties": {
@@ -3723,7 +3802,12 @@
"additionalProperties": false,
"properties": {
"sandbox": {
"$ref": "#/definitions/WindowsSandboxModeToml"
"allOf": [
{
"$ref": "#/definitions/WindowsSandboxModeToml"
}
],
"default": null
},
"sandbox_private_desktop": {
"description": "Defaults to `true`. Set to `false` to launch the final sandboxed child process on `Winsta0\\\\Default` instead of a private desktop.",
@@ -3774,6 +3858,7 @@
"$ref": "#/definitions/AskForApproval"
}
],
"default": null,
"description": "Default approval policy for executing commands."
},
"approvals_reviewer": {
@@ -3782,6 +3867,7 @@
"$ref": "#/definitions/ApprovalsReviewer"
}
],
"default": null,
"description": "Configures who approval requests are routed to for review once they have been escalated. This does not disable separate safety checks such as ARC."
},
"apps": {
@@ -3896,6 +3982,7 @@
"$ref": "#/definitions/ThreadStoreToml"
}
],
"default": null,
"description": "Experimental / do not use. Selects the thread store implementation."
},
"experimental_thread_store_endpoint": {
@@ -4179,6 +4266,7 @@
"$ref": "#/definitions/UriBasedFileOpener"
}
],
"default": null,
"description": "Optional URI-based file opener. If set, citations to files in the model output will be hyperlinked using the specified URI scheme."
},
"forced_chatgpt_workspace_id": {
@@ -4339,10 +4427,20 @@
"type": "object"
},
"model_reasoning_effort": {
"$ref": "#/definitions/ReasoningEffort"
"allOf": [
{
"$ref": "#/definitions/ReasoningEffort"
}
],
"default": null
},
"model_reasoning_summary": {
"$ref": "#/definitions/ReasoningSummary"
"allOf": [
{
"$ref": "#/definitions/ReasoningSummary"
}
],
"default": null
},
"model_supports_reasoning_summaries": {
"description": "Override to force-enable reasoning summaries for the configured model.",
@@ -4354,6 +4452,7 @@
"$ref": "#/definitions/Verbosity"
}
],
"default": null,
"description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`)."
},
"notice": {
@@ -4403,10 +4502,16 @@
"$ref": "#/definitions/Personality"
}
],
"default": null,
"description": "Optionally specify a personality for the model"
},
"plan_mode_reasoning_effort": {
"$ref": "#/definitions/ReasoningEffort"
"allOf": [
{
"$ref": "#/definitions/ReasoningEffort"
}
],
"default": null
},
"plugins": {
"additionalProperties": {
@@ -4476,6 +4581,7 @@
"$ref": "#/definitions/SandboxMode"
}
],
"default": null,
"description": "Sandbox mode to use."
},
"sandbox_workspace_write": {
@@ -4492,6 +4598,7 @@
"$ref": "#/definitions/ServiceTier"
}
],
"default": null,
"description": "Optional explicit service tier preference for new turns (`fast` or `flex`)."
},
"shell_environment_policy": {
@@ -4569,6 +4676,7 @@
"$ref": "#/definitions/WebSearchMode"
}
],
"default": null,
"description": "Controls the web search tool mode: disabled, cached, or live."
},
"windows": {

View File

@@ -23,8 +23,12 @@ use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
use codex_config::loader::load_config_layers_state;
use codex_config::loader::load_requirements_toml;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverable;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_config::version_for_toml;
use codex_exec_server::LOCAL_FS;
use codex_model_provider_info::WireApi;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::PermissionProfile;
@@ -33,6 +37,7 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::path::Path;
use tempfile::tempdir;
@@ -118,6 +123,368 @@ async fn returns_config_error_for_invalid_user_config_toml() {
assert_eq!(config_error, &expected_config_error);
}
#[tokio::test]
async fn invalid_enum_values_emit_warnings_without_poisoning_config() -> anyhow::Result<()> {
let tmp = tempdir().expect("tempdir");
let contents = r#"
model = "gpt-5-codex"
sandbox_mode = "make-it-so"
[tui]
notification_method = "loudly"
theme = "loudly"
[tools.web_search]
context_size = "galactic"
"#;
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
// The warning pass reads the final TOML value without mutating it, while
// field-level fallbacks let unrelated valid settings still deserialize.
let (effective_config, _config_toml, enum_warnings) =
layers.deserialize_effective_config_with_warnings()?;
let expected_config = toml::from_str::<TomlValue>(
r#"
model = "gpt-5-codex"
sandbox_mode = "make-it-so"
[tui]
notification_method = "loudly"
theme = "loudly"
[tools.web_search]
context_size = "galactic"
"#,
)
.expect("expected config should parse");
let expected_startup_warnings = vec![
"Ignoring invalid config value at sandbox_mode: \"make-it-so\"".to_string(),
"Ignoring invalid config value at tools.web_search.context_size: \"galactic\"".to_string(),
"Ignoring invalid config value at tui.notification_method: \"loudly\"".to_string(),
];
assert_eq!(
(effective_config, enum_warnings),
(expected_config, expected_startup_warnings)
);
Ok(())
}
#[tokio::test]
async fn nested_invalid_enum_values_emit_warnings_without_poisoning_config() -> anyhow::Result<()> {
let tmp = tempdir().expect("tempdir");
let contents = r#"
[model_providers.custom]
name = "Custom"
base_url = "https://example.invalid/v1"
wire_api = "telegraph"
[otel.exporter.otlp-http]
endpoint = "http://localhost:4318/v1/logs"
protocol = "xml"
[otel.trace_exporter.otlp-http]
endpoint = "http://localhost:4318/v1/traces"
protocol = "yaml"
"#;
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let (_effective_config, config_toml, enum_warnings) =
layers.deserialize_effective_config_with_warnings()?;
let otel = config_toml.otel.expect("otel config should deserialize");
let expected_warnings = BTreeSet::from([
"Ignoring invalid config value at model_providers.custom.wire_api: \"telegraph\""
.to_string(),
"Ignoring invalid config value at otel.exporter.otlp-http.protocol: \"xml\"".to_string(),
"Ignoring invalid config value at otel.trace_exporter.otlp-http.protocol: \"yaml\""
.to_string(),
]);
assert_eq!(
(
config_toml
.model_providers
.get("custom")
.map(|provider| provider.wire_api),
otel.exporter,
otel.trace_exporter,
enum_warnings.into_iter().collect::<BTreeSet<_>>(),
),
(Some(WireApi::Responses), None, None, expected_warnings)
);
Ok(())
}
#[tokio::test]
async fn invalid_project_and_tool_suggest_enums_warn_without_poisoning_config() -> anyhow::Result<()>
{
let tmp = tempdir().expect("tempdir");
let project_key = "/tmp/codex-invalid-enum-project";
let contents = format!(
r#"
[projects."{project_key}"]
trust_level = "semi-trusted"
[tool_suggest]
discoverables = [
{{ type = "plugin", id = "plugin_alpha@openai-curated" }},
{{ type = "spaceship", id = "bad_plugin" }},
]
disabled_tools = [
{{ type = "connector", id = "connector_calendar" }},
{{ type = "spaceship", id = "bad_connector" }},
]
"#
);
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let (_effective_config, config_toml, enum_warnings) =
layers.deserialize_effective_config_with_warnings()?;
let projects = config_toml.projects.expect("projects should deserialize");
let tool_suggest = config_toml
.tool_suggest
.expect("tool_suggest should deserialize");
let expected_warnings = BTreeSet::from([
format!(
"Ignoring invalid config value at projects.\"{project_key}\".trust_level: \"semi-trusted\""
),
"Ignoring invalid config value at tool_suggest.disabled_tools[1].type: \"spaceship\""
.to_string(),
"Ignoring invalid config value at tool_suggest.discoverables[1].type: \"spaceship\""
.to_string(),
]);
assert_eq!(
(
projects
.get(project_key)
.and_then(|project| project.trust_level),
tool_suggest.discoverables,
tool_suggest.disabled_tools,
enum_warnings.into_iter().collect::<BTreeSet<_>>(),
),
(
None,
vec![ToolSuggestDiscoverable {
kind: ToolSuggestDiscoverableType::Plugin,
id: "plugin_alpha@openai-curated".to_string(),
}],
vec![ToolSuggestDisabledTool::connector("connector_calendar")],
expected_warnings,
)
);
Ok(())
}
#[tokio::test]
async fn invalid_higher_precedence_enum_warns_without_mutating_merge() -> anyhow::Result<()> {
let tmp = tempdir().expect("tempdir");
let system_config_path = tmp.path().join("system_config.toml");
std::fs::write(&system_config_path, r#"sandbox_mode = "workspace-write""#)
.expect("write system config");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
r#"sandbox_mode = "make-it-so""#,
)
.expect("write user config");
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides {
system_config_path: Some(system_config_path),
..LoaderOverrides::without_managed_config_for_tests()
},
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let (effective_config, config_toml, enum_warnings) =
layers.deserialize_effective_config_with_warnings()?;
assert_eq!(
(
effective_config.get("sandbox_mode"),
config_toml.sandbox_mode,
enum_warnings,
),
(
Some(&TomlValue::String("make-it-so".to_string())),
None,
vec!["Ignoring invalid config value at sandbox_mode: \"make-it-so\"".to_string()],
)
);
Ok(())
}
#[tokio::test]
async fn invalid_enum_values_do_not_poison_relative_path_resolution() -> anyhow::Result<()> {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
r#"
model_instructions_file = "instructions.md"
sandbox_mode = "make-it-so"
[tui]
notification_method = "loudly"
"#,
)
.expect("write config");
std::fs::write(tmp.path().join("instructions.md"), "resolved instructions")
.expect("write instructions");
let config = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(tmp.path().to_path_buf()),
..Default::default()
})
.build()
.await?;
assert_eq!(
(config.base_instructions.as_deref(), config.startup_warnings,),
(
Some("resolved instructions"),
vec![
"Ignoring invalid config value at sandbox_mode: \"make-it-so\"".to_string(),
"Ignoring invalid config value at tui.notification_method: \"loudly\"".to_string(),
],
)
);
Ok(())
}
#[tokio::test]
async fn invalid_non_enum_union_value_does_not_delete_tui_table() -> anyhow::Result<()> {
let tmp = tempdir().expect("tempdir");
let contents = r#"
[tui]
notifications = "sometimes"
theme = "sometimes"
"#;
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let (effective_config, _config_toml, enum_warnings) =
layers.deserialize_effective_config_with_warnings()?;
let expected = (
toml::from_str::<TomlValue>(
r#"
[tui]
notifications = "sometimes"
theme = "sometimes"
"#,
)
.expect("expected config should parse"),
Vec::<String>::new(),
);
assert_eq!((effective_config, enum_warnings), expected);
Ok(())
}
#[tokio::test]
async fn invalid_enum_warning_paths_preserve_literal_dotted_keys() -> anyhow::Result<()> {
let tmp = tempdir().expect("tempdir");
let contents = r#"
[profiles."alpha.beta"]
model = "gpt-5-codex"
sandbox_mode = "hold-my-coffee"
"#;
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let (effective_config, _config_toml, enum_warnings) =
layers.deserialize_effective_config_with_warnings()?;
let expected = (
toml::from_str::<TomlValue>(
r#"
[profiles."alpha.beta"]
model = "gpt-5-codex"
sandbox_mode = "hold-my-coffee"
"#,
)
.expect("expected config should parse"),
vec![
"Ignoring invalid config value at profiles.\"alpha.beta\".sandbox_mode: \
\"hold-my-coffee\""
.to_string(),
],
);
assert_eq!((effective_config, enum_warnings), expected);
Ok(())
}
#[tokio::test]
async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> {
let tmp = tempdir().expect("tempdir");

View File

@@ -8723,6 +8723,55 @@ save_fields_resolved_from_model_catalog = false
Ok(())
}
#[tokio::test]
async fn debug_config_lockfile_load_path_reports_invalid_enum_warnings() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let lock_path = codex_home.path().join("session.config.lock.toml");
std::fs::write(
&lock_path,
format!(
r#"version = {}
codex_version = "older-version"
[config]
model = "gpt-5-codex"
sandbox_mode = "make-it-so"
"#,
crate::config_lock::CONFIG_LOCK_VERSION
),
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
format!(
r#"[debug.config_lockfile]
load_path = '{}'
allow_codex_version_mismatch = true
"#,
lock_path.display()
),
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
(
config.model,
config.config_lock_toml.is_some(),
config.startup_warnings,
),
(
Some("gpt-5-codex".to_string()),
true,
vec!["Ignoring invalid config value at sandbox_mode: \"make-it-so\"".to_string()],
)
);
Ok(())
}
#[tokio::test]
async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -9895,17 +9944,15 @@ fn test_tui_notification_condition_always() {
}
#[test]
fn test_tui_notification_condition_rejects_unknown_value() {
fn test_tui_notification_condition_defaults_unknown_value() {
let toml = r#"
[tui]
notification_condition = "background"
"#;
let err = toml::from_str::<RootTomlTest>(toml).expect_err("reject unknown condition");
let err = err.to_string();
assert!(
err.contains("unknown variant `background`")
&& err.contains("unfocused")
&& err.contains("always"),
"unexpected error: {err}"
let parsed: RootTomlTest =
toml::from_str(toml).expect("deserialize invalid notification condition as default");
assert_eq!(
parsed.tui.notifications.condition,
NotificationCondition::Unfocused
);
}

View File

@@ -971,14 +971,17 @@ impl ConfigBuilder {
.unwrap_or(&codex_config::NoopThreadConfigLoader),
)
.await?;
let merged_toml = config_layer_stack.effective_config();
// Note that each layer in ConfigLayerStack should have resolved
// relative paths to absolute paths based on the parent folder of the
// respective config file, so we should be safe to deserialize without
// AbsolutePathBufGuard here.
let config_toml: ConfigToml = match merged_toml.try_into() {
Ok(config_toml) => config_toml,
//
// Invalid enum-valued settings default at the typed field boundary.
// The warning scan reads the final merged TOML view without mutating it.
let (_merged_toml, config_toml, enum_warnings) = match config_layer_stack
.deserialize_effective_config_with_warnings()
{
Ok(result) => result,
Err(err) => {
if let Some(config_error) = codex_config::first_layer_config_error::<ConfigToml>(
&config_layer_stack,
@@ -995,6 +998,7 @@ impl ConfigBuilder {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err));
}
};
let config_layer_stack = config_layer_stack.with_additional_startup_warnings(enum_warnings);
let config_lock_settings = config_toml
.debug
.as_ref()
@@ -1008,7 +1012,8 @@ impl ConfigBuilder {
let save_fields_resolved_from_model_catalog = config_lock_settings
.and_then(|config_lock| config_lock.save_fields_resolved_from_model_catalog)
.unwrap_or(true);
let lockfile_toml = read_config_lock_from_path(config_lock_load_path).await?;
let (lockfile_toml, lock_enum_warnings) =
read_config_lock_from_path(config_lock_load_path).await?;
let expected_lock_config = lockfile_toml.clone();
let lock_layer = lock_layer_from_config(config_lock_load_path, &lockfile_toml)?;
let lock_config_toml = config_without_lock_controls(&lockfile_toml.config);
@@ -1016,7 +1021,8 @@ impl ConfigBuilder {
vec![lock_layer],
config_layer_stack.requirements().clone(),
config_layer_stack.requirements_toml().clone(),
)?;
)?
.with_additional_startup_warnings(lock_enum_warnings);
let mut config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
lock_config_toml,
@@ -1299,11 +1305,17 @@ pub async fn load_config_as_toml_with_cli_and_loader_overrides(
)
.await?;
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}");
e
})?;
let _guard = AbsolutePathBufGuard::new(codex_home);
// This helper returns ConfigToml directly, so enum warnings are intentionally
// not surfaced here. The full ConfigBuilder path is responsible for
// attaching them to startup warnings.
let (_merged_toml, cfg, _enum_warnings) = config_layer_stack
.deserialize_effective_config_with_warnings()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
.map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
e
})?;
Ok(cfg)
}

View File

@@ -4,10 +4,12 @@ use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::config_toml::ConfigToml;
use codex_config::invalid_enum_warnings;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Serialize;
use serde::de::DeserializeOwned;
use similar::TextDiff;
use toml::Value as TomlValue;
pub(crate) const CONFIG_LOCK_VERSION: u32 = 1;
@@ -16,23 +18,37 @@ pub(crate) struct ConfigLockReplayOptions {
pub allow_codex_version_mismatch: bool,
}
/// Read a config lock and collect warnings from its raw replay config.
pub(crate) async fn read_config_lock_from_path(
path: &AbsolutePathBuf,
) -> io::Result<ConfigLockfileToml> {
) -> io::Result<(ConfigLockfileToml, Vec<String>)> {
let contents = tokio::fs::read_to_string(path).await.map_err(|err| {
config_lock_error(format!(
"failed to read config lock file {}: {err}",
path.display()
))
})?;
let lockfile: ConfigLockfileToml = toml::from_str(&contents).map_err(|err| {
let raw_lockfile: TomlValue = toml::from_str(&contents).map_err(|err| {
config_lock_error(format!(
"failed to parse config lock file {}: {err}",
path.display()
))
})?;
// Lockfiles store an effective `ConfigToml` under `[config]`. Scan that raw
// subtree before typed deserialization applies field-level enum fallbacks,
// then keep the warning paths relative to ordinary config keys.
let enum_warnings = raw_lockfile
.get("config")
.map(invalid_enum_warnings)
.unwrap_or_default();
let lockfile: ConfigLockfileToml = raw_lockfile.try_into().map_err(|err| {
config_lock_error(format!(
"failed to parse config lock file {}: {err}",
path.display()
))
})?;
validate_config_lock_metadata_shape(&lockfile)?;
Ok(lockfile)
Ok((lockfile, enum_warnings))
}
pub(crate) fn config_lockfile(config: ConfigToml) -> ConfigLockfileToml {

View File

@@ -0,0 +1,49 @@
use codex_config::CONFIG_TOML_FILE;
use codex_protocol::config_types::Verbosity;
use codex_protocol::protocol::AskForApproval;
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 INVALID_SANDBOX_WARNING: &str =
"Ignoring invalid config value at sandbox_mode: \"hyperdrive\"";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn invalid_enum_config_emits_startup_warning_and_keeps_valid_settings() {
let server = start_mock_server().await;
let config_toml = r#"
model = "gpt-4o"
approval_policy = "never"
sandbox_mode = "hyperdrive"
model_verbosity = "high"
"#;
let mut builder = test_codex().with_pre_build_hook(move |home| {
std::fs::write(home.join(CONFIG_TOML_FILE), config_toml).expect("seed config.toml");
});
let test = builder.build(&server).await.expect("create conversation");
assert_eq!(
(
test.session_configured.model.as_str(),
test.session_configured.approval_policy,
test.config.model_verbosity,
),
("gpt-4o", AskForApproval::Never, Some(Verbosity::High)),
);
let warning = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::Warning(WarningEvent { message }) if message == INVALID_SANDBOX_WARNING
)
})
.await;
let EventMsg::Warning(WarningEvent { message }) = warning else {
panic!("expected invalid config warning");
};
assert_eq!(message, INVALID_SANDBOX_WARNING);
}

View File

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