use codex_config::HooksFile;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_plugins::find_plugin_manifest_path;use serde::Deserialize;use serde_json::Value as JsonValue;use std::fs;use std::path::Component;use std::path::Path;const MAX_DEFAULT_PROMPT_COUNT: usize = 3;const MAX_DEFAULT_PROMPT_LEN: usize = 128;pub type PluginManifest = codex_plugin::manifest::PluginManifest<AbsolutePathBuf>;pub type PluginManifestHooks = codex_plugin::manifest::PluginManifestHooks<AbsolutePathBuf>;pub type PluginManifestInterface = codex_plugin::manifest::PluginManifestInterface<AbsolutePathBuf>;pub type PluginManifestPaths = codex_plugin::manifest::PluginManifestPaths<AbsolutePathBuf>;#[derive(Debug, Default, Deserialize)]#[serde(rename_all = "camelCase")]struct RawPluginManifest { #[serde(default)] name: String, #[serde(default)] version: Option<String>, #[serde(default)] description: Option<String>, #[serde(default)] keywords: Vec<String>, // 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>, #[serde(default)] mcp_servers: Option<String>, #[serde(default)] apps: Option<String>, #[serde(default)] hooks: Option<RawPluginManifestHooks>, #[serde(default)] interface: Option<RawPluginManifestInterface>,}#[derive(Debug, Default, Deserialize)]#[serde(rename_all = "camelCase")]struct RawPluginManifestInterface { #[serde(default)] display_name: Option<String>, #[serde(default)] short_description: Option<String>, #[serde(default)] long_description: Option<String>, #[serde(default)] developer_name: Option<String>, #[serde(default)] category: Option<String>, #[serde(default)] capabilities: Vec<String>, #[serde(default)] #[serde(alias = "websiteURL")] website_url: Option<String>, #[serde(default)] #[serde(alias = "privacyPolicyURL")] privacy_policy_url: Option<String>, #[serde(default)] #[serde(alias = "termsOfServiceURL")] terms_of_service_url: Option<String>, #[serde(default)] default_prompt: Option<RawPluginManifestDefaultPrompt>, #[serde(default)] brand_color: Option<String>, #[serde(default)] composer_icon: Option<String>, #[serde(default)] logo: Option<String>, #[serde(default)] screenshots: Vec<String>,}#[derive(Debug, Deserialize)]#[serde(untagged)]enum RawPluginManifestDefaultPrompt { String(String), List(Vec<RawPluginManifestDefaultPromptEntry>), Invalid(JsonValue),}#[derive(Debug, Deserialize)]#[serde(untagged)]enum RawPluginManifestDefaultPromptEntry { String(String), Invalid(JsonValue),}#[derive(Debug, Deserialize)]#[serde(untagged)]enum RawPluginManifestPath { Path(String), Invalid(JsonValue),}#[derive(Debug, Deserialize)]#[serde(untagged)]enum RawPluginManifestHooks { Path(String), Paths(Vec<String>), Inline(HooksFile), InlineList(Vec<HooksFile>), Invalid(JsonValue),}/// Loads a plugin manifest from the local host filesystem.pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> { let manifest_path = find_plugin_manifest_path(plugin_root)?; let contents = fs::read_to_string(&manifest_path).ok()?; match parse_plugin_manifest(plugin_root, &manifest_path, &contents) { Ok(manifest) => Some(manifest), Err(err) => { tracing::warn!( path = %manifest_path.display(), "failed to parse plugin manifest: {err}" ); None } }}pub(crate) fn parse_plugin_manifest( plugin_root: &Path, manifest_path: &Path, contents: &str,) -> Result<PluginManifest, serde_json::Error> { let RawPluginManifest { name: raw_name, version, description, keywords, skills, mcp_servers, apps, hooks, interface, } = serde_json::from_str::<RawPluginManifest>(contents)?; let name = plugin_root .file_name() .and_then(|entry| entry.to_str()) .filter(|_| raw_name.trim().is_empty()) .unwrap_or(&raw_name) .to_string(); let version = version.and_then(|version| { let version = version.trim(); (!version.is_empty()).then(|| version.to_string()) }); let interface = interface.and_then(|interface| { let RawPluginManifestInterface { display_name, short_description, long_description, developer_name, category, capabilities, website_url, privacy_policy_url, terms_of_service_url, default_prompt, brand_color, composer_icon, logo, screenshots, } = interface; let interface = PluginManifestInterface { display_name, short_description, long_description, developer_name, category, capabilities, website_url, privacy_policy_url, terms_of_service_url, default_prompt: resolve_default_prompts(manifest_path, default_prompt.as_ref()), brand_color, composer_icon: resolve_interface_asset_path( plugin_root, "interface.composerIcon", composer_icon.as_deref(), ), logo: resolve_interface_asset_path(plugin_root, "interface.logo", logo.as_deref()), screenshots: screenshots .iter() .filter_map(|screenshot| { resolve_interface_asset_path( plugin_root, "interface.screenshots", Some(screenshot), ) }) .collect(), }; let has_fields = interface.display_name.is_some() || interface.short_description.is_some() || interface.long_description.is_some() || interface.developer_name.is_some() || interface.category.is_some() || !interface.capabilities.is_empty() || interface.website_url.is_some() || interface.privacy_policy_url.is_some() || interface.terms_of_service_url.is_some() || interface.default_prompt.is_some() || interface.brand_color.is_some() || interface.composer_icon.is_some() || interface.logo.is_some() || !interface.screenshots.is_empty(); has_fields.then_some(interface) }); Ok(PluginManifest { name, version, description, keywords, paths: PluginManifestPaths { skills: resolve_manifest_path_value(plugin_root, "skills", skills.as_ref()), mcp_servers: resolve_manifest_path(plugin_root, "mcpServers", mcp_servers.as_deref()), apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), hooks: resolve_manifest_hooks(plugin_root, hooks), }, interface, })}fn resolve_manifest_hooks( plugin_root: &Path, hooks: Option<RawPluginManifestHooks>,) -> Option<PluginManifestHooks> { match hooks? { RawPluginManifestHooks::Path(path) => { resolve_manifest_path(plugin_root, "hooks", Some(&path)) .map(|path| PluginManifestHooks::Paths(vec![path])) } RawPluginManifestHooks::Paths(paths) => { let hooks = paths .iter() .filter_map(|path| resolve_manifest_path(plugin_root, "hooks", Some(path))) .collect::<Vec<_>>(); (!hooks.is_empty()).then_some(PluginManifestHooks::Paths(hooks)) } RawPluginManifestHooks::Inline(hooks) => Some(PluginManifestHooks::Inline(vec![hooks])), RawPluginManifestHooks::InlineList(hooks) => { (!hooks.is_empty()).then_some(PluginManifestHooks::Inline(hooks)) } RawPluginManifestHooks::Invalid(value) => { tracing::warn!( "ignoring hooks: expected a string, string array, object, or object array; found {}", json_value_type(&value) ); None } }}fn resolve_interface_asset_path( plugin_root: &Path, field: &'static str, path: Option<&str>,) -> Option<AbsolutePathBuf> { resolve_manifest_path(plugin_root, field, path)}fn resolve_default_prompts( manifest_path: &Path, value: Option<&RawPluginManifestDefaultPrompt>,) -> Option<Vec<String>> { match value? { RawPluginManifestDefaultPrompt::String(prompt) => { resolve_default_prompt_str(manifest_path, "interface.defaultPrompt", prompt) .map(|prompt| vec![prompt]) } RawPluginManifestDefaultPrompt::List(values) => { let mut prompts = Vec::new(); for (index, item) in values.iter().enumerate() { if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT { warn_invalid_default_prompt( manifest_path, "interface.defaultPrompt", &format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"), ); break; } match item { RawPluginManifestDefaultPromptEntry::String(prompt) => { let field = format!("interface.defaultPrompt[{index}]"); if let Some(prompt) = resolve_default_prompt_str(manifest_path, &field, prompt) { prompts.push(prompt); } } RawPluginManifestDefaultPromptEntry::Invalid(value) => { let field = format!("interface.defaultPrompt[{index}]"); warn_invalid_default_prompt( manifest_path, &field, &format!("expected a string, found {}", json_value_type(value)), ); } } } (!prompts.is_empty()).then_some(prompts) } RawPluginManifestDefaultPrompt::Invalid(value) => { warn_invalid_default_prompt( manifest_path, "interface.defaultPrompt", &format!( "expected a string or array of strings, found {}", json_value_type(value) ), ); None } }}fn resolve_default_prompt_str(manifest_path: &Path, field: &str, prompt: &str) -> Option<String> { let prompt = prompt.split_whitespace().collect::<Vec<_>>().join(" "); if prompt.is_empty() { warn_invalid_default_prompt(manifest_path, field, "prompt must not be empty"); return None; } if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN { warn_invalid_default_prompt( manifest_path, field, &format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"), ); return None; } Some(prompt)}fn warn_invalid_default_prompt(manifest_path: &Path, field: &str, message: &str) { tracing::warn!( path = %manifest_path.display(), "ignoring {field}: {message}" );}fn json_value_type(value: &JsonValue) -> &'static str { match value { JsonValue::Null => "null", JsonValue::Bool(_) => "boolean", JsonValue::Number(_) => "number", JsonValue::String(_) => "string", JsonValue::Array(_) => "array", JsonValue::Object(_) => "object", }}fn resolve_manifest_path_value( 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) => { tracing::warn!( "ignoring {field}: expected a string; found {}", json_value_type(value) ); None } }}fn resolve_manifest_path( plugin_root: &Path, field: &'static str, path: Option<&str>,) -> Option<AbsolutePathBuf> { // `plugin.json` paths are required to be relative to the plugin root and we return the // normalized absolute path to the rest of the system. let path = path?; if path.is_empty() { return None; } let Some(relative_path) = path.strip_prefix("./") else { tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root"); return None; }; if relative_path.is_empty() { tracing::warn!("ignoring {field}: path must not be `./`"); return None; } let mut normalized = std::path::PathBuf::new(); for component in Path::new(relative_path).components() { match component { Component::Normal(component) => normalized.push(component), Component::ParentDir => { tracing::warn!("ignoring {field}: path must not contain '..'"); return None; } _ => { tracing::warn!("ignoring {field}: path must stay within the plugin root"); return None; } } } AbsolutePathBuf::try_from(plugin_root.join(normalized)) .map_err(|err| { tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}"); err }) .ok()}#[cfg(test)]mod tests { use super::MAX_DEFAULT_PROMPT_LEN; use super::PluginManifest; use super::load_plugin_manifest; use codex_exec_server::EnvironmentManager; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_plugin::PluginProvider; use codex_plugin::ResolvedPlugin; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; use std::sync::Arc; use tempfile::tempdir; use crate::ExecutorPluginProvider; const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json"; fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) { fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); let version = version .map(|version| format!(" \"version\": \"{version}\",\n")) .unwrap_or_default(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), format!( r#"{{ "name": "demo-plugin",{version} "interface": {interface}}}"# ), ) .expect("write manifest"); } fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) { let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH); fs::create_dir_all(manifest_path.parent().expect("manifest parent")) .expect("create manifest dir"); fs::write(manifest_path, contents).expect("write manifest"); } fn load_manifest(plugin_root: &Path) -> PluginManifest { load_plugin_manifest(plugin_root).expect("load plugin manifest") } #[test] fn plugin_interface_accepts_legacy_default_prompt_string() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); write_manifest( &plugin_root, /*version*/ None, r#"{ "displayName": "Demo Plugin", "defaultPrompt": " Summarize my inbox " }"#, ); let manifest = load_manifest(&plugin_root); let interface = manifest.interface.expect("plugin interface"); assert_eq!( interface.default_prompt, Some(vec!["Summarize my inbox".to_string()]) ); } #[test] fn plugin_interface_normalizes_default_prompt_array() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1); write_manifest( &plugin_root, /*version*/ None, &format!( r#"{{ "displayName": "Demo Plugin", "defaultPrompt": [ " Summarize my inbox ", 123, "{too_long}", " ", "Draft the reply ", "Find my next action", "Archive old mail" ] }}"# ), ); let manifest = load_manifest(&plugin_root); let interface = manifest.interface.expect("plugin interface"); assert_eq!( interface.default_prompt, Some(vec![ "Summarize my inbox".to_string(), "Draft the reply".to_string(), "Find my next action".to_string(), ]) ); } #[test] fn plugin_interface_ignores_invalid_default_prompt_shape() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); write_manifest( &plugin_root, /*version*/ None, r#"{ "displayName": "Demo Plugin", "defaultPrompt": { "text": "Summarize my inbox" } }"#, ); let manifest = load_manifest(&plugin_root); let interface = manifest.interface.expect("plugin interface"); assert_eq!(interface.default_prompt, None); } #[test] fn plugin_manifest_reads_trimmed_version() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); write_manifest( &plugin_root, Some(" 1.2.3-beta+7 "), r#"{ "displayName": "Demo Plugin" }"#, ); let manifest = load_manifest(&plugin_root); assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string())); } #[test] fn plugin_manifest_reads_keywords() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{ "name": "demo-plugin", "keywords": ["api-key", "developer tools"]}"#, ) .expect("write manifest"); let manifest = load_manifest(&plugin_root); assert_eq!( manifest.keywords, vec!["api-key".to_string(), "developer tools".to_string()] ); } #[test] fn plugin_manifest_uses_alternate_discoverable_path() { let tmp = tempdir().expect("tempdir"); let plugin_root = tmp.path().join("demo-plugin"); write_alternate_plugin_manifest( &plugin_root, r#"{ "name": "demo-plugin", "version": " 2.0.0 ", "interface": { "displayName": "Fallback Plugin" }}"#, ); let manifest = load_manifest(&plugin_root); assert_eq!(manifest.version, Some("2.0.0".to_string())); assert_eq!( manifest .interface .as_ref() .and_then(|interface| interface.display_name.as_deref()), Some("Fallback Plugin") ); } #[tokio::test] async fn host_and_executor_sources_parse_the_same_manifest() { let temp_dir = tempdir().expect("tempdir"); let plugin_root = temp_dir.path().join("demo-plugin"); write_manifest( &plugin_root, Some(" 1.2.3 "), r#"{ "displayName": "Demo Plugin", "composerIcon": "./assets/icon.svg" }"#, ); let host_manifest = load_plugin_manifest(&plugin_root).expect("host manifest"); let provider = ExecutorPluginProvider::new(Arc::new(EnvironmentManager::default_for_tests())); let selected_root = SelectedCapabilityRoot { id: "selected-demo".to_string(), location: CapabilityRootLocation::Environment { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), path: plugin_root.to_string_lossy().into_owned(), }, }; let executor_plugin = provider .resolve(&selected_root) .await .expect("resolve executor plugin") .expect("plugin descriptor"); let plugin_root = AbsolutePathBuf::from_absolute_path_checked(plugin_root).expect("absolute plugin root"); let expected_plugin = ResolvedPlugin::from_environment( "selected-demo".to_string(), LOCAL_ENVIRONMENT_ID.to_string(), plugin_root.clone(), plugin_root.join(".codex-plugin/plugin.json"), host_manifest, ) .expect("valid expected descriptor"); assert_eq!(executor_plugin, expected_plugin); }}