diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 6e607bf0b4..add390fed7 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -229,19 +229,29 @@ impl AccountRequestProcessor { Arc::clone(thread_manager), config_manager.clone(), ); + let plugins_config = config.plugins_config_input(); let refresh_thread_manager = Arc::clone(thread_manager); let refresh_config_manager = config_manager.clone(); + let on_effective_plugins_changed: Arc< + dyn Fn(codex_core_plugins::EffectivePluginsChange) + Send + Sync, + > = Arc::new(move |_change| { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&refresh_thread_manager), + refresh_config_manager.clone(), + ); + }); + thread_manager + .plugins_manager() + .maybe_start_curated_repo_sync_for_config( + &plugins_config, + Some(Arc::clone(&on_effective_plugins_changed)), + ); thread_manager .plugins_manager() .maybe_start_remote_plugin_caches_refresh( - &config.plugins_config_input(), + &plugins_config, auth, - Some(Arc::new(move |_change| { - Self::spawn_effective_plugins_changed_task( - Arc::clone(&refresh_thread_manager), - refresh_config_manager.clone(), - ); - })), + Some(on_effective_plugins_changed), ); } Err(err) => { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index bdc05b051f..0285e16667 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -188,10 +188,15 @@ fn convert_configured_marketplace_plugin_to_plugin_summary( } } -fn remote_installed_plugin_visible_marketplaces(config: &Config) -> Vec<&'static str> { +fn remote_installed_plugin_visible_marketplaces( + config: &Config, + use_remote_global_catalog: bool, +) -> Vec<&'static str> { let mut marketplaces = Vec::new(); - if config.features.enabled(Feature::RemotePlugin) { + if use_remote_global_catalog { marketplaces.push(REMOTE_GLOBAL_MARKETPLACE_NAME); + } + if config.features.enabled(Feature::RemotePlugin) { marketplaces.push(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME); } marketplaces.push(REMOTE_WORKSPACE_MARKETPLACE_NAME); @@ -831,11 +836,14 @@ impl PluginRequestProcessor { { return Ok(empty_response()); } - plugins_manager.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode)); + 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 use_remote_global_catalog = config.features.enabled(Feature::RemotePlugin) + && auth_mode.is_some_and(DomainAuthMode::uses_codex_backend); let remote_installed_plugin_visible_marketplaces = - remote_installed_plugin_visible_marketplaces(&config); + remote_installed_plugin_visible_marketplaces(&config, use_remote_global_catalog); plugins_manager.maybe_start_remote_installed_plugin_bundle_sync( &plugins_input, auth.clone(), @@ -861,10 +869,7 @@ impl PluginRequestProcessor { ) .await, ); - filter_openai_curated_installed_conflicts( - &mut data, - config.features.enabled(Feature::RemotePlugin), - ); + filter_openai_curated_installed_conflicts(&mut data, use_remote_global_catalog); Ok(PluginInstalledResponse { marketplaces: data, diff --git a/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs b/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs new file mode 100644 index 0000000000..6143d0cd0a --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/curated_mcp_sync.rs @@ -0,0 +1,335 @@ +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use app_test_support::ChatGptAuthFixture; +use app_test_support::MockResponsesConfig; +use app_test_support::TestAppServer; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::McpServerToolCallParams; +use codex_app_server_protocol::McpServerToolCallResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_config::types::AuthCredentialsStoreMode; +use codex_features::Feature; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::task::JoinHandle; +use tokio::time::timeout; +use wiremock::MockServer; + +use super::mcp_tool::TEST_SERVER_NAME; +use super::mcp_tool::TEST_TOOL_NAME; +use super::mcp_tool::start_mcp_server; + +const API_CURATED_PLUGIN_NAME: &str = "api-plugin"; +const REFRESH_PROBE_SERVER_NAME: &str = "refresh-probe"; +const GITHUB_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git"; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +struct CuratedMcpSyncFixture { + mcp: TestAppServer, + sync_barrier: PathBuf, + mcp_server_handle: JoinHandle<()>, + _fixture_root: TempDir, + _responses_server: MockServer, +} + +impl CuratedMcpSyncFixture { + async fn set_up() -> Result { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let fixture_root = TempDir::new()?; + let codex_home = fixture_root.path().join("codex-home"); + let curated_repo = fixture_root.path().join("curated-repo"); + let git_wrapper_dir = fixture_root.path().join("bin"); + let git_config = fixture_root.path().join("gitconfig"); + let sync_barrier = fixture_root.path().join("allow-curated-sync"); + std::fs::create_dir_all(&codex_home)?; + std::fs::create_dir_all(&curated_repo)?; + std::fs::create_dir_all(&git_wrapper_dir)?; + + write_curated_marketplace(&curated_repo, "marketplace.json", "openai-curated", &[])?; + write_curated_marketplace( + &curated_repo, + "api_marketplace.json", + "openai-api-curated", + &[API_CURATED_PLUGIN_NAME], + )?; + write_plugin( + &curated_repo.join("plugins").join(API_CURATED_PLUGIN_NAME), + API_CURATED_PLUGIN_NAME, + TEST_SERVER_NAME, + &mcp_server_url, + )?; + + let real_git = + find_executable_on_path("git").context("find git for curated sync fixture")?; + run_git(&real_git, &curated_repo, &["init", "-b", "main"])?; + run_git( + &real_git, + &curated_repo, + &["config", "user.email", "codex-tests@openai.com"], + )?; + run_git( + &real_git, + &curated_repo, + &["config", "user.name", "Codex Tests"], + )?; + run_git(&real_git, &curated_repo, &["add", "."])?; + run_git( + &real_git, + &curated_repo, + &["commit", "-m", "test curated plugins"], + )?; + + let curated_repo_url = format!("file://{}", curated_repo.display()); + let rewrite_key = format!("url.{curated_repo_url}.insteadOf"); + run_git( + &real_git, + &curated_repo, + &[ + "config", + "--file", + git_config + .to_str() + .context("git config path should be UTF-8")?, + &rewrite_key, + GITHUB_PLUGINS_GIT_URL, + ], + )?; + + let git_wrapper = git_wrapper_dir.join("git"); + std::fs::write( + &git_wrapper, + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + while [ ! -f "$CURATED_SYNC_BARRIER" ]; do + sleep 0.01 + done +fi +exec "$REAL_GIT" "$@" +"#, + )?; + let mut wrapper_permissions = std::fs::metadata(&git_wrapper)?.permissions(); + wrapper_permissions.set_mode(0o755); + std::fs::set_permissions(&git_wrapper, wrapper_permissions)?; + + MockResponsesConfig::new(&responses_server.uri()) + .enable_feature(Feature::Plugins) + .with_root_config(&format!( + r#"chatgpt_base_url = "{}/backend-api/""#, + responses_server.uri() + )) + .with_extra_config(&format!( + r#"[plugins."{API_CURATED_PLUGIN_NAME}@openai-api-curated"] +enabled = true + +[mcp_servers.{REFRESH_PROBE_SERVER_NAME}] +url = "{mcp_server_url}/mcp""# + )) + .write(&codex_home)?; + write_chatgpt_auth( + &codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let inherited_path = std::env::var_os("PATH").context("PATH should be set")?; + let child_path = std::env::join_paths( + std::iter::once(git_wrapper_dir).chain(std::env::split_paths(&inherited_path)), + )?; + let child_path = child_path.to_string_lossy(); + let real_git = real_git.to_string_lossy(); + let git_config = git_config.to_string_lossy(); + let sync_barrier_env = sync_barrier.to_string_lossy(); + let mcp = TestAppServer::builder() + .with_codex_home(&codex_home) + .with_env_overrides(&[ + ("PATH", Some(child_path.as_ref())), + ("REAL_GIT", Some(real_git.as_ref())), + ("GIT_CONFIG_GLOBAL", Some(git_config.as_ref())), + ("CURATED_SYNC_BARRIER", Some(sync_barrier_env.as_ref())), + ]) + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + + Ok(Self { + mcp, + sync_barrier, + mcp_server_handle, + _fixture_root: fixture_root, + _responses_server: responses_server, + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn existing_thread_loads_api_curated_mcp_after_auth_switch_sync() -> Result<()> { + let mut fixture = CuratedMcpSyncFixture::set_up().await?; + let thread_id = fixture + .mcp + .start_thread(ThreadStartParams::default()) + .await? + .thread + .id; + // Establish the existing thread's MCP runtime while curated Git sync is still blocked. + wait_for_mcp_starting(&mut fixture.mcp, REFRESH_PROBE_SERVER_NAME).await?; + + let request_id = fixture + .mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let response: LoginAccountResponse = + timeout(DEFAULT_TIMEOUT, fixture.mcp.read_response(request_id)).await??; + assert_eq!(response, LoginAccountResponse::ApiKey {}); + timeout( + DEFAULT_TIMEOUT, + fixture + .mcp + .read_stream_until_notification_message("account/updated"), + ) + .await??; + + // The account-change refresh has completed without the API-curated bundle on disk. + wait_for_mcp_starting(&mut fixture.mcp, REFRESH_PROBE_SERVER_NAME).await?; + + // Let sync materialize the bundle; its completion callback must refresh this same thread. + std::fs::write(&fixture.sync_barrier, "continue")?; + wait_for_mcp_starting(&mut fixture.mcp, TEST_SERVER_NAME).await?; + + let response: McpServerToolCallResponse = fixture + .mcp + .request(|request_id| ClientRequest::McpServerToolCall { + request_id, + params: McpServerToolCallParams { + thread_id: thread_id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({"message": "available after sync"})), + meta: None, + }, + }) + .await?; + assert_eq!( + response.structured_content, + Some(json!({ + "echoed": "available after sync", + "threadId": thread_id, + })) + ); + + fixture.mcp_server_handle.abort(); + let _ = fixture.mcp_server_handle.await; + Ok(()) +} + +async fn wait_for_mcp_starting(mcp: &mut TestAppServer, server_name: &str) -> Result<()> { + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_matching_notification( + "mcpServer/startupStatus/updated starting", + |notification| { + notification.method == "mcpServer/startupStatus/updated" + && notification + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(serde_json::Value::as_str) + == Some(server_name) + && notification + .params + .as_ref() + .and_then(|params| params.get("status")) + .and_then(serde_json::Value::as_str) + == Some("starting") + }, + ), + ) + .await??; + Ok(()) +} + +fn write_curated_marketplace( + repo_root: &Path, + manifest_name: &str, + marketplace_name: &str, + plugin_names: &[&str], +) -> Result<()> { + let manifest_root = repo_root.join(".agents/plugins"); + std::fs::create_dir_all(&manifest_root)?; + let plugins = plugin_names + .iter() + .map(|plugin_name| { + json!({ + "name": plugin_name, + "source": { + "source": "local", + "path": format!("./plugins/{plugin_name}"), + }, + }) + }) + .collect::>(); + std::fs::write( + manifest_root.join(manifest_name), + serde_json::to_vec_pretty(&json!({ + "name": marketplace_name, + "plugins": plugins, + }))?, + )?; + Ok(()) +} + +fn write_plugin( + root: &Path, + plugin_name: &str, + server_name: &str, + mcp_server_url: &str, +) -> Result<()> { + std::fs::create_dir_all(root.join(".codex-plugin"))?; + std::fs::write( + root.join(".codex-plugin/plugin.json"), + serde_json::to_vec_pretty(&json!({"name": plugin_name}))?, + )?; + std::fs::write( + root.join(".mcp.json"), + serde_json::to_vec_pretty(&json!({ + "mcpServers": { + server_name: { + "type": "http", + "url": format!("{mcp_server_url}/mcp"), + }, + }, + }))?, + )?; + Ok(()) +} + +fn find_executable_on_path(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|directory| directory.join(name)) + .find(|candidate| candidate.is_file()) +} + +fn run_git(git: &Path, cwd: &Path, args: &[&str]) -> Result<()> { + let output = Command::new(git).current_dir(cwd).args(args).output()?; + ensure!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 814fccb827..eae0efab0d 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -72,8 +72,8 @@ use super::exec_server_test_support::read_exec_server_json; const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); const AUTO_COMPACT_LIMIT: i64 = 1024; const LARGE_OUTPUT_AUTO_COMPACT_LIMIT: i64 = 1_000_000; -const TEST_SERVER_NAME: &str = "tool_server"; -const TEST_TOOL_NAME: &str = "echo_tool"; +pub(super) const TEST_SERVER_NAME: &str = "tool_server"; +pub(super) const TEST_TOOL_NAME: &str = "echo_tool"; const LARGE_RESPONSE_MESSAGE: &str = "large"; const ELICITATION_TRIGGER_MESSAGE: &str = "confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; @@ -708,7 +708,7 @@ impl ServerHandler for ToolAppsMcpServer { } } -async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> { +pub(super) async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> { let listener = TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; let mcp_service = StreamableHttpService::new( diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 5f766bacce..767e4ca401 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -15,6 +15,8 @@ mod config_rpc; mod connection_handling_websocket; #[cfg(unix)] mod connection_handling_websocket_unix; +#[cfg(unix)] +mod curated_mcp_sync; mod current_time; mod dynamic_tools; mod environment_add; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 0859c69395..e601f265ad 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::HookTrustStatus; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallPolicySource; @@ -325,6 +326,106 @@ enabled = true Ok(()) } +#[tokio::test] +async fn plugin_installed_prefers_api_curated_conflicts_after_switching_to_api_auth() -> Result<()> +{ + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_openai_api_curated_marketplace(codex_home.path(), &["linear"])?; + write_installed_plugin(&codex_home, "openai-api-curated", "linear")?; + let config = format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +plugin_sharing = false + +[plugins."linear@openai-api-curated"] +enabled = true +"#, + server.uri() + ); + std::fs::write(codex_home.path().join("config.toml"), &config)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + mount_remote_installed_plugins( + &server, + "GLOBAL", + &remote_installed_plugin_body("", "1.2.3", /*enabled*/ true), + ) + .await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + mount_empty_user_installed_plugins(&server).await; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + + let request_id = app_server + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + let response: PluginInstalledResponse = + timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??; + assert_eq!( + response + .marketplaces + .iter() + .flat_map(|marketplace| &marketplace.plugins) + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["linear@openai-curated-remote"] + ); + + // Keep the ChatGPT remote snapshot cached while changing auth to exercise endpoint-level + // filtering even when the account-change cache refresh cannot run. + std::fs::write(codex_home.path().join("config.toml"), "invalid config")?; + let request_id = app_server + .send_login_account_api_key_request("sk-test-key") + .await?; + let response: LoginAccountResponse = + timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??; + assert_eq!(response, LoginAccountResponse::ApiKey {}); + timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_notification_message("account/updated"), + ) + .await??; + std::fs::write(codex_home.path().join("config.toml"), config)?; + + let request_id = app_server + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + let response: PluginInstalledResponse = + timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??; + + assert_eq!( + response + .marketplaces + .iter() + .flat_map(|marketplace| &marketplace.plugins) + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["linear@openai-api-curated"] + ); + assert_eq!(response.marketplace_load_errors, Vec::new()); + Ok(()) +} + #[tokio::test] async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> { let codex_home = TempDir::new()?; @@ -2942,6 +3043,70 @@ plugins = true Ok(()) } +#[tokio::test] +async fn plugin_list_includes_chatgpt_curated_marketplace_for_bedrock_with_chatgpt_auth() +-> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"model_provider = "amazon-bedrock" + +[model_providers.amazon-bedrock.aws] +region = "us-east-2" +profile = "default" + +[features] +plugins = true +remote_plugin = false +"#, + )?; + write_openai_curated_marketplace(codex_home.path(), &["chatgpt-plugin"])?; + write_openai_api_curated_marketplace(codex_home.path(), &["api-plugin"])?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized_with_timeout(DEFAULT_TIMEOUT) + .await?; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + force_refetch: false, + }) + .await?; + let response: PluginListResponse = + timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??; + + let chatgpt_curated_marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "openai-curated") + .expect("expected ChatGPT curated marketplace"); + assert_eq!(chatgpt_curated_marketplace.plugins.len(), 1); + assert_eq!( + chatgpt_curated_marketplace.plugins[0].id, + "chatgpt-plugin@openai-curated" + ); + assert!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "openai-api-curated") + ); + assert!(response.marketplace_load_errors.is_empty()); + Ok(()) +} + #[tokio::test] async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index cd39176b2e..b2be52d13d 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -34,6 +34,8 @@ use codex_rmcp_client::perform_oauth_login; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::format_env_display; +use crate::plugin_cmd::load_cli_auth_mode; + /// Subcommands: /// - `list` — list configured servers (with `--json`) /// - `get` — show a single server (with `--json`) @@ -445,6 +447,12 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr Ok(()) } +async fn load_mcp_manager(config: &Config) -> McpManager { + let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); + plugins_manager.set_auth_mode(load_cli_auth_mode(config).await); + McpManager::new(plugins_manager) +} + async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> { let overrides = config_overrides .parse_overrides() @@ -452,9 +460,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( - config.codex_home.to_path_buf(), - ))); + let mcp_manager = load_mcp_manager(&config).await; let mcp_servers = mcp_manager.configured_servers(&config).await; let LoginArgs { name, scopes } = login_args; @@ -507,9 +513,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( - config.codex_home.to_path_buf(), - ))); + let mcp_manager = load_mcp_manager(&config).await; let mcp_servers = mcp_manager.configured_servers(&config).await; let LogoutArgs { name } = logout_args; @@ -544,9 +548,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( - config.codex_home.to_path_buf(), - ))); + let mcp_manager = load_mcp_manager(&config).await; let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await; let auth = auth_manager.auth().await; @@ -810,9 +812,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; - let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( - config.codex_home.to_path_buf(), - ))); + let mcp_manager = load_mcp_manager(&config).await; let mcp_servers = mcp_manager.configured_servers(&config).await; let Some(server) = mcp_servers.get(&get_args.name) else { diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 6b762baaf0..7590153a6d 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -9,6 +9,7 @@ use anyhow::Result; use codex_config::types::McpServerTransportConfig; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::load_global_mcp_servers; +use codex_login::CODEX_API_KEY_ENV_VAR; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; @@ -57,6 +58,58 @@ fn list_shows_empty_state() -> Result<()> { Ok(()) } +#[test] +fn api_key_auth_exposes_api_curated_plugin_mcp_servers() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true +remote_plugin = false + +[plugins."api-docs@openai-api-curated"] +enabled = true +"#, + )?; + let plugin_root = codex_home + .path() + .join("plugins/cache/openai-api-curated/api-docs/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"api-docs","version":"local"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "api-docs": { + "type": "stdio", + "command": "api-docs-mcp" + } + } +}"#, + )?; + + let mut list_cmd = codex_command(codex_home.path())?; + list_cmd + .env(CODEX_API_KEY_ENV_VAR, "sk-test") + .args(["mcp", "list", "--json"]) + .assert() + .success() + .stdout(contains(r#""name": "api-docs""#)); + + let mut get_cmd = codex_command(codex_home.path())?; + get_cmd + .env(CODEX_API_KEY_ENV_VAR, "sk-test") + .args(["mcp", "get", "api-docs", "--json"]) + .assert() + .success() + .stdout(contains(r#""name": "api-docs""#)); + + Ok(()) +} + #[tokio::test] async fn list_discovers_local_oauth_server_through_environment_proxy() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index f47e4d6023..8cd57bb4c7 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -76,6 +76,14 @@ pub struct PluginHookLoadOutcome { pub hook_load_warnings: Vec, } +/// The built-in curated marketplace selection for the current runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TargetCuratedMarketplace { + OpenAi, + OpenAiWithRemote, + OpenAiApi, +} + enum PluginLoadScope<'a> { AllCapabilities { restriction_product: Option, @@ -192,9 +200,10 @@ pub async fn load_plugin_hooks_from_layer_stack( config_layer_stack: &ConfigLayerStack, extra_plugins: HashMap, store: &PluginStore, + target_curated_marketplace: TargetCuratedMarketplace, remote_global_catalog_active: bool, ) -> PluginHookLoadOutcome { - let plugins = load_plugins_from_layer_stack_with_scope( + let mut plugins = load_plugins_from_layer_stack_with_scope( config_layer_stack, extra_plugins, store, @@ -202,6 +211,9 @@ pub async fn load_plugin_hooks_from_layer_stack( PluginLoadScope::HooksOnly, ) .await; + plugins.retain(|plugin| { + plugin_is_eligible_for_target_marketplace(&plugin.config_name, target_curated_marketplace) + }); PluginHookLoadOutcome { hook_sources: plugins .iter() @@ -236,7 +248,7 @@ fn merge_configured_plugins_with_remote_installed( let Ok(plugin_id) = PluginId::parse(plugin_key) else { continue; }; - if !is_openai_curated_marketplace_name(&plugin_id.marketplace_name) + if plugin_id.marketplace_name != crate::OPENAI_CURATED_MARKETPLACE_NAME || store.active_plugin_version(&plugin_id).is_none() { continue; @@ -267,6 +279,28 @@ fn merge_configured_plugins_with_remote_installed( configured_plugins } +pub(crate) fn plugin_is_eligible_for_target_marketplace( + plugin_key: &str, + target_curated_marketplace: TargetCuratedMarketplace, +) -> bool { + let Ok(plugin_id) = PluginId::parse(plugin_key) else { + return true; + }; + match target_curated_marketplace { + TargetCuratedMarketplace::OpenAi => { + plugin_id.marketplace_name != crate::OPENAI_API_CURATED_MARKETPLACE_NAME + && plugin_id.marketplace_name != REMOTE_GLOBAL_MARKETPLACE_NAME + } + TargetCuratedMarketplace::OpenAiWithRemote => { + plugin_id.marketplace_name != crate::OPENAI_API_CURATED_MARKETPLACE_NAME + } + TargetCuratedMarketplace::OpenAiApi => { + plugin_id.marketplace_name != crate::OPENAI_CURATED_MARKETPLACE_NAME + && plugin_id.marketplace_name != REMOTE_GLOBAL_MARKETPLACE_NAME + } + } +} + fn installed_plugin_name_for_marketplace( plugin_key: &str, marketplace_name: &str, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 69f2490d20..98b0bc14dd 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -4,6 +4,7 @@ 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::TargetCuratedMarketplace; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; use crate::loader::load_plugin_apps_from_manifest; @@ -15,6 +16,7 @@ use crate::loader::load_plugins_from_layer_stack; use crate::loader::log_plugin_load_errors; use crate::loader::materialize_marketplace_plugin_source; use crate::loader::plugin_capability_summary_from_root; +use crate::loader::plugin_is_eligible_for_target_marketplace; use crate::loader::refresh_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_detailed; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall_detailed; @@ -484,6 +486,26 @@ impl PluginLoadCacheKey { } } +fn target_curated_marketplace( + auth_mode: Option, + model_provider_id: &str, +) -> TargetCuratedMarketplace { + match auth_mode { + Some(auth_mode) => { + if auth_mode.uses_codex_backend() { + TargetCuratedMarketplace::OpenAiWithRemote + } else { + TargetCuratedMarketplace::OpenAiApi + } + } + // Bedrock can use ambient AWS credentials without producing a stored auth mode. + None if model_provider_id == AMAZON_BEDROCK_PROVIDER_ID => { + TargetCuratedMarketplace::OpenAiApi + } + None => TargetCuratedMarketplace::OpenAi, + } +} + impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { Self::new_with_options(codex_home, Some(Product::Codex), /*auth_mode*/ None) @@ -562,6 +584,20 @@ impl PluginsManager { config.remote_plugin_enabled && self.auth_mode().is_some_and(AuthMode::uses_codex_backend) } + /// Starts the local curated marketplace sync when the remote catalog is unavailable. + pub fn maybe_start_curated_repo_sync_for_config( + self: &Arc, + config: &PluginsConfigInput, + on_effective_plugins_changed: Option, + ) { + if config.plugins_enabled && !self.remote_global_catalog_active(config) { + self.start_curated_repo_sync( + config.http_client_factory.clone(), + on_effective_plugins_changed, + ); + } + } + pub fn set_analytics_events_client(&self, analytics_events_client: AnalyticsEventsClient) { let mut stored_client = match self.analytics_events_client.write() { Ok(client_guard) => client_guard, @@ -633,7 +669,7 @@ impl PluginsManager { remote_global_catalog_active, ); if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) { - return self.resolve_loaded_plugins_for_auth(plugins); + return self.resolve_loaded_plugins_for_auth(plugins, &config.model_provider_id); } let Ok(_load_permit) = self.loaded_plugins_load_semaphore.acquire().await else { @@ -641,7 +677,7 @@ impl PluginsManager { return PluginLoadOutcome::default(); }; if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) { - return self.resolve_loaded_plugins_for_auth(plugins); + return self.resolve_loaded_plugins_for_auth(plugins, &config.model_provider_id); } let cache_generation = self.loaded_plugins_cache_generation(); let plugin_skill_snapshots = PluginSkillSnapshots::for_plugin_load(); @@ -662,11 +698,22 @@ impl PluginsManager { plugins.clone(), plugin_skill_snapshots, ); - self.resolve_loaded_plugins_for_auth(plugins) + self.resolve_loaded_plugins_for_auth(plugins, &config.model_provider_id) } - fn resolve_loaded_plugins_for_auth(&self, mut plugins: Vec) -> PluginLoadOutcome { + fn resolve_loaded_plugins_for_auth( + &self, + mut plugins: Vec, + model_provider_id: &str, + ) -> PluginLoadOutcome { let auth_mode = self.auth_mode(); + let target_curated_marketplace = target_curated_marketplace(auth_mode, model_provider_id); + plugins.retain(|plugin| { + plugin_is_eligible_for_target_marketplace( + &plugin.config_name, + target_curated_marketplace, + ) + }); for plugin in &mut plugins { let plugin_active = plugin.is_active(); apply_app_mcp_routing_policy( @@ -714,9 +761,13 @@ impl PluginsManager { fn clear_caches_after_marketplace_source_refresh( &self, installed_plugin_cache_refreshed: bool, + on_effective_plugins_changed: Option<&EffectivePluginsChangedCallback>, ) { if installed_plugin_cache_refreshed { self.clear_cache(); + if let Some(on_effective_plugins_changed) = on_effective_plugins_changed { + on_effective_plugins_changed(EffectivePluginsChange::default()); + } } else { self.tool_suggest_metadata_cache.clear(); } @@ -741,7 +792,7 @@ impl PluginsManager { Arc::clone(&self.skill_root_scan_slots), ) .await; - self.resolve_loaded_plugins_for_auth(plugins) + self.resolve_loaded_plugins_for_auth(plugins, &config.model_provider_id) } /// Resolve plugin hooks for a config layer stack without loading other plugin capabilities. @@ -753,10 +804,13 @@ impl PluginsManager { if !config.plugins_enabled { return PluginHookLoadOutcome::default(); } + let target_curated_marketplace = + target_curated_marketplace(self.auth_mode(), &config.model_provider_id); load_plugin_hooks_from_layer_stack( config_layer_stack, self.remote_installed_plugin_configs(), &self.store, + target_curated_marketplace, self.remote_global_catalog_active(config), ) .await @@ -2049,11 +2103,10 @@ impl PluginsManager { on_effective_plugins_changed: Option, ) { if config.plugins_enabled { - let use_remote_global_catalog = - config.remote_plugin_enabled && auth_manager.current_auth_uses_codex_backend(); - if !use_remote_global_catalog { - self.start_curated_repo_sync(config.http_client_factory.clone()); - } + self.maybe_start_curated_repo_sync_for_config( + config, + on_effective_plugins_changed.clone(), + ); let should_spawn_marketplace_auto_upgrade = { let mut state = match self.configured_marketplace_upgrade_state.write() { Ok(state) => state, @@ -2189,6 +2242,7 @@ impl PluginsManager { Ok(refresh_outcome) => { self.clear_caches_after_marketplace_source_refresh( refresh_outcome.cache_refreshed, + /*on_effective_plugins_changed*/ None, ); outcome .errors @@ -2517,10 +2571,27 @@ impl PluginsManager { } } - fn start_curated_repo_sync(self: &Arc, http_client_factory: HttpClientFactory) { + fn start_curated_repo_sync( + self: &Arc, + http_client_factory: HttpClientFactory, + on_effective_plugins_changed: Option, + ) { if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) { return; } + let on_effective_plugins_changed = + on_effective_plugins_changed.map(|on_effective_plugins_changed| { + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return on_effective_plugins_changed; + }; + let callback: EffectivePluginsChangedCallback = Arc::new(move |change| { + let on_effective_plugins_changed = Arc::clone(&on_effective_plugins_changed); + runtime.spawn(async move { + on_effective_plugins_changed(change); + }); + }); + callback + }); let manager = Arc::clone(self); let codex_home = self.codex_home.clone(); if let Err(err) = std::thread::Builder::new() @@ -2536,8 +2607,10 @@ impl PluginsManager { &configured_curated_plugin_ids, ) { Ok(cache_refreshed) => { - manager - .clear_caches_after_marketplace_source_refresh(cache_refreshed); + manager.clear_caches_after_marketplace_source_refresh( + cache_refreshed, + on_effective_plugins_changed.as_ref(), + ); } Err(err) => { manager.clear_cache(); @@ -2800,20 +2873,18 @@ impl PluginsManager { self.codex_home.as_path(), )); let curated_marketplace_path = if include_openai_curated { - if config.model_provider_id == AMAZON_BEDROCK_PROVIDER_ID - || 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) + match target_curated_marketplace(self.auth_mode(), &config.model_provider_id) { + TargetCuratedMarketplace::OpenAi | TargetCuratedMarketplace::OpenAiWithRemote => { + let curated_repo_root = curated_plugins_repo_path(self.codex_home.as_path()); + curated_repo_root.is_dir().then_some(curated_repo_root) + } + TargetCuratedMarketplace::OpenAiApi => { + 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 { None diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 30a938ccda..77cc8bdf49 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -138,6 +138,54 @@ fn plugins_manager_tracks_auth_mode() { assert_eq!(manager_with_auth.auth_mode(), Some(AuthMode::Chatgpt)); } +#[test] +fn curated_repo_sync_stays_deferred_for_remote_chatgpt_catalog() { + CURATED_REPO_SYNC_STARTED.store(false, std::sync::atomic::Ordering::SeqCst); + let tmp = TempDir::new().unwrap(); + let config = PluginsConfigInput::new( + unrestricted_config_layer_stack(), + "openai".to_string(), + /*plugins_enabled*/ true, + /*remote_plugin_enabled*/ true, + "https://chatgpt.com".to_string(), + test_http_client_factory(), + ); + let manager = Arc::new(PluginsManager::new_with_options( + tmp.path().to_path_buf(), + Some(Product::Codex), + Some(AuthMode::Chatgpt), + )); + + manager.maybe_start_curated_repo_sync_for_config( + &config, /*on_effective_plugins_changed*/ None, + ); + + assert!(!CURATED_REPO_SYNC_STARTED.load(std::sync::atomic::Ordering::SeqCst)); +} + +#[test] +fn marketplace_source_refresh_notifies_only_after_installed_cache_changes() { + let tmp = TempDir::new().unwrap(); + let manager = PluginsManager::new(tmp.path().to_path_buf()); + let callback_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_count_for_callback = Arc::clone(&callback_count); + let callback: EffectivePluginsChangedCallback = Arc::new(move |_change| { + callback_count_for_callback.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + }); + + manager.clear_caches_after_marketplace_source_refresh( + /*installed_plugin_cache_refreshed*/ false, + Some(&callback), + ); + assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 0); + + manager.clear_caches_after_marketplace_source_refresh( + /*installed_plugin_cache_refreshed*/ true, + Some(&callback), + ); + assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 1); +} + #[tokio::test] async fn marketplace_policy_projection_disables_installed_plugin_and_invalidates_cache() { let codex_home = TempDir::new().expect("create Codex home"); @@ -805,6 +853,16 @@ fn write_cached_plugin(codex_home: &Path, marketplace_name: &str, plugin_name: & ); } +async fn loaded_plugin_names(manager: &PluginsManager, config: &PluginsConfigInput) -> Vec { + manager + .plugins_for_config(config) + .await + .plugins() + .iter() + .map(|plugin| plugin.config_name.clone()) + .collect() +} + #[tokio::test] async fn load_plugins_loads_default_skills_and_mcp_servers() { let codex_home = TempDir::new().unwrap(); @@ -1286,27 +1344,30 @@ enabled = true [plugins."calendar@openai-curated"] enabled = true + +[plugins."linear@openai-api-curated"] +enabled = true "#, ); write_cached_plugin(codex_home.path(), "openai-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-curated", "calendar"); + write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); let config = load_config(codex_home.path(), codex_home.path()).await; - let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let manager = PluginsManager::new_with_options( + codex_home.path().to_path_buf(), + Some(Product::Codex), + Some(AuthMode::Chatgpt), + ); manager.write_remote_installed_plugins_cache(vec![ remote_installed_plugin("linear"), remote_installed_plugin("remote-only"), ]); - let outcome = manager.plugins_for_config(&config).await; assert_eq!( - outcome - .plugins() - .iter() - .map(|plugin| plugin.config_name.clone()) - .collect::>(), + loaded_plugin_names(&manager, &config).await, vec![ "calendar@openai-curated".to_string(), "linear@openai-curated".to_string(), @@ -1315,6 +1376,36 @@ enabled = true ); } +#[tokio::test] +async fn api_curated_plugin_does_not_suppress_remote_curated_conflict_for_chatgpt() { + let codex_home = TempDir::new().unwrap(); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = false + +[plugins."linear@openai-api-curated"] +enabled = true +"#, + ); + write_cached_plugin(codex_home.path(), "openai-api-curated", "linear"); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new_with_options( + codex_home.path().to_path_buf(), + Some(Product::Codex), + Some(AuthMode::Chatgpt), + ); + manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]); + + assert_eq!( + loaded_plugin_names(&manager, &config).await, + vec!["linear@openai-curated-remote".to_string()] + ); +} + #[tokio::test] async fn remote_global_catalog_ignores_local_curated_plugins() { let codex_home = TempDir::new().unwrap(); @@ -1350,15 +1441,9 @@ enabled = true remote_installed_plugin("remote-only"), ]); - let outcome = manager.plugins_for_config(&config).await; assert_eq!( - outcome - .plugins() - .iter() - .map(|plugin| plugin.config_name.clone()) - .collect::>(), + loaded_plugin_names(&manager, &config).await, vec![ - "linear@openai-api-curated".to_string(), "linear@openai-curated-remote".to_string(), "remote-only@openai-curated-remote".to_string(), ] @@ -1366,7 +1451,7 @@ enabled = true } #[tokio::test] -async fn remote_plugin_feature_keeps_local_curated_without_codex_backend() { +async fn non_chatgpt_auth_rejects_cached_remote_curated_plugins_after_auth_switch() { let codex_home = TempDir::new().unwrap(); write_file( &codex_home.path().join(CONFIG_TOML_FILE), @@ -1382,26 +1467,46 @@ 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-remote", "remote-only"); - let config = load_config(codex_home.path(), codex_home.path()).await; + let mut config = load_config(codex_home.path(), codex_home.path()).await; let manager = PluginsManager::new_with_options( codex_home.path().to_path_buf(), Some(Product::Codex), - Some(AuthMode::ApiKey), + Some(AuthMode::Chatgpt), + ); + manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("remote-only")]); + assert_eq!( + loaded_plugin_names(&manager, &config).await, + vec!["remote-only@openai-curated-remote".to_string()] ); - let outcome = manager.plugins_for_config(&config).await; - + manager.set_auth_mode(/*auth_mode*/ None); assert_eq!( - outcome - .plugins() - .iter() - .map(|plugin| plugin.config_name.clone()) - .collect::>(), - vec![ - "linear@openai-api-curated".to_string(), - "linear@openai-curated".to_string(), - ] + loaded_plugin_names(&manager, &config).await, + vec!["linear@openai-curated".to_string()] + ); + + for auth_mode in [AuthMode::ApiKey, AuthMode::BedrockApiKey] { + manager.set_auth_mode(Some(auth_mode)); + + assert_eq!( + loaded_plugin_names(&manager, &config).await, + vec!["linear@openai-api-curated".to_string()] + ); + } + + manager.set_auth_mode(/*auth_mode*/ None); + config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string(); + assert_eq!( + loaded_plugin_names(&manager, &config).await, + vec!["linear@openai-api-curated".to_string()] + ); + + manager.set_auth_mode(Some(AuthMode::Chatgpt)); + assert_eq!( + loaded_plugin_names(&manager, &config).await, + vec!["remote-only@openai-curated-remote".to_string()] ); } @@ -2715,7 +2820,11 @@ enabled = true .write_remote_plugin_id(&plugin_id, "plugins~Plugin_persisted") .expect("persist remote plugin id"); let config = load_config(codex_home.path(), codex_home.path()).await; - let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let manager = PluginsManager::new_with_options( + codex_home.path().to_path_buf(), + Some(Product::Codex), + Some(AuthMode::Chatgpt), + ); if let Some(installed_plugins) = installed_plugins { manager.write_remote_installed_plugins_cache(installed_plugins); } @@ -4527,6 +4636,42 @@ plugins = true } } +#[tokio::test] +async fn list_marketplaces_uses_chatgpt_curated_manifest_for_bedrock_with_chatgpt_auth() { + let tmp = tempfile::tempdir().unwrap(); + let curated_root = curated_plugins_repo_path(tmp.path()); + + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"model_provider = "amazon-bedrock" + +[features] +plugins = true +"#, + ); + write_openai_curated_marketplace(&curated_root, &["chatgpt-plugin"]); + write_openai_api_curated_marketplace(&curated_root, &["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::Chatgpt)); + let marketplaces = manager + .list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true) + .unwrap() + .marketplaces; + + assert!( + marketplaces + .iter() + .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + ); + assert!( + marketplaces + .iter() + .all(|marketplace| marketplace.name != OPENAI_API_CURATED_MARKETPLACE_NAME) + ); +} + #[tokio::test] async fn list_marketplaces_skips_missing_api_curated_manifest() { let tmp = tempfile::tempdir().unwrap(); @@ -6158,6 +6303,97 @@ async fn plugin_hooks_for_layer_stack_loads_configured_plugin_hooks() { assert_eq!(outcome.hook_load_warnings, Vec::::new()); } +#[tokio::test] +async fn plugin_hooks_for_layer_stack_follow_auth_mode_and_provider() { + let codex_home = TempDir::new().unwrap(); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"model_provider = "amazon-bedrock" + +[features] +plugins = true +remote_plugin = false + +[plugins."linear@openai-curated"] +enabled = true + +[plugins."linear@openai-api-curated"] +enabled = true +"#, + ); + for marketplace_name in [ + OPENAI_CURATED_MARKETPLACE_NAME, + OPENAI_API_CURATED_MARKETPLACE_NAME, + ] { + write_cached_plugin(codex_home.path(), marketplace_name, "linear"); + write_file( + &codex_home + .path() + .join("plugins/cache") + .join(marketplace_name) + .join("linear/local/hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ] + } +}"#, + ); + } + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new_with_options( + codex_home.path().to_path_buf(), + Some(Product::Codex), + Some(AuthMode::Chatgpt), + ); + + let chatgpt_hooks = manager + .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) + .await; + assert_eq!( + chatgpt_hooks + .hook_sources + .iter() + .map(|source| source.plugin_id.as_key()) + .collect::>(), + vec!["linear@openai-curated"] + ); + + assert!(manager.set_auth_mode(Some(AuthMode::ApiKey))); + let api_hooks = manager + .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) + .await; + assert_eq!( + api_hooks + .hook_sources + .iter() + .map(|source| source.plugin_id.as_key()) + .collect::>(), + vec!["linear@openai-api-curated"] + ); + + assert!(manager.set_auth_mode(/*auth_mode*/ None)); + let bedrock_hooks = manager + .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) + .await; + assert_eq!( + bedrock_hooks + .hook_sources + .iter() + .map(|source| source.plugin_id.as_key()) + .collect::>(), + vec!["linear@openai-api-curated"] + ); +} + #[test] fn remote_installed_plugins_cache_refresh_coalesces_materializations() { let tmp = TempDir::new().unwrap(); diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 73ac51d911..bfc9ff31c0 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -10,7 +10,10 @@ use codex_core_plugins::store::PluginStore; use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; +use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_plugin::PluginId; +use codex_protocol::auth::AuthMode; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -40,6 +43,7 @@ use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::wait_for_mcp_server; +use pretty_assertions::assert_eq; use tempfile::TempDir; use wiremock::MockServer; @@ -308,6 +312,7 @@ async fn persisted_remote_plugin_command_attribution_flows_through_turn_context( let mut builder = test_codex() .with_home(Arc::clone(&codex_home)) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_model("gpt-5.2"); let test_codex = builder.build_with_auto_env(&server).await?; let codex = Arc::clone(&test_codex.codex); @@ -435,6 +440,260 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn agent_turns_route_curated_plugin_skills_after_auth_switch() -> Result<()> { + const CHATGPT_CURATED_PLUGIN_SKILL: &str = "chatgpt-plugin:chatgpt-skill"; + const API_CURATED_PLUGIN_SKILL: &str = "api-plugin:api-skill"; + const CURATED_PLUGIN_SKILLS: &[&str] = + &[CHATGPT_CURATED_PLUGIN_SKILL, API_CURATED_PLUGIN_SKILL]; + + #[derive(Clone, Copy)] + enum TargetAuth { + Chatgpt, + ApiKey, + BedrockApiKey, + NoCodexAuth, + } + + #[derive(Clone, Copy)] + struct Fixture { + name: &'static str, + target_auth: TargetAuth, + target_model_provider_id: &'static str, + target_prompt: &'static str, + expected_target_loaded_plugin_skills: &'static [&'static str], + expected_target_skill_description: &'static str, + } + + const FIXTURES: &[Fixture] = &[ + Fixture { + name: "ChatGPT", + target_auth: TargetAuth::Chatgpt, + target_model_provider_id: OPENAI_PROVIDER_ID, + target_prompt: "chatgpt target turn", + expected_target_loaded_plugin_skills: &[CHATGPT_CURATED_PLUGIN_SKILL], + expected_target_skill_description: "chatgpt description", + }, + Fixture { + name: "API key", + target_auth: TargetAuth::ApiKey, + target_model_provider_id: OPENAI_PROVIDER_ID, + target_prompt: "api key target turn", + expected_target_loaded_plugin_skills: &[API_CURATED_PLUGIN_SKILL], + expected_target_skill_description: "api description before", + }, + Fixture { + name: "Bedrock API key", + target_auth: TargetAuth::BedrockApiKey, + target_model_provider_id: AMAZON_BEDROCK_PROVIDER_ID, + target_prompt: "bedrock key target turn", + expected_target_loaded_plugin_skills: &[API_CURATED_PLUGIN_SKILL], + expected_target_skill_description: "api description before", + }, + Fixture { + name: "ambient Bedrock", + target_auth: TargetAuth::NoCodexAuth, + target_model_provider_id: AMAZON_BEDROCK_PROVIDER_ID, + target_prompt: "ambient bedrock target turn", + expected_target_loaded_plugin_skills: &[API_CURATED_PLUGIN_SKILL], + expected_target_skill_description: "api description before", + }, + ]; + + async fn skills_for_agent_turn( + test_codex: &TestCodex, + response: &ResponseMock, + model_provider_id: &str, + prompt: &str, + expected_request_count: usize, + ) -> Result { + let mut config = test_codex.config.clone(); + config.model_provider_id = model_provider_id.to_string(); + let thread = test_codex + .thread_manager + .start_thread(codex_core::StartThreadOptions::new(config)) + .await? + .thread; + thread + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&thread, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let requests = response.requests(); + assert_eq!(requests.len(), expected_request_count); + Ok(requests + .last() + .expect("agent turn should send a request") + .message_input_text_groups("developer") + .into_iter() + .rev() + .find(|texts| texts.iter().any(|text| text.contains("## Skills"))) + .expect("agent turn should include a skills developer message") + .join("\n")) + } + + skip_if_no_network!(Ok(())); + let assert_loaded_plugin_skills = + |fixture_name: &str, phase: &str, skills: &str, expected: &[&str]| { + let loaded_plugin_skills = CURATED_PLUGIN_SKILLS + .iter() + .copied() + .filter(|plugin_skill| skills.contains(plugin_skill)) + .collect::>(); + assert_eq!( + loaded_plugin_skills.as_slice(), + expected, + "unexpected curated plugin skills for {fixture_name} during {phase}: {skills:?}" + ); + }; + + for fixture in FIXTURES { + let server = start_mock_server().await; + let response = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-initial"), + ev_completed("resp-initial"), + ]), + sse(vec![ + ev_response_created("resp-target"), + ev_completed("resp-target"), + ]), + ], + ) + .await; + + let codex_home = Arc::new(TempDir::new()?); + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true +remote_plugin = false + +[plugins."chatgpt-plugin@openai-curated"] +enabled = true + +[plugins."api-plugin@openai-api-curated"] +enabled = true +"#, + )?; + for (marketplace_name, plugin_name, skill_name, description) in [ + ( + "openai-curated", + "chatgpt-plugin", + "chatgpt-skill", + "chatgpt description", + ), + ( + "openai-api-curated", + "api-plugin", + "api-skill", + "api description before", + ), + ] { + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name) + .join("local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + format!(r#"{{"name":"{plugin_name}","description":"{plugin_name}"}}"#), + )?; + let skill_dir = plugin_root.join("skills").join(skill_name); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\ndescription: {description}\n---\n\n# body\n"), + )?; + } + + let mut builder = test_codex() + .with_home(Arc::clone(&codex_home)) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let test_codex = builder.build_with_auto_env(&server).await?; + let plugins_manager = test_codex.thread_manager.plugins_manager(); + let skills_service = test_codex.thread_manager.skills_service(); + + let initial_skills = skills_for_agent_turn( + &test_codex, + &response, + OPENAI_PROVIDER_ID, + "initial chatgpt turn", + /*expected_request_count*/ 1, + ) + .await?; + assert_loaded_plugin_skills( + fixture.name, + "initial ChatGPT turn", + &initial_skills, + &[CHATGPT_CURATED_PLUGIN_SKILL], + ); + assert!(initial_skills.contains("chatgpt description")); + + std::fs::write( + codex_home.path().join( + "plugins/cache/openai-api-curated/api-plugin/local/skills/api-skill/SKILL.md", + ), + "---\ndescription: api description after\n---\n\n# body\n", + )?; + + match fixture.target_auth { + TargetAuth::Chatgpt => {} + TargetAuth::ApiKey => { + plugins_manager.set_auth_mode(Some(AuthMode::ApiKey)); + } + TargetAuth::BedrockApiKey => { + plugins_manager.set_auth_mode(Some(AuthMode::BedrockApiKey)); + } + TargetAuth::NoCodexAuth => { + test_codex.thread_manager.auth_manager().logout().await?; + assert_eq!( + test_codex.thread_manager.auth_manager().get_api_auth_mode(), + None + ); + plugins_manager.set_auth_mode(/*auth_mode*/ None); + } + } + skills_service.clear_cache(); + let target_skills = skills_for_agent_turn( + &test_codex, + &response, + fixture.target_model_provider_id, + fixture.target_prompt, + /*expected_request_count*/ 2, + ) + .await?; + assert_loaded_plugin_skills( + fixture.name, + "target turn", + &target_skills, + fixture.expected_target_loaded_plugin_skills, + ); + assert!( + target_skills.contains(fixture.expected_target_skill_description), + "expected {:?} in current skills: {skills:?}", + fixture.expected_target_skill_description, + skills = target_skills + ); + assert!(!target_skills.contains("api description after")); + } + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> Result<()> { skip_if_no_network!(Ok(()));