mirror of
https://github.com/openai/codex.git
synced 2026-06-02 19:31:59 +00:00
Cache remote plugin catalog for suggestions
This commit is contained in:
@@ -662,6 +662,7 @@ impl PluginRequestProcessor {
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
&remote_sources,
|
||||
Some(config.codex_home.as_path()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
111
codex-rs/core-plugins/src/remote/catalog_cache.rs
Normal file
111
codex-rs/core-plugins/src/remote/catalog_cache.rs
Normal 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"))
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user