use crate::manifest::PluginManifestInterface;use crate::manifest::load_plugin_manifest;use codex_app_server_protocol::PluginAuthPolicy;use codex_app_server_protocol::PluginInstallPolicy;use codex_git_utils::get_git_repo_root;use codex_plugin::PluginId;use codex_plugin::PluginIdError;use codex_protocol::protocol::Product;use codex_utils_absolute_path::AbsolutePathBuf;use serde::Deserialize;use serde_json::Value as JsonValue;use std::fs;use std::io;use std::path::Component;use std::path::Path;use std::path::PathBuf;use tracing::warn;const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[ ".agents/plugins/marketplace.json", ".agents/plugins/api_marketplace.json", ".claude-plugin/marketplace.json",];#[derive(Debug, Clone, PartialEq, Eq)]pub struct ResolvedMarketplacePlugin { pub plugin_id: PluginId, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option<PluginManifestInterface>, pub manifest: Option<crate::manifest::PluginManifest>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct Marketplace { pub name: String, pub path: AbsolutePathBuf, pub interface: Option<MarketplaceInterface>, pub plugins: Vec<MarketplacePlugin>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct MarketplaceListError { pub path: AbsolutePathBuf, pub message: String,}#[derive(Debug, Clone, Default, PartialEq, Eq)]pub struct MarketplaceListOutcome { pub marketplaces: Vec<Marketplace>, pub errors: Vec<MarketplaceListError>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct MarketplaceInterface { pub display_name: Option<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct MarketplacePlugin { pub name: String, pub local_version: Option<String>, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option<PluginManifestInterface>, pub keywords: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub enum MarketplacePluginSource { Local { path: AbsolutePathBuf, }, Git { url: String, path: Option<String>, ref_name: Option<String>, sha: Option<String>, },}#[derive(Debug, Clone, PartialEq, Eq)]pub struct MarketplacePluginPolicy { pub installation: MarketplacePluginInstallPolicy, pub authentication: MarketplacePluginAuthPolicy, // TODO: Surface or enforce product gating at the Codex/plugin consumer boundary instead of // only carrying it through core marketplace metadata. pub products: Option<Vec<Product>>,}#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]pub enum MarketplacePluginInstallPolicy { #[serde(rename = "NOT_AVAILABLE")] NotAvailable, #[default] #[serde(rename = "AVAILABLE")] Available, #[serde(rename = "INSTALLED_BY_DEFAULT")] InstalledByDefault,}#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]pub enum MarketplacePluginAuthPolicy { #[default] #[serde(rename = "ON_INSTALL")] OnInstall, #[serde(rename = "ON_USE")] OnUse,}impl From<MarketplacePluginInstallPolicy> for PluginInstallPolicy { fn from(value: MarketplacePluginInstallPolicy) -> Self { match value { MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable, MarketplacePluginInstallPolicy::Available => Self::Available, MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault, } }}impl From<MarketplacePluginAuthPolicy> for PluginAuthPolicy { fn from(value: MarketplacePluginAuthPolicy) -> Self { match value { MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall, MarketplacePluginAuthPolicy::OnUse => Self::OnUse, } }}#[derive(Debug, thiserror::Error)]pub enum MarketplaceError { #[error("{context}: {source}")] Io { context: &'static str, #[source] source: io::Error, }, #[error("marketplace file `{path}` does not exist")] MarketplaceNotFound { path: PathBuf }, #[error("invalid marketplace file `{path}`: {message}")] InvalidMarketplaceFile { path: PathBuf, message: String }, #[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")] PluginNotFound { plugin_name: String, marketplace_name: String, }, #[error( "plugin `{plugin_name}` is not available for install in marketplace `{marketplace_name}`" )] PluginNotAvailable { plugin_name: String, marketplace_name: String, }, #[error("plugins feature is disabled")] PluginsDisabled, #[error("{0}")] InvalidPlugin(String),}impl MarketplaceError { fn io(context: &'static str, source: io::Error) -> Self { Self::Io { context, source } }}pub fn find_marketplace_plugin( marketplace_path: &AbsolutePathBuf, plugin_name: &str,) -> Result<ResolvedMarketplacePlugin, MarketplaceError> { let marketplace = load_raw_marketplace_manifest(marketplace_path)?; let marketplace_name = marketplace.name; let marketplace_name_for_not_found = marketplace_name.clone(); for plugin in marketplace.plugins { if plugin.name != plugin_name { continue; } if let Some(plugin) = resolve_marketplace_plugin_entry(marketplace_path, &marketplace_name, plugin)? { return Ok(plugin); } } Err(MarketplaceError::PluginNotFound { plugin_name: plugin_name.to_string(), marketplace_name: marketplace_name_for_not_found, })}pub fn find_installable_marketplace_plugin( marketplace_path: &AbsolutePathBuf, plugin_name: &str, restriction_product: Option<Product>,) -> Result<ResolvedMarketplacePlugin, MarketplaceError> { let resolved = find_marketplace_plugin(marketplace_path, plugin_name)?; let product_allowed = match resolved.policy.products.as_deref() { None => true, Some([]) => false, Some(products) => { restriction_product.is_some_and(|product| product.matches_product_restriction(products)) } }; if resolved.policy.installation == MarketplacePluginInstallPolicy::NotAvailable || !product_allowed { return Err(MarketplaceError::PluginNotAvailable { plugin_name: resolved.plugin_id.plugin_name, marketplace_name: resolved.plugin_id.marketplace_name, }); } Ok(resolved)}pub fn list_marketplaces( additional_roots: &[AbsolutePathBuf],) -> Result<MarketplaceListOutcome, MarketplaceError> { list_marketplaces_with_home(additional_roots, home_dir().as_deref())}pub(crate) fn home_dir() -> Option<PathBuf> { ["HOME", "USERPROFILE"] .into_iter() .filter_map(std::env::var_os) .filter(|value| !value.is_empty()) .map(PathBuf::from) .find(|path| path.is_absolute()) .or_else(dirs::home_dir)}pub fn validate_marketplace_root(root: &Path) -> Result<String, MarketplaceError> { let Some(path) = find_marketplace_manifest_path(root) else { return Err(MarketplaceError::InvalidMarketplaceFile { path: root.to_path_buf(), message: "marketplace root does not contain a supported manifest".to_string(), }); }; let marketplace = load_marketplace(&path)?; Ok(marketplace.name)}pub fn find_marketplace_manifest_path(root: &Path) -> Option<AbsolutePathBuf> { MARKETPLACE_MANIFEST_RELATIVE_PATHS .iter() .find_map(|relative_path| { let path = root.join(relative_path); if !path.is_file() { return None; } AbsolutePathBuf::try_from(path).ok() })}fn supported_marketplace_manifest_path(path: &Path) -> Option<AbsolutePathBuf> { if !path.is_file() { return None; } if !MARKETPLACE_MANIFEST_RELATIVE_PATHS .iter() .any(|relative_path| marketplace_root_from_layout(path, relative_path).is_some()) { return None; } AbsolutePathBuf::try_from(path.to_path_buf()).ok()}fn invalid_marketplace_layout_error(path: &AbsolutePathBuf) -> MarketplaceError { MarketplaceError::InvalidMarketplaceFile { path: path.to_path_buf(), message: "marketplace file is not in a supported location".to_string(), }}fn marketplace_root_from_layout(marketplace_path: &Path, relative_path: &str) -> Option<PathBuf> { let mut current = marketplace_path; for component in Path::new(relative_path).components().rev() { let expected = match component { Component::Normal(expected) => expected, _ => return None, }; if current.file_name() != Some(expected) { return None; } current = current.parent()?; } Some(current.to_path_buf())}pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, MarketplaceError> { let marketplace = load_raw_marketplace_manifest(path)?; let mut plugins = Vec::new(); for plugin in marketplace.plugins { let plugin = match resolve_marketplace_plugin_entry(path, &marketplace.name, plugin) { Ok(Some(plugin)) => plugin, Ok(None) => continue, Err(MarketplaceError::InvalidPlugin(message)) => { warn!( path = %path.display(), marketplace = %marketplace.name, error = %message, "skipping invalid marketplace plugin" ); continue; } Err(err) => return Err(err), }; let local_version = plugin .manifest .as_ref() .and_then(|manifest| manifest.version.clone()); let keywords = plugin .manifest .map(|manifest| manifest.keywords) .unwrap_or_default(); plugins.push(MarketplacePlugin { name: plugin.plugin_id.plugin_name, local_version, source: plugin.source, policy: plugin.policy, interface: plugin.interface, keywords, }); } Ok(Marketplace { name: marketplace.name, path: path.clone(), interface: resolve_marketplace_interface(marketplace.interface), plugins, })}#[doc(hidden)]pub fn list_marketplaces_with_home( additional_roots: &[AbsolutePathBuf], home_dir: Option<&Path>,) -> Result<MarketplaceListOutcome, MarketplaceError> { let mut outcome = MarketplaceListOutcome::default(); for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) { match load_marketplace(&marketplace_path) { Ok(marketplace) => outcome.marketplaces.push(marketplace), Err(err) => { warn!( path = %marketplace_path.display(), error = %err, "skipping marketplace that failed to load" ); outcome.errors.push(MarketplaceListError { path: marketplace_path, message: err.to_string(), }); } } } Ok(outcome)}fn discover_marketplace_paths_from_roots( additional_roots: &[AbsolutePathBuf], home_dir: Option<&Path>,) -> Vec<AbsolutePathBuf> { let mut paths = Vec::new(); if let Some(home) = home_dir && let Some(path) = find_marketplace_manifest_path(home) { paths.push(path); } for root in additional_roots { if let Some(path) = supported_marketplace_manifest_path(root.as_path()) && !paths.contains(&path) { paths.push(path); continue; } // Curated marketplaces can now come from an HTTP-downloaded directory that is not a git // checkout, so check the root directly before falling back to repo-root discovery. if let Some(path) = find_marketplace_manifest_path(root.as_path()) && !paths.contains(&path) { paths.push(path); continue; } if let Some(repo_root) = get_git_repo_root(root.as_path()) && let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root) && let Some(path) = find_marketplace_manifest_path(repo_root.as_path()) && !paths.contains(&path) { paths.push(path); } } paths}fn load_raw_marketplace_manifest( path: &AbsolutePathBuf,) -> Result<RawMarketplaceManifest, MarketplaceError> { let contents = fs::read_to_string(path.as_path()).map_err(|err| { if err.kind() == io::ErrorKind::NotFound { MarketplaceError::MarketplaceNotFound { path: path.to_path_buf(), } } else { MarketplaceError::io("failed to read marketplace file", err) } })?; serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile { path: path.to_path_buf(), message: err.to_string(), })}fn resolve_marketplace_plugin_entry( marketplace_path: &AbsolutePathBuf, marketplace_name: &str, plugin: RawMarketplaceManifestPlugin,) -> Result<Option<ResolvedMarketplacePlugin>, MarketplaceError> { let RawMarketplaceManifestPlugin { name, source, policy, category, } = plugin; let Some(source) = resolve_supported_plugin_source(marketplace_path, &name, source) else { return Ok(None); }; let manifest = match &source { MarketplacePluginSource::Local { path } => load_plugin_manifest(path.as_path()), MarketplacePluginSource::Git { .. } => None, }; let interface = plugin_interface_with_marketplace_category( manifest .as_ref() .and_then(|manifest| manifest.interface.clone()), category, ); Ok(Some(ResolvedMarketplacePlugin { plugin_id: PluginId::new(name, marketplace_name.to_string()).map_err(|err| match err { PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message), })?, source, policy: MarketplacePluginPolicy { installation: policy.installation, authentication: policy.authentication, products: policy.products, }, interface, manifest, }))}fn resolve_supported_plugin_source( marketplace_path: &AbsolutePathBuf, plugin_name: &str, source: RawMarketplaceManifestPluginSource,) -> Option<MarketplacePluginSource> { match source { RawMarketplaceManifestPluginSource::Unsupported(_) => { warn!( path = %marketplace_path.display(), plugin = plugin_name, "skipping marketplace plugin with unsupported source" ); None } source => match resolve_plugin_source(marketplace_path, source) { Ok(source) => Some(source), Err(err) => { warn!( path = %marketplace_path.display(), plugin = plugin_name, error = %err, "skipping marketplace plugin that failed to resolve" ); None } }, }}fn resolve_plugin_source( marketplace_path: &AbsolutePathBuf, source: RawMarketplaceManifestPluginSource,) -> Result<MarketplacePluginSource, MarketplaceError> { match source { RawMarketplaceManifestPluginSource::Path(path) | RawMarketplaceManifestPluginSource::Object( RawMarketplaceManifestPluginSourceObject::Local { path }, ) => Ok(MarketplacePluginSource::Local { path: resolve_local_plugin_source_path(marketplace_path, &path)?, }), RawMarketplaceManifestPluginSource::Object( RawMarketplaceManifestPluginSourceObject::Url { url, path, ref_name, sha, }, ) => Ok(MarketplacePluginSource::Git { url: normalize_git_plugin_source_url(marketplace_path, &url)?, path: path .as_deref() .map(|path| normalize_remote_plugin_subdir(marketplace_path, path)) .transpose()?, ref_name: normalize_optional_git_selector(&ref_name), sha: normalize_optional_git_selector(&sha), }), RawMarketplaceManifestPluginSource::Object( RawMarketplaceManifestPluginSourceObject::GitSubdir { url, path, ref_name, sha, }, ) => Ok(MarketplacePluginSource::Git { url: normalize_git_plugin_source_url(marketplace_path, &url)?, path: Some(normalize_remote_plugin_subdir(marketplace_path, &path)?), ref_name: normalize_optional_git_selector(&ref_name), sha: normalize_optional_git_selector(&sha), }), RawMarketplaceManifestPluginSource::Unsupported(_) => { unreachable!("unsupported plugin sources should be filtered before resolution") } }}fn resolve_local_plugin_source_path( marketplace_path: &AbsolutePathBuf, path: &str,) -> Result<AbsolutePathBuf, MarketplaceError> { let Some(path) = path.strip_prefix("./") else { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "local plugin source path must start with `./`".to_string(), }); }; if path.is_empty() { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "local plugin source path must not be empty".to_string(), }); } let relative_source_path = Path::new(path); if relative_source_path .components() .any(|component| !matches!(component, Component::Normal(_))) { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "local plugin source path must stay within the marketplace root".to_string(), }); } // `marketplace.json` lives under a supported marketplace layout beneath `<root>`, // but local plugin paths are resolved relative to `<root>`. Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path))}fn normalize_remote_plugin_subdir( marketplace_path: &AbsolutePathBuf, path: &str,) -> Result<String, MarketplaceError> { let path = path.trim(); let path = path.strip_prefix("./").unwrap_or(path); if path.is_empty() { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "git plugin source path must not be empty".to_string(), }); } let relative_path = Path::new(path); if relative_path .components() .any(|component| !matches!(component, Component::Normal(_))) { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "git plugin source path must stay within the repository root".to_string(), }); } Ok(path.to_string())}fn normalize_git_plugin_source_url( marketplace_path: &AbsolutePathBuf, url: &str,) -> Result<String, MarketplaceError> { let url = url.trim(); if url.is_empty() { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "git plugin source url must not be empty".to_string(), }); } if url.starts_with("http://") || url.starts_with("https://") { return Ok(normalize_github_git_url(url)); } if url.starts_with("./") || url.starts_with("../") || url.starts_with(".\\") || url.starts_with("..\\") { return normalize_relative_git_plugin_source_url(marketplace_path, url); } if url.starts_with("file://") || url.starts_with('/') { return Ok(url.to_string()); } if url.starts_with("ssh://") || url.starts_with("git@") && url.contains(':') { return Ok(url.to_string()); } if let Some(url) = normalize_github_shorthand_url(url) { return Ok(url); } Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: format!("invalid git plugin source url: {url}"), })}fn normalize_relative_git_plugin_source_url( marketplace_path: &AbsolutePathBuf, url: &str,) -> Result<String, MarketplaceError> { let mut normalized = marketplace_root_dir(marketplace_path)? .as_path() .to_path_buf(); for segment in url.split(['/', '\\']) { match segment { "" | "." => {} ".." => { return Err(MarketplaceError::InvalidMarketplaceFile { path: marketplace_path.to_path_buf(), message: "relative git plugin source url must stay within the marketplace root" .to_string(), }); } segment => normalized.push(segment), } } Ok(normalized.display().to_string())}fn normalize_optional_git_selector(value: &Option<String>) -> Option<String> { value .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string)}fn normalize_github_git_url(url: &str) -> String { if url.starts_with("https://github.com/") && !url.ends_with(".git") { format!("{url}.git") } else { url.to_string() }}fn normalize_github_shorthand_url(source: &str) -> Option<String> { if !looks_like_github_shorthand(source) { return None; } let mut segments = source.split('/'); let owner = segments.next()?; let repo = segments.next()?; let repo = repo.strip_suffix(".git").unwrap_or(repo); if repo.is_empty() { return None; } Some(format!("https://github.com/{owner}/{repo}.git"))}fn looks_like_github_shorthand(source: &str) -> bool { let mut segments = source.split('/'); let owner = segments.next(); let repo = segments.next(); let extra = segments.next(); owner.is_some_and(is_github_shorthand_segment) && repo.is_some_and(is_github_shorthand_segment) && extra.is_none()}fn is_github_shorthand_segment(segment: &str) -> bool { !segment.is_empty() && segment .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))}pub fn plugin_interface_with_marketplace_category( mut interface: Option<PluginManifestInterface>, category: Option<String>,) -> Option<PluginManifestInterface> { if let Some(category) = category { // Marketplace taxonomy wins when both sources provide a category. interface .get_or_insert_with(PluginManifestInterface::default) .category = Some(category); } interface}#[doc(hidden)]pub fn marketplace_root_dir( marketplace_path: &AbsolutePathBuf,) -> Result<AbsolutePathBuf, MarketplaceError> { for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS { if let Some(marketplace_root) = marketplace_root_from_layout(marketplace_path.as_path(), relative_path) { return AbsolutePathBuf::try_from(marketplace_root) .map_err(|_| invalid_marketplace_layout_error(marketplace_path)); } } Err(invalid_marketplace_layout_error(marketplace_path))}#[derive(Debug, Deserialize)]#[serde(rename_all = "camelCase")]struct RawMarketplaceManifest { name: String, #[serde(default)] interface: Option<RawMarketplaceManifestInterface>, plugins: Vec<RawMarketplaceManifestPlugin>,}#[derive(Debug, Default, Deserialize)]#[serde(rename_all = "camelCase")]struct RawMarketplaceManifestInterface { #[serde(default)] display_name: Option<String>,}#[derive(Debug, Deserialize)]#[serde(rename_all = "camelCase")]struct RawMarketplaceManifestPlugin { name: String, source: RawMarketplaceManifestPluginSource, #[serde(default)] policy: RawMarketplaceManifestPluginPolicy, #[serde(default)] category: Option<String>,}#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]#[serde(rename_all = "camelCase")]struct RawMarketplaceManifestPluginPolicy { #[serde(default)] installation: MarketplacePluginInstallPolicy, #[serde(default)] authentication: MarketplacePluginAuthPolicy, products: Option<Vec<Product>>,}#[derive(Debug, Deserialize)]#[serde(untagged)]enum RawMarketplaceManifestPluginSource { Path(String), Object(RawMarketplaceManifestPluginSourceObject), #[allow(dead_code)] Unsupported(JsonValue),}#[derive(Debug, Deserialize)]#[serde(tag = "source", rename_all = "lowercase")]enum RawMarketplaceManifestPluginSourceObject { Local { path: String, }, Url { url: String, path: Option<String>, #[serde(rename = "ref")] ref_name: Option<String>, sha: Option<String>, }, #[serde(rename = "git-subdir")] GitSubdir { url: String, path: String, #[serde(rename = "ref")] ref_name: Option<String>, sha: Option<String>, },}fn resolve_marketplace_interface( interface: Option<RawMarketplaceManifestInterface>,) -> Option<MarketplaceInterface> { let interface = interface?; if interface.display_name.is_some() { Some(MarketplaceInterface { display_name: interface.display_name, }) } else { None }}#[cfg(test)]#[path = "marketplace_tests.rs"]mod tests;