#[path = "bundled_plugin_exclusions.rs"] mod bundled_plugin_exclusions; #[path = "remote_mutations.rs"] mod remote_mutations; pub use remote_mutations::RemotePluginInstallOutcome; pub use remote_mutations::RemotePluginInstallRequest; pub use remote_mutations::RemotePluginOperationError; pub use remote_mutations::RemotePluginOperationErrorKind; pub use remote_mutations::RemotePluginUninstallOutcome; #[path = "marketplace_context.rs"] mod marketplace_context; pub use marketplace_context::PluginMarketplaceContext; pub use marketplace_context::PluginMarketplaceScope; use super::LoadedPlugin; use super::PluginLoadOutcome; use crate::PluginGitMode; use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; use crate::is_openai_curated_marketplace_name; use crate::loaded_cache_metrics; use crate::loaded_cache_metrics::RequestOutcome; use crate::loader::PluginHookLoadOutcome; use crate::loader::TargetCuratedMarketplace; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; use crate::loader::load_plugin_apps_from_manifest; use crate::loader::load_plugin_hooks; use crate::loader::load_plugin_hooks_from_layer_stack; use crate::loader::load_plugin_mcp_servers_from_manifest_with_format; use crate::loader::load_plugin_skill_inventory; use crate::loader::load_plugins_from_layer_stack; use crate::loader::log_plugin_load_errors; use crate::loader::materialize_marketplace_plugin_source; use crate::loader::plugin_capability_summary_from_root; use crate::loader::plugin_is_eligible_for_target_marketplace; use crate::loader::refresh_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_detailed; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall_detailed; use crate::loader::remote_installed_plugins_to_config; use crate::manifest::PluginManifestFormat; use crate::manifest::PluginManifestInterface; use crate::manifest::load_plugin_manifest; use crate::manifest::load_plugin_manifest_with_format; use crate::marketplace::MarketplaceError; use crate::marketplace::MarketplaceInterface; use crate::marketplace::MarketplaceListError; use crate::marketplace::MarketplaceListOutcome; use crate::marketplace::MarketplacePluginAuthPolicy; use crate::marketplace::MarketplacePluginManifestFallback; use crate::marketplace::MarketplacePluginPolicy; use crate::marketplace::MarketplacePluginSource; use crate::marketplace::ResolvedMarketplacePlugin; use crate::marketplace::find_installable_marketplace_plugin; use crate::marketplace::find_marketplace_plugin; use crate::marketplace::home_dir; use crate::marketplace::list_marketplaces_with_home; use crate::marketplace::plugin_interface_with_marketplace_category; use crate::marketplace_policy::MarketplacePolicy; use crate::marketplace_policy::configured_plugins_from_stack; use crate::marketplace_upgrade::ConfigLayerReload; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; use crate::marketplace_upgrade::upgrade_configured_git_marketplaces_with_mode; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::RecommendedPluginsMode; use crate::remote::RemoteInstalledPlugin; use crate::remote::RemoteInstalledPluginBundleSyncError; use crate::remote::RemoteInstalledPluginBundleSyncOutcome; use crate::remote::RemotePluginCapabilities; use crate::remote::RemotePluginCatalogError; use crate::remote::RemotePluginChange; use crate::remote::RemotePluginMaterialization; use crate::remote::RemotePluginScope; use crate::remote::RemotePluginServiceConfig; use crate::remote_legacy::RemotePluginFetchError; use crate::remote_legacy::RemotePluginMutationError; use crate::remote_plugin_id_resolver::RemoteInstalledPluginsSnapshot; use crate::remote_plugin_id_resolver::RemotePluginIdResolver; use crate::remote_plugin_id_resolver::persisted_remote_plugin_id_for_installation; use crate::skill_snapshots::new_plugin_skill_snapshots; use crate::startup_sync::OPENAI_PLUGINS_GIT_URL; use crate::startup_sync::curated_plugins_api_marketplace_path; use crate::startup_sync::curated_plugins_repo_path; use crate::startup_sync::read_curated_plugins_sha; use crate::startup_sync::sync_openai_plugins_repo; use crate::store::PluginInstallResult as StorePluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; use crate::store::error_context_sub_error_type; use crate::tool_suggest_metadata::ToolSuggestMetadataCache; use codex_analytics::AnalyticsEventsClient; use codex_analytics::PluginInstallSource; use codex_config::ConfigLayerStack; use codex_config::SkillConfigRules; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; use codex_config::skill_config_rules_from_stack; use codex_config::types::PluginConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverableType; use codex_connectors::ConnectorSnapshot; use codex_connectors::PluginConnectorSource; use codex_hooks::plugin_hook_declarations; use codex_http_client::HttpClientFactory; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_protocol::auth::AuthMode; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; use codex_skills::SkillMetadata; use codex_skills::SkillRootLoader; use codex_skills::SkillRootSnapshots; use codex_tools::DiscoverablePluginInfo; use codex_tools::DiscoverableTool; use codex_tools::filter_request_plugin_install_discoverable_tools_for_client; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::PluginIdentity; use codex_utils_plugins::PluginSkillRoot; use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::RwLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; use tokio::sync::OnceCell; use tokio::sync::OwnedSemaphorePermit; use tokio::sync::Semaphore; use tokio::sync::watch; use tracing::instrument; use tracing::warn; static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60 * 60 * 3); const REMOTE_INSTALLED_PLUGIN_SYNC_WAIT_TIMEOUT: Duration = Duration::from_secs(120); const LOADED_PLUGINS_CACHE_CAPACITY: usize = 8; type EffectivePluginsChangedCallback = Arc; #[derive(Debug, Clone)] pub struct PluginsConfigInput { pub config_layer_stack: ConfigLayerStack, pub model_provider_id: String, pub plugins_enabled: bool, pub remote_plugin_enabled: bool, pub chatgpt_base_url: String, http_client_factory: HttpClientFactory, } impl PluginsConfigInput { pub fn new( config_layer_stack: ConfigLayerStack, model_provider_id: String, plugins_enabled: bool, remote_plugin_enabled: bool, chatgpt_base_url: String, http_client_factory: HttpClientFactory, ) -> Self { Self { config_layer_stack, model_provider_id, plugins_enabled, remote_plugin_enabled, chatgpt_base_url, http_client_factory, } } /// Builds route-aware service state for remote plugin requests. pub fn remote_plugin_service_config(&self) -> RemotePluginServiceConfig { RemotePluginServiceConfig::new( self.chatgpt_base_url.clone(), self.http_client_factory.clone(), ) } } /// Effective-plugin changes that downstream composition layers may act on. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct EffectivePluginsChange { /// Remote bundles installed or updated by background installed-plugin sync. pub materialized_remote_plugins: Vec, } /// Inputs used to select endpoint-backed plugin install candidates. pub struct RecommendedPluginCandidatesInput<'a> { pub plugins_config: &'a PluginsConfigInput, pub loaded_plugins: &'a PluginLoadOutcome, pub auth: Option<&'a CodexAuth>, pub disabled_tools: &'a [ToolSuggestDisabledTool], pub app_server_client_name: Option<&'a str>, } #[derive(Clone, PartialEq, Eq)] struct FeaturedPluginIdsCacheKey { chatgpt_base_url: String, account_id: Option, chatgpt_user_id: Option, is_workspace_account: bool, } #[derive(Clone, Hash, PartialEq, Eq)] struct RecommendedPluginsCacheKey { chatgpt_base_url: String, } #[derive(Clone)] struct CachedFeaturedPluginIds { key: FeaturedPluginIdsCacheKey, expires_at: Instant, featured_plugin_ids: Vec, } #[derive(Clone, PartialEq, Eq)] struct RemoteInstalledPluginsAuthIdentity { auth_mode: Option, account_id: Option, chatgpt_user_id: Option, is_workspace_account: Option, } impl RemoteInstalledPluginsAuthIdentity { fn from_auth(auth: Option<&CodexAuth>) -> Self { Self { auth_mode: auth.map(CodexAuth::api_auth_mode), account_id: auth.and_then(CodexAuth::get_account_id), chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id), is_workspace_account: auth.map(CodexAuth::is_workspace_account), } } } #[derive(Default)] struct RemoteInstalledPluginsCache { generation: u64, auth_identity: Option, reconciliation_generation: Option, // A cancelled pass may mutate bundles before publication; retry must notify consumers. needs_effective_plugins_refresh: bool, plugins: Option>, } enum RemoteInstalledPluginsCachePublication { Refresh, Reconcile, } /// Holds the cache-root gate shared by full installed-bundle sync, reconciliation, and direct /// remote plugin mutations. /// /// Callers should keep this guard only while mutating the remote plugin cache and backend /// installed state, then release it before unrelated analytics, OAuth, or runtime refresh work. struct RemoteInstalledPluginSyncGuard { _permit: OwnedSemaphorePermit, } struct RemoteInstalledPluginsReconciliationGuard<'a> { manager: &'a PluginsManager, generation: u64, committed: bool, } impl Drop for RemoteInstalledPluginsReconciliationGuard<'_> { fn drop(&mut self) { if !self.committed { self.manager .abandon_remote_installed_plugins_reconcile(self.generation); } } } struct RemoteInstalledPluginsCacheRefreshRequest { generation: u64, service_config: RemotePluginServiceConfig, auth: Option, notify: RemoteInstalledPluginsCacheRefreshNotify, // App-server attaches side effects such as skills metadata invalidation and MCP refreshes when // remote installed state changes. on_effective_plugins_changed: Option, change: EffectivePluginsChange, } #[derive(Clone, Copy)] enum RemoteInstalledPluginsCacheRefreshNotify { IfCacheChanged, // Remote mutations may change local bundles or active MCP state even when the installed set is // unchanged. Notify after `/installed` succeeds so MCP refreshes are ordered after the remote // installed cache. AfterSuccessfulRefresh, } #[derive(Default)] struct RemoteInstalledPluginsCacheRefreshState { requested: Option, in_flight: bool, } struct RemoteCatalogCacheRefreshRequest { service_config: RemotePluginServiceConfig, auth: Option, scopes: BTreeSet, mode: RemoteCatalogCacheRefreshMode, } impl RemoteCatalogCacheRefreshRequest { fn has_same_cache_identity(&self, other: &Self) -> bool { self.service_config == other.service_config && self.auth.as_ref().and_then(CodexAuth::get_account_id) == other.auth.as_ref().and_then(CodexAuth::get_account_id) && self.auth.as_ref().and_then(CodexAuth::get_chatgpt_user_id) == other.auth.as_ref().and_then(CodexAuth::get_chatgpt_user_id) && self.auth.as_ref().map(CodexAuth::is_workspace_account) == other.auth.as_ref().map(CodexAuth::is_workspace_account) } } #[derive(Clone, Copy, PartialEq, Eq)] enum RemoteCatalogCacheRefreshMode { OnlyIfStale, Force, } #[derive(Default)] struct RemoteCatalogCacheRefreshState { requests: VecDeque, in_flight: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PluginListBackgroundTaskOptions { pub local_marketplaces: Vec, pub remote_catalog_cache_refresh_scopes: BTreeSet, } #[derive(Clone, PartialEq, Eq)] struct NonCuratedCacheRefreshRequest { roots: Vec, configured_plugin_keys: Vec, configured_plugin_sources: Vec, mode: NonCuratedCacheRefreshMode, git_mode: PluginGitMode, } #[derive(Clone, PartialEq, Eq)] struct NonCuratedPluginSource { marketplace_path: AbsolutePathBuf, plugin_key: String, source: MarketplacePluginSource, local_version: Option, } #[derive(Clone, Copy, PartialEq, Eq)] enum NonCuratedCacheRefreshMode { IfVersionChanged, ForceReinstall, } #[derive(Default)] struct NonCuratedCacheRefreshState { requested: Option, last_refreshed: Option, in_flight: bool, } #[derive(Clone, Copy, Default)] struct NonCuratedCacheRefreshCompletion { sequence: u64, changed_sequence: u64, } #[derive(Default)] struct ConfiguredMarketplaceUpgradeState { in_flight: bool, } fn remote_plugin_service_config(config: &PluginsConfigInput) -> RemotePluginServiceConfig { config.remote_plugin_service_config() } fn featured_plugin_ids_cache_key( config: &PluginsConfigInput, auth: Option<&CodexAuth>, ) -> FeaturedPluginIdsCacheKey { FeaturedPluginIdsCacheKey { chatgpt_base_url: config.chatgpt_base_url.clone(), account_id: auth.and_then(CodexAuth::get_account_id), chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id), is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account), } } fn recommended_plugins_cache_key(config: &PluginsConfigInput) -> RecommendedPluginsCacheKey { RecommendedPluginsCacheKey { chatgpt_base_url: config.chatgpt_base_url.clone(), } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallRequest { pub plugin_name: String, pub marketplace_path: AbsolutePathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginReadRequest { pub plugin_name: String, pub marketplace_path: AbsolutePathBuf, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallOutcome { pub plugin_id: PluginId, pub plugin_version: String, pub installed_path: AbsolutePathBuf, pub auth_policy: MarketplacePluginAuthPolicy, } #[derive(Debug, Clone, PartialEq)] pub struct PluginReadOutcome { pub marketplace_name: String, pub marketplace_path: Option, pub plugin: PluginDetail, } #[derive(Debug, Clone, PartialEq)] pub struct PluginDetail { pub id: String, pub name: String, pub local_version: Option, pub description: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, pub keywords: Vec, pub installed: bool, pub enabled: bool, pub skills: Vec, pub disabled_skill_paths: HashSet, pub hooks: Vec, pub apps: Vec, pub app_category_by_id: HashMap, pub mcp_server_names: Vec, pub details_unavailable_reason: Option, } #[derive(Debug, Clone, PartialEq)] pub struct PluginHookSummary { pub key: String, pub event_name: HookEventName, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PluginDetailsUnavailableReason { InstallRequiredForRemoteSource, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfiguredMarketplace { pub name: String, pub path: AbsolutePathBuf, pub interface: Option, pub plugins: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfiguredMarketplacePlugin { pub id: String, pub name: String, pub local_version: Option, pub installed_version: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, pub keywords: Vec, pub manifest_fallback: Option, pub installed: bool, pub enabled: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct ConfiguredMarketplaceListOutcome { pub marketplaces: Vec, pub errors: Vec, } #[derive(Default)] struct ConfiguredPluginStates { installed: HashSet, enabled: HashSet, } impl From for PluginCapabilitySummary { fn from(value: PluginDetail) -> Self { let has_skills = value.skills.iter().any(|skill| { !value .disabled_skill_paths .contains(&skill.path_to_skills_md) }); Self { config_name: value.id, display_name: value.name, plugin_namespace: None, description: prompt_safe_plugin_description(value.description.as_deref()), has_skills, mcp_server_names: value.mcp_server_names, app_connector_ids: value.apps, } } } pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, featured_plugin_ids_cache: RwLock>, recommended_plugins_cache: RwLock>, recommended_plugins_refreshes: RwLock>>>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_lock: Semaphore, non_curated_cache_refresh_state: RwLock, non_curated_cache_refresh_completion: watch::Sender, // Loaded capabilities vary by effective configuration and, for remote plugins, account. loaded_plugins_cache: Mutex, loaded_plugins_load_semaphore: Semaphore, skill_root_loader: Arc>, tool_suggest_metadata_cache: ToolSuggestMetadataCache, remote_installed_plugins_cache: RwLock, // TODO(sites-migration): Remove this throttle together with the bundled Sites migration. sites_migration_checked_at: Mutex>, remote_installed_plugin_bundle_sync_gate: Arc, remote_installed_plugins_cache_refresh_state: RwLock, remote_catalog_cache_refresh_state: RwLock, restriction_product: Option, auth_manager: Arc, analytics_events_client: RwLock>, plugin_install_source: PluginInstallSource, } #[derive(Clone)] struct LoadedPluginsCacheEntry { key: PluginLoadCacheKey, plugins: Vec, plugin_skill_snapshots: SkillRootSnapshots, } #[derive(Default)] struct LoadedPluginsCache { generation: u64, // Most recently used first. entries: VecDeque, } impl LoadedPluginsCache { fn get( &mut self, key: &PluginLoadCacheKey, store: &PluginStore, ) -> Option<&LoadedPluginsCacheEntry> { let index = self.entries.iter().position(|entry| &entry.key == key)?; let entry = self.entries.remove(index)?; // Another process can replace installed versions without invalidating this manager. // Keep the parsed skills paired with the installation whose paths they advertise. if entry.plugins.iter().any(|plugin| { let Ok(plugin_id) = PluginId::parse(&plugin.config_name) else { return false; }; let installed_root = store .active_plugin_root(&plugin_id) .unwrap_or_else(|| store.plugin_base_root(&plugin_id)); installed_root != plugin.root }) { return None; } self.entries.push_front(entry); self.entries.front() } } #[derive(Clone, PartialEq, Eq)] struct PluginLoadCacheKey { configured_plugins: HashMap, skill_config_rules: SkillConfigRules, remote_global_catalog_active: bool, auth_identity: Option, // TODO(sites-migration): Remove once bundled Sites no longer needs scoped exclusions. excluded_plugin_ids: BTreeSet, } impl PluginLoadCacheKey { fn from_config( config: &PluginsConfigInput, codex_home: &Path, remote_global_catalog_active: bool, auth_identity: RemoteInstalledPluginsAuthIdentity, excluded_plugin_ids: BTreeSet, ) -> Self { Self { configured_plugins: configured_plugins_from_stack( &config.config_layer_stack, codex_home, ), skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack), remote_global_catalog_active, // Local curated loads are auth-independent; only remote snapshots vary by account. auth_identity: remote_global_catalog_active.then_some(auth_identity), excluded_plugin_ids, } } } fn target_curated_marketplace(auth_mode: Option) -> TargetCuratedMarketplace { if auth_mode.is_some_and(AuthMode::uses_codex_backend) { TargetCuratedMarketplace::OpenAiWithRemote } else { TargetCuratedMarketplace::OpenAiApi } } impl PluginsManager { pub fn new( codex_home: PathBuf, auth_manager: Arc, skill_root_loader: Arc>, ) -> Self { Self::new_with_options( codex_home, Some(Product::Codex), auth_manager, skill_root_loader, ) } pub fn new_with_options( codex_home: PathBuf, restriction_product: Option, auth_manager: Arc, skill_root_loader: Arc>, ) -> Self { // Product restrictions are enforced at marketplace admission time for a given CODEX_HOME: // listing, install, and curated refresh all consult this restriction context before new // plugins enter local config or cache. After admission, runtime plugin loading trusts the // contents of that CODEX_HOME and does not re-filter configured plugins by product, so // already-admitted plugins may continue exposing MCP servers/tools from shared local state. // // This assumes a single CODEX_HOME is only used by one product. let remote_installed_plugin_bundle_sync_gate = crate::remote::remote_installed_plugin_bundle_sync_gate(&codex_home); Self { codex_home: codex_home.clone(), store: PluginStore::new(codex_home), featured_plugin_ids_cache: RwLock::new(None), recommended_plugins_cache: RwLock::new(HashMap::new()), recommended_plugins_refreshes: RwLock::new(HashMap::new()), configured_marketplace_upgrade_state: RwLock::new( ConfiguredMarketplaceUpgradeState::default(), ), non_curated_cache_refresh_lock: Semaphore::new(/*permits*/ 1), non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()), non_curated_cache_refresh_completion: watch::channel( NonCuratedCacheRefreshCompletion::default(), ) .0, loaded_plugins_cache: Mutex::new(LoadedPluginsCache::default()), loaded_plugins_load_semaphore: Semaphore::new(/*permits*/ 1), skill_root_loader, tool_suggest_metadata_cache: ToolSuggestMetadataCache::new(), remote_installed_plugins_cache: RwLock::new(RemoteInstalledPluginsCache::default()), sites_migration_checked_at: Mutex::new(None), remote_installed_plugin_bundle_sync_gate, remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), ), remote_catalog_cache_refresh_state: RwLock::new( RemoteCatalogCacheRefreshState::default(), ), restriction_product, auth_manager, analytics_events_client: RwLock::new(None), plugin_install_source: PluginInstallSource::Manual, } } pub fn with_plugin_install_source(mut self, source: PluginInstallSource) -> Self { self.plugin_install_source = source; self } pub fn auth_mode(&self) -> Option { self.auth_manager.get_api_auth_mode() } fn remote_global_catalog_active(&self, config: &PluginsConfigInput) -> bool { config.remote_plugin_enabled && self.auth_mode().is_some_and(AuthMode::uses_codex_backend) } /// Starts the local curated marketplace sync when the remote catalog is unavailable. pub fn maybe_start_curated_repo_sync_for_config( self: &Arc, config: &PluginsConfigInput, on_effective_plugins_changed: Option, ) { if config.plugins_enabled && !self.remote_global_catalog_active(config) && MarketplacePolicy::from_requirements(config.config_layer_stack.requirements()) .validate_git_source(OPENAI_PLUGINS_GIT_URL, /*ref_name*/ None) .is_ok() { self.start_curated_repo_sync( config.http_client_factory.clone(), on_effective_plugins_changed, ); } } pub fn set_analytics_events_client(&self, analytics_events_client: AnalyticsEventsClient) { let mut stored_client = match self.analytics_events_client.write() { Ok(client_guard) => client_guard, Err(err) => err.into_inner(), }; *stored_client = Some(analytics_events_client); } fn restriction_product_matches(&self, products: Option<&[Product]>) -> bool { match products { None => true, Some([]) => false, Some(products) => self .restriction_product .is_some_and(|product| product.matches_product_restriction(products)), } } /// Returns skill snapshots parsed while loading the matching plugin cache entry. pub fn plugin_skill_snapshots_for_config( &self, config: &PluginsConfigInput, ) -> Option> { if !config.plugins_enabled { return None; } let auth = self.auth_manager.auth_cached(); let key = PluginLoadCacheKey::from_config( config, self.codex_home.as_path(), self.remote_global_catalog_active(config), RemoteInstalledPluginsAuthIdentity::from_auth(auth.as_ref()), self.excluded_bundled_plugin_ids(config), ); self.loaded_plugins_cache .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&key, &self.store) .map(|cached| cached.plugin_skill_snapshots.clone()) } #[instrument( name = "plugins_for_config", level = "info", skip_all, fields( otel.name = "plugins_for_config", plugins_enabled = config.plugins_enabled ) )] pub async fn plugins_for_config(&self, config: &PluginsConfigInput) -> PluginLoadOutcome { if !config.plugins_enabled { return PluginLoadOutcome::default(); } // Both the semaphore wait and skill loading can outlive the auth state captured at the // start of this load. Account switches require a restart, so discard those passes; // same-account revisions also cover ordinary token refreshes, where returning no plugins // is wrong, so retry those instead. let auth_change_receiver = self.auth_manager.auth_change_receiver(); let mut cache_outcome = RequestOutcome::Hit; loop { let auth_revision = *auth_change_receiver.borrow(); let remote_global_catalog_active = self.remote_global_catalog_active(config); let auth = self.auth_manager.auth_cached(); let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode); let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth.as_ref()); let mut cache_key = PluginLoadCacheKey::from_config( config, self.codex_home.as_path(), remote_global_catalog_active, auth_identity.clone(), self.excluded_bundled_plugin_ids(config), ); if let Some(plugins) = self.cached_loaded_plugins(&cache_key) { let outcome = self.resolve_loaded_plugins_for_auth(plugins, auth_mode); if *auth_change_receiver.borrow() == auth_revision { cache_outcome.record(); return outcome; } if !self.remote_installed_plugins_auth_is_current(&auth_identity) { return PluginLoadOutcome::default(); } continue; } if matches!(cache_outcome, RequestOutcome::Hit) { cache_outcome = RequestOutcome::HitAfterWait; } let wait_started = Instant::now(); let load_permit = self.loaded_plugins_load_semaphore.acquire().await; loaded_cache_metrics::record_duration( loaded_cache_metrics::WAIT_DURATION, wait_started.elapsed(), ); let Ok(_load_permit) = load_permit else { warn!("plugin load semaphore closed"); return PluginLoadOutcome::default(); }; if *auth_change_receiver.borrow() != auth_revision { if !self.remote_installed_plugins_auth_is_current(&auth_identity) { return PluginLoadOutcome::default(); } continue; } // Migration may have changed the effective exclusion while this load was queued. cache_key.excluded_plugin_ids = self.excluded_bundled_plugin_ids(config); if let Some(plugins) = self.cached_loaded_plugins(&cache_key) { let outcome = self.resolve_loaded_plugins_for_auth(plugins, auth_mode); if *auth_change_receiver.borrow() == auth_revision { cache_outcome.record(); return outcome; } if !self.remote_installed_plugins_auth_is_current(&auth_identity) { return PluginLoadOutcome::default(); } continue; } let cache_generation = self.loaded_plugins_cache_generation(); let plugin_skill_snapshots = new_plugin_skill_snapshots(); cache_outcome = RequestOutcome::Load; let load_started = Instant::now(); let plugins = load_plugins_from_layer_stack( &config.config_layer_stack, self.remote_installed_plugins_snapshot(), &self.store, Some(&plugin_skill_snapshots), self.restriction_product, remote_global_catalog_active, self.skill_root_loader.as_ref(), &cache_key.excluded_plugin_ids, ) .await; loaded_cache_metrics::record_duration( loaded_cache_metrics::LOAD_DURATION, load_started.elapsed(), ); if *auth_change_receiver.borrow() != auth_revision { if !self.remote_installed_plugins_auth_is_current(&auth_identity) { return PluginLoadOutcome::default(); } continue; } log_plugin_load_errors(&plugins); self.cache_loaded_plugins_if_current( cache_generation, cache_key, plugins.clone(), plugin_skill_snapshots, ); let outcome = self.resolve_loaded_plugins_for_auth(plugins, auth_mode); if *auth_change_receiver.borrow() == auth_revision { cache_outcome.record(); return outcome; } if !self.remote_installed_plugins_auth_is_current(&auth_identity) { return PluginLoadOutcome::default(); } } } fn resolve_loaded_plugins_for_auth( &self, mut plugins: Vec, auth_mode: Option, ) -> PluginLoadOutcome { let target_curated_marketplace = target_curated_marketplace(auth_mode); plugins.retain(|plugin| { plugin_is_eligible_for_target_marketplace( &plugin.config_name, target_curated_marketplace, ) }); for plugin in &mut plugins { let plugin_active = plugin.is_active(); apply_app_mcp_routing_policy( &mut plugin.apps, &mut plugin.mcp_servers, auth_mode, plugin_active, ); } PluginLoadOutcome::from_plugins(plugins) } pub fn clear_cache(&self) { self.clear_loaded_plugins_cache(); let mut featured_plugin_ids_cache = match self.featured_plugin_ids_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; *featured_plugin_ids_cache = None; } pub fn clear_recommended_plugins_cache(&self) { let mut refreshes = match self.recommended_plugins_refreshes.write() { Ok(refreshes) => refreshes, Err(err) => err.into_inner(), }; refreshes.clear(); let mut cache = match self.recommended_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; cache.clear(); } fn clear_loaded_plugins_cache(&self) { self.tool_suggest_metadata_cache.clear(); let mut cache = match self.loaded_plugins_cache.lock() { Ok(cache) => cache, Err(err) => err.into_inner(), }; cache.generation = cache.generation.wrapping_add(1); cache.entries.clear(); drop(cache); loaded_cache_metrics::record_event("clear"); } fn clear_caches_after_marketplace_source_refresh( &self, installed_plugin_cache_refreshed: bool, on_effective_plugins_changed: Option<&EffectivePluginsChangedCallback>, ) { if installed_plugin_cache_refreshed { self.clear_cache(); if let Some(on_effective_plugins_changed) = on_effective_plugins_changed { on_effective_plugins_changed(EffectivePluginsChange::default()); } } else { self.tool_suggest_metadata_cache.clear(); } } /// Resolve plugin hooks for a config layer stack without loading other plugin capabilities. pub async fn plugin_hooks_for_layer_stack( &self, config_layer_stack: &ConfigLayerStack, config: &PluginsConfigInput, ) -> PluginHookLoadOutcome { if !config.plugins_enabled { return PluginHookLoadOutcome::default(); } let target_curated_marketplace = target_curated_marketplace(self.auth_mode()); load_plugin_hooks_from_layer_stack( config_layer_stack, self.remote_installed_plugin_configs(), &self.excluded_bundled_plugin_ids(config), &self.store, target_curated_marketplace, self.remote_global_catalog_active(config), ) .await } fn cached_loaded_plugins(&self, key: &PluginLoadCacheKey) -> Option> { self.loaded_plugins_cache .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(key, &self.store) .map(|cached| cached.plugins.clone()) } fn loaded_plugins_cache_generation(&self) -> u64 { match self.loaded_plugins_cache.lock() { Ok(cache) => cache.generation, Err(err) => err.into_inner().generation, } } fn cache_loaded_plugins_if_current( &self, generation: u64, key: PluginLoadCacheKey, plugins: Vec, plugin_skill_snapshots: SkillRootSnapshots, ) { let mut cache = match self.loaded_plugins_cache.lock() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if cache.generation != generation { return; } cache.entries.retain(|entry| entry.key != key); cache.entries.push_front(LoadedPluginsCacheEntry { key, plugins, plugin_skill_snapshots, }); let evicted = cache.entries.len() > LOADED_PLUGINS_CACHE_CAPACITY; cache.entries.truncate(LOADED_PLUGINS_CACHE_CAPACITY); drop(cache); if evicted { loaded_cache_metrics::record_event("capacity_eviction"); } } /// Applies plugin exclusions and canonical ownership from the current account's installed cache. /// A canonical owner's bundle need not be installed on this host. pub fn connector_snapshot( &self, sources: impl IntoIterator, disabled_plugin_ids: &[String], ) -> ConnectorSnapshot { let mut canonical_app_ids = HashSet::new(); if !disabled_plugin_ids.is_empty() { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if self.remote_installed_plugins_cache_matches_current_auth(&cache) { canonical_app_ids = cache .plugins .as_deref() .unwrap_or_default() .iter() .filter_map(|plugin| { let app_id = plugin.canonical_app_id.as_ref()?; let plugin_id = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) .ok()?; disabled_plugin_ids .contains(&plugin_id.as_key()) .then(|| app_id.clone()) }) .collect(); } } ConnectorSnapshot::from_plugin_sources(sources, disabled_plugin_ids, canonical_app_ids) } fn remote_installed_plugin_configs(&self) -> HashMap { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if !self.remote_installed_plugins_cache_matches_current_auth(&cache) { return HashMap::new(); } let Some(plugins) = cache.plugins.as_ref() else { return HashMap::new(); }; remote_installed_plugins_to_config(plugins, &self.store) } fn remote_installed_plugins_snapshot(&self) -> RemoteInstalledPluginsSnapshot { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if !self.remote_installed_plugins_cache_matches_current_auth(&cache) { return RemoteInstalledPluginsSnapshot::default(); } let Some(plugins) = cache.plugins.as_ref() else { return RemoteInstalledPluginsSnapshot::default(); }; RemoteInstalledPluginsSnapshot { configs: remote_installed_plugins_to_config(plugins, &self.store), remote_plugin_id_resolver: RemotePluginIdResolver::new(plugins), } } fn remote_plugin_id_for(&self, plugin_id: &PluginId) -> Option { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if self.remote_installed_plugins_cache_matches_current_auth(&cache) && let Some(plugins) = cache.plugins.as_ref() { return plugins.iter().find_map(|plugin| { (plugin.name == plugin_id.plugin_name && plugin.marketplace_name == plugin_id.marketplace_name) .then(|| plugin.id.clone()) }); } drop(cache); let installation = self.store.active_plugin_installation(plugin_id)?; persisted_remote_plugin_id_for_installation(&installation) } fn remote_installed_plugins_cache_matches_current_auth( &self, cache: &RemoteInstalledPluginsCache, ) -> bool { let auth = self.auth_manager.auth_cached(); cache.auth_identity.as_ref() == Some(&RemoteInstalledPluginsAuthIdentity::from_auth( auth.as_ref(), )) } fn remote_installed_plugins_auth_is_current( &self, auth_identity: &RemoteInstalledPluginsAuthIdentity, ) -> bool { let auth = self.auth_manager.auth_cached(); RemoteInstalledPluginsAuthIdentity::from_auth(auth.as_ref()) == *auth_identity } pub async fn telemetry_metadata_for_installed_plugin( &self, plugin_id: &PluginId, ) -> PluginTelemetryMetadata { let mut metadata = self.telemetry_metadata_for_plugin_id(plugin_id); metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { Some(plugin_root) => { plugin_capability_summary_from_root( plugin_id, &plugin_root, self.skill_root_loader.as_ref(), ) .await } None => None, }; metadata } pub async fn telemetry_metadata_for_installed_plugin_with_remote_id( &self, plugin_id: &PluginId, remote_plugin_id: &str, ) -> PluginTelemetryMetadata { let mut metadata = self.telemetry_metadata_for_plugin_id_with_remote_id(plugin_id, remote_plugin_id); metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { Some(plugin_root) => { plugin_capability_summary_from_root( plugin_id, &plugin_root, self.skill_root_loader.as_ref(), ) .await } None => None, }; metadata } pub fn telemetry_metadata_for_plugin_id( &self, plugin_id: &PluginId, ) -> PluginTelemetryMetadata { PluginTelemetryMetadata { plugin_id: Some(plugin_id.clone()), remote_plugin_id: self.remote_plugin_id_for(plugin_id), capability_summary: None, } } pub fn telemetry_metadata_for_plugin_id_with_remote_id( &self, plugin_id: &PluginId, remote_plugin_id: &str, ) -> PluginTelemetryMetadata { PluginTelemetryMetadata { remote_plugin_id: Some(remote_plugin_id.to_string()), ..self.telemetry_metadata_for_plugin_id(plugin_id) } } pub fn telemetry_metadata_for_capability_summary( &self, summary: &PluginCapabilitySummary, ) -> Option { let plugin_id = PluginId::parse(&summary.config_name).ok()?; Some(PluginTelemetryMetadata { remote_plugin_id: self.remote_plugin_id_for(&plugin_id), plugin_id: Some(plugin_id), capability_summary: Some(summary.clone()), }) } pub fn build_remote_installed_plugin_marketplaces_from_cache( &self, visible_marketplaces: &[&str], ) -> Option> { let auth = self.auth_manager.auth_cached(); self.build_remote_installed_plugin_marketplaces_from_cache_for_auth( auth.as_ref(), visible_marketplaces, ) } fn build_remote_installed_plugin_marketplaces_from_cache_for_auth( &self, auth: Option<&CodexAuth>, visible_marketplaces: &[&str], ) -> Option> { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if cache.auth_identity.as_ref() != Some(&RemoteInstalledPluginsAuthIdentity::from_auth(auth)) { return None; } let plugins = cache.plugins.as_ref()?; Some( crate::remote::group_remote_installed_plugins_by_marketplaces( plugins, visible_marketplaces, ), ) } pub fn cached_global_remote_discoverable_plugins_for_config( &self, config: &PluginsConfigInput, auth: Option<&CodexAuth>, ) -> Vec { if !config.plugins_enabled || !config.remote_plugin_enabled { return Vec::new(); } let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { return Vec::new(); }; let Some(account_id) = auth.get_account_id() else { return Vec::new(); }; if account_id.is_empty() { return Vec::new(); } crate::remote::cached_global_remote_discoverable_plugins( self.codex_home.as_path(), &remote_plugin_service_config(config), auth, ) } pub async fn build_and_cache_remote_installed_plugin_marketplaces( &self, config: &PluginsConfigInput, auth: Option<&CodexAuth>, visible_marketplaces: &[&str], on_effective_plugins_changed: Option, ) -> Result, RemotePluginCatalogError> { let generation = self .prepare_remote_installed_plugins_cache_generation(auth) .ok_or(RemotePluginCatalogError::AuthRequired)?; let plugins = crate::remote::fetch_remote_installed_plugins( &remote_plugin_service_config(config), auth, ) .await?; let marketplaces = crate::remote::group_remote_installed_plugins_by_marketplaces( &plugins, visible_marketplaces, ); let Some(changed) = self.write_remote_installed_plugins_cache_snapshot( generation, plugins, auth, &config.chatgpt_base_url, RemoteInstalledPluginsCachePublication::Refresh, ) else { let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth); if !self.remote_installed_plugins_auth_is_current(&auth_identity) { return Err(RemotePluginCatalogError::AuthRequired); } return Ok(self .build_remote_installed_plugin_marketplaces_from_cache_for_auth( auth, visible_marketplaces, ) .unwrap_or(marketplaces)); }; if changed && let Some(on_effective_plugins_changed) = on_effective_plugins_changed { on_effective_plugins_changed(EffectivePluginsChange::default()); } Ok(self .build_remote_installed_plugin_marketplaces_from_cache_for_auth( auth, visible_marketplaces, ) .unwrap_or(marketplaces)) } fn prepare_remote_installed_plugins_cache_generation( &self, auth: Option<&CodexAuth>, ) -> Option { let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth); if !self.remote_installed_plugins_auth_is_current(&auth_identity) { return None; } let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; let cache_changed = if cache.auth_identity.as_ref() != Some(&auth_identity) { cache.generation = cache.generation.wrapping_add(1); cache.auth_identity = Some(auth_identity); cache.reconciliation_generation = None; cache.needs_effective_plugins_refresh = false; cache.plugins.take().is_some() } else { false }; let generation = cache.generation; drop(cache); if cache_changed { self.clear_loaded_plugins_cache(); } Some(generation) } fn begin_remote_installed_plugins_reconcile(&self, auth: Option<&CodexAuth>) -> Option { let generation = self.prepare_remote_installed_plugins_cache_generation(auth)?; let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth); let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if cache.generation != generation || cache.auth_identity.as_ref() != Some(&auth_identity) { return None; } cache.generation = cache.generation.wrapping_add(1); cache.reconciliation_generation = Some(cache.generation); Some(cache.generation) } fn abandon_remote_installed_plugins_reconcile(&self, generation: u64) { let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if cache.reconciliation_generation != Some(generation) { return; } cache.reconciliation_generation = None; cache.generation = cache.generation.wrapping_add(1); cache.needs_effective_plugins_refresh = true; drop(cache); self.clear_loaded_plugins_cache(); } fn remote_installed_plugins_cache_generation_if_current( &self, auth: Option<&CodexAuth>, ) -> Option { let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth); if !self.remote_installed_plugins_auth_is_current(&auth_identity) { return None; } let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; (cache.auth_identity.as_ref() == Some(&auth_identity)).then_some(cache.generation) } fn write_remote_installed_plugins_cache_snapshot( &self, generation: u64, plugins: Vec, auth: Option<&CodexAuth>, service_base_url: &str, publication: RemoteInstalledPluginsCachePublication, ) -> Option { let auth_identity = RemoteInstalledPluginsAuthIdentity::from_auth(auth); if !self.remote_installed_plugins_auth_is_current(&auth_identity) { return None; } let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if cache.generation != generation || cache .auth_identity .as_ref() .is_some_and(|current| current != &auth_identity) { return None; } let is_reconcile = matches!( publication, RemoteInstalledPluginsCachePublication::Reconcile ); if !is_reconcile && cache.reconciliation_generation == Some(generation) { return None; } if cache.auth_identity.is_none() { cache.auth_identity = Some(auth_identity); } if is_reconcile { cache.reconciliation_generation = None; cache.generation = cache.generation.wrapping_add(1); } // TODO(sites-migration): Remove exclusion publication once bundled Sites is retired. let exclusion_changed = self.update_sites_exclusion(service_base_url, auth, &plugins); let needs_effective_plugins_refresh = (is_reconcile && std::mem::take(&mut cache.needs_effective_plugins_refresh)) || exclusion_changed; if cache.plugins.as_ref() == Some(&plugins) { drop(cache); if needs_effective_plugins_refresh { self.clear_loaded_plugins_cache(); } return Some(needs_effective_plugins_refresh); } cache.plugins = Some(plugins); drop(cache); self.clear_loaded_plugins_cache(); Some(true) } #[cfg(test)] fn write_remote_installed_plugins_cache(&self, plugins: Vec) -> bool { let auth = self.auth_manager.auth_cached(); let generation = self .prepare_remote_installed_plugins_cache_generation(auth.as_ref()) .expect("test cache identity should remain current"); self.write_remote_installed_plugins_cache_snapshot( generation, plugins, auth.as_ref(), "", RemoteInstalledPluginsCachePublication::Refresh, ) .expect("test cache generation should remain current") } pub fn clear_remote_installed_plugins_cache(&self) -> bool { let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; cache.generation = cache.generation.wrapping_add(1); cache.auth_identity = None; cache.reconciliation_generation = None; cache.needs_effective_plugins_refresh = false; if cache.plugins.take().is_none() { return false; } drop(cache); self.clear_loaded_plugins_cache(); true } fn clear_remote_installed_plugins_cache_if_current(&self, generation: u64) -> Option { let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; if cache.generation != generation { return None; } cache.generation = cache.generation.wrapping_add(1); cache.auth_identity = None; cache.reconciliation_generation = None; cache.needs_effective_plugins_refresh = false; if cache.plugins.take().is_none() { return Some(false); } drop(cache); self.clear_loaded_plugins_cache(); Some(true) } pub fn maybe_start_remote_plugin_caches_refresh( self: &Arc, config: &PluginsConfigInput, auth: Option, on_effective_plugins_changed: Option, ) { self.maybe_start_remote_installed_plugins_cache_refresh_with_notify( config, auth.clone(), RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged, on_effective_plugins_changed, EffectivePluginsChange::default(), ); let manager = Arc::clone(self); let config = config.clone(); tokio::spawn(async move { manager .recommended_plugins_mode_for_config(&config, auth.as_ref()) .await; }); } fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation( self: &Arc, config: &PluginsConfigInput, auth: Option, on_effective_plugins_changed: Option, ) { self.maybe_start_remote_installed_plugins_cache_refresh_with_notify( config, auth, RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh, on_effective_plugins_changed, EffectivePluginsChange::default(), ); } fn maybe_start_remote_installed_plugins_cache_refresh_with_notify( self: &Arc, config: &PluginsConfigInput, auth: Option, notify: RemoteInstalledPluginsCacheRefreshNotify, on_effective_plugins_changed: Option, change: EffectivePluginsChange, ) { if !config.plugins_enabled { return; } let Some(generation) = self.prepare_remote_installed_plugins_cache_generation(auth.as_ref()) else { return; }; self.schedule_remote_installed_plugins_cache_refresh( RemoteInstalledPluginsCacheRefreshRequest { generation, service_config: remote_plugin_service_config(config), auth, notify, on_effective_plugins_changed, change, }, ); } pub fn maybe_start_remote_installed_plugin_bundle_sync( self: &Arc, config: &PluginsConfigInput, auth: Option, on_effective_plugins_changed: Option, ) { if !config.plugins_enabled { return; } let Some(auth) = auth else { return; }; let Ok(permit) = Arc::clone(&self.remote_installed_plugin_bundle_sync_gate).try_acquire_owned() else { return; }; let manager = Arc::clone(self); let config = config.clone(); tokio::spawn(async move { let _permit = permit; let result = manager .reconcile_remote_installed_plugins_after_acquiring_gate(&config, Some(&auth)) .await; match result { Ok((outcome, effective_plugins_changed)) => { tracing::info!( materialized_remote_plugins = ?outcome.materialized_remote_plugins, changed_plugins = ?outcome.changed_plugins, failed_remote_plugin_ids = ?outcome.failed_remote_plugin_ids, failed_materialization_remote_plugin_ids = ?outcome.failed_materialization_remote_plugin_ids, "completed remote installed plugin bundle sync" ); if (effective_plugins_changed || !outcome.changed_plugins.is_empty()) && let Some(on_effective_plugins_changed) = on_effective_plugins_changed { on_effective_plugins_changed(EffectivePluginsChange { materialized_remote_plugins: outcome.materialized_remote_plugins, }); } } Err(err) => { warn!( error = %err, "remote installed plugin bundle sync failed" ); manager.maybe_start_remote_installed_plugins_cache_refresh_with_notify( &config, Some(auth), RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged, on_effective_plugins_changed, EffectivePluginsChange::default(), ); } } }); } /// Acquires the cache-root gate shared by full installed-bundle sync, reconciliation, and /// direct remote plugin mutations. async fn acquire_remote_installed_plugin_sync_guard( &self, ) -> Result { let permit = tokio::time::timeout( REMOTE_INSTALLED_PLUGIN_SYNC_WAIT_TIMEOUT, Arc::clone(&self.remote_installed_plugin_bundle_sync_gate).acquire_owned(), ) .await .map_err(|_| RemoteInstalledPluginBundleSyncError::LockTimeout)? .map_err(|_| RemoteInstalledPluginBundleSyncError::Superseded)?; Ok(RemoteInstalledPluginSyncGuard { _permit: permit }) } /// Synchronizes remote bundles and publishes the same installed-plugin snapshot. /// /// The shared gate orders this blocking path with background bundle synchronization. A /// reconciled publication advances the cache generation so metadata fetches that started /// before or during the bundle pass cannot overwrite its snapshot afterward. pub async fn reconcile_remote_installed_plugins( &self, config: &PluginsConfigInput, auth: Option<&CodexAuth>, ) -> Result { let _guard = self.acquire_remote_installed_plugin_sync_guard().await?; let (outcome, _) = self .reconcile_remote_installed_plugins_after_acquiring_gate(config, auth) .await?; Ok(outcome) } async fn reconcile_remote_installed_plugins_after_acquiring_gate( &self, config: &PluginsConfigInput, auth: Option<&CodexAuth>, ) -> Result<(RemoteInstalledPluginBundleSyncOutcome, bool), RemoteInstalledPluginBundleSyncError> { let generation = self .begin_remote_installed_plugins_reconcile(auth) .ok_or(RemoteInstalledPluginBundleSyncError::Superseded)?; let mut reconciliation = RemoteInstalledPluginsReconciliationGuard { manager: self, generation, committed: false, }; let previous_enabled = { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; cache.plugins.as_ref().map(|plugins| { plugins .iter() .filter_map(|plugin| { PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) .ok() .map(|plugin_id| (plugin_id, plugin.enabled)) }) .collect::>() }) }; let previous_plugin_ids = previous_enabled .iter() .flat_map(HashMap::keys) .cloned() .collect::>(); let result = crate::remote::sync_remote_installed_plugin_bundles_once_with_snapshot( self.codex_home.clone(), &remote_plugin_service_config(config), auth, &previous_plugin_ids, ) .await?; let mut outcome = result.outcome; // The generation fence keeps this comparison on the snapshot replaced by this pass. // In a known snapshot, absence means inactive: cached reinstalls need the same hints // as re-enablement, without becoming materializations or triggering hook-trust writes. if let Some(previous_enabled) = previous_enabled { for plugin in &result.installed_plugins { let plugin_id = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) .map_err(|err| RemotePluginCatalogError::UnexpectedResponse(err.to_string()))?; if previous_enabled .get(&plugin_id) .copied() .unwrap_or_default() == plugin.enabled { continue; } let plugin_key = plugin_id.as_key(); if outcome .changed_plugins .iter() .any(|change| change.plugin_id == plugin_key) || self.store.active_plugin_root(&plugin_id).is_none() { continue; } let mut capabilities = RemotePluginCapabilities::default(); capabilities .include_active_bundle(&self.store, &plugin_id) .await; outcome.changed_plugins.push(RemotePluginChange { plugin_id: plugin_key, capabilities, }); } } outcome .changed_plugins .sort_unstable_by(|left, right| left.plugin_id.cmp(&right.plugin_id)); let Some(effective_plugins_changed) = self.write_remote_installed_plugins_cache_snapshot( generation, result.installed_plugins, auth, &config.chatgpt_base_url, RemoteInstalledPluginsCachePublication::Reconcile, ) else { return Err(RemoteInstalledPluginBundleSyncError::Superseded); }; reconciliation.committed = true; if !effective_plugins_changed && !outcome.changed_plugins.is_empty() { self.clear_loaded_plugins_cache(); } Ok((outcome, effective_plugins_changed)) } fn maybe_start_remote_catalog_cache_refresh( self: &Arc, config: &PluginsConfigInput, auth: Option, scopes: BTreeSet, mode: RemoteCatalogCacheRefreshMode, ) { if !config.plugins_enabled || scopes.is_empty() { return; } self.schedule_remote_catalog_cache_refresh(RemoteCatalogCacheRefreshRequest { service_config: remote_plugin_service_config(config), auth, scopes, mode, }); } pub fn maybe_start_plugin_list_background_tasks( self: &Arc, context: &PluginMarketplaceContext, auth: Option, options: PluginListBackgroundTaskOptions, on_effective_plugins_changed: Option, ) { self.maybe_start_non_curated_plugin_cache_refresh(context, &options.local_marketplaces); self.maybe_start_remote_catalog_cache_refresh( &context.global_config, auth.clone(), options.remote_catalog_cache_refresh_scopes, RemoteCatalogCacheRefreshMode::OnlyIfStale, ); self.maybe_start_remote_plugin_caches_refresh( &context.global_config, auth.clone(), on_effective_plugins_changed.clone(), ); self.maybe_start_remote_installed_plugin_bundle_sync( &context.global_config, auth, on_effective_plugins_changed, ); } fn cached_featured_plugin_ids( &self, cache_key: &FeaturedPluginIdsCacheKey, ) -> Option> { { let cache = match self.featured_plugin_ids_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; let now = Instant::now(); if let Some(cached) = cache.as_ref() && now < cached.expires_at && cached.key == *cache_key { return Some(cached.featured_plugin_ids.clone()); } } let mut cache = match self.featured_plugin_ids_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; let now = Instant::now(); if cache .as_ref() .is_some_and(|cached| now >= cached.expires_at || cached.key != *cache_key) { *cache = None; } None } fn write_featured_plugin_ids_cache( &self, cache_key: FeaturedPluginIdsCacheKey, featured_plugin_ids: &[String], ) { let mut cache = match self.featured_plugin_ids_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; *cache = Some(CachedFeaturedPluginIds { key: cache_key, expires_at: Instant::now() + FEATURED_PLUGIN_IDS_CACHE_TTL, featured_plugin_ids: featured_plugin_ids.to_vec(), }); } pub async fn featured_plugin_ids_for_config( &self, config: &PluginsConfigInput, auth: Option<&CodexAuth>, ) -> Result, RemotePluginFetchError> { if !config.plugins_enabled { return Ok(Vec::new()); } let cache_key = featured_plugin_ids_cache_key(config, auth); if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) { return Ok(featured_plugin_ids); } let featured_plugin_ids = crate::remote_legacy::fetch_remote_featured_plugin_ids( &remote_plugin_service_config(config), auth, self.restriction_product, ) .await?; self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids); Ok(featured_plugin_ids) } #[instrument( level = "trace", skip_all, fields( plugins_enabled = config.plugins_enabled, remote_plugin_enabled = config.remote_plugin_enabled ) )] pub async fn recommended_plugins_mode_for_config( &self, config: &PluginsConfigInput, auth: Option<&CodexAuth>, ) -> RecommendedPluginsMode { if !config.plugins_enabled || !config.remote_plugin_enabled || !auth.is_some_and(CodexAuth::uses_codex_backend) { return RecommendedPluginsMode::Legacy; } let cache_key = recommended_plugins_cache_key(config); if let Some(cached) = self.cached_recommended_plugins_mode(&cache_key) { return cached; } let refresh = { let mut refreshes = match self.recommended_plugins_refreshes.write() { Ok(refreshes) => refreshes, Err(err) => err.into_inner(), }; if let Some(cached) = self.cached_recommended_plugins_mode(&cache_key) { return cached; } refreshes .entry(cache_key.clone()) .or_insert_with(|| Arc::new(OnceCell::new())) .clone() }; let mode = refresh .get_or_init(|| async { match crate::remote::fetch_recommended_plugins( &remote_plugin_service_config(config), auth, ) .await { Ok(mode) => { let refreshes = match self.recommended_plugins_refreshes.read() { Ok(refreshes) => refreshes, Err(err) => err.into_inner(), }; // Hold the refresh lock through publication, matching cache-clear lock // order. An invalidated initializer must not repopulate the cache. if refreshes .get(&cache_key) .is_some_and(|current| Arc::ptr_eq(current, &refresh)) { let mut cache = match self.recommended_plugins_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; cache.insert(cache_key.clone(), mode.clone()); } mode } Err(err) => { warn!(error = %err, "failed to load recommended plugins"); RecommendedPluginsMode::Legacy } } }) .await .clone(); let mut refreshes = match self.recommended_plugins_refreshes.write() { Ok(refreshes) => refreshes, Err(err) => err.into_inner(), }; if refreshes .get(&cache_key) .is_some_and(|current| Arc::ptr_eq(current, &refresh)) { refreshes.remove(&cache_key); } mode } /// Returns endpoint recommendations eligible for installation in the current client. /// `None` selects the legacy discovery workflow. #[instrument(level = "trace", skip_all)] pub async fn recommended_plugin_candidates_for_config( &self, input: RecommendedPluginCandidatesInput<'_>, ) -> Option> { let RecommendedPluginsMode::Endpoint { plugins } = self .recommended_plugins_mode_for_config(input.plugins_config, input.auth) .await else { return None; }; if plugins.is_empty() { return Some(Vec::new()); } let installed_plugin_ids = input .loaded_plugins .plugins() .iter() .map(|plugin| plugin.config_name.as_str()) .collect::>(); let installed_remote_plugin_ids = { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; let plugins = self .remote_installed_plugins_cache_matches_current_auth(&cache) .then_some(cache.plugins.as_deref()) .flatten() .unwrap_or_default(); plugins .iter() .filter(|plugin| plugin.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME) .map(|plugin| plugin.id.clone()) .collect::>() }; let disabled_plugin_ids = input .disabled_tools .iter() .filter(|tool| tool.kind == ToolSuggestDiscoverableType::Plugin) .map(|tool| tool.id.as_str()) .collect::>(); let candidates = plugins .into_iter() .filter(|plugin| { !installed_plugin_ids.contains(plugin.config_id.as_str()) && !installed_remote_plugin_ids.contains(plugin.remote_plugin_id.as_str()) && !disabled_plugin_ids.contains(plugin.config_id.as_str()) }) .map(|plugin| { DiscoverableTool::from(DiscoverablePluginInfo { id: plugin.config_id, remote_plugin_id: Some(plugin.remote_plugin_id), name: plugin.display_name, description: None, has_skills: false, mcp_server_names: Vec::new(), app_connector_ids: Vec::new(), }) }) .collect(); Some(filter_request_plugin_install_discoverable_tools_for_client( candidates, input.app_server_client_name, )) } fn cached_recommended_plugins_mode( &self, cache_key: &RecommendedPluginsCacheKey, ) -> Option { let cache = match self.recommended_plugins_cache.read() { Ok(cache) => cache, Err(err) => err.into_inner(), }; cache.get(cache_key).cloned() } pub async fn install_plugin( &self, config: &PluginsConfigInput, request: PluginInstallRequest, ) -> Result { let resolved = self.resolve_installable_plugin(config, &request)?; let plugin_id = resolved.plugin_id.clone(); match self.install_resolved_plugin(resolved).await { Ok(outcome) => Ok(outcome), Err(err) => { self.track_plugin_install_failed( &plugin_id, plugin_install_error_type(&err), err.sub_error_type(), err.to_string(), ); Err(err) } } } fn resolve_installable_plugin( &self, config: &PluginsConfigInput, request: &PluginInstallRequest, ) -> Result { let resolved = match find_installable_marketplace_plugin( &request.marketplace_path, &request.plugin_name, self.restriction_product, ) { Ok(resolved) => resolved, Err(err) => { self.track_plugin_install_resolution_failed(&err); return Err(err.into()); } }; if self.bundled_sites_is_hidden(config, &resolved.plugin_id.as_key()) { let err = MarketplaceError::PluginNotFound { plugin_name: resolved.plugin_id.plugin_name, marketplace_name: resolved.plugin_id.marketplace_name, }; self.track_plugin_install_resolution_failed(&err); return Err(err.into()); } let config_layer_stack = &config.config_layer_stack; if let Err(message) = MarketplacePolicy::from_requirements(config_layer_stack.requirements()) .validate_install( config_layer_stack, self.codex_home.as_path(), &request.marketplace_path, &resolved.plugin_id.marketplace_name, ) { let err = MarketplaceError::InvalidMarketplaceFile { path: request.marketplace_path.to_path_buf(), message, }; self.track_plugin_install_resolution_failed(&err); return Err(err.into()); } Ok(resolved) } pub async fn install_plugin_with_remote_sync( &self, config: &PluginsConfigInput, auth: Option<&CodexAuth>, request: PluginInstallRequest, ) -> Result { let resolved = self.resolve_installable_plugin(config, &request)?; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. if let Err(err) = crate::remote_legacy::enable_remote_plugin( &remote_plugin_service_config(config), auth, &plugin_id, ) .await { let err = PluginInstallError::from(err); self.track_plugin_install_failed( &resolved.plugin_id, plugin_install_error_type(&err), err.sub_error_type(), err.to_string(), ); return Err(err); } let plugin_id = resolved.plugin_id.clone(); match self.install_resolved_plugin(resolved).await { Ok(outcome) => Ok(outcome), Err(err) => { self.track_plugin_install_failed( &plugin_id, plugin_install_error_type(&err), err.sub_error_type(), err.to_string(), ); Err(err) } } } fn track_plugin_install_resolution_failed(&self, err: &MarketplaceError) { let sub_error_type = marketplace_error_sub_error_type(err); let plugin_id = match err { MarketplaceError::PluginNotFound { plugin_name, marketplace_name, } | MarketplaceError::PluginNotAvailable { plugin_name, marketplace_name, } => PluginId::new(plugin_name.clone(), marketplace_name.clone()).ok(), MarketplaceError::Io { .. } | MarketplaceError::MarketplaceNotFound { .. } | MarketplaceError::InvalidMarketplaceFile { .. } | MarketplaceError::PluginsDisabled | MarketplaceError::InvalidPlugin(_) => None, }; if let Some(plugin_id) = plugin_id { self.track_plugin_install_failed( &plugin_id, marketplace_error_type(err), sub_error_type, err.to_string(), ); } else { tracing::warn!( error_type = %marketplace_error_type(err), sub_error_type = sub_error_type.as_deref(), error = %err, "plugin install failed while resolving marketplace plugin" ); self.emit_plugin_install_failed( PluginTelemetryMetadata { plugin_id: None, remote_plugin_id: None, capability_summary: None, }, marketplace_error_type(err), sub_error_type, ); } } fn track_plugin_install_failed( &self, plugin_id: &PluginId, error_type: &'static str, sub_error_type: Option, error_message: String, ) { tracing::warn!( plugin_id = %plugin_id.as_key(), error_type = %error_type, sub_error_type = sub_error_type.as_deref(), error = %error_message, "plugin install failed" ); self.emit_plugin_install_failed( self.telemetry_metadata_for_plugin_id(plugin_id), error_type, sub_error_type, ); } fn emit_plugin_install_failed( &self, plugin: PluginTelemetryMetadata, error_type: &'static str, sub_error_type: Option, ) { let analytics_events_client = match self.analytics_events_client.read() { Ok(client) => client.clone(), Err(err) => err.into_inner().clone(), }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_install_failed( plugin, self.plugin_install_source, error_type.to_string(), sub_error_type, ); } } async fn install_resolved_plugin( &self, resolved: ResolvedMarketplacePlugin, ) -> Result { let auth_policy = resolved.policy.authentication; let plugin_version = if is_openai_curated_marketplace_name(&resolved.plugin_id.marketplace_name) { let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path()) .ok_or_else(|| { PluginStoreError::Invalid( "local curated marketplace sha is not available".to_string(), ) })?; Some(curated_plugin_cache_version(&curated_plugin_version)) } else { None }; let store = self.store.clone(); let codex_home = self.codex_home.clone(); let manifest_fallback_contents = resolved .manifest_fallback .contents_if_has_metadata() .map(str::to_string); let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || { let materialized = materialize_marketplace_plugin_source(codex_home.as_path(), &resolved.source) .map_err(PluginStoreError::Invalid)?; let source_path = materialized.path; match (plugin_version, manifest_fallback_contents.as_deref()) { (Some(plugin_version), Some(manifest_contents)) => store .install_with_version_and_fallback_manifest( source_path, resolved.plugin_id, plugin_version, manifest_contents, ), (Some(plugin_version), None) => { store.install_with_version(source_path, resolved.plugin_id, plugin_version) } (None, Some(manifest_contents)) => store.install_with_fallback_manifest( source_path, resolved.plugin_id, manifest_contents, ), (None, None) => store.install(source_path, resolved.plugin_id), } }) .await .map_err(PluginInstallError::join)??; set_user_plugin_enabled( &self.codex_home, result.plugin_id.as_key(), /*enabled*/ true, ) .await .map_err(anyhow::Error::from)?; let analytics_events_client = match self.analytics_events_client.read() { Ok(client) => client.clone(), Err(err) => err.into_inner().clone(), }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_installed( self.telemetry_metadata_for_installed_plugin(&result.plugin_id) .await, ); } Ok(PluginInstallOutcome { plugin_id: result.plugin_id, plugin_version: result.plugin_version, installed_path: result.installed_path, auth_policy, }) } pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> { let plugin_id = PluginId::parse(&plugin_id)?; self.uninstall_plugin_id(plugin_id).await } pub async fn uninstall_plugin_with_remote_sync( &self, config: &PluginsConfigInput, auth: Option<&CodexAuth>, plugin_id: String, ) -> Result<(), PluginUninstallError> { // TODO: Remove this legacy remote-sync path once remote plugins have // their own manager and installed-state API. let plugin_id = PluginId::parse(&plugin_id)?; let plugin_key = plugin_id.as_key(); // This only forwards the backend mutation before the local uninstall flow. crate::remote_legacy::uninstall_remote_plugin( &remote_plugin_service_config(config), auth, &plugin_key, ) .await .map_err(PluginUninstallError::from)?; self.uninstall_plugin_id(plugin_id).await } async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> { let plugin_telemetry = if self.store.active_plugin_root(&plugin_id).is_some() { Some( self.telemetry_metadata_for_installed_plugin(&plugin_id) .await, ) } else { None }; let store = self.store.clone(); let plugin_id_for_store = plugin_id.clone(); tokio::task::spawn_blocking(move || store.uninstall(&plugin_id_for_store)) .await .map_err(PluginUninstallError::join)??; clear_user_plugin(&self.codex_home, plugin_id.as_key()) .await .map_err(anyhow::Error::from)?; let analytics_events_client = match self.analytics_events_client.read() { Ok(client) => client.clone(), Err(err) => err.into_inner().clone(), }; if let Some(plugin_telemetry) = plugin_telemetry && let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_uninstalled(plugin_telemetry); } Ok(()) } pub fn list_marketplaces_for_context( &self, context: &PluginMarketplaceContext, include_openai_curated: bool, ) -> Result { context.list_marketplaces(self, include_openai_curated) } pub fn list_marketplaces_for_config( &self, config: &PluginsConfigInput, additional_roots: &[AbsolutePathBuf], include_openai_curated: bool, ) -> Result { if !config.plugins_enabled { return Ok(ConfiguredMarketplaceListOutcome::default()); } let plugin_states = self.configured_plugin_states(config); self.list_marketplaces_for_config_with_states( config, additional_roots, include_openai_curated, &plugin_states, ) } fn list_marketplaces_for_config_with_states( &self, config: &PluginsConfigInput, additional_roots: &[AbsolutePathBuf], include_openai_curated: bool, plugin_states: &ConfiguredPluginStates, ) -> Result { let excluded_plugin_ids = self.excluded_bundled_plugin_ids(config); let marketplace_roots = self.marketplace_roots(config, additional_roots, include_openai_curated); let marketplace_outcome = self.list_marketplaces_with_policy(config, &marketplace_roots)?; let mut seen_plugin_keys = HashSet::new(); let marketplaces = marketplace_outcome .marketplaces .into_iter() .filter_map(|marketplace| { let marketplace_name = marketplace.name.clone(); let plugins = marketplace .plugins .into_iter() .filter_map(|plugin| { let plugin_key = format!("{}@{marketplace_name}", plugin.name); if excluded_plugin_ids.contains(&plugin_key) { return None; } if !seen_plugin_keys.insert(plugin_key.clone()) { return None; } if !self.restriction_product_matches(plugin.policy.products.as_deref()) { return None; } let plugin_id = PluginId::new(plugin.name.clone(), marketplace_name.clone()).ok(); let installed = plugin_states.installed.contains(&plugin_key); let installed_version = installed.then_some(()).and_then(|_| { plugin_id .as_ref() .and_then(|plugin_id| self.store.active_plugin_version(plugin_id)) }); let enabled = plugin_states.enabled.contains(&plugin_key); let mut interface = plugin.interface; let mut local_version = plugin.local_version; let manifest_fallback = plugin.manifest_fallback.clone(); if installed && plugin.source.is_install_materialized() && let Some(plugin_id) = plugin_id.as_ref() && let Some(plugin_root) = self.store.active_plugin_root(plugin_id) && let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) { local_version = manifest.version.clone(); let marketplace_category = interface .as_ref() .and_then(|interface| interface.category.clone()); interface = plugin_interface_with_marketplace_category( manifest.interface, marketplace_category, ); } Some(ConfiguredMarketplacePlugin { // Enabled state is keyed by `@`, so duplicate // plugin entries from duplicate marketplace files intentionally // resolve to the first discovered source. id: plugin_key, installed_version, installed, enabled, name: plugin.name, local_version, source: plugin.source, policy: plugin.policy, keywords: plugin.keywords, interface, manifest_fallback, }) }) .collect::>(); (!plugins.is_empty()).then_some(ConfiguredMarketplace { name: marketplace.name, path: marketplace.path, interface: marketplace.interface, plugins, }) }) .collect(); Ok(ConfiguredMarketplaceListOutcome { marketplaces, errors: marketplace_outcome.errors, }) } pub fn discover_marketplaces_for_config( &self, config: &PluginsConfigInput, additional_roots: &[AbsolutePathBuf], ) -> Result { if !config.plugins_enabled { return Ok(MarketplaceListOutcome::default()); } let marketplace_roots = self.marketplace_roots( config, additional_roots, /*include_openai_curated*/ true, ); self.list_marketplaces_with_policy(config, &marketplace_roots) } pub(crate) async fn tool_suggest_metadata_for_marketplace_plugin( &self, marketplace_name: &str, plugin: &ConfiguredMarketplacePlugin, skill_config_rules: &SkillConfigRules, ) -> Result { let fragment = self .tool_suggest_metadata_cache .metadata_for_plugin( marketplace_name, plugin, self.restriction_product, self.skill_root_loader.as_ref(), ) .await?; Ok(fragment.project(skill_config_rules, self.auth_mode())) } pub async fn read_plugin_for_config( &self, config: &PluginsConfigInput, request: &PluginReadRequest, ) -> Result { if !config.plugins_enabled { return Err(MarketplaceError::PluginsDisabled); } let plugin = find_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?; if self.bundled_sites_is_hidden(config, &plugin.plugin_id.as_key()) { return Err(MarketplaceError::PluginNotFound { plugin_name: plugin.plugin_id.plugin_name, marketplace_name: plugin.plugin_id.marketplace_name, }); } MarketplacePolicy::from_requirements(config.config_layer_stack.requirements()) .validate_install( &config.config_layer_stack, self.codex_home.as_path(), &request.marketplace_path, &plugin.plugin_id.marketplace_name, ) .map_err(|message| MarketplaceError::InvalidMarketplaceFile { path: request.marketplace_path.to_path_buf(), message, })?; if !self.restriction_product_matches(plugin.policy.products.as_deref()) { return Err(MarketplaceError::PluginNotFound { plugin_name: plugin.plugin_id.plugin_name, marketplace_name: plugin.plugin_id.marketplace_name, }); } let marketplace_name = plugin.plugin_id.marketplace_name.clone(); let plugin_key = plugin.plugin_id.as_key(); let manifest_fallback = plugin .manifest_fallback .contents_if_has_metadata() .map(|_| plugin.manifest_fallback.clone()); let plugin_states = self.configured_plugin_states(config); let installed = plugin_states.installed.contains(&plugin_key); let installed_version = if installed { self.store.active_plugin_version(&plugin.plugin_id) } else { None }; let plugin = self .read_plugin_detail_for_marketplace_plugin( config, &marketplace_name, ConfiguredMarketplacePlugin { id: plugin_key.clone(), name: plugin.plugin_id.plugin_name, local_version: plugin .manifest .as_ref() .and_then(|manifest| manifest.version.clone()), installed_version, source: plugin.source, policy: plugin.policy, interface: plugin.interface, keywords: plugin .manifest .as_ref() .map(|manifest| manifest.keywords.clone()) .unwrap_or_default(), manifest_fallback, installed, enabled: plugin_states.enabled.contains(&plugin_key), }, ) .await?; Ok(PluginReadOutcome { marketplace_name, marketplace_path: Some(request.marketplace_path.clone()), plugin, }) } #[instrument(level = "trace", skip_all)] pub async fn read_plugin_detail_for_marketplace_plugin( &self, config: &PluginsConfigInput, marketplace_name: &str, plugin: ConfiguredMarketplacePlugin, ) -> Result { if !self.restriction_product_matches(plugin.policy.products.as_deref()) { return Err(MarketplaceError::PluginNotFound { plugin_name: plugin.name, marketplace_name: marketplace_name.to_string(), }); } let plugin_id = PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| { match err { PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message), } })?; let plugin_key = plugin_id.as_key(); if plugin.source.is_install_materialized() && !plugin.installed { let description = remote_plugin_install_required_description(&plugin.source); return Ok(PluginDetail { id: plugin_key, name: plugin.name, local_version: None, description: Some(description), source: plugin.source, policy: plugin.policy, interface: plugin.interface, keywords: plugin.keywords, installed: plugin.installed, enabled: plugin.enabled, skills: Vec::new(), disabled_skill_paths: HashSet::new(), hooks: Vec::new(), apps: Vec::new(), app_category_by_id: HashMap::new(), mcp_server_names: Vec::new(), details_unavailable_reason: Some( PluginDetailsUnavailableReason::InstallRequiredForRemoteSource, ), }); } let source_path = if plugin.source.is_install_materialized() && plugin.installed { self.store.active_plugin_root(&plugin_id).ok_or_else(|| { MarketplaceError::InvalidPlugin(format!( "installed plugin cache entry is missing for {plugin_key}" )) })? } else { let codex_home = self.codex_home.clone(); let source = plugin.source.clone(); let materialized = tokio::task::spawn_blocking(move || { materialize_marketplace_plugin_source(codex_home.as_path(), &source) }) .await .map_err(|err| { MarketplaceError::InvalidPlugin(format!( "failed to materialize plugin source: {err}" )) })? .map_err(MarketplaceError::InvalidPlugin)?; materialized.path.clone() }; if !source_path.as_path().is_dir() { return Err(MarketplaceError::InvalidPlugin( "path does not exist or is not a directory".to_string(), )); } let loaded_manifest = if codex_utils_plugins::find_plugin_manifest_path(source_path.as_path()).is_some() { load_plugin_manifest_with_format(source_path.as_path()) } else { plugin .manifest_fallback .as_ref() .and_then(|fallback| fallback.parse_for_plugin_root(source_path.as_path())) .map(|manifest| crate::manifest::LoadedPluginManifest { manifest, format: PluginManifestFormat::Legacy, }) } .ok_or_else(|| { MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string()) })?; let manifest_format = loaded_manifest.format; let manifest = loaded_manifest.manifest; let description = manifest.description.clone(); let marketplace_category = plugin .interface .as_ref() .and_then(|interface| interface.category.clone()); let interface = plugin_interface_with_marketplace_category( manifest.interface.clone(), marketplace_category, ); let plugin_identity = PluginIdentity { plugin_id: plugin_id.as_key(), remote_plugin_id: self.remote_plugin_id_for(&plugin_id), }; let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack); let resolved_skills = load_plugin_skill_inventory( &source_path, &plugin_identity, &manifest, manifest_format, self.restriction_product, /*plugin_skill_snapshots*/ None, self.skill_root_loader.as_ref(), ) .await .resolve(&skill_config_rules); let plugin_data_root = self.store.plugin_data_root(&plugin_id); let (hook_sources, _hook_load_warnings) = if manifest_format == PluginManifestFormat::Legacy { load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths) } else { (Vec::new(), Vec::new()) }; let hooks = plugin_hook_declarations(&hook_sources) .into_iter() .map(|hook| PluginHookSummary { key: hook.key, event_name: hook.event_name, }) .collect(); let auth_mode = self.auth_mode(); let mut app_declarations = if manifest_format == PluginManifestFormat::Legacy { load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await } else { Vec::new() }; let mcp_data_root = (manifest_format == PluginManifestFormat::AgentPlugin) .then(|| self.store.mcp_data_root(&plugin_id, manifest_format)); let mut mcp_servers = load_plugin_mcp_servers_from_manifest_with_format( source_path.as_path(), &manifest.paths, /*plugin_policy*/ None, mcp_data_root.as_deref(), manifest_format, ) .await; if manifest_format == PluginManifestFormat::Legacy { apply_app_mcp_routing_policy( &mut app_declarations, &mut mcp_servers, auth_mode, /*plugin_active*/ true, ); } let apps = app_connector_ids_from_declarations(&app_declarations); let mut seen_app_connector_ids = HashSet::new(); let mut app_category_by_id = HashMap::new(); for app in &app_declarations { if seen_app_connector_ids.insert(app.connector_id.0.as_str()) && let Some(category) = &app.category { app_category_by_id.insert(app.connector_id.0.clone(), category.clone()); } } let mut mcp_server_names = mcp_servers.into_keys().collect::>(); mcp_server_names.sort_unstable(); mcp_server_names.dedup(); Ok(PluginDetail { id: plugin.id, name: plugin.name, local_version: manifest.version.clone(), description, source: plugin.source, policy: plugin.policy, interface, keywords: manifest.keywords, installed: plugin.installed, enabled: plugin.enabled, skills: resolved_skills.skills, disabled_skill_paths: resolved_skills.disabled_skill_paths, hooks, apps, app_category_by_id, mcp_server_names, details_unavailable_reason: None, }) } pub fn maybe_start_plugin_startup_tasks_for_config( self: &Arc, config: &PluginsConfigInput, reload_config: ConfigLayerReload, on_effective_plugins_changed: Option, ) { if config.plugins_enabled { self.maybe_start_curated_repo_sync_for_config( config, on_effective_plugins_changed.clone(), ); let should_spawn_marketplace_auto_upgrade = { let mut state = match self.configured_marketplace_upgrade_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; if state.in_flight { false } else { state.in_flight = true; true } }; if should_spawn_marketplace_auto_upgrade { let manager = Arc::clone(self); let config = config.clone(); let on_effective_plugins_changed = on_effective_plugins_changed.clone(); let runtime = tokio::runtime::Handle::current(); if let Err(err) = std::thread::Builder::new() .name("plugins-marketplace-auto-upgrade".to_string()) .spawn(move || { let outcome = manager.upgrade_configured_marketplaces_for_config_with_mode( &config, /*marketplace_name*/ None, PluginGitMode::Automatic, &reload_config, ); match outcome { Ok(outcome) => { if !outcome.upgraded_roots.is_empty() && let Some(on_effective_plugins_changed) = on_effective_plugins_changed { runtime.spawn(async move { on_effective_plugins_changed( EffectivePluginsChange::default(), ); }); } for error in outcome.errors { warn!( marketplace = error.marketplace_name, error = %error.message, "failed to auto-upgrade configured marketplace" ); } } Err(err) => { warn!("failed to auto-upgrade configured marketplaces: {err}"); } } let mut state = match manager.configured_marketplace_upgrade_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; state.in_flight = false; }) { let mut state = match self.configured_marketplace_upgrade_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; state.in_flight = false; warn!("failed to start configured marketplace auto-upgrade task: {err}"); } } let config_for_remote_sync = config.clone(); let manager = Arc::clone(self); let on_effective_plugins_changed = on_effective_plugins_changed.clone(); tokio::spawn(async move { let auth = manager.auth_manager.auth().await; manager.maybe_start_remote_plugin_caches_refresh( &config_for_remote_sync, auth.clone(), on_effective_plugins_changed.clone(), ); manager.maybe_start_remote_installed_plugin_bundle_sync( &config_for_remote_sync, auth.clone(), on_effective_plugins_changed, ); let mut scopes = crate::remote::cached_remote_plugin_catalog_scopes( manager.codex_home.as_path(), &remote_plugin_service_config(&config_for_remote_sync), auth.as_ref(), ); if config_for_remote_sync.remote_plugin_enabled { scopes.insert(RemotePluginScope::Global); } else { scopes.retain(|scope| *scope == RemotePluginScope::Workspace); } manager.maybe_start_remote_catalog_cache_refresh( &config_for_remote_sync, auth, scopes, RemoteCatalogCacheRefreshMode::Force, ); }); let config_for_featured_plugins = config.clone(); let manager = Arc::clone(self); tokio::spawn(async move { let auth = manager.auth_manager.auth().await; if let Err(err) = manager .featured_plugin_ids_for_config(&config_for_featured_plugins, auth.as_ref()) .await { warn!( error = %err, "failed to warm featured plugin ids cache" ); } }); } } pub fn upgrade_configured_marketplaces_for_config( &self, config: &PluginsConfigInput, marketplace_name: Option<&str>, reload_config: &ConfigLayerReload, ) -> Result { self.upgrade_configured_marketplaces_for_config_with_mode( config, marketplace_name, PluginGitMode::Manual, reload_config, ) } /// Carries the initiating Git trust policy through marketplace and plugin refreshes. fn upgrade_configured_marketplaces_for_config_with_mode( &self, config: &PluginsConfigInput, marketplace_name: Option<&str>, mode: PluginGitMode, reload_config: &ConfigLayerReload, ) -> Result { let mut outcome = upgrade_configured_git_marketplaces_with_mode( self.codex_home.as_path(), &config.config_layer_stack, marketplace_name, mode, reload_config, ); if let Some(marketplace_name) = marketplace_name && outcome.selected_marketplaces.is_empty() { return Err(format!( "marketplace `{marketplace_name}` is not configured as a Git marketplace" )); } if !outcome.upgraded_roots.is_empty() { let mut configured_plugin_keys = configured_plugins_from_stack( &config.config_layer_stack, self.codex_home.as_path(), ) .into_keys() .collect::>(); configured_plugin_keys.sort_unstable(); match refresh_non_curated_plugin_cache_force_reinstall_detailed( self.codex_home.as_path(), &outcome.upgraded_roots, &configured_plugin_keys, mode, ) { Ok(refresh_outcome) => { self.clear_caches_after_marketplace_source_refresh( refresh_outcome.cache_refreshed, /*on_effective_plugins_changed*/ None, ); outcome .errors .extend(refresh_outcome.errors.into_iter().map(|error| { ConfiguredMarketplaceUpgradeError { marketplace_name: error.marketplace_name, message: error.message, } })); } Err(err) => { self.clear_cache(); outcome.errors.push(ConfiguredMarketplaceUpgradeError { marketplace_name: marketplace_name .unwrap_or("all configured marketplaces") .to_string(), message: format!( "failed to refresh installed plugin cache after marketplace upgrade: {err}" ), }); } } } Ok(outcome) } pub fn maybe_start_non_curated_plugin_cache_refresh( self: &Arc, context: &PluginMarketplaceContext, marketplaces: &[ConfiguredMarketplace], ) { if let Some(request) = context.non_curated_cache_refresh_request( self, marketplaces, NonCuratedCacheRefreshMode::IfVersionChanged, PluginGitMode::Automatic, ) { self.schedule_non_curated_plugin_cache_refresh(request); } } /// Runs an explicitly requested refresh using the caller's normal Git configuration. pub async fn refresh_non_curated_plugin_cache_for_context( self: &Arc, context: &PluginMarketplaceContext, ) -> bool { let Ok(_refresh_permit) = self.non_curated_cache_refresh_lock.acquire().await else { return false; }; let mut completion = self.non_curated_cache_refresh_completion.subscribe(); let changed_sequence = completion.borrow_and_update().changed_sequence; let marketplaces = match self.list_marketplaces_for_context(context, /*include_openai_curated*/ false) { Ok(outcome) => outcome.marketplaces, Err(err) => { warn!("failed to prepare non-curated plugin cache refresh: {err}"); Vec::new() } }; if let Some(request) = context.non_curated_cache_refresh_request( self, &marketplaces, NonCuratedCacheRefreshMode::IfVersionChanged, PluginGitMode::Manual, ) { self.schedule_non_curated_plugin_cache_refresh(request); } loop { let in_flight = match self.non_curated_cache_refresh_state.read() { Ok(state) => state.in_flight, Err(err) => err.into_inner().in_flight, }; if !in_flight { return completion.borrow().changed_sequence != changed_sequence; } if completion.changed().await.is_err() { return false; } } } fn schedule_remote_installed_plugins_cache_refresh( self: &Arc, mut request: RemoteInstalledPluginsCacheRefreshRequest, ) { if self.remote_installed_plugins_cache_generation_if_current(request.auth.as_ref()) != Some(request.generation) { return; } let should_spawn = { let mut state = match self.remote_installed_plugins_cache_refresh_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; if self.remote_installed_plugins_cache_generation_if_current(request.auth.as_ref()) != Some(request.generation) { return; } if let Some(existing_request) = state.requested.as_ref() && existing_request.generation == request.generation { if matches!( existing_request.notify, RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh ) { request.notify = RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh; } if !existing_request .change .materialized_remote_plugins .is_empty() && let Some(existing_callback) = existing_request.on_effective_plugins_changed.as_ref() { request.on_effective_plugins_changed = Some(Arc::clone(existing_callback)); } else if request.on_effective_plugins_changed.is_none() { request.on_effective_plugins_changed = existing_request.on_effective_plugins_changed.clone(); } for materialization in &existing_request.change.materialized_remote_plugins { if !request .change .materialized_remote_plugins .iter() .any(|pending| pending.plugin_id == materialization.plugin_id) { request .change .materialized_remote_plugins .push(materialization.clone()); } } request .change .materialized_remote_plugins .sort_by_key(|materialization| materialization.plugin_id.as_key()); } state.requested = Some(request); if state.in_flight { false } else { state.in_flight = true; true } }; if !should_spawn { return; } let manager = Arc::clone(self); tokio::spawn(async move { manager .run_remote_installed_plugins_cache_refresh_loop() .await; }); } fn schedule_remote_catalog_cache_refresh( self: &Arc, request: RemoteCatalogCacheRefreshRequest, ) { let should_spawn = { let mut state = match self.remote_catalog_cache_refresh_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; if let Some(pending) = state .requests .iter_mut() .find(|pending| pending.has_same_cache_identity(&request)) { pending.scopes.extend(request.scopes); pending.auth = request.auth; pending.mode = match (pending.mode, request.mode) { (RemoteCatalogCacheRefreshMode::Force, _) | (_, RemoteCatalogCacheRefreshMode::Force) => { RemoteCatalogCacheRefreshMode::Force } ( RemoteCatalogCacheRefreshMode::OnlyIfStale, RemoteCatalogCacheRefreshMode::OnlyIfStale, ) => RemoteCatalogCacheRefreshMode::OnlyIfStale, }; } else { state.requests.push_back(request); } if state.in_flight { false } else { state.in_flight = true; true } }; if !should_spawn { return; } let manager = Arc::clone(self); tokio::spawn(async move { manager.run_remote_catalog_cache_refresh_loop().await; }); } fn schedule_non_curated_plugin_cache_refresh( self: &Arc, mut request: NonCuratedCacheRefreshRequest, ) { let should_spawn = { let mut state = match self.non_curated_cache_refresh_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; if request.mode == NonCuratedCacheRefreshMode::IfVersionChanged && state.last_refreshed.as_ref().is_some_and(|last_refreshed| { request.configured_plugin_sources.iter().any(|source| { last_refreshed .configured_plugin_sources .iter() .any(|previous_source| { previous_source.plugin_key == source.plugin_key && previous_source.local_version == source.local_version && (previous_source.marketplace_path != source.marketplace_path || previous_source.source != source.source) }) }) }) { request.mode = NonCuratedCacheRefreshMode::ForceReinstall; } if request.mode == NonCuratedCacheRefreshMode::IfVersionChanged && state.requested.as_ref().is_some_and(|requested| { requested.mode == NonCuratedCacheRefreshMode::ForceReinstall && requested.roots == request.roots }) { request.mode = NonCuratedCacheRefreshMode::ForceReinstall; } // Reconcile each canonical plugin generation once before publishing its resource. if state.requested.as_ref().is_some_and(|requested| { requested == &request || (requested.git_mode == PluginGitMode::Manual && request.git_mode == PluginGitMode::Automatic && requested.roots == request.roots && requested.configured_plugin_keys == request.configured_plugin_keys && requested.configured_plugin_sources == request.configured_plugin_sources && requested.mode == request.mode) }) || (request.mode == NonCuratedCacheRefreshMode::IfVersionChanged && !state.in_flight && state.last_refreshed.as_ref().is_some_and(|last_refreshed| { last_refreshed.roots == request.roots && last_refreshed.configured_plugin_keys == request.configured_plugin_keys && last_refreshed.configured_plugin_sources == request.configured_plugin_sources && (last_refreshed.git_mode == request.git_mode || last_refreshed.git_mode == PluginGitMode::Manual) })) { return; } state.requested = Some(request); if state.in_flight { false } else { state.in_flight = true; true } }; if !should_spawn { return; } let manager = Arc::clone(self); if let Err(err) = std::thread::Builder::new() .name("plugins-non-curated-cache-refresh".to_string()) .spawn(move || manager.run_non_curated_plugin_cache_refresh_loop()) { let mut state = match self.non_curated_cache_refresh_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; state.in_flight = false; state.requested = None; self.non_curated_cache_refresh_completion .send_modify(|completion| { completion.sequence = completion.sequence.wrapping_add(1); }); warn!("failed to start non-curated plugin cache refresh task: {err}"); } } fn start_curated_repo_sync( self: &Arc, http_client_factory: HttpClientFactory, on_effective_plugins_changed: Option, ) { if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) { return; } let on_effective_plugins_changed = on_effective_plugins_changed.map(|on_effective_plugins_changed| { let Ok(runtime) = tokio::runtime::Handle::try_current() else { return on_effective_plugins_changed; }; let callback: EffectivePluginsChangedCallback = Arc::new(move |change| { let on_effective_plugins_changed = Arc::clone(&on_effective_plugins_changed); runtime.spawn(async move { on_effective_plugins_changed(change); }); }); callback }); let manager = Arc::clone(self); let codex_home = self.codex_home.clone(); if let Err(err) = std::thread::Builder::new() .name("plugins-curated-repo-sync".to_string()) .spawn(move || { match sync_openai_plugins_repo(codex_home.as_path(), http_client_factory) { Ok(curated_plugin_version) => { let configured_curated_plugin_ids = configured_curated_plugin_ids_from_codex_home(codex_home.as_path()); match refresh_curated_plugin_cache( codex_home.as_path(), &curated_plugin_version, &configured_curated_plugin_ids, ) { Ok(cache_refreshed) => { manager.clear_caches_after_marketplace_source_refresh( cache_refreshed, on_effective_plugins_changed.as_ref(), ); } Err(err) => { manager.clear_cache(); CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); warn!("failed to refresh curated plugin cache after sync: {err}"); } } } Err(err) => { CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); warn!("failed to sync curated plugins repo: {err}"); } } }) { CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst); warn!("failed to start curated plugins repo sync task: {err}"); } } async fn run_remote_installed_plugins_cache_refresh_loop(self: Arc) { loop { let request = { let mut state = match self.remote_installed_plugins_cache_refresh_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; match state.requested.take() { Some(request) => request, None => { state.in_flight = false; return; } } }; let plugins = crate::remote::fetch_remote_installed_plugins( &request.service_config, request.auth.as_ref(), ) .await; match plugins { Ok(plugins) => { let Some(changed) = self.write_remote_installed_plugins_cache_snapshot( request.generation, plugins, request.auth.as_ref(), &request.service_config.chatgpt_base_url, RemoteInstalledPluginsCachePublication::Refresh, ) else { continue; }; let should_notify = changed || !request.change.materialized_remote_plugins.is_empty() || matches!( request.notify, RemoteInstalledPluginsCacheRefreshNotify::AfterSuccessfulRefresh ); if should_notify && let Some(on_effective_plugins_changed) = request.on_effective_plugins_changed { on_effective_plugins_changed(request.change); } } Err( RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode, ) => { let Some(changed) = self.clear_remote_installed_plugins_cache_if_current(request.generation) else { continue; }; if changed && let Some(on_effective_plugins_changed) = request.on_effective_plugins_changed { on_effective_plugins_changed(EffectivePluginsChange::default()); } } Err(err) => { warn!( error = %err, materialized_remote_plugin_count = request .change .materialized_remote_plugins .len(), "failed to refresh remote installed plugins cache" ); } } } } async fn run_remote_catalog_cache_refresh_loop(self: Arc) { loop { let request = { let mut state = match self.remote_catalog_cache_refresh_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; match state.requests.pop_front() { Some(request) => request, None => { state.in_flight = false; return; } } }; for scope in request.scopes { if request.mode == RemoteCatalogCacheRefreshMode::OnlyIfStale && crate::remote::has_fresh_cached_remote_plugin_catalog( self.codex_home.as_path(), &request.service_config, request.auth.as_ref(), scope, ) { continue; } match crate::remote::fetch_and_cache_remote_plugin_catalog( self.codex_home.as_path(), &request.service_config, request.auth.as_ref(), scope, ) .await { Ok(()) => {} Err( RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode, ) => {} Err(err) => { warn!( error = %err, scope = ?scope, "failed to refresh cached remote plugin catalog" ); } } } } } fn run_non_curated_plugin_cache_refresh_loop(self: Arc) { loop { let request = { let state = match self.non_curated_cache_refresh_state.read() { Ok(state) => state, Err(err) => err.into_inner(), }; state.requested.clone() }; let Some(request) = request else { let mut state = match self.non_curated_cache_refresh_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; state.in_flight = false; self.non_curated_cache_refresh_completion .send_modify(|completion| { completion.sequence = completion.sequence.wrapping_add(1); }); return; }; let refresh_result = match request.mode { NonCuratedCacheRefreshMode::IfVersionChanged => { refresh_non_curated_plugin_cache_detailed( self.codex_home.as_path(), &request.roots, &request.configured_plugin_keys, request.git_mode, ) } NonCuratedCacheRefreshMode::ForceReinstall => { refresh_non_curated_plugin_cache_force_reinstall_detailed( self.codex_home.as_path(), &request.roots, &request.configured_plugin_keys, request.git_mode, ) } }; let (refreshed, cache_changed) = match refresh_result { Ok(refresh_outcome) => { if refresh_outcome.cache_refreshed { self.clear_cache(); } for error in &refresh_outcome.errors { warn!( marketplace = error.marketplace_name, error = %error.message, "failed to refresh configured plugin cache" ); } ( refresh_outcome.errors.is_empty(), refresh_outcome.cache_refreshed, ) } Err(err) => { self.clear_cache(); warn!("failed to refresh non-curated plugin cache: {err}"); (false, false) } }; let mut state = match self.non_curated_cache_refresh_state.write() { Ok(state) => state, Err(err) => err.into_inner(), }; if refreshed { state.last_refreshed = Some(request.clone()); } let complete = state.requested.as_ref() == Some(&request); if complete { state.requested = None; state.in_flight = false; } self.non_curated_cache_refresh_completion .send_modify(|completion| { completion.sequence = completion.sequence.wrapping_add(1); if cache_changed { completion.changed_sequence = completion.changed_sequence.wrapping_add(1); } }); if complete { return; } } } fn configured_plugin_states(&self, config: &PluginsConfigInput) -> ConfiguredPluginStates { let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack, self.codex_home.as_path()); let installed = configured_plugins .keys() .filter(|plugin_key| { PluginId::parse(plugin_key) .ok() .is_some_and(|plugin_id| self.store.is_installed(&plugin_id)) }) .cloned() .collect::>(); let enabled = configured_plugins .into_iter() .filter_map(|(plugin_key, plugin)| plugin.enabled.then_some(plugin_key)) .collect::>(); ConfiguredPluginStates { installed, enabled } } fn marketplace_roots( &self, config: &PluginsConfigInput, additional_roots: &[AbsolutePathBuf], include_openai_curated: bool, ) -> Vec { // Treat the curated catalog as an extra marketplace root so plugin listing can surface it // without requiring every caller to know where it is stored. let mut roots = additional_roots.to_vec(); roots.extend(installed_marketplace_roots_from_layer_stack( &config.config_layer_stack, self.codex_home.as_path(), )); let curated_marketplace_path = if include_openai_curated { match target_curated_marketplace(self.auth_mode()) { TargetCuratedMarketplace::OpenAi | TargetCuratedMarketplace::OpenAiWithRemote => { let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path()); curated_repo_root.is_dir().then_some(curated_repo_root) } TargetCuratedMarketplace::OpenAiApi => { let api_marketplace_path = curated_plugins_api_marketplace_path(self.codex_home.as_path()); api_marketplace_path .is_file() .then_some(api_marketplace_path) } } } else { None }; if let Some(curated_marketplace_path) = curated_marketplace_path && let Ok(curated_marketplace_path) = AbsolutePathBuf::try_from(curated_marketplace_path) { roots.push(curated_marketplace_path); } roots.sort_unstable(); roots.dedup(); roots } fn list_marketplaces_with_policy( &self, config: &PluginsConfigInput, roots: &[AbsolutePathBuf], ) -> Result { let mut outcome = list_marketplaces_with_home(roots, home_dir().as_deref())?; let policy = MarketplacePolicy::from_requirements(config.config_layer_stack.requirements()); outcome.marketplaces.retain(|marketplace| { policy .validate_install( &config.config_layer_stack, self.codex_home.as_path(), &marketplace.path, &marketplace.name, ) .is_ok() }); Ok(outcome) } } pub(crate) fn remote_plugin_install_required_description( source: &MarketplacePluginSource, ) -> String { let source_description = match source { MarketplacePluginSource::Git { url, path, ref_name, sha, } => { let mut parts = vec![url.clone()]; if let Some(path) = path { parts.push(format!("path `{path}`")); } if let Some(ref_name) = ref_name { parts.push(format!("ref `{ref_name}`")); } if let Some(sha) = sha { parts.push(format!("sha `{sha}`")); } parts.join(", ") } MarketplacePluginSource::Local { path } => path.as_path().display().to_string(), MarketplacePluginSource::Npm { package, version, registry, } => { let mut parts = vec![package.clone()]; if let Some(version) = version { parts.push(format!("version `{version}`")); } if let Some(registry) = registry { parts.push(format!("registry `{registry}`")); } parts.join(", ") } }; let source_kind = if matches!(source, MarketplacePluginSource::Npm { .. }) { "an npm plugin" } else { "a cross-repo plugin" }; format!( "This is {source_kind}. Install it to view more detailed information. The source of the plugin is {source_description}." ) } #[derive(Debug, thiserror::Error)] pub enum PluginInstallError { #[error("{0}")] Marketplace(#[from] MarketplaceError), #[error("{0}")] Remote(#[from] RemotePluginMutationError), #[error("{0}")] Store(#[from] PluginStoreError), #[error("{0}")] Config(#[from] anyhow::Error), #[error("failed to join plugin install task: {0}")] Join(#[from] tokio::task::JoinError), } impl PluginInstallError { fn join(source: tokio::task::JoinError) -> Self { Self::Join(source) } pub fn is_invalid_request(&self) -> bool { matches!( self, Self::Marketplace( MarketplaceError::MarketplaceNotFound { .. } | MarketplaceError::InvalidMarketplaceFile { .. } | MarketplaceError::PluginNotFound { .. } | MarketplaceError::PluginNotAvailable { .. } | MarketplaceError::InvalidPlugin(_) ) | Self::Store(PluginStoreError::Invalid(_)) ) } pub fn sub_error_type(&self) -> Option { match self { Self::Marketplace(err) => marketplace_error_sub_error_type(err), Self::Remote(err) => err.sub_error_type(), Self::Store(err) => err.sub_error_type(), Self::Config(_) => Some("failed_to_enable_plugin".to_string()), Self::Join(_) => Some("plugin_install_task_failed".to_string()), } } } fn plugin_install_error_type(err: &PluginInstallError) -> &'static str { match err { PluginInstallError::Marketplace(err) => marketplace_error_type(err), PluginInstallError::Remote(err) => remote_plugin_mutation_error_type(err), PluginInstallError::Store(err) => plugin_store_error_type(err), PluginInstallError::Config(_) => "config", PluginInstallError::Join(_) => "join", } } fn marketplace_error_type(err: &MarketplaceError) -> &'static str { match err { MarketplaceError::Io { .. } => "marketplace_io", MarketplaceError::MarketplaceNotFound { .. } => "marketplace_not_found", MarketplaceError::InvalidMarketplaceFile { .. } => "invalid_marketplace_file", MarketplaceError::PluginNotFound { .. } => "plugin_not_found", MarketplaceError::PluginNotAvailable { .. } => "plugin_not_available", MarketplaceError::PluginsDisabled => "plugins_disabled", MarketplaceError::InvalidPlugin(_) => "invalid_plugin", } } fn marketplace_error_sub_error_type(err: &MarketplaceError) -> Option { match err { MarketplaceError::Io { context, .. } => Some(error_context_sub_error_type(context)), MarketplaceError::MarketplaceNotFound { .. } | MarketplaceError::InvalidMarketplaceFile { .. } | MarketplaceError::PluginNotFound { .. } | MarketplaceError::PluginNotAvailable { .. } | MarketplaceError::PluginsDisabled | MarketplaceError::InvalidPlugin(_) => None, } } fn remote_plugin_mutation_error_type(err: &RemotePluginMutationError) -> &'static str { match err { RemotePluginMutationError::AuthRequired => "remote_mutation_auth_required", RemotePluginMutationError::UnsupportedAuthMode => "remote_mutation_unsupported_auth_mode", RemotePluginMutationError::AuthToken(_) => "remote_mutation_auth_token", RemotePluginMutationError::InvalidBaseUrl(_) => "remote_mutation_invalid_base_url", RemotePluginMutationError::InvalidBaseUrlPath => "remote_mutation_invalid_base_url_path", RemotePluginMutationError::Request { .. } => "remote_mutation_request", RemotePluginMutationError::UnexpectedStatus { .. } => "remote_mutation_unexpected_status", RemotePluginMutationError::Decode { .. } => "remote_mutation_decode", RemotePluginMutationError::UnexpectedPluginId { .. } => { "remote_mutation_unexpected_plugin_id" } RemotePluginMutationError::UnexpectedEnabledState { .. } => { "remote_mutation_unexpected_enabled_state" } } } fn plugin_store_error_type(err: &PluginStoreError) -> &'static str { match err { PluginStoreError::Io { .. } => "store_io", PluginStoreError::Invalid(_) => "store_invalid", } } #[derive(Debug, thiserror::Error)] pub enum PluginUninstallError { #[error("{0}")] InvalidPluginId(#[from] PluginIdError), #[error("{0}")] Remote(#[from] RemotePluginMutationError), #[error("{0}")] Store(#[from] PluginStoreError), #[error("{0}")] Config(#[from] anyhow::Error), #[error("failed to join plugin uninstall task: {0}")] Join(#[from] tokio::task::JoinError), } impl PluginUninstallError { fn join(source: tokio::task::JoinError) -> Self { Self::Join(source) } pub fn is_invalid_request(&self) -> bool { matches!(self, Self::InvalidPluginId(_)) } } #[cfg(test)] #[path = "manager_tests.rs"] mod tests;