TUI Plugin Sharing 2 - add remote plugin section plumbing (#26702)

This adds the background plumbing for remote-backed plugin catalog
sections while leaving the fuller directory presentation to the next PR.
The TUI can fetch section-specific remote marketplace results, keep
local plugin data available, and carry section errors forward for later
rendering.

- Fetches explicit remote marketplace kinds for curated, workspace, and
shared-with-me sections.
- Gates shared-with-me loading on the plugin sharing feature flag.
- Adds section-level error state and user-actionable error copy.
- Merges remote marketplace results into the cached plugin list without
discarding local results.
This commit is contained in:
canvrno-oai
2026-06-15 10:25:37 -07:00
committed by GitHub
Unverified
parent a18de1f3b6
commit 29224d945b
9 changed files with 364 additions and 16 deletions
@@ -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,
@@ -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(())
}
+3
View File
@@ -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;
+214 -3
View File
@@ -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<PluginMarketplaceEntry>, Vec<PluginRemoteSectionError>) {
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<PluginListResponse> {
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<PluginListMarketplaceKind>,
) -> Result<PluginListResponse> {
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<Vec<PluginListMarketplaceKind>>,
) -> Result<PluginListResponse> {
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![
+11
View File
@@ -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);
}
+15
View File
@@ -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<PluginListResponse, String>,
},
/// Result of explicitly fetching remote-backed plugin sections.
PluginRemoteSectionsLoaded {
cwd: PathBuf,
marketplaces: Vec<PluginMarketplaceEntry>,
section_errors: Vec<PluginRemoteSectionError>,
},
/// Result of fetching lifecycle hook inventory.
HooksLoaded {
cwd: PathBuf,
+3
View File
@@ -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<crate::app_event::PluginRemoteSectionError>,
plugin_install_apps_needing_auth: Vec<AppSummary>,
plugin_install_auth_flow: Option<PluginInstallAuthFlowState>,
plugins_active_tab_id: Option<String>,
@@ -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,
+102 -5
View File
@@ -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<PluginListResponse, String>,
) {
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<PluginMarketplaceEntry>,
section_errors: Vec<PluginRemoteSectionError>,
) {
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<PluginMarketplaceEntry>,
) {
let remote_names = remote_marketplaces
.iter()
.map(|marketplace| marketplace.name.clone())
.collect::<std::collections::HashSet<_>>();
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<String>) -> Vec<String> {
let mut counts: Vec<(String, usize)> = Vec::new();
for label in &labels {