mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(_) => {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user