mirror of
https://github.com/openai/codex.git
synced 2026-09-06 15:29:32 +00:00
Support plugin roots in the host skill loader (#37267)
## What changed - Carry plugin identity, namespace, root, and discovery mode through host skill loading. - Apply the owning plugin namespace and IDs to loaded skill metadata. - Respect direct-child discovery for Agent Plugins and reject skills that resolve outside the plugin root or are not regular files. - Preserve recursive discovery and symlink behavior for legacy plugin roots, and allow the full 64-character namespace plus 64-character skill name. ## Testing Add host-loader coverage for plugin metadata, namespace ownership, direct-child filtering, path containment, recursive symlinks, shared assets, and maximum-length qualified names. GitOrigin-RevId: cdde821643ce39bd030d0c3753b3304b75161690
This commit is contained in:
@@ -99,12 +99,11 @@ fn roots_from_layer_stack(
|
||||
match &layer.name {
|
||||
ConfigLayerSource::Project { .. } => {
|
||||
if let Some(repository_file_system) = &repository_file_system {
|
||||
roots.push(HostSkillRoot {
|
||||
path: config_folder.join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Repo,
|
||||
file_system: Arc::clone(repository_file_system),
|
||||
plugin_root: None,
|
||||
});
|
||||
roots.push(HostSkillRoot::host(
|
||||
config_folder.join(SKILLS_DIR_NAME),
|
||||
SkillScope::Repo,
|
||||
Arc::clone(repository_file_system),
|
||||
));
|
||||
}
|
||||
}
|
||||
ConfigLayerSource::User { .. } => {
|
||||
@@ -145,12 +144,7 @@ fn roots_from_layer_stack(
|
||||
}
|
||||
|
||||
fn local_root(path: AbsolutePathBuf, scope: SkillScope) -> HostSkillRoot {
|
||||
HostSkillRoot {
|
||||
path,
|
||||
scope,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
plugin_root: None,
|
||||
}
|
||||
HostSkillRoot::host(path, scope, Arc::clone(&LOCAL_FS))
|
||||
}
|
||||
|
||||
fn host_root_to_skill_root(root: HostSkillRoot) -> SkillRoot {
|
||||
@@ -193,12 +187,11 @@ async fn repo_agents_skill_roots(
|
||||
.buffered(MAX_CONCURRENT_ANCESTOR_PROBES);
|
||||
while let Some((agents_skills, result)) = results.next().await {
|
||||
match result {
|
||||
Ok(metadata) if metadata.is_directory => roots.push(HostSkillRoot {
|
||||
path: agents_skills,
|
||||
scope: SkillScope::Repo,
|
||||
file_system: Arc::clone(&repository_file_system),
|
||||
plugin_root: None,
|
||||
}),
|
||||
Ok(metadata) if metadata.is_directory => roots.push(HostSkillRoot::host(
|
||||
agents_skills,
|
||||
SkillScope::Repo,
|
||||
Arc::clone(&repository_file_system),
|
||||
)),
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => {
|
||||
|
||||
@@ -303,12 +303,11 @@ impl HostSkillsService {
|
||||
let snapshot = if use_legacy_loader {
|
||||
load_skill_root_snapshot(root, plugin_skill_snapshots).await
|
||||
} else {
|
||||
let snapshot = load_host_skill_root(HostSkillRoot {
|
||||
path: root.path,
|
||||
scope: root.scope,
|
||||
file_system: root.file_system,
|
||||
plugin_root: root.plugin_root,
|
||||
})
|
||||
let snapshot = load_host_skill_root(HostSkillRoot::host(
|
||||
root.path,
|
||||
root.scope,
|
||||
root.file_system,
|
||||
))
|
||||
.await;
|
||||
SkillRootSnapshot::new(
|
||||
snapshot.root,
|
||||
|
||||
@@ -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,15 @@ 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
|
||||
|| entry
|
||||
.path
|
||||
.parent()
|
||||
.and_then(|parent| parent.parent())
|
||||
.as_ref()
|
||||
== Some(root))
|
||||
{
|
||||
skill_files.push(entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use codex_skills::SkillDependencies;
|
||||
use codex_skills::SkillPolicy;
|
||||
use codex_skills::parse_skill_frontmatter_metadata;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use codex_utils_plugins::SkillDiscoveryMode;
|
||||
use futures::StreamExt;
|
||||
|
||||
use super::MAX_QUALIFIED_NAME_LEN;
|
||||
@@ -120,6 +121,7 @@ pub async fn load_environment_skills_from_root(
|
||||
SkillDiscoveryOptions {
|
||||
directory_symlinks: DirectorySymlinkPolicy::Follow,
|
||||
hidden_directories: HiddenDirectoryPolicy::Include,
|
||||
mode: SkillDiscoveryMode::Recursive,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -8,6 +8,10 @@ use codex_skills::SkillMetadata;
|
||||
use codex_skills::parse_skill_frontmatter_metadata;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use codex_utils_plugins::PluginIdentity;
|
||||
#[cfg(test)]
|
||||
use codex_utils_plugins::PluginSkillRoot;
|
||||
use codex_utils_plugins::SkillDiscoveryMode;
|
||||
use futures::StreamExt;
|
||||
use tracing::error;
|
||||
|
||||
@@ -33,7 +37,64 @@ pub struct HostSkillRoot {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub scope: SkillScope,
|
||||
pub file_system: Arc<dyn ExecutorFileSystem>,
|
||||
pub plugin_root: Option<AbsolutePathBuf>,
|
||||
plugin: Option<PluginSkillRootContext>,
|
||||
}
|
||||
|
||||
struct PluginSkillRootContext {
|
||||
identity: PluginIdentity,
|
||||
namespace: String,
|
||||
root: AbsolutePathBuf,
|
||||
discovery_mode: SkillDiscoveryMode,
|
||||
}
|
||||
|
||||
impl HostSkillRoot {
|
||||
pub(crate) fn host(
|
||||
path: AbsolutePathBuf,
|
||||
scope: SkillScope,
|
||||
file_system: Arc<dyn ExecutorFileSystem>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path,
|
||||
scope,
|
||||
file_system,
|
||||
plugin: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn plugin(root: PluginSkillRoot, file_system: Arc<dyn ExecutorFileSystem>) -> Self {
|
||||
Self {
|
||||
path: root.path,
|
||||
scope: SkillScope::User,
|
||||
file_system,
|
||||
plugin: Some(PluginSkillRootContext {
|
||||
identity: root.plugin_identity,
|
||||
namespace: root.plugin_namespace,
|
||||
root: root.plugin_root,
|
||||
discovery_mode: root.discovery_mode,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_identity(&self) -> Option<&PluginIdentity> {
|
||||
self.plugin.as_ref().map(|plugin| &plugin.identity)
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_namespace(&self) -> Option<&str> {
|
||||
self.plugin.as_ref().map(|plugin| plugin.namespace.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_root(&self) -> Option<&AbsolutePathBuf> {
|
||||
self.plugin.as_ref().map(|plugin| &plugin.root)
|
||||
}
|
||||
|
||||
pub(crate) fn discovery_mode(&self) -> SkillDiscoveryMode {
|
||||
self.plugin
|
||||
.as_ref()
|
||||
.map_or(SkillDiscoveryMode::Recursive, |plugin| {
|
||||
plugin.discovery_mode
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -81,10 +142,12 @@ async fn load_skills_under_root(
|
||||
Vec<HostSkillError>,
|
||||
) {
|
||||
let file_system = skill_root.file_system.as_ref();
|
||||
let plugin_root = match skill_root.plugin_root.as_ref() {
|
||||
let plugin_identity = skill_root.plugin_identity();
|
||||
let plugin_root = match skill_root.plugin_root() {
|
||||
Some(plugin_root) => Some(canonicalize_for_skill_identity(file_system, plugin_root).await),
|
||||
None => None,
|
||||
};
|
||||
let discovery_mode = skill_root.discovery_mode();
|
||||
let directory_symlinks = match skill_root.scope {
|
||||
SkillScope::User | SkillScope::Repo | SkillScope::Admin => DirectorySymlinkPolicy::Follow,
|
||||
SkillScope::System => DirectorySymlinkPolicy::Ignore,
|
||||
@@ -100,6 +163,7 @@ async fn load_skills_under_root(
|
||||
SkillDiscoveryOptions {
|
||||
directory_symlinks,
|
||||
hidden_directories: HiddenDirectoryPolicy::Skip,
|
||||
mode: discovery_mode,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -111,12 +175,23 @@ async fn load_skills_under_root(
|
||||
}
|
||||
|
||||
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 = file_system
|
||||
let path_uri = match file_system
|
||||
.canonicalize(&skill.path, /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap_or_else(|_| skill.path.clone());
|
||||
{
|
||||
Ok(path) => path,
|
||||
Err(error) if discovery_mode == SkillDiscoveryMode::DirectChildren => {
|
||||
error!(
|
||||
"failed to resolve Agent Plugin skill path {}: {error}",
|
||||
skill.path
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(_) => skill.path.clone(),
|
||||
};
|
||||
let path = match path_uri.to_abs_path() {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
@@ -124,6 +199,37 @@ async fn load_skills_under_root(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if 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 file_system.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(error) => {
|
||||
error!(
|
||||
"failed to inspect Agent Plugin skill path {}: {error}",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ResolvedDiscoveredSkill {
|
||||
skill,
|
||||
path,
|
||||
@@ -143,13 +249,21 @@ async fn load_skills_under_root(
|
||||
.iter()
|
||||
.map(|skill| skill.path_uri.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let namespace_resolver = SkillNamespaceResolver::discover(
|
||||
file_system,
|
||||
&root_uri,
|
||||
&skill_paths,
|
||||
plugin_roots,
|
||||
namespace_roots,
|
||||
);
|
||||
let namespace_resolver = async {
|
||||
match skill_root.plugin_namespace() {
|
||||
Some(namespace) => SkillNamespaceResolver::with_provided_namespace(namespace),
|
||||
None => {
|
||||
SkillNamespaceResolver::discover(
|
||||
file_system,
|
||||
&root_uri,
|
||||
&skill_paths,
|
||||
plugin_roots,
|
||||
namespace_roots,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
};
|
||||
let skill_results = futures::stream::iter(resolved_skills)
|
||||
.map(|skill| {
|
||||
let plugin_root = plugin_root.as_ref();
|
||||
@@ -165,6 +279,7 @@ async fn load_skills_under_root(
|
||||
&skill.path,
|
||||
&skill.path_uri,
|
||||
skill_root.scope,
|
||||
plugin_identity,
|
||||
plugin_root,
|
||||
)
|
||||
.await;
|
||||
@@ -212,6 +327,7 @@ async fn parse_skill_file(
|
||||
path: &AbsolutePathBuf,
|
||||
path_uri: &PathUri,
|
||||
scope: SkillScope,
|
||||
plugin_identity: Option<&PluginIdentity>,
|
||||
plugin_root: Option<&AbsolutePathBuf>,
|
||||
) -> Result<SkillMetadata, String> {
|
||||
let metadata_path = path_uri
|
||||
@@ -250,8 +366,8 @@ async fn parse_skill_file(
|
||||
policy,
|
||||
path_to_skills_md: path.clone(),
|
||||
scope,
|
||||
plugin_id: None,
|
||||
remote_plugin_id: None,
|
||||
plugin_id: plugin_identity.map(|identity| identity.plugin_id.clone()),
|
||||
remote_plugin_id: plugin_identity.and_then(|identity| identity.remote_plugin_id.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,14 @@ use codex_skills::SkillMetadata;
|
||||
use codex_skills::SkillPolicy;
|
||||
use codex_skills::SkillToolDependency;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PluginIdentity;
|
||||
use codex_utils_plugins::PluginSkillRoot;
|
||||
use codex_utils_plugins::SkillDiscoveryMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::loader::MAX_NAME_LEN;
|
||||
|
||||
use super::HostSkillRoot;
|
||||
use super::load_host_skill_root;
|
||||
|
||||
@@ -36,12 +41,11 @@ fn write_metadata(root: &TempDir, directory: &str, contents: &str) {
|
||||
}
|
||||
|
||||
fn root_for(temp_dir: &TempDir, scope: SkillScope) -> HostSkillRoot {
|
||||
HostSkillRoot {
|
||||
path: AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute root"),
|
||||
HostSkillRoot::host(
|
||||
AbsolutePathBuf::from_absolute_path(temp_dir.path()).expect("absolute root"),
|
||||
scope,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
plugin_root: None,
|
||||
}
|
||||
Arc::clone(&LOCAL_FS),
|
||||
)
|
||||
}
|
||||
|
||||
struct PluginSkillFixture {
|
||||
@@ -81,12 +85,19 @@ impl PluginSkillFixture {
|
||||
}
|
||||
|
||||
fn host_root(&self) -> HostSkillRoot {
|
||||
HostSkillRoot {
|
||||
path: self.plugin_root.join("skills"),
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
plugin_root: Some(self.plugin_root.clone()),
|
||||
}
|
||||
HostSkillRoot::plugin(
|
||||
PluginSkillRoot {
|
||||
path: self.plugin_root.join("skills"),
|
||||
plugin_identity: PluginIdentity {
|
||||
plugin_id: "fixture@test".to_string(),
|
||||
remote_plugin_id: None,
|
||||
},
|
||||
plugin_namespace: "plugin".to_string(),
|
||||
plugin_root: self.plugin_root.clone(),
|
||||
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
},
|
||||
Arc::clone(&LOCAL_FS),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +245,7 @@ async fn loads_plugin_skill_interface_icons_from_local_and_shared_assets() {
|
||||
assert_eq!(
|
||||
snapshot.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "send-message".to_string(),
|
||||
name: "plugin:send-message".to_string(),
|
||||
description: "Send messages".to_string(),
|
||||
short_description: None,
|
||||
interface: Some(SkillInterface {
|
||||
@@ -253,7 +264,7 @@ async fn loads_plugin_skill_interface_icons_from_local_and_shared_assets() {
|
||||
policy: None,
|
||||
path_to_skills_md: fixture.skill_path,
|
||||
scope: SkillScope::User,
|
||||
plugin_id: None,
|
||||
plugin_id: Some("fixture@test".to_string()),
|
||||
remote_plugin_id: None,
|
||||
}]
|
||||
);
|
||||
@@ -276,7 +287,7 @@ async fn rejects_plugin_skill_interface_icons_outside_shared_assets() {
|
||||
assert_eq!(
|
||||
snapshot.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "send-message".to_string(),
|
||||
name: "plugin:send-message".to_string(),
|
||||
description: "Send messages".to_string(),
|
||||
short_description: None,
|
||||
interface: Some(SkillInterface {
|
||||
@@ -291,7 +302,7 @@ async fn rejects_plugin_skill_interface_icons_outside_shared_assets() {
|
||||
policy: None,
|
||||
path_to_skills_md: fixture.skill_path,
|
||||
scope: SkillScope::User,
|
||||
plugin_id: None,
|
||||
plugin_id: Some("fixture@test".to_string()),
|
||||
remote_plugin_id: None,
|
||||
}]
|
||||
);
|
||||
@@ -377,6 +388,254 @@ async fn discovers_nested_plugin_namespace_without_plugin_identity() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_root_accepts_maximum_length_qualified_skill_name() {
|
||||
let root = TempDir::new().expect("temp dir");
|
||||
let plugin_namespace = "p".repeat(MAX_NAME_LEN);
|
||||
let skill_name = "s".repeat(MAX_NAME_LEN);
|
||||
let skill_path = write_skill(
|
||||
&root,
|
||||
"skills/search",
|
||||
&format!("name: {skill_name}\ndescription: Search skill"),
|
||||
);
|
||||
let plugin_root = AbsolutePathBuf::from_absolute_path(root.path()).expect("plugin root");
|
||||
|
||||
let snapshot = load_host_skill_root(HostSkillRoot::plugin(
|
||||
PluginSkillRoot {
|
||||
path: plugin_root.join("skills"),
|
||||
plugin_identity: PluginIdentity {
|
||||
plugin_id: "demo@test".to_string(),
|
||||
remote_plugin_id: None,
|
||||
},
|
||||
plugin_namespace: plugin_namespace.clone(),
|
||||
plugin_root,
|
||||
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
},
|
||||
Arc::clone(&LOCAL_FS),
|
||||
))
|
||||
.await;
|
||||
|
||||
assert_eq!(snapshot.errors, Vec::new());
|
||||
assert_eq!(
|
||||
snapshot.skills,
|
||||
vec![SkillMetadata {
|
||||
name: format!("{plugin_namespace}:{skill_name}"),
|
||||
description: "Search skill".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
path_to_skills_md: skill_path,
|
||||
scope: SkillScope::User,
|
||||
plugin_id: Some("demo@test".to_string()),
|
||||
remote_plugin_id: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recursive_plugin_root_preserves_owner_namespace_and_shared_asset_policy() {
|
||||
let root = TempDir::new().expect("temp dir");
|
||||
let skill_path = write_skill(
|
||||
&root,
|
||||
"skills/group/demo",
|
||||
"name: demo\ndescription: Demo skill",
|
||||
);
|
||||
let nested_manifest = root.path().join("skills/group/.codex-plugin/plugin.json");
|
||||
fs::create_dir_all(nested_manifest.parent().expect("nested manifest parent"))
|
||||
.expect("create nested plugin manifest directory");
|
||||
fs::write(nested_manifest, r#"{"name":"conflicting-plugin"}"#)
|
||||
.expect("write nested plugin manifest");
|
||||
write_metadata(
|
||||
&root,
|
||||
"skills/group/demo",
|
||||
"interface:\n icon_small: ../../../assets/logo.svg\n",
|
||||
);
|
||||
fs::create_dir_all(root.path().join("assets")).expect("create assets directory");
|
||||
fs::write(root.path().join("assets/logo.svg"), "<svg/>").expect("write asset");
|
||||
let plugin_root = AbsolutePathBuf::from_absolute_path(root.path()).expect("plugin root");
|
||||
let canonical_plugin_root = AbsolutePathBuf::from_absolute_path(
|
||||
fs::canonicalize(root.path()).expect("canonicalize plugin root"),
|
||||
)
|
||||
.expect("canonical plugin root");
|
||||
|
||||
let snapshot = load_host_skill_root(HostSkillRoot::plugin(
|
||||
PluginSkillRoot {
|
||||
path: plugin_root.join("skills"),
|
||||
plugin_identity: PluginIdentity {
|
||||
plugin_id: "demo@test".to_string(),
|
||||
remote_plugin_id: Some("remote-demo".to_string()),
|
||||
},
|
||||
plugin_namespace: "plugin".to_string(),
|
||||
plugin_root,
|
||||
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
},
|
||||
Arc::clone(&LOCAL_FS),
|
||||
))
|
||||
.await;
|
||||
|
||||
assert_eq!(snapshot.errors, Vec::new());
|
||||
assert_eq!(
|
||||
snapshot.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "plugin:demo".to_string(),
|
||||
description: "Demo skill".to_string(),
|
||||
short_description: None,
|
||||
interface: Some(SkillInterface {
|
||||
display_name: None,
|
||||
short_description: None,
|
||||
icon_small: Some(canonical_plugin_root.join("assets/logo.svg")),
|
||||
icon_large: None,
|
||||
brand_color: None,
|
||||
default_prompt: None,
|
||||
}),
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
path_to_skills_md: skill_path.clone(),
|
||||
scope: SkillScope::User,
|
||||
plugin_id: Some("demo@test".to_string()),
|
||||
remote_plugin_id: Some("remote-demo".to_string()),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.skill_discovery_path_by_path.get(&skill_path),
|
||||
Some(&skill_path)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_child_plugin_root_ignores_nested_skills() {
|
||||
let root = TempDir::new().expect("temp dir");
|
||||
let direct_path = write_skill(
|
||||
&root,
|
||||
"skills/direct",
|
||||
"name: direct\ndescription: Direct skill",
|
||||
);
|
||||
write_skill(
|
||||
&root,
|
||||
"skills/nested/too-deep",
|
||||
"name: nested\ndescription: Nested skill",
|
||||
);
|
||||
let plugin_root = AbsolutePathBuf::from_absolute_path(root.path()).expect("plugin root");
|
||||
|
||||
let snapshot = load_host_skill_root(HostSkillRoot::plugin(
|
||||
PluginSkillRoot {
|
||||
path: plugin_root.join("skills"),
|
||||
plugin_identity: PluginIdentity {
|
||||
plugin_id: "demo@test".to_string(),
|
||||
remote_plugin_id: None,
|
||||
},
|
||||
plugin_namespace: "plugin".to_string(),
|
||||
plugin_root,
|
||||
discovery_mode: SkillDiscoveryMode::DirectChildren,
|
||||
},
|
||||
Arc::clone(&LOCAL_FS),
|
||||
))
|
||||
.await;
|
||||
|
||||
assert_eq!(snapshot.errors, Vec::new());
|
||||
assert_eq!(
|
||||
snapshot.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: direct_path,
|
||||
scope: SkillScope::User,
|
||||
plugin_id: Some("demo@test".to_string()),
|
||||
remote_plugin_id: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn direct_child_plugin_root_skips_skills_resolving_outside_plugin_root() {
|
||||
let root = TempDir::new().expect("temp dir");
|
||||
let outside_root = TempDir::new().expect("outside temp dir");
|
||||
write_skill(
|
||||
&outside_root,
|
||||
"escaped",
|
||||
"name: escaped\ndescription: Escaped skill",
|
||||
);
|
||||
fs::create_dir_all(root.path().join("skills")).expect("create skills root");
|
||||
std::os::unix::fs::symlink(
|
||||
outside_root.path().join("escaped"),
|
||||
root.path().join("skills/escaped"),
|
||||
)
|
||||
.expect("create skill symlink");
|
||||
let plugin_root = AbsolutePathBuf::from_absolute_path(root.path()).expect("plugin root");
|
||||
|
||||
let snapshot = load_host_skill_root(HostSkillRoot::plugin(
|
||||
PluginSkillRoot {
|
||||
path: plugin_root.join("skills"),
|
||||
plugin_identity: PluginIdentity {
|
||||
plugin_id: "demo@test".to_string(),
|
||||
remote_plugin_id: None,
|
||||
},
|
||||
plugin_namespace: "plugin".to_string(),
|
||||
plugin_root,
|
||||
discovery_mode: SkillDiscoveryMode::DirectChildren,
|
||||
},
|
||||
Arc::clone(&LOCAL_FS),
|
||||
))
|
||||
.await;
|
||||
|
||||
assert_eq!(snapshot.errors, Vec::new());
|
||||
assert_eq!(snapshot.skills, Vec::new());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn recursive_plugin_root_preserves_symlinked_skill_discovery_path() {
|
||||
let root = TempDir::new().expect("temp dir");
|
||||
let target = TempDir::new().expect("target temp dir");
|
||||
let target_skill = write_skill(&target, "demo", "name: demo\ndescription: Symlinked skill");
|
||||
fs::create_dir_all(root.path().join("skills")).expect("create skills root");
|
||||
std::os::unix::fs::symlink(target.path().join("demo"), root.path().join("skills/alias"))
|
||||
.expect("create skill symlink");
|
||||
let plugin_root = AbsolutePathBuf::from_absolute_path(root.path()).expect("plugin root");
|
||||
|
||||
let snapshot = load_host_skill_root(HostSkillRoot::plugin(
|
||||
PluginSkillRoot {
|
||||
path: plugin_root.join("skills"),
|
||||
plugin_identity: PluginIdentity {
|
||||
plugin_id: "demo@test".to_string(),
|
||||
remote_plugin_id: None,
|
||||
},
|
||||
plugin_namespace: "plugin".to_string(),
|
||||
plugin_root,
|
||||
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
},
|
||||
Arc::clone(&LOCAL_FS),
|
||||
))
|
||||
.await;
|
||||
|
||||
assert_eq!(snapshot.errors, Vec::new());
|
||||
assert_eq!(
|
||||
snapshot.skills,
|
||||
vec![SkillMetadata {
|
||||
name: "plugin:demo".to_string(),
|
||||
description: "Symlinked skill".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
path_to_skills_md: target_skill.clone(),
|
||||
scope: SkillScope::User,
|
||||
plugin_id: Some("demo@test".to_string()),
|
||||
remote_plugin_id: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.skill_discovery_path_by_path.get(&target_skill),
|
||||
Some(&snapshot.root.join("alias/SKILL.md"))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn follows_directory_symlinks_for_user_but_not_system_scope() {
|
||||
|
||||
@@ -13,7 +13,7 @@ pub(super) const SKILLS_FILENAME: &str = "SKILL.md";
|
||||
pub(super) const SKILLS_METADATA_DIR: &str = "agents";
|
||||
pub(super) const SKILLS_METADATA_FILENAME: &str = "openai.yaml";
|
||||
pub(super) const MAX_NAME_LEN: usize = 64;
|
||||
pub(super) const MAX_QUALIFIED_NAME_LEN: usize = 128;
|
||||
pub(super) const MAX_QUALIFIED_NAME_LEN: usize = MAX_NAME_LEN * 2 + 1;
|
||||
pub(super) const MAX_DESCRIPTION_LEN: usize = 1024;
|
||||
pub(super) const MAX_DEPENDENCY_TYPE_LEN: usize = MAX_NAME_LEN;
|
||||
pub(super) const MAX_DEPENDENCY_TRANSPORT_LEN: usize = MAX_NAME_LEN;
|
||||
|
||||
@@ -28,6 +28,14 @@ pub(crate) struct SkillNamespaceResolver {
|
||||
}
|
||||
|
||||
impl SkillNamespaceResolver {
|
||||
/// Uses the authoritative namespace supplied by an owning plugin.
|
||||
pub(crate) fn with_provided_namespace(namespace: &str) -> Self {
|
||||
Self {
|
||||
inherited_namespace: ResolvedSkillNamespace::Plugin(namespace.to_string()),
|
||||
nested_namespaces: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn discover(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
root: &PathUri,
|
||||
|
||||
Reference in New Issue
Block a user