Load executor skills without host path conversion (#29626)

## Why

After #28918, selected skill roots are `PathUri`, but the executor skill
provider still converts them to the app-server host's `AbsolutePathBuf`.
A foreign Windows root therefore cannot be discovered by a Unix host,
and the inverse has the same problem.

This PR keeps executor skill discovery and reads on the filesystem that
owns the selected root while reusing the existing skill rules.

## What changed

- Generalize the existing skill traversal to operate on `PathUri`
through `ExecutorFileSystem`, preserving its depth, directory, symlink,
and sibling-metadata concurrency behavior.
- Add a small environment skill loader that reuses the shared discovery,
frontmatter validation, dependency parsing, product policy, and
prompt-visibility rules.
- Keep the environment id and entrypoint `PathUri` in the skill catalog,
then route `skills.read` back through the same environment filesystem.
- Preserve the executor's path convention when deriving catalog handles,
including literal backslashes in POSIX filenames.
- Resolve plugin namespaces from nearby manifests through URI-native
filesystem reads.
- Cover foreign Windows roots, executor-owned reads, namespaces,
metadata, policy, and path identity.

```text
selected root (PathUri)
        |
        v
shared discovery over ExecutorFileSystem
        |
        v
environment-bound catalog entry --skills.read--> same ExecutorFileSystem
```

No second filesystem abstraction or duplicate traversal implementation
is introduced.

## Stack

1. #29614 — add lexical `PathUri` containment.
2. #29620 — share URI-native manifest path resolution.
3. #28918 — keep selected plugin roots and resources URI-native.
4. **This PR** — load executor skills without host path conversion.
5. #29628 — resolve executor MCP working directories without host path
conversion.
This commit is contained in:
jif authored and GitHub committed 2026-06-23 23:26:06 +01:00
1 parent db6e676afc
commit 220f5b76b2
10 files changed
+520 -200

No files matched your search

+172 -97
View File
@@ -1,3 +1,9 @@
mod environment;
pub use environment::EnvironmentSkillLoadOutcome;
pub use environment::EnvironmentSkillMetadata;
pub use environment::load_environment_skills_from_root;
use crate::model::SkillDependencies;
use crate::model::SkillError;
use crate::model::SkillInterface;
@@ -104,6 +110,13 @@ struct DependencyTool {
url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedSkillFrontmatter {
name: String,
description: String,
short_description: Option<String>,
}
const SKILLS_FILENAME: &str = "SKILL.md";
const AGENTS_DIR_NAME: &str = ".agents";
const SKILLS_METADATA_DIR: &str = "agents";
@@ -124,6 +137,17 @@ const MAX_DEPENDENCY_URL_LEN: usize = MAX_DESCRIPTION_LEN;
const MAX_SCAN_DEPTH: usize = 6;
const MAX_SKILLS_DIRS_PER_ROOT: usize = 2000;
#[derive(Clone, Copy)]
enum SymlinkPolicy {
FollowDirectories,
Ignore,
}
struct SkillFileDiscovery {
skill_files: Vec<PathUri>,
warnings: Vec<String>,
}
#[derive(Debug)]
enum SkillParseError {
Read(std::io::Error),
@@ -189,7 +213,7 @@ pub(crate) async fn load_skill_root(root: SkillRoot) -> SkillRootSnapshot {
} = root;
let root = canonicalize_for_skill_identity(file_system.as_ref(), &path).await;
let mut outcome = SkillLoadOutcome::default();
discover_skills_under_root(
load_skills_under_root(
file_system.as_ref(),
&root,
scope,
@@ -470,35 +494,31 @@ async fn canonicalize_for_skill_identity(
async fn discover_skills_under_root(
fs: &dyn ExecutorFileSystem,
root: &AbsolutePathBuf,
scope: SkillScope,
plugin_id: Option<&str>,
plugin_namespace: Option<&str>,
plugin_root: Option<&AbsolutePathBuf>,
outcome: &mut SkillLoadOutcome,
) {
root: &PathUri,
symlink_policy: SymlinkPolicy,
) -> SkillFileDiscovery {
let root = root.clone();
let plugin_root = match plugin_root {
Some(plugin_root) => Some(canonicalize_for_skill_identity(fs, plugin_root).await),
None => None,
let mut discovery = SkillFileDiscovery {
skill_files: Vec::new(),
warnings: Vec::new(),
};
let root_uri = PathUri::from_abs_path(&root);
match fs.get_metadata(&root_uri, /*sandbox*/ None).await {
match fs.get_metadata(&root, /*sandbox*/ None).await {
Ok(metadata) if metadata.is_directory => {}
Ok(_) => return,
Err(err) if err.kind() == io::ErrorKind::NotFound => return,
Ok(_) => return discovery,
Err(err) if err.kind() == io::ErrorKind::NotFound => return discovery,
Err(err) => {
error!("failed to stat skills root {}: {err:#}", root.display());
return;
discovery
.warnings
.push(format!("failed to stat skills root {root}: {err:#}"));
return discovery;
}
}
fn enqueue_dir(
queue: &mut VecDeque<(AbsolutePathBuf, usize)>,
visited_dirs: &mut HashSet<AbsolutePathBuf>,
queue: &mut VecDeque<(PathUri, usize)>,
visited_dirs: &mut HashSet<PathUri>,
truncated_by_dir_limit: &mut bool,
path: AbsolutePathBuf,
path: PathUri,
depth: usize,
) {
if depth > MAX_SCAN_DEPTH {
@@ -513,24 +533,18 @@ async fn discover_skills_under_root(
}
}
// Follow symlinked directories for user, admin, and repo skills. System skills are written by Codex itself.
let follow_symlinks = matches!(
scope,
SkillScope::Repo | SkillScope::User | SkillScope::Admin
);
let mut visited_dirs: HashSet<AbsolutePathBuf> = HashSet::new();
visited_dirs.insert(root.clone());
let mut queue: VecDeque<(AbsolutePathBuf, usize)> = VecDeque::from([(root.clone(), 0)]);
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 dir_uri = PathUri::from_abs_path(&dir);
let entries = match fs.read_directory(&dir_uri, /*sandbox*/ None).await {
let entries = match fs.read_directory(&dir, /*sandbox*/ None).await {
Ok(entries) => entries,
Err(e) => {
error!("failed to read skills dir {}: {e:#}", dir.display());
Err(err) => {
discovery
.warnings
.push(format!("failed to read skills directory {dir}: {err:#}"));
continue;
}
};
@@ -542,25 +556,31 @@ async fn discover_skills_under_root(
if file_name.starts_with('.') {
return None;
}
let path = dir.join(&file_name);
let path_uri = PathUri::from_abs_path(&path);
Some((file_name, path, path_uri))
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_uri)| fs.get_metadata(path_uri, /*sandbox*/ None)),
.map(|(_, path)| fs.get_metadata(path, /*sandbox*/ None)),
)
.await;
for ((file_name, path, path_uri), metadata_result) in
paths.into_iter().zip(metadata_results)
{
for ((file_name, path), metadata_result) in paths.into_iter().zip(metadata_results) {
let metadata = match metadata_result {
Ok(metadata) => metadata,
Err(e) => {
error!("failed to stat skills path {}: {e:#}", path.display());
Err(err) => {
discovery
.warnings
.push(format!("failed to stat skill path {path}: {err:#}"));
continue;
}
};
@@ -569,9 +589,9 @@ async fn discover_skills_under_root(
if !follow_symlinks {
continue;
}
match fs.read_directory(&path_uri, /*sandbox*/ None).await {
match fs.read_directory(&path, /*sandbox*/ None).await {
Ok(_) => {
let resolved_dir = canonicalize_for_skill_identity(fs, &path).await;
let resolved_dir = canonicalize_uri_for_skill_identity(fs, &path).await;
enqueue_dir(
&mut queue,
&mut visited_dirs,
@@ -585,51 +605,26 @@ async fn discover_skills_under_root(
err.kind(),
io::ErrorKind::NotADirectory | io::ErrorKind::NotFound
) => {}
Err(err) => {
error!(
"failed to read skills symlink dir {}: {err:#}",
path.display()
);
}
Err(err) => discovery.warnings.push(format!(
"failed to read skills symlink directory {path}: {err:#}"
)),
}
continue;
}
if metadata.is_directory {
let resolved_dir = canonicalize_for_skill_identity(fs, &path).await;
enqueue_dir(
&mut queue,
&mut visited_dirs,
&mut truncated_by_dir_limit,
resolved_dir,
path,
depth + 1,
);
continue;
}
if metadata.is_file && file_name == SKILLS_FILENAME {
match parse_skill_file(
fs,
&path,
scope,
plugin_id,
plugin_namespace,
plugin_root.as_ref(),
)
.await
{
Ok(skill) => {
outcome.skills.push(skill);
}
Err(err) => {
if scope != SkillScope::System {
outcome.errors.push(SkillError {
path: path.clone(),
message: err.to_string(),
});
}
}
}
discovery.skill_files.push(path);
}
}
}
@@ -638,9 +633,72 @@ async fn discover_skills_under_root(
tracing::warn!(
"skills scan truncated after {} directories (root: {})",
MAX_SKILLS_DIRS_PER_ROOT,
root.display()
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(
fs: &dyn ExecutorFileSystem,
root: &AbsolutePathBuf,
scope: SkillScope,
plugin_id: Option<&str>,
plugin_namespace: Option<&str>,
plugin_root: Option<&AbsolutePathBuf>,
outcome: &mut SkillLoadOutcome,
) {
let plugin_root = match plugin_root {
Some(plugin_root) => Some(canonicalize_for_skill_identity(fs, plugin_root).await),
None => None,
};
let symlink_policy = match scope {
SkillScope::User | SkillScope::Repo | SkillScope::Admin => SymlinkPolicy::FollowDirectories,
SkillScope::System => SymlinkPolicy::Ignore,
};
let SkillFileDiscovery {
skill_files,
warnings,
} = discover_skills_under_root(fs, &PathUri::from_abs_path(root), symlink_policy).await;
for warning in warnings {
error!("{warning}");
}
for path_uri in skill_files {
let path = match path_uri.to_abs_path() {
Ok(path) => path,
Err(err) => {
error!("failed to convert discovered skill path {path_uri}: {err}");
continue;
}
};
match parse_skill_file(
fs,
&path,
scope,
plugin_id,
plugin_namespace,
plugin_root.as_ref(),
)
.await
{
Ok(skill) => outcome.skills.push(skill),
Err(err) if scope != SkillScope::System => outcome.errors.push(SkillError {
path,
message: err.to_string(),
}),
Err(_) => {}
}
}
}
async fn parse_skill_file(
@@ -656,8 +714,40 @@ async fn parse_skill_file(
.read_file_text(&path_uri, /*sandbox*/ None)
.await
.map_err(SkillParseError::Read)?;
let ParsedSkillFrontmatter {
name: base_name,
description,
short_description,
} = parse_skill_frontmatter_metadata_inner(&contents, || default_skill_name(path))?;
let name = namespaced_skill_name(fs, path, &base_name, plugin_namespace).await;
let LoadedSkillMetadata {
interface,
dependencies,
policy,
} = load_skill_metadata(fs, path, plugin_root).await;
let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?;
validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?;
let resolved_path = canonicalize_for_skill_identity(fs, path).await;
Ok(SkillMetadata {
name,
description,
short_description,
interface,
dependencies,
policy,
path_to_skills_md: resolved_path,
scope,
plugin_id: plugin_id.map(str::to_string),
})
}
fn parse_skill_frontmatter_metadata_inner(
contents: &str,
default_name: impl FnOnce() -> String,
) -> Result<ParsedSkillFrontmatter, SkillParseError> {
let frontmatter = extract_frontmatter(contents).ok_or(SkillParseError::MissingFrontmatter)?;
let parsed: SkillFrontmatter = match serde_yaml::from_str(&frontmatter) {
Ok(parsed) => Ok(parsed),
@@ -673,13 +763,12 @@ async fn parse_skill_file(
}
.map_err(SkillParseError::InvalidYaml)?;
let base_name = parsed
let name = parsed
.name
.as_deref()
.map(sanitize_single_line)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| default_skill_name(path));
let name = namespaced_skill_name(fs, path, &base_name, plugin_namespace).await;
.unwrap_or_else(default_name);
let description = parsed
.description
.as_deref()
@@ -691,30 +780,16 @@ async fn parse_skill_file(
.as_deref()
.map(sanitize_single_line)
.filter(|value| !value.is_empty());
let LoadedSkillMetadata {
interface,
dependencies,
policy,
} = load_skill_metadata(fs, path, plugin_root).await;
validate_len(&base_name, MAX_NAME_LEN, "name")?;
validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?;
validate_len(&name, MAX_NAME_LEN, "name")?;
if description.is_empty() {
return Err(SkillParseError::MissingField("description"));
}
let resolved_path = canonicalize_for_skill_identity(fs, path).await;
Ok(SkillMetadata {
Ok(ParsedSkillFrontmatter {
name,
description,
short_description,
interface,
dependencies,
policy,
path_to_skills_md: resolved_path,
scope,
plugin_id: plugin_id.map(str::to_string),
})
}