diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 4b42c28aa9..809314388f 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -616,6 +616,26 @@ fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a Tom Some(current) } +/// Looks up a path according to its config semantics. +/// +/// Shell filter patterns compare case-insensitively and are normalized in the +/// merged effective config, while raw layers preserve their original spelling. +/// Match that path segment case-insensitively so equivalent raw and effective +/// paths compare without changing ordinary TOML path semantics. +fn value_at_semantic_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> { + let [policy, filters, pattern] = segments else { + return value_at_path(root, segments); + }; + if policy != "shell_environment_policy" || filters != "filters" { + return value_at_path(root, segments); + } + + let filters = value_at_path(root, &segments[..2])?.as_table()?; + filters + .iter() + .find_map(|(candidate, value)| candidate.eq_ignore_ascii_case(pattern).then_some(value)) +} + fn override_message(layer: &ConfigLayerSource) -> String { match layer { ConfigLayerSource::Mdm { domain, key: _ } => { @@ -653,10 +673,10 @@ fn compute_override_metadata( segments: &[String], ) -> Option { let user_value = match layers.get_active_user_layer() { - Some(user_layer) => value_at_path(&user_layer.config, segments), + Some(user_layer) => value_at_semantic_path(&user_layer.config, segments), None => return None, }; - let effective_value = value_at_path(effective, segments); + let effective_value = value_at_semantic_path(effective, segments); if user_value.is_some() && user_value == effective_value { return None; @@ -696,7 +716,9 @@ fn find_effective_layer( segments: &[String], ) -> Option { for layer in layers.layers_high_to_low() { - if let Some(meta) = value_at_path(&layer.config, segments).map(|_| layer.metadata()) { + if let Some(meta) = + value_at_semantic_path(&layer.config, segments).map(|_| layer.metadata()) + { return Some(meta); } } 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 8deae8b065..f09da45832 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -841,6 +841,68 @@ async fn write_value_reports_managed_override() { assert_eq!(overridden.effective_value, serde_json::json!("never")); } +#[tokio::test] +async fn write_value_matches_shell_filter_paths_case_insensitively() -> Result<()> { + let tmp = tempdir()?; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, "")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let result = service + .write_value(ConfigValueWriteParams { + file_path: Some(config_path.display().to_string()), + key_path: "shell_environment_policy.filters.PATH".to_string(), + value: serde_json::json!("include"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await?; + + assert_eq!(result.status, WriteStatus::Ok); + assert_eq!(result.overridden_metadata, None); + assert!(std::fs::read_to_string(config_path)?.contains("PATH = \"include\"")); + Ok(()) +} + +#[tokio::test] +async fn write_value_reports_case_insensitive_shell_filter_override() -> Result<()> { + let tmp = tempdir()?; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, "")?; + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write( + &managed_path, + "[shell_environment_policy.filters]\npath = \"exclude\"\n", + )?; + let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?; + let service = ConfigManager::new_for_tests( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides::with_managed_config_path_for_tests(managed_path), + CloudConfigBundleLoader::default(), + ); + + let result = service + .write_value(ConfigValueWriteParams { + file_path: Some(config_path.display().to_string()), + key_path: "shell_environment_policy.filters.PATH".to_string(), + value: serde_json::json!("include"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await?; + + assert_eq!(result.status, WriteStatus::OkOverridden); + let overridden = result.overridden_metadata.expect("overridden metadata"); + assert_eq!( + overridden.overriding_layer.name, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file } + ); + assert_eq!(overridden.effective_value, serde_json::json!("exclude")); + Ok(()) +} + #[tokio::test] async fn upsert_merges_tables_replace_overwrites() -> Result<()> { let tmp = tempdir().expect("tempdir");