Support portable Agent Plugins throughout installation (#36544)

## Why

Agent Plugins use a schema-declared root `plugin.json` and can have dotted names or versions that do not fit Codex's directory-safe version format. The packaging and installation paths still assumed the legacy manifest layout and identifier rules.

## What changed

- Recognize valid root Agent Plugin manifests when discovering, packing, and installing plugins, while leaving unrelated root manifests on the legacy path.
- Accept safe dotted plugin names, default missing Agent Plugin versions to `1.0.0`, and derive stable directory-safe versions when necessary without rewriting the portable manifest.
- Skip legacy command migration for Agent Plugins and reject symlinks or other unsupported file types while copying plugin sources.

## Testing

Add coverage for portable bundle round trips, manifest discovery, dotted names, version handling, command preservation, and symlink rejection.

GitOrigin-RevId: 61476c4c4100495842253d8b429c0b896490962d
This commit is contained in:
jacobzhou-oai
2026-08-02 02:29:45 +00:00
committed by copyberry
parent 5825699981
commit 2b5bdcf675
13 changed files with 399 additions and 39 deletions
@@ -8,6 +8,8 @@ use std::path::Path;
use std::path::PathBuf;
pub const AGENT_PLUGIN_MANIFEST_RELATIVE_PATH: &str = "plugin.json";
/// Published Agent Plugins v1 manifest schema:
/// https://github.com/agentplugins/agent-plugins-spec/blob/main/schemas/1.0.0/plugin.schema.json
pub const AGENT_PLUGIN_SCHEMA_URI: &str =
"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
pub const SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS: &[&str] = &[AGENT_PLUGIN_SCHEMA_URI];
@@ -37,6 +39,25 @@ pub fn agent_plugin_schema_status(contents: &str) -> AgentPluginSchemaStatus {
}
pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
let agent_manifest_path = plugin_root.join(AGENT_PLUGIN_MANIFEST_RELATIVE_PATH);
match std::fs::symlink_metadata(&agent_manifest_path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.file_type().is_file() => {
return None;
}
Ok(_) => {
if std::fs::read_to_string(&agent_manifest_path)
.ok()
.is_some_and(|contents| {
agent_plugin_schema_status(&contents) != AgentPluginSchemaStatus::Unrelated
})
{
return Some(agent_manifest_path);
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return None,
}
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
.iter()
.map(|relative_path| plugin_root.join(relative_path))
@@ -105,6 +126,8 @@ pub async fn plugin_namespace_for_skill_uri(
#[cfg(test)]
mod tests {
use super::AGENT_PLUGIN_MANIFEST_RELATIVE_PATH;
use super::AGENT_PLUGIN_SCHEMA_URI;
use super::find_plugin_manifest_path;
use super::plugin_namespace_for_skill_path;
use codex_exec_server::LOCAL_FS;
@@ -176,6 +199,22 @@ mod tests {
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
}
#[test]
fn recognizes_schema_declared_root_plugin_manifest() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("plugins/portable");
let skill_path = plugin_root.join("skills/search/SKILL.md");
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
let manifest_path = plugin_root.join(AGENT_PLUGIN_MANIFEST_RELATIVE_PATH);
fs::write(
&manifest_path,
format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"portable"}}"#),
)
.expect("write manifest");
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
}
#[test]
fn ignores_unrelated_root_plugin_manifest_before_legacy_fallback() {
let tmp = tempdir().expect("tempdir");
@@ -189,6 +228,39 @@ mod tests {
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(legacy_path));
}
#[test]
fn rejects_nonregular_root_plugin_manifest() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("plugins/sample");
let legacy_path = plugin_root.join(".codex-plugin/plugin.json");
fs::create_dir_all(plugin_root.join("plugin.json")).expect("root manifest directory");
fs::create_dir_all(legacy_path.parent().expect("parent")).expect("legacy parent");
fs::write(&legacy_path, r#"{"name":"sample"}"#).expect("legacy manifest");
assert_eq!(find_plugin_manifest_path(&plugin_root), None);
}
#[cfg(unix)]
#[test]
fn rejects_symlinked_root_plugin_manifest() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("plugins/sample");
let manifest_target = tmp.path().join("manifest.json");
let legacy_path = plugin_root.join(".codex-plugin/plugin.json");
fs::create_dir_all(&plugin_root).expect("plugin root");
fs::write(
&manifest_target,
format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"sample"}}"#),
)
.expect("manifest target");
std::os::unix::fs::symlink(&manifest_target, plugin_root.join("plugin.json"))
.expect("root manifest symlink");
fs::create_dir_all(legacy_path.parent().expect("parent")).expect("legacy parent");
fs::write(&legacy_path, r#"{"name":"sample"}"#).expect("legacy manifest");
assert_eq!(find_plugin_manifest_path(&plugin_root), None);
}
#[test]
fn preserves_codex_claude_cursor_legacy_precedence() {
let tmp = tempdir().expect("tempdir");