Support Agent Plugins manifests (#35105)

## What changed

- Recognize root `plugin.json` files using the Agent Plugins 1.0 schema and map their portable metadata, `skills/`, and `mcp.json` into Codex plugin manifests.
- Apply Codex-specific apps, hooks, and interface settings from the inline `com.openai` extension, with `.codex-plugin/plugin.json` as a fallback overlay.
- Preserve legacy manifest precedence when a root `plugin.json` is unrelated, and reject unsupported Agent Plugins schema versions.
- Add a direct-child skill discovery mode that excludes nested skills and paths resolving outside the plugin root.

## Testing

- Cover manifest metadata, validation, extension precedence, legacy fallback, and direct-child skill path boundaries.

GitOrigin-RevId: eab24139f13a5cc5cb3ad3fb444d8e904511aca6
This commit is contained in:
jacobzhou-oai
2026-07-24 05:59:16 +00:00
committed by copyberry
parent f61b51ddd9
commit a28374e0db
16 changed files with 872 additions and 43 deletions
@@ -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<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
author: Option<RawAgentPluginAuthor>,
#[serde(default)]
homepage: Option<String>,
#[serde(default, rename = "repository")]
_repository: Option<String>,
#[serde(default, rename = "license")]
_license: Option<String>,
#[serde(default)]
keywords: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawAgentPluginAuthor {
#[serde(default)]
name: Option<String>,
#[serde(default, rename = "email")]
_email: Option<String>,
#[serde(default, rename = "url")]
_url: Option<String>,
}
pub(super) fn parse_agent_plugin_manifest_uri(
plugin_root: &PathUri,
manifest_path: &PathUri,
contents: &str,
overlay: Option<(&PathUri, &str)>,
) -> Result<UriPluginManifest, serde_json::Error> {
let value = serde_json::from_str::<JsonValue>(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::<RawAgentPluginManifest>(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<String> {
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)
}
@@ -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<PluginManifest> {
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())
);
}
+2
View File
@@ -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::<Vec<_>>();
let outcome = load_skills_from_roots(roots, plugin_skill_snapshots, root_scan_slots).await;
+94 -5
View File
@@ -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<AbsolutePathBuf>;
pub type PluginManifestHooks = codex_plugin::manifest::PluginManifestHooks<AbsolutePathBuf>;
pub type PluginManifestInterface = codex_plugin::manifest::PluginManifestInterface<AbsolutePathBuf>;
@@ -135,7 +145,23 @@ enum RawPluginManifestHooks {
pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
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<PluginManifest, serde_json::Error> {
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<PluginManifest, serde_json::Error> {
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<UriPluginManifest, serde_json::Error> {
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<UriPluginManifest, serde_json::Error> {
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<UriPluginManifest, serde_json::Error> {
resolve_raw_plugin_manifest(
plugin_root,
manifest_path,
serde_json::from_str::<RawPluginManifest>(contents)?,
)
}
fn resolve_raw_plugin_manifest(
plugin_root: &PathUri,
manifest_path: &PathUri,
raw: RawPluginManifest,
) -> Result<UriPluginManifest, serde_json::Error> {
let RawPluginManifest {
name: raw_name,
@@ -193,7 +275,7 @@ pub fn parse_plugin_manifest_uri(
apps,
hooks,
interface,
} = serde_json::from_str::<RawPluginManifest>(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<String>) -> 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<RawPluginManifestHooks>,
@@ -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) {
+67 -36
View File
@@ -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<String>,
pub plugin_namespace: Option<String>,
pub plugin_root: Option<AbsolutePathBuf>,
pub discovery_mode: SkillDiscoveryMode,
}
pub async fn load_skills_from_roots<I>(
@@ -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::<Vec<_>>();
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(_) => {}
+14 -2
View File
@@ -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 {
@@ -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;
+83
View File
@@ -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::<Vec<_>>();
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,
+1
View File
@@ -66,6 +66,7 @@ where
plugin_id,
plugin_namespace,
plugin_root,
discovery_mode: root.discovery_mode,
})
}
_ => None,
@@ -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,
}
}
@@ -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)),
@@ -38,6 +38,7 @@ async fn host_catalog_entries_carry_their_prompt_scope() -> Result<(), Box<dyn s
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: Default::default(),
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(1)),
@@ -212,6 +212,7 @@ async fn skill_loading_and_reads_use_the_supplied_executor_file_system() {
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: Default::default(),
}],
/*plugin_skill_snapshots*/ None,
Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
+3
View File
@@ -3,6 +3,7 @@ use std::collections::HashSet;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PluginSkillRoot;
use codex_utils_plugins::SkillDiscoveryMode;
use crate::AppConnectorId;
use crate::AppDeclaration;
@@ -137,6 +138,7 @@ impl<M: Clone> PluginLoadOutcome<M> {
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,
}]
);
}
+14
View File
@@ -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,
}
@@ -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::<serde_json::Value>(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<PathBuf> {
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));
}
}