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

@@ -0,0 +1,180 @@
use std::io;
use codex_exec_server::ExecutorFileSystem;
use codex_protocol::protocol::Product;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::plugin_namespace_for_skill_uri;
use crate::model::SkillDependencies;
use crate::model::SkillPolicy;
use super::MAX_QUALIFIED_NAME_LEN;
use super::ParsedSkillFrontmatter;
use super::SKILLS_METADATA_DIR;
use super::SKILLS_METADATA_FILENAME;
use super::SkillMetadataFile;
use super::SymlinkPolicy;
use super::discover_skills_under_root;
use super::parse_skill_frontmatter_metadata_inner;
use super::resolve_dependencies;
use super::resolve_policy;
use super::sanitize_single_line;
use super::validate_len;
/// URI-native metadata for one skill owned by an execution environment.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EnvironmentSkillMetadata {
pub path_to_skills_md: PathUri,
pub name: String,
pub description: String,
pub short_description: Option<String>,
pub dependencies: Option<SkillDependencies>,
pub policy: Option<SkillPolicy>,
}
impl EnvironmentSkillMetadata {
pub fn allows_implicit_invocation(&self) -> bool {
self.policy
.as_ref()
.and_then(|policy| policy.allow_implicit_invocation)
.unwrap_or(true)
}
fn matches_product_restriction(&self, restriction_product: Option<Product>) -> bool {
match &self.policy {
Some(policy) => {
policy.products.is_empty()
|| restriction_product.is_some_and(|product| {
product.matches_product_restriction(&policy.products)
})
}
None => true,
}
}
async fn parse(file_system: &dyn ExecutorFileSystem, path: &PathUri) -> Result<Self, String> {
let contents = file_system
.read_file_text(path, /*sandbox*/ None)
.await
.map_err(|err| format!("failed to read file: {err}"))?;
let ParsedSkillFrontmatter {
name: base_name,
description,
short_description,
} = parse_skill_frontmatter_metadata_inner(&contents, || default_skill_name(path))
.map_err(|err| err.to_string())?;
let name = plugin_namespace_for_skill_uri(file_system, path)
.await
.map(|namespace| format!("{namespace}:{base_name}"))
.unwrap_or(base_name);
validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")
.map_err(|err| err.to_string())?;
let (dependencies, policy) = load_skill_metadata(file_system, path).await;
Ok(Self {
path_to_skills_md: path.clone(),
name,
description,
short_description,
dependencies,
policy,
})
}
}
#[derive(Debug, Default)]
pub struct EnvironmentSkillLoadOutcome {
pub skills: Vec<EnvironmentSkillMetadata>,
pub warnings: Vec<String>,
}
/// Discovers skills without converting environment-owned paths to host paths.
pub async fn load_environment_skills_from_root(
file_system: &dyn ExecutorFileSystem,
root: &PathUri,
restriction_product: Option<Product>,
) -> EnvironmentSkillLoadOutcome {
let mut outcome = EnvironmentSkillLoadOutcome::default();
let discovery =
discover_skills_under_root(file_system, root, SymlinkPolicy::FollowDirectories).await;
outcome.warnings.extend(discovery.warnings);
for path in discovery.skill_files {
match EnvironmentSkillMetadata::parse(file_system, &path).await {
Ok(skill) if skill.matches_product_restriction(restriction_product) => {
outcome.skills.push(skill);
}
Ok(_) => {}
Err(message) => outcome.warnings.push(format!(
"Failed to load environment skill at {path}: {message}"
)),
}
}
outcome.skills.sort_by(|left, right| {
left.name.cmp(&right.name).then_with(|| {
left.path_to_skills_md
.to_string()
.cmp(&right.path_to_skills_md.to_string())
})
});
outcome
}
async fn load_skill_metadata(
file_system: &dyn ExecutorFileSystem,
skill_path: &PathUri,
) -> (Option<SkillDependencies>, Option<SkillPolicy>) {
let Some(skill_dir) = skill_path.parent() else {
return (None, None);
};
let Ok(metadata_path) =
skill_dir.join(&format!("{SKILLS_METADATA_DIR}/{SKILLS_METADATA_FILENAME}"))
else {
return (None, None);
};
match file_system
.get_metadata(&metadata_path, /*sandbox*/ None)
.await
{
Ok(metadata) if metadata.is_file => {}
Ok(_) => return (None, None),
Err(error) if error.kind() == io::ErrorKind::NotFound => return (None, None),
Err(error) => {
tracing::warn!("ignoring {metadata_path}: failed to stat metadata: {error}");
return (None, None);
}
}
let contents = match file_system
.read_file_text(&metadata_path, /*sandbox*/ None)
.await
{
Ok(contents) => contents,
Err(error) => {
tracing::warn!("ignoring {metadata_path}: failed to read metadata: {error}");
return (None, None);
}
};
let parsed: SkillMetadataFile = match serde_yaml::from_str(&contents) {
Ok(parsed) => parsed,
Err(error) => {
tracing::warn!("ignoring {metadata_path}: invalid metadata: {error}");
return (None, None);
}
};
(
resolve_dependencies(parsed.dependencies),
resolve_policy(parsed.policy),
)
}
fn default_skill_name(path: &PathUri) -> String {
path.parent()
.and_then(|parent| parent.basename())
.map(|name| sanitize_single_line(&name))
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "skill".to_string())
}
#[cfg(test)]
#[path = "environment_tests.rs"]
mod tests;
@@ -0,0 +1,78 @@
use std::fs;
use codex_exec_server::LOCAL_FS;
use codex_protocol::protocol::Product;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use crate::model::SkillDependencies;
use crate::model::SkillPolicy;
use crate::model::SkillToolDependency;
use super::EnvironmentSkillMetadata;
use super::load_environment_skills_from_root;
#[tokio::test]
async fn loads_plugin_namespace_dependencies_and_policy() {
let root = tempdir().expect("tempdir");
let skill_dir = root.path().join("skills/deploy");
fs::create_dir_all(root.path().join(".codex-plugin")).expect("manifest dir");
fs::create_dir_all(skill_dir.join("agents")).expect("metadata dir");
fs::write(
root.path().join(".codex-plugin/plugin.json"),
r#"{"name":"demo-plugin"}"#,
)
.expect("manifest");
fs::write(
skill_dir.join("SKILL.md"),
"---\nname: deploy\ndescription: Deploy the service.\n---\n",
)
.expect("skill");
fs::write(
skill_dir.join("agents/openai.yaml"),
r#"
dependencies:
tools:
- type: mcp
value: deploy-server
description: Deploy MCP
policy:
allow_implicit_invocation: false
products: [codex]
"#,
)
.expect("metadata");
let root_uri = PathUri::from_host_native_path(root.path()).expect("root URI");
let outcome =
load_environment_skills_from_root(LOCAL_FS.as_ref(), &root_uri, Some(Product::Codex)).await;
assert_eq!(
outcome.skills,
vec![EnvironmentSkillMetadata {
path_to_skills_md: PathUri::from_host_native_path(skill_dir.join("SKILL.md"),).unwrap(),
name: "demo-plugin:deploy".to_string(),
description: "Deploy the service.".to_string(),
short_description: None,
dependencies: Some(SkillDependencies {
tools: vec![SkillToolDependency {
r#type: "mcp".to_string(),
value: "deploy-server".to_string(),
description: Some("Deploy MCP".to_string()),
transport: None,
command: None,
url: None,
}],
}),
policy: Some(SkillPolicy {
allow_implicit_invocation: Some(false),
products: vec![Product::Codex],
}),
}]
);
let filtered =
load_environment_skills_from_root(LOCAL_FS.as_ref(), &root_uri, Some(Product::Chatgpt))
.await;
assert!(filtered.skills.is_empty());
}