mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
## 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
278 lines
8.5 KiB
Rust
278 lines
8.5 KiB
Rust
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use codex_app_server_protocol::PluginSearchParams;
|
|
use codex_app_server_protocol::PluginSearchResponse;
|
|
use codex_app_server_protocol::PluginSearchScope;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
use wiremock::matchers::query_param;
|
|
use wiremock::matchers::query_param_is_missing;
|
|
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
#[tokio::test]
|
|
async fn plugin_search_omits_shared_workspace_results_when_plugin_sharing_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
plugin_sharing = false
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/search"))
|
|
.and(query_param("q", "linear"))
|
|
.and(query_param("limit", "16"))
|
|
.and(query_param("pageToken", "incoming-token"))
|
|
.and(query_param_is_missing("scope"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"plugins": [
|
|
remote_plugin_json(
|
|
"plugin-global",
|
|
"global-linear",
|
|
"GLOBAL",
|
|
/*discoverability*/ None,
|
|
),
|
|
remote_plugin_json(
|
|
"plugin-user",
|
|
"personal-linear",
|
|
"USER",
|
|
/*discoverability*/ None,
|
|
),
|
|
remote_plugin_json(
|
|
"plugin-listed",
|
|
"listed-linear",
|
|
"WORKSPACE",
|
|
/*discoverability*/ Some("LISTED"),
|
|
),
|
|
remote_plugin_json(
|
|
"plugin-private",
|
|
"private-linear",
|
|
"WORKSPACE",
|
|
/*discoverability*/ Some("PRIVATE"),
|
|
),
|
|
remote_plugin_json(
|
|
"plugin-unlisted",
|
|
"unlisted-linear",
|
|
"WORKSPACE",
|
|
/*discoverability*/ Some("UNLISTED"),
|
|
),
|
|
],
|
|
"pagination": {"next_page_token": "outgoing-token"},
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_search_request(PluginSearchParams {
|
|
search_term: "linear".to_string(),
|
|
scope: None,
|
|
cwds: None,
|
|
cursor: Some("incoming-token".to_string()),
|
|
limit: None,
|
|
})
|
|
.await?;
|
|
let response: PluginSearchResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.next_cursor.as_deref(), Some("outgoing-token"));
|
|
assert_eq!(
|
|
response
|
|
.data
|
|
.iter()
|
|
.map(|result| { (result.marketplace_name.as_str(), result.plugin.id.as_str(),) })
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(
|
|
"openai-curated-remote",
|
|
"global-linear@openai-curated-remote"
|
|
),
|
|
(
|
|
"created-by-me-remote",
|
|
"personal-linear@created-by-me-remote"
|
|
),
|
|
("workspace-directory", "listed-linear@workspace-directory"),
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_search_only_searches_workspace_when_remote_plugin_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/search"))
|
|
.and(query_param("q", "linear"))
|
|
.and(query_param("scope", "WORKSPACE"))
|
|
.and(query_param("limit", "16"))
|
|
.and(query_param_is_missing("pageToken"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"plugins": [remote_plugin_json(
|
|
"plugin-workspace",
|
|
"workspace-linear",
|
|
"WORKSPACE",
|
|
/*discoverability*/ Some("LISTED"),
|
|
)],
|
|
"pagination": {"next_page_token": null},
|
|
})))
|
|
.expect(2)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.without_auto_env()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
for scope in [None, Some(PluginSearchScope::Workspace)] {
|
|
let request_id = mcp
|
|
.send_plugin_search_request(PluginSearchParams {
|
|
search_term: "linear".to_string(),
|
|
scope,
|
|
cwds: None,
|
|
cursor: None,
|
|
limit: None,
|
|
})
|
|
.await?;
|
|
let response: PluginSearchResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(
|
|
response
|
|
.data
|
|
.iter()
|
|
.map(|result| (result.marketplace_name.as_str(), result.plugin.id.as_str(),))
|
|
.collect::<Vec<_>>(),
|
|
vec![(
|
|
"workspace-directory",
|
|
"workspace-linear@workspace-directory",
|
|
)]
|
|
);
|
|
}
|
|
|
|
for scope in [PluginSearchScope::Global, PluginSearchScope::Personal] {
|
|
let request_id = mcp
|
|
.send_plugin_search_request(PluginSearchParams {
|
|
search_term: "linear".to_string(),
|
|
scope: Some(scope),
|
|
cwds: None,
|
|
cursor: None,
|
|
limit: None,
|
|
})
|
|
.await?;
|
|
let response: PluginSearchResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginSearchResponse {
|
|
data: Vec::new(),
|
|
next_cursor: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
let search_requests = server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.into_iter()
|
|
.filter(|request| request.url.path() == "/backend-api/ps/plugins/search")
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(search_requests.len(), 2);
|
|
assert_eq!(
|
|
search_requests
|
|
.iter()
|
|
.filter_map(|request| {
|
|
request
|
|
.url
|
|
.query_pairs()
|
|
.find(|(name, _value)| name == "scope")
|
|
.map(|(_name, value)| value.into_owned())
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec!["WORKSPACE", "WORKSPACE"]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn remote_plugin_json(
|
|
remote_plugin_id: &str,
|
|
plugin_name: &str,
|
|
scope: &str,
|
|
discoverability: Option<&str>,
|
|
) -> serde_json::Value {
|
|
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": {},
|
|
},
|
|
})
|
|
}
|