From 0345352c4a45fe5af88d2b9fa0630090d8babdb4 Mon Sep 17 00:00:00 2001 From: Xin Lin Date: Sun, 31 May 2026 15:15:20 -0700 Subject: [PATCH] Cache remote plugin catalog for suggestions --- .../src/request_processors/plugins.rs | 1 + .../app-server/tests/suite/v2/plugin_list.rs | 17 + codex-rs/core-plugins/src/lib.rs | 14 + codex-rs/core-plugins/src/manager.rs | 62 ++- codex-rs/core-plugins/src/remote.rs | 100 ++++- .../core-plugins/src/remote/catalog_cache.rs | 111 ++++++ codex-rs/core/src/connectors.rs | 15 +- codex-rs/core/src/connectors_tests.rs | 16 +- codex-rs/core/src/plugins/discoverable.rs | 68 +++- .../core/src/plugins/discoverable_tests.rs | 377 ++++++++++++++++-- codex-rs/core/src/session/turn.rs | 1 + .../tools/handlers/request_plugin_install.rs | 11 + .../handlers/request_plugin_install_tests.rs | 11 + 13 files changed, 742 insertions(+), 62 deletions(-) create mode 100644 codex-rs/core-plugins/src/remote/catalog_cache.rs diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 4171887cdd..f8fa33930b 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -662,6 +662,7 @@ impl PluginRequestProcessor { &remote_plugin_service_config, auth.as_ref(), &remote_sources, + Some(config.codex_home.as_path()), ) .await { diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index ea9e5f4f35..0df7c82eef 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -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::, _>>()?; + 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::>(); + assert_eq!( + cached_plugin_ids, + vec!["plugins~Plugin_00000000000000000000000000000000".to_string()] + ); assert_eq!(response.featured_plugin_ids, Vec::::new()); assert!( !server diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 4e59162e34..60cb50a885 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -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", ]; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index ff596273f9..980cdb5348 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -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 { + 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> { + 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(); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 16ae512931..3c86f6b14b 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -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, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteDiscoverablePlugin { + pub config_id: String, + pub remote_plugin_id: String, + pub name: String, + pub description: Option, + pub has_skills: bool, + pub app_ids: Vec, + 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, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginSkillInterfaceResponse { display_name: Option, short_description: Option, @@ -350,7 +365,7 @@ struct RemotePluginSkillInterfaceResponse { icon_large_url: Option, } -#[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, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginReleaseInterfaceResponse { short_description: Option, long_description: Option, @@ -383,7 +398,7 @@ struct RemotePluginReleaseInterfaceResponse { screenshot_urls: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginReleaseResponse { #[serde(default)] version: Option, @@ -402,7 +417,7 @@ struct RemotePluginReleaseResponse { skills: Vec, } -#[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, 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 { + 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 { + 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, RemotePluginCatalogError> { diff --git a/codex-rs/core-plugins/src/remote/catalog_cache.rs b/codex-rs/core-plugins/src/remote/catalog_cache.rs new file mode 100644 index 0000000000..d49885dfbb --- /dev/null +++ b/codex-rs/core-plugins/src/remote/catalog_cache.rs @@ -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, + chatgpt_user_id: Option, + 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, +} + +pub(crate) fn load_cached_global_directory_plugins( + codex_home: &Path, + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Option> { + 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")) +} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 55a941e8c4..c0b49cb633 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -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()) diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 285df9a29a..5db026c3b1 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -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, diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 4f0d88b512..dffca93f02 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -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> { 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, + discoverable_plugins: &mut Vec, +) { + 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; diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index 21203aea23..22352ccad5 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -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> { + 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> { + 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> { + 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!["github@openai-curated-remote"] + ); + let remote_plugins = discoverable_plugins + .into_iter() + .filter(|plugin| plugin.id == "github@openai-curated-remote") + .collect::>(); + + 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::::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::::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 diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index f808134f99..d1e9e59f70 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -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, diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index c134eae079..51032b27c5 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -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" diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs index 1a8caf0dce..830e9d408e 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install_tests.rs @@ -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(