feat: Handle alternate plugin manifest paths (#18182)

Load plugin manifests through a shared discoverable-path helper so
manifest reads, installs, and skill names all see the same alternate
manifest location.
This commit is contained in:
xl-openai
2026-04-16 19:43:19 -07:00
committed by GitHub
Unverified
parent a803790a10
commit 37161bc76e
13 changed files with 581 additions and 222 deletions
@@ -34,6 +34,8 @@ use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> {
std::fs::write(
@@ -246,6 +248,138 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
Ok(())
}
#[tokio::test]
async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverable_plugins()
-> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
let valid_plugin_root = repo_root.path().join("plugins/valid-plugin");
std::fs::create_dir_all(repo_root.path().join(".git"))?;
std::fs::create_dir_all(
repo_root
.path()
.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH)
.parent()
.unwrap(),
)?;
std::fs::create_dir_all(
valid_plugin_root
.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH)
.parent()
.unwrap(),
)?;
write_plugins_enabled_config(codex_home.path())?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(ALTERNATE_MARKETPLACE_RELATIVE_PATH))?;
let valid_plugin_path = AbsolutePathBuf::try_from(valid_plugin_root.clone())?;
std::fs::write(
marketplace_path.as_path(),
r#"{
"name": "alternate-marketplace",
"plugins": [
{
"name": "valid-plugin",
"source": "./plugins/valid-plugin"
},
{
"name": "missing-plugin",
"source": "./plugins/missing-plugin"
}
]
}"#,
)?;
std::fs::write(
valid_plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH),
r#"{
"name": "valid-plugin",
"interface": {
"displayName": "Valid Plugin"
}
}"#,
)?;
let home = codex_home.path().to_string_lossy().into_owned();
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[
("HOME", Some(home.as_str())),
("USERPROFILE", Some(home.as_str())),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
force_remote_sync: false,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert_eq!(
response.marketplaces,
vec![PluginMarketplaceEntry {
name: "alternate-marketplace".to_string(),
path: marketplace_path,
interface: None,
plugins: vec![
PluginSummary {
id: "valid-plugin@alternate-marketplace".to_string(),
name: "valid-plugin".to_string(),
source: PluginSource::Local {
path: valid_plugin_path,
},
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnInstall,
interface: Some(codex_app_server_protocol::PluginInterface {
display_name: Some("Valid Plugin".to_string()),
short_description: None,
long_description: None,
developer_name: None,
category: None,
capabilities: Vec::new(),
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
logo: None,
screenshots: Vec::new(),
}),
},
PluginSummary {
id: "missing-plugin@alternate-marketplace".to_string(),
name: "missing-plugin".to_string(),
source: PluginSource::Local {
path: AbsolutePathBuf::try_from(
repo_root.path().join("plugins/missing-plugin"),
)?,
},
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnInstall,
interface: None,
},
],
}]
);
assert!(response.marketplace_load_errors.is_empty());
Ok(())
}
#[tokio::test]
async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -512,11 +512,7 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() -
.await??;
assert_eq!(err.error.code, -32600);
assert!(
err.error
.message
.contains("missing or invalid .codex-plugin/plugin.json")
);
assert!(err.error.message.contains("missing or invalid plugin.json"));
Ok(())
}
+1 -1
View File
@@ -462,7 +462,7 @@ async fn load_plugin(
}
let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else {
loaded_plugin.error = Some("missing or invalid .codex-plugin/plugin.json".to_string());
loaded_plugin.error = Some("missing or invalid plugin.json".to_string());
return loaded_plugin;
};
+46 -10
View File
@@ -1,5 +1,5 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
use codex_utils_plugins::find_plugin_manifest_path;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
@@ -115,10 +115,7 @@ enum RawPluginManifestDefaultPromptEntry {
}
pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return None;
}
let manifest_path = find_plugin_manifest_path(plugin_root)?;
let contents = fs::read_to_string(&manifest_path).ok()?;
match serde_json::from_str::<RawPluginManifest>(&contents) {
Ok(manifest) => {
@@ -319,11 +316,14 @@ fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) ->
}
fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
tracing::warn!(
path = %manifest_path.display(),
"ignoring {field}: {message}"
);
if let Some(manifest_path) = find_plugin_manifest_path(plugin_root) {
tracing::warn!(
path = %manifest_path.display(),
"ignoring {field}: {message}"
);
} else {
tracing::warn!("ignoring {field}: {message}");
}
}
fn json_value_type(value: &JsonValue) -> &'static str {
@@ -390,6 +390,8 @@ mod tests {
use std::path::Path;
use tempfile::tempdir;
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) {
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
let version = version
@@ -408,6 +410,13 @@ mod tests {
.expect("write manifest");
}
fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) {
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("create manifest dir");
fs::write(manifest_path, contents).expect("write manifest");
}
fn load_manifest(plugin_root: &Path) -> PluginManifest {
load_plugin_manifest(plugin_root).expect("load plugin manifest")
}
@@ -506,4 +515,31 @@ mod tests {
assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string()));
}
#[test]
fn plugin_manifest_uses_alternate_discoverable_path() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_alternate_plugin_manifest(
&plugin_root,
r#"{
"name": "demo-plugin",
"version": " 2.0.0 ",
"interface": {
"displayName": "Fallback Plugin"
}
}"#,
);
let manifest = load_manifest(&plugin_root);
assert_eq!(manifest.version, Some("2.0.0".to_string()));
assert_eq!(
manifest
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("Fallback Plugin")
);
}
}
+93 -65
View File
@@ -26,8 +26,10 @@ const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedMarketplacePlugin {
pub plugin_id: PluginId,
pub source_path: AbsolutePathBuf,
pub auth_policy: MarketplacePluginAuthPolicy,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
pub manifest: Option<crate::manifest::PluginManifest>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -158,12 +160,9 @@ impl MarketplaceError {
}
}
// Always read the specified marketplace file from disk so installs see the
// latest marketplace.json contents without any in-memory cache invalidation.
pub fn resolve_marketplace_plugin(
pub fn find_marketplace_plugin(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
restriction_product: Option<Product>,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
let marketplace = load_raw_marketplace_manifest(marketplace_path)?;
let marketplace_name = marketplace.name;
@@ -173,39 +172,11 @@ pub fn resolve_marketplace_plugin(
continue;
}
let RawMarketplaceManifestPlugin {
name,
source,
policy,
..
} = plugin;
let install_policy = policy.installation;
let product_allowed = match policy.products.as_deref() {
None => true,
Some([]) => false,
Some(products) => restriction_product
.is_some_and(|product| product.matches_product_restriction(products)),
};
if install_policy == MarketplacePluginInstallPolicy::NotAvailable || !product_allowed {
return Err(MarketplaceError::PluginNotAvailable {
plugin_name: name,
marketplace_name,
});
if let Some(plugin) =
resolve_marketplace_plugin_entry(marketplace_path, &marketplace_name, plugin)?
{
return Ok(plugin);
}
let Some(source_path) =
resolve_supported_plugin_source_path(marketplace_path, &name, source)
else {
continue;
};
return Ok(ResolvedMarketplacePlugin {
plugin_id: PluginId::new(name, marketplace_name).map_err(|err| match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
})?,
source_path,
auth_policy: policy.authentication,
});
}
Err(MarketplaceError::PluginNotFound {
@@ -214,6 +185,31 @@ pub fn resolve_marketplace_plugin(
})
}
pub fn find_installable_marketplace_plugin(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
restriction_product: Option<Product>,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
let resolved = find_marketplace_plugin(marketplace_path, plugin_name)?;
let product_allowed = match resolved.policy.products.as_deref() {
None => true,
Some([]) => false,
Some(products) => {
restriction_product.is_some_and(|product| product.matches_product_restriction(products))
}
};
if resolved.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|| !product_allowed
{
return Err(MarketplaceError::PluginNotAvailable {
plugin_name: resolved.plugin_id.plugin_name,
marketplace_name: resolved.plugin_id.marketplace_name,
});
}
Ok(resolved)
}
pub fn list_marketplaces(
additional_roots: &[AbsolutePathBuf],
) -> Result<MarketplaceListOutcome, MarketplaceError> {
@@ -270,36 +266,26 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
let mut plugins = Vec::new();
for plugin in marketplace.plugins {
let RawMarketplaceManifestPlugin {
name,
source,
policy,
category,
} = plugin;
let Some(source_path) = resolve_supported_plugin_source_path(path, &name, source) else {
continue;
let plugin = match resolve_marketplace_plugin_entry(path, &marketplace.name, plugin) {
Ok(Some(plugin)) => plugin,
Ok(None) => continue,
Err(MarketplaceError::InvalidPlugin(message)) => {
warn!(
path = %path.display(),
marketplace = %marketplace.name,
error = %message,
"skipping invalid marketplace plugin"
);
continue;
}
Err(err) => return Err(err),
};
let source = MarketplacePluginSource::Local {
path: source_path.clone(),
};
let mut interface =
load_plugin_manifest(source_path.as_path()).and_then(|manifest| manifest.interface);
if let Some(category) = category {
// Marketplace taxonomy wins when both sources provide a category.
interface
.get_or_insert_with(PluginManifestInterface::default)
.category = Some(category);
}
plugins.push(MarketplacePlugin {
name,
source,
policy: MarketplacePluginPolicy {
installation: policy.installation,
authentication: policy.authentication,
products: policy.products,
},
interface,
name: plugin.plugin_id.plugin_name,
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
});
}
@@ -389,6 +375,48 @@ fn load_raw_marketplace_manifest(
})
}
fn resolve_marketplace_plugin_entry(
marketplace_path: &AbsolutePathBuf,
marketplace_name: &str,
plugin: RawMarketplaceManifestPlugin,
) -> Result<Option<ResolvedMarketplacePlugin>, MarketplaceError> {
let RawMarketplaceManifestPlugin {
name,
source,
policy,
category,
} = plugin;
let Some(source_path) = resolve_supported_plugin_source_path(marketplace_path, &name, source)
else {
return Ok(None);
};
let manifest = load_plugin_manifest(source_path.as_path());
let mut interface = manifest
.as_ref()
.and_then(|manifest| manifest.interface.clone());
if let Some(category) = category {
// Marketplace taxonomy wins when both sources provide a category.
interface
.get_or_insert_with(PluginManifestInterface::default)
.category = Some(category);
}
Ok(Some(ResolvedMarketplacePlugin {
plugin_id: PluginId::new(name, marketplace_name.to_string()).map_err(|err| match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
})?,
source: MarketplacePluginSource::Local { path: source_path },
policy: MarketplacePluginPolicy {
installation: policy.installation,
authentication: policy.authentication,
products: policy.products,
},
interface,
manifest,
}))
}
fn resolve_supported_plugin_source_path(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
+190 -40
View File
@@ -5,6 +5,7 @@ use std::path::Path;
use tempfile::tempdir;
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH);
@@ -13,8 +14,14 @@ fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePath
AbsolutePathBuf::try_from(marketplace_path).unwrap()
}
fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) {
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
fs::write(manifest_path, contents).unwrap();
}
#[test]
fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() {
fn find_marketplace_plugin_finds_repo_marketplace_plugin() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -37,10 +44,9 @@ fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() {
)
.unwrap();
let resolved = resolve_marketplace_plugin(
let resolved = find_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
Some(Product::Codex),
)
.unwrap();
@@ -49,14 +55,22 @@ fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() {
ResolvedMarketplacePlugin {
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
manifest: None,
}
);
}
#[test]
fn resolve_marketplace_plugin_supports_alternate_layout_and_string_local_source() {
fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -73,12 +87,7 @@ fn resolve_marketplace_plugin_supports_alternate_layout_and_string_local_source(
}"#,
);
let resolved = resolve_marketplace_plugin(
&marketplace_path,
"string-source-plugin",
Some(Product::Codex),
)
.unwrap();
let resolved = find_marketplace_plugin(&marketplace_path, "string-source-plugin").unwrap();
assert_eq!(
resolved,
@@ -88,15 +97,23 @@ fn resolve_marketplace_plugin_supports_alternate_layout_and_string_local_source(
"alternate-marketplace".to_string()
)
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin"))
.unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin"))
.unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
manifest: None,
}
);
}
#[test]
fn resolve_marketplace_plugin_reports_missing_plugin() {
fn find_marketplace_plugin_reports_missing_plugin() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -107,10 +124,9 @@ fn resolve_marketplace_plugin_reports_missing_plugin() {
)
.unwrap();
let err = resolve_marketplace_plugin(
let err = find_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"missing",
Some(Product::Codex),
)
.unwrap_err();
@@ -124,8 +140,18 @@ fn resolve_marketplace_plugin_reports_missing_plugin() {
fn list_marketplaces_supports_alternate_manifest_layout() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/string-source-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
write_alternate_plugin_manifest(
&plugin_root,
r#"{
"name":"string-source-plugin",
"interface": {
"displayName": "String Source Plugin"
}
}"#,
);
let marketplace_path = write_alternate_marketplace(
&repo_root,
r#"{
@@ -163,6 +189,70 @@ fn list_marketplaces_supports_alternate_manifest_layout() {
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: Some(PluginManifestInterface {
display_name: Some("String Source Plugin".to_string()),
short_description: None,
long_description: None,
developer_name: None,
category: None,
capabilities: Vec::new(),
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
logo: None,
screenshots: Vec::new(),
}),
}],
}]
);
}
#[test]
fn list_marketplaces_includes_plugins_without_discoverable_manifest() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
let marketplace_path = write_alternate_marketplace(
&repo_root,
r#"{
"name": "alternate-marketplace",
"plugins": [
{
"name": "missing-plugin",
"source": "./plugins/missing-plugin"
}
]
}"#,
);
let marketplaces = list_marketplaces_with_home(
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
/*home_dir*/ None,
)
.unwrap()
.marketplaces;
assert_eq!(
marketplaces,
vec![Marketplace {
name: "alternate-marketplace".to_string(),
path: marketplace_path,
interface: None,
plugins: vec![MarketplacePlugin {
name: "missing-plugin".to_string(),
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugins/missing-plugin"),)
.unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
}],
}]
@@ -448,16 +538,17 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
]
);
let resolved = resolve_marketplace_plugin(
let resolved = find_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_marketplace).unwrap(),
"local-plugin",
Some(Product::Codex),
)
.unwrap();
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap()
resolved.source,
MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
}
);
}
@@ -621,6 +712,67 @@ fn list_marketplaces_skips_invalid_plugins_but_keeps_marketplace() {
assert!(marketplaces[1].plugins.is_empty());
}
#[test]
fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "invalid-name-marketplace",
"plugins": [
{
"name": "valid-plugin",
"source": {
"source": "local",
"path": "./valid-plugin"
}
},
{
"name": "invalid.plugin",
"source": {
"source": "local",
"path": "./invalid-plugin"
}
}
]
}"#,
)
.unwrap();
let marketplaces = list_marketplaces_with_home(
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
/*home_dir*/ None,
)
.unwrap()
.marketplaces;
assert_eq!(
marketplaces,
vec![Marketplace {
name: "invalid-name-marketplace".to_string(),
path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
.unwrap(),
interface: None,
plugins: vec![MarketplacePlugin {
name: "valid-plugin".to_string(),
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("valid-plugin")).unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
}],
}]
);
}
#[test]
fn list_marketplaces_reports_marketplace_load_errors() {
let tmp = tempdir().unwrap();
@@ -940,7 +1092,7 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
}
#[test]
fn resolve_marketplace_plugin_skips_invalid_local_paths() {
fn find_marketplace_plugin_skips_invalid_local_paths() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -962,10 +1114,9 @@ fn resolve_marketplace_plugin_skips_invalid_local_paths() {
)
.unwrap();
let err = resolve_marketplace_plugin(
let err = find_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
Some(Product::Codex),
)
.unwrap_err();
@@ -976,7 +1127,7 @@ fn resolve_marketplace_plugin_skips_invalid_local_paths() {
}
#[test]
fn resolve_marketplace_plugin_skips_unsupported_sources() {
fn find_marketplace_plugin_skips_unsupported_sources() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -996,8 +1147,7 @@ fn resolve_marketplace_plugin_skips_unsupported_sources() {
}"#,
);
let err = resolve_marketplace_plugin(&marketplace_path, "remote-plugin", Some(Product::Codex))
.unwrap_err();
let err = find_marketplace_plugin(&marketplace_path, "remote-plugin").unwrap_err();
assert_eq!(
err.to_string(),
@@ -1006,7 +1156,7 @@ fn resolve_marketplace_plugin_skips_unsupported_sources() {
}
#[test]
fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
fn find_marketplace_plugin_uses_first_duplicate_entry() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -1035,21 +1185,22 @@ fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
)
.unwrap();
let resolved = resolve_marketplace_plugin(
let resolved = find_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
Some(Product::Codex),
)
.unwrap();
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join("first")).unwrap()
resolved.source,
MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("first")).unwrap(),
}
);
}
#[test]
fn resolve_marketplace_plugin_rejects_disallowed_product() {
fn find_installable_marketplace_plugin_rejects_disallowed_product() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -1074,7 +1225,7 @@ fn resolve_marketplace_plugin_rejects_disallowed_product() {
)
.unwrap();
let err = resolve_marketplace_plugin(
let err = find_installable_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"chatgpt-plugin",
Some(Product::Atlas),
@@ -1088,7 +1239,7 @@ fn resolve_marketplace_plugin_rejects_disallowed_product() {
}
#[test]
fn resolve_marketplace_plugin_allows_missing_products_field() {
fn find_marketplace_plugin_allows_missing_products_field() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -1111,10 +1262,9 @@ fn resolve_marketplace_plugin_allows_missing_products_field() {
)
.unwrap();
let resolved = resolve_marketplace_plugin(
let resolved = find_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"default-plugin",
Some(Product::Codex),
)
.unwrap();
@@ -1122,7 +1272,7 @@ fn resolve_marketplace_plugin_allows_missing_products_field() {
}
#[test]
fn resolve_marketplace_plugin_rejects_explicit_empty_products() {
fn find_installable_marketplace_plugin_rejects_explicit_empty_products() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -1147,7 +1297,7 @@ fn resolve_marketplace_plugin_rejects_explicit_empty_products() {
)
.unwrap();
let err = resolve_marketplace_plugin(
let err = find_installable_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"disabled-plugin",
Some(Product::Codex),
+15 -38
View File
@@ -3,7 +3,7 @@ use crate::manifest::load_plugin_manifest;
use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
use codex_utils_plugins::find_plugin_manifest_path;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
@@ -114,7 +114,7 @@ impl PluginStore {
let plugin_name = plugin_name_for_source(source_path.as_path())?;
if plugin_name != plugin_id.plugin_name {
return Err(PluginStoreError::Invalid(format!(
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
"plugin.json name `{plugin_name}` does not match marketplace plugin name `{}`",
plugin_id.plugin_name
)));
}
@@ -184,20 +184,8 @@ fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> {
}
fn plugin_manifest_for_source(source_path: &Path) -> Result<PluginManifest, PluginStoreError> {
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return Err(PluginStoreError::Invalid(format!(
"missing plugin manifest: {}",
manifest_path.display()
)));
}
load_plugin_manifest(source_path).ok_or_else(|| {
PluginStoreError::Invalid(format!(
"missing or invalid plugin manifest: {}",
manifest_path.display()
))
})
load_plugin_manifest(source_path)
.ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string()))
}
#[derive(Debug, Deserialize)]
@@ -210,37 +198,26 @@ struct RawPluginManifestVersion {
fn plugin_manifest_version_for_source(
source_path: &Path,
) -> Result<Option<String>, PluginStoreError> {
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return Err(PluginStoreError::Invalid(format!(
"missing plugin manifest: {}",
manifest_path.display()
)));
}
let manifest_path = find_plugin_manifest_path(source_path)
.ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?;
let contents = fs::read_to_string(&manifest_path)
.map_err(|err| PluginStoreError::io("failed to read plugin manifest", err))?;
let manifest: RawPluginManifestVersion = serde_json::from_str(&contents).map_err(|err| {
PluginStoreError::Invalid(format!(
"failed to parse plugin manifest {}: {err}",
manifest_path.display()
))
})?;
.map_err(|err| PluginStoreError::io("failed to read plugin.json", err))?;
let manifest: RawPluginManifestVersion = serde_json::from_str(&contents)
.map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}")))?;
let Some(version) = manifest.version else {
return Ok(None);
};
let Some(version) = version.as_str() else {
return Err(PluginStoreError::Invalid(format!(
"invalid plugin version in manifest {}: expected string",
manifest_path.display()
)));
return Err(PluginStoreError::Invalid(
"invalid plugin version in plugin.json: expected string".to_string(),
));
};
let version = version.trim();
if version.is_empty() {
return Err(PluginStoreError::Invalid(format!(
"invalid plugin version in manifest {}: must not be blank",
manifest_path.display()
)));
return Err(PluginStoreError::Invalid(
"invalid plugin version in plugin.json: must not be blank".to_string(),
));
}
Ok(Some(version.to_string()))
}
+4 -8
View File
@@ -173,13 +173,9 @@ fn install_rejects_blank_manifest_version() {
.expect_err("blank manifest version should be rejected");
let err = err.to_string().replace('\\', "/");
assert!(
err.starts_with("invalid plugin version in manifest "),
"unexpected error: {err}"
);
assert!(
err.ends_with("sample-plugin/.codex-plugin/plugin.json: must not be blank"),
"unexpected error: {err}"
assert_eq!(
err,
"invalid plugin version in plugin.json: must not be blank"
);
}
@@ -305,6 +301,6 @@ fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() {
assert_eq!(
err.to_string(),
"plugin manifest name `manifest-name` does not match marketplace plugin name `different-name`"
"plugin.json name `manifest-name` does not match marketplace plugin name `different-name`"
);
}
+27 -44
View File
@@ -35,9 +35,10 @@ use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy;
use codex_core_plugins::marketplace::MarketplacePluginPolicy;
use codex_core_plugins::marketplace::MarketplacePluginSource;
use codex_core_plugins::marketplace::ResolvedMarketplacePlugin;
use codex_core_plugins::marketplace::find_installable_marketplace_plugin;
use codex_core_plugins::marketplace::find_marketplace_plugin;
use codex_core_plugins::marketplace::list_marketplaces;
use codex_core_plugins::marketplace::load_marketplace;
use codex_core_plugins::marketplace::resolve_marketplace_plugin;
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
use codex_core_plugins::marketplace_upgrade::configured_git_marketplace_names;
@@ -535,7 +536,7 @@ impl PluginsManager {
&self,
request: PluginInstallRequest,
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = resolve_marketplace_plugin(
let resolved = find_installable_marketplace_plugin(
&request.marketplace_path,
&request.plugin_name,
self.restriction_product,
@@ -549,7 +550,7 @@ impl PluginsManager {
auth: Option<&CodexAuth>,
request: PluginInstallRequest,
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = resolve_marketplace_plugin(
let resolved = find_installable_marketplace_plugin(
&request.marketplace_path,
&request.plugin_name,
self.restriction_product,
@@ -572,7 +573,7 @@ impl PluginsManager {
&self,
resolved: ResolvedMarketplacePlugin,
) -> Result<PluginInstallOutcome, PluginInstallError> {
let auth_policy = resolved.auth_policy;
let auth_policy = resolved.policy.authentication;
let plugin_version =
if resolved.plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
Some(
@@ -587,10 +588,11 @@ impl PluginsManager {
};
let store = self.store.clone();
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
let MarketplacePluginSource::Local { path: source_path } = resolved.source;
if let Some(plugin_version) = plugin_version {
store.install_with_version(resolved.source_path, resolved.plugin_id, plugin_version)
store.install_with_version(source_path, resolved.plugin_id, plugin_version)
} else {
store.install(resolved.source_path, resolved.plugin_id)
store.install(source_path, resolved.plugin_id)
}
})
.await
@@ -978,32 +980,24 @@ impl PluginsManager {
return Err(MarketplaceError::PluginsDisabled);
}
let marketplace = load_marketplace(&request.marketplace_path)?;
let marketplace_name = marketplace.name.clone();
let plugin = marketplace
.plugins
.into_iter()
.find(|plugin| plugin.name == request.plugin_name);
let Some(plugin) = plugin else {
let plugin = find_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
return Err(MarketplaceError::PluginNotFound {
plugin_name: request.plugin_name.clone(),
marketplace_name,
plugin_name: plugin.plugin_id.plugin_name,
marketplace_name: plugin.plugin_id.marketplace_name,
});
};
let plugin_id = PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err(
|err| match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
},
)?;
let plugin_key = plugin_id.as_key();
}
let marketplace_name = plugin.plugin_id.marketplace_name.clone();
let plugin_key = plugin.plugin_id.as_key();
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
let plugin = self
.read_plugin_detail_for_marketplace_plugin(
config,
&marketplace.name,
&marketplace_name,
ConfiguredMarketplacePlugin {
id: plugin_key.clone(),
name: plugin.name,
name: plugin.plugin_id.plugin_name,
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
@@ -1014,12 +1008,12 @@ impl PluginsManager {
.await?;
Ok(PluginReadOutcome {
marketplace_name: if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME {
marketplace_name: if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()
} else {
marketplace.name
marketplace_name
},
marketplace_path: marketplace.path,
marketplace_path: request.marketplace_path.clone(),
plugin,
})
}
@@ -1037,13 +1031,6 @@ impl PluginsManager {
});
}
let plugin_id =
PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| {
match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
}
})?;
let plugin_key = plugin_id.as_key();
let source_path = match &plugin.source {
MarketplacePluginSource::Local { path } => path.clone(),
};
@@ -1053,20 +1040,16 @@ impl PluginsManager {
));
}
let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| {
MarketplaceError::InvalidPlugin(
"missing or invalid .codex-plugin/plugin.json".to_string(),
)
MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string())
})?;
let description = manifest.description.clone();
let manifest_paths = &manifest.paths;
let skill_config_rules = codex_core_skills::config_rules::skill_config_rules_from_stack(
&config.config_layer_stack,
);
let resolved_skills = load_plugin_skills(
&source_path,
manifest_paths,
&manifest.paths,
self.restriction_product,
&skill_config_rules,
&codex_core_skills::config_rules::skill_config_rules_from_stack(
&config.config_layer_stack,
),
)
.await;
let apps = load_plugin_apps(source_path.as_path()).await;
@@ -1078,7 +1061,7 @@ impl PluginsManager {
mcp_server_names.dedup();
Ok(PluginDetail {
id: plugin_key,
id: plugin.id,
name: plugin.name,
description,
source: plugin.source,
+24 -3
View File
@@ -1578,7 +1578,13 @@ source = "/tmp/debug"
let marketplace = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "debug")
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(
marketplace_root.join(".agents/plugins/marketplace.json"),
)
.unwrap()
})
.expect("installed marketplace should be listed");
assert_eq!(
@@ -1648,7 +1654,13 @@ source = "/tmp/debug"
let marketplace = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "debug")
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(
marketplace_root.join(".agents/plugins/marketplace.json"),
)
.unwrap()
})
.expect("configured marketplace should be discovered");
assert_eq!(marketplace.plugins[0].id, "sample@debug");
@@ -1695,7 +1707,16 @@ plugins = true
.unwrap()
.marketplaces;
assert!(marketplaces.is_empty());
assert!(
marketplaces.iter().all(|marketplace| {
marketplace.path
!= AbsolutePathBuf::try_from(
marketplace_root.join(".agents/plugins/marketplace.json"),
)
.unwrap()
}),
"installed marketplace root missing from config should not be listed"
);
}
#[tokio::test]
-1
View File
@@ -1,6 +1,5 @@
//! Shared plugin identifiers and telemetry-facing summaries.
pub use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
pub use codex_utils_plugins::mention_syntax;
pub use codex_utils_plugins::plugin_namespace_for_skill_path;
+1 -1
View File
@@ -5,5 +5,5 @@ pub mod mcp_connector;
pub mod mention_syntax;
pub mod plugin_namespace;
pub use plugin_namespace::PLUGIN_MANIFEST_PATH;
pub use plugin_namespace::find_plugin_manifest_path;
pub use plugin_namespace::plugin_namespace_for_skill_path;
+45 -6
View File
@@ -2,9 +2,18 @@
use codex_exec_server::ExecutorFileSystem;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Path;
use std::path::PathBuf;
/// Relative path from a plugin root to its manifest file.
pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] =
&[".codex-plugin/plugin.json", ".claude-plugin/plugin.json"];
pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
.iter()
.map(|relative_path| plugin_root.join(relative_path))
.find(|manifest_path| manifest_path.is_file())
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -17,11 +26,18 @@ async fn plugin_manifest_name(
fs: &dyn ExecutorFileSystem,
plugin_root: &AbsolutePathBuf,
) -> Option<String> {
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
match fs.get_metadata(&manifest_path, /*sandbox*/ None).await {
Ok(metadata) if metadata.is_file => {}
Ok(_) | Err(_) => return None,
let mut manifest_path = None;
for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
let candidate = plugin_root.join(relative_path);
match fs.get_metadata(&candidate, /*sandbox*/ None).await {
Ok(metadata) if metadata.is_file => {
manifest_path = Some(candidate);
break;
}
Ok(_) | Err(_) => {}
}
}
let manifest_path = manifest_path?;
let contents = fs
.read_file_text(&manifest_path, /*sandbox*/ None)
.await
@@ -53,12 +69,15 @@ pub async fn plugin_namespace_for_skill_path(
#[cfg(test)]
mod tests {
use super::find_plugin_manifest_path;
use super::plugin_namespace_for_skill_path;
use codex_exec_server::LOCAL_FS;
use codex_utils_absolute_path::test_support::PathBufExt;
use std::fs;
use tempfile::tempdir;
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
#[tokio::test]
async fn uses_manifest_name() {
let tmp = tempdir().expect("tempdir");
@@ -79,4 +98,24 @@ mod tests {
Some("sample".to_string())
);
}
#[tokio::test]
async fn uses_name_from_alternate_discoverable_manifest_path() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("plugins/sample");
let skill_path = plugin_root.join("skills/search/SKILL.md");
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("mkdir manifest");
fs::write(&manifest_path, r#"{"name":"sample"}"#).expect("write manifest");
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
assert_eq!(
plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
Some("sample".to_string())
);
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
}
}