Remove the legacy core skill loader (#37457)

## What changed

- Use `HostSkillRoot` throughout host root resolution and loading, including plugin roots.
- Remove the duplicate loader, root snapshot, and product-filtering implementation from `core-skills` now that loading is owned by the skills extension.
- Move and expand loader coverage for discovery, namespaces, filesystem routing, root merging, symlinks, and frontmatter parsing.

## Testing

- Added focused unit and integration tests under `ext/skills` and `skills` for the consolidated loader behavior.

GitOrigin-RevId: 214d06d59bf3033ee0f220ea5959ee4feff66782
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-08-07 15:16:06 +00:00
1 parent c5d9431971
commit 33e365b19e
37 files changed
+1223 -4531

No files matched your search

+10 -37
View File
@@ -7,7 +7,6 @@ use codex_config::ConfigLayerStack;
use codex_config::default_project_root_markers;
use codex_config::merge_toml_values;
use codex_config::project_root_markers_from_config;
use codex_core_skills::loader::SkillRoot;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_protocol::protocol::SkillScope;
@@ -15,7 +14,6 @@ use codex_skills::system_cache_root_dir;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::PluginSkillRoot;
use codex_utils_plugins::SkillDiscoveryMode;
use dirs::home_dir;
use futures::StreamExt;
use toml::Value as TomlValue;
@@ -32,7 +30,7 @@ pub(crate) async fn resolve_skill_roots(
cwd: &AbsolutePathBuf,
plugin_skill_roots: Vec<PluginSkillRoot>,
extra_skill_roots: Vec<AbsolutePathBuf>,
) -> Vec<SkillRoot> {
) -> Vec<HostSkillRoot> {
let home_dir =
home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok());
resolve_skill_roots_with_home_dir(
@@ -53,33 +51,20 @@ async fn resolve_skill_roots_with_home_dir(
home_dir: Option<&AbsolutePathBuf>,
plugin_skill_roots: Vec<PluginSkillRoot>,
extra_skill_roots: Vec<AbsolutePathBuf>,
) -> Vec<SkillRoot> {
) -> Vec<HostSkillRoot> {
let mut roots =
roots_from_layer_stack(config_layer_stack, home_dir, repository_file_system.clone())
roots_from_layer_stack(config_layer_stack, home_dir, repository_file_system.clone());
roots.extend(
plugin_skill_roots
.into_iter()
.map(host_root_to_skill_root)
.collect::<Vec<_>>();
roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot {
path: root.path,
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_identity: Some(root.plugin_identity),
plugin_namespace: Some(root.plugin_namespace),
plugin_root: Some(root.plugin_root),
discovery_mode: root.discovery_mode,
}));
.map(|root| HostSkillRoot::plugin(root, Arc::clone(&LOCAL_FS))),
);
roots.extend(
extra_skill_roots
.into_iter()
.map(|path| local_root(path, SkillScope::User))
.map(host_root_to_skill_root),
);
roots.extend(
repo_agents_skill_roots(repository_file_system, config_layer_stack, cwd)
.await
.into_iter()
.map(host_root_to_skill_root),
.map(|path| local_root(path, SkillScope::User)),
);
roots.extend(repo_agents_skill_roots(repository_file_system, config_layer_stack, cwd).await);
dedupe_skill_roots_by_path(&mut roots);
roots
}
@@ -147,18 +132,6 @@ fn local_root(path: AbsolutePathBuf, scope: SkillScope) -> HostSkillRoot {
HostSkillRoot::host(path, scope, Arc::clone(&LOCAL_FS))
}
fn host_root_to_skill_root(root: HostSkillRoot) -> SkillRoot {
SkillRoot {
path: root.path,
scope: root.scope,
file_system: root.file_system,
plugin_identity: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
}
}
async fn repo_agents_skill_roots(
repository_file_system: Option<Arc<dyn ExecutorFileSystem>>,
config_layer_stack: &ConfigLayerStack,
@@ -285,7 +258,7 @@ fn dirs_between_project_root_and_cwd(
directories
}
fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) {
fn dedupe_skill_roots_by_path(roots: &mut Vec<HostSkillRoot>) {
let mut seen = HashSet::new();
roots.retain(|root| seen.insert(root.path.clone()));
}
+22 -18
View File
@@ -8,8 +8,6 @@ use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirementsToml;
use codex_core_skills::SkillMetadata;
use codex_core_skills::loader::MAX_CONCURRENT_ROOT_SCANS;
use codex_core_skills::loader::load_skills_from_roots;
use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
@@ -36,6 +34,8 @@ use tokio::sync::Semaphore;
use super::repo_agents_skill_roots;
use super::resolve_skill_roots_with_home_dir;
use super::roots_from_layer_stack;
use crate::loader::MAX_CONCURRENT_ROOT_SCANS;
use crate::loader::load_and_merge_host_skill_roots;
struct BlockingMetadataFileSystem {
inner: Arc<dyn ExecutorFileSystem>,
@@ -317,10 +317,10 @@ async fn plugin_roots_preserve_plugin_resolution_metadata() {
(
root.path.clone(),
root.scope,
root.plugin_identity.clone(),
root.plugin_namespace.clone(),
root.plugin_root.clone(),
root.discovery_mode,
root.plugin_identity().cloned(),
root.plugin_namespace().map(str::to_string),
root.plugin_root().cloned(),
root.discovery_mode(),
),
(
skills_root,
@@ -357,10 +357,10 @@ async fn unique_extra_root_loads_as_recursive_user_root() {
(
root.path.clone(),
root.scope,
root.plugin_identity.clone(),
root.plugin_namespace.clone(),
root.plugin_root.clone(),
root.discovery_mode,
root.plugin_identity().cloned(),
root.plugin_namespace().map(str::to_string),
root.plugin_root().cloned(),
root.discovery_mode(),
),
(
extra_root,
@@ -373,10 +373,11 @@ async fn unique_extra_root_loads_as_recursive_user_root() {
);
assert!(Arc::ptr_eq(&root.file_system, &LOCAL_FS));
let outcome = load_skills_from_roots(
let outcome = load_and_merge_host_skill_roots(
roots,
&Semaphore::new(MAX_CONCURRENT_ROOT_SCANS),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
)
.await;
@@ -455,10 +456,11 @@ async fn resolved_project_layer_loads_skill_without_git_marker() {
Vec::new(),
)
.await;
let outcome = load_skills_from_roots(
let outcome = load_and_merge_host_skill_roots(
roots,
&Semaphore::new(MAX_CONCURRENT_ROOT_SCANS),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
)
.await;
@@ -491,10 +493,11 @@ async fn resolved_project_layer_loads_skill_when_cwd_is_file() {
Vec::new(),
)
.await;
let outcome = load_skills_from_roots(
let outcome = load_and_merge_host_skill_roots(
roots,
&Semaphore::new(MAX_CONCURRENT_ROOT_SCANS),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
)
.await;
@@ -645,10 +648,11 @@ async fn resolved_config_and_repo_roots_preserve_order_and_dedupe_paths_not_name
)
.await;
assert_eq!(roots.len(), 8);
let outcome = load_skills_from_roots(
let outcome = load_and_merge_host_skill_roots(
roots,
&Semaphore::new(MAX_CONCURRENT_ROOT_SCANS),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
)
.await;
assert!(outcome.errors.is_empty());
+8 -33
View File
@@ -24,8 +24,6 @@ use codex_core_skills::SkillLoadOutcome;
use codex_core_skills::config_rules::SkillConfigRules;
use codex_core_skills::config_rules::resolve_disabled_skill_paths;
use codex_core_skills::config_rules::skill_config_rules_from_stack;
use codex_core_skills::loader::MAX_CONCURRENT_ROOT_SCANS;
use codex_core_skills::loader::SkillRoot;
use codex_skills::LoadedSkills;
use codex_skills::SkillLoadFuture;
use codex_skills::SkillRootLoadRequest;
@@ -36,6 +34,7 @@ use codex_skills::install_system_skills;
use crate::HostSkillsSnapshot;
use crate::host_roots::resolve_skill_roots;
use crate::loader::HostSkillRoot;
use crate::loader::MAX_CONCURRENT_ROOT_SCANS;
use crate::loader::load_and_merge_host_skill_roots;
#[derive(Debug, Clone)]
@@ -165,7 +164,7 @@ impl HostSkillsService {
&self,
input: &HostSkillsLoadInput,
fs: Option<Arc<dyn ExecutorFileSystem>>,
) -> Vec<SkillRoot> {
) -> Vec<HostSkillRoot> {
if input.bundled_skills_enabled {
self.ensure_system_skills_installed();
}
@@ -247,7 +246,7 @@ impl HostSkillsService {
async fn snapshot_for_skill_roots(
&self,
input: &HostSkillsLoadInput,
roots: Vec<SkillRoot>,
roots: Vec<HostSkillRoot>,
skill_config_rules: &SkillConfigRules,
cache_key: ConfigSkillsCacheKey,
force_reload: bool,
@@ -285,33 +284,9 @@ impl HostSkillsService {
async fn build_skill_outcome(
&self,
input: &HostSkillsLoadInput,
roots: Vec<SkillRoot>,
roots: Vec<HostSkillRoot>,
skill_config_rules: &SkillConfigRules,
) -> SkillLoadOutcome {
let roots = roots
.into_iter()
.map(|root| {
match (
root.plugin_identity,
root.plugin_namespace,
root.plugin_root,
) {
(Some(plugin_identity), Some(plugin_namespace), Some(plugin_root)) => {
HostSkillRoot::plugin(
PluginSkillRoot {
path: root.path,
plugin_identity,
plugin_namespace,
plugin_root,
discovery_mode: root.discovery_mode,
},
root.file_system,
)
}
_ => HostSkillRoot::host(root.path, root.scope, root.file_system),
}
})
.collect();
let outcome = load_and_merge_host_skill_roots(
roots,
&self.root_scan_slots,
@@ -466,7 +441,7 @@ pub fn bundled_skills_enabled_from_stack(
}
fn config_skills_cache_key(
roots: &[SkillRoot],
roots: &[HostSkillRoot],
skill_config_rules: &SkillConfigRules,
plugin_skill_snapshots: Option<&SkillRootSnapshots<PluginSkillRoot>>,
) -> ConfigSkillsCacheKey {
@@ -483,15 +458,15 @@ fn config_skills_cache_key(
ConfigSkillRootCacheKey {
path: root.path.clone(),
scope_rank,
plugin_identity: root.plugin_identity.clone(),
plugin_namespace: root.plugin_namespace.clone(),
plugin_identity: root.plugin_identity().cloned(),
plugin_namespace: root.plugin_namespace().map(str::to_string),
file_system: FileSystemIdentity(Arc::downgrade(&root.file_system)),
}
})
.collect(),
skill_config_rules: skill_config_rules.clone(),
plugin_skill_snapshots: plugin_skill_snapshots
.filter(|_| roots.iter().any(|root| root.plugin_identity.is_some()))
.filter(|_| roots.iter().any(|root| root.plugin_identity().is_some()))
.cloned(),
}
}
+1
View File
@@ -28,6 +28,7 @@ pub use host_service::HostSkillsLoadInput;
pub use host_service::HostSkillsService;
pub use host_service::bundled_skills_enabled_from_stack;
pub use host_snapshot::HostSkillsSnapshot;
pub use loader::HostSkillRoot;
pub use provider::ExecutorSkillProvider;
pub use provider::HostSkillProvider;
pub use provider::OrchestratorSkillProvider;
@@ -218,3 +218,7 @@ fn discover_skill_metadata(
SkillMetadataDiscovery::Probe(metadata_path)
}
}
#[cfg(test)]
#[path = "discovery_tests.rs"]
mod tests;
@@ -0,0 +1,125 @@
use std::fs;
use std::path::Path;
use codex_exec_server::LOCAL_FS;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::SkillDiscoveryMode;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use super::DirectorySymlinkPolicy;
use super::HiddenDirectoryPolicy;
use super::SkillDiscovery;
use super::SkillDiscoveryOptions;
use super::discover_skills;
fn write_skill(root: &Path, directory: &str) {
let path = root.join(directory).join("SKILL.md");
fs::create_dir_all(path.parent().expect("skill parent")).expect("create skill directory");
fs::write(path, "---\nname: demo\ndescription: Demo skill\n---\n")
.expect("write skill frontmatter");
}
fn canonical_uri(path: &Path) -> PathUri {
PathUri::from_host_native_path(fs::canonicalize(path).expect("canonical path"))
.expect("absolute path URI")
}
async fn discover(root: &Path) -> SkillDiscovery {
discover_skills(
LOCAL_FS.as_ref(),
&canonical_uri(root),
SkillDiscoveryOptions {
directory_symlinks: DirectorySymlinkPolicy::Follow,
hidden_directories: HiddenDirectoryPolicy::Skip,
mode: SkillDiscoveryMode::Recursive,
},
)
.await
}
#[cfg(unix)]
#[tokio::test]
async fn discovers_hidden_directory_through_visible_symlink() {
let root = TempDir::new().expect("root temp dir");
write_skill(root.path(), ".hidden/search");
std::os::unix::fs::symlink(root.path().join(".hidden"), root.path().join("visible"))
.expect("create visible skill directory symlink");
let discovery = discover(root.path()).await;
assert_eq!(discovery.warnings, Vec::<String>::new());
assert_eq!(
discovery
.skills
.into_iter()
.map(|skill| skill.path)
.collect::<Vec<_>>(),
vec![
canonical_uri(root.path())
.join("visible/search/SKILL.md")
.expect("visible skill URI"),
]
);
}
#[cfg(unix)]
#[tokio::test]
async fn ignores_symlinked_skill_files_and_directory_cycles() {
let root = TempDir::new().expect("root temp dir");
let external = TempDir::new().expect("external temp dir");
write_skill(external.path(), "external");
write_skill(root.path(), "cycle/demo");
fs::create_dir_all(root.path().join("linked-file")).expect("create linked skill directory");
std::os::unix::fs::symlink(
external.path().join("external/SKILL.md"),
root.path().join("linked-file/SKILL.md"),
)
.expect("create skill file symlink");
std::os::unix::fs::symlink(root.path().join("cycle"), root.path().join("cycle/loop"))
.expect("create directory symlink cycle");
let discovery = tokio::time::timeout(
std::time::Duration::from_secs(/*secs*/ 5),
discover(root.path()),
)
.await
.expect("directory symlink cycles must not prevent discovery from completing");
assert_eq!(
discovery
.skills
.into_iter()
.map(|skill| skill.path)
.collect::<Vec<_>>(),
vec![
canonical_uri(root.path())
.join("cycle/demo/SKILL.md")
.expect("valid skill URI"),
]
);
}
#[tokio::test]
async fn respects_maximum_skill_scan_depth() {
let root = TempDir::new().expect("root temp dir");
let within_depth = "d0/d1/d2/d3/d4/d5";
let beyond_depth = "d0/d1/d2/d3/d4/d5/d6";
write_skill(root.path(), within_depth);
write_skill(root.path(), beyond_depth);
let discovery = discover(root.path()).await;
assert_eq!(
discovery
.skills
.into_iter()
.map(|skill| skill.path)
.collect::<Vec<_>>(),
vec![
canonical_uri(root.path())
.join(&format!("{within_depth}/SKILL.md"))
.expect("reachable skill URI"),
]
);
}
@@ -1,208 +1,18 @@
use std::fs;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::ExecutorFileSystemFuture;
use codex_exec_server::FileMetadata;
use codex_exec_server::FileSystemReadStream;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::LOCAL_FS;
use codex_exec_server::ReadDirectoryEntry;
use codex_exec_server::RemoveOptions;
use codex_exec_server::WalkOptions;
use codex_exec_server::WalkOutcome;
use codex_skills::EnvironmentSkillMetadata;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use tokio::sync::Notify;
use crate::loader::io_test_support::FileSystemCalls;
use crate::loader::io_test_support::ManifestMetadataBehavior;
use crate::loader::io_test_support::RecordingFileSystem;
use super::load_environment_skills_from_root;
#[derive(Clone, Copy)]
enum ManifestMetadataBehavior {
Immediate,
WaitForSkillRead,
}
struct RecordingFileSystem<'a> {
inner: &'a dyn ExecutorFileSystem,
read_files: Mutex<Vec<PathUri>>,
metadata_files: Mutex<Vec<PathUri>>,
walks: AtomicUsize,
manifest_metadata_behavior: ManifestMetadataBehavior,
skill_read_started: AtomicBool,
skill_read_started_notify: Notify,
}
#[derive(Debug, PartialEq, Eq)]
struct FileSystemCalls {
walks: usize,
read_files: Vec<PathUri>,
metadata_files: Vec<PathUri>,
}
impl<'a> RecordingFileSystem<'a> {
fn new(
inner: &'a dyn ExecutorFileSystem,
manifest_metadata_behavior: ManifestMetadataBehavior,
) -> Self {
Self {
inner,
read_files: Mutex::new(Vec::new()),
metadata_files: Mutex::new(Vec::new()),
walks: AtomicUsize::new(0),
manifest_metadata_behavior,
skill_read_started: AtomicBool::new(false),
skill_read_started_notify: Notify::new(),
}
}
fn calls(&self) -> FileSystemCalls {
let mut read_files = self
.read_files
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
read_files.sort_by_key(ToString::to_string);
let mut metadata_files = self
.metadata_files
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
metadata_files.sort_by_key(ToString::to_string);
FileSystemCalls {
walks: self.walks.load(Ordering::Relaxed),
read_files,
metadata_files,
}
}
}
impl ExecutorFileSystem for RecordingFileSystem<'_> {
fn canonicalize<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, PathUri> {
self.inner.canonicalize(path, sandbox)
}
fn read_file<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, Vec<u8>> {
self.read_files
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(path.clone());
if path.basename().as_deref() == Some("SKILL.md") {
self.skill_read_started.store(true, Ordering::Release);
self.skill_read_started_notify.notify_waiters();
}
self.inner.read_file(path, sandbox)
}
fn read_file_stream<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> {
self.inner.read_file_stream(path, sandbox)
}
fn write_file<'a>(
&'a self,
path: &'a PathUri,
contents: Vec<u8>,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
self.inner.write_file(path, contents, sandbox)
}
fn create_directory<'a>(
&'a self,
path: &'a PathUri,
options: CreateDirectoryOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
self.inner.create_directory(path, options, sandbox)
}
fn get_metadata<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FileMetadata> {
self.metadata_files
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(path.clone());
if matches!(
self.manifest_metadata_behavior,
ManifestMetadataBehavior::WaitForSkillRead
) && path.basename().as_deref() == Some("plugin.json")
{
return Box::pin(async move {
loop {
let notified = self.skill_read_started_notify.notified();
if self.skill_read_started.load(Ordering::Acquire) {
break;
}
notified.await;
}
self.inner.get_metadata(path, sandbox).await
});
}
self.inner.get_metadata(path, sandbox)
}
fn read_directory<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, Vec<ReadDirectoryEntry>> {
self.inner.read_directory(path, sandbox)
}
fn walk<'a>(
&'a self,
path: &'a PathUri,
options: WalkOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, WalkOutcome> {
self.walks.fetch_add(1, Ordering::Relaxed);
self.inner.walk(path, options, sandbox)
}
fn remove<'a>(
&'a self,
path: &'a PathUri,
options: RemoveOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
self.inner.remove(path, options, sandbox)
}
fn copy<'a>(
&'a self,
source_path: &'a PathUri,
destination_path: &'a PathUri,
options: CopyOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
self.inner
.copy(source_path, destination_path, options, sandbox)
}
}
#[tokio::test]
async fn loads_nearest_plugin_namespaces_without_reading_unused_sibling_manifests() {
let root = tempdir().expect("tempdir");
@@ -1,18 +1,92 @@
use std::fs;
use codex_core_skills::loader::load_environment_skills_from_root;
use codex_exec_server::CapabilityRootDiscoverRequest;
use codex_exec_server::CapabilityRootsDiscoverParams;
use codex_exec_server::LOCAL_FS;
use codex_exec_server::discover_capability_roots;
use codex_protocol::protocol::Product;
use codex_skills::EnvironmentSkillMetadata;
use codex_skills::SkillDependencies;
use codex_skills::SkillPolicy;
use codex_skills::SkillToolDependency;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use super::load_environment_skills_from_discovery;
use super::load_environment_skills_from_root;
#[tokio::test]
async fn executor_bundle_parser_matches_the_existing_environment_loader() {
async fn direct_environment_loader_preserves_plugin_dependencies_and_product_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
transport: stdio
command: deploy-mcp
policy:
allow_implicit_invocation: false
products: [codex, atlas]
"#,
)
.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: Some("stdio".to_string()),
command: Some("deploy-mcp".to_string()),
url: None,
}],
}),
policy: Some(SkillPolicy {
allow_implicit_invocation: Some(false),
products: vec![Product::Codex, Product::Atlas],
}),
}]
);
let atlas =
load_environment_skills_from_root(LOCAL_FS.as_ref(), &root_uri, Some(Product::Atlas)).await;
assert_eq!(atlas.skills, outcome.skills);
let filtered =
load_environment_skills_from_root(LOCAL_FS.as_ref(), &root_uri, Some(Product::Chatgpt))
.await;
assert!(filtered.skills.is_empty());
}
#[tokio::test]
async fn executor_bundle_parser_matches_direct_environment_loader() {
let root = tempdir().expect("tempdir");
let plugin_manifest = root.path().join(".codex-plugin/plugin.json");
let nested_manifest = root.path().join("nested/.claude-plugin/plugin.json");
+6 -1
View File
@@ -75,7 +75,8 @@ impl HostSkillRoot {
}
}
pub(crate) fn plugin_identity(&self) -> Option<&PluginIdentity> {
/// Returns the owning plugin identity when this root belongs to a plugin.
pub fn plugin_identity(&self) -> Option<&PluginIdentity> {
self.plugin.as_ref().map(|plugin| &plugin.identity)
}
@@ -404,3 +405,7 @@ async fn canonicalize_for_skill_identity(
#[cfg(test)]
#[path = "host_tests.rs"]
mod tests;
#[cfg(all(test, unix))]
#[path = "host_io_tests.rs"]
mod io_tests;
@@ -0,0 +1,138 @@
use std::fs;
use std::os::unix::fs::symlink;
use std::sync::Arc;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_protocol::protocol::SkillScope;
use codex_skills::SkillMetadata;
use codex_skills::SkillPolicy;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
use tokio::sync::Semaphore;
use crate::loader::HostSkillRoot;
use crate::loader::MAX_CONCURRENT_ROOT_SCANS;
use crate::loader::io_test_support::ManifestMetadataBehavior;
use crate::loader::io_test_support::RecordingFileSystem;
use crate::loader::load_and_merge_host_skill_roots;
#[tokio::test]
async fn host_loading_reuses_walk_inventory_for_symlinked_skill_pack() {
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 root_scan_slots = Semaphore::new(MAX_CONCURRENT_ROOT_SCANS);
let future = load_and_merge_host_skill_roots(
vec![HostSkillRoot::host(
host_root.abs(),
SkillScope::User,
file_system,
)],
&root_scan_slots,
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
);
fn assert_send<T: Send>(_: &T) {}
assert_send(&future);
let outcome = future.await;
assert_eq!(outcome.errors, Vec::new());
let first_skill_path = dunce::canonicalize(skills_root.join("first/SKILL.md"))
.unwrap()
.abs();
let second_skill_path = dunce::canonicalize(skills_root.join("second/SKILL.md"))
.unwrap()
.abs();
assert_eq!(
outcome.skills,
vec![
SkillMetadata {
name: "linked:first".to_string(),
description: "first skill.".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: Some(SkillPolicy {
allow_implicit_invocation: Some(false),
products: Vec::new(),
}),
path_to_skills_md: first_skill_path,
scope: SkillScope::User,
plugin_id: None,
remote_plugin_id: None,
},
SkillMetadata {
name: "linked:second".to_string(),
description: "second skill.".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: second_skill_path,
scope: SkillScope::User,
plugin_id: None,
remote_plugin_id: None,
},
]
);
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
);
}
+1 -1
View File
@@ -3,7 +3,6 @@ use std::collections::HashSet;
use std::sync::Arc;
use codex_core_skills::SkillLoadOutcome;
use codex_core_skills::loader::MAX_CONCURRENT_ROOT_SCANS;
use codex_exec_server::ExecutorFileSystem;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
@@ -18,6 +17,7 @@ use tokio::sync::OnceCell;
use tokio::sync::Semaphore;
use super::HostSkillRoot;
use super::MAX_CONCURRENT_ROOT_SCANS;
use super::host::HostSkillRootSnapshot;
use super::load_host_skill_root;
@@ -2,13 +2,19 @@ use std::collections::HashMap;
use std::fs;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::Ordering;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_skills::LoadedSkillRoot;
use codex_skills::SkillError;
use codex_skills::SkillRootSnapshotCache;
use codex_skills::SkillRootSnapshots;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::PluginIdentity;
use codex_utils_plugins::PluginSkillRoot;
use codex_utils_plugins::SkillDiscoveryMode;
@@ -17,6 +23,10 @@ use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::sync::Semaphore;
use crate::loader::MAX_CONCURRENT_ROOT_SCANS;
use crate::loader::io_test_support::ManifestMetadataBehavior;
use crate::loader::io_test_support::RecordingFileSystem;
use super::HostSkillRoot;
use super::load_and_merge_host_skill_roots;
@@ -311,3 +321,188 @@ async fn recognizes_symlinked_migrated_command_roots() {
]
);
}
#[tokio::test]
async fn host_root_scans_wait_for_shared_capacity() {
let temp = TempDir::new().expect("temp dir");
let root = AbsolutePathBuf::from_absolute_path(temp.path()).expect("absolute skill root");
let root_scan_slots = Semaphore::new(/*permits*/ 1);
let held_slot = root_scan_slots
.try_acquire()
.expect("root scan slot should be available");
let load = load_and_merge_host_skill_roots(
vec![HostSkillRoot::host(
root,
SkillScope::Repo,
Arc::clone(&LOCAL_FS),
)],
&root_scan_slots,
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
);
tokio::pin!(load);
assert!(futures::poll!(load.as_mut()).is_pending());
drop(held_slot);
let outcome = load.await;
assert_eq!(outcome.skills, Vec::new());
assert_eq!(outcome.errors, Vec::new());
}
#[tokio::test]
async fn duplicate_host_root_paths_preserve_first_root_scope() {
let temp = TempDir::new().expect("temp dir");
let root = AbsolutePathBuf::from_absolute_path(temp.path()).expect("absolute skill root");
write_skill(&root, "demo", "demo", "Shared skill");
let outcome = load_and_merge_host_skill_roots(
vec![
HostSkillRoot::host(root.clone(), SkillScope::Repo, Arc::clone(&LOCAL_FS)),
HostSkillRoot::host(root.clone(), SkillScope::User, Arc::clone(&LOCAL_FS)),
],
&Semaphore::new(/*permits*/ 2),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
)
.await;
assert_eq!(outcome.errors, Vec::new());
assert_eq!(outcome.skills.len(), 1);
assert_eq!(outcome.skills[0].scope, SkillScope::Repo);
let canonical_root = AbsolutePathBuf::from_absolute_path(
fs::canonicalize(root.as_path()).expect("canonicalize skill root"),
)
.expect("absolute canonical skill root");
assert_eq!(
outcome.skill_root_for_path(&outcome.skills[0].path_to_skills_md),
Some(&canonical_root)
);
}
#[cfg(unix)]
#[tokio::test]
async fn deduplicated_symlinked_skill_preserves_first_discovery_path() {
let source = TempDir::new().expect("source temp dir");
let first = TempDir::new().expect("first root temp dir");
let second = TempDir::new().expect("second root temp dir");
let source_root =
AbsolutePathBuf::from_absolute_path(source.path()).expect("absolute source root");
let first_root =
AbsolutePathBuf::from_absolute_path(first.path()).expect("absolute first root");
let second_root =
AbsolutePathBuf::from_absolute_path(second.path()).expect("absolute second root");
write_skill(&source_root, "demo", "demo", "Shared skill");
std::os::unix::fs::symlink(source.path().join("demo"), first.path().join("first-link"))
.expect("create first skill directory symlink");
std::os::unix::fs::symlink(
source.path().join("demo"),
second.path().join("second-link"),
)
.expect("create second skill directory symlink");
let outcome = load_and_merge_host_skill_roots(
vec![
HostSkillRoot::host(first_root.clone(), SkillScope::Repo, Arc::clone(&LOCAL_FS)),
HostSkillRoot::host(second_root, SkillScope::User, Arc::clone(&LOCAL_FS)),
],
&Semaphore::new(/*permits*/ 2),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
)
.await;
assert_eq!(outcome.errors, Vec::new());
assert_eq!(outcome.skills.len(), 1);
let skill_path = &outcome.skills[0].path_to_skills_md;
let canonical_first_root = AbsolutePathBuf::from_absolute_path(
fs::canonicalize(first_root.as_path()).expect("canonicalize first skill root"),
)
.expect("absolute first skill root");
assert_eq!(
(
outcome.skill_root_for_path(skill_path),
outcome.skill_discovery_path_for_path(skill_path),
),
(
Some(&canonical_first_root),
Some(&canonical_first_root.join("first-link/SKILL.md")),
)
);
}
#[tokio::test]
async fn merges_host_root_results_in_input_order_when_scans_finish_out_of_order() {
const ROOT_COUNT: usize = MAX_CONCURRENT_ROOT_SCANS + 1;
let temp = TempDir::new().expect("temp dir");
let roots = (0..ROOT_COUNT)
.map(|index| temp.path().join(format!("root-{index}")))
.collect::<Vec<_>>();
for root in &roots {
fs::create_dir_all(root).expect("create skill root");
}
let first_skill = roots[0].join("broken/SKILL.md");
let second_skill = roots[1].join("broken/SKILL.md");
for path in [&first_skill, &second_skill] {
fs::create_dir_all(path.parent().expect("skill parent"))
.expect("create invalid skill directory");
fs::write(path, "missing frontmatter").expect("write invalid skill");
}
let mut recording =
RecordingFileSystem::new(LOCAL_FS.as_ref(), ManifestMetadataBehavior::Immediate);
recording.blocked_walk_root = Some(PathUri::from_abs_path(
&dunce::canonicalize(&roots[0])
.expect("canonical blocked skill root")
.abs(),
));
let recording = Arc::new(recording);
let file_system: Arc<dyn ExecutorFileSystem> = recording.clone();
let host_roots = roots
.iter()
.map(|root| HostSkillRoot::host(root.clone().abs(), SkillScope::User, file_system.clone()))
.collect::<Vec<_>>();
let load = tokio::spawn(async move {
load_and_merge_host_skill_roots(
host_roots,
&Semaphore::new(MAX_CONCURRENT_ROOT_SCANS),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
)
.await
});
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), async {
loop {
let walk_started = recording.walk_started.notified();
if recording.walks.load(Ordering::Acquire) == ROOT_COUNT {
break;
}
walk_started.await;
}
})
.await
.expect("all host root scans should start despite the blocked first root");
recording.blocked_walk_gate.add_permits(/*n*/ 1);
let outcome = load.await.expect("host skill root loading should complete");
assert_eq!(outcome.skills, Vec::new());
assert_eq!(
outcome.errors,
vec![
SkillError {
path: dunce::canonicalize(first_skill)
.expect("canonical first invalid skill path")
.abs(),
message: "missing YAML frontmatter delimited by ---".to_string(),
},
SkillError {
path: dunce::canonicalize(second_skill)
.expect("canonical second invalid skill path")
.abs(),
message: "missing YAML frontmatter delimited by ---".to_string(),
},
]
);
}
+56 -14
View File
@@ -119,7 +119,7 @@ async fn loads_host_frontmatter_dependencies_and_policy() {
description: Demo tool
policy:
allow_implicit_invocation: false
products: [codex]
products: [codex, CHATGPT, atlas]
"##,
);
@@ -145,7 +145,7 @@ policy:
}),
policy: Some(SkillPolicy {
allow_implicit_invocation: Some(false),
products: vec![Product::Codex],
products: vec![Product::Codex, Product::Chatgpt, Product::Atlas],
}),
path_to_skills_md: skill_path,
scope: SkillScope::User,
@@ -433,6 +433,41 @@ async fn plugin_root_accepts_maximum_length_qualified_skill_name() {
);
}
#[tokio::test]
async fn plugin_root_rejects_overlong_qualified_skill_name() {
let root = TempDir::new().expect("temp dir");
let skill_name = "s".repeat(MAX_NAME_LEN);
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: "p".repeat(MAX_NAME_LEN + 1),
plugin_root,
discovery_mode: SkillDiscoveryMode::Recursive,
},
Arc::clone(&LOCAL_FS),
))
.await;
assert_eq!(snapshot.skills, Vec::new());
assert_eq!(snapshot.errors.len(), 1);
assert!(
snapshot.errors[0]
.message
.contains("invalid qualified name")
);
}
#[tokio::test]
async fn recursive_plugin_root_preserves_owner_namespace_and_shared_asset_policy() {
let root = TempDir::new().expect("temp dir");
@@ -638,7 +673,7 @@ async fn recursive_plugin_root_preserves_symlinked_skill_discovery_path() {
#[cfg(unix)]
#[tokio::test]
async fn follows_directory_symlinks_for_user_but_not_system_scope() {
async fn follows_directory_symlinks_except_for_system_scope() {
use std::os::unix::fs::symlink;
let root = TempDir::new().expect("temp dir");
@@ -646,18 +681,25 @@ async fn follows_directory_symlinks_for_user_but_not_system_scope() {
let target_skill = write_skill(&target, "demo", "name: demo\ndescription: Symlinked skill");
symlink(target.path().join("demo"), root.path().join("alias")).expect("create symlink");
let user_snapshot = load_host_skill_root(root_for(&root, SkillScope::User)).await;
let system_snapshot = load_host_skill_root(root_for(&root, SkillScope::System)).await;
for scope in [SkillScope::User, SkillScope::Repo, SkillScope::Admin] {
let snapshot = load_host_skill_root(root_for(&root, scope)).await;
assert_eq!(user_snapshot.errors, Vec::new());
assert_eq!(user_snapshot.skills.len(), 1);
assert_eq!(user_snapshot.skills[0].path_to_skills_md, target_skill);
assert_eq!(
user_snapshot
.skill_discovery_path_by_path
.get(&target_skill),
Some(&user_snapshot.root.join("alias/SKILL.md"))
);
assert_eq!(snapshot.errors, Vec::new());
assert_eq!(snapshot.skills.len(), 1);
assert_eq!(
(
&snapshot.skills[0].path_to_skills_md,
snapshot.skills[0].scope
),
(&target_skill, scope)
);
assert_eq!(
snapshot.skill_discovery_path_by_path.get(&target_skill),
Some(&snapshot.root.join("alias/SKILL.md"))
);
}
let system_snapshot = load_host_skill_root(root_for(&root, SkillScope::System)).await;
assert_eq!(system_snapshot.errors, Vec::new());
assert_eq!(system_snapshot.skills, Vec::new());
}
@@ -0,0 +1,214 @@
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::ExecutorFileSystemFuture;
use codex_exec_server::FileMetadata;
use codex_exec_server::FileSystemReadStream;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::ReadDirectoryEntry;
use codex_exec_server::RemoveOptions;
use codex_exec_server::WalkOptions;
use codex_exec_server::WalkOutcome;
use codex_utils_path_uri::PathUri;
use tokio::sync::Notify;
use tokio::sync::Semaphore;
#[derive(Clone, Copy)]
pub(super) enum ManifestMetadataBehavior {
Immediate,
WaitForSkillRead,
}
pub(super) struct RecordingFileSystem<'a> {
inner: &'a dyn ExecutorFileSystem,
read_files: Mutex<Vec<PathUri>>,
metadata_files: Mutex<Vec<PathUri>>,
pub(super) walks: AtomicUsize,
manifest_metadata_behavior: ManifestMetadataBehavior,
skill_read_started: AtomicBool,
skill_read_started_notify: Notify,
pub(super) blocked_walk_root: Option<PathUri>,
pub(super) blocked_walk_gate: Semaphore,
pub(super) walk_started: Notify,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct FileSystemCalls {
pub(super) walks: usize,
pub(super) read_files: Vec<PathUri>,
pub(super) metadata_files: Vec<PathUri>,
}
impl<'a> RecordingFileSystem<'a> {
pub(super) fn new(
inner: &'a dyn ExecutorFileSystem,
manifest_metadata_behavior: ManifestMetadataBehavior,
) -> Self {
Self {
inner,
read_files: Mutex::new(Vec::new()),
metadata_files: Mutex::new(Vec::new()),
walks: AtomicUsize::new(/*v*/ 0),
manifest_metadata_behavior,
skill_read_started: AtomicBool::new(/*v*/ false),
skill_read_started_notify: Notify::new(),
blocked_walk_root: None,
blocked_walk_gate: Semaphore::new(/*permits*/ 0),
walk_started: Notify::new(),
}
}
pub(super) fn calls(&self) -> FileSystemCalls {
let mut read_files = self
.read_files
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
read_files.sort_by_key(ToString::to_string);
let mut metadata_files = self
.metadata_files
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
metadata_files.sort_by_key(ToString::to_string);
FileSystemCalls {
walks: self.walks.load(Ordering::Relaxed),
read_files,
metadata_files,
}
}
}
impl ExecutorFileSystem for RecordingFileSystem<'_> {
fn canonicalize<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, PathUri> {
self.inner.canonicalize(path, sandbox)
}
fn read_file<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, Vec<u8>> {
self.read_files
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(path.clone());
if path.basename().as_deref() == Some("SKILL.md") {
self.skill_read_started.store(true, Ordering::Release);
self.skill_read_started_notify.notify_waiters();
}
self.inner.read_file(path, sandbox)
}
fn read_file_stream<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> {
self.inner.read_file_stream(path, sandbox)
}
fn write_file<'a>(
&'a self,
path: &'a PathUri,
contents: Vec<u8>,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
self.inner.write_file(path, contents, sandbox)
}
fn create_directory<'a>(
&'a self,
path: &'a PathUri,
options: CreateDirectoryOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
self.inner.create_directory(path, options, sandbox)
}
fn get_metadata<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FileMetadata> {
self.metadata_files
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(path.clone());
if matches!(
self.manifest_metadata_behavior,
ManifestMetadataBehavior::WaitForSkillRead
) && path.basename().as_deref() == Some("plugin.json")
{
return Box::pin(async move {
loop {
let notified = self.skill_read_started_notify.notified();
if self.skill_read_started.load(Ordering::Acquire) {
break;
}
notified.await;
}
self.inner.get_metadata(path, sandbox).await
});
}
self.inner.get_metadata(path, sandbox)
}
fn read_directory<'a>(
&'a self,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, Vec<ReadDirectoryEntry>> {
self.inner.read_directory(path, sandbox)
}
fn walk<'a>(
&'a self,
path: &'a PathUri,
options: WalkOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, WalkOutcome> {
self.walks.fetch_add(/*val*/ 1, Ordering::AcqRel);
self.walk_started.notify_waiters();
if self.blocked_walk_root.as_ref() != Some(path) {
return self.inner.walk(path, options, sandbox);
}
Box::pin(async move {
self.blocked_walk_gate
.acquire()
.await
.expect("blocked walk gate should remain open")
.forget();
self.inner.walk(path, options, sandbox).await
})
}
fn remove<'a>(
&'a self,
path: &'a PathUri,
options: RemoveOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
self.inner.remove(path, options, sandbox)
}
fn copy<'a>(
&'a self,
source_path: &'a PathUri,
destination_path: &'a PathUri,
options: CopyOptions,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, ()> {
self.inner
.copy(source_path, destination_path, options, sandbox)
}
}
+4 -1
View File
@@ -2,15 +2,18 @@ mod discovery;
mod environment;
mod host;
mod host_merge;
#[cfg(test)]
mod io_test_support;
mod metadata;
mod namespace;
pub(crate) use environment::load_environment_skills_from_discovery;
pub(crate) use environment::load_environment_skills_from_root;
pub(crate) use host::HostSkillRoot;
pub use host::HostSkillRoot;
pub(crate) use host::load_host_skill_root;
pub(crate) use host_merge::load_and_merge_host_skill_roots;
pub(crate) const MAX_CONCURRENT_ROOT_SCANS: usize = 8;
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";
@@ -180,3 +180,7 @@ impl ResolvedSkillNamespace {
}
}
}
#[cfg(test)]
#[path = "namespace_tests.rs"]
mod tests;
@@ -0,0 +1,172 @@
use std::collections::HashSet;
use std::fs;
use std::path::Path;
#[cfg(unix)]
use std::sync::Arc;
use codex_exec_server::LOCAL_FS;
#[cfg(unix)]
use codex_protocol::protocol::SkillScope;
#[cfg(unix)]
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[cfg(unix)]
use crate::loader::HostSkillRoot;
#[cfg(unix)]
use crate::loader::load_host_skill_root;
use super::SkillNamespaceResolver;
fn canonical_uri(path: &Path) -> PathUri {
PathUri::from_host_native_path(fs::canonicalize(path).expect("canonical path"))
.expect("absolute path URI")
}
fn write_skill(root: &Path, directory: &str) -> PathUri {
let path = root.join(directory).join("SKILL.md");
fs::create_dir_all(path.parent().expect("skill parent")).expect("create skill directory");
fs::write(&path, "---\nname: skill\ndescription: Demo skill\n---\n")
.expect("write skill frontmatter");
canonical_uri(&path)
}
fn write_manifest(root: &Path, contents: &str) -> PathUri {
let path = root.join(".codex-plugin/plugin.json");
fs::create_dir_all(path.parent().expect("manifest parent"))
.expect("create plugin manifest directory");
fs::write(path, contents).expect("write plugin manifest");
canonical_uri(root)
}
async fn namespaces_for(
root: &Path,
skill_paths: &[PathUri],
plugin_roots: HashSet<PathUri>,
mut namespace_roots: HashSet<PathUri>,
) -> Vec<String> {
let root = canonical_uri(root);
namespace_roots.insert(root.clone());
let resolver = SkillNamespaceResolver::discover(
LOCAL_FS.as_ref(),
&root,
skill_paths,
plugin_roots,
namespace_roots,
)
.await;
skill_paths
.iter()
.map(|path| resolver.for_skill(&root, path).qualify("skill"))
.collect()
}
#[tokio::test]
async fn nested_plugin_namespaces_do_not_apply_to_plain_siblings() {
let root = TempDir::new().expect("root temp dir");
let nested_plugin = root.path().join("nested-plugin");
let plugin_root = write_manifest(&nested_plugin, r#"{"name":"nested"}"#);
let plain_skill = write_skill(root.path(), "plain");
let nested_skill = write_skill(&nested_plugin, "skills/search");
let namespaces = namespaces_for(
root.path(),
&[plain_skill, nested_skill],
HashSet::from([plugin_root]),
HashSet::new(),
)
.await;
assert_eq!(namespaces, vec!["skill", "nested:skill"]);
}
#[tokio::test]
async fn nearest_valid_manifest_overrides_or_falls_back_to_outer_namespace() {
let root = TempDir::new().expect("root temp dir");
let plugin_root = root.path().join("outer");
let skills_root = plugin_root.join("skills");
let nested_plugin = skills_root.join("nested");
let invalid_plugin = skills_root.join("invalid");
write_manifest(&plugin_root, r#"{"name":"outer"}"#);
let nested_root = write_manifest(&nested_plugin, r#"{"name":"nested"}"#);
let invalid_root = write_manifest(&invalid_plugin, "not json");
let direct_skill = write_skill(&skills_root, "direct");
let nested_skill = write_skill(&nested_plugin, "skills/search");
let fallback_skill = write_skill(&invalid_plugin, "skills/fallback");
let namespaces = namespaces_for(
&skills_root,
&[direct_skill, nested_skill, fallback_skill],
HashSet::from([nested_root, invalid_root]),
HashSet::new(),
)
.await;
assert_eq!(
namespaces,
vec!["outer:skill", "nested:skill", "outer:skill"]
);
}
#[cfg(unix)]
#[tokio::test]
async fn symlinked_external_directories_do_not_inherit_plugin_namespace() {
let root = TempDir::new().expect("root temp dir");
let external = TempDir::new().expect("external temp dir");
let plugin_root = root.path().join("outer");
let skills_root = plugin_root.join("skills");
write_manifest(&plugin_root, r#"{"name":"outer"}"#);
write_skill(external.path(), "plain");
fs::create_dir_all(&skills_root).expect("create skill root");
std::os::unix::fs::symlink(external.path(), skills_root.join("linked"))
.expect("create external directory symlink");
let snapshot = load_host_skill_root(HostSkillRoot::host(
AbsolutePathBuf::from_absolute_path(&skills_root).expect("absolute skills root"),
SkillScope::User,
Arc::clone(&LOCAL_FS),
))
.await;
assert_eq!(snapshot.errors, Vec::new());
assert_eq!(
snapshot
.skills
.into_iter()
.map(|skill| skill.name)
.collect::<Vec<_>>(),
vec!["skill"]
);
}
#[cfg(unix)]
#[tokio::test]
async fn ancestor_symlink_roots_preserve_inherited_plugin_namespace() {
let root = TempDir::new().expect("root temp dir");
let plugin_root = root.path().join("a/b/c/d/e/f/outer");
let skills_root = plugin_root.join("skills");
write_manifest(&plugin_root, r#"{"name":"outer"}"#);
write_skill(&skills_root, "direct");
std::os::unix::fs::symlink(root.path(), skills_root.join("ancestor"))
.expect("create ancestor directory symlink");
let snapshot = load_host_skill_root(HostSkillRoot::host(
AbsolutePathBuf::from_absolute_path(&skills_root).expect("absolute skills root"),
SkillScope::User,
Arc::clone(&LOCAL_FS),
))
.await;
assert_eq!(snapshot.errors, Vec::new());
assert_eq!(
snapshot
.skills
.into_iter()
.map(|skill| skill.name)
.collect::<Vec<_>>(),
vec!["outer:skill"]
);
}
+18 -24
View File
@@ -2,8 +2,6 @@ use std::sync::Arc;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use codex_core_skills::loader::SkillRoot;
use codex_core_skills::loader::load_skills_from_roots;
use codex_exec_server::LOCAL_FS;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -11,6 +9,8 @@ use pretty_assertions::assert_eq;
use tokio::sync::Semaphore;
use super::catalog_from_outcome;
use crate::loader::HostSkillRoot;
use crate::loader::load_and_merge_host_skill_roots;
#[tokio::test]
async fn host_catalog_entries_carry_their_render_metadata() -> Result<(), Box<dyn std::error::Error>>
@@ -31,18 +31,15 @@ async fn host_catalog_entries_carry_their_render_metadata() -> Result<(), Box<dy
"---\nname: demo\ndescription: Demo skill.\n---\n# Demo\n",
)?;
let root = AbsolutePathBuf::try_from(std::fs::canonicalize(root)?)?;
let outcome = load_skills_from_roots(
[SkillRoot {
path: root.clone(),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_identity: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: Default::default(),
}],
let outcome = load_and_merge_host_skill_roots(
vec![HostSkillRoot::host(
root.clone(),
SkillScope::User,
Arc::clone(&LOCAL_FS),
)],
&Semaphore::new(/*permits*/ 1),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(1)),
)
.await;
@@ -79,18 +76,15 @@ async fn host_catalog_preserves_symlinked_skill_discovery_paths()
std::os::unix::fs::symlink(&source_skill_dir, root.path().join("linked-skill"))?;
let root = AbsolutePathBuf::try_from(std::fs::canonicalize(root.path())?)?;
let outcome = load_skills_from_roots(
[SkillRoot {
path: root.clone(),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_identity: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: Default::default(),
}],
let outcome = load_and_merge_host_skill_roots(
vec![HostSkillRoot::host(
root.clone(),
SkillScope::User,
Arc::clone(&LOCAL_FS),
)],
&Semaphore::new(/*permits*/ 1),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(1)),
)
.await;
let catalog = catalog_from_outcome(&outcome);
+12 -23
View File
@@ -6,8 +6,6 @@ use crate::catalog::SkillResourceId;
use crate::provider::HostSkillProvider;
use crate::provider::SkillListQuery;
use crate::provider::SkillProvider;
use codex_core_skills::loader::SkillRoot;
use codex_core_skills::loader::load_skills_from_roots;
use codex_exec_server::LOCAL_FS;
use codex_extension_api::ContextualUserFragment;
use codex_protocol::protocol::SkillScope;
@@ -19,6 +17,8 @@ use std::time::UNIX_EPOCH;
use tokio::sync::Semaphore;
use crate::catalog_prompt::render_available_skills_body;
use crate::loader::HostSkillRoot;
use crate::loader::load_and_merge_host_skill_roots;
fn entry(name: &str, description: &str, short_description: Option<&str>) -> SkillCatalogEntry {
entry_with_path(
@@ -301,29 +301,18 @@ async fn host_alias_roots_follow_core_discovery_order() -> Result<(), Box<dyn st
}
let user_root = AbsolutePathBuf::try_from(std::fs::canonicalize(&user_root_path)?)?;
let system_root = AbsolutePathBuf::try_from(std::fs::canonicalize(&system_root_path)?)?;
let outcome = load_skills_from_roots(
[
SkillRoot {
path: user_root.clone(),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_identity: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: Default::default(),
},
SkillRoot {
path: system_root.clone(),
scope: SkillScope::System,
file_system: Arc::clone(&LOCAL_FS),
plugin_identity: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: Default::default(),
},
let outcome = load_and_merge_host_skill_roots(
vec![
HostSkillRoot::host(user_root.clone(), SkillScope::User, Arc::clone(&LOCAL_FS)),
HostSkillRoot::host(
system_root.clone(),
SkillScope::System,
Arc::clone(&LOCAL_FS),
),
],
&Semaphore::new(/*permits*/ 2),
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(2)),
)
.await;
let catalog = HostSkillProvider::new()