[codex] Limit app-based plugin suggestions to remote catalogs (#27988)

## Summary

- Keep local plugin suggestions bounded to fallback and explicitly
configured plugins.
- Preserve app-overlap recommendations for remote plugins using cached
catalog metadata.
- Remove the WSL-specific local discovery exception and move
manager-owned discovery tests into `codex-core-plugins`.

## Why

Local curated marketplaces were allowlisted before plugin detail
loading, so every uninstalled candidate could be deep-read before its
app IDs were checked. That caused per-turn reads of candidate plugin
manifests, skills, app configs, hooks, and MCP configs, which is
especially expensive on slow disks.

Remote discovery does not need those local candidate reads because app
IDs are already available in the cached remote catalog. Installed local
plugins are still loaded when needed to determine the user's installed
app IDs.

## Validation

- `just fmt`
- `just test -p codex-core-plugins discoverable::tests` (13 passed)
- `just test -p codex-core plugins::discoverable::tests` (4 passed)
- `just bazel-lock-update`
- `just bazel-lock-check`
- `git diff --check`
This commit is contained in:
xl-openai
2026-06-12 17:51:09 -07:00
committed by GitHub
Unverified
parent c884536d84
commit 044c1420a1
5 changed files with 740 additions and 644 deletions
@@ -1,24 +1,14 @@
use crate::plugins::test_support::load_plugins_config;
use crate::plugins::test_support::write_curated_plugin;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::test_support::write_plugins_feature_config;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote::fetch_and_cache_global_remote_plugin_catalog;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::tempdir;
use tracing::Level;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_test::internal::MockWriter;
async fn list_discoverable_plugins(
config: &crate::config::Config,
@@ -58,101 +48,6 @@ async fn list_discoverable_plugins_with_manager_and_auth(
.await
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]);
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
"openai-developers@openai-curated".to_string(),
"slack@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample", "slack", "hubspot"]);
write_plugin_app(&curated_root, "sample", "sample", "connector_sample");
write_plugins_feature_config(codex_home.path());
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["hubspot@openai-curated".to_string()]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps() {
let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b";
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["hubspot", "granola"]);
write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[hubspot_app_id.to_string()])
.await
.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["hubspot@openai-curated".to_string()]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(
&curated_root,
&["teams", "sharepoint", "outlook-email", "outlook-calendar"],
);
write_plugins_feature_config(codex_home.path());
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
"outlook-calendar@openai-curated".to_string(),
"outlook-email@openai-curated".to_string(),
"sharepoint@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_includes_cached_remote_global_plugins() {
use codex_login::CodexAuth;
@@ -431,216 +326,6 @@ disabled_tools = [
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_filters_sales_apps_by_marketplace() {
let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b";
let test_app_id = "asdk_app_test_source";
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "test-source"]);
write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
write_plugin_app(&curated_root, "test-source", "test_source", test_app_id);
let sales_marketplace_name = "oai-maintained-plugins";
let sales_marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{sales_marketplace_name}"));
write_file(
&sales_marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{sales_marketplace_name}",
"plugins": [
{{"name": "sales", "source": {{"source": "local", "path": "./plugins/sales"}}}}
]
}}
"#
),
);
write_curated_plugin(&sales_marketplace_root, "sales");
write_file(
&sales_marketplace_root.join("plugins/sales/.app.json"),
&format!(
r#"{{
"apps": {{
"hubspot": {{
"id": "{hubspot_app_id}"
}},
"granola": {{
"id": "{granola_app_id}"
}}
}}
}}
"#
),
);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{sales_marketplace_name}]
source_type = "git"
source = "/tmp/{sales_marketplace_name}"
"#
),
);
install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
"granola@openai-curated".to_string(),
"hubspot@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_openai_curated_when_remote_enabled() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
let bundled_marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
let bundled_marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{bundled_marketplace_name}"));
write_file(
&bundled_marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{bundled_marketplace_name}",
"plugins": [
{{"name": "chrome", "source": {{"source": "local", "path": "./plugins/chrome"}}}}
]
}}
"#
),
);
write_curated_plugin(&bundled_marketplace_root, "chrome");
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
remote_plugin = true
[marketplaces.{bundled_marketplace_name}]
source_type = "git"
source = "/tmp/{bundled_marketplace_name}"
"#
),
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["chrome@openai-bundled".to_string()]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_deduplicates_configured_marketplace_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let plugin_name = "sample";
let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
let plugin_id = format!("{plugin_name}@{marketplace_name}");
let marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{marketplace_name}"));
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{marketplace_name}",
"plugins": [
{{"name": "{plugin_name}", "source": {{"source": "local", "path": "./plugins/{plugin_name}"}}}}
]
}}
"#
),
);
write_curated_plugin(&marketplace_root, plugin_name);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
[tool_suggest]
discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
"#
),
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, plugin_id.as_str());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_ignores_missing_marketplace_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["installed", "slack"]);
let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
let marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{marketplace_name}"));
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{marketplace_name}",
"plugins": [
{{"name": "sample", "source": {{"source": "local", "path": "./plugins/sample"}}}}
]
}}
"#
),
);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
"#
),
);
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() {
let codex_home = tempdir().expect("tempdir should succeed");
@@ -659,65 +344,6 @@ plugins = false
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["installed", "slack"]);
write_plugins_feature_config(codex_home.path());
write_file(
&curated_root.join("plugins/slack/.codex-plugin/plugin.json"),
r#"{
"name": "slack",
"description": " Plugin\n with extra spacing "
}"#,
);
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
remote_plugin_id: None,
name: "slack".to_string(),
description: Some("Plugin with extra spacing".to_string()),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(codex_home.path());
write_plugins_feature_config(codex_home.path());
PluginsManager::new(codex_home.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "slack".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
curated_root.join(".agents/plugins/marketplace.json"),
)
.expect("marketplace path"),
})
.await
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&refreshed_config, &[])
.await
.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_disabled_tool_suggestions() {
let codex_home = tempdir().expect("tempdir should succeed");
@@ -741,61 +367,6 @@ disabled_tools = [
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_file(
&curated_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "openai-curated",
"plugins": [
{
"name": "installed",
"source": {
"source": "local",
"path": "./plugins/installed"
}
},
{
"name": "slack",
"source": {
"source": "local",
"path": "./plugins/slack"
}
},
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail"
},
"policy": {
"installation": "NOT_AVAILABLE"
}
}
]
}
"#,
);
write_curated_plugin(&curated_root, "installed");
write_curated_plugin(&curated_root, "slack");
write_curated_plugin(&curated_root, "gmail");
write_plugins_feature_config(codex_home.path());
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["slack@openai-curated".to_string()]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() {
let codex_home = tempdir().expect("tempdir should succeed");
@@ -829,98 +400,3 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack", "gmail", "openai-developers"]);
write_plugins_feature_config(codex_home.path());
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
let too_long_prompt = "x".repeat(129);
for plugin_name in ["gmail", "openai-developers"] {
write_file(
&curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")),
&format!(
r#"{{
"name": "{plugin_name}",
"description": "Plugin that includes skills, MCP servers, and app connectors",
"interface": {{
"defaultPrompt": "{too_long_prompt}"
}}
}}"#
),
);
}
let config = load_plugins_config(codex_home.path()).await;
let buffer: &'static std::sync::Mutex<Vec<u8>> =
Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_level(true)
.with_ansi(false)
.with_max_level(Level::WARN)
.with_span_events(FmtSpan::NONE)
.with_writer(MockWriter::new(buffer))
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.iter()
.map(|plugin| plugin.id.as_str())
.collect::<Vec<_>>(),
vec!["gmail@openai-curated", "openai-developers@openai-curated"]
);
let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone())
.expect("utf8 logs")
.replace('\\', "/");
assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 8);
let normalized_logs = logs.replace('\\', "/");
assert_eq!(
normalized_logs
.matches("gmail/.codex-plugin/plugin.json")
.count(),
4
);
assert_eq!(
normalized_logs
.matches("openai-developers/.codex-plugin/plugin.json")
.count(),
4
);
}
async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, plugin_name: &str) {
write_curated_plugin_sha(codex_home);
PluginsManager::new(codex_home.to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: plugin_name.to_string(),
marketplace_path: AbsolutePathBuf::try_from(
marketplace_root.join(".agents/plugins/marketplace.json"),
)
.expect("marketplace path"),
})
.await
.expect("plugin should install");
}
fn write_plugin_app(root: &Path, plugin_name: &str, app_name: &str, app_id: &str) {
write_file(
&root.join(format!("plugins/{plugin_name}/.app.json")),
&format!(
r#"{{
"apps": {{
"{app_name}": {{
"id": "{app_id}"
}}
}}
}}
"#
),
);
}