[plugins] Support configuration tool suggest allowlist. (#15022)

- [x] Support configuration tool suggest allowlist.

Supports both plugins and connectors.
This commit is contained in:
Matthew Zeng 2026-03-17 23:58:27 -07:00 committed by GitHub
parent 84f4e7b39d
commit 40a7d1d15b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 252 additions and 6 deletions

View file

@ -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"
}
}

View file

@ -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(

View file

@ -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<ToolsToml>,
/// Additional discoverable tools that can be suggested for installation.
pub tool_suggest: Option<ToolSuggestConfig>,
/// Agent-related settings (thread limits, etc.).
pub agents: Option<AgentsToml>,
@ -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()

View file

@ -372,6 +372,28 @@ pub struct FeedbackConfigToml {
pub enabled: Option<bool>,
}
#[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<ToolSuggestDiscoverable>,
}
/// Memories settings loaded from config.toml.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]

View file

@ -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<String> {
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::<HashSet<_>>();
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(

View file

@ -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(

View file

@ -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::<HashSet<_>>();
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::<PluginCapabilitySummary>::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| {

View file

@ -117,3 +117,40 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::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::<Vec<_>>();
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()],
}]
);
}