use crate::app_mcp_routing::apply_app_mcp_routing_policy;use crate::loader::plugin_app_declarations_from_value;use crate::store::PLUGINS_CACHE_DIR;use crate::store::PluginStore;use codex_app_server_protocol::JSONRPCErrorError;use codex_app_server_protocol::PluginAuthPolicy;use codex_app_server_protocol::PluginAvailability;use codex_app_server_protocol::PluginInstallPolicy;use codex_app_server_protocol::PluginInterface;use codex_app_server_protocol::SkillInterface;use codex_login::CodexAuth;use codex_login::default_client::build_reqwest_client;use codex_plugin::AppConnectorId;use codex_plugin::AppDeclaration;use codex_plugin::PluginId;use codex_plugin::app_connector_ids_from_declarations;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::HashMap;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;#[cfg(test)]#[path = "remote_tests.rs"]mod tests;pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError;pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome;pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard;pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight;pub(crate) use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once;pub use share::RemotePluginShareAccessPolicy;pub use share::RemotePluginShareDiscoverability;pub use share::RemotePluginSharePrincipal;pub use share::RemotePluginSharePrincipalRole;pub use share::RemotePluginSharePrincipalType;pub use share::RemotePluginShareSaveResult;pub use share::RemotePluginShareTarget;pub use share::RemotePluginShareTargetRole;pub use share::RemotePluginShareUpdateDiscoverability;pub use share::RemotePluginShareUpdateTargetsResult;pub use share::checkout_remote_plugin_share;pub use share::delete_remote_plugin_share;pub use share::list_remote_plugin_shares;pub use share::load_plugin_share_remote_ids_by_local_path;pub use share::save_remote_plugin_share;pub use share::update_remote_plugin_share_targets;pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "openai-curated-remote";pub const REMOTE_CREATED_BY_ME_MARKETPLACE_NAME: &str = "created-by-me-remote";pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory";pub const REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "workspace-shared-with-me";pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str = "workspace-shared-with-me-private";pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str = "workspace-shared-with-me-unlisted";pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated Remote";pub const REMOTE_CREATED_BY_ME_MARKETPLACE_DISPLAY_NAME: &str = "Created by me";pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory";pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me";pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me (unlisted)";const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical";const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku";const CODEX_PRODUCT_SKU: &str = "codex";const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);const RECOMMENDED_PLUGINS_TIMEOUT: Duration = Duration::from_secs(5);const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;const MAX_RECOMMENDED_PLUGINS: usize = 50;const MAX_RECOMMENDED_PLUGIN_NAME_LEN: usize = 64;const MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN: usize = 64;const MAX_REMOTE_DEFAULT_PROMPT_COUNT: usize = 3;const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;const INVALID_REQUEST_ERROR_CODE: i64 = -32600;const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 6] = [ ( REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, ), ( REMOTE_CREATED_BY_ME_MARKETPLACE_NAME, REMOTE_CREATED_BY_ME_MARKETPLACE_DISPLAY_NAME, ), ( REMOTE_WORKSPACE_MARKETPLACE_NAME, REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, ), ( REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, ), ( REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, ), ( REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME, ),];#[derive(Debug, Clone, PartialEq, Eq)]pub struct RemotePluginServiceConfig { pub chatgpt_base_url: String,}#[derive(Debug, Clone, PartialEq)]pub struct RemoteMarketplace { pub name: String, pub display_name: String, pub plugins: Vec<RemotePluginSummary>,}#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum RemoteMarketplaceSource { Global, CreatedByMeRemote, WorkspaceDirectory, SharedWithMe,}#[derive(Debug, Clone, PartialEq)]pub struct RemoteInstalledPlugin { pub marketplace_name: String, pub id: String, pub name: String, pub enabled: bool, pub install_policy: PluginInstallPolicy, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, pub interface: Option<PluginInterface>, pub keywords: Vec<String>,}#[derive(Debug, Clone, PartialEq)]pub struct RemotePluginSummary { pub id: String, pub remote_plugin_id: String, pub name: String, pub share_context: Option<RemotePluginShareContext>, pub installed: bool, pub enabled: bool, pub install_policy: PluginInstallPolicy, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, pub interface: Option<PluginInterface>, pub keywords: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct RemotePluginShareContext { pub remote_plugin_id: String, pub remote_version: Option<String>, pub discoverability: RemotePluginShareDiscoverability, pub share_url: Option<String>, pub creator_account_user_id: Option<String>, pub creator_name: Option<String>, pub share_principals: Option<Vec<RemotePluginSharePrincipal>>,}#[derive(Debug, Clone, PartialEq)]pub struct RemotePluginShareSummary { pub summary: RemotePluginSummary, pub local_plugin_path: Option<AbsolutePathBuf>,}#[derive(Debug, Clone, PartialEq)]pub struct RemotePluginDetail { pub marketplace_name: String, pub marketplace_display_name: String, pub summary: RemotePluginSummary, pub share_url: Option<String>, pub description: Option<String>, pub release_version: Option<String>, pub bundle_download_url: Option<String>, pub app_manifest: Option<JsonValue>, pub skills: Vec<RemotePluginSkill>, pub app_ids: Vec<String>, pub app_templates: Vec<RemoteAppTemplate>, pub mcp_servers: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct RemoteAppTemplate { pub template_id: String, pub name: String, pub description: Option<String>, pub category: Option<String>, pub canonical_connector_id: Option<String>, pub logo_url: Option<String>, pub logo_url_dark: Option<String>, pub materialized_app_ids: Vec<String>, pub reason: Option<RemoteAppTemplateUnavailableReason>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]#[serde(rename_all = "SCREAMING_SNAKE_CASE")]pub enum RemoteAppTemplateUnavailableReason { NotConfiguredForWorkspace, NoActiveWorkspace,}#[derive(Debug, Clone, PartialEq)]pub struct RemotePluginSkill { pub name: String, pub description: String, pub short_description: Option<String>, pub interface: Option<SkillInterface>, pub enabled: bool,}#[derive(Debug, Clone, PartialEq)]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,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct RecommendedPlugin { pub config_id: String, pub remote_plugin_id: String, pub display_name: String, pub app_connector_ids: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub enum RecommendedPluginsMode { Legacy, Endpoint { plugins: Vec<RecommendedPlugin> },}pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool { !plugin_id.is_empty() && plugin_id .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')}pub fn validate_remote_plugin_id(plugin_id: &str) -> Result<(), JSONRPCErrorError> { if !is_valid_remote_plugin_id(plugin_id) { return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: "invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed" .to_string(), data: None, }); } Ok(())}#[derive(Debug, thiserror::Error)]pub enum RemotePluginCatalogError { #[error("chatgpt authentication required for remote plugin catalog")] AuthRequired, #[error( "chatgpt authentication required for remote plugin catalog; api key auth is not supported" )] UnsupportedAuthMode, #[error("failed to read auth token for remote plugin catalog: {0}")] AuthToken(#[source] std::io::Error), #[error("failed to send remote plugin catalog request to {url}: {source}")] Request { url: String, #[source] source: reqwest::Error, }, #[error("remote plugin catalog request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, status: reqwest::StatusCode, body: String, }, #[error("failed to parse remote plugin catalog response from {url}: {source}")] Decode { url: String, #[source] source: serde_json::Error, }, #[error("invalid remote plugin catalog base URL: {0}")] InvalidBaseUrl(#[source] url::ParseError), #[error("invalid remote plugin catalog base URL path")] InvalidBaseUrlPath, #[error("remote marketplace `{marketplace_name}` is not supported")] UnknownMarketplace { marketplace_name: String }, #[error( "remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`" )] UnexpectedPluginId { expected: String, actual: String }, #[error( "remote plugin skill response returned unexpected skill name: expected `{expected}`, got `{actual}`" )] UnexpectedSkillName { expected: String, actual: String }, #[error( "remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}" )] UnexpectedEnabledState { plugin_id: String, expected_enabled: bool, actual_enabled: bool, }, #[error("invalid plugin path `{path}`: {reason}")] InvalidPluginPath { path: PathBuf, reason: String }, #[error("remote plugin `{remote_plugin_id}` is not available for plugin/share/checkout")] PluginShareCheckoutNotAvailable { remote_plugin_id: String }, #[error("failed to archive plugin at `{path}`: {source}")] Archive { path: PathBuf, #[source] source: std::io::Error, }, #[error("failed to join plugin archive task: {0}")] ArchiveJoin(#[source] tokio::task::JoinError), #[error( "plugin archive would be {bytes} bytes, exceeding the maximum upload size of {max_bytes} bytes" )] ArchiveTooLarge { bytes: usize, max_bytes: usize }, #[error("workspace plugin upload response did not include an etag")] MissingUploadEtag, #[error("{0}")] UnexpectedResponse(String), #[error("{0}")] CacheRemove(String),}#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]pub enum RemotePluginScope { #[serde(rename = "GLOBAL")] Global, #[serde(rename = "USER")] User, #[serde(rename = "WORKSPACE")] Workspace,}impl RemotePluginScope { fn api_value(self) -> &'static str { match self { Self::Global => "GLOBAL", Self::User => "USER", Self::Workspace => "WORKSPACE", } } fn marketplace_name(self) -> &'static str { match self { Self::Global => REMOTE_GLOBAL_MARKETPLACE_NAME, Self::User => REMOTE_CREATED_BY_ME_MARKETPLACE_NAME, Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_NAME, } } fn marketplace_display_name(self) -> &'static str { match self { Self::Global => REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, Self::User => REMOTE_CREATED_BY_ME_MARKETPLACE_DISPLAY_NAME, Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, } } fn from_marketplace_name(name: &str) -> Option<Self> { match name { REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), REMOTE_CREATED_BY_ME_MARKETPLACE_NAME => Some(Self::User), REMOTE_WORKSPACE_MARKETPLACE_NAME | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Some(Self::Workspace), _ => None, } }}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RemotePluginPagination { next_page_token: Option<String>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]struct RemotePluginSkillInterfaceResponse { display_name: Option<String>, short_description: Option<String>, brand_color: Option<String>, default_prompt: Option<String>, icon_small_url: Option<String>, icon_large_url: Option<String>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]struct RemotePluginSkillResponse { name: String, description: String, interface: Option<RemotePluginSkillInterfaceResponse>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RemotePluginSkillDetailResponse { plugin_id: String, name: String, skill_md_contents: Option<String>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]struct RemotePluginReleaseInterfaceResponse { short_description: Option<String>, long_description: Option<String>, developer_name: Option<String>, category: Option<String>, #[serde(default)] capabilities: Vec<String>, website_url: Option<String>, privacy_policy_url: Option<String>, terms_of_service_url: Option<String>, brand_color: Option<String>, default_prompt: Option<String>, default_prompts: Option<Vec<String>>, composer_icon_url: Option<String>, logo_url: Option<String>, #[serde(default)] screenshot_urls: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]struct RemotePluginReleaseResponse { #[serde(default)] version: Option<String>, display_name: String, description: String, #[serde(default)] bundle_download_url: Option<String>, #[serde(default)] app_ids: Vec<String>, #[serde(default)] app_manifest: Option<JsonValue>, #[serde(default, alias = "unavailable_app_templates")] app_templates: Vec<RemoteAppTemplateResponse>, #[serde(default)] keywords: Vec<String>, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec<RemotePluginSkillResponse>, #[serde(default, skip_serializing_if = "Vec::is_empty")] mcp_servers: Vec<RemotePluginMcpServerResponse>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]struct RemotePluginMcpServerResponse { key: String,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]struct RemoteAppTemplateResponse { template_id: String, name: String, #[serde(default)] description: Option<String>, #[serde(default)] category: Option<String>, #[serde(default)] canonical_connector_id: Option<String>, #[serde(default)] logo_url: Option<String>, #[serde(default)] logo_url_dark: Option<String>, #[serde(default)] materialized_app_ids: Vec<String>, #[serde(default)] reason: Option<RemoteAppTemplateUnavailableReason>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]struct RemotePluginDirectoryItem { id: String, name: String, scope: RemotePluginScope, #[serde(default)] discoverability: Option<RemotePluginShareDiscoverability>, #[serde(default)] creator_account_user_id: Option<String>, #[serde(default)] creator_name: Option<String>, #[serde(default)] share_url: Option<String>, #[serde(default)] share_principals: Option<Vec<RemotePluginDirectorySharePrincipal>>, installation_policy: PluginInstallPolicy, authentication_policy: PluginAuthPolicy, #[serde(rename = "status", default)] availability: PluginAvailability, release: RemotePluginReleaseResponse,}fn remote_plugin_canonical_marketplace_name( plugin: &RemotePluginDirectoryItem,) -> Result<&'static str, RemotePluginCatalogError> { match plugin.scope { RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME), RemotePluginScope::User => Ok(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME), RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? { RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME), RemotePluginShareDiscoverability::Private | RemotePluginShareDiscoverability::Unlisted => { Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME) } }, }}fn workspace_plugin_discoverability( plugin: &RemotePluginDirectoryItem,) -> Result<RemotePluginShareDiscoverability, RemotePluginCatalogError> { plugin.discoverability.ok_or_else(|| { RemotePluginCatalogError::UnexpectedResponse(format!( "workspace plugin `{}` did not include discoverability", plugin.id )) })}#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]struct RemotePluginDirectorySharePrincipal { principal_type: RemotePluginSharePrincipalType, principal_id: String, role: RemotePluginSharePrincipalRole, name: String,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RemotePluginInstalledItem { #[serde(flatten)] plugin: RemotePluginDirectoryItem, enabled: bool, #[serde(default)] disabled_skill_names: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RemotePluginListResponse { plugins: Vec<RemotePluginDirectoryItem>, pagination: RemotePluginPagination,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RecommendedPluginsResponse { #[serde(default)] enabled: Option<bool>, #[serde(default)] plugins: Vec<RecommendedPluginItem>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RecommendedPluginItem { id: String, name: String, #[serde(default)] status: Option<PluginAvailability>, #[serde(default)] installation_policy: Option<PluginInstallPolicy>, release: RecommendedPluginRelease,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RecommendedPluginRelease { display_name: String, #[serde(default)] app_ids: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RemotePluginInstalledResponse { plugins: Vec<RemotePluginInstalledItem>, pagination: RemotePluginPagination,}#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]struct RemotePluginMutationResponse { id: String, enabled: bool, app_ids_needing_auth: Option<Vec<String>>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct RemotePluginInstallResult { pub app_ids_needing_auth: Option<Vec<String>>,}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(); let needs_workspace_installed = sources.iter().any(|source| { matches!( source, RemoteMarketplaceSource::WorkspaceDirectory | RemoteMarketplaceSource::SharedWithMe ) }); let workspace_installed_plugins = if needs_workspace_installed { Some(fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace).await?) } else { None }; for source in sources { match source { RemoteMarketplaceSource::Global => { let scope = RemotePluginScope::Global; if let Some(codex_home) = global_catalog_cache_path && let Some(directory_plugins) = catalog_cache::load_cached_global_directory_plugins( codex_home, config, auth, ) { let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?; if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins, installed_plugins, /*include_installed_only*/ true, )? { marketplaces.push(marketplace); } continue; } let (directory_plugins, installed_plugins) = tokio::try_join!( 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(), directory_plugins, installed_plugins, /*include_installed_only*/ true, )? { 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::CreatedByMeRemote => { let scope = RemotePluginScope::User; let (directory_plugins, installed_plugins) = tokio::try_join!( fetch_directory_plugins_for_scope(config, auth, scope), fetch_installed_plugins_for_scope(config, auth, scope), )?; if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins, installed_plugins, /*include_installed_only*/ false, )? { marketplaces.push(marketplace); } } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; let directory_plugins = fetch_directory_plugins_for_scope(config, auth, scope).await?; if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins, workspace_installed_plugins.clone().unwrap_or_default(), /*include_installed_only*/ false, )? { marketplaces.push(marketplace); } } RemoteMarketplaceSource::SharedWithMe => { // The shared endpoint is the source of truth for plugins explicitly shared // with the user. Installed unlisted plugins that are not returned there are // link-installed and stay in the separate unlisted bucket. let shared_plugins = fetch_shared_workspace_plugins(config, auth).await?; let shared_plugin_ids = shared_plugins .iter() .map(|plugin| plugin.id.clone()) .collect::<BTreeSet<_>>(); let directly_shared_plugins = shared_plugins .into_iter() .filter_map(|plugin| match workspace_plugin_discoverability(&plugin) { Ok( RemotePluginShareDiscoverability::Private | RemotePluginShareDiscoverability::Unlisted, ) => Some(Ok(plugin)), Ok(RemotePluginShareDiscoverability::Listed) => None, Err(err) => Some(Err(err)), }) .collect::<Result<Vec<_>, _>>()?; if let Some(marketplace) = build_remote_marketplace( REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, directly_shared_plugins, workspace_installed_plugins.clone().unwrap_or_default(), /*include_installed_only*/ false, )? { marketplaces.push(marketplace); } let unlisted_installed_plugins = workspace_installed_plugins .clone() .unwrap_or_default() .into_iter() .filter_map( |plugin| match workspace_plugin_discoverability(&plugin.plugin) { Ok(RemotePluginShareDiscoverability::Unlisted) if !shared_plugin_ids.contains(&plugin.plugin.id) => { Some(Ok(plugin)) } Ok(RemotePluginShareDiscoverability::Unlisted) => None, Ok(RemotePluginShareDiscoverability::Listed) | Ok(RemotePluginShareDiscoverability::Private) => None, Err(err) => Some(Err(err)), }, ) .collect::<Result<Vec<_>, _>>()?; if let Some(marketplace) = build_remote_marketplace( REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME, Vec::new(), unlisted_installed_plugins, /*include_installed_only*/ true, )? { marketplaces.push(marketplace); } } } } 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 async fn fetch_recommended_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>,) -> Result<RecommendedPluginsMode, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/suggested"); let client = build_reqwest_client(); let request = authenticated_request(client.get(&url), auth)? .timeout(RECOMMENDED_PLUGINS_TIMEOUT) .query(&[("scope", "GLOBAL")]); let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?; Ok(recommended_plugins_mode(response))}fn recommended_plugins_mode(response: RecommendedPluginsResponse) -> RecommendedPluginsMode { if response.enabled != Some(true) { return RecommendedPluginsMode::Legacy; } let mut plugins = BTreeMap::new(); for plugin in response.plugins { if !is_valid_remote_plugin_id(&plugin.id) || plugin.name.chars().count() > MAX_RECOMMENDED_PLUGIN_NAME_LEN || plugin .status .is_some_and(|status| status != PluginAvailability::Available) || plugin .installation_policy .is_some_and(|policy| policy != PluginInstallPolicy::Available) { continue; } let plugin_id = match PluginId::new( plugin.name.clone(), REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), ) { Ok(plugin_id) => plugin_id, Err(err) => { tracing::warn!( plugin_name = plugin.name, error = %err, "ignoring invalid recommended plugin" ); continue; } }; let RecommendedPluginRelease { display_name, app_ids, } = plugin.release; let display_name = non_empty_string(Some(&display_name)) .unwrap_or_else(|| plugin.name.clone()) .chars() .take(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN) .collect(); let mut seen_app_ids = HashSet::new(); let app_connector_ids = app_ids .into_iter() .filter(|app_id| !app_id.is_empty() && seen_app_ids.insert(app_id.clone())) .collect(); let config_id = plugin_id.as_key(); plugins .entry(config_id.clone()) .or_insert(RecommendedPlugin { config_id, remote_plugin_id: plugin.id, display_name, app_connector_ids, }); } RecommendedPluginsMode::Endpoint { plugins: plugins .into_values() .take(MAX_RECOMMENDED_PLUGINS) .collect(), }}pub fn has_cached_global_remote_plugin_catalog( codex_home: &Path, config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>,) -> bool { let Ok(auth) = ensure_chatgpt_auth(auth) else { return false; }; catalog_cache::load_cached_global_directory_plugins(codex_home, config, auth).is_some()}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>,) -> Result<Option<RemoteMarketplace>, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let scope = RemotePluginScope::Global; let (directory_plugins, installed_plugins) = tokio::try_join!( fetch_directory_plugins_for_scope_with_collection( config, auth, scope, OPENAI_CURATED_REMOTE_COLLECTION_KEY, ), fetch_installed_plugins_for_scope(config, auth, scope), )?; build_remote_marketplace( REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, directory_plugins, installed_plugins, /*include_installed_only*/ false, )}fn build_remote_marketplace( name: &str, display_name: &str, directory_plugins: Vec<RemotePluginDirectoryItem>, installed_plugins: Vec<RemotePluginInstalledItem>, include_installed_only: bool,) -> Result<Option<RemoteMarketplace>, RemotePluginCatalogError> { let mut installed_plugins = installed_plugins .into_iter() .map(|plugin| (plugin.plugin.id.clone(), plugin)) .collect::<BTreeMap<_, _>>(); let mut plugins = directory_plugins .into_iter() .map(|plugin| { let installed_plugin = installed_plugins.remove(&plugin.id); build_remote_plugin_summary(&plugin, installed_plugin.as_ref()) }) .collect::<Result<Vec<_>, _>>()?; if include_installed_only { plugins.extend( installed_plugins .into_values() .map(|plugin| build_remote_plugin_summary(&plugin.plugin, Some(&plugin))) .collect::<Result<Vec<_>, _>>()?, ); } if plugins.is_empty() { return Ok(None); } Ok(Some(RemoteMarketplace { name: name.to_string(), display_name: display_name.to_string(), plugins, }))}pub(crate) async fn fetch_remote_installed_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>,) -> Result<Vec<RemoteInstalledPlugin>, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let global = async { let scope = RemotePluginScope::Global; let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?; Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) }; let workspace = async { let scope = RemotePluginScope::Workspace; let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?; Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) }; let user = async { let scope = RemotePluginScope::User; let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?; Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) }; let (global, workspace, user) = tokio::try_join!(global, workspace, user)?; let mut installed_plugins = [global, workspace, user] .into_iter() .flat_map(|(_scope, plugins)| plugins) .map(|plugin| remote_installed_plugin_to_cache_entry(&plugin)) .collect::<Result<Vec<_>, _>>()?; installed_plugins.sort_by(|left, right| { left.marketplace_name .cmp(&right.marketplace_name) .then_with(|| left.id.cmp(&right.id)) }); Ok(installed_plugins)}pub fn group_remote_installed_plugins_by_marketplaces( plugins: &[RemoteInstalledPlugin], visible_marketplaces: &[&str],) -> Vec<RemoteMarketplace> { let mut plugins_by_marketplace = BTreeMap::<String, Vec<RemotePluginSummary>>::new(); for plugin in plugins { if !visible_marketplaces.contains(&plugin.marketplace_name.as_str()) { continue; } let Ok(plugin_id) = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) else { continue; }; let plugin_summary = RemotePluginSummary { id: plugin_id.as_key(), remote_plugin_id: plugin.id.clone(), name: plugin.name.clone(), share_context: None, installed: true, enabled: plugin.enabled, install_policy: plugin.install_policy, auth_policy: plugin.auth_policy, availability: plugin.availability, interface: plugin.interface.clone(), keywords: plugin.keywords.clone(), }; plugins_by_marketplace .entry(plugin.marketplace_name.clone()) .or_default() .push(plugin_summary); } REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER .into_iter() .filter_map(|(marketplace_name, display_name)| { let mut marketplace_plugins = plugins_by_marketplace.remove(marketplace_name)?; sort_remote_plugin_summaries_by_display_name(&mut marketplace_plugins); Some(RemoteMarketplace { name: marketplace_name.to_string(), display_name: display_name.to_string(), plugins: marketplace_plugins, }) }) .collect()}pub async fn fetch_remote_plugin_detail( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, marketplace_name: &str, plugin_id: &str,) -> Result<RemotePluginDetail, RemotePluginCatalogError> { fetch_remote_plugin_detail_with_download_url_option( config, auth, marketplace_name, plugin_id, /*include_download_urls*/ false, ) .await}pub async fn fetch_remote_plugin_share_context( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, plugin_id: &str,) -> Result<Option<RemotePluginShareContext>, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let plugin = fetch_plugin_detail( config, auth, plugin_id, /*include_download_urls*/ false, ) .await?; remote_plugin_share_context(&plugin)}pub async fn fetch_remote_plugin_detail_with_download_urls( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, marketplace_name: &str, plugin_id: &str,) -> Result<RemotePluginDetail, RemotePluginCatalogError> { fetch_remote_plugin_detail_with_download_url_option( config, auth, marketplace_name, plugin_id, /*include_download_urls*/ true, ) .await}pub async fn fetch_remote_plugin_skill_detail( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, marketplace_name: &str, plugin_id: &str, skill_name: &str,) -> Result<RemotePluginSkillDetail, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() { return Err(RemotePluginCatalogError::UnknownMarketplace { marketplace_name: marketplace_name.to_string(), }); } let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?; let client = build_reqwest_client(); let request = authenticated_request(client.get(&url), auth)?; let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?; if response.plugin_id != plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { expected: plugin_id.to_string(), actual: response.plugin_id, }); } if response.name != skill_name { return Err(RemotePluginCatalogError::UnexpectedSkillName { expected: skill_name.to_string(), actual: response.name, }); } Ok(RemotePluginSkillDetail { contents: response.skill_md_contents, })}async fn fetch_remote_plugin_detail_with_download_url_option( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, _marketplace_name: &str, plugin_id: &str, include_download_urls: bool,) -> Result<RemotePluginDetail, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?; let scope = plugin.scope; let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); // Remote plugin IDs uniquely identify remote plugins, so the caller-provided // marketplace name is not validated here. The backend detail response is the // source of truth for the plugin's actual scope/marketplace. build_remote_plugin_detail(config, auth, scope, marketplace_name, plugin_id, plugin).await}async fn build_remote_plugin_detail( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, marketplace_name: String, plugin_id: &str, plugin: RemotePluginDirectoryItem,) -> Result<RemotePluginDetail, RemotePluginCatalogError> { let installed_plugin = fetch_installed_plugins_for_scope(config, auth, scope) .await? .into_iter() .find(|installed_plugin| installed_plugin.plugin.id == plugin_id); let disabled_skill_names = installed_plugin .as_ref() .map(|plugin| { plugin .disabled_skill_names .iter() .cloned() .collect::<HashSet<_>>() }) .unwrap_or_default(); let skills = plugin .release .skills .iter() .map(|skill| RemotePluginSkill { name: skill.name.clone(), description: skill.description.clone(), short_description: skill .interface .as_ref() .and_then(|interface| interface.short_description.clone()), interface: remote_skill_interface_to_info(skill.interface.clone()), enabled: !disabled_skill_names.contains(&skill.name), }) .collect(); let mut app_declarations = plugin .release .app_manifest .as_ref() .map(plugin_app_declarations_from_value) .unwrap_or_else(|| app_declarations_from_remote_app_ids(&plugin.release.app_ids)); let mut mcp_servers = plugin .release .mcp_servers .iter() .map(|server| (server.key.clone(), ())) .collect::<HashMap<_, _>>(); apply_app_mcp_routing_policy( &mut app_declarations, &mut mcp_servers, Some(auth.api_auth_mode()), /*plugin_active*/ true, ); let app_ids = app_connector_ids_from_declarations(&app_declarations) .into_iter() .map(|app_id| app_id.0) .collect(); let mut mcp_servers = mcp_servers.into_keys().collect::<Vec<_>>(); mcp_servers.sort_unstable(); mcp_servers.dedup(); Ok(RemotePluginDetail { marketplace_name, marketplace_display_name: scope.marketplace_display_name().to_string(), summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref())?, share_url: plugin.share_url, description: non_empty_string(Some(&plugin.release.description)), release_version: plugin.release.version, bundle_download_url: plugin.release.bundle_download_url, app_manifest: plugin.release.app_manifest, skills, app_ids, app_templates: plugin .release .app_templates .into_iter() .map(|template| RemoteAppTemplate { template_id: template.template_id, name: template.name, description: template.description, category: template.category, canonical_connector_id: template.canonical_connector_id, logo_url: template.logo_url, logo_url_dark: template.logo_url_dark, materialized_app_ids: template.materialized_app_ids, reason: template.reason, }) .collect(), mcp_servers, })}fn app_declarations_from_remote_app_ids(app_ids: &[String]) -> Vec<AppDeclaration> { app_ids .iter() .map(|app_id| AppDeclaration { name: app_id.clone(), connector_id: AppConnectorId(app_id.clone()), category: None, }) .collect()}pub async fn install_remote_plugin( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, _marketplace_name: &str, plugin_id: &str,) -> Result<RemotePluginInstallResult, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; // Remote plugin IDs uniquely identify remote plugins, so the caller-provided // marketplace name is not validated before sending the install mutation. let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}/install"); let client = build_reqwest_client(); let request = authenticated_request( client .post(&url) .query(&[("includeAppsNeedingAuth", "true")]), auth, )?; let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; if response.id != plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { expected: plugin_id.to_string(), actual: response.id, }); } if !response.enabled { return Err(RemotePluginCatalogError::UnexpectedEnabledState { plugin_id: plugin_id.to_string(), expected_enabled: true, actual_enabled: response.enabled, }); } Ok(RemotePluginInstallResult { app_ids_needing_auth: response.app_ids_needing_auth, })}pub async fn uninstall_remote_plugin( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, codex_home: PathBuf, plugin_id: &str,) -> Result<(), RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let plugin = fetch_plugin_detail( config, auth, plugin_id, /*include_download_urls*/ false, ) .await?; let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); let plugin_name = plugin.name; let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}/uninstall"); let client = build_reqwest_client(); let request = authenticated_request(client.post(&url), auth)?; let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; if response.id != plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { expected: plugin_id.to_string(), actual: response.id, }); } if response.enabled { return Err(RemotePluginCatalogError::UnexpectedEnabledState { plugin_id: plugin_id.to_string(), expected_enabled: false, actual_enabled: response.enabled, }); } let legacy_plugin_id = plugin_id.to_string(); tokio::task::spawn_blocking(move || { remove_remote_plugin_cache(codex_home, marketplace_name, plugin_name, legacy_plugin_id) }) .await .map_err(|err| { RemotePluginCatalogError::CacheRemove(format!( "failed to join remote plugin cache removal task: {err}" )) })? .map_err(RemotePluginCatalogError::CacheRemove)?; Ok(())}fn remove_remote_plugin_cache( codex_home: PathBuf, marketplace_name: String, plugin_name: String, legacy_plugin_id: String,) -> Result<(), String> { let store = PluginStore::try_new(codex_home.clone()) .map_err(|err| format!("failed to resolve remote plugin cache root: {err}"))?; let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone()).map_err(|err| { format!( "invalid remote plugin cache id for `{plugin_name}` in `{marketplace_name}`: {err}" ) })?; let plugin_cache_root = store.plugin_base_root(&plugin_id); store.uninstall(&plugin_id).map_err(|err| { format!( "failed to remove remote plugin cache entry {}: {err}", plugin_cache_root.display() ) })?; let legacy_remote_plugin_cache_root = codex_home .join(PLUGINS_CACHE_DIR) .join(marketplace_name) .join(legacy_plugin_id); if legacy_remote_plugin_cache_root != plugin_cache_root.as_path() && legacy_remote_plugin_cache_root.exists() { let result = if legacy_remote_plugin_cache_root.is_dir() { fs::remove_dir_all(&legacy_remote_plugin_cache_root) } else { fs::remove_file(&legacy_remote_plugin_cache_root) }; result.map_err(|err| { format!( "failed to remove remote plugin cache entry {}: {err}", legacy_remote_plugin_cache_root.display() ) })?; } Ok(())}fn build_remote_plugin_summary( plugin: &RemotePluginDirectoryItem, installed_plugin: Option<&RemotePluginInstalledItem>,) -> Result<RemotePluginSummary, 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 )) })?; Ok(RemotePluginSummary { id: plugin_id.as_key(), remote_plugin_id: plugin.id.clone(), name: plugin.name.clone(), share_context: remote_plugin_share_context(plugin)?, installed: installed_plugin.is_some(), enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), install_policy: plugin.installation_policy, auth_policy: plugin.authentication_policy, availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), keywords: plugin.release.keywords.clone(), })}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> { match plugin.scope { RemotePluginScope::Global | RemotePluginScope::User => Ok(None), RemotePluginScope::Workspace => { let discoverability = workspace_plugin_discoverability(plugin)?; Ok(Some(RemotePluginShareContext { remote_plugin_id: plugin.id.clone(), remote_version: plugin.release.version.clone(), discoverability, share_url: plugin.share_url.clone(), creator_account_user_id: plugin.creator_account_user_id.clone(), creator_name: plugin.creator_name.clone(), share_principals: plugin.share_principals.as_ref().map(|share_principals| { share_principals .iter() .map(|principal| RemotePluginSharePrincipal { principal_type: principal.principal_type, principal_id: principal.principal_id.clone(), role: principal.role, name: principal.name.clone(), }) .collect() }), })) } }}fn remote_installed_plugin_to_cache_entry( installed_plugin: &RemotePluginInstalledItem,) -> Result<RemoteInstalledPlugin, RemotePluginCatalogError> { let plugin = &installed_plugin.plugin; // Remote per-skill disabled state (`disabled_skill_names`) is intentionally // not projected into skills/list yet; local skills.config remains the // supported source for skill enablement. Ok(RemoteInstalledPlugin { marketplace_name: remote_plugin_canonical_marketplace_name(plugin)?.to_string(), id: plugin.id.clone(), name: plugin.name.clone(), enabled: installed_plugin.enabled, install_policy: plugin.installation_policy, auth_policy: plugin.authentication_policy, availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), keywords: plugin.release.keywords.clone(), })}fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option<PluginInterface> { let interface = &plugin.release.interface; let display_name = non_empty_string(Some(&plugin.release.display_name)); let default_prompt = interface .default_prompts .as_deref() .and_then(normalize_remote_default_prompts) .or_else(|| { interface .default_prompt .as_deref() .and_then(normalize_remote_default_prompt) .map(|prompt| vec![prompt]) }); let result = PluginInterface { display_name, short_description: interface.short_description.clone(), long_description: interface.long_description.clone(), developer_name: interface.developer_name.clone(), category: interface.category.clone(), capabilities: interface.capabilities.clone(), website_url: interface.website_url.clone(), privacy_policy_url: interface.privacy_policy_url.clone(), terms_of_service_url: interface.terms_of_service_url.clone(), default_prompt, brand_color: interface.brand_color.clone(), composer_icon: None, composer_icon_url: interface.composer_icon_url.clone(), logo: None, logo_url: interface.logo_url.clone(), screenshots: Vec::new(), screenshot_urls: interface.screenshot_urls.clone(), }; let has_fields = result.display_name.is_some() || result.short_description.is_some() || result.long_description.is_some() || result.developer_name.is_some() || result.category.is_some() || !result.capabilities.is_empty() || result.website_url.is_some() || result.privacy_policy_url.is_some() || result.terms_of_service_url.is_some() || result.default_prompt.is_some() || result.brand_color.is_some() || result.composer_icon_url.is_some() || result.logo_url.is_some() || !result.screenshot_urls.is_empty(); has_fields.then_some(result)}fn remote_skill_interface_to_info( interface: Option<RemotePluginSkillInterfaceResponse>,) -> Option<SkillInterface> { interface.and_then(|interface| { let result = SkillInterface { display_name: interface.display_name, short_description: interface.short_description, icon_small: None, icon_large: None, brand_color: interface.brand_color, default_prompt: interface.default_prompt, }; let has_fields = result.display_name.is_some() || result.short_description.is_some() || result.brand_color.is_some() || result.default_prompt.is_some(); has_fields.then_some(result) })}fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str { plugin .interface .as_ref() .and_then(|interface| interface.display_name.as_deref()) .unwrap_or(&plugin.name)}fn sort_remote_plugin_summaries_by_display_name(plugins: &mut [RemotePluginSummary]) { plugins.sort_by(|left, right| { let left_display_name = remote_plugin_display_name(left); let right_display_name = remote_plugin_display_name(right); left_display_name .to_ascii_lowercase() .cmp(&right_display_name.to_ascii_lowercase()) .then_with(|| left_display_name.cmp(right_display_name)) .then_with(|| left.id.cmp(&right.id)) });}fn non_empty_string(value: Option<&str>) -> Option<String> { value.and_then(|value| { let value = value.trim(); (!value.is_empty()).then(|| value.to_string()) })}fn normalize_remote_default_prompts(prompts: &[String]) -> Option<Vec<String>> { let prompts = prompts .iter() .filter_map(|prompt| normalize_remote_default_prompt(prompt)) .take(MAX_REMOTE_DEFAULT_PROMPT_COUNT) .collect::<Vec<_>>(); (!prompts.is_empty()).then_some(prompts)}fn normalize_remote_default_prompt(prompt: &str) -> Option<String> { let prompt = prompt.trim(); if prompt.is_empty() || prompt.chars().count() > MAX_REMOTE_DEFAULT_PROMPT_LEN { return None; } Some(prompt.to_string())}async fn fetch_directory_plugins_for_scope( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope,) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> { fetch_directory_plugins_for_scope_with_optional_collection( config, auth, scope, /*collection*/ None, ) .await}async fn fetch_directory_plugins_for_scope_with_collection( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, collection: &str,) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> { fetch_directory_plugins_for_scope_with_optional_collection( config, auth, scope, Some(collection), ) .await}async fn fetch_directory_plugins_for_scope_with_optional_collection( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, collection: Option<&str>,) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> { let mut plugins = Vec::new(); let mut page_token = None; loop { let response = get_remote_plugin_list_page(config, auth, scope, page_token.as_deref(), collection) .await?; plugins.extend(response.plugins); let Some(next_page_token) = response.pagination.next_page_token else { break; }; page_token = Some(next_page_token); } Ok(plugins)}async fn fetch_shared_workspace_plugins( config: &RemotePluginServiceConfig, auth: &CodexAuth,) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> { let mut plugins = Vec::new(); let mut page_token = None; loop { let response = get_remote_shared_workspace_plugins_page(config, auth, page_token.as_deref()).await?; plugins.extend(response.plugins); let Some(next_page_token) = response.pagination.next_page_token else { break; }; page_token = Some(next_page_token); } Ok(plugins)}async fn fetch_installed_plugins_for_scope( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope,) -> Result<Vec<RemotePluginInstalledItem>, RemotePluginCatalogError> { fetch_installed_plugins_for_scope_with_download_url( config, auth, scope, /*include_download_urls*/ false, ) .await}async fn fetch_installed_plugins_for_scope_with_download_url( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, include_download_urls: bool,) -> Result<Vec<RemotePluginInstalledItem>, RemotePluginCatalogError> { let mut plugins = Vec::new(); let mut page_token = None; loop { let response = get_remote_plugin_installed_page( config, auth, scope, page_token.as_deref(), include_download_urls, ) .await?; plugins.extend(response.plugins); let Some(next_page_token) = response.pagination.next_page_token else { break; }; page_token = Some(next_page_token); } Ok(plugins)}async fn get_remote_plugin_list_page( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, page_token: Option<&str>, collection: Option<&str>,) -> Result<RemotePluginListResponse, RemotePluginCatalogError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/list"); let client = build_reqwest_client(); let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("scope", scope.api_value())]); request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); if let Some(collection) = collection { request = request.query(&[("collection", collection)]); } if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } send_and_decode(request, &url).await}async fn get_remote_shared_workspace_plugins_page( config: &RemotePluginServiceConfig, auth: &CodexAuth, page_token: Option<&str>,) -> Result<RemotePluginListResponse, RemotePluginCatalogError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/workspace/shared"); let client = build_reqwest_client(); let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } send_and_decode(request, &url).await}async fn get_remote_plugin_installed_page( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, page_token: Option<&str>, include_download_urls: bool,) -> Result<RemotePluginInstalledResponse, RemotePluginCatalogError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/installed"); let client = build_reqwest_client(); let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("scope", scope.api_value())]); if include_download_urls { request = request.query(&[("includeDownloadUrls", true)]); } if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } send_and_decode(request, &url).await}async fn fetch_plugin_detail( config: &RemotePluginServiceConfig, auth: &CodexAuth, plugin_id: &str, include_download_urls: bool,) -> Result<RemotePluginDirectoryItem, RemotePluginCatalogError> { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}"); let client = build_reqwest_client(); let mut request = authenticated_request(client.get(&url), auth)?; if include_download_urls { request = request.query(&[("includeDownloadUrls", true)]); } send_and_decode(request, &url).await}fn remote_plugin_skill_detail_url( config: &RemotePluginServiceConfig, plugin_id: &str, skill_name: &str,) -> Result<String, RemotePluginCatalogError> { let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/')) .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; { let mut segments = url .path_segments_mut() .map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?; segments.pop_if_empty(); segments.push("ps"); segments.push("plugins"); segments.push(plugin_id); segments.push("skills"); segments.push(skill_name); } Ok(url.to_string())}fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> { let Some(auth) = auth else { return Err(RemotePluginCatalogError::AuthRequired); }; if !auth.uses_codex_backend() { return Err(RemotePluginCatalogError::UnsupportedAuthMode); } Ok(auth)}fn authenticated_request( request: RequestBuilder, auth: &CodexAuth,) -> Result<RequestBuilder, RemotePluginCatalogError> { Ok(request .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()) .header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU))}async fn send_and_decode<T: for<'de> Deserialize<'de>>( request: RequestBuilder, url: &str,) -> Result<T, RemotePluginCatalogError> { let response = request .send() .await .map_err(|source| RemotePluginCatalogError::Request { url: url.to_string(), source, })?; let status = response.status(); let body = response.text().await.unwrap_or_default(); if !status.is_success() { return Err(RemotePluginCatalogError::UnexpectedStatus { url: url.to_string(), status, body, }); } serde_json::from_str(&body).map_err(|source| RemotePluginCatalogError::Decode { url: url.to_string(), source, })}