Enrich V2 capability discovery with resolved plugin and skill metadata (#53763)

## What changed

- Return qualified skill names such as `team:deploy` and parsed `dependencies` and `policy` instead of a separate namespace and raw metadata file. Share `agents/openai.yaml` parsing and validation between skill loaders and executor discovery.
- Include each standalone skill's search-root ID and path in `discoveryRoot`, while accepting responses from older executors that omit it.
- Add plugin `displayName`, falling back to the manifest name or installation directory.
- Materialize inline MCP declarations in `mcpConfig` with the manifest as their source path, and report incomplete discovery when declared MCP or Apps configuration files have no returned contents.

## Testing

Add coverage for JSON round trips, omitted `discoveryRoot`, optional and invalid skill metadata, qualified names, display-name fallbacks, and inline MCP configuration. Verify standalone search roots survive cached discovery and sandboxed RPC responses.

GitOrigin-RevId: 4124972444ad7c2fb8c6418acbf9a589d797fad2
This commit is contained in:
Anthony Tafoya
2026-10-13 20:40:38 +00:00
committed by copyberry
parent 21c1616119
commit 20b4ff2f35
20 changed files with 621 additions and 233 deletions
@@ -6,16 +6,19 @@ use std::sync::Arc;
use codex_file_system::ExecutorFileSystem;
use codex_skills::parse_skill_frontmatter_metadata;
use codex_skills::parse_skill_metadata;
use futures::StreamExt;
use serde::Deserialize;
use serde::Serialize;
use crate::CapabilityRootDiscoverRequest;
use crate::CapabilityRootDiscovery;
use crate::CapabilityTextFile;
use crate::DiscoverV2CapabilitiesResponse;
use crate::ExecutorPlugin;
use crate::ExecutorSkill;
use crate::LocalFileSystem;
use crate::SkillDiscoveryRoot;
use crate::capability_discovery::MAX_CONCURRENT_ROOTS;
use crate::capability_discovery::discover_root;
use crate::discover_v2::capability_locations::CapabilityLocation;
@@ -39,6 +42,23 @@ struct CapabilityDiscovery {
warnings: Vec<String>,
}
// Only the metadata V2 returns or needs to materialize discovered configuration.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginManifestMetadata {
#[serde(default)]
name: String,
interface: Option<PluginManifestInterface>,
mcp_servers: Option<serde_json::Value>,
apps: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginManifestInterface {
display_name: Option<String>,
}
impl Default for CapabilityDiscovery {
fn default() -> Self {
Self {
@@ -237,18 +257,87 @@ fn parse_root(
}
ancestor = root.parent();
}
let metadata = skill.metadata.as_ref().and_then(|metadata| {
parse_skill_metadata(&metadata.contents).map_err(|error| {
tracing::warn!(path = %metadata.path, %error, "ignoring invalid discovered skill metadata");
}).ok()
}).unwrap_or_default();
skills.push(ExecutorSkill {
name: parsed.name,
namespace,
name: namespace
.map(|namespace| format!("{namespace}:{}", parsed.name))
.unwrap_or(parsed.name),
description: parsed.description,
short_description: parsed.short_description,
path,
metadata: skill.metadata,
discovery_root: location
.standalone_root_id
.as_ref()
.map(|id| SkillDiscoveryRoot {
id: id.clone(),
path: location.root.clone(),
}),
dependencies: metadata.dependencies,
policy: metadata.policy,
});
}
let content = if let (Some(plugin), Some(files)) = (location.plugin, discovery.plugin) {
let content = if let (Some(plugin), Some(mut files)) = (location.plugin, discovery.plugin) {
let manifest =
match serde_json::from_str::<PluginManifestMetadata>(&files.manifest.contents) {
Ok(manifest) => manifest,
Err(_) => {
warnings.push(format!(
"invalid plugin manifest at {}",
files.manifest.path
));
return CapabilityDiscovery {
warnings,
..Default::default()
};
}
};
let name = if manifest.name.trim().is_empty() {
location.root.basename().unwrap_or(manifest.name)
} else {
manifest.name
};
let display_name = manifest
.interface
.and_then(|interface| interface.display_name)
.map(|display_name| display_name.trim().to_string())
.filter(|display_name| !display_name.is_empty())
.unwrap_or(name);
// V2 materializes inline declarations; the path identifies their source manifest.
if files.mcp_config.is_none() {
if let Some(contents) = manifest
.mcp_servers
.as_ref()
.filter(|value| value.is_object())
{
files.mcp_config = Some(CapabilityTextFile {
path: files.manifest.path.clone(),
contents: contents.to_string(),
});
} else if manifest
.mcp_servers
.as_ref()
.is_some_and(serde_json::Value::is_string)
{
warnings.push(format!(
"plugin `{}` at {} declares an MCP configuration file, but discovery returned no contents",
plugin.id, location.root
));
}
}
if files.apps_config.is_none() && manifest.apps.is_some() {
warnings.push(format!(
"plugin `{}` at {} declares an Apps configuration file, but discovery returned no contents",
plugin.id, location.root
));
}
DiscoveredSource::Plugin(Box::new(ExecutorPlugin {
id: plugin.id,
display_name,
remote_plugin_id: plugin.remote_plugin_id,
version: plugin.version,
root: location.root,
@@ -39,6 +39,7 @@ const AGENTS_SKILLS_SUFFIX: &str = ".agents/skills";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct CapabilityLocation {
pub root: PathUri,
pub standalone_root_id: Option<String>,
pub plugin: Option<InstalledPlugin>,
}
@@ -69,19 +70,22 @@ pub(crate) async fn prewarm_locations(
.map(PathUri::to_abs_path)
.transpose()?;
// Fixed standalone roots extend Flora's V1 root; project skill discovery is deferred.
let mut skill_roots = vec![codex_home.join("skills")];
let mut skill_roots = vec![("codex-home-skills", codex_home.join("skills"))];
if let Some(home) = user_home {
skill_roots.push(home.join(AGENTS_SKILLS_SUFFIX));
skill_roots.push(("user-agent-skills", home.join(AGENTS_SKILLS_SUFFIX)));
}
if let Some(system_root) = &request.system_skills_root {
skill_roots.push(system_root.to_abs_path()?);
skill_roots.push(("system-skills", system_root.to_abs_path()?));
}
CandidateCapabilityLocations::resolve(
fs,
Some(PathUri::from_abs_path(&codex_home.join(PLUGINS_CACHE_DIR))),
skill_roots
.into_iter()
.map(|root| PathUri::from_abs_path(&root))
.map(|(id, root)| crate::SkillDiscoveryRoot {
id: id.to_string(),
path: PathUri::from_abs_path(&root),
})
.collect(),
)
.await
@@ -171,6 +175,7 @@ async fn discover_cached_plugins(
};
locations.locations.push(CapabilityLocation {
root: PathUri::from_abs_path(&package_root.join(&version)),
standalone_root_id: None,
plugin: Some(InstalledPlugin {
id,
version,
@@ -5,6 +5,7 @@ use super::CapabilityLocations;
use super::MAX_LOCATIONS;
use super::discover_cached_plugins;
use crate::LocalFileSystem;
use crate::SkillDiscoveryRoot;
use codex_file_system::ExecutorFileSystem;
use codex_file_watcher::WatchPath;
use codex_utils_path_uri::PathUri;
@@ -22,7 +23,7 @@ pub(super) struct CandidateCapabilityLocations {
#[derive(Clone, Debug, PartialEq, Eq)]
enum CandidateCapabilityLocation {
Cache(PathUri),
Standalone(PathUri),
Standalone(SkillDiscoveryRoot),
}
impl CandidateCapabilityLocations {
@@ -30,7 +31,7 @@ impl CandidateCapabilityLocations {
pub(super) async fn resolve(
fs: &LocalFileSystem,
cache_root: Option<PathUri>,
skill_roots: Vec<PathUri>,
skill_roots: Vec<SkillDiscoveryRoot>,
) -> io::Result<CapabilityLocations> {
let mut candidates = cache_root
.into_iter()
@@ -38,7 +39,7 @@ impl CandidateCapabilityLocations {
.collect::<Vec<_>>();
let mut standalone = skill_roots;
let mut seen = std::collections::HashSet::new();
standalone.retain(|root| seen.insert(root.clone()));
standalone.retain(|root| seen.insert(root.path.clone()));
candidates.extend(
standalone
.into_iter()
@@ -49,8 +50,8 @@ impl CandidateCapabilityLocations {
let mut retained_candidates = Vec::new();
for candidate in candidates {
let root = match &candidate {
CandidateCapabilityLocation::Cache(root)
| CandidateCapabilityLocation::Standalone(root) => root,
CandidateCapabilityLocation::Cache(root) => root,
CandidateCapabilityLocation::Standalone(root) => &root.path,
};
let exists = match fs
.get_metadata(root, Default::default(), /*sandbox*/ None)
@@ -90,7 +91,8 @@ impl CandidateCapabilityLocations {
// Standalone roots need no manifest or version selection.
CandidateCapabilityLocation::Standalone(root) => Ok(CapabilityLocations {
locations: vec![CapabilityLocation {
root: root.clone(),
root: root.path.clone(),
standalone_root_id: Some(root.id.clone()),
plugin: None,
}],
..Default::default()
@@ -9,6 +9,7 @@ use crate::CapabilityRootDiscoverRequest;
use crate::DiscoverV2CapabilitiesRequest;
use crate::ExecutorSkill;
use crate::LocalFileSystem;
use crate::SkillDiscoveryRoot;
use crate::capability_discovery::discover_root;
use crate::discover_v2::capability_locations::tests::MANIFEST;
use crate::discover_v2::capability_locations::tests::request;
@@ -36,6 +37,24 @@ async fn discovery_preserves_inventory_and_v1_metadata() -> anyhow::Result<()> {
MANIFEST,
)?;
}
let inline_root = "codex/plugins/cache/test/unconfigured/local";
write(
temp.path(),
"codex/plugins/cache/test/disabled/local/.codex-plugin/plugin.json",
r#"{"name":" ","interface":{"displayName":" "},"mcpServers":"./missing-mcp.json","apps":"./missing-apps.json"}"#,
)?;
let inline_manifest = r#"{"name":"inline","interface":{"displayName":" Inline Plugin "},"mcpServers":{"inline":{"command":"node"}}}"#;
write(
temp.path(),
&format!("{inline_root}/.codex-plugin/plugin.json"),
inline_manifest,
)?;
// Inline declarations take precedence over a conventional MCP file.
write(
temp.path(),
&format!("{inline_root}/.mcp.json"),
r#"{"wrong":{"command":"wrong"}}"#,
)?;
let root = "codex/plugins/cache/test/demo/local";
write(
temp.path(),
@@ -86,6 +105,17 @@ async fn discovery_preserves_inventory_and_v1_metadata() -> anyhow::Result<()> {
.await;
// V1 keeps undeclared/migrated skills and excludes the skill beyond its depth limit.
assert_eq!(v1.skills.len(), 4);
let inline_v1 = discover_root(
&file_system,
CapabilityRootDiscoverRequest {
id: "unconfigured@test".to_string(),
path: PathUri::from_host_native_path(temp.path().join(inline_root))?,
sandbox: None,
},
)
.await;
let inline_v1_files = inline_v1.plugin.expect("inline plugin");
assert_eq!(inline_v1_files.mcp_config, None);
let manager = CapabilityManager::new(file_system);
manager.seed_prewarmed_locations(request.clone()).await?;
let response = manager
@@ -102,6 +132,14 @@ async fn discovery_preserves_inventory_and_v1_metadata() -> anyhow::Result<()> {
.collect::<Vec<_>>(),
vec!["demo@test", "disabled@test", "unconfigured@test"],
);
assert_eq!(
response
.plugins
.iter()
.map(|plugin| plugin.display_name.as_str())
.collect::<Vec<_>>(),
vec!["demo", "local", "Inline Plugin"],
);
let plugin = response
.plugins
.first()
@@ -122,38 +160,60 @@ async fn discovery_preserves_inventory_and_v1_metadata() -> anyhow::Result<()> {
v1.skills
.into_iter()
.map(|skill| {
let namespace = if skill
let name = if skill
.instructions
.path
.starts_with(&plugin.root.join("nested")?)
{
"team"
"team:deploy"
} else {
"demo"
"demo:deploy"
};
Ok(expected_skill(skill.instructions.path, Some(namespace)))
Ok(expected_skill(skill.instructions.path, name))
})
.collect::<anyhow::Result<Vec<_>>>()?
);
assert_eq!(
response.skills,
vec![
ExecutorSkill {
name: "deploy skill".to_string(),
..expected_skill(
request.codex_home.join("skills/deploy skill/SKILL.md")?,
/*namespace*/ None,
)
},
expected_skill(
request.codex_home.join("skills/deploy skill/SKILL.md")?,
"deploy skill",
),
expected_skill(
request
.codex_home
.join("skills/nested/skills/deploy/SKILL.md")?,
Some("team")
"team:deploy"
),
],
]
.into_iter()
.map(|mut skill| {
skill.discovery_root = Some(SkillDiscoveryRoot {
id: "codex-home-skills".to_string(),
path: request.codex_home.join("skills").expect("skills root"),
});
skill
})
.collect::<Vec<_>>(),
);
let inline_plugin = response
.plugins
.iter()
.find(|plugin| plugin.id == "unconfigured@test")
.expect("inline plugin");
assert_eq!(inline_plugin.files.manifest, inline_v1_files.manifest);
assert_eq!(
inline_plugin.files.mcp_config,
Some(crate::CapabilityTextFile {
path: inline_v1_files.manifest.path,
contents: serde_json::json!({"inline": {"command": "node"}}).to_string(),
})
);
assert_eq!(
response.warnings,
vec!["some capability metadata could not be loaded; discovery may be incomplete"],
);
assert_eq!(response.warnings, v1.warnings);
let json = serde_json::to_string(&response)?;
assert!(
response.plugins.len() + plugin.skills.len() + response.skills.len() <= MAX_CAPABILITIES
@@ -164,14 +224,15 @@ async fn discovery_preserves_inventory_and_v1_metadata() -> anyhow::Result<()> {
Ok(())
}
fn expected_skill(path: PathUri, namespace: Option<&str>) -> ExecutorSkill {
fn expected_skill(path: PathUri, name: &str) -> ExecutorSkill {
ExecutorSkill {
name: "deploy".to_string(),
namespace: namespace.map(str::to_string),
name: name.to_string(),
description: "Deploy the service.".to_string(),
short_description: None,
path,
metadata: None,
discovery_root: None,
dependencies: None,
policy: None,
}
}
@@ -214,3 +275,133 @@ async fn discovery_reports_location_failures_without_paths() -> anyhow::Result<(
}
Ok(())
}
#[tokio::test]
async fn discovery_resolves_optional_skill_metadata() -> anyhow::Result<()> {
use codex_protocol::protocol::Product;
use codex_skills::SkillDependencies;
use codex_skills::SkillPolicy;
use codex_skills::SkillToolDependency;
let dependencies = Some(SkillDependencies {
tools: vec![SkillToolDependency {
r#type: "mcp".into(),
value: "deploy".into(),
description: None,
transport: Some("stdio".into()),
command: Some("deploy-mcp".into()),
url: None,
oauth_callback_port: Some(3118),
}],
});
let policy = Some(SkillPolicy {
allow_implicit_invocation: Some(false),
products: vec![Product::Codex, Product::Chatgpt],
});
for (yaml, dependencies, policy) in [
(None, None, None),
(Some("interface: [invalid]"), None, None),
(Some("dependencies:\n tools: [{type: mcp}]"), None, None),
(Some("dependencies: {tools: []}"), None, None),
(
Some(
r#"dependencies:
tools:
- type: " mcp "
value: " deploy "
transport: stdio
command: deploy-mcp
oauth: {callback_port: 3118}
- type: mcp
value: " "
policy:
allow_implicit_invocation: false
products: [codex, CHATGPT]
"#,
),
dependencies,
policy,
),
] {
let temp = tempfile::tempdir()?;
let request = request(temp.path())?;
let plugin_root = "codex/plugins/cache/test/demo/local";
write(
temp.path(),
&format!("{plugin_root}/.codex-plugin/plugin.json"),
MANIFEST,
)?;
for root in [
"codex/skills/deploy".to_string(),
format!("{plugin_root}/skills/deploy"),
] {
write(temp.path(), &format!("{root}/SKILL.md"), SKILL)?;
if let Some(yaml) = yaml {
write(temp.path(), &format!("{root}/agents/openai.yaml"), yaml)?;
}
}
let manager = CapabilityManager::new(LocalFileSystem::unsandboxed());
manager.seed_prewarmed_locations(request.clone()).await?;
let response = manager
.get_or_refresh_discovery(DiscoverV2CapabilitiesRequest {
cwd: PathUri::from_host_native_path(temp.path().join("project"))?,
sandbox: None,
})
.await?;
let mut expected =
expected_skill(request.codex_home.join("skills/deploy/SKILL.md")?, "deploy");
expected.dependencies = dependencies;
expected.policy = policy;
expected.discovery_root = Some(SkillDiscoveryRoot {
id: "codex-home-skills".to_string(),
path: request.codex_home.join("skills")?,
});
assert_eq!(response.skills, vec![expected.clone()]);
expected.path = request
.codex_home
.join("plugins/cache/test/demo/local/skills/deploy/SKILL.md")?;
expected.name = "demo:deploy".into();
expected.discovery_root = None;
assert_eq!(response.plugins[0].skills, vec![expected]);
assert_eq!(response.warnings, Vec::<String>::new());
}
Ok(())
}
#[tokio::test]
async fn standalone_skills_retain_their_search_roots() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;
let request = request(temp.path())?;
let roots = [
("codex-home-skills", "codex/skills"),
("user-agent-skills", "home/.agents/skills"),
("system-skills", "etc/codex/skills"),
];
let mut expected = Vec::new();
for (id, root) in roots {
for relative in ["deploy/SKILL.md", "nested/deploy/SKILL.md"] {
let path = format!("{root}/{relative}");
write(temp.path(), &path, SKILL)?;
let mut skill = expected_skill(
PathUri::from_host_native_path(temp.path().join(path))?,
"deploy",
);
skill.discovery_root = Some(SkillDiscoveryRoot {
id: id.to_string(),
path: PathUri::from_host_native_path(temp.path().join(root))?,
});
expected.push(skill);
}
}
let manager = CapabilityManager::new(LocalFileSystem::unsandboxed());
manager.seed_prewarmed_locations(request.clone()).await?;
let query = DiscoverV2CapabilitiesRequest {
cwd: PathUri::from_host_native_path(temp.path().join("project"))?,
sandbox: None,
};
let response = manager.get_or_refresh_discovery(query.clone()).await?;
assert_eq!(response.skills, expected);
// Cached discovery must retain the same root metadata.
assert_eq!(manager.get_or_refresh_discovery(query).await?, response);
Ok(())
}
@@ -67,15 +67,21 @@ async fn prewarming_resolves_global_locations_without_cwd_or_config() -> anyhow:
actual.sort_unstable_by_key(|location| location.root.to_string());
let mut expected = vec![CapabilityLocation {
root: request.codex_home.join("plugins/cache/team/demo/1.10.0")?,
standalone_root_id: None,
plugin: Some(InstalledPlugin {
id: "demo@team".to_string(),
version: "1.10.0".to_string(),
remote_plugin_id: Some("plugins~demo".to_string()),
}),
}];
for root in ["codex/skills", "home/.agents/skills", "etc/codex/skills"] {
for (id, root) in [
("codex-home-skills", "codex/skills"),
("user-agent-skills", "home/.agents/skills"),
("system-skills", "etc/codex/skills"),
] {
expected.push(CapabilityLocation {
root: PathUri::from_host_native_path(temp.path().join(root))?,
standalone_root_id: Some(id.to_string()),
plugin: None,
});
}
+1
View File
@@ -228,6 +228,7 @@ pub use protocol::ShellInfo;
pub use protocol::ShellSnapshotRequest;
pub use protocol::SignalParams;
pub use protocol::SignalResponse;
pub use protocol::SkillDiscoveryRoot;
pub use protocol::TerminateParams;
pub use protocol::TerminateResponse;
pub use protocol::WriteParams;