mirror of
https://github.com/openai/codex.git
synced 2026-06-03 11:52:03 +00:00
Filter plugin install suggestions by installed apps
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -44,18 +45,47 @@ 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 = HashSet::<String>::new();
|
||||
for marketplace in &marketplaces {
|
||||
for plugin in &marketplace.plugins {
|
||||
if !plugin.installed {
|
||||
continue;
|
||||
}
|
||||
|
||||
let plugin_id = plugin.id.clone();
|
||||
match plugins_manager
|
||||
.read_plugin_detail_for_marketplace_plugin(
|
||||
&plugins_input,
|
||||
&marketplace.name,
|
||||
plugin.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(plugin) => {
|
||||
installed_app_connector_ids
|
||||
.extend(plugin.apps.into_iter().map(|connector_id| connector_id.0));
|
||||
}
|
||||
Err(err) => warn!(
|
||||
"failed to load installed plugin apps for tool suggestion {plugin_id}: {err:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut discoverable_plugins = Vec::<DiscoverablePluginInfo>::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 +102,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,
|
||||
|
||||
@@ -5,18 +5,20 @@ 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"]);
|
||||
@@ -28,34 +30,42 @@ async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plug
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins,
|
||||
discoverable_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.id)
|
||||
.collect::<Vec<_>>(),
|
||||
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<_>>(),
|
||||
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,6 +73,7 @@ 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)
|
||||
@@ -78,28 +89,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<_>>(),
|
||||
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}"));
|
||||
@@ -138,23 +213,15 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
|
||||
.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,6 +249,7 @@ 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)
|
||||
@@ -216,7 +284,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,6 +293,7 @@ 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)
|
||||
@@ -296,6 +365,63 @@ disabled_tools = [
|
||||
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::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<_>>(),
|
||||
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");
|
||||
@@ -335,14 +461,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!(
|
||||
@@ -373,24 +497,59 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
|
||||
.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<_>>(),
|
||||
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}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user