mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Filter plugin install suggestions by installed apps (#24996)
## Summary - Keep the original `TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST` as a fallback seed list, so users with no installed plugins still get initial install suggestions. - Allow additional install suggestions from trusted marketplaces: `openai-curated` and `openai-bundled`. - Require non-fallback, non-configured marketplace candidates to share `.app.json` connector IDs with already installed plugins. - Preserve explicit configured plugin discoverables as an override, while still omitting installed, disabled, and `NOT_AVAILABLE` plugins. ## Context `list_available_plugins_to_install` controls which plugins the model can trigger via `request_plugin_install`. We want a small starter set for empty/new users, but we also want installed workflow plugins to unlock relevant source plugins without maintaining every source plugin ID by hand. This keeps the legacy plugin ID allowlist only as the starter fallback. For everything else, the trusted marketplace is the candidate boundary, and installed app connector overlap is the relevance filter. For example, an installed Sales plugin can make HubSpot and Granola suggestible when those source plugins are in `openai-curated` and share Sales app connector IDs, while an unrelated test-source plugin with an app connector not declared by Sales stays hidden. ## Test Coverage - Empty/no-installed-plugin case: returns the fallback seed plugins from the original allowlist. - Installed-app expansion: returns non-fallback marketplace plugins only when their app connector IDs overlap with an installed plugin. - Sales workflow case: installed Sales declares HubSpot and Granola apps, so `hubspot@openai-curated` and `granola@openai-curated` are returned. - Sales negative case: `test-source@openai-curated` has an app connector not declared by Sales, so it is not returned. - Existing guardrails: installed plugins, disabled suggestions, and `NOT_AVAILABLE` plugins remain omitted; explicit configured discoverables still work as an override. ## Validation - `just fmt` - `just test -p codex-core plugins::discoverable::tests` - `just test -p codex-core` was attempted earlier, but current `main` / local env failed with unrelated existing failures around missing `test_stdio_server`, CLI/code-mode MCP tool setup, and unified_exec/shell snapshot flakes/timeouts. The touched discoverable tests pass.
This commit is contained in:
committed by
GitHub
Unverified
parent
a076b21730
commit
8e5f561697
@@ -113,6 +113,7 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
loaded_plugin_app_connector_ids: &[String],
|
||||
) -> anyhow::Result<Vec<DiscoverableTool>> {
|
||||
let connector_ids = tool_suggest_connector_ids(config).await;
|
||||
let directory_connectors = codex_connectors::merge::merge_plugin_connectors(
|
||||
@@ -128,10 +129,11 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
)
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from);
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from);
|
||||
let discoverable_plugins =
|
||||
list_tool_suggest_discoverable_plugins(config, loaded_plugin_app_connector_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from);
|
||||
Ok(discoverable_connectors
|
||||
.chain(discoverable_plugins)
|
||||
.collect())
|
||||
|
||||
@@ -1238,7 +1238,7 @@ discoverables = [
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
let discoverable_tools =
|
||||
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[])
|
||||
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
|
||||
.await
|
||||
.expect("discoverable tools should load");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,6 +20,7 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
|
||||
|
||||
pub(crate) async fn list_tool_suggest_discoverable_plugins(
|
||||
config: &Config,
|
||||
loaded_plugin_app_connector_ids: &[String],
|
||||
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
|
||||
if !config.features.enabled(Feature::Plugins) {
|
||||
return Ok(Vec::new());
|
||||
@@ -44,18 +46,30 @@ 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 = plugins_manager
|
||||
.plugins_for_config(&plugins_input)
|
||||
.await
|
||||
.capability_summaries()
|
||||
.iter()
|
||||
.flat_map(|plugin| plugin.app_connector_ids.iter())
|
||||
.map(|connector_id| connector_id.0.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
installed_app_connector_ids.extend(loaded_plugin_app_connector_ids.iter().cloned());
|
||||
|
||||
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 +86,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,57 +5,93 @@ 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"]);
|
||||
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)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
|
||||
.await
|
||||
.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_by_loaded_plugin_apps() {
|
||||
let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14";
|
||||
let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b";
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["hubspot", "granola"]);
|
||||
write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
|
||||
write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
|
||||
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, &[hubspot_app_id.to_string()])
|
||||
.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,9 +99,10 @@ 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)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -78,28 +115,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}"));
|
||||
@@ -133,28 +234,20 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
|
||||
.await
|
||||
.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,9 +275,10 @@ 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)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -205,7 +299,7 @@ plugins = false
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -216,7 +310,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,9 +319,10 @@ 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)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -264,7 +359,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
|
||||
.expect("plugin should install");
|
||||
|
||||
let refreshed_config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -289,13 +384,70 @@ disabled_tools = [
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
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");
|
||||
@@ -312,7 +464,7 @@ 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)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -335,14 +487,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!(
|
||||
@@ -369,28 +519,63 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
|
||||
.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}"
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1071,12 +1071,18 @@ pub(crate) async fn built_tools(
|
||||
None
|
||||
};
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let loaded_plugin_app_connector_ids = loaded_plugins
|
||||
.effective_apps()
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect::<Vec<_>>();
|
||||
let discoverable_tools = if apps_enabled && tool_suggest_enabled(turn_context) {
|
||||
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
|
||||
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn_context.config,
|
||||
auth.as_ref(),
|
||||
accessible_connectors.as_slice(),
|
||||
&loaded_plugin_app_connector_ids,
|
||||
)
|
||||
.await
|
||||
.map(|discoverable_tools| {
|
||||
|
||||
@@ -37,7 +37,15 @@ use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_i
|
||||
use crate::tools::registry::CoreToolRuntime;
|
||||
use crate::tools::registry::ToolExecutor;
|
||||
|
||||
pub struct RequestPluginInstallHandler;
|
||||
pub struct RequestPluginInstallHandler {
|
||||
discoverable_tools: Vec<DiscoverableTool>,
|
||||
}
|
||||
|
||||
impl RequestPluginInstallHandler {
|
||||
pub(crate) fn new(discoverable_tools: Vec<DiscoverableTool>) -> Self {
|
||||
Self { discoverable_tools }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
@@ -53,10 +61,6 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
true
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "plugin install discovery reads through the session-owned manager guard"
|
||||
)]
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
@@ -99,31 +103,10 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
));
|
||||
}
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
let mcp_tools = manager.list_all_tools().await;
|
||||
drop(manager);
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
let discoverable_tools = filter_request_plugin_install_discoverable_tools_for_client(
|
||||
self.discoverable_tools.clone(),
|
||||
turn.app_server_client_name.as_deref(),
|
||||
);
|
||||
let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn.config,
|
||||
auth.as_ref(),
|
||||
&accessible_connectors,
|
||||
)
|
||||
.await
|
||||
.map(|discoverable_tools| {
|
||||
filter_request_plugin_install_discoverable_tools_for_client(
|
||||
discoverable_tools,
|
||||
turn.app_server_client_name.as_deref(),
|
||||
)
|
||||
})
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"plugin install requests are unavailable right now: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let tool = discoverable_tools
|
||||
.into_iter()
|
||||
@@ -154,6 +137,7 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
|
||||
.as_ref()
|
||||
.is_some_and(|response| response.action == ElicitationAction::Accept);
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let completed = if user_confirmed {
|
||||
verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await
|
||||
} else {
|
||||
|
||||
@@ -615,7 +615,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
|
||||
planned_tools.add(ListAvailablePluginsToInstallHandler::new(
|
||||
collect_request_plugin_install_entries(discoverable_tools),
|
||||
));
|
||||
planned_tools.add(RequestPluginInstallHandler);
|
||||
planned_tools.add(RequestPluginInstallHandler::new(
|
||||
discoverable_tools.to_vec(),
|
||||
));
|
||||
}
|
||||
|
||||
if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some()
|
||||
|
||||
Reference in New Issue
Block a user