Files
codex/codex-rs/core-skills/src/model.rs
T
jifandGitHub d46a98d31a Bridge host-loaded skills into the skills extension (#26172)
## Why

The skills extension needs to become the path that exposes local host
skills without losing the behavior already owned by core skill loading.
Host skill discovery is not just `$CODEX_HOME/skills`: it also includes
config layers, bundled-skill settings, plugin roots, runtime extra
roots, and the filesystem for the selected primary environment.

Rather than making the extension reload host skills and risk drifting
from that authoritative load, this PR bridges the already-loaded
per-turn skills outcome into the extension. That lets the extension
advertise host skills and inject explicit `$skill` prompts while
preserving the same roots, disabled/hidden state, rendered paths, and
environment-backed file reads that the legacy path uses.

## What Changed

- Adds `HostLoadedSkills` in `core-skills` to wrap the turn's
`SkillLoadOutcome` and read `SKILL.md` through the filesystem that
loaded that skill.
- Stores `HostLoadedSkills` in turn extension data for normal turns and
review turns, so the skills extension can consume the loaded host
catalog without reloading it.
- Adds `HostSkillProvider` under `ext/skills/src/provider/host.rs`,
mapping host-loaded skill metadata into the skills-extension
catalog/read contract.
- Registers the host provider by default from
`codex_skills_extension::install()`.
- Preserves host skill metadata such as dependencies, disabled state,
hidden-from-prompt policy, and slash-normalized display paths.
- Passes host-loaded skills through `SkillListQuery` and
`SkillReadRequest` so explicit skill invocation reads only resources
from the loaded host catalog.
- Adds integration coverage for a real legacy
`$CODEX_HOME/skills/.../SKILL.md` skill being listed and injected
through the installed extension.

## Testing

- Added `installed_extension_loads_host_skills_from_legacy_roots` in
`ext/skills/tests/skills_extension.rs`.
- `just test -p codex-skills-extension`
2026-06-04 15:28:06 +02:00

241 lines
7.4 KiB
Rust

use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt;
use std::io;
use std::sync::Arc;
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;
#[derive(Debug, Clone, PartialEq)]
pub struct SkillMetadata {
pub name: String,
pub description: String,
pub short_description: Option<String>,
pub interface: Option<SkillInterface>,
pub dependencies: Option<SkillDependencies>,
pub policy: Option<SkillPolicy>,
/// Path to the SKILLS.md file that declares this skill.
pub path_to_skills_md: AbsolutePathBuf,
pub scope: SkillScope,
pub plugin_id: Option<String>,
}
impl SkillMetadata {
pub fn allows_implicit_invocation(&self) -> bool {
self.policy
.as_ref()
.and_then(|policy| policy.allow_implicit_invocation)
.unwrap_or(true)
}
pub fn matches_product_restriction_for_product(
&self,
restriction_product: Option<Product>,
) -> bool {
match &self.policy {
Some(policy) => {
policy.products.is_empty()
|| restriction_product.is_some_and(|product| {
product.matches_product_restriction(&policy.products)
})
}
None => true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SkillPolicy {
pub allow_implicit_invocation: Option<bool>,
// TODO: Enforce product gating in Codex skill selection/injection instead of only parsing and
// storing this metadata.
pub products: Vec<Product>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillInterface {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub icon_small: Option<AbsolutePathBuf>,
pub icon_large: Option<AbsolutePathBuf>,
pub brand_color: Option<String>,
pub default_prompt: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillDependencies {
pub tools: Vec<SkillToolDependency>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillToolDependency {
pub r#type: String,
pub value: String,
pub description: Option<String>,
pub transport: Option<String>,
pub command: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillError {
pub path: AbsolutePathBuf,
pub message: String,
}
#[derive(Debug, Clone, Default)]
pub struct SkillLoadOutcome {
pub skills: Vec<SkillMetadata>,
pub errors: Vec<SkillError>,
pub disabled_paths: HashSet<AbsolutePathBuf>,
pub(crate) skill_roots: Vec<AbsolutePathBuf>,
pub(crate) skill_root_by_path: Arc<HashMap<AbsolutePathBuf, 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>>,
}
impl SkillLoadOutcome {
pub fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool {
!self.disabled_paths.contains(&skill.path_to_skills_md)
}
pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool {
self.is_skill_enabled(skill) && skill.allows_implicit_invocation()
}
pub fn allowed_skills_for_implicit_invocation(&self) -> Vec<SkillMetadata> {
self.skills
.iter()
.filter(|skill| self.is_skill_allowed_for_implicit_invocation(skill))
.cloned()
.collect()
}
pub fn skills_with_enabled(&self) -> impl Iterator<Item = (&SkillMetadata, bool)> {
self.skills
.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)
}
}
/// Host-loaded skills for one turn, including the filesystem mapping needed to
/// read skill bodies through the environment that loaded them.
#[derive(Debug, Clone)]
pub struct HostLoadedSkills {
outcome: Arc<SkillLoadOutcome>,
}
impl HostLoadedSkills {
pub fn new(outcome: Arc<SkillLoadOutcome>) -> Self {
Self { outcome }
}
pub fn outcome(&self) -> &SkillLoadOutcome {
self.outcome.as_ref()
}
pub async fn read_skill_text(&self, skill: &SkillMetadata) -> io::Result<String> {
let fs = self
.outcome
.file_system_for_skill(skill)
.unwrap_or_else(|| Arc::clone(&LOCAL_FS));
fs.read_file_text(&skill.path_to_skills_md, /*sandbox*/ None)
.await
}
}
#[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(
mut outcome: SkillLoadOutcome,
restriction_product: Option<Product>,
) -> SkillLoadOutcome {
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.skill_root_by_path = Arc::new(
outcome
.skill_root_by_path
.iter()
.filter(|(path, _)| retained_paths.contains(*path))
.map(|(path, root)| (path.clone(), root.clone()))
.collect(),
);
let retained_roots: HashSet<AbsolutePathBuf> =
outcome.skill_root_by_path.values().cloned().collect();
outcome
.skill_roots
.retain(|root| retained_roots.contains(root));
outcome.implicit_skills_by_scripts_dir = Arc::new(
outcome
.implicit_skills_by_scripts_dir
.iter()
.filter(|(_, skill)| skill.matches_product_restriction_for_product(restriction_product))
.map(|(path, skill)| (path.clone(), skill.clone()))
.collect(),
);
outcome.implicit_skills_by_doc_path = Arc::new(
outcome
.implicit_skills_by_doc_path
.iter()
.filter(|(_, skill)| skill.matches_product_restriction_for_product(restriction_product))
.map(|(path, skill)| (path.clone(), skill.clone()))
.collect(),
);
outcome
}