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::PluginId; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use serde::Deserialize; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashSet; use std::fs; use std::path::PathBuf; use std::time::Duration; use url::Url; mod remote_installed_plugin_sync; mod share; 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 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 = "chatgpt-global"; 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 = "ChatGPT Plugins"; 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 REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[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, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoteMarketplaceSource { Global, WorkspaceDirectory, SharedWithMe, } #[derive(Debug, Clone, PartialEq)] pub struct RemoteInstalledPlugin { pub marketplace_name: String, pub id: String, pub name: String, pub enabled: bool, } #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginSummary { pub id: String, pub remote_plugin_id: String, pub name: String, pub share_context: Option, pub installed: bool, pub enabled: bool, pub install_policy: PluginInstallPolicy, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, 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>, } #[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 description: Option, pub release_version: Option, pub bundle_download_url: Option, pub skills: Vec, pub app_ids: Vec, } #[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, } 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)] enum RemotePluginScope { #[serde(rename = "GLOBAL")] Global, #[serde(rename = "WORKSPACE")] Workspace, } impl RemotePluginScope { fn api_value(self) -> &'static str { match self { Self::Global => "GLOBAL", Self::Workspace => "WORKSPACE", } } fn marketplace_name(self) -> &'static str { match self { Self::Global => REMOTE_GLOBAL_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::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, } } fn from_marketplace_name(name: &str) -> Option { match name { REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), 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)] 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)] 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)] 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, composer_icon_url: Option, logo_url: Option, #[serde(default)] screenshot_urls: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginReleaseResponse { #[serde(default)] version: Option, display_name: String, description: String, #[serde(default)] bundle_download_url: Option, #[serde(default)] app_ids: Vec, #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] 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>, 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::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)] 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, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginListResponse { plugins: Vec, pagination: RemotePluginPagination, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginInstalledResponse { plugins: Vec, pagination: RemotePluginPagination, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginMutationResponse { id: String, enabled: bool, } pub async fn fetch_remote_marketplaces( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, sources: &[RemoteMarketplaceSource], ) -> Result, 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; 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*/ true, )? { 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::>(); 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(marketplaces) } fn build_remote_marketplace( name: &str, display_name: &str, directory_plugins: Vec, installed_plugins: Vec, include_installed_only: bool, ) -> Result, RemotePluginCatalogError> { let directory_plugins = directory_plugins .into_iter() .map(|plugin| (plugin.id.clone(), plugin)) .collect::>(); let installed_plugins = installed_plugins .into_iter() .map(|plugin| (plugin.plugin.id.clone(), plugin)) .collect::>(); let plugin_ids = directory_plugins .keys() .chain( include_installed_only .then_some(&installed_plugins) .into_iter() .flat_map(|plugins| plugins.keys()), ) .cloned() .collect::>(); if plugin_ids.is_empty() { return Ok(None); } let mut plugins = plugin_ids .into_iter() .filter_map(|plugin_id| { let directory_plugin = directory_plugins.get(&plugin_id); let installed_plugin = installed_plugins.get(&plugin_id); directory_plugin .or_else(|| installed_plugin.map(|plugin| &plugin.plugin)) .map(|plugin| (plugin, installed_plugin)) }) .map(|(plugin, installed_plugin)| build_remote_plugin_summary(plugin, installed_plugin)) .collect::, _>>()?; plugins.sort_by(|left, right| { remote_plugin_display_name(left) .to_ascii_lowercase() .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) .then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))) .then_with(|| left.id.cmp(&right.id)) }); Ok(Some(RemoteMarketplace { name: name.to_string(), display_name: display_name.to_string(), plugins, })) } pub async fn fetch_remote_installed_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, ) -> Result, 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 (global, workspace) = tokio::try_join!(global, workspace)?; let mut installed_plugins = [global, workspace] .into_iter() .flat_map(|(_scope, plugins)| plugins) .map(|plugin| remote_installed_plugin_to_info(&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 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 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 { 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(); Ok(RemotePluginDetail { marketplace_name, marketplace_display_name: scope.marketplace_display_name().to_string(), summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref())?, description: non_empty_string(Some(&plugin.release.description)), release_version: plugin.release.version, bundle_download_url: plugin.release.bundle_download_url, skills, app_ids: plugin.release.app_ids, }) } pub async fn install_remote_plugin( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, _marketplace_name: &str, plugin_id: &str, ) -> Result<(), 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), 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(()) } 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}/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 { 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_plugin_share_context( plugin: &RemotePluginDirectoryItem, ) -> Result, RemotePluginCatalogError> { match plugin.scope { RemotePluginScope::Global => 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_info( installed_plugin: &RemotePluginInstalledItem, ) -> Result { 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, }) } 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_prompt .as_ref() .and_then(|prompt| normalize_remote_default_prompt(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, ) -> Option { 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 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_prompt(prompt: &str) -> Option> { let prompt = prompt.trim(); if prompt.is_empty() || prompt.chars().count() > MAX_REMOTE_DEFAULT_PROMPT_LEN { return None; } Some(vec![prompt.to_string()]) } async fn fetch_directory_plugins_for_scope( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, ) -> Result, 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()).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_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, 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>, ) -> Result { 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(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 { 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 { 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 { 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 { 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 { Ok(request .timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT) .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())) } async fn send_and_decode Deserialize<'de>>( request: RequestBuilder, 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, }) }