mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +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:
1 parent
f61b51ddd9
commit
a28374e0db
16 files changed
+872
-43
No files matched your search
@@ -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::SkillConfigRules;
|
||||||
use codex_skills::SkillMetadata;
|
use codex_skills::SkillMetadata;
|
||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
|
use codex_utils_plugins::SkillDiscoveryMode;
|
||||||
use codex_utils_plugins::find_plugin_manifest_path;
|
use codex_utils_plugins::find_plugin_manifest_path;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -955,6 +956,7 @@ pub(crate) async fn load_plugin_skill_inventory(
|
|||||||
plugin_id: Some(plugin_id.as_key()),
|
plugin_id: Some(plugin_id.as_key()),
|
||||||
plugin_namespace: Some(manifest.name.clone()),
|
plugin_namespace: Some(manifest.name.clone()),
|
||||||
plugin_root: Some(plugin_root.clone()),
|
plugin_root: Some(plugin_root.clone()),
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let outcome = load_skills_from_roots(roots, plugin_skill_snapshots, root_scan_slots).await;
|
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_absolute_path::AbsolutePathBuf;
|
||||||
use codex_utils_path_uri::PathConvention;
|
use codex_utils_path_uri::PathConvention;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
|
use codex_utils_plugins::AGENT_PLUGIN_MANIFEST_RELATIVE_PATH;
|
||||||
use codex_utils_plugins::find_plugin_manifest_path;
|
use codex_utils_plugins::find_plugin_manifest_path;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value as JsonValue;
|
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_COUNT: usize = 3;
|
||||||
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
|
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 PluginManifest = codex_plugin::manifest::PluginManifest<AbsolutePathBuf>;
|
||||||
pub type PluginManifestHooks = codex_plugin::manifest::PluginManifestHooks<AbsolutePathBuf>;
|
pub type PluginManifestHooks = codex_plugin::manifest::PluginManifestHooks<AbsolutePathBuf>;
|
||||||
pub type PluginManifestInterface = codex_plugin::manifest::PluginManifestInterface<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> {
|
pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||||
let manifest_path = find_plugin_manifest_path(plugin_root)?;
|
let manifest_path = find_plugin_manifest_path(plugin_root)?;
|
||||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
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),
|
Ok(manifest) => Some(manifest),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -169,19 +195,75 @@ pub(crate) fn parse_plugin_manifest(
|
|||||||
plugin_root: &Path,
|
plugin_root: &Path,
|
||||||
manifest_path: &Path,
|
manifest_path: &Path,
|
||||||
contents: &str,
|
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> {
|
) -> Result<PluginManifest, serde_json::Error> {
|
||||||
let plugin_root_uri =
|
let plugin_root_uri =
|
||||||
PathUri::from_host_native_path(plugin_root).map_err(serde_json::Error::io)?;
|
PathUri::from_host_native_path(plugin_root).map_err(serde_json::Error::io)?;
|
||||||
let manifest_path_uri =
|
let manifest_path_uri =
|
||||||
PathUri::from_host_native_path(manifest_path).map_err(serde_json::Error::io)?;
|
PathUri::from_host_native_path(manifest_path).map_err(serde_json::Error::io)?;
|
||||||
parse_plugin_manifest_uri(&plugin_root_uri, &manifest_path_uri, contents)?
|
let overlay = overlay
|
||||||
.try_map_resources(|path| path.to_abs_path().map_err(serde_json::Error::io))
|
.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(
|
pub fn parse_plugin_manifest_uri(
|
||||||
plugin_root: &PathUri,
|
plugin_root: &PathUri,
|
||||||
manifest_path: &PathUri,
|
manifest_path: &PathUri,
|
||||||
contents: &str,
|
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> {
|
) -> Result<UriPluginManifest, serde_json::Error> {
|
||||||
let RawPluginManifest {
|
let RawPluginManifest {
|
||||||
name: raw_name,
|
name: raw_name,
|
||||||
@@ -193,7 +275,7 @@ pub fn parse_plugin_manifest_uri(
|
|||||||
apps,
|
apps,
|
||||||
hooks,
|
hooks,
|
||||||
interface,
|
interface,
|
||||||
} = serde_json::from_str::<RawPluginManifest>(contents)?;
|
} = raw;
|
||||||
let name = plugin_root
|
let name = plugin_root
|
||||||
.basename()
|
.basename()
|
||||||
.filter(|_| raw_name.trim().is_empty())
|
.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(
|
fn resolve_manifest_hooks(
|
||||||
plugin_root: &PathUri,
|
plugin_root: &PathUri,
|
||||||
hooks: Option<RawPluginManifestHooks>,
|
hooks: Option<RawPluginManifestHooks>,
|
||||||
@@ -556,7 +646,6 @@ mod tests {
|
|||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
use crate::ExecutorPluginProvider;
|
use crate::ExecutorPluginProvider;
|
||||||
|
|
||||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||||
|
|
||||||
fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) {
|
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_absolute_path::AbsolutePathBufGuard;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use codex_utils_plugins::PluginSkillRoot;
|
use codex_utils_plugins::PluginSkillRoot;
|
||||||
|
use codex_utils_plugins::SkillDiscoveryMode;
|
||||||
use dirs::home_dir;
|
use dirs::home_dir;
|
||||||
use discovery::DirectorySymlinkPolicy;
|
use discovery::DirectorySymlinkPolicy;
|
||||||
use discovery::DiscoveredSkill;
|
use discovery::DiscoveredSkill;
|
||||||
@@ -197,6 +198,7 @@ pub struct SkillRoot {
|
|||||||
pub plugin_id: Option<String>,
|
pub plugin_id: Option<String>,
|
||||||
pub plugin_namespace: Option<String>,
|
pub plugin_namespace: Option<String>,
|
||||||
pub plugin_root: Option<AbsolutePathBuf>,
|
pub plugin_root: Option<AbsolutePathBuf>,
|
||||||
|
pub discovery_mode: SkillDiscoveryMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_skills_from_roots<I>(
|
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 {
|
pub(crate) async fn load_skill_root(root: SkillRoot) -> SkillRootSnapshot {
|
||||||
let SkillRoot {
|
let canonical_root =
|
||||||
path,
|
canonicalize_for_skill_identity(root.file_system.as_ref(), &root.path).await;
|
||||||
scope,
|
|
||||||
file_system,
|
|
||||||
plugin_id,
|
|
||||||
plugin_namespace,
|
|
||||||
plugin_root,
|
|
||||||
} = root;
|
|
||||||
let root = canonicalize_for_skill_identity(file_system.as_ref(), &path).await;
|
|
||||||
let mut outcome = SkillLoadOutcome::default();
|
let mut outcome = SkillLoadOutcome::default();
|
||||||
load_skills_under_root(
|
load_skills_under_root(&root, &canonical_root, &mut outcome).await;
|
||||||
file_system.as_ref(),
|
|
||||||
&root,
|
|
||||||
scope,
|
|
||||||
plugin_id.as_deref(),
|
|
||||||
plugin_namespace.as_deref(),
|
|
||||||
plugin_root.as_ref(),
|
|
||||||
&mut outcome,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
SkillRootSnapshot {
|
SkillRootSnapshot {
|
||||||
root,
|
root: canonical_root,
|
||||||
skills: outcome.skills,
|
skills: outcome.skills,
|
||||||
errors: outcome.errors,
|
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_id: Some(root.plugin_id),
|
||||||
plugin_namespace: Some(root.plugin_namespace),
|
plugin_namespace: Some(root.plugin_namespace),
|
||||||
plugin_root: Some(root.plugin_root),
|
plugin_root: Some(root.plugin_root),
|
||||||
|
discovery_mode: root.discovery_mode,
|
||||||
}));
|
}));
|
||||||
roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot {
|
roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot {
|
||||||
path,
|
path,
|
||||||
@@ -294,6 +281,7 @@ async fn skill_roots_with_home_dir(
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}));
|
}));
|
||||||
roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await);
|
roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await);
|
||||||
dedupe_skill_roots_by_path(&mut roots);
|
dedupe_skill_roots_by_path(&mut roots);
|
||||||
@@ -325,6 +313,7 @@ fn skill_roots_from_layer_stack_inner(
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +327,7 @@ fn skill_roots_from_layer_stack_inner(
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
});
|
});
|
||||||
|
|
||||||
// `$HOME/.agents/skills` (user-installed skills).
|
// `$HOME/.agents/skills` (user-installed skills).
|
||||||
@@ -349,6 +339,7 @@ fn skill_roots_from_layer_stack_inner(
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +352,7 @@ fn skill_roots_from_layer_stack_inner(
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ConfigLayerSource::System { .. } => {
|
ConfigLayerSource::System { .. } => {
|
||||||
@@ -373,6 +365,7 @@ fn skill_roots_from_layer_stack_inner(
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ConfigLayerSource::Mdm { .. }
|
ConfigLayerSource::Mdm { .. }
|
||||||
@@ -418,6 +411,7 @@ async fn repo_agents_skill_roots(
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}),
|
}),
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
|
||||||
@@ -531,19 +525,16 @@ async fn canonicalize_for_skill_identity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_skills_under_root(
|
async fn load_skills_under_root(
|
||||||
fs: &dyn ExecutorFileSystem,
|
skill_root: &SkillRoot,
|
||||||
root: &AbsolutePathBuf,
|
root: &AbsolutePathBuf,
|
||||||
scope: SkillScope,
|
|
||||||
plugin_id: Option<&str>,
|
|
||||||
plugin_namespace: Option<&str>,
|
|
||||||
plugin_root: Option<&AbsolutePathBuf>,
|
|
||||||
outcome: &mut SkillLoadOutcome,
|
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),
|
Some(plugin_root) => Some(canonicalize_for_skill_identity(fs, plugin_root).await),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let directory_symlinks = match scope {
|
let directory_symlinks = match skill_root.scope {
|
||||||
SkillScope::User | SkillScope::Repo | SkillScope::Admin => DirectorySymlinkPolicy::Follow,
|
SkillScope::User | SkillScope::Repo | SkillScope::Admin => DirectorySymlinkPolicy::Follow,
|
||||||
SkillScope::System => DirectorySymlinkPolicy::Ignore,
|
SkillScope::System => DirectorySymlinkPolicy::Ignore,
|
||||||
};
|
};
|
||||||
@@ -560,6 +551,7 @@ async fn load_skills_under_root(
|
|||||||
SkillDiscoveryOptions {
|
SkillDiscoveryOptions {
|
||||||
directory_symlinks,
|
directory_symlinks,
|
||||||
hidden_directories: HiddenDirectoryPolicy::Skip,
|
hidden_directories: HiddenDirectoryPolicy::Skip,
|
||||||
|
mode: skill_root.discovery_mode,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -571,12 +563,20 @@ async fn load_skills_under_root(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let root_uri = PathUri::from_abs_path(root);
|
let root_uri = PathUri::from_abs_path(root);
|
||||||
|
let resolved_plugin_root = plugin_root.as_ref();
|
||||||
let resolved_skills = futures::stream::iter(skills)
|
let resolved_skills = futures::stream::iter(skills)
|
||||||
.map(|skill| async move {
|
.map(|skill| async move {
|
||||||
let path_uri = fs
|
let path_uri = match fs.canonicalize(&skill.path, /*sandbox*/ None).await {
|
||||||
.canonicalize(&skill.path, /*sandbox*/ None)
|
Ok(path) => path,
|
||||||
.await
|
Err(err) if skill_root.discovery_mode == SkillDiscoveryMode::DirectChildren => {
|
||||||
.unwrap_or_else(|_| skill.path.clone());
|
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() {
|
let path = match path_uri.to_abs_path() {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -584,6 +584,37 @@ async fn load_skills_under_root(
|
|||||||
return None;
|
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 {
|
Some(ResolvedDiscoveredSkill {
|
||||||
skill,
|
skill,
|
||||||
path,
|
path,
|
||||||
@@ -604,7 +635,7 @@ async fn load_skills_under_root(
|
|||||||
.map(|skill| skill.path_uri.clone())
|
.map(|skill| skill.path_uri.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let namespace_resolver = async {
|
let namespace_resolver = async {
|
||||||
match plugin_namespace {
|
match skill_root.plugin_namespace.as_deref() {
|
||||||
Some(namespace) => SkillNamespaceResolver::with_provided_namespace(namespace),
|
Some(namespace) => SkillNamespaceResolver::with_provided_namespace(namespace),
|
||||||
None => {
|
None => {
|
||||||
SkillNamespaceResolver::discover(
|
SkillNamespaceResolver::discover(
|
||||||
@@ -627,8 +658,8 @@ async fn load_skills_under_root(
|
|||||||
&skill.skill,
|
&skill.skill,
|
||||||
&skill.path,
|
&skill.path,
|
||||||
&skill.path_uri,
|
&skill.path_uri,
|
||||||
scope,
|
skill_root.scope,
|
||||||
plugin_id,
|
skill_root.plugin_id.as_deref(),
|
||||||
plugin_root,
|
plugin_root,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -651,7 +682,7 @@ async fn load_skills_under_root(
|
|||||||
});
|
});
|
||||||
match result {
|
match result {
|
||||||
Ok(skill) => outcome.skills.push(skill),
|
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 })
|
outcome.errors.push(SkillError { path, message: err })
|
||||||
}
|
}
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use codex_exec_server::WalkEntryKind;
|
|||||||
use codex_exec_server::WalkOptions;
|
use codex_exec_server::WalkOptions;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
|
use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
|
||||||
|
use codex_utils_plugins::SkillDiscoveryMode;
|
||||||
|
|
||||||
use super::MAX_SCAN_DEPTH;
|
use super::MAX_SCAN_DEPTH;
|
||||||
use super::MAX_SKILLS_DIRS_PER_ROOT;
|
use super::MAX_SKILLS_DIRS_PER_ROOT;
|
||||||
@@ -29,6 +30,7 @@ pub(super) enum HiddenDirectoryPolicy {
|
|||||||
pub(super) struct SkillDiscoveryOptions {
|
pub(super) struct SkillDiscoveryOptions {
|
||||||
pub directory_symlinks: DirectorySymlinkPolicy,
|
pub directory_symlinks: DirectorySymlinkPolicy,
|
||||||
pub hidden_directories: HiddenDirectoryPolicy,
|
pub hidden_directories: HiddenDirectoryPolicy,
|
||||||
|
pub mode: SkillDiscoveryMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct SkillDiscovery {
|
pub(super) struct SkillDiscovery {
|
||||||
@@ -64,7 +66,10 @@ pub(super) async fn discover_skills(
|
|||||||
.walk(
|
.walk(
|
||||||
root,
|
root,
|
||||||
WalkOptions {
|
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_directories: MAX_SKILLS_DIRS_PER_ROOT,
|
||||||
max_entries: MAX_SKILLS_ENTRIES_PER_ROOT,
|
max_entries: MAX_SKILLS_ENTRIES_PER_ROOT,
|
||||||
follow_directory_symlinks: matches!(
|
follow_directory_symlinks: matches!(
|
||||||
@@ -137,7 +142,10 @@ pub(super) async fn discover_skills(
|
|||||||
}
|
}
|
||||||
WalkEntryKind::File => {
|
WalkEntryKind::File => {
|
||||||
file_paths.insert(entry.path.clone());
|
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);
|
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 {
|
fn has_hidden_ancestor_below_root(path: &PathUri, root: &PathUri) -> bool {
|
||||||
let mut ancestor = path.parent();
|
let mut ancestor = path.parent();
|
||||||
while let Some(current) = ancestor {
|
while let Some(current) = ancestor {
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ pub async fn load_environment_skills_from_root(
|
|||||||
SkillDiscoveryOptions {
|
SkillDiscoveryOptions {
|
||||||
directory_symlinks: DirectorySymlinkPolicy::Follow,
|
directory_symlinks: DirectorySymlinkPolicy::Follow,
|
||||||
hidden_directories: HiddenDirectoryPolicy::Include,
|
hidden_directories: HiddenDirectoryPolicy::Include,
|
||||||
|
mode: codex_utils_plugins::SkillDiscoveryMode::Recursive,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -574,6 +574,7 @@ async fn load_user_skills_root(root: &Path) -> SkillLoadOutcome {
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
||||||
@@ -1061,6 +1062,7 @@ interface:
|
|||||||
plugin_id: Some("twilio-developer-kit@test".to_string()),
|
plugin_id: Some("twilio-developer-kit@test".to_string()),
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: Some(plugin_root_abs.clone()),
|
plugin_root: Some(plugin_root_abs.clone()),
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
||||||
@@ -1123,6 +1125,7 @@ interface:
|
|||||||
plugin_id: Some("twilio-developer-kit@test".to_string()),
|
plugin_id: Some("twilio-developer-kit@test".to_string()),
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: Some(plugin_root.abs()),
|
plugin_root: Some(plugin_root.abs()),
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
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_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
||||||
@@ -1387,6 +1391,7 @@ async fn system_scope_ignores_symlinked_subdir() {
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
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_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
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_id: Some("sample@test".to_string()),
|
||||||
plugin_namespace: Some("sample".to_string()),
|
plugin_namespace: Some("sample".to_string()),
|
||||||
plugin_root: Some(plugin_root.abs()),
|
plugin_root: Some(plugin_root.abs()),
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
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_id: Some("sample@test".to_string()),
|
||||||
plugin_namespace: Some(plugin_name.clone()),
|
plugin_namespace: Some(plugin_name.clone()),
|
||||||
plugin_root: Some(plugin_root.abs()),
|
plugin_root: Some(plugin_root.abs()),
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
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_id: Some("sample@test".to_string()),
|
||||||
plugin_namespace: Some(plugin_name.clone()),
|
plugin_namespace: Some(plugin_name.clone()),
|
||||||
plugin_root: Some(plugin_root.abs()),
|
plugin_root: Some(plugin_root.abs()),
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
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]
|
#[tokio::test]
|
||||||
async fn loads_short_description_from_metadata() {
|
async fn loads_short_description_from_metadata() {
|
||||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
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_id: None,
|
||||||
plugin_namespace: Some("test".to_string()),
|
plugin_namespace: Some("test".to_string()),
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let root_scan_slots = Semaphore::new(MAX_CONCURRENT_ROOT_SCANS);
|
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_id: None,
|
||||||
plugin_namespace: Some("test".to_string()),
|
plugin_namespace: Some("test".to_string()),
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
&root_scan_slots,
|
&root_scan_slots,
|
||||||
@@ -2518,6 +2599,7 @@ async fn deduplicates_by_path_preferring_first_root() {
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
},
|
},
|
||||||
SkillRoot {
|
SkillRoot {
|
||||||
path: root.path().abs(),
|
path: root.path().abs(),
|
||||||
@@ -2526,6 +2608,7 @@ async fn deduplicates_by_path_preferring_first_root() {
|
|||||||
plugin_id: None,
|
plugin_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ where
|
|||||||
plugin_id,
|
plugin_id,
|
||||||
plugin_namespace,
|
plugin_namespace,
|
||||||
plugin_root,
|
plugin_root,
|
||||||
|
discovery_mode: root.discovery_mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => 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::PathBufExt;
|
||||||
use codex_utils_absolute_path::test_support::PathExt;
|
use codex_utils_absolute_path::test_support::PathExt;
|
||||||
use codex_utils_plugins::PluginSkillRoot;
|
use codex_utils_plugins::PluginSkillRoot;
|
||||||
|
use codex_utils_plugins::SkillDiscoveryMode;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -72,6 +73,7 @@ fn plugin_skill_root_for_skill_path(
|
|||||||
plugin_id: plugin_id.to_string(),
|
plugin_id: plugin_id.to_string(),
|
||||||
plugin_namespace: plugin_namespace.to_string(),
|
plugin_namespace: plugin_namespace.to_string(),
|
||||||
plugin_root: plugin_root.abs(),
|
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_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: codex_utils_plugins::SkillDiscoveryMode::Recursive,
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
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_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: Default::default(),
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(Semaphore::new(1)),
|
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_id: None,
|
||||||
plugin_namespace: None,
|
plugin_namespace: None,
|
||||||
plugin_root: None,
|
plugin_root: None,
|
||||||
|
discovery_mode: Default::default(),
|
||||||
}],
|
}],
|
||||||
/*plugin_skill_snapshots*/ None,
|
/*plugin_skill_snapshots*/ None,
|
||||||
Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
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_absolute_path::AbsolutePathBuf;
|
||||||
use codex_utils_plugins::PluginSkillRoot;
|
use codex_utils_plugins::PluginSkillRoot;
|
||||||
|
use codex_utils_plugins::SkillDiscoveryMode;
|
||||||
|
|
||||||
use crate::AppConnectorId;
|
use crate::AppConnectorId;
|
||||||
use crate::AppDeclaration;
|
use crate::AppDeclaration;
|
||||||
@@ -137,6 +138,7 @@ impl<M: Clone> PluginLoadOutcome<M> {
|
|||||||
plugin_id: plugin.config_name.clone(),
|
plugin_id: plugin.config_name.clone(),
|
||||||
plugin_namespace: plugin_namespace.clone(),
|
plugin_namespace: plugin_namespace.clone(),
|
||||||
plugin_root: plugin.root.clone(),
|
plugin_root: plugin.root.clone(),
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,6 +260,7 @@ mod tests {
|
|||||||
plugin_id: "zeta@test".to_string(),
|
plugin_id: "zeta@test".to_string(),
|
||||||
plugin_namespace: "zeta".to_string(),
|
plugin_namespace: "zeta".to_string(),
|
||||||
plugin_root: test_path("zeta@test"),
|
plugin_root: test_path("zeta@test"),
|
||||||
|
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,29 @@ pub mod mention_syntax;
|
|||||||
pub mod plugin_namespace;
|
pub mod plugin_namespace;
|
||||||
|
|
||||||
pub use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
|
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::find_plugin_manifest_path;
|
||||||
pub use plugin_namespace::plugin_namespace_for_root_uri;
|
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_path;
|
||||||
pub use plugin_namespace::plugin_namespace_for_skill_uri;
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct PluginSkillRoot {
|
pub struct PluginSkillRoot {
|
||||||
pub path: AbsolutePathBuf,
|
pub path: AbsolutePathBuf,
|
||||||
pub plugin_id: String,
|
pub plugin_id: String,
|
||||||
pub plugin_namespace: String,
|
pub plugin_namespace: String,
|
||||||
pub plugin_root: AbsolutePathBuf,
|
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::Path;
|
||||||
use std::path::PathBuf;
|
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> {
|
pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
|
||||||
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -146,4 +175,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
|
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