mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
feat: support disable skills by name. (#15378)
Support disabling skills by name, primarily for plugin skills. We can’t use the path, since plugin skill paths may change across versions.
This commit is contained in:
@@ -46,8 +46,10 @@ pub enum ConfigEdit {
|
||||
RecordModelMigrationSeen { from: String, to: String },
|
||||
/// Replace the entire `[mcp_servers]` table.
|
||||
ReplaceMcpServers(BTreeMap<String, McpServerConfig>),
|
||||
/// Set or clear a skill config entry under `[[skills.config]]`.
|
||||
/// Set or clear a skill config entry under `[[skills.config]]` by path.
|
||||
SetSkillConfig { path: PathBuf, enabled: bool },
|
||||
/// Set or clear a skill config entry under `[[skills.config]]` by name.
|
||||
SetSkillConfigByName { name: String, enabled: bool },
|
||||
/// Set trust_level under `[projects."<path>"]`,
|
||||
/// migrating inline tables to explicit tables.
|
||||
SetProjectTrustLevel { path: PathBuf, level: TrustLevel },
|
||||
@@ -60,6 +62,12 @@ pub enum ConfigEdit {
|
||||
ClearPath { segments: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum SkillConfigSelector {
|
||||
Name(String),
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
/// Produces a config edit that sets `[tui].theme = "<name>"`.
|
||||
pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
|
||||
ConfigEdit::SetPath {
|
||||
@@ -387,7 +395,10 @@ impl ConfigDocument {
|
||||
)),
|
||||
ConfigEdit::ReplaceMcpServers(servers) => Ok(self.replace_mcp_servers(servers)),
|
||||
ConfigEdit::SetSkillConfig { path, enabled } => {
|
||||
Ok(self.set_skill_config(path.as_path(), *enabled))
|
||||
Ok(self.set_skill_config(SkillConfigSelector::Path(path.clone()), *enabled))
|
||||
}
|
||||
ConfigEdit::SetSkillConfigByName { name, enabled } => {
|
||||
Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled))
|
||||
}
|
||||
ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())),
|
||||
ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)),
|
||||
@@ -478,8 +489,16 @@ impl ConfigDocument {
|
||||
true
|
||||
}
|
||||
|
||||
fn set_skill_config(&mut self, path: &Path, enabled: bool) -> bool {
|
||||
let normalized_path = normalize_skill_config_path(path);
|
||||
fn set_skill_config(&mut self, selector: SkillConfigSelector, enabled: bool) -> bool {
|
||||
let selector = match selector {
|
||||
SkillConfigSelector::Name(name) => SkillConfigSelector::Name(name.trim().to_string()),
|
||||
SkillConfigSelector::Path(path) => {
|
||||
SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(&path)))
|
||||
}
|
||||
};
|
||||
if matches!(&selector, SkillConfigSelector::Name(name) if name.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
let mut remove_skills_table = false;
|
||||
let mut mutated = false;
|
||||
|
||||
@@ -538,12 +557,8 @@ impl ConfigDocument {
|
||||
};
|
||||
|
||||
let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| {
|
||||
table
|
||||
.get("path")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(Path::new)
|
||||
.map(normalize_skill_config_path)
|
||||
.filter(|value| *value == normalized_path)
|
||||
skill_config_selector_from_table(table)
|
||||
.filter(|value| value == &selector)
|
||||
.map(|_| idx)
|
||||
});
|
||||
|
||||
@@ -561,7 +576,7 @@ impl ConfigDocument {
|
||||
} else if let Some(index) = existing_index {
|
||||
for (idx, table) in overrides.iter_mut().enumerate() {
|
||||
if idx == index {
|
||||
table["path"] = value(normalized_path);
|
||||
write_skill_config_selector(table, &selector);
|
||||
table["enabled"] = value(false);
|
||||
mutated = true;
|
||||
break;
|
||||
@@ -570,7 +585,7 @@ impl ConfigDocument {
|
||||
} else {
|
||||
let mut entry = TomlTable::new();
|
||||
entry.set_implicit(false);
|
||||
entry["path"] = value(normalized_path);
|
||||
write_skill_config_selector(&mut entry, &selector);
|
||||
entry["enabled"] = value(false);
|
||||
overrides.push(entry);
|
||||
mutated = true;
|
||||
@@ -699,6 +714,38 @@ fn normalize_skill_config_path(path: &Path) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn skill_config_selector_from_table(table: &TomlTable) -> Option<SkillConfigSelector> {
|
||||
let path = table
|
||||
.get("path")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(Path::new)
|
||||
.map(|path| SkillConfigSelector::Path(PathBuf::from(normalize_skill_config_path(path))));
|
||||
let name = table
|
||||
.get("name")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(|name| SkillConfigSelector::Name(name.to_string()));
|
||||
|
||||
match (path, name) {
|
||||
(Some(selector), None) | (None, Some(selector)) => Some(selector),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSelector) {
|
||||
match selector {
|
||||
SkillConfigSelector::Name(name) => {
|
||||
table.remove("path");
|
||||
table["name"] = value(name.clone());
|
||||
}
|
||||
SkillConfigSelector::Path(path) => {
|
||||
table.remove("name");
|
||||
table["path"] = value(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist edits using a blocking strategy.
|
||||
pub fn apply_blocking(
|
||||
codex_home: &Path,
|
||||
|
||||
@@ -110,6 +110,27 @@ enabled = false
|
||||
assert_eq!(contents, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_skill_config_writes_name_selector_entry() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.with_edits([ConfigEdit::SetSkillConfigByName {
|
||||
name: "github:yeet".to_string(),
|
||||
enabled: false,
|
||||
}])
|
||||
.apply_blocking()
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = false
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_model_preserves_inline_table_contents() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
|
||||
@@ -804,7 +804,12 @@ impl Notice {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SkillConfig {
|
||||
pub path: AbsolutePathBuf,
|
||||
/// Path-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<AbsolutePathBuf>,
|
||||
/// Name-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,47 @@ fn deserialize_server_config_with_tool_filters() {
|
||||
assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_skill_config_with_name_selector() {
|
||||
let cfg: SkillConfig = toml::from_str(
|
||||
r#"
|
||||
name = "github:yeet"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize skill config with name selector");
|
||||
|
||||
assert_eq!(cfg.name.as_deref(), Some("github:yeet"));
|
||||
assert_eq!(cfg.path, None);
|
||||
assert!(!cfg.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_skill_config_with_path_selector() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let cfg: SkillConfig = toml::from_str(&format!(
|
||||
r#"
|
||||
path = {path:?}
|
||||
enabled = false
|
||||
"#,
|
||||
path = skill_path.display().to_string(),
|
||||
))
|
||||
.expect("should deserialize skill config with path selector");
|
||||
|
||||
assert_eq!(
|
||||
cfg,
|
||||
SkillConfig {
|
||||
path: Some(
|
||||
AbsolutePathBuf::from_absolute_path(&skill_path)
|
||||
.expect("skill path should be absolute"),
|
||||
),
|
||||
name: None,
|
||||
enabled: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_command_and_url() {
|
||||
toml::from_str::<McpServerConfig>(
|
||||
|
||||
@@ -19,7 +19,6 @@ use super::remote::fetch_remote_featured_plugin_ids;
|
||||
use super::remote::fetch_remote_plugin_status;
|
||||
use super::remote::uninstall_remote_plugin;
|
||||
use super::startup_sync::start_startup_remote_plugin_sync_once;
|
||||
use super::store::DEFAULT_PLUGIN_VERSION;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use super::store::PluginInstallResult as StorePluginInstallResult;
|
||||
@@ -38,6 +37,9 @@ use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::PluginConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::config_rules::SkillConfigRules;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use crate::skills::loader::SkillRoot;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
@@ -152,6 +154,7 @@ pub struct PluginDetail {
|
||||
pub installed: bool,
|
||||
pub enabled: bool,
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
pub disabled_skill_paths: HashSet<PathBuf>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
}
|
||||
@@ -183,6 +186,8 @@ pub struct LoadedPlugin {
|
||||
pub root: AbsolutePathBuf,
|
||||
pub enabled: bool,
|
||||
pub skill_roots: Vec<PathBuf>,
|
||||
pub disabled_skill_paths: HashSet<PathBuf>,
|
||||
pub has_enabled_skills: bool,
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub error: Option<String>,
|
||||
@@ -235,7 +240,7 @@ impl PluginCapabilitySummary {
|
||||
.clone()
|
||||
.unwrap_or_else(|| plugin.config_name.clone()),
|
||||
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
|
||||
has_skills: !plugin.skill_roots.is_empty(),
|
||||
has_skills: plugin.has_enabled_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: plugin.apps.clone(),
|
||||
};
|
||||
@@ -258,11 +263,16 @@ impl PluginCapabilitySummary {
|
||||
|
||||
impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
fn from(value: PluginDetail) -> Self {
|
||||
let has_skills = value.skills.iter().any(|skill| {
|
||||
!value
|
||||
.disabled_skill_paths
|
||||
.contains(&skill.path_to_skills_md)
|
||||
});
|
||||
Self {
|
||||
config_name: value.id,
|
||||
display_name: value.name,
|
||||
description: prompt_safe_plugin_description(value.description.as_deref()),
|
||||
has_skills: !value.skills.is_empty(),
|
||||
has_skills,
|
||||
mcp_server_names: value.mcp_server_names,
|
||||
app_connector_ids: value.apps,
|
||||
}
|
||||
@@ -531,7 +541,11 @@ impl PluginsManager {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let outcome = load_plugins_from_layer_stack(&config.config_layer_stack, &self.store);
|
||||
let outcome = load_plugins_from_layer_stack(
|
||||
&config.config_layer_stack,
|
||||
&self.store,
|
||||
self.restriction_product,
|
||||
);
|
||||
log_plugin_load_errors(&outcome);
|
||||
let mut cache = match self.cached_enabled_outcome.write() {
|
||||
Ok(cache) => cache,
|
||||
@@ -1070,6 +1084,11 @@ impl PluginsManager {
|
||||
let source_path = match &plugin.source {
|
||||
MarketplacePluginSource::Local { path } => path.clone(),
|
||||
};
|
||||
if !source_path.as_path().is_dir() {
|
||||
return Err(MarketplaceError::InvalidPlugin(
|
||||
"path does not exist or is not a directory".to_string(),
|
||||
));
|
||||
}
|
||||
let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| {
|
||||
MarketplaceError::InvalidPlugin(
|
||||
"missing or invalid .codex-plugin/plugin.json".to_string(),
|
||||
@@ -1077,15 +1096,13 @@ impl PluginsManager {
|
||||
})?;
|
||||
let description = manifest.description.clone();
|
||||
let manifest_paths = &manifest.paths;
|
||||
let skill_roots = plugin_skill_roots(source_path.as_path(), manifest_paths);
|
||||
let skills = load_skills_from_roots(skill_roots.into_iter().map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
}))
|
||||
.skills
|
||||
.into_iter()
|
||||
.filter(|skill| skill.matches_product_restriction_for_product(self.restriction_product))
|
||||
.collect();
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack);
|
||||
let resolved_skills = load_plugin_skills(
|
||||
source_path.as_path(),
|
||||
manifest_paths,
|
||||
self.restriction_product,
|
||||
&skill_config_rules,
|
||||
);
|
||||
let apps = load_plugin_apps(source_path.as_path());
|
||||
let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), manifest_paths);
|
||||
let mut mcp_server_names = Vec::new();
|
||||
@@ -1111,7 +1128,8 @@ impl PluginsManager {
|
||||
interface: plugin.interface,
|
||||
installed: installed_plugins.contains(&plugin_key),
|
||||
enabled: enabled_plugins.contains(&plugin_key),
|
||||
skills,
|
||||
skills: resolved_skills.skills,
|
||||
disabled_skill_paths: resolved_skills.disabled_skill_paths,
|
||||
apps,
|
||||
mcp_server_names,
|
||||
},
|
||||
@@ -1347,7 +1365,9 @@ struct PluginAppConfig {
|
||||
pub(crate) fn load_plugins_from_layer_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
store: &PluginStore,
|
||||
restriction_product: Option<Product>,
|
||||
) -> PluginLoadOutcome {
|
||||
let skill_config_rules = skill_config_rules_from_stack(config_layer_stack);
|
||||
let mut configured_plugins: Vec<_> = configured_plugins_from_stack(config_layer_stack)
|
||||
.into_iter()
|
||||
.collect();
|
||||
@@ -1356,7 +1376,13 @@ pub(crate) fn load_plugins_from_layer_stack(
|
||||
let mut plugins = Vec::with_capacity(configured_plugins.len());
|
||||
let mut seen_mcp_server_names = HashMap::<String, String>::new();
|
||||
for (configured_name, plugin) in configured_plugins {
|
||||
let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store);
|
||||
let loaded_plugin = load_plugin(
|
||||
configured_name.clone(),
|
||||
&plugin,
|
||||
store,
|
||||
restriction_product,
|
||||
&skill_config_rules,
|
||||
);
|
||||
for name in loaded_plugin.mcp_servers.keys() {
|
||||
if let Some(previous_plugin) =
|
||||
seen_mcp_server_names.insert(name.clone(), configured_name.clone())
|
||||
@@ -1463,16 +1489,24 @@ fn configured_plugins_from_stack(
|
||||
}
|
||||
}
|
||||
|
||||
fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) -> LoadedPlugin {
|
||||
let plugin_root = PluginId::parse(&config_name).map(|plugin_id| {
|
||||
store
|
||||
.active_plugin_root(&plugin_id)
|
||||
.unwrap_or_else(|| store.plugin_root(&plugin_id, DEFAULT_PLUGIN_VERSION))
|
||||
});
|
||||
let root = match &plugin_root {
|
||||
Ok(plugin_root) => plugin_root.clone(),
|
||||
Err(_) => store.root().clone(),
|
||||
};
|
||||
fn load_plugin(
|
||||
config_name: String,
|
||||
plugin: &PluginConfig,
|
||||
store: &PluginStore,
|
||||
restriction_product: Option<Product>,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> LoadedPlugin {
|
||||
let plugin_id = PluginId::parse(&config_name);
|
||||
let active_plugin_root = plugin_id
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|plugin_id| store.active_plugin_root(plugin_id));
|
||||
let root = active_plugin_root
|
||||
.clone()
|
||||
.unwrap_or_else(|| match &plugin_id {
|
||||
Ok(plugin_id) => store.plugin_base_root(plugin_id),
|
||||
Err(_) => store.root().clone(),
|
||||
});
|
||||
let mut loaded_plugin = LoadedPlugin {
|
||||
config_name,
|
||||
manifest_name: None,
|
||||
@@ -1480,6 +1514,8 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
||||
root,
|
||||
enabled: plugin.enabled,
|
||||
skill_roots: Vec::new(),
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
error: None,
|
||||
@@ -1489,8 +1525,14 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
||||
return loaded_plugin;
|
||||
}
|
||||
|
||||
let plugin_root = match plugin_root {
|
||||
Ok(plugin_root) => plugin_root,
|
||||
let plugin_root = match plugin_id {
|
||||
Ok(_) => match active_plugin_root {
|
||||
Some(plugin_root) => plugin_root,
|
||||
None => {
|
||||
loaded_plugin.error = Some("plugin is not installed".to_string());
|
||||
return loaded_plugin;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
loaded_plugin.error = Some(err.to_string());
|
||||
return loaded_plugin;
|
||||
@@ -1511,6 +1553,15 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
||||
loaded_plugin.manifest_name = Some(manifest.name.clone());
|
||||
loaded_plugin.manifest_description = manifest.description.clone();
|
||||
loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), manifest_paths);
|
||||
let resolved_skills = load_plugin_skills(
|
||||
plugin_root.as_path(),
|
||||
manifest_paths,
|
||||
restriction_product,
|
||||
skill_config_rules,
|
||||
);
|
||||
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;
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) {
|
||||
let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path);
|
||||
@@ -1530,6 +1581,52 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
||||
loaded_plugin
|
||||
}
|
||||
|
||||
struct ResolvedPluginSkills {
|
||||
skills: Vec<SkillMetadata>,
|
||||
disabled_skill_paths: HashSet<PathBuf>,
|
||||
had_errors: bool,
|
||||
}
|
||||
|
||||
impl ResolvedPluginSkills {
|
||||
fn has_enabled_skills(&self) -> bool {
|
||||
// Keep the plugin visible in capability summaries if skill loading was partial.
|
||||
self.had_errors
|
||||
|| self
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| !self.disabled_skill_paths.contains(&skill.path_to_skills_md))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_plugin_skills(
|
||||
plugin_root: &Path,
|
||||
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 had_errors = !outcome.errors.is_empty();
|
||||
let skills = outcome
|
||||
.skills
|
||||
.into_iter()
|
||||
.filter(|skill| skill.matches_product_restriction_for_product(restriction_product))
|
||||
.collect::<Vec<_>>();
|
||||
let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules);
|
||||
|
||||
ResolvedPluginSkills {
|
||||
skills,
|
||||
disabled_skill_paths,
|
||||
had_errors,
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_skill_roots(plugin_root: &Path, manifest_paths: &PluginManifestPaths) -> Vec<PathBuf> {
|
||||
let mut paths = default_skill_roots(plugin_root);
|
||||
if let Some(path) = &manifest_paths.skills {
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::protocol::Product;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
@@ -139,6 +140,8 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
|
||||
enabled: true,
|
||||
skill_roots: vec![plugin_root.join("skills")],
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: true,
|
||||
mcp_servers: HashMap::from([(
|
||||
"sample".to_string(),
|
||||
McpServerConfig {
|
||||
@@ -185,6 +188,89 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join("test/sample/local");
|
||||
let skill_path = plugin_root.join("skills/sample-search/SKILL.md");
|
||||
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
);
|
||||
write_file(
|
||||
&skill_path,
|
||||
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
||||
);
|
||||
|
||||
let config_toml = r#"[features]
|
||||
plugins = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "sample:sample-search"
|
||||
enabled = false
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#;
|
||||
let outcome = load_plugins_from_config(config_toml, codex_home.path());
|
||||
let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize");
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].disabled_skill_paths,
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
assert!(!outcome.plugins[0].has_enabled_skills);
|
||||
assert!(outcome.capability_summaries().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugins_ignores_unknown_disabled_skill_names() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join("test/sample/local");
|
||||
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("skills/sample-search/SKILL.md"),
|
||||
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
||||
);
|
||||
|
||||
let config_toml = r#"[features]
|
||||
plugins = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "sample:missing-skill"
|
||||
enabled = false
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#;
|
||||
let outcome = load_plugins_from_config(config_toml, codex_home.path());
|
||||
|
||||
assert!(outcome.plugins[0].disabled_skill_paths.is_empty());
|
||||
assert!(outcome.plugins[0].has_enabled_skills);
|
||||
assert_eq!(
|
||||
outcome.capability_summaries(),
|
||||
&[PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_telemetry_metadata_uses_default_mcp_config_path() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -540,6 +626,8 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
|
||||
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
|
||||
enabled: false,
|
||||
skill_roots: Vec::new(),
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
error: None,
|
||||
@@ -651,6 +739,8 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(),
|
||||
enabled: true,
|
||||
skill_roots: Vec::new(),
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
error: None,
|
||||
@@ -664,6 +754,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
let outcome = PluginLoadOutcome::from_plugins(vec![
|
||||
LoadedPlugin {
|
||||
skill_roots: vec![codex_home.path().join("skills-plugin/skills")],
|
||||
has_enabled_skills: true,
|
||||
..plugin("skills@test", "skills-plugin", "skills-plugin")
|
||||
},
|
||||
LoadedPlugin {
|
||||
@@ -1166,6 +1257,70 @@ enabled = true
|
||||
assert!(matches!(err, MarketplaceError::PluginsDisabled));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_plugin_for_config_uses_user_layer_skill_settings_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let plugin_root = repo_root.join("enabled-plugin");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
write_file(
|
||||
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "enabled-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./enabled-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"enabled-plugin"}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("skills/sample-search/SKILL.md"),
|
||||
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
||||
);
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."enabled-plugin@debug"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
write_file(
|
||||
&repo_root.join(".codex/config.toml"),
|
||||
r#"[[skills.config]]
|
||||
name = "enabled-plugin:sample-search"
|
||||
enabled = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let outcome = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.read_plugin_for_config(
|
||||
&config,
|
||||
&PluginReadRequest {
|
||||
plugin_name: "enabled-plugin".to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.plugin.disabled_skill_paths.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2082,8 +2237,11 @@ fn load_plugins_ignores_project_config_files() {
|
||||
)
|
||||
.expect("config layer stack should build");
|
||||
|
||||
let outcome =
|
||||
load_plugins_from_layer_stack(&stack, &PluginStore::new(codex_home.path().to_path_buf()));
|
||||
let outcome = load_plugins_from_layer_stack(
|
||||
&stack,
|
||||
&PluginStore::new(codex_home.path().to_path_buf()),
|
||||
Some(Product::Codex),
|
||||
);
|
||||
|
||||
assert_eq!(outcome, PluginLoadOutcome::default());
|
||||
}
|
||||
|
||||
@@ -110,10 +110,15 @@ impl PluginStore {
|
||||
.filter(|version| validate_plugin_segment(version, "plugin version").is_ok())
|
||||
.collect::<Vec<_>>();
|
||||
discovered_versions.sort_unstable();
|
||||
if discovered_versions.len() == 1 {
|
||||
discovered_versions.pop()
|
||||
} else {
|
||||
if discovered_versions.is_empty() {
|
||||
None
|
||||
} else if discovered_versions
|
||||
.iter()
|
||||
.any(|version| version == DEFAULT_PLUGIN_VERSION)
|
||||
{
|
||||
Some(DEFAULT_PLUGIN_VERSION.to_string())
|
||||
} else {
|
||||
discovered_versions.pop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,50 @@ fn active_plugin_version_reads_version_directory_name() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/0123456789abcdef",
|
||||
"sample-plugin",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/local",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("local".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/0123456789abcdef",
|
||||
"sample-plugin",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/fedcba9876543210",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("fedcba9876543210".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_root_rejects_path_separators_in_key_segments() {
|
||||
let err = PluginId::parse("../../etc@debug").unwrap_err();
|
||||
|
||||
135
codex-rs/core/src/skills/config_rules.rs
Normal file
135
codex-rs/core/src/skills/config_rules.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::types::SkillConfig;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub(crate) enum SkillConfigRuleSelector {
|
||||
Name(String),
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct SkillConfigRule {
|
||||
pub selector: SkillConfigRuleSelector,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct SkillConfigRules {
|
||||
pub entries: Vec<SkillConfigRule>,
|
||||
}
|
||||
|
||||
pub(crate) fn skill_config_rules_from_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> SkillConfigRules {
|
||||
let mut entries = Vec::new();
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
) {
|
||||
if !matches!(
|
||||
layer.name,
|
||||
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(skills_value) = layer.config.get("skills") else {
|
||||
continue;
|
||||
};
|
||||
let skills: SkillsConfig = match skills_value.clone().try_into() {
|
||||
Ok(skills) => skills,
|
||||
Err(err) => {
|
||||
warn!("invalid skills config: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in skills.config {
|
||||
let Some(selector) = skill_config_rule_selector(&entry) else {
|
||||
continue;
|
||||
};
|
||||
// Preserve layer order so a later name selector can override an earlier path selector
|
||||
// for the same loaded skill.
|
||||
entries.retain(|entry: &SkillConfigRule| entry.selector != selector);
|
||||
entries.push(SkillConfigRule {
|
||||
selector,
|
||||
enabled: entry.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SkillConfigRules { entries }
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_disabled_skill_paths(
|
||||
skills: &[SkillMetadata],
|
||||
rules: &SkillConfigRules,
|
||||
) -> HashSet<PathBuf> {
|
||||
let mut disabled_paths = HashSet::new();
|
||||
|
||||
for entry in &rules.entries {
|
||||
match &entry.selector {
|
||||
SkillConfigRuleSelector::Path(path) => {
|
||||
if entry.enabled {
|
||||
disabled_paths.remove(path);
|
||||
} else {
|
||||
disabled_paths.insert(path.clone());
|
||||
}
|
||||
}
|
||||
SkillConfigRuleSelector::Name(name) => {
|
||||
for path in skills
|
||||
.iter()
|
||||
.filter(|skill| skill.name == *name)
|
||||
.map(|skill| skill.path_to_skills_md.clone())
|
||||
{
|
||||
if entry.enabled {
|
||||
disabled_paths.remove(&path);
|
||||
} else {
|
||||
disabled_paths.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disabled_paths
|
||||
}
|
||||
|
||||
fn skill_config_rule_selector(entry: &SkillConfig) -> Option<SkillConfigRuleSelector> {
|
||||
match (entry.path.as_ref(), entry.name.as_deref()) {
|
||||
(Some(path), None) => Some(SkillConfigRuleSelector::Path(normalize_rule_path(
|
||||
path.as_path(),
|
||||
))),
|
||||
(None, Some(name)) => {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
warn!("ignoring empty skills.config name override");
|
||||
None
|
||||
} else {
|
||||
Some(SkillConfigRuleSelector::Name(name.to_string()))
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
warn!("ignoring skills.config entry with both path and name selectors");
|
||||
None
|
||||
}
|
||||
(None, None) => {
|
||||
warn!("ignoring skills.config entry without a path or name selector");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_rule_path(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
@@ -5,7 +5,6 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -16,12 +15,14 @@ use tracing::warn;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::build_implicit_skill_path_indexes;
|
||||
use crate::skills::config_rules::SkillConfigRules;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use crate::skills::loader::SkillRoot;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use crate::skills::loader::skill_roots;
|
||||
@@ -81,15 +82,13 @@ impl SkillsManager {
|
||||
/// to share a directory.
|
||||
pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome {
|
||||
let roots = self.skill_roots_for_config(config);
|
||||
let cache_key = config_skills_cache_key(&roots, &config.config_layer_stack);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config.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 = crate::skills::filter_skill_load_outcome_for_product(
|
||||
finalize_skill_outcome(load_skills_from_roots(roots), &config.config_layer_stack),
|
||||
self.restriction_product,
|
||||
);
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
|
||||
let mut cache = self
|
||||
.cache_by_config
|
||||
.write()
|
||||
@@ -192,7 +191,8 @@ impl SkillsManager {
|
||||
scope: SkillScope::User,
|
||||
}),
|
||||
);
|
||||
let outcome = self.build_skill_outcome(roots, &config_layer_stack);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config_layer_stack);
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
|
||||
let mut cache = self
|
||||
.cache_by_cwd
|
||||
.write()
|
||||
@@ -204,12 +204,14 @@ impl SkillsManager {
|
||||
fn build_skill_outcome(
|
||||
&self,
|
||||
roots: Vec<SkillRoot>,
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> SkillLoadOutcome {
|
||||
crate::skills::filter_skill_load_outcome_for_product(
|
||||
finalize_skill_outcome(load_skills_from_roots(roots), config_layer_stack),
|
||||
let outcome = crate::skills::filter_skill_load_outcome_for_product(
|
||||
load_skills_from_roots(roots),
|
||||
self.restriction_product,
|
||||
)
|
||||
);
|
||||
let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules);
|
||||
finalize_skill_outcome(outcome, disabled_paths)
|
||||
}
|
||||
|
||||
pub fn clear_cache(&self) {
|
||||
@@ -256,7 +258,7 @@ impl SkillsManager {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ConfigSkillsCacheKey {
|
||||
roots: Vec<(PathBuf, u8)>,
|
||||
disabled_paths: Vec<PathBuf>,
|
||||
skill_config_rules: SkillConfigRules,
|
||||
}
|
||||
|
||||
pub(crate) fn bundled_skills_enabled_from_stack(
|
||||
@@ -281,53 +283,10 @@ pub(crate) fn bundled_skills_enabled_from_stack(
|
||||
skills.bundled.unwrap_or_default().enabled
|
||||
}
|
||||
|
||||
fn disabled_paths_from_stack(
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
) -> HashSet<PathBuf> {
|
||||
let mut configs = HashMap::new();
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
) {
|
||||
if !matches!(
|
||||
layer.name,
|
||||
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(skills_value) = layer.config.get("skills") else {
|
||||
continue;
|
||||
};
|
||||
let skills: SkillsConfig = match skills_value.clone().try_into() {
|
||||
Ok(skills) => skills,
|
||||
Err(err) => {
|
||||
warn!("invalid skills config: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in skills.config {
|
||||
let path = normalize_override_path(entry.path.as_path());
|
||||
configs.insert(path, entry.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
configs
|
||||
.into_iter()
|
||||
.filter_map(|(path, enabled)| (!enabled).then_some(path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn config_skills_cache_key(
|
||||
roots: &[SkillRoot],
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> ConfigSkillsCacheKey {
|
||||
let mut disabled_paths: Vec<PathBuf> = disabled_paths_from_stack(config_layer_stack)
|
||||
.into_iter()
|
||||
.collect();
|
||||
disabled_paths.sort_unstable();
|
||||
|
||||
ConfigSkillsCacheKey {
|
||||
roots: roots
|
||||
.iter()
|
||||
@@ -341,15 +300,15 @@ fn config_skills_cache_key(
|
||||
(root.path.clone(), scope_rank)
|
||||
})
|
||||
.collect(),
|
||||
disabled_paths,
|
||||
skill_config_rules: skill_config_rules.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_skill_outcome(
|
||||
mut outcome: SkillLoadOutcome,
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
disabled_paths: HashSet<PathBuf>,
|
||||
) -> SkillLoadOutcome {
|
||||
outcome.disabled_paths = disabled_paths_from_stack(config_layer_stack);
|
||||
outcome.disabled_paths = disabled_paths;
|
||||
let (by_scripts_dir, by_doc_path) =
|
||||
build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation());
|
||||
outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir);
|
||||
@@ -357,10 +316,6 @@ fn finalize_skill_outcome(
|
||||
outcome
|
||||
}
|
||||
|
||||
fn normalize_override_path(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn normalize_extra_user_roots(extra_user_roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut normalized: Vec<PathBuf> = extra_user_roots
|
||||
.iter()
|
||||
|
||||
@@ -5,6 +5,10 @@ use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -17,6 +21,49 @@ fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &s
|
||||
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_plugin_skill(
|
||||
codex_home: &TempDir,
|
||||
marketplace: &str,
|
||||
plugin_name: &str,
|
||||
dir: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
) -> PathBuf {
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join(marketplace)
|
||||
.join(plugin_name)
|
||||
.join("local");
|
||||
let skill_dir = plugin_root.join("skills").join(dir);
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{plugin_name}"}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
fs::write(&skill_path, content).unwrap();
|
||||
skill_path
|
||||
}
|
||||
|
||||
fn test_skill(name: &str, path: PathBuf) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: name.to_string(),
|
||||
description: "test".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
permission_profile: None,
|
||||
managed_network_override: None,
|
||||
path_to_skills_md: path,
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
@@ -68,6 +115,68 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() {
|
||||
assert_eq!(outcome2.skills, outcome1.skills);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_disables_plugin_skills_by_name() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = write_plugin_skill(
|
||||
&codex_home,
|
||||
"test",
|
||||
"sample",
|
||||
"sample-search",
|
||||
"sample-search",
|
||||
"search sample data",
|
||||
);
|
||||
fs::write(
|
||||
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "sample:sample-search"
|
||||
enabled = false
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
plugins_manager,
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
|
||||
let outcome = skills_manager.skills_for_config(&config);
|
||||
let skill = outcome
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == "sample:sample-search")
|
||||
.expect("plugin skill should load");
|
||||
let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize");
|
||||
|
||||
assert_eq!(skill.path_to_skills_md, skill_path);
|
||||
assert!(outcome.disabled_paths.contains(&skill.path_to_skills_md));
|
||||
assert!(
|
||||
!outcome
|
||||
.allowed_skills_for_implicit_invocation()
|
||||
.iter()
|
||||
.any(|allowed_skill| allowed_skill.path_to_skills_md == skill.path_to_skills_md)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
@@ -282,9 +391,10 @@ fn normalize_extra_user_roots_is_stable_for_equivalent_inputs() {
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_from_stack_allows_session_flags_to_override_user_layer() {
|
||||
fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("demo-skill", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
@@ -316,14 +426,19 @@ enabled = true
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
assert_eq!(disabled_paths_from_stack(&stack), HashSet::new());
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_from_stack_allows_session_flags_to_disable_user_enabled_skill() {
|
||||
fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("demo-skill", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
@@ -355,12 +470,88 @@ enabled = false
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
disabled_paths_from_stack(&stack),
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_for_skills_disables_matching_name_selectors() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("github:yeet", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(
|
||||
r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("user layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("github:yeet", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("user layer toml"),
|
||||
);
|
||||
let session_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(
|
||||
r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("session layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer, session_layer],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod config_rules;
|
||||
mod env_var_dependencies;
|
||||
pub mod injection;
|
||||
pub(crate) mod invocation_utils;
|
||||
|
||||
Reference in New Issue
Block a user