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
@@ -26,6 +26,8 @@ struct RemotePluginCatalogCacheKey {
// Global catalogs predate scoped cache keys and must keep their existing filenames.
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<RemotePluginScope>,
#[serde(skip_serializing_if = "Option::is_none")]
collection: Option<String>,
}
impl RemotePluginCatalogCacheKey {
@@ -33,6 +35,7 @@ impl RemotePluginCatalogCacheKey {
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
collection: Option<&str>,
) -> Option<Self> {
let cache_key = Self {
chatgpt_base_url: config.chatgpt_base_url.clone(),
@@ -40,6 +43,7 @@ impl RemotePluginCatalogCacheKey {
chatgpt_user_id: auth.get_chatgpt_user_id(),
is_workspace_account: auth.is_workspace_account(),
scope: (scope != RemotePluginScope::Global).then_some(scope),
collection: collection.map(str::to_owned),
};
// Preserve global catalog caching for existing header-auth clients, but never share
// user or workspace catalogs when the auth mode cannot identify their owner.
@@ -79,8 +83,9 @@ pub(crate) fn load_cached_directory_plugins(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
collection: Option<&str>,
) -> Option<CachedDirectoryPlugins> {
let cache_key = RemotePluginCatalogCacheKey::new(config, auth, scope)?;
let cache_key = RemotePluginCatalogCacheKey::new(config, auth, scope, collection)?;
let cache_path = cache_path(codex_home, &cache_key);
let bytes = match std::fs::read(&cache_path) {
Ok(bytes) => bytes,
@@ -125,9 +130,10 @@ pub(crate) fn write_cached_directory_plugins(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
collection: Option<&str>,
plugins: &[RemotePluginDirectoryItem],
) {
let Some(cache_key) = RemotePluginCatalogCacheKey::new(config, auth, scope) else {
let Some(cache_key) = RemotePluginCatalogCacheKey::new(config, auth, scope, collection) else {
return;
};
let cache_path = cache_path(codex_home, &cache_key);
@@ -146,8 +152,9 @@ pub(crate) fn remove_cached_directory_plugins(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
collection: Option<&str>,
) {
let Some(cache_key) = RemotePluginCatalogCacheKey::new(config, auth, scope) else {
let Some(cache_key) = RemotePluginCatalogCacheKey::new(config, auth, scope, collection) else {
return;
};
let cache_path = cache_path(codex_home, &cache_key);
@@ -18,7 +18,7 @@ fn catalog_cache_freshness_honors_ttl() {
}
#[test]
fn catalog_cache_paths_are_isolated_by_scope() {
fn catalog_cache_paths_are_isolated_by_scope_and_collection() {
let codex_home = Path::new("/tmp/codex-home");
let cache_key_for_scope = |scope| RemotePluginCatalogCacheKey {
chatgpt_base_url: "https://chatgpt.com/backend-api".to_string(),
@@ -26,6 +26,7 @@ fn catalog_cache_paths_are_isolated_by_scope() {
chatgpt_user_id: Some("user-id".to_string()),
is_workspace_account: true,
scope: (scope != RemotePluginScope::Global).then_some(scope),
collection: None,
};
let paths = [
@@ -38,6 +39,10 @@ fn catalog_cache_paths_are_isolated_by_scope() {
assert_ne!(paths[0], paths[1]);
assert_ne!(paths[0], paths[2]);
assert_ne!(paths[1], paths[2]);
let mut collection_key = cache_key_for_scope(RemotePluginScope::Global);
collection_key.collection = Some("vertical".to_string());
assert!(!paths.contains(&cache_path(codex_home, &collection_key)));
}
#[test]
@@ -59,9 +64,14 @@ fn global_catalog_cache_reuses_legacy_cache_file() {
let contents = serde_json::to_string_pretty(&legacy_cache).expect("serialize legacy cache");
codex_utils_path::write_atomically(&legacy_cache_path, &contents).expect("write legacy cache");
let cached =
load_cached_directory_plugins(codex_home.path(), &config, &auth, RemotePluginScope::Global)
.expect("load legacy global cache");
let cached = load_cached_directory_plugins(
codex_home.path(),
&config,
&auth,
RemotePluginScope::Global,
/*collection*/ None,
)
.expect("load legacy global cache");
assert!(cached.plugins.is_empty());
assert_eq!(cached.freshness, RemotePluginCatalogCacheFreshness::Stale);
@@ -70,6 +80,7 @@ fn global_catalog_cache_reuses_legacy_cache_file() {
&config,
&auth,
RemotePluginScope::Global,
/*collection*/ None,
&[],
);
let refreshed_cache: RemotePluginCatalogDiskCache = serde_json::from_slice(
@@ -93,11 +104,18 @@ fn header_auth_does_not_cache_private_catalogs_without_a_stable_identity() {
&config,
&auth,
RemotePluginScope::Global,
/*collection*/ None,
&[],
);
assert!(
load_cached_directory_plugins(codex_home.path(), &config, &auth, RemotePluginScope::Global)
.is_some()
load_cached_directory_plugins(
codex_home.path(),
&config,
&auth,
RemotePluginScope::Global,
/*collection*/ None,
)
.is_some()
);
for scope in [RemotePluginScope::User, RemotePluginScope::Workspace] {
@@ -107,10 +125,18 @@ fn header_auth_does_not_cache_private_catalogs_without_a_stable_identity() {
chatgpt_user_id: None,
is_workspace_account: false,
scope: Some(scope),
collection: None,
};
let insecure_cache_path = cache_path(codex_home.path(), &insecure_cache_key);
write_cached_directory_plugins(codex_home.path(), &config, &auth, scope, &[]);
write_cached_directory_plugins(
codex_home.path(),
&config,
&auth,
scope,
/*collection*/ None,
&[],
);
assert!(!insecure_cache_path.exists());
let insecure_cache = RemotePluginCatalogDiskCache {
@@ -122,6 +148,15 @@ fn header_auth_does_not_cache_private_catalogs_without_a_stable_identity() {
codex_utils_path::write_atomically(&insecure_cache_path, &contents)
.expect("write insecure cache");
assert!(load_cached_directory_plugins(codex_home.path(), &config, &auth, scope).is_none());
assert!(
load_cached_directory_plugins(
codex_home.path(),
&config,
&auth,
scope,
/*collection*/ None,
)
.is_none()
);
}
}