Implement remote plugin search (#36409)

## What changed

- Implement `plugin/search` by querying the remote plugin service without using the catalog cache.
- Support global, workspace, and personal scopes with bounded page sizes and passthrough cursors.
- Respect plugin feature gates and omit shared workspace results when plugin sharing is disabled.
- Keep search terms and pagination tokens out of transport errors and telemetry, and return search results as uninstalled plugin summaries.

## Testing

- Add remote search coverage for request parameters, result conversion, authentication, pagination, scope mapping, and error redaction.
- Add app-server coverage for remote-plugin and plugin-sharing feature gates.

GitOrigin-RevId: ac29c5480ed8089d998b6275bc5f11c8d9a43fd1
This commit is contained in:
Greg Brisebois
2026-08-01 00:17:45 +00:00
committed by copyberry
parent 670f69416b
commit a850875a8e
6 changed files with 947 additions and 15 deletions
@@ -0,0 +1,91 @@
use super::RemotePluginCatalogError;
use super::RemotePluginListResponse;
use super::RemotePluginScope;
use super::RemotePluginServiceConfig;
use super::RemotePluginSummary;
use super::authenticated_request;
use super::build_remote_plugin_summary;
use super::ensure_chatgpt_auth;
use super::send_and_decode;
use codex_login::CodexAuth;
use http::Method;
use tracing::instrument;
use url::Url;
/// Search parameters forwarded directly to plugin-service.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RemotePluginSearchRequest<'a> {
pub query: &'a str,
pub scope: Option<RemotePluginScope>,
pub limit: u32,
pub page_token: Option<&'a str>,
}
/// One uncached page of remote plugin search results.
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginSearchPage {
pub plugins: Vec<RemotePluginSummary>,
pub next_page_token: Option<String>,
}
/// Searches plugin-service without reading or populating the remote catalog cache.
#[instrument(
level = "debug",
skip_all,
fields(plugin.scope = ?search.scope, plugin.limit = search.limit)
)]
pub async fn search_remote_plugins(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
search: RemotePluginSearchRequest<'_>,
) -> Result<RemotePluginSearchPage, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let mut url = Url::parse(&format!("{base_url}/ps/plugins/search"))
.map_err(RemotePluginCatalogError::InvalidBaseUrl)?;
// Search terms and page tokens can contain user data. Keep the queryless endpoint for
// diagnostics so neither value is exposed through errors or telemetry.
let url_for_error = url.to_string();
{
let mut query_pairs = url.query_pairs_mut();
query_pairs.append_pair("q", search.query);
if let Some(scope) = search.scope {
query_pairs.append_pair("scope", scope.api_value());
}
query_pairs.append_pair("limit", &search.limit.to_string());
if let Some(page_token) = search.page_token {
query_pairs.append_pair("pageToken", page_token);
}
}
let url = url.to_string();
let request = authenticated_request(config.http_request(Method::GET, &url), auth);
let response: RemotePluginListResponse = send_and_decode(request, &url_for_error)
.await
.map_err(|error| match error {
RemotePluginCatalogError::Request { url, source } => {
RemotePluginCatalogError::Request {
url,
source: source.without_url(),
}
}
other => other,
})?;
let plugins = response
.plugins
.iter()
// Search intentionally does not join against `/ps/plugins/installed`, so these
// summaries always report `installed: false`.
.map(|plugin| build_remote_plugin_summary(plugin, /*installed_plugin*/ None))
.collect::<Result<Vec<_>, _>>()?;
Ok(RemotePluginSearchPage {
plugins,
next_page_token: response.pagination.next_page_token,
})
}
#[cfg(test)]
#[path = "search_tests.rs"]
mod tests;
@@ -0,0 +1,490 @@
use super::*;
use crate::remote::REMOTE_CREATED_BY_ME_MARKETPLACE_NAME;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME;
use crate::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
use crate::remote::RemotePluginShareDiscoverability;
use crate::test_support::recorded_http_client_urls;
use crate::test_support::recording_remote_plugin_service_config;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginDisabledReason;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInstallPolicySource;
use codex_app_server_protocol::PluginInterface;
use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::header_exists;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
use wiremock::matchers::query_param_is_missing;
fn remote_plugin_json(remote_plugin_id: &str, plugin_name: &str, scope: &str) -> serde_json::Value {
let discoverability = (scope == "WORKSPACE").then_some("LISTED");
json!({
"id": remote_plugin_id,
"name": plugin_name,
"scope": scope,
"discoverability": discoverability,
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": plugin_name,
"description": format!("{plugin_name} description"),
"interface": {},
},
})
}
#[tokio::test]
async fn search_remote_plugins_forwards_parameters_and_converts_results() {
let server = MockServer::start().await;
let remote_plugin = json!({
"id": "plugins~Plugin_linear",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "NOT_AVAILABLE",
"installation_policy_source": "WORKSPACE_SETTING",
"must_show_installation_interstitial": true,
"authentication_policy": "ON_INSTALL",
"status": "DISABLED_BY_ADMIN",
"disabled_reason": "plan_not_eligible",
"eligible_plan_types": ["pro"],
"release": {
"version": "1.2.3",
"display_name": "Linear",
"description": "Track issues",
"keywords": ["issues"],
"interface": {
"short_description": "Issue tracking",
"category": "Productivity",
"capabilities": ["Create issues"],
"logo_url": "https://example.com/linear.png",
},
},
});
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/search"))
.and(query_param("q", "linear & docs/+"))
.and(query_param("scope", "GLOBAL"))
.and(query_param("limit", "16"))
.and(query_param("pageToken", "next page/+"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(header("oai-product-sku", "codex"))
.and(header_exists("user-agent"))
.and(header_exists("originator"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin],
"pagination": {"next_page_token": "later page/+"},
})))
.expect(1)
.mount(&server)
.await;
let (config, selected_urls) =
recording_remote_plugin_service_config(format!("{}/backend-api/", server.uri()));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let result = search_remote_plugins(
&config,
Some(&auth),
RemotePluginSearchRequest {
query: "linear & docs/+",
scope: Some(RemotePluginScope::Global),
limit: 16,
page_token: Some("next page/+"),
},
)
.await
.expect("plugin search should succeed");
assert_eq!(
result,
RemotePluginSearchPage {
plugins: vec![RemotePluginSummary {
id: format!("linear@{REMOTE_GLOBAL_MARKETPLACE_NAME}"),
remote_plugin_id: "plugins~Plugin_linear".to_string(),
version: Some("1.2.3".to_string()),
local_version: None,
name: "linear".to_string(),
share_context: None,
installed: false,
installed_at: None,
enabled: false,
install_policy: PluginInstallPolicy::NotAvailable,
install_policy_source: Some(PluginInstallPolicySource::WorkspaceSetting),
must_show_installation_interstitial: Some(true),
auth_policy: PluginAuthPolicy::OnInstall,
availability: PluginAvailability::DisabledByAdmin,
disabled_reason: Some(PluginDisabledReason::PlanNotEligible),
eligible_plan_types: Some(vec!["pro".to_string()]),
interface: Some(PluginInterface {
display_name: Some("Linear".to_string()),
short_description: Some("Issue tracking".to_string()),
long_description: None,
developer_name: None,
category: Some("Productivity".to_string()),
capabilities: vec!["Create issues".to_string()],
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_dark: None,
logo_url: Some("https://example.com/linear.png".to_string()),
logo_url_dark: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
}),
keywords: vec!["issues".to_string()],
}],
next_page_token: Some("later page/+".to_string()),
}
);
assert_eq!(
recorded_http_client_urls(&selected_urls),
vec![format!(
"{}/backend-api/ps/plugins/search?q=linear+%26+docs%2F%2B&scope=GLOBAL&limit=16&pageToken=next+page%2F%2B",
server.uri()
)]
);
}
#[tokio::test]
async fn search_remote_plugins_omits_optional_scope_and_page_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/search"))
.and(query_param("q", "calendar"))
.and(query_param("limit", "25"))
.and(query_param_is_missing("scope"))
.and(query_param_is_missing("pageToken"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [],
"pagination": {"next_page_token": null},
})))
.expect(2)
.mount(&server)
.await;
let (config, _) =
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
for _ in 0..2 {
let result = search_remote_plugins(
&config,
Some(&auth),
RemotePluginSearchRequest {
query: "calendar",
scope: None,
limit: 25,
page_token: None,
},
)
.await
.expect("unscoped plugin search should succeed");
assert_eq!(
result,
RemotePluginSearchPage {
plugins: Vec::new(),
next_page_token: None,
}
);
}
}
#[tokio::test]
async fn search_remote_plugins_forwards_each_supported_scope() {
let server = MockServer::start().await;
let (config, _) =
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
for (scope, expected_scope) in [
(RemotePluginScope::Global, "GLOBAL"),
(RemotePluginScope::User, "USER"),
(RemotePluginScope::Workspace, "WORKSPACE"),
] {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/search"))
.and(query_param("scope", expected_scope))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [],
"pagination": {"next_page_token": null},
})))
.expect(1)
.mount(&server)
.await;
search_remote_plugins(
&config,
Some(&auth),
RemotePluginSearchRequest {
query: "calendar",
scope: Some(scope),
limit: 16,
page_token: None,
},
)
.await
.expect("scoped plugin search should succeed");
}
}
#[tokio::test]
async fn search_remote_plugins_preserves_order_and_canonical_marketplaces() {
let server = MockServer::start().await;
let mut shared_plugin = remote_plugin_json("plugin-shared", "shared", "WORKSPACE");
shared_plugin["discoverability"] = json!("PRIVATE");
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [
remote_plugin_json("plugin-user", "personal", "USER"),
remote_plugin_json("plugin-global", "global", "GLOBAL"),
remote_plugin_json("plugin-workspace", "workspace", "WORKSPACE"),
shared_plugin,
],
"pagination": {"next_page_token": null},
})))
.expect(1)
.mount(&server)
.await;
let (config, _) =
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let result = search_remote_plugins(
&config,
Some(&auth),
RemotePluginSearchRequest {
query: "plugin",
scope: None,
limit: 16,
page_token: None,
},
)
.await
.expect("mixed-scope plugin search should succeed");
let identities = result
.plugins
.into_iter()
.map(|plugin| {
(
plugin.id,
plugin.remote_plugin_id,
plugin.share_context.map(|context| context.discoverability),
)
})
.collect::<Vec<_>>();
assert_eq!(
identities,
vec![
(
format!("personal@{REMOTE_CREATED_BY_ME_MARKETPLACE_NAME}"),
"plugin-user".to_string(),
None,
),
(
format!("global@{REMOTE_GLOBAL_MARKETPLACE_NAME}"),
"plugin-global".to_string(),
None,
),
(
format!("workspace@{REMOTE_WORKSPACE_MARKETPLACE_NAME}"),
"plugin-workspace".to_string(),
Some(RemotePluginShareDiscoverability::Listed),
),
(
format!("shared@{REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME}"),
"plugin-shared".to_string(),
Some(RemotePluginShareDiscoverability::Private),
),
]
);
}
#[tokio::test]
async fn search_remote_plugins_requires_chatgpt_authentication() {
let (config, selected_urls) =
recording_remote_plugin_service_config("https://chatgpt.example/backend-api".to_string());
let result = search_remote_plugins(
&config,
/*auth*/ None,
RemotePluginSearchRequest {
query: "calendar",
scope: None,
limit: 16,
page_token: None,
},
)
.await;
assert!(matches!(
result,
Err(RemotePluginCatalogError::AuthRequired)
));
assert_eq!(
recorded_http_client_urls(&selected_urls),
Vec::<String>::new()
);
}
#[tokio::test]
async fn search_remote_plugins_rejects_api_key_authentication() {
let (config, selected_urls) =
recording_remote_plugin_service_config("https://chatgpt.example/backend-api".to_string());
let auth = CodexAuth::from_api_key("test-api-key");
let result = search_remote_plugins(
&config,
Some(&auth),
RemotePluginSearchRequest {
query: "calendar",
scope: Some(RemotePluginScope::Global),
limit: 16,
page_token: None,
},
)
.await;
assert!(matches!(
result,
Err(RemotePluginCatalogError::UnsupportedAuthMode)
));
assert_eq!(
recorded_http_client_urls(&selected_urls),
Vec::<String>::new()
);
}
#[tokio::test]
async fn search_remote_plugins_redacts_sensitive_parameters_from_transport_errors() {
let listener = std::net::TcpListener::bind("127.0.0.1:0")
.expect("test listener should bind to a local port");
let address = listener
.local_addr()
.expect("test listener should have a local address");
let connection = std::thread::spawn(move || {
let (stream, _) = listener
.accept()
.expect("test listener should accept the plugin search request");
drop(stream);
});
let (config, _) =
recording_remote_plugin_service_config(format!("http://{address}/backend-api"));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let error = search_remote_plugins(
&config,
Some(&auth),
RemotePluginSearchRequest {
query: "sensitive search term",
scope: Some(RemotePluginScope::Global),
limit: 16,
page_token: Some("sensitive pagination token"),
},
)
.await
.expect_err("closed connection should fail the plugin search request");
connection
.join()
.expect("test listener should close the accepted connection");
let error_message = error.to_string();
assert!(!error_message.contains("sensitive search term"));
assert!(!error_message.contains("sensitive pagination token"));
let RemotePluginCatalogError::Request { url, source } = error else {
panic!("expected transport request error");
};
assert_eq!(
url,
format!("http://{address}/backend-api/ps/plugins/search")
);
assert!(!source.to_string().contains("sensitive"));
}
#[tokio::test]
async fn search_remote_plugins_preserves_upstream_http_errors() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/search"))
.respond_with(ResponseTemplate::new(503).set_body_string("plugin search unavailable"))
.expect(1)
.mount(&server)
.await;
let (config, _) =
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let result = search_remote_plugins(
&config,
Some(&auth),
RemotePluginSearchRequest {
query: "sensitive search term",
scope: Some(RemotePluginScope::Global),
limit: 16,
page_token: Some("sensitive pagination token"),
},
)
.await;
let error = result.expect_err("upstream HTTP status should fail");
let error_message = error.to_string();
assert!(!error_message.contains("sensitive search term"));
assert!(!error_message.contains("sensitive pagination token"));
let RemotePluginCatalogError::UnexpectedStatus { url, status, body } = error else {
panic!("expected upstream HTTP status error");
};
assert_eq!(
(url, status, body),
(
format!("{}/backend-api/ps/plugins/search", server.uri()),
StatusCode::SERVICE_UNAVAILABLE,
"plugin search unavailable".to_string(),
)
);
}
#[tokio::test]
async fn search_remote_plugins_preserves_response_decode_errors() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/search"))
.respond_with(ResponseTemplate::new(200).set_body_string("not-json"))
.expect(1)
.mount(&server)
.await;
let (config, _) =
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let result = search_remote_plugins(
&config,
Some(&auth),
RemotePluginSearchRequest {
query: "calendar",
scope: None,
limit: 16,
page_token: None,
},
)
.await;
assert!(matches!(
result,
Err(RemotePluginCatalogError::Decode { .. })
));
}