Files
codex/codex-rs/core/src/plugins/discoverable.rs
T
Noah MacCallum 8e5f561697 Filter plugin install suggestions by installed apps (#24996)
## Summary

- Keep the original `TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST` as a
fallback seed list, so users with no installed plugins still get initial
install suggestions.
- Allow additional install suggestions from trusted marketplaces:
`openai-curated` and `openai-bundled`.
- Require non-fallback, non-configured marketplace candidates to share
`.app.json` connector IDs with already installed plugins.
- Preserve explicit configured plugin discoverables as an override,
while still omitting installed, disabled, and `NOT_AVAILABLE` plugins.

## Context

`list_available_plugins_to_install` controls which plugins the model can
trigger via `request_plugin_install`. We want a small starter set for
empty/new users, but we also want installed workflow plugins to unlock
relevant source plugins without maintaining every source plugin ID by
hand.

This keeps the legacy plugin ID allowlist only as the starter fallback.
For everything else, the trusted marketplace is the candidate boundary,
and installed app connector overlap is the relevance filter. For
example, an installed Sales plugin can make HubSpot and Granola
suggestible when those source plugins are in `openai-curated` and share
Sales app connector IDs, while an unrelated test-source plugin with an
app connector not declared by Sales stays hidden.

## Test Coverage

- Empty/no-installed-plugin case: returns the fallback seed plugins from
the original allowlist.
- Installed-app expansion: returns non-fallback marketplace plugins only
when their app connector IDs overlap with an installed plugin.
- Sales workflow case: installed Sales declares HubSpot and Granola
apps, so `hubspot@openai-curated` and `granola@openai-curated` are
returned.
- Sales negative case: `test-source@openai-curated` has an app connector
not declared by Sales, so it is not returned.
- Existing guardrails: installed plugins, disabled suggestions, and
`NOT_AVAILABLE` plugins remain omitted; explicit configured
discoverables still work as an override.

## Validation

- `just fmt`
- `just test -p codex-core plugins::discoverable::tests`
- `just test -p codex-core` was attempted earlier, but current `main` /
local env failed with unrelated existing failures around missing
`test_stdio_server`, CLI/code-mode MCP tool setup, and
unified_exec/shell snapshot flakes/timeouts. The touched discoverable
tests pass.
2026-05-29 15:32:04 -07:00

127 lines
4.9 KiB
Rust

use anyhow::Context;
use std::collections::HashSet;
use tracing::warn;
use super::PluginCapabilitySummary;
use crate::config::Config;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST as TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_features::Feature;
use codex_tools::DiscoverablePluginInfo;
const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
OPENAI_BUNDLED_MARKETPLACE_NAME,
OPENAI_CURATED_MARKETPLACE_NAME,
];
pub(crate) async fn list_tool_suggest_discoverable_plugins(
config: &Config,
loaded_plugin_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
}
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
let plugins_input = config.plugins_config_input();
let configured_plugin_ids = config
.tool_suggest
.discoverables
.iter()
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin)
.map(|discoverable| discoverable.id.as_str())
.collect::<HashSet<_>>();
let disabled_plugin_ids = config
.tool_suggest
.disabled_tools
.iter()
.filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Plugin)
.map(|disabled_tool| disabled_tool.id.as_str())
.collect::<HashSet<_>>();
let marketplaces = plugins_manager
.list_marketplaces_for_config(&plugins_input, &[])
.context("failed to list plugin marketplaces for tool suggestions")?
.marketplaces;
let mut installed_app_connector_ids = plugins_manager
.plugins_for_config(&plugins_input)
.await
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.collect::<HashSet<_>>();
installed_app_connector_ids.extend(loaded_plugin_app_connector_ids.iter().cloned());
let mut discoverable_plugins = Vec::<DiscoverablePluginInfo>::new();
for marketplace in marketplaces {
let marketplace_name = marketplace.name;
let is_allowlisted_marketplace =
TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str());
for plugin in marketplace.plugins {
let is_configured_plugin = configured_plugin_ids.contains(plugin.id.as_str());
let is_fallback_plugin =
TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST.contains(&plugin.id.as_str());
if plugin.installed
|| plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|| disabled_plugin_ids.contains(plugin.id.as_str())
|| (!is_allowlisted_marketplace && !is_configured_plugin)
{
continue;
}
let plugin_id = plugin.id.clone();
match plugins_manager
.read_plugin_detail_for_marketplace_plugin(
&plugins_input,
&marketplace_name,
plugin,
)
.await
{
Ok(plugin) => {
let plugin: PluginCapabilitySummary = plugin.into();
let matches_installed_app =
plugin.app_connector_ids.iter().any(|connector_id| {
installed_app_connector_ids.contains(connector_id.0.as_str())
});
if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app {
continue;
}
discoverable_plugins.push(DiscoverablePluginInfo {
id: plugin.config_name,
name: plugin.display_name,
description: plugin.description,
has_skills: plugin.has_skills,
mcp_server_names: plugin.mcp_server_names,
app_connector_ids: plugin
.app_connector_ids
.into_iter()
.map(|connector_id| connector_id.0)
.collect(),
});
}
Err(err) => {
warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}")
}
}
}
}
discoverable_plugins.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(discoverable_plugins)
}
#[cfg(test)]
#[path = "discoverable_tests.rs"]
mod tests;