feat: Add product-aware plugin policies and clean up manifest naming (#14993)

- Add shared Product support to marketplace plugin policy and skill
policy (no enforced yet).
- Move marketplace installation/authentication under policy and model it
as MarketplacePluginPolicy.
- Rename plugin/marketplace local manifest types to separate raw serde
shapes from resolved in-memory models.
This commit is contained in:
xl-openai 2026-03-17 17:01:34 -07:00 committed by GitHub
parent fc75d07504
commit a5d3114e97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 511 additions and 314 deletions

View file

@ -220,7 +220,7 @@ use codex_core::mcp::group_tools_by_server;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::parse_cursor;
use codex_core::plugins::MarketplaceError;
use codex_core::plugins::MarketplacePluginSourceSummary;
use codex_core::plugins::MarketplacePluginSource;
use codex_core::plugins::PluginInstallError as CorePluginInstallError;
use codex_core::plugins::PluginInstallRequest;
use codex_core::plugins::PluginReadRequest;
@ -5429,8 +5429,8 @@ impl CodexMessageProcessor {
enabled: plugin.enabled,
name: plugin.name,
source: marketplace_plugin_source_to_info(plugin.source),
install_policy: plugin.install_policy.into(),
auth_policy: plugin.auth_policy.into(),
install_policy: plugin.policy.installation.into(),
auth_policy: plugin.policy.authentication.into(),
interface: plugin.interface.map(plugin_interface_to_info),
})
.collect(),
@ -5519,8 +5519,8 @@ impl CodexMessageProcessor {
source: marketplace_plugin_source_to_info(outcome.plugin.source),
installed: outcome.plugin.installed,
enabled: outcome.plugin.enabled,
install_policy: outcome.plugin.install_policy.into(),
auth_policy: outcome.plugin.auth_policy.into(),
install_policy: outcome.plugin.policy.installation.into(),
auth_policy: outcome.plugin.policy.authentication.into(),
interface: outcome.plugin.interface.map(plugin_interface_to_info),
},
description: outcome.plugin.description,
@ -7456,7 +7456,7 @@ fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec<Sk
}
fn plugin_interface_to_info(
interface: codex_core::plugins::PluginManifestInterfaceSummary,
interface: codex_core::plugins::PluginManifestInterface,
) -> PluginInterface {
PluginInterface {
display_name: interface.display_name,
@ -7476,9 +7476,9 @@ fn plugin_interface_to_info(
}
}
fn marketplace_plugin_source_to_info(source: MarketplacePluginSourceSummary) -> PluginSource {
fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginSource {
match source {
MarketplacePluginSourceSummary::Local { path } => PluginSource::Local { path },
MarketplacePluginSource::Local { path } => PluginSource::Local { path },
}
}

View file

@ -655,12 +655,24 @@ fn write_plugin_marketplace(
install_policy: Option<&str>,
auth_policy: Option<&str>,
) -> std::io::Result<()> {
let install_policy = install_policy
.map(|install_policy| format!(",\n \"installPolicy\": \"{install_policy}\""))
.unwrap_or_default();
let auth_policy = auth_policy
.map(|auth_policy| format!(",\n \"authPolicy\": \"{auth_policy}\""))
.unwrap_or_default();
let policy = if install_policy.is_some() || auth_policy.is_some() {
let installation = install_policy
.map(|installation| format!("\n \"installation\": \"{installation}\""))
.unwrap_or_default();
let separator = if install_policy.is_some() && auth_policy.is_some() {
","
} else {
""
};
let authentication = auth_policy
.map(|authentication| {
format!("{separator}\n \"authentication\": \"{authentication}\"")
})
.unwrap_or_default();
format!(",\n \"policy\": {{{installation}{authentication}\n }}")
} else {
String::new()
};
std::fs::create_dir_all(repo_root.join(".git"))?;
std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;
std::fs::write(
@ -674,7 +686,7 @@ fn write_plugin_marketplace(
"source": {{
"source": "local",
"path": "{source_path}"
}}{install_policy}{auth_policy}
}}{policy}
}}
]
}}"#

View file

@ -396,8 +396,10 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
"source": "local",
"path": "./plugins/demo-plugin"
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Design"
}
]

View file

@ -36,8 +36,10 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()>
"source": "local",
"path": "./plugins/demo-plugin"
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Design"
}
]

View file

@ -1,18 +1,16 @@
use super::PluginManifestPaths;
use super::curated_plugins_repo_path;
use super::load_plugin_manifest;
use super::manifest::PluginManifestInterfaceSummary;
use super::manifest::PluginManifestInterface;
use super::marketplace::MarketplaceError;
use super::marketplace::MarketplaceInterfaceSummary;
use super::marketplace::MarketplaceInterface;
use super::marketplace::MarketplacePluginAuthPolicy;
use super::marketplace::MarketplacePluginInstallPolicy;
use super::marketplace::MarketplacePluginSourceSummary;
use super::marketplace::MarketplacePluginPolicy;
use super::marketplace::MarketplacePluginSource;
use super::marketplace::ResolvedMarketplacePlugin;
use super::marketplace::list_marketplaces;
use super::marketplace::load_marketplace_summary;
use super::marketplace::load_marketplace;
use super::marketplace::resolve_marketplace_plugin;
use super::plugin_manifest_name;
use super::plugin_manifest_paths;
use super::read_curated_plugins_sha;
use super::remote::RemotePluginFetchError;
use super::remote::RemotePluginMutationError;
@ -99,18 +97,17 @@ pub struct PluginInstallOutcome {
pub struct PluginReadOutcome {
pub marketplace_name: String,
pub marketplace_path: AbsolutePathBuf,
pub plugin: PluginDetailSummary,
pub plugin: PluginDetail,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PluginDetailSummary {
pub struct PluginDetail {
pub id: String,
pub name: String,
pub description: Option<String>,
pub source: MarketplacePluginSourceSummary,
pub install_policy: MarketplacePluginInstallPolicy,
pub auth_policy: MarketplacePluginAuthPolicy,
pub interface: Option<PluginManifestInterfaceSummary>,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
pub installed: bool,
pub enabled: bool,
pub skills: Vec<SkillMetadata>,
@ -119,21 +116,20 @@ pub struct PluginDetailSummary {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredMarketplaceSummary {
pub struct ConfiguredMarketplace {
pub name: String,
pub path: AbsolutePathBuf,
pub interface: Option<MarketplaceInterfaceSummary>,
pub plugins: Vec<ConfiguredMarketplacePluginSummary>,
pub interface: Option<MarketplaceInterface>,
pub plugins: Vec<ConfiguredMarketplacePlugin>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredMarketplacePluginSummary {
pub struct ConfiguredMarketplacePlugin {
pub id: String,
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub install_policy: MarketplacePluginInstallPolicy,
pub auth_policy: MarketplacePluginAuthPolicy,
pub interface: Option<PluginManifestInterfaceSummary>,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
pub installed: bool,
pub enabled: bool,
}
@ -219,8 +215,8 @@ impl PluginCapabilitySummary {
}
}
impl From<PluginDetailSummary> for PluginCapabilitySummary {
fn from(value: PluginDetailSummary) -> Self {
impl From<PluginDetail> for PluginCapabilitySummary {
fn from(value: PluginDetail) -> Self {
Self {
config_name: value.id,
display_name: value.name,
@ -648,7 +644,7 @@ impl PluginsManager {
curated_marketplace_root.join(".agents/plugins/marketplace.json"),
)
.map_err(|_| PluginRemoteSyncError::LocalMarketplaceNotFound)?;
let curated_marketplace = match load_marketplace_summary(&curated_marketplace_path) {
let curated_marketplace = match load_marketplace(&curated_marketplace_path) {
Ok(marketplace) => marketplace,
Err(MarketplaceError::MarketplaceNotFound { .. }) => {
return Err(PluginRemoteSyncError::LocalMarketplaceNotFound);
@ -685,7 +681,7 @@ impl PluginsManager {
let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone())?;
let plugin_key = plugin_id.as_key();
let source_path = match plugin.source {
MarketplacePluginSourceSummary::Local { path } => path,
MarketplacePluginSource::Local { path } => path,
};
let current_enabled = configured_plugins
.get(&plugin_key)
@ -820,7 +816,7 @@ impl PluginsManager {
&self,
config: &Config,
additional_roots: &[AbsolutePathBuf],
) -> Result<Vec<ConfiguredMarketplaceSummary>, MarketplaceError> {
) -> Result<Vec<ConfiguredMarketplace>, MarketplaceError> {
let (installed_plugins, configured_plugins) = self.configured_plugin_states(config);
let marketplaces = list_marketplaces(&self.marketplace_roots(additional_roots))?;
let mut seen_plugin_keys = HashSet::new();
@ -838,7 +834,7 @@ impl PluginsManager {
return None;
}
Some(ConfiguredMarketplacePluginSummary {
Some(ConfiguredMarketplacePlugin {
// Enabled state is keyed by `<plugin>@<marketplace>`, so duplicate
// plugin entries from duplicate marketplace files intentionally
// resolve to the first discovered source.
@ -850,14 +846,13 @@ impl PluginsManager {
.unwrap_or(false),
name: plugin.name,
source: plugin.source,
install_policy: plugin.install_policy,
auth_policy: plugin.auth_policy,
policy: plugin.policy,
interface: plugin.interface,
})
})
.collect::<Vec<_>>();
(!plugins.is_empty()).then_some(ConfiguredMarketplaceSummary {
(!plugins.is_empty()).then_some(ConfiguredMarketplace {
name: marketplace.name,
path: marketplace.path,
interface: marketplace.interface,
@ -872,7 +867,7 @@ impl PluginsManager {
config: &Config,
request: &PluginReadRequest,
) -> Result<PluginReadOutcome, MarketplaceError> {
let marketplace = load_marketplace_summary(&request.marketplace_path)?;
let marketplace = load_marketplace(&request.marketplace_path)?;
let marketplace_name = marketplace.name.clone();
let plugin = marketplace
.plugins
@ -893,7 +888,7 @@ impl PluginsManager {
let plugin_key = plugin_id.as_key();
let (installed_plugins, configured_plugins) = self.configured_plugin_states(config);
let source_path = match &plugin.source {
MarketplacePluginSourceSummary::Local { path } => path.clone(),
MarketplacePluginSource::Local { path } => path.clone(),
};
let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| {
MarketplaceError::InvalidPlugin(
@ -901,15 +896,15 @@ impl PluginsManager {
)
})?;
let description = manifest.description.clone();
let manifest_paths = plugin_manifest_paths(&manifest, source_path.as_path());
let skill_roots = plugin_skill_roots(source_path.as_path(), &manifest_paths);
let manifest_paths = &manifest.paths;
let skill_roots = plugin_skill_roots(source_path.as_path(), manifest_paths);
let skills = load_skills_from_roots(skill_roots.into_iter().map(|path| SkillRoot {
path,
scope: SkillScope::User,
}))
.skills;
let apps = load_plugin_apps(source_path.as_path());
let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), &manifest_paths);
let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), manifest_paths);
let mut mcp_server_names = Vec::new();
for mcp_config_path in mcp_config_paths {
mcp_server_names.extend(
@ -924,13 +919,12 @@ impl PluginsManager {
Ok(PluginReadOutcome {
marketplace_name: marketplace.name,
marketplace_path: marketplace.path,
plugin: PluginDetailSummary {
plugin: PluginDetail {
id: plugin_key.clone(),
name: plugin.name,
description,
source: plugin.source,
install_policy: plugin.install_policy,
auth_policy: plugin.auth_policy,
policy: plugin.policy,
interface: plugin.interface,
installed: installed_plugins.contains(&plugin_key),
enabled: configured_plugins
@ -1200,7 +1194,7 @@ pub(crate) fn load_plugins_from_layer_stack(
pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
for ancestor in path.ancestors() {
if let Some(manifest) = load_plugin_manifest(ancestor) {
return Some(plugin_manifest_name(&manifest, ancestor));
return Some(manifest.name);
}
}
@ -1217,7 +1211,7 @@ fn refresh_curated_plugin_cache(
curated_plugins_repo_path(codex_home).join(".agents/plugins/marketplace.json"),
)
.map_err(|_| "local curated marketplace is not available".to_string())?;
let curated_marketplace = load_marketplace_summary(&curated_marketplace_path)
let curated_marketplace = load_marketplace(&curated_marketplace_path)
.map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?;
let mut plugin_sources = HashMap::<String, AbsolutePathBuf>::new();
@ -1232,7 +1226,7 @@ fn refresh_curated_plugin_cache(
continue;
}
let source_path = match plugin.source {
MarketplacePluginSourceSummary::Local { path } => path,
MarketplacePluginSource::Local { path } => path,
};
plugin_sources.insert(plugin_name, source_path);
}
@ -1329,12 +1323,12 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
return loaded_plugin;
};
let manifest_paths = plugin_manifest_paths(&manifest, plugin_root.as_path());
loaded_plugin.manifest_name = Some(plugin_manifest_name(&manifest, plugin_root.as_path()));
loaded_plugin.manifest_description = manifest.description;
loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), &manifest_paths);
let manifest_paths = &manifest.paths;
loaded_plugin.manifest_name = Some(manifest.name.clone());
loaded_plugin.manifest_description = manifest.description.clone();
loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), manifest_paths);
let mut mcp_servers = HashMap::new();
for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), &manifest_paths) {
for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) {
let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path);
for (name, config) in plugin_mcp.mcp_servers {
if mcp_servers.insert(name.clone(), config).is_some() {
@ -1396,10 +1390,9 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
pub fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
if let Some(manifest) = load_plugin_manifest(plugin_root) {
let manifest_paths = plugin_manifest_paths(&manifest, plugin_root);
return load_apps_from_paths(
plugin_root,
plugin_app_config_paths(plugin_root, &manifest_paths),
plugin_app_config_paths(plugin_root, &manifest.paths),
);
}
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root))
@ -1475,10 +1468,10 @@ pub fn plugin_telemetry_metadata_from_root(
return PluginTelemetryMetadata::from_plugin_id(plugin_id);
};
let manifest_paths = plugin_manifest_paths(&manifest, plugin_root);
let has_skills = !plugin_skill_roots(plugin_root, &manifest_paths).is_empty();
let manifest_paths = &manifest.paths;
let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty();
let mut mcp_server_names = Vec::new();
for path in plugin_mcp_config_paths(plugin_root, &manifest_paths) {
for path in plugin_mcp_config_paths(plugin_root, manifest_paths) {
mcp_server_names.extend(
load_mcp_servers_from_file(plugin_root, &path)
.mcp_servers

View file

@ -7,6 +7,7 @@ use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::MarketplacePluginInstallPolicy;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
@ -811,7 +812,9 @@ async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
"source": "local",
"path": "./sample-plugin"
},
"authPolicy": "ON_USE"
"policy": {
"authentication": "ON_USE"
}
}
]
}"#,
@ -952,7 +955,7 @@ enabled = false
assert_eq!(
marketplace,
ConfiguredMarketplaceSummary {
ConfiguredMarketplace {
name: "debug".to_string(),
path: AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
@ -960,28 +963,34 @@ enabled = false
.unwrap(),
interface: None,
plugins: vec![
ConfiguredMarketplacePluginSummary {
ConfiguredMarketplacePlugin {
id: "enabled-plugin@debug".to_string(),
name: "enabled-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
.unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
installed: true,
enabled: true,
},
ConfiguredMarketplacePluginSummary {
ConfiguredMarketplacePlugin {
id: "disabled-plugin@debug".to_string(),
name: "disabled-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),)
.unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
installed: true,
enabled: false,
@ -1036,21 +1045,24 @@ async fn list_marketplaces_includes_curated_repo_marketplace() {
assert_eq!(
curated_marketplace,
ConfiguredMarketplaceSummary {
ConfiguredMarketplace {
name: "openai-curated".to_string(),
path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json"))
.unwrap(),
interface: Some(MarketplaceInterfaceSummary {
interface: Some(MarketplaceInterface {
display_name: Some("ChatGPT Official".to_string()),
}),
plugins: vec![ConfiguredMarketplacePluginSummary {
plugins: vec![ConfiguredMarketplacePlugin {
id: "linear@openai-curated".to_string(),
name: "linear".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
installed: false,
enabled: false,
@ -1143,14 +1155,17 @@ enabled = false
.expect("repo-a marketplace should be listed");
assert_eq!(
repo_a_marketplace.plugins,
vec![ConfiguredMarketplacePluginSummary {
vec![ConfiguredMarketplacePlugin {
id: "dup-plugin@debug".to_string(),
name: "dup-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
installed: false,
enabled: true,
@ -1169,14 +1184,17 @@ enabled = false
.expect("repo-b marketplace should be listed");
assert_eq!(
repo_b_marketplace.plugins,
vec![ConfiguredMarketplacePluginSummary {
vec![ConfiguredMarketplacePlugin {
id: "b-only-plugin@debug".to_string(),
name: "b-only-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
installed: false,
enabled: false,
@ -1241,21 +1259,24 @@ enabled = true
assert_eq!(
marketplace,
ConfiguredMarketplaceSummary {
ConfiguredMarketplace {
name: "debug".to_string(),
path: AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap(),
interface: None,
plugins: vec![ConfiguredMarketplacePluginSummary {
plugins: vec![ConfiguredMarketplacePlugin {
id: "sample-plugin@debug".to_string(),
name: "sample-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
installed: false,
enabled: true,

View file

@ -11,11 +11,11 @@ const MAX_DEFAULT_PROMPT_LEN: usize = 128;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PluginManifest {
struct RawPluginManifest {
#[serde(default)]
pub(crate) name: String,
name: String,
#[serde(default)]
pub(crate) description: Option<String>,
description: Option<String>,
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
#[serde(default)]
@ -25,7 +25,15 @@ pub(crate) struct PluginManifest {
#[serde(default)]
apps: Option<String>,
#[serde(default)]
interface: Option<PluginManifestInterface>,
interface: Option<RawPluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PluginManifest {
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) paths: PluginManifestPaths,
pub(crate) interface: Option<PluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -36,7 +44,7 @@ pub struct PluginManifestPaths {
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginManifestInterfaceSummary {
pub struct PluginManifestInterface {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub long_description: Option<String>,
@ -55,7 +63,7 @@ pub struct PluginManifestInterfaceSummary {
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginManifestInterface {
struct RawPluginManifestInterface {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
@ -78,7 +86,7 @@ struct PluginManifestInterface {
#[serde(alias = "termsOfServiceURL")]
terms_of_service_url: Option<String>,
#[serde(default)]
default_prompt: Option<PluginManifestDefaultPrompt>,
default_prompt: Option<RawPluginManifestDefaultPrompt>,
#[serde(default)]
brand_color: Option<String>,
#[serde(default)]
@ -91,15 +99,15 @@ struct PluginManifestInterface {
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PluginManifestDefaultPrompt {
enum RawPluginManifestDefaultPrompt {
String(String),
List(Vec<PluginManifestDefaultPromptEntry>),
List(Vec<RawPluginManifestDefaultPromptEntry>),
Invalid(JsonValue),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PluginManifestDefaultPromptEntry {
enum RawPluginManifestDefaultPromptEntry {
String(String),
Invalid(JsonValue),
}
@ -110,8 +118,106 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest>
return None;
}
let contents = fs::read_to_string(&manifest_path).ok()?;
match serde_json::from_str(&contents) {
Ok(manifest) => Some(manifest),
match serde_json::from_str::<RawPluginManifest>(&contents) {
Ok(manifest) => {
let RawPluginManifest {
name: raw_name,
description,
skills,
mcp_servers,
apps,
interface,
} = manifest;
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 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(plugin_root, 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)
});
Some(PluginManifest {
name,
description,
paths: PluginManifestPaths {
skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()),
mcp_servers: resolve_manifest_path(
plugin_root,
"mcpServers",
mcp_servers.as_deref(),
),
apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()),
},
interface,
})
}
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
@ -122,84 +228,6 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest>
}
}
pub(crate) fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path) -> String {
plugin_root
.file_name()
.and_then(|name| name.to_str())
.filter(|_| manifest.name.trim().is_empty())
.unwrap_or(&manifest.name)
.to_string()
}
pub(crate) fn plugin_manifest_interface(
manifest: &PluginManifest,
plugin_root: &Path,
) -> Option<PluginManifestInterfaceSummary> {
let interface = manifest.interface.as_ref()?;
let interface = PluginManifestInterfaceSummary {
display_name: interface.display_name.clone(),
short_description: interface.short_description.clone(),
long_description: interface.long_description.clone(),
developer_name: interface.developer_name.clone(),
category: interface.category.clone(),
capabilities: interface.capabilities.clone(),
website_url: interface.website_url.clone(),
privacy_policy_url: interface.privacy_policy_url.clone(),
terms_of_service_url: interface.terms_of_service_url.clone(),
default_prompt: resolve_default_prompts(plugin_root, interface.default_prompt.as_ref()),
brand_color: interface.brand_color.clone(),
composer_icon: resolve_interface_asset_path(
plugin_root,
"interface.composerIcon",
interface.composer_icon.as_deref(),
),
logo: resolve_interface_asset_path(
plugin_root,
"interface.logo",
interface.logo.as_deref(),
),
screenshots: interface
.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)
}
pub(crate) fn plugin_manifest_paths(
manifest: &PluginManifest,
plugin_root: &Path,
) -> PluginManifestPaths {
PluginManifestPaths {
skills: resolve_manifest_path(plugin_root, "skills", manifest.skills.as_deref()),
mcp_servers: resolve_manifest_path(
plugin_root,
"mcpServers",
manifest.mcp_servers.as_deref(),
),
apps: resolve_manifest_path(plugin_root, "apps", manifest.apps.as_deref()),
}
}
fn resolve_interface_asset_path(
plugin_root: &Path,
field: &'static str,
@ -210,14 +238,14 @@ fn resolve_interface_asset_path(
fn resolve_default_prompts(
plugin_root: &Path,
value: Option<&PluginManifestDefaultPrompt>,
value: Option<&RawPluginManifestDefaultPrompt>,
) -> Option<Vec<String>> {
match value? {
PluginManifestDefaultPrompt::String(prompt) => {
RawPluginManifestDefaultPrompt::String(prompt) => {
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
.map(|prompt| vec![prompt])
}
PluginManifestDefaultPrompt::List(values) => {
RawPluginManifestDefaultPrompt::List(values) => {
let mut prompts = Vec::new();
for (index, item) in values.iter().enumerate() {
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
@ -230,7 +258,7 @@ fn resolve_default_prompts(
}
match item {
PluginManifestDefaultPromptEntry::String(prompt) => {
RawPluginManifestDefaultPromptEntry::String(prompt) => {
let field = format!("interface.defaultPrompt[{index}]");
if let Some(prompt) =
resolve_default_prompt_str(plugin_root, &field, prompt)
@ -238,7 +266,7 @@ fn resolve_default_prompts(
prompts.push(prompt);
}
}
PluginManifestDefaultPromptEntry::Invalid(value) => {
RawPluginManifestDefaultPromptEntry::Invalid(value) => {
let field = format!("interface.defaultPrompt[{index}]");
warn_invalid_default_prompt(
plugin_root,
@ -251,7 +279,7 @@ fn resolve_default_prompts(
(!prompts.is_empty()).then_some(prompts)
}
PluginManifestDefaultPrompt::Invalid(value) => {
RawPluginManifestDefaultPrompt::Invalid(value) => {
warn_invalid_default_prompt(
plugin_root,
"interface.defaultPrompt",
@ -348,7 +376,7 @@ fn resolve_manifest_path(
mod tests {
use super::MAX_DEFAULT_PROMPT_LEN;
use super::PluginManifest;
use super::plugin_manifest_interface;
use super::load_plugin_manifest;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
@ -369,13 +397,11 @@ mod tests {
}
fn load_manifest(plugin_root: &Path) -> PluginManifest {
let manifest_path = plugin_root.join(".codex-plugin/plugin.json");
let contents = fs::read_to_string(manifest_path).expect("read manifest");
serde_json::from_str(&contents).expect("parse manifest")
load_plugin_manifest(plugin_root).expect("load plugin manifest")
}
#[test]
fn plugin_manifest_interface_accepts_legacy_default_prompt_string() {
fn plugin_interface_accepts_legacy_default_prompt_string() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
@ -387,8 +413,7 @@ mod tests {
);
let manifest = load_manifest(&plugin_root);
let interface =
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
let interface = manifest.interface.expect("plugin interface");
assert_eq!(
interface.default_prompt,
@ -397,7 +422,7 @@ mod tests {
}
#[test]
fn plugin_manifest_interface_normalizes_default_prompt_array() {
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);
@ -420,8 +445,7 @@ mod tests {
);
let manifest = load_manifest(&plugin_root);
let interface =
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
let interface = manifest.interface.expect("plugin interface");
assert_eq!(
interface.default_prompt,
@ -434,7 +458,7 @@ mod tests {
}
#[test]
fn plugin_manifest_interface_ignores_invalid_default_prompt_shape() {
fn plugin_interface_ignores_invalid_default_prompt_shape() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
@ -446,8 +470,7 @@ mod tests {
);
let manifest = load_manifest(&plugin_root);
let interface =
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
let interface = manifest.interface.expect("plugin interface");
assert_eq!(interface.default_prompt, None);
}

View file

@ -1,11 +1,11 @@
use super::PluginManifestInterfaceSummary;
use super::PluginManifestInterface;
use super::load_plugin_manifest;
use super::plugin_manifest_interface;
use super::store::PluginId;
use super::store::PluginIdError;
use crate::git_info::get_git_repo_root;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_protocol::protocol::Product;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
use serde::Deserialize;
@ -25,32 +25,40 @@ pub struct ResolvedMarketplacePlugin {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceSummary {
pub struct Marketplace {
pub name: String,
pub path: AbsolutePathBuf,
pub interface: Option<MarketplaceInterfaceSummary>,
pub plugins: Vec<MarketplacePluginSummary>,
pub interface: Option<MarketplaceInterface>,
pub plugins: Vec<MarketplacePlugin>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceInterfaceSummary {
pub struct MarketplaceInterface {
pub display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplacePluginSummary {
pub struct MarketplacePlugin {
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub install_policy: MarketplacePluginInstallPolicy,
pub auth_policy: MarketplacePluginAuthPolicy,
pub interface: Option<PluginManifestInterfaceSummary>,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarketplacePluginSourceSummary {
pub enum MarketplacePluginSource {
Local { path: AbsolutePathBuf },
}
#[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: Vec<Product>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
pub enum MarketplacePluginInstallPolicy {
#[serde(rename = "NOT_AVAILABLE")]
@ -135,7 +143,7 @@ pub fn resolve_marketplace_plugin(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
let marketplace = load_marketplace(marketplace_path)?;
let marketplace = load_raw_marketplace_manifest(marketplace_path)?;
let marketplace_name = marketplace.name;
let plugin = marketplace
.plugins
@ -149,13 +157,13 @@ pub fn resolve_marketplace_plugin(
});
};
let MarketplacePlugin {
let RawMarketplaceManifestPlugin {
name,
source,
install_policy,
auth_policy,
policy,
..
} = plugin;
let install_policy = policy.installation;
if install_policy == MarketplacePluginInstallPolicy::NotAvailable {
return Err(MarketplaceError::PluginNotAvailable {
plugin_name: name,
@ -169,56 +177,56 @@ pub fn resolve_marketplace_plugin(
Ok(ResolvedMarketplacePlugin {
plugin_id,
source_path: resolve_plugin_source_path(marketplace_path, source)?,
auth_policy,
auth_policy: policy.authentication,
})
}
pub fn list_marketplaces(
additional_roots: &[AbsolutePathBuf],
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
) -> Result<Vec<Marketplace>, MarketplaceError> {
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
}
pub(crate) fn load_marketplace_summary(
path: &AbsolutePathBuf,
) -> Result<MarketplaceSummary, MarketplaceError> {
let marketplace = load_marketplace(path)?;
pub(crate) 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 MarketplacePlugin {
let RawMarketplaceManifestPlugin {
name,
source,
install_policy,
auth_policy,
policy,
category,
} = plugin;
let source_path = resolve_plugin_source_path(path, source)?;
let source = MarketplacePluginSourceSummary::Local {
let source = MarketplacePluginSource::Local {
path: source_path.clone(),
};
let mut interface = load_plugin_manifest(source_path.as_path())
.and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path()));
let mut interface =
load_plugin_manifest(source_path.as_path()).and_then(|manifest| manifest.interface);
if let Some(category) = category {
// Marketplace taxonomy wins when both sources provide a category.
interface
.get_or_insert_with(PluginManifestInterfaceSummary::default)
.get_or_insert_with(PluginManifestInterface::default)
.category = Some(category);
}
plugins.push(MarketplacePluginSummary {
plugins.push(MarketplacePlugin {
name,
source,
install_policy,
auth_policy,
policy: MarketplacePluginPolicy {
installation: policy.installation,
authentication: policy.authentication,
products: policy.products,
},
interface,
});
}
Ok(MarketplaceSummary {
Ok(Marketplace {
name: marketplace.name,
path: path.clone(),
interface: marketplace_interface_summary(marketplace.interface),
interface: resolve_marketplace_interface(marketplace.interface),
plugins,
})
}
@ -226,11 +234,11 @@ pub(crate) fn load_marketplace_summary(
fn list_marketplaces_with_home(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
) -> Result<Vec<Marketplace>, MarketplaceError> {
let mut marketplaces = Vec::new();
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
marketplaces.push(load_marketplace_summary(&marketplace_path)?);
marketplaces.push(load_marketplace(&marketplace_path)?);
}
Ok(marketplaces)
@ -274,7 +282,9 @@ fn discover_marketplace_paths_from_roots(
paths
}
fn load_marketplace(path: &AbsolutePathBuf) -> Result<MarketplaceFile, MarketplaceError> {
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 {
@ -292,10 +302,10 @@ fn load_marketplace(path: &AbsolutePathBuf) -> Result<MarketplaceFile, Marketpla
fn resolve_plugin_source_path(
marketplace_path: &AbsolutePathBuf,
source: MarketplacePluginSource,
source: RawMarketplaceManifestPluginSource,
) -> Result<AbsolutePathBuf, MarketplaceError> {
match source {
MarketplacePluginSource::Local { path } => {
RawMarketplaceManifestPluginSource::Local { path } => {
let Some(path) = path.strip_prefix("./") else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
@ -373,45 +383,54 @@ fn marketplace_root_dir(
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MarketplaceFile {
struct RawMarketplaceManifest {
name: String,
#[serde(default)]
interface: Option<MarketplaceInterface>,
plugins: Vec<MarketplacePlugin>,
interface: Option<RawMarketplaceManifestInterface>,
plugins: Vec<RawMarketplaceManifestPlugin>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MarketplaceInterface {
struct RawMarketplaceManifestInterface {
#[serde(default)]
display_name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MarketplacePlugin {
struct RawMarketplaceManifestPlugin {
name: String,
source: MarketplacePluginSource,
source: RawMarketplaceManifestPluginSource,
#[serde(default)]
install_policy: MarketplacePluginInstallPolicy,
#[serde(default)]
auth_policy: MarketplacePluginAuthPolicy,
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,
#[serde(default)]
products: Vec<Product>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
enum MarketplacePluginSource {
enum RawMarketplaceManifestPluginSource {
Local { path: String },
}
fn marketplace_interface_summary(
interface: Option<MarketplaceInterface>,
) -> Option<MarketplaceInterfaceSummary> {
fn resolve_marketplace_interface(
interface: Option<RawMarketplaceManifestInterface>,
) -> Option<MarketplaceInterface> {
let interface = interface?;
if interface.display_name.is_some() {
Some(MarketplaceInterfaceSummary {
Some(MarketplaceInterface {
display_name: interface.display_name,
})
} else {

View file

@ -1,4 +1,5 @@
use super::*;
use codex_protocol::protocol::Product;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
@ -132,56 +133,68 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
assert_eq!(
marketplaces,
vec![
MarketplaceSummary {
Marketplace {
name: "codex-curated".to_string(),
path:
AbsolutePathBuf::try_from(home_root.join(".agents/plugins/marketplace.json"),)
.unwrap(),
interface: None,
plugins: vec![
MarketplacePluginSummary {
MarketplacePlugin {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(home_root.join("home-shared")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
},
MarketplacePluginSummary {
MarketplacePlugin {
name: "home-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(home_root.join("home-only")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
},
],
},
MarketplaceSummary {
Marketplace {
name: "codex-curated".to_string(),
path:
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"),)
.unwrap(),
interface: None,
plugins: vec![
MarketplacePluginSummary {
MarketplacePlugin {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
},
MarketplacePluginSummary {
MarketplacePlugin {
name: "repo-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("repo-only")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
},
],
@ -244,31 +257,37 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
assert_eq!(
marketplaces,
vec![
MarketplaceSummary {
Marketplace {
name: "codex-curated".to_string(),
path: AbsolutePathBuf::try_from(home_marketplace).unwrap(),
interface: None,
plugins: vec![MarketplacePluginSummary {
plugins: vec![MarketplacePlugin {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
}],
},
MarketplaceSummary {
Marketplace {
name: "codex-curated".to_string(),
path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(),
interface: None,
plugins: vec![MarketplacePluginSummary {
plugins: vec![MarketplacePlugin {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
}],
},
@ -324,18 +343,21 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
assert_eq!(
marketplaces,
vec![MarketplaceSummary {
vec![Marketplace {
name: "codex-curated".to_string(),
path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
.unwrap(),
interface: None,
plugins: vec![MarketplacePluginSummary {
plugins: vec![MarketplacePlugin {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
},
install_policy: MarketplacePluginInstallPolicy::Available,
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: vec![],
},
interface: None,
}],
}]
@ -375,7 +397,7 @@ fn list_marketplaces_reads_marketplace_display_name() {
assert_eq!(
marketplaces[0].interface,
Some(MarketplaceInterfaceSummary {
Some(MarketplaceInterface {
display_name: Some("ChatGPT Official".to_string()),
})
);
@ -400,8 +422,11 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
"source": "local",
"path": "./plugins/demo-plugin"
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL",
"products": ["CODEX", "CHATGPT", "ATLAS"]
},
"category": "Design"
}
]
@ -429,16 +454,20 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].install_policy,
marketplaces[0].plugins[0].policy.installation,
MarketplacePluginInstallPolicy::Available
);
assert_eq!(
marketplaces[0].plugins[0].auth_policy,
marketplaces[0].plugins[0].policy.authentication,
MarketplacePluginAuthPolicy::OnInstall
);
assert_eq!(
marketplaces[0].plugins[0].policy.products,
vec![Product::Codex, Product::Chatgpt, Product::Atlas]
);
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
Some(PluginManifestInterface {
display_name: Some("Demo".to_string()),
short_description: None,
long_description: None,
@ -461,6 +490,47 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
);
}
#[test]
fn list_marketplaces_ignores_legacy_top_level_policy_fields() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "demo-plugin",
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
},
"installPolicy": "NOT_AVAILABLE",
"authPolicy": "ON_USE"
}
]
}"#,
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].policy.installation,
MarketplacePluginInstallPolicy::Available
);
assert_eq!(
marketplaces[0].plugins[0].policy.authentication,
MarketplacePluginAuthPolicy::OnInstall
);
assert_eq!(marketplaces[0].plugins[0].policy.products, Vec::new());
}
#[test]
fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
let tmp = tempdir().unwrap();
@ -507,7 +577,7 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
Some(PluginManifestInterface {
display_name: Some("Demo".to_string()),
short_description: None,
long_description: None,
@ -525,13 +595,14 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
})
);
assert_eq!(
marketplaces[0].plugins[0].install_policy,
marketplaces[0].plugins[0].policy.installation,
MarketplacePluginInstallPolicy::Available
);
assert_eq!(
marketplaces[0].plugins[0].auth_policy,
marketplaces[0].plugins[0].policy.authentication,
MarketplacePluginAuthPolicy::OnInstall
);
assert_eq!(marketplaces[0].plugins[0].policy.products, Vec::new());
}
#[test]

View file

@ -17,12 +17,12 @@ pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
pub use manager::ConfiguredMarketplaceSummary;
pub use manager::ConfiguredMarketplace;
pub use manager::ConfiguredMarketplacePlugin;
pub use manager::LoadedPlugin;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginDetailSummary;
pub use manager::PluginDetail;
pub use manager::PluginInstallError;
pub use manager::PluginInstallOutcome;
pub use manager::PluginInstallRequest;
@ -38,16 +38,14 @@ pub use manager::installed_plugin_telemetry_metadata;
pub use manager::load_plugin_apps;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub use manager::plugin_telemetry_metadata_from_root;
pub use manifest::PluginManifestInterfaceSummary;
pub use manifest::PluginManifestInterface;
pub(crate) use manifest::PluginManifestPaths;
pub(crate) use manifest::load_plugin_manifest;
pub(crate) use manifest::plugin_manifest_interface;
pub(crate) use manifest::plugin_manifest_name;
pub(crate) use manifest::plugin_manifest_paths;
pub use marketplace::MarketplaceError;
pub use marketplace::MarketplacePluginAuthPolicy;
pub use marketplace::MarketplacePluginInstallPolicy;
pub use marketplace::MarketplacePluginSourceSummary;
pub use marketplace::MarketplacePluginPolicy;
pub use marketplace::MarketplacePluginSource;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use render::render_plugins_section;
pub use store::PluginId;

View file

@ -1,6 +1,5 @@
use super::load_plugin_manifest;
use super::manifest::PLUGIN_MANIFEST_PATH;
use super::plugin_manifest_name;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
use std::io;
@ -211,7 +210,7 @@ fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError
))
})?;
let plugin_name = plugin_manifest_name(&manifest, source_path);
let plugin_name = manifest.name;
validate_plugin_segment(&plugin_name, "plugin name")
.map_err(PluginStoreError::Invalid)
.map(|_| plugin_name)

View file

@ -18,6 +18,7 @@ use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use dirs::home_dir;
@ -114,6 +115,8 @@ struct Dependencies {
struct Policy {
#[serde(default)]
allow_implicit_invocation: Option<bool>,
#[serde(default)]
products: Vec<Product>,
}
#[derive(Debug, Default, Deserialize)]
@ -735,6 +738,7 @@ fn resolve_dependencies(dependencies: Option<Dependencies>) -> Option<SkillDepen
fn resolve_policy(policy: Option<Policy>) -> Option<SkillPolicy> {
policy.map(|policy| SkillPolicy {
allow_implicit_invocation: policy.allow_implicit_invocation,
products: policy.products,
})
}

View file

@ -15,6 +15,7 @@ use codex_protocol::models::MacOsContactsPermission;
use codex_protocol::models::MacOsPreferencesPermission;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@ -482,6 +483,7 @@ policy:
outcome.skills[0].policy,
Some(SkillPolicy {
allow_implicit_invocation: Some(false),
products: vec![],
})
);
assert!(outcome.allowed_skills_for_implicit_invocation().is_empty());
@ -513,6 +515,7 @@ policy: {}
outcome.skills[0].policy,
Some(SkillPolicy {
allow_implicit_invocation: None,
products: vec![],
})
);
assert_eq!(
@ -521,6 +524,41 @@ policy: {}
);
}
#[tokio::test]
async fn loads_skill_policy_products_from_yaml() {
let codex_home = tempfile::tempdir().expect("tempdir");
let skill_path = write_skill(&codex_home, "demo", "policy-products", "from yaml");
let skill_dir = skill_path.parent().expect("skill dir");
write_skill_metadata_at(
skill_dir,
r#"
policy:
products:
- codex
- CHATGPT
- atlas
"#,
);
let cfg = make_config(&codex_home).await;
let outcome = load_skills_for_test(&cfg);
assert!(
outcome.errors.is_empty(),
"unexpected errors: {:?}",
outcome.errors
);
assert_eq!(outcome.skills.len(), 1);
assert_eq!(
outcome.skills[0].policy,
Some(SkillPolicy {
allow_implicit_invocation: None,
products: vec![Product::Codex, Product::Chatgpt, Product::Atlas],
})
);
}
#[tokio::test]
async fn loads_skill_permissions_from_yaml() {
let codex_home = tempfile::tempdir().expect("tempdir");

View file

@ -4,6 +4,7 @@ use std::path::PathBuf;
use std::sync::Arc;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use serde::Deserialize;
@ -43,9 +44,12 @@ impl SkillMetadata {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SkillPolicy {
pub allow_implicit_invocation: Option<bool>,
// TODO: Enforce product gating in Codex skill selection/injection instead of only parsing and
// storing this metadata.
pub products: Vec<Product>,
}
#[derive(Debug, Clone, PartialEq, Eq)]

View file

@ -2908,6 +2908,17 @@ pub struct ListSkillsResponseEvent {
pub skills: Vec<SkillsListEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
pub enum Product {
#[serde(alias = "CHATGPT")]
Chatgpt,
#[serde(alias = "CODEX")]
Codex,
#[serde(alias = "ATLAS")]
Atlas,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]