[codex] Limit app-based plugin suggestions to remote catalogs (#27988)

## Summary

- Keep local plugin suggestions bounded to fallback and explicitly
configured plugins.
- Preserve app-overlap recommendations for remote plugins using cached
catalog metadata.
- Remove the WSL-specific local discovery exception and move
manager-owned discovery tests into `codex-core-plugins`.

## Why

Local curated marketplaces were allowlisted before plugin detail
loading, so every uninstalled candidate could be deep-read before its
app IDs were checked. That caused per-turn reads of candidate plugin
manifests, skills, app configs, hooks, and MCP configs, which is
especially expensive on slow disks.

Remote discovery does not need those local candidate reads because app
IDs are already available in the cached remote catalog. Installed local
plugins are still loaded when needed to determine the user's installed
app IDs.

## Validation

- `just fmt`
- `just test -p codex-core-plugins discoverable::tests` (13 passed)
- `just test -p codex-core plugins::discoverable::tests` (4 passed)
- `just bazel-lock-update`
- `just bazel-lock-check`
- `git diff --check`
This commit is contained in:
xl-openai
2026-06-12 17:51:09 -07:00
committed by GitHub
Unverified
parent c884536d84
commit 044c1420a1
5 changed files with 740 additions and 644 deletions
+2
View File
@@ -2741,6 +2741,8 @@ dependencies = [
"tokio",
"toml 0.9.11+spec-1.1.0",
"tracing",
"tracing-subscriber",
"tracing-test",
"url",
"wiremock",
"zip 2.4.2",
+2
View File
@@ -51,4 +51,6 @@ zip = { workspace = true }
libc = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-test = { workspace = true, features = ["no-env-filter"] }
wiremock = { workspace = true }
+11 -67
View File
@@ -4,12 +4,8 @@ use codex_app_server_protocol::PluginInstallPolicy;
use codex_login::CodexAuth;
use codex_plugin::PluginCapabilitySummary;
use std::collections::HashSet;
use std::path::Component;
use std::path::Path;
use tracing::warn;
use crate::OPENAI_BUNDLED_MARKETPLACE_NAME;
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::PluginsConfigInput;
use crate::PluginsManager;
use crate::marketplace::MarketplacePluginInstallPolicy;
@@ -48,15 +44,6 @@ const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
"computer-use@openai-bundled",
];
const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
OPENAI_BUNDLED_MARKETPLACE_NAME,
OPENAI_CURATED_MARKETPLACE_NAME,
REMOTE_GLOBAL_MARKETPLACE_NAME,
];
const OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX: &str =
".tmp/plugins/.agents/plugins/marketplace.json";
#[derive(Debug, Clone)]
pub struct ToolSuggestPluginDiscoveryInput {
pub plugins: PluginsConfigInput,
@@ -94,15 +81,6 @@ impl PluginsManager {
)
.context("failed to list plugin marketplaces for tool suggestions")?
.marketplaces;
let mut installed_app_connector_ids = self
.plugins_for_config(&input.plugins)
.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(input.loaded_plugin_app_connector_ids.iter().cloned());
let remote_installed_marketplaces = if input.plugins.remote_plugin_enabled {
self.build_remote_installed_plugin_marketplaces_from_cache(&[
REMOTE_GLOBAL_MARKETPLACE_NAME,
@@ -114,12 +92,6 @@ impl PluginsManager {
let mut discoverable_plugins = Vec::<ToolSuggestDiscoverablePlugin>::new();
for marketplace in marketplaces {
let marketplace_name = marketplace.name;
let use_legacy_local_curated_filter = should_use_legacy_local_curated_discovery_filter(
&marketplace_name,
marketplace.path.as_path(),
);
let is_allowlisted_marketplace = TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST
.contains(&marketplace_name.as_str());
for plugin in marketplace.plugins {
let is_configured_plugin = input.configured_plugin_ids.contains(plugin.id.as_str());
@@ -128,18 +100,11 @@ impl PluginsManager {
if plugin.installed
|| plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|| input.disabled_plugin_ids.contains(plugin.id.as_str())
|| (!is_allowlisted_marketplace && !is_configured_plugin)
|| (!is_configured_plugin && !is_fallback_plugin)
{
continue;
}
// On Windows-backed WSL mounts, keep local curated discovery bounded to the
// legacy fallback/configured set instead of reading every plugin detail for app
// ids. Remote curated has cached app ids and still expands by installed apps.
if use_legacy_local_curated_filter && !is_configured_plugin && !is_fallback_plugin {
continue;
}
let plugin_id = plugin.id.clone();
match self
@@ -152,14 +117,6 @@ impl PluginsManager {
{
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(ToolSuggestDiscoverablePlugin {
id: plugin.config_name,
remote_plugin_id: None,
@@ -181,6 +138,16 @@ impl PluginsManager {
}
}
if let Some(remote_installed_marketplaces) = remote_installed_marketplaces.as_ref() {
let mut installed_app_connector_ids = self
.plugins_for_config(&input.plugins)
.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(input.loaded_plugin_app_connector_ids.iter().cloned());
let installed_remote_plugin_ids = remote_installed_marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
@@ -236,29 +203,6 @@ impl PluginsManager {
}
}
fn should_use_legacy_local_curated_discovery_filter(
marketplace_name: &str,
marketplace_path: &Path,
) -> bool {
marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME
&& is_wsl_windows_drive_path(marketplace_path)
&& marketplace_path.ends_with(Path::new(OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX))
}
fn is_wsl_windows_drive_path(path: &Path) -> bool {
let mut components = path.components();
if components.next() != Some(Component::RootDir) {
return false;
}
if components.next().and_then(|part| part.as_os_str().to_str()) != Some("mnt") {
return false;
}
let Some(drive) = components.next().and_then(|part| part.as_os_str().to_str()) else {
return false;
};
drive.len() == 1 && drive.as_bytes()[0].is_ascii_alphabetic()
}
#[cfg(test)]
#[path = "discoverable_tests.rs"]
mod tests;
+725 -53
View File
@@ -1,61 +1,733 @@
use std::path::Path;
use super::is_wsl_windows_drive_path;
use super::should_use_legacy_local_curated_discovery_filter;
use super::ToolSuggestDiscoverablePlugin;
use super::ToolSuggestPluginDiscoveryInput;
use crate::OPENAI_BUNDLED_MARKETPLACE_NAME;
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::PluginInstallRequest;
use crate::PluginsConfigInput;
use crate::PluginsManager;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::remote::RemotePluginServiceConfig;
use crate::remote::fetch_and_cache_global_remote_plugin_catalog;
use crate::startup_sync::curated_plugins_repo_path;
use crate::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::test_support::load_plugins_config;
use crate::test_support::write_curated_plugin;
use crate::test_support::write_curated_plugin_sha_with;
use crate::test_support::write_file;
use crate::test_support::write_openai_curated_marketplace;
use codex_config::CONFIG_TOML_FILE;
use codex_login::CodexAuth;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashSet;
use std::path::Path;
use tempfile::tempdir;
use tracing::Level;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_test::internal::MockWriter;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
#[test]
fn legacy_local_curated_filter_matches_wsl_windows_backed_curated_checkout() {
let marketplace_path =
Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json");
#[tokio::test]
async fn 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"]);
assert!(should_use_legacy_local_curated_discovery_filter(
OPENAI_CURATED_MARKETPLACE_NAME,
marketplace_path,
));
}
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
#[test]
fn legacy_local_curated_filter_does_not_match_native_wsl_curated_checkout() {
let marketplace_path =
Path::new("/home/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json");
assert!(!should_use_legacy_local_curated_discovery_filter(
OPENAI_CURATED_MARKETPLACE_NAME,
marketplace_path,
));
}
#[test]
fn legacy_local_curated_filter_does_not_match_other_wsl_marketplaces() {
let other_marketplace_path = Path::new(
"/mnt/c/Users/user/.codex/.tmp/marketplaces/other/.agents/plugins/marketplace.json",
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
"openai-developers@openai-curated".to_string(),
"slack@openai-curated".to_string(),
]
);
let local_curated_marketplace_path =
Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json");
assert!(!should_use_legacy_local_curated_discovery_filter(
OPENAI_CURATED_MARKETPLACE_NAME,
other_marketplace_path,
));
assert!(!should_use_legacy_local_curated_discovery_filter(
OPENAI_BUNDLED_MARKETPLACE_NAME,
local_curated_marketplace_path,
));
}
#[test]
fn wsl_windows_drive_path_matches_only_mnt_drive_paths() {
assert!(is_wsl_windows_drive_path(Path::new(
"/mnt/c/Users/user/.codex/.tmp/plugins",
)));
assert!(is_wsl_windows_drive_path(Path::new("/mnt/Z/tmp")));
assert!(!is_wsl_windows_drive_path(Path::new("/home/user/.codex")));
assert!(!is_wsl_windows_drive_path(Path::new(
"/mnt/codex/Users/user/.codex",
)));
assert!(!is_wsl_windows_drive_path(Path::new(
"/media/c/Users/user/.codex",
)));
#[tokio::test]
async fn returns_microsoft_fallback_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(
&curated_root,
&["teams", "sharepoint", "outlook-email", "outlook-calendar"],
);
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await;
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
"outlook-calendar@openai-curated".to_string(),
"outlook-email@openai-curated".to_string(),
"sharepoint@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn omits_openai_curated_when_remote_enabled() {
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 bundled_marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
let bundled_marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{bundled_marketplace_name}"));
write_file(
&bundled_marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{bundled_marketplace_name}",
"plugins": [
{{"name": "chrome", "source": {{"source": "local", "path": "./plugins/chrome"}}}}
]
}}
"#
),
);
write_curated_plugin(&bundled_marketplace_root, "chrome");
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
remote_plugin = true
[marketplaces.{bundled_marketplace_name}]
source_type = "git"
source = "/tmp/{bundled_marketplace_name}"
"#
),
);
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["chrome@openai-bundled".to_string()]
);
}
#[tokio::test]
async fn 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}"));
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{marketplace_name}",
"plugins": [
{{"name": "{plugin_name}", "source": {{"source": "local", "path": "./plugins/{plugin_name}"}}}}
]
}}
"#
),
);
write_curated_plugin(&marketplace_root, plugin_name);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
"#
),
);
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[plugin_id.as_str()], &[], &[]),
/*auth*/ None,
)
.await;
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, plugin_id);
}
#[tokio::test]
async fn 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, &["installed", "slack"]);
let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
let marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{marketplace_name}"));
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{marketplace_name}",
"plugins": [
{{"name": "sample", "source": {{"source": "local", "path": "./plugins/sample"}}}}
]
}}
"#
),
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
"#
),
);
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
}
#[tokio::test]
async fn 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, &["installed", "slack"]);
write_file(
&curated_root.join("plugins/slack/.codex-plugin/plugin.json"),
r#"{
"name": "slack",
"description": " Plugin\n with extra spacing "
}"#,
);
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
assert_eq!(
discoverable_plugins,
vec![ToolSuggestDiscoverablePlugin {
id: "slack@openai-curated".to_string(),
remote_plugin_id: None,
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 omits_installed_curated_plugins() {
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"]);
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
assert_eq!(discoverable_plugins, Vec::new());
}
#[tokio::test]
async fn 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");
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["slack@openai-curated".to_string()]
);
}
#[tokio::test]
async fn 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", "gmail", "openai-developers"]);
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
let too_long_prompt = "x".repeat(129);
for plugin_name in ["gmail", "openai-developers"] {
write_file(
&curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")),
&format!(
r#"{{
"name": "{plugin_name}",
"description": "Plugin that includes skills, MCP servers, and app connectors",
"interface": {{
"defaultPrompt": "{too_long_prompt}"
}}
}}"#
),
);
}
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let buffer: &'static std::sync::Mutex<Vec<u8>> =
Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_level(true)
.with_ansi(false)
.with_max_level(Level::WARN)
.with_span_events(FmtSpan::NONE)
.with_writer(MockWriter::new(buffer))
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
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(), 8);
assert_eq!(logs.matches("gmail/.codex-plugin/plugin.json").count(), 4);
assert_eq!(
logs.matches("openai-developers/.codex-plugin/plugin.json")
.count(),
4
);
}
#[tokio::test]
async fn does_not_expand_local_plugins_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");
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
assert_eq!(discoverable_plugins, Vec::new());
}
#[tokio::test]
async fn does_not_read_local_plugins_for_loaded_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", "sample"]);
write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id);
write_plugin_app(&curated_root, "granola", "granola", granola_app_id);
write_file(
&curated_root.join("plugins/sample/.app.json"),
"invalid json",
);
let plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let buffer: &'static std::sync::Mutex<Vec<u8>> =
Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_level(true)
.with_ansi(false)
.with_max_level(Level::WARN)
.with_span_events(FmtSpan::NONE)
.with_writer(MockWriter::new(buffer))
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[hubspot_app_id]),
/*auth*/ None,
)
.await;
assert_eq!(discoverable_plugins, Vec::new());
let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone())
.expect("utf8 logs")
.replace('\\', "/");
assert_eq!(logs.matches("plugins/sample/.app.json").count(), 0);
}
#[tokio::test]
async fn does_not_expand_local_sales_apps() {
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 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(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 plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &[]),
/*auth*/ None,
)
.await;
assert_eq!(discoverable_plugins, Vec::new());
}
#[tokio::test]
async fn expands_cached_remote_plugins_by_loaded_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "GLOBAL"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [
{
"id": "plugins~Plugin_remote_unlisted",
"name": "remote-unlisted",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "AVAILABLE",
"release": {
"display_name": "Remote Unlisted",
"description": "Remote Unlisted long",
"app_ids": ["remote-unlisted-app"],
"interface": {
"short_description": "Remote Unlisted short",
"long_description": null,
"developer_name": null,
"category": null,
"capabilities": [],
"website_url": null,
"privacy_policy_url": null,
"terms_of_service_url": null,
"brand_color": null,
"default_prompt": null,
"composer_icon_url": null,
"logo_url": null,
"screenshot_urls": []
},
"skills": [
{
"name": "remote-unlisted",
"description": "Use unlisted remote plugin",
"interface": null
}
]
}
}
],
"pagination": {
"next_page_token": null
}
})))
.expect(1)
.mount(&server)
.await;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let mut plugins = load_plugins_config(codex_home.path(), codex_home.path()).await;
plugins.chatgpt_base_url = format!("{}/backend-api", server.uri());
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
fetch_and_cache_global_remote_plugin_catalog(
codex_home.path(),
&RemotePluginServiceConfig {
chatgpt_base_url: plugins.chatgpt_base_url.clone(),
},
Some(&auth),
)
.await
.expect("remote plugin catalog cache should write");
for scope in ["GLOBAL", "WORKSPACE"] {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", scope))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [],
"pagination": {
"next_page_token": null
}
})))
.expect(1)
.mount(&server)
.await;
}
plugins_manager
.build_and_cache_remote_installed_plugin_marketplaces(
&plugins,
Some(&auth),
&[REMOTE_GLOBAL_MARKETPLACE_NAME],
/*on_effective_plugins_changed*/ None,
)
.await
.expect("remote installed plugin cache should write");
let discoverable_plugins = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[], &[], &["remote-unlisted-app"]),
Some(&auth),
)
.await;
assert_eq!(
discoverable_plugins,
vec![ToolSuggestDiscoverablePlugin {
id: "remote-unlisted@openai-curated-remote".to_string(),
remote_plugin_id: Some("plugins~Plugin_remote_unlisted".to_string()),
name: "Remote Unlisted".to_string(),
description: Some("Remote Unlisted short".to_string()),
has_skills: true,
mcp_server_names: Vec::new(),
app_connector_ids: vec!["remote-unlisted-app".to_string()],
}]
);
}
fn discovery_input(
plugins: PluginsConfigInput,
configured_plugin_ids: &[&str],
disabled_plugin_ids: &[&str],
loaded_plugin_app_connector_ids: &[&str],
) -> ToolSuggestPluginDiscoveryInput {
ToolSuggestPluginDiscoveryInput {
plugins,
configured_plugin_ids: string_set(configured_plugin_ids),
disabled_plugin_ids: string_set(disabled_plugin_ids),
loaded_plugin_app_connector_ids: string_set(loaded_plugin_app_connector_ids),
}
}
async fn list_discoverable_plugins(
plugins_manager: &PluginsManager,
input: ToolSuggestPluginDiscoveryInput,
auth: Option<&CodexAuth>,
) -> Vec<ToolSuggestDiscoverablePlugin> {
plugins_manager
.list_tool_suggest_discoverable_plugins(&input, auth)
.await
.expect("discoverable plugins should load")
}
fn string_set(values: &[&str]) -> HashSet<String> {
values.iter().map(ToString::to_string).collect()
}
async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, plugin_name: &str) {
write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA);
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}"
}}
}}
}}
"#
),
);
}
@@ -1,24 +1,14 @@
use crate::plugins::test_support::load_plugins_config;
use crate::plugins::test_support::write_curated_plugin;
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::PluginsManager;
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote::fetch_and_cache_global_remote_plugin_catalog;
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;
async fn list_discoverable_plugins(
config: &crate::config::Config,
@@ -58,101 +48,6 @@ async fn list_discoverable_plugins_with_manager_and_auth(
.await
}
#[tokio::test]
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_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
"openai-developers@openai-curated".to_string(),
"slack@openai-curated".to_string(),
]
);
}
#[tokio::test]
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_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_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(
&curated_root,
&["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_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
"outlook-calendar@openai-curated".to_string(),
"outlook-email@openai-curated".to_string(),
"sharepoint@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_includes_cached_remote_global_plugins() {
use codex_login::CodexAuth;
@@ -431,216 +326,6 @@ disabled_tools = [
);
}
#[tokio::test]
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 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_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_omits_openai_curated_when_remote_enabled() {
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 bundled_marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
let bundled_marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{bundled_marketplace_name}"));
write_file(
&bundled_marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{bundled_marketplace_name}",
"plugins": [
{{"name": "chrome", "source": {{"source": "local", "path": "./plugins/chrome"}}}}
]
}}
"#
),
);
write_curated_plugin(&bundled_marketplace_root, "chrome");
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
remote_plugin = true
[marketplaces.{bundled_marketplace_name}]
source_type = "git"
source = "/tmp/{bundled_marketplace_name}"
"#
),
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec!["chrome@openai-bundled".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}"));
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{marketplace_name}",
"plugins": [
{{"name": "{plugin_name}", "source": {{"source": "local", "path": "./plugins/{plugin_name}"}}}}
]
}}
"#
),
);
write_curated_plugin(&marketplace_root, plugin_name);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
[tool_suggest]
discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
"#
),
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, plugin_id.as_str());
}
#[tokio::test]
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, &["installed", "slack"]);
let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME;
let marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{marketplace_name}"));
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{marketplace_name}",
"plugins": [
{{"name": "sample", "source": {{"source": "local", "path": "./plugins/sample"}}}}
]
}}
"#
),
);
write_file(
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{marketplace_name}]
source_type = "git"
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_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() {
let codex_home = tempdir().expect("tempdir should succeed");
@@ -659,65 +344,6 @@ plugins = false
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 = curated_plugins_repo_path(codex_home.path());
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"),
r#"{
"name": "slack",
"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_discoverable_plugins(&config, &[]).await.unwrap();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
remote_plugin_id: None,
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 = 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_discoverable_plugins(&refreshed_config, &[])
.await
.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_disabled_tool_suggestions() {
let codex_home = tempdir().expect("tempdir should succeed");
@@ -741,61 +367,6 @@ 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_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");
@@ -829,98 +400,3 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
}]
);
}
#[tokio::test]
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", "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 ["gmail", "openai-developers"] {
write_file(
&curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")),
&format!(
r#"{{
"name": "{plugin_name}",
"description": "Plugin that includes skills, MCP servers, and app connectors",
"interface": {{
"defaultPrompt": "{too_long_prompt}"
}}
}}"#
),
);
}
let config = load_plugins_config(codex_home.path()).await;
let buffer: &'static std::sync::Mutex<Vec<u8>> =
Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_level(true)
.with_ansi(false)
.with_max_level(Level::WARN)
.with_span_events(FmtSpan::NONE)
.with_writer(MockWriter::new(buffer))
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let discoverable_plugins = list_discoverable_plugins(&config, &[]).await.unwrap();
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(), 8);
let normalized_logs = logs.replace('\\', "/");
assert_eq!(
normalized_logs
.matches("gmail/.codex-plugin/plugin.json")
.count(),
4
);
assert_eq!(
normalized_logs
.matches("openai-developers/.codex-plugin/plugin.json")
.count(),
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}"
}}
}}
}}
"#
),
);
}