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
+77 -36
View File
@@ -204,6 +204,8 @@ pub enum RemoteMarketplaceSource {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemotePluginCatalogCacheMode {
PreferCache,
/// Reuse fresh entries and synchronously refresh missing or stale entries.
PreferFreshCache,
ForceRefetch,
}
@@ -211,6 +213,15 @@ pub enum RemotePluginCatalogCacheMode {
pub struct RemoteMarketplacesFetchOutcome {
pub marketplaces: Vec<RemoteMarketplace>,
pub catalog_cache_refresh_scopes: BTreeSet<RemotePluginScope>,
/// Whether any requested directory was served from disk cache, even if it was empty.
pub catalog_cache_used: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemoteMarketplaceFetchOutcome {
pub marketplace: Option<RemoteMarketplace>,
/// Whether the requested directory was served from disk cache, even if it was empty.
pub catalog_cache_used: bool,
}
#[derive(Debug, Clone, PartialEq)]
@@ -791,6 +802,7 @@ pub async fn fetch_remote_marketplaces(
let auth = ensure_chatgpt_auth(auth)?;
let mut marketplaces = Vec::new();
let mut catalog_cache_refresh_scopes = BTreeSet::new();
let mut catalog_cache_used = false;
let needs_workspace_installed = sources.iter().any(|source| {
matches!(
source,
@@ -813,6 +825,7 @@ pub async fn fetch_remote_marketplaces(
config,
auth,
scope,
/*collection*/ None,
catalog_cache_mode,
),
fetch_installed_plugins_for_scope(config, auth, scope),
@@ -820,6 +833,7 @@ pub async fn fetch_remote_marketplaces(
if directory_plugins.cache_refresh_needed {
catalog_cache_refresh_scopes.insert(scope);
}
catalog_cache_used |= directory_plugins.catalog_cache_used;
if let Some(marketplace) = build_remote_marketplace(
scope.marketplace_name(),
scope.marketplace_display_name(),
@@ -838,6 +852,7 @@ pub async fn fetch_remote_marketplaces(
config,
auth,
scope,
/*collection*/ None,
catalog_cache_mode,
),
fetch_installed_plugins_for_scope(config, auth, scope),
@@ -845,6 +860,7 @@ pub async fn fetch_remote_marketplaces(
if directory_plugins.cache_refresh_needed {
catalog_cache_refresh_scopes.insert(scope);
}
catalog_cache_used |= directory_plugins.catalog_cache_used;
if let Some(marketplace) = build_remote_marketplace(
scope.marketplace_name(),
scope.marketplace_display_name(),
@@ -862,12 +878,14 @@ pub async fn fetch_remote_marketplaces(
config,
auth,
scope,
/*collection*/ None,
catalog_cache_mode,
)
.await?;
if directory_plugins.cache_refresh_needed {
catalog_cache_refresh_scopes.insert(scope);
}
catalog_cache_used |= directory_plugins.catalog_cache_used;
if let Some(marketplace) = build_remote_marketplace(
scope.marketplace_name(),
scope.marketplace_display_name(),
@@ -942,6 +960,7 @@ pub async fn fetch_remote_marketplaces(
Ok(RemoteMarketplacesFetchOutcome {
marketplaces,
catalog_cache_refresh_scopes,
catalog_cache_used,
})
}
@@ -953,7 +972,9 @@ pub(crate) async fn fetch_and_cache_remote_plugin_catalog(
) -> Result<(), RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let plugins = fetch_directory_plugins_for_scope(config, auth, scope).await?;
catalog_cache::write_cached_directory_plugins(codex_home, config, auth, scope, &plugins);
catalog_cache::write_cached_directory_plugins(
codex_home, config, auth, scope, /*collection*/ None, &plugins,
);
Ok(())
}
@@ -975,7 +996,18 @@ pub fn invalidate_cached_remote_plugin_catalog_scopes(
return;
};
for scope in scopes {
catalog_cache::remove_cached_directory_plugins(codex_home, config, auth, *scope);
catalog_cache::remove_cached_directory_plugins(
codex_home, config, auth, *scope, /*collection*/ None,
);
if *scope == RemotePluginScope::Global {
catalog_cache::remove_cached_directory_plugins(
codex_home,
config,
auth,
*scope,
Some(OPENAI_CURATED_REMOTE_COLLECTION_KEY),
);
}
}
}
@@ -1094,14 +1126,15 @@ pub(crate) fn has_fresh_cached_remote_plugin_catalog(
let Ok(auth) = ensure_chatgpt_auth(auth) else {
return false;
};
catalog_cache::load_cached_directory_plugins(codex_home, config, auth, scope).is_some_and(
|cached| {
matches!(
cached.freshness,
catalog_cache::RemotePluginCatalogCacheFreshness::Fresh
)
},
catalog_cache::load_cached_directory_plugins(
codex_home, config, auth, scope, /*collection*/ None,
)
.is_some_and(|cached| {
matches!(
cached.freshness,
catalog_cache::RemotePluginCatalogCacheFreshness::Fresh
)
})
}
pub(crate) fn cached_remote_plugin_catalog_scopes(
@@ -1115,7 +1148,10 @@ pub(crate) fn cached_remote_plugin_catalog_scopes(
RemotePluginScope::CATALOG_CACHE_SCOPES
.into_iter()
.filter(|scope| {
catalog_cache::load_cached_directory_plugins(codex_home, config, auth, *scope).is_some()
catalog_cache::load_cached_directory_plugins(
codex_home, config, auth, *scope, /*collection*/ None,
)
.is_some()
})
.collect()
}
@@ -1130,6 +1166,7 @@ pub fn cached_global_remote_discoverable_plugins(
config,
auth,
RemotePluginScope::Global,
/*collection*/ None,
)
.map(|cached| cached.plugins)
.unwrap_or_default()
@@ -1149,26 +1186,34 @@ pub fn cached_global_remote_discoverable_plugins(
pub async fn fetch_openai_curated_remote_collection_marketplace(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Option<RemoteMarketplace>, RemotePluginCatalogError> {
catalog_cache_root: Option<&Path>,
catalog_cache_mode: RemotePluginCatalogCacheMode,
) -> Result<RemoteMarketplaceFetchOutcome, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let scope = RemotePluginScope::Global;
let (directory_plugins, installed_plugins) = tokio::try_join!(
fetch_directory_plugins_for_scope_with_collection(
fetch_directory_plugins_for_scope_with_cache(
catalog_cache_root,
config,
auth,
scope,
OPENAI_CURATED_REMOTE_COLLECTION_KEY,
Some(OPENAI_CURATED_REMOTE_COLLECTION_KEY),
catalog_cache_mode,
),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
build_remote_marketplace(
let marketplace = build_remote_marketplace(
REMOTE_GLOBAL_MARKETPLACE_NAME,
REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME,
directory_plugins,
directory_plugins.plugins,
installed_plugins,
/*include_installed_only*/ false,
)
)?;
Ok(RemoteMarketplaceFetchOutcome {
marketplace,
catalog_cache_used: directory_plugins.catalog_cache_used,
})
}
fn build_remote_marketplace(
@@ -1974,6 +2019,7 @@ fn normalize_remote_default_prompt(prompt: &str) -> Option<String> {
struct DirectoryPluginsFetchOutcome {
plugins: Vec<RemotePluginDirectoryItem>,
cache_refresh_needed: bool,
catalog_cache_used: bool,
}
async fn fetch_directory_plugins_for_scope_with_cache(
@@ -1981,12 +2027,16 @@ async fn fetch_directory_plugins_for_scope_with_cache(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
collection: Option<&str>,
cache_mode: RemotePluginCatalogCacheMode,
) -> Result<DirectoryPluginsFetchOutcome, RemotePluginCatalogError> {
if cache_mode == RemotePluginCatalogCacheMode::PreferCache
if cache_mode != RemotePluginCatalogCacheMode::ForceRefetch
&& let Some(codex_home) = codex_home
&& let Some(cached) =
catalog_cache::load_cached_directory_plugins(codex_home, config, auth, scope)
&& let Some(cached) = catalog_cache::load_cached_directory_plugins(
codex_home, config, auth, scope, collection,
)
&& (cache_mode == RemotePluginCatalogCacheMode::PreferCache
|| cached.freshness == catalog_cache::RemotePluginCatalogCacheFreshness::Fresh)
{
return Ok(DirectoryPluginsFetchOutcome {
plugins: cached.plugins,
@@ -1994,16 +2044,22 @@ async fn fetch_directory_plugins_for_scope_with_cache(
cached.freshness,
catalog_cache::RemotePluginCatalogCacheFreshness::Stale
),
catalog_cache_used: true,
});
}
let plugins = fetch_directory_plugins_for_scope(config, auth, scope).await?;
let plugins =
fetch_directory_plugins_for_scope_with_optional_collection(config, auth, scope, collection)
.await?;
if let Some(codex_home) = codex_home {
catalog_cache::write_cached_directory_plugins(codex_home, config, auth, scope, &plugins);
catalog_cache::write_cached_directory_plugins(
codex_home, config, auth, scope, collection, &plugins,
);
}
Ok(DirectoryPluginsFetchOutcome {
plugins,
cache_refresh_needed: false,
catalog_cache_used: false,
})
}
@@ -2018,21 +2074,6 @@ async fn fetch_directory_plugins_for_scope(
.await
}
async fn fetch_directory_plugins_for_scope_with_collection(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
collection: &str,
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
fetch_directory_plugins_for_scope_with_optional_collection(
config,
auth,
scope,
Some(collection),
)
.await
}
async fn fetch_directory_plugins_for_scope_with_optional_collection(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,