[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
+52 -11
View File
@@ -7,10 +7,10 @@ use crate::loader::PluginHookLoadOutcome;
use crate::loader::configured_curated_plugin_ids_from_codex_home;
use crate::loader::curated_plugin_cache_version;
use crate::loader::installed_plugin_telemetry_metadata;
use crate::loader::load_plugin_apps;
use crate::loader::load_plugin_apps_from_manifest;
use crate::loader::load_plugin_hooks;
use crate::loader::load_plugin_hooks_from_layer_stack;
use crate::loader::load_plugin_mcp_servers;
use crate::loader::load_plugin_mcp_servers_from_manifest;
use crate::loader::load_plugin_skills;
use crate::loader::load_plugins_from_layer_stack;
use crate::loader::log_plugin_load_errors;
@@ -27,6 +27,7 @@ use crate::marketplace::MarketplaceInterface;
use crate::marketplace::MarketplaceListError;
use crate::marketplace::MarketplaceListOutcome;
use crate::marketplace::MarketplacePluginAuthPolicy;
use crate::marketplace::MarketplacePluginManifestFallback;
use crate::marketplace::MarketplacePluginPolicy;
use crate::marketplace::MarketplacePluginSource;
use crate::marketplace::ResolvedMarketplacePlugin;
@@ -315,6 +316,7 @@ pub struct ConfiguredMarketplacePlugin {
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
pub keywords: Vec<String>,
pub manifest_fallback: Option<MarketplacePluginManifestFallback>,
pub installed: bool,
pub enabled: bool,
}
@@ -1256,15 +1258,32 @@ impl PluginsManager {
};
let store = self.store.clone();
let codex_home = self.codex_home.clone();
let manifest_fallback_contents = resolved
.manifest_fallback
.contents_if_has_metadata()
.map(str::to_string);
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
let materialized =
materialize_marketplace_plugin_source(codex_home.as_path(), &resolved.source)
.map_err(PluginStoreError::Invalid)?;
let source_path = materialized.path;
if let Some(plugin_version) = plugin_version {
store.install_with_version(source_path, resolved.plugin_id, plugin_version)
} else {
store.install(source_path, resolved.plugin_id)
match (plugin_version, manifest_fallback_contents.as_deref()) {
(Some(plugin_version), Some(manifest_contents)) => store
.install_with_version_and_fallback_manifest(
source_path,
resolved.plugin_id,
plugin_version,
manifest_contents,
),
(Some(plugin_version), None) => {
store.install_with_version(source_path, resolved.plugin_id, plugin_version)
}
(None, Some(manifest_contents)) => store.install_with_fallback_manifest(
source_path,
resolved.plugin_id,
manifest_contents,
),
(None, None) => store.install(source_path, resolved.plugin_id),
}
})
.await
@@ -1394,6 +1413,7 @@ impl PluginsManager {
let enabled = enabled_plugins.contains(&plugin_key);
let mut interface = plugin.interface;
let mut local_version = plugin.local_version;
let manifest_fallback = plugin.manifest_fallback.clone();
if installed
&& matches!(&plugin.source, MarketplacePluginSource::Git { .. })
&& let Some(plugin_id) = plugin_id.as_ref()
@@ -1424,6 +1444,7 @@ impl PluginsManager {
policy: plugin.policy,
keywords: plugin.keywords,
interface,
manifest_fallback,
})
})
.collect::<Vec<_>>();
@@ -1491,6 +1512,10 @@ impl PluginsManager {
let marketplace_name = plugin.plugin_id.marketplace_name.clone();
let plugin_key = plugin.plugin_id.as_key();
let manifest_fallback = plugin
.manifest_fallback
.contents_if_has_metadata()
.map(|_| plugin.manifest_fallback.clone());
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let installed = installed_plugins.contains(&plugin_key);
let installed_version = if installed {
@@ -1518,6 +1543,7 @@ impl PluginsManager {
.as_ref()
.map(|manifest| manifest.keywords.clone())
.unwrap_or_default(),
manifest_fallback,
installed,
enabled: enabled_plugins.contains(&plugin_key),
},
@@ -1604,9 +1630,18 @@ impl PluginsManager {
"path does not exist or is not a directory".to_string(),
));
}
let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| {
MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string())
})?;
let manifest =
if codex_utils_plugins::find_plugin_manifest_path(source_path.as_path()).is_some() {
load_plugin_manifest(source_path.as_path())
} else {
plugin
.manifest_fallback
.as_ref()
.and_then(|fallback| fallback.parse_for_plugin_root(source_path.as_path()))
}
.ok_or_else(|| {
MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string())
})?;
let description = manifest.description.clone();
let marketplace_category = plugin
.interface
@@ -1637,8 +1672,14 @@ impl PluginsManager {
})
.collect();
let auth_mode = self.auth_mode();
let mut app_declarations = load_plugin_apps(source_path.as_path()).await;
let mut mcp_servers = load_plugin_mcp_servers(source_path.as_path(), auth_mode).await;
let mut app_declarations =
load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await;
let mut mcp_servers = load_plugin_mcp_servers_from_manifest(
source_path.as_path(),
&manifest.paths,
/*plugin_policy*/ None,
)
.await;
if auth_mode.is_some() {
apply_app_mcp_routing_policy(
&mut app_declarations,