[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
+241 -1
View File
@@ -2241,6 +2241,98 @@ async fn install_plugin_uses_manifest_version_for_non_curated_plugins() {
);
}
#[tokio::test]
async fn install_plugin_writes_marketplace_manifest_fallback_when_missing_plugin_json() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/quality-review");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap();
fs::write(
plugin_root.join("skills/thermo-nuclear-code-quality-review/SKILL.md"),
"review skill",
)
.unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "quality-review",
"description": "Strict code quality review focused on maintainability.",
"source": "./plugins/quality-review",
"author": {
"name": "Byron Grogan"
},
"skills": [
"./skills/thermo-nuclear-code-quality-review"
],
"category": "code-review"
}
]
}"#,
)
.unwrap();
let result = PluginsManager::new(tmp.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "quality-review".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
})
.await
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/quality-review/local");
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new("quality-review".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
}
);
assert!(!plugin_root.join(".codex-plugin/plugin.json").exists());
assert!(
!tmp.path()
.join("plugins/.marketplace-plugin-source-staging")
.exists()
);
let manifest = crate::manifest::load_plugin_manifest(&installed_path).unwrap();
assert_eq!(manifest.name, "quality-review");
assert_eq!(
manifest.description.as_deref(),
Some("Strict code quality review focused on maintainability.")
);
assert_eq!(
manifest.paths.skills,
vec![
AbsolutePathBuf::try_from(
installed_path.join("skills/thermo-nuclear-code-quality-review")
)
.unwrap()
]
);
let interface = manifest.interface.expect("fallback interface");
assert_eq!(interface.developer_name.as_deref(), Some("Byron Grogan"));
assert_eq!(interface.category.as_deref(), Some("code-review"));
let fallback_json: serde_json::Value = serde_json::from_str(
&fs::read_to_string(installed_path.join(".codex-plugin/plugin.json")).unwrap(),
)
.unwrap();
assert_eq!(
fallback_json["author"],
serde_json::json!({ "name": "Byron Grogan" })
);
assert_eq!(fallback_json["category"], "code-review");
}
#[tokio::test]
async fn install_plugin_supports_git_subdir_marketplace_sources() {
let tmp = tempfile::tempdir().unwrap();
@@ -2484,6 +2576,7 @@ enabled = false
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: true,
enabled: true,
},
@@ -2503,6 +2596,7 @@ enabled = false
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: true,
enabled: false,
},
@@ -2632,6 +2726,7 @@ plugins = true
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: false,
}]
@@ -2768,6 +2863,142 @@ plugins = true
assert!(api_key_outcome.plugin.apps.is_empty());
}
#[tokio::test]
async fn read_plugin_for_config_uses_marketplace_manifest_fallback_paths_for_local_source() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("sample-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": "./sample-plugin",
"apps": "./config/custom.app.json",
"mcpServers": {
"sample-mcp": {
"command": "sample-mcp"
}
}
}
]
}"#,
);
write_file(
&plugin_root.join("config/custom.app.json"),
r#"{"apps":{"sample-app":{"id":"connector_sample"}}}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let manager = PluginsManager::new(tmp.path().to_path_buf());
let outcome = manager
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
assert_eq!(
outcome.plugin.apps,
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
outcome.plugin.mcp_server_names,
vec!["sample-mcp".to_string()]
);
let listed_plugin = manager
.list_marketplaces_for_config(
&config,
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
/*include_openai_curated*/ false,
)
.unwrap()
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "debug")
.unwrap()
.plugins
.into_iter()
.find(|plugin| plugin.name == "sample-plugin")
.unwrap();
let listed_detail = manager
.read_plugin_detail_for_marketplace_plugin(&config, "debug", listed_plugin)
.await
.unwrap();
assert_eq!(
listed_detail.apps,
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
listed_detail.mcp_server_names,
vec!["sample-mcp".to_string()]
);
}
#[tokio::test]
async fn read_plugin_for_config_does_not_fallback_from_invalid_plugin_manifest() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("sample-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": "./sample-plugin",
"description": "Fallback metadata"
}
]
}"#,
);
write_file(&plugin_root.join(".codex-plugin/plugin.json"), "{");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let err = PluginsManager::new(tmp.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap_err();
assert_eq!(err.to_string(), "missing or invalid plugin.json");
}
#[tokio::test]
async fn read_plugin_for_config_uses_user_layer_skill_settings_only() {
let tmp = tempfile::tempdir().unwrap();
@@ -3150,8 +3381,11 @@ enabled = true
.find(|marketplace| marketplace.name == "debug")
.expect("debug marketplace should be listed");
let mut plugins = marketplace.plugins;
assert!(plugins[0].manifest_fallback.is_some());
plugins[0].manifest_fallback = None;
assert_eq!(
marketplace.plugins,
plugins,
vec![ConfiguredMarketplacePlugin {
id: "toolkit@debug".to_string(),
name: "toolkit".to_string(),
@@ -3186,6 +3420,7 @@ enabled = true
..Default::default()
}),
keywords: Vec::new(),
manifest_fallback: None,
installed: true,
enabled: true,
}]
@@ -3266,6 +3501,7 @@ plugins = true
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: false,
}],
@@ -3388,6 +3624,7 @@ plugins = true
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: false,
}],
@@ -3815,6 +4052,7 @@ enabled = false
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: true,
}]
@@ -3847,6 +4085,7 @@ enabled = false
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: false,
}]
@@ -3937,6 +4176,7 @@ enabled = true
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: true,
}],