[codex] Support plugin manifest path lists (#28790)

## Summary

Allow plugin manifests to declare `skills` as either a single path
string or an array of path strings in the core plugin loader.

## Why

Some plugin packages need to expose skills from more than one directory.
Before this change, `plugin.json` only accepted a single string for
`skills`, so manifests like this were ignored as an invalid `skills`
shape:

```json
{
  "skills": ["./skills/abc", "./skills/edk"]
}
```

This keeps the existing single-string form working while adding support
for the list form. The final scope is intentionally limited to the core
plugin manifest/load path for `skills`; `apps`, file-backed
`mcpServers`, and the bundled plugin-creator assets are unchanged in
this PR.

## What changed

- Parse `skills` as either a string or an array of strings in
`plugin.json`.
- Store resolved skill paths as a list in `PluginManifestPaths`.
- Load manifest-declared skill roots in addition to the default
`./skills` root.
- Deduplicate exact duplicate skill roots before loading.
- Rely on existing skill-loader dedupe by canonical `SKILL.md` path for
overlapping roots such as `./skills` plus `./skills/abc`.
- Update plugin manifest tests to cover:
  - single string `skills`
  - list of string `skills`
  - duplicate skill roots
  - `./skills` as a manifest path
  - explicit child roots like `./skills/abc` and `./skills/edk`
  - overlapping-root dedupe

## Validation

- `just test -p codex-plugin`
- `just test -p codex-core-plugins`
- `just test -p codex-mcp-extension`
- `git diff --check`
This commit is contained in:
charlesgong-openai
2026-06-17 21:33:53 -07:00
committed by GitHub
parent 78a9e169bb
commit e12dd73b7d
6 changed files with 202 additions and 96 deletions
+21 -11
View File
@@ -30,7 +30,7 @@ struct RawPluginManifest {
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
#[serde(default)]
skills: Option<RawPluginManifestPath>,
skills: Option<RawPluginManifestPaths>,
#[serde(default)]
mcp_servers: Option<RawPluginManifestMcpServers>,
#[serde(default)]
@@ -94,8 +94,9 @@ enum RawPluginManifestDefaultPromptEntry {
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawPluginManifestPath {
enum RawPluginManifestPaths {
Path(String),
Paths(Vec<String>),
Invalid(JsonValue),
}
@@ -230,7 +231,7 @@ pub(crate) fn parse_plugin_manifest(
description,
keywords,
paths: PluginManifestPaths {
skills: resolve_manifest_path_value(plugin_root, "skills", skills.as_ref()),
skills: resolve_manifest_paths(plugin_root, "skills", skills.as_ref()),
mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers),
apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()),
hooks: resolve_manifest_hooks(plugin_root, hooks),
@@ -395,20 +396,29 @@ fn json_value_type(value: &JsonValue) -> &'static str {
}
}
fn resolve_manifest_path_value(
fn resolve_manifest_paths(
plugin_root: &Path,
field: &'static str,
path: Option<&RawPluginManifestPath>,
) -> Option<AbsolutePathBuf> {
match path? {
RawPluginManifestPath::Path(path) => resolve_manifest_path(plugin_root, field, Some(path)),
RawPluginManifestPath::Invalid(value) => {
paths: Option<&RawPluginManifestPaths>,
) -> Vec<AbsolutePathBuf> {
match paths {
Some(RawPluginManifestPaths::Path(path)) => {
resolve_manifest_path(plugin_root, field, Some(path))
.map(|path| vec![path])
.unwrap_or_default()
}
Some(RawPluginManifestPaths::Paths(paths)) => paths
.iter()
.filter_map(|path| resolve_manifest_path(plugin_root, field, Some(path)))
.collect(),
Some(RawPluginManifestPaths::Invalid(value)) => {
tracing::warn!(
"ignoring {field}: expected a string; found {}",
"ignoring {field}: expected a string or string array; found {}",
json_value_type(value)
);
None
Vec::new()
}
None => Vec::new(),
}
}