[codex] Cache remote plugin catalog for suggestions (#25457)

## Summary
- cache the global remote plugin catalog when remote plugin listing runs
and warm it during startup
- use the cached remote catalog in plugin install recommendations with
canonical `plugin@openai-curated-remote` ids
- reuse the session `PluginsManager` for plugin recommendations so
remote cache state is visible on the recommend path
- skip core installed-state verification for remote plugin install
suggestions while leaving local plugin and connector verification
unchanged

## Testing
- `just fmt`
- `git diff --check`
- `cargo test -p codex-core
list_tool_suggest_discoverable_plugins_includes_cached_remote_global_plugins`
- `cargo test -p codex-core
remote_plugin_install_suggestions_skip_core_installed_verification`
- `cargo test -p codex-app-server
plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled`

Earlier focused checks during the same branch: codex-tools TUI filter
test, request_plugin_install tests, and codex-app-server build.
This commit is contained in:
xl-openai
2026-06-01 22:10:52 -07:00
committed by GitHub
parent cb63ee7f5d
commit f2b725102b
13 changed files with 771 additions and 62 deletions
+14
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",
];
+47 -1
View File
@@ -592,6 +592,31 @@ 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 async fn build_and_cache_remote_installed_plugin_marketplaces(
&self,
config: &PluginsConfigInput,
@@ -1548,9 +1573,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();
+93 -7
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> {
@@ -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"))
}