From 40a7d1d15b446991094c5ecfbb1d0f21f2d9ad40 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 17 Mar 2026 23:58:27 -0700 Subject: [PATCH] [plugins] Support configuration tool suggest allowlist. (#15022) - [x] Support configuration tool suggest allowlist. Supports both plugins and connectors. --- codex-rs/core/config.schema.json | 46 ++++++++++++- codex-rs/core/src/config/config_tests.rs | 64 +++++++++++++++++++ codex-rs/core/src/config/mod.rs | 33 +++++++++- codex-rs/core/src/config/types.rs | 22 +++++++ codex-rs/core/src/connectors.rs | 14 +++- codex-rs/core/src/connectors_tests.rs | 27 ++++++++ codex-rs/core/src/plugins/discoverable.rs | 15 ++++- .../core/src/plugins/discoverable_tests.rs | 37 +++++++++++ 8 files changed, 252 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ea00a7a2a..b2f88d334 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1549,6 +1549,42 @@ }, "type": "object" }, + "ToolSuggestConfig": { + "additionalProperties": false, + "properties": { + "discoverables": { + "default": [], + "items": { + "$ref": "#/definitions/ToolSuggestDiscoverable" + }, + "type": "array" + } + }, + "type": "object" + }, + "ToolSuggestDiscoverable": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/ToolSuggestDiscoverableType" + } + }, + "required": [ + "id", + "type" + ], + "type": "object" + }, + "ToolSuggestDiscoverableType": { + "enum": [ + "connector", + "plugin" + ], + "type": "string" + }, "ToolsToml": { "additionalProperties": false, "properties": { @@ -2431,6 +2467,14 @@ "minimum": 0.0, "type": "integer" }, + "tool_suggest": { + "allOf": [ + { + "$ref": "#/definitions/ToolSuggestConfig" + } + ], + "description": "Additional discoverable tools that can be suggested for installation." + }, "tools": { "allOf": [ { @@ -2479,4 +2523,4 @@ }, "title": "ConfigToml", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b917bae00..ee856664d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -11,6 +11,7 @@ use crate::config::types::MemoriesToml; use crate::config::types::ModelAvailabilityNuxConfig; use crate::config::types::NotificationMethod; use crate::config::types::Notifications; +use crate::config::types::ToolSuggestDiscoverableType; use crate::config_loader::RequirementSource; use crate::features::Feature; use assert_matches::assert_matches; @@ -4344,6 +4345,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_availability_nux: ModelAvailabilityNuxConfig::default(), analytics_enabled: Some(true), feedback_enabled: true, + tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, @@ -4484,6 +4486,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_availability_nux: ModelAvailabilityNuxConfig::default(), analytics_enabled: Some(true), feedback_enabled: true, + tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, @@ -4622,6 +4625,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_availability_nux: ModelAvailabilityNuxConfig::default(), analytics_enabled: Some(false), feedback_enabled: true, + tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, @@ -4746,6 +4750,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_availability_nux: ModelAvailabilityNuxConfig::default(), analytics_enabled: Some(true), feedback_enabled: true, + tool_suggest: ToolSuggestConfig::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, @@ -5882,6 +5887,65 @@ async fn feature_requirements_reject_collab_legacy_alias() { ); } +#[test] +fn tool_suggest_discoverables_load_from_config_toml() -> std::io::Result<()> { + let cfg: ConfigToml = toml::from_str( + r#" +[tool_suggest] +discoverables = [ + { type = "connector", id = "connector_alpha" }, + { type = "plugin", id = "plugin_alpha@openai-curated" }, + { type = "connector", id = " " } +] +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.tool_suggest, + Some(ToolSuggestConfig { + discoverables: vec![ + ToolSuggestDiscoverable { + kind: ToolSuggestDiscoverableType::Connector, + id: "connector_alpha".to_string(), + }, + ToolSuggestDiscoverable { + kind: ToolSuggestDiscoverableType::Plugin, + id: "plugin_alpha@openai-curated".to_string(), + }, + ToolSuggestDiscoverable { + kind: ToolSuggestDiscoverableType::Connector, + id: " ".to_string(), + }, + ], + }) + ); + + let codex_home = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.tool_suggest, + ToolSuggestConfig { + discoverables: vec![ + ToolSuggestDiscoverable { + kind: ToolSuggestDiscoverableType::Connector, + id: "connector_alpha".to_string(), + }, + ToolSuggestDiscoverable { + kind: ToolSuggestDiscoverableType::Plugin, + id: "plugin_alpha@openai-curated".to_string(), + }, + ], + } + ); + Ok(()) +} + #[test] fn experimental_realtime_start_instructions_load_from_config_toml() -> std::io::Result<()> { let cfg: ConfigToml = toml::from_str( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ae7a5b258..a1f270458 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -21,6 +21,8 @@ use crate::config::types::SandboxWorkspaceWrite; use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::SkillsConfig; +use crate::config::types::ToolSuggestConfig; +use crate::config::types::ToolSuggestDiscoverable; use crate::config::types::Tui; use crate::config::types::UriBasedFileOpener; use crate::config::types::WindowsSandboxModeToml; @@ -581,6 +583,9 @@ pub struct Config { /// Defaults to `true`. pub feedback_enabled: bool, + /// Configured discoverable tools for tool suggestions. + pub tool_suggest: ToolSuggestConfig, + /// OTEL configuration (exporter type, endpoint, headers, etc.). pub otel: crate::config::types::OtelConfig, } @@ -1424,6 +1429,9 @@ pub struct ConfigToml { /// Nested tools section for feature toggles pub tools: Option, + /// Additional discoverable tools that can be suggested for installation. + pub tool_suggest: Option, + /// Agent-related settings (thread limits, etc.). pub agents: Option, @@ -1621,6 +1629,28 @@ where }) } +fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { + let discoverables = config_toml + .tool_suggest + .as_ref() + .into_iter() + .flat_map(|tool_suggest| tool_suggest.discoverables.iter()) + .filter_map(|discoverable| { + let trimmed = discoverable.id.trim(); + if trimmed.is_empty() { + None + } else { + Some(ToolSuggestDiscoverable { + kind: discoverable.kind, + id: trimmed.to_string(), + }) + } + }) + .collect(); + + ToolSuggestConfig { discoverables } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct AgentsToml { @@ -2140,6 +2170,7 @@ impl Config { .clone(), None => ConfigProfile::default(), }; + let tool_suggest = resolve_tool_suggest_config(&cfg); let feature_overrides = FeatureOverrides { include_apply_patch_tool: include_apply_patch_tool_override, web_search_request: override_tools_web_search_request, @@ -2618,7 +2649,6 @@ impl Config { } else { NetworkSandboxPolicy::from(&effective_sandbox_policy) }; - let config = Self { model, service_tier, @@ -2760,6 +2790,7 @@ impl Config { .as_ref() .and_then(|feedback| feedback.enabled) .unwrap_or(true), + tool_suggest, tui_notifications: cfg .tui .as_ref() diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 00f613011..113dfcd2f 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -372,6 +372,28 @@ pub struct FeedbackConfigToml { pub enabled: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToolSuggestDiscoverableType { + Connector, + Plugin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestDiscoverable { + #[serde(rename = "type")] + pub kind: ToolSuggestDiscoverableType, + pub id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ToolSuggestConfig { + #[serde(default)] + pub discoverables: Vec, +} + /// Memories settings loaded from config.toml. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 3221e3408..fdd5cfb59 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -28,6 +28,7 @@ use crate::SandboxState; use crate::config::Config; use crate::config::types::AppToolApproval; use crate::config::types::AppsConfigToml; +use crate::config::types::ToolSuggestDiscoverableType; use crate::config_loader::AppsRequirementsToml; use crate::default_client::create_client; use crate::default_client::is_first_party_chat_originator; @@ -376,13 +377,22 @@ fn filter_tool_suggest_discoverable_connectors( } fn tool_suggest_connector_ids(config: &Config) -> HashSet { - PluginsManager::new(config.codex_home.clone()) + let mut connector_ids = PluginsManager::new(config.codex_home.clone()) .plugins_for_config(config) .capability_summaries() .iter() .flat_map(|plugin| plugin.app_connector_ids.iter()) .map(|connector_id| connector_id.0.clone()) - .collect() + .collect::>(); + connector_ids.extend( + config + .tool_suggest + .discoverables + .iter() + .filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector) + .map(|discoverable| discoverable.id.clone()), + ); + connector_ids } async fn list_directory_connectors_for_tool_suggest_with_auth( diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index f0ec1309c..5172db406 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -980,6 +980,33 @@ fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() { ); } +#[tokio::test] +async fn tool_suggest_connector_ids_include_configured_tool_suggest_discoverables() { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[tool_suggest] +discoverables = [ + { type = "connector", id = "connector_2128aebfecb84f64a069897515042a44" }, + { type = "plugin", id = "slack@openai-curated" }, + { type = "connector", id = " " } +] +"#, + ) + .expect("write config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should load"); + + assert_eq!( + tool_suggest_connector_ids(&config), + HashSet::from(["connector_2128aebfecb84f64a069897515042a44".to_string()]) + ); +} + #[test] fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() { let filtered = filter_tool_suggest_discoverable_connectors( diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index ddadd749e..0de3ac1c1 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -1,4 +1,5 @@ use anyhow::Context; +use std::collections::HashSet; use tracing::warn; use super::OPENAI_CURATED_MARKETPLACE_NAME; @@ -6,6 +7,7 @@ use super::PluginCapabilitySummary; use super::PluginReadRequest; use super::PluginsManager; use crate::config::Config; +use crate::config::types::ToolSuggestDiscoverableType; use crate::features::Feature; const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ @@ -28,6 +30,13 @@ pub(crate) fn list_tool_suggest_discoverable_plugins( } let plugins_manager = PluginsManager::new(config.codex_home.clone()); + let configured_plugin_ids = config + .tool_suggest + .discoverables + .iter() + .filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin) + .map(|discoverable| discoverable.id.as_str()) + .collect::>(); let marketplaces = plugins_manager .list_marketplaces_for_config(config, &[]) .context("failed to list plugin marketplaces for tool suggestions")?; @@ -41,10 +50,12 @@ pub(crate) fn list_tool_suggest_discoverable_plugins( let mut discoverable_plugins = Vec::::new(); for plugin in curated_marketplace.plugins { if plugin.installed - || !TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()) + || (!TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()) + && !configured_plugin_ids.contains(plugin.id.as_str())) { continue; } + let plugin_id = plugin.id.clone(); let plugin_name = plugin.name.clone(); @@ -56,7 +67,7 @@ pub(crate) fn list_tool_suggest_discoverable_plugins( }, ) { Ok(plugin) => discoverable_plugins.push(plugin.plugin.into()), - Err(err) => warn!("failed to load curated plugin suggestion {plugin_id}: {err:#}"), + Err(err) => warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}"), } } discoverable_plugins.sort_by(|left, right| { diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index f624172ed..cb2ac1549 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -117,3 +117,40 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins( assert_eq!(discoverable_plugins, Vec::::new()); } + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample"]); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[tool_suggest] +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) + .unwrap() + .into_iter() + .map(DiscoverablePluginInfo::from) + .collect::>(); + + assert_eq!( + discoverable_plugins, + vec![DiscoverablePluginInfo { + id: "sample@openai-curated".to_string(), + name: "sample".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()], + }] + ); +}