Skip to content
42 changes: 23 additions & 19 deletions codex-rs/app-server/src/request_processors/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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::<
(
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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 =
Expand Down
137 changes: 135 additions & 2 deletions codex-rs/app-server/tests/suite/v2/plugin_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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"))?;
Expand All @@ -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}
]
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/cli/src/marketplace_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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, &[])
Expand Down
21 changes: 21 additions & 0 deletions codex-rs/cli/src/plugin_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -550,13 +553,31 @@ 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,
manager,
})
}

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,
Expand Down
Loading
Loading