diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index b5adda30bb..7beed95ae0 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1575,7 +1575,8 @@ impl PluginRequestProcessor { .await; } - let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await; + let plugin_app_declarations = + load_plugin_apps(result.installed_path.as_path(), &result.plugin_id).await; let apps_needing_auth = self .plugin_apps_needing_auth_for_install( &config, @@ -1737,7 +1738,8 @@ impl PluginRequestProcessor { .await } } else { - let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await; + let plugin_app_declarations = + load_plugin_apps(result.installed_path.as_path(), &result.plugin_id).await; self.plugin_apps_needing_auth_for_install( &config, auth.as_ref(), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 6c30dc727d..87999efce1 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -2302,23 +2302,51 @@ async fn plugin_install_skips_remote_mcp_oauth_disabled_by_plugin_config() -> Re Ok(()) } +#[test_case(false; "legacy manifest")] +#[test_case(true; "agent plugin manifest")] #[tokio::test] -async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Result<()> { +async fn plugin_install_skips_remote_mcp_oauth_for_service_generated_app( + agent_plugin: bool, +) -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; let oauth_server = MockServer::start().await; + let plugin_manifest = if agent_plugin { + json!({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "linear", + "extensions": {"com.openai": {"apps": "./.app.json"}} + }) + } else { + json!({"name": "linear"}) + }; + let mcp_config = json!({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": {"sample-mcp": { + "type": if agent_plugin { "streamable-http" } else { "http" }, + "url": format!("{}/mcp", oauth_server.uri()) + }} + }); + let service_app_manifest = json!({"apps": {"sample-mcp": {"id": "alpha"}}}); let bundle_url = mount_remote_plugin_bundle( &server, /*status_code*/ 200, - remote_plugin_bundle_tar_gz_bytes_with_app_and_mcp_config( - "linear", - r#"{"apps":{"sample-mcp":{"id":"alpha"}}}"#, - &oauth_server.uri(), + remote_plugin_bundle_tar_gz_bytes_with_entries( + &plugin_manifest.to_string(), + Some(r#"{"apps":{"bundled":{"id":"bundled-app"}}}"#), + Some(&mcp_config.to_string()), )?, ) .await; configure_remote_plugin_with_apps_test(codex_home.path(), &server)?; - mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_remote_plugin_detail_with_app_manifest( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + Some(&bundle_url), + service_app_manifest.clone(), + ) + .await; mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await; @@ -2345,7 +2373,21 @@ async fn plugin_install_skips_remote_mcp_oauth_for_bundled_same_name_app() -> Re }], } ); - assert_eq!(oauth_discovery_request_count(&oauth_server).await, 0); + assert_eq!( + oauth_server + .received_requests() + .await + .expect("recorded MCP endpoint requests") + .len(), + 0 + ); + let installed_app_manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string( + codex_home + .path() + .join("plugins/cache/openai-curated-remote/linear/1.2.3/.app.json"), + )?)?; + assert_eq!(installed_app_manifest, service_app_manifest); Ok(()) } @@ -3403,29 +3445,6 @@ fn remote_plugin_bundle_tar_gz_bytes_with_mcp_config( ) } -fn remote_plugin_bundle_tar_gz_bytes_with_app_and_mcp_config( - plugin_name: &str, - app_manifest: &str, - mcp_base_url: &str, -) -> Result> { - let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); - let mcp_config = format!( - r#"{{ - "mcpServers": {{ - "sample-mcp": {{ - "type": "http", - "url": "{mcp_base_url}/mcp" - }} - }} -}}"# - ); - remote_plugin_bundle_tar_gz_bytes_with_entries( - &manifest, - Some(app_manifest), - Some(mcp_config.as_str()), - ) -} - fn remote_plugin_bundle_tar_gz_bytes_with_contents( plugin_manifest: &str, app_manifest: Option<&str>, @@ -3442,6 +3461,10 @@ fn remote_plugin_bundle_tar_gz_bytes_with_entries( app_manifest: Option<&str>, mcp_config: Option<&str>, ) -> Result> { + let agent_plugin = + serde_json::from_str::(plugin_manifest).is_ok_and(|manifest| { + manifest["$schema"] == "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" + }); let skill = "---\nname: plan-work\ndescription: Track work in Linear.\n---\n\n# Plan Work\n"; let encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut tar = tar::Builder::new(encoder); @@ -3457,11 +3480,23 @@ fn remote_plugin_bundle_tar_gz_bytes_with_entries( /*mode*/ 0o644, ), ]; + if agent_plugin { + entries.push(( + "plugin.json", + plugin_manifest.as_bytes(), + /*mode*/ 0o644, + )); + } if let Some(app_manifest) = app_manifest { entries.push((".app.json", app_manifest.as_bytes(), /*mode*/ 0o644)); } if let Some(mcp_config) = mcp_config { - entries.push((".mcp.json", mcp_config.as_bytes(), /*mode*/ 0o644)); + let path = if agent_plugin { + "mcp.json" + } else { + ".mcp.json" + }; + entries.push((path, mcp_config.as_bytes(), /*mode*/ 0o644)); } for (path, contents, mode) in entries { let mut header = tar::Header::new_gnu(); diff --git a/codex-rs/core-plugins/src/app_mcp_routing.rs b/codex-rs/core-plugins/src/app_mcp_routing.rs index 0034dddc4a..19faca327a 100644 --- a/codex-rs/core-plugins/src/app_mcp_routing.rs +++ b/codex-rs/core-plugins/src/app_mcp_routing.rs @@ -1,4 +1,7 @@ +use crate::manifest::PluginManifestFormat; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_plugin::AppDeclaration; +use codex_plugin::PluginId; use codex_protocol::auth::AuthMode; use std::collections::HashMap; use std::collections::HashSet; @@ -7,6 +10,13 @@ pub fn apps_route_available(auth_mode: Option) -> bool { auth_mode.is_some_and(AuthMode::uses_codex_backend) } +pub(crate) fn plugin_supports_apps(format: PluginManifestFormat, plugin_id: &PluginId) -> bool { + // The remote curated installer overwrites app declarations with plugin-service metadata. + // Keep portable app declarations inactive for other Agent Plugins. + format == PluginManifestFormat::Legacy + || plugin_id.marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME +} + pub(crate) fn apply_app_mcp_routing_policy( apps: &mut Vec, mcp_servers: &mut HashMap, diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index f43575d57e..83e691a99f 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -1,6 +1,7 @@ use crate::PluginGitMode; use crate::app_mcp_routing::apply_app_mcp_routing_policy; use crate::app_mcp_routing::apps_route_available; +use crate::app_mcp_routing::plugin_supports_apps; use crate::is_openai_curated_marketplace_name; use crate::manifest::ManifestCache; use crate::manifest::PluginManifest; @@ -962,7 +963,7 @@ async fn load_plugin( loaded_manifest.format, ) .await; - if loaded_manifest.format == PluginManifestFormat::Legacy { + if plugin_supports_apps(loaded_manifest.format, &loaded_plugin_id) { loaded_plugin.apps = load_plugin_apps_from_manifest(plugin_root.as_path(), manifest_paths).await; } @@ -1151,9 +1152,9 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec { paths } -pub async fn load_plugin_apps(plugin_root: &Path) -> Vec { +pub async fn load_plugin_apps(plugin_root: &Path, plugin_id: &PluginId) -> Vec { if let Some(loaded_manifest) = load_plugin_manifest_with_format(plugin_root) { - if loaded_manifest.format == PluginManifestFormat::AgentPlugin { + if !plugin_supports_apps(loaded_manifest.format, plugin_id) { return Vec::new(); } return load_plugin_apps_from_manifest(plugin_root, &loaded_manifest.manifest.paths).await; @@ -1397,10 +1398,10 @@ pub(crate) async fn plugin_capability_summary_from_root( mcp_server_names.sort_unstable(); mcp_server_names.dedup(); - let app_declarations = if manifest_format == PluginManifestFormat::AgentPlugin { - Vec::new() - } else { + let app_declarations = if plugin_supports_apps(manifest_format, plugin_id) { load_plugin_apps_from_manifest(plugin_root.as_path(), manifest_paths).await + } else { + Vec::new() }; let app_connector_ids = app_connector_ids_from_declarations(&app_declarations); @@ -1418,9 +1419,16 @@ pub(crate) async fn plugin_capability_summary_from_root( /// Loads plugin MCP servers without applying user-specific policy overrides. pub async fn load_plugin_mcp_servers( plugin_root: &Path, + plugin_id: &PluginId, auth_mode: Option, ) -> HashMap { - load_plugin_mcp_servers_with_policy(plugin_root, auth_mode, /*plugin_policy*/ None).await + load_plugin_mcp_servers_with_policy( + plugin_root, + plugin_id, + auth_mode, + /*plugin_policy*/ None, + ) + .await } /// Loads plugin MCP servers with the effective configuration policy for an installed plugin. @@ -1432,12 +1440,11 @@ pub async fn load_configured_plugin_mcp_servers( codex_home: &Path, ) -> HashMap { let configured_plugins = configured_plugins_from_stack(config_layer_stack, codex_home); - let plugin_id = plugin_id.as_key(); let plugin_policy = configured_plugins - .get(&plugin_id) + .get(&plugin_id.as_key()) .map(|plugin| &plugin.mcp_servers); - load_plugin_mcp_servers_with_policy(plugin_root, auth_mode, plugin_policy).await + load_plugin_mcp_servers_with_policy(plugin_root, plugin_id, auth_mode, plugin_policy).await } /// Applies user policy without widening the selected plugin's declared restrictions. @@ -1505,6 +1512,7 @@ pub fn apply_configured_plugin_mcp_server_policies( async fn load_plugin_mcp_servers_with_policy( plugin_root: &Path, + plugin_id: &PluginId, auth_mode: Option, plugin_policy: Option<&HashMap>, ) -> HashMap { @@ -1513,7 +1521,7 @@ async fn load_plugin_mcp_servers_with_policy( return mcp_servers; } - let mut app_declarations = load_plugin_apps(plugin_root).await; + let mut app_declarations = load_plugin_apps(plugin_root, plugin_id).await; apply_app_mcp_routing_policy( &mut app_declarations, &mut mcp_servers, diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index be59a1148a..cd96e72408 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -45,7 +45,11 @@ async fn agent_plugin_overlay_apps_are_not_runtime_active() { r#"{"apps":{"example":{"id":"connector_example"}}}"#, ); - assert!(load_plugin_apps(&plugin_root).await.is_empty()); + assert!( + load_plugin_apps(&plugin_root, &PluginId::parse("plugin@test").unwrap()) + .await + .is_empty() + ); } #[tokio::test] @@ -79,7 +83,12 @@ async fn agent_plugin_codex_mcp_overlay_only_forwards_matching_stdio_server_env_ }"#, ); - let mut expected = load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await; + let mut expected = load_plugin_mcp_servers( + &plugin_root, + &PluginId::parse("plugin@test").unwrap(), + /*auth_mode*/ None, + ) + .await; let Some(McpServerConfig { transport: McpServerTransportConfig::Stdio { env, env_vars, .. }, .. @@ -111,7 +120,12 @@ async fn agent_plugin_codex_mcp_overlay_only_forwards_matching_stdio_server_env_ ); assert_eq!( - load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await, + load_plugin_mcp_servers( + &plugin_root, + &PluginId::parse("plugin@test").unwrap(), + /*auth_mode*/ None + ) + .await, expected ); } @@ -137,7 +151,12 @@ async fn agent_plugin_codex_mcp_overlay_supports_inline_legacy_servers_without_p }"#, ); - let mut expected = load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await; + let mut expected = load_plugin_mcp_servers( + &plugin_root, + &PluginId::parse("plugin@test").unwrap(), + /*auth_mode*/ None, + ) + .await; let Some(McpServerConfig { transport: McpServerTransportConfig::Stdio { env_vars, .. }, .. @@ -158,7 +177,12 @@ async fn agent_plugin_codex_mcp_overlay_supports_inline_legacy_servers_without_p ); assert_eq!( - load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await, + load_plugin_mcp_servers( + &plugin_root, + &PluginId::parse("plugin@test").unwrap(), + /*auth_mode*/ None + ) + .await, expected ); } @@ -440,11 +464,17 @@ async fn legacy_ema_policy_disables_installed_and_selected_plugin_servers() { let policy = &config.plugins.plugins["plugin@market"].mcp_servers; let installed = load_plugin_mcp_servers_with_policy( &plugin_root, + &PluginId::parse("plugin@test").unwrap(), /*auth_mode*/ None, Some(policy), ) .await; - let mut selected = load_plugin_mcp_servers(&plugin_root, /*auth_mode*/ None).await; + let mut selected = load_plugin_mcp_servers( + &plugin_root, + &PluginId::parse("plugin@test").unwrap(), + /*auth_mode*/ None, + ) + .await; assert!(selected["example"].enabled); apply_configured_plugin_mcp_server_policies(policy, &mut selected); assert!(!installed["example"].enabled); diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index d4411b50da..9a1c0ee236 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -14,6 +14,7 @@ use super::LoadedPlugin; use super::PluginLoadOutcome; use crate::PluginGitMode; use crate::app_mcp_routing::apply_app_mcp_routing_policy; +use crate::app_mcp_routing::plugin_supports_apps; use crate::http_client_selector::HttpClientSelector; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; use crate::is_openai_curated_marketplace_name; @@ -2762,7 +2763,7 @@ impl PluginsManager { }) .collect(); let auth_mode = self.auth_mode(); - let mut app_declarations = if manifest_format == PluginManifestFormat::Legacy { + let mut app_declarations = if plugin_supports_apps(manifest_format, &plugin_id) { load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await } else { Vec::new() @@ -2777,14 +2778,12 @@ impl PluginsManager { manifest_format, ) .await; - if manifest_format == PluginManifestFormat::Legacy { - apply_app_mcp_routing_policy( - &mut app_declarations, - &mut mcp_servers, - auth_mode, - /*plugin_active*/ true, - ); - } + apply_app_mcp_routing_policy( + &mut app_declarations, + &mut mcp_servers, + auth_mode, + /*plugin_active*/ true, + ); let apps = app_connector_ids_from_declarations(&app_declarations); let mut seen_app_connector_ids = HashSet::new(); let mut app_category_by_id = HashMap::new(); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 55eca550cb..fd531feeb5 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -530,6 +530,73 @@ async fn plugin_auth_projection_hides_matching_mcp_with_chatgpt_apps_route() { assert!(docs.app_connector_ids.is_empty()); } +#[tokio::test] +async fn agent_plugin_app_routing_respects_marketplace_and_auth() { + for (marketplace, uses_apps, apps_enabled) in [ + (REMOTE_GLOBAL_MARKETPLACE_NAME, true, true), + (REMOTE_GLOBAL_MARKETPLACE_NAME, true, false), + ("test", false, true), + ("test", false, false), + ] { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join(marketplace) + .join("sample/local"); + write_file( + &plugin_root.join("plugin.json"), + r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sample","extensions":{"com.openai":{"apps":"./.app.json"}}}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{"apps":{"sample":{"id":"connector_sample"}}}"#, + ); + write_file( + &plugin_root.join("mcp.json"), + r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"sample":{"type":"stdio","command":"sample-mcp"},"unrelated":{"type":"stdio","command":"other-mcp"}}}"#, + ); + let outcome = load_plugins_from_config( + &format!( + "[features]\nplugins = true\napps = {apps_enabled}\n[plugins.\"sample@{marketplace}\"]\nenabled = true\n" + ), + codex_home.path(), + Some(AuthMode::Chatgpt), + ) + .await; + let expected_apps = if uses_apps { + vec![AppConnectorId("connector_sample".to_string())] + } else { + Vec::new() + }; + let expected_mcp = if uses_apps { + vec!["unrelated".to_string()] + } else { + vec!["sample".to_string(), "unrelated".to_string()] + }; + assert_eq!(outcome.effective_apps(), expected_apps); + assert_eq!(sorted_effective_mcp_server_names(&outcome), expected_mcp); + let [summary] = outcome.capability_summaries() else { + panic!("expected one installed plugin"); + }; + assert_eq!(summary.app_connector_ids, expected_apps); + assert_eq!(summary.mcp_server_names, expected_mcp); + + // Remote curated plugins are not selected in API-key mode, but the standalone + // loader must still preserve direct MCP when no app route is available. + let mut direct_servers = crate::loader::load_plugin_mcp_servers( + &plugin_root, + &PluginId::parse(&format!("sample@{marketplace}")).unwrap(), + Some(AuthMode::ApiKey), + ) + .await + .into_keys() + .collect::>(); + direct_servers.sort(); + assert_eq!(direct_servers, vec!["sample", "unrelated"]); + } +} + #[tokio::test] async fn plugin_auth_projection_hides_dual_surface_mcp_with_agent_identity_apps_route() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core-plugins/src/tool_suggest_metadata.rs b/codex-rs/core-plugins/src/tool_suggest_metadata.rs index 658e7219b5..a0c0a9ecb0 100644 --- a/codex-rs/core-plugins/src/tool_suggest_metadata.rs +++ b/codex-rs/core-plugins/src/tool_suggest_metadata.rs @@ -17,6 +17,7 @@ use codex_utils_plugins::PluginSkillRoot; use tokio::sync::Semaphore; use crate::app_mcp_routing::apply_app_mcp_routing_policy; +use crate::app_mcp_routing::plugin_supports_apps; use crate::loader::PluginSkillInventory; use crate::loader::load_plugin_apps_from_manifest; use crate::loader::load_plugin_mcp_servers_from_manifest_with_format; @@ -24,7 +25,6 @@ use crate::loader::load_plugin_skill_inventory; use crate::manager::ConfiguredMarketplacePlugin; use crate::manager::remote_plugin_install_required_description; use crate::manifest::ManifestCache; -use crate::manifest::PluginManifestFormat; use crate::marketplace::MarketplaceError; use crate::marketplace::MarketplacePluginSource; @@ -257,10 +257,10 @@ async fn load_plugin_metadata( .collect::>(); mcp_server_names.sort_unstable(); mcp_server_names.dedup(); - let app_declarations = if loaded_manifest.format == PluginManifestFormat::AgentPlugin { - Vec::new() - } else { + let app_declarations = if plugin_supports_apps(loaded_manifest.format, &plugin_id) { load_plugin_apps_from_manifest(plugin_root.as_path(), &manifest.paths).await + } else { + Vec::new() }; Ok(Arc::new(ToolSuggestMetadataFragment { diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 01c434c00a..6336db0414 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -1756,6 +1756,97 @@ enabled = true Ok(()) } +#[test_case(true; "apps enabled")] +#[test_case(false; "apps disabled without direct MCP fallback")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn curated_agent_plugin_app_routing_reaches_turns(apps_enabled: bool) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; + let mock = mount_plugin_tool_search_turn(&server).await; + let codex_home = Arc::new(TempDir::new()?); + let plugin_root = codex_home + .path() + .join("plugins/cache/openai-curated-remote/sample/local"); + std::fs::create_dir_all(&plugin_root)?; + let command_name = format!("test_stdio_server{}", std::env::consts::EXE_SUFFIX); + codex_utils_cargo_bin::copy_executable( + std::path::Path::new(&stdio_server_bin()?), + &plugin_root.join(&command_name), + )?; + std::fs::write( + plugin_root.join("plugin.json"), + r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"sample","extensions":{"com.openai":{"apps":"./.app.json"}}}"#, + )?; + std::fs::write( + plugin_root.join(".app.json"), + r#"{"apps":{"calendar":{"id":"calendar"}}}"#, + )?; + let mcp_server = serde_json::json!({ + "type": "stdio", + "command": format!("./{command_name}"), + }); + std::fs::write( + plugin_root.join("mcp.json"), + serde_json::json!({ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": {"calendar": mcp_server, "sample": mcp_server}, + }) + .to_string(), + )?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + "[features]\nplugins = true\napps = {apps_enabled}\n[plugins.\"{SAMPLE_REMOTE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n" + ), + )?; + let mut builder = test_codex() + .with_home(codex_home) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config + .features + .set_enabled(Feature::Apps, apps_enabled) + .expect("test config should allow feature update"); + config.chatgpt_base_url = apps_server.chatgpt_base_url; + }); + let test = builder.build_with_remote_and_local_env(&server).await?; + assert!(test.config.features.enabled(Feature::Plugins)); + assert_eq!(test.config.features.enabled(Feature::Apps), apps_enabled); + wait_for_mcp_server(&test.codex, "sample").await?; + + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Mention { + name: "sample".into(), + path: format!("plugin://{SAMPLE_REMOTE_PLUGIN_CONFIG_NAME}"), + }])) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = mock.requests(); + let (calendar_tool, echo_tool) = searched_plugin_tools(&requests[1]); + assert_eq!(calendar_tool.is_some(), apps_enabled); + if let Some(calendar_tool) = calendar_tool { + assert_plugin_provenance(&calendar_tool); + } + assert_plugin_provenance(&echo_tool.expect("unrelated MCP tool remains available")); + // Disabling Apps must not restore the matching direct MCP connection. + assert!(requests[0].tool_by_name("mcp__calendar", "echo").is_none()); + let mcp_output = requests[1].tool_search_output(PLUGIN_MCP_SEARCH_CALL_ID); + assert!(namespace_child_tool(&mcp_output, "mcp__calendar", "echo").is_none()); + let apps_connected = server + .received_requests() + .await + .expect("recorded app endpoint requests") + .iter() + .any(|request| request.url.path().trim_end_matches('/') == "/api/codex/ps/mcp"); + assert_eq!(apps_connected, apps_enabled); + Ok(()) +} + #[test_case(true; "enabled app")] #[test_case(false; "disabled app")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]