mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user