Compare commits

...

16 Commits

Author SHA1 Message Date
starr-openai
5fb05f5e90 codex: fix CI failure on PR #24992 2026-05-28 22:31:07 -07:00
starr-openai
8bfbedcd21 codex: fix CI failure on PR #24992 2026-05-28 22:18:27 -07:00
starr-openai
2f7cbea49a codex: fix rebased skills roots test 2026-05-28 22:10:23 -07:00
starr-openai
a436b1ce6d codex: update skills list schema fixtures 2026-05-28 22:09:45 -07:00
starr-openai
fefc6e139c codex: update disabled local skills test 2026-05-28 22:09:45 -07:00
starr-openai
06182c277b codex: clarify turn skill fs routing 2026-05-28 22:09:44 -07:00
starr-openai
f83aa1c92d codex: cover skills without local fs 2026-05-28 22:09:44 -07:00
starr-openai
e657d066ed codex: simplify local skill fs comment 2026-05-28 22:09:44 -07:00
starr-openai
908d1fab06 codex: clarify skill root routing comments 2026-05-28 22:09:44 -07:00
starr-openai
6059a80c20 codex: document local skill path choices 2026-05-28 22:09:44 -07:00
starr-openai
929716f185 codex: use public environment path ref 2026-05-28 22:09:43 -07:00
starr-openai
3ac60316a8 codex: fix environment path ref clippy 2026-05-28 22:09:43 -07:00
starr-openai
6170e5b310 codex: fix skills path ref compile errors 2026-05-28 22:09:43 -07:00
starr-openai
f93f3d3f39 codex: preserve local skill root authority 2026-05-28 22:09:43 -07:00
starr-openai
a4db9b34d4 codex: simplify environment path refs 2026-05-28 22:09:19 -07:00
starr-openai
be7728da60 Move skills path refs into exec server 2026-05-28 22:08:58 -07:00
29 changed files with 1028 additions and 363 deletions

View File

@@ -3011,6 +3011,13 @@
},
"type": "array"
},
"environmentId": {
"description": "Environment whose filesystem should be used for all requested cwd values.",
"type": [
"string",
"null"
]
},
"forceReload": {
"description": "When true, bypass the skills cache and re-scan skills from disk.",
"type": "boolean"

View File

@@ -15201,6 +15201,13 @@
},
"type": "array"
},
"environmentId": {
"description": "Environment whose filesystem should be used for all requested cwd values.",
"type": [
"string",
"null"
]
},
"forceReload": {
"description": "When true, bypass the skills cache and re-scan skills from disk.",
"type": "boolean"

View File

@@ -13025,6 +13025,13 @@
},
"type": "array"
},
"environmentId": {
"description": "Environment whose filesystem should be used for all requested cwd values.",
"type": [
"string",
"null"
]
},
"forceReload": {
"description": "When true, bypass the skills cache and re-scan skills from disk.",
"type": "boolean"

View File

@@ -8,6 +8,13 @@
},
"type": "array"
},
"environmentId": {
"description": "Environment whose filesystem should be used for all requested cwd values.",
"type": [
"string",
"null"
]
},
"forceReload": {
"description": "When true, bypass the skills cache and re-scan skills from disk.",
"type": "boolean"

View File

@@ -7,6 +7,10 @@ export type SkillsListParams = {
* When empty, defaults to the current session working directory.
*/
cwds?: Array<string>,
/**
* Environment whose filesystem should be used for all requested cwd values.
*/
environmentId?: string | null,
/**
* When true, bypass the skills cache and re-scan skills from disk.
*/

View File

@@ -1718,6 +1718,7 @@ mod tests {
request_id: request_id(),
params: v2::SkillsListParams {
cwds: Vec::new(),
environment_id: None,
force_reload: false,
},
};

View File

@@ -23,6 +23,10 @@ pub struct SkillsListParams {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cwds: Vec<PathBuf>,
/// Environment whose filesystem should be used for all requested cwd values.
#[ts(optional = nullable)]
pub environment_id: Option<String>,
/// When true, bypass the skills cache and re-scan skills from disk.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub force_reload: bool,

View File

@@ -2607,23 +2607,41 @@ fn skills_list_params_serialization_uses_force_reload() {
assert_eq!(
serde_json::to_value(SkillsListParams {
cwds: Vec::new(),
environment_id: None,
force_reload: false,
})
.unwrap(),
json!({}),
json!({
"environmentId": null,
}),
);
assert_eq!(
serde_json::to_value(SkillsListParams {
cwds: vec![PathBuf::from("/repo")],
environment_id: None,
force_reload: true,
})
.unwrap(),
json!({
"cwds": ["/repo"],
"environmentId": null,
"forceReload": true,
}),
);
assert_eq!(
serde_json::to_value(SkillsListParams {
cwds: vec![PathBuf::from("/repo")],
environment_id: Some("devbox".to_string()),
force_reload: false,
})
.unwrap(),
json!({
"cwds": ["/repo"],
"environmentId": "devbox",
}),
);
}
#[test]

View File

@@ -1504,13 +1504,14 @@ Example:
$skill-creator Add a new skill for triaging flaky CI and include step-by-step usage.
```
Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`).
Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, `environmentId`, and `forceReload`).
`skills/list` might reuse a cached skills result per `cwd`; setting `forceReload` to `true` refreshes the result from disk.
The server also emits `skills/changed` notifications when watched local skill files change. Treat this as an invalidation signal and re-run `skills/list` with your current params when needed.
Use `skills/extraRoots/set` to replace additional standalone skill roots for the current app-server process. These roots use the same layout as other standalone skill roots: each root contains skill directories, and each skill directory contains `SKILL.md`. Missing roots are accepted and load no skills until they exist. This setting is lost when app-server exits.
```json
{ "method": "skills/list", "id": 25, "params": {
"environmentId": "local",
"cwds": ["/Users/me/project", "/Users/me/other-project"],
"forceReload": true
} }

View File

@@ -499,7 +499,11 @@ impl CatalogRequestProcessor {
&self,
params: SkillsListParams,
) -> Result<SkillsListResponse, JSONRPCErrorError> {
let SkillsListParams { cwds, force_reload } = params;
let SkillsListParams {
cwds,
environment_id,
force_reload,
} = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.to_path_buf()]
} else {
@@ -513,15 +517,28 @@ impl CatalogRequestProcessor {
.await;
let skills_manager = self.thread_manager.skills_manager();
let plugins_manager = self.thread_manager.plugins_manager();
let fs = self
.thread_manager
.environment_manager()
.default_environment()
let environment_manager = self.thread_manager.environment_manager();
let selected_environment = match environment_id {
Some(environment_id) => {
let environment = environment_manager
.get_environment(&environment_id)
.ok_or_else(|| {
invalid_request(format!(
"unknown skills/list environment id `{environment_id}`"
))
})?;
Some(environment)
}
None => environment_manager.try_local_environment(),
};
let local_file_system = environment_manager
.try_local_environment()
.map(|environment| environment.get_filesystem());
let mut data = futures::stream::iter(cwds.into_iter().enumerate())
.map(|(index, cwd)| {
let config = &config;
let fs = fs.clone();
let local_file_system = local_file_system.clone();
let selected_environment = selected_environment.clone();
let plugins_manager = &plugins_manager;
let skills_manager = &skills_manager;
async move {
@@ -553,14 +570,31 @@ impl CatalogRequestProcessor {
} else {
Vec::new()
};
let Some(environment) = selected_environment.as_ref() else {
// Omitted `environmentId` uses the legacy implicit local target. When
// local exec is disabled there is no valid implicit target, so return an
// empty catalog rather than silently switching to the default environment.
return (
index,
codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: Vec::new(),
},
);
};
let skills_input = codex_core::skills::SkillsLoadInput::new(
cwd_abs.clone(),
codex_exec_server::EnvironmentPathRef::new(
environment.get_filesystem(),
cwd_abs.clone(),
),
local_file_system,
effective_skill_roots,
config_layer_stack,
config.bundled_skills_enabled(),
);
let outcome = skills_manager
.skills_for_cwd(&skills_input, force_reload, fs)
.skills_for_cwd(&skills_input, force_reload)
.await;
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths);

View File

@@ -9,6 +9,7 @@ use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::skills::SkillsLoadInput;
use codex_core::skills::SkillsManager;
use codex_exec_server::EnvironmentPathRef;
use codex_file_watcher::FileWatcher;
use codex_file_watcher::FileWatcherSubscriber;
use codex_file_watcher::Receiver;
@@ -100,22 +101,29 @@ impl SkillsWatcher {
return WatchRegistration::default();
}
let root_path_ref =
EnvironmentPathRef::new(environment.get_filesystem(), config.cwd.clone());
let local_file_system = thread_manager
.environment_manager()
.try_local_environment()
.map(|environment| environment.get_filesystem());
let plugins_input = config.plugins_config_input();
let plugins_manager = thread_manager.plugins_manager();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let skills_input = SkillsLoadInput::new(
config.cwd.clone(),
root_path_ref,
local_file_system,
plugin_outcome.effective_plugin_skill_roots(),
config.config_layer_stack.clone(),
config.bundled_skills_enabled(),
);
let roots = thread_manager
.skills_manager()
.skill_roots_for_config(&skills_input, Some(environment.get_filesystem()))
.skill_roots_for_config(&skills_input)
.await
.into_iter()
.map(|root| WatchPath {
path: root.path.into_path_buf(),
path: root.path.path().to_path_buf(),
recursive: true,
})
.collect();

View File

@@ -20,6 +20,7 @@ use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_config::types::AuthCredentialsStoreMode;
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -248,6 +249,7 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result
let stale_skills_list_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: true,
})
.await?;
@@ -298,6 +300,7 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result
let skills_list_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: false,
})
.await?;
@@ -372,6 +375,7 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![repo_root.path().to_path_buf()],
environment_id: None,
force_reload: true,
})
.await?;
@@ -401,7 +405,7 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable
}
#[tokio::test]
async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> {
async fn skills_list_returns_empty_when_implicit_local_environment_is_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
write_skill(&codex_home, "home-skill")?;
@@ -422,6 +426,7 @@ async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> {
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: true,
})
.await?;
@@ -435,18 +440,7 @@ async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> {
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 != "repo-skill")
);
assert_eq!(data[0].skills, Vec::new());
Ok(())
}
@@ -462,6 +456,7 @@ async fn skills_list_accepts_relative_cwds() -> Result<()> {
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![relative_cwd.clone()],
environment_id: Some(LOCAL_ENVIRONMENT_ID.to_string()),
force_reload: true,
})
.await?;
@@ -493,6 +488,7 @@ async fn skills_list_preserves_requested_cwd_order() -> Result<()> {
first_cwd.path().to_path_buf(),
second_cwd.path().to_path_buf(),
],
environment_id: None,
force_reload: true,
})
.await?;
@@ -527,6 +523,7 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> {
let first_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: false,
})
.await?;
@@ -554,6 +551,7 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> {
let second_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: false,
})
.await?;
@@ -574,6 +572,7 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> {
let third_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: true,
})
.await?;
@@ -625,6 +624,7 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> {
let skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: false,
})
.await?;
@@ -660,6 +660,7 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> {
let skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: false,
})
.await?;
@@ -693,6 +694,7 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> {
let skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: false,
})
.await?;
@@ -717,6 +719,7 @@ async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> {
let skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![cwd.path().to_path_buf()],
environment_id: None,
force_reload: false,
})
.await?;
@@ -755,6 +758,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
let initial_skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![codex_home.path().to_path_buf()],
environment_id: None,
force_reload: true,
})
.await?;
@@ -828,6 +832,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
let updated_skills_request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![codex_home.path().to_path_buf()],
environment_id: None,
force_reload: false,
})
.await?;

View File

@@ -19,7 +19,7 @@ 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::SkillRoot;
use codex_core_skills::loader::load_skills_from_roots;
use codex_exec_server::LOCAL_FS;
use codex_exec_server::EnvironmentPathRef;
use codex_plugin::AppConnectorId;
use codex_plugin::LoadedPlugin;
use codex_plugin::PluginCapabilitySummary;
@@ -40,7 +40,6 @@ use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use tempfile::TempDir;
use tracing::warn;
@@ -564,8 +563,10 @@ async fn load_plugin(
.or_else(|| Some(manifest.name.clone()));
loaded_plugin.manifest_description = manifest.description.clone();
loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths);
// TODO: Support multi-env plugin skill loading. Plugin discovery is local-only today, so bind
// plugin skill reads to the local environment instead of the selected turn environment.
let resolved_skills = load_plugin_skills(
&plugin_root,
EnvironmentPathRef::local(plugin_root.clone()),
&loaded_plugin_id,
manifest_paths,
restriction_product,
@@ -642,18 +643,17 @@ impl ResolvedPluginSkills {
}
pub async fn load_plugin_skills(
plugin_root: &AbsolutePathBuf,
plugin_root: EnvironmentPathRef,
plugin_id: &PluginId,
manifest_paths: &PluginManifestPaths,
restriction_product: Option<Product>,
skill_config_rules: &SkillConfigRules,
) -> ResolvedPluginSkills {
let roots = plugin_skill_roots(plugin_root, manifest_paths)
let roots = plugin_skill_roots(plugin_root.path(), manifest_paths)
.into_iter()
.map(|path| SkillRoot {
path,
path: plugin_root.with_path(path),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: Some(plugin_id.as_key()),
plugin_root: Some(plugin_root.clone()),
})

View File

@@ -1419,8 +1419,10 @@ impl PluginsManager {
manifest.interface.clone(),
marketplace_category,
);
// TODO: Support multi-env plugin skill loading. Marketplace plugins are installed and
// scanned locally today, so bind plugin skill reads to the local environment.
let resolved_skills = load_plugin_skills(
&source_path,
codex_exec_server::EnvironmentPathRef::local(source_path.clone()),
&plugin_id,
&manifest.paths,
self.restriction_product,

View File

@@ -13,8 +13,8 @@ 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::EnvironmentPathRef;
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;
@@ -151,11 +151,10 @@ impl fmt::Display for SkillParseError {
impl Error for SkillParseError {}
pub struct SkillRoot {
pub path: AbsolutePathBuf,
pub path: EnvironmentPathRef,
pub scope: SkillScope,
pub file_system: Arc<dyn ExecutorFileSystem>,
pub plugin_id: Option<String>,
pub plugin_root: Option<AbsolutePathBuf>,
pub plugin_root: Option<EnvironmentPathRef>,
}
pub async fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
@@ -168,12 +167,12 @@ where
let mut file_systems_by_skill_path: HashMap<AbsolutePathBuf, Arc<dyn ExecutorFileSystem>> =
HashMap::new();
for root in roots {
let root_path = canonicalize_for_skill_identity(&root.path);
let fs = root.file_system;
let root_path = canonicalize_for_skill_identity(root.path.path());
let root_path_ref = root.path.with_path(root_path.clone());
let fs = root_path_ref.file_system();
let skills_before_root = outcome.skills.len();
discover_skills_under_root(
fs.as_ref(),
&root_path,
&root_path_ref,
root.scope,
root.plugin_id.as_deref(),
root.plugin_root.as_ref(),
@@ -231,18 +230,21 @@ where
}
pub(crate) async fn skill_roots(
fs: Option<Arc<dyn ExecutorFileSystem>>,
env_path: &EnvironmentPathRef,
local_file_system: Option<&Arc<dyn ExecutorFileSystem>>,
config_layer_stack: &ConfigLayerStack,
cwd: &AbsolutePathBuf,
plugin_skill_roots: Vec<PluginSkillRoot>,
extra_skill_roots: Vec<AbsolutePathBuf>,
) -> Vec<SkillRoot> {
// `env_path` owns workspace/repo-relative skill discovery for the selected environment.
// `local_file_system` owns client-local system/user/plugin roots and is absent when local exec
// is disabled, in which case those local roots intentionally do not load.
let home_dir =
home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok());
skill_roots_with_home_dir(
fs,
env_path,
local_file_system,
config_layer_stack,
cwd,
home_dir.as_ref(),
plugin_skill_roots,
extra_skill_roots,
@@ -251,38 +253,43 @@ pub(crate) async fn skill_roots(
}
async fn skill_roots_with_home_dir(
fs: Option<Arc<dyn ExecutorFileSystem>>,
env_path: &EnvironmentPathRef,
local_file_system: Option<&Arc<dyn ExecutorFileSystem>>,
config_layer_stack: &ConfigLayerStack,
cwd: &AbsolutePathBuf,
home_dir: Option<&AbsolutePathBuf>,
plugin_skill_roots: Vec<PluginSkillRoot>,
extra_skill_roots: Vec<AbsolutePathBuf>,
) -> Vec<SkillRoot> {
let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir, fs.clone());
roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot {
path: root.path,
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: Some(root.plugin_id),
plugin_root: Some(root.plugin_root),
}));
roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot {
path,
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
}));
roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await);
// Assemble one precedence-ordered root list from the two authorities above, then dedupe before
// any reads happen so downstream loading stays oblivious to local-vs-selected-env routing.
let mut roots = repo_config_skill_roots(env_path, config_layer_stack);
roots.extend(local_skill_roots(
local_file_system,
config_layer_stack,
home_dir,
plugin_skill_roots,
extra_skill_roots,
));
roots.extend(repo_agents_skill_roots(env_path, config_layer_stack).await);
dedupe_skill_roots_by_path(&mut roots);
roots
}
fn skill_roots_from_layer_stack_inner(
fn local_skill_roots(
local_file_system: Option<&Arc<dyn ExecutorFileSystem>>,
config_layer_stack: &ConfigLayerStack,
home_dir: Option<&AbsolutePathBuf>,
repo_fs: Option<Arc<dyn ExecutorFileSystem>>,
plugin_skill_roots: Vec<PluginSkillRoot>,
extra_skill_roots: Vec<AbsolutePathBuf>,
) -> Vec<SkillRoot> {
// These roots are absolute paths on the client machine, not paths inside the selected
// workspace environment. Bind them to the available local exec filesystem so remote turns
// keep loading local installed/bundled/plugin skills without reading those paths remotely.
let Some(local_file_system) = local_file_system else {
// Local exec can be disabled. In that case, keep repo/env skill loading intact but skip
// local absolute roots instead of falling back to the process-global local filesystem.
return Vec::new();
};
let mut roots = Vec::new();
for layer in config_layer_stack.get_layers(
@@ -294,24 +301,17 @@ fn skill_roots_from_layer_stack_inner(
};
match &layer.name {
ConfigLayerSource::Project { .. } => {
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),
plugin_id: None,
plugin_root: None,
});
}
}
ConfigLayerSource::Project { .. } => {}
ConfigLayerSource::User { .. } => {
// Deprecated user skills location (`$CODEX_HOME/skills`), kept for backward
// compatibility.
// compatibility. These are client-local absolute paths even when the active repo
// environment is remote, so bind them to the available local filesystem only.
roots.push(SkillRoot {
path: config_folder.join(SKILLS_DIR_NAME),
path: EnvironmentPathRef::new(
Arc::clone(local_file_system),
config_folder.join(SKILLS_DIR_NAME),
),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
});
@@ -319,9 +319,11 @@ fn skill_roots_from_layer_stack_inner(
// `$HOME/.agents/skills` (user-installed skills).
if let Some(home_dir) = home_dir {
roots.push(SkillRoot {
path: home_dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
path: EnvironmentPathRef::new(
Arc::clone(local_file_system),
home_dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
});
@@ -330,9 +332,11 @@ fn skill_roots_from_layer_stack_inner(
// Embedded system skills are cached under `$CODEX_HOME/skills/.system` and are a
// special case (not a config layer).
roots.push(SkillRoot {
path: system_cache_root_dir(&config_folder),
path: EnvironmentPathRef::new(
Arc::clone(local_file_system),
system_cache_root_dir(&config_folder),
),
scope: SkillScope::System,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
});
@@ -341,9 +345,11 @@ fn skill_roots_from_layer_stack_inner(
// The system config layer lives under `/etc/codex/` on Unix, so treat
// `/etc/codex/skills` as admin-scoped skills.
roots.push(SkillRoot {
path: config_folder.join(SKILLS_DIR_NAME),
path: EnvironmentPathRef::new(
Arc::clone(local_file_system),
config_folder.join(SKILLS_DIR_NAME),
),
scope: SkillScope::Admin,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
});
@@ -355,28 +361,77 @@ fn skill_roots_from_layer_stack_inner(
}
}
roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot {
// Plugin discovery is currently local-only; multi-env plugin skill loading should be an
// explicit future change rather than inheriting the selected repo environment here.
path: EnvironmentPathRef::new(Arc::clone(local_file_system), root.path),
scope: SkillScope::User,
plugin_id: Some(root.plugin_id),
plugin_root: Some(EnvironmentPathRef::new(
Arc::clone(local_file_system),
root.plugin_root,
)),
}));
roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot {
path: EnvironmentPathRef::new(Arc::clone(local_file_system), path),
scope: SkillScope::User,
plugin_id: None,
plugin_root: None,
}));
roots
}
async fn repo_agents_skill_roots(
fs: Option<Arc<dyn ExecutorFileSystem>>,
fn repo_config_skill_roots(
env_path: &EnvironmentPathRef,
config_layer_stack: &ConfigLayerStack,
cwd: &AbsolutePathBuf,
) -> Vec<SkillRoot> {
let Some(fs) = fs else {
return Vec::new();
};
// Project config layers describe workspace-local `.codex/skills` directories. Their absolute
// paths only make sense inside the selected environment, so keep them bound to `env_path`
// rather than the optional local filesystem used for client-local system/user/plugin roots.
config_layer_stack
.get_layers(
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.filter_map(|layer| match &layer.name {
ConfigLayerSource::Project { .. } => layer.config_folder().map(|config_folder| {
SkillRoot {
// Project and repo `.agents` roots belong to the selected environment because
// their absolute paths are only meaningful within that environment's cwd/repo.
path: env_path.with_path(config_folder.join(SKILLS_DIR_NAME)),
scope: SkillScope::Repo,
plugin_id: None,
plugin_root: None,
}
}),
ConfigLayerSource::User { .. }
| ConfigLayerSource::System { .. }
| ConfigLayerSource::Mdm { .. }
| ConfigLayerSource::SessionFlags
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
})
.collect()
}
async fn repo_agents_skill_roots(
env_path: &EnvironmentPathRef,
config_layer_stack: &ConfigLayerStack,
) -> Vec<SkillRoot> {
// Discover repo `.agents/skills` folders by walking from the selected environment's project
// root to its cwd; this must never consult the local filesystem for remote workspaces.
let project_root_markers = project_root_markers_from_stack(config_layer_stack);
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 project_root = find_project_root(env_path, &project_root_markers).await;
let dirs = dirs_between_project_root_and_cwd(env_path.path(), project_root.path());
let mut roots = Vec::new();
for dir in dirs {
let agents_skills = dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME);
match fs.get_metadata(&agents_skills, /*sandbox*/ None).await {
let agents_skills = env_path.with_path(dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME));
match agents_skills.metadata(/*sandbox*/ None).await {
Ok(metadata) if metadata.is_directory => roots.push(SkillRoot {
path: agents_skills,
scope: SkillScope::Repo,
file_system: Arc::clone(&fs),
plugin_id: None,
plugin_root: None,
}),
@@ -385,7 +440,7 @@ async fn repo_agents_skill_roots(
Err(err) => {
tracing::warn!(
"failed to stat repo skills root {}: {err:#}",
agents_skills.display()
agents_skills.path().display()
);
}
}
@@ -416,24 +471,23 @@ fn project_root_markers_from_stack(config_layer_stack: &ConfigLayerStack) -> Vec
}
async fn find_project_root(
fs: &dyn ExecutorFileSystem,
cwd: &AbsolutePathBuf,
cwd: &EnvironmentPathRef,
project_root_markers: &[String],
) -> AbsolutePathBuf {
) -> EnvironmentPathRef {
if project_root_markers.is_empty() {
return cwd.clone();
}
for ancestor in cwd.ancestors() {
for ancestor in cwd.path().ancestors() {
for marker in project_root_markers {
let marker_path = ancestor.join(marker);
match fs.get_metadata(&marker_path, /*sandbox*/ None).await {
Ok(_) => return ancestor,
let marker_path = cwd.with_path(ancestor.join(marker));
match marker_path.metadata(/*sandbox*/ None).await {
Ok(_) => return cwd.with_path(ancestor),
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => {
tracing::warn!(
"failed to stat project root marker {}: {err:#}",
marker_path.display()
marker_path.path().display()
);
}
}
@@ -465,7 +519,7 @@ fn dirs_between_project_root_and_cwd(
}
fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) {
let mut seen: HashSet<AbsolutePathBuf> = HashSet::new();
let mut seen: HashSet<EnvironmentPathRef> = HashSet::new();
roots.retain(|root| seen.insert(root.path.clone()));
}
@@ -474,31 +528,35 @@ fn canonicalize_for_skill_identity(path: &AbsolutePathBuf) -> AbsolutePathBuf {
}
async fn discover_skills_under_root(
fs: &dyn ExecutorFileSystem,
root: &AbsolutePathBuf,
root: &EnvironmentPathRef,
scope: SkillScope,
plugin_id: Option<&str>,
plugin_root: Option<&AbsolutePathBuf>,
plugin_root: Option<&EnvironmentPathRef>,
outcome: &mut SkillLoadOutcome,
) {
let root = canonicalize_for_skill_identity(root);
let plugin_root = plugin_root.map(canonicalize_for_skill_identity);
let root = root.with_path(canonicalize_for_skill_identity(root.path()));
let plugin_root = plugin_root.map(|plugin_root| {
plugin_root.with_path(canonicalize_for_skill_identity(plugin_root.path()))
});
match fs.get_metadata(&root, /*sandbox*/ None).await {
match root.metadata(/*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());
error!(
"failed to stat skills root {}: {err:#}",
root.path().display()
);
return;
}
}
fn enqueue_dir(
queue: &mut VecDeque<(AbsolutePathBuf, usize)>,
queue: &mut VecDeque<(EnvironmentPathRef, usize)>,
visited_dirs: &mut HashSet<AbsolutePathBuf>,
truncated_by_dir_limit: &mut bool,
path: AbsolutePathBuf,
path: EnvironmentPathRef,
depth: usize,
) {
if depth > MAX_SCAN_DEPTH {
@@ -508,7 +566,7 @@ async fn discover_skills_under_root(
*truncated_by_dir_limit = true;
return;
}
if visited_dirs.insert(path.clone()) {
if visited_dirs.insert(path.path().clone()) {
queue.push_back((path, depth));
}
}
@@ -520,16 +578,16 @@ async fn discover_skills_under_root(
);
let mut visited_dirs: HashSet<AbsolutePathBuf> = HashSet::new();
visited_dirs.insert(root.clone());
visited_dirs.insert(root.path().clone());
let mut queue: VecDeque<(AbsolutePathBuf, usize)> = VecDeque::from([(root.clone(), 0)]);
let mut queue: VecDeque<(EnvironmentPathRef, usize)> = VecDeque::from([(root.clone(), 0)]);
let mut truncated_by_dir_limit = false;
while let Some((dir, depth)) = queue.pop_front() {
let entries = match fs.read_directory(&dir, /*sandbox*/ None).await {
let entries = match dir.read_directory(/*sandbox*/ None).await {
Ok(entries) => entries,
Err(e) => {
error!("failed to read skills dir {}: {e:#}", dir.display());
error!("failed to read skills dir {}: {e:#}", dir.path().display());
continue;
}
};
@@ -540,11 +598,16 @@ async fn discover_skills_under_root(
continue;
}
let path = dir.join(&file_name);
let metadata = match fs.get_metadata(&path, /*sandbox*/ None).await {
let Some(path) = dir.join_relative(Path::new(&file_name)) else {
continue;
};
let metadata = match path.metadata(/*sandbox*/ None).await {
Ok(metadata) => metadata,
Err(e) => {
error!("failed to stat skills path {}: {e:#}", path.display());
error!(
"failed to stat skills path {}: {e:#}",
path.path().display()
);
continue;
}
};
@@ -553,9 +616,10 @@ async fn discover_skills_under_root(
if !follow_symlinks {
continue;
}
match fs.read_directory(&path, /*sandbox*/ None).await {
match path.read_directory(/*sandbox*/ None).await {
Ok(_) => {
let resolved_dir = canonicalize_for_skill_identity(&path);
let resolved_dir =
path.with_path(canonicalize_for_skill_identity(path.path()));
enqueue_dir(
&mut queue,
&mut visited_dirs,
@@ -572,7 +636,7 @@ async fn discover_skills_under_root(
Err(err) => {
error!(
"failed to read skills symlink dir {}: {err:#}",
path.display()
path.path().display()
);
}
}
@@ -580,7 +644,7 @@ async fn discover_skills_under_root(
}
if metadata.is_directory {
let resolved_dir = canonicalize_for_skill_identity(&path);
let resolved_dir = path.with_path(canonicalize_for_skill_identity(path.path()));
enqueue_dir(
&mut queue,
&mut visited_dirs,
@@ -592,14 +656,14 @@ async fn discover_skills_under_root(
}
if metadata.is_file && file_name == SKILLS_FILENAME {
match parse_skill_file(fs, &path, scope, plugin_id, plugin_root.as_ref()).await {
match parse_skill_file(&path, scope, plugin_id, plugin_root.as_ref()).await {
Ok(skill) => {
outcome.skills.push(skill);
}
Err(err) => {
if scope != SkillScope::System {
outcome.errors.push(SkillError {
path: path.clone(),
path: path.path().clone(),
message: err.to_string(),
});
}
@@ -613,22 +677,22 @@ async fn discover_skills_under_root(
tracing::warn!(
"skills scan truncated after {} directories (root: {})",
MAX_SKILLS_DIRS_PER_ROOT,
root.display()
root.path().display()
);
}
}
async fn parse_skill_file(
fs: &dyn ExecutorFileSystem,
path: &AbsolutePathBuf,
skill_path: &EnvironmentPathRef,
scope: SkillScope,
plugin_id: Option<&str>,
plugin_root: Option<&AbsolutePathBuf>,
plugin_root: Option<&EnvironmentPathRef>,
) -> Result<SkillMetadata, SkillParseError> {
let contents = fs
.read_file_text(path, /*sandbox*/ None)
let contents = skill_path
.read_to_string(/*sandbox*/ None)
.await
.map_err(SkillParseError::Read)?;
let path = skill_path.path();
let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?;
@@ -641,7 +705,7 @@ async fn parse_skill_file(
.map(sanitize_single_line)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| default_skill_name(path));
let name = namespaced_skill_name(fs, path, &base_name).await;
let name = namespaced_skill_name(skill_path.file_system().as_ref(), path, &base_name).await;
let description = parsed
.description
.as_deref()
@@ -657,7 +721,7 @@ async fn parse_skill_file(
interface,
dependencies,
policy,
} = load_skill_metadata(fs, path, plugin_root).await;
} = load_skill_metadata(skill_path, plugin_root).await;
validate_len(&name, MAX_NAME_LEN, "name")?;
validate_len(&description, MAX_DESCRIPTION_LEN, "description")?;
@@ -708,18 +772,21 @@ async fn namespaced_skill_name(
}
async fn load_skill_metadata(
fs: &dyn ExecutorFileSystem,
skill_path: &AbsolutePathBuf,
plugin_root: Option<&AbsolutePathBuf>,
skill_path: &EnvironmentPathRef,
plugin_root: Option<&EnvironmentPathRef>,
) -> LoadedSkillMetadata {
// Fail open: optional metadata should not block loading SKILL.md.
let Some(skill_dir) = skill_path.parent() else {
let Some(skill_dir) = skill_path.parent_dir() else {
return LoadedSkillMetadata::default();
};
let metadata_path = skill_dir
.join(SKILLS_METADATA_DIR)
.join(SKILLS_METADATA_FILENAME);
match fs.get_metadata(&metadata_path, /*sandbox*/ None).await {
let Some(metadata_path) = skill_dir.join_relative(
Path::new(SKILLS_METADATA_DIR)
.join(SKILLS_METADATA_FILENAME)
.as_path(),
) else {
return LoadedSkillMetadata::default();
};
match metadata_path.metadata(/*sandbox*/ None).await {
Ok(metadata) if metadata.is_file => {}
Ok(_) => return LoadedSkillMetadata::default(),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
@@ -728,19 +795,19 @@ async fn load_skill_metadata(
Err(error) => {
tracing::warn!(
"ignoring {path}: failed to stat {label}: {error}",
path = metadata_path.display(),
path = metadata_path.path().display(),
label = SKILLS_METADATA_FILENAME
);
return LoadedSkillMetadata::default();
}
}
let contents = match fs.read_file_text(&metadata_path, /*sandbox*/ None).await {
let contents = match metadata_path.read_to_string(/*sandbox*/ None).await {
Ok(contents) => contents,
Err(error) => {
tracing::warn!(
"ignoring {path}: failed to read {label}: {error}",
path = metadata_path.display(),
path = metadata_path.path().display(),
label = SKILLS_METADATA_FILENAME
);
return LoadedSkillMetadata::default();
@@ -748,13 +815,13 @@ async fn load_skill_metadata(
};
let parsed: SkillMetadataFile = {
let _guard = AbsolutePathBufGuard::new(skill_dir.as_path());
let _guard = AbsolutePathBufGuard::new(skill_dir.path().as_path());
match serde_yaml::from_str(&contents) {
Ok(parsed) => parsed,
Err(error) => {
tracing::warn!(
"ignoring {path}: invalid {label}: {error}",
path = metadata_path.display(),
path = metadata_path.path().display(),
label = SKILLS_METADATA_FILENAME
);
return LoadedSkillMetadata::default();
@@ -768,7 +835,11 @@ async fn load_skill_metadata(
policy,
} = parsed;
LoadedSkillMetadata {
interface: resolve_interface(interface, &skill_dir, plugin_root),
interface: resolve_interface(
interface,
skill_dir.path(),
plugin_root.map(EnvironmentPathRef::path),
),
dependencies: resolve_dependencies(dependencies),
policy: resolve_policy(policy),
}
@@ -1061,10 +1132,12 @@ pub(crate) async fn skill_roots_from_layer_stack(
cwd: &AbsolutePathBuf,
home_dir: Option<&AbsolutePathBuf>,
) -> Vec<SkillRoot> {
let path_ref = EnvironmentPathRef::new(fs, cwd.clone());
let local_file_system = path_ref.file_system();
skill_roots_with_home_dir(
Some(fs),
&path_ref,
Some(&local_file_system),
config_layer_stack,
cwd,
home_dir,
Vec::new(),
Vec::new(),

View File

@@ -4,7 +4,9 @@ use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_exec_server::LocalFileSystem;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -144,6 +146,19 @@ fn normalized(path: &Path) -> AbsolutePathBuf {
.abs()
}
fn local_env_path(path: AbsolutePathBuf) -> EnvironmentPathRef {
EnvironmentPathRef::local(path)
}
fn local_skill_root(path: AbsolutePathBuf, scope: SkillScope) -> SkillRoot {
SkillRoot {
path: local_env_path(path),
scope,
plugin_id: None,
plugin_root: None,
}
}
#[tokio::test]
async fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_system_to_admin()
-> anyhow::Result<()> {
@@ -187,7 +202,7 @@ async fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_sys
)
.await
.into_iter()
.map(|root| (root.scope, root.path.to_path_buf()))
.map(|root| (root.scope, root.path.path().to_path_buf()))
.collect::<Vec<_>>();
assert_eq!(
@@ -256,7 +271,7 @@ async fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyh
)
.await
.into_iter()
.map(|root| (root.scope, root.path.to_path_buf()))
.map(|root| (root.scope, root.path.path().to_path_buf()))
.collect::<Vec<_>>();
assert_eq!(
@@ -278,6 +293,57 @@ async fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyh
Ok(())
}
#[tokio::test]
async fn skill_roots_bind_repo_and_local_roots_to_their_own_file_systems() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
let project_root = codex_home.path().join("workspace");
fs::create_dir_all(project_root.join(".git"))?;
fs::create_dir_all(project_root.join(REPO_ROOT_CONFIG_DIR_NAME))?;
let cfg = make_config_for_cwd(&codex_home, project_root.clone()).await;
let env_file_system: Arc<dyn ExecutorFileSystem> = Arc::new(LocalFileSystem::unsandboxed());
let local_file_system: Arc<dyn ExecutorFileSystem> = Arc::new(LocalFileSystem::unsandboxed());
let env_path = EnvironmentPathRef::new(env_file_system.clone(), cfg.cwd.clone());
let roots = super::skill_roots(
&env_path,
Some(&local_file_system),
&cfg.config_layer_stack,
Vec::new(),
Vec::new(),
)
.await;
let repo_root = roots
.iter()
.find(|root| root.scope == SkillScope::Repo)
.expect("repo root");
let user_root = roots
.iter()
.find(|root| root.scope == SkillScope::User)
.expect("user root");
assert!(Arc::ptr_eq(&repo_root.path.file_system(), &env_file_system));
assert!(Arc::ptr_eq(
&user_root.path.file_system(),
&local_file_system
));
let roots_without_local = super::skill_roots(
&env_path,
/*local_file_system*/ None,
&cfg.config_layer_stack,
Vec::new(),
Vec::new(),
)
.await;
assert!(
roots_without_local
.iter()
.all(|root| root.scope == SkillScope::Repo)
);
Ok(())
}
#[tokio::test]
async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
@@ -846,11 +912,10 @@ interface:
let plugin_root_abs = plugin_root.abs();
let outcome = load_skills_from_roots([SkillRoot {
path: plugin_root.join("skills").abs(),
path: local_env_path(plugin_root.join("skills").abs()),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: Some("twilio-developer-kit@test".to_string()),
plugin_root: Some(plugin_root_abs.clone()),
plugin_root: Some(local_env_path(plugin_root_abs.clone())),
}])
.await;
@@ -903,11 +968,10 @@ interface:
);
let outcome = load_skills_from_roots([SkillRoot {
path: plugin_root.join("skills").abs(),
path: local_env_path(plugin_root.join("skills").abs()),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: Some("twilio-developer-kit@test".to_string()),
plugin_root: Some(plugin_root.abs()),
plugin_root: Some(local_env_path(plugin_root.abs())),
}])
.await;
@@ -1048,14 +1112,9 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() {
fs::create_dir_all(admin_root.path()).unwrap();
symlink_dir(shared.path(), &admin_root.path().join("shared"));
let outcome = load_skills_from_roots([SkillRoot {
path: admin_root.path().abs(),
scope: SkillScope::Admin,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
}])
.await;
let outcome =
load_skills_from_roots([local_skill_root(admin_root.path().abs(), SkillScope::Admin)])
.await;
assert!(
outcome.errors.is_empty(),
@@ -1130,14 +1189,8 @@ async fn system_scope_ignores_symlinked_subdir() {
fs::create_dir_all(&system_root).unwrap();
symlink_dir(shared.path(), &system_root.join("shared"));
let outcome = load_skills_from_roots([SkillRoot {
path: system_root.abs(),
scope: SkillScope::System,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
}])
.await;
let outcome =
load_skills_from_roots([local_skill_root(system_root.abs(), SkillScope::System)]).await;
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
@@ -1164,14 +1217,8 @@ async fn respects_max_scan_depth_for_user_scope() {
);
let skills_root = codex_home.path().join("skills");
let outcome = load_skills_from_roots([SkillRoot {
path: skills_root.abs(),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
}])
.await;
let outcome =
load_skills_from_roots([local_skill_root(skills_root.abs(), SkillScope::User)]).await;
assert!(
outcome.errors.is_empty(),
@@ -1272,11 +1319,10 @@ async fn namespaces_plugin_skills_using_plugin_name() {
.unwrap();
let outcome = load_skills_from_roots([SkillRoot {
path: plugin_root.join("skills").abs(),
path: local_env_path(plugin_root.join("skills").abs()),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: Some("sample@test".to_string()),
plugin_root: Some(plugin_root.abs()),
plugin_root: Some(local_env_path(plugin_root.abs())),
}])
.await;
@@ -1595,16 +1641,14 @@ async fn deduplicates_by_path_preferring_first_root() {
let outcome = load_skills_from_roots([
SkillRoot {
path: root.path().abs(),
path: local_env_path(root.path().abs()),
scope: SkillScope::Repo,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
},
SkillRoot {
path: root.path().abs(),
path: local_env_path(root.path().abs()),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: None,
plugin_root: None,
},
@@ -1895,10 +1939,12 @@ async fn skill_roots_include_admin_with_lowest_priority() {
let codex_home = tempfile::tempdir().expect("tempdir");
let cfg = make_config(&codex_home).await;
let cwd = local_env_path(cfg.cwd.clone());
let local_file_system = cwd.file_system();
let scopes: Vec<SkillScope> = super::skill_roots(
Some(Arc::clone(&LOCAL_FS)),
&cwd,
Some(&local_file_system),
&cfg.config_layer_stack,
&cfg.cwd,
Vec::new(),
Vec::new(),
)

View File

@@ -4,7 +4,6 @@ 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;
@@ -23,24 +22,42 @@ use crate::loader::skill_roots;
use crate::system::install_system_skills;
use crate::system::uninstall_system_skills;
use codex_config::SkillsConfig;
use codex_exec_server::EnvironmentPathRef;
use codex_exec_server::ExecutorFileSystem;
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct SkillsLoadInput {
pub cwd: AbsolutePathBuf,
pub env_path: EnvironmentPathRef,
/// Local user/system/plugin skill roots are read from this filesystem for now.
pub local_file_system: Option<Arc<dyn ExecutorFileSystem>>,
pub effective_skill_roots: Vec<PluginSkillRoot>,
pub config_layer_stack: ConfigLayerStack,
pub bundled_skills_enabled: bool,
}
impl std::fmt::Debug for SkillsLoadInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SkillsLoadInput")
.field("env_path", &self.env_path)
.field("has_local_file_system", &self.local_file_system.is_some())
.field("effective_skill_roots", &self.effective_skill_roots)
.field("config_layer_stack", &self.config_layer_stack)
.field("bundled_skills_enabled", &self.bundled_skills_enabled)
.finish()
}
}
impl SkillsLoadInput {
pub fn new(
cwd: AbsolutePathBuf,
env_path: EnvironmentPathRef,
local_file_system: Option<Arc<dyn ExecutorFileSystem>>,
effective_skill_roots: Vec<PluginSkillRoot>,
config_layer_stack: ConfigLayerStack,
bundled_skills_enabled: bool,
) -> Self {
Self {
cwd,
env_path,
local_file_system,
effective_skill_roots,
config_layer_stack,
bundled_skills_enabled,
@@ -52,7 +69,7 @@ pub struct SkillsManager {
codex_home: AbsolutePathBuf,
restriction_product: Option<Product>,
extra_roots: RwLock<Vec<AbsolutePathBuf>>,
cache_by_cwd: RwLock<HashMap<AbsolutePathBuf, SkillLoadOutcome>>,
cache_by_path_ref: RwLock<HashMap<SkillsPathCacheKey, SkillLoadOutcome>>,
cache_by_config: RwLock<HashMap<ConfigSkillsCacheKey, SkillLoadOutcome>>,
}
@@ -70,7 +87,7 @@ impl SkillsManager {
codex_home,
restriction_product,
extra_roots: RwLock::new(Vec::new()),
cache_by_cwd: RwLock::new(HashMap::new()),
cache_by_path_ref: RwLock::new(HashMap::new()),
cache_by_config: RwLock::new(HashMap::new()),
};
if !bundled_skills_enabled {
@@ -100,12 +117,8 @@ 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 async fn skills_for_config(
&self,
input: &SkillsLoadInput,
fs: Option<Arc<dyn ExecutorFileSystem>>,
) -> SkillLoadOutcome {
let roots = self.skill_roots_for_config(input, fs).await;
pub async fn skills_for_config(&self, input: &SkillsLoadInput) -> SkillLoadOutcome {
let roots = self.skill_roots_for_config(input).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) {
@@ -121,15 +134,11 @@ impl SkillsManager {
outcome
}
pub async fn skill_roots_for_config(
&self,
input: &SkillsLoadInput,
fs: Option<Arc<dyn ExecutorFileSystem>>,
) -> Vec<SkillRoot> {
pub async fn skill_roots_for_config(&self, input: &SkillsLoadInput) -> Vec<SkillRoot> {
let mut roots = skill_roots(
fs,
&input.env_path,
input.local_file_system.as_ref(),
&input.config_layer_stack,
&input.cwd,
input.effective_skill_roots.clone(),
self.extra_roots(),
)
@@ -144,20 +153,24 @@ impl SkillsManager {
&self,
input: &SkillsLoadInput,
force_reload: bool,
fs: Option<Arc<dyn ExecutorFileSystem>>,
) -> SkillLoadOutcome {
let use_cwd_cache = fs.is_some();
if use_cwd_cache
&& !force_reload
&& let Some(outcome) = self.cached_outcome_for_cwd(&input.cwd)
let path_ref_cache_key = SkillsPathCacheKey {
env_path: input.env_path.clone(),
local_file_system: input
.local_file_system
.as_ref()
.map(ExecutorFileSystemRef::new),
};
if !force_reload
&& let Some(outcome) = self.cached_outcome_for_path_ref(&path_ref_cache_key)
{
return outcome;
}
let mut roots = skill_roots(
fs.clone(),
&input.env_path,
input.local_file_system.as_ref(),
&input.config_layer_stack,
&input.cwd,
input.effective_skill_roots.clone(),
self.extra_roots(),
)
@@ -167,13 +180,11 @@ impl SkillsManager {
}
let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack);
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());
}
let mut cache = self
.cache_by_path_ref
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache.insert(path_ref_cache_key, outcome.clone());
outcome
}
@@ -191,9 +202,9 @@ impl SkillsManager {
}
pub fn clear_cache(&self) {
let cleared_cwd = {
let cleared_path_refs = {
let mut cache = self
.cache_by_cwd
.cache_by_path_ref
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cleared = cache.len();
@@ -209,14 +220,17 @@ impl SkillsManager {
cache.clear();
cleared
};
let cleared = cleared_cwd + cleared_config;
let cleared = cleared_path_refs + cleared_config;
info!("skills cache cleared ({cleared} entries)");
}
fn cached_outcome_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option<SkillLoadOutcome> {
match self.cache_by_cwd.read() {
Ok(cache) => cache.get(cwd).cloned(),
Err(err) => err.into_inner().get(cwd).cloned(),
fn cached_outcome_for_path_ref(
&self,
cache_key: &SkillsPathCacheKey,
) -> Option<SkillLoadOutcome> {
match self.cache_by_path_ref.read() {
Ok(cache) => cache.get(cache_key).cloned(),
Err(err) => err.into_inner().get(cache_key).cloned(),
}
}
@@ -238,9 +252,44 @@ impl SkillsManager {
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct SkillsPathCacheKey {
env_path: EnvironmentPathRef,
local_file_system: Option<ExecutorFileSystemRef>,
}
#[derive(Clone)]
struct ExecutorFileSystemRef(Arc<dyn ExecutorFileSystem>);
impl ExecutorFileSystemRef {
fn new(file_system: &Arc<dyn ExecutorFileSystem>) -> Self {
Self(Arc::clone(file_system))
}
}
impl PartialEq for ExecutorFileSystemRef {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
}
impl Eq for ExecutorFileSystemRef {}
impl std::hash::Hash for ExecutorFileSystemRef {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
std::hash::Hash::hash(&(Arc::as_ptr(&self.0) as *const () as usize), state);
}
}
impl std::fmt::Debug for ExecutorFileSystemRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExecutorFileSystemRef").finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ConfigSkillsCacheKey {
roots: Vec<(AbsolutePathBuf, u8, Option<String>)>,
roots: Vec<(EnvironmentPathRef, u8, Option<String>)>,
skill_config_rules: SkillConfigRules,
}

View File

@@ -87,6 +87,25 @@ fn test_skill(name: &str, path: PathBuf) -> SkillMetadata {
}
}
fn skill_root_path_ref(path: AbsolutePathBuf) -> EnvironmentPathRef {
EnvironmentPathRef::local(path)
}
fn local_skills_input(
cwd: AbsolutePathBuf,
effective_skill_roots: Vec<PluginSkillRoot>,
config_layer_stack: ConfigLayerStack,
) -> SkillsLoadInput {
let path_ref = skill_root_path_ref(cwd);
SkillsLoadInput::new(
path_ref,
Some(Arc::clone(&LOCAL_FS)),
effective_skill_roots,
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(&config_layer_stack),
)
}
fn write_demo_skill(tempdir: &TempDir) -> PathBuf {
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
fs::create_dir_all(skill_path.parent().expect("skill path should have parent"))
@@ -164,15 +183,12 @@ async fn skills_for_config_with_stack(
config_layer_stack: &ConfigLayerStack,
effective_skill_roots: &[PluginSkillRoot],
) -> SkillLoadOutcome {
let skills_input = SkillsLoadInput::new(
let skills_input = local_skills_input(
cwd.path().abs(),
effective_skill_roots.to_vec(),
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(config_layer_stack),
);
skills_manager
.skills_for_config(&skills_input, Some(Arc::clone(&LOCAL_FS)))
.await
skills_manager.skills_for_config(&skills_input).await
}
#[test]
@@ -232,18 +248,9 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() {
/*bundled_skills_enabled*/ true,
);
let skills_input = SkillsLoadInput::new(
cwd.path().abs(),
Vec::new(),
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(&config_layer_stack),
);
let skills_input = local_skills_input(cwd.path().abs(), Vec::new(), config_layer_stack.clone());
let empty_outcome = skills_manager
.skills_for_cwd(
&skills_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.skills_for_cwd(&skills_input, /*force_reload*/ false)
.await;
assert!(
empty_outcome
@@ -263,11 +270,7 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() {
skills_manager.set_extra_roots(vec![extra_skills_root.abs()]);
let runtime_outcome = skills_manager
.skills_for_cwd(
&skills_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.skills_for_cwd(&skills_input, /*force_reload*/ false)
.await;
assert!(
runtime_outcome
@@ -278,11 +281,7 @@ async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() {
skills_manager.set_extra_roots(vec![extra_root.path().join("missing-skills").abs()]);
let replaced_outcome = skills_manager
.skills_for_cwd(
&skills_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.skills_for_cwd(&skills_input, /*force_reload*/ false)
.await;
assert_eq!(replaced_outcome.errors, Vec::new());
assert!(
@@ -421,23 +420,14 @@ async fn skills_for_cwd_loads_repo_and_user_roots_with_local_fs() {
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_input = local_skills_input(cwd.path().abs(), Vec::new(), config_layer_stack.clone());
let skills_manager = SkillsManager::new(
codex_home.path().abs(),
/*bundled_skills_enabled*/ true,
);
let outcome = skills_manager
.skills_for_cwd(
&skills_input,
/*force_reload*/ true,
Some(Arc::clone(&LOCAL_FS)),
)
.skills_for_cwd(&skills_input, /*force_reload*/ true)
.await;
assert!(
@@ -455,7 +445,7 @@ async fn skills_for_cwd_loads_repo_and_user_roots_with_local_fs() {
}
#[tokio::test]
async fn skills_for_cwd_without_fs_skips_repo_roots() {
async fn skills_for_cwd_without_local_fs_skips_local_roots() {
let codex_home = tempfile::tempdir().expect("tempdir");
let cwd = tempfile::tempdir().expect("tempdir");
let repo_dot_codex = cwd.path().join(".codex");
@@ -485,7 +475,8 @@ async fn skills_for_cwd_without_fs_skips_repo_roots() {
)
.expect("valid config layer stack");
let skills_input = SkillsLoadInput::new(
cwd.path().abs(),
skill_root_path_ref(cwd.path().abs()),
/*local_file_system*/ None,
Vec::new(),
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(&config_layer_stack),
@@ -496,7 +487,7 @@ async fn skills_for_cwd_without_fs_skips_repo_roots() {
);
let outcome = skills_manager
.skills_for_cwd(&skills_input, /*force_reload*/ true, /*fs*/ None)
.skills_for_cwd(&skills_input, /*force_reload*/ true)
.await;
assert!(
@@ -509,8 +500,8 @@ async fn skills_for_cwd_without_fs_skips_repo_roots() {
.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("user-skill"));
assert!(loaded_names.contains("repo-skill"));
}
#[tokio::test]
@@ -565,18 +556,9 @@ async fn skills_for_cwd_uses_cached_result_until_force_reload() {
/*bundled_skills_enabled*/ true,
);
let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await;
let base_input = SkillsLoadInput::new(
cwd.path().abs(),
Vec::new(),
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(&config_layer_stack),
);
let base_input = local_skills_input(cwd.path().abs(), Vec::new(), config_layer_stack.clone());
let outcome_a = skills_manager
.skills_for_cwd(
&base_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.skills_for_cwd(&base_input, /*force_reload*/ false)
.await;
assert!(
outcome_a
@@ -588,11 +570,7 @@ async fn skills_for_cwd_uses_cached_result_until_force_reload() {
write_user_skill(&codex_home, "late", "late-skill", "added after cache");
let outcome_b = skills_manager
.skills_for_cwd(
&base_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.skills_for_cwd(&base_input, /*force_reload*/ false)
.await;
assert!(
outcome_b
@@ -602,11 +580,7 @@ async fn skills_for_cwd_uses_cached_result_until_force_reload() {
);
let outcome_reloaded = skills_manager
.skills_for_cwd(
&base_input,
/*force_reload*/ true,
Some(Arc::clone(&LOCAL_FS)),
)
.skills_for_cwd(&base_input, /*force_reload*/ true)
.await;
assert!(
outcome_reloaded
@@ -779,19 +753,10 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill()
codex_home.path().abs(),
/*bundled_skills_enabled*/ true,
);
let parent_input = SkillsLoadInput::new(
cwd.path().abs(),
Vec::new(),
parent_stack.clone(),
bundled_skills_enabled_from_stack(&parent_stack),
);
let parent_input = local_skills_input(cwd.path().abs(), Vec::new(), parent_stack.clone());
let parent_outcome = skills_manager
.skills_for_cwd(
&parent_input,
/*force_reload*/ true,
Some(Arc::clone(&LOCAL_FS)),
)
.skills_for_cwd(&parent_input, /*force_reload*/ true)
.await;
let parent_skill = parent_outcome
.skills

View File

@@ -1,12 +1,11 @@
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;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;
#[derive(Debug, Clone, PartialEq)]
pub struct SkillMetadata {
@@ -210,3 +209,30 @@ pub fn filter_skill_load_outcome_for_product(
);
outcome
}
#[cfg(test)]
mod tests {
use std::path::Path;
use codex_exec_server::EnvironmentPathRef;
use codex_utils_absolute_path::test_support::PathBufExt;
use pretty_assertions::assert_eq;
#[test]
fn environment_path_ref_joins_only_relative_paths() {
let root_path = std::env::temp_dir().join("skills");
let path_ref = EnvironmentPathRef::local(root_path.abs());
assert_eq!(
path_ref
.join_relative(Path::new("demo/SKILL.md"))
.map(|path_ref| path_ref.path().clone()),
Some(root_path.join("demo/SKILL.md").abs())
);
assert!(
path_ref
.join_relative(std::env::temp_dir().join("SKILL.md").as_path())
.is_none()
);
}
}

View File

@@ -421,13 +421,17 @@ enabled = false
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
let outcome = skills_manager
.skills_for_config(
&skills_input,
Some(Arc::clone(&codex_exec_server::LOCAL_FS)),
)
.await;
let cwd = crate::skills::EnvironmentPathRef::new(
Arc::clone(&codex_exec_server::LOCAL_FS),
config.cwd.clone(),
);
let skills_input = skills_load_input_from_config(
&config,
cwd,
Some(Arc::clone(&codex_exec_server::LOCAL_FS)),
effective_skill_roots,
);
let outcome = skills_manager.skills_for_config(&skills_input).await;
let skill = outcome
.skills
.iter()

View File

@@ -2,7 +2,6 @@ use std::collections::HashSet;
use std::sync::Arc;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecutorFileSystem;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::protocol::TurnEnvironmentSelection;
@@ -45,11 +44,6 @@ impl ResolvedTurnEnvironments {
self.primary()
.map(|environment| Arc::clone(&environment.environment))
}
pub(crate) fn primary_filesystem(&self) -> Option<Arc<dyn ExecutorFileSystem>> {
self.primary()
.map(|environment| environment.environment.get_filesystem())
}
}
pub(crate) fn resolve_environment_selections(

View File

@@ -447,20 +447,36 @@ async fn warm_plugins_and_skills_for_session_init(
skills_manager: Arc<SkillsManager>,
environments: Vec<TurnEnvironmentSelection>,
) -> Vec<SkillError> {
let fs = crate::environment_selection::resolve_environment_selections(
let resolved_environments = crate::environment_selection::resolve_environment_selections(
environment_manager.as_ref(),
&environments,
)
.ok()
.and_then(|resolved| resolved.primary_filesystem());
.ok();
let path_ref = resolved_environments
.as_ref()
.and_then(crate::environment_selection::ResolvedTurnEnvironments::primary)
.map(|environment| {
crate::skills::EnvironmentPathRef::new(
environment.environment.get_filesystem(),
environment.cwd.clone(),
)
});
let Some(path_ref) = path_ref else {
return Vec::new();
};
let local_file_system = environment_manager
.try_local_environment()
.map(|environment| environment.get_filesystem());
let plugins_input = config.plugins_config_input();
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input = skills_load_input_from_config(config.as_ref(), effective_skill_roots);
skills_manager
.skills_for_config(&skills_input, fs)
.await
.errors
let skills_input = skills_load_input_from_config(
config.as_ref(),
path_ref,
local_file_system,
effective_skill_roots,
);
skills_manager.skills_for_config(&skills_input).await.errors
}
impl Session {

View File

@@ -4127,9 +4127,20 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() {
.services
.skills_manager
.skills_for_cwd(
&crate::skills_load_input_from_config(&parent_config, Vec::new()),
&crate::skills_load_input_from_config(
&parent_config,
crate::skills::EnvironmentPathRef::new(
Arc::clone(&skill_fs),
parent_config.cwd.clone(),
),
session
.services
.environment_manager
.try_local_environment()
.map(|environment| environment.get_filesystem()),
Vec::new(),
),
/*force_reload*/ true,
Some(Arc::clone(&skill_fs)),
)
.await;
let parent_skill = parent_outcome
@@ -4692,13 +4703,23 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
.plugins_for_config(&per_turn_config.plugins_config_input())
.await;
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input =
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
let skill_fs = environment.get_filesystem();
let cwd = crate::skills::EnvironmentPathRef::new(
environment.get_filesystem(),
per_turn_config.cwd.clone(),
);
let skills_input = crate::skills_load_input_from_config(
&per_turn_config,
cwd,
services
.environment_manager
.try_local_environment()
.map(|environment| environment.get_filesystem()),
effective_skill_roots,
);
let skills_outcome = Arc::new(
services
.skills_manager
.skills_for_config(&skills_input, Some(Arc::clone(&skill_fs)))
.skills_for_config(&skills_input)
.await,
);
let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd);
@@ -6535,13 +6556,23 @@ where
.plugins_for_config(&per_turn_config.plugins_config_input())
.await;
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input =
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
let skill_fs = environment.get_filesystem();
let cwd = crate::skills::EnvironmentPathRef::new(
environment.get_filesystem(),
per_turn_config.cwd.clone(),
);
let skills_input = crate::skills_load_input_from_config(
&per_turn_config,
cwd,
services
.environment_manager
.try_local_environment()
.map(|environment| environment.get_filesystem()),
effective_skill_roots,
);
let skills_outcome = Arc::new(
services
.skills_manager
.skills_for_config(&skills_input, Some(Arc::clone(&skill_fs)))
.skills_for_config(&skills_input)
.await,
);
let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd);

View File

@@ -678,21 +678,42 @@ impl Session {
&per_turn_config.to_models_manager_config(),
)
.await;
let plugin_outcome = self
.services
.plugins_manager
.plugins_for_config(&per_turn_config.plugins_config_input())
.await;
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
let fs = primary_turn_environment
.map(|turn_environment| turn_environment.environment.get_filesystem());
let skills_outcome = Arc::new(
self.services
.skills_manager
.skills_for_config(&skills_input, fs)
.await,
);
let path_ref = primary_turn_environment.map(|turn_environment| {
crate::skills::EnvironmentPathRef::new(
turn_environment.environment.get_filesystem(),
turn_environment.cwd.clone(),
)
});
let skills_outcome = if let Some(path_ref) = path_ref {
// Workspace/repo skill roots use the selected turn environment path above, while
// user/system/plugin skill roots still read from the available local filesystem.
let local_file_system = self
.services
.environment_manager
.try_local_environment()
.map(|environment| environment.get_filesystem());
let plugins_input = per_turn_config.plugins_config_input();
let plugin_outcome = self
.services
.plugins_manager
.plugins_for_config(&plugins_input)
.await;
let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots();
let skills_input = skills_load_input_from_config(
&per_turn_config,
path_ref,
local_file_system,
effective_skill_roots,
);
Arc::new(
self.services
.skills_manager
.skills_for_config(&skills_input)
.await,
)
} else {
Arc::new(SkillLoadOutcome::default())
};
let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some();
let mut turn_context: TurnContext = Self::make_turn_context(
self.thread_id(),

View File

@@ -4,9 +4,12 @@ use crate::session::turn_context::TurnContext;
use codex_analytics::InvocationType;
use codex_analytics::SkillInvocation;
use codex_analytics::build_track_events_context;
pub use codex_exec_server::EnvironmentPathRef;
use codex_exec_server::ExecutorFileSystem;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PluginSkillRoot;
use std::sync::Arc;
pub use codex_core_skills::SkillError;
pub use codex_core_skills::SkillLoadOutcome;
@@ -35,10 +38,13 @@ pub use codex_core_skills::system;
pub(crate) fn skills_load_input_from_config(
config: &Config,
env_path: EnvironmentPathRef,
local_file_system: Option<Arc<dyn ExecutorFileSystem>>,
effective_skill_roots: Vec<PluginSkillRoot>,
) -> SkillsLoadInput {
SkillsLoadInput::new(
config.cwd.clone(),
env_path,
local_file_system,
effective_skill_roots,
config.config_layer_stack.clone(),
config.bundled_skills_enabled(),

View File

@@ -0,0 +1,325 @@
use std::fmt;
use std::hash::Hash;
use std::hash::Hasher;
use std::io;
use std::path::Path;
use std::sync::Arc;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::ExecutorFileSystem;
use crate::FileMetadata;
use crate::FileSystemSandboxContext;
use crate::LOCAL_FS;
use crate::ReadDirectoryEntry;
/// Binds an absolute path to the executor filesystem that owns it.
#[derive(Clone)]
pub struct EnvironmentPathRef {
file_system: Arc<dyn ExecutorFileSystem>,
path: AbsolutePathBuf,
}
impl EnvironmentPathRef {
pub fn new(file_system: Arc<dyn ExecutorFileSystem>, path: AbsolutePathBuf) -> Self {
Self { file_system, path }
}
pub fn local(path: AbsolutePathBuf) -> Self {
Self::new(Arc::clone(&LOCAL_FS), path)
}
pub fn path(&self) -> &AbsolutePathBuf {
&self.path
}
pub fn file_system(&self) -> Arc<dyn ExecutorFileSystem> {
Arc::clone(&self.file_system)
}
pub async fn read_to_string(
&self,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<String> {
self.file_system.read_file_text(&self.path, sandbox).await
}
pub async fn metadata(
&self,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<FileMetadata> {
self.file_system.get_metadata(&self.path, sandbox).await
}
pub async fn read_directory(
&self,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<Vec<ReadDirectoryEntry>> {
self.file_system.read_directory(&self.path, sandbox).await
}
pub fn join_relative(&self, relative: &Path) -> Option<Self> {
relative
.is_relative()
.then(|| self.with_path(self.path.join(relative)))
}
pub fn parent_dir(&self) -> Option<Self> {
self.path.parent().map(|path| self.with_path(path))
}
pub fn with_path(&self, path: AbsolutePathBuf) -> Self {
Self::new(Arc::clone(&self.file_system), path)
}
}
impl PartialEq for EnvironmentPathRef {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.file_system, &other.file_system) && self.path == other.path
}
}
impl Eq for EnvironmentPathRef {}
impl Hash for EnvironmentPathRef {
fn hash<H: Hasher>(&self, state: &mut H) {
(Arc::as_ptr(&self.file_system) as *const () as usize).hash(state);
self.path.hash(state);
}
}
impl fmt::Debug for EnvironmentPathRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EnvironmentPathRef")
.field("path", &self.path)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::test_support::PathBufExt;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use std::sync::Mutex;
#[derive(Clone, Debug, Eq, PartialEq)]
enum RecordedMethod {
ReadFileText,
Metadata,
ReadDirectory,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct RecordedCall {
method: RecordedMethod,
path: AbsolutePathBuf,
sandbox: Option<FileSystemSandboxContext>,
}
#[derive(Default)]
struct RecordingFileSystem {
calls: Mutex<Vec<RecordedCall>>,
}
impl RecordingFileSystem {
fn recorded_calls(&self) -> Vec<RecordedCall> {
match self.calls.lock() {
Ok(calls) => calls.clone(),
Err(err) => err.into_inner().clone(),
}
}
fn push_call(&self, call: RecordedCall) {
match self.calls.lock() {
Ok(mut calls) => calls.push(call),
Err(err) => err.into_inner().push(call),
}
}
}
#[async_trait]
impl ExecutorFileSystem for RecordingFileSystem {
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<Vec<u8>> {
self.push_call(RecordedCall {
method: RecordedMethod::ReadFileText,
path: path.clone(),
sandbox: sandbox.cloned(),
});
Ok(b"skill contents".to_vec())
}
async fn write_file(
&self,
_path: &AbsolutePathBuf,
_contents: Vec<u8>,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<()> {
unreachable!("write_file should not be called")
}
async fn create_directory(
&self,
_path: &AbsolutePathBuf,
_create_directory_options: crate::CreateDirectoryOptions,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<()> {
unreachable!("create_directory should not be called")
}
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<FileMetadata> {
self.push_call(RecordedCall {
method: RecordedMethod::Metadata,
path: path.clone(),
sandbox: sandbox.cloned(),
});
Ok(FileMetadata {
is_directory: true,
is_file: false,
is_symlink: false,
created_at_ms: 1,
modified_at_ms: 2,
})
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<Vec<ReadDirectoryEntry>> {
self.push_call(RecordedCall {
method: RecordedMethod::ReadDirectory,
path: path.clone(),
sandbox: sandbox.cloned(),
});
Ok(vec![ReadDirectoryEntry {
file_name: "SKILL.md".to_string(),
is_directory: false,
is_file: true,
}])
}
async fn remove(
&self,
_path: &AbsolutePathBuf,
_remove_options: crate::RemoveOptions,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<()> {
unreachable!("remove should not be called")
}
async fn copy(
&self,
_source_path: &AbsolutePathBuf,
_destination_path: &AbsolutePathBuf,
_copy_options: crate::CopyOptions,
_sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<()> {
unreachable!("copy should not be called")
}
}
fn restricted_sandbox() -> FileSystemSandboxContext {
FileSystemSandboxContext::from_permission_profile(
PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(Vec::new()),
NetworkSandboxPolicy::Restricted,
),
)
}
#[tokio::test]
async fn environment_path_ref_forwards_sandbox_to_file_system_methods() {
let path = std::env::temp_dir().join("skills/demo").abs();
let file_system = Arc::new(RecordingFileSystem::default());
let path_ref = EnvironmentPathRef::new(file_system.clone(), path.clone());
let sandbox = restricted_sandbox();
assert_eq!(
path_ref
.read_to_string(Some(&sandbox))
.await
.expect("read skill contents"),
"skill contents".to_string()
);
assert_eq!(
path_ref
.metadata(Some(&sandbox))
.await
.expect("read metadata"),
FileMetadata {
is_directory: true,
is_file: false,
is_symlink: false,
created_at_ms: 1,
modified_at_ms: 2,
}
);
assert_eq!(
path_ref
.read_directory(Some(&sandbox))
.await
.expect("read directory"),
vec![ReadDirectoryEntry {
file_name: "SKILL.md".to_string(),
is_directory: false,
is_file: true,
}]
);
assert_eq!(
file_system.recorded_calls(),
vec![
RecordedCall {
method: RecordedMethod::ReadFileText,
path: path.clone(),
sandbox: Some(sandbox.clone()),
},
RecordedCall {
method: RecordedMethod::Metadata,
path: path.clone(),
sandbox: Some(sandbox.clone()),
},
RecordedCall {
method: RecordedMethod::ReadDirectory,
path,
sandbox: Some(sandbox),
},
]
);
}
#[test]
fn environment_path_ref_equality_and_hash_include_file_system_identity() {
let path = std::env::temp_dir().join("skills/demo").abs();
let file_system = Arc::new(RecordingFileSystem::default());
let same_file_system: Arc<dyn ExecutorFileSystem> = file_system.clone();
let different_file_system: Arc<dyn ExecutorFileSystem> =
Arc::new(RecordingFileSystem::default());
let left = EnvironmentPathRef::new(same_file_system.clone(), path.clone());
let same = EnvironmentPathRef::new(same_file_system, path.clone());
let different_path = EnvironmentPathRef::new(file_system, path.parent().unwrap());
let different_fs = EnvironmentPathRef::new(different_file_system, path);
assert_eq!(left, same);
assert_ne!(left, different_path);
assert_ne!(left, different_fs);
let set = HashSet::from([left, same, different_path, different_fs]);
assert_eq!(set.len(), 3);
}
}

View File

@@ -3,6 +3,7 @@ mod client_api;
mod client_transport;
mod connection;
mod environment;
mod environment_path;
mod environment_provider;
mod environment_toml;
mod fs_helper;
@@ -43,6 +44,7 @@ pub use environment::Environment;
pub use environment::EnvironmentManager;
pub use environment::LOCAL_ENVIRONMENT_ID;
pub use environment::REMOTE_ENVIRONMENT_ID;
pub use environment_path::EnvironmentPathRef;
pub use environment_provider::DefaultEnvironmentProvider;
pub use environment_provider::EnvironmentProvider;
pub use fs_helper::CODEX_FS_HELPER_ARG1;

View File

@@ -674,6 +674,7 @@ pub(super) async fn fetch_skills_list(
request_id,
params: SkillsListParams {
cwds: vec![cwd],
environment_id: None,
force_reload: true,
},
})

View File

@@ -621,6 +621,7 @@ impl App {
app_server
.skills_list(codex_app_server_protocol::SkillsListParams {
cwds: cwds.clone(),
environment_id: None,
force_reload: *force_reload,
})
.await,