diff --git a/codex-rs/core-plugins/src/agent_plugin_manifest.rs b/codex-rs/core-plugins/src/agent_plugin_manifest.rs new file mode 100644 index 0000000000..4f68f842f3 --- /dev/null +++ b/codex-rs/core-plugins/src/agent_plugin_manifest.rs @@ -0,0 +1,235 @@ +use super::RawPluginManifest; +use super::RawPluginManifestInterface; +use super::RawPluginManifestMcpServers; +use super::RawPluginManifestPaths; +use super::UriPluginManifest; +use super::compatibility_json_error; +use super::parse_legacy_plugin_manifest_uri; +use super::resolve_raw_plugin_manifest; +use codex_utils_path_uri::PathUri; +use codex_utils_plugins::AGENT_PLUGIN_SCHEMA_PREFIX; +use codex_utils_plugins::AGENT_PLUGIN_SCHEMA_URI; +use codex_utils_plugins::SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS; +use serde::Deserialize; +use serde_json::Value as JsonValue; + +const CODEX_AGENT_PLUGIN_EXTENSION_NAMESPACE: &str = "com.openai"; +const AGENT_PLUGIN_FIELDS: &[&str] = &[ + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", +]; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawAgentPluginManifest { + #[serde(rename = "$schema")] + schema: String, + name: String, + #[serde(default)] + version: Option, + #[serde(default)] + description: Option, + #[serde(default)] + author: Option, + #[serde(default)] + homepage: Option, + #[serde(default, rename = "repository")] + _repository: Option, + #[serde(default, rename = "license")] + _license: Option, + #[serde(default)] + keywords: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawAgentPluginAuthor { + #[serde(default)] + name: Option, + #[serde(default, rename = "email")] + _email: Option, + #[serde(default, rename = "url")] + _url: Option, +} + +pub(super) fn parse_agent_plugin_manifest_uri( + plugin_root: &PathUri, + manifest_path: &PathUri, + contents: &str, + overlay: Option<(&PathUri, &str)>, +) -> Result { + let value = serde_json::from_str::(contents)?; + let JsonValue::Object(mut object) = value else { + return Err(compatibility_json_error( + "Agent Plugins root `plugin.json` must contain a JSON object", + )); + }; + for field in object.keys() { + if !AGENT_PLUGIN_FIELDS.contains(&field.as_str()) { + tracing::warn!(path = %manifest_path, field, "ignoring unknown Agent Plugins manifest field"); + } + } + object.retain(|field, _| AGENT_PLUGIN_FIELDS.contains(&field.as_str())); + if object + .get("extensions") + .is_some_and(|extensions| !extensions.is_object()) + { + tracing::warn!(path = %manifest_path, "ignoring non-object Agent Plugins `extensions` field"); + object.remove("extensions"); + } + let codex_extension = object + .get("extensions") + .and_then(JsonValue::as_object) + .and_then(|extensions| extensions.get(CODEX_AGENT_PLUGIN_EXTENSION_NAMESPACE)) + .and_then(|extension| { + if extension.is_object() { + Some(serde_json::to_string(extension)) + } else { + tracing::warn!( + path = %manifest_path, + namespace = CODEX_AGENT_PLUGIN_EXTENSION_NAMESPACE, + "ignoring non-object Agent Plugins extension" + ); + None + } + }) + .transpose()?; + for field in [ + "version", + "description", + "author", + "homepage", + "repository", + "license", + ] { + if object.get(field).is_some_and(JsonValue::is_null) { + return Err(compatibility_json_error(format!( + "Agent Plugins `{field}` must use its declared type when present" + ))); + } + } + if let Some(author) = object.get("author").and_then(JsonValue::as_object) { + for field in ["name", "email", "url"] { + if author.get(field).is_some_and(JsonValue::is_null) { + return Err(compatibility_json_error(format!( + "Agent Plugins `author.{field}` must be a string when present" + ))); + } + } + } + + let raw = serde_json::from_value::(JsonValue::Object(object))?; + if !SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS.contains(&raw.schema.as_str()) { + let message = if raw.schema.starts_with(AGENT_PLUGIN_SCHEMA_PREFIX) { + format!( + "unsupported Agent Plugins schema `{}`; supported schemas: `{AGENT_PLUGIN_SCHEMA_URI}`", + raw.schema + ) + } else { + format!( + "root `plugin.json` is not an Agent Plugins manifest; expected `$schema` `{AGENT_PLUGIN_SCHEMA_URI}`" + ) + }; + return Err(compatibility_json_error(message)); + } + if !is_valid_agent_plugin_name(&raw.name) { + return Err(compatibility_json_error(format!( + "invalid Agent Plugins name `{}`; use lowercase letters, numbers, dots, or hyphens", + raw.name + ))); + } + + let version = raw.version.and_then(non_empty_trimmed); + let description = raw.description.and_then(non_empty_trimmed); + let developer_name = raw + .author + .and_then(|author| author.name) + .and_then(non_empty_trimmed); + let homepage = raw.homepage.and_then(non_empty_trimmed); + let name = raw.name; + let mut resolved = resolve_raw_plugin_manifest( + plugin_root, + manifest_path, + RawPluginManifest { + name: name.clone(), + version, + description: description.clone(), + keywords: raw.keywords, + skills: Some(RawPluginManifestPaths::Path("./skills".to_string())), + mcp_servers: Some(RawPluginManifestMcpServers::Path("./mcp.json".to_string())), + interface: Some(RawPluginManifestInterface { + display_name: Some(name), + short_description: description.clone(), + long_description: description, + developer_name, + category: Some("Other".to_string()), + website_url: homepage, + ..RawPluginManifestInterface::default() + }), + ..RawPluginManifest::default() + }, + )?; + + if let Some(extension_contents) = codex_extension.as_deref() { + apply_codex_agent_plugin_extension( + &mut resolved, + plugin_root, + manifest_path, + extension_contents, + )?; + } else if let Some((overlay_path, overlay_contents)) = overlay { + apply_codex_agent_plugin_extension( + &mut resolved, + plugin_root, + overlay_path, + overlay_contents, + )?; + } + Ok(resolved) +} + +fn non_empty_trimmed(value: String) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn apply_codex_agent_plugin_extension( + resolved: &mut UriPluginManifest, + plugin_root: &PathUri, + source_path: &PathUri, + contents: &str, +) -> Result<(), serde_json::Error> { + let extension = parse_legacy_plugin_manifest_uri(plugin_root, source_path, contents)?; + resolved.paths.apps = extension.paths.apps; + resolved.paths.hooks = extension.paths.hooks; + if extension.interface.is_some() { + resolved.interface = extension.interface; + } + Ok(()) +} + +fn is_valid_agent_plugin_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && !name.contains("--") + && !name.contains("..") + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b".-".contains(&byte)) + && name + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && name + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) +} diff --git a/codex-rs/core-plugins/src/agent_plugin_manifest_tests.rs b/codex-rs/core-plugins/src/agent_plugin_manifest_tests.rs new file mode 100644 index 0000000000..6b51be3bb6 --- /dev/null +++ b/codex-rs/core-plugins/src/agent_plugin_manifest_tests.rs @@ -0,0 +1,291 @@ +use super::PluginManifest; +use super::PluginManifestMcpServers; +use super::parse_resolved_plugin_manifest; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::AGENT_PLUGIN_SCHEMA_URI; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +fn write_agent_plugin_manifest(plugin_root: &Path, extra_fields: &str) { + fs::create_dir_all(plugin_root).expect("create plugin root"); + fs::write( + plugin_root.join("plugin.json"), + format!( + r#"{{ + "$schema": "{AGENT_PLUGIN_SCHEMA_URI}", + "name": "demo-plugin"{extra_fields} +}}"# + ), + ) + .expect("write Agent Plugins manifest"); +} + +fn load_manifest(plugin_root: &Path) -> PluginManifest { + try_load_manifest(plugin_root).expect("load plugin manifest") +} + +fn try_load_manifest(plugin_root: &Path) -> Option { + let manifest_path = plugin_root.join("plugin.json"); + let contents = fs::read_to_string(&manifest_path).ok()?; + let overlay_path = plugin_root.join(".codex-plugin/plugin.json"); + let overlay_contents = fs::read_to_string(&overlay_path).ok(); + parse_resolved_plugin_manifest( + plugin_root, + &manifest_path, + &contents, + overlay_contents + .as_ref() + .map(|contents| (overlay_path.as_path(), contents.as_str())), + ) + .ok() +} + +#[test] +fn uses_portable_metadata_and_fixed_components() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_agent_plugin_manifest( + &plugin_root, + r#", + "version": "release-2026-07", + "description": "Portable demo", + "author": {"name": "Portable Author"}, + "homepage": "https://example.com/plugin", + "keywords": ["portable"]"#, + ); + + let manifest = load_manifest(&plugin_root); + + assert_eq!(manifest.name, "demo-plugin"); + assert_eq!(manifest.version.as_deref(), Some("release-2026-07")); + assert_eq!(manifest.description.as_deref(), Some("Portable demo")); + assert_eq!(manifest.keywords, vec!["portable"]); + assert_eq!( + manifest.paths.skills, + vec![ + AbsolutePathBuf::from_absolute_path_checked(plugin_root.join("skills")) + .expect("skills path") + ] + ); + assert_eq!( + manifest.paths.mcp_servers, + Some(PluginManifestMcpServers::Path( + AbsolutePathBuf::from_absolute_path_checked(plugin_root.join("mcp.json")) + .expect("MCP path") + )) + ); + let interface = manifest.interface.expect("default portable interface"); + assert_eq!(interface.display_name.as_deref(), Some("demo-plugin")); + assert_eq!( + interface.short_description.as_deref(), + Some("Portable demo") + ); + assert_eq!(interface.long_description.as_deref(), Some("Portable demo")); + assert_eq!(interface.developer_name.as_deref(), Some("Portable Author")); + assert_eq!( + interface.website_url.as_deref(), + Some("https://example.com/plugin") + ); + assert_eq!(interface.category.as_deref(), Some("Other")); +} + +#[test] +fn does_not_invent_optional_metadata() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_agent_plugin_manifest(&plugin_root, ""); + + let manifest = load_manifest(&plugin_root); + + assert_eq!(manifest.version, None); + assert_eq!(manifest.description, None); + let interface = manifest.interface.expect("default portable interface"); + assert_eq!(interface.display_name.as_deref(), Some("demo-plugin")); + assert_eq!(interface.short_description, None); + assert_eq!(interface.long_description, None); + assert_eq!(interface.developer_name, None); + assert_eq!(interface.website_url, None); +} + +#[test] +fn normalizes_empty_optional_metadata() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_agent_plugin_manifest( + &plugin_root, + r#", + "version": "", + "description": " ", + "author": {"name": " ", "email": ""}, + "homepage": "", + "keywords": [""]"#, + ); + + let manifest = load_manifest(&plugin_root); + + assert_eq!(manifest.version, None); + assert_eq!(manifest.description, None); + assert_eq!(manifest.keywords, vec![""]); + let interface = manifest.interface.expect("default portable interface"); + assert_eq!(interface.short_description, None); + assert_eq!(interface.long_description, None); + assert_eq!(interface.developer_name, None); + assert_eq!(interface.website_url, None); +} + +#[test] +fn accepts_dotted_names_and_ignores_extensions() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("acme-tools"); + fs::create_dir_all(&plugin_root).expect("create plugin root"); + fs::write( + plugin_root.join("plugin.json"), + format!( + r#"{{ + "$schema":"{AGENT_PLUGIN_SCHEMA_URI}", + "name":"acme.tools", + "extensions":{{ + "com.example.client":{{"future":true}}, + "com.example.unimplemented":"ignored" + }} +}}"# + ), + ) + .expect("write manifest"); + + assert_eq!(load_manifest(&plugin_root).name, "acme.tools"); + + fs::write( + plugin_root.join("plugin.json"), + format!( + r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"acme.tools","extensions":false}}"# + ), + ) + .expect("write manifest with invalid extensions"); + assert_eq!(load_manifest(&plugin_root).name, "acme.tools"); +} + +#[test] +fn rejects_overlong_name_and_wrong_metadata_types() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + fs::create_dir_all(&plugin_root).expect("create plugin root"); + fs::write( + plugin_root.join("plugin.json"), + format!( + r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"{}"}}"#, + "a".repeat(65) + ), + ) + .expect("write manifest"); + assert_eq!(try_load_manifest(&plugin_root), None); + + fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"demo-plugin","homepage":42}}"#), + ) + .expect("write manifest"); + assert_eq!(try_load_manifest(&plugin_root), None); + + fs::write( + plugin_root.join("plugin.json"), + format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"demo-plugin","version":null}}"#), + ) + .expect("write manifest"); + assert_eq!(try_load_manifest(&plugin_root), None); + + fs::write( + plugin_root.join("plugin.json"), + format!( + r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"demo-plugin","author":{{"name":null}}}}"# + ), + ) + .expect("write manifest"); + assert_eq!(try_load_manifest(&plugin_root), None); +} + +#[test] +fn legacy_codex_overlay_keeps_portable_components_fixed() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_agent_plugin_manifest( + &plugin_root, + r#", + "version": "portable-version", + "description": "Portable description""#, + ); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create overlay dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "different-name", + "version": "9.9.9", + "description": "Codex description", + "skills": [], + "mcpServers": null, + "interface": {"displayName": "Codex Demo"} +}"#, + ) + .expect("write overlay"); + + let manifest = load_manifest(&plugin_root); + + assert_eq!(manifest.name, "demo-plugin"); + assert_eq!(manifest.version.as_deref(), Some("portable-version")); + assert_eq!( + manifest.description.as_deref(), + Some("Portable description") + ); + assert_eq!( + manifest.paths.skills, + vec![ + AbsolutePathBuf::from_absolute_path_checked(plugin_root.join("skills")) + .expect("skills path") + ] + ); + assert_eq!( + manifest.paths.mcp_servers, + Some(PluginManifestMcpServers::Path( + AbsolutePathBuf::from_absolute_path_checked(plugin_root.join("mcp.json")) + .expect("MCP path") + )) + ); + assert_eq!( + manifest + .interface + .and_then(|interface| interface.display_name), + Some("Codex Demo".to_string()) + ); +} + +#[test] +fn inline_openai_extension_precedes_legacy_overlay() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_agent_plugin_manifest( + &plugin_root, + r#", + "extensions": { + "com.openai": { + "interface": {"displayName": "Inline Codex"} + } + }"#, + ); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create overlay dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"interface":{"displayName":"Legacy Codex"}}"#, + ) + .expect("write overlay"); + + let manifest = load_manifest(&plugin_root); + + assert_eq!( + manifest + .interface + .and_then(|interface| interface.display_name), + Some("Inline Codex".to_string()) + ); +} diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index f458a1d2a7..727c8cb4d5 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -45,6 +45,7 @@ use codex_protocol::protocol::SkillScope; use codex_skills::SkillConfigRules; use codex_skills::SkillMetadata; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::SkillDiscoveryMode; use codex_utils_plugins::find_plugin_manifest_path; use serde_json::Value as JsonValue; use std::collections::HashMap; @@ -955,6 +956,7 @@ pub(crate) async fn load_plugin_skill_inventory( plugin_id: Some(plugin_id.as_key()), plugin_namespace: Some(manifest.name.clone()), plugin_root: Some(plugin_root.clone()), + discovery_mode: SkillDiscoveryMode::Recursive, }) .collect::>(); let outcome = load_skills_from_roots(roots, plugin_skill_snapshots, root_scan_slots).await; diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index d1b5568c63..3503a8d80d 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -2,6 +2,7 @@ use codex_config::HooksFile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; +use codex_utils_plugins::AGENT_PLUGIN_MANIFEST_RELATIVE_PATH; use codex_utils_plugins::find_plugin_manifest_path; use serde::Deserialize; use serde_json::Value as JsonValue; @@ -12,6 +13,15 @@ use std::path::PathBuf; const MAX_DEFAULT_PROMPT_COUNT: usize = 3; const MAX_DEFAULT_PROMPT_LEN: usize = 128; +#[path = "agent_plugin_manifest.rs"] +mod agent_plugin_manifest; + +#[cfg(test)] +#[path = "agent_plugin_manifest_tests.rs"] +mod agent_plugin_manifest_tests; + +use agent_plugin_manifest::parse_agent_plugin_manifest_uri; + pub type PluginManifest = codex_plugin::manifest::PluginManifest; pub type PluginManifestHooks = codex_plugin::manifest::PluginManifestHooks; pub type PluginManifestInterface = codex_plugin::manifest::PluginManifestInterface; @@ -135,7 +145,23 @@ enum RawPluginManifestHooks { pub fn load_plugin_manifest(plugin_root: &Path) -> Option { let manifest_path = find_plugin_manifest_path(plugin_root)?; let contents = fs::read_to_string(&manifest_path).ok()?; - match parse_plugin_manifest(plugin_root, &manifest_path, &contents) { + let is_agent_plugin = manifest_path == plugin_root.join(AGENT_PLUGIN_MANIFEST_RELATIVE_PATH); + let overlay = if is_agent_plugin { + let overlay_path = plugin_root.join(".codex-plugin/plugin.json"); + fs::read_to_string(&overlay_path) + .ok() + .map(|contents| (overlay_path, contents)) + } else { + None + }; + match parse_resolved_plugin_manifest( + plugin_root, + &manifest_path, + &contents, + overlay + .as_ref() + .map(|(path, contents)| (path.as_path(), contents.as_str())), + ) { Ok(manifest) => Some(manifest), Err(err) => { tracing::warn!( @@ -169,19 +195,75 @@ pub(crate) fn parse_plugin_manifest( plugin_root: &Path, manifest_path: &Path, contents: &str, +) -> Result { + parse_resolved_plugin_manifest(plugin_root, manifest_path, contents, /*overlay*/ None) +} + +fn parse_resolved_plugin_manifest( + plugin_root: &Path, + manifest_path: &Path, + contents: &str, + overlay: Option<(&Path, &str)>, ) -> Result { let plugin_root_uri = PathUri::from_host_native_path(plugin_root).map_err(serde_json::Error::io)?; let manifest_path_uri = PathUri::from_host_native_path(manifest_path).map_err(serde_json::Error::io)?; - parse_plugin_manifest_uri(&plugin_root_uri, &manifest_path_uri, contents)? - .try_map_resources(|path| path.to_abs_path().map_err(serde_json::Error::io)) + let overlay = overlay + .map(|(path, contents)| { + PathUri::from_host_native_path(path) + .map(|path| (path, contents)) + .map_err(serde_json::Error::io) + }) + .transpose()?; + parse_resolved_plugin_manifest_uri( + &plugin_root_uri, + &manifest_path_uri, + contents, + overlay.as_ref().map(|(path, contents)| (path, *contents)), + )? + .try_map_resources(|path| path.to_abs_path().map_err(serde_json::Error::io)) } pub fn parse_plugin_manifest_uri( plugin_root: &PathUri, manifest_path: &PathUri, contents: &str, +) -> Result { + parse_resolved_plugin_manifest_uri(plugin_root, manifest_path, contents, /*overlay*/ None) +} + +pub(crate) fn parse_resolved_plugin_manifest_uri( + plugin_root: &PathUri, + manifest_path: &PathUri, + contents: &str, + overlay: Option<(&PathUri, &str)>, +) -> Result { + let root_manifest_path = plugin_root + .join(AGENT_PLUGIN_MANIFEST_RELATIVE_PATH) + .map_err(path_uri_json_error)?; + if manifest_path == &root_manifest_path { + return parse_agent_plugin_manifest_uri(plugin_root, manifest_path, contents, overlay); + } + parse_legacy_plugin_manifest_uri(plugin_root, manifest_path, contents) +} + +fn parse_legacy_plugin_manifest_uri( + plugin_root: &PathUri, + manifest_path: &PathUri, + contents: &str, +) -> Result { + resolve_raw_plugin_manifest( + plugin_root, + manifest_path, + serde_json::from_str::(contents)?, + ) +} + +fn resolve_raw_plugin_manifest( + plugin_root: &PathUri, + manifest_path: &PathUri, + raw: RawPluginManifest, ) -> Result { let RawPluginManifest { name: raw_name, @@ -193,7 +275,7 @@ pub fn parse_plugin_manifest_uri( apps, hooks, interface, - } = serde_json::from_str::(contents)?; + } = raw; let name = plugin_root .basename() .filter(|_| raw_name.trim().is_empty()) @@ -293,6 +375,14 @@ pub fn parse_plugin_manifest_uri( }) } +fn compatibility_json_error(message: impl Into) -> serde_json::Error { + serde_json::Error::io(io::Error::new(io::ErrorKind::InvalidData, message.into())) +} + +fn path_uri_json_error(error: impl std::fmt::Display) -> serde_json::Error { + compatibility_json_error(error.to_string()) +} + fn resolve_manifest_hooks( plugin_root: &PathUri, hooks: Option, @@ -556,7 +646,6 @@ mod tests { use tempfile::tempdir; use crate::ExecutorPluginProvider; - const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) { diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index c95a688cf6..e5b0ac4970 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -31,6 +31,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use codex_utils_path_uri::PathUri; use codex_utils_plugins::PluginSkillRoot; +use codex_utils_plugins::SkillDiscoveryMode; use dirs::home_dir; use discovery::DirectorySymlinkPolicy; use discovery::DiscoveredSkill; @@ -197,6 +198,7 @@ pub struct SkillRoot { pub plugin_id: Option, pub plugin_namespace: Option, pub plugin_root: Option, + pub discovery_mode: SkillDiscoveryMode, } pub async fn load_skills_from_roots( @@ -222,31 +224,15 @@ pub(crate) struct SkillRootSnapshot { } pub(crate) async fn load_skill_root(root: SkillRoot) -> SkillRootSnapshot { - let SkillRoot { - path, - scope, - file_system, - plugin_id, - plugin_namespace, - plugin_root, - } = root; - let root = canonicalize_for_skill_identity(file_system.as_ref(), &path).await; + let canonical_root = + canonicalize_for_skill_identity(root.file_system.as_ref(), &root.path).await; let mut outcome = SkillLoadOutcome::default(); - load_skills_under_root( - file_system.as_ref(), - &root, - scope, - plugin_id.as_deref(), - plugin_namespace.as_deref(), - plugin_root.as_ref(), - &mut outcome, - ) - .await; + load_skills_under_root(&root, &canonical_root, &mut outcome).await; SkillRootSnapshot { - root, + root: canonical_root, skills: outcome.skills, errors: outcome.errors, - file_system, + file_system: root.file_system, } } @@ -286,6 +272,7 @@ async fn skill_roots_with_home_dir( plugin_id: Some(root.plugin_id), plugin_namespace: Some(root.plugin_namespace), plugin_root: Some(root.plugin_root), + discovery_mode: root.discovery_mode, })); roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot { path, @@ -294,6 +281,7 @@ async fn skill_roots_with_home_dir( plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); dedupe_skill_roots_by_path(&mut roots); @@ -325,6 +313,7 @@ fn skill_roots_from_layer_stack_inner( plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }); } } @@ -338,6 +327,7 @@ fn skill_roots_from_layer_stack_inner( plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }); // `$HOME/.agents/skills` (user-installed skills). @@ -349,6 +339,7 @@ fn skill_roots_from_layer_stack_inner( plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }); } @@ -361,6 +352,7 @@ fn skill_roots_from_layer_stack_inner( plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }); } ConfigLayerSource::System { .. } => { @@ -373,6 +365,7 @@ fn skill_roots_from_layer_stack_inner( plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }); } ConfigLayerSource::Mdm { .. } @@ -418,6 +411,7 @@ async fn repo_agents_skill_roots( plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }), Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} @@ -531,19 +525,16 @@ async fn canonicalize_for_skill_identity( } async fn load_skills_under_root( - fs: &dyn ExecutorFileSystem, + skill_root: &SkillRoot, root: &AbsolutePathBuf, - scope: SkillScope, - plugin_id: Option<&str>, - plugin_namespace: Option<&str>, - plugin_root: Option<&AbsolutePathBuf>, outcome: &mut SkillLoadOutcome, ) { - let plugin_root = match plugin_root { + let fs = skill_root.file_system.as_ref(); + let plugin_root = match skill_root.plugin_root.as_ref() { Some(plugin_root) => Some(canonicalize_for_skill_identity(fs, plugin_root).await), None => None, }; - let directory_symlinks = match scope { + let directory_symlinks = match skill_root.scope { SkillScope::User | SkillScope::Repo | SkillScope::Admin => DirectorySymlinkPolicy::Follow, SkillScope::System => DirectorySymlinkPolicy::Ignore, }; @@ -560,6 +551,7 @@ async fn load_skills_under_root( SkillDiscoveryOptions { directory_symlinks, hidden_directories: HiddenDirectoryPolicy::Skip, + mode: skill_root.discovery_mode, }, ) .await; @@ -571,12 +563,20 @@ async fn load_skills_under_root( return; } let root_uri = PathUri::from_abs_path(root); + let resolved_plugin_root = plugin_root.as_ref(); let resolved_skills = futures::stream::iter(skills) .map(|skill| async move { - let path_uri = fs - .canonicalize(&skill.path, /*sandbox*/ None) - .await - .unwrap_or_else(|_| skill.path.clone()); + let path_uri = match fs.canonicalize(&skill.path, /*sandbox*/ None).await { + Ok(path) => path, + Err(err) if skill_root.discovery_mode == SkillDiscoveryMode::DirectChildren => { + error!( + "failed to resolve Agent Plugin skill path {}: {err}", + skill.path + ); + return None; + } + Err(_) => skill.path.clone(), + }; let path = match path_uri.to_abs_path() { Ok(path) => path, Err(err) => { @@ -584,6 +584,37 @@ async fn load_skills_under_root( return None; } }; + if skill_root.discovery_mode == SkillDiscoveryMode::DirectChildren { + let Some(plugin_root) = resolved_plugin_root else { + error!("Agent Plugin skill root is missing its plugin root"); + return None; + }; + if !path.as_path().starts_with(plugin_root.as_path()) { + error!( + "Agent Plugin skill path {} resolves outside plugin root {}", + path.display(), + plugin_root.display() + ); + return None; + } + match fs.get_metadata(&path_uri, /*sandbox*/ None).await { + Ok(metadata) if metadata.is_file => {} + Ok(_) => { + error!( + "Agent Plugin skill path {} is not a regular file", + path.display() + ); + return None; + } + Err(err) => { + error!( + "failed to inspect Agent Plugin skill path {}: {err}", + path.display() + ); + return None; + } + } + } Some(ResolvedDiscoveredSkill { skill, path, @@ -604,7 +635,7 @@ async fn load_skills_under_root( .map(|skill| skill.path_uri.clone()) .collect::>(); let namespace_resolver = async { - match plugin_namespace { + match skill_root.plugin_namespace.as_deref() { Some(namespace) => SkillNamespaceResolver::with_provided_namespace(namespace), None => { SkillNamespaceResolver::discover( @@ -627,8 +658,8 @@ async fn load_skills_under_root( &skill.skill, &skill.path, &skill.path_uri, - scope, - plugin_id, + skill_root.scope, + skill_root.plugin_id.as_deref(), plugin_root, ) .await @@ -651,7 +682,7 @@ async fn load_skills_under_root( }); match result { Ok(skill) => outcome.skills.push(skill), - Err(err) if scope != SkillScope::System => { + Err(err) if skill_root.scope != SkillScope::System => { outcome.errors.push(SkillError { path, message: err }) } Err(_) => {} diff --git a/codex-rs/core-skills/src/loader/discovery.rs b/codex-rs/core-skills/src/loader/discovery.rs index 547400bc4f..cadc8218c0 100644 --- a/codex-rs/core-skills/src/loader/discovery.rs +++ b/codex-rs/core-skills/src/loader/discovery.rs @@ -6,6 +6,7 @@ use codex_exec_server::WalkEntryKind; use codex_exec_server::WalkOptions; use codex_utils_path_uri::PathUri; use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; +use codex_utils_plugins::SkillDiscoveryMode; use super::MAX_SCAN_DEPTH; use super::MAX_SKILLS_DIRS_PER_ROOT; @@ -29,6 +30,7 @@ pub(super) enum HiddenDirectoryPolicy { pub(super) struct SkillDiscoveryOptions { pub directory_symlinks: DirectorySymlinkPolicy, pub hidden_directories: HiddenDirectoryPolicy, + pub mode: SkillDiscoveryMode, } pub(super) struct SkillDiscovery { @@ -64,7 +66,10 @@ pub(super) async fn discover_skills( .walk( root, WalkOptions { - max_depth: MAX_SCAN_DEPTH, + max_depth: match options.mode { + SkillDiscoveryMode::Recursive => MAX_SCAN_DEPTH, + SkillDiscoveryMode::DirectChildren => 2, + }, max_directories: MAX_SKILLS_DIRS_PER_ROOT, max_entries: MAX_SKILLS_ENTRIES_PER_ROOT, follow_directory_symlinks: matches!( @@ -137,7 +142,10 @@ pub(super) async fn discover_skills( } WalkEntryKind::File => { file_paths.insert(entry.path.clone()); - if entry.path.basename().as_deref() == Some(SKILLS_FILENAME) { + if entry.path.basename().as_deref() == Some(SKILLS_FILENAME) + && (options.mode == SkillDiscoveryMode::Recursive + || is_direct_child_skill_path(&entry.path, root)) + { skill_files.push(entry.path); } } @@ -164,6 +172,10 @@ pub(super) async fn discover_skills( } } +fn is_direct_child_skill_path(path: &PathUri, root: &PathUri) -> bool { + path.parent().and_then(|parent| parent.parent()).as_ref() == Some(root) +} + fn has_hidden_ancestor_below_root(path: &PathUri, root: &PathUri) -> bool { let mut ancestor = path.parent(); while let Some(current) = ancestor { diff --git a/codex-rs/core-skills/src/loader/environment.rs b/codex-rs/core-skills/src/loader/environment.rs index 15e2227b03..0e881b6c24 100644 --- a/codex-rs/core-skills/src/loader/environment.rs +++ b/codex-rs/core-skills/src/loader/environment.rs @@ -121,6 +121,7 @@ pub async fn load_environment_skills_from_root( SkillDiscoveryOptions { directory_symlinks: DirectorySymlinkPolicy::Follow, hidden_directories: HiddenDirectoryPolicy::Include, + mode: codex_utils_plugins::SkillDiscoveryMode::Recursive, }, ) .await; diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 71985ceb0d..58a4a5c74f 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -574,6 +574,7 @@ async fn load_user_skills_root(root: &Path) -> SkillLoadOutcome { plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), @@ -1061,6 +1062,7 @@ interface: plugin_id: Some("twilio-developer-kit@test".to_string()), plugin_namespace: None, plugin_root: Some(plugin_root_abs.clone()), + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), @@ -1123,6 +1125,7 @@ interface: plugin_id: Some("twilio-developer-kit@test".to_string()), plugin_namespace: None, plugin_root: Some(plugin_root.abs()), + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), @@ -1300,6 +1303,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), @@ -1387,6 +1391,7 @@ async fn system_scope_ignores_symlinked_subdir() { plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), @@ -1426,6 +1431,7 @@ async fn respects_max_scan_depth_for_user_scope() { plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), @@ -1538,6 +1544,7 @@ async fn namespaces_plugin_skills_using_provided_namespace() { plugin_id: Some("sample@test".to_string()), plugin_namespace: Some("sample".to_string()), plugin_root: Some(plugin_root.abs()), + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), @@ -1786,6 +1793,7 @@ async fn plugin_skill_name_length_limit_allows_max_qualified_name() { plugin_id: Some("sample@test".to_string()), plugin_namespace: Some(plugin_name.clone()), plugin_root: Some(plugin_root.abs()), + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), @@ -1836,6 +1844,7 @@ async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() { plugin_id: Some("sample@test".to_string()), plugin_namespace: Some(plugin_name.clone()), plugin_root: Some(plugin_root.abs()), + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), @@ -1851,6 +1860,76 @@ async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() { ); } +#[tokio::test] +async fn direct_child_discovery_ignores_nested_skills() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugin"); + let skills_root = plugin_root.join("skills"); + let direct = write_skill_at(&skills_root, "direct", "direct", "direct skill"); + write_skill_at(&skills_root, "nested/too-deep", "too-deep", "nested skill"); + + let outcome = load_skills_from_roots( + [SkillRoot { + path: skills_root.abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("plugin@test".to_string()), + plugin_namespace: Some("plugin".to_string()), + plugin_root: Some(plugin_root.abs()), + discovery_mode: SkillDiscoveryMode::DirectChildren, + }], + /*plugin_skill_snapshots*/ None, + Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), + ) + .await; + + assert!(outcome.errors.is_empty()); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "plugin:direct".to_string(), + description: "direct skill".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&direct), + scope: SkillScope::User, + plugin_id: Some("plugin@test".to_string()), + }] + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn direct_child_discovery_skips_skills_resolving_outside_plugin_root() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugin"); + let skills_root = plugin_root.join("skills"); + let outside_root = root.path().join("outside"); + write_skill_at(&outside_root, "escaped", "escaped", "escaped skill"); + fs::create_dir_all(&skills_root).expect("create skills root"); + std::os::unix::fs::symlink(outside_root.join("escaped"), skills_root.join("escaped")) + .expect("create skill symlink"); + + let outcome = load_skills_from_roots( + [SkillRoot { + path: skills_root.abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("plugin@test".to_string()), + plugin_namespace: Some("plugin".to_string()), + plugin_root: Some(plugin_root.abs()), + discovery_mode: SkillDiscoveryMode::DirectChildren, + }], + /*plugin_skill_snapshots*/ None, + Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), + ) + .await; + + assert!(outcome.skills.is_empty()); +} + #[tokio::test] async fn loads_short_description_from_metadata() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -2385,6 +2464,7 @@ async fn merges_root_results_in_input_order_when_scans_finish_out_of_order() { plugin_id: None, plugin_namespace: Some("test".to_string()), plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }) .collect::>(); let root_scan_slots = Semaphore::new(MAX_CONCURRENT_ROOT_SCANS); @@ -2450,6 +2530,7 @@ async fn skill_root_scans_wait_for_shared_capacity() { plugin_id: None, plugin_namespace: Some("test".to_string()), plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, &root_scan_slots, @@ -2518,6 +2599,7 @@ async fn deduplicates_by_path_preferring_first_root() { plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }, SkillRoot { path: root.path().abs(), @@ -2526,6 +2608,7 @@ async fn deduplicates_by_path_preferring_first_root() { plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: SkillDiscoveryMode::Recursive, }, ], /*plugin_skill_snapshots*/ None, diff --git a/codex-rs/core-skills/src/root_loader.rs b/codex-rs/core-skills/src/root_loader.rs index f15887d513..a8d30efbe5 100644 --- a/codex-rs/core-skills/src/root_loader.rs +++ b/codex-rs/core-skills/src/root_loader.rs @@ -66,6 +66,7 @@ where plugin_id, plugin_namespace, plugin_root, + discovery_mode: root.discovery_mode, }) } _ => None, diff --git a/codex-rs/core-skills/src/service_tests.rs b/codex-rs/core-skills/src/service_tests.rs index 6026975097..0d9f4e73b1 100644 --- a/codex-rs/core-skills/src/service_tests.rs +++ b/codex-rs/core-skills/src/service_tests.rs @@ -12,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::PathExt; use codex_utils_plugins::PluginSkillRoot; +use codex_utils_plugins::SkillDiscoveryMode; use pretty_assertions::assert_eq; use std::collections::HashSet; use std::fs; @@ -72,6 +73,7 @@ fn plugin_skill_root_for_skill_path( plugin_id: plugin_id.to_string(), plugin_namespace: plugin_namespace.to_string(), plugin_root: plugin_root.abs(), + discovery_mode: SkillDiscoveryMode::Recursive, } } diff --git a/codex-rs/core-skills/tests/environment_loader.rs b/codex-rs/core-skills/tests/environment_loader.rs index 2482f7ce29..7c35ef8880 100644 --- a/codex-rs/core-skills/tests/environment_loader.rs +++ b/codex-rs/core-skills/tests/environment_loader.rs @@ -472,6 +472,7 @@ async fn host_loading_reuses_walk_inventory_for_symlinked_skill_pack() { plugin_id: None, plugin_namespace: None, plugin_root: None, + discovery_mode: codex_utils_plugins::SkillDiscoveryMode::Recursive, }], /*plugin_skill_snapshots*/ None, Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)), diff --git a/codex-rs/ext/skills/src/provider/host_tests.rs b/codex-rs/ext/skills/src/provider/host_tests.rs index 072c638785..83f795d4b2 100644 --- a/codex-rs/ext/skills/src/provider/host_tests.rs +++ b/codex-rs/ext/skills/src/provider/host_tests.rs @@ -38,6 +38,7 @@ async fn host_catalog_entries_carry_their_prompt_scope() -> Result<(), Box PluginLoadOutcome { plugin_id: plugin.config_name.clone(), plugin_namespace: plugin_namespace.clone(), plugin_root: plugin.root.clone(), + discovery_mode: SkillDiscoveryMode::Recursive, }); } } @@ -258,6 +260,7 @@ mod tests { plugin_id: "zeta@test".to_string(), plugin_namespace: "zeta".to_string(), plugin_root: test_path("zeta@test"), + discovery_mode: SkillDiscoveryMode::Recursive, }] ); } diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs index 6b203955b2..eb3e21131f 100644 --- a/codex-rs/utils/plugins/src/lib.rs +++ b/codex-rs/utils/plugins/src/lib.rs @@ -8,15 +8,29 @@ pub mod mention_syntax; pub mod plugin_namespace; pub use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; +pub use plugin_namespace::AGENT_PLUGIN_MANIFEST_RELATIVE_PATH; +pub use plugin_namespace::AGENT_PLUGIN_SCHEMA_PREFIX; +pub use plugin_namespace::AGENT_PLUGIN_SCHEMA_URI; +pub use plugin_namespace::AgentPluginSchemaStatus; +pub use plugin_namespace::SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS; +pub use plugin_namespace::agent_plugin_schema_status; pub use plugin_namespace::find_plugin_manifest_path; pub use plugin_namespace::plugin_namespace_for_root_uri; pub use plugin_namespace::plugin_namespace_for_skill_path; pub use plugin_namespace::plugin_namespace_for_skill_uri; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum SkillDiscoveryMode { + #[default] + Recursive, + DirectChildren, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PluginSkillRoot { pub path: AbsolutePathBuf, pub plugin_id: String, pub plugin_namespace: String, pub plugin_root: AbsolutePathBuf, + pub discovery_mode: SkillDiscoveryMode, } diff --git a/codex-rs/utils/plugins/src/plugin_namespace.rs b/codex-rs/utils/plugins/src/plugin_namespace.rs index 1407c93b2c..6e2081c7ad 100644 --- a/codex-rs/utils/plugins/src/plugin_namespace.rs +++ b/codex-rs/utils/plugins/src/plugin_namespace.rs @@ -7,6 +7,35 @@ use codex_utils_path_uri::PathUri; use std::path::Path; use std::path::PathBuf; +pub const AGENT_PLUGIN_MANIFEST_RELATIVE_PATH: &str = "plugin.json"; +pub const AGENT_PLUGIN_SCHEMA_URI: &str = + "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; +pub const SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS: &[&str] = &[AGENT_PLUGIN_SCHEMA_URI]; +pub const AGENT_PLUGIN_SCHEMA_PREFIX: &str = "https://agent-plugins.org/schemas/"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AgentPluginSchemaStatus { + Supported, + Unsupported, + Unrelated, +} + +pub fn agent_plugin_schema_status(contents: &str) -> AgentPluginSchemaStatus { + let Ok(value) = serde_json::from_str::(contents) else { + return AgentPluginSchemaStatus::Unrelated; + }; + let Some(schema) = value.get("$schema").and_then(serde_json::Value::as_str) else { + return AgentPluginSchemaStatus::Unrelated; + }; + if SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS.contains(&schema) { + AgentPluginSchemaStatus::Supported + } else if schema.starts_with(AGENT_PLUGIN_SCHEMA_PREFIX) { + AgentPluginSchemaStatus::Unsupported + } else { + AgentPluginSchemaStatus::Unrelated + } +} + pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option { DISCOVERABLE_PLUGIN_MANIFEST_PATHS .iter() @@ -146,4 +175,37 @@ mod tests { ); assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path)); } + + #[test] + fn ignores_unrelated_root_plugin_manifest_before_legacy_fallback() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("plugins/sample"); + let legacy_path = plugin_root.join(".codex-plugin/plugin.json"); + fs::create_dir_all(legacy_path.parent().expect("parent")).expect("mkdir"); + fs::write(plugin_root.join("plugin.json"), r#"{"name":"npm-package"}"#) + .expect("write unrelated root"); + fs::write(&legacy_path, r#"{"name":"sample"}"#).expect("write legacy"); + + assert_eq!(find_plugin_manifest_path(&plugin_root), Some(legacy_path)); + } + + #[test] + fn preserves_codex_claude_cursor_legacy_precedence() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("plugins/sample"); + let codex_path = plugin_root.join(".codex-plugin/plugin.json"); + let claude_path = plugin_root.join(".claude-plugin/plugin.json"); + let cursor_path = plugin_root.join(".cursor-plugin/plugin.json"); + for path in [&codex_path, &claude_path, &cursor_path] { + fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); + fs::write(path, r#"{"name":"sample"}"#).expect("write manifest"); + } + + assert_eq!( + find_plugin_manifest_path(&plugin_root), + Some(codex_path.clone()) + ); + fs::remove_file(codex_path).expect("remove Codex manifest"); + assert_eq!(find_plugin_manifest_path(&plugin_root), Some(claude_path)); + } }