diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 2efda3319..c31a6ff2d 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::PluginListBackgroundTaskOptions; +use codex_core_plugins::is_openai_curated_marketplace_name; use codex_core_plugins::remote::REMOTE_CREATED_BY_ME_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; @@ -174,9 +175,9 @@ fn filter_openai_curated_installed_conflicts( ) { let local_installed_plugin_names = marketplaces .iter() - .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - .map(|marketplace| installed_plugin_names(&marketplace.plugins)) - .unwrap_or_default(); + .filter(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) + .flat_map(|marketplace| installed_plugin_names(&marketplace.plugins)) + .collect::>(); let remote_installed_plugin_names = marketplaces .iter() .find(|marketplace| marketplace.name == REMOTE_GLOBAL_MARKETPLACE_NAME) @@ -190,13 +191,12 @@ fn filter_openai_curated_installed_conflicts( return; } - let marketplace_to_filter = if prefer_remote_curated_conflicts { - OPENAI_CURATED_MARKETPLACE_NAME - } else { - REMOTE_GLOBAL_MARKETPLACE_NAME - }; for marketplace in marketplaces.iter_mut() { - if marketplace.name != marketplace_to_filter { + if prefer_remote_curated_conflicts { + if !is_openai_curated_marketplace_name(&marketplace.name) { + continue; + } + } else if marketplace.name != REMOTE_GLOBAL_MARKETPLACE_NAME { continue; } marketplace @@ -551,6 +551,8 @@ impl PluginRequestProcessor { { return Ok(empty_response()); } + let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode); + plugins_manager.set_auth_mode(auth_mode); let plugins_input = config.plugins_config_input(); let include_shared_with_me = marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe); @@ -559,10 +561,12 @@ impl PluginRequestProcessor { && config.features.enabled(Feature::RemotePlugin); let include_global_remote = !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin); + let use_remote_global_catalog = + include_global_remote && auth_mode.is_some_and(AuthMode::uses_codex_backend); let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; - let refresh_global_remote_catalog_cache = include_global_remote + let refresh_global_remote_catalog_cache = use_remote_global_catalog && codex_core_plugins::remote::has_cached_global_remote_plugin_catalog( config.codex_home.as_path(), &remote_plugin_service_config, @@ -578,7 +582,7 @@ impl PluginRequestProcessor { .list_marketplaces_for_config( &config_for_marketplace_listing, &roots_for_marketplace_listing, - /*include_openai_curated*/ true, + /*include_openai_curated*/ !use_remote_global_catalog, )?; Ok::< ( @@ -649,16 +653,14 @@ impl PluginRequestProcessor { data.push(remote_marketplace_to_info(remote_marketplace)); } Ok(None) => {} + Err(RemotePluginCatalogError::UnsupportedAuthMode) => {} Err(err) if explicit_marketplace_kinds => { return Err(remote_plugin_catalog_error_to_jsonrpc( err, "list OpenAI Curated remote plugin catalog", )); } - Err( - RemotePluginCatalogError::AuthRequired - | RemotePluginCatalogError::UnsupportedAuthMode, - ) => {} + Err(RemotePluginCatalogError::AuthRequired) => {} Err(err) => { warn!( error = %err, @@ -669,7 +671,7 @@ impl PluginRequestProcessor { } let mut remote_sources = Vec::new(); - if include_global_remote { + if use_remote_global_catalog { remote_sources.push(RemoteMarketplaceSource::Global); } if include_created_by_me_remote { @@ -741,9 +743,10 @@ impl PluginRequestProcessor { ); } - let featured_plugin_ids = if data - .iter() - .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + let featured_plugin_ids = if !plugins_input.remote_plugin_enabled + && data + .iter() + .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) { match plugins_manager .featured_plugin_ids_for_config(&plugins_input, auth.as_ref()) @@ -799,6 +802,7 @@ impl PluginRequestProcessor { { return Ok(empty_response()); } + plugins_manager.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode)); let plugins_input = config.plugins_config_input(); let remote_installed_plugin_visible_marketplaces = 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 1f1f47bd0..62a17278f 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -21,6 +21,8 @@ use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; +use codex_login::AuthKeyringBackendKind; +use codex_login::login_with_api_key; use codex_protocol::config_types::TrustLevel; use codex_utils_absolute_path::AbsolutePathBuf; use flate2::Compression; @@ -2126,6 +2128,98 @@ async fn plugin_list_propagates_explicit_openai_curated_remote_collection_errors Ok(()) } +#[tokio::test] +async fn plugin_list_skips_explicit_openai_curated_remote_collection_for_api_auth() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!(response.marketplaces.is_empty()); + assert!(response.marketplace_load_errors.is_empty()); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_includes_api_curated_marketplace_for_api_auth_when_remote_plugin_enabled() +-> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_openai_api_curated_marketplace(codex_home.path(), &["api-plugin"])?; + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let api_curated_marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "openai-api-curated") + .expect("expected API curated marketplace"); + assert_eq!( + api_curated_marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("OpenAI Curated") + ); + assert_eq!(api_curated_marketplace.plugins.len(), 1); + assert_eq!( + api_curated_marketplace.plugins[0].id, + "api-plugin@openai-api-curated" + ); + assert!(response.marketplace_load_errors.is_empty()); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> { let codex_home = TempDir::new()?; @@ -3928,6 +4022,35 @@ remote_plugin = true fn write_openai_curated_marketplace( codex_home: &std::path::Path, plugin_names: &[&str], +) -> std::io::Result<()> { + write_curated_marketplace( + codex_home, + "marketplace.json", + "openai-curated", + /*display_name*/ None, + plugin_names, + ) +} + +fn write_openai_api_curated_marketplace( + codex_home: &std::path::Path, + plugin_names: &[&str], +) -> std::io::Result<()> { + write_curated_marketplace( + codex_home, + "api_marketplace.json", + "openai-api-curated", + Some("OpenAI Curated"), + plugin_names, + ) +} + +fn write_curated_marketplace( + codex_home: &std::path::Path, + manifest_name: &str, + marketplace_name: &str, + display_name: Option<&str>, + plugin_names: &[&str], ) -> std::io::Result<()> { let curated_root = codex_home.join(".tmp/plugins"); std::fs::create_dir_all(curated_root.join(".git"))?; @@ -3947,11 +4070,21 @@ fn write_openai_curated_marketplace( }) .collect::>() .join(",\n"); + let interface = display_name + .map(|display_name| { + format!( + r#" + "interface": {{ + "displayName": "{display_name}" + }},"# + ) + }) + .unwrap_or_default(); std::fs::write( - curated_root.join(".agents/plugins/marketplace.json"), + curated_root.join(".agents/plugins").join(manifest_name), format!( r#"{{ - "name": "openai-curated", + "name": "{marketplace_name}",{interface} "plugins": [ {plugins} ] diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index b392bb53b..f94447734 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -26,6 +26,7 @@ use std::path::PathBuf; use crate::plugin_cmd::JsonMarketplaceSource; use crate::plugin_cmd::configured_marketplace_snapshot_issues; use crate::plugin_cmd::configured_marketplace_sources; +use crate::plugin_cmd::load_cli_auth_mode; #[derive(Debug, Parser)] #[command(bin_name = "codex plugin marketplace")] @@ -208,6 +209,7 @@ async fn run_list(overrides: Vec<(String, toml::Value)>, args: ListMarketplaceAr .await .context("failed to load configuration")?; let manager = PluginsManager::new(config.codex_home.to_path_buf()); + manager.set_auth_mode(load_cli_auth_mode(&config).await); let plugins_input = config.plugins_config_input(); let marketplace_listing = manager .discover_marketplaces_for_config(&plugins_input, &[]) diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index d6e28b5da..1fc670c87 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -2,6 +2,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; use clap::Parser; +use codex_app_server_protocol::AuthMode; use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::ConfiguredMarketplace; @@ -17,6 +18,8 @@ use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy; use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; use codex_core_plugins::marketplace::MarketplacePluginSource; use codex_core_plugins::marketplace::find_marketplace_manifest_path; +use codex_login::CodexAuth; +use codex_login::auth::read_codex_api_key_from_env; use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; @@ -550,6 +553,7 @@ async fn load_plugin_command_context( .context("failed to load configuration")?; let plugins_input = config.plugins_config_input(); let manager = PluginsManager::new(codex_home.to_path_buf()); + manager.set_auth_mode(load_cli_auth_mode(&config).await); Ok(PluginCommandContext { codex_home: codex_home.to_path_buf(), plugins_input, @@ -557,6 +561,23 @@ async fn load_plugin_command_context( }) } +pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option { + if let Some(api_key) = read_codex_api_key_from_env() { + return Some(CodexAuth::from_api_key(&api_key).api_auth_mode()); + } + + CodexAuth::from_auth_storage( + &config.codex_home, + config.cli_auth_credentials_store_mode, + Some(&config.chatgpt_base_url), + config.auth_keyring_backend_kind(), + ) + .await + .ok() + .flatten() + .map(|auth| auth.api_auth_mode()) +} + struct PluginSelection { plugin_name: String, marketplace_name: String, diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index 81586bd28..c6a7146a8 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -3,9 +3,12 @@ use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginId; use std::collections::HashSet; use tracing::warn; +use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginsConfigInput; use crate::PluginsManager; use crate::marketplace::MarketplacePluginInstallPolicy; @@ -74,11 +77,7 @@ impl PluginsManager { } let marketplaces = self - .list_marketplaces_for_config( - &input.plugins, - &[], - /*include_openai_curated*/ !input.plugins.remote_plugin_enabled, - ) + .list_marketplaces_for_config(&input.plugins, &[], /*include_openai_curated*/ true) .context("failed to list plugin marketplaces for tool suggestions")? .marketplaces; let remote_installed_marketplaces = if input.plugins.remote_plugin_enabled { @@ -95,8 +94,7 @@ impl PluginsManager { for plugin in marketplace.plugins { let is_configured_plugin = input.configured_plugin_ids.contains(plugin.id.as_str()); - let is_fallback_plugin = - TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()); + let is_fallback_plugin = is_tool_suggest_fallback_plugin(&plugin.id); if plugin.installed || plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable || input.disabled_plugin_ids.contains(plugin.id.as_str()) @@ -162,8 +160,7 @@ impl PluginsManager { || input .configured_plugin_ids .contains(plugin.remote_plugin_id.as_str()); - let is_fallback_plugin = - TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.config_id.as_str()); + let is_fallback_plugin = is_tool_suggest_fallback_plugin(&plugin.config_id); let matches_installed_app = plugin .app_ids .iter() @@ -203,6 +200,25 @@ impl PluginsManager { } } +fn is_tool_suggest_fallback_plugin(plugin_id: &str) -> bool { + if TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin_id) { + return true; + } + + let Ok(plugin_id) = PluginId::parse(plugin_id) else { + return false; + }; + if plugin_id.marketplace_name != OPENAI_API_CURATED_MARKETPLACE_NAME { + return false; + } + + let default_curated_plugin_id = format!( + "{}@{}", + plugin_id.plugin_name, OPENAI_CURATED_MARKETPLACE_NAME + ); + TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&default_curated_plugin_id.as_str()) +} + #[cfg(test)] #[path = "discoverable_tests.rs"] mod tests; diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index 26f6a5653..c1fb04f92 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -13,6 +13,7 @@ use crate::test_support::load_plugins_config; use crate::test_support::write_curated_plugin; use crate::test_support::write_curated_plugin_sha_with; use crate::test_support::write_file; +use crate::test_support::write_openai_api_curated_marketplace; use crate::test_support::write_openai_curated_marketplace; use codex_config::CONFIG_TOML_FILE; use codex_login::CodexAuth; @@ -59,6 +60,34 @@ async fn returns_fallback_plugins_without_installed_apps() { ); } +#[tokio::test] +async fn returns_api_curated_fallback_plugins_for_direct_provider_auth() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_api_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); + + let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let auth = CodexAuth::from_api_key("test-api-key"); + let discoverable_plugins = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[], &[], &[]), + Some(&auth), + ) + .await; + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec![ + "openai-developers@openai-api-curated".to_string(), + "slack@openai-api-curated".to_string(), + ] + ); +} + #[tokio::test] async fn returns_microsoft_fallback_plugins() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -92,7 +121,7 @@ async fn returns_microsoft_fallback_plugins() { } #[tokio::test] -async fn omits_openai_curated_when_remote_enabled() { +async fn includes_openai_curated_when_remote_enabled() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); @@ -142,7 +171,10 @@ source = "/tmp/{bundled_marketplace_name}" .into_iter() .map(|plugin| plugin.id) .collect::>(), - vec!["chrome@openai-bundled".to_string()] + vec![ + "chrome@openai-bundled".to_string(), + "slack@openai-curated".to_string(), + ] ); } diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 126f7b37e..56775d076 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -20,8 +20,14 @@ mod test_support; pub mod toggles; pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; +pub const OPENAI_API_CURATED_MARKETPLACE_NAME: &str = "openai-api-curated"; pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled"; +pub fn is_openai_curated_marketplace_name(marketplace_name: &str) -> bool { + marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME + || marketplace_name == OPENAI_API_CURATED_MARKETPLACE_NAME +} + pub type LoadedPlugin = codex_plugin::LoadedPlugin; pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index f156f7848..9abe4ac7f 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -1,13 +1,12 @@ -use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::app_mcp_routing::apps_route_available; +use crate::is_openai_curated_marketplace_name; use crate::manifest::PluginManifestHooks; use crate::manifest::PluginManifestPaths; use crate::manifest::load_plugin_manifest; use crate::marketplace::MarketplacePluginSource; use crate::marketplace::list_marketplaces; use crate::marketplace::load_marketplace; -use crate::marketplace::load_raw_marketplace_plugin_names; use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::RemoteInstalledPlugin; use crate::store::PluginStore; @@ -198,17 +197,21 @@ fn merge_configured_plugins_with_remote_installed( store: &PluginStore, prefer_remote_curated_conflicts: bool, ) -> HashMap { - let local_curated_installed_plugin_keys = configured_plugins - .keys() - .filter_map(|plugin_key| { - installed_plugin_name_for_marketplace( - plugin_key, - OPENAI_CURATED_MARKETPLACE_NAME, - store, - ) - .map(|plugin_name| (plugin_name, plugin_key.clone())) - }) - .collect::>(); + let mut local_curated_installed_plugin_keys = HashMap::>::new(); + for plugin_key in configured_plugins.keys() { + let Ok(plugin_id) = PluginId::parse(plugin_key) else { + continue; + }; + if !is_openai_curated_marketplace_name(&plugin_id.marketplace_name) + || store.active_plugin_version(&plugin_id).is_none() + { + continue; + } + local_curated_installed_plugin_keys + .entry(plugin_id.plugin_name) + .or_default() + .push(plugin_key.clone()); + } for (plugin_key, plugin_config) in extra_plugins { let remote_curated_plugin_name = installed_plugin_name_for_marketplace( @@ -216,13 +219,15 @@ fn merge_configured_plugins_with_remote_installed( REMOTE_GLOBAL_MARKETPLACE_NAME, store, ); - let local_curated_plugin_key = remote_curated_plugin_name + let local_curated_plugin_keys = remote_curated_plugin_name .as_ref() .and_then(|plugin_name| local_curated_installed_plugin_keys.get(plugin_name)); - if let Some(local_curated_plugin_key) = local_curated_plugin_key { + if let Some(local_curated_plugin_keys) = local_curated_plugin_keys { if prefer_remote_curated_conflicts { - configured_plugins.remove(local_curated_plugin_key); + for local_curated_plugin_key in local_curated_plugin_keys { + configured_plugins.remove(local_curated_plugin_key); + } } else { continue; } @@ -289,41 +294,53 @@ pub fn refresh_curated_plugin_cache( ) -> Result { let cache_plugin_version = curated_plugin_cache_version(plugin_version); let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?; - let curated_marketplace_path = AbsolutePathBuf::try_from( - codex_home - .join(".tmp/plugins") - .join(".agents/plugins/marketplace.json"), - ) - .map_err(|_| "local curated marketplace is not available".to_string())?; - let marketplace_plugin_names = load_raw_marketplace_plugin_names(&curated_marketplace_path) - .map_err(|err| { - format!("failed to load curated marketplace plugin names for cache refresh: {err}") - })?; - let curated_marketplace = load_marketplace(&curated_marketplace_path) - .map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?; - + let curated_marketplace_paths = curated_marketplace_paths_for_cache_refresh(codex_home)?; + let mut loaded_marketplace_names = HashSet::::new(); + let mut marketplace_plugin_keys = HashSet::::new(); let mut plugin_sources = HashMap::::new(); - for plugin in curated_marketplace.plugins { - let plugin_name = plugin.name; - if plugin_sources.contains_key(&plugin_name) { - warn!( - plugin = plugin_name, - marketplace = OPENAI_CURATED_MARKETPLACE_NAME, - "ignoring duplicate curated plugin entry during cache refresh" - ); - continue; - } - if let MarketplacePluginSource::Local { path } = plugin.source { - plugin_sources.insert(plugin_name, path); + + for curated_marketplace_path in curated_marketplace_paths { + let curated_marketplace = load_marketplace(&curated_marketplace_path).map_err(|err| { + format!("failed to load curated marketplace for cache refresh: {err}") + })?; + let marketplace_name = curated_marketplace.name; + loaded_marketplace_names.insert(marketplace_name.clone()); + + for plugin in curated_marketplace.plugins { + let plugin_id = + PluginId::new(plugin.name.clone(), marketplace_name.clone()).map_err(|err| { + match err { + PluginIdError::Invalid(message) => { + format!("failed to prepare curated plugin cache refresh: {message}") + } + } + })?; + let plugin_key = plugin_id.as_key(); + marketplace_plugin_keys.insert(plugin_key.clone()); + if plugin_sources.contains_key(&plugin_key) { + warn!( + plugin = %plugin.name, + marketplace = %marketplace_name, + "ignoring duplicate curated plugin entry during cache refresh" + ); + continue; + } + if let MarketplacePluginSource::Local { path } = plugin.source { + plugin_sources.insert(plugin_key, path); + } } } let mut cache_refreshed = false; for plugin_id in configured_curated_plugin_ids { - if !marketplace_plugin_names.contains(&plugin_id.plugin_name) { + let plugin_key = plugin_id.as_key(); + if !marketplace_plugin_keys.contains(&plugin_key) { + if !loaded_marketplace_names.contains(&plugin_id.marketplace_name) { + continue; + } warn!( - plugin = plugin_id.plugin_name, - marketplace = OPENAI_CURATED_MARKETPLACE_NAME, + plugin = %plugin_id.plugin_name, + marketplace = %plugin_id.marketplace_name, "configured curated plugin no longer exists in curated marketplace during cache refresh" ); if store.plugin_base_root(plugin_id).as_path().exists() { @@ -338,7 +355,7 @@ pub fn refresh_curated_plugin_cache( continue; } - let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else { + let Some(source_path) = plugin_sources.get(&plugin_key).cloned() else { continue; }; @@ -361,6 +378,30 @@ pub fn refresh_curated_plugin_cache( Ok(cache_refreshed) } +fn curated_marketplace_paths_for_cache_refresh( + codex_home: &Path, +) -> Result, String> { + let curated_marketplace_path = AbsolutePathBuf::try_from( + codex_home + .join(".tmp/plugins") + .join(".agents/plugins/marketplace.json"), + ) + .map_err(|_| "local curated marketplace is not available".to_string())?; + let mut paths = vec![curated_marketplace_path]; + + let api_marketplace_path = codex_home + .join(".tmp/plugins") + .join(".agents/plugins/api_marketplace.json"); + if api_marketplace_path.is_file() { + paths.push( + AbsolutePathBuf::try_from(api_marketplace_path) + .map_err(|_| "local API curated marketplace is not available".to_string())?, + ); + } + + Ok(paths) +} + pub fn curated_plugin_cache_version(plugin_version: &str) -> String { if is_full_git_sha(plugin_version) { plugin_version[..CURATED_PLUGIN_CACHE_VERSION_SHA_PREFIX_LEN].to_string() @@ -416,7 +457,7 @@ fn refresh_non_curated_plugin_cache_with_mode( let mut plugin_sources = HashMap::::new(); for marketplace in marketplace_outcome.marketplaces { - if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME { + if is_openai_curated_marketplace_name(&marketplace.name) { continue; } @@ -570,7 +611,7 @@ fn curated_plugin_ids_from_config_keys( "ignoring invalid configured plugin key during curated sync setup", ) .into_iter() - .filter(|plugin_id| plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME) + .filter(|plugin_id| is_openai_curated_marketplace_name(&plugin_id.marketplace_name)) .collect::>(); configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key); configured_curated_plugin_ids @@ -584,7 +625,7 @@ fn non_curated_plugin_ids_from_config_keys( "ignoring invalid plugin key during non-curated cache refresh setup", ) .into_iter() - .filter(|plugin_id| plugin_id.marketplace_name != OPENAI_CURATED_MARKETPLACE_NAME) + .filter(|plugin_id| !is_openai_curated_marketplace_name(&plugin_id.marketplace_name)) .collect::>(); configured_non_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key); configured_non_curated_plugin_ids diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 6d2022ae5..47a3a20ad 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1,7 +1,7 @@ use super::PluginLoadOutcome; -use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; +use crate::is_openai_curated_marketplace_name; use crate::loader::PluginHookLoadOutcome; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; @@ -42,6 +42,7 @@ use crate::remote::RemotePluginCatalogError; use crate::remote::RemotePluginServiceConfig; use crate::remote_legacy::RemotePluginFetchError; use crate::remote_legacy::RemotePluginMutationError; +use crate::startup_sync::curated_plugins_api_marketplace_path; use crate::startup_sync::curated_plugins_repo_path; use crate::startup_sync::read_curated_plugins_sha; use crate::startup_sync::sync_openai_plugins_repo; @@ -933,7 +934,7 @@ impl PluginsManager { ) -> Result { let auth_policy = resolved.policy.authentication; let plugin_version = - if resolved.plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { + if is_openai_curated_marketplace_name(&resolved.plugin_id.marketplace_name) { let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path()) .ok_or_else(|| { PluginStoreError::Invalid( @@ -1053,11 +1054,8 @@ impl PluginsManager { } let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); - let mut marketplace_roots = self.marketplace_roots(config, additional_roots); - if !include_openai_curated { - let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path()); - marketplace_roots.retain(|root| root.as_path() != curated_repo_root.as_path()); - } + let marketplace_roots = + self.marketplace_roots(config, additional_roots, include_openai_curated); let marketplace_outcome = list_marketplaces(&marketplace_roots)?; let mut seen_plugin_keys = HashSet::new(); let marketplaces = marketplace_outcome @@ -1145,7 +1143,11 @@ impl PluginsManager { return Ok(MarketplaceListOutcome::default()); } - list_marketplaces(&self.marketplace_roots(config, additional_roots)) + list_marketplaces(&self.marketplace_roots( + config, + additional_roots, + /*include_openai_curated*/ true, + )) } pub async fn read_plugin_for_config( @@ -1879,6 +1881,7 @@ impl PluginsManager { &self, config: &PluginsConfigInput, additional_roots: &[AbsolutePathBuf], + include_openai_curated: bool, ) -> Vec { // Treat the curated catalog as an extra marketplace root so plugin listing can surface it // without requiring every caller to know where it is stored. @@ -1887,11 +1890,28 @@ impl PluginsManager { &config.config_layer_stack, self.codex_home.as_path(), )); - let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path()); - if curated_repo_root.is_dir() - && let Ok(curated_repo_root) = AbsolutePathBuf::try_from(curated_repo_root) + let curated_marketplace_path = if include_openai_curated { + if matches!( + self.auth_mode(), + Some(AuthMode::ApiKey | AuthMode::BedrockApiKey) + ) { + let api_marketplace_path = + curated_plugins_api_marketplace_path(self.codex_home.as_path()); + api_marketplace_path + .is_file() + .then_some(api_marketplace_path) + } else { + let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path()); + curated_repo_root.is_dir().then_some(curated_repo_root) + } + } else { + None + }; + if let Some(curated_marketplace_path) = curated_marketplace_path + && let Ok(curated_marketplace_path) = + AbsolutePathBuf::try_from(curated_marketplace_path) { - roots.push(curated_repo_root); + roots.push(curated_marketplace_path); } roots.sort_unstable(); roots.dedup(); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 2e68c8b25..1fb987312 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1,5 +1,7 @@ use super::*; use crate::LoadedPlugin; +use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginLoadOutcome; use crate::installed_marketplaces::marketplace_install_root; use crate::loader::load_plugins_from_layer_stack; @@ -16,6 +18,7 @@ use crate::test_support::TEST_CURATED_PLUGIN_SHA; use crate::test_support::load_plugins_config as load_plugins_config_input; use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; use crate::test_support::write_file; +use crate::test_support::write_openai_api_curated_marketplace; use crate::test_support::write_openai_curated_marketplace; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ConfigLayerSource; @@ -778,11 +781,15 @@ remote_plugin = true [plugins."linear@openai-curated"] enabled = true +[plugins."linear@openai-api-curated"] +enabled = true + [plugins."calendar@openai-curated"] enabled = true "#, ); write_cached_plugin(codex_home.path(), "openai-curated", "linear"); + write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-curated", "calendar"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); @@ -3037,6 +3044,130 @@ plugins = true ); } +#[tokio::test] +async fn list_marketplaces_uses_api_curated_manifest_when_selected() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + write_file( + &curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "siwc-plugin", + "source": { + "source": "local", + "path": "./plugins/siwc-plugin" + } + } + ] +}"#, + ); + write_file( + &curated_root.join(".agents/plugins/api_marketplace.json"), + r#"{ + "name": "openai-api-curated", + "interface": { + "displayName": "OpenAI Curated" + }, + "plugins": [ + { + "name": "api-plugin", + "source": { + "source": "local", + "path": "./plugins/api-plugin" + } + } + ] +}"#, + ); + + let config = load_config(tmp.path(), tmp.path()).await; + let manager = PluginsManager::new(tmp.path().to_path_buf()); + manager.set_auth_mode(Some(AuthMode::ApiKey)); + let marketplaces = manager + .list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true) + .unwrap() + .marketplaces; + let curated_marketplace = marketplaces + .into_iter() + .find(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME) + .expect("API curated marketplace should be listed"); + + assert_eq!( + curated_marketplace, + ConfiguredMarketplace { + name: "openai-api-curated".to_string(), + path: AbsolutePathBuf::try_from( + curated_root.join(".agents/plugins/api_marketplace.json") + ) + .unwrap(), + interface: Some(MarketplaceInterface { + display_name: Some("OpenAI Curated".to_string()), + }), + plugins: vec![ConfiguredMarketplacePlugin { + id: "api-plugin@openai-api-curated".to_string(), + name: "api-plugin".to_string(), + local_version: None, + installed_version: None, + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(curated_root.join("plugins/api-plugin")) + .unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + installed: false, + enabled: false, + }], + } + ); +} + +#[tokio::test] +async fn list_marketplaces_skips_missing_api_curated_manifest() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +"#, + ); + write_file( + &curated_root.join(".agents/plugins/marketplace.json"), + "{not valid json", + ); + + let config = load_config(tmp.path(), tmp.path()).await; + let manager = PluginsManager::new(tmp.path().to_path_buf()); + manager.set_auth_mode(Some(AuthMode::BedrockApiKey)); + let outcome = manager + .list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true) + .unwrap(); + + assert_eq!(outcome.errors, Vec::new()); + assert_eq!( + outcome + .marketplaces + .iter() + .any(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME), + false + ); +} + #[tokio::test] async fn list_marketplaces_includes_installed_marketplace_roots() { let tmp = tempfile::tempdir().unwrap(); @@ -3591,6 +3722,56 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren ); } +#[test] +fn refresh_curated_plugin_cache_reinstalls_missing_api_curated_plugin() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &[]); + write_openai_api_curated_marketplace(&curated_root, &["api-only"]); + write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); + let plugin_id = PluginId::new( + "api-only".to_string(), + OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + + assert!( + refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should recreate missing configured API curated plugin") + ); + + assert!( + tmp.path() + .join(format!( + "plugins/cache/openai-api-curated/api-only/{TEST_CURATED_PLUGIN_CACHE_VERSION}" + )) + .is_dir() + ); +} + +#[test] +fn refresh_curated_plugin_cache_leaves_api_curated_plugin_when_api_manifest_missing() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&curated_root, &[]); + write_cached_plugin(tmp.path(), OPENAI_API_CURATED_MARKETPLACE_NAME, "api-only"); + let plugin_id = PluginId::new( + "api-only".to_string(), + OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(), + ) + .unwrap(); + + assert!( + !refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id]) + .expect("cache refresh should skip missing API curated manifest") + ); + assert!( + tmp.path() + .join("plugins/cache/openai-api-curated/api-only/local") + .is_dir() + ); +} + #[test] fn refresh_curated_plugin_cache_removes_cache_for_plugin_removed_from_marketplace() { let tmp = tempfile::tempdir().unwrap(); @@ -3629,6 +3810,9 @@ plugins = true [plugins."slack@openai-curated"] enabled = true +[plugins."api-only@openai-api-curated"] +enabled = true + [plugins."sample@debug"] enabled = true "#, @@ -3639,7 +3823,10 @@ enabled = true .into_iter() .map(|plugin_id| plugin_id.as_key()) .collect::>(), - vec!["slack@openai-curated".to_string()] + vec![ + "api-only@openai-api-curated".to_string(), + "slack@openai-curated".to_string(), + ] ); write_file( diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 049d88275..228495c48 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -9,7 +9,6 @@ use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde_json::Value as JsonValue; -use std::collections::HashSet; use std::fs; use std::io; use std::path::Component; @@ -19,6 +18,7 @@ use tracing::warn; const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[ ".agents/plugins/marketplace.json", + ".agents/plugins/api_marketplace.json", ".claude-plugin/marketplace.json", ]; @@ -258,6 +258,19 @@ pub fn find_marketplace_manifest_path(root: &Path) -> Option { }) } +fn supported_marketplace_manifest_path(path: &Path) -> Option { + if !path.is_file() { + return None; + } + if !MARKETPLACE_MANIFEST_RELATIVE_PATHS + .iter() + .any(|relative_path| marketplace_root_from_layout(path, relative_path).is_some()) + { + return None; + } + AbsolutePathBuf::try_from(path.to_path_buf()).ok() +} + fn invalid_marketplace_layout_error(path: &AbsolutePathBuf) -> MarketplaceError { MarketplaceError::InvalidMarketplaceFile { path: path.to_path_buf(), @@ -327,16 +340,6 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result Result, MarketplaceError> { - Ok(load_raw_marketplace_manifest(path)? - .plugins - .into_iter() - .map(|plugin| plugin.name) - .collect()) -} - #[doc(hidden)] pub fn list_marketplaces_with_home( additional_roots: &[AbsolutePathBuf], @@ -377,6 +380,12 @@ fn discover_marketplace_paths_from_roots( } for root in additional_roots { + if let Some(path) = supported_marketplace_manifest_path(root.as_path()) + && !paths.contains(&path) + { + paths.push(path); + continue; + } // Curated marketplaces can now come from an HTTP-downloaded directory that is not a git // checkout, so check the root directly before falling back to repo-root discovery. if let Some(path) = find_marketplace_manifest_path(root.as_path()) diff --git a/codex-rs/core-plugins/src/marketplace_add.rs b/codex-rs/core-plugins/src/marketplace_add.rs index 927d337d2..ff7ccbd8f 100644 --- a/codex-rs/core-plugins/src/marketplace_add.rs +++ b/codex-rs/core-plugins/src/marketplace_add.rs @@ -1,5 +1,5 @@ -use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::installed_marketplaces::marketplace_install_root; +use crate::is_openai_curated_marketplace_name; use codex_utils_absolute_path::AbsolutePathBuf; use std::fs; use std::path::Path; @@ -121,9 +121,9 @@ where if let MarketplaceSource::Local { path } = &source { let marketplace_name = validate_marketplace_source_root(path)?; - if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { + if is_openai_curated_marketplace_name(&marketplace_name) { return Err(MarketplaceAddError::InvalidRequest(format!( - "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from this source" + "marketplace '{marketplace_name}' is reserved and cannot be added from this source" ))); } if find_marketplace_root_by_name(codex_home, &install_root, &marketplace_name)?.is_some() { @@ -165,9 +165,9 @@ where stage_marketplace_source(&source, &sparse_paths, &staged_root, clone_source)?; let marketplace_name = validate_marketplace_source_root(&staged_root)?; - if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME { + if is_openai_curated_marketplace_name(&marketplace_name) { return Err(MarketplaceAddError::InvalidRequest(format!( - "marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from this source" + "marketplace '{marketplace_name}' is reserved and cannot be added from this source" ))); } diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index e13415717..47c88b73a 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -522,6 +522,62 @@ fn list_marketplaces_prefers_first_supported_manifest_layout() { ); } +#[test] +fn list_marketplaces_supports_explicit_api_marketplace_manifest_path() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/api_marketplace.json")).unwrap(); + fs::write( + marketplace_path.as_path(), + r#"{ + "name": "openai-api-curated", + "plugins": [ + { + "name": "api-plugin", + "source": { + "source": "local", + "path": "./plugins/api-plugin" + } + } + ] +}"#, + ) + .unwrap(); + + let marketplaces = list_marketplaces_with_home( + std::slice::from_ref(&marketplace_path), + /*home_dir*/ None, + ) + .unwrap() + .marketplaces; + + assert_eq!( + marketplaces, + vec![Marketplace { + name: "openai-api-curated".to_string(), + path: marketplace_path, + interface: None, + plugins: vec![MarketplacePlugin { + name: "api-plugin".to_string(), + local_version: None, + source: MarketplacePluginSource::Local { + path: AbsolutePathBuf::try_from(repo_root.join("plugins/api-plugin")).unwrap(), + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: None, + keywords: Vec::new(), + }], + }] + ); +} + #[test] fn list_marketplaces_returns_home_and_repo_marketplaces() { let tmp = tempdir().unwrap(); diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index c5965f221..44fa95794 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -59,6 +59,10 @@ pub fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf { codex_home.join(CURATED_PLUGINS_RELATIVE_DIR) } +pub fn curated_plugins_api_marketplace_path(codex_home: &Path) -> PathBuf { + curated_plugins_repo_path(codex_home).join(".agents/plugins/api_marketplace.json") +} + pub fn read_curated_plugins_sha(codex_home: &Path) -> Option { read_sha_file(curated_plugins_sha_path(codex_home).as_path()) } diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 2da64f967..19148df32 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -1,6 +1,7 @@ use std::fs; use std::path::Path; +use crate::OPENAI_API_CURATED_MARKETPLACE_NAME; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::PluginsConfigInput; use codex_config::LoaderOverrides; @@ -57,6 +58,32 @@ pub(crate) fn write_curated_plugin(root: &Path, plugin_name: &str) { } pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) { + write_curated_marketplace( + root, + "marketplace.json", + OPENAI_CURATED_MARKETPLACE_NAME, + /*display_name*/ None, + plugin_names, + ); +} + +pub(crate) fn write_openai_api_curated_marketplace(root: &Path, plugin_names: &[&str]) { + write_curated_marketplace( + root, + "api_marketplace.json", + OPENAI_API_CURATED_MARKETPLACE_NAME, + Some("OpenAI Curated"), + plugin_names, + ); +} + +fn write_curated_marketplace( + root: &Path, + manifest_name: &str, + marketplace_name: &str, + display_name: Option<&str>, + plugin_names: &[&str], +) { let plugins = plugin_names .iter() .map(|plugin_name| { @@ -72,11 +99,21 @@ pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str }) .collect::>() .join(",\n"); + let interface = display_name + .map(|display_name| { + format!( + r#" + "interface": {{ + "displayName": "{display_name}" + }},"# + ) + }) + .unwrap_or_default(); write_file( - &root.join(".agents/plugins/marketplace.json"), + &root.join(".agents/plugins").join(manifest_name), &format!( r#"{{ - "name": "{OPENAI_CURATED_MARKETPLACE_NAME}", + "name": "{marketplace_name}",{interface} "plugins": [ {plugins} ] diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 203e09944..5e091b48a 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -37,7 +37,7 @@ use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; -use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; +use codex_core_plugins::is_openai_curated_marketplace_name; use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; @@ -1554,7 +1554,7 @@ impl ChatWidget { let curated_marketplace = marketplaces .iter() - .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + .find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) .copied(); let curated_entries = curated_marketplace .map(|marketplace| plugin_entries_for_marketplaces([marketplace])) @@ -1582,7 +1582,7 @@ impl ChatWidget { let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces .iter() .copied() - .filter(|marketplace| marketplace.name != OPENAI_CURATED_MARKETPLACE_NAME) + .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) .collect(); additional_marketplaces.sort_by(|left, right| { marketplace_display_name(left)