use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::error_subtype::http_status_sub_error_type; use crate::http_client_selector::HttpClientSelector; use crate::loader::plugin_app_declarations_from_value; use crate::store::PLUGINS_CACHE_DIR; use crate::store::PluginStore; use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginDisabledReason; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::ScheduledTaskSummary; use codex_app_server_protocol::SkillInterface; use codex_http_client::ClientRouteClass; use codex_http_client::HttpClientFactory; use codex_http_client::RouteAwareClientPool; use codex_http_client::RouteAwareRequestBuilder; use codex_http_client::RouteAwareRequestError; use codex_login::CodexAuth; use codex_login::default_client::default_headers; use codex_plugin::AppConnectorId; use codex_plugin::AppDeclaration; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_utils_absolute_path::AbsolutePathBuf; use http::Method; use http::StatusCode; 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::sync::Arc; use std::time::Duration; use tracing::instrument; use url::Url; mod catalog_cache; mod plugin_capabilities; mod remote_installed_plugin_sync; mod search; mod share; #[cfg(test)] #[path = "remote_tests.rs"] mod tests; pub use plugin_capabilities::RemotePluginCapabilities; 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::RemotePluginChange; pub use remote_installed_plugin_sync::RemotePluginMaterialization; pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight; pub(crate) use remote_installed_plugin_sync::remote_installed_plugin_bundle_sync_gate; pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once; pub(crate) use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once_with_snapshot; pub use search::RemotePluginSearchPage; pub use search::RemotePluginSearchRequest; pub use search::search_remote_plugins; 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)] pub struct RemotePluginServiceConfig { pub chatgpt_base_url: String, pub(crate) http_clients: Arc, } impl RemotePluginServiceConfig { /// Creates remote plugin service state from the effective application HTTP configuration. /// /// Keeping the factory mandatory ensures every catalog, mutation, upload, and bundle request /// follows the same outbound proxy policy. pub fn new(chatgpt_base_url: String, http_client_factory: HttpClientFactory) -> Self { let http_clients = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging( http_client_factory, ClientRouteClass::Api, ); Self { chatgpt_base_url, http_clients: Arc::new(http_clients), } } pub(crate) fn http_request(&self, method: Method, url: &str) -> RouteAwareRequestBuilder { self.http_clients .request(method, url) .headers(default_headers()) } } impl PartialEq for RemotePluginServiceConfig { fn eq(&self, other: &Self) -> bool { self.chatgpt_base_url == other.chatgpt_base_url && self.http_clients.outbound_proxy_policy() == other.http_clients.outbound_proxy_policy() } } impl Eq for RemotePluginServiceConfig {} #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginUninstallTarget { pub plugin_id: PluginId, pub remote_plugin_id: String, pub fallback_capability_summary: PluginCapabilitySummary, } #[derive(Debug, Clone, PartialEq)] pub struct RemoteMarketplace { pub name: String, pub display_name: String, pub plugins: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoteMarketplaceSource { Global, CreatedByMeRemote, WorkspaceDirectory, SharedWithMe, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemotePluginCatalogCacheMode { PreferCache, /// Reuse fresh entries and synchronously refresh missing or stale entries. PreferFreshCache, ForceRefetch, } #[derive(Debug, Clone, PartialEq)] pub struct RemoteMarketplacesFetchOutcome { pub marketplaces: Vec, pub catalog_cache_refresh_scopes: BTreeSet, /// Whether any requested directory was served from disk cache, even if it was empty. pub catalog_cache_used: bool, } #[derive(Debug, Clone, PartialEq)] pub struct RemoteMarketplaceFetchOutcome { pub marketplace: Option, /// Whether the requested directory was served from disk cache, even if it was empty. pub catalog_cache_used: bool, } #[derive(Debug, Clone, PartialEq)] pub struct RemoteInstalledPlugin { pub marketplace_name: String, pub id: String, pub version: Option, pub name: String, pub installed_at: Option>, pub enabled: bool, pub install_policy: PluginInstallPolicy, pub install_policy_source: Option, pub must_show_installation_interstitial: Option, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, pub disabled_reason: Option, pub eligible_plan_types: Option>, pub interface: Option, pub keywords: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginSummary { pub id: String, pub remote_plugin_id: String, pub version: Option, pub local_version: Option, pub name: String, pub share_context: Option, pub installed: bool, pub installed_at: Option>, pub enabled: bool, pub install_policy: PluginInstallPolicy, pub install_policy_source: Option, pub must_show_installation_interstitial: Option, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, pub disabled_reason: Option, pub eligible_plan_types: Option>, pub interface: Option, pub keywords: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginShareContext { pub remote_plugin_id: String, pub remote_version: Option, pub discoverability: RemotePluginShareDiscoverability, pub share_url: Option, pub creator_account_user_id: Option, pub creator_name: Option, pub share_principals: Option>, pub can_publish_to_workspace: Option, } #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginShareSummary { pub summary: RemotePluginSummary, pub local_plugin_path: Option, } #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginDetail { pub marketplace_name: String, pub marketplace_display_name: String, pub summary: RemotePluginSummary, pub share_url: Option, pub description: Option, pub release_version: Option, pub bundle_download_url: Option, pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, pub app_templates: Vec, pub mcp_servers: Vec, pub scheduled_tasks: Option>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteAppTemplate { pub template_id: String, pub name: String, pub description: Option, pub category: Option, pub canonical_connector_id: Option, pub logo_url: Option, pub logo_url_dark: Option, pub materialized_app_ids: Vec, pub reason: Option, } #[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, pub interface: Option, pub enabled: bool, } #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginSkillDetail { pub contents: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteDiscoverablePlugin { pub config_id: String, pub remote_plugin_id: String, pub name: String, pub description: Option, pub has_skills: bool, pub app_ids: Vec, pub install_policy: PluginInstallPolicy, pub availability: PluginAvailability, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RecommendedPlugin { pub config_id: String, pub remote_plugin_id: String, pub display_name: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum RecommendedPluginsMode { Legacy, Endpoint { plugins: Vec }, } 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: RouteAwareRequestError, }, #[error("remote plugin catalog request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, status: 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), } impl RemotePluginCatalogError { /// Stable low-cardinality detail for plugin-install failure telemetry. pub fn sub_error_type(&self) -> Option { match self { Self::UnexpectedStatus { status, .. } => { Some(http_status_sub_error_type(*status).to_string()) } Self::AuthRequired | Self::UnsupportedAuthMode | Self::AuthToken(_) | Self::Request { .. } | Self::Decode { .. } | Self::InvalidBaseUrl(_) | Self::InvalidBaseUrlPath | Self::UnknownMarketplace { .. } | Self::UnexpectedPluginId { .. } | Self::UnexpectedSkillName { .. } | Self::UnexpectedEnabledState { .. } | Self::InvalidPluginPath { .. } | Self::PluginShareCheckoutNotAvailable { .. } | Self::Archive { .. } | Self::ArchiveJoin(_) | Self::ArchiveTooLarge { .. } | Self::MissingUploadEtag | Self::UnexpectedResponse(_) | Self::CacheRemove(_) => None, } } } #[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, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RemoteInstalledPluginScope { All, Single(RemotePluginScope), } impl RemotePluginScope { const CATALOG_CACHE_SCOPES: [Self; 3] = [Self::Global, Self::User, Self::Workspace]; 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, } } pub(crate) fn from_marketplace_name(name: &str) -> Option { 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, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginSkillInterfaceResponse { display_name: Option, short_description: Option, brand_color: Option, default_prompt: Option, icon_small_url: Option, icon_large_url: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginSkillResponse { name: String, description: String, interface: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginSkillDetailResponse { plugin_id: String, name: String, skill_md_contents: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginReleaseInterfaceResponse { short_description: Option, long_description: Option, developer_name: Option, category: Option, #[serde(default)] capabilities: Vec, website_url: Option, privacy_policy_url: Option, terms_of_service_url: Option, brand_color: Option, default_prompt: Option, default_prompts: Option>, composer_icon_url: Option, logo_url: Option, logo_url_dark: Option, #[serde(default)] screenshot_urls: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginReleaseResponse { #[serde(default)] version: Option, display_name: String, description: String, #[serde(default)] bundle_download_url: Option, #[serde(default)] app_ids: Vec, #[serde(default)] app_manifest: Option, #[serde(default, alias = "unavailable_app_templates")] app_templates: Vec, #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] mcp_servers: Vec, scheduled_tasks: Option>, } #[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, #[serde(default)] category: Option, #[serde(default)] canonical_connector_id: Option, #[serde(default)] logo_url: Option, #[serde(default)] logo_url_dark: Option, #[serde(default)] materialized_app_ids: Vec, #[serde(default)] reason: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] enum RemotePluginInstallPolicySource { #[serde(rename = "WORKSPACE_SETTING")] WorkspaceSetting, #[serde(rename = "IMPLICIT_CANONICAL_APP")] ImplicitCanonicalApp, #[serde(other)] Unknown, } impl RemotePluginInstallPolicySource { fn into_protocol(self) -> Option { match self { Self::WorkspaceSetting => Some(PluginInstallPolicySource::WorkspaceSetting), Self::ImplicitCanonicalApp => Some(PluginInstallPolicySource::ImplicitCanonicalApp), Self::Unknown => None, } } } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectoryItem { id: String, name: String, scope: RemotePluginScope, #[serde(default)] discoverability: Option, #[serde(default)] creator_account_user_id: Option, #[serde(default)] creator_name: Option, #[serde(default)] share_url: Option, #[serde(default)] share_principals: Option>, #[serde(default)] can_publish_to_workspace: Option, installation_policy: PluginInstallPolicy, installation_policy_source: Option, #[serde(default)] must_show_installation_interstitial: Option, authentication_policy: PluginAuthPolicy, #[serde(rename = "status", default)] availability: PluginAvailability, #[serde(default)] disabled_reason: Option, #[serde(default)] eligible_plan_types: Option>, 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 { 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, #[serde(default)] installed_at: Option>, enabled: bool, #[serde(default)] disabled_skill_names: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginListResponse { plugins: Vec, pagination: RemotePluginPagination, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RecommendedPluginsResponse { enabled: bool, plugins: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RecommendedPluginItem { id: String, name: String, display_name: String, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginInstalledResponse { plugins: Vec, pagination: RemotePluginPagination, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginMutationResponse { id: String, enabled: bool, app_ids_needing_auth: Option>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginInstallResult { pub app_ids_needing_auth: Option>, } pub async fn fetch_remote_marketplaces( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, sources: &[RemoteMarketplaceSource], catalog_cache_root: Option<&Path>, catalog_cache_mode: RemotePluginCatalogCacheMode, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let mut marketplaces = Vec::new(); let mut catalog_cache_refresh_scopes = BTreeSet::new(); let mut catalog_cache_used = false; 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; let (directory_plugins, installed_plugins) = tokio::try_join!( fetch_directory_plugins_for_scope_with_cache( catalog_cache_root, config, auth, scope, /*collection*/ None, catalog_cache_mode, ), fetch_installed_plugins_for_scope(config, auth, scope), )?; if directory_plugins.cache_refresh_needed { catalog_cache_refresh_scopes.insert(scope); } catalog_cache_used |= directory_plugins.catalog_cache_used; if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins.plugins, installed_plugins, /*include_installed_only*/ true, )? { marketplaces.push(marketplace); } } RemoteMarketplaceSource::CreatedByMeRemote => { let scope = RemotePluginScope::User; let (directory_plugins, installed_plugins) = tokio::try_join!( fetch_directory_plugins_for_scope_with_cache( catalog_cache_root, config, auth, scope, /*collection*/ None, catalog_cache_mode, ), fetch_installed_plugins_for_scope(config, auth, scope), )?; if directory_plugins.cache_refresh_needed { catalog_cache_refresh_scopes.insert(scope); } catalog_cache_used |= directory_plugins.catalog_cache_used; if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins.plugins, installed_plugins, /*include_installed_only*/ false, )? { marketplaces.push(marketplace); } } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; let directory_plugins = fetch_directory_plugins_for_scope_with_cache( catalog_cache_root, config, auth, scope, /*collection*/ None, catalog_cache_mode, ) .await?; if directory_plugins.cache_refresh_needed { catalog_cache_refresh_scopes.insert(scope); } catalog_cache_used |= directory_plugins.catalog_cache_used; if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins.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::>(); 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::, _>>()?; 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::, _>>()?; 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(RemoteMarketplacesFetchOutcome { marketplaces, catalog_cache_refresh_scopes, catalog_cache_used, }) } pub(crate) async fn fetch_and_cache_remote_plugin_catalog( codex_home: &Path, config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, scope: RemotePluginScope, ) -> Result<(), RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let plugins = fetch_directory_plugins_for_scope(config, auth, scope).await?; catalog_cache::write_cached_directory_plugins( codex_home, config, auth, scope, /*collection*/ None, &plugins, ); Ok(()) } pub async fn fetch_and_cache_global_remote_plugin_catalog( codex_home: &Path, config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, ) -> Result<(), RemotePluginCatalogError> { fetch_and_cache_remote_plugin_catalog(codex_home, config, auth, RemotePluginScope::Global).await } pub fn invalidate_cached_remote_plugin_catalog_scopes( codex_home: &Path, config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, scopes: &[RemotePluginScope], ) { let Ok(auth) = ensure_chatgpt_auth(auth) else { return; }; for scope in scopes { catalog_cache::remove_cached_directory_plugins( codex_home, config, auth, *scope, /*collection*/ None, ); if *scope == RemotePluginScope::Global { catalog_cache::remove_cached_directory_plugins( codex_home, config, auth, *scope, Some(OPENAI_CURATED_REMOTE_COLLECTION_KEY), ); } } } #[instrument(level = "trace", skip_all)] pub async fn fetch_recommended_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let base_url = config.chatgpt_base_url.trim_end_matches('/'); let mut url = Url::parse(&format!("{base_url}/ps/plugins/suggested/codex")) .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; url.query_pairs_mut().append_pair("scope", "GLOBAL"); let url = url.to_string(); let request = authenticated_request(config.http_request(Method::GET, &url), auth) .timeout(RECOMMENDED_PLUGINS_TIMEOUT); let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?; Ok(recommended_plugins_mode(response)) } #[instrument(level = "trace", skip_all)] pub(crate) async fn fetch_recommended_plugin_install_metadata( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, plugin_id: &str, ) -> Result>, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let plugin = match fetch_plugin_detail_with_timeout( config, auth, plugin_id, /*include_download_urls*/ false, RECOMMENDED_PLUGINS_TIMEOUT, ) .await { Ok(plugin) => plugin, Err(RemotePluginCatalogError::UnexpectedStatus { status: StatusCode::NOT_FOUND, .. }) => return Ok(None), Err(err) => return Err(err), }; if plugin.id != plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { expected: plugin_id.to_string(), actual: plugin.id, }); } if plugin.availability != PluginAvailability::Available || plugin.installation_policy != PluginInstallPolicy::Available { return Ok(None); } let (app_connector_ids, _) = effective_remote_plugin_apps_and_mcp_servers(&plugin.release, auth); Ok(Some(app_connector_ids)) } fn recommended_plugins_mode(response: RecommendedPluginsResponse) -> RecommendedPluginsMode { if !response.enabled { 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 { 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 display_name = non_empty_string(Some(&plugin.display_name)) .unwrap_or_else(|| plugin.name.clone()) .chars() .take(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN) .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, }); } RecommendedPluginsMode::Endpoint { plugins: plugins .into_values() .take(MAX_RECOMMENDED_PLUGINS) .collect(), } } pub(crate) fn has_fresh_cached_remote_plugin_catalog( codex_home: &Path, config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, scope: RemotePluginScope, ) -> bool { let Ok(auth) = ensure_chatgpt_auth(auth) else { return false; }; catalog_cache::load_cached_directory_plugins( codex_home, config, auth, scope, /*collection*/ None, ) .is_some_and(|cached| { matches!( cached.freshness, catalog_cache::RemotePluginCatalogCacheFreshness::Fresh ) }) } pub(crate) fn cached_remote_plugin_catalog_scopes( codex_home: &Path, config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, ) -> BTreeSet { let Ok(auth) = ensure_chatgpt_auth(auth) else { return BTreeSet::new(); }; RemotePluginScope::CATALOG_CACHE_SCOPES .into_iter() .filter(|scope| { catalog_cache::load_cached_directory_plugins( codex_home, config, auth, *scope, /*collection*/ None, ) .is_some() }) .collect() } pub fn cached_global_remote_discoverable_plugins( codex_home: &Path, config: &RemotePluginServiceConfig, auth: &CodexAuth, ) -> Vec { catalog_cache::load_cached_directory_plugins( codex_home, config, auth, RemotePluginScope::Global, /*collection*/ None, ) .map(|cached| cached.plugins) .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>, catalog_cache_root: Option<&Path>, catalog_cache_mode: RemotePluginCatalogCacheMode, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let scope = RemotePluginScope::Global; let (directory_plugins, installed_plugins) = tokio::try_join!( fetch_directory_plugins_for_scope_with_cache( catalog_cache_root, config, auth, scope, Some(OPENAI_CURATED_REMOTE_COLLECTION_KEY), catalog_cache_mode, ), fetch_installed_plugins_for_scope(config, auth, scope), )?; let marketplace = build_remote_marketplace( REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, directory_plugins.plugins, installed_plugins, /*include_installed_only*/ false, )?; Ok(RemoteMarketplaceFetchOutcome { marketplace, catalog_cache_used: directory_plugins.catalog_cache_used, }) } fn build_remote_marketplace( name: &str, display_name: &str, directory_plugins: Vec, installed_plugins: Vec, include_installed_only: bool, ) -> Result, RemotePluginCatalogError> { let mut installed_plugins = installed_plugins .into_iter() .map(|plugin| (plugin.plugin.id.clone(), plugin)) .collect::>(); 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::, _>>()?; if include_installed_only { plugins.extend( installed_plugins .into_values() .map(|plugin| build_remote_plugin_summary(&plugin.plugin, Some(&plugin))) .collect::, _>>()?, ); } 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, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let mut installed_plugins = fetch_installed_plugins( config, auth, RemoteInstalledPluginScope::All, /*include_download_urls*/ false, ) .await? .into_iter() .map(|plugin| remote_installed_plugin_to_cache_entry(&plugin)) .collect::, _>>()?; 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 { let mut plugins_by_marketplace = BTreeMap::>::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(), version: plugin.version.clone(), local_version: None, name: plugin.name.clone(), share_context: None, installed: true, installed_at: plugin.installed_at, enabled: plugin.enabled, install_policy: plugin.install_policy, install_policy_source: plugin.install_policy_source, must_show_installation_interstitial: plugin.must_show_installation_interstitial, auth_policy: plugin.auth_policy, availability: plugin.availability, disabled_reason: plugin.disabled_reason, eligible_plan_types: plugin.eligible_plan_types.clone(), 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 { 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, 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 { 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 { 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 request = authenticated_request(config.http_request(Method::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 { 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 { 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::>() }) .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 (app_ids, mcp_servers) = effective_remote_plugin_apps_and_mcp_servers(&plugin.release, auth); 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, scheduled_tasks: plugin.release.scheduled_tasks, }) } fn app_declarations_from_remote_app_ids(app_ids: &[String]) -> Vec { app_ids .iter() .map(|app_id| AppDeclaration { name: app_id.clone(), connector_id: AppConnectorId(app_id.clone()), category: None, }) .collect() } fn effective_remote_plugin_apps_and_mcp_servers( release: &RemotePluginReleaseResponse, auth: &CodexAuth, ) -> (Vec, Vec) { let mut app_declarations = release .app_manifest .as_ref() .map(plugin_app_declarations_from_value) .unwrap_or_else(|| app_declarations_from_remote_app_ids(&release.app_ids)); let mut mcp_servers = release .mcp_servers .iter() .map(|server| (server.key.clone(), ())) .collect::>(); 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_server_names = mcp_servers.into_keys().collect::>(); mcp_server_names.sort_unstable(); mcp_server_names.dedup(); (app_ids, mcp_server_names) } #[derive(Serialize)] struct RemotePluginInstallRequest<'a> { install_attempt_id: &'a str, } pub async fn install_remote_plugin( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, marketplace_name: &str, plugin_id: &str, ) -> Result { install_remote_plugin_inner( config, auth, marketplace_name, plugin_id, /*install_attempt_id*/ None, ) .await } pub async fn install_remote_plugin_with_install_attempt_id( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, marketplace_name: &str, plugin_id: &str, install_attempt_id: &str, ) -> Result { install_remote_plugin_inner( config, auth, marketplace_name, plugin_id, Some(install_attempt_id), ) .await } async fn install_remote_plugin_inner( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, _marketplace_name: &str, plugin_id: &str, install_attempt_id: Option<&str>, ) -> Result { 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 mut url = Url::parse(&format!("{base_url}/ps/plugins/{plugin_id}/install")) .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; url.query_pairs_mut() .append_pair("includeAppsNeedingAuth", "true"); let url = url.to_string(); let request = authenticated_request(config.http_request(Method::POST, &url), auth); let request = if let Some(install_attempt_id) = install_attempt_id { request.json(&RemotePluginInstallRequest { install_attempt_id }) } else { request }; 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 resolve_remote_plugin_uninstall_target( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, remote_plugin_id: &str, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let plugin = fetch_plugin_detail( config, auth, remote_plugin_id, /*include_download_urls*/ false, ) .await?; let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); let plugin_id = PluginId::new(plugin.name.clone(), marketplace_name).map_err(|err| { RemotePluginCatalogError::UnexpectedResponse(format!( "invalid local plugin id for remote plugin `{}`: {err}", plugin.id )) })?; let 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_server_names = plugin .release .mcp_servers .iter() .map(|server| server.key.clone()) .collect::>(); mcp_server_names.sort_unstable(); mcp_server_names.dedup(); let fallback_capability_summary = PluginCapabilitySummary { config_name: plugin_id.as_key(), display_name: plugin.release.display_name, plugin_namespace: Some(plugin_id.plugin_name.clone()), description: prompt_safe_plugin_description(Some(&plugin.release.description)), has_skills: !plugin.release.skills.is_empty(), mcp_server_names, app_connector_ids: app_connector_ids_from_declarations(&app_declarations), }; Ok(RemotePluginUninstallTarget { plugin_id, remote_plugin_id: plugin.id, fallback_capability_summary, }) } pub async fn uninstall_remote_plugin( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, codex_home: PathBuf, target: RemotePluginUninstallTarget, ) -> Result<(), RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; let RemotePluginUninstallTarget { plugin_id, remote_plugin_id, fallback_capability_summary: _, } = target; let marketplace_name = plugin_id.marketplace_name.clone(); let plugin_name = plugin_id.plugin_name.clone(); let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/uninstall"); let request = authenticated_request(config.http_request(Method::POST, &url), auth); let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; if response.id != remote_plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { expected: remote_plugin_id, actual: response.id, }); } if response.enabled { return Err(RemotePluginCatalogError::UnexpectedEnabledState { plugin_id: response.id, expected_enabled: false, actual_enabled: response.enabled, }); } let legacy_plugin_id = response.id; 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 { 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(), version: plugin.release.version.clone(), local_version: installed_plugin .and_then(|installed| installed.plugin.release.version.clone()), name: plugin.name.clone(), share_context: remote_plugin_share_context(plugin)?, installed: installed_plugin.is_some(), installed_at: installed_plugin.and_then(|installed| installed.installed_at), enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), install_policy: plugin.installation_policy, install_policy_source: plugin .installation_policy_source .and_then(RemotePluginInstallPolicySource::into_protocol), must_show_installation_interstitial: plugin.must_show_installation_interstitial, auth_policy: plugin.authentication_policy, availability: plugin.availability, disabled_reason: plugin.disabled_reason, eligible_plan_types: plugin.eligible_plan_types.clone(), interface: remote_plugin_interface_to_info(plugin), keywords: plugin.release.keywords.clone(), }) } fn remote_discoverable_plugin_from_directory_item( plugin: &RemotePluginDirectoryItem, ) -> Result { let marketplace_name = remote_plugin_canonical_marketplace_name(plugin)?; let plugin_id = PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| { RemotePluginCatalogError::UnexpectedResponse(format!( "invalid remote plugin config id for `{}` in `{marketplace_name}`: {err}", plugin.name )) })?; let display_name = non_empty_string(Some(&plugin.release.display_name)).unwrap_or_else(|| plugin.name.clone()); let description = non_empty_string(plugin.release.interface.short_description.as_deref()) .or_else(|| non_empty_string(Some(&plugin.release.description))); Ok(RemoteDiscoverablePlugin { config_id: plugin_id.as_key(), remote_plugin_id: plugin.id.clone(), name: display_name, description, has_skills: !plugin.release.skills.is_empty(), app_ids: plugin.release.app_ids.clone(), install_policy: plugin.installation_policy, availability: plugin.availability, }) } fn remote_plugin_share_context( plugin: &RemotePluginDirectoryItem, ) -> Result, RemotePluginCatalogError> { 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() }), can_publish_to_workspace: plugin.can_publish_to_workspace, })) } } } fn remote_installed_plugin_to_cache_entry( installed_plugin: &RemotePluginInstalledItem, ) -> Result { let plugin = &installed_plugin.plugin; let marketplace_name = remote_plugin_canonical_marketplace_name(plugin)?.to_string(); PluginId::new(plugin.name.clone(), marketplace_name.clone()).map_err(|err| { RemotePluginCatalogError::UnexpectedResponse(format!( "invalid remote plugin config id for `{}` in `{marketplace_name}`: {err}", plugin.name )) })?; // 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, id: plugin.id.clone(), version: plugin.release.version.clone(), name: plugin.name.clone(), installed_at: installed_plugin.installed_at, enabled: installed_plugin.enabled, install_policy: plugin.installation_policy, install_policy_source: plugin .installation_policy_source .and_then(RemotePluginInstallPolicySource::into_protocol), must_show_installation_interstitial: plugin.must_show_installation_interstitial, auth_policy: plugin.authentication_policy, availability: plugin.availability, disabled_reason: plugin.disabled_reason, eligible_plan_types: plugin.eligible_plan_types.clone(), interface: remote_plugin_interface_to_info(plugin), keywords: plugin.release.keywords.clone(), }) } fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option { 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_dark: None, logo_url: interface.logo_url.clone(), logo_url_dark: interface.logo_url_dark.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.logo_url_dark.is_some() || !result.screenshot_urls.is_empty(); has_fields.then_some(result) } fn remote_skill_interface_to_info( interface: Option, ) -> Option { interface.and_then(|interface| { let result = SkillInterface { display_name: interface.display_name, short_description: interface.short_description, icon_small: None, icon_large: None, icon_small_url: interface.icon_small_url, icon_large_url: interface.icon_large_url, brand_color: interface.brand_color, default_prompt: interface.default_prompt, }; let has_fields = result.display_name.is_some() || result.short_description.is_some() || result.icon_small_url.is_some() || result.icon_large_url.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 { value.and_then(|value| { let value = value.trim(); (!value.is_empty()).then(|| value.to_string()) }) } fn normalize_remote_default_prompts(prompts: &[String]) -> Option> { let prompts = prompts .iter() .filter_map(|prompt| normalize_remote_default_prompt(prompt)) .take(MAX_REMOTE_DEFAULT_PROMPT_COUNT) .collect::>(); (!prompts.is_empty()).then_some(prompts) } fn normalize_remote_default_prompt(prompt: &str) -> Option { let prompt = prompt.trim(); if prompt.is_empty() || prompt.chars().count() > MAX_REMOTE_DEFAULT_PROMPT_LEN { return None; } Some(prompt.to_string()) } struct DirectoryPluginsFetchOutcome { plugins: Vec, cache_refresh_needed: bool, catalog_cache_used: bool, } async fn fetch_directory_plugins_for_scope_with_cache( codex_home: Option<&Path>, config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, collection: Option<&str>, cache_mode: RemotePluginCatalogCacheMode, ) -> Result { if cache_mode != RemotePluginCatalogCacheMode::ForceRefetch && let Some(codex_home) = codex_home && let Some(cached) = catalog_cache::load_cached_directory_plugins( codex_home, config, auth, scope, collection, ) && (cache_mode == RemotePluginCatalogCacheMode::PreferCache || cached.freshness == catalog_cache::RemotePluginCatalogCacheFreshness::Fresh) { return Ok(DirectoryPluginsFetchOutcome { plugins: cached.plugins, cache_refresh_needed: matches!( cached.freshness, catalog_cache::RemotePluginCatalogCacheFreshness::Stale ), catalog_cache_used: true, }); } let plugins = fetch_directory_plugins_for_scope_with_optional_collection(config, auth, scope, collection) .await?; if let Some(codex_home) = codex_home { catalog_cache::write_cached_directory_plugins( codex_home, config, auth, scope, collection, &plugins, ); } Ok(DirectoryPluginsFetchOutcome { plugins, cache_refresh_needed: false, catalog_cache_used: false, }) } async fn fetch_directory_plugins_for_scope( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, ) -> Result, RemotePluginCatalogError> { fetch_directory_plugins_for_scope_with_optional_collection( config, auth, scope, /*collection*/ None, ) .await } async fn fetch_directory_plugins_for_scope_with_optional_collection( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, collection: Option<&str>, ) -> Result, RemotePluginCatalogError> { tracing::info!( operation = "plugins.remote_catalog.list", http.method = "GET", api.path = "ps/plugins/list", plugin.scope = scope.api_value(), plugin.collection = collection.unwrap_or_default(), "fetching remote plugin catalog" ); 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, 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, RemotePluginCatalogError> { fetch_installed_plugins( config, auth, RemoteInstalledPluginScope::Single(scope), /*include_download_urls*/ false, ) .await } async fn fetch_installed_plugins( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemoteInstalledPluginScope, include_download_urls: bool, ) -> Result, 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 { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let mut url = Url::parse(&format!("{base_url}/ps/plugins/list")) .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; url.query_pairs_mut() .append_pair("scope", scope.api_value()) .append_pair("limit", &REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string()); if let Some(collection) = collection { url.query_pairs_mut().append_pair("collection", collection); } if let Some(page_token) = page_token { url.query_pairs_mut().append_pair("pageToken", page_token); } let url = url.to_string(); let request = authenticated_request(config.http_request(Method::GET, &url), auth); send_and_decode(request, &url).await } async fn get_remote_shared_workspace_plugins_page( config: &RemotePluginServiceConfig, auth: &CodexAuth, page_token: Option<&str>, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let mut url = Url::parse(&format!("{base_url}/ps/plugins/workspace/shared")) .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; url.query_pairs_mut() .append_pair("limit", &REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string()); if let Some(page_token) = page_token { url.query_pairs_mut().append_pair("pageToken", page_token); } let url = url.to_string(); let request = authenticated_request(config.http_request(Method::GET, &url), auth); send_and_decode(request, &url).await } async fn get_remote_plugin_installed_page( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemoteInstalledPluginScope, page_token: Option<&str>, include_download_urls: bool, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let mut url = Url::parse(&format!("{base_url}/ps/plugins/installed")) .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; match scope { RemoteInstalledPluginScope::All => { url.query_pairs_mut() .append_pair("limit", &REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string()); } RemoteInstalledPluginScope::Single(scope) => { url.query_pairs_mut() .append_pair("scope", scope.api_value()); } } if include_download_urls { url.query_pairs_mut() .append_pair("includeDownloadUrls", "true"); } if let Some(page_token) = page_token { url.query_pairs_mut().append_pair("pageToken", page_token); } let url = url.to_string(); let request = authenticated_request(config.http_request(Method::GET, &url), auth); send_and_decode(request, &url).await } async fn fetch_plugin_detail( config: &RemotePluginServiceConfig, auth: &CodexAuth, plugin_id: &str, include_download_urls: bool, ) -> Result { fetch_plugin_detail_with_timeout( config, auth, plugin_id, include_download_urls, REMOTE_PLUGIN_CATALOG_TIMEOUT, ) .await } async fn fetch_plugin_detail_with_timeout( config: &RemotePluginServiceConfig, auth: &CodexAuth, plugin_id: &str, include_download_urls: bool, timeout: Duration, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let mut url = Url::parse(&format!("{base_url}/ps/plugins/{plugin_id}")) .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; if include_download_urls { url.query_pairs_mut() .append_pair("includeDownloadUrls", "true"); } let url = url.to_string(); let request = authenticated_request(config.http_request(Method::GET, &url), auth).timeout(timeout); send_and_decode(request, &url).await } fn remote_plugin_skill_detail_url( config: &RemotePluginServiceConfig, plugin_id: &str, skill_name: &str, ) -> Result { 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: RouteAwareRequestBuilder, auth: &CodexAuth, ) -> RouteAwareRequestBuilder { 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 Deserialize<'de>>( request: RouteAwareRequestBuilder, url: &str, ) -> Result { 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, }) }