mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Load API curated marketplace by auth (#28383)
## Summary - choose the local OpenAI curated marketplace manifest based on auth: Codex backend auth gets the existing marketplace, direct provider auth gets `api_marketplace.json` - include Bedrock API key auth in the direct-provider API marketplace path - safely skip the API marketplace when `api_marketplace.json` is absent ## Validation - `just fmt` - `git diff --check origin/main...HEAD` - CI should run the full validation ## Manual Testing ### - New api marketplace not available for API key sign 1. Safely not display anything from api marketplace <img width="1161" height="289" alt="Screenshot 2026-06-15 at 21 37 43" src="https://github.com/user-attachments/assets/a5f16642-8a20-4ac1-a0de-1274a4c7b5b2" /> ### - New api marketplace for API key sign in 1. Setup api_marketplace.json ``` { "name": "openai-curated", "interface": { "displayName": "Codex official" }, "plugins": [ { "name": "linear", "source": { "source": "local", "path": "./plugins/linear" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" }, "category": "Productivity" } ] } ``` 2. Log in with API key, observe that only the defined plugin from api_marketplace.json is available from "Codex Official" (outside of local testing marketplaces) <img width="1167" height="446" alt="Screenshot 2026-06-15 at 21 16 53" src="https://github.com/user-attachments/assets/7cf61477-d826-4ef6-bc05-0a23ac1c0259" /> also checked functionality on codex app ### - SiWC users Still uses 'default' marketplace.json and renders all plugins <img width="1171" height="502" alt="Screenshot 2026-06-15 at 21 40 25" src="https://github.com/user-attachments/assets/d212ea9b-0aa5-470b-8ea4-450efe65bb2b" /> also checked functionality on codex app ## Notes - `just test -p codex-core-plugins` was started locally before splitting branches, but I stopped relying on local tests per follow-up and left final validation to PR CI.
This commit is contained in:
committed by
GitHub
Unverified
parent
6e50b22e55
commit
02dce8eb8d
@@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::PluginListBackgroundTaskOptions;
|
||||
use codex_core_plugins::is_openai_curated_marketplace_name;
|
||||
use codex_core_plugins::remote::REMOTE_CREATED_BY_ME_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME;
|
||||
@@ -174,9 +175,9 @@ fn filter_openai_curated_installed_conflicts(
|
||||
) {
|
||||
let local_installed_plugin_names = marketplaces
|
||||
.iter()
|
||||
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
.map(|marketplace| installed_plugin_names(&marketplace.plugins))
|
||||
.unwrap_or_default();
|
||||
.filter(|marketplace| is_openai_curated_marketplace_name(&marketplace.name))
|
||||
.flat_map(|marketplace| installed_plugin_names(&marketplace.plugins))
|
||||
.collect::<HashSet<_>>();
|
||||
let remote_installed_plugin_names = marketplaces
|
||||
.iter()
|
||||
.find(|marketplace| marketplace.name == REMOTE_GLOBAL_MARKETPLACE_NAME)
|
||||
@@ -190,13 +191,12 @@ fn filter_openai_curated_installed_conflicts(
|
||||
return;
|
||||
}
|
||||
|
||||
let marketplace_to_filter = if prefer_remote_curated_conflicts {
|
||||
OPENAI_CURATED_MARKETPLACE_NAME
|
||||
} else {
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME
|
||||
};
|
||||
for marketplace in marketplaces.iter_mut() {
|
||||
if marketplace.name != marketplace_to_filter {
|
||||
if prefer_remote_curated_conflicts {
|
||||
if !is_openai_curated_marketplace_name(&marketplace.name) {
|
||||
continue;
|
||||
}
|
||||
} else if marketplace.name != REMOTE_GLOBAL_MARKETPLACE_NAME {
|
||||
continue;
|
||||
}
|
||||
marketplace
|
||||
@@ -551,6 +551,8 @@ impl PluginRequestProcessor {
|
||||
{
|
||||
return Ok(empty_response());
|
||||
}
|
||||
let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode);
|
||||
plugins_manager.set_auth_mode(auth_mode);
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let include_shared_with_me =
|
||||
marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe);
|
||||
@@ -559,10 +561,12 @@ impl PluginRequestProcessor {
|
||||
&& config.features.enabled(Feature::RemotePlugin);
|
||||
let include_global_remote =
|
||||
!explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin);
|
||||
let use_remote_global_catalog =
|
||||
include_global_remote && auth_mode.is_some_and(AuthMode::uses_codex_backend);
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
let refresh_global_remote_catalog_cache = include_global_remote
|
||||
let refresh_global_remote_catalog_cache = use_remote_global_catalog
|
||||
&& codex_core_plugins::remote::has_cached_global_remote_plugin_catalog(
|
||||
config.codex_home.as_path(),
|
||||
&remote_plugin_service_config,
|
||||
@@ -578,7 +582,7 @@ impl PluginRequestProcessor {
|
||||
.list_marketplaces_for_config(
|
||||
&config_for_marketplace_listing,
|
||||
&roots_for_marketplace_listing,
|
||||
/*include_openai_curated*/ true,
|
||||
/*include_openai_curated*/ !use_remote_global_catalog,
|
||||
)?;
|
||||
Ok::<
|
||||
(
|
||||
@@ -649,16 +653,14 @@ impl PluginRequestProcessor {
|
||||
data.push(remote_marketplace_to_info(remote_marketplace));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(RemotePluginCatalogError::UnsupportedAuthMode) => {}
|
||||
Err(err) if explicit_marketplace_kinds => {
|
||||
return Err(remote_plugin_catalog_error_to_jsonrpc(
|
||||
err,
|
||||
"list OpenAI Curated remote plugin catalog",
|
||||
));
|
||||
}
|
||||
Err(
|
||||
RemotePluginCatalogError::AuthRequired
|
||||
| RemotePluginCatalogError::UnsupportedAuthMode,
|
||||
) => {}
|
||||
Err(RemotePluginCatalogError::AuthRequired) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
@@ -669,7 +671,7 @@ impl PluginRequestProcessor {
|
||||
}
|
||||
|
||||
let mut remote_sources = Vec::new();
|
||||
if include_global_remote {
|
||||
if use_remote_global_catalog {
|
||||
remote_sources.push(RemoteMarketplaceSource::Global);
|
||||
}
|
||||
if include_created_by_me_remote {
|
||||
@@ -741,9 +743,10 @@ impl PluginRequestProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
let featured_plugin_ids = if data
|
||||
.iter()
|
||||
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
let featured_plugin_ids = if !plugins_input.remote_plugin_enabled
|
||||
&& data
|
||||
.iter()
|
||||
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
{
|
||||
match plugins_manager
|
||||
.featured_plugin_ids_for_config(&plugins_input, auth.as_ref())
|
||||
@@ -799,6 +802,7 @@ impl PluginRequestProcessor {
|
||||
{
|
||||
return Ok(empty_response());
|
||||
}
|
||||
plugins_manager.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode));
|
||||
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let remote_installed_plugin_visible_marketplaces =
|
||||
|
||||
@@ -21,6 +21,8 @@ use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_core::config::set_project_trust_level;
|
||||
use codex_login::AuthKeyringBackendKind;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use flate2::Compression;
|
||||
@@ -2126,6 +2128,98 @@ async fn plugin_list_propagates_explicit_openai_curated_remote_collection_errors
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_skips_explicit_openai_curated_remote_collection_for_api_auth() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_plugins_enabled_config_with_base_url(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
login_with_api_key(
|
||||
codex_home.path(),
|
||||
"sk-test-key",
|
||||
AuthCredentialsStoreMode::File,
|
||||
AuthKeyringBackendKind::default(),
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
assert!(response.marketplaces.is_empty());
|
||||
assert!(response.marketplace_load_errors.is_empty());
|
||||
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_includes_api_curated_marketplace_for_api_auth_when_remote_plugin_enabled()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_openai_api_curated_marketplace(codex_home.path(), &["api-plugin"])?;
|
||||
login_with_api_key(
|
||||
codex_home.path(),
|
||||
"sk-test-key",
|
||||
AuthCredentialsStoreMode::File,
|
||||
AuthKeyringBackendKind::default(),
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
marketplace_kinds: None,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
let api_curated_marketplace = response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.find(|marketplace| marketplace.name == "openai-api-curated")
|
||||
.expect("expected API curated marketplace");
|
||||
assert_eq!(
|
||||
api_curated_marketplace
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref()),
|
||||
Some("OpenAI Curated")
|
||||
);
|
||||
assert_eq!(api_curated_marketplace.plugins.len(), 1);
|
||||
assert_eq!(
|
||||
api_curated_marketplace.plugins[0].id,
|
||||
"api-plugin@openai-api-curated"
|
||||
);
|
||||
assert!(response.marketplace_load_errors.is_empty());
|
||||
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -3928,6 +4022,35 @@ remote_plugin = true
|
||||
fn write_openai_curated_marketplace(
|
||||
codex_home: &std::path::Path,
|
||||
plugin_names: &[&str],
|
||||
) -> std::io::Result<()> {
|
||||
write_curated_marketplace(
|
||||
codex_home,
|
||||
"marketplace.json",
|
||||
"openai-curated",
|
||||
/*display_name*/ None,
|
||||
plugin_names,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_openai_api_curated_marketplace(
|
||||
codex_home: &std::path::Path,
|
||||
plugin_names: &[&str],
|
||||
) -> std::io::Result<()> {
|
||||
write_curated_marketplace(
|
||||
codex_home,
|
||||
"api_marketplace.json",
|
||||
"openai-api-curated",
|
||||
Some("OpenAI Curated"),
|
||||
plugin_names,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_curated_marketplace(
|
||||
codex_home: &std::path::Path,
|
||||
manifest_name: &str,
|
||||
marketplace_name: &str,
|
||||
display_name: Option<&str>,
|
||||
plugin_names: &[&str],
|
||||
) -> std::io::Result<()> {
|
||||
let curated_root = codex_home.join(".tmp/plugins");
|
||||
std::fs::create_dir_all(curated_root.join(".git"))?;
|
||||
@@ -3947,11 +4070,21 @@ fn write_openai_curated_marketplace(
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n");
|
||||
let interface = display_name
|
||||
.map(|display_name| {
|
||||
format!(
|
||||
r#"
|
||||
"interface": {{
|
||||
"displayName": "{display_name}"
|
||||
}},"#
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
std::fs::write(
|
||||
curated_root.join(".agents/plugins/marketplace.json"),
|
||||
curated_root.join(".agents/plugins").join(manifest_name),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "openai-curated",
|
||||
"name": "{marketplace_name}",{interface}
|
||||
"plugins": [
|
||||
{plugins}
|
||||
]
|
||||
|
||||
@@ -26,6 +26,7 @@ use std::path::PathBuf;
|
||||
use crate::plugin_cmd::JsonMarketplaceSource;
|
||||
use crate::plugin_cmd::configured_marketplace_snapshot_issues;
|
||||
use crate::plugin_cmd::configured_marketplace_sources;
|
||||
use crate::plugin_cmd::load_cli_auth_mode;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(bin_name = "codex plugin marketplace")]
|
||||
@@ -208,6 +209,7 @@ async fn run_list(overrides: Vec<(String, toml::Value)>, args: ListMarketplaceAr
|
||||
.await
|
||||
.context("failed to load configuration")?;
|
||||
let manager = PluginsManager::new(config.codex_home.to_path_buf());
|
||||
manager.set_auth_mode(load_cli_auth_mode(&config).await);
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let marketplace_listing = manager
|
||||
.discover_marketplaces_for_config(&plugins_input, &[])
|
||||
|
||||
@@ -2,6 +2,7 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use clap::Parser;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core_plugins::ConfiguredMarketplace;
|
||||
@@ -17,6 +18,8 @@ use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginSource;
|
||||
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::auth::read_codex_api_key_from_env;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::validate_plugin_segment;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
@@ -550,6 +553,7 @@ async fn load_plugin_command_context(
|
||||
.context("failed to load configuration")?;
|
||||
let plugins_input = config.plugins_config_input();
|
||||
let manager = PluginsManager::new(codex_home.to_path_buf());
|
||||
manager.set_auth_mode(load_cli_auth_mode(&config).await);
|
||||
Ok(PluginCommandContext {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
plugins_input,
|
||||
@@ -557,6 +561,23 @@ async fn load_plugin_command_context(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option<AuthMode> {
|
||||
if let Some(api_key) = read_codex_api_key_from_env() {
|
||||
return Some(CodexAuth::from_api_key(&api_key).api_auth_mode());
|
||||
}
|
||||
|
||||
CodexAuth::from_auth_storage(
|
||||
&config.codex_home,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
Some(&config.chatgpt_base_url),
|
||||
config.auth_keyring_backend_kind(),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|auth| auth.api_auth_mode())
|
||||
}
|
||||
|
||||
struct PluginSelection {
|
||||
plugin_name: String,
|
||||
marketplace_name: String,
|
||||
|
||||
@@ -3,9 +3,12 @@ use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
use std::collections::HashSet;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::OPENAI_API_CURATED_MARKETPLACE_NAME;
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::PluginsConfigInput;
|
||||
use crate::PluginsManager;
|
||||
use crate::marketplace::MarketplacePluginInstallPolicy;
|
||||
@@ -74,11 +77,7 @@ impl PluginsManager {
|
||||
}
|
||||
|
||||
let marketplaces = self
|
||||
.list_marketplaces_for_config(
|
||||
&input.plugins,
|
||||
&[],
|
||||
/*include_openai_curated*/ !input.plugins.remote_plugin_enabled,
|
||||
)
|
||||
.list_marketplaces_for_config(&input.plugins, &[], /*include_openai_curated*/ true)
|
||||
.context("failed to list plugin marketplaces for tool suggestions")?
|
||||
.marketplaces;
|
||||
let remote_installed_marketplaces = if input.plugins.remote_plugin_enabled {
|
||||
@@ -95,8 +94,7 @@ impl PluginsManager {
|
||||
|
||||
for plugin in marketplace.plugins {
|
||||
let is_configured_plugin = input.configured_plugin_ids.contains(plugin.id.as_str());
|
||||
let is_fallback_plugin =
|
||||
TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str());
|
||||
let is_fallback_plugin = is_tool_suggest_fallback_plugin(&plugin.id);
|
||||
if plugin.installed
|
||||
|| plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|
||||
|| input.disabled_plugin_ids.contains(plugin.id.as_str())
|
||||
@@ -162,8 +160,7 @@ impl PluginsManager {
|
||||
|| input
|
||||
.configured_plugin_ids
|
||||
.contains(plugin.remote_plugin_id.as_str());
|
||||
let is_fallback_plugin =
|
||||
TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.config_id.as_str());
|
||||
let is_fallback_plugin = is_tool_suggest_fallback_plugin(&plugin.config_id);
|
||||
let matches_installed_app = plugin
|
||||
.app_ids
|
||||
.iter()
|
||||
@@ -203,6 +200,25 @@ impl PluginsManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tool_suggest_fallback_plugin(plugin_id: &str) -> bool {
|
||||
if TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Ok(plugin_id) = PluginId::parse(plugin_id) else {
|
||||
return false;
|
||||
};
|
||||
if plugin_id.marketplace_name != OPENAI_API_CURATED_MARKETPLACE_NAME {
|
||||
return false;
|
||||
}
|
||||
|
||||
let default_curated_plugin_id = format!(
|
||||
"{}@{}",
|
||||
plugin_id.plugin_name, OPENAI_CURATED_MARKETPLACE_NAME
|
||||
);
|
||||
TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&default_curated_plugin_id.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "discoverable_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -13,6 +13,7 @@ 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_api_curated_marketplace;
|
||||
use crate::test_support::write_openai_curated_marketplace;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_login::CodexAuth;
|
||||
@@ -59,6 +60,34 @@ async fn returns_fallback_plugins_without_installed_apps() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_api_curated_fallback_plugins_for_direct_provider_auth() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_api_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 auth = CodexAuth::from_api_key("test-api-key");
|
||||
let discoverable_plugins = list_discoverable_plugins(
|
||||
&plugins_manager,
|
||||
discovery_input(plugins, &[], &[], &[]),
|
||||
Some(&auth),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"openai-developers@openai-api-curated".to_string(),
|
||||
"slack@openai-api-curated".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_microsoft_fallback_plugins() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
@@ -92,7 +121,7 @@ async fn returns_microsoft_fallback_plugins() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn omits_openai_curated_when_remote_enabled() {
|
||||
async fn includes_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"]);
|
||||
@@ -142,7 +171,10 @@ source = "/tmp/{bundled_marketplace_name}"
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["chrome@openai-bundled".to_string()]
|
||||
vec![
|
||||
"chrome@openai-bundled".to_string(),
|
||||
"slack@openai-curated".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,14 @@ mod test_support;
|
||||
pub mod toggles;
|
||||
|
||||
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
pub const OPENAI_API_CURATED_MARKETPLACE_NAME: &str = "openai-api-curated";
|
||||
pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled";
|
||||
|
||||
pub fn is_openai_curated_marketplace_name(marketplace_name: &str) -> bool {
|
||||
marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME
|
||||
|| marketplace_name == OPENAI_API_CURATED_MARKETPLACE_NAME
|
||||
}
|
||||
|
||||
pub type LoadedPlugin = codex_plugin::LoadedPlugin<codex_config::McpServerConfig>;
|
||||
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<codex_config::McpServerConfig>;
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
|
||||
use crate::app_mcp_routing::apps_route_available;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
use crate::manifest::PluginManifestHooks;
|
||||
use crate::manifest::PluginManifestPaths;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::marketplace::MarketplacePluginSource;
|
||||
use crate::marketplace::list_marketplaces;
|
||||
use crate::marketplace::load_marketplace;
|
||||
use crate::marketplace::load_raw_marketplace_plugin_names;
|
||||
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use crate::remote::RemoteInstalledPlugin;
|
||||
use crate::store::PluginStore;
|
||||
@@ -198,17 +197,21 @@ fn merge_configured_plugins_with_remote_installed(
|
||||
store: &PluginStore,
|
||||
prefer_remote_curated_conflicts: bool,
|
||||
) -> HashMap<String, PluginConfig> {
|
||||
let local_curated_installed_plugin_keys = configured_plugins
|
||||
.keys()
|
||||
.filter_map(|plugin_key| {
|
||||
installed_plugin_name_for_marketplace(
|
||||
plugin_key,
|
||||
OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
store,
|
||||
)
|
||||
.map(|plugin_name| (plugin_name, plugin_key.clone()))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut local_curated_installed_plugin_keys = HashMap::<String, Vec<String>>::new();
|
||||
for plugin_key in configured_plugins.keys() {
|
||||
let Ok(plugin_id) = PluginId::parse(plugin_key) else {
|
||||
continue;
|
||||
};
|
||||
if !is_openai_curated_marketplace_name(&plugin_id.marketplace_name)
|
||||
|| store.active_plugin_version(&plugin_id).is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
local_curated_installed_plugin_keys
|
||||
.entry(plugin_id.plugin_name)
|
||||
.or_default()
|
||||
.push(plugin_key.clone());
|
||||
}
|
||||
|
||||
for (plugin_key, plugin_config) in extra_plugins {
|
||||
let remote_curated_plugin_name = installed_plugin_name_for_marketplace(
|
||||
@@ -216,13 +219,15 @@ fn merge_configured_plugins_with_remote_installed(
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME,
|
||||
store,
|
||||
);
|
||||
let local_curated_plugin_key = remote_curated_plugin_name
|
||||
let local_curated_plugin_keys = remote_curated_plugin_name
|
||||
.as_ref()
|
||||
.and_then(|plugin_name| local_curated_installed_plugin_keys.get(plugin_name));
|
||||
|
||||
if let Some(local_curated_plugin_key) = local_curated_plugin_key {
|
||||
if let Some(local_curated_plugin_keys) = local_curated_plugin_keys {
|
||||
if prefer_remote_curated_conflicts {
|
||||
configured_plugins.remove(local_curated_plugin_key);
|
||||
for local_curated_plugin_key in local_curated_plugin_keys {
|
||||
configured_plugins.remove(local_curated_plugin_key);
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
@@ -289,41 +294,53 @@ pub fn refresh_curated_plugin_cache(
|
||||
) -> Result<bool, String> {
|
||||
let cache_plugin_version = curated_plugin_cache_version(plugin_version);
|
||||
let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?;
|
||||
let curated_marketplace_path = AbsolutePathBuf::try_from(
|
||||
codex_home
|
||||
.join(".tmp/plugins")
|
||||
.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.map_err(|_| "local curated marketplace is not available".to_string())?;
|
||||
let marketplace_plugin_names = load_raw_marketplace_plugin_names(&curated_marketplace_path)
|
||||
.map_err(|err| {
|
||||
format!("failed to load curated marketplace plugin names for cache refresh: {err}")
|
||||
})?;
|
||||
let curated_marketplace = load_marketplace(&curated_marketplace_path)
|
||||
.map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?;
|
||||
|
||||
let curated_marketplace_paths = curated_marketplace_paths_for_cache_refresh(codex_home)?;
|
||||
let mut loaded_marketplace_names = HashSet::<String>::new();
|
||||
let mut marketplace_plugin_keys = HashSet::<String>::new();
|
||||
let mut plugin_sources = HashMap::<String, AbsolutePathBuf>::new();
|
||||
for plugin in curated_marketplace.plugins {
|
||||
let plugin_name = plugin.name;
|
||||
if plugin_sources.contains_key(&plugin_name) {
|
||||
warn!(
|
||||
plugin = plugin_name,
|
||||
marketplace = OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
"ignoring duplicate curated plugin entry during cache refresh"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let MarketplacePluginSource::Local { path } = plugin.source {
|
||||
plugin_sources.insert(plugin_name, path);
|
||||
|
||||
for curated_marketplace_path in curated_marketplace_paths {
|
||||
let curated_marketplace = load_marketplace(&curated_marketplace_path).map_err(|err| {
|
||||
format!("failed to load curated marketplace for cache refresh: {err}")
|
||||
})?;
|
||||
let marketplace_name = curated_marketplace.name;
|
||||
loaded_marketplace_names.insert(marketplace_name.clone());
|
||||
|
||||
for plugin in curated_marketplace.plugins {
|
||||
let plugin_id =
|
||||
PluginId::new(plugin.name.clone(), marketplace_name.clone()).map_err(|err| {
|
||||
match err {
|
||||
PluginIdError::Invalid(message) => {
|
||||
format!("failed to prepare curated plugin cache refresh: {message}")
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let plugin_key = plugin_id.as_key();
|
||||
marketplace_plugin_keys.insert(plugin_key.clone());
|
||||
if plugin_sources.contains_key(&plugin_key) {
|
||||
warn!(
|
||||
plugin = %plugin.name,
|
||||
marketplace = %marketplace_name,
|
||||
"ignoring duplicate curated plugin entry during cache refresh"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let MarketplacePluginSource::Local { path } = plugin.source {
|
||||
plugin_sources.insert(plugin_key, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut cache_refreshed = false;
|
||||
for plugin_id in configured_curated_plugin_ids {
|
||||
if !marketplace_plugin_names.contains(&plugin_id.plugin_name) {
|
||||
let plugin_key = plugin_id.as_key();
|
||||
if !marketplace_plugin_keys.contains(&plugin_key) {
|
||||
if !loaded_marketplace_names.contains(&plugin_id.marketplace_name) {
|
||||
continue;
|
||||
}
|
||||
warn!(
|
||||
plugin = plugin_id.plugin_name,
|
||||
marketplace = OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
plugin = %plugin_id.plugin_name,
|
||||
marketplace = %plugin_id.marketplace_name,
|
||||
"configured curated plugin no longer exists in curated marketplace during cache refresh"
|
||||
);
|
||||
if store.plugin_base_root(plugin_id).as_path().exists() {
|
||||
@@ -338,7 +355,7 @@ pub fn refresh_curated_plugin_cache(
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else {
|
||||
let Some(source_path) = plugin_sources.get(&plugin_key).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -361,6 +378,30 @@ pub fn refresh_curated_plugin_cache(
|
||||
Ok(cache_refreshed)
|
||||
}
|
||||
|
||||
fn curated_marketplace_paths_for_cache_refresh(
|
||||
codex_home: &Path,
|
||||
) -> Result<Vec<AbsolutePathBuf>, String> {
|
||||
let curated_marketplace_path = AbsolutePathBuf::try_from(
|
||||
codex_home
|
||||
.join(".tmp/plugins")
|
||||
.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.map_err(|_| "local curated marketplace is not available".to_string())?;
|
||||
let mut paths = vec![curated_marketplace_path];
|
||||
|
||||
let api_marketplace_path = codex_home
|
||||
.join(".tmp/plugins")
|
||||
.join(".agents/plugins/api_marketplace.json");
|
||||
if api_marketplace_path.is_file() {
|
||||
paths.push(
|
||||
AbsolutePathBuf::try_from(api_marketplace_path)
|
||||
.map_err(|_| "local API curated marketplace is not available".to_string())?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
pub fn curated_plugin_cache_version(plugin_version: &str) -> String {
|
||||
if is_full_git_sha(plugin_version) {
|
||||
plugin_version[..CURATED_PLUGIN_CACHE_VERSION_SHA_PREFIX_LEN].to_string()
|
||||
@@ -416,7 +457,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
||||
let mut plugin_sources = HashMap::<String, MarketplacePluginSource>::new();
|
||||
|
||||
for marketplace in marketplace_outcome.marketplaces {
|
||||
if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
if is_openai_curated_marketplace_name(&marketplace.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -570,7 +611,7 @@ fn curated_plugin_ids_from_config_keys(
|
||||
"ignoring invalid configured plugin key during curated sync setup",
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|plugin_id| plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
.filter(|plugin_id| is_openai_curated_marketplace_name(&plugin_id.marketplace_name))
|
||||
.collect::<Vec<_>>();
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
|
||||
configured_curated_plugin_ids
|
||||
@@ -584,7 +625,7 @@ fn non_curated_plugin_ids_from_config_keys(
|
||||
"ignoring invalid plugin key during non-curated cache refresh setup",
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|plugin_id| plugin_id.marketplace_name != OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
.filter(|plugin_id| !is_openai_curated_marketplace_name(&plugin_id.marketplace_name))
|
||||
.collect::<Vec<_>>();
|
||||
configured_non_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
|
||||
configured_non_curated_plugin_ids
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::PluginLoadOutcome;
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::app_mcp_routing::apply_app_mcp_routing_policy;
|
||||
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
use crate::loader::PluginHookLoadOutcome;
|
||||
use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
use crate::loader::curated_plugin_cache_version;
|
||||
@@ -42,6 +42,7 @@ use crate::remote::RemotePluginCatalogError;
|
||||
use crate::remote::RemotePluginServiceConfig;
|
||||
use crate::remote_legacy::RemotePluginFetchError;
|
||||
use crate::remote_legacy::RemotePluginMutationError;
|
||||
use crate::startup_sync::curated_plugins_api_marketplace_path;
|
||||
use crate::startup_sync::curated_plugins_repo_path;
|
||||
use crate::startup_sync::read_curated_plugins_sha;
|
||||
use crate::startup_sync::sync_openai_plugins_repo;
|
||||
@@ -933,7 +934,7 @@ impl PluginsManager {
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let auth_policy = resolved.policy.authentication;
|
||||
let plugin_version =
|
||||
if resolved.plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
if is_openai_curated_marketplace_name(&resolved.plugin_id.marketplace_name) {
|
||||
let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path())
|
||||
.ok_or_else(|| {
|
||||
PluginStoreError::Invalid(
|
||||
@@ -1053,11 +1054,8 @@ impl PluginsManager {
|
||||
}
|
||||
|
||||
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
|
||||
let mut marketplace_roots = self.marketplace_roots(config, additional_roots);
|
||||
if !include_openai_curated {
|
||||
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
|
||||
marketplace_roots.retain(|root| root.as_path() != curated_repo_root.as_path());
|
||||
}
|
||||
let marketplace_roots =
|
||||
self.marketplace_roots(config, additional_roots, include_openai_curated);
|
||||
let marketplace_outcome = list_marketplaces(&marketplace_roots)?;
|
||||
let mut seen_plugin_keys = HashSet::new();
|
||||
let marketplaces = marketplace_outcome
|
||||
@@ -1145,7 +1143,11 @@ impl PluginsManager {
|
||||
return Ok(MarketplaceListOutcome::default());
|
||||
}
|
||||
|
||||
list_marketplaces(&self.marketplace_roots(config, additional_roots))
|
||||
list_marketplaces(&self.marketplace_roots(
|
||||
config,
|
||||
additional_roots,
|
||||
/*include_openai_curated*/ true,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn read_plugin_for_config(
|
||||
@@ -1879,6 +1881,7 @@ impl PluginsManager {
|
||||
&self,
|
||||
config: &PluginsConfigInput,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
include_openai_curated: bool,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
// Treat the curated catalog as an extra marketplace root so plugin listing can surface it
|
||||
// without requiring every caller to know where it is stored.
|
||||
@@ -1887,11 +1890,28 @@ impl PluginsManager {
|
||||
&config.config_layer_stack,
|
||||
self.codex_home.as_path(),
|
||||
));
|
||||
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
|
||||
if curated_repo_root.is_dir()
|
||||
&& let Ok(curated_repo_root) = AbsolutePathBuf::try_from(curated_repo_root)
|
||||
let curated_marketplace_path = if include_openai_curated {
|
||||
if matches!(
|
||||
self.auth_mode(),
|
||||
Some(AuthMode::ApiKey | AuthMode::BedrockApiKey)
|
||||
) {
|
||||
let api_marketplace_path =
|
||||
curated_plugins_api_marketplace_path(self.codex_home.as_path());
|
||||
api_marketplace_path
|
||||
.is_file()
|
||||
.then_some(api_marketplace_path)
|
||||
} else {
|
||||
let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path());
|
||||
curated_repo_root.is_dir().then_some(curated_repo_root)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(curated_marketplace_path) = curated_marketplace_path
|
||||
&& let Ok(curated_marketplace_path) =
|
||||
AbsolutePathBuf::try_from(curated_marketplace_path)
|
||||
{
|
||||
roots.push(curated_repo_root);
|
||||
roots.push(curated_marketplace_path);
|
||||
}
|
||||
roots.sort_unstable();
|
||||
roots.dedup();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use crate::LoadedPlugin;
|
||||
use crate::OPENAI_API_CURATED_MARKETPLACE_NAME;
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::PluginLoadOutcome;
|
||||
use crate::installed_marketplaces::marketplace_install_root;
|
||||
use crate::loader::load_plugins_from_layer_stack;
|
||||
@@ -16,6 +18,7 @@ use crate::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::test_support::load_plugins_config as load_plugins_config_input;
|
||||
use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::test_support::write_file;
|
||||
use crate::test_support::write_openai_api_curated_marketplace;
|
||||
use crate::test_support::write_openai_curated_marketplace;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
@@ -778,11 +781,15 @@ remote_plugin = true
|
||||
[plugins."linear@openai-curated"]
|
||||
enabled = true
|
||||
|
||||
[plugins."linear@openai-api-curated"]
|
||||
enabled = true
|
||||
|
||||
[plugins."calendar@openai-curated"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
write_cached_plugin(codex_home.path(), "openai-curated", "linear");
|
||||
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
|
||||
write_cached_plugin(codex_home.path(), "openai-curated", "calendar");
|
||||
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
|
||||
write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only");
|
||||
@@ -3037,6 +3044,130 @@ plugins = true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_uses_api_curated_manifest_when_selected() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
write_file(
|
||||
&curated_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "openai-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "siwc-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/siwc-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&curated_root.join(".agents/plugins/api_marketplace.json"),
|
||||
r#"{
|
||||
"name": "openai-api-curated",
|
||||
"interface": {
|
||||
"displayName": "OpenAI Curated"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "api-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/api-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
manager.set_auth_mode(Some(AuthMode::ApiKey));
|
||||
let marketplaces = manager
|
||||
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
let curated_marketplace = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME)
|
||||
.expect("API curated marketplace should be listed");
|
||||
|
||||
assert_eq!(
|
||||
curated_marketplace,
|
||||
ConfiguredMarketplace {
|
||||
name: "openai-api-curated".to_string(),
|
||||
path: AbsolutePathBuf::try_from(
|
||||
curated_root.join(".agents/plugins/api_marketplace.json")
|
||||
)
|
||||
.unwrap(),
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("OpenAI Curated".to_string()),
|
||||
}),
|
||||
plugins: vec![ConfiguredMarketplacePlugin {
|
||||
id: "api-plugin@openai-api-curated".to_string(),
|
||||
name: "api-plugin".to_string(),
|
||||
local_version: None,
|
||||
installed_version: None,
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(curated_root.join("plugins/api-plugin"))
|
||||
.unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
keywords: Vec::new(),
|
||||
installed: false,
|
||||
enabled: false,
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_skips_missing_api_curated_manifest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
"#,
|
||||
);
|
||||
write_file(
|
||||
&curated_root.join(".agents/plugins/marketplace.json"),
|
||||
"{not valid json",
|
||||
);
|
||||
|
||||
let config = load_config(tmp.path(), tmp.path()).await;
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
manager.set_auth_mode(Some(AuthMode::BedrockApiKey));
|
||||
let outcome = manager
|
||||
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.errors, Vec::new());
|
||||
assert_eq!(
|
||||
outcome
|
||||
.marketplaces
|
||||
.iter()
|
||||
.any(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_marketplaces_includes_installed_marketplace_roots() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -3591,6 +3722,56 @@ fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_curren
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_curated_plugin_cache_reinstalls_missing_api_curated_plugin() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &[]);
|
||||
write_openai_api_curated_marketplace(&curated_root, &["api-only"]);
|
||||
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
||||
let plugin_id = PluginId::new(
|
||||
"api-only".to_string(),
|
||||
OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
||||
.expect("cache refresh should recreate missing configured API curated plugin")
|
||||
);
|
||||
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join(format!(
|
||||
"plugins/cache/openai-api-curated/api-only/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
|
||||
))
|
||||
.is_dir()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_curated_plugin_cache_leaves_api_curated_plugin_when_api_manifest_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &[]);
|
||||
write_cached_plugin(tmp.path(), OPENAI_API_CURATED_MARKETPLACE_NAME, "api-only");
|
||||
let plugin_id = PluginId::new(
|
||||
"api-only".to_string(),
|
||||
OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
||||
.expect("cache refresh should skip missing API curated manifest")
|
||||
);
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join("plugins/cache/openai-api-curated/api-only/local")
|
||||
.is_dir()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_curated_plugin_cache_removes_cache_for_plugin_removed_from_marketplace() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -3629,6 +3810,9 @@ plugins = true
|
||||
[plugins."slack@openai-curated"]
|
||||
enabled = true
|
||||
|
||||
[plugins."api-only@openai-api-curated"]
|
||||
enabled = true
|
||||
|
||||
[plugins."sample@debug"]
|
||||
enabled = true
|
||||
"#,
|
||||
@@ -3639,7 +3823,10 @@ enabled = true
|
||||
.into_iter()
|
||||
.map(|plugin_id| plugin_id.as_key())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["slack@openai-curated".to_string()]
|
||||
vec![
|
||||
"api-only@openai-api-curated".to_string(),
|
||||
"slack@openai-curated".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
write_file(
|
||||
|
||||
@@ -9,7 +9,6 @@ use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Component;
|
||||
@@ -19,6 +18,7 @@ use tracing::warn;
|
||||
|
||||
const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[
|
||||
".agents/plugins/marketplace.json",
|
||||
".agents/plugins/api_marketplace.json",
|
||||
".claude-plugin/marketplace.json",
|
||||
];
|
||||
|
||||
@@ -258,6 +258,19 @@ pub fn find_marketplace_manifest_path(root: &Path) -> Option<AbsolutePathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
fn supported_marketplace_manifest_path(path: &Path) -> Option<AbsolutePathBuf> {
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
if !MARKETPLACE_MANIFEST_RELATIVE_PATHS
|
||||
.iter()
|
||||
.any(|relative_path| marketplace_root_from_layout(path, relative_path).is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
AbsolutePathBuf::try_from(path.to_path_buf()).ok()
|
||||
}
|
||||
|
||||
fn invalid_marketplace_layout_error(path: &AbsolutePathBuf) -> MarketplaceError {
|
||||
MarketplaceError::InvalidMarketplaceFile {
|
||||
path: path.to_path_buf(),
|
||||
@@ -327,16 +340,6 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_raw_marketplace_plugin_names(
|
||||
path: &AbsolutePathBuf,
|
||||
) -> Result<HashSet<String>, MarketplaceError> {
|
||||
Ok(load_raw_marketplace_manifest(path)?
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| plugin.name)
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn list_marketplaces_with_home(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
@@ -377,6 +380,12 @@ fn discover_marketplace_paths_from_roots(
|
||||
}
|
||||
|
||||
for root in additional_roots {
|
||||
if let Some(path) = supported_marketplace_manifest_path(root.as_path())
|
||||
&& !paths.contains(&path)
|
||||
{
|
||||
paths.push(path);
|
||||
continue;
|
||||
}
|
||||
// Curated marketplaces can now come from an HTTP-downloaded directory that is not a git
|
||||
// checkout, so check the root directly before falling back to repo-root discovery.
|
||||
if let Some(path) = find_marketplace_manifest_path(root.as_path())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::installed_marketplaces::marketplace_install_root;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -121,9 +121,9 @@ where
|
||||
|
||||
if let MarketplaceSource::Local { path } = &source {
|
||||
let marketplace_name = validate_marketplace_source_root(path)?;
|
||||
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
if is_openai_curated_marketplace_name(&marketplace_name) {
|
||||
return Err(MarketplaceAddError::InvalidRequest(format!(
|
||||
"marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from this source"
|
||||
"marketplace '{marketplace_name}' is reserved and cannot be added from this source"
|
||||
)));
|
||||
}
|
||||
if find_marketplace_root_by_name(codex_home, &install_root, &marketplace_name)?.is_some() {
|
||||
@@ -165,9 +165,9 @@ where
|
||||
stage_marketplace_source(&source, &sparse_paths, &staged_root, clone_source)?;
|
||||
|
||||
let marketplace_name = validate_marketplace_source_root(&staged_root)?;
|
||||
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
if is_openai_curated_marketplace_name(&marketplace_name) {
|
||||
return Err(MarketplaceAddError::InvalidRequest(format!(
|
||||
"marketplace '{OPENAI_CURATED_MARKETPLACE_NAME}' is reserved and cannot be added from this source"
|
||||
"marketplace '{marketplace_name}' is reserved and cannot be added from this source"
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -522,6 +522,62 @@ fn list_marketplaces_prefers_first_supported_manifest_layout() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_supports_explicit_api_marketplace_manifest_path() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/api_marketplace.json")).unwrap();
|
||||
fs::write(
|
||||
marketplace_path.as_path(),
|
||||
r#"{
|
||||
"name": "openai-api-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "api-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/api-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
std::slice::from_ref(&marketplace_path),
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![Marketplace {
|
||||
name: "openai-api-curated".to_string(),
|
||||
path: marketplace_path,
|
||||
interface: None,
|
||||
plugins: vec![MarketplacePlugin {
|
||||
name: "api-plugin".to_string(),
|
||||
local_version: None,
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugins/api-plugin")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
keywords: Vec::new(),
|
||||
}],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -59,6 +59,10 @@ pub fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
|
||||
}
|
||||
|
||||
pub fn curated_plugins_api_marketplace_path(codex_home: &Path) -> PathBuf {
|
||||
curated_plugins_repo_path(codex_home).join(".agents/plugins/api_marketplace.json")
|
||||
}
|
||||
|
||||
pub fn read_curated_plugins_sha(codex_home: &Path) -> Option<String> {
|
||||
read_sha_file(curated_plugins_sha_path(codex_home).as_path())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::OPENAI_API_CURATED_MARKETPLACE_NAME;
|
||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use crate::PluginsConfigInput;
|
||||
use codex_config::LoaderOverrides;
|
||||
@@ -57,6 +58,32 @@ pub(crate) fn write_curated_plugin(root: &Path, plugin_name: &str) {
|
||||
}
|
||||
|
||||
pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
|
||||
write_curated_marketplace(
|
||||
root,
|
||||
"marketplace.json",
|
||||
OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
/*display_name*/ None,
|
||||
plugin_names,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn write_openai_api_curated_marketplace(root: &Path, plugin_names: &[&str]) {
|
||||
write_curated_marketplace(
|
||||
root,
|
||||
"api_marketplace.json",
|
||||
OPENAI_API_CURATED_MARKETPLACE_NAME,
|
||||
Some("OpenAI Curated"),
|
||||
plugin_names,
|
||||
);
|
||||
}
|
||||
|
||||
fn write_curated_marketplace(
|
||||
root: &Path,
|
||||
manifest_name: &str,
|
||||
marketplace_name: &str,
|
||||
display_name: Option<&str>,
|
||||
plugin_names: &[&str],
|
||||
) {
|
||||
let plugins = plugin_names
|
||||
.iter()
|
||||
.map(|plugin_name| {
|
||||
@@ -72,11 +99,21 @@ pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n");
|
||||
let interface = display_name
|
||||
.map(|display_name| {
|
||||
format!(
|
||||
r#"
|
||||
"interface": {{
|
||||
"displayName": "{display_name}"
|
||||
}},"#
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
write_file(
|
||||
&root.join(".agents/plugins/marketplace.json"),
|
||||
&root.join(".agents/plugins").join(manifest_name),
|
||||
&format!(
|
||||
r#"{{
|
||||
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
|
||||
"name": "{marketplace_name}",{interface}
|
||||
"plugins": [
|
||||
{plugins}
|
||||
]
|
||||
|
||||
@@ -37,7 +37,7 @@ use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::is_openai_curated_marketplace_name;
|
||||
use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME;
|
||||
@@ -1554,7 +1554,7 @@ impl ChatWidget {
|
||||
|
||||
let curated_marketplace = marketplaces
|
||||
.iter()
|
||||
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
.find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name))
|
||||
.copied();
|
||||
let curated_entries = curated_marketplace
|
||||
.map(|marketplace| plugin_entries_for_marketplaces([marketplace]))
|
||||
@@ -1582,7 +1582,7 @@ impl ChatWidget {
|
||||
let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|marketplace| marketplace.name != OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
.filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name))
|
||||
.collect();
|
||||
additional_marketplaces.sort_by(|left, right| {
|
||||
marketplace_display_name(left)
|
||||
|
||||
Reference in New Issue
Block a user