mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
Preserve plugin manifest precedence
This commit is contained in:
@@ -26,7 +26,6 @@ use codex_utils_plugins::PluginSkillRoot;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
@@ -502,7 +501,7 @@ async fn discover_skills_under_root(
|
||||
}
|
||||
|
||||
fn enqueue_dir(
|
||||
queue: &mut VecDeque<(AbsolutePathBuf, usize)>,
|
||||
queue: &mut Vec<(AbsolutePathBuf, usize)>,
|
||||
visited_dirs: &mut HashSet<AbsolutePathBuf>,
|
||||
truncated_by_dir_limit: &mut bool,
|
||||
path: AbsolutePathBuf,
|
||||
@@ -516,7 +515,7 @@ async fn discover_skills_under_root(
|
||||
return;
|
||||
}
|
||||
if visited_dirs.insert(path.clone()) {
|
||||
queue.push_back((path, depth));
|
||||
queue.push((path, depth));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,12 +528,12 @@ async fn discover_skills_under_root(
|
||||
let mut visited_dirs: HashSet<AbsolutePathBuf> = HashSet::new();
|
||||
visited_dirs.insert(root.clone());
|
||||
|
||||
let mut queue: VecDeque<(AbsolutePathBuf, usize)> = VecDeque::from([(root.clone(), 0)]);
|
||||
let mut queue = vec![(root.clone(), 0)];
|
||||
let mut truncated_by_dir_limit = false;
|
||||
let mut skill_paths = Vec::new();
|
||||
|
||||
while !queue.is_empty() {
|
||||
let dirs = queue.drain(..).collect::<Vec<_>>();
|
||||
let dirs = std::mem::take(&mut queue);
|
||||
let directory_results = match fs
|
||||
.execute_batch(
|
||||
dirs.iter()
|
||||
@@ -663,10 +662,11 @@ async fn discover_skills_under_root(
|
||||
}
|
||||
}
|
||||
|
||||
let mut plugin_namespace_resolver = PluginNamespaceResolver::default();
|
||||
if plugin_namespace.is_none() {
|
||||
plugin_namespace_resolver.prime(fs, &skill_paths).await;
|
||||
}
|
||||
let plugin_namespace_resolver = if plugin_namespace.is_none() {
|
||||
Some(PluginNamespaceResolver::load(fs, &skill_paths).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let preloaded_skills = match preload_skill_files(fs, &skill_paths).await {
|
||||
Ok(preloaded_skills) => preloaded_skills,
|
||||
Err(err) => {
|
||||
@@ -675,18 +675,19 @@ async fn discover_skills_under_root(
|
||||
}
|
||||
};
|
||||
for (path, preloaded) in skill_paths.into_iter().zip(preloaded_skills) {
|
||||
let plugin_namespace = plugin_namespace.or_else(|| {
|
||||
plugin_namespace_resolver
|
||||
.as_ref()
|
||||
.and_then(|resolver| resolver.resolve(&path))
|
||||
});
|
||||
match parse_skill_file(
|
||||
fs,
|
||||
&path,
|
||||
scope,
|
||||
plugin_id,
|
||||
plugin_namespace,
|
||||
plugin_root.as_ref(),
|
||||
&mut plugin_namespace_resolver,
|
||||
preloaded,
|
||||
)
|
||||
.await
|
||||
{
|
||||
) {
|
||||
Ok(skill) => outcome.skills.push(skill),
|
||||
Err(err) if scope != SkillScope::System => outcome.errors.push(SkillError {
|
||||
path,
|
||||
@@ -778,14 +779,12 @@ fn decode_text_operation_result(
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_skill_file(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
fn parse_skill_file(
|
||||
path: &AbsolutePathBuf,
|
||||
scope: SkillScope,
|
||||
plugin_id: Option<&str>,
|
||||
plugin_namespace: Option<&str>,
|
||||
plugin_root: Option<&AbsolutePathBuf>,
|
||||
plugin_namespace_resolver: &mut PluginNamespaceResolver,
|
||||
preloaded: PreloadedSkillFile,
|
||||
) -> Result<SkillMetadata, SkillParseError> {
|
||||
let PreloadedSkillFile {
|
||||
@@ -817,14 +816,9 @@ 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,
|
||||
plugin_namespace,
|
||||
plugin_namespace_resolver,
|
||||
)
|
||||
.await;
|
||||
let name = plugin_namespace
|
||||
.map(|namespace| format!("{namespace}:{base_name}"))
|
||||
.unwrap_or_else(|| base_name.clone());
|
||||
let description = parsed
|
||||
.description
|
||||
.as_deref()
|
||||
@@ -878,23 +872,6 @@ fn default_skill_name(path: &AbsolutePathBuf) -> String {
|
||||
.unwrap_or_else(|| "skill".to_string())
|
||||
}
|
||||
|
||||
async fn namespaced_skill_name(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
base_name: &str,
|
||||
plugin_namespace: Option<&str>,
|
||||
plugin_namespace_resolver: &mut PluginNamespaceResolver,
|
||||
) -> String {
|
||||
if let Some(plugin_namespace) = plugin_namespace {
|
||||
return format!("{plugin_namespace}:{base_name}");
|
||||
}
|
||||
plugin_namespace_resolver
|
||||
.resolve(fs, path)
|
||||
.await
|
||||
.map(|namespace| format!("{namespace}:{base_name}"))
|
||||
.unwrap_or_else(|| base_name.to_string())
|
||||
}
|
||||
|
||||
fn load_skill_metadata(
|
||||
skill_path: &AbsolutePathBuf,
|
||||
plugin_root: Option<&AbsolutePathBuf>,
|
||||
|
||||
@@ -41,56 +41,71 @@ fn plugin_manifest_name(plugin_root: &AbsolutePathBuf, contents: &[u8]) -> Optio
|
||||
}
|
||||
|
||||
/// Resolves plugin namespaces while caching and batching ancestor manifest probes.
|
||||
#[derive(Default)]
|
||||
pub struct PluginNamespaceResolver {
|
||||
manifests_by_root: HashMap<AbsolutePathBuf, Option<String>>,
|
||||
manifests_by_root: HashMap<AbsolutePathBuf, String>,
|
||||
}
|
||||
|
||||
impl PluginNamespaceResolver {
|
||||
pub async fn prime(&mut self, fs: &dyn ExecutorFileSystem, paths: &[AbsolutePathBuf]) {
|
||||
/// Loads plugin manifest names for every ancestor of `paths`.
|
||||
pub async fn load(fs: &dyn ExecutorFileSystem, paths: &[AbsolutePathBuf]) -> Self {
|
||||
let mut seen = HashSet::new();
|
||||
let unresolved_roots = paths
|
||||
let roots = paths
|
||||
.iter()
|
||||
.flat_map(AbsolutePathBuf::ancestors)
|
||||
.filter(|ancestor| !self.manifests_by_root.contains_key(ancestor))
|
||||
.filter(|ancestor| seen.insert(ancestor.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let operations = unresolved_roots
|
||||
let operations = roots
|
||||
.iter()
|
||||
.flat_map(|root| {
|
||||
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
||||
.iter()
|
||||
.map(|relative_path| FileSystemOperation::ReadFile {
|
||||
path: PathUri::from_abs_path(&root.join(relative_path)),
|
||||
.flat_map(|relative_path| {
|
||||
let path = PathUri::from_abs_path(&root.join(relative_path));
|
||||
[
|
||||
FileSystemOperation::GetMetadata { path: path.clone() },
|
||||
FileSystemOperation::ReadFile { path },
|
||||
]
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut results = match fs.execute_batch(operations, /*sandbox*/ None).await {
|
||||
Ok(results) => results.into_iter(),
|
||||
Err(_) => Vec::new().into_iter(),
|
||||
};
|
||||
for root in unresolved_roots {
|
||||
let mut name = None;
|
||||
for _ in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
|
||||
if let Some(Ok(FileSystemOperationOutput::ReadFile(contents))) = results.next()
|
||||
&& name.is_none()
|
||||
{
|
||||
name = plugin_manifest_name(&root, &contents);
|
||||
let mut results = fs
|
||||
.execute_batch(operations, /*sandbox*/ None)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter();
|
||||
let manifests_by_root = roots
|
||||
.into_iter()
|
||||
.filter_map(|root| {
|
||||
let mut name = None;
|
||||
let mut selected = false;
|
||||
for _ in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
|
||||
let metadata_result = results.next();
|
||||
let contents_result = results.next();
|
||||
let is_file = matches!(
|
||||
metadata_result,
|
||||
Some(Ok(FileSystemOperationOutput::GetMetadata(metadata)))
|
||||
if metadata.is_file
|
||||
);
|
||||
if !selected && is_file {
|
||||
selected = true;
|
||||
if let Some(Ok(FileSystemOperationOutput::ReadFile(contents))) =
|
||||
contents_result
|
||||
{
|
||||
name = plugin_manifest_name(&root, &contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.manifests_by_root.insert(root, name);
|
||||
}
|
||||
name.map(|name| (root, name))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self { manifests_by_root }
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
&mut self,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
) -> Option<String> {
|
||||
self.prime(fs, std::slice::from_ref(path)).await;
|
||||
|
||||
/// Returns the nearest preloaded plugin namespace for `path`.
|
||||
pub fn resolve(&self, path: &AbsolutePathBuf) -> Option<&str> {
|
||||
path.ancestors()
|
||||
.find_map(|ancestor| self.manifests_by_root.get(&ancestor).cloned().flatten())
|
||||
.find_map(|ancestor| self.manifests_by_root.get(&ancestor).map(String::as_str))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +115,10 @@ pub async fn plugin_namespace_for_skill_path(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
) -> Option<String> {
|
||||
PluginNamespaceResolver::default().resolve(fs, path).await
|
||||
PluginNamespaceResolver::load(fs, std::slice::from_ref(path))
|
||||
.await
|
||||
.resolve(path)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -112,6 +130,7 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const PRIMARY_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".codex-plugin/plugin.json";
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
|
||||
#[tokio::test]
|
||||
@@ -123,7 +142,7 @@ mod tests {
|
||||
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest");
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
plugin_root.join(PRIMARY_PLUGIN_MANIFEST_RELATIVE_PATH),
|
||||
r#"{"name":"sample"}"#,
|
||||
)
|
||||
.expect("write manifest");
|
||||
@@ -154,4 +173,32 @@ mod tests {
|
||||
);
|
||||
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_primary_manifest_does_not_fall_back_to_alternate() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("plugins/sample");
|
||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||
let primary_manifest_path = plugin_root.join(PRIMARY_PLUGIN_MANIFEST_RELATIVE_PATH);
|
||||
let alternate_manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
|
||||
|
||||
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
||||
fs::create_dir_all(primary_manifest_path.parent().expect("manifest parent"))
|
||||
.expect("mkdir primary manifest");
|
||||
fs::create_dir_all(alternate_manifest_path.parent().expect("manifest parent"))
|
||||
.expect("mkdir alternate manifest");
|
||||
fs::write(&primary_manifest_path, "invalid json").expect("write primary manifest");
|
||||
fs::write(&alternate_manifest_path, r#"{"name":"sample"}"#)
|
||||
.expect("write alternate manifest");
|
||||
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
|
||||
|
||||
assert_eq!(
|
||||
plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
find_plugin_manifest_path(&plugin_root),
|
||||
Some(primary_manifest_path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user