mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
dce673905a
commit
772c5c5195
@@ -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,
|
||||
}],
|
||||
|
||||
Reference in New Issue
Block a user