mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user