mirror of
https://github.com/openai/codex.git
synced 2026-11-11 06:31:37 +00:00
perf(skills): reuse walk inventory for host loading (#31566)
## Why CCA thread startup loads repository skills through the primary remote environment. This still used the older host skill loader, which recursively issued directory and metadata requests over the exec-server connection. On a high-latency connection, one skill scan could turn into hundreds of round trips. Executor-selected skill roots already avoid this pattern: they use one bounded filesystem walk, reuse the returned inventory, and overlap independent reads. ## What changed - Extract the URI-native walk inventory into a shared private discovery module. - Use that discovery path for both environment-owned and host/repository skill roots. - Reuse the walk inventory to avoid per-skill metadata existence checks when the inventory is complete. - Canonicalize host skill identities and parse skill files with bounded concurrency. - Prune hidden host directories during traversal, preserving visible aliases and walk limits. - Cache namespace probes by ancestor and keep all remote namespace reads under the same concurrency bound. - Keep safe metadata probes for incomplete walks, file symlinks, and case aliases. ## Dependency #31570 is now merged. It provides the optional `pruneHiddenDirectories` walk flag used here. That flag defaults to false and is omitted when disabled. No other exec-server API changes are introduced.
This commit is contained in:
7 files changed
+596
-474
No files matched your search
+161
-260
@@ -1,3 +1,4 @@
|
|||||||
|
mod discovery;
|
||||||
mod environment;
|
mod environment;
|
||||||
mod namespace;
|
mod namespace;
|
||||||
|
|
||||||
@@ -26,15 +27,21 @@ use codex_protocol::protocol::SkillScope;
|
|||||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
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::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
|
|
||||||
use codex_utils_plugins::PluginSkillRoot;
|
use codex_utils_plugins::PluginSkillRoot;
|
||||||
use dirs::home_dir;
|
use dirs::home_dir;
|
||||||
use futures::future::join_all;
|
use discovery::DirectorySymlinkPolicy;
|
||||||
use namespace::ResolvedSkillNamespace;
|
use discovery::DiscoveredSkill;
|
||||||
|
use discovery::HiddenDirectoryPolicy;
|
||||||
|
use discovery::MAX_CONCURRENT_SKILL_LOADS;
|
||||||
|
use discovery::SkillDiscovery;
|
||||||
|
use discovery::SkillDiscoveryOptions;
|
||||||
|
use discovery::SkillMetadataDiscovery;
|
||||||
|
use discovery::discover_skills;
|
||||||
|
use futures::FutureExt;
|
||||||
|
use futures::StreamExt;
|
||||||
use namespace::SkillNamespaceResolver;
|
use namespace::SkillNamespaceResolver;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -140,17 +147,10 @@ const MAX_DEPENDENCY_URL_LEN: usize = MAX_DESCRIPTION_LEN;
|
|||||||
const MAX_SCAN_DEPTH: usize = 6;
|
const MAX_SCAN_DEPTH: usize = 6;
|
||||||
const MAX_SKILLS_DIRS_PER_ROOT: usize = 2000;
|
const MAX_SKILLS_DIRS_PER_ROOT: usize = 2000;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
struct ResolvedDiscoveredSkill {
|
||||||
enum SymlinkPolicy {
|
skill: DiscoveredSkill,
|
||||||
FollowDirectories,
|
path: AbsolutePathBuf,
|
||||||
Ignore,
|
path_uri: PathUri,
|
||||||
}
|
|
||||||
|
|
||||||
struct SkillFileDiscovery {
|
|
||||||
skill_files: Vec<PathUri>,
|
|
||||||
plugin_roots: HashSet<PathUri>,
|
|
||||||
namespace_roots: HashSet<PathUri>,
|
|
||||||
warnings: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -194,9 +194,12 @@ pub async fn load_skills_from_roots<I>(
|
|||||||
plugin_skill_snapshots: Option<&crate::PluginSkillSnapshots>,
|
plugin_skill_snapshots: Option<&crate::PluginSkillSnapshots>,
|
||||||
) -> SkillLoadOutcome
|
) -> SkillLoadOutcome
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = SkillRoot>,
|
I: IntoIterator<Item = SkillRoot> + Send,
|
||||||
|
I::IntoIter: Send,
|
||||||
{
|
{
|
||||||
crate::root_loader::load_and_merge_skill_roots(roots, plugin_skill_snapshots).await
|
crate::root_loader::load_and_merge_skill_roots(roots, plugin_skill_snapshots)
|
||||||
|
.boxed()
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -497,172 +500,6 @@ async fn canonicalize_for_skill_identity(
|
|||||||
.unwrap_or_else(|_| path.clone())
|
.unwrap_or_else(|_| path.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn discover_skills_under_root(
|
|
||||||
fs: &dyn ExecutorFileSystem,
|
|
||||||
root: &PathUri,
|
|
||||||
symlink_policy: SymlinkPolicy,
|
|
||||||
) -> SkillFileDiscovery {
|
|
||||||
let root = root.clone();
|
|
||||||
let mut discovery = SkillFileDiscovery {
|
|
||||||
skill_files: Vec::new(),
|
|
||||||
plugin_roots: HashSet::new(),
|
|
||||||
namespace_roots: HashSet::from([root.clone()]),
|
|
||||||
warnings: Vec::new(),
|
|
||||||
};
|
|
||||||
match fs.get_metadata(&root, /*sandbox*/ None).await {
|
|
||||||
Ok(metadata) if metadata.is_directory => {}
|
|
||||||
Ok(_) => return discovery,
|
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return discovery,
|
|
||||||
Err(err) => {
|
|
||||||
discovery
|
|
||||||
.warnings
|
|
||||||
.push(format!("failed to stat skills root {root}: {err:#}"));
|
|
||||||
return discovery;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enqueue_dir(
|
|
||||||
queue: &mut VecDeque<(PathUri, usize)>,
|
|
||||||
visited_dirs: &mut HashSet<PathUri>,
|
|
||||||
truncated_by_dir_limit: &mut bool,
|
|
||||||
path: PathUri,
|
|
||||||
depth: usize,
|
|
||||||
) {
|
|
||||||
if depth > MAX_SCAN_DEPTH {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if visited_dirs.len() >= MAX_SKILLS_DIRS_PER_ROOT {
|
|
||||||
*truncated_by_dir_limit = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if visited_dirs.insert(path.clone()) {
|
|
||||||
queue.push_back((path, depth));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let follow_symlinks = matches!(symlink_policy, SymlinkPolicy::FollowDirectories);
|
|
||||||
let mut visited_dirs: HashSet<PathUri> = HashSet::from([root.clone()]);
|
|
||||||
let mut queue: VecDeque<(PathUri, usize)> = VecDeque::from([(root.clone(), 0)]);
|
|
||||||
let mut truncated_by_dir_limit = false;
|
|
||||||
|
|
||||||
while let Some((dir, depth)) = queue.pop_front() {
|
|
||||||
let entries = match fs.read_directory(&dir, /*sandbox*/ None).await {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(err) => {
|
|
||||||
discovery
|
|
||||||
.warnings
|
|
||||||
.push(format!("failed to read skills directory {dir}: {err:#}"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let paths = entries
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|entry| {
|
|
||||||
let file_name = entry.file_name;
|
|
||||||
if DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
|
||||||
.iter()
|
|
||||||
.any(|path| path.split('/').next() == Some(file_name.as_str()))
|
|
||||||
{
|
|
||||||
discovery.plugin_roots.insert(dir.clone());
|
|
||||||
}
|
|
||||||
if file_name.starts_with('.') {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match dir.join(&file_name) {
|
|
||||||
Ok(path) => Some((file_name, path)),
|
|
||||||
Err(err) => {
|
|
||||||
discovery.warnings.push(format!(
|
|
||||||
"failed to resolve skill path {dir}/{file_name}: {err}"
|
|
||||||
));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let metadata_results = join_all(
|
|
||||||
paths
|
|
||||||
.iter()
|
|
||||||
.map(|(_, path)| fs.get_metadata(path, /*sandbox*/ None)),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
for ((file_name, path), metadata_result) in paths.into_iter().zip(metadata_results) {
|
|
||||||
let metadata = match metadata_result {
|
|
||||||
Ok(metadata) => metadata,
|
|
||||||
Err(err) => {
|
|
||||||
discovery
|
|
||||||
.warnings
|
|
||||||
.push(format!("failed to stat skill path {path}: {err:#}"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if metadata.is_symlink {
|
|
||||||
if !follow_symlinks {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match fs.read_directory(&path, /*sandbox*/ None).await {
|
|
||||||
Ok(_) => {
|
|
||||||
let resolved_dir = canonicalize_uri_for_skill_identity(fs, &path).await;
|
|
||||||
discovery.namespace_roots.insert(resolved_dir.clone());
|
|
||||||
enqueue_dir(
|
|
||||||
&mut queue,
|
|
||||||
&mut visited_dirs,
|
|
||||||
&mut truncated_by_dir_limit,
|
|
||||||
resolved_dir,
|
|
||||||
depth + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err)
|
|
||||||
if matches!(
|
|
||||||
err.kind(),
|
|
||||||
io::ErrorKind::NotADirectory | io::ErrorKind::NotFound
|
|
||||||
) => {}
|
|
||||||
Err(err) => discovery.warnings.push(format!(
|
|
||||||
"failed to read skills symlink directory {path}: {err:#}"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.is_directory {
|
|
||||||
enqueue_dir(
|
|
||||||
&mut queue,
|
|
||||||
&mut visited_dirs,
|
|
||||||
&mut truncated_by_dir_limit,
|
|
||||||
path,
|
|
||||||
depth + 1,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.is_file && file_name == SKILLS_FILENAME {
|
|
||||||
discovery.skill_files.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if truncated_by_dir_limit {
|
|
||||||
tracing::warn!(
|
|
||||||
"skills scan truncated after {} directories (root: {})",
|
|
||||||
MAX_SKILLS_DIRS_PER_ROOT,
|
|
||||||
root
|
|
||||||
);
|
|
||||||
}
|
|
||||||
discovery
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn canonicalize_uri_for_skill_identity(
|
|
||||||
file_system: &dyn ExecutorFileSystem,
|
|
||||||
path: &PathUri,
|
|
||||||
) -> PathUri {
|
|
||||||
file_system
|
|
||||||
.canonicalize(path, /*sandbox*/ None)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|_| path.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_skills_under_root(
|
async fn load_skills_under_root(
|
||||||
fs: &dyn ExecutorFileSystem,
|
fs: &dyn ExecutorFileSystem,
|
||||||
root: &AbsolutePathBuf,
|
root: &AbsolutePathBuf,
|
||||||
@@ -676,56 +513,113 @@ async fn load_skills_under_root(
|
|||||||
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 symlink_policy = match scope {
|
let directory_symlinks = match scope {
|
||||||
SkillScope::User | SkillScope::Repo | SkillScope::Admin => SymlinkPolicy::FollowDirectories,
|
SkillScope::User | SkillScope::Repo | SkillScope::Admin => DirectorySymlinkPolicy::Follow,
|
||||||
SkillScope::System => SymlinkPolicy::Ignore,
|
SkillScope::System => DirectorySymlinkPolicy::Ignore,
|
||||||
};
|
};
|
||||||
let SkillFileDiscovery {
|
let SkillDiscovery {
|
||||||
skill_files,
|
skills,
|
||||||
plugin_roots,
|
plugin_roots,
|
||||||
namespace_roots,
|
mut namespace_roots,
|
||||||
warnings,
|
warnings,
|
||||||
} = discover_skills_under_root(fs, &PathUri::from_abs_path(root), symlink_policy).await;
|
} = discover_skills(
|
||||||
|
fs,
|
||||||
|
&PathUri::from_abs_path(root),
|
||||||
|
// Preserve host discovery behavior: directory aliases are scope-dependent, while hidden
|
||||||
|
// directories are skipped unless reached through a visible alias.
|
||||||
|
SkillDiscoveryOptions {
|
||||||
|
directory_symlinks,
|
||||||
|
hidden_directories: HiddenDirectoryPolicy::Skip,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
for warning in warnings {
|
for warning in warnings {
|
||||||
error!("{warning}");
|
error!("{warning}");
|
||||||
}
|
}
|
||||||
let root_uri = PathUri::from_abs_path(root);
|
let root_uri = PathUri::from_abs_path(root);
|
||||||
let namespace_resolver = match plugin_namespace {
|
let resolved_skills = futures::stream::iter(skills)
|
||||||
Some(namespace) => SkillNamespaceResolver::with_provided_namespace(namespace),
|
.map(|skill| async move {
|
||||||
None => {
|
let path_uri = fs
|
||||||
SkillNamespaceResolver::discover(
|
.canonicalize(&skill.path, /*sandbox*/ None)
|
||||||
fs,
|
.await
|
||||||
&root_uri,
|
.unwrap_or_else(|_| skill.path.clone());
|
||||||
&skill_files,
|
let path = match path_uri.to_abs_path() {
|
||||||
plugin_roots,
|
Ok(path) => path,
|
||||||
namespace_roots,
|
Err(err) => {
|
||||||
)
|
error!("failed to convert discovered skill path {path_uri}: {err}");
|
||||||
.await
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(ResolvedDiscoveredSkill {
|
||||||
|
skill,
|
||||||
|
path,
|
||||||
|
path_uri,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.buffered(MAX_CONCURRENT_SKILL_LOADS)
|
||||||
|
.filter_map(futures::future::ready)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await;
|
||||||
|
namespace_roots.extend(resolved_skills.iter().filter_map(|skill| {
|
||||||
|
(skill.path_uri != skill.skill.path)
|
||||||
|
.then(|| skill.path_uri.parent())
|
||||||
|
.flatten()
|
||||||
|
}));
|
||||||
|
let skill_paths = resolved_skills
|
||||||
|
.iter()
|
||||||
|
.map(|skill| skill.path_uri.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let namespace_resolver = async {
|
||||||
|
match plugin_namespace {
|
||||||
|
Some(namespace) => SkillNamespaceResolver::with_provided_namespace(namespace),
|
||||||
|
None => {
|
||||||
|
SkillNamespaceResolver::discover(
|
||||||
|
fs,
|
||||||
|
&root_uri,
|
||||||
|
&skill_paths,
|
||||||
|
plugin_roots,
|
||||||
|
namespace_roots,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for path_uri in skill_files {
|
let skill_results = futures::stream::iter(resolved_skills)
|
||||||
let path = match path_uri.to_abs_path() {
|
.map(|skill| {
|
||||||
Ok(path) => path,
|
let plugin_root = plugin_root.as_ref();
|
||||||
Err(err) => {
|
async move {
|
||||||
error!("failed to convert discovered skill path {path_uri}: {err}");
|
let result = parse_skill_file(
|
||||||
continue;
|
fs,
|
||||||
|
&skill.skill,
|
||||||
|
&skill.path,
|
||||||
|
&skill.path_uri,
|
||||||
|
scope,
|
||||||
|
plugin_id,
|
||||||
|
plugin_root,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string());
|
||||||
|
(skill.path, skill.path_uri, result)
|
||||||
}
|
}
|
||||||
};
|
})
|
||||||
match parse_skill_file(
|
.buffered(MAX_CONCURRENT_SKILL_LOADS)
|
||||||
fs,
|
.collect::<Vec<_>>()
|
||||||
&path,
|
.boxed();
|
||||||
scope,
|
let (namespace_resolver, skill_results) = tokio::join!(namespace_resolver, skill_results);
|
||||||
plugin_id,
|
for (path, path_uri, result) in skill_results {
|
||||||
namespace_resolver.for_skill(&root_uri, &path_uri),
|
let result = result.and_then(|mut skill| {
|
||||||
plugin_root.as_ref(),
|
skill.name = namespace_resolver
|
||||||
)
|
.for_skill(&root_uri, &path_uri)
|
||||||
.await
|
.qualify(&skill.name);
|
||||||
{
|
validate_len(&skill.name, MAX_QUALIFIED_NAME_LEN, "qualified name")
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
Ok(skill)
|
||||||
|
});
|
||||||
|
match result {
|
||||||
Ok(skill) => outcome.skills.push(skill),
|
Ok(skill) => outcome.skills.push(skill),
|
||||||
Err(err) if scope != SkillScope::System => outcome.errors.push(SkillError {
|
Err(err) if scope != SkillScope::System => {
|
||||||
path,
|
outcome.errors.push(SkillError { path, message: err })
|
||||||
message: err.to_string(),
|
}
|
||||||
}),
|
|
||||||
Err(_) => {}
|
Err(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,41 +627,47 @@ async fn load_skills_under_root(
|
|||||||
|
|
||||||
async fn parse_skill_file(
|
async fn parse_skill_file(
|
||||||
fs: &dyn ExecutorFileSystem,
|
fs: &dyn ExecutorFileSystem,
|
||||||
|
skill: &DiscoveredSkill,
|
||||||
path: &AbsolutePathBuf,
|
path: &AbsolutePathBuf,
|
||||||
|
path_uri: &PathUri,
|
||||||
scope: SkillScope,
|
scope: SkillScope,
|
||||||
plugin_id: Option<&str>,
|
plugin_id: Option<&str>,
|
||||||
namespace: &ResolvedSkillNamespace,
|
|
||||||
plugin_root: Option<&AbsolutePathBuf>,
|
plugin_root: Option<&AbsolutePathBuf>,
|
||||||
) -> Result<SkillMetadata, SkillParseError> {
|
) -> Result<SkillMetadata, SkillParseError> {
|
||||||
let path_uri = PathUri::from_abs_path(path);
|
let metadata_path = path_uri
|
||||||
let contents = fs
|
.parent()
|
||||||
.read_file_text(&path_uri, /*sandbox*/ None)
|
.and_then(|parent| parent.join(SKILLS_METADATA_DIR).ok())
|
||||||
.await
|
.and_then(|directory| directory.join(SKILLS_METADATA_FILENAME).ok());
|
||||||
.map_err(SkillParseError::Read)?;
|
let metadata = match &skill.metadata {
|
||||||
|
SkillMetadataDiscovery::Present(_) => metadata_path.map(SkillMetadataDiscovery::Present),
|
||||||
|
SkillMetadataDiscovery::Probe(_) => metadata_path.map(SkillMetadataDiscovery::Probe),
|
||||||
|
SkillMetadataDiscovery::Absent => None,
|
||||||
|
}
|
||||||
|
.unwrap_or(SkillMetadataDiscovery::Absent);
|
||||||
|
let (contents, loaded_metadata) = tokio::join!(
|
||||||
|
fs.read_file_text(path_uri, /*sandbox*/ None),
|
||||||
|
load_skill_metadata(fs, path, &metadata, plugin_root),
|
||||||
|
);
|
||||||
|
let contents = contents.map_err(SkillParseError::Read)?;
|
||||||
let ParsedSkillFrontmatter {
|
let ParsedSkillFrontmatter {
|
||||||
name: base_name,
|
name: base_name,
|
||||||
description,
|
description,
|
||||||
short_description,
|
short_description,
|
||||||
} = parse_skill_frontmatter_metadata_inner(&contents, || default_skill_name(path))?;
|
} = parse_skill_frontmatter_metadata_inner(&contents, || default_skill_name(path))?;
|
||||||
let name = namespace.qualify(&base_name);
|
|
||||||
let LoadedSkillMetadata {
|
let LoadedSkillMetadata {
|
||||||
interface,
|
interface,
|
||||||
dependencies,
|
dependencies,
|
||||||
policy,
|
policy,
|
||||||
} = load_skill_metadata(fs, path, plugin_root).await;
|
} = loaded_metadata;
|
||||||
|
|
||||||
validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?;
|
|
||||||
|
|
||||||
let resolved_path = canonicalize_for_skill_identity(fs, path).await;
|
|
||||||
|
|
||||||
Ok(SkillMetadata {
|
Ok(SkillMetadata {
|
||||||
name,
|
name: base_name,
|
||||||
description,
|
description,
|
||||||
short_description,
|
short_description,
|
||||||
interface,
|
interface,
|
||||||
dependencies,
|
dependencies,
|
||||||
policy,
|
policy,
|
||||||
path_to_skills_md: resolved_path,
|
path_to_skills_md: path.clone(),
|
||||||
scope,
|
scope,
|
||||||
plugin_id: plugin_id.map(str::to_string),
|
plugin_id: plugin_id.map(str::to_string),
|
||||||
})
|
})
|
||||||
@@ -838,41 +738,42 @@ fn default_skill_name(path: &AbsolutePathBuf) -> String {
|
|||||||
async fn load_skill_metadata(
|
async fn load_skill_metadata(
|
||||||
fs: &dyn ExecutorFileSystem,
|
fs: &dyn ExecutorFileSystem,
|
||||||
skill_path: &AbsolutePathBuf,
|
skill_path: &AbsolutePathBuf,
|
||||||
|
metadata: &SkillMetadataDiscovery,
|
||||||
plugin_root: Option<&AbsolutePathBuf>,
|
plugin_root: Option<&AbsolutePathBuf>,
|
||||||
) -> LoadedSkillMetadata {
|
) -> LoadedSkillMetadata {
|
||||||
// Fail open: optional metadata should not block loading SKILL.md.
|
// Fail open: optional metadata should not block loading SKILL.md.
|
||||||
let Some(skill_dir) = skill_path.parent() else {
|
let Some(skill_dir) = skill_path.parent() else {
|
||||||
return LoadedSkillMetadata::default();
|
return LoadedSkillMetadata::default();
|
||||||
};
|
};
|
||||||
let metadata_path = skill_dir
|
let metadata_path_uri = match metadata {
|
||||||
.join(SKILLS_METADATA_DIR)
|
SkillMetadataDiscovery::Present(path) => path,
|
||||||
.join(SKILLS_METADATA_FILENAME);
|
SkillMetadataDiscovery::Absent => return LoadedSkillMetadata::default(),
|
||||||
let metadata_path_uri = PathUri::from_abs_path(&metadata_path);
|
SkillMetadataDiscovery::Probe(path) => {
|
||||||
match fs.get_metadata(&metadata_path_uri, /*sandbox*/ None).await {
|
match fs.get_metadata(path, /*sandbox*/ None).await {
|
||||||
Ok(metadata) if metadata.is_file => {}
|
Ok(metadata) if metadata.is_file => {}
|
||||||
Ok(_) => return LoadedSkillMetadata::default(),
|
Ok(_) => return LoadedSkillMetadata::default(),
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||||
return LoadedSkillMetadata::default();
|
return LoadedSkillMetadata::default();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"ignoring {path}: failed to stat {label}: {error}",
|
||||||
|
path = path,
|
||||||
|
label = SKILLS_METADATA_FILENAME
|
||||||
|
);
|
||||||
|
return LoadedSkillMetadata::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path
|
||||||
}
|
}
|
||||||
Err(error) => {
|
};
|
||||||
tracing::warn!(
|
|
||||||
"ignoring {path}: failed to stat {label}: {error}",
|
|
||||||
path = metadata_path.display(),
|
|
||||||
label = SKILLS_METADATA_FILENAME
|
|
||||||
);
|
|
||||||
return LoadedSkillMetadata::default();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let contents = match fs
|
let contents = match fs.read_file_text(metadata_path_uri, /*sandbox*/ None).await {
|
||||||
.read_file_text(&metadata_path_uri, /*sandbox*/ None)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(contents) => contents,
|
Ok(contents) => contents,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"ignoring {path}: failed to read {label}: {error}",
|
"ignoring {path}: failed to read {label}: {error}",
|
||||||
path = metadata_path.display(),
|
path = metadata_path_uri,
|
||||||
label = SKILLS_METADATA_FILENAME
|
label = SKILLS_METADATA_FILENAME
|
||||||
);
|
);
|
||||||
return LoadedSkillMetadata::default();
|
return LoadedSkillMetadata::default();
|
||||||
@@ -886,7 +787,7 @@ async fn load_skill_metadata(
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"ignoring {path}: invalid {label}: {error}",
|
"ignoring {path}: invalid {label}: {error}",
|
||||||
path = metadata_path.display(),
|
path = metadata_path_uri,
|
||||||
label = SKILLS_METADATA_FILENAME
|
label = SKILLS_METADATA_FILENAME
|
||||||
);
|
);
|
||||||
return LoadedSkillMetadata::default();
|
return LoadedSkillMetadata::default();
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use codex_exec_server::ExecutorFileSystem;
|
||||||
|
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 super::MAX_SCAN_DEPTH;
|
||||||
|
use super::MAX_SKILLS_DIRS_PER_ROOT;
|
||||||
|
use super::SKILLS_FILENAME;
|
||||||
|
use super::SKILLS_METADATA_DIR;
|
||||||
|
use super::SKILLS_METADATA_FILENAME;
|
||||||
|
|
||||||
|
const MAX_SKILLS_ENTRIES_PER_ROOT: usize = 20_000;
|
||||||
|
pub(super) const MAX_CONCURRENT_SKILL_LOADS: usize = 64;
|
||||||
|
|
||||||
|
pub(super) enum DirectorySymlinkPolicy {
|
||||||
|
Follow,
|
||||||
|
Ignore,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) enum HiddenDirectoryPolicy {
|
||||||
|
Include,
|
||||||
|
Skip,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct SkillDiscoveryOptions {
|
||||||
|
pub directory_symlinks: DirectorySymlinkPolicy,
|
||||||
|
pub hidden_directories: HiddenDirectoryPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct SkillDiscovery {
|
||||||
|
pub skills: Vec<DiscoveredSkill>,
|
||||||
|
pub plugin_roots: HashSet<PathUri>,
|
||||||
|
pub namespace_roots: HashSet<PathUri>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct DiscoveredSkill {
|
||||||
|
pub path: PathUri,
|
||||||
|
pub metadata: SkillMetadataDiscovery,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) enum SkillMetadataDiscovery {
|
||||||
|
Present(PathUri),
|
||||||
|
Absent,
|
||||||
|
Probe(PathUri),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn discover_skills(
|
||||||
|
file_system: &dyn ExecutorFileSystem,
|
||||||
|
root: &PathUri,
|
||||||
|
options: SkillDiscoveryOptions,
|
||||||
|
) -> SkillDiscovery {
|
||||||
|
let empty_discovery = || SkillDiscovery {
|
||||||
|
skills: Vec::new(),
|
||||||
|
plugin_roots: HashSet::new(),
|
||||||
|
namespace_roots: HashSet::new(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
};
|
||||||
|
let walk = match file_system
|
||||||
|
.walk(
|
||||||
|
root,
|
||||||
|
WalkOptions {
|
||||||
|
max_depth: MAX_SCAN_DEPTH,
|
||||||
|
max_directories: MAX_SKILLS_DIRS_PER_ROOT,
|
||||||
|
max_entries: MAX_SKILLS_ENTRIES_PER_ROOT,
|
||||||
|
follow_directory_symlinks: matches!(
|
||||||
|
options.directory_symlinks,
|
||||||
|
DirectorySymlinkPolicy::Follow
|
||||||
|
),
|
||||||
|
prune_hidden_directories: matches!(
|
||||||
|
options.hidden_directories,
|
||||||
|
HiddenDirectoryPolicy::Skip
|
||||||
|
),
|
||||||
|
},
|
||||||
|
/*sandbox*/ None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(walk) => walk,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => return empty_discovery(),
|
||||||
|
Err(error) => {
|
||||||
|
let mut discovery = empty_discovery();
|
||||||
|
discovery
|
||||||
|
.warnings
|
||||||
|
.push(format!("failed to walk skills root {root}: {error:#}"));
|
||||||
|
return discovery;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let inventory_complete = !walk.truncated && walk.errors.is_empty();
|
||||||
|
let mut warnings = walk
|
||||||
|
.errors
|
||||||
|
.into_iter()
|
||||||
|
.map(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to scan skill path {}: {}",
|
||||||
|
error.path, error.message
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if walk.truncated {
|
||||||
|
warnings.push(format!(
|
||||||
|
"skills scan reached its traversal limit (root: {root})"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let skip_hidden = matches!(options.hidden_directories, HiddenDirectoryPolicy::Skip);
|
||||||
|
let mut skill_files = Vec::new();
|
||||||
|
let mut file_paths = HashSet::new();
|
||||||
|
let mut metadata_directory_parents = HashSet::new();
|
||||||
|
let mut plugin_roots = HashSet::new();
|
||||||
|
for entry in walk.entries {
|
||||||
|
if skip_hidden && has_hidden_ancestor_below_root(&entry.path, root) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match entry.kind {
|
||||||
|
WalkEntryKind::Directory => {
|
||||||
|
if entry
|
||||||
|
.path
|
||||||
|
.basename()
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case(SKILLS_METADATA_DIR))
|
||||||
|
&& let Some(skill_dir) = entry.path.parent()
|
||||||
|
{
|
||||||
|
metadata_directory_parents.insert(skill_dir);
|
||||||
|
}
|
||||||
|
if DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
||||||
|
.iter()
|
||||||
|
.any(|path| path.split('/').next() == entry.path.basename().as_deref())
|
||||||
|
&& let Some(plugin_root) = entry.path.parent()
|
||||||
|
{
|
||||||
|
plugin_roots.insert(plugin_root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WalkEntryKind::File => {
|
||||||
|
file_paths.insert(entry.path.clone());
|
||||||
|
if entry.path.basename().as_deref() == Some(SKILLS_FILENAME) {
|
||||||
|
skill_files.push(entry.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let skills = skill_files
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| DiscoveredSkill {
|
||||||
|
metadata: discover_skill_metadata(
|
||||||
|
&path,
|
||||||
|
&file_paths,
|
||||||
|
&metadata_directory_parents,
|
||||||
|
inventory_complete,
|
||||||
|
),
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
SkillDiscovery {
|
||||||
|
skills,
|
||||||
|
plugin_roots,
|
||||||
|
namespace_roots: HashSet::from([root.clone()]),
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_hidden_ancestor_below_root(path: &PathUri, root: &PathUri) -> bool {
|
||||||
|
let mut ancestor = path.parent();
|
||||||
|
while let Some(current) = ancestor {
|
||||||
|
if ¤t == root {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if current.basename().is_some_and(|name| name.starts_with('.')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ancestor = current.parent();
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_skill_metadata(
|
||||||
|
skill_path: &PathUri,
|
||||||
|
file_paths: &HashSet<PathUri>,
|
||||||
|
metadata_directory_parents: &HashSet<PathUri>,
|
||||||
|
inventory_complete: bool,
|
||||||
|
) -> SkillMetadataDiscovery {
|
||||||
|
let Some(skill_dir) = skill_path.parent() else {
|
||||||
|
return SkillMetadataDiscovery::Absent;
|
||||||
|
};
|
||||||
|
let Ok(metadata_dir) = skill_dir.join(SKILLS_METADATA_DIR) else {
|
||||||
|
return SkillMetadataDiscovery::Absent;
|
||||||
|
};
|
||||||
|
let Ok(metadata_path) = metadata_dir.join(SKILLS_METADATA_FILENAME) else {
|
||||||
|
return SkillMetadataDiscovery::Absent;
|
||||||
|
};
|
||||||
|
if file_paths.contains(&metadata_path) {
|
||||||
|
return SkillMetadataDiscovery::Present(metadata_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if inventory_complete && !metadata_directory_parents.contains(&skill_dir) {
|
||||||
|
SkillMetadataDiscovery::Absent
|
||||||
|
} else {
|
||||||
|
// A complete walk proves ordinary absence, but keep a filesystem probe for case aliases,
|
||||||
|
// file symlinks omitted by the walk, and incomplete inventories.
|
||||||
|
SkillMetadataDiscovery::Probe(metadata_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use codex_exec_server::ExecutorFileSystem;
|
use codex_exec_server::ExecutorFileSystem;
|
||||||
use codex_exec_server::WalkEntryKind;
|
|
||||||
use codex_exec_server::WalkOptions;
|
|
||||||
use codex_protocol::protocol::Product;
|
use codex_protocol::protocol::Product;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
|
||||||
use crate::model::SkillDependencies;
|
use crate::model::SkillDependencies;
|
||||||
use crate::model::SkillPolicy;
|
use crate::model::SkillPolicy;
|
||||||
|
|
||||||
use super::MAX_QUALIFIED_NAME_LEN;
|
use super::MAX_QUALIFIED_NAME_LEN;
|
||||||
use super::MAX_SCAN_DEPTH;
|
|
||||||
use super::MAX_SKILLS_DIRS_PER_ROOT;
|
|
||||||
use super::ParsedSkillFrontmatter;
|
use super::ParsedSkillFrontmatter;
|
||||||
use super::SKILLS_FILENAME;
|
|
||||||
use super::SKILLS_METADATA_DIR;
|
|
||||||
use super::SKILLS_METADATA_FILENAME;
|
|
||||||
use super::SkillMetadataFile;
|
use super::SkillMetadataFile;
|
||||||
|
use super::discovery::DirectorySymlinkPolicy;
|
||||||
|
use super::discovery::DiscoveredSkill;
|
||||||
|
use super::discovery::HiddenDirectoryPolicy;
|
||||||
|
use super::discovery::MAX_CONCURRENT_SKILL_LOADS;
|
||||||
|
use super::discovery::SkillDiscoveryOptions;
|
||||||
|
use super::discovery::SkillMetadataDiscovery;
|
||||||
|
use super::discovery::discover_skills;
|
||||||
use super::namespace::SkillNamespaceResolver;
|
use super::namespace::SkillNamespaceResolver;
|
||||||
use super::parse_skill_frontmatter_metadata_inner;
|
use super::parse_skill_frontmatter_metadata_inner;
|
||||||
use super::resolve_dependencies;
|
use super::resolve_dependencies;
|
||||||
@@ -27,21 +25,6 @@ use super::resolve_policy;
|
|||||||
use super::sanitize_single_line;
|
use super::sanitize_single_line;
|
||||||
use super::validate_len;
|
use super::validate_len;
|
||||||
|
|
||||||
const MAX_SKILLS_ENTRIES_PER_ROOT: usize = 20_000;
|
|
||||||
const MAX_CONCURRENT_SKILL_LOADS: usize = 64;
|
|
||||||
|
|
||||||
struct EnvironmentSkillDiscovery {
|
|
||||||
skills: Vec<DiscoveredEnvironmentSkill>,
|
|
||||||
plugin_roots: HashSet<PathUri>,
|
|
||||||
namespace_roots: HashSet<PathUri>,
|
|
||||||
warnings: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DiscoveredEnvironmentSkill {
|
|
||||||
path: PathUri,
|
|
||||||
metadata: SkillMetadataDiscovery,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ParsedEnvironmentSkill {
|
struct ParsedEnvironmentSkill {
|
||||||
path_to_skills_md: PathUri,
|
path_to_skills_md: PathUri,
|
||||||
base_name: String,
|
base_name: String,
|
||||||
@@ -51,12 +34,6 @@ struct ParsedEnvironmentSkill {
|
|||||||
policy: Option<SkillPolicy>,
|
policy: Option<SkillPolicy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SkillMetadataDiscovery {
|
|
||||||
Present(PathUri),
|
|
||||||
Absent,
|
|
||||||
Probe(PathUri),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// URI-native metadata for one skill owned by an execution environment.
|
/// URI-native metadata for one skill owned by an execution environment.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct EnvironmentSkillMetadata {
|
pub struct EnvironmentSkillMetadata {
|
||||||
@@ -92,7 +69,7 @@ impl EnvironmentSkillMetadata {
|
|||||||
impl ParsedEnvironmentSkill {
|
impl ParsedEnvironmentSkill {
|
||||||
async fn load(
|
async fn load(
|
||||||
file_system: &dyn ExecutorFileSystem,
|
file_system: &dyn ExecutorFileSystem,
|
||||||
skill: &DiscoveredEnvironmentSkill,
|
skill: &DiscoveredSkill,
|
||||||
) -> Result<Self, String> {
|
) -> Result<Self, String> {
|
||||||
let (contents, discovered_metadata) = match &skill.metadata {
|
let (contents, discovered_metadata) = match &skill.metadata {
|
||||||
SkillMetadataDiscovery::Present(metadata_path) => {
|
SkillMetadataDiscovery::Present(metadata_path) => {
|
||||||
@@ -152,93 +129,17 @@ pub async fn load_environment_skills_from_root(
|
|||||||
restriction_product: Option<Product>,
|
restriction_product: Option<Product>,
|
||||||
) -> EnvironmentSkillLoadOutcome {
|
) -> EnvironmentSkillLoadOutcome {
|
||||||
let mut outcome = EnvironmentSkillLoadOutcome::default();
|
let mut outcome = EnvironmentSkillLoadOutcome::default();
|
||||||
let discovery = match file_system
|
let discovery = discover_skills(
|
||||||
.walk(
|
file_system,
|
||||||
root,
|
root,
|
||||||
WalkOptions {
|
// Preserve environment discovery behavior by following directory aliases and including
|
||||||
max_depth: MAX_SCAN_DEPTH,
|
// hidden directories exposed by the executor.
|
||||||
max_directories: MAX_SKILLS_DIRS_PER_ROOT,
|
SkillDiscoveryOptions {
|
||||||
max_entries: MAX_SKILLS_ENTRIES_PER_ROOT,
|
directory_symlinks: DirectorySymlinkPolicy::Follow,
|
||||||
follow_directory_symlinks: true,
|
hidden_directories: HiddenDirectoryPolicy::Include,
|
||||||
prune_hidden_directories: false,
|
|
||||||
},
|
|
||||||
/*sandbox*/ None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(walk) => {
|
|
||||||
let inventory_complete = !walk.truncated && walk.errors.is_empty();
|
|
||||||
let mut warnings = walk
|
|
||||||
.errors
|
|
||||||
.into_iter()
|
|
||||||
.map(|error| {
|
|
||||||
format!(
|
|
||||||
"failed to scan skill path {}: {}",
|
|
||||||
error.path, error.message
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if walk.truncated {
|
|
||||||
warnings.push(format!(
|
|
||||||
"skills scan reached its traversal limit (root: {root})"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut skill_files = Vec::new();
|
|
||||||
let mut file_paths = HashSet::new();
|
|
||||||
let mut directory_paths = HashSet::new();
|
|
||||||
let mut plugin_roots = HashSet::new();
|
|
||||||
for entry in walk.entries {
|
|
||||||
match entry.kind {
|
|
||||||
WalkEntryKind::Directory => {
|
|
||||||
directory_paths.insert(entry.path.clone());
|
|
||||||
if DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
|
||||||
.iter()
|
|
||||||
.any(|path| path.split('/').next() == entry.path.basename().as_deref())
|
|
||||||
&& let Some(plugin_root) = entry.path.parent()
|
|
||||||
{
|
|
||||||
plugin_roots.insert(plugin_root);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WalkEntryKind::File => {
|
|
||||||
file_paths.insert(entry.path.clone());
|
|
||||||
if entry.path.basename().as_deref() == Some(SKILLS_FILENAME) {
|
|
||||||
skill_files.push(entry.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let skills = skill_files
|
|
||||||
.into_iter()
|
|
||||||
.map(|path| DiscoveredEnvironmentSkill {
|
|
||||||
metadata: discover_skill_metadata(
|
|
||||||
&path,
|
|
||||||
&file_paths,
|
|
||||||
&directory_paths,
|
|
||||||
inventory_complete,
|
|
||||||
),
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
EnvironmentSkillDiscovery {
|
|
||||||
skills,
|
|
||||||
plugin_roots,
|
|
||||||
namespace_roots: HashSet::from([root.clone()]),
|
|
||||||
warnings,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => EnvironmentSkillDiscovery {
|
|
||||||
skills: Vec::new(),
|
|
||||||
plugin_roots: HashSet::new(),
|
|
||||||
namespace_roots: HashSet::new(),
|
|
||||||
warnings: Vec::new(),
|
|
||||||
},
|
},
|
||||||
Err(error) => EnvironmentSkillDiscovery {
|
)
|
||||||
skills: Vec::new(),
|
.await;
|
||||||
plugin_roots: HashSet::new(),
|
|
||||||
namespace_roots: HashSet::new(),
|
|
||||||
warnings: vec![format!("failed to walk skills root {root}: {error:#}")],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
tracing::Span::current().record("skill_count", discovery.skills.len());
|
tracing::Span::current().record("skill_count", discovery.skills.len());
|
||||||
outcome.warnings.extend(discovery.warnings);
|
outcome.warnings.extend(discovery.warnings);
|
||||||
if discovery.skills.is_empty() {
|
if discovery.skills.is_empty() {
|
||||||
@@ -311,32 +212,6 @@ pub async fn load_environment_skills_from_root(
|
|||||||
outcome
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
fn discover_skill_metadata(
|
|
||||||
skill_path: &PathUri,
|
|
||||||
file_paths: &HashSet<PathUri>,
|
|
||||||
directory_paths: &HashSet<PathUri>,
|
|
||||||
inventory_complete: bool,
|
|
||||||
) -> SkillMetadataDiscovery {
|
|
||||||
let Some(skill_dir) = skill_path.parent() else {
|
|
||||||
return SkillMetadataDiscovery::Absent;
|
|
||||||
};
|
|
||||||
let Ok(metadata_dir) = skill_dir.join(SKILLS_METADATA_DIR) else {
|
|
||||||
return SkillMetadataDiscovery::Absent;
|
|
||||||
};
|
|
||||||
let Ok(metadata_path) = metadata_dir.join(SKILLS_METADATA_FILENAME) else {
|
|
||||||
return SkillMetadataDiscovery::Absent;
|
|
||||||
};
|
|
||||||
if file_paths.contains(&metadata_path) {
|
|
||||||
SkillMetadataDiscovery::Present(metadata_path)
|
|
||||||
} else if inventory_complete && !directory_paths.contains(&metadata_dir) {
|
|
||||||
SkillMetadataDiscovery::Absent
|
|
||||||
} else {
|
|
||||||
// The walk can omit entries after an error or traversal limit. It also omits file
|
|
||||||
// symlinks, so keep the existing probe when the metadata directory itself was observed.
|
|
||||||
SkillMetadataDiscovery::Probe(metadata_path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_skill_contents(
|
async fn read_skill_contents(
|
||||||
file_system: &dyn ExecutorFileSystem,
|
file_system: &dyn ExecutorFileSystem,
|
||||||
skill_path: &PathUri,
|
skill_path: &PathUri,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use codex_exec_server::ExecutorFileSystem;
|
use codex_exec_server::ExecutorFileSystem;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
use codex_utils_plugins::plugin_namespace_for_root_uri;
|
use codex_utils_plugins::plugin_namespace_for_root_uri;
|
||||||
use codex_utils_plugins::plugin_namespace_for_skill_uri;
|
use futures::StreamExt;
|
||||||
use futures::future::join_all;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use super::discovery::MAX_CONCURRENT_SKILL_LOADS;
|
||||||
|
|
||||||
/// Resolves the namespace prefix applied to skill names during one skills scan.
|
/// Resolves the namespace prefix applied to skill names during one skills scan.
|
||||||
///
|
///
|
||||||
/// A plugin namespace is the plugin name from the nearest valid plugin manifest
|
/// A plugin namespace is the plugin name from the nearest valid plugin manifest
|
||||||
@@ -55,43 +57,89 @@ impl SkillNamespaceResolver {
|
|||||||
.filter(|plugin_root| skill_ancestors.contains(plugin_root))
|
.filter(|plugin_root| skill_ancestors.contains(plugin_root))
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
|
|
||||||
// Ordinary descendants fall back to the nearest valid manifest at or above the scan root.
|
|
||||||
let inherited_namespace = plugin_namespace_for_skill_uri(fs, root)
|
|
||||||
.await
|
|
||||||
.map(ResolvedSkillNamespace::Plugin)
|
|
||||||
.unwrap_or(ResolvedSkillNamespace::Plain);
|
|
||||||
// The scan root is already the fallback above if nothing else matches, exclude from the search.
|
// The scan root is already the fallback above if nothing else matches, exclude from the search.
|
||||||
let namespace_roots = namespace_roots
|
let namespace_roots = namespace_roots
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|namespace_root| namespace_root != root)
|
.filter(|namespace_root| namespace_root != root)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let namespace_root_set = namespace_roots.iter().cloned().collect::<HashSet<_>>();
|
let namespace_root_set = namespace_roots.iter().cloned().collect::<HashSet<_>>();
|
||||||
// Keep independent probes concurrent for remote executor latency.
|
let plugin_roots = plugin_roots
|
||||||
let namespace_lookups = join_all(namespace_roots.into_iter().map(|namespace_root| async {
|
.into_iter()
|
||||||
let namespace = plugin_namespace_for_skill_uri(fs, &namespace_root)
|
.filter(|plugin_root| plugin_root != root && !namespace_root_set.contains(plugin_root))
|
||||||
.await
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let lookup_roots = std::iter::once(root.clone())
|
||||||
|
.chain(namespace_roots.iter().cloned())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut pending_lookups = lookup_roots
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|lookup_root| (lookup_root.clone(), lookup_root))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut direct_plugin_roots = plugin_roots.iter().cloned().collect::<HashSet<_>>();
|
||||||
|
let mut namespaces_by_root = HashMap::new();
|
||||||
|
let mut namespaces_by_lookup_root = HashMap::new();
|
||||||
|
while !pending_lookups.is_empty() {
|
||||||
|
let probe_roots = pending_lookups
|
||||||
|
.iter()
|
||||||
|
.map(|(_, ancestor)| ancestor.clone())
|
||||||
|
.chain(direct_plugin_roots.drain())
|
||||||
|
.filter(|ancestor| !namespaces_by_root.contains_key(ancestor))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
namespaces_by_root.extend(
|
||||||
|
futures::stream::iter(probe_roots)
|
||||||
|
.map(|manifest_root| async move {
|
||||||
|
let namespace = plugin_namespace_for_root_uri(fs, &manifest_root).await;
|
||||||
|
(manifest_root, namespace)
|
||||||
|
})
|
||||||
|
.buffered(MAX_CONCURRENT_SKILL_LOADS)
|
||||||
|
.collect::<HashMap<_, _>>()
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut next_lookups = Vec::new();
|
||||||
|
for (lookup_root, ancestor) in pending_lookups {
|
||||||
|
match namespaces_by_root.get(&ancestor) {
|
||||||
|
Some(Some(namespace)) => {
|
||||||
|
namespaces_by_lookup_root.insert(lookup_root, Some(namespace.clone()));
|
||||||
|
}
|
||||||
|
Some(None) => match ancestor.parent() {
|
||||||
|
Some(parent) => next_lookups.push((lookup_root, parent)),
|
||||||
|
None => {
|
||||||
|
namespaces_by_lookup_root.insert(lookup_root, None);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => unreachable!("pending namespace ancestor was not probed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending_lookups = next_lookups;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordinary descendants fall back to the nearest valid manifest at or above the scan root.
|
||||||
|
let inherited_namespace = namespaces_by_lookup_root
|
||||||
|
.get(root)
|
||||||
|
.and_then(Option::as_ref)
|
||||||
|
.cloned()
|
||||||
|
.map(ResolvedSkillNamespace::Plugin)
|
||||||
|
.unwrap_or(ResolvedSkillNamespace::Plain);
|
||||||
|
let namespace_lookups = namespace_roots.into_iter().map(|namespace_root| {
|
||||||
|
let namespace = namespaces_by_lookup_root
|
||||||
|
.get(&namespace_root)
|
||||||
|
.and_then(Option::as_ref)
|
||||||
|
.cloned()
|
||||||
.map(ResolvedSkillNamespace::Plugin)
|
.map(ResolvedSkillNamespace::Plugin)
|
||||||
.unwrap_or(ResolvedSkillNamespace::Plain);
|
.unwrap_or(ResolvedSkillNamespace::Plain);
|
||||||
(namespace_root, namespace)
|
(namespace_root, namespace)
|
||||||
}));
|
});
|
||||||
// Invalid nested manifests are omitted, so the deepest remaining match wins.
|
// Invalid nested manifests are omitted, so the deepest remaining match wins.
|
||||||
let plugin_lookups = join_all(
|
let plugin_lookups = plugin_roots.into_iter().filter_map(|plugin_root| {
|
||||||
plugin_roots
|
namespaces_by_root
|
||||||
.into_iter()
|
.get(&plugin_root)
|
||||||
.filter(|plugin_root| {
|
.and_then(Option::as_ref)
|
||||||
plugin_root != root && !namespace_root_set.contains(plugin_root)
|
.cloned()
|
||||||
})
|
.map(|namespace| (plugin_root, ResolvedSkillNamespace::Plugin(namespace)))
|
||||||
.map(|plugin_root| async move {
|
});
|
||||||
plugin_namespace_for_root_uri(fs, &plugin_root)
|
let nested_namespaces = namespace_lookups.chain(plugin_lookups).collect();
|
||||||
.await
|
|
||||||
.map(|namespace| (plugin_root, ResolvedSkillNamespace::Plugin(namespace)))
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let (namespace_lookups, plugin_lookups) = tokio::join!(namespace_lookups, plugin_lookups);
|
|
||||||
let nested_namespaces = namespace_lookups
|
|
||||||
.into_iter()
|
|
||||||
.chain(plugin_lookups.into_iter().flatten())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
inherited_namespace,
|
inherited_namespace,
|
||||||
|
|||||||
@@ -1021,6 +1021,32 @@ async fn loads_skills_via_symlinked_subdir_for_user_scope() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directory symlinks on Windows can require Developer Mode or administrator privileges.
|
||||||
|
#[tokio::test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
async fn loads_skills_through_visible_alias_to_hidden_directory() {
|
||||||
|
let root = tempfile::tempdir().expect("tempdir");
|
||||||
|
let hidden_root = root.path().join(".hidden");
|
||||||
|
let skill_path = write_skill_at(&hidden_root, "search", "search-skill", "search description");
|
||||||
|
symlink_dir(&hidden_root, &root.path().join("visible"));
|
||||||
|
|
||||||
|
let outcome = load_user_skills_root(root.path()).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
outcome.errors.is_empty(),
|
||||||
|
"unexpected errors: {:?}",
|
||||||
|
outcome.errors
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.skills,
|
||||||
|
vec![expected_user_skill(
|
||||||
|
&skill_path,
|
||||||
|
"search-skill",
|
||||||
|
"search description",
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
async fn ignores_symlinked_skill_file_for_user_scope() {
|
async fn ignores_symlinked_skill_file_for_user_scope() {
|
||||||
@@ -1485,48 +1511,6 @@ async fn invalid_nested_plugin_manifest_falls_back_to_outer_namespace() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directory symlinks on Windows can require Developer Mode or administrator privileges.
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn namespaces_skills_in_symlinked_plugin_skills_dir() {
|
|
||||||
// skills/
|
|
||||||
// └── linked-plugin -> shared-plugin/skills/
|
|
||||||
// shared-plugin/
|
|
||||||
// ├── .codex-plugin/plugin.json
|
|
||||||
// └── skills/search/SKILL.md
|
|
||||||
let root = tempfile::tempdir().expect("tempdir");
|
|
||||||
let shared_plugin_root = tempfile::tempdir().expect("tempdir");
|
|
||||||
write_plugin_manifest(shared_plugin_root.path(), r#"{"name":"linked"}"#);
|
|
||||||
let skill_path = write_skill_at(
|
|
||||||
&shared_plugin_root.path().join("skills"),
|
|
||||||
"search",
|
|
||||||
"search-skill",
|
|
||||||
"search description",
|
|
||||||
);
|
|
||||||
let skills_root = root.path().join("skills");
|
|
||||||
fs::create_dir_all(&skills_root).unwrap();
|
|
||||||
symlink_dir(
|
|
||||||
&shared_plugin_root.path().join("skills"),
|
|
||||||
&skills_root.join("linked-plugin"),
|
|
||||||
);
|
|
||||||
|
|
||||||
let outcome = load_user_skills_root(&skills_root).await;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
outcome.errors.is_empty(),
|
|
||||||
"unexpected errors: {:?}",
|
|
||||||
outcome.errors
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
outcome.skills,
|
|
||||||
vec![expected_user_skill(
|
|
||||||
&skill_path,
|
|
||||||
"linked:search-skill",
|
|
||||||
"search description",
|
|
||||||
)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Directory symlinks on Windows can require Developer Mode or administrator privileges.
|
// Directory symlinks on Windows can require Developer Mode or administrator privileges.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -411,3 +411,100 @@ async fn reads_skill_files_while_resolving_plugin_namespaces() {
|
|||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn host_loading_reuses_walk_inventory_for_symlinked_skill_pack() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use codex_core_skills::loader::SkillRoot;
|
||||||
|
use codex_core_skills::loader::load_skills_from_roots;
|
||||||
|
use codex_protocol::protocol::SkillScope;
|
||||||
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||||
|
|
||||||
|
let root = tempdir().expect("tempdir");
|
||||||
|
let shared_plugin_root = tempdir().expect("tempdir");
|
||||||
|
let manifest_path = shared_plugin_root.path().join(".codex-plugin/plugin.json");
|
||||||
|
fs::create_dir_all(manifest_path.parent().expect("manifest parent")).expect("manifest dir");
|
||||||
|
fs::write(&manifest_path, r#"{"name":"linked"}"#).expect("manifest");
|
||||||
|
|
||||||
|
let skills_root = shared_plugin_root.path().join("skills");
|
||||||
|
for name in ["first", "second"] {
|
||||||
|
let skill_path = skills_root.join(name).join("SKILL.md");
|
||||||
|
fs::create_dir_all(skill_path.parent().expect("skill parent")).expect("skill dir");
|
||||||
|
fs::write(
|
||||||
|
&skill_path,
|
||||||
|
format!("---\nname: {name}\ndescription: {name} skill.\n---\n"),
|
||||||
|
)
|
||||||
|
.expect("skill");
|
||||||
|
}
|
||||||
|
let metadata_path = skills_root.join("first/agents/openai.yaml");
|
||||||
|
fs::create_dir_all(metadata_path.parent().expect("metadata parent")).expect("metadata dir");
|
||||||
|
fs::write(
|
||||||
|
&metadata_path,
|
||||||
|
"policy:\n allow_implicit_invocation: false\n",
|
||||||
|
)
|
||||||
|
.expect("metadata");
|
||||||
|
|
||||||
|
let host_root = root.path().join("skills");
|
||||||
|
fs::create_dir_all(&host_root).expect("host skills dir");
|
||||||
|
let linked_root = host_root.join("linked-plugin");
|
||||||
|
symlink(&skills_root, &linked_root).expect("skill pack symlink");
|
||||||
|
|
||||||
|
let recording = Arc::new(RecordingFileSystem::new(
|
||||||
|
LOCAL_FS.as_ref(),
|
||||||
|
ManifestMetadataBehavior::Immediate,
|
||||||
|
));
|
||||||
|
let file_system: Arc<dyn ExecutorFileSystem> = recording.clone();
|
||||||
|
let future = load_skills_from_roots(
|
||||||
|
[SkillRoot {
|
||||||
|
path: host_root.abs(),
|
||||||
|
scope: SkillScope::User,
|
||||||
|
file_system,
|
||||||
|
plugin_id: None,
|
||||||
|
plugin_namespace: None,
|
||||||
|
plugin_root: None,
|
||||||
|
}],
|
||||||
|
/*plugin_skill_snapshots*/ None,
|
||||||
|
);
|
||||||
|
fn assert_send<T: Send>(_: &T) {}
|
||||||
|
assert_send(&future);
|
||||||
|
let outcome = future.await;
|
||||||
|
|
||||||
|
assert_eq!(outcome.errors, Vec::new());
|
||||||
|
assert_eq!(
|
||||||
|
outcome
|
||||||
|
.skills
|
||||||
|
.iter()
|
||||||
|
.map(|skill| skill.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["linked:first", "linked:second"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let calls = recording.calls();
|
||||||
|
assert_eq!(calls.walks, 1);
|
||||||
|
let linked_root = PathUri::from_host_native_path(linked_root).unwrap();
|
||||||
|
assert!(
|
||||||
|
calls
|
||||||
|
.read_files
|
||||||
|
.iter()
|
||||||
|
.all(|path| !path.starts_with(&linked_root))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
calls
|
||||||
|
.metadata_files
|
||||||
|
.iter()
|
||||||
|
.all(|path| path.basename().as_deref() != Some("openai.yaml"))
|
||||||
|
);
|
||||||
|
let manifest_uri =
|
||||||
|
PathUri::from_host_native_path(dunce::canonicalize(manifest_path).unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
calls
|
||||||
|
.metadata_files
|
||||||
|
.iter()
|
||||||
|
.filter(|path| **path == manifest_uri)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -137,6 +137,16 @@ async fn serve_exec_with_pushed_events(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
Some("fs/walk") => {
|
||||||
|
send_exec_server_json(
|
||||||
|
&mut websocket,
|
||||||
|
json!({
|
||||||
|
"id": request["id"],
|
||||||
|
"result": { "entries": [], "errors": [], "truncated": false }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
method => panic!("unexpected exec-server request before process/start: {method:?}"),
|
method => panic!("unexpected exec-server request before process/start: {method:?}"),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user