diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index 7133ea9d0a..d07489e454 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -493,6 +493,49 @@ enabled = false Ok(()) } +#[tokio::test] +async fn write_value_rejects_unknown_mcp_field_hidden_by_cloud_config() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, "")?; + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::without_managed_config_for_tests(), + CloudConfigBundleFixture::loader_with_enterprise_config( + r#"[mcp_servers.docs] +command = "cloud-docs-server" +enabled = false +"#, + ), + ); + + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(config_path.display().to_string()), + key_path: "mcp_servers.docs.typo".to_string(), + value: serde_json::json!(true), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("the cloud transport must not hide an unknown user-layer field"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("unknown configuration field `mcp_servers.docs.typo`"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(config_path)?, ""); + + Ok(()) +} + #[tokio::test] async fn read_includes_origins_and_layers() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/config_toml_layer.rs b/codex-rs/config/src/config_toml_layer.rs index 53d114eae2..077d5ecddd 100644 --- a/codex-rs/config/src/config_toml_layer.rs +++ b/codex-rs/config/src/config_toml_layer.rs @@ -1,7 +1,9 @@ use crate::config_toml::ConfigToml; use crate::mcp_types::RawMcpServerConfig; +use crate::strict_config::ignored_toml_value_field; use codex_utils_absolute_path::AbsolutePathBufGuard; use serde::Deserialize; +use serde::de::Error as _; use std::collections::HashMap; use std::path::Path; use toml::Value as TomlValue; @@ -17,6 +19,13 @@ impl ParsedConfigTomlLayer { pub fn parse(raw: TomlValue, base_dir: &Path) -> Result { let parts = split_config_toml_layer(raw.clone()); let _guard = AbsolutePathBufGuard::new(base_dir); + if let Some(ignored_field) = + ignored_toml_value_field::(parts.composable.clone()) + { + return Err(toml::de::Error::custom(format!( + "unknown configuration field `{ignored_field}`" + ))); + } let _: ConfigTomlComposableSections = parts.composable.try_into()?; let self_contained_config = parts.self_contained.try_into()?;