Support Agent Plugins manifests (#35105)

## What changed

- Recognize root `plugin.json` files using the Agent Plugins 1.0 schema and map their portable metadata, `skills/`, and `mcp.json` into Codex plugin manifests.
- Apply Codex-specific apps, hooks, and interface settings from the inline `com.openai` extension, with `.codex-plugin/plugin.json` as a fallback overlay.
- Preserve legacy manifest precedence when a root `plugin.json` is unrelated, and reject unsupported Agent Plugins schema versions.
- Add a direct-child skill discovery mode that excludes nested skills and paths resolving outside the plugin root.

## Testing

- Cover manifest metadata, validation, extension precedence, legacy fallback, and direct-child skill path boundaries.

GitOrigin-RevId: eab24139f13a5cc5cb3ad3fb444d8e904511aca6
This commit is contained in:
jacobzhou-oai authored and copyberry committed 2026-07-24 05:59:16 +00:00
1 parent f61b51ddd9
commit a28374e0db
16 files changed
+872 -43

No files matched your search

+67 -36
View File
@@ -31,6 +31,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::PluginSkillRoot;
use codex_utils_plugins::SkillDiscoveryMode;
use dirs::home_dir;
use discovery::DirectorySymlinkPolicy;
use discovery::DiscoveredSkill;
@@ -197,6 +198,7 @@ pub struct SkillRoot {
pub plugin_id: Option<String>,
pub plugin_namespace: Option<String>,
pub plugin_root: Option<AbsolutePathBuf>,
pub discovery_mode: SkillDiscoveryMode,
}
pub async fn load_skills_from_roots<I>(
@@ -222,31 +224,15 @@ pub(crate) struct SkillRootSnapshot {
}
pub(crate) async fn load_skill_root(root: SkillRoot) -> SkillRootSnapshot {
let SkillRoot {
path,
scope,
file_system,
plugin_id,
plugin_namespace,
plugin_root,
} = root;
let root = canonicalize_for_skill_identity(file_system.as_ref(), &path).await;
let canonical_root =
canonicalize_for_skill_identity(root.file_system.as_ref(), &root.path).await;
let mut outcome = SkillLoadOutcome::default();
load_skills_under_root(
file_system.as_ref(),
&root,
scope,
plugin_id.as_deref(),
plugin_namespace.as_deref(),
plugin_root.as_ref(),
&mut outcome,
)
.await;
load_skills_under_root(&root, &canonical_root, &mut outcome).await;
SkillRootSnapshot {
root,
root: canonical_root,
skills: outcome.skills,
errors: outcome.errors,
file_system,
file_system: root.file_system,
}
}
@@ -286,6 +272,7 @@ async fn skill_roots_with_home_dir(
plugin_id: Some(root.plugin_id),
plugin_namespace: Some(root.plugin_namespace),
plugin_root: Some(root.plugin_root),
discovery_mode: root.discovery_mode,
}));
roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot {
path,
@@ -294,6 +281,7 @@ async fn skill_roots_with_home_dir(
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
}));
roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await);
dedupe_skill_roots_by_path(&mut roots);
@@ -325,6 +313,7 @@ fn skill_roots_from_layer_stack_inner(
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
});
}
}
@@ -338,6 +327,7 @@ fn skill_roots_from_layer_stack_inner(
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
});
// `$HOME/.agents/skills` (user-installed skills).
@@ -349,6 +339,7 @@ fn skill_roots_from_layer_stack_inner(
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
});
}
@@ -361,6 +352,7 @@ fn skill_roots_from_layer_stack_inner(
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
});
}
ConfigLayerSource::System { .. } => {
@@ -373,6 +365,7 @@ fn skill_roots_from_layer_stack_inner(
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
});
}
ConfigLayerSource::Mdm { .. }
@@ -418,6 +411,7 @@ async fn repo_agents_skill_roots(
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
}),
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
@@ -531,19 +525,16 @@ async fn canonicalize_for_skill_identity(
}
async fn load_skills_under_root(
fs: &dyn ExecutorFileSystem,
skill_root: &SkillRoot,
root: &AbsolutePathBuf,
scope: SkillScope,
plugin_id: Option<&str>,
plugin_namespace: Option<&str>,
plugin_root: Option<&AbsolutePathBuf>,
outcome: &mut SkillLoadOutcome,
) {
let plugin_root = match plugin_root {
let fs = skill_root.file_system.as_ref();
let plugin_root = match skill_root.plugin_root.as_ref() {
Some(plugin_root) => Some(canonicalize_for_skill_identity(fs, plugin_root).await),
None => None,
};
let directory_symlinks = match scope {
let directory_symlinks = match skill_root.scope {
SkillScope::User | SkillScope::Repo | SkillScope::Admin => DirectorySymlinkPolicy::Follow,
SkillScope::System => DirectorySymlinkPolicy::Ignore,
};
@@ -560,6 +551,7 @@ async fn load_skills_under_root(
SkillDiscoveryOptions {
directory_symlinks,
hidden_directories: HiddenDirectoryPolicy::Skip,
mode: skill_root.discovery_mode,
},
)
.await;
@@ -571,12 +563,20 @@ async fn load_skills_under_root(
return;
}
let root_uri = PathUri::from_abs_path(root);
let resolved_plugin_root = plugin_root.as_ref();
let resolved_skills = futures::stream::iter(skills)
.map(|skill| async move {
let path_uri = fs
.canonicalize(&skill.path, /*sandbox*/ None)
.await
.unwrap_or_else(|_| skill.path.clone());
let path_uri = match fs.canonicalize(&skill.path, /*sandbox*/ None).await {
Ok(path) => path,
Err(err) if skill_root.discovery_mode == SkillDiscoveryMode::DirectChildren => {
error!(
"failed to resolve Agent Plugin skill path {}: {err}",
skill.path
);
return None;
}
Err(_) => skill.path.clone(),
};
let path = match path_uri.to_abs_path() {
Ok(path) => path,
Err(err) => {
@@ -584,6 +584,37 @@ async fn load_skills_under_root(
return None;
}
};
if skill_root.discovery_mode == SkillDiscoveryMode::DirectChildren {
let Some(plugin_root) = resolved_plugin_root else {
error!("Agent Plugin skill root is missing its plugin root");
return None;
};
if !path.as_path().starts_with(plugin_root.as_path()) {
error!(
"Agent Plugin skill path {} resolves outside plugin root {}",
path.display(),
plugin_root.display()
);
return None;
}
match fs.get_metadata(&path_uri, /*sandbox*/ None).await {
Ok(metadata) if metadata.is_file => {}
Ok(_) => {
error!(
"Agent Plugin skill path {} is not a regular file",
path.display()
);
return None;
}
Err(err) => {
error!(
"failed to inspect Agent Plugin skill path {}: {err}",
path.display()
);
return None;
}
}
}
Some(ResolvedDiscoveredSkill {
skill,
path,
@@ -604,7 +635,7 @@ async fn load_skills_under_root(
.map(|skill| skill.path_uri.clone())
.collect::<Vec<_>>();
let namespace_resolver = async {
match plugin_namespace {
match skill_root.plugin_namespace.as_deref() {
Some(namespace) => SkillNamespaceResolver::with_provided_namespace(namespace),
None => {
SkillNamespaceResolver::discover(
@@ -627,8 +658,8 @@ async fn load_skills_under_root(
&skill.skill,
&skill.path,
&skill.path_uri,
scope,
plugin_id,
skill_root.scope,
skill_root.plugin_id.as_deref(),
plugin_root,
)
.await
@@ -651,7 +682,7 @@ async fn load_skills_under_root(
});
match result {
Ok(skill) => outcome.skills.push(skill),
Err(err) if scope != SkillScope::System => {
Err(err) if skill_root.scope != SkillScope::System => {
outcome.errors.push(SkillError { path, message: err })
}
Err(_) => {}
+14 -2
View File
@@ -6,6 +6,7 @@ use codex_exec_server::WalkEntryKind;
use codex_exec_server::WalkOptions;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
use codex_utils_plugins::SkillDiscoveryMode;
use super::MAX_SCAN_DEPTH;
use super::MAX_SKILLS_DIRS_PER_ROOT;
@@ -29,6 +30,7 @@ pub(super) enum HiddenDirectoryPolicy {
pub(super) struct SkillDiscoveryOptions {
pub directory_symlinks: DirectorySymlinkPolicy,
pub hidden_directories: HiddenDirectoryPolicy,
pub mode: SkillDiscoveryMode,
}
pub(super) struct SkillDiscovery {
@@ -64,7 +66,10 @@ pub(super) async fn discover_skills(
.walk(
root,
WalkOptions {
max_depth: MAX_SCAN_DEPTH,
max_depth: match options.mode {
SkillDiscoveryMode::Recursive => MAX_SCAN_DEPTH,
SkillDiscoveryMode::DirectChildren => 2,
},
max_directories: MAX_SKILLS_DIRS_PER_ROOT,
max_entries: MAX_SKILLS_ENTRIES_PER_ROOT,
follow_directory_symlinks: matches!(
@@ -137,7 +142,10 @@ pub(super) async fn discover_skills(
}
WalkEntryKind::File => {
file_paths.insert(entry.path.clone());
if entry.path.basename().as_deref() == Some(SKILLS_FILENAME) {
if entry.path.basename().as_deref() == Some(SKILLS_FILENAME)
&& (options.mode == SkillDiscoveryMode::Recursive
|| is_direct_child_skill_path(&entry.path, root))
{
skill_files.push(entry.path);
}
}
@@ -164,6 +172,10 @@ pub(super) async fn discover_skills(
}
}
fn is_direct_child_skill_path(path: &PathUri, root: &PathUri) -> bool {
path.parent().and_then(|parent| parent.parent()).as_ref() == Some(root)
}
fn has_hidden_ancestor_below_root(path: &PathUri, root: &PathUri) -> bool {
let mut ancestor = path.parent();
while let Some(current) = ancestor {
@@ -121,6 +121,7 @@ pub async fn load_environment_skills_from_root(
SkillDiscoveryOptions {
directory_symlinks: DirectorySymlinkPolicy::Follow,
hidden_directories: HiddenDirectoryPolicy::Include,
mode: codex_utils_plugins::SkillDiscoveryMode::Recursive,
},
)
.await;
+83
View File
@@ -574,6 +574,7 @@ async fn load_user_skills_root(root: &Path) -> SkillLoadOutcome {
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
@@ -1061,6 +1062,7 @@ interface:
plugin_id: Some("twilio-developer-kit@test".to_string()),
plugin_namespace: None,
plugin_root: Some(plugin_root_abs.clone()),
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
@@ -1123,6 +1125,7 @@ interface:
plugin_id: Some("twilio-developer-kit@test".to_string()),
plugin_namespace: None,
plugin_root: Some(plugin_root.abs()),
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
@@ -1300,6 +1303,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() {
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
@@ -1387,6 +1391,7 @@ async fn system_scope_ignores_symlinked_subdir() {
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
@@ -1426,6 +1431,7 @@ async fn respects_max_scan_depth_for_user_scope() {
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
@@ -1538,6 +1544,7 @@ async fn namespaces_plugin_skills_using_provided_namespace() {
plugin_id: Some("sample@test".to_string()),
plugin_namespace: Some("sample".to_string()),
plugin_root: Some(plugin_root.abs()),
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
@@ -1786,6 +1793,7 @@ async fn plugin_skill_name_length_limit_allows_max_qualified_name() {
plugin_id: Some("sample@test".to_string()),
plugin_namespace: Some(plugin_name.clone()),
plugin_root: Some(plugin_root.abs()),
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
@@ -1836,6 +1844,7 @@ async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() {
plugin_id: Some("sample@test".to_string()),
plugin_namespace: Some(plugin_name.clone()),
plugin_root: Some(plugin_root.abs()),
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
@@ -1851,6 +1860,76 @@ async fn plugin_skill_name_length_limit_rejects_overlong_qualified_name() {
);
}
#[tokio::test]
async fn direct_child_discovery_ignores_nested_skills() {
let root = tempfile::tempdir().expect("tempdir");
let plugin_root = root.path().join("plugin");
let skills_root = plugin_root.join("skills");
let direct = write_skill_at(&skills_root, "direct", "direct", "direct skill");
write_skill_at(&skills_root, "nested/too-deep", "too-deep", "nested skill");
let outcome = load_skills_from_roots(
[SkillRoot {
path: skills_root.abs(),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: Some("plugin@test".to_string()),
plugin_namespace: Some("plugin".to_string()),
plugin_root: Some(plugin_root.abs()),
discovery_mode: SkillDiscoveryMode::DirectChildren,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
)
.await;
assert!(outcome.errors.is_empty());
assert_eq!(
outcome.skills,
vec![SkillMetadata {
name: "plugin:direct".to_string(),
description: "direct skill".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: normalized(&direct),
scope: SkillScope::User,
plugin_id: Some("plugin@test".to_string()),
}]
);
}
#[cfg(unix)]
#[tokio::test]
async fn direct_child_discovery_skips_skills_resolving_outside_plugin_root() {
let root = tempfile::tempdir().expect("tempdir");
let plugin_root = root.path().join("plugin");
let skills_root = plugin_root.join("skills");
let outside_root = root.path().join("outside");
write_skill_at(&outside_root, "escaped", "escaped", "escaped skill");
fs::create_dir_all(&skills_root).expect("create skills root");
std::os::unix::fs::symlink(outside_root.join("escaped"), skills_root.join("escaped"))
.expect("create skill symlink");
let outcome = load_skills_from_roots(
[SkillRoot {
path: skills_root.abs(),
scope: SkillScope::User,
file_system: Arc::clone(&LOCAL_FS),
plugin_id: Some("plugin@test".to_string()),
plugin_namespace: Some("plugin".to_string()),
plugin_root: Some(plugin_root.abs()),
discovery_mode: SkillDiscoveryMode::DirectChildren,
}],
/*plugin_skill_snapshots*/ None,
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
)
.await;
assert!(outcome.skills.is_empty());
}
#[tokio::test]
async fn loads_short_description_from_metadata() {
let codex_home = tempfile::tempdir().expect("tempdir");
@@ -2385,6 +2464,7 @@ async fn merges_root_results_in_input_order_when_scans_finish_out_of_order() {
plugin_id: None,
plugin_namespace: Some("test".to_string()),
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
})
.collect::<Vec<_>>();
let root_scan_slots = Semaphore::new(MAX_CONCURRENT_ROOT_SCANS);
@@ -2450,6 +2530,7 @@ async fn skill_root_scans_wait_for_shared_capacity() {
plugin_id: None,
plugin_namespace: Some("test".to_string()),
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
}],
/*plugin_skill_snapshots*/ None,
&root_scan_slots,
@@ -2518,6 +2599,7 @@ async fn deduplicates_by_path_preferring_first_root() {
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
},
SkillRoot {
path: root.path().abs(),
@@ -2526,6 +2608,7 @@ async fn deduplicates_by_path_preferring_first_root() {
plugin_id: None,
plugin_namespace: None,
plugin_root: None,
discovery_mode: SkillDiscoveryMode::Recursive,
},
],
/*plugin_skill_snapshots*/ None,
+1
View File
@@ -66,6 +66,7 @@ where
plugin_id,
plugin_namespace,
plugin_root,
discovery_mode: root.discovery_mode,
})
}
_ => None,
@@ -12,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::PathExt;
use codex_utils_plugins::PluginSkillRoot;
use codex_utils_plugins::SkillDiscoveryMode;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use std::fs;
@@ -72,6 +73,7 @@ fn plugin_skill_root_for_skill_path(
plugin_id: plugin_id.to_string(),
plugin_namespace: plugin_namespace.to_string(),
plugin_root: plugin_root.abs(),
discovery_mode: SkillDiscoveryMode::Recursive,
}
}