diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e8e98f9ab..b17ab82d8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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 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 }, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 06b0fcb55..bde356475 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -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} }} ] }}"# diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index a628275cd..c409fdeb3 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -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" } ] diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 8917ab4e8..cbd36c37a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -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" } ] diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 60c375c9c..22c536f21 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -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, - pub source: MarketplacePluginSourceSummary, - pub install_policy: MarketplacePluginInstallPolicy, - pub auth_policy: MarketplacePluginAuthPolicy, - pub interface: Option, + pub source: MarketplacePluginSource, + pub policy: MarketplacePluginPolicy, + pub interface: Option, pub installed: bool, pub enabled: bool, pub skills: Vec, @@ -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, - pub plugins: Vec, + pub interface: Option, + pub plugins: Vec, } #[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, + pub source: MarketplacePluginSource, + pub policy: MarketplacePluginPolicy, + pub interface: Option, pub installed: bool, pub enabled: bool, } @@ -219,8 +215,8 @@ impl PluginCapabilitySummary { } } -impl From for PluginCapabilitySummary { - fn from(value: PluginDetailSummary) -> Self { +impl From 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, MarketplaceError> { + ) -> Result, 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 `@`, 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::>(); - (!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 { - 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 { 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::::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 { pub fn load_plugin_apps(plugin_root: &Path) -> Vec { 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 diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 926f07cb2..d113d56a9 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -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, diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index b6ab34f0c..91c7cbbb3 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -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, + description: Option, // 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, #[serde(default)] - interface: Option, + interface: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PluginManifest { + pub(crate) name: String, + pub(crate) description: Option, + pub(crate) paths: PluginManifestPaths, + pub(crate) interface: Option, } #[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, pub short_description: Option, pub long_description: Option, @@ -55,7 +63,7 @@ pub struct PluginManifestInterfaceSummary { #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] -struct PluginManifestInterface { +struct RawPluginManifestInterface { #[serde(default)] display_name: Option, #[serde(default)] @@ -78,7 +86,7 @@ struct PluginManifestInterface { #[serde(alias = "termsOfServiceURL")] terms_of_service_url: Option, #[serde(default)] - default_prompt: Option, + default_prompt: Option, #[serde(default)] brand_color: Option, #[serde(default)] @@ -91,15 +99,15 @@ struct PluginManifestInterface { #[derive(Debug, Deserialize)] #[serde(untagged)] -enum PluginManifestDefaultPrompt { +enum RawPluginManifestDefaultPrompt { String(String), - List(Vec), + List(Vec), 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 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::(&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 } } -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 { - 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> { 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); } diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index c7c9a70b3..ff6bdecc8 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -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, - pub plugins: Vec, + pub interface: Option, + pub plugins: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct MarketplaceInterfaceSummary { +pub struct MarketplaceInterface { pub display_name: Option, } #[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, + pub source: MarketplacePluginSource, + pub policy: MarketplacePluginPolicy, + pub interface: Option, } #[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, +} + #[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 { - 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, MarketplaceError> { +) -> Result, MarketplaceError> { list_marketplaces_with_home(additional_roots, home_dir().as_deref()) } -pub(crate) fn load_marketplace_summary( - path: &AbsolutePathBuf, -) -> Result { - let marketplace = load_marketplace(path)?; +pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result { + 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, MarketplaceError> { +) -> Result, 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 { +fn load_raw_marketplace_manifest( + path: &AbsolutePathBuf, +) -> Result { 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 Result { 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, - plugins: Vec, + interface: Option, + plugins: Vec, } #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] -struct MarketplaceInterface { +struct RawMarketplaceManifestInterface { #[serde(default)] display_name: Option, } #[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, } +#[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, +} + #[derive(Debug, Deserialize)] #[serde(tag = "source", rename_all = "lowercase")] -enum MarketplacePluginSource { +enum RawMarketplaceManifestPluginSource { Local { path: String }, } -fn marketplace_interface_summary( - interface: Option, -) -> Option { +fn resolve_marketplace_interface( + interface: Option, +) -> Option { let interface = interface?; if interface.display_name.is_some() { - Some(MarketplaceInterfaceSummary { + Some(MarketplaceInterface { display_name: interface.display_name, }) } else { diff --git a/codex-rs/core/src/plugins/marketplace_tests.rs b/codex-rs/core/src/plugins/marketplace_tests.rs index 1fac8bec6..2516419ed 100644 --- a/codex-rs/core/src/plugins/marketplace_tests.rs +++ b/codex-rs/core/src/plugins/marketplace_tests.rs @@ -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] diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 8b45954ab..97d45fc58 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -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; diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 22452767c..262ca1028 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -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, + #[serde(default)] + products: Vec, } #[derive(Debug, Default, Deserialize)] @@ -735,6 +738,7 @@ fn resolve_dependencies(dependencies: Option) -> Option) -> Option { policy.map(|policy| SkillPolicy { allow_implicit_invocation: policy.allow_implicit_invocation, + products: policy.products, }) } diff --git a/codex-rs/core/src/skills/loader_tests.rs b/codex-rs/core/src/skills/loader_tests.rs index 2da1f9cd2..7f758735c 100644 --- a/codex-rs/core/src/skills/loader_tests.rs +++ b/codex-rs/core/src/skills/loader_tests.rs @@ -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"); diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index f8a63f0b2..0949300ec 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -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, + // TODO: Enforce product gating in Codex skill selection/injection instead of only parsing and + // storing this metadata. + pub products: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f74afb956..c80e3b41a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2908,6 +2908,17 @@ pub struct ListSkillsResponseEvent { pub skills: Vec, } +#[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")]