mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Make skill loading filesystem-aware (#17720)
Migrates skill loading to support reading repo skills from the remote environment.
This commit is contained in:
committed by
GitHub
Unverified
parent
5ecaf09ab0
commit
96254a763a
Generated
+4
@@ -2015,6 +2015,7 @@ dependencies = [
|
||||
"codex-analytics",
|
||||
"codex-app-server-protocol",
|
||||
"codex-config",
|
||||
"codex-exec-server",
|
||||
"codex-instructions",
|
||||
"codex-login",
|
||||
"codex-otel",
|
||||
@@ -3075,10 +3076,13 @@ dependencies = [
|
||||
name = "codex-utils-plugins"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-exec-server",
|
||||
"codex-login",
|
||||
"codex-utils-absolute-path",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -6292,6 +6292,23 @@ impl CodexMessageProcessor {
|
||||
};
|
||||
let skills_manager = self.thread_manager.skills_manager();
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let fs = match self.thread_manager.environment_manager().current().await {
|
||||
Ok(Some(environment)) => Some(environment.get_filesystem()),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to create environment: {err}"),
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let cli_overrides = self.current_cli_overrides();
|
||||
let mut data = Vec::new();
|
||||
for cwd in cwds {
|
||||
@@ -6349,7 +6366,12 @@ impl CodexMessageProcessor {
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
let outcome = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(&skills_input, force_reload, extra_roots)
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
&skills_input,
|
||||
force_reload,
|
||||
extra_roots,
|
||||
fs.clone(),
|
||||
)
|
||||
.await;
|
||||
let errors = errors_to_info(&outcome.errors);
|
||||
let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths);
|
||||
|
||||
@@ -11,6 +11,7 @@ use codex_app_server_protocol::SkillsListExtraRootsForCwd;
|
||||
use codex_app_server_protocol::SkillsListParams;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
@@ -64,6 +65,56 @@ async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let cwd = TempDir::new()?;
|
||||
let extra_root = TempDir::new()?;
|
||||
write_skill(&codex_home, "home-skill")?;
|
||||
write_skill(&extra_root, "extra-skill")?;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_skills_list_request(SkillsListParams {
|
||||
cwds: vec![cwd.path().to_path_buf()],
|
||||
force_reload: true,
|
||||
per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
extra_user_roots: vec![extra_root.path().to_path_buf()],
|
||||
}]),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let SkillsListResponse { data } = to_response(response)?;
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].cwd, cwd.path().to_path_buf());
|
||||
assert_eq!(data[0].errors, Vec::new());
|
||||
assert!(
|
||||
data[0]
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.name == "home-skill")
|
||||
);
|
||||
assert!(
|
||||
data[0]
|
||||
.skills
|
||||
.iter()
|
||||
.all(|skill| skill.name != "extra-skill")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_list_rejects_relative_extra_user_roots() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -17,6 +17,7 @@ anyhow = { workspace = true }
|
||||
codex-analytics = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-instructions = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::SkillLoadOutcome;
|
||||
use crate::SkillMetadata;
|
||||
use crate::build_skill_name_counts;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SkillInvocation;
|
||||
use codex_analytics::TrackEventsContext;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_instructions::SkillInstructions;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SkillInjections {
|
||||
@@ -23,6 +25,7 @@ pub struct SkillInjections {
|
||||
|
||||
pub async fn build_skill_injections(
|
||||
mentioned_skills: &[SkillMetadata],
|
||||
loaded_skills: Option<&SkillLoadOutcome>,
|
||||
otel: Option<&SessionTelemetry>,
|
||||
analytics_client: &AnalyticsEventsClient,
|
||||
tracking: TrackEventsContext,
|
||||
@@ -38,7 +41,13 @@ pub async fn build_skill_injections(
|
||||
let mut invocations = Vec::new();
|
||||
|
||||
for skill in mentioned_skills {
|
||||
match fs::read_to_string(&skill.path_to_skills_md).await {
|
||||
let fs = loaded_skills
|
||||
.and_then(|outcome| outcome.file_system_for_skill(skill))
|
||||
.unwrap_or_else(|| Arc::clone(&LOCAL_FS));
|
||||
match fs
|
||||
.read_file_text(&skill.path_to_skills_md, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => {
|
||||
emit_skill_injected_metric(otel, skill, "ok");
|
||||
invocations.push(SkillInvocation {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::model::SkillDependencies;
|
||||
use crate::model::SkillError;
|
||||
use crate::model::SkillFileSystemsByPath;
|
||||
use crate::model::SkillInterface;
|
||||
use crate::model::SkillLoadOutcome;
|
||||
use crate::model::SkillMetadata;
|
||||
@@ -12,22 +13,24 @@ use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::default_project_root_markers;
|
||||
use codex_config::merge_toml_values;
|
||||
use codex_config::project_root_markers_from_config;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_plugins::plugin_namespace_for_skill_path;
|
||||
use dirs::home_dir;
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::error;
|
||||
|
||||
@@ -148,21 +151,38 @@ impl Error for SkillParseError {}
|
||||
pub struct SkillRoot {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub scope: SkillScope,
|
||||
pub file_system: Arc<dyn ExecutorFileSystem>,
|
||||
}
|
||||
|
||||
pub fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
|
||||
pub async fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
|
||||
where
|
||||
I: IntoIterator<Item = SkillRoot>,
|
||||
{
|
||||
let mut outcome = SkillLoadOutcome::default();
|
||||
let mut file_systems_by_skill_path: HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>> =
|
||||
HashMap::new();
|
||||
for root in roots {
|
||||
discover_skills_under_root(&root.path, root.scope, &mut outcome);
|
||||
let fs = root.file_system;
|
||||
let skills_before_root = outcome.skills.len();
|
||||
discover_skills_under_root(fs.as_ref(), &root.path, root.scope, &mut outcome).await;
|
||||
for skill in &outcome.skills[skills_before_root..] {
|
||||
file_systems_by_skill_path
|
||||
.entry(skill.path_to_skills_md.clone())
|
||||
.or_insert_with(|| Arc::clone(&fs));
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen: HashSet<AbsolutePathBuf> = HashSet::new();
|
||||
outcome
|
||||
.skills
|
||||
.retain(|skill| seen.insert(skill.path_to_skills_md.clone()));
|
||||
let retained_skill_paths: HashSet<AbsolutePathBuf> = outcome
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| skill.path_to_skills_md.clone())
|
||||
.collect();
|
||||
file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path));
|
||||
outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path);
|
||||
|
||||
fn scope_rank(scope: SkillScope) -> u8 {
|
||||
// Higher-priority scopes first (matches root scan order for dedupe).
|
||||
@@ -184,7 +204,8 @@ where
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) fn skill_roots(
|
||||
pub(crate) async fn skill_roots(
|
||||
fs: Option<Arc<dyn ExecutorFileSystem>>,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
cwd: &AbsolutePathBuf,
|
||||
plugin_skill_roots: Vec<AbsolutePathBuf>,
|
||||
@@ -192,25 +213,29 @@ pub(crate) fn skill_roots(
|
||||
let home_dir =
|
||||
home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok());
|
||||
skill_roots_with_home_dir(
|
||||
fs,
|
||||
config_layer_stack,
|
||||
cwd,
|
||||
home_dir.as_ref(),
|
||||
plugin_skill_roots,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn skill_roots_with_home_dir(
|
||||
async fn skill_roots_with_home_dir(
|
||||
fs: Option<Arc<dyn ExecutorFileSystem>>,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
cwd: &AbsolutePathBuf,
|
||||
home_dir: Option<&AbsolutePathBuf>,
|
||||
plugin_skill_roots: Vec<AbsolutePathBuf>,
|
||||
) -> Vec<SkillRoot> {
|
||||
let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir);
|
||||
let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir, fs.clone());
|
||||
roots.extend(plugin_skill_roots.into_iter().map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
}));
|
||||
roots.extend(repo_agents_skill_roots(config_layer_stack, cwd));
|
||||
roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await);
|
||||
dedupe_skill_roots_by_path(&mut roots);
|
||||
roots
|
||||
}
|
||||
@@ -218,6 +243,7 @@ fn skill_roots_with_home_dir(
|
||||
fn skill_roots_from_layer_stack_inner(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
home_dir: Option<&AbsolutePathBuf>,
|
||||
repo_fs: Option<Arc<dyn ExecutorFileSystem>>,
|
||||
) -> Vec<SkillRoot> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
@@ -231,10 +257,13 @@ fn skill_roots_from_layer_stack_inner(
|
||||
|
||||
match &layer.name {
|
||||
ConfigLayerSource::Project { .. } => {
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Repo,
|
||||
});
|
||||
if let Some(repo_fs) = &repo_fs {
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Repo,
|
||||
file_system: Arc::clone(repo_fs),
|
||||
});
|
||||
}
|
||||
}
|
||||
ConfigLayerSource::User { .. } => {
|
||||
// Deprecated user skills location (`$CODEX_HOME/skills`), kept for backward
|
||||
@@ -242,6 +271,7 @@ fn skill_roots_from_layer_stack_inner(
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
});
|
||||
|
||||
// `$HOME/.agents/skills` (user-installed skills).
|
||||
@@ -249,6 +279,7 @@ fn skill_roots_from_layer_stack_inner(
|
||||
roots.push(SkillRoot {
|
||||
path: home_dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,6 +288,7 @@ fn skill_roots_from_layer_stack_inner(
|
||||
roots.push(SkillRoot {
|
||||
path: system_cache_root_dir(&config_folder),
|
||||
scope: SkillScope::System,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::System { .. } => {
|
||||
@@ -265,6 +297,7 @@ fn skill_roots_from_layer_stack_inner(
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Admin,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::Mdm { .. }
|
||||
@@ -277,21 +310,34 @@ fn skill_roots_from_layer_stack_inner(
|
||||
roots
|
||||
}
|
||||
|
||||
fn repo_agents_skill_roots(
|
||||
async fn repo_agents_skill_roots(
|
||||
fs: Option<Arc<dyn ExecutorFileSystem>>,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> Vec<SkillRoot> {
|
||||
let Some(fs) = fs else {
|
||||
return Vec::new();
|
||||
};
|
||||
let project_root_markers = project_root_markers_from_stack(config_layer_stack);
|
||||
let project_root = find_project_root(cwd, &project_root_markers);
|
||||
let project_root = find_project_root(fs.as_ref(), cwd, &project_root_markers).await;
|
||||
let dirs = dirs_between_project_root_and_cwd(cwd, &project_root);
|
||||
let mut roots = Vec::new();
|
||||
for dir in dirs {
|
||||
let agents_skills = dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
if agents_skills.is_dir() {
|
||||
roots.push(SkillRoot {
|
||||
match fs.get_metadata(&agents_skills, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_directory => roots.push(SkillRoot {
|
||||
path: agents_skills,
|
||||
scope: SkillScope::Repo,
|
||||
});
|
||||
file_system: Arc::clone(&fs),
|
||||
}),
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to stat repo skills root {}: {err:#}",
|
||||
agents_skills.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
roots
|
||||
@@ -319,16 +365,27 @@ fn project_root_markers_from_stack(config_layer_stack: &ConfigLayerStack) -> Vec
|
||||
}
|
||||
}
|
||||
|
||||
fn find_project_root(cwd: &AbsolutePathBuf, project_root_markers: &[String]) -> AbsolutePathBuf {
|
||||
async fn find_project_root(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
cwd: &AbsolutePathBuf,
|
||||
project_root_markers: &[String],
|
||||
) -> AbsolutePathBuf {
|
||||
if project_root_markers.is_empty() {
|
||||
return cwd.clone();
|
||||
}
|
||||
|
||||
for path in cwd.ancestors() {
|
||||
for ancestor in cwd.ancestors() {
|
||||
for marker in project_root_markers {
|
||||
let marker_path = path.join(marker);
|
||||
if marker_path.exists() {
|
||||
return path;
|
||||
let marker_path = ancestor.join(marker);
|
||||
match fs.get_metadata(&marker_path, /*sandbox*/ None).await {
|
||||
Ok(_) => return ancestor,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to stat project root marker {}: {err:#}",
|
||||
marker_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,24 +419,33 @@ fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) {
|
||||
roots.retain(|root| seen.insert(root.path.clone()));
|
||||
}
|
||||
|
||||
fn discover_skills_under_root(
|
||||
fn canonicalize_for_skill_identity(path: &AbsolutePathBuf) -> AbsolutePathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.clone())
|
||||
}
|
||||
|
||||
async fn discover_skills_under_root(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
root: &AbsolutePathBuf,
|
||||
scope: SkillScope,
|
||||
outcome: &mut SkillLoadOutcome,
|
||||
) {
|
||||
let Ok(root) = canonicalize_path(root.as_path()) else {
|
||||
return;
|
||||
};
|
||||
let root = canonicalize_for_skill_identity(root);
|
||||
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
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,
|
||||
Err(err) => {
|
||||
error!("failed to stat skills root {}: {err:#}", root.display());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_dir(
|
||||
queue: &mut VecDeque<(PathBuf, usize)>,
|
||||
visited_dirs: &mut HashSet<PathBuf>,
|
||||
queue: &mut VecDeque<(AbsolutePathBuf, usize)>,
|
||||
visited_dirs: &mut HashSet<AbsolutePathBuf>,
|
||||
truncated_by_dir_limit: &mut bool,
|
||||
path: PathBuf,
|
||||
path: AbsolutePathBuf,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > MAX_SCAN_DEPTH {
|
||||
@@ -400,14 +466,14 @@ fn discover_skills_under_root(
|
||||
SkillScope::Repo | SkillScope::User | SkillScope::Admin
|
||||
);
|
||||
|
||||
let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
|
||||
let mut visited_dirs: HashSet<AbsolutePathBuf> = HashSet::new();
|
||||
visited_dirs.insert(root.clone());
|
||||
|
||||
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from([(root.clone(), 0)]);
|
||||
let mut queue: VecDeque<(AbsolutePathBuf, 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_dir(&dir) {
|
||||
let entries = match fs.read_directory(&dir, /*sandbox*/ None).await {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
error!("failed to read skills dir {}: {e:#}", dir.display());
|
||||
@@ -415,65 +481,53 @@ fn discover_skills_under_root(
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = match AbsolutePathBuf::from_absolute_path_checked(entry.path()) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
error!("failed to normalize skills entry path: {err:#}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let file_name = match path.file_name().and_then(|f| f.to_str()) {
|
||||
Some(name) => name,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
for entry in entries {
|
||||
let file_name = entry.file_name;
|
||||
if file_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
let path = dir.join(&file_name);
|
||||
let metadata = match fs.get_metadata(&path, /*sandbox*/ None).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) => {
|
||||
error!("failed to stat skills path {}: {e:#}", path.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if file_type.is_symlink() {
|
||||
if metadata.is_symlink {
|
||||
if !follow_symlinks {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Follow the symlink to determine what it points to.
|
||||
let metadata = match fs::metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) => {
|
||||
match fs.read_directory(&path, /*sandbox*/ None).await {
|
||||
Ok(_) => {
|
||||
let resolved_dir = canonicalize_for_skill_identity(&path);
|
||||
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) => {
|
||||
error!(
|
||||
"failed to stat skills entry {} (symlink): {e:#}",
|
||||
"failed to read skills symlink dir {}: {err:#}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if metadata.is_dir() {
|
||||
let Ok(resolved_dir) = canonicalize_path(&path) else {
|
||||
continue;
|
||||
};
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
&mut truncated_by_dir_limit,
|
||||
resolved_dir,
|
||||
depth + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_dir() {
|
||||
let Ok(resolved_dir) = canonicalize_path(&path) else {
|
||||
continue;
|
||||
};
|
||||
if metadata.is_directory {
|
||||
let resolved_dir = canonicalize_for_skill_identity(&path);
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
@@ -484,15 +538,15 @@ fn discover_skills_under_root(
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_file() && file_name == SKILLS_FILENAME {
|
||||
match parse_skill_file(&path, scope) {
|
||||
if metadata.is_file && file_name == SKILLS_FILENAME {
|
||||
match parse_skill_file(fs, &path, scope).await {
|
||||
Ok(skill) => {
|
||||
outcome.skills.push(skill);
|
||||
}
|
||||
Err(err) => {
|
||||
if scope != SkillScope::System {
|
||||
outcome.errors.push(SkillError {
|
||||
path,
|
||||
path: path.clone(),
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
@@ -511,11 +565,15 @@ fn discover_skills_under_root(
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_skill_file(
|
||||
async fn parse_skill_file(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
scope: SkillScope,
|
||||
) -> Result<SkillMetadata, SkillParseError> {
|
||||
let contents = fs::read_to_string(path.as_path()).map_err(SkillParseError::Read)?;
|
||||
let contents = fs
|
||||
.read_file_text(path, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(SkillParseError::Read)?;
|
||||
|
||||
let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?;
|
||||
|
||||
@@ -527,8 +585,8 @@ fn parse_skill_file(
|
||||
.as_deref()
|
||||
.map(sanitize_single_line)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| default_skill_name(path.as_path()));
|
||||
let name = namespaced_skill_name(path.as_path(), &base_name);
|
||||
.unwrap_or_else(|| default_skill_name(path));
|
||||
let name = namespaced_skill_name(fs, path, &base_name).await;
|
||||
let description = parsed
|
||||
.description
|
||||
.as_deref()
|
||||
@@ -544,7 +602,7 @@ fn parse_skill_file(
|
||||
interface,
|
||||
dependencies,
|
||||
policy,
|
||||
} = load_skill_metadata(path);
|
||||
} = load_skill_metadata(fs, path).await;
|
||||
|
||||
validate_len(&name, MAX_NAME_LEN, "name")?;
|
||||
validate_len(&description, MAX_DESCRIPTION_LEN, "description")?;
|
||||
@@ -556,7 +614,7 @@ fn parse_skill_file(
|
||||
)?;
|
||||
}
|
||||
|
||||
let resolved_path = path.canonicalize().map_err(SkillParseError::Read)?;
|
||||
let resolved_path = canonicalize_for_skill_identity(path);
|
||||
|
||||
Ok(SkillMetadata {
|
||||
name,
|
||||
@@ -570,22 +628,33 @@ fn parse_skill_file(
|
||||
})
|
||||
}
|
||||
|
||||
fn default_skill_name(path: &Path) -> String {
|
||||
fn default_skill_name(path: &AbsolutePathBuf) -> String {
|
||||
path.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
.map(sanitize_single_line)
|
||||
.and_then(|parent| {
|
||||
parent
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(sanitize_single_line)
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "skill".to_string())
|
||||
}
|
||||
|
||||
fn namespaced_skill_name(path: &Path, base_name: &str) -> String {
|
||||
plugin_namespace_for_skill_path(path)
|
||||
async fn namespaced_skill_name(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
base_name: &str,
|
||||
) -> String {
|
||||
plugin_namespace_for_skill_path(fs, path)
|
||||
.await
|
||||
.map(|namespace| format!("{namespace}:{base_name}"))
|
||||
.unwrap_or_else(|| base_name.to_string())
|
||||
}
|
||||
|
||||
fn load_skill_metadata(skill_path: &AbsolutePathBuf) -> LoadedSkillMetadata {
|
||||
async fn load_skill_metadata(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
skill_path: &AbsolutePathBuf,
|
||||
) -> LoadedSkillMetadata {
|
||||
// Fail open: optional metadata should not block loading SKILL.md.
|
||||
let Some(skill_dir) = skill_path.parent() else {
|
||||
return LoadedSkillMetadata::default();
|
||||
@@ -593,11 +662,23 @@ fn load_skill_metadata(skill_path: &AbsolutePathBuf) -> LoadedSkillMetadata {
|
||||
let metadata_path = skill_dir
|
||||
.join(SKILLS_METADATA_DIR)
|
||||
.join(SKILLS_METADATA_FILENAME);
|
||||
if !metadata_path.as_path().exists() {
|
||||
return LoadedSkillMetadata::default();
|
||||
match fs.get_metadata(&metadata_path, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_file => {}
|
||||
Ok(_) => return LoadedSkillMetadata::default(),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
return LoadedSkillMetadata::default();
|
||||
}
|
||||
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::read_to_string(metadata_path.as_path()) {
|
||||
let contents = match fs.read_file_text(&metadata_path, /*sandbox*/ None).await {
|
||||
Ok(contents) => contents,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
@@ -863,12 +944,13 @@ fn extract_frontmatter(contents: &str) -> Option<String> {
|
||||
Some(frontmatter_lines.join("\n"))
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn skill_roots_from_layer_stack(
|
||||
pub(crate) async fn skill_roots_from_layer_stack(
|
||||
fs: Arc<dyn ExecutorFileSystem>,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
cwd: &AbsolutePathBuf,
|
||||
home_dir: Option<&AbsolutePathBuf>,
|
||||
) -> Vec<SkillRoot> {
|
||||
skill_roots_with_home_dir(config_layer_stack, cwd, home_dir, Vec::new())
|
||||
skill_roots_with_home_dir(Some(fs), config_layer_stack, cwd, home_dir, Vec::new()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,14 +4,18 @@ use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
@@ -113,14 +117,18 @@ async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> TestConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_skills_for_test(config: &TestConfig) -> SkillLoadOutcome {
|
||||
async fn load_skills_for_test(config: &TestConfig) -> SkillLoadOutcome {
|
||||
// Keep unit tests hermetic by never scanning the real `$HOME/.agents/skills`.
|
||||
super::load_skills_from_roots(super::skill_roots_with_home_dir(
|
||||
&config.config_layer_stack,
|
||||
&config.cwd,
|
||||
/*home_dir*/ None,
|
||||
Vec::new(),
|
||||
))
|
||||
super::load_skills_from_roots(
|
||||
super::skill_roots_from_layer_stack(
|
||||
Arc::clone(&LOCAL_FS),
|
||||
&config.config_layer_stack,
|
||||
&config.cwd,
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn mark_as_git_repo(dir: &Path) {
|
||||
@@ -135,8 +143,8 @@ fn normalized(path: &Path) -> AbsolutePathBuf {
|
||||
.abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to_admin()
|
||||
#[tokio::test]
|
||||
async fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to_admin()
|
||||
-> anyhow::Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
|
||||
@@ -167,10 +175,16 @@ fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to
|
||||
)?;
|
||||
|
||||
let home_folder_abs = home_folder.abs();
|
||||
let got = skill_roots_from_layer_stack(&stack, &home_folder_abs, Some(&home_folder_abs))
|
||||
.into_iter()
|
||||
.map(|root| (root.scope, root.path.to_path_buf()))
|
||||
.collect::<Vec<_>>();
|
||||
let got = skill_roots_from_layer_stack(
|
||||
Arc::clone(&LOCAL_FS),
|
||||
&stack,
|
||||
&home_folder_abs,
|
||||
Some(&home_folder_abs),
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|root| (root.scope, root.path.to_path_buf()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
got,
|
||||
@@ -191,8 +205,8 @@ fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Result<()> {
|
||||
#[tokio::test]
|
||||
async fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
|
||||
let home_folder = tmp.path().join("home");
|
||||
@@ -227,10 +241,16 @@ fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Re
|
||||
|
||||
let home_folder_abs = home_folder.abs();
|
||||
let project_root_abs = project_root.abs();
|
||||
let got = skill_roots_from_layer_stack(&stack, &project_root_abs, Some(&home_folder_abs))
|
||||
.into_iter()
|
||||
.map(|root| (root.scope, root.path.to_path_buf()))
|
||||
.collect::<Vec<_>>();
|
||||
let got = skill_roots_from_layer_stack(
|
||||
Arc::clone(&LOCAL_FS),
|
||||
&stack,
|
||||
&project_root_abs,
|
||||
Some(&home_folder_abs),
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|root| (root.scope, root.path.to_path_buf()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
got,
|
||||
@@ -251,8 +271,8 @@ fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> {
|
||||
#[tokio::test]
|
||||
async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
|
||||
let home_folder = tmp.path().join("home");
|
||||
@@ -278,11 +298,14 @@ fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
let home_folder_abs = home_folder.abs();
|
||||
let outcome = load_skills_from_roots(skill_roots_from_layer_stack(
|
||||
let roots = skill_roots_from_layer_stack(
|
||||
Arc::clone(&LOCAL_FS),
|
||||
&stack,
|
||||
&home_folder_abs,
|
||||
Some(&home_folder_abs),
|
||||
));
|
||||
)
|
||||
.await;
|
||||
let outcome = load_skills_from_roots(roots).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -396,7 +419,7 @@ async fn loads_skill_dependencies_metadata_from_yaml() {
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -474,7 +497,7 @@ interface:
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -523,7 +546,7 @@ policy:
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -555,7 +578,7 @@ policy: {}
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -594,7 +617,7 @@ policy:
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -632,7 +655,7 @@ async fn accepts_icon_paths_under_assets_dir() {
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -679,7 +702,7 @@ async fn ignores_invalid_brand_color() {
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -725,7 +748,7 @@ async fn ignores_default_prompt_over_max_length() {
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -773,7 +796,7 @@ async fn drops_interface_when_icons_are_invalid() {
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -817,7 +840,7 @@ async fn loads_skills_via_symlinked_subdir_for_user_scope() {
|
||||
symlink_dir(shared.path(), &codex_home.path().join("skills/shared"));
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -852,7 +875,7 @@ async fn ignores_symlinked_skill_file_for_user_scope() {
|
||||
symlink_file(&shared_skill_path, &skill_dir.join(SKILLS_FILENAME));
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -876,7 +899,7 @@ async fn does_not_loop_on_symlink_cycle_for_user_scope() {
|
||||
let skill_path = write_skill_at(&cycle_dir, "demo", "cycle-skill", "still loads");
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -898,9 +921,9 @@ async fn does_not_loop_on_symlink_cycle_for_user_scope() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[tokio::test]
|
||||
#[cfg(unix)]
|
||||
fn loads_skills_via_symlinked_subdir_for_admin_scope() {
|
||||
async fn loads_skills_via_symlinked_subdir_for_admin_scope() {
|
||||
let admin_root = tempfile::tempdir().expect("tempdir");
|
||||
let shared = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
@@ -912,7 +935,9 @@ fn loads_skills_via_symlinked_subdir_for_admin_scope() {
|
||||
let outcome = load_skills_from_roots([SkillRoot {
|
||||
path: admin_root.path().abs(),
|
||||
scope: SkillScope::Admin,
|
||||
}]);
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
}])
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -951,7 +976,7 @@ async fn loads_skills_via_symlinked_subdir_for_repo_scope() {
|
||||
symlink_dir(shared.path(), &repo_skills_root.join("shared"));
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -988,7 +1013,9 @@ async fn system_scope_ignores_symlinked_subdir() {
|
||||
let outcome = load_skills_from_roots([SkillRoot {
|
||||
path: system_root.abs(),
|
||||
scope: SkillScope::System,
|
||||
}]);
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
}])
|
||||
.await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1018,7 +1045,9 @@ async fn respects_max_scan_depth_for_user_scope() {
|
||||
let outcome = load_skills_from_roots([SkillRoot {
|
||||
path: skills_root.abs(),
|
||||
scope: SkillScope::User,
|
||||
}]);
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
}])
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -1046,7 +1075,7 @@ async fn loads_valid_skill() {
|
||||
let skill_path = write_skill(&codex_home, "demo", "demo-skill", "does things\ncarefully");
|
||||
let cfg = make_config(&codex_home).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1077,7 +1106,7 @@ async fn falls_back_to_directory_name_when_skill_name_is_missing() {
|
||||
);
|
||||
let cfg = make_config(&codex_home).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -1118,7 +1147,9 @@ async fn namespaces_plugin_skills_using_plugin_name() {
|
||||
let outcome = load_skills_from_roots([SkillRoot {
|
||||
path: plugin_root.join("skills").abs(),
|
||||
scope: SkillScope::User,
|
||||
}]);
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
}])
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -1150,7 +1181,7 @@ async fn loads_short_description_from_metadata() {
|
||||
fs::write(&skill_path, contents).unwrap();
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1183,7 +1214,7 @@ async fn enforces_short_description_length_limits() {
|
||||
fs::write(skill_dir.join(SKILLS_FILENAME), contents).unwrap();
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert_eq!(outcome.skills.len(), 0);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert!(
|
||||
@@ -1212,7 +1243,7 @@ async fn skips_hidden_and_invalid() {
|
||||
fs::write(invalid_dir.join(SKILLS_FILENAME), "---\nname: bad").unwrap();
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert_eq!(outcome.skills.len(), 0);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert!(
|
||||
@@ -1230,7 +1261,7 @@ async fn enforces_length_limits() {
|
||||
write_skill(&codex_home, "max-len", "max-len", &max_desc);
|
||||
let cfg = make_config(&codex_home).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1240,7 +1271,7 @@ async fn enforces_length_limits() {
|
||||
|
||||
let too_long_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN + 1);
|
||||
write_skill(&codex_home, "too-long", "too-long", &too_long_desc);
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert!(
|
||||
@@ -1262,7 +1293,7 @@ async fn loads_skills_from_repo_root() {
|
||||
let skill_path = write_skill_at(&skills_root, "repo", "repo-skill", "from repo");
|
||||
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1297,7 +1328,7 @@ async fn loads_skills_from_agents_dir_without_codex_dir() {
|
||||
);
|
||||
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1349,7 +1380,7 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() {
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1399,7 +1430,7 @@ async fn loads_skills_from_codex_dir_when_not_git_repo() {
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1430,12 +1461,15 @@ async fn deduplicates_by_path_preferring_first_root() {
|
||||
SkillRoot {
|
||||
path: root.path().abs(),
|
||||
scope: SkillScope::Repo,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
},
|
||||
SkillRoot {
|
||||
path: root.path().abs(),
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
},
|
||||
]);
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -1476,7 +1510,7 @@ async fn keeps_duplicate_names_from_repo_and_user() {
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, repo_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1539,7 +1573,7 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() {
|
||||
);
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
@@ -1601,7 +1635,7 @@ async fn repo_skills_search_does_not_escape_repo_root() {
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, repo_dir).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1630,7 +1664,7 @@ async fn loads_skills_when_cwd_is_file_in_repo() {
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, file_path).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1670,7 +1704,7 @@ async fn non_git_repo_skills_search_does_not_walk_parents() {
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, nested_dir).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1688,7 +1722,7 @@ async fn loads_skills_from_system_cache_when_present() {
|
||||
|
||||
let cfg = make_config_for_cwd(&codex_home, work_dir.path().to_path_buf()).await;
|
||||
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
let outcome = load_skills_for_test(&cfg).await;
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
@@ -1714,10 +1748,16 @@ async fn skill_roots_include_admin_with_lowest_priority() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cfg = make_config(&codex_home).await;
|
||||
|
||||
let scopes: Vec<SkillScope> = super::skill_roots(&cfg.config_layer_stack, &cfg.cwd, Vec::new())
|
||||
.into_iter()
|
||||
.map(|root| root.scope)
|
||||
.collect();
|
||||
let scopes: Vec<SkillScope> = super::skill_roots(
|
||||
Some(Arc::clone(&LOCAL_FS)),
|
||||
&cfg.config_layer_stack,
|
||||
&cfg.cwd,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|root| root.scope)
|
||||
.collect();
|
||||
let mut expected = vec![SkillScope::User, SkillScope::System];
|
||||
if home_dir().is_some() {
|
||||
expected.insert(1, SkillScope::User);
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -85,15 +86,19 @@ impl SkillsManager {
|
||||
/// This path uses a cache keyed by the effective skill-relevant config state rather than just
|
||||
/// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen
|
||||
/// to share a directory.
|
||||
pub fn skills_for_config(&self, input: &SkillsLoadInput) -> SkillLoadOutcome {
|
||||
let roots = self.skill_roots_for_config(input);
|
||||
pub async fn skills_for_config(
|
||||
&self,
|
||||
input: &SkillsLoadInput,
|
||||
fs: Option<Arc<dyn ExecutorFileSystem>>,
|
||||
) -> SkillLoadOutcome {
|
||||
let roots = self.skill_roots_for_config(input, fs).await;
|
||||
let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack);
|
||||
let cache_key = config_skills_cache_key(&roots, &skill_config_rules);
|
||||
if let Some(outcome) = self.cached_outcome_for_config(&cache_key) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules).await;
|
||||
let mut cache = self
|
||||
.cache_by_config
|
||||
.write()
|
||||
@@ -102,12 +107,18 @@ impl SkillsManager {
|
||||
outcome
|
||||
}
|
||||
|
||||
pub fn skill_roots_for_config(&self, input: &SkillsLoadInput) -> Vec<SkillRoot> {
|
||||
pub async fn skill_roots_for_config(
|
||||
&self,
|
||||
input: &SkillsLoadInput,
|
||||
fs: Option<Arc<dyn ExecutorFileSystem>>,
|
||||
) -> Vec<SkillRoot> {
|
||||
let mut roots = skill_roots(
|
||||
fs,
|
||||
&input.config_layer_stack,
|
||||
&input.cwd,
|
||||
input.effective_skill_roots.clone(),
|
||||
);
|
||||
)
|
||||
.await;
|
||||
if !input.bundled_skills_enabled {
|
||||
roots.retain(|root| root.scope != SkillScope::System);
|
||||
}
|
||||
@@ -118,12 +129,9 @@ impl SkillsManager {
|
||||
&self,
|
||||
input: &SkillsLoadInput,
|
||||
force_reload: bool,
|
||||
fs: Option<Arc<dyn ExecutorFileSystem>>,
|
||||
) -> SkillLoadOutcome {
|
||||
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(&input.cwd) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
self.skills_for_cwd_with_extra_user_roots(input, force_reload, &[])
|
||||
self.skills_for_cwd_with_extra_user_roots(input, force_reload, &[], fs)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -132,46 +140,56 @@ impl SkillsManager {
|
||||
input: &SkillsLoadInput,
|
||||
force_reload: bool,
|
||||
extra_user_roots: &[AbsolutePathBuf],
|
||||
fs: Option<Arc<dyn ExecutorFileSystem>>,
|
||||
) -> SkillLoadOutcome {
|
||||
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(&input.cwd) {
|
||||
let use_cwd_cache = fs.is_some();
|
||||
if use_cwd_cache
|
||||
&& !force_reload
|
||||
&& let Some(outcome) = self.cached_outcome_for_cwd(&input.cwd)
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
let normalized_extra_user_roots = normalize_extra_user_roots(extra_user_roots);
|
||||
|
||||
let mut roots = skill_roots(
|
||||
fs.clone(),
|
||||
&input.config_layer_stack,
|
||||
&input.cwd,
|
||||
input.effective_skill_roots.clone(),
|
||||
);
|
||||
)
|
||||
.await;
|
||||
if !bundled_skills_enabled_from_stack(&input.config_layer_stack) {
|
||||
roots.retain(|root| root.scope != SkillScope::System);
|
||||
}
|
||||
roots.extend(
|
||||
normalized_extra_user_roots
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
}),
|
||||
);
|
||||
if let Some(fs) = fs {
|
||||
roots.extend(
|
||||
normalize_extra_user_roots(extra_user_roots)
|
||||
.into_iter()
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&fs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack);
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
|
||||
let mut cache = self
|
||||
.cache_by_cwd
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache.insert(input.cwd.clone(), outcome.clone());
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules).await;
|
||||
if use_cwd_cache {
|
||||
let mut cache = self
|
||||
.cache_by_cwd
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache.insert(input.cwd.clone(), outcome.clone());
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
fn build_skill_outcome(
|
||||
async fn build_skill_outcome(
|
||||
&self,
|
||||
roots: Vec<SkillRoot>,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> SkillLoadOutcome {
|
||||
let outcome = crate::filter_skill_load_outcome_for_product(
|
||||
load_skills_from_roots(roots),
|
||||
load_skills_from_roots(roots).await,
|
||||
self.restriction_product,
|
||||
);
|
||||
let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules);
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
@@ -15,6 +16,7 @@ use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) {
|
||||
@@ -136,22 +138,21 @@ enabled = {enabled}
|
||||
)
|
||||
}
|
||||
|
||||
fn skills_for_config_with_stack(
|
||||
async fn skills_for_config_with_stack(
|
||||
skills_manager: &SkillsManager,
|
||||
cwd: &TempDir,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
effective_skill_roots: &[PathBuf],
|
||||
effective_skill_roots: &[AbsolutePathBuf],
|
||||
) -> SkillLoadOutcome {
|
||||
let skills_input = SkillsLoadInput::new(
|
||||
cwd.path().abs(),
|
||||
effective_skill_roots
|
||||
.iter()
|
||||
.map(codex_utils_absolute_path::test_support::PathBufExt::abs)
|
||||
.collect(),
|
||||
effective_skill_roots.to_vec(),
|
||||
config_layer_stack.clone(),
|
||||
bundled_skills_enabled_from_stack(config_layer_stack),
|
||||
);
|
||||
skills_manager.skills_for_config(&skills_input)
|
||||
skills_manager
|
||||
.skills_for_config(&skills_input, Some(Arc::clone(&LOCAL_FS)))
|
||||
.await
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -184,7 +185,8 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() {
|
||||
);
|
||||
|
||||
write_user_skill(&codex_home, "a", "skill-a", "from a");
|
||||
let outcome1 = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
let outcome1 =
|
||||
skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await;
|
||||
assert!(
|
||||
outcome1.skills.iter().any(|s| s.name == "skill-a"),
|
||||
"expected skill-a to be discovered"
|
||||
@@ -193,7 +195,8 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() {
|
||||
// Write a new skill after the first call; the second call should reuse the config-aware cache
|
||||
// entry because the effective skill config is unchanged.
|
||||
write_user_skill(&codex_home, "b", "skill-b", "from b");
|
||||
let outcome2 = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
let outcome2 =
|
||||
skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await;
|
||||
assert_eq!(outcome2.errors, outcome1.errors);
|
||||
assert_eq!(outcome2.skills, outcome1.skills);
|
||||
}
|
||||
@@ -218,7 +221,7 @@ async fn skills_for_config_disables_plugin_skills_by_name() {
|
||||
.parent()
|
||||
.and_then(std::path::Path::parent)
|
||||
.expect("plugin skill should live under a skills root")
|
||||
.to_path_buf();
|
||||
.abs();
|
||||
let skills_manager = SkillsManager::new(
|
||||
codex_home.path().abs(),
|
||||
/*bundled_skills_enabled*/ true,
|
||||
@@ -229,7 +232,8 @@ async fn skills_for_config_disables_plugin_skills_by_name() {
|
||||
&cwd,
|
||||
&config_layer_stack,
|
||||
&[plugin_skill_root],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
let skill = outcome
|
||||
.skills
|
||||
.iter()
|
||||
@@ -259,7 +263,7 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
|
||||
codex_home.path().abs(),
|
||||
/*bundled_skills_enabled*/ true,
|
||||
);
|
||||
let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await;
|
||||
|
||||
write_user_skill(&extra_root, "x", "extra-skill", "from extra root");
|
||||
let extra_root_path = extra_root.path().abs();
|
||||
@@ -274,6 +278,7 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
|
||||
&base_input,
|
||||
/*force_reload*/ true,
|
||||
std::slice::from_ref(&extra_root_path),
|
||||
Some(Arc::clone(&LOCAL_FS)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
@@ -298,12 +303,150 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
|
||||
bundled_skills_enabled_from_stack(&config_layer_stack),
|
||||
);
|
||||
let outcome_without_extra = skills_manager
|
||||
.skills_for_cwd(&base_input, /*force_reload*/ false)
|
||||
.skills_for_cwd(
|
||||
&base_input,
|
||||
/*force_reload*/ false,
|
||||
Some(Arc::clone(&LOCAL_FS)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(outcome_without_extra.skills, outcome_with_extra.skills);
|
||||
assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_cwd_loads_repo_user_and_extra_roots_with_local_fs() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let extra_root = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dot_codex = cwd.path().join(".codex");
|
||||
fs::create_dir_all(&repo_dot_codex).expect("create repo config dir");
|
||||
|
||||
write_user_skill(&codex_home, "user", "user-skill", "from local user root");
|
||||
write_user_skill(&extra_root, "extra", "extra-skill", "from extra root");
|
||||
let repo_skill_dir = repo_dot_codex.join("skills/repo");
|
||||
fs::create_dir_all(&repo_skill_dir).expect("create repo skill dir");
|
||||
fs::write(
|
||||
repo_skill_dir.join("SKILL.md"),
|
||||
"---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n",
|
||||
)
|
||||
.expect("write repo skill");
|
||||
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
vec![
|
||||
user_config_layer(&codex_home, ""),
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project {
|
||||
dot_codex_folder: repo_dot_codex.abs(),
|
||||
},
|
||||
toml::Value::Table(toml::map::Map::new()),
|
||||
),
|
||||
],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
let skills_input = SkillsLoadInput::new(
|
||||
cwd.path().abs(),
|
||||
Vec::new(),
|
||||
config_layer_stack.clone(),
|
||||
bundled_skills_enabled_from_stack(&config_layer_stack),
|
||||
);
|
||||
let skills_manager = SkillsManager::new(
|
||||
codex_home.path().abs(),
|
||||
/*bundled_skills_enabled*/ true,
|
||||
);
|
||||
|
||||
let outcome = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
&skills_input,
|
||||
/*force_reload*/ true,
|
||||
&[extra_root.path().abs()],
|
||||
Some(Arc::clone(&LOCAL_FS)),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
let loaded_names = outcome
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| skill.name.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
assert!(loaded_names.contains("user-skill"));
|
||||
assert!(loaded_names.contains("repo-skill"));
|
||||
assert!(loaded_names.contains("extra-skill"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_cwd_without_fs_skips_repo_and_extra_roots() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let extra_root = tempfile::tempdir().expect("tempdir");
|
||||
let repo_dot_codex = cwd.path().join(".codex");
|
||||
fs::create_dir_all(&repo_dot_codex).expect("create repo config dir");
|
||||
|
||||
write_user_skill(&codex_home, "user", "user-skill", "from local user root");
|
||||
write_user_skill(&extra_root, "extra", "extra-skill", "from extra root");
|
||||
let repo_skill_dir = repo_dot_codex.join("skills/repo");
|
||||
fs::create_dir_all(&repo_skill_dir).expect("create repo skill dir");
|
||||
fs::write(
|
||||
repo_skill_dir.join("SKILL.md"),
|
||||
"---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n",
|
||||
)
|
||||
.expect("write repo skill");
|
||||
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
vec![
|
||||
user_config_layer(&codex_home, ""),
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project {
|
||||
dot_codex_folder: repo_dot_codex.abs(),
|
||||
},
|
||||
toml::Value::Table(toml::map::Map::new()),
|
||||
),
|
||||
],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
let skills_input = SkillsLoadInput::new(
|
||||
cwd.path().abs(),
|
||||
Vec::new(),
|
||||
config_layer_stack.clone(),
|
||||
bundled_skills_enabled_from_stack(&config_layer_stack),
|
||||
);
|
||||
let skills_manager = SkillsManager::new(
|
||||
codex_home.path().abs(),
|
||||
/*bundled_skills_enabled*/ true,
|
||||
);
|
||||
|
||||
let outcome = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
&skills_input,
|
||||
/*force_reload*/ true,
|
||||
&[extra_root.path().abs()],
|
||||
/*fs*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
let loaded_names = outcome
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| skill.name.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
assert!(loaded_names.contains("user-skill"));
|
||||
assert!(!loaded_names.contains("repo-skill"));
|
||||
assert!(!loaded_names.contains("extra-skill"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
@@ -330,7 +473,8 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() {
|
||||
)
|
||||
.expect("rewrite bundled skill");
|
||||
|
||||
let outcome = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
let outcome =
|
||||
skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await;
|
||||
assert!(
|
||||
outcome
|
||||
.skills
|
||||
@@ -356,7 +500,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
|
||||
codex_home.path().abs(),
|
||||
/*bundled_skills_enabled*/ true,
|
||||
);
|
||||
let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
|
||||
let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await;
|
||||
|
||||
write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a");
|
||||
write_user_skill(&extra_root_b, "x", "extra-skill-b", "from extra root b");
|
||||
@@ -373,6 +517,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
|
||||
&base_input,
|
||||
/*force_reload*/ true,
|
||||
std::slice::from_ref(&extra_root_a_path),
|
||||
Some(Arc::clone(&LOCAL_FS)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
@@ -394,6 +539,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
|
||||
&base_input,
|
||||
/*force_reload*/ false,
|
||||
std::slice::from_ref(&extra_root_b_path),
|
||||
Some(Arc::clone(&LOCAL_FS)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
@@ -414,6 +560,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
|
||||
&base_input,
|
||||
/*force_reload*/ true,
|
||||
std::slice::from_ref(&extra_root_b_path),
|
||||
Some(Arc::clone(&LOCAL_FS)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
@@ -600,7 +747,11 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill()
|
||||
);
|
||||
|
||||
let parent_outcome = skills_manager
|
||||
.skills_for_cwd(&parent_input, /*force_reload*/ true)
|
||||
.skills_for_cwd(
|
||||
&parent_input,
|
||||
/*force_reload*/ true,
|
||||
Some(Arc::clone(&LOCAL_FS)),
|
||||
)
|
||||
.await;
|
||||
let parent_skill = parent_outcome
|
||||
.skills
|
||||
@@ -609,7 +760,8 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill()
|
||||
.expect("demo skill should be discovered");
|
||||
assert_eq!(parent_outcome.is_skill_enabled(parent_skill), false);
|
||||
|
||||
let child_outcome = skills_for_config_with_stack(&skills_manager, &cwd, &child_stack, &[]);
|
||||
let child_outcome =
|
||||
skills_for_config_with_stack(&skills_manager, &cwd, &child_stack, &[]).await;
|
||||
let child_skill = child_outcome
|
||||
.skills
|
||||
.iter()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -87,6 +89,7 @@ pub struct SkillLoadOutcome {
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
pub errors: Vec<SkillError>,
|
||||
pub disabled_paths: HashSet<AbsolutePathBuf>,
|
||||
pub(crate) file_systems_by_skill_path: SkillFileSystemsByPath,
|
||||
pub(crate) implicit_skills_by_scripts_dir: Arc<HashMap<AbsolutePathBuf, SkillMetadata>>,
|
||||
pub(crate) implicit_skills_by_doc_path: Arc<HashMap<AbsolutePathBuf, SkillMetadata>>,
|
||||
}
|
||||
@@ -113,6 +116,49 @@ impl SkillLoadOutcome {
|
||||
.iter()
|
||||
.map(|skill| (skill, self.is_skill_enabled(skill)))
|
||||
}
|
||||
|
||||
pub(crate) fn file_system_for_skill(
|
||||
&self,
|
||||
skill: &SkillMetadata,
|
||||
) -> Option<Arc<dyn ExecutorFileSystem>> {
|
||||
self.file_systems_by_skill_path
|
||||
.get(&skill.path_to_skills_md)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct SkillFileSystemsByPath {
|
||||
values: Arc<HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>>>,
|
||||
}
|
||||
|
||||
impl SkillFileSystemsByPath {
|
||||
pub(crate) fn new(values: HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>>) -> Self {
|
||||
Self {
|
||||
values: Arc::new(values),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&self, path: &AbsolutePathBuf) -> Option<Arc<dyn ExecutorFileSystem>> {
|
||||
self.values.get(path).map(Arc::clone)
|
||||
}
|
||||
|
||||
fn retain_paths(&mut self, paths: &HashSet<AbsolutePathBuf>) {
|
||||
self.values = Arc::new(
|
||||
self.values
|
||||
.iter()
|
||||
.filter(|(path, _)| paths.contains(*path))
|
||||
.map(|(path, fs)| (path.clone(), Arc::clone(fs)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for SkillFileSystemsByPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("SkillFileSystemsByPath")
|
||||
.field("len", &self.values.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_skill_load_outcome_for_product(
|
||||
@@ -122,6 +168,14 @@ pub fn filter_skill_load_outcome_for_product(
|
||||
outcome
|
||||
.skills
|
||||
.retain(|skill| skill.matches_product_restriction_for_product(restriction_product));
|
||||
let retained_paths: HashSet<AbsolutePathBuf> = outcome
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| skill.path_to_skills_md.clone())
|
||||
.collect();
|
||||
outcome
|
||||
.file_systems_by_skill_path
|
||||
.retain_paths(&retained_paths);
|
||||
outcome.implicit_skills_by_scripts_dir = Arc::new(
|
||||
outcome
|
||||
.implicit_skills_by_scripts_dir
|
||||
|
||||
@@ -658,7 +658,12 @@ enabled = false
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&config).await;
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
|
||||
let outcome = skills_manager.skills_for_config(&skills_input);
|
||||
let outcome = skills_manager
|
||||
.skills_for_config(
|
||||
&skills_input,
|
||||
Some(Arc::clone(&codex_exec_server::LOCAL_FS)),
|
||||
)
|
||||
.await;
|
||||
let skill = outcome
|
||||
.skills
|
||||
.iter()
|
||||
|
||||
@@ -496,10 +496,17 @@ impl Codex {
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
|
||||
let environment = environment_manager
|
||||
.current()
|
||||
.await
|
||||
.map_err(|err| CodexErr::Fatal(format!("failed to create environment: {err}")))?;
|
||||
let fs = environment
|
||||
.as_ref()
|
||||
.map(|environment| environment.get_filesystem());
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&config).await;
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
|
||||
let loaded_skills = skills_manager.skills_for_config(&skills_input);
|
||||
let loaded_skills = skills_manager.skills_for_config(&skills_input, fs).await;
|
||||
|
||||
for err in &loaded_skills.errors {
|
||||
error!(
|
||||
@@ -544,10 +551,6 @@ impl Codex {
|
||||
config.startup_warnings.push(message);
|
||||
}
|
||||
|
||||
let environment = environment_manager
|
||||
.current()
|
||||
.await
|
||||
.map_err(|err| CodexErr::Fatal(format!("failed to create environment: {err}")))?;
|
||||
let user_instructions = get_user_instructions(&config, environment.as_deref()).await;
|
||||
|
||||
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
|
||||
@@ -2670,10 +2673,16 @@ impl Session {
|
||||
.await;
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let fs = self
|
||||
.services
|
||||
.environment
|
||||
.as_ref()
|
||||
.map(|environment| environment.get_filesystem());
|
||||
let skills_outcome = Arc::new(
|
||||
self.services
|
||||
.skills_manager
|
||||
.skills_for_config(&skills_input),
|
||||
.skills_for_config(&skills_input, fs)
|
||||
.await,
|
||||
);
|
||||
let mut turn_context: TurnContext = Self::make_turn_context(
|
||||
self.conversation_id,
|
||||
@@ -5398,6 +5407,11 @@ mod handlers {
|
||||
|
||||
let skills_manager = &sess.services.skills_manager;
|
||||
let plugins_manager = &sess.services.plugins_manager;
|
||||
let fs = sess
|
||||
.services
|
||||
.environment
|
||||
.as_ref()
|
||||
.map(|environment| environment.get_filesystem());
|
||||
let config = sess.get_config().await;
|
||||
let codex_home = sess.codex_home().await;
|
||||
let mut skills = Vec::new();
|
||||
@@ -5454,7 +5468,7 @@ mod handlers {
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
let outcome = skills_manager
|
||||
.skills_for_cwd(&skills_input, force_reload)
|
||||
.skills_for_cwd(&skills_input, force_reload, fs.clone())
|
||||
.await;
|
||||
let errors = super::errors_to_info(&outcome.errors);
|
||||
let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths);
|
||||
@@ -6232,6 +6246,7 @@ pub(crate) async fn run_turn(
|
||||
warnings: skill_warnings,
|
||||
} = build_skill_injections(
|
||||
&mentioned_skills,
|
||||
skills_outcome,
|
||||
Some(&session_telemetry),
|
||||
&sess.services.analytics_events_client,
|
||||
tracking.clone(),
|
||||
|
||||
@@ -2450,12 +2450,19 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() {
|
||||
)
|
||||
.expect("write skill");
|
||||
|
||||
let skill_fs = session
|
||||
.services
|
||||
.environment
|
||||
.as_ref()
|
||||
.map(|environment| environment.get_filesystem())
|
||||
.unwrap_or_else(|| std::sync::Arc::clone(&codex_exec_server::LOCAL_FS));
|
||||
let parent_outcome = session
|
||||
.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(
|
||||
&crate::skills_load_input_from_config(&parent_config, Vec::new()),
|
||||
/*force_reload*/ true,
|
||||
Some(Arc::clone(&skill_fs)),
|
||||
)
|
||||
.await;
|
||||
let parent_skill = parent_outcome
|
||||
@@ -2845,7 +2852,13 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input =
|
||||
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input));
|
||||
let skill_fs = environment.get_filesystem();
|
||||
let skills_outcome = Arc::new(
|
||||
services
|
||||
.skills_manager
|
||||
.skills_for_config(&skills_input, Some(Arc::clone(&skill_fs)))
|
||||
.await,
|
||||
);
|
||||
let turn_context = Session::make_turn_context(
|
||||
conversation_id,
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
@@ -3691,7 +3704,13 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input =
|
||||
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input));
|
||||
let skill_fs = environment.get_filesystem();
|
||||
let skills_outcome = Arc::new(
|
||||
services
|
||||
.skills_manager
|
||||
.skills_for_config(&skills_input, Some(Arc::clone(&skill_fs)))
|
||||
.await,
|
||||
);
|
||||
let turn_context = Arc::new(Session::make_turn_context(
|
||||
conversation_id,
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
|
||||
@@ -252,6 +252,16 @@ pub struct WatchRegistration {
|
||||
watched_paths: Vec<WatchPath>,
|
||||
}
|
||||
|
||||
impl Default for WatchRegistration {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
file_watcher: std::sync::Weak::new(),
|
||||
subscriber_id: 0,
|
||||
watched_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WatchRegistration {
|
||||
fn drop(&mut self) {
|
||||
if let Some(file_watcher) = self.file_watcher.upgrade() {
|
||||
|
||||
@@ -46,6 +46,7 @@ use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
@@ -1031,7 +1032,8 @@ impl PluginsManager {
|
||||
manifest_paths,
|
||||
self.restriction_product,
|
||||
&skill_config_rules,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
let apps = load_apps_from_paths(
|
||||
source_path.as_path(),
|
||||
plugin_app_config_paths(source_path.as_path(), manifest_paths),
|
||||
@@ -1751,7 +1753,8 @@ async fn load_plugin(
|
||||
manifest_paths,
|
||||
restriction_product,
|
||||
skill_config_rules,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
let has_enabled_skills = resolved_skills.has_enabled_skills();
|
||||
loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths;
|
||||
loaded_plugin.has_enabled_skills = has_enabled_skills;
|
||||
@@ -1795,20 +1798,21 @@ impl ResolvedPluginSkills {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_plugin_skills(
|
||||
async fn load_plugin_skills(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
restriction_product: Option<Product>,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> ResolvedPluginSkills {
|
||||
let outcome = load_skills_from_roots(
|
||||
plugin_skill_roots(plugin_root, manifest_paths)
|
||||
.into_iter()
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
}),
|
||||
);
|
||||
let roots = plugin_skill_roots(plugin_root, manifest_paths)
|
||||
.into_iter()
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let outcome = load_skills_from_roots(roots).await;
|
||||
let had_errors = !outcome.errors.is_empty();
|
||||
let skills = outcome
|
||||
.skills
|
||||
|
||||
@@ -59,12 +59,14 @@ impl SkillsWatcher {
|
||||
config: &Config,
|
||||
skills_manager: &SkillsManager,
|
||||
plugins_manager: &PluginsManager,
|
||||
fs: Option<Arc<dyn codex_exec_server::ExecutorFileSystem>>,
|
||||
) -> WatchRegistration {
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(config).await;
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(config, effective_skill_roots);
|
||||
let roots = skills_manager
|
||||
.skill_roots_for_config(&skills_input)
|
||||
.skill_roots_for_config(&skills_input, fs)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|root| WatchPath {
|
||||
path: root.path.into_path_buf(),
|
||||
|
||||
@@ -365,6 +365,10 @@ impl ThreadManager {
|
||||
self.state.mcp_manager.clone()
|
||||
}
|
||||
|
||||
pub fn environment_manager(&self) -> Arc<EnvironmentManager> {
|
||||
self.state.environment_manager.clone()
|
||||
}
|
||||
|
||||
pub fn get_models_manager(&self) -> Arc<ModelsManager> {
|
||||
self.state.models_manager.clone()
|
||||
}
|
||||
@@ -903,14 +907,24 @@ impl ThreadManagerState {
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
user_shell_override: Option<crate::shell::Shell>,
|
||||
) -> CodexResult<NewThread> {
|
||||
let watch_registration = self
|
||||
.skills_watcher
|
||||
.register_config(
|
||||
&config,
|
||||
self.skills_manager.as_ref(),
|
||||
self.plugins_manager.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let environment = self
|
||||
.environment_manager
|
||||
.current()
|
||||
.await
|
||||
.map_err(|err| CodexErr::Fatal(format!("failed to create environment: {err}")))?;
|
||||
let watch_registration = match environment.as_ref() {
|
||||
Some(environment) if !environment.is_remote() => {
|
||||
self.skills_watcher
|
||||
.register_config(
|
||||
&config,
|
||||
self.skills_manager.as_ref(),
|
||||
self.plugins_manager.as_ref(),
|
||||
Some(environment.get_filesystem()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Some(_) | None => crate::file_watcher::WatchRegistration::default(),
|
||||
};
|
||||
let CodexSpawnOk {
|
||||
codex, thread_id, ..
|
||||
} = Codex::spawn(CodexSpawnArgs {
|
||||
|
||||
@@ -2,10 +2,19 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
@@ -14,16 +23,39 @@ use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_skill(home: &Path, name: &str, description: &str, body: &str) -> std::path::PathBuf {
|
||||
let skill_dir = home.join("skills").join(name);
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
async fn write_repo_skill(
|
||||
cwd: AbsolutePathBuf,
|
||||
fs: Arc<dyn ExecutorFileSystem>,
|
||||
name: &str,
|
||||
description: &str,
|
||||
body: &str,
|
||||
) -> Result<()> {
|
||||
let skill_dir = cwd.join(".agents").join("skills").join(name);
|
||||
fs.create_directory(
|
||||
&skill_dir,
|
||||
CreateDirectoryOptions { recursive: true },
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await?;
|
||||
let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n");
|
||||
let path = skill_dir.join("SKILL.md");
|
||||
fs::write(&path, contents).unwrap();
|
||||
path
|
||||
fs.write_file(&path, contents.into_bytes(), /*sandbox*/ None)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_home_skill(codex_home: &Path, dir: &str, name: &str, description: &str) -> Result<()> {
|
||||
let skill_dir = codex_home.join("skills").join(dir);
|
||||
fs::create_dir_all(&skill_dir)?;
|
||||
let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
fs::write(skill_dir.join("SKILL.md"), contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn system_skill_md_path(home: impl AsRef<Path>, name: &str) -> std::path::PathBuf {
|
||||
@@ -40,13 +72,18 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let skill_body = "skill body";
|
||||
let mut builder = test_codex().with_pre_build_hook(|home| {
|
||||
write_skill(home, "demo", "demo skill", skill_body);
|
||||
let mut builder = test_codex().with_workspace_setup(move |cwd, fs| async move {
|
||||
write_repo_skill(cwd, fs, "demo", "demo skill", skill_body).await
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let test = builder.build_remote_aware(&server).await?;
|
||||
|
||||
let skill_path = test.codex_home_path().join("skills/demo/SKILL.md");
|
||||
let skill_path = std::fs::canonicalize(skill_path)?;
|
||||
let skill_path = test
|
||||
.config
|
||||
.cwd
|
||||
.join(".agents/skills/demo/SKILL.md")
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| test.config.cwd.join(".agents/skills/demo/SKILL.md"))
|
||||
.to_path_buf();
|
||||
|
||||
let mock = mount_sse_once(
|
||||
&server,
|
||||
@@ -72,7 +109,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
|
||||
},
|
||||
],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
cwd: test.config.cwd.to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
@@ -106,6 +143,138 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn list_skills_includes_repo_and_home_skills_remote_aware() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
write_home_skill(home, "home-demo", "home-demo", "from home")
|
||||
.expect("write home skill");
|
||||
})
|
||||
.with_workspace_setup(|cwd, fs| async move {
|
||||
write_repo_skill(cwd, fs, "repo-demo", "from repo", "# Body").await
|
||||
});
|
||||
let test = builder.build_remote_aware(&server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::ListSkills {
|
||||
cwds: Vec::new(),
|
||||
force_reload: true,
|
||||
})
|
||||
.await?;
|
||||
let response =
|
||||
core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event {
|
||||
codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => {
|
||||
Some(response.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let cwd = test.config.cwd.as_path();
|
||||
let skills = response
|
||||
.skills
|
||||
.iter()
|
||||
.find(|entry| entry.cwd.as_path() == cwd)
|
||||
.map(|entry| entry.skills.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let repo_skill = skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == "repo-demo")
|
||||
.expect("expected repo skill");
|
||||
assert_eq!(repo_skill.scope, codex_protocol::protocol::SkillScope::Repo);
|
||||
let repo_path = repo_skill.path.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
repo_path.ends_with("/.agents/skills/repo-demo/SKILL.md"),
|
||||
"unexpected repo skill path: {repo_path}"
|
||||
);
|
||||
|
||||
let home_skill = skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == "home-demo")
|
||||
.expect("expected home skill");
|
||||
assert_eq!(home_skill.scope, codex_protocol::protocol::SkillScope::User);
|
||||
let home_path = home_skill.path.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
home_path.ends_with("/skills/home-demo/SKILL.md"),
|
||||
"unexpected home skill path: {home_path}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let cwd = TempDir::new()?;
|
||||
write_home_skill(
|
||||
codex_home.path(),
|
||||
"home-disabled",
|
||||
"home-disabled",
|
||||
"from home",
|
||||
)?;
|
||||
let repo_skill_dir = cwd
|
||||
.path()
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("repo-disabled");
|
||||
fs::create_dir_all(&repo_skill_dir)?;
|
||||
fs::write(
|
||||
repo_skill_dir.join("SKILL.md"),
|
||||
"---\nname: repo-disabled\ndescription: from repo\n---\n\n# Body\n",
|
||||
)?;
|
||||
let mut config = load_default_config_for_test(&codex_home).await;
|
||||
config.cwd = AbsolutePathBuf::from_absolute_path_checked(cwd.path())?;
|
||||
|
||||
let thread_manager = ThreadManager::new(
|
||||
&config,
|
||||
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
|
||||
SessionSource::Exec,
|
||||
CollaborationModesConfig::default(),
|
||||
Arc::new(EnvironmentManager::new(Some("none".to_string()))),
|
||||
/*analytics_events_client*/ None,
|
||||
);
|
||||
let new_thread = thread_manager.start_thread(config.clone()).await?;
|
||||
let cwd = config.cwd.to_path_buf();
|
||||
|
||||
new_thread
|
||||
.thread
|
||||
.submit(Op::ListSkills {
|
||||
cwds: vec![cwd.clone()],
|
||||
force_reload: true,
|
||||
})
|
||||
.await?;
|
||||
let response =
|
||||
core_test_support::wait_for_event_match(new_thread.thread.as_ref(), |event| match event {
|
||||
codex_protocol::protocol::EventMsg::ListSkillsResponse(response) => {
|
||||
Some(response.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(response.skills.len(), 1);
|
||||
assert_eq!(response.skills[0].cwd, cwd);
|
||||
assert_eq!(response.skills[0].errors.len(), 0);
|
||||
assert!(
|
||||
response.skills[0]
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.name == "home-disabled")
|
||||
);
|
||||
assert!(
|
||||
response.skills[0]
|
||||
.skills
|
||||
.iter()
|
||||
.all(|skill| skill.name != "repo-disabled")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn skill_load_errors_surface_in_session_configured() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -14,9 +14,12 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
/// Relative path from a plugin root to its manifest file.
|
||||
pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
@@ -13,12 +13,19 @@ struct RawPluginManifestName {
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn plugin_manifest_name(plugin_root: &Path) -> Option<String> {
|
||||
async fn plugin_manifest_name(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
) -> Option<String> {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return None;
|
||||
match fs.get_metadata(&manifest_path, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_file => {}
|
||||
Ok(_) | Err(_) => return None,
|
||||
}
|
||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
||||
let contents = fs
|
||||
.read_file_text(&manifest_path, /*sandbox*/ None)
|
||||
.await
|
||||
.ok()?;
|
||||
let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?;
|
||||
Some(
|
||||
plugin_root
|
||||
@@ -32,9 +39,12 @@ fn plugin_manifest_name(plugin_root: &Path) -> Option<String> {
|
||||
|
||||
/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid
|
||||
/// plugin manifest (same `name` rules as full manifest loading in codex-core).
|
||||
pub fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
|
||||
pub async fn plugin_namespace_for_skill_path(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
) -> Option<String> {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(name) = plugin_manifest_name(ancestor) {
|
||||
if let Some(name) = plugin_manifest_name(fs, &ancestor).await {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
@@ -44,11 +54,13 @@ pub fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::plugin_namespace_for_skill_path;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn uses_manifest_name() {
|
||||
#[tokio::test]
|
||||
async fn uses_manifest_name() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("plugins/sample");
|
||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||
@@ -63,7 +75,7 @@ mod tests {
|
||||
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
|
||||
|
||||
assert_eq!(
|
||||
plugin_namespace_for_skill_path(&skill_path),
|
||||
plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
|
||||
Some("sample".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user