[plugins] Support plugin installation elicitation. (#14896)

It now supports:

- Connectors that are from installed and enabled plugins that are not
installed yet
- Plugins that are on the allowlist that are not installed yet.
This commit is contained in:
Matthew Zeng
2026-03-17 13:19:28 -07:00
committed by GitHub
parent 49e7dda2df
commit 683c37ce75
18 changed files with 755 additions and 244 deletions
+72
View File
@@ -0,0 +1,72 @@
use anyhow::Context;
use tracing::warn;
use super::OPENAI_CURATED_MARKETPLACE_NAME;
use super::PluginCapabilitySummary;
use super::PluginReadRequest;
use super::PluginsManager;
use crate::config::Config;
use crate::features::Feature;
const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
"github@openai-curated",
"notion@openai-curated",
"slack@openai-curated",
"gmail@openai-curated",
"google-calendar@openai-curated",
"google-docs@openai-curated",
"google-drive@openai-curated",
"google-sheets@openai-curated",
"google-slides@openai-curated",
];
pub(crate) fn list_tool_suggest_discoverable_plugins(
config: &Config,
) -> anyhow::Result<Vec<PluginCapabilitySummary>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
}
let plugins_manager = PluginsManager::new(config.codex_home.clone());
let marketplaces = plugins_manager
.list_marketplaces_for_config(config, &[])
.context("failed to list plugin marketplaces for tool suggestions")?;
let Some(curated_marketplace) = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
else {
return Ok(Vec::new());
};
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())
{
continue;
}
let plugin_id = plugin.id.clone();
let plugin_name = plugin.name.clone();
match plugins_manager.read_plugin_for_config(
config,
&PluginReadRequest {
plugin_name,
marketplace_path: curated_marketplace.path.clone(),
},
) {
Ok(plugin) => discoverable_plugins.push(plugin.plugin.into()),
Err(err) => warn!("failed to load curated plugin suggestion {plugin_id}: {err:#}"),
}
}
discoverable_plugins.sort_by(|left, right| {
left.display_name
.cmp(&right.display_name)
.then_with(|| left.config_name.cmp(&right.config_name))
});
Ok(discoverable_plugins)
}
#[cfg(test)]
#[path = "discoverable_tests.rs"]
mod tests;
@@ -0,0 +1,119 @@
use super::*;
use crate::plugins::PluginInstallRequest;
use crate::plugins::test_support::load_plugins_config;
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 crate::tools::discoverable::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() {
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", "slack"]);
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)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(
discoverable_plugins,
vec![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()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() {
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, &["slack"]);
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>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
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, &["slack"]);
write_plugins_feature_config(codex_home.path());
write_file(
&curated_root.join("plugins/slack/.codex-plugin/plugin.json"),
r#"{
"name": "slack",
"description": " Plugin\n with extra spacing "
}"#,
);
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: "slack@openai-curated".to_string(),
name: "slack".to_string(),
description: Some("Plugin with extra spacing".to_string()),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() {
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, &["slack"]);
write_curated_plugin_sha(codex_home.path());
write_plugins_feature_config(codex_home.path());
PluginsManager::new(codex_home.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "slack".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
curated_root.join(".agents/plugins/marketplace.json"),
)
.expect("marketplace path"),
})
.await
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
+14 -1
View File
@@ -68,7 +68,7 @@ use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
@@ -219,6 +219,19 @@ impl PluginCapabilitySummary {
}
}
impl From<PluginDetailSummary> for PluginCapabilitySummary {
fn from(value: PluginDetailSummary) -> Self {
Self {
config_name: value.id,
display_name: value.name,
description: prompt_safe_plugin_description(value.description.as_deref()),
has_skills: !value.skills.is_empty(),
mcp_server_names: value.mcp_server_names,
app_connector_ids: value.apps,
}
}
}
fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
let description = description?
.split_whitespace()
+4 -45
View File
@@ -7,6 +7,10 @@ use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use codex_app_server_protocol::ConfigLayerSource;
use pretty_assertions::assert_eq;
use std::fs;
@@ -19,13 +23,6 @@ use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
@@ -39,44 +36,6 @@ fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
}
fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
fs::create_dir_all(root.join(".agents/plugins")).unwrap();
let plugins = plugin_names
.iter()
.map(|plugin_name| {
format!(
r#"{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./plugins/{plugin_name}"
}}
}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
fs::write(
root.join(".agents/plugins/marketplace.json"),
format!(
r#"{{
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
"plugins": [
{plugins}
]
}}"#
),
)
.unwrap();
for plugin_name in plugin_names {
write_plugin(root, &format!("plugins/{plugin_name}"), plugin_name);
}
}
fn write_curated_plugin_sha(codex_home: &Path, sha: &str) {
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
}
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
let mut root = toml::map::Map::new();
+5
View File
@@ -1,4 +1,5 @@
mod curated_repo;
mod discoverable;
mod injection;
mod manager;
mod manifest;
@@ -6,16 +7,20 @@ mod marketplace;
mod remote;
mod render;
mod store;
#[cfg(test)]
pub(crate) mod test_support;
mod toggles;
pub(crate) use curated_repo::curated_plugins_repo_path;
pub(crate) use curated_repo::read_curated_plugins_sha;
pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
pub use manager::ConfiguredMarketplaceSummary;
pub use manager::LoadedPlugin;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginDetailSummary;
pub use manager::PluginInstallError;
+109
View File
@@ -0,0 +1,109 @@
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use std::fs;
use std::path::Path;
use super::OPENAI_CURATED_MARKETPLACE_NAME;
pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
pub(crate) fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
pub(crate) fn write_curated_plugin(root: &Path, plugin_name: &str) {
let plugin_root = root.join("plugins").join(plugin_name);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(
r#"{{
"name": "{plugin_name}",
"description": "Plugin that includes skills, MCP servers, and app connectors"
}}"#
),
);
write_file(
&plugin_root.join("skills/SKILL.md"),
"---\nname: sample\ndescription: sample\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample-docs": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "connector_calendar"
}
}
}"#,
);
}
pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
let plugins = plugin_names
.iter()
.map(|plugin_name| {
format!(
r#"{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./plugins/{plugin_name}"
}}
}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
write_file(
&root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
"plugins": [
{plugins}
]
}}"#
),
);
for plugin_name in plugin_names {
write_curated_plugin(root, plugin_name);
}
}
pub(crate) fn write_curated_plugin_sha(codex_home: &Path) {
write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA);
}
pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) {
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
}
pub(crate) fn write_plugins_feature_config(codex_home: &Path) {
write_file(
&codex_home.join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
}
pub(crate) async fn load_plugins_config(codex_home: &Path) -> crate::config::Config {
ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.fallback_cwd(Some(codex_home.to_path_buf()))
.build()
.await
.expect("config should load")
}