[codex] Ignore local curated plugins when remote catalog is active (#29765)

## Summary

- suppress configured `openai-curated` plugins when the remote plugin
feature is enabled and auth uses the Codex backend
- preserve `openai-api-curated` and non-Codex-backend behavior while
including remote catalog activation in the plugin load cache key
- add core plugin coverage and an app-server integration test for
runtime feature enablement

## Why

The Codex app enables remote plugins through process-local runtime
feature enablement, which can happen after app-server startup tasks have
already observed legacy local plugin state. The existing conflict logic
only preferred a remote plugin when the same plugin was already
installed remotely, so a configured legacy-only plugin could continue
exposing skills and other capabilities from `openai-curated`.

## Impact

When the remote catalog is active, legacy `openai-curated` plugins no
longer contribute skills, MCP servers, apps, or hooks. Remote installed
plugins continue to load normally, and `openai-api-curated` remains
unaffected. This does not change remote fetch, bundle sync, or uninstall
behavior.

## Validation

- `just test -p codex-core-plugins
remote_global_catalog_ignores_local_curated_plugins
remote_plugin_feature_keeps_local_curated_without_codex_backend`
- `just test -p codex-app-server
runtime_remote_plugin_enablement_excludes_local_curated_plugin_skills`
- `just fmt`
- `git diff --check`
This commit is contained in:
xl-openai
2026-06-23 19:51:31 -07:00
committed by GitHub
Unverified
parent 2696e7199b
commit ff78e21215
5 changed files with 189 additions and 30 deletions
+18 -15
View File
@@ -118,14 +118,14 @@ pub(crate) async fn load_plugins_from_layer_stack(
store: &PluginStore,
plugin_skill_snapshots: Option<&PluginSkillSnapshots>,
restriction_product: Option<Product>,
prefer_remote_curated_conflicts: bool,
remote_global_catalog_active: bool,
) -> Vec<LoadedPlugin<McpServerConfig>> {
let skill_config_rules = skill_config_rules_from_stack(config_layer_stack);
load_plugins_from_layer_stack_with_scope(
config_layer_stack,
extra_plugins,
store,
prefer_remote_curated_conflicts,
remote_global_catalog_active,
PluginLoadScope::AllCapabilities {
restriction_product,
skill_config_rules: &skill_config_rules,
@@ -139,14 +139,14 @@ async fn load_plugins_from_layer_stack_with_scope(
config_layer_stack: &ConfigLayerStack,
extra_plugins: HashMap<String, PluginConfig>,
store: &PluginStore,
prefer_remote_curated_conflicts: bool,
remote_global_catalog_active: bool,
scope: PluginLoadScope<'_>,
) -> Vec<LoadedPlugin<McpServerConfig>> {
let configured_plugins = merge_configured_plugins_with_remote_installed(
configured_plugins_from_stack(config_layer_stack),
extra_plugins,
store,
prefer_remote_curated_conflicts,
remote_global_catalog_active,
);
let mut configured_plugins: Vec<_> = configured_plugins.into_iter().collect();
configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
@@ -178,13 +178,13 @@ pub async fn load_plugin_hooks_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
extra_plugins: HashMap<String, PluginConfig>,
store: &PluginStore,
prefer_remote_curated_conflicts: bool,
remote_global_catalog_active: bool,
) -> PluginHookLoadOutcome {
let plugins = load_plugins_from_layer_stack_with_scope(
config_layer_stack,
extra_plugins,
store,
prefer_remote_curated_conflicts,
remote_global_catalog_active,
PluginLoadScope::HooksOnly,
)
.await;
@@ -206,8 +206,17 @@ fn merge_configured_plugins_with_remote_installed(
mut configured_plugins: HashMap<String, PluginConfig>,
extra_plugins: HashMap<String, PluginConfig>,
store: &PluginStore,
prefer_remote_curated_conflicts: bool,
remote_global_catalog_active: bool,
) -> HashMap<String, PluginConfig> {
if remote_global_catalog_active {
configured_plugins.retain(|plugin_key, _| match PluginId::parse(plugin_key) {
Ok(plugin_id) => plugin_id.marketplace_name != crate::OPENAI_CURATED_MARKETPLACE_NAME,
Err(_) => true,
});
configured_plugins.extend(extra_plugins);
return configured_plugins;
}
let mut local_curated_installed_plugin_keys = HashMap::<String, Vec<String>>::new();
for plugin_key in configured_plugins.keys() {
let Ok(plugin_id) = PluginId::parse(plugin_key) else {
@@ -234,14 +243,8 @@ fn merge_configured_plugins_with_remote_installed(
.as_ref()
.and_then(|plugin_name| local_curated_installed_plugin_keys.get(plugin_name));
if let Some(local_curated_plugin_keys) = local_curated_plugin_keys {
if prefer_remote_curated_conflicts {
for local_curated_plugin_key in local_curated_plugin_keys {
configured_plugins.remove(local_curated_plugin_key);
}
} else {
continue;
}
if local_curated_plugin_keys.is_some() {
continue;
}
configured_plugins.insert(plugin_key, plugin_config);
+2 -2
View File
@@ -162,14 +162,14 @@ enabled = true
&store,
/*plugin_skill_snapshots*/ None,
Some(Product::Codex),
/*prefer_remote_curated_conflicts*/ false,
/*remote_global_catalog_active*/ false,
)
.await;
let hooks_only = load_plugins_from_layer_stack_with_scope(
&stack,
HashMap::new(),
&store,
/*prefer_remote_curated_conflicts*/ false,
/*remote_global_catalog_active*/ false,
PluginLoadScope::HooksOnly,
)
.await;
+14 -8
View File
@@ -384,15 +384,15 @@ struct LoadedPluginsCache {
struct PluginLoadCacheKey {
configured_plugins: HashMap<String, PluginConfig>,
skill_config_rules: SkillConfigRules,
remote_plugin_enabled: bool,
remote_global_catalog_active: bool,
}
impl PluginLoadCacheKey {
fn from_config(config: &PluginsConfigInput) -> Self {
fn from_config(config: &PluginsConfigInput, remote_global_catalog_active: bool) -> Self {
Self {
configured_plugins: configured_plugins_from_stack(&config.config_layer_stack),
skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack),
remote_plugin_enabled: config.remote_plugin_enabled,
remote_global_catalog_active,
}
}
}
@@ -459,6 +459,10 @@ impl PluginsManager {
}
}
fn remote_global_catalog_active(&self, config: &PluginsConfigInput) -> bool {
config.remote_plugin_enabled && self.auth_mode().is_some_and(AuthMode::uses_codex_backend)
}
pub fn set_analytics_events_client(&self, analytics_events_client: AnalyticsEventsClient) {
let mut stored_client = match self.analytics_events_client.write() {
Ok(client_guard) => client_guard,
@@ -490,7 +494,8 @@ impl PluginsManager {
if !config.plugins_enabled {
return None;
}
let key = PluginLoadCacheKey::from_config(config);
let key =
PluginLoadCacheKey::from_config(config, self.remote_global_catalog_active(config));
self.loaded_plugins_cache
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
@@ -519,7 +524,8 @@ impl PluginsManager {
return PluginLoadOutcome::default();
}
let cache_key = PluginLoadCacheKey::from_config(config);
let remote_global_catalog_active = self.remote_global_catalog_active(config);
let cache_key = PluginLoadCacheKey::from_config(config, remote_global_catalog_active);
if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) {
return self.resolve_loaded_plugins_for_auth(plugins);
}
@@ -539,7 +545,7 @@ impl PluginsManager {
&self.store,
Some(&plugin_skill_snapshots),
self.restriction_product,
config.remote_plugin_enabled,
remote_global_catalog_active,
)
.await;
log_plugin_load_errors(&plugins);
@@ -624,7 +630,7 @@ impl PluginsManager {
&self.store,
/*plugin_skill_snapshots*/ None,
self.restriction_product,
config.remote_plugin_enabled,
self.remote_global_catalog_active(config),
)
.await;
self.resolve_loaded_plugins_for_auth(plugins)
@@ -643,7 +649,7 @@ impl PluginsManager {
config_layer_stack,
self.remote_installed_plugin_configs(),
&self.store,
config.remote_plugin_enabled,
self.remote_global_catalog_active(config),
)
.await
}
+50 -5
View File
@@ -1061,7 +1061,7 @@ enabled = true
}
#[tokio::test]
async fn remote_installed_cache_prefers_remote_curated_conflicts_when_remote_plugin_enabled() {
async fn remote_global_catalog_ignores_local_curated_plugins() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
@@ -1086,7 +1086,11 @@ enabled = true
write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only");
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = PluginsManager::new(codex_home.path().to_path_buf());
let manager = PluginsManager::new_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
manager.write_remote_installed_plugins_cache(vec![
remote_installed_plugin("linear"),
remote_installed_plugin("remote-only"),
@@ -1100,13 +1104,54 @@ enabled = true
.map(|plugin| plugin.config_name.clone())
.collect::<Vec<_>>(),
vec![
"calendar@openai-curated".to_string(),
"linear@openai-api-curated".to_string(),
"linear@openai-curated-remote".to_string(),
"remote-only@openai-curated-remote".to_string(),
]
);
}
#[tokio::test]
async fn remote_plugin_feature_keeps_local_curated_without_codex_backend() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = true
[plugins."linear@openai-curated"]
enabled = true
[plugins."linear@openai-api-curated"]
enabled = true
"#,
);
write_cached_plugin(codex_home.path(), "openai-curated", "linear");
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = PluginsManager::new_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::ApiKey),
);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(
outcome
.plugins()
.iter()
.map(|plugin| plugin.config_name.clone())
.collect::<Vec<_>>(),
vec![
"linear@openai-api-curated".to_string(),
"linear@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() {
let codex_home = TempDir::new().unwrap();
@@ -2273,7 +2318,7 @@ fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() {
let cache_key = PluginLoadCacheKey {
configured_plugins: HashMap::new(),
skill_config_rules: SkillConfigRules::default(),
remote_plugin_enabled: false,
remote_global_catalog_active: false,
};
let stale_generation = manager.loaded_plugins_cache_generation();
@@ -5398,7 +5443,7 @@ async fn load_plugins_ignores_project_config_files() {
&PluginStore::new(codex_home.path().to_path_buf()),
/*plugin_skill_snapshots*/ None,
Some(Product::Codex),
/*prefer_remote_curated_conflicts*/ false,
/*remote_global_catalog_active*/ false,
)
.await;