Support remote marketplaces in the plugin CLI (#42150)

## What changed

- Include remote catalog entries in `codex plugin list`, including their source,
  version, install policy, and authentication policy in JSON output.
- Support adding and removing remote plugins through the existing plugin CLI.
- Cache remote catalogs by scope and collection. Prefer fresh cached results, and
  refetch once when an add request misses a plugin in the cache.
- Preserve the local curated catalog when an unfiltered remote listing fails,
  while surfacing errors for explicitly selected remote marketplaces.

## Testing

- Cover remote listing, installation, removal, catalog fallback, cache refresh,
  collection isolation, and install failure behavior.

GitOrigin-RevId: 09796b2c393d102e00ba9289f784d78a2e166a54
This commit is contained in:
willwang-openai
2026-09-01 22:51:14 +00:00
committed by copyberry
parent 68c9556cdf
commit 6b59cefcbb
9 changed files with 1373 additions and 132 deletions
+140 -1
View File
@@ -162,8 +162,116 @@ async fn remote_installed_plugins_paginate_across_all_scopes_without_download_ur
);
}
#[tokio::test]
async fn remote_catalog_cache_modes_control_refresh_and_persist_fetched_results() {
let server = MockServer::start().await;
let plugin = directory_plugin("plugin-gmail", "gmail");
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "GLOBAL"))
.and(query_param_is_missing("collection"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"plugins": [plugin],
"pagination": {"next_page_token": null},
})))
.expect(3)
.mount(&server)
.await;
let codex_home = tempfile::tempdir().expect("create codex home");
let (config, selected_urls) =
recording_remote_plugin_service_config(format!("{}/backend-api", server.uri()));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
for (mode, expire_cache, expected_refresh_needed, expected_cache_used) in [
(
RemotePluginCatalogCacheMode::PreferFreshCache,
false,
false,
false,
),
(
RemotePluginCatalogCacheMode::PreferFreshCache,
false,
false,
true,
),
(RemotePluginCatalogCacheMode::PreferCache, true, true, true),
(
RemotePluginCatalogCacheMode::PreferFreshCache,
false,
false,
false,
),
(
RemotePluginCatalogCacheMode::ForceRefetch,
false,
false,
false,
),
] {
if expire_cache {
let cache_path =
std::fs::read_dir(codex_home.path().join("cache/remote_plugin_catalog"))
.expect("read catalog cache directory")
.next()
.expect("catalog cache exists")
.expect("read cache entry")
.path();
let mut cached: serde_json::Value =
serde_json::from_slice(&std::fs::read(&cache_path).expect("read cache"))
.expect("decode cache");
cached["fetched_at"] = serde_json::json!("2000-01-01T00:00:00Z");
std::fs::write(
cache_path,
serde_json::to_vec(&cached).expect("encode cache"),
)
.expect("expire catalog cache");
}
let outcome = fetch_directory_plugins_for_scope_with_cache(
Some(codex_home.path()),
&config,
&auth,
RemotePluginScope::Global,
/*collection*/ None,
mode,
)
.await
.expect("fetch directory plugins");
assert_eq!(
(
outcome.plugins,
outcome.cache_refresh_needed,
outcome.catalog_cache_used,
),
(
vec![plugin.clone()],
expected_refresh_needed,
expected_cache_used,
),
);
}
assert_eq!(
recorded_http_client_urls(&selected_urls),
vec![
format!(
"{}/backend-api/ps/plugins/list?scope=GLOBAL&limit=200",
server.uri()
);
3
],
);
assert!(has_fresh_cached_remote_plugin_catalog(
codex_home.path(),
&config,
Some(&auth),
RemotePluginScope::Global,
));
}
#[test]
fn cached_remote_plugin_catalog_scopes_returns_existing_scopes() {
fn catalog_cache_invalidation_clears_global_collections_and_preserves_other_scopes() {
let codex_home = tempfile::tempdir().expect("create codex home");
let config = RemotePluginServiceConfig::new(
"https://chatgpt.com/backend-api".to_string(),
@@ -176,6 +284,7 @@ fn cached_remote_plugin_catalog_scopes_returns_existing_scopes() {
&config,
&auth,
scope,
/*collection*/ None,
&[],
);
}
@@ -184,6 +293,36 @@ fn cached_remote_plugin_catalog_scopes_returns_existing_scopes() {
cached_remote_plugin_catalog_scopes(codex_home.path(), &config, Some(&auth)),
BTreeSet::from([RemotePluginScope::Global, RemotePluginScope::Workspace])
);
catalog_cache::write_cached_directory_plugins(
codex_home.path(),
&config,
&auth,
RemotePluginScope::Global,
Some(OPENAI_CURATED_REMOTE_COLLECTION_KEY),
&[],
);
invalidate_cached_remote_plugin_catalog_scopes(
codex_home.path(),
&config,
Some(&auth),
&[RemotePluginScope::Global],
);
assert_eq!(
cached_remote_plugin_catalog_scopes(codex_home.path(), &config, Some(&auth)),
BTreeSet::from([RemotePluginScope::Workspace]),
);
assert!(
catalog_cache::load_cached_directory_plugins(
codex_home.path(),
&config,
&auth,
RemotePluginScope::Global,
Some(OPENAI_CURATED_REMOTE_COLLECTION_KEY),
)
.is_none()
);
}
#[test]