[codex] Support marketplace plugin manifest fallback (#28789)

## Summary

Support marketplace plugins whose source directory does not include a
discoverable plugin manifest. Metadata-rich `marketplace.json` entries
now act as fallback plugin manifests for listing, local detail reads,
install, and non-curated cache refresh.

The fallback preserves marketplace-entry plugin fields wholesale, then
adds the small Codex-facing compatibility bridge for presentation
metadata. A real source `plugin.json` always wins when present.

## Details

- Capture flattened marketplace-entry fields into
`MarketplacePluginManifestFallback`, preserving fields such as
`version`, `description`, `skills`, `mcpServers`, `apps`, `hooks`,
`agents`, `commands`, `strict`, `author`, and future manifest fields
without a per-field translation list.
- Bridge Claude-style top-level `displayName`, `author.name`,
`homepage`, and marketplace `category` into Codex's nested `interface`
fields only when the nested values are absent.
- Treat fallback metadata as installable only when the marketplace entry
contributes metadata beyond bare `name` and `source`; existing
missing-manifest behavior remains for metadata-free entries.
- Read local plugin details from the already parsed fallback manifest,
including fallback-declared app and MCP paths, instead of rereading only
an on-disk manifest.
- Pass fallback contents into `PluginStore`, which validates them and
injects `.codex-plugin/plugin.json` into Store's existing atomic copy.
Local marketplace source directories are never mutated, and the fallback
path no longer needs an additional staging directory.
- Keep Git source materialization unchanged; Git clones still use the
existing marketplace source staging area before Store installation.
This commit is contained in:
charlesgong-openai
2026-06-18 15:49:27 -07:00
committed by GitHub
Unverified
parent dce673905a
commit 772c5c5195
7 changed files with 897 additions and 47 deletions
+53 -14
View File
@@ -7,12 +7,14 @@ use crate::manifest::PluginManifestMcpServers;
use crate::manifest::PluginManifestPaths;
use crate::manifest::load_plugin_manifest;
use crate::marketplace::MarketplacePluginSource;
use crate::marketplace::find_marketplace_plugin;
use crate::marketplace::list_marketplaces;
use crate::marketplace::load_marketplace;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::remote::RemoteInstalledPlugin;
use crate::store::PluginStore;
use crate::store::plugin_version_for_source;
use crate::store::plugin_version_for_source_with_fallback_manifest;
use codex_app_server_protocol::AuthMode;
use codex_config::ConfigLayerStack;
use codex_config::HooksFile;
@@ -460,7 +462,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?;
let marketplace_outcome = list_marketplaces(additional_roots)
.map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?;
let mut plugin_sources = HashMap::<String, MarketplacePluginSource>::new();
let mut plugin_sources = HashMap::<String, (MarketplacePluginSource, Option<String>)>::new();
for marketplace in marketplace_outcome.marketplaces {
if is_openai_curated_marketplace_name(&marketplace.name) {
@@ -489,14 +491,31 @@ fn refresh_non_curated_plugin_cache_with_mode(
continue;
}
plugin_sources.insert(plugin_key, plugin.source);
let manifest_fallback = find_marketplace_plugin(&marketplace.path, &plugin.name)
.map(|resolved| {
resolved
.manifest_fallback
.contents_if_has_metadata()
.map(str::to_string)
})
.unwrap_or_else(|err| {
warn!(
plugin = plugin.name,
marketplace = marketplace.name,
error = %err,
"failed to resolve marketplace plugin manifest fallback during cache refresh"
);
None
});
plugin_sources.insert(plugin_key, (plugin.source, manifest_fallback));
}
}
let mut cache_refreshed = false;
for plugin_id in configured_non_curated_plugin_ids {
let plugin_key = plugin_id.as_key();
let Some(source) = plugin_sources.get(&plugin_key).cloned() else {
let Some((source, manifest_fallback_contents)) = plugin_sources.get(&plugin_key).cloned()
else {
warn!(
plugin = plugin_id.plugin_name,
marketplace = plugin_id.marketplace_name,
@@ -509,8 +528,14 @@ fn refresh_non_curated_plugin_cache_with_mode(
format!("failed to materialize plugin source for {plugin_key}: {err}")
})?;
let source_path = materialized.path.clone();
let plugin_version = plugin_version_for_source(source_path.as_path())
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
let plugin_version = match manifest_fallback_contents.as_deref() {
Some(manifest_contents) => plugin_version_for_source_with_fallback_manifest(
source_path.as_path(),
manifest_contents,
),
None => plugin_version_for_source(source_path.as_path()),
}
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
if mode == NonCuratedCacheRefreshMode::IfVersionChanged
&& store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str())
@@ -518,9 +543,16 @@ fn refresh_non_curated_plugin_cache_with_mode(
continue;
}
store
.install_with_version(source_path, plugin_id.clone(), plugin_version)
.map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?;
match manifest_fallback_contents.as_deref() {
Some(manifest_contents) => store.install_with_version_and_fallback_manifest(
source_path,
plugin_id.clone(),
plugin_version,
manifest_contents,
),
None => store.install_with_version(source_path, plugin_id.clone(), plugin_version),
}
.map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?;
cache_refreshed = true;
}
@@ -901,15 +933,22 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppDeclaration> {
if let Some(manifest) = load_plugin_manifest(plugin_root) {
return load_apps_from_paths(
plugin_root,
plugin_app_config_paths(plugin_root, &manifest.paths),
)
.await;
return load_plugin_apps_from_manifest(plugin_root, &manifest.paths).await;
}
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await
}
pub(crate) async fn load_plugin_apps_from_manifest(
plugin_root: &Path,
manifest_paths: &PluginManifestPaths,
) -> Vec<AppDeclaration> {
load_apps_from_paths(
plugin_root,
plugin_app_config_paths(plugin_root, manifest_paths),
)
.await
}
pub fn plugin_app_declarations_from_value(value: &JsonValue) -> Vec<AppDeclaration> {
let Ok(parsed) = serde_json::from_value::<PluginAppFile>(value.clone()) else {
return Vec::new();
@@ -1181,7 +1220,7 @@ async fn load_declared_plugin_mcp_servers(plugin_root: &Path) -> HashMap<String,
.await
}
async fn load_plugin_mcp_servers_from_manifest(
pub(crate) async fn load_plugin_mcp_servers_from_manifest(
plugin_root: &Path,
manifest_paths: &PluginManifestPaths,
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,