Cache remote plugin catalogs by scope (#34849)

## What changed

- Cache global, user, and workspace remote plugin catalogs on disk with a three-hour TTL and scope- and account-specific keys.
- Serve cached catalogs from `plugin/list`, refresh stale entries in the background, and warm existing caches at app-server startup without blocking list requests.
- Make `forceRefetch: true` bypass cached catalog data and replace an entry only after a successful fetch.
- Invalidate user and workspace catalog caches after remote plugin sharing changes.

## Testing

- Cover cache freshness, scope isolation, legacy global cache compatibility, identity requirements for private catalogs, forced refetches, and non-blocking startup refreshes.

GitOrigin-RevId: 7844e9dfb72e43b26d749b4afd16fd9c1793caba
This commit is contained in:
brisebois-oai
2026-07-23 00:52:55 +00:00
committed by copyberry
parent 9d82334302
commit 83ff1c2f80
9 changed files with 1223 additions and 146 deletions
+1 -1
View File
@@ -216,7 +216,7 @@ Example with notification opt-out:
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `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 `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. The optional `forceRefetch` request field is currently accepted but has no effect. `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/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 `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. 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/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`; local rows return `null`. 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. 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.
@@ -17,6 +17,8 @@ use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME;
use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason;
use codex_core_plugins::remote::RemotePluginCatalogCacheMode;
use codex_core_plugins::remote::RemotePluginScope;
use codex_core_plugins::remote::is_valid_remote_plugin_id;
use codex_core_plugins::remote::validate_remote_plugin_id;
use codex_core_plugins::remote_bundle::RemotePluginBundleInstallError;
@@ -540,7 +542,7 @@ impl PluginRequestProcessor {
let PluginListParams {
cwds,
marketplace_kinds,
force_refetch: _,
force_refetch,
} = params;
let roots = cwds.unwrap_or_default();
let explicit_marketplace_kinds = marketplace_kinds.is_some();
@@ -578,12 +580,12 @@ impl PluginRequestProcessor {
let use_remote_global_catalog =
include_global_remote && auth_mode.is_some_and(DomainAuthMode::uses_codex_backend);
let remote_plugin_service_config = remote_plugin_service_config(&config);
let refresh_global_remote_catalog_cache = use_remote_global_catalog
&& codex_core_plugins::remote::has_cached_global_remote_plugin_catalog(
config.codex_home.as_path(),
&remote_plugin_service_config,
auth.as_ref(),
);
let remote_catalog_cache_mode = if force_refetch {
RemotePluginCatalogCacheMode::ForceRefetch
} else {
RemotePluginCatalogCacheMode::PreferCache
};
let mut remote_catalog_cache_refresh_scopes = Default::default();
let (mut data, marketplace_load_errors) = if include_local {
let config_for_marketplace_listing = plugins_input.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
@@ -700,12 +702,15 @@ impl PluginRequestProcessor {
&remote_plugin_service_config,
auth.as_ref(),
&remote_sources,
/*global_catalog_cache_path*/ Some(config.codex_home.as_path()),
/*catalog_cache_root*/ Some(config.codex_home.as_path()),
remote_catalog_cache_mode,
)
.await
{
Ok(remote_marketplaces) => {
for remote_marketplace in remote_marketplaces
Ok(outcome) => {
remote_catalog_cache_refresh_scopes = outcome.catalog_cache_refresh_scopes;
for remote_marketplace in outcome
.marketplaces
.into_iter()
.map(remote_marketplace_to_info)
{
@@ -743,13 +748,14 @@ impl PluginRequestProcessor {
|| include_created_by_me_remote
|| include_shared_with_me
|| include_global_remote
|| !remote_catalog_cache_refresh_scopes.is_empty()
{
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
&plugins_input,
auth.clone(),
&roots,
PluginListBackgroundTaskOptions {
refresh_global_remote_catalog_cache,
remote_catalog_cache_refresh_scopes,
},
Some(self.effective_plugins_changed_callback()),
);
@@ -1254,6 +1260,12 @@ impl PluginRequestProcessor {
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
codex_core_plugins::remote::invalidate_cached_remote_plugin_catalog_scopes(
config.codex_home.as_path(),
&remote_plugin_service_config,
auth.as_ref(),
&[RemotePluginScope::User, RemotePluginScope::Workspace],
);
let remote_plugin_id = result.remote_plugin_id;
self.clear_plugin_related_caches();
Ok(PluginShareSaveResponse {
@@ -1292,6 +1304,12 @@ impl PluginRequestProcessor {
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "update remote plugin share targets")
})?;
codex_core_plugins::remote::invalidate_cached_remote_plugin_catalog_scopes(
config.codex_home.as_path(),
&remote_plugin_service_config,
auth.as_ref(),
&[RemotePluginScope::User, RemotePluginScope::Workspace],
);
self.clear_plugin_related_caches();
Ok(PluginShareUpdateTargetsResponse {
principals: result
@@ -1385,6 +1403,12 @@ impl PluginRequestProcessor {
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "delete remote plugin share"))?;
codex_core_plugins::remote::invalidate_cached_remote_plugin_catalog_scopes(
config.codex_home.as_path(),
&remote_plugin_service_config,
auth.as_ref(),
&[RemotePluginScope::User, RemotePluginScope::Workspace],
);
self.clear_plugin_related_caches();
Ok(PluginShareDeleteResponse {})
}
@@ -4,7 +4,10 @@ use anyhow::Result;
use anyhow::bail;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use chrono::Duration as ChronoDuration;
use chrono::Utc;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HookTrustStatus;
use codex_app_server_protocol::HooksListParams;
@@ -1902,6 +1905,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
let cached_catalog: serde_json::Value =
serde_json::from_slice(&std::fs::read(&cache_files[0])?)?;
assert_eq!(cached_catalog["schema_version"], serde_json::json!(1));
assert!(cached_catalog["fetched_at"].as_str().is_some());
assert_eq!(
cached_catalog["plugins"][0]["installation_policy_source"],
serde_json::json!("IMPLICIT_CANONICAL_APP")
@@ -1943,7 +1947,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
}
#[tokio::test]
async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Result<()> {
async fn plugin_list_honors_global_remote_catalog_cache_ttl() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
@@ -2032,9 +2036,468 @@ async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Res
remote_marketplace.plugins[0].id,
"linear@openai-curated-remote"
);
sleep(Duration::from_millis(100)).await;
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id])
.await?;
rewrite_cached_remote_catalog_fetched_at(
codex_home.path(),
Utc::now() - ChronoDuration::hours(4),
)?;
server.reset().await;
mount_delayed_remote_plugin_list(&server, "GLOBAL", &refreshed_body).await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
force_refetch: false,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
let remote_marketplace = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "openai-curated-remote")
.expect("expected stale cached remote marketplace");
assert_eq!(
remote_marketplace.plugins[0].id,
"linear@openai-curated-remote"
);
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
force_refetch: false,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
let remote_marketplace = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "openai-curated-remote")
.expect("expected stale cached remote marketplace");
assert_eq!(
remote_marketplace.plugins[0].id,
"linear@openai-curated-remote"
);
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[refreshed_remote_plugin_id])
.await?;
sleep(Duration::from_millis(100)).await;
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
Ok(())
}
#[tokio::test]
async fn app_server_startup_refreshes_cached_remote_catalog_without_blocking_plugin_list()
-> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", 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,
)?;
let cached_remote_plugin_id = "plugins~Plugin_00000000000000000000000000000000";
let refreshed_remote_plugin_id = "plugins~Plugin_11111111111111111111111111111111";
mount_remote_plugin_list(
&server,
"GLOBAL",
&remote_plugin_list_body(cached_remote_plugin_id, "linear", "Linear", "Plan work"),
)
.await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
let request_id = app_server
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
force_refetch: false,
})
.await?;
let _: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id])
.await?;
timeout(DEFAULT_TIMEOUT, app_server.shutdown_gracefully()).await??;
server.reset().await;
let refreshed_body = remote_plugin_list_body(
refreshed_remote_plugin_id,
"notion",
"Notion",
"Capture notes",
);
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "GLOBAL"))
.and(query_param("limit", "200"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(refreshed_body)
.set_delay(Duration::from_secs(/*secs*/ 2)),
)
.mount(&server)
.await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_plugin_startup_tasks()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
let request_id = app_server
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
force_refetch: false,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
let remote_marketplace = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "openai-curated-remote")
.expect("expected cached remote marketplace");
assert_eq!(
remote_marketplace.plugins[0].id,
"linear@openai-curated-remote"
);
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[refreshed_remote_plugin_id])
.await?;
let request_id = app_server
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
force_refetch: false,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
let remote_marketplace = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "openai-curated-remote")
.expect("expected refreshed remote marketplace");
assert_eq!(
remote_marketplace.plugins[0].id,
"notion@openai-curated-remote"
);
sleep(Duration::from_millis(100)).await;
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
Ok(())
}
#[tokio::test]
async fn app_server_startup_skips_disabled_remote_plugin_catalog_scopes() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
let base_url = format!("{}/backend-api/", server.uri());
write_remote_plugin_catalog_config(codex_home.path(), &base_url)?;
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,
)?;
let global_plugin_id = "plugins~Plugin_00000000000000000000000000000000";
let user_plugin_id = "plugins~Plugin_11111111111111111111111111111111";
let workspace_plugin_id = "plugins~Plugin_22222222222222222222222222222222";
let global_body = remote_plugin_list_body(global_plugin_id, "global-linear", "Linear", "Plan");
let user_body = user_remote_plugin_page_body(
user_plugin_id,
"private-linear",
"Private Linear",
"PRIVATE",
/*enabled*/ None,
);
let workspace_body = workspace_remote_plugin_page_body(
workspace_plugin_id,
"workspace-linear",
"Workspace Linear",
"LISTED",
/*enabled*/ None,
);
mount_remote_plugin_list(&server, "GLOBAL", &global_body).await;
mount_remote_plugin_list(&server, "USER", &user_body).await;
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_body).await;
for scope in ["GLOBAL", "USER", "WORKSPACE"] {
mount_remote_installed_plugins(&server, scope, empty_remote_installed_plugins_body()).await;
}
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
for marketplace_kinds in [
None,
Some(vec![
PluginListMarketplaceKind::CreatedByMeRemote,
PluginListMarketplaceKind::WorkspaceDirectory,
]),
] {
let request_id = app_server
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds,
force_refetch: false,
})
.await?;
let _: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
}
wait_for_cached_remote_catalog_plugin_ids(
codex_home.path(),
&[global_plugin_id, user_plugin_id, workspace_plugin_id],
)
.await?;
timeout(DEFAULT_TIMEOUT, app_server.shutdown_gracefully()).await??;
write_remote_plugins_disabled_config_with_base_url(codex_home.path(), &base_url)?;
server.reset().await;
mount_remote_plugin_list(&server, "GLOBAL", &global_body).await;
mount_remote_plugin_list(&server, "USER", &user_body).await;
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_body).await;
for scope in ["GLOBAL", "USER", "WORKSPACE"] {
mount_remote_installed_plugins(&server, scope, empty_remote_installed_plugins_body()).await;
}
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_plugin_startup_tasks()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
wait_for_remote_plugin_list_scope_request_count(
&server,
"WORKSPACE",
/*expected_count*/ 1,
)
.await?;
let requested_scopes = server
.received_requests()
.await
.expect("wiremock should record requests")
.into_iter()
.filter(|request| {
request.method == "GET" && request.url.path().ends_with("/ps/plugins/list")
})
.filter_map(|request| {
request
.url
.query_pairs()
.find(|(name, _)| name == "scope")
.map(|(_, scope)| scope.into_owned())
})
.collect::<Vec<_>>();
assert_eq!(requested_scopes, vec!["WORKSPACE".to_string()]);
Ok(())
}
#[tokio::test]
async fn plugin_list_force_refetch_bypasses_fresh_global_remote_catalog_cache() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", 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,
)?;
let cached_remote_plugin_id = "plugins~Plugin_00000000000000000000000000000000";
let refreshed_remote_plugin_id = "plugins~Plugin_11111111111111111111111111111111";
mount_remote_plugin_list(
&server,
"GLOBAL",
&remote_plugin_list_body(cached_remote_plugin_id, "linear", "Linear", "Plan work"),
)
.await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
force_refetch: false,
})
.await?;
let _: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id])
.await?;
server.reset().await;
mount_delayed_remote_plugin_list(
&server,
"GLOBAL",
&remote_plugin_list_body(
refreshed_remote_plugin_id,
"notion",
"Notion",
"Capture notes",
),
)
.await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
force_refetch: true,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
let remote_marketplace = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "openai-curated-remote")
.expect("expected refreshed remote marketplace");
assert_eq!(
remote_marketplace.plugins[0].id,
"notion@openai-curated-remote"
);
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[refreshed_remote_plugin_id])
.await?;
server.reset().await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
force_refetch: false,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
let remote_marketplace = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "openai-curated-remote")
.expect("expected cached refreshed remote marketplace");
assert_eq!(
remote_marketplace.plugins[0].id,
"notion@openai-curated-remote"
);
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
Ok(())
}
@@ -2948,6 +3411,13 @@ async fn plugin_list_fetches_workspace_directory_kind_when_remote_plugin_disable
"LISTED",
/*enabled*/ Some(false),
);
let refreshed_workspace_plugin_body = workspace_remote_plugin_page_body(
"plugins~Plugin_22222222222222222222222222222222",
"workspace-notion",
"Workspace Notion",
"LISTED",
/*enabled*/ None,
);
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
mount_empty_user_installed_plugins(&server).await;
@@ -3002,6 +3472,100 @@ async fn plugin_list_fetches_workspace_directory_kind_when_remote_plugin_disable
.query()
.is_some_and(|query| query.contains("scope=GLOBAL")))
);
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]),
force_refetch: false,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
assert_eq!(
response.marketplaces[0].plugins[0].id,
"workspace-linear@workspace-directory"
);
sleep(Duration::from_millis(100)).await;
wait_for_remote_plugin_list_scope_request_count(
&server,
"WORKSPACE",
/*expected_count*/ 1,
)
.await?;
rewrite_cached_remote_catalog_fetched_at(
codex_home.path(),
Utc::now() - ChronoDuration::hours(4),
)?;
server.reset().await;
mount_delayed_remote_plugin_list(&server, "WORKSPACE", &refreshed_workspace_plugin_body).await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
mount_empty_user_installed_plugins(&server).await;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]),
force_refetch: false,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
assert_eq!(
response.marketplaces[0].plugins[0].id,
"workspace-linear@workspace-directory"
);
wait_for_remote_plugin_list_scope_request_count(
&server,
"WORKSPACE",
/*expected_count*/ 1,
)
.await?;
wait_for_cached_remote_catalog_plugin_ids(
codex_home.path(),
&["plugins~Plugin_22222222222222222222222222222222"],
)
.await?;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]),
force_refetch: false,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
assert_eq!(
response.marketplaces[0].plugins[0].id,
"workspace-notion@workspace-directory"
);
sleep(Duration::from_millis(100)).await;
wait_for_remote_plugin_list_scope_request_count(
&server,
"WORKSPACE",
/*expected_count*/ 1,
)
.await?;
Ok(())
}
@@ -3137,6 +3701,24 @@ plugin_sharing = false
.any(|(key, value)| key == "scope" && value != "USER")
})
);
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::CreatedByMeRemote]),
force_refetch: false,
})
.await?;
let response: PluginListResponse = to_response(
timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??,
)?;
assert_eq!(response.marketplaces[0].plugins.len(), 2);
sleep(Duration::from_millis(100)).await;
wait_for_remote_plugin_list_scope_request_count(&server, "USER", /*expected_count*/ 2).await?;
Ok(())
}
@@ -3759,6 +4341,42 @@ async fn wait_for_remote_plugin_request_count(
Ok(())
}
async fn wait_for_remote_plugin_list_scope_request_count(
server: &MockServer,
scope: &str,
expected_count: usize,
) -> Result<()> {
timeout(DEFAULT_TIMEOUT, async {
loop {
let Some(requests) = server.received_requests().await else {
bail!("wiremock did not record requests");
};
let request_count = requests
.iter()
.filter(|request| {
request.method == "GET"
&& request.url.path().ends_with("/ps/plugins/list")
&& request
.url
.query_pairs()
.any(|(name, value)| name == "scope" && value == scope)
})
.count();
if request_count == expected_count {
return Ok::<(), anyhow::Error>(());
}
if request_count > expected_count {
bail!(
"expected exactly {expected_count} /ps/plugins/list requests for scope {scope}, got {request_count}"
);
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await??;
Ok(())
}
async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &str) -> Result<()> {
timeout(DEFAULT_TIMEOUT, async {
loop {
@@ -3828,6 +4446,20 @@ fn cached_remote_catalog_plugin_ids(codex_home: &std::path::Path) -> Result<Vec<
Ok(plugin_ids)
}
fn rewrite_cached_remote_catalog_fetched_at(
codex_home: &std::path::Path,
fetched_at: chrono::DateTime<Utc>,
) -> Result<()> {
let cache_dir = codex_home.join("cache/remote_plugin_catalog");
for entry in std::fs::read_dir(cache_dir)? {
let path = entry?.path();
let mut cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
cached_catalog["fetched_at"] = serde_json::json!(fetched_at);
std::fs::write(path, serde_json::to_vec_pretty(&cached_catalog)?)?;
}
Ok(())
}
async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> {
timeout(DEFAULT_TIMEOUT, async {
loop {
@@ -3914,6 +4546,22 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str)
.await;
}
async fn mount_delayed_remote_plugin_list(server: &MockServer, scope: &str, body: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", scope))
.and(query_param("limit", "200"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(body)
.set_delay(Duration::from_millis(/*millis*/ 200)),
)
.mount(server)
.await;
}
fn remote_plugin_list_body(
remote_plugin_id: &str,
plugin_name: &str,