config: add strict config parsing

This commit is contained in:
Michael Bolin
2026-05-13 08:32:32 -07:00
parent 68e045a631
commit 731b5d92d8
45 changed files with 1458 additions and 178 deletions
+217 -21
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;
@@ -133,7 +134,8 @@ async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<(
#[tokio::test]
async fn returns_config_error_for_invalid_user_config_toml() {
let tmp = tempdir().expect("tempdir");
let contents = "model = \"gpt-4\"\ninvalid = [";
let contents = r#"model = "gpt-4"
invalid = ["#;
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
@@ -161,7 +163,8 @@ async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> {
let tmp = tempdir().expect("tempdir");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
"model = \"from-user-config\"\ninvalid = [",
r#"model = "from-user-config"
invalid = ["#,
)
.expect("write config");
@@ -219,7 +222,8 @@ async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io:
async fn returns_config_error_for_invalid_managed_config_toml() {
let tmp = tempdir().expect("tempdir");
let managed_path = tmp.path().join("managed_config.toml");
let contents = "model = \"gpt-4\"\ninvalid = [";
let contents = r#"model = "gpt-4"
invalid = ["#;
std::fs::write(&managed_path, contents).expect("write managed config");
let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone());
@@ -336,10 +340,151 @@ command = "python3 /tmp/user-hook.py"
Ok(())
}
#[tokio::test]
async fn strict_config_rejects_unknown_user_config_key() {
let tmp = tempdir().expect("tempdir");
let contents = r#"model = "gpt-5"
unknown_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 = r#"[features]
foo = 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 = r#"[mcp_servers.local]
command = "echo"
unknown_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");
let contents = "[features]\ncollaboration_modes = \"true\"";
let contents = r#"[features]
collaboration_modes = "true""#;
let config_path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&config_path, contents).expect("write config");
@@ -716,7 +861,12 @@ async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<()
let tmp = tempdir()?;
let managed_path = tmp.path().join("managed_config.toml");
tokio::fs::write(&managed_path, "approval_policy = \"on-request\"\n").await?;
tokio::fs::write(
&managed_path,
r#"approval_policy = "on-request"
"#,
)
.await?;
let mut loader_overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path);
loader_overrides.macos_managed_config_requirements_base64 = Some(
@@ -1201,11 +1351,17 @@ async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let managed_config_path = tmp.path().join("managed_config.toml");
tokio::fs::write(&managed_config_path, "approval_policy = \"never\"\n").await?;
tokio::fs::write(
&managed_config_path,
r#"approval_policy = "never"
"#,
)
.await?;
let system_requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&system_requirements_path,
"allowed_sandbox_modes = [\"read-only\"]\n",
r#"allowed_sandbox_modes = ["read-only"]
"#,
)
.await?;
@@ -1391,12 +1547,14 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> {
tokio::fs::write(
project_root.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"root\"\n",
r#"foo = "root"
"#,
)
.await?;
tokio::fs::write(
nested.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"child\"\n",
r#"foo = "child"
"#,
)
.await?;
@@ -1867,7 +2025,12 @@ async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::R
let home_dir = tmp.path().join("home");
let codex_home = home_dir.join(".codex");
tokio::fs::create_dir_all(&codex_home).await?;
tokio::fs::write(codex_home.join(CONFIG_TOML_FILE), "foo = \"user\"\n").await?;
tokio::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"foo = "user"
"#,
)
.await?;
let cwd = AbsolutePathBuf::from_absolute_path(&home_dir)?;
let layers = load_config_layers_state(
@@ -1909,7 +2072,12 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul
tokio::fs::create_dir_all(&nested_dot_codex).await?;
tokio::fs::create_dir_all(project_root.join(".git")).await?;
tokio::fs::write(nested_dot_codex.join(CONFIG_TOML_FILE), "foo = \"child\"\n").await?;
tokio::fs::write(
nested_dot_codex.join(CONFIG_TOML_FILE),
r#"foo = "child"
"#,
)
.await?;
tokio::fs::create_dir_all(&project_dot_codex).await?;
make_config_for_test(
@@ -1923,7 +2091,10 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul
let user_config_contents = tokio::fs::read_to_string(&user_config_path).await?;
tokio::fs::write(
&user_config_path,
format!("foo = \"user\"\n{user_config_contents}"),
format!(
r#"foo = "user"
{user_config_contents}"#
),
)
.await?;
@@ -1948,7 +2119,11 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect();
let child_config: TomlValue = toml::from_str("foo = \"child\"\n").expect("parse child config");
let child_config: TomlValue = toml::from_str(
r#"foo = "child"
"#,
)
.expect("parse child config");
let expected_project_layer = ConfigLayerEntry::new(
ConfigLayerSource::Project {
dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?,
@@ -1972,7 +2147,9 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
tokio::fs::create_dir_all(nested.join(".codex")).await?;
tokio::fs::write(
nested.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"child\"\nprofile = \"ignored\"\n",
r#"foo = "child"
profile = "ignored"
"#,
)
.await?;
@@ -1991,7 +2168,10 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
let untrusted_config_contents = tokio::fs::read_to_string(&untrusted_config_path).await?;
tokio::fs::write(
&untrusted_config_path,
format!("foo = \"user\"\n{untrusted_config_contents}"),
format!(
r#"foo = "user"
{untrusted_config_contents}"#
),
)
.await?;
@@ -2037,7 +2217,8 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
tokio::fs::create_dir_all(&codex_home_unknown).await?;
tokio::fs::write(
codex_home_unknown.join(CONFIG_TOML_FILE),
"foo = \"user\"\n",
r#"foo = "user"
"#,
)
.await?;
@@ -2207,7 +2388,8 @@ async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> st
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
tokio::fs::write(
project_root.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"project\"\n",
r#"foo = "project"
"#,
)
.await?;
std::os::unix::fs::symlink(&project_root, &alias_root)?;
@@ -2378,9 +2560,21 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
)
.await?;
let config_contents = tokio::fs::read_to_string(&config_path).await?;
tokio::fs::write(&config_path, format!("foo = \"user\"\n{config_contents}")).await?;
tokio::fs::write(
&config_path,
format!(
r#"foo = "user"
{config_contents}"#
),
)
.await?;
} else {
tokio::fs::write(&config_path, "foo = \"user\"\n").await?;
tokio::fs::write(
&config_path,
r#"foo = "user"
"#,
)
.await?;
}
let layers = load_config_layers_state(
@@ -2537,12 +2731,14 @@ async fn project_root_markers_supports_alternate_markers() -> std::io::Result<()
tokio::fs::write(project_root.join(".hg"), "hg").await?;
tokio::fs::write(
project_root.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"root\"\n",
r#"foo = "root"
"#,
)
.await?;
tokio::fs::write(
nested.join(".codex").join(CONFIG_TOML_FILE),
"foo = \"child\"\n",
r#"foo = "child"
"#,
)
.await?;
+32 -39
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;
@@ -136,9 +135,11 @@ mod otel;
mod permissions;
#[cfg(test)]
mod schema;
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;
@@ -902,6 +903,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>,
@@ -928,6 +930,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
@@ -957,6 +964,7 @@ impl ConfigBuilder {
cli_overrides,
harness_overrides,
loader_overrides,
strict_config,
cloud_requirements,
thread_config_loader,
fallback_cwd,
@@ -979,7 +987,10 @@ impl ConfigBuilder {
&codex_home,
Some(cwd),
&cli_overrides,
loader_overrides,
ConfigLoadOptions {
loader_overrides,
strict_config,
},
cloud_requirements,
thread_config_loader
.as_deref()
@@ -1260,56 +1271,38 @@ impl Config {
)
.await
}
/// This is a secondary way of creating [Config], which is appropriate when
/// the harness is meant to be used with a specific configuration that
/// ignores user settings. For example, the `codex exec` subcommand is
/// designed to use [AskForApproval::Never] exclusively.
///
/// Further, [ConfigOverrides] contains some options that are not supported
/// in [ConfigToml], such as `cwd`, `codex_self_exe`, `codex_linux_sandbox_exe`, and
/// `main_execve_wrapper_exe`.
pub async fn load_with_cli_overrides_and_harness_overrides(
cli_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
) -> std::io::Result<Self> {
ConfigBuilder::default()
.cli_overrides(cli_overrides)
.harness_overrides(harness_overrides)
.build()
.await
}
}
/// 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
}
/// DEPRECATED for most callers: prefer [Config::load_with_cli_overrides()] or
/// [ConfigBuilder] because working with [ConfigToml] directly means
/// [ConfigRequirements] have not been applied yet, which risks skipping
/// required constraints.
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
}
/// DEPRECATED for most callers: prefer [Config::load_with_cli_overrides()] or
/// [ConfigBuilder] because working with [ConfigToml] directly means
/// [ConfigRequirements] have not been applied yet, which risks skipping
/// required constraints.
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,
)