diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 3e328165bc..3ecd08b65d 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -8,7 +8,8 @@ use codex_config::types::ToolSuggestDiscoverableType; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::PluginsManager; -use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST; +use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST as TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST; +use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; use codex_features::Feature; use codex_tools::DiscoverablePluginInfo; @@ -44,18 +45,47 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins( .list_marketplaces_for_config(&plugins_input, &[]) .context("failed to list plugin marketplaces for tool suggestions")? .marketplaces; + let mut installed_app_connector_ids = HashSet::::new(); + for marketplace in &marketplaces { + for plugin in &marketplace.plugins { + if !plugin.installed { + continue; + } + + let plugin_id = plugin.id.clone(); + match plugins_manager + .read_plugin_detail_for_marketplace_plugin( + &plugins_input, + &marketplace.name, + plugin.clone(), + ) + .await + { + Ok(plugin) => { + installed_app_connector_ids + .extend(plugin.apps.into_iter().map(|connector_id| connector_id.0)); + } + Err(err) => warn!( + "failed to load installed plugin apps for tool suggestion {plugin_id}: {err:#}" + ), + } + } + } + let mut discoverable_plugins = Vec::::new(); for marketplace in marketplaces { let marketplace_name = marketplace.name; - if !TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()) { - continue; - } + let is_allowlisted_marketplace = + TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()); for plugin in marketplace.plugins { + let is_configured_plugin = configured_plugin_ids.contains(plugin.id.as_str()); + let is_fallback_plugin = + TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST.contains(&plugin.id.as_str()); if plugin.installed + || plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable || disabled_plugin_ids.contains(plugin.id.as_str()) - || (!TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()) - && !configured_plugin_ids.contains(plugin.id.as_str())) + || (!is_allowlisted_marketplace && !is_configured_plugin) { continue; } @@ -72,6 +102,14 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins( { Ok(plugin) => { let plugin: PluginCapabilitySummary = plugin.into(); + let matches_installed_app = + plugin.app_connector_ids.iter().any(|connector_id| { + installed_app_connector_ids.contains(connector_id.0.as_str()) + }); + if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app { + continue; + } + discoverable_plugins.push(DiscoverablePluginInfo { id: plugin.config_name, name: plugin.display_name, diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index a481f45822..8e4eaf1bee 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -5,18 +5,20 @@ use crate::plugins::test_support::write_curated_plugin_sha; use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use crate::plugins::test_support::write_plugins_feature_config; +use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::startup_sync::curated_plugins_repo_path; use codex_tools::DiscoverablePluginInfo; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::path::Path; use tempfile::tempdir; use tracing::Level; use tracing_subscriber::fmt::format::FmtSpan; use tracing_test::internal::MockWriter; #[tokio::test] -async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() { +async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); @@ -28,34 +30,42 @@ async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plug .unwrap(); assert_eq!( - discoverable_plugins, + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), vec![ - DiscoverablePluginInfo { - id: "openai-developers@openai-curated".to_string(), - name: "openai-developers".to_string(), - description: Some( - "Plugin that includes skills, MCP servers, and app connectors".to_string(), - ), - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_calendar".to_string()], - }, - DiscoverablePluginInfo { - id: "slack@openai-curated".to_string(), - name: "slack".to_string(), - description: Some( - "Plugin that includes skills, MCP servers, and app connectors".to_string(), - ), - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_calendar".to_string()], - }, + "openai-developers@openai-curated".to_string(), + "slack@openai-curated".to_string(), ] ); } #[tokio::test] -async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugins() { +async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installed_apps() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample", "slack", "hubspot"]); + write_plugin_app(&curated_root, "sample", "sample", "connector_sample"); + write_plugins_feature_config(codex_home.path()); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .await + .unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["hubspot@openai-curated".to_string()] + ); +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_apps() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace( @@ -63,6 +73,7 @@ async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugin &["teams", "sharepoint", "outlook-email", "outlook-calendar"], ); write_plugins_feature_config(codex_home.path()); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await; let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) @@ -78,28 +89,92 @@ async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugin "outlook-calendar@openai-curated".to_string(), "outlook-email@openai-curated".to_string(), "sharepoint@openai-curated".to_string(), - "teams@openai-curated".to_string(), ] ); } #[tokio::test] -async fn list_tool_suggest_discoverable_plugins_deduplicates_allowlisted_configured_plugin() { +async fn list_tool_suggest_discoverable_plugins_filters_sales_apps_by_marketplace() { + let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; + let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b"; + let test_app_id = "asdk_app_test_source"; let codex_home = tempdir().expect("tempdir should succeed"); - let plugin_id = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST - .iter() - .copied() - .find(|plugin_id| { - plugin_id - .rsplit_once('@') - .is_some_and(|(_plugin_name, marketplace_name)| { - marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME - }) - }) - .expect("allowlist should include a bundled plugin"); - let (plugin_name, marketplace_name) = plugin_id - .rsplit_once('@') - .expect("plugin id should include a marketplace"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "test-source"]); + write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id); + write_plugin_app(&curated_root, "granola", "granola", granola_app_id); + write_plugin_app(&curated_root, "test-source", "test_source", test_app_id); + + let sales_marketplace_name = "oai-maintained-plugins"; + let sales_marketplace_root = codex_home + .path() + .join(format!(".tmp/marketplaces/{sales_marketplace_name}")); + write_file( + &sales_marketplace_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{sales_marketplace_name}", + "plugins": [ + {{"name": "sales", "source": {{"source": "local", "path": "./plugins/sales"}}}} + ] +}} +"# + ), + ); + write_curated_plugin(&sales_marketplace_root, "sales"); + write_file( + &sales_marketplace_root.join("plugins/sales/.app.json"), + &format!( + r#"{{ + "apps": {{ + "hubspot": {{ + "id": "{hubspot_app_id}" + }}, + "granola": {{ + "id": "{granola_app_id}" + }} + }} +}} +"# + ), + ); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true + +[marketplaces.{sales_marketplace_name}] +source_type = "git" +source = "/tmp/{sales_marketplace_name}" +"# + ), + ); + install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await; + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .await + .unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec![ + "granola@openai-curated".to_string(), + "hubspot@openai-curated".to_string(), + ] + ); +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_deduplicates_configured_marketplace_plugin() { + let codex_home = tempdir().expect("tempdir should succeed"); + let plugin_name = "sample"; + let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; + let plugin_id = format!("{plugin_name}@{marketplace_name}"); let marketplace_root = codex_home .path() .join(format!(".tmp/marketplaces/{marketplace_name}")); @@ -138,23 +213,15 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}] .unwrap(); assert_eq!(discoverable_plugins.len(), 1); - assert_eq!(discoverable_plugins[0].id, plugin_id); + assert_eq!(discoverable_plugins[0].id, plugin_id.as_str()); } #[tokio::test] -async fn list_tool_suggest_discoverable_plugins_ignores_missing_allowlisted_plugin() { +async fn list_tool_suggest_discoverable_plugins_ignores_missing_marketplace_plugin() { 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"]); - let marketplace_name = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST - .iter() - .copied() - .filter_map(|plugin_id| plugin_id.rsplit_once('@')) - .find(|(_plugin_name, marketplace_name)| { - *marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME - }) - .map(|(_plugin_name, marketplace_name)| marketplace_name) - .expect("allowlist should include a bundled plugin"); + write_openai_curated_marketplace(&curated_root, &["installed", "slack"]); + let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; let marketplace_root = codex_home .path() .join(format!(".tmp/marketplaces/{marketplace_name}")); @@ -182,6 +249,7 @@ source = "/tmp/{marketplace_name}" "# ), ); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) @@ -216,7 +284,7 @@ plugins = false async fn list_tool_suggest_discoverable_plugins_normalizes_description() { 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"]); + write_openai_curated_marketplace(&curated_root, &["installed", "slack"]); write_plugins_feature_config(codex_home.path()); write_file( &curated_root.join("plugins/slack/.codex-plugin/plugin.json"), @@ -225,6 +293,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() { "description": " Plugin\n with extra spacing " }"#, ); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) @@ -296,6 +365,63 @@ disabled_tools = [ assert_eq!(discoverable_plugins, Vec::::new()); } +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_file( + &curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "installed", + "source": { + "source": "local", + "path": "./plugins/installed" + } + }, + { + "name": "slack", + "source": { + "source": "local", + "path": "./plugins/slack" + } + }, + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + }, + "policy": { + "installation": "NOT_AVAILABLE" + } + } + ] +} +"#, + ); + write_curated_plugin(&curated_root, "installed"); + write_curated_plugin(&curated_root, "slack"); + write_curated_plugin(&curated_root, "gmail"); + write_plugins_feature_config(codex_home.path()); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + .await + .unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["slack@openai-curated".to_string()] + ); +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -335,14 +461,12 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }] async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_plugin() { 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", "build-ios-apps", "life-science-research"], - ); + write_openai_curated_marketplace(&curated_root, &["slack", "gmail", "openai-developers"]); write_plugins_feature_config(codex_home.path()); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; let too_long_prompt = "x".repeat(129); - for plugin_name in ["build-ios-apps", "life-science-research"] { + for plugin_name in ["gmail", "openai-developers"] { write_file( &curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")), &format!( @@ -373,24 +497,59 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_ .await .unwrap(); - assert_eq!(discoverable_plugins.len(), 1); - assert_eq!(discoverable_plugins[0].id, "slack@openai-curated"); + assert_eq!( + discoverable_plugins + .iter() + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["gmail@openai-curated", "openai-developers@openai-curated"] + ); let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone()) .expect("utf8 logs") .replace('\\', "/"); - assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 2); + assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 8); let normalized_logs = logs.replace('\\', "/"); assert_eq!( normalized_logs - .matches("build-ios-apps/.codex-plugin/plugin.json") + .matches("gmail/.codex-plugin/plugin.json") .count(), - 1 + 4 ); assert_eq!( normalized_logs - .matches("life-science-research/.codex-plugin/plugin.json") + .matches("openai-developers/.codex-plugin/plugin.json") .count(), - 1 + 4 + ); +} + +async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, plugin_name: &str) { + write_curated_plugin_sha(codex_home); + PluginsManager::new(codex_home.to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: plugin_name.to_string(), + marketplace_path: AbsolutePathBuf::try_from( + marketplace_root.join(".agents/plugins/marketplace.json"), + ) + .expect("marketplace path"), + }) + .await + .expect("plugin should install"); +} + +fn write_plugin_app(root: &Path, plugin_name: &str, app_name: &str, app_id: &str) { + write_file( + &root.join(format!("plugins/{plugin_name}/.app.json")), + &format!( + r#"{{ + "apps": {{ + "{app_name}": {{ + "id": "{app_id}" + }} + }} +}} +"# + ), ); }