[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 authored and GitHub committed 2026-06-18 15:49:27 -07:00
1 parent dce673905a
commit 772c5c5195
7 files changed
+897 -47

No files matched your search

+157 -1
View File
@@ -8,6 +8,7 @@ use codex_plugin::PluginIdError;
use codex_protocol::protocol::Product;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
@@ -29,6 +30,7 @@ pub struct ResolvedMarketplacePlugin {
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
pub manifest: Option<crate::manifest::PluginManifest>,
pub manifest_fallback: MarketplacePluginManifestFallback,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -56,6 +58,57 @@ pub struct MarketplaceInterface {
pub display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplacePluginManifestFallback {
contents: String,
has_metadata: bool,
}
impl MarketplacePluginManifestFallback {
pub fn contents(&self) -> &str {
&self.contents
}
pub(crate) fn contents_if_has_metadata(&self) -> Option<&str> {
self.has_metadata.then_some(self.contents())
}
pub(crate) fn parse_for_plugin_root(
&self,
plugin_root: &Path,
) -> Option<crate::manifest::PluginManifest> {
crate::manifest::parse_plugin_manifest(
plugin_root,
&fallback_plugin_manifest_path(plugin_root),
&self.contents,
)
.ok()
}
pub(crate) fn parse_for_listing(&self) -> Option<crate::manifest::PluginManifest> {
// Git sources have no plugin root before install. Parse against a synthetic absolute root,
// then discard path-bearing fields so listings expose metadata only.
let mut manifest = crate::manifest::parse_plugin_manifest(
Path::new("/"),
Path::new("/.codex-plugin/plugin.json"),
&self.contents,
)
.ok()?;
manifest.paths = crate::manifest::PluginManifestPaths {
skills: Vec::new(),
mcp_servers: None,
apps: None,
hooks: None,
};
if let Some(interface) = manifest.interface.as_mut() {
interface.composer_icon = None;
interface.logo = None;
interface.screenshots.clear();
}
Some(manifest)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplacePlugin {
pub name: String,
@@ -64,6 +117,7 @@ pub struct MarketplacePlugin {
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
pub keywords: Vec<String>,
pub manifest_fallback: Option<MarketplacePluginManifestFallback>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -313,6 +367,10 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
Err(err) => return Err(err),
};
let manifest_fallback = plugin
.manifest_fallback
.contents_if_has_metadata()
.map(|_| plugin.manifest_fallback.clone());
let local_version = plugin
.manifest
.as_ref()
@@ -329,6 +387,7 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
policy: plugin.policy,
interface: plugin.interface,
keywords,
manifest_fallback,
});
}
@@ -434,13 +493,27 @@ fn resolve_marketplace_plugin_entry(
source,
policy,
category,
manifest_fields,
} = plugin;
let Some(source) = resolve_supported_plugin_source(marketplace_path, &name, source) else {
return Ok(None);
};
let manifest_fallback =
marketplace_plugin_manifest_fallback(&name, category.as_deref(), &manifest_fields);
let manifest = match &source {
MarketplacePluginSource::Local { path } => load_plugin_manifest(path.as_path()),
MarketplacePluginSource::Local { path } => {
if codex_utils_plugins::find_plugin_manifest_path(path.as_path()).is_some() {
load_plugin_manifest(path.as_path())
} else if manifest_fallback.has_metadata {
manifest_fallback.parse_for_plugin_root(path.as_path())
} else {
None
}
}
MarketplacePluginSource::Git { .. } if manifest_fallback.has_metadata => {
manifest_fallback.parse_for_listing()
}
MarketplacePluginSource::Git { .. } => None,
};
let interface = plugin_interface_with_marketplace_category(
@@ -462,6 +535,7 @@ fn resolve_marketplace_plugin_entry(
},
interface,
manifest,
manifest_fallback,
}))
}
@@ -765,6 +839,9 @@ struct RawMarketplaceManifestPlugin {
policy: RawMarketplaceManifestPluginPolicy,
#[serde(default)]
category: Option<String>,
#[serde(default)]
#[serde(flatten)]
manifest_fields: JsonMap<String, JsonValue>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
@@ -822,6 +899,85 @@ fn resolve_marketplace_interface(
}
}
fn fallback_plugin_manifest_path(plugin_root: &Path) -> PathBuf {
plugin_root.join(".codex-plugin/plugin.json")
}
fn marketplace_plugin_manifest_fallback(
name: &str,
category: Option<&str>,
manifest_fields: &JsonMap<String, JsonValue>,
) -> MarketplacePluginManifestFallback {
let mut manifest = manifest_fields.clone();
manifest.insert("name".to_string(), JsonValue::String(name.to_string()));
if let Some(category) = category {
manifest.insert(
"category".to_string(),
JsonValue::String(category.to_string()),
);
}
if let Some(interface) = plugin_manifest_interface(manifest_fields, category) {
manifest.insert("interface".to_string(), interface);
}
let contents = serde_json::to_string_pretty(&JsonValue::Object(manifest))
.unwrap_or_else(|_| format!(r#"{{"name":"{name}"}}"#));
MarketplacePluginManifestFallback {
contents,
has_metadata: !manifest_fields.is_empty() || category.is_some(),
}
}
fn plugin_manifest_interface(
fields: &JsonMap<String, JsonValue>,
category: Option<&str>,
) -> Option<JsonValue> {
let mut interface = fields
.get("interface")
.and_then(JsonValue::as_object)
.cloned()
.unwrap_or_default();
if !interface.contains_key("displayName")
&& let Some(display_name) = fields.get("displayName").and_then(JsonValue::as_str)
{
interface.insert(
"displayName".to_string(),
JsonValue::String(display_name.to_string()),
);
}
if !interface.contains_key("developerName")
&& let Some(author_name) = fields
.get("author")
.and_then(|author| author.get("name"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
interface.insert(
"developerName".to_string(),
JsonValue::String(author_name.to_string()),
);
}
if !interface.contains_key("websiteUrl")
&& !interface.contains_key("websiteURL")
&& let Some(homepage) = fields.get("homepage").and_then(JsonValue::as_str)
{
interface.insert(
"websiteUrl".to_string(),
JsonValue::String(homepage.to_string()),
);
}
if let Some(category) = category.map(str::trim).filter(|value| !value.is_empty()) {
interface.insert(
"category".to_string(),
JsonValue::String(category.to_string()),
);
}
(!interface.is_empty()).then_some(JsonValue::Object(interface))
}
#[cfg(test)]
#[path = "marketplace_tests.rs"]
mod tests;