mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
## 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`
734 lines
24 KiB
Rust
734 lines
24 KiB
Rust
use super::ToolSuggestDiscoverablePlugin;
|
|
use super::ToolSuggestPluginDiscoveryInput;
|
|
use crate::OPENAI_BUNDLED_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;
|
|
|
|
#[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"]);
|
|
|
|
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![
|
|
"openai-developers@openai-curated".to_string(),
|
|
"slack@openai-curated".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[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}"
|
|
}}
|
|
}}
|
|
}}
|
|
"#
|
|
),
|
|
);
|
|
}
|