diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 09717bb12..70ccbc83d 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -113,6 +113,7 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], + loaded_plugin_app_connector_ids: &[String], ) -> anyhow::Result> { let connector_ids = tool_suggest_connector_ids(config).await; let directory_connectors = codex_connectors::merge::merge_plugin_connectors( @@ -128,10 +129,11 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( ) .into_iter() .map(DiscoverableTool::from); - let discoverable_plugins = list_tool_suggest_discoverable_plugins(config) - .await? - .into_iter() - .map(DiscoverableTool::from); + let discoverable_plugins = + list_tool_suggest_discoverable_plugins(config, loaded_plugin_app_connector_ids) + .await? + .into_iter() + .map(DiscoverableTool::from); Ok(discoverable_connectors .chain(discoverable_plugins) .collect()) diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 3733d78c3..972743c6e 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -1238,7 +1238,7 @@ discoverables = [ let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let discoverable_tools = - list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[]) + list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[]) .await .expect("discoverable tools should load"); diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 3e328165b..4f0d88b51 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; @@ -19,6 +20,7 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ pub(crate) async fn list_tool_suggest_discoverable_plugins( config: &Config, + loaded_plugin_app_connector_ids: &[String], ) -> anyhow::Result> { if !config.features.enabled(Feature::Plugins) { return Ok(Vec::new()); @@ -44,18 +46,30 @@ 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 = plugins_manager + .plugins_for_config(&plugins_input) + .await + .capability_summaries() + .iter() + .flat_map(|plugin| plugin.app_connector_ids.iter()) + .map(|connector_id| connector_id.0.clone()) + .collect::>(); + installed_app_connector_ids.extend(loaded_plugin_app_connector_ids.iter().cloned()); + 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 +86,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 a481f4582..21203aea2 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -5,57 +5,93 @@ 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"]); write_plugins_feature_config(codex_home.path()); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .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_by_loaded_plugin_apps() { + let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; + let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b"; + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["hubspot", "granola"]); + write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id); + write_plugin_app(&curated_root, "granola", "granola", granola_app_id); + write_plugins_feature_config(codex_home.path()); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = + list_tool_suggest_discoverable_plugins(&config, &[hubspot_app_id.to_string()]) + .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,9 +99,10 @@ 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) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -78,28 +115,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}")); @@ -133,28 +234,20 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}] ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .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,9 +275,10 @@ 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) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -205,7 +299,7 @@ plugins = false ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -216,7 +310,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,9 +319,10 @@ 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) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -264,7 +359,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins( .expect("plugin should install"); let refreshed_config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, &[]) .await .unwrap(); @@ -289,13 +384,70 @@ disabled_tools = [ ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); 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"); @@ -312,7 +464,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }] ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -335,14 +487,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!( @@ -369,28 +519,63 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_ .finish(); let _guard = tracing::subscriber::set_default(subscriber); - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .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}" + }} + }} +}} +"# + ), ); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 7944be57a..f808134f9 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1071,12 +1071,18 @@ pub(crate) async fn built_tools( None }; let auth = sess.services.auth_manager.auth().await; + let loaded_plugin_app_connector_ids = loaded_plugins + .effective_apps() + .into_iter() + .map(|connector_id| connector_id.0) + .collect::>(); let discoverable_tools = if apps_enabled && tool_suggest_enabled(turn_context) { if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { match connectors::list_tool_suggest_discoverable_tools_with_auth( &turn_context.config, auth.as_ref(), accessible_connectors.as_slice(), + &loaded_plugin_app_connector_ids, ) .await .map(|discoverable_tools| { diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index b73a81a94..c134eae07 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -37,7 +37,15 @@ use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_i use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -pub struct RequestPluginInstallHandler; +pub struct RequestPluginInstallHandler { + discoverable_tools: Vec, +} + +impl RequestPluginInstallHandler { + pub(crate) fn new(discoverable_tools: Vec) -> Self { + Self { discoverable_tools } + } +} #[async_trait::async_trait] impl ToolExecutor for RequestPluginInstallHandler { @@ -53,10 +61,6 @@ impl ToolExecutor for RequestPluginInstallHandler { true } - #[expect( - clippy::await_holding_invalid_type, - reason = "plugin install discovery reads through the session-owned manager guard" - )] async fn handle( &self, invocation: ToolInvocation, @@ -99,31 +103,10 @@ impl ToolExecutor for RequestPluginInstallHandler { )); } - let auth = session.services.auth_manager.auth().await; - let manager = session.services.mcp_connection_manager.read().await; - let mcp_tools = manager.list_all_tools().await; - drop(manager); - let accessible_connectors = connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn.config, + let discoverable_tools = filter_request_plugin_install_discoverable_tools_for_client( + self.discoverable_tools.clone(), + turn.app_server_client_name.as_deref(), ); - let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth( - &turn.config, - auth.as_ref(), - &accessible_connectors, - ) - .await - .map(|discoverable_tools| { - filter_request_plugin_install_discoverable_tools_for_client( - discoverable_tools, - turn.app_server_client_name.as_deref(), - ) - }) - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "plugin install requests are unavailable right now: {err}" - )) - })?; let tool = discoverable_tools .into_iter() @@ -154,6 +137,7 @@ impl ToolExecutor for RequestPluginInstallHandler { .as_ref() .is_some_and(|response| response.action == ElicitationAction::Accept); + let auth = session.services.auth_manager.auth().await; let completed = if user_confirmed { verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await } else { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 9aaccbbd0..c6fd80c0a 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -615,7 +615,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut planned_tools.add(ListAvailablePluginsToInstallHandler::new( collect_request_plugin_install_entries(discoverable_tools), )); - planned_tools.add(RequestPluginInstallHandler); + planned_tools.add(RequestPluginInstallHandler::new( + discoverable_tools.to_vec(), + )); } if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some()