Cache remote plugin catalog for suggestions

This commit is contained in:
Xin Lin
2026-05-31 15:15:20 -07:00
parent 11e0f3d3ae
commit 0345352c4a
13 changed files with 742 additions and 62 deletions

View File

@@ -662,6 +662,7 @@ impl PluginRequestProcessor {
&remote_plugin_service_config,
auth.as_ref(),
&remote_sources,
Some(config.codex_home.as_path()),
)
.await
{

View File

@@ -1900,6 +1900,23 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
"project management".to_string()
]
);
let cache_files = std::fs::read_dir(codex_home.path().join("cache/remote_plugin_catalog"))?
.map(|entry| entry.map(|entry| entry.path()))
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(cache_files.len(), 1);
let cached_catalog: serde_json::Value =
serde_json::from_slice(&std::fs::read(&cache_files[0])?)?;
assert_eq!(cached_catalog["schema_version"], serde_json::json!(1));
let cached_plugin_ids = cached_catalog["plugins"]
.as_array()
.expect("cached plugins should be an array")
.iter()
.map(|plugin| plugin["id"].as_str().expect("cached plugin id").to_string())
.collect::<Vec<_>>();
assert_eq!(
cached_plugin_ids,
vec!["plugins~Plugin_00000000000000000000000000000000".to_string()]
);
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
assert!(
!server

View File

@@ -35,6 +35,20 @@ pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
"outlook-calendar@openai-curated",
"linear@openai-curated",
"figma@openai-curated",
"github@openai-curated-remote",
"notion@openai-curated-remote",
"slack@openai-curated-remote",
"gmail@openai-curated-remote",
"google-calendar@openai-curated-remote",
"google-drive@openai-curated-remote",
"openai-developers@openai-curated-remote",
"canva@openai-curated-remote",
"teams@openai-curated-remote",
"sharepoint@openai-curated-remote",
"outlook-email@openai-curated-remote",
"outlook-calendar@openai-curated-remote",
"linear@openai-curated-remote",
"figma@openai-curated-remote",
"chrome@openai-bundled",
"computer-use@openai-bundled",
];

View File

@@ -592,6 +592,45 @@ impl PluginsManager {
Some(crate::remote::group_remote_installed_plugins_by_marketplaces(plugins, visible_scopes))
}
pub fn cached_global_remote_discoverable_plugins_for_config(
&self,
config: &PluginsConfigInput,
auth: Option<&CodexAuth>,
) -> Vec<crate::remote::RemoteDiscoverablePlugin> {
if !config.plugins_enabled || !config.remote_plugin_enabled {
return Vec::new();
}
let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else {
return Vec::new();
};
let Some(account_id) = auth.get_account_id() else {
return Vec::new();
};
if account_id.is_empty() {
return Vec::new();
}
crate::remote::cached_global_remote_discoverable_plugins(
self.codex_home.as_path(),
&remote_plugin_service_config(config),
auth,
)
}
pub fn remote_installed_plugin_ids_from_cache(&self) -> Option<HashSet<String>> {
let cache = match self.remote_installed_plugins_cache.read() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
Some(
cache
.as_ref()?
.iter()
.map(|plugin| plugin.id.clone())
.collect(),
)
}
pub async fn build_and_cache_remote_installed_plugin_marketplaces(
&self,
config: &PluginsConfigInput,
@@ -1548,9 +1587,30 @@ impl PluginsManager {
);
manager.maybe_start_remote_installed_plugin_bundle_sync(
&config_for_remote_sync,
auth,
auth.clone(),
on_effective_plugins_changed,
);
if config_for_remote_sync.remote_plugin_enabled {
match crate::remote::fetch_and_cache_global_remote_plugin_catalog(
manager.codex_home.as_path(),
&remote_plugin_service_config(&config_for_remote_sync),
auth.as_ref(),
)
.await
{
Ok(()) => {}
Err(
RemotePluginCatalogError::AuthRequired
| RemotePluginCatalogError::UnsupportedAuthMode,
) => {}
Err(err) => {
warn!(
error = %err,
"failed to warm remote plugin catalog cache"
);
}
}
}
});
let config = config.clone();

View File

@@ -12,15 +12,18 @@ use codex_plugin::PluginId;
use codex_utils_absolute_path::AbsolutePathBuf;
use reqwest::RequestBuilder;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use url::Url;
mod catalog_cache;
mod remote_installed_plugin_sync;
mod share;
@@ -179,6 +182,18 @@ pub struct RemotePluginSkillDetail {
pub contents: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteDiscoverablePlugin {
pub config_id: String,
pub remote_plugin_id: String,
pub name: String,
pub description: Option<String>,
pub has_skills: bool,
pub app_ids: Vec<String>,
pub install_policy: PluginInstallPolicy,
pub availability: PluginAvailability,
}
pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool {
!plugin_id.is_empty()
&& plugin_id
@@ -293,7 +308,7 @@ pub enum RemotePluginCatalogError {
CacheRemove(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
pub enum RemotePluginScope {
#[serde(rename = "GLOBAL")]
Global,
@@ -340,7 +355,7 @@ struct RemotePluginPagination {
next_page_token: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct RemotePluginSkillInterfaceResponse {
display_name: Option<String>,
short_description: Option<String>,
@@ -350,7 +365,7 @@ struct RemotePluginSkillInterfaceResponse {
icon_large_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct RemotePluginSkillResponse {
name: String,
description: String,
@@ -364,7 +379,7 @@ struct RemotePluginSkillDetailResponse {
skill_md_contents: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct RemotePluginReleaseInterfaceResponse {
short_description: Option<String>,
long_description: Option<String>,
@@ -383,7 +398,7 @@ struct RemotePluginReleaseInterfaceResponse {
screenshot_urls: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct RemotePluginReleaseResponse {
#[serde(default)]
version: Option<String>,
@@ -402,7 +417,7 @@ struct RemotePluginReleaseResponse {
skills: Vec<RemotePluginSkillResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct RemotePluginDirectoryItem {
id: String,
name: String,
@@ -450,7 +465,7 @@ fn workspace_plugin_discoverability(
})
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct RemotePluginDirectorySharePrincipal {
principal_type: RemotePluginSharePrincipalType,
principal_id: String,
@@ -489,6 +504,7 @@ pub async fn fetch_remote_marketplaces(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
sources: &[RemoteMarketplaceSource],
global_catalog_cache_path: Option<&Path>,
) -> Result<Vec<RemoteMarketplace>, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let mut marketplaces = Vec::new();
@@ -512,6 +528,8 @@ pub async fn fetch_remote_marketplaces(
fetch_directory_plugins_for_scope(config, auth, scope),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
let directory_plugins_for_cache =
global_catalog_cache_path.map(|_| directory_plugins.clone());
if let Some(marketplace) = build_remote_marketplace(
scope.marketplace_name(),
scope.marketplace_display_name(),
@@ -521,6 +539,16 @@ pub async fn fetch_remote_marketplaces(
)? {
marketplaces.push(marketplace);
}
if let (Some(codex_home), Some(directory_plugins)) =
(global_catalog_cache_path, directory_plugins_for_cache)
{
catalog_cache::write_cached_global_directory_plugins(
codex_home,
config,
auth,
&directory_plugins,
);
}
}
RemoteMarketplaceSource::WorkspaceDirectory => {
let scope = RemotePluginScope::Workspace;
@@ -600,6 +628,36 @@ pub async fn fetch_remote_marketplaces(
Ok(marketplaces)
}
pub async fn fetch_and_cache_global_remote_plugin_catalog(
codex_home: &Path,
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<(), RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let plugins =
fetch_directory_plugins_for_scope(config, auth, RemotePluginScope::Global).await?;
catalog_cache::write_cached_global_directory_plugins(codex_home, config, auth, &plugins);
Ok(())
}
pub fn cached_global_remote_discoverable_plugins(
codex_home: &Path,
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
) -> Vec<RemoteDiscoverablePlugin> {
catalog_cache::load_cached_global_directory_plugins(codex_home, config, auth)
.unwrap_or_default()
.into_iter()
.filter_map(|plugin| match remote_discoverable_plugin_from_directory_item(&plugin) {
Ok(plugin) => Some(plugin),
Err(err) => {
tracing::warn!(error = %err, "ignoring cached remote plugin recommendation entry");
None
}
})
.collect()
}
pub async fn fetch_openai_curated_remote_collection_marketplace(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -1053,6 +1111,34 @@ fn build_remote_plugin_summary(
})
}
fn remote_discoverable_plugin_from_directory_item(
plugin: &RemotePluginDirectoryItem,
) -> Result<RemoteDiscoverablePlugin, RemotePluginCatalogError> {
let marketplace_name = remote_plugin_canonical_marketplace_name(plugin)?;
let plugin_id =
PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| {
RemotePluginCatalogError::UnexpectedResponse(format!(
"invalid remote plugin config id for `{}` in `{marketplace_name}`: {err}",
plugin.name
))
})?;
let display_name =
non_empty_string(Some(&plugin.release.display_name)).unwrap_or_else(|| plugin.name.clone());
let description = non_empty_string(plugin.release.interface.short_description.as_deref())
.or_else(|| non_empty_string(Some(&plugin.release.description)));
Ok(RemoteDiscoverablePlugin {
config_id: plugin_id.as_key(),
remote_plugin_id: plugin.id.clone(),
name: display_name,
description,
has_skills: !plugin.release.skills.is_empty(),
app_ids: plugin.release.app_ids.clone(),
install_policy: plugin.installation_policy,
availability: plugin.availability,
})
}
fn remote_plugin_share_context(
plugin: &RemotePluginDirectoryItem,
) -> Result<Option<RemotePluginShareContext>, RemotePluginCatalogError> {

View File

@@ -0,0 +1,111 @@
use super::RemotePluginDirectoryItem;
use super::RemotePluginServiceConfig;
use codex_login::CodexAuth;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
use tracing::warn;
const REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION: u8 = 1;
const REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR: &str = "cache/remote_plugin_catalog";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct RemotePluginCatalogCacheKey {
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
}
impl RemotePluginCatalogCacheKey {
fn global(config: &RemotePluginServiceConfig, auth: &CodexAuth) -> Self {
Self {
chatgpt_base_url: config.chatgpt_base_url.clone(),
account_id: auth.get_account_id(),
chatgpt_user_id: auth.get_chatgpt_user_id(),
is_workspace_account: auth.is_workspace_account(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RemotePluginCatalogDiskCache {
schema_version: u8,
plugins: Vec<RemotePluginDirectoryItem>,
}
pub(crate) fn load_cached_global_directory_plugins(
codex_home: &Path,
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
) -> Option<Vec<RemotePluginDirectoryItem>> {
let cache_path = cache_path(
codex_home,
&RemotePluginCatalogCacheKey::global(config, auth),
);
let bytes = match std::fs::read(&cache_path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
Err(err) => {
warn!(
cache_path = %cache_path.display(),
"failed to read remote plugin catalog disk cache: {err}"
);
return None;
}
};
let cache: RemotePluginCatalogDiskCache = match serde_json::from_slice(&bytes) {
Ok(cache) => cache,
Err(err) => {
warn!(
cache_path = %cache_path.display(),
"failed to parse remote plugin catalog disk cache: {err}"
);
let _ = std::fs::remove_file(cache_path);
return None;
}
};
if cache.schema_version != REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION {
let _ = std::fs::remove_file(cache_path);
return None;
}
Some(cache.plugins)
}
pub(crate) fn write_cached_global_directory_plugins(
codex_home: &Path,
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
plugins: &[RemotePluginDirectoryItem],
) {
let cache_path = cache_path(
codex_home,
&RemotePluginCatalogCacheKey::global(config, auth),
);
if let Some(parent) = cache_path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
return;
}
let Ok(bytes) = serde_json::to_vec_pretty(&RemotePluginCatalogDiskCache {
schema_version: REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION,
plugins: plugins.to_vec(),
}) else {
return;
};
let _ = std::fs::write(cache_path, bytes);
}
fn cache_path(codex_home: &Path, cache_key: &RemotePluginCatalogCacheKey) -> PathBuf {
let cache_key_json = serde_json::to_vec(cache_key).unwrap_or_default();
let mut cache_key_hash = 0xcbf29ce484222325_u64;
for byte in cache_key_json {
cache_key_hash ^= u64::from(byte);
cache_key_hash = cache_key_hash.wrapping_mul(0x100000001b3);
}
codex_home
.join(REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR)
.join(format!("{cache_key_hash:016x}.json"))
}

View File

@@ -111,6 +111,7 @@ pub(crate) async fn list_accessible_and_enabled_connectors_from_manager(
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
plugins_manager: &PluginsManager,
auth: Option<&CodexAuth>,
accessible_connectors: &[AppInfo],
loaded_plugin_app_connector_ids: &[String],
@@ -129,11 +130,15 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
)
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins =
list_tool_suggest_discoverable_plugins(config, loaded_plugin_app_connector_ids)
.await?
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(
config,
plugins_manager,
auth,
loaded_plugin_app_connector_ids,
)
.await?
.into_iter()
.map(DiscoverableTool::from);
Ok(discoverable_connectors
.chain(discoverable_plugins)
.collect())

View File

@@ -1236,11 +1236,17 @@ discoverables = [
.await
.expect("config should load");
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
let discoverable_tools =
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
.await
.expect("discoverable tools should load");
let discoverable_tools = list_tool_suggest_discoverable_tools_with_auth(
&config,
&plugins_manager,
Some(&auth),
&[],
&[],
)
.await
.expect("discoverable tools should load");
assert_eq!(
discoverable_tools,
@@ -1268,9 +1274,11 @@ apps = true
.expect("config should load");
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let loaded_plugin_app_connector_ids = vec!["asdk_app_databricks_workspace".to_string()];
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
let discoverable_tools = list_tool_suggest_discoverable_tools_with_auth(
&config,
&plugins_manager,
Some(&auth),
&[],
&loaded_plugin_app_connector_ids,

View File

@@ -4,29 +4,35 @@ use tracing::warn;
use super::PluginCapabilitySummary;
use crate::config::Config;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
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::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_tools::DiscoverablePluginInfo;
const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
OPENAI_BUNDLED_MARKETPLACE_NAME,
OPENAI_CURATED_MARKETPLACE_NAME,
REMOTE_GLOBAL_MARKETPLACE_NAME,
];
pub(crate) async fn list_tool_suggest_discoverable_plugins(
config: &Config,
plugins_manager: &PluginsManager,
auth: Option<&CodexAuth>,
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
@@ -65,7 +71,7 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
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());
TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str());
if plugin.installed
|| plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|| disabled_plugin_ids.contains(plugin.id.as_str())
@@ -113,6 +119,15 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
}
}
}
append_cached_remote_discoverable_plugins(
plugins_manager,
&plugins_input,
auth,
&configured_plugin_ids,
&disabled_plugin_ids,
&installed_app_connector_ids,
&mut discoverable_plugins,
);
discoverable_plugins.sort_by(|left, right| {
left.name
.cmp(&right.name)
@@ -121,6 +136,53 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
Ok(discoverable_plugins)
}
fn append_cached_remote_discoverable_plugins(
plugins_manager: &PluginsManager,
plugins_input: &codex_core_plugins::PluginsConfigInput,
auth: Option<&CodexAuth>,
configured_plugin_ids: &HashSet<&str>,
disabled_plugin_ids: &HashSet<&str>,
installed_app_connector_ids: &HashSet<String>,
discoverable_plugins: &mut Vec<DiscoverablePluginInfo>,
) {
let Some(installed_remote_plugin_ids) =
plugins_manager.remote_installed_plugin_ids_from_cache()
else {
return;
};
for plugin in
plugins_manager.cached_global_remote_discoverable_plugins_for_config(plugins_input, auth)
{
let is_configured_plugin = configured_plugin_ids.contains(plugin.config_id.as_str())
|| configured_plugin_ids.contains(plugin.remote_plugin_id.as_str());
let is_fallback_plugin =
TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.config_id.as_str());
let matches_installed_app = plugin
.app_ids
.iter()
.any(|app_id| installed_app_connector_ids.contains(app_id.as_str()));
let is_disabled = disabled_plugin_ids.contains(plugin.config_id.as_str())
|| disabled_plugin_ids.contains(plugin.remote_plugin_id.as_str());
if installed_remote_plugin_ids.contains(&plugin.remote_plugin_id)
|| plugin.install_policy == PluginInstallPolicy::NotAvailable
|| plugin.availability == PluginAvailability::DisabledByAdmin
|| is_disabled
|| (!is_configured_plugin && !is_fallback_plugin && !matches_installed_app)
{
continue;
}
discoverable_plugins.push(DiscoverablePluginInfo {
id: plugin.config_id,
name: plugin.name,
description: plugin.description,
has_skills: plugin.has_skills,
mcp_server_names: Vec::new(),
app_connector_ids: plugin.app_ids,
});
}
}
#[cfg(test)]
#[path = "discoverable_tests.rs"]
mod tests;

View File

@@ -1,4 +1,3 @@
use super::*;
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;
@@ -7,6 +6,10 @@ 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::RemotePluginScope;
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;
@@ -17,6 +20,44 @@ use tracing::Level;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_test::internal::MockWriter;
async fn list_discoverable_plugins(
config: &crate::config::Config,
loaded_plugin_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
list_discoverable_plugins_with_auth(config, /*auth*/ None, loaded_plugin_app_connector_ids)
.await
}
async fn list_discoverable_plugins_with_auth(
config: &crate::config::Config,
auth: Option<&codex_login::CodexAuth>,
loaded_plugin_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
list_discoverable_plugins_with_manager_and_auth(
config,
&plugins_manager,
auth,
loaded_plugin_app_connector_ids,
)
.await
}
async fn list_discoverable_plugins_with_manager_and_auth(
config: &crate::config::Config,
plugins_manager: &PluginsManager,
auth: Option<&codex_login::CodexAuth>,
loaded_plugin_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
super::list_tool_suggest_discoverable_plugins(
config,
plugins_manager,
auth,
loaded_plugin_app_connector_ids,
)
.await
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
@@ -25,9 +66,7 @@ async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
@@ -51,9 +90,7 @@ async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installe
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_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
@@ -76,10 +113,9 @@ async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps()
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins =
list_tool_suggest_discoverable_plugins(&config, &[hubspot_app_id.to_string()])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[hubspot_app_id.to_string()])
.await
.unwrap();
assert_eq!(
discoverable_plugins
@@ -102,9 +138,7 @@ async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_a
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_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
@@ -119,6 +153,283 @@ async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_a
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_includes_cached_remote_global_plugins() {
use codex_login::CodexAuth;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
let codex_home = tempdir().expect("tempdir should succeed");
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "GLOBAL"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [
{
"id": "plugins~Plugin_remote_github",
"name": "github",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "AVAILABLE",
"release": {
"display_name": "Remote GitHub",
"description": "Remote GitHub long",
"app_ids": ["github"],
"interface": {
"short_description": "Remote GitHub short",
"long_description": null,
"developer_name": null,
"category": null,
"capabilities": [],
"website_url": null,
"privacy_policy_url": null,
"terms_of_service_url": null,
"brand_color": null,
"default_prompt": null,
"composer_icon_url": null,
"logo_url": null,
"screenshot_urls": []
},
"skills": [
{
"name": "github",
"description": "Use GitHub",
"interface": null
}
]
}
},
{
"id": "plugins~Plugin_remote_unlisted",
"name": "remote-unlisted",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "AVAILABLE",
"release": {
"display_name": "Remote Unlisted",
"description": "Remote Unlisted long",
"app_ids": [],
"interface": {
"short_description": "Remote Unlisted short",
"long_description": null,
"developer_name": null,
"category": null,
"capabilities": [],
"website_url": null,
"privacy_policy_url": null,
"terms_of_service_url": null,
"brand_color": null,
"default_prompt": null,
"composer_icon_url": null,
"logo_url": null,
"screenshot_urls": []
},
"skills": [
{
"name": "remote-unlisted",
"description": "Use unlisted remote plugin",
"interface": null
}
]
}
},
{
"id": "plugins~Plugin_remote_slack_not_available",
"name": "slack",
"scope": "GLOBAL",
"installation_policy": "NOT_AVAILABLE",
"authentication_policy": "ON_USE",
"status": "AVAILABLE",
"release": {
"display_name": "Remote Slack",
"description": "Remote Slack long",
"app_ids": [],
"interface": {
"short_description": "Remote Slack short",
"long_description": null,
"developer_name": null,
"category": null,
"capabilities": [],
"website_url": null,
"privacy_policy_url": null,
"terms_of_service_url": null,
"brand_color": null,
"default_prompt": null,
"composer_icon_url": null,
"logo_url": null,
"screenshot_urls": []
},
"skills": []
}
},
{
"id": "plugins~Plugin_remote_figma_admin_disabled",
"name": "figma",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "DISABLED_BY_ADMIN",
"release": {
"display_name": "Remote Figma",
"description": "Remote Figma long",
"app_ids": [],
"interface": {
"short_description": "Remote Figma short",
"long_description": null,
"developer_name": null,
"category": null,
"capabilities": [],
"website_url": null,
"privacy_policy_url": null,
"terms_of_service_url": null,
"brand_color": null,
"default_prompt": null,
"composer_icon_url": null,
"logo_url": null,
"screenshot_urls": []
},
"skills": []
}
}
],
"pagination": {
"next_page_token": null
}
})))
.expect(1)
.mount(&server)
.await;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let mut config = load_plugins_config(codex_home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
fetch_and_cache_global_remote_plugin_catalog(
codex_home.path(),
&RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
},
Some(&auth),
)
.await
.expect("remote plugin catalog cache should write");
let discoverable_plugins = list_discoverable_plugins_with_manager_and_auth(
&config,
&plugins_manager,
Some(&auth),
&[],
)
.await
.unwrap();
assert!(
discoverable_plugins
.iter()
.all(|plugin| plugin.id != "github@openai-curated-remote")
);
for scope in ["GLOBAL", "WORKSPACE"] {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", scope))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [],
"pagination": {
"next_page_token": null
}
})))
.expect(1)
.mount(&server)
.await;
}
plugins_manager
.build_and_cache_remote_installed_plugin_marketplaces(
&config.plugins_config_input(),
Some(&auth),
&[RemotePluginScope::Global],
/*on_effective_plugins_changed*/ None,
)
.await
.expect("remote installed plugin cache should write");
let discoverable_plugins = list_discoverable_plugins_with_manager_and_auth(
&config,
&plugins_manager,
Some(&auth),
&[],
)
.await
.unwrap();
assert_eq!(
discoverable_plugins
.iter()
.filter(|plugin| plugin.id.ends_with("@openai-curated-remote"))
.map(|plugin| plugin.id.as_str())
.collect::<Vec<_>>(),
vec!["github@openai-curated-remote"]
);
let remote_plugins = discoverable_plugins
.into_iter()
.filter(|plugin| plugin.id == "github@openai-curated-remote")
.collect::<Vec<_>>();
assert_eq!(
remote_plugins,
vec![DiscoverablePluginInfo {
id: "github@openai-curated-remote".to_string(),
name: "Remote GitHub".to_string(),
description: Some("Remote GitHub short".to_string()),
has_skills: true,
mcp_server_names: Vec::new(),
app_connector_ids: vec!["github".to_string()],
}]
);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = true
[tool_suggest]
disabled_tools = [
{ type = "plugin", id = "github@openai-curated-remote" }
]
"#,
);
let mut config_with_disabled_remote_plugin = load_plugins_config(codex_home.path()).await;
config_with_disabled_remote_plugin.chatgpt_base_url = config.chatgpt_base_url.clone();
let discoverable_plugins = list_discoverable_plugins_with_manager_and_auth(
&config_with_disabled_remote_plugin,
&plugins_manager,
Some(&auth),
&[],
)
.await
.unwrap();
assert!(
discoverable_plugins
.iter()
.all(|plugin| plugin.id != "github@openai-curated-remote")
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_filters_sales_apps_by_marketplace() {
let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
@@ -179,9 +490,7 @@ 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_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
@@ -234,9 +543,7 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
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());
@@ -278,9 +585,7 @@ 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_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
@@ -299,9 +604,7 @@ plugins = false
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
@@ -322,9 +625,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
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_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins,
@@ -359,7 +660,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, &[])
let discoverable_plugins = list_discoverable_plugins(&refreshed_config, &[])
.await
.unwrap();
@@ -384,9 +685,7 @@ disabled_tools = [
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
@@ -435,9 +734,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plug
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_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
@@ -464,9 +761,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins,
@@ -519,9 +814,7 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
.await
.unwrap();
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins

View File

@@ -1080,6 +1080,7 @@ pub(crate) async fn built_tools(
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
match connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn_context.config,
sess.services.plugins_manager.as_ref(),
auth.as_ref(),
accessible_connectors.as_slice(),
&loaded_plugin_app_connector_ids,

View File

@@ -2,6 +2,7 @@ use std::collections::HashSet;
use codex_app_server_protocol::AppInfo;
use codex_config::types::ToolSuggestDisabledTool;
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
@@ -273,6 +274,10 @@ async fn verify_request_plugin_install_completed(
verified_connector_install_completed(connector.id.as_str(), &accessible_connectors)
}),
DiscoverableTool::Plugin(plugin) => {
if is_remote_plugin_install_suggestion(&plugin.id) {
return true;
}
session.reload_user_config_layer().await;
let config = session.get_config().await;
let completed = verified_plugin_install_completed(
@@ -293,6 +298,12 @@ async fn verify_request_plugin_install_completed(
}
}
fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool {
plugin_id
.rsplit_once('@')
.is_some_and(|(_, marketplace_name)| marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME)
}
#[expect(
clippy::await_holding_invalid_type,
reason = "connector cache refresh reads through the session-owned manager guard"

View File

@@ -57,6 +57,17 @@ async fn verified_plugin_install_completed_requires_installed_plugin() {
));
}
#[test]
fn remote_plugin_install_suggestions_skip_core_installed_verification() {
assert!(is_remote_plugin_install_suggestion(
"snowflake@openai-curated-remote"
));
assert!(!is_remote_plugin_install_suggestion(
"snowflake@openai-curated"
));
assert!(!is_remote_plugin_install_suggestion("Plugin_123"));
}
#[test]
fn request_plugin_install_response_persists_only_decline_always_mode() {
assert!(request_plugin_install_response_requests_persistent_disable(