Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Bolin
7726a0dcac config: add strict config parsing 2026-05-04 09:51:39 -07:00
42 changed files with 1192 additions and 178 deletions

1
MODULE.bazel.lock generated
View File

@@ -1482,6 +1482,7 @@
"serde_derive_1.0.228": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.81\"}],\"features\":{\"default\":[],\"deserialize_in_place\":[]}}",
"serde_derive_internals_0.29.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{}}",
"serde_html_form_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches2\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"divan\",\"req\":\"^0.1.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"form_urlencoded\",\"req\":\"^1.0.1\"},{\"default_features\":false,\"name\":\"indexmap\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.45.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.9\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"}],\"features\":{\"default\":[\"ryu\",\"std\"],\"std\":[]}}",
"serde_ignored_0.1.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.110\"}],\"features\":{}}",
"serde_json_1.0.149": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.11\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.2.3\"},{\"kind\":\"dev\",\"name\":\"indoc\",\"req\":\"^2.0.2\"},{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.10\"},{\"default_features\":false,\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_stacker\",\"req\":\"^0.1.8\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.108\"},{\"name\":\"zmij\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"serde_core/alloc\"],\"arbitrary_precision\":[],\"default\":[\"std\"],\"float_roundtrip\":[],\"preserve_order\":[\"indexmap\",\"std\"],\"raw_value\":[],\"std\":[\"memchr/std\",\"serde_core/std\"],\"unbounded_depth\":[]}}",
"serde_path_to_error_0.1.20": "{\"dependencies\":[{\"name\":\"itoa\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"}],\"features\":{}}",
"serde_repr_0.1.20": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.100\"},{\"name\":\"syn\",\"req\":\"^2.0.46\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.81\"}],\"features\":{}}",

19
codex-rs/Cargo.lock generated
View File

@@ -2367,6 +2367,7 @@ dependencies = [
"prost 0.14.3",
"schemars 0.8.22",
"serde",
"serde_ignored",
"serde_json",
"serde_path_to_error",
"sha2",
@@ -5323,7 +5324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -8871,7 +8872,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.21.7",
"base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.4.0",
@@ -9339,7 +9340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11510,6 +11511,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_ignored"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -13983,7 +13994,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -344,6 +344,7 @@ seccompiler = "0.5.0"
semver = "1.0"
sentry = "0.46.0"
serde = "1"
serde_ignored = "0.1.14"
serde_json = "1"
serde_path_to_error = "0.1.20"
serde_with = "3.17"

View File

@@ -337,6 +337,8 @@ pub struct InProcessClientStartArgs {
pub cli_overrides: Vec<(String, TomlValue)>,
/// Loader override knobs used by config API paths.
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Feedback sink used by app-server/core telemetry and logs.
@@ -400,6 +402,7 @@ impl InProcessClientStartArgs {
config: self.config,
cli_overrides: self.cli_overrides,
loader_overrides: self.loader_overrides,
strict_config: self.strict_config,
cloud_requirements: self.cloud_requirements,
thread_config_loader,
feedback: self.feedback,
@@ -980,6 +983,7 @@ mod tests {
config: Arc::new(build_test_config().await),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
@@ -2054,6 +2058,7 @@ mod tests {
config: config.clone(),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,
@@ -2093,6 +2098,7 @@ mod tests {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
log_db: None,

View File

@@ -2125,6 +2125,7 @@ impl TestClientTracing {
async fn initialize(config_overrides: &[String]) -> Result<Self> {
let cli_kv_overrides = CliConfigOverrides {
raw_overrides: config_overrides.to_vec(),
strict_config: false,
}
.parse_overrides()
.map_err(|e| anyhow::anyhow!("error parsing -c overrides: {e}"))?;

View File

@@ -30,6 +30,7 @@ pub(crate) struct ConfigManager {
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<RwLock<Arc<dyn ThreadConfigLoader>>>,
@@ -40,6 +41,7 @@ impl ConfigManager {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
arg0_paths: Arg0DispatchPaths,
thread_config_loader: Arc<dyn ThreadConfigLoader>,
@@ -49,6 +51,7 @@ impl ConfigManager {
cli_overrides: Arc::new(RwLock::new(cli_overrides)),
runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())),
loader_overrides,
strict_config,
cloud_requirements: Arc::new(RwLock::new(cloud_requirements)),
arg0_paths,
thread_config_loader: Arc::new(RwLock::new(thread_config_loader)),
@@ -202,6 +205,7 @@ impl ConfigManager {
.codex_home(self.codex_home.clone())
.cli_overrides(merged_cli_overrides)
.loader_overrides(self.loader_overrides.clone())
.strict_config(self.strict_config)
.harness_overrides(typesafe_overrides)
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
@@ -230,7 +234,10 @@ impl ConfigManager {
&self.codex_home,
cwd,
&self.current_cli_overrides(),
self.loader_overrides.clone(),
codex_config::ConfigLoadOptions {
loader_overrides: self.loader_overrides.clone(),
strict_config: self.strict_config,
},
self.current_cloud_requirements(),
thread_config_loader.as_ref(),
)
@@ -265,6 +272,7 @@ impl ConfigManager {
codex_home,
cli_overrides,
loader_overrides,
/*strict_config*/ false,
cloud_requirements,
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),

View File

@@ -118,6 +118,8 @@ pub struct InProcessStartArgs {
pub cli_overrides: Vec<(String, TomlValue)>,
/// Loader override knobs used by config API paths.
pub loader_overrides: LoaderOverrides,
/// Whether config API paths should reject unknown config fields.
pub strict_config: bool,
/// Preloaded cloud requirements provider.
pub cloud_requirements: CloudRequirementsLoader,
/// Loader used to fetch typed thread config sources before a thread starts.
@@ -403,6 +405,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
args.config.codex_home.to_path_buf(),
args.cli_overrides,
args.loader_overrides,
args.strict_config,
args.cloud_requirements,
args.arg0_paths.clone(),
args.thread_config_loader,
@@ -757,6 +760,7 @@ mod tests {
config: Arc::new(build_test_config().await),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),

View File

@@ -416,6 +416,7 @@ pub async fn run_main_with_transport_options(
auth: AppServerWebsocketAuthSettings,
runtime_options: AppServerRuntimeOptions,
) -> IoResult<()> {
let strict_config = cli_config_overrides.strict_config;
let environment_manager = Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(
ExecServerRuntimePaths::from_optional_paths(
@@ -444,6 +445,7 @@ pub async fn run_main_with_transport_options(
codex_home.to_path_buf(),
cli_kv_overrides.clone(),
loader_overrides,
strict_config,
Default::default(),
arg0_paths.clone(),
Arc::new(NoopThreadConfigLoader),

View File

@@ -271,6 +271,7 @@ async fn build_test_processor(
config.codex_home.to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),

View File

@@ -465,6 +465,7 @@ mod thread_processor_behavior_tests {
temp_dir.path().to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(StaticThreadConfigLoader::new(vec![

View File

@@ -200,6 +200,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),

View File

@@ -76,6 +76,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(NoopThreadConfigLoader),
feedback: CodexFeedback::new(),

View File

@@ -296,6 +296,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -359,6 +360,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
@@ -443,6 +445,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_requirements: CloudRequirementsLoader::default(),
thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
feedback: CodexFeedback::new(),

View File

@@ -1,7 +1,7 @@
use std::path::PathBuf;
use clap::Parser;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_git_utils::ApplyGitRequest;
use codex_git_utils::apply_git_patch;
use codex_utils_cli::CliConfigOverrides;
@@ -23,13 +23,15 @@ pub async fn run_apply_command(
apply_cli: ApplyCommand,
cwd: Option<PathBuf>,
) -> anyhow::Result<()> {
let config = Config::load_with_cli_overrides(
apply_cli
.config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?,
)
.await?;
let cli_overrides = apply_cli
.config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.strict_config(apply_cli.config_overrides.strict_config)
.build()
.await?;
let task_response = get_task(&config, apply_cli.task_id).await?;
apply_diff_from_task(task_response, cwd).await

View File

@@ -190,6 +190,7 @@ async fn run_command_under_sandbox(
.map_err(anyhow::Error::msg)?,
codex_linux_sandbox_exe,
config_options,
config_overrides.strict_config,
)
.await?;
@@ -629,12 +630,14 @@ async fn load_debug_sandbox_config(
cli_overrides: Vec<(String, TomlValue)>,
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
strict_config: bool,
) -> anyhow::Result<Config> {
load_debug_sandbox_config_with_codex_home(
cli_overrides,
codex_linux_sandbox_exe,
options,
/*codex_home*/ None,
strict_config,
)
.await
}
@@ -644,6 +647,7 @@ async fn load_debug_sandbox_config_with_codex_home(
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
codex_home: Option<PathBuf>,
strict_config: bool,
) -> anyhow::Result<Config> {
let DebugSandboxConfigOptions {
permissions_profile,
@@ -673,6 +677,7 @@ async fn load_debug_sandbox_config_with_codex_home(
},
codex_home.clone(),
managed_requirements_mode,
strict_config,
)
.await?;
@@ -690,6 +695,7 @@ async fn load_debug_sandbox_config_with_codex_home(
},
codex_home,
managed_requirements_mode,
strict_config,
)
.await
.map_err(Into::into)
@@ -700,14 +706,16 @@ async fn build_debug_sandbox_config(
harness_overrides: ConfigOverrides,
codex_home: Option<PathBuf>,
managed_requirements_mode: ManagedRequirementsMode,
strict_config: bool,
) -> std::io::Result<Config> {
let mut builder = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.harness_overrides(harness_overrides);
if let ManagedRequirementsMode::Ignore = managed_requirements_mode {
.harness_overrides(harness_overrides)
.strict_config(strict_config);
if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) {
builder = builder.loader_overrides(LoaderOverrides {
ignore_managed_requirements: true,
..Default::default()
..LoaderOverrides::default()
});
}
if let Some(codex_home) = codex_home {
@@ -776,6 +784,7 @@ mod tests {
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
let legacy_config = build_debug_sandbox_config(
@@ -786,6 +795,7 @@ mod tests {
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -798,6 +808,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
@@ -833,6 +844,7 @@ mod tests {
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
let read_only_config = build_debug_sandbox_config(
@@ -843,6 +855,7 @@ mod tests {
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -855,6 +868,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
@@ -898,6 +912,7 @@ mod tests {
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -910,6 +925,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
@@ -935,6 +951,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
@@ -968,6 +985,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
@@ -997,6 +1015,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;
@@ -1008,6 +1027,7 @@ mod tests {
ConfigOverrides::default(),
Some(codex_home.path().to_path_buf()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
@@ -1033,6 +1053,7 @@ mod tests {
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.await?;

View File

@@ -10,6 +10,7 @@
use codex_app_server_protocol::AuthMode;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
use codex_login::ServerOptions;
@@ -431,7 +432,12 @@ async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config
}
};
match Config::load_with_cli_overrides(cli_overrides).await {
match ConfigBuilder::default()
.cli_overrides(cli_overrides)
.strict_config(cli_config_overrides.strict_config)
.build()
.await
{
Ok(config) => config,
Err(e) => {
eprintln!("Error loading configuration: {e}");

View File

@@ -52,7 +52,7 @@ use crate::marketplace_cmd::MarketplaceCli;
use crate::mcp_cmd::McpCli;
use codex_core::build_models_manager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
@@ -1209,11 +1209,12 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
..Default::default()
};
let config = Config::load_with_cli_overrides_and_harness_overrides(
cli_kv_overrides,
overrides,
)
.await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.strict_config(root_config_overrides.strict_config)
.build()
.await?;
let mut rows = Vec::with_capacity(FEATURES.len());
let mut name_width = 0;
let mut stage_width = 0;
@@ -1372,8 +1373,12 @@ async fn run_debug_prompt_input_command(
additional_writable_roots: shared.add_dir,
..Default::default()
};
let config =
Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.strict_config(root_config_overrides.strict_config)
.build()
.await?;
let mut input = shared
.images
@@ -1404,7 +1409,11 @@ async fn run_debug_models_command(
let cli_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(cli_overrides).await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.strict_config(root_config_overrides.strict_config)
.build()
.await?;
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await;
let models_manager = build_models_manager(&config, auth_manager);
@@ -1429,8 +1438,12 @@ async fn run_debug_clear_memories_command(
config_profile: interactive.config_profile.clone(),
..Default::default()
};
let config =
Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?;
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.strict_config(root_config_overrides.strict_config)
.build()
.await?;
let state_path = state_db_path(config.sqlite_home.as_path());
let mut cleared_state_db = false;
@@ -1465,9 +1478,7 @@ fn prepend_config_flags(
subcommand_config_overrides: &mut CliConfigOverrides,
cli_config_overrides: CliConfigOverrides,
) {
subcommand_config_overrides
.raw_overrides
.splice(0..0, cli_config_overrides.raw_overrides);
subcommand_config_overrides.prepend_root_overrides(cli_config_overrides);
}
fn reject_remote_mode_for_subcommand(
@@ -1672,6 +1683,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli)
.config_overrides
.raw_overrides
.extend(config_overrides.raw_overrides);
interactive.config_overrides.strict_config |= config_overrides.strict_config;
}
fn print_completion(cmd: CompletionCommand) {
@@ -2150,6 +2162,7 @@ mod tests {
"my-profile",
"-C",
"/tmp",
"--strict-config",
"-i",
"/tmp/a.png,/tmp/b.png",
]
@@ -2172,6 +2185,7 @@ mod tests {
Some(std::path::Path::new("/tmp"))
);
assert!(interactive.web_search);
assert!(interactive.config_overrides.strict_config);
let has_a = interactive
.images
.iter()
@@ -2605,4 +2619,38 @@ mod tests {
.expect_err("feature should be rejected");
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn strict_config_with_unknown_enable_errors() {
let err = strict_config_feature_toggle_error(["--enable", "does_not_exist"].as_ref());
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn strict_config_with_unknown_disable_errors() {
let err = strict_config_feature_toggle_error(["--disable", "does_not_exist"].as_ref());
assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist");
}
#[test]
fn strict_config_with_compound_enable_errors() {
let err = strict_config_feature_toggle_error(
["--enable", "multi_agent_v2.subagent_usage_hint_text"].as_ref(),
);
assert_eq!(
err.to_string(),
"Unknown feature flag: multi_agent_v2.subagent_usage_hint_text"
);
}
fn strict_config_feature_toggle_error(args: &[&str]) -> anyhow::Error {
let cli_args = std::iter::once("codex")
.chain(std::iter::once("--strict-config"))
.chain(args.iter().copied());
let cli = MultitoolCli::try_parse_from(cli_args).expect("parse should succeed");
assert!(cli.config_overrides.strict_config);
cli.feature_toggles
.to_overrides()
.expect_err("feature should be rejected")
}
}

View File

@@ -2,7 +2,7 @@ use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::find_codex_home;
use codex_core_plugins::PluginMarketplaceUpgradeOutcome;
use codex_core_plugins::PluginsManager;
@@ -70,10 +70,13 @@ impl MarketplaceCli {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let strict_config = config_overrides.strict_config;
match subcommand {
MarketplaceSubcommand::Add(args) => run_add(args).await?,
MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?,
MarketplaceSubcommand::Upgrade(args) => {
run_upgrade(overrides, strict_config, args).await?
}
MarketplaceSubcommand::Remove(args) => run_remove(args).await?,
}
@@ -120,10 +123,14 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
async fn run_upgrade(
overrides: Vec<(String, toml::Value)>,
strict_config: bool,
args: UpgradeMarketplaceArgs,
) -> Result<()> {
let UpgradeMarketplaceArgs { marketplace_name } = args;
let config = Config::load_with_cli_overrides(overrides)
let config = ConfigBuilder::default()
.cli_overrides(overrides)
.strict_config(strict_config)
.build()
.await
.context("failed to load configuration")?;
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;

View File

@@ -11,6 +11,7 @@ use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::McpManager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::find_codex_home;
use codex_core::config::load_global_mcp_servers;
@@ -188,6 +189,18 @@ impl McpCli {
}
}
async fn load_config(config_overrides: &CliConfigOverrides) -> Result<Config> {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
ConfigBuilder::default()
.cli_overrides(overrides)
.strict_config(config_overrides.strict_config)
.build()
.await
.context("failed to load configuration")
}
/// Preserve compatibility with servers that still expect the legacy empty-scope
/// OAuth request. If a discovered-scope request is rejected by the provider,
/// retry the login flow once without scopes.
@@ -237,13 +250,7 @@ async fn perform_oauth_login_retry_without_scopes(
}
async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> {
// Validate any provided overrides even though they are not currently applied.
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let config = load_config(config_overrides).await?;
let AddArgs {
name,
@@ -388,12 +395,7 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr
}
async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let config = load_config(config_overrides).await?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));
@@ -441,12 +443,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
}
async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let config = load_config(config_overrides).await?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));
@@ -473,12 +470,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
}
async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let config = load_config(config_overrides).await?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));
@@ -728,12 +720,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
}
async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> {
let overrides = config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let config = load_config(config_overrides).await?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));

View File

@@ -11,6 +11,32 @@ fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
Ok(cmd)
}
#[test]
fn strict_config_rejects_unknown_config_override() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["--strict-config", "-c", "foo=bar", "features", "list"])
.assert()
.failure()
.stderr(contains("unknown configuration field"));
Ok(())
}
#[test]
fn strict_config_rejects_unknown_cloud_config_override() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["--strict-config", "-c", "foo=bar", "cloud", "list"])
.assert()
.failure()
.stderr(contains("unknown configuration field"));
Ok(())
}
#[tokio::test]
async fn features_enable_writes_feature_flag_to_config() -> Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -13,6 +13,7 @@ use codex_cloud_tasks_client::TaskStatus;
use codex_git_utils::current_branch_name;
use codex_git_utils::default_branch_name;
use codex_login::default_client::get_codex_user_agent;
use codex_utils_cli::CliConfigOverrides;
use owo_colors::OwoColorize;
use owo_colors::Stream;
use std::cmp::Ordering;
@@ -40,7 +41,10 @@ struct BackendContext {
base_url: String,
}
async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext> {
async fn init_backend(
user_agent_suffix: &str,
config_overrides: &CliConfigOverrides,
) -> anyhow::Result<BackendContext> {
#[cfg(debug_assertions)]
let use_mock = matches!(
std::env::var("CODEX_CLOUD_TASKS_MODE").ok().as_deref(),
@@ -53,6 +57,9 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
#[cfg(debug_assertions)]
if use_mock {
if config_overrides.strict_config {
let _ = util::load_auth_manager(Some(base_url.clone()), config_overrides).await?;
}
return Ok(BackendContext {
backend: Arc::new(codex_cloud_tasks_mock_client::MockClient),
base_url,
@@ -68,11 +75,8 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
};
append_error_log(format!("startup: base_url={base_url} path_style={style}"));
let auth_manager = util::load_auth_manager(Some(base_url.clone())).await;
let auth = match auth_manager.as_ref() {
Some(manager) => manager.auth().await,
None => None,
};
let auth_manager = util::load_auth_manager(Some(base_url.clone()), config_overrides).await?;
let auth = auth_manager.auth().await;
let auth = match auth {
Some(auth) => auth,
None => {
@@ -154,14 +158,17 @@ async fn resolve_git_ref_with_git_info(
}
}
async fn run_exec_command(args: crate::cli::ExecCommand) -> anyhow::Result<()> {
async fn run_exec_command(
args: crate::cli::ExecCommand,
config_overrides: &CliConfigOverrides,
) -> anyhow::Result<()> {
let crate::cli::ExecCommand {
query,
environment,
branch,
attempts,
} = args;
let ctx = init_backend("codex_cloud_tasks_exec").await?;
let ctx = init_backend("codex_cloud_tasks_exec", config_overrides).await?;
let prompt = resolve_query_input(query)?;
let env_id = resolve_environment_id(&ctx, &environment).await?;
let git_ref = resolve_git_ref(branch.as_ref()).await;
@@ -490,8 +497,11 @@ fn format_task_list_lines(
lines
}
async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_status").await?;
async fn run_status_command(
args: crate::cli::StatusCommand,
config_overrides: &CliConfigOverrides,
) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_status", config_overrides).await?;
let task_id = parse_task_id(&args.task_id)?;
let summary =
codex_cloud_tasks_client::CloudBackend::get_task_summary(&*ctx.backend, task_id).await?;
@@ -506,8 +516,11 @@ async fn run_status_command(args: crate::cli::StatusCommand) -> anyhow::Result<(
Ok(())
}
async fn run_list_command(args: crate::cli::ListCommand) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_list").await?;
async fn run_list_command(
args: crate::cli::ListCommand,
config_overrides: &CliConfigOverrides,
) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_list", config_overrides).await?;
let env_filter = if let Some(env) = args.environment {
Some(resolve_environment_id(&ctx, &env).await?)
} else {
@@ -573,8 +586,11 @@ async fn run_list_command(args: crate::cli::ListCommand) -> anyhow::Result<()> {
Ok(())
}
async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_diff").await?;
async fn run_diff_command(
args: crate::cli::DiffCommand,
config_overrides: &CliConfigOverrides,
) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_diff", config_overrides).await?;
let task_id = parse_task_id(&args.task_id)?;
let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?;
let selected = select_attempt(&attempts, args.attempt)?;
@@ -582,8 +598,11 @@ async fn run_diff_command(args: crate::cli::DiffCommand) -> anyhow::Result<()> {
Ok(())
}
async fn run_apply_command(args: crate::cli::ApplyCommand) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_apply").await?;
async fn run_apply_command(
args: crate::cli::ApplyCommand,
config_overrides: &CliConfigOverrides,
) -> anyhow::Result<()> {
let ctx = init_backend("codex_cloud_tasks_apply", config_overrides).await?;
let task_id = parse_task_id(&args.task_id)?;
let attempts = collect_attempt_diffs(&*ctx.backend, &task_id).await?;
let selected = select_attempt(&attempts, args.attempt)?;
@@ -729,16 +748,19 @@ fn spawn_apply(
/// Entry point for the `codex cloud` subcommand.
pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
if let Some(command) = cli.command {
let Cli {
config_overrides,
command,
} = cli;
if let Some(command) = command {
return match command {
crate::cli::Command::Exec(args) => run_exec_command(args).await,
crate::cli::Command::Status(args) => run_status_command(args).await,
crate::cli::Command::List(args) => run_list_command(args).await,
crate::cli::Command::Apply(args) => run_apply_command(args).await,
crate::cli::Command::Diff(args) => run_diff_command(args).await,
crate::cli::Command::Exec(args) => run_exec_command(args, &config_overrides).await,
crate::cli::Command::Status(args) => run_status_command(args, &config_overrides).await,
crate::cli::Command::List(args) => run_list_command(args, &config_overrides).await,
crate::cli::Command::Apply(args) => run_apply_command(args, &config_overrides).await,
crate::cli::Command::Diff(args) => run_diff_command(args, &config_overrides).await,
};
}
let Cli { .. } = cli;
// Very minimal logging setup; mirrors other crates' pattern.
let default_level = "error";
@@ -753,7 +775,8 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
.try_init();
info!("Launching Cloud Tasks list UI");
let BackendContext { backend, .. } = init_backend("codex_cloud_tasks_tui").await?;
let BackendContext { backend, .. } =
init_backend("codex_cloud_tasks_tui", &config_overrides).await?;
let backend = backend;
// Terminal setup

View File

@@ -3,8 +3,10 @@ use chrono::Local;
use chrono::Utc;
use reqwest::header::HeaderMap;
use codex_core::config::Config;
use anyhow::anyhow;
use codex_core::config::ConfigBuilder;
use codex_login::AuthManager;
use codex_utils_cli::CliConfigOverrides;
pub fn set_user_agent_suffix(suffix: &str) {
if let Ok(mut guard) = codex_login::default_client::USER_AGENT_SUFFIX.lock() {
@@ -41,18 +43,25 @@ pub fn normalize_base_url(input: &str) -> String {
base_url
}
pub async fn load_auth_manager(chatgpt_base_url: Option<String>) -> Option<AuthManager> {
// TODO: pass in cli overrides once cloud tasks properly support them.
let config = Config::load_with_cli_overrides(Vec::new()).await.ok()?;
Some(
AuthManager::new(
config.codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config.cli_auth_credentials_store_mode,
chatgpt_base_url.or(Some(config.chatgpt_base_url)),
)
.await,
pub async fn load_auth_manager(
chatgpt_base_url: Option<String>,
config_overrides: &CliConfigOverrides,
) -> anyhow::Result<AuthManager> {
let cli_overrides = config_overrides
.parse_overrides()
.map_err(|err| anyhow!("Error parsing -c overrides: {err}"))?;
let config = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.strict_config(config_overrides.strict_config)
.build()
.await?;
Ok(AuthManager::new(
config.codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config.cli_auth_credentials_store_mode,
chatgpt_base_url.or(Some(config.chatgpt_base_url)),
)
.await)
}
/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`,
@@ -68,7 +77,8 @@ pub async fn build_chatgpt_headers() -> HeaderMap {
USER_AGENT,
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
);
if let Some(am) = load_auth_manager(/*chatgpt_base_url*/ None).await
let config_overrides = CliConfigOverrides::default();
if let Ok(am) = load_auth_manager(/*chatgpt_base_url*/ None, &config_overrides).await
&& let Some(auth) = am.auth().await
&& auth.uses_codex_backend()
{

View File

@@ -32,6 +32,7 @@ multimap = { workspace = true }
prost = "0.14.3"
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_ignored = { workspace = true }
serde_json = { workspace = true }
serde_path_to_error = { workspace = true }
sha2 = { workspace = true }

View File

@@ -86,6 +86,23 @@ impl std::error::Error for ConfigLoadError {
}
}
#[derive(Clone, Copy)]
pub(crate) enum ConfigDiagnosticSource<'a> {
Path(&'a Path),
#[cfg(any(target_os = "macos", test))]
DisplayName(&'a str),
}
impl ConfigDiagnosticSource<'_> {
pub(crate) fn to_path_buf(self) -> PathBuf {
match self {
ConfigDiagnosticSource::Path(path) => path.to_path_buf(),
#[cfg(any(target_os = "macos", test))]
ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name),
}
}
}
pub fn io_error_from_config_error(
kind: io::ErrorKind,
error: ConfigError,
@@ -98,21 +115,39 @@ pub fn config_error_from_toml(
path: impl AsRef<Path>,
contents: &str,
err: toml::de::Error,
) -> ConfigError {
config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err)
}
pub(crate) fn config_error_from_toml_for_source(
source: ConfigDiagnosticSource<'_>,
contents: &str,
err: toml::de::Error,
) -> ConfigError {
let range = err
.span()
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
ConfigError::new(path.as_ref().to_path_buf(), range, err.message())
ConfigError::new(source.to_path_buf(), range, err.message())
}
pub fn config_error_from_typed_toml<T: DeserializeOwned>(
path: impl AsRef<Path>,
contents: &str,
) -> Option<ConfigError> {
config_error_from_typed_toml_for_source::<T>(
ConfigDiagnosticSource::Path(path.as_ref()),
contents,
)
}
fn config_error_from_typed_toml_for_source<T: DeserializeOwned>(
source: ConfigDiagnosticSource<'_>,
contents: &str,
) -> Option<ConfigError> {
let deserializer = match toml::de::Deserializer::parse(contents) {
Ok(deserializer) => deserializer,
Err(err) => return Some(config_error_from_toml(path, contents, err)),
Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)),
};
let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);
@@ -126,7 +161,7 @@ pub fn config_error_from_typed_toml<T: DeserializeOwned>(
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
Some(ConfigError::new(
path.as_ref().to_path_buf(),
source.to_path_buf(),
range,
toml_err.message(),
))
@@ -205,7 +240,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op
}
}
fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {
pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {
let start = position_for_offset(contents, span.start);
let end_index = if span.end > span.start {
span.end - 1
@@ -290,7 +325,7 @@ fn position_for_offset(contents: &str, index: usize) -> TextPosition {
}
}
fn default_range() -> TextRange {
pub(crate) fn default_range() -> TextRange {
let position = TextPosition { line: 1, column: 1 };
TextRange {
start: position,
@@ -314,7 +349,10 @@ fn span_for_path(contents: &str, path: &SerdePath) -> Option<std::ops::Range<usi
}
}
fn span_for_config_path(contents: &str, path: &SerdePath) -> Option<std::ops::Range<usize>> {
pub(crate) fn span_for_config_path(
contents: &str,
path: &SerdePath,
) -> Option<std::ops::Range<usize>> {
if is_features_table_path(path)
&& let Some(span) = span_for_features_value(contents)
{
@@ -323,6 +361,48 @@ fn span_for_config_path(contents: &str, path: &SerdePath) -> Option<std::ops::Ra
span_for_path(contents, path)
}
pub(crate) fn span_for_toml_key_path(
contents: &str,
path: &[String],
) -> Option<std::ops::Range<usize>> {
let doc = contents.parse::<Document<String>>().ok()?;
let mut node = TomlNode::Item(doc.as_item());
for (index, segment) in path.iter().enumerate() {
if index + 1 == path.len() {
let key_span = match &node {
TomlNode::Item(item) => item
.as_table_like()
.and_then(|table| table.get_key_value(segment))
.and_then(|(key, _)| key.span()),
TomlNode::Table(table) => {
table.get_key_value(segment).and_then(|(key, _)| key.span())
}
TomlNode::Value(Value::InlineTable(table)) => {
table.get_key_value(segment).and_then(|(key, _)| key.span())
}
_ => None,
};
if key_span.is_some() {
return key_span;
}
}
if let Some(next) = map_child(&node, segment) {
node = next;
continue;
}
let index = segment.parse::<usize>().ok()?;
node = seq_child(&node, index)?;
}
match node {
TomlNode::Item(item) => item.span(),
TomlNode::Table(table) => table.span(),
TomlNode::Value(value) => value.span(),
}
}
fn is_features_table_path(path: &SerdePath) -> bool {
let mut segments = path.iter();
matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features")

View File

@@ -21,6 +21,7 @@ mod requirements_exec_policy;
pub mod schema;
mod skills_config;
mod state;
mod strict_config;
mod thread_config;
mod tui_keymap;
pub mod types;
@@ -114,7 +115,9 @@ pub use skills_config::SkillsConfig;
pub use state::ConfigLayerEntry;
pub use state::ConfigLayerStack;
pub use state::ConfigLayerStackOrdering;
pub use state::ConfigLoadOptions;
pub use state::LoaderOverrides;
pub use strict_config::config_error_from_ignored_toml_fields;
pub use thread_config::NoopThreadConfigLoader;
pub use thread_config::RemoteThreadConfigLoader;
pub use thread_config::SessionThreadConfig;

View File

@@ -10,13 +10,14 @@ This module is the canonical place to **load and describe Codex configuration la
Exported from `codex_config::loader`:
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, overrides, cloud_requirements, thread_config_loader) -> ConfigLayerStack`
- `load_config_layers_state(fs, codex_home, cwd_opt, cli_overrides, options, cloud_requirements, thread_config_loader) -> ConfigLayerStack`
- `ConfigLayerStack`
- `effective_config() -> toml::Value`
- `origins() -> HashMap<String, ConfigLayerMetadata>`
- `layers_high_to_low() -> Vec<ConfigLayer>`
- `with_user_config(user_config) -> ConfigLayerStack`
- `ConfigLayerEntry` (one layers `{name, config, version, disabled_reason}`; `name` carries source metadata)
- `ConfigLoadOptions` (user-facing load behavior such as strict config validation)
- `LoaderOverrides` (test/override hooks for managed config sources)
- `merge_toml_values(base, overlay)` (public helper used elsewhere)

View File

@@ -2,11 +2,14 @@
use super::macos::ManagedAdminConfigLayer;
#[cfg(target_os = "macos")]
use super::macos::load_managed_admin_config_layer;
use crate::config_toml::ConfigToml;
use crate::diagnostics::config_error_from_toml;
use crate::diagnostics::io_error_from_config_error;
use crate::state::LoaderOverrides;
use crate::strict_config::config_error_from_ignored_toml_value_fields;
use codex_file_system::ExecutorFileSystem;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use std::io;
use std::path::Path;
use std::path::PathBuf;
@@ -39,6 +42,7 @@ pub(super) async fn load_config_layers_internal(
fs: &dyn ExecutorFileSystem,
codex_home: &Path,
overrides: LoaderOverrides,
strict_config: bool,
) -> io::Result<LoadedConfigLayers> {
#[cfg(target_os = "macos")]
let LoaderOverrides {
@@ -57,19 +61,26 @@ pub(super) async fn load_config_layers_internal(
managed_config_path.unwrap_or_else(|| managed_config_default_path(codex_home)),
)?;
let managed_config =
read_config_from_path(fs, &managed_config_path, /*log_missing_as_info*/ false)
.await?
.map(|managed_config| MangedConfigFromFile {
managed_config,
file: managed_config_path.clone(),
});
let managed_config = read_config_from_path(
fs,
&managed_config_path,
/*log_missing_as_info*/ false,
strict_config,
)
.await?
.map(|loaded| MangedConfigFromFile {
managed_config: loaded,
file: managed_config_path.clone(),
});
#[cfg(target_os = "macos")]
let managed_preferences =
load_managed_admin_config_layer(managed_preferences_base64.as_deref())
.await?
.map(map_managed_admin_layer);
let managed_preferences = load_managed_admin_config_layer(
managed_preferences_base64.as_deref(),
strict_config,
codex_home,
)
.await?
.map(map_managed_admin_layer);
#[cfg(not(target_os = "macos"))]
let managed_preferences = None;
@@ -93,10 +104,14 @@ pub(super) async fn read_config_from_path(
fs: &dyn ExecutorFileSystem,
path: &AbsolutePathBuf,
log_missing_as_info: bool,
strict_config: bool,
) -> io::Result<Option<TomlValue>> {
match fs.read_file_text(path, /*sandbox*/ None).await {
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
Ok(value) => Ok(Some(value)),
Ok(value) => {
validate_config_toml_strictly_if_requested(strict_config, path, &contents, &value)?;
Ok(Some(value))
}
Err(err) => {
tracing::error!("Failed to parse {}: {err}", path.as_path().display());
let config_error = config_error_from_toml(path.as_path(), &contents, err.clone());
@@ -122,6 +137,38 @@ pub(super) async fn read_config_from_path(
}
}
fn validate_config_toml_strictly_if_requested(
strict_config: bool,
path: &AbsolutePathBuf,
contents: &str,
value: &TomlValue,
) -> io::Result<()> {
if !strict_config {
return Ok(());
}
let Some(base_dir) = path.as_path().parent() else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Config file {} has no parent directory", path.display()),
));
};
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(config_error) = config_error_from_ignored_toml_value_fields::<ConfigToml>(
path.as_path(),
contents,
value.clone(),
) {
return Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
));
}
Ok(())
}
/// Return the default managed config path.
pub(super) fn managed_config_default_path(codex_home: &Path) -> PathBuf {
#[cfg(unix)]

View File

@@ -2,13 +2,20 @@ use super::merge_requirements_with_remote_sandbox_config;
use crate::config_requirements::ConfigRequirementsToml;
use crate::config_requirements::ConfigRequirementsWithSources;
use crate::config_requirements::RequirementSource;
use crate::config_toml::ConfigToml;
use crate::diagnostics::ConfigDiagnosticSource;
use crate::diagnostics::config_error_from_toml_for_source;
use crate::diagnostics::io_error_from_config_error;
use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name;
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use core_foundation::base::TCFType;
use core_foundation::string::CFString;
use core_foundation::string::CFStringRef;
use std::ffi::c_void;
use std::io;
use std::path::Path;
use tokio::task;
use toml::Value as TomlValue;
@@ -31,17 +38,20 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
pub(crate) async fn load_managed_admin_config_layer(
override_base64: Option<&str>,
strict_config: bool,
base_dir: &Path,
) -> io::Result<Option<ManagedAdminConfigLayer>> {
if let Some(encoded) = override_base64 {
let trimmed = encoded.trim();
return if trimmed.is_empty() {
Ok(None)
} else {
parse_managed_config_base64(trimmed).map(Some)
parse_managed_config_base64(trimmed, strict_config, base_dir).map(Some)
};
}
match task::spawn_blocking(load_managed_admin_config).await {
let base_dir = base_dir.to_path_buf();
match task::spawn_blocking(move || load_managed_admin_config(strict_config, &base_dir)).await {
Ok(result) => result,
Err(join_err) => {
if join_err.is_cancelled() {
@@ -54,11 +64,14 @@ pub(crate) async fn load_managed_admin_config_layer(
}
}
fn load_managed_admin_config() -> io::Result<Option<ManagedAdminConfigLayer>> {
fn load_managed_admin_config(
strict_config: bool,
base_dir: &Path,
) -> io::Result<Option<ManagedAdminConfigLayer>> {
load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)?
.as_deref()
.map(str::trim)
.map(parse_managed_config_base64)
.map(|encoded| parse_managed_config_base64(encoded, strict_config, base_dir))
.transpose()
}
@@ -134,24 +147,73 @@ fn load_managed_preference(key_name: &str) -> io::Result<Option<String>> {
Ok(Some(value))
}
fn parse_managed_config_base64(encoded: &str) -> io::Result<ManagedAdminConfigLayer> {
fn parse_managed_config_base64(
encoded: &str,
strict_config: bool,
base_dir: &Path,
) -> io::Result<ManagedAdminConfigLayer> {
let raw_toml = decode_managed_preferences_base64(encoded)?;
match toml::from_str::<TomlValue>(&raw_toml) {
Ok(TomlValue::Table(parsed)) => Ok(ManagedAdminConfigLayer {
let source_name =
format!("{MANAGED_PREFERENCES_APPLICATION_ID}:{MANAGED_PREFERENCES_CONFIG_KEY}");
let parsed = toml::from_str::<TomlValue>(&raw_toml).map_err(|err| {
tracing::error!("Failed to parse managed config TOML: {err}");
if strict_config {
let config_error = config_error_from_toml_for_source(
ConfigDiagnosticSource::DisplayName(&source_name),
&raw_toml,
err.clone(),
);
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
} else {
io::Error::new(io::ErrorKind::InvalidData, err)
}
})?;
validate_managed_config_toml_strictly_if_requested(
strict_config,
&source_name,
&raw_toml,
&parsed,
base_dir,
)?;
match parsed {
TomlValue::Table(parsed) => Ok(ManagedAdminConfigLayer {
config: TomlValue::Table(parsed),
raw_toml,
}),
Ok(other) => {
other => {
tracing::error!("Managed config TOML must have a table at the root, found {other:?}",);
Err(io::Error::new(
io::ErrorKind::InvalidData,
"managed config root must be a table",
))
}
Err(err) => {
tracing::error!("Failed to parse managed config TOML: {err}");
Err(io::Error::new(io::ErrorKind::InvalidData, err))
}
}
}
fn validate_managed_config_toml_strictly_if_requested(
strict_config: bool,
source_name: &str,
raw_toml: &str,
parsed: &TomlValue,
base_dir: &Path,
) -> io::Result<()> {
if !strict_config {
return Ok(());
}
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::<
ConfigToml,
>(source_name, raw_toml, parsed.clone())
{
Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
))
} else {
Ok(())
}
}

View File

@@ -21,7 +21,11 @@ use crate::project_root_markers::default_project_root_markers;
use crate::project_root_markers::project_root_markers_from_config;
use crate::state::ConfigLayerEntry;
use crate::state::ConfigLayerStack;
use crate::state::ConfigLoadOptions;
use crate::state::LoaderOverrides;
use crate::strict_config::config_error_from_ignored_toml_value_fields;
use crate::strict_config::ignored_toml_value_field;
use crate::strict_config::unknown_feature_toml_value_field;
use crate::thread_config::ThreadConfigContext;
use crate::thread_config::ThreadConfigLoader;
use codex_app_server_protocol::ConfigLayerSource;
@@ -103,10 +107,14 @@ pub async fn load_config_layers_state(
codex_home: &Path,
cwd: Option<AbsolutePathBuf>,
cli_overrides: &[(String, TomlValue)],
overrides: LoaderOverrides,
options: impl Into<ConfigLoadOptions>,
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: &dyn ThreadConfigLoader,
) -> io::Result<ConfigLayerStack> {
let ConfigLoadOptions {
loader_overrides: overrides,
strict_config,
} = options.into();
let ignore_managed_requirements = overrides.ignore_managed_requirements;
let ignore_user_config = overrides.ignore_user_config;
let ignore_user_and_project_exec_policy_rules =
@@ -139,7 +147,8 @@ pub async fn load_config_layers_state(
// Make a best-effort to support the legacy `managed_config.toml` as a
// requirements specification.
let loaded_config_layers =
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?;
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config)
.await?;
if !ignore_managed_requirements {
load_requirements_from_legacy_scheme(
&mut config_requirements_toml,
@@ -167,6 +176,11 @@ pub async fn load_config_layers_state(
.as_ref()
.map(AbsolutePathBuf::as_path)
.unwrap_or(codex_home);
validate_cli_overrides_strictly_if_requested(
strict_config,
&cli_overrides_layer,
base_dir,
)?;
Some(resolve_relative_paths_in_config_toml(
cli_overrides_layer,
base_dir,
@@ -176,16 +190,20 @@ pub async fn load_config_layers_state(
// Include an entry for the "system" config folder, loading its config.toml,
// if it exists.
let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?;
let system_layer =
load_config_toml_for_required_layer(fs, &system_config_toml_file, |config_toml| {
let system_layer = load_config_toml_for_required_layer(
fs,
&system_config_toml_file,
strict_config,
|config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::System {
file: system_config_toml_file.clone(),
},
config_toml,
)
})
.await?;
},
)
.await?;
layers.push(system_layer);
// Add a layer for $CODEX_HOME/config.toml so folder-derived resources such
@@ -200,7 +218,7 @@ pub async fn load_config_layers_state(
TomlValue::Table(toml::map::Map::new()),
)
} else {
load_config_toml_for_required_layer(fs, &user_file, |config_toml| {
load_config_toml_for_required_layer(fs, &user_file, strict_config, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
@@ -267,6 +285,7 @@ pub async fn load_config_layers_state(
&project_trust_context.project_root,
&project_trust_context,
codex_home,
strict_config,
)
.await?;
layers.extend(project_layers.layers);
@@ -358,15 +377,11 @@ fn insert_layer_by_precedence(layers: &mut Vec<ConfigLayerEntry>, layer: ConfigL
async fn load_config_toml_for_required_layer(
fs: &dyn ExecutorFileSystem,
toml_file: &AbsolutePathBuf,
strict_config: bool,
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
) -> io::Result<ConfigLayerEntry> {
let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await {
Ok(contents) => {
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
let config_error =
config_error_from_toml(toml_file.as_path(), &contents, err.clone());
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
})?;
let config_parent = toml_file.as_path().parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
@@ -376,6 +391,18 @@ async fn load_config_toml_for_required_layer(
),
)
})?;
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
let config_error =
config_error_from_toml(toml_file.as_path(), &contents, err.clone());
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
})?;
validate_config_toml_strictly_if_requested(
strict_config,
toml_file.as_path(),
&contents,
&config,
config_parent,
)?;
resolve_relative_paths_in_config_toml(config, config_parent)
}
Err(e) => {
@@ -396,6 +423,61 @@ async fn load_config_toml_for_required_layer(
Ok(create_entry(toml_value))
}
fn validate_config_toml_strictly_if_requested(
strict_config: bool,
toml_file: &Path,
contents: &str,
value: &TomlValue,
base_dir: &Path,
) -> io::Result<()> {
if !strict_config {
return Ok(());
}
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(config_error) = config_error_from_ignored_toml_value_fields::<ConfigToml>(
toml_file,
contents,
value.clone(),
) {
Err(io_error_from_config_error(
io::ErrorKind::InvalidData,
config_error,
/*source*/ None,
))
} else {
Ok(())
}
}
fn validate_cli_overrides_strictly_if_requested(
strict_config: bool,
cli_overrides_layer: &TomlValue,
base_dir: &Path,
) -> io::Result<()> {
if !strict_config {
return Ok(());
}
let _guard = AbsolutePathBufGuard::new(base_dir);
if let Some(ignored_path) = ignored_toml_value_field::<ConfigToml>(cli_overrides_layer.clone())
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
));
}
if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown configuration field `{ignored_path}` in -c/--config override"),
));
}
Ok(())
}
/// If available, apply requirements from the platform system
/// `requirements.toml` location to `config_requirements_toml` by filling in
/// any unset fields.
@@ -960,6 +1042,7 @@ async fn load_project_layers(
project_root: &AbsolutePathBuf,
trust_context: &ProjectTrustContext,
codex_home: &Path,
strict_config: bool,
) -> io::Result<LoadedProjectLayers> {
let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?;
let codex_home_normalized =
@@ -1023,6 +1106,15 @@ async fn load_project_layers(
}
};
let mut config = config;
if disabled_reason.is_none() {
validate_config_toml_strictly_if_requested(
strict_config,
config_file.as_path(),
&contents,
&config,
dot_codex_abs.as_path(),
)?;
}
let ignored_project_config_keys = sanitize_project_config(&mut config);
let config =
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;

View File

@@ -14,6 +14,22 @@ use std::collections::HashMap;
use std::path::PathBuf;
use toml::Value as TomlValue;
/// User-facing config loading behavior that is not part of the config document.
#[derive(Debug, Default, Clone)]
pub struct ConfigLoadOptions {
pub loader_overrides: LoaderOverrides,
pub strict_config: bool,
}
impl From<LoaderOverrides> for ConfigLoadOptions {
fn from(loader_overrides: LoaderOverrides) -> Self {
Self {
loader_overrides,
strict_config: false,
}
}
}
/// LoaderOverrides overrides managed configuration inputs (primarily for tests).
#[derive(Debug, Default, Clone)]
pub struct LoaderOverrides {

View File

@@ -0,0 +1,201 @@
//! Strict config validation built on top of serde's ignored-field tracking.
use crate::diagnostics::ConfigDiagnosticSource;
use crate::diagnostics::ConfigError;
use crate::diagnostics::config_error_from_toml_for_source;
use crate::diagnostics::default_range;
use crate::diagnostics::span_for_config_path;
use crate::diagnostics::span_for_toml_key_path;
use crate::diagnostics::text_range_from_span;
use codex_features::is_known_feature_key;
use serde::de::DeserializeOwned;
use std::path::Path;
use toml::Value as TomlValue;
pub fn config_error_from_ignored_toml_fields<T: DeserializeOwned>(
path: impl AsRef<Path>,
contents: &str,
) -> Option<ConfigError> {
let source = ConfigDiagnosticSource::Path(path.as_ref());
match toml::from_str::<TomlValue>(contents) {
Ok(value) => {
config_error_from_ignored_toml_value_fields_for_source::<T>(source, contents, value)
}
Err(err) => Some(config_error_from_toml_for_source(source, contents, err)),
}
}
pub(crate) fn config_error_from_ignored_toml_value_fields<T: DeserializeOwned>(
path: impl AsRef<Path>,
contents: &str,
value: TomlValue,
) -> Option<ConfigError> {
config_error_from_ignored_toml_value_fields_for_source::<T>(
ConfigDiagnosticSource::Path(path.as_ref()),
contents,
value,
)
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn config_error_from_ignored_toml_value_fields_for_source_name<T: DeserializeOwned>(
source_name: &str,
contents: &str,
value: TomlValue,
) -> Option<ConfigError> {
config_error_from_ignored_toml_value_fields_for_source::<T>(
ConfigDiagnosticSource::DisplayName(source_name),
contents,
value,
)
}
fn config_error_from_ignored_toml_value_fields_for_source<T: DeserializeOwned>(
source: ConfigDiagnosticSource<'_>,
contents: &str,
value: TomlValue,
) -> Option<ConfigError> {
let unknown_feature_paths = unknown_feature_toml_value_path(&value);
let mut ignored_paths = Vec::new();
let mut ignored_callback = |ignored_path: serde_ignored::Path<'_>| {
let path_segments = ignored_path_segments(&ignored_path);
if !path_segments.is_empty() {
ignored_paths.push(path_segments);
}
};
let deserializer = serde_ignored::Deserializer::new(value, &mut ignored_callback);
let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);
match result {
Ok(_) => unknown_field_error_from_paths(source, contents, ignored_paths)
.or_else(|| unknown_field_error_from_paths(source, contents, unknown_feature_paths)),
Err(err) => {
let path_hint = err.path().clone();
let toml_err = err.into_inner();
let range = span_for_config_path(contents, &path_hint)
.or_else(|| toml_err.span())
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
Some(ConfigError::new(
source.to_path_buf(),
range,
toml_err.message(),
))
}
}
}
pub(crate) fn ignored_toml_value_field<T: DeserializeOwned>(value: TomlValue) -> Option<String> {
let mut ignored_paths = Vec::new();
let result: Result<T, _> = serde_ignored::deserialize(value, |ignored_path| {
let path_segments = ignored_path_segments(&ignored_path);
if !path_segments.is_empty() {
ignored_paths.push(path_segments);
}
});
if result.is_err() {
return None;
}
ignored_paths
.into_iter()
.next()
.map(|path_segments| path_segments.join("."))
}
pub(crate) fn unknown_feature_toml_value_field(value: &TomlValue) -> Option<String> {
unknown_feature_toml_value_path(value)
.into_iter()
.next()
.map(|path_segments| path_segments.join("."))
}
fn unknown_field_error_from_paths(
source: ConfigDiagnosticSource<'_>,
contents: &str,
ignored_paths: Vec<Vec<String>>,
) -> Option<ConfigError> {
let path_segments = ignored_paths.into_iter().next()?;
let ignored_path = path_segments.join(".");
let range = span_for_toml_key_path(contents, &path_segments)
.map(|span| text_range_from_span(contents, span))
.unwrap_or_else(default_range);
Some(ConfigError::new(
source.to_path_buf(),
range,
format!("unknown configuration field `{ignored_path}`"),
))
}
fn unknown_feature_toml_value_path(value: &TomlValue) -> Vec<Vec<String>> {
let Some(root) = value.as_table() else {
return Vec::new();
};
let mut paths = Vec::new();
push_unknown_feature_paths(&mut paths, &["features"], root.get("features"));
if let Some(profiles) = root.get("profiles").and_then(TomlValue::as_table) {
for (profile_name, profile) in profiles {
let prefix = ["profiles", profile_name.as_str(), "features"];
let features = profile
.as_table()
.and_then(|profile| profile.get("features"));
push_unknown_feature_paths(&mut paths, &prefix, features);
}
}
paths
}
fn push_unknown_feature_paths(
paths: &mut Vec<Vec<String>>,
prefix: &[&str],
features: Option<&TomlValue>,
) {
let Some(features) = features.and_then(TomlValue::as_table) else {
return;
};
for feature_key in features
.keys()
.map(String::as_str)
.filter(|key| !is_known_feature_key(key))
{
let mut path = prefix
.iter()
.map(|segment| (*segment).to_string())
.collect::<Vec<_>>();
path.push(feature_key.to_string());
paths.push(path);
}
}
fn ignored_path_segments(path: &serde_ignored::Path<'_>) -> Vec<String> {
let mut segments = Vec::new();
push_ignored_path_segments(path, &mut segments);
segments
}
fn push_ignored_path_segments(path: &serde_ignored::Path<'_>, segments: &mut Vec<String>) {
match path {
serde_ignored::Path::Root => {}
serde_ignored::Path::Seq { parent, index } => {
push_ignored_path_segments(parent, segments);
segments.push(index.to_string());
}
serde_ignored::Path::Map { parent, key } => {
push_ignored_path_segments(parent, segments);
segments.push(key.clone());
}
serde_ignored::Path::Some { parent }
| serde_ignored::Path::NewtypeStruct { parent }
| serde_ignored::Path::NewtypeVariant { parent } => {
push_ignored_path_segments(parent, segments);
}
}
}
#[cfg(test)]
#[path = "strict_config_tests.rs"]
mod tests;

View File

@@ -0,0 +1,112 @@
use super::*;
use crate::config_toml::ConfigToml;
use crate::diagnostics::TextPosition;
use crate::diagnostics::TextRange;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn ignored_toml_field_errors_accept_non_file_source_names() {
let source_name = "com.openai.codex:config_toml_base64";
let contents = r#"
model = "gpt-5"
unknown_key = true"#;
let value = toml::from_str::<TomlValue>(contents).expect("valid TOML");
let error = config_error_from_ignored_toml_value_fields_for_source_name::<ConfigToml>(
source_name,
contents,
value,
)
.expect("unknown field error");
assert_eq!(
error,
ConfigError::new(
PathBuf::from(source_name),
TextRange {
start: TextPosition { line: 3, column: 1 },
end: TextPosition {
line: 3,
column: 11,
},
},
"unknown configuration field `unknown_key`",
)
);
}
#[test]
fn type_errors_take_precedence_over_ignored_fields() {
let path = Path::new("/tmp/config.toml");
let contents = r#"
model_context_window = "wide"
unknown_key = true"#;
let error =
config_error_from_ignored_toml_fields::<ConfigToml>(path, contents).expect("type error");
assert_eq!(
error,
ConfigError::new(
path.to_path_buf(),
TextRange {
start: TextPosition {
line: 2,
column: 24,
},
end: TextPosition {
line: 2,
column: 29,
},
},
"invalid type: string \"wide\", expected i64",
)
);
}
#[test]
fn strict_config_rejects_unknown_feature_key() {
let path = Path::new("/tmp/config.toml");
let contents = r#"
[features]
foo = true"#;
let error = config_error_from_ignored_toml_fields::<ConfigToml>(path, contents)
.expect("unknown feature error");
assert_eq!(
error,
ConfigError::new(
path.to_path_buf(),
TextRange {
start: TextPosition { line: 3, column: 1 },
end: TextPosition { line: 3, column: 3 },
},
"unknown configuration field `features.foo`",
)
);
}
#[test]
fn strict_config_rejects_unknown_profile_feature_key() {
let path = Path::new("/tmp/config.toml");
let contents = r#"
[profiles.work.features]
foo = true"#;
let error = config_error_from_ignored_toml_fields::<ConfigToml>(path, contents)
.expect("unknown feature error");
assert_eq!(
error,
ConfigError::new(
path.to_path_buf(),
TextRange {
start: TextPosition { line: 3, column: 1 },
end: TextPosition { line: 3, column: 3 },
},
"unknown configuration field `profiles.work.features.foo`",
)
);
}

View File

@@ -18,6 +18,7 @@ use codex_config::RequirementSource;
use codex_config::SessionThreadConfig;
use codex_config::StaticThreadConfigLoader;
use codex_config::ThreadConfigSource;
use codex_config::config_error_from_ignored_toml_fields;
use codex_config::config_error_from_toml;
use codex_config::config_toml::ConfigToml;
use codex_config::config_toml::ProjectConfig;
@@ -228,6 +229,143 @@ async fn returns_config_error_for_schema_error_in_user_config() {
assert_eq!(config_error, &expected_config_error);
}
#[tokio::test]
async fn strict_config_rejects_unknown_user_config_key() {
let tmp = tempdir().expect("tempdir");
let contents = "model = \"gpt-5\"\nunknown_key = true";
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let err = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.fallback_cwd(Some(tmp.path().to_path_buf()))
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.strict_config(/*strict_config*/ true)
.build()
.await
.expect_err("expected error");
let config_error = config_error_from_io(&err);
let expected_config_error =
config_error_from_ignored_toml_fields::<ConfigToml>(&config_path, contents)
.expect("unknown field error");
assert_eq!(config_error, &expected_config_error);
}
#[tokio::test]
async fn strict_config_rejects_unknown_cli_override_key() {
let tmp = tempdir().expect("tempdir");
let err = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.fallback_cwd(Some(tmp.path().to_path_buf()))
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.cli_overrides(vec![(
"foo".to_string(),
TomlValue::String("bar".to_string()),
)])
.strict_config(/*strict_config*/ true)
.build()
.await
.expect_err("expected error");
assert_eq!(
err.to_string(),
"unknown configuration field `foo` in -c/--config override"
);
}
#[tokio::test]
async fn strict_config_rejects_unknown_cli_override_key_with_relative_path_override() {
let tmp = tempdir().expect("tempdir");
let instructions_path = tmp.path().join("instructions.md");
std::fs::write(&instructions_path, "instructions").expect("write instructions");
let err = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.fallback_cwd(Some(tmp.path().to_path_buf()))
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.cli_overrides(vec![
(
"model_instructions_file".to_string(),
TomlValue::String("instructions.md".to_string()),
),
("foo".to_string(), TomlValue::String("bar".to_string())),
])
.strict_config(/*strict_config*/ true)
.build()
.await
.expect_err("expected error");
assert_eq!(
err.to_string(),
"unknown configuration field `foo` in -c/--config override"
);
}
#[tokio::test]
async fn strict_config_rejects_unknown_feature_cli_override_key() {
let tmp = tempdir().expect("tempdir");
let err = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.fallback_cwd(Some(tmp.path().to_path_buf()))
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.cli_overrides(vec![("features.foo".to_string(), TomlValue::Boolean(true))])
.strict_config(/*strict_config*/ true)
.build()
.await
.expect_err("expected error");
assert_eq!(
err.to_string(),
"unknown configuration field `features.foo` in -c/--config override"
);
}
#[tokio::test]
async fn strict_config_rejects_unknown_feature_user_config_key() {
let tmp = tempdir().expect("tempdir");
let contents = "[features]\nfoo = true";
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let err = ConfigBuilder::default()
.codex_home(tmp.path().to_path_buf())
.fallback_cwd(Some(tmp.path().to_path_buf()))
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.strict_config(/*strict_config*/ true)
.build()
.await
.expect_err("expected error");
let config_error = config_error_from_io(&err);
assert_eq!(
config_error.message,
"unknown configuration field `features.foo`"
);
assert_eq!(config_error.range.start.line, 2);
assert_eq!(config_error.range.start.column, 1);
}
#[test]
fn strict_config_points_to_unknown_nested_key() {
let tmp = tempdir().expect("tempdir");
let contents = "[mcp_servers.local]\ncommand = \"echo\"\nunknown_key = true";
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
let error = config_error_from_ignored_toml_fields::<ConfigToml>(&config_path, contents)
.expect("unknown field error");
assert_eq!(
error.message,
"unknown configuration field `mcp_servers.local.unknown_key`"
);
assert_eq!(error.range.start.line, 3);
assert_eq!(error.range.start.column, 1);
}
#[test]
fn schema_error_points_to_feature_value() {
let tmp = tempdir().expect("tempdir");

View File

@@ -15,7 +15,6 @@ use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::LoaderOverrides;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::PluginRequirementsToml;
@@ -133,9 +132,11 @@ mod permissions;
#[cfg(test)]
mod schema;
pub(crate) mod template_interpolation;
pub use codex_config::ConfigLoadOptions;
pub use codex_config::Constrained;
pub use codex_config::ConstraintError;
pub use codex_config::ConstraintResult;
pub use codex_config::LoaderOverrides;
pub use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
pub use codex_sandboxing::system_bwrap_warning;
@@ -879,6 +880,7 @@ pub struct ConfigBuilder {
cli_overrides: Option<Vec<(String, TomlValue)>>,
harness_overrides: Option<ConfigOverrides>,
loader_overrides: Option<LoaderOverrides>,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: Option<Arc<dyn ThreadConfigLoader>>,
fallback_cwd: Option<PathBuf>,
@@ -905,6 +907,11 @@ impl ConfigBuilder {
self
}
pub fn strict_config(mut self, strict_config: bool) -> Self {
self.strict_config = strict_config;
self
}
pub fn cloud_requirements(mut self, cloud_requirements: CloudRequirementsLoader) -> Self {
self.cloud_requirements = cloud_requirements;
self
@@ -934,6 +941,7 @@ impl ConfigBuilder {
cli_overrides,
harness_overrides,
loader_overrides,
strict_config,
cloud_requirements,
thread_config_loader,
fallback_cwd,
@@ -956,7 +964,10 @@ impl ConfigBuilder {
&codex_home,
Some(cwd),
&cli_overrides,
loader_overrides,
ConfigLoadOptions {
loader_overrides,
strict_config,
},
cloud_requirements,
thread_config_loader
.as_deref()
@@ -1191,35 +1202,28 @@ impl Config {
}
}
/// DEPRECATED: Use [Config::load_with_cli_overrides()] instead because working
/// with [ConfigToml] directly means that [ConfigRequirements] have not been
/// applied yet, which risks failing to enforce required constraints.
pub async fn load_config_as_toml_with_cli_overrides(
codex_home: &Path,
cwd: Option<&AbsolutePathBuf>,
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<ConfigToml> {
load_config_as_toml_with_cli_and_loader_overrides(
codex_home,
cwd,
cli_overrides,
LoaderOverrides::default(),
)
.await
}
pub async fn load_config_as_toml_with_cli_and_loader_overrides(
codex_home: &Path,
cwd: Option<&AbsolutePathBuf>,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
) -> std::io::Result<ConfigToml> {
load_config_as_toml_with_cli_and_load_options(codex_home, cwd, cli_overrides, loader_overrides)
.await
}
pub async fn load_config_as_toml_with_cli_and_load_options(
codex_home: &Path,
cwd: Option<&AbsolutePathBuf>,
cli_overrides: Vec<(String, TomlValue)>,
options: impl Into<ConfigLoadOptions>,
) -> std::io::Result<ConfigToml> {
let config_layer_stack = load_config_layers_state(
LOCAL_FS.as_ref(),
codex_home,
cwd.cloned(),
&cli_overrides,
loader_overrides,
options,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
)

View File

@@ -55,6 +55,7 @@ use codex_app_server_protocol::TurnStartedNotification;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_requirements::cloud_requirements_loader_for_storage;
use codex_config::ConfigLoadError;
use codex_config::ConfigLoadOptions;
use codex_config::LoaderOverrides;
use codex_config::format_config_error_with_source;
use codex_core::check_execpolicy_for_warnings;
@@ -62,7 +63,7 @@ use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_and_loader_overrides;
use codex_core::config::load_config_as_toml_with_cli_and_load_options;
use codex_core::config::resolve_oss_provider;
use codex_core::find_thread_meta_by_name_str;
use codex_core::format_exec_policy_error_with_source;
@@ -314,17 +315,21 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
};
#[allow(clippy::print_stderr)]
let strict_config = config_overrides.strict_config;
let loader_overrides = LoaderOverrides {
ignore_user_config,
ignore_user_and_project_exec_policy_rules: ignore_rules,
..Default::default()
};
let config_toml = match load_config_as_toml_with_cli_and_loader_overrides(
let config_toml = match load_config_as_toml_with_cli_and_load_options(
&codex_home,
Some(&config_cwd),
cli_kv_overrides.clone(),
loader_overrides.clone(),
ConfigLoadOptions {
loader_overrides: loader_overrides.clone(),
strict_config,
},
)
.await
{
@@ -425,6 +430,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.loader_overrides(loader_overrides)
.strict_config(strict_config)
.cloud_requirements(cloud_requirements)
.build()
.await?;
@@ -508,6 +514,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
config: std::sync::Arc::new(config.clone()),
cli_overrides: run_cli_overrides,
loader_overrides: run_loader_overrides,
strict_config,
cloud_requirements: run_cloud_requirements,
feedback: CodexFeedback::new(),
log_db: None,

View File

@@ -32,8 +32,7 @@ fn main() -> anyhow::Result<()> {
let mut inner = top_cli.inner;
inner
.config_overrides
.raw_overrides
.splice(0..0, top_cli.config_overrides.raw_overrides);
.prepend_root_overrides(top_cli.config_overrides);
run_main(inner, arg0_paths).await?;
Ok(())

View File

@@ -7,6 +7,7 @@ fn top_cli_parses_resume_prompt_after_config_flag() {
let cli = TopCli::parse_from([
"codex-exec",
"resume",
"--strict-config",
"--last",
"--json",
"--model",
@@ -17,8 +18,12 @@ fn top_cli_parses_resume_prompt_after_config_flag() {
"--skip-git-repo-check",
PROMPT,
]);
let mut inner = cli.inner;
inner
.config_overrides
.prepend_root_overrides(cli.config_overrides);
let Some(codex_exec::Command::Resume(args)) = cli.inner.command else {
let Some(codex_exec::Command::Resume(args)) = inner.command.as_ref() else {
panic!("expected resume command");
};
let effective_prompt = args.prompt.clone().or_else(|| {
@@ -29,9 +34,10 @@ fn top_cli_parses_resume_prompt_after_config_flag() {
}
});
assert_eq!(effective_prompt.as_deref(), Some(PROMPT));
assert_eq!(cli.config_overrides.raw_overrides.len(), 1);
assert_eq!(inner.config_overrides.raw_overrides.len(), 1);
assert_eq!(
cli.config_overrides.raw_overrides[0],
inner.config_overrides.raw_overrides[0],
"reasoning_level=xhigh"
);
assert!(inner.config_overrides.strict_config);
}

View File

@@ -6,7 +6,7 @@ use std::io::Result as IoResult;
use std::sync::Arc;
use codex_arg0::Arg0DispatchPaths;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::EnvironmentManagerArgs;
use codex_exec_server::ExecServerRuntimePaths;
@@ -77,7 +77,10 @@ pub async fn run_main(
format!("error parsing -c overrides: {e}"),
)
})?;
let config = Config::load_with_cli_overrides(cli_kv_overrides)
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.strict_config(cli_config_overrides.strict_config)
.build()
.await
.map_err(|e| {
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))

View File

@@ -8,7 +8,7 @@ use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::find_codex_home;
use crate::legacy_core::config::load_config_as_toml_with_cli_overrides;
use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options;
use crate::legacy_core::config::resolve_oss_provider;
use crate::legacy_core::format_exec_policy_error_with_source;
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
@@ -268,6 +268,7 @@ async fn start_embedded_app_server(
config: Config,
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
@@ -278,6 +279,7 @@ async fn start_embedded_app_server(
config,
cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
feedback,
log_db,
@@ -395,6 +397,7 @@ async fn start_app_server(
config: Config,
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
@@ -406,6 +409,7 @@ async fn start_app_server(
config,
cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
feedback,
log_db,
@@ -431,6 +435,7 @@ pub(crate) async fn start_app_server_for_picker(
config.clone(),
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
@@ -458,6 +463,7 @@ async fn start_embedded_app_server_with<F, Fut>(
config: Config,
cli_kv_overrides: Vec<(String, toml::Value)>,
loader_overrides: LoaderOverrides,
strict_config: bool,
cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
@@ -483,6 +489,7 @@ where
config: Arc::new(config),
cli_overrides: cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
feedback,
log_db,
@@ -689,6 +696,7 @@ pub async fn run_main(
remote: Option<String>,
remote_auth_token: Option<String>,
) -> std::io::Result<AppExitInfo> {
let strict_config = cli.config_overrides.strict_config;
let remote_url = remote;
if let (Some(websocket_url), Some(_)) = (remote_url.as_deref(), remote_auth_token.as_ref()) {
validate_remote_auth_token_transport(websocket_url).map_err(std::io::Error::other)?;
@@ -727,7 +735,10 @@ pub async fn run_main(
// gpt-oss:20b) and ensure it is present locally. Also, force the builtin
let raw_overrides = cli.config_overrides.raw_overrides.clone();
// `oss` model provider.
let overrides_cli = codex_utils_cli::CliConfigOverrides { raw_overrides };
let overrides_cli = codex_utils_cli::CliConfigOverrides {
raw_overrides,
strict_config: false,
};
let cli_kv_overrides = match overrides_cli.parse_overrides() {
// Parse `-c` overrides from the CLI.
Ok(v) => v,
@@ -762,10 +773,14 @@ pub async fn run_main(
config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?;
#[allow(clippy::print_stderr)]
let config_toml = match load_config_as_toml_with_cli_overrides(
let config_toml = match load_config_as_toml_with_cli_and_load_options(
&codex_home,
config_cwd.as_ref(),
cli_kv_overrides.clone(),
codex_config::ConfigLoadOptions {
loader_overrides: loader_overrides.clone(),
strict_config,
},
)
.await
{
@@ -869,6 +884,8 @@ pub async fn run_main(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
loader_overrides.clone(),
strict_config,
)
.await;
@@ -1021,6 +1038,7 @@ pub async fn run_main(
cli,
arg0_paths,
loader_overrides,
strict_config,
app_server_target,
remote_cwd_override,
config,
@@ -1042,6 +1060,7 @@ async fn run_ratatui_app(
cli: Cli,
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
strict_config: bool,
app_server_target: AppServerTarget,
remote_cwd_override: Option<PathBuf>,
initial_config: Config,
@@ -1106,6 +1125,7 @@ async fn run_ratatui_app(
initial_config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
strict_config,
cloud_requirements.clone(),
feedback.clone(),
log_db.clone(),
@@ -1192,6 +1212,8 @@ async fn run_ratatui_app(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
loader_overrides.clone(),
strict_config,
)
.await
} else {
@@ -1393,6 +1415,8 @@ async fn run_ratatui_app(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
loader_overrides.clone(),
strict_config,
fallback_cwd,
)
.await
@@ -1435,6 +1459,7 @@ async fn run_ratatui_app(
config.clone(),
cli_kv_overrides.clone(),
loader_overrides,
strict_config,
cloud_requirements.clone(),
feedback.clone(),
log_db.clone(),
@@ -1587,11 +1612,15 @@ async fn load_config_or_exit(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,
cloud_requirements: CloudRequirementsLoader,
loader_overrides: LoaderOverrides,
strict_config: bool,
) -> Config {
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides,
overrides,
cloud_requirements,
loader_overrides,
strict_config,
/*fallback_cwd*/ None,
)
.await
@@ -1601,12 +1630,16 @@ async fn load_config_or_exit_with_fallback_cwd(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,
cloud_requirements: CloudRequirementsLoader,
loader_overrides: LoaderOverrides,
strict_config: bool,
fallback_cwd: Option<PathBuf>,
) -> Config {
#[allow(clippy::print_stderr)]
match ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.loader_overrides(loader_overrides)
.strict_config(strict_config)
.cloud_requirements(cloud_requirements)
.fallback_cwd(fallback_cwd)
.build()
@@ -1677,6 +1710,7 @@ mod tests {
config,
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
@@ -2023,6 +2057,7 @@ mod tests {
config,
Vec::new(),
LoaderOverrides::default(),
/*strict_config*/ false,
CloudRequirementsLoader::default(),
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,

View File

@@ -49,6 +49,7 @@ fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
let top_cli = TopCli::parse();
let mut inner = top_cli.inner;
inner.config_overrides.strict_config |= top_cli.config_overrides.strict_config;
inner
.config_overrides
.raw_overrides

View File

@@ -1039,6 +1039,7 @@ mod tests {
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides: Default::default(),
strict_config: false,
cloud_requirements: cloud_requirements_loader_for_storage(
codex_home_path.clone(),
/*enable_codex_api_key_env*/ false,

View File

@@ -17,6 +17,10 @@ use toml::Value;
/// calling code can decide how to interpret the right-hand side.
#[derive(Parser, Debug, Default, Clone)]
pub struct CliConfigOverrides {
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", global = true, default_value_t = false)]
pub strict_config: bool,
/// Override a configuration value that would otherwise be loaded from
/// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override
/// nested values. The `value` portion is parsed as TOML. If it fails to
@@ -37,6 +41,14 @@ pub struct CliConfigOverrides {
}
impl CliConfigOverrides {
/// Prepend root-level config flags so they have lower precedence than
/// command-specific flags parsed after a subcommand.
pub fn prepend_root_overrides(&mut self, root_overrides: Self) {
self.strict_config |= root_overrides.strict_config;
self.raw_overrides
.splice(0..0, root_overrides.raw_overrides);
}
/// Parse the raw strings captured from the CLI into a list of `(path,
/// value)` tuples where `value` is a `serde_json::Value`.
pub fn parse_overrides(&self) -> Result<Vec<(String, Value)>, String> {
@@ -184,12 +196,34 @@ mod tests {
fn canonicalizes_use_legacy_landlock_alias() {
let overrides = CliConfigOverrides {
raw_overrides: vec!["use_legacy_landlock=true".to_string()],
strict_config: false,
};
let parsed = overrides.parse_overrides().expect("parse_overrides");
assert_eq!(parsed[0].0.as_str(), "features.use_legacy_landlock");
assert_eq!(parsed[0].1.as_bool(), Some(true));
}
#[test]
fn prepends_root_overrides_and_preserves_strict_config() {
let mut subcommand_overrides = CliConfigOverrides {
strict_config: false,
raw_overrides: vec!["model=\"gpt-5.2\"".to_string()],
};
subcommand_overrides.prepend_root_overrides(CliConfigOverrides {
strict_config: true,
raw_overrides: vec!["model=\"gpt-5.1\"".to_string()],
});
assert_eq!(
subcommand_overrides.raw_overrides,
vec![
"model=\"gpt-5.1\"".to_string(),
"model=\"gpt-5.2\"".to_string(),
]
);
assert!(subcommand_overrides.strict_config);
}
#[test]
fn parses_inline_table() {
let v = parse_toml_value("{a = 1, b = 2}").expect("parse");