diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 449689871..8e124694c 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -644,6 +644,12 @@ impl PluginRequestProcessor { data.push(remote_marketplace_to_info(remote_marketplace)); } Ok(None) => {} + Err(err) if explicit_marketplace_kinds => { + return Err(remote_plugin_catalog_error_to_jsonrpc( + err, + "list OpenAI Curated remote plugin catalog", + )); + } Err( RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index a49c5d3c7..811f87216 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -2064,7 +2064,7 @@ async fn plugin_list_includes_openai_curated_remote_collection_when_requested() } #[tokio::test] -async fn plugin_list_fail_opens_openai_curated_remote_collection_errors() -> Result<()> { +async fn plugin_list_propagates_explicit_openai_curated_remote_collection_errors() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_plugins_enabled_config_with_base_url( @@ -2103,18 +2103,17 @@ async fn plugin_list_fail_opens_openai_curated_remote_collection_errors() -> Res marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]), }) .await?; - let response: JSONRPCResponse = timeout( + let err = timeout( DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), ) .await??; - let response: PluginListResponse = to_response(response)?; + assert_eq!(err.error.code, -32603); assert!( - response - .marketplaces - .iter() - .all(|marketplace| marketplace.name != "openai-curated-remote") + err.error + .message + .contains("list OpenAI Curated remote plugin catalog") ); Ok(()) } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e442d235d..076dd780e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -12,6 +12,7 @@ use crate::app_event::FeedbackCategory; use crate::app_event::HistoryLookupResponse; use crate::app_event::PermissionProfileSelection; use crate::app_event::PluginLocation; +use crate::app_event::PluginRemoteSectionError; use crate::app_event::RateLimitRefreshOrigin; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; @@ -104,8 +105,10 @@ use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallParams; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index a560cc703..a486e11e3 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -156,13 +156,34 @@ impl App { } pub(super) fn fetch_plugins_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { + self.chat_widget.on_plugins_list_fetch_started(cwd.clone()); let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); + let plugin_sharing_enabled = self.config.features.enabled(Feature::PluginSharing); + let remote_plugin_enabled = self.config.features.enabled(Feature::RemotePlugin); tokio::spawn(async move { - let result = fetch_plugins_list(request_handle, cwd.clone()) + let result = fetch_plugins_list(request_handle.clone(), cwd.clone()) .await .map_err(|err| err.to_string()); - app_event_tx.send(AppEvent::PluginsLoaded { cwd, result }); + let should_fetch_additional_remote_sections = result.is_ok(); + app_event_tx.send(AppEvent::PluginsLoaded { + cwd: cwd.clone(), + result, + }); + if should_fetch_additional_remote_sections { + let (marketplaces, section_errors) = fetch_additional_plugin_remote_sections( + request_handle, + cwd.clone(), + plugin_sharing_enabled, + remote_plugin_enabled, + ) + .await; + app_event_tx.send(AppEvent::PluginRemoteSectionsLoaded { + cwd, + marketplaces, + section_errors, + }); + } }); } @@ -756,6 +777,114 @@ pub(super) async fn fetch_plugins_list( Ok(response) } +pub(super) async fn fetch_additional_plugin_remote_sections( + request_handle: AppServerRequestHandle, + cwd: PathBuf, + plugin_sharing_enabled: bool, + remote_plugin_enabled: bool, +) -> (Vec, Vec) { + let mut marketplaces = Vec::new(); + let mut section_errors = Vec::new(); + let mut sections = Vec::new(); + if !remote_plugin_enabled { + sections.push(( + "vertical", + "OpenAI Curated", + vec![PluginListMarketplaceKind::Vertical], + )); + } + sections.push(( + "workspace", + "Workspace", + vec![PluginListMarketplaceKind::WorkspaceDirectory], + )); + if plugin_sharing_enabled { + sections.push(( + "shared-with-me", + "Shared with me", + vec![PluginListMarketplaceKind::SharedWithMe], + )); + } else { + section_errors.push(plugin_sharing_disabled_remote_section_error()); + } + + for (section_id, label, marketplace_kinds) in sections { + match request_plugin_list_for_kinds(request_handle.clone(), cwd.clone(), marketplace_kinds) + .await + { + Ok(mut response) => { + hide_cli_only_plugin_marketplaces(&mut response); + marketplaces.extend(response.marketplaces); + } + Err(err) => { + let message = format!("{err:#}"); + section_errors.push(PluginRemoteSectionError { + section_id: section_id.to_string(), + label: label.to_string(), + message: plugin_remote_section_error_message(label, &message), + }); + } + } + } + + (marketplaces, section_errors) +} + +fn plugin_remote_section_error_message(label: &str, err: &str) -> String { + let next_step = plugin_remote_section_error_next_step(label, err); + if next_step.is_empty() { + err.to_string() + } else { + format!("{err} {next_step}") + } +} + +fn plugin_remote_section_error_next_step(label: &str, err: &str) -> &'static str { + let err = err.to_ascii_lowercase(); + if err.contains("api key auth is not supported") { + "Sign in with ChatGPT auth; API key auth cannot load remote plugin catalogs." + } else if err.contains("authentication required") + || err.contains("not signed in") + || err.contains("not logged in") + { + "Sign in to ChatGPT, then try loading this section again." + } else if err.contains("codex plugins are disabled") + || err.contains("plugin sharing is disabled") + || err.contains("plugin sharing is not enabled") + || err.contains("feature disabled") + { + "Ask a workspace admin to enable Codex plugins or plugin sharing." + } else if err.contains("workspace") && (err.contains("access") || err.contains("mismatch")) { + "Switch to the matching workspace or ask the sharer for access." + } else if err.contains("not found") || err.contains("status 404") { + "Check that you are signed in to the correct workspace and still have access." + } else if err.contains("old build") || err.contains("update codex") || err.contains("stale") { + "Update Codex, then try opening the shared plugin again." + } else if err.contains("service unavailable") + || err.contains("temporarily unavailable") + || err.contains("status 503") + || err.contains("failed to send") + || err.contains("request") + || err.contains("status") + { + "Try again later; local plugin functionality is still available." + } else if err.contains("disabled by admin") || err.contains("admin disabled") { + "Ask a workspace admin to confirm plugin access." + } else if label == "Shared with me" && err.contains("plugin") && err.contains("disabled") { + "Ask the sharer or a workspace admin to confirm plugin access." + } else { + "" + } +} + +fn plugin_sharing_disabled_remote_section_error() -> PluginRemoteSectionError { + PluginRemoteSectionError { + section_id: "shared-with-me".to_string(), + label: "Shared with me".to_string(), + message: "Plugin sharing is disabled for this Codex session. Enable plugin sharing to load shared plugins.".to_string(), + } +} + const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"]; pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) { @@ -767,6 +896,23 @@ pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListRespons pub(super) async fn request_plugin_list( request_handle: AppServerRequestHandle, cwd: PathBuf, +) -> Result { + request_plugin_list_with_marketplace_kinds(request_handle, cwd, /*marketplace_kinds*/ None) + .await +} + +pub(super) async fn request_plugin_list_for_kinds( + request_handle: AppServerRequestHandle, + cwd: PathBuf, + marketplace_kinds: Vec, +) -> Result { + request_plugin_list_with_marketplace_kinds(request_handle, cwd, Some(marketplace_kinds)).await +} + +async fn request_plugin_list_with_marketplace_kinds( + request_handle: AppServerRequestHandle, + cwd: PathBuf, + marketplace_kinds: Option>, ) -> Result { let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?; let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4())); @@ -775,7 +921,7 @@ pub(super) async fn request_plugin_list( request_id, params: PluginListParams { cwds: Some(vec![cwd]), - marketplace_kinds: None, + marketplace_kinds, }, }) .await @@ -1118,6 +1264,71 @@ mod tests { ); } + #[test] + fn plugin_remote_section_error_message_adds_concrete_next_steps() { + let cases = [ + ( + "Workspace", + "chatgpt authentication required for remote plugin catalog", + "Sign in to ChatGPT, then try loading this section again.", + ), + ( + "OpenAI Curated", + "chatgpt authentication required for remote plugin catalog; api key auth is not supported", + "Sign in with ChatGPT auth; API key auth cannot load remote plugin catalogs.", + ), + ( + "Shared with me", + "remote plugin catalog request failed with status 404: missing", + "Check that you are signed in to the correct workspace and still have access.", + ), + ( + "Shared with me", + "workspace access mismatch", + "Switch to the matching workspace or ask the sharer for access.", + ), + ( + "Shared with me", + "old build fallback", + "Update Codex, then try opening the shared plugin again.", + ), + ( + "Shared with me", + "remote service unavailable", + "Try again later; local plugin functionality is still available.", + ), + ( + "Workspace", + "plugin disabled by admin", + "Ask a workspace admin to confirm plugin access.", + ), + ( + "Shared with me", + "plugin sharing is not enabled", + "Ask a workspace admin to enable Codex plugins or plugin sharing.", + ), + ]; + + for (label, err, next_step) in cases { + assert_eq!( + plugin_remote_section_error_message(label, err), + format!("{err} {next_step}") + ); + } + } + + #[test] + fn plugin_sharing_disabled_remote_section_error_targets_shared_with_me() { + assert_eq!( + plugin_sharing_disabled_remote_section_error(), + PluginRemoteSectionError { + section_id: "shared-with-me".to_string(), + label: "Shared with me".to_string(), + message: "Plugin sharing is disabled for this Codex session. Enable plugin sharing to load shared plugins.".to_string(), + } + ); + } + #[test] fn mcp_inventory_maps_prefix_tool_names_by_server() { let statuses = vec![ diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0a3ab007a..1f7b8e39f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -507,6 +507,17 @@ impl App { AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } + AppEvent::PluginRemoteSectionsLoaded { + cwd, + marketplaces, + section_errors, + } => { + self.chat_widget.on_plugin_remote_sections_loaded( + cwd, + marketplaces, + section_errors, + ); + } AppEvent::HooksLoaded { cwd, result } => { self.chat_widget.on_hooks_loaded(cwd, result); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 39e0b9503..3f6189f27 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::McpServerStatus; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; @@ -102,6 +103,13 @@ impl PluginLocation { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PluginRemoteSectionError { + pub(crate) section_id: String, + pub(crate) label: String, + pub(crate) message: String, +} + /// Distinguishes why a rate-limit refresh was requested so the completion /// handler can route the result correctly. /// @@ -406,6 +414,13 @@ pub(crate) enum AppEvent { result: Result, }, + /// Result of explicitly fetching remote-backed plugin sections. + PluginRemoteSectionsLoaded { + cwd: PathBuf, + marketplaces: Vec, + section_errors: Vec, + }, + /// Result of fetching lifecycle hook inventory. HooksLoaded { cwd: PathBuf, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 232890ed1..caed3297b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -598,6 +598,9 @@ pub(crate) struct ChatWidget { ide_context: IdeContextState, plugins_cache: PluginsCacheState, plugins_fetch_state: PluginListFetchState, + plugin_remote_sections_loading: bool, + plugin_remote_sections_loaded: bool, + plugin_remote_section_errors: Vec, plugin_install_apps_needing_auth: Vec, plugin_install_auth_flow: Option, plugins_active_tab_id: Option, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index a6ceef558..363d8e4ce 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -160,6 +160,9 @@ impl ChatWidget { ide_context: IdeContextState::default(), plugins_cache: PluginsCacheState::default(), plugins_fetch_state: PluginListFetchState::default(), + plugin_remote_sections_loading: false, + plugin_remote_sections_loaded: false, + plugin_remote_section_errors: Vec::new(), plugin_install_apps_needing_auth: Vec::new(), plugin_install_auth_flow: None, plugins_active_tab_id: None, diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 9bc62fe26..203e09944 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -6,6 +6,7 @@ use std::time::Instant; use super::ChatWidget; use crate::app_event::AppEvent; use crate::app_event::PluginLocation; +use crate::app_event::PluginRemoteSectionError; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -37,6 +38,10 @@ use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; use codex_features::Feature; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -201,7 +206,9 @@ impl ChatWidget { cwd: PathBuf, result: Result, ) { - if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { + let request_was_in_flight = + self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()); + if request_was_in_flight { self.plugins_fetch_state.in_flight_cwd = None; } @@ -210,10 +217,28 @@ impl ChatWidget { } let auth_flow_active = self.plugin_install_auth_flow.is_some(); + let should_refresh_plugins_popup = !auth_flow_active + && (self + .bottom_pane + .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) + .is_some() + || self + .bottom_pane + .selected_index_for_active_view(PLUGINS_SELECTION_VIEW_ID) + .is_some() + || !matches!( + self.plugins_cache_for_current_cwd(), + PluginsCacheState::Ready(_) + )); match result { Ok(response) => { self.plugins_fetch_state.cache_cwd = Some(cwd); + self.plugin_remote_sections_loading = request_was_in_flight; + if request_was_in_flight { + self.plugin_remote_sections_loaded = false; + } + self.plugin_remote_section_errors.clear(); let active_tab_id = self .plugins_active_tab_id .as_deref() @@ -229,13 +254,15 @@ impl ChatWidget { }); self.plugins_active_tab_id = active_tab_id; self.plugins_cache = PluginsCacheState::Ready(response.clone()); - if !auth_flow_active { + if should_refresh_plugins_popup { self.refresh_plugins_popup_if_open(&response); } self.newly_installed_marketplace_tab_id = None; } Err(err) => { - if !auth_flow_active { + self.plugin_remote_sections_loading = false; + self.plugin_remote_sections_loaded = false; + if should_refresh_plugins_popup { self.plugins_fetch_state.cache_cwd = None; self.plugins_cache = PluginsCacheState::Failed(err.clone()); let _ = self.bottom_pane.replace_selection_view_if_active( @@ -247,18 +274,62 @@ impl ChatWidget { } } + pub(crate) fn on_plugin_remote_sections_loaded( + &mut self, + cwd: PathBuf, + marketplaces: Vec, + section_errors: Vec, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + let should_refresh_plugins_popup = self + .bottom_pane + .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) + .is_some(); + self.plugin_remote_sections_loading = false; + self.plugin_remote_sections_loaded = true; + let refreshed_response = match &mut self.plugins_cache { + PluginsCacheState::Ready(response) + if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) => + { + merge_remote_marketplaces(response, marketplaces); + self.plugin_remote_section_errors = section_errors; + Some(response.clone()) + } + _ => { + self.plugin_remote_section_errors = section_errors; + None + } + }; + + if let Some(response) = refreshed_response + && should_refresh_plugins_popup + { + self.refresh_plugins_popup_if_open(&response); + } + } + fn prefetch_plugins(&mut self) { let cwd = self.config.cwd.to_path_buf(); if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { return; } + self.on_plugins_list_fetch_started(cwd.clone()); + self.app_event_tx.send(AppEvent::FetchPluginsList { cwd }); + } + + pub(crate) fn on_plugins_list_fetch_started(&mut self, cwd: PathBuf) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } - - self.app_event_tx.send(AppEvent::FetchPluginsList { cwd }); } fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { @@ -2005,6 +2076,32 @@ fn marketplace_tab_id_matching_saved_id( }) } +fn merge_remote_marketplaces( + response: &mut PluginListResponse, + remote_marketplaces: Vec, +) { + let remote_names = remote_marketplaces + .iter() + .map(|marketplace| marketplace.name.clone()) + .collect::>(); + response.marketplaces.retain(|marketplace| { + marketplace.path.is_some() + || !remote_marketplace_is_remote_section(marketplace) + && !remote_names.contains(marketplace.name.as_str()) + }); + response.marketplaces.extend(remote_marketplaces); +} + +fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool { + matches!( + marketplace.name.as_str(), + REMOTE_WORKSPACE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME + ) +} + fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { let mut counts: Vec<(String, usize)> = Vec::new(); for label in &labels {