From d75f94a94d5cb0bbabc59b86c0427c7ad09a9d6d Mon Sep 17 00:00:00 2001 From: Greg Brisebois Date: Tue, 4 Aug 2026 16:11:19 +0000 Subject: [PATCH] Merge local plugins into plugin search results (#36919) ## What changed - Discover plugins from configured and repository marketplaces using the request's `cwds`, and merge up to 100 ranked local matches into the first remote result page. - Match local plugin names, display names, and keywords without case or punctuation sensitivity, while applying global, personal, and workspace scope semantics. - Deduplicate local and remote copies, preserve local installation metadata on the remote result, and treat the remote global catalog as authoritative over the local curated marketplace. - Keep local search available for API-key authentication and when `remote_plugin` is disabled, and consistently report `enabled: false` because search results describe discovery rather than effective activation. ## Testing - Add coverage for merged pagination and ranking, API-key local search, scope and feature behavior, deduplication, installed state, and explicit disabled-state reporting. GitOrigin-RevId: 63696a00d16166dfdd86e0cc456769c583ebe9a7 --- codex-rs/app-server/README.md | 2 +- .../src/request_processors/plugins/search.rs | 344 +++++++++-- .../tests/suite/v2/plugin_search.rs | 564 ++++++++++++++++++ 3 files changed, 854 insertions(+), 56 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ddb47c51822e..49d5ee83ae37 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -245,7 +245,7 @@ Example with notification opt-out: - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, nullable remote install-policy provenance in `installPolicySource` (`WORKSPACE_SETTING` or `IMPLICIT_CANONICAL_APP`), the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Every `PluginSummary` returned by plugin list, installed, read, and share-list methods includes nullable `disabledReason` and `eligiblePlanTypes`, preserving plugin-service availability metadata and raw plan identifiers for remote plugins while returning `null` for local plugins or older remote responses. The same summaries include `mustShowInstallationInterstitial`: remote service values preserve `true` or `false`, while local plugins and remote responses that omit the policy return `null`. Clients should fail closed when the value is `null`. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. Set `forceRefetch: true` to bypass TTL-backed remote catalog caches for the requested marketplaces and wait for fresh data; cache entries are replaced only after a successful fetch. When local marketplaces are included, the request also waits for configured plugin caches to reconcile before marketplace summaries are returned. At app-server startup, existing cached catalogs remain available to `plugin/list` while they refresh in the background. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). -- `plugin/search` — search the remote plugin service directly without using cached plugin catalogs. Accepts a `searchTerm`, optional `global`, `workspace`, or `personal` scope, and optional `cursor` and `limit`; `personal` searches user-owned plugins. When `remote_plugin` is disabled, an omitted scope is treated as `workspace`, explicit workspace search remains available, and global or personal search returns an empty page without querying the remote service. Returns marketplace-qualified plugin summaries in `data` and passes the remote pagination token through unchanged as `nextCursor`. When `plugin_sharing` is disabled, shared/private workspace results are omitted after the remote page is fetched, so a page can contain fewer than `limit` entries while retaining its upstream `nextCursor`. Because this endpoint does not join results with the installed-plugin snapshot, every returned summary has `installed: false` (**under development; do not call from production clients yet**). +- `plugin/search` — search the remote plugin service directly and combine matching local marketplace plugins into the first result page. Accepts a `searchTerm`, optional `global`, `workspace`, or `personal` scope, optional `cwds` for discovering repo marketplaces, and optional `cursor` and `limit`; `personal` searches user-owned plugins. Local matching uses plugin names, display names, and keywords, with case- and punctuation-insensitive relevance ordering. Global searches include applicable built-in local plugins, personal searches include other local plugins, workspace searches remain remote-only, and an omitted scope includes all local plugins. When the remote global catalog is active, it is authoritative and replaces the local curated marketplace. Local results remain available with API-key authentication and when `remote_plugin` is disabled; in the latter case, omitted-scope and explicit workspace searches can still query the remote workspace catalog, while explicit global and personal searches do not query plugin-service. The first page includes at most 100 local matches and can exceed `limit`; subsequent pages contain remote results only, and the upstream pagination token is passed through unchanged as `nextCursor`. Local and remote copies are deduplicated by shared remote identity, with the remote summary retaining local installed state. Every result always explicitly returns `plugin.enabled: false`, including enabled local plugins, deduplicated plugins, and later remote-only pages; search reports discovery metadata rather than effective activation. Use `plugin/list` or `plugin/read` to determine whether a plugin is actually enabled. When `plugin_sharing` is disabled, shared/private workspace results are omitted after the remote page is fetched (**under development; do not call from production clients yet**). - `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Remote rows include nullable `installPolicySource` and `installedAt`, the backend installation timestamp in Unix seconds. `installedAt` is also returned by `plugin/list`, `plugin/read`, and `plugin/share/list`; it is `null` for local plugins, uninstalled plugins, plugins installed by default, and older backend responses that do not include an installation timestamp. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details can include scheduled task summaries from the catalog; `scheduledTasks: null` means the metadata is unavailable, while an empty array means the catalog found no scheduled tasks. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. For owned workspace plugins, `summary.shareContext.canPublishToWorkspace` reports whether the current user may add the plugin to the workspace directory; `plugin/share/save` returns the same capability after creating or updating a share, and clients should fail closed when either value is `null`. Remote skill interfaces expose `iconSmallUrl` and `iconLargeUrl` when the catalog supplies icon URLs. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. diff --git a/codex-rs/app-server/src/request_processors/plugins/search.rs b/codex-rs/app-server/src/request_processors/plugins/search.rs index fcedd001565b..0e140cdbbaf2 100644 --- a/codex-rs/app-server/src/request_processors/plugins/search.rs +++ b/codex-rs/app-server/src/request_processors/plugins/search.rs @@ -3,11 +3,14 @@ use codex_app_server_protocol::PluginSearchParams; use codex_app_server_protocol::PluginSearchResponse; use codex_app_server_protocol::PluginSearchResult; use codex_app_server_protocol::PluginSearchScope; +use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::remote::RemotePluginSearchRequest; use codex_core_plugins::remote::search_remote_plugins; const DEFAULT_PLUGIN_SEARCH_LIMIT: u32 = 16; const MAX_PLUGIN_SEARCH_LIMIT: u32 = 1_000; +const MAX_LOCAL_PLUGIN_SEARCH_RESULTS: usize = 100; +const PLUGIN_SEARCH_NO_MATCH_RANK: usize = 6; impl PluginRequestProcessor { pub(crate) async fn plugin_search( @@ -26,7 +29,7 @@ impl PluginRequestProcessor { let PluginSearchParams { search_term, scope, - cwds: _, + cwds, cursor, limit, } = params; @@ -43,81 +46,312 @@ impl PluginRequestProcessor { if !config.features.enabled(Feature::Plugins) { return Ok(empty_response()); } - let scope = if config.features.enabled(Feature::RemotePlugin) { - scope - } else { - match scope { - None | Some(PluginSearchScope::Workspace) => Some(PluginSearchScope::Workspace), - Some(PluginSearchScope::Global | PluginSearchScope::Personal) => { - return Ok(empty_response()); - } - } - }; let plugin_sharing_enabled = config.features.enabled(Feature::PluginSharing); let auth = self.auth_manager.auth().await; if !self .workspace_codex_plugins_enabled(&config, auth.as_ref()) .await - || !auth - .as_ref() - .map(CodexAuth::api_auth_mode) - .is_some_and(DomainAuthMode::uses_codex_backend) { return Ok(empty_response()); } - let scope = scope.map(|scope| match scope { - PluginSearchScope::Global => RemotePluginScope::Global, - PluginSearchScope::Workspace => RemotePluginScope::Workspace, - PluginSearchScope::Personal => RemotePluginScope::User, - }); + let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode); + self.thread_manager + .plugins_manager() + .set_auth_mode(auth_mode); + let remote_plugin_enabled = config.features.enabled(Feature::RemotePlugin); + let use_remote_global_catalog = + remote_plugin_enabled && auth_mode.is_some_and(DomainAuthMode::uses_codex_backend); + let remote_scope = if remote_plugin_enabled { + Some(scope.map(|scope| match scope { + PluginSearchScope::Global => RemotePluginScope::Global, + PluginSearchScope::Workspace => RemotePluginScope::Workspace, + PluginSearchScope::Personal => RemotePluginScope::User, + })) + } else { + match scope { + None | Some(PluginSearchScope::Workspace) => { + Some(Some(RemotePluginScope::Workspace)) + } + Some(PluginSearchScope::Global | PluginSearchScope::Personal) => None, + } + }; let limit = limit .unwrap_or(DEFAULT_PLUGIN_SEARCH_LIMIT) .clamp(1, MAX_PLUGIN_SEARCH_LIMIT); - let page = search_remote_plugins( - &remote_plugin_service_config(&config), - auth.as_ref(), - RemotePluginSearchRequest { - query: search_term, - scope, - limit, - page_token: cursor.as_deref(), - }, - ) - .await - .map_err(|err| { - remote_plugin_catalog_error_to_jsonrpc(err, "search remote plugin catalog") - })?; - - let next_cursor = page.next_page_token; - let mut data = Vec::with_capacity(page.plugins.len()); - for plugin in page.plugins { - let plugin_id = PluginId::parse(&plugin.id).map_err(|err| { - internal_error(format!("invalid remote plugin search result id: {err}")) + let mut next_cursor = None; + let mut remote_results = Vec::new(); + if auth_mode.is_some_and(DomainAuthMode::uses_codex_backend) + && let Some(remote_scope) = remote_scope + { + let page = search_remote_plugins( + &remote_plugin_service_config(&config), + auth.as_ref(), + RemotePluginSearchRequest { + query: search_term, + scope: remote_scope, + limit, + page_token: cursor.as_deref(), + }, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "search remote plugin catalog") })?; - // NOTE: (brisebois) filter out plugins from the results that belong to "shared" - // marketplaces if plugin sharing is disabled. There is a chance that this filters - // out all results and returns an empty list to the client. Ideally this filtering - // would be done server-side to avoid this problem. - if !plugin_sharing_enabled - && matches!( - plugin_id.marketplace_name.as_str(), - REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME + next_cursor = page.next_page_token; + remote_results.reserve(page.plugins.len()); + for plugin in page.plugins { + let plugin_id = PluginId::parse(&plugin.id).map_err(|err| { + internal_error(format!("invalid remote plugin search result id: {err}")) + })?; + + // NOTE: (brisebois) filter out plugins from the results that belong to "shared" + // marketplaces if plugin sharing is disabled. There is a chance that this filters + // out all results and returns an empty list to the client. Ideally this filtering + // would be done server-side to avoid this problem. + if !plugin_sharing_enabled + && matches!( + plugin_id.marketplace_name.as_str(), + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME + ) + { + continue; + } + remote_results.push(plugin_search_result( + remote_plugin_summary_to_info(plugin), + plugin_id.marketplace_name, + /*marketplace_path*/ None, + )); + } + } + + // All local results are stitched into the first page; if + // we are not on the first page, don't even check local + if cursor.is_some() { + return Ok(PluginSearchResponse { + data: remote_results, + next_cursor, + }); + } + + let normalized_search_term = normalize_search_text(search_term); + let mut local_results = Vec::new(); + if scope != Some(PluginSearchScope::Workspace) { + let roots = cwds.unwrap_or_default(); + let plugins_input = config.plugins_config_input(); + let plugins_manager = self.thread_manager.plugins_manager(); + let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config) + .unwrap_or_else(|err| { + warn!( + error = %err.message, + "plugin/search could not load shared plugin identities" + ); + Default::default() + }); + let outcome = tokio::task::spawn_blocking(move || { + plugins_manager.list_marketplaces_for_config( + &plugins_input, + &roots, + /*include_openai_curated*/ !use_remote_global_catalog, ) + }) + .await + .map_err(|err| internal_error(format!("failed to list marketplace plugins: {err}")))? + .map_err(|err| Self::marketplace_error(err, "list marketplace plugins"))?; + + for error in outcome.errors { + warn!( + marketplace_path = %error.path.as_path().display(), + error = %error.message, + "plugin/search skipped a local marketplace that could not be loaded" + ); + } + + for marketplace in outcome.marketplaces { + if !marketplace_matches_search_scope(&marketplace.name, scope) { + continue; + } + + for plugin in marketplace.plugins { + let plugin = convert_configured_marketplace_plugin_to_plugin_summary( + plugin, + &shared_plugin_ids_by_local_path, + ); + local_results.push(plugin_search_result( + plugin, + marketplace.name.clone(), + Some(marketplace.path.clone()), + )); + } + } + } + + let mut data = Vec::with_capacity( + local_results.len().min(MAX_LOCAL_PLUGIN_SEARCH_RESULTS) + remote_results.len(), + ); + let mut local_matches = Vec::new(); + let mut remote_results = remote_results.into_iter().map(Some).collect::>(); + let mut seen_local_plugin_identities = HashSet::new(); + + for local_result in local_results { + let local_remote_plugin_id = + local_result.plugin.remote_plugin_id.as_deref().or_else(|| { + local_result + .plugin + .share_context + .as_ref() + .map(|context| context.remote_plugin_id.as_str()) + }); + let remote_result_index = remote_results.iter().position(|remote_result| { + remote_result.as_ref().is_some_and(|remote_result| { + local_remote_plugin_id.is_some_and(|remote_plugin_id| { + remote_result.plugin.remote_plugin_id.as_deref() == Some(remote_plugin_id) + }) || local_result.plugin.id == remote_result.plugin.id + || (is_openai_curated_marketplace_name(&local_result.marketplace_name) + && remote_result.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME + && local_result.plugin.name == remote_result.plugin.name) + }) + }); + + if remote_result_index.is_none() + && plugin_search_match_rank(&local_result.plugin, &normalized_search_term).is_none() { continue; } - data.push(PluginSearchResult { - plugin: remote_plugin_summary_to_info(plugin), - marketplace_name: plugin_id.marketplace_name, - marketplace_path: None, - }); + + let local_identity = local_remote_plugin_id.map_or_else( + || { + if is_openai_curated_marketplace_name(&local_result.marketplace_name) { + format!("curated:{}", local_result.plugin.name) + } else { + format!("plugin:{}", local_result.plugin.id) + } + }, + |remote_plugin_id| format!("remote:{remote_plugin_id}"), + ); + if !seen_local_plugin_identities.insert(local_identity) { + continue; + } + + if let Some(remote_result_index) = remote_result_index + && let Some(mut remote_result) = remote_results[remote_result_index].take() + { + remote_result.plugin.installed = local_result.plugin.installed; + remote_result.plugin.local_version = local_result.plugin.local_version; + data.push(remote_result); + } else { + local_matches.push(local_result); + } } + local_matches.sort_by_key(|result| { + plugin_search_match_rank(&result.plugin, &normalized_search_term) + .unwrap_or(PLUGIN_SEARCH_NO_MATCH_RANK) + }); + local_matches.truncate(MAX_LOCAL_PLUGIN_SEARCH_RESULTS); + data.extend(local_matches); + data.extend(remote_results.into_iter().flatten()); + data.sort_by_key(|result| { + plugin_search_match_rank(&result.plugin, &normalized_search_term) + .unwrap_or(PLUGIN_SEARCH_NO_MATCH_RANK) + }); + Ok(PluginSearchResponse { data, next_cursor }) } } + +/// Plugin discovery does not resolve effective activation, so all results explicitly report +/// `enabled: false`, regardless of their source, installation state, or page. +fn plugin_search_result( + mut plugin: PluginSummary, + marketplace_name: String, + marketplace_path: Option, +) -> PluginSearchResult { + plugin.enabled = false; + + PluginSearchResult { + plugin, + marketplace_name, + marketplace_path, + } +} + +fn marketplace_matches_search_scope( + marketplace_name: &str, + scope: Option, +) -> bool { + let is_built_in = is_openai_curated_marketplace_name(marketplace_name) + || matches!( + marketplace_name, + OPENAI_BUNDLED_MARKETPLACE_NAME + | "openai-bundled-alpha" + | "codex-official" + | "openai-curated-remote" + | "openai-primary-runtime" + ); + + match scope { + None => true, + Some(PluginSearchScope::Global) => is_built_in, + Some(PluginSearchScope::Workspace) => false, + Some(PluginSearchScope::Personal) => !is_built_in, + } +} + +fn normalize_search_text(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + for character in value.to_lowercase().chars() { + if character.is_alphanumeric() { + normalized.push(character); + } else if !normalized.is_empty() && !normalized.ends_with(' ') { + normalized.push(' '); + } + } + if normalized.ends_with(' ') { + normalized.pop(); + } + normalized +} + +fn plugin_search_match_rank(plugin: &PluginSummary, normalized_query: &str) -> Option { + if normalized_query.is_empty() { + return None; + } + + let visible_name = normalize_search_text( + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .unwrap_or_default(), + ); + let internal_name = normalize_search_text(&plugin.name); + let names = [&visible_name, &internal_name]; + let keywords = plugin + .keywords + .iter() + .map(|keyword| normalize_search_text(keyword)) + .collect::>(); + let joined_search_values = normalize_search_text(&format!( + "{internal_name} {visible_name} {}", + keywords.join(" ") + )); + + [ + visible_name == normalized_query, + internal_name == normalized_query, + names.iter().any(|name| name.starts_with(normalized_query)), + names.iter().any(|name| name.contains(normalized_query)), + keywords.iter().any(|keyword| keyword == normalized_query), + keywords + .iter() + .any(|keyword| keyword.contains(normalized_query)) + || joined_search_values.contains(normalized_query), + ] + .iter() + .position(|matches| *matches) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_search.rs b/codex-rs/app-server/tests/suite/v2/plugin_search.rs index 64bbd0b6e304..1ca2dde2f2d7 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_search.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_search.rs @@ -8,6 +8,9 @@ use codex_app_server_protocol::PluginSearchParams; use codex_app_server_protocol::PluginSearchResponse; use codex_app_server_protocol::PluginSearchScope; use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; +use codex_login::login_with_api_key; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; @@ -255,6 +258,567 @@ remote_plugin = false Ok(()) } +#[tokio::test] +async fn plugin_search_stitches_local_results_into_the_first_remote_page() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + std::fs::create_dir_all(codex_home.path().join(".tmp"))?; + std::fs::write( + codex_home + .path() + .join(".tmp/plugin-share-local-paths-v1.json"), + "{invalid json", + )?; + let overflow_plugin_names = (0..101) + .map(|index| format!("integration-{index:03}")) + .collect::>(); + let mut local_plugins = vec![ + LocalPluginFixture { + name: "calendar-notes", + display_name: "Calendar Notes", + keywords: &[], + description: "Take notes", + }, + LocalPluginFixture { + name: "integrations", + display_name: "Integrations", + keywords: &["calendar"], + description: "Connect services", + }, + LocalPluginFixture { + name: "task-sync", + display_name: "Task Sync", + keywords: &[], + description: "Sync calendar events", + }, + ]; + local_plugins.extend(overflow_plugin_names.iter().map(|name| LocalPluginFixture { + name, + display_name: name, + keywords: &["calendar"], + description: "Connect calendar services", + })); + local_plugins.push(LocalPluginFixture { + name: "calendar-priority", + display_name: "Calendar Priority", + keywords: &[], + description: "Prioritized calendar", + }); + let marketplace_path = write_local_marketplace( + repo_root.path(), + "personal-tools", + "marketplace.json", + &local_plugins, + )?; + write_remote_plugin_search_config(codex_home.path(), &server, /*remote_plugin*/ true)?; + write_chatgpt_search_auth(codex_home.path())?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/search")) + .and(query_param("q", "calendar")) + .and(query_param("limit", "1")) + .and(query_param_is_missing("pageToken")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [ + remote_plugin_json("remote-exact", "calendar", "GLOBAL", /*discoverability*/ None), + remote_plugin_json( + "remote-substring", + "connected-calendar", + "GLOBAL", + /*discoverability*/ None, + ), + ], + "pagination": {"next_page_token": "next-page"}, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/search")) + .and(query_param("q", "calendar")) + .and(query_param("limit", "1")) + .and(query_param("pageToken", "next-page")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [remote_plugin_json( + "remote-later", + "calendar-later", + "GLOBAL", + /*discoverability*/ None, + )], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + let roots = vec![AbsolutePathBuf::try_from(repo_root.path())?]; + let request_id = app_server + .send_plugin_search_request(PluginSearchParams { + search_term: "calendar".to_string(), + scope: None, + cwds: Some(roots.clone()), + cursor: None, + limit: Some(1), + }) + .await?; + let response: PluginSearchResponse = + timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??; + + assert_eq!(response.next_cursor.as_deref(), Some("next-page")); + let mut expected_results = vec![ + ("calendar", None, false), + ("calendar-notes", Some(&marketplace_path), false), + ("calendar-priority", Some(&marketplace_path), false), + ("connected-calendar", None, false), + ("integrations", Some(&marketplace_path), false), + ]; + expected_results.extend( + overflow_plugin_names + .iter() + .take(/*n*/ 97) + .map(|name| (name.as_str(), Some(&marketplace_path), false)), + ); + assert_eq!( + response + .data + .iter() + .map(|result| ( + result.plugin.name.as_str(), + result.marketplace_path.as_ref(), + result.plugin.enabled, + )) + .collect::>(), + expected_results + ); + + let request_id = app_server + .send_plugin_search_request(PluginSearchParams { + search_term: "calendar".to_string(), + scope: None, + cwds: Some(roots), + cursor: response.next_cursor, + limit: Some(1), + }) + .await?; + let response: PluginSearchResponse = + timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??; + + assert_eq!(response.next_cursor, None); + assert_eq!( + response + .data + .iter() + .map(|result| (result.plugin.name.as_str(), result.plugin.enabled)) + .collect::>(), + vec![("calendar-later", false)] + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_search_returns_local_matches_for_api_key_auth() -> Result<()> { + for remote_plugin_enabled in [false, true] { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let curated_root = codex_home.path().join(".tmp/plugins"); + let bundled_alpha_root = codex_home + .path() + .join(".tmp/bundled-marketplaces/openai-bundled-alpha"); + let server = MockServer::start().await; + write_local_marketplace( + &curated_root, + "openai-api-curated", + "api_marketplace.json", + &[LocalPluginFixture { + name: "calendar-built-in", + display_name: "Calendar Built In", + keywords: &[], + description: "Built-in calendar", + }], + )?; + write_local_marketplace( + repo_root.path(), + "personal-tools", + "marketplace.json", + &[ + LocalPluginFixture { + name: "developer-tools", + display_name: "Developer Tools", + keywords: &["api-key"], + description: "Manage credentials", + }, + LocalPluginFixture { + name: "japanese-notes", + display_name: "日本語メモ", + keywords: &[], + description: "Japanese notes", + }, + ], + )?; + write_local_marketplace( + &bundled_alpha_root, + "openai-bundled-alpha", + "marketplace.json", + &[LocalPluginFixture { + name: "alpha-calendar", + display_name: "Alpha Calendar", + keywords: &[], + description: "Built-in alpha calendar", + }], + )?; + write_remote_plugin_search_config(codex_home.path(), &server, remote_plugin_enabled)?; + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + + for (scope, search_term, expected_names) in [ + ( + Some(PluginSearchScope::Global), + "calendar", + vec!["calendar-built-in", "alpha-calendar"], + ), + ( + Some(PluginSearchScope::Personal), + "API key", + vec!["developer-tools"], + ), + ( + Some(PluginSearchScope::Personal), + "日本語", + vec!["japanese-notes"], + ), + (Some(PluginSearchScope::Personal), "calendar", Vec::new()), + (Some(PluginSearchScope::Workspace), "calendar", Vec::new()), + ( + None, + "calendar", + vec!["calendar-built-in", "alpha-calendar"], + ), + (None, "!!!", Vec::new()), + ] { + let request_id = app_server + .send_plugin_search_request(PluginSearchParams { + search_term: search_term.to_string(), + scope, + cwds: Some(vec![ + AbsolutePathBuf::try_from(repo_root.path())?, + AbsolutePathBuf::try_from(bundled_alpha_root.as_path())?, + ]), + cursor: None, + limit: None, + }) + .await?; + let response: PluginSearchResponse = + timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??; + + assert_eq!( + response + .data + .iter() + .map(|result| result.plugin.name.as_str()) + .collect::>(), + expected_names + ); + assert_eq!(response.next_cursor, None); + } + + assert!( + server + .received_requests() + .await + .expect("wiremock should record requests") + .is_empty() + ); + } + Ok(()) +} + +#[tokio::test] +async fn plugin_search_deduplicates_shared_remote_identities_while_ignoring_local_curated_plugins() +-> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let curated_root = codex_home.path().join(".tmp/plugins"); + let server = MockServer::start().await; + write_local_marketplace( + &curated_root, + "openai-curated", + "marketplace.json", + &[ + LocalPluginFixture { + name: "calendar", + display_name: "Calendar", + keywords: &[], + description: "Built-in calendar", + }, + LocalPluginFixture { + name: "calendar-stale", + display_name: "Stale Calendar", + keywords: &[], + description: "Removed from the remote curated catalog", + }, + ], + )?; + let personal_marketplace_path = write_local_marketplace( + repo_root.path(), + "personal-tools", + "marketplace.json", + &[ + LocalPluginFixture { + name: "local-planner", + display_name: "Local Planner", + keywords: &[], + description: "Shared calendar", + }, + LocalPluginFixture { + name: "calendar-local-only", + display_name: "Calendar Local Only", + keywords: &[], + description: "Enabled local calendar", + }, + ], + )?; + let shared_plugin_path = repo_root.path().join("plugins/local-planner"); + std::fs::create_dir_all(codex_home.path().join(".tmp"))?; + std::fs::write( + codex_home + .path() + .join(".tmp/plugin-share-local-paths-v1.json"), + serde_json::to_string(&json!({ + "localPluginPathsByRemotePluginId": { + "remote-shared": shared_plugin_path, + }, + }))?, + )?; + write_installed_plugin(codex_home.path(), "openai-curated", "calendar")?; + write_installed_plugin(codex_home.path(), "personal-tools", "local-planner")?; + write_installed_plugin(codex_home.path(), "personal-tools", "calendar-local-only")?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = true + +[plugins."calendar@openai-curated"] +enabled = true + +[plugins."local-planner@personal-tools"] +enabled = true + +[plugins."calendar-local-only@personal-tools"] +enabled = true +"#, + server.uri() + ), + )?; + write_chatgpt_search_auth(codex_home.path())?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/search")) + .and(query_param("q", "calendar")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [ + remote_plugin_json( + "remote-curated", + "calendar", + "GLOBAL", + /*discoverability*/ None, + ), + remote_plugin_json( + "remote-shared", + "remote-calendar", + "WORKSPACE", + Some("LISTED"), + ), + ], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + let request_id = app_server + .send_plugin_search_request(PluginSearchParams { + search_term: "calendar".to_string(), + scope: None, + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + cursor: None, + limit: None, + }) + .await?; + let raw_response: serde_json::Value = + timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??; + assert_eq!( + raw_response["data"] + .as_array() + .expect("plugin/search should return a data array") + .iter() + .map(|result| result["plugin"] + .get("enabled") + .and_then(serde_json::Value::as_bool)) + .collect::>(), + vec![Some(false), Some(false), Some(false)] + ); + let response: PluginSearchResponse = serde_json::from_value(raw_response)?; + + assert_eq!( + response + .data + .iter() + .map(|result| ( + result.plugin.id.as_str(), + result.plugin.installed, + result.plugin.enabled, + result.marketplace_path.as_ref(), + )) + .collect::>(), + vec![ + ("calendar@openai-curated-remote", false, false, None), + ( + "calendar-local-only@personal-tools", + true, + false, + Some(&personal_marketplace_path), + ), + ("remote-calendar@workspace-directory", true, false, None), + ] + ); + Ok(()) +} + +struct LocalPluginFixture<'a> { + name: &'a str, + display_name: &'a str, + keywords: &'a [&'a str], + description: &'a str, +} + +fn write_local_marketplace( + root: &std::path::Path, + marketplace_name: &str, + manifest_name: &str, + plugins: &[LocalPluginFixture<'_>], +) -> Result { + std::fs::create_dir_all(root.join(".git"))?; + std::fs::create_dir_all(root.join(".agents/plugins"))?; + let marketplace_path = root.join(".agents/plugins").join(manifest_name); + let entries = plugins + .iter() + .map(|plugin| { + json!({ + "name": plugin.name, + "source": { + "source": "local", + "path": format!("./plugins/{}", plugin.name), + }, + }) + }) + .collect::>(); + std::fs::write( + &marketplace_path, + serde_json::to_string(&json!({ + "name": marketplace_name, + "plugins": entries, + }))?, + )?; + + for plugin in plugins { + let plugin_manifest = root.join("plugins").join(plugin.name).join(".codex-plugin"); + std::fs::create_dir_all(&plugin_manifest)?; + std::fs::write( + plugin_manifest.join("plugin.json"), + serde_json::to_string(&json!({ + "name": plugin.name, + "keywords": plugin.keywords, + "interface": { + "displayName": plugin.display_name, + "shortDescription": plugin.description, + }, + }))?, + )?; + } + + Ok(AbsolutePathBuf::try_from(marketplace_path)?) +} + +fn write_remote_plugin_search_config( + codex_home: &std::path::Path, + server: &MockServer, + remote_plugin: bool, +) -> Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = {remote_plugin} +"#, + server.uri() + ), + )?; + Ok(()) +} + +fn write_chatgpt_search_auth(codex_home: &std::path::Path) -> Result<()> { + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + Ok(()) +} + +fn write_installed_plugin( + codex_home: &std::path::Path, + marketplace_name: &str, + plugin_name: &str, +) -> Result<()> { + let plugin_manifest = codex_home + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join("1.2.3") + .join(".codex-plugin"); + std::fs::create_dir_all(&plugin_manifest)?; + std::fs::write( + plugin_manifest.join("plugin.json"), + serde_json::to_string(&json!({ + "name": plugin_name, + "version": "1.2.3", + "interface": {"displayName": plugin_name}, + }))?, + )?; + Ok(()) +} + fn remote_plugin_json( remote_plugin_id: &str, plugin_name: &str,