Skip to content

Commit 1ae82ce

Browse files
mzeng-openaicopyberry
authored andcommitted
Fetch remote installed plugins across all scopes (#37210)
## What changed - Fetch the paginated installed-plugin snapshot without a `scope` query instead of issuing separate requests for global, user, and workspace plugins. - Use the combined snapshot for installed-plugin caching and bundle synchronization while continuing to reconcile each scope's marketplace independently. ## Testing - Cover pagination across mixed scopes, request query parameters, marketplace reconciliation, stale cache cleanup, and discoverable-plugin filtering. GitOrigin-RevId: 7428a9db81d7ec04e65cc4b236b652278b200956
1 parent 0a0ebb8 commit 1ae82ce

8 files changed

Lines changed: 562 additions & 529 deletions

File tree

codex-rs/app-server/tests/suite/v2/plugin_list.rs

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
use std::collections::BTreeMap;
2+
use std::sync::Mutex;
3+
use std::sync::OnceLock;
14
use std::time::Duration;
25

36
use anyhow::Result;
@@ -55,6 +58,9 @@ const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
5558
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
5659
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
5760
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
61+
type RemoteInstalledPluginFixtures = BTreeMap<String, BTreeMap<String, Vec<serde_json::Value>>>;
62+
static REMOTE_INSTALLED_PLUGIN_FIXTURES: OnceLock<Mutex<RemoteInstalledPluginFixtures>> =
63+
OnceLock::new();
5864

5965
fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> {
6066
std::fs::write(
@@ -3379,8 +3385,7 @@ plugin_sharing = true
33793385
)
33803386
]
33813387
);
3382-
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
3383-
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
3388+
wait_for_remote_installed_snapshot_request(&server).await?;
33843389
Ok(())
33853390
}
33863391

@@ -3466,8 +3471,7 @@ plugin_sharing = false
34663471
true
34673472
)]
34683473
);
3469-
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
3470-
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
3474+
wait_for_remote_installed_snapshot_request(&server).await?;
34713475
Ok(())
34723476
}
34733477

@@ -3553,7 +3557,7 @@ plugin_sharing = false
35533557
),
35543558
)
35553559
.await?;
3556-
wait_for_remote_installed_scope_request(&server, "USER").await?;
3560+
wait_for_remote_installed_snapshot_request(&server).await?;
35573561
Ok(())
35583562
}
35593563

@@ -4271,8 +4275,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
42714275
share_context.discoverability,
42724276
Some(PluginShareDiscoverability::Unlisted)
42734277
);
4274-
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
4275-
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
4278+
wait_for_remote_installed_snapshot_request(&server).await?;
42764279
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
42774280
Ok(())
42784281
}
@@ -4751,7 +4754,7 @@ async fn wait_for_remote_plugin_list_scope_request_count(
47514754
Ok(())
47524755
}
47534756

4754-
async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &str) -> Result<()> {
4757+
async fn wait_for_remote_installed_snapshot_request(server: &MockServer) -> Result<()> {
47554758
timeout(DEFAULT_TIMEOUT, async {
47564759
loop {
47574760
let Some(requests) = server.received_requests().await else {
@@ -4760,10 +4763,7 @@ async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &st
47604763
if requests.iter().any(|request| {
47614764
request.method == "GET"
47624765
&& request.url.path().ends_with("/ps/plugins/installed")
4763-
&& request
4764-
.url
4765-
.query_pairs()
4766-
.any(|(name, value)| name == "scope" && value == scope)
4766+
&& request.url.query_pairs().all(|(name, _)| name != "scope")
47674767
}) {
47684768
return Ok::<(), anyhow::Error>(());
47694769
}
@@ -4998,6 +4998,19 @@ async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) {
49984998
}
49994999

50005000
async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) {
5001+
let plugins = serde_json::from_str::<serde_json::Value>(body)
5002+
.expect("installed plugin fixture should be valid JSON")["plugins"]
5003+
.as_array()
5004+
.expect("installed plugin fixture should contain plugins")
5005+
.clone();
5006+
REMOTE_INSTALLED_PLUGIN_FIXTURES
5007+
.get_or_init(Default::default)
5008+
.lock()
5009+
.unwrap_or_else(std::sync::PoisonError::into_inner)
5010+
.entry(server.uri())
5011+
.or_default()
5012+
.insert(scope.to_string(), plugins);
5013+
50015014
Mock::given(method("GET"))
50025015
.and(path("/backend-api/ps/plugins/installed"))
50035016
.and(query_param("scope", scope))
@@ -5006,6 +5019,34 @@ async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body:
50065019
.respond_with(ResponseTemplate::new(200).set_body_string(body))
50075020
.mount(server)
50085021
.await;
5022+
5023+
let server_uri = server.uri();
5024+
Mock::given(method("GET"))
5025+
.and(path("/backend-api/ps/plugins/installed"))
5026+
.and(query_param_is_missing("scope"))
5027+
.and(header("authorization", "Bearer chatgpt-token"))
5028+
.and(header("chatgpt-account-id", "account-123"))
5029+
.respond_with(move |_request: &wiremock::Request| {
5030+
let fixtures = REMOTE_INSTALLED_PLUGIN_FIXTURES
5031+
.get()
5032+
.expect("installed plugin fixtures should exist")
5033+
.lock()
5034+
.unwrap_or_else(std::sync::PoisonError::into_inner);
5035+
let scoped_plugins = fixtures
5036+
.get(&server_uri)
5037+
.expect("installed plugin fixtures should exist for this server");
5038+
let plugins = ["GLOBAL", "WORKSPACE", "USER"]
5039+
.into_iter()
5040+
.flat_map(|scope| scoped_plugins.get(scope).into_iter().flatten())
5041+
.cloned()
5042+
.collect::<Vec<_>>();
5043+
ResponseTemplate::new(200).set_body_json(serde_json::json!({
5044+
"plugins": plugins,
5045+
"pagination": {"limit": 50, "next_page_token": null},
5046+
}))
5047+
})
5048+
.mount(server)
5049+
.await;
50095050
}
50105051

50115052
async fn mount_empty_user_installed_plugins(server: &MockServer) {

codex-rs/app-server/tests/suite/v2/skills_list.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use wiremock::matchers::header;
3737
use wiremock::matchers::method;
3838
use wiremock::matchers::path;
3939
use wiremock::matchers::query_param;
40+
use wiremock::matchers::query_param_is_missing;
4041

4142
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
4243
const WATCHER_TIMEOUT: Duration = Duration::from_secs(20);
@@ -579,6 +580,14 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result
579580
.mount(&server)
580581
.await;
581582
}
583+
Mock::given(method("GET"))
584+
.and(path("/backend-api/ps/plugins/installed"))
585+
.and(query_param_is_missing("scope"))
586+
.and(header("authorization", "Bearer chatgpt-token"))
587+
.and(header("chatgpt-account-id", "account-123"))
588+
.respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body))
589+
.mount(&server)
590+
.await;
582591

583592
let plugin_list_request_id = mcp
584593
.send_plugin_list_request(PluginListParams {

codex-rs/core-plugins/src/discoverable_tests.rs

Lines changed: 111 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ use wiremock::ResponseTemplate;
3333
use wiremock::matchers::method;
3434
use wiremock::matchers::path;
3535
use wiremock::matchers::query_param;
36+
use wiremock::matchers::query_param_is_missing;
3637

3738
#[tokio::test]
3839
async fn returns_fallback_plugins_when_remote_disabled_for_codex_auth() {
@@ -786,7 +787,7 @@ source = "/tmp/{sales_marketplace_name}"
786787
}
787788

788789
#[tokio::test]
789-
async fn expands_cached_remote_plugins_by_loaded_apps() {
790+
async fn cached_remote_discovery_requires_installed_cache_and_filters_candidates() {
790791
let codex_home = tempdir().expect("tempdir should succeed");
791792
write_file(
792793
&codex_home.path().join(CONFIG_TOML_FILE),
@@ -801,6 +802,25 @@ plugins = true
801802
.and(query_param("scope", "GLOBAL"))
802803
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
803804
"plugins": [
805+
{
806+
"id": "plugins~Plugin_remote_github",
807+
"name": "github",
808+
"scope": "GLOBAL",
809+
"installation_policy": "AVAILABLE",
810+
"authentication_policy": "ON_USE",
811+
"status": "AVAILABLE",
812+
"release": {
813+
"display_name": "Remote GitHub",
814+
"description": "Remote GitHub long",
815+
"app_ids": ["github"],
816+
"interface": {"short_description": "Remote GitHub short"},
817+
"skills": [{
818+
"name": "github",
819+
"description": "Use GitHub",
820+
"interface": null
821+
}]
822+
}
823+
},
804824
{
805825
"id": "plugins~Plugin_remote_unlisted",
806826
"name": "remote-unlisted",
@@ -835,6 +855,32 @@ plugins = true
835855
}
836856
]
837857
}
858+
},
859+
{
860+
"id": "plugins~Plugin_remote_slack_not_available",
861+
"name": "slack",
862+
"scope": "GLOBAL",
863+
"installation_policy": "NOT_AVAILABLE",
864+
"authentication_policy": "ON_USE",
865+
"status": "AVAILABLE",
866+
"release": {
867+
"display_name": "Remote Slack",
868+
"description": "Remote Slack long",
869+
"interface": {"short_description": "Remote Slack short"}
870+
}
871+
},
872+
{
873+
"id": "plugins~Plugin_remote_figma_admin_disabled",
874+
"name": "figma",
875+
"scope": "GLOBAL",
876+
"installation_policy": "AVAILABLE",
877+
"authentication_policy": "ON_USE",
878+
"status": "DISABLED_BY_ADMIN",
879+
"release": {
880+
"display_name": "Remote Figma",
881+
"description": "Remote Figma long",
882+
"interface": {"short_description": "Remote Figma short"}
883+
}
838884
}
839885
],
840886
"pagination": {
@@ -860,20 +906,29 @@ plugins = true
860906
.await
861907
.expect("remote plugin catalog cache should write");
862908

863-
for scope in ["GLOBAL", "USER", "WORKSPACE"] {
864-
Mock::given(method("GET"))
865-
.and(path("/backend-api/ps/plugins/installed"))
866-
.and(query_param("scope", scope))
867-
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
868-
"plugins": [],
869-
"pagination": {
870-
"next_page_token": null
871-
}
872-
})))
873-
.expect(1)
874-
.mount(&server)
875-
.await;
876-
}
909+
assert_eq!(
910+
list_discoverable_plugins(
911+
&plugins_manager,
912+
discovery_input(plugins.clone(), &[], &[], &["remote-unlisted-app"]),
913+
Some(&auth),
914+
)
915+
.await,
916+
Vec::new()
917+
);
918+
919+
Mock::given(method("GET"))
920+
.and(path("/backend-api/ps/plugins/installed"))
921+
.and(query_param_is_missing("scope"))
922+
.and(query_param_is_missing("includeDownloadUrls"))
923+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
924+
"plugins": [],
925+
"pagination": {
926+
"next_page_token": null
927+
}
928+
})))
929+
.expect(1)
930+
.mount(&server)
931+
.await;
877932
plugins_manager
878933
.build_and_cache_remote_installed_plugin_marketplaces(
879934
&plugins,
@@ -884,24 +939,55 @@ plugins = true
884939
.await
885940
.expect("remote installed plugin cache should write");
886941

942+
let expected_github = ToolSuggestDiscoverablePlugin {
943+
id: "github@openai-curated-remote".to_string(),
944+
remote_plugin_id: Some("plugins~Plugin_remote_github".to_string()),
945+
name: "Remote GitHub".to_string(),
946+
description: Some("Remote GitHub short".to_string()),
947+
has_skills: true,
948+
mcp_server_names: Vec::new(),
949+
app_connector_ids: vec!["github".to_string()],
950+
};
951+
assert_eq!(
952+
list_discoverable_plugins(
953+
&plugins_manager,
954+
discovery_input(plugins.clone(), &[], &[], &[]),
955+
Some(&auth),
956+
)
957+
.await,
958+
vec![expected_github.clone()]
959+
);
960+
887961
let discoverable_plugins = list_discoverable_plugins(
888962
&plugins_manager,
889-
discovery_input(plugins, &[], &[], &["remote-unlisted-app"]),
963+
discovery_input(plugins.clone(), &[], &[], &["remote-unlisted-app"]),
890964
Some(&auth),
891965
)
892966
.await;
893967

894968
assert_eq!(
895969
discoverable_plugins,
896-
vec![ToolSuggestDiscoverablePlugin {
897-
id: "remote-unlisted@openai-curated-remote".to_string(),
898-
remote_plugin_id: Some("plugins~Plugin_remote_unlisted".to_string()),
899-
name: "Remote Unlisted".to_string(),
900-
description: Some("Remote Unlisted short".to_string()),
901-
has_skills: true,
902-
mcp_server_names: Vec::new(),
903-
app_connector_ids: vec!["remote-unlisted-app".to_string()],
904-
}]
970+
vec![
971+
expected_github,
972+
ToolSuggestDiscoverablePlugin {
973+
id: "remote-unlisted@openai-curated-remote".to_string(),
974+
remote_plugin_id: Some("plugins~Plugin_remote_unlisted".to_string()),
975+
name: "Remote Unlisted".to_string(),
976+
description: Some("Remote Unlisted short".to_string()),
977+
has_skills: true,
978+
mcp_server_names: Vec::new(),
979+
app_connector_ids: vec!["remote-unlisted-app".to_string()],
980+
},
981+
]
982+
);
983+
assert_eq!(
984+
list_discoverable_plugins(
985+
&plugins_manager,
986+
discovery_input(plugins, &[], &["github@openai-curated-remote"], &[]),
987+
Some(&auth),
988+
)
989+
.await,
990+
Vec::new()
905991
);
906992
}
907993

0 commit comments

Comments
 (0)