mirror of
https://github.com/openai/codex.git
synced 2026-05-09 05:42:32 +00:00
Compare commits
50 Commits
dev/cc/new
...
config-len
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d98019b0ac | ||
|
|
fb66343740 | ||
|
|
3f55561327 | ||
|
|
dbb15289c4 | ||
|
|
a21345c5f9 | ||
|
|
f366ac3326 | ||
|
|
b487b3592d | ||
|
|
6eb417e76c | ||
|
|
e6c51d58b4 | ||
|
|
b3f8ccf7bb | ||
|
|
28739bb664 | ||
|
|
bf035fcc0d | ||
|
|
6c6dfa77fb | ||
|
|
c9c16fca70 | ||
|
|
4d88b9961a | ||
|
|
0e3631c931 | ||
|
|
bf4737b520 | ||
|
|
bd84525f3c | ||
|
|
e3de954486 | ||
|
|
5684d7b5bd | ||
|
|
646f9baf7e | ||
|
|
a81898ca68 | ||
|
|
634718a1d3 | ||
|
|
85bfc885ac | ||
|
|
3e4c052664 | ||
|
|
f18c4565ea | ||
|
|
2c8de33e0a | ||
|
|
22ca0e68c0 | ||
|
|
f4a38f2555 | ||
|
|
0abc304330 | ||
|
|
73815250e5 | ||
|
|
a8ce48a160 | ||
|
|
e4ea70f95f | ||
|
|
23f42ddeae | ||
|
|
e1d8a67d5d | ||
|
|
c1331e6da6 | ||
|
|
883c70fcce | ||
|
|
b1c09a4426 | ||
|
|
01c513fcaa | ||
|
|
4007c4c1ec | ||
|
|
da01f35b00 | ||
|
|
d595ef0604 | ||
|
|
9bc30cf95f | ||
|
|
2e1b882d19 | ||
|
|
e064d502ae | ||
|
|
c49e2318a3 | ||
|
|
098f4aa6ef | ||
|
|
2a9061ba5e | ||
|
|
d6e2ff811b | ||
|
|
5e1dbff17e |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2391,6 +2391,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_with",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
521
codex-rs/config/src/enum_warnings.rs
Normal file
521
codex-rs/config/src/enum_warnings.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
49
codex-rs/core/tests/suite/config_warnings.rs
Normal file
49
codex-rs/core/tests/suite/config_warnings.rs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user