Files
codex/codex-rs/utils/plugins/src/plugin_namespace.rs
jacobzhou-oai a28374e0db 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
2026-07-24 05:59:16 +00:00

212 lines
8.2 KiB
Rust

//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`.
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
use codex_utils_absolute_path::AbsolutePathBuf;
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()
.map(|relative_path| plugin_root.join(relative_path))
.find(|manifest_path| manifest_path.is_file())
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawPluginManifestName {
#[serde(default)]
name: String,
}
/// Returns the plugin manifest `name` defined directly below `plugin_root`.
pub async fn plugin_namespace_for_root_uri(
fs: &dyn ExecutorFileSystem,
plugin_root: &PathUri,
) -> Option<String> {
let mut manifest_path = None;
for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
let candidate = plugin_root.join(relative_path).ok()?;
match fs.get_metadata(&candidate, /*sandbox*/ None).await {
Ok(metadata) if metadata.is_file => {
manifest_path = Some(candidate);
break;
}
Ok(_) | Err(_) => {}
}
}
let contents = fs
.read_file_text(&manifest_path?, /*sandbox*/ None)
.await
.ok()?;
let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?;
Some(
plugin_root
.basename()
.filter(|_| raw_name.trim().is_empty())
.unwrap_or(raw_name),
)
}
/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid
/// plugin manifest (same `name` rules as full manifest loading in codex-core).
pub async fn plugin_namespace_for_skill_path(
fs: &dyn ExecutorFileSystem,
path: &AbsolutePathBuf,
) -> Option<String> {
plugin_namespace_for_skill_uri(fs, &PathUri::from_abs_path(path)).await
}
/// Returns the plugin manifest `name` for the nearest URI ancestor of `path`.
pub async fn plugin_namespace_for_skill_uri(
fs: &dyn ExecutorFileSystem,
path: &PathUri,
) -> Option<String> {
let mut ancestor = Some(path.clone());
while let Some(path) = ancestor {
if let Some(name) = plugin_namespace_for_root_uri(fs, &path).await {
return Some(name);
}
ancestor = path.parent();
}
None
}
#[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_CLA_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
const ALTERNATE_PLUGIN_CUR_MANIFEST_RELATIVE_PATH: &str = ".cursor-plugin/plugin.json";
#[tokio::test]
async fn uses_manifest_name() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("plugins/sample");
let skill_path = plugin_root.join("skills/search/SKILL.md");
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest");
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
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())
);
}
#[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_CLA_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));
}
#[tokio::test]
async fn uses_name_from_cur_plugin_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_CUR_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));
}
#[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));
}
}