From d959664420a5f39641eba661c240c35e3739607c Mon Sep 17 00:00:00 2001 From: felixxia-oai Date: Tue, 16 Jun 2026 02:25:22 +0100 Subject: [PATCH] [codex] Make plugin details capability aware (#27958) ## Summary Makes plugin details/read flows capability-aware so auth-filtered plugin surfaces report the same usable app/MCP/skill shape as the marketplace and install flows. ## Validation Not run; this change was rebased onto the current plugin auth stack and pushed as a draft PR. **Manual test** 1. set up a local marketplace with a plugin that has both app and mcp declarations ``` // .app.json { "apps": { "linear": { "id": "some_id" } } } ``` ``` // .mcp.json { "mcpServers": { "linear": { "type": "http", "url": "https://mcp.linear.app/mcp", "oauth_resource": "https://mcp.linear.app/mcp" }, "linear2": { "type": "http", "url": "https://mcp.linear2.app/mcp", "oauth_resource": "https://mcp.linear2.app/mcp" } } } ``` 2a. **login in with api key** and observe plugin details page which shows no apps (note we don't show "app not available due to api key log in as there's no way to differentiate between no apps and app without substitute mcp exists" without significantly more code changes, i've separated this to a follow up if we want that behaviour. Screenshot 2026-06-15 at 23 45 40 Screenshot 2026-06-15 at 18 40 30 2b. **log in with chat** and observe intended conflict resolution logic Screenshot 2026-06-15 at 17 17 30 Screenshot 2026-06-15 at 18 38 59 --- .../src/request_processors/plugins.rs | 4 +- .../app-server/tests/suite/v2/plugin_read.rs | 149 +++++++++++++++++- codex-rs/core-plugins/src/manager.rs | 17 +- codex-rs/core-plugins/src/manager_tests.rs | 5 + codex-rs/core-plugins/src/remote.rs | 40 ++++- 5 files changed, 204 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index c31a6ff2d..609d89459 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -992,6 +992,8 @@ impl PluginRequestProcessor { let config = self.load_latest_config(config_cwd).await?; let plugins_input = config.plugins_config_input(); + let auth = self.auth_manager.auth().await; + plugins_manager.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode)); let plugin = match read_source { Ok(marketplace_path) => { @@ -1011,7 +1013,6 @@ impl PluginRequestProcessor { ); let share_context = match share_context { Some(context) => { - let auth = self.auth_manager.auth().await; let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; @@ -1118,7 +1119,6 @@ impl PluginRequestProcessor { "remote plugin read is not enabled for marketplace {remote_marketplace_name}" ))); } - let auth = self.auth_manager.auth().await; let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 7b253cd68..5009beb64 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -125,6 +125,7 @@ chatgpt_base_url = "{}/backend-api/" [features] plugins = true +apps = true "#, server.uri() ), @@ -149,6 +150,13 @@ plugins = true "display_name": "Example Plugin", "description": "Example plugin", "app_ids": [], + "app_manifest": { + "apps": { + "example-server": { + "id": "example-app" + } + } + }, "keywords": [], "interface": { "short_description": "Example plugin", @@ -163,6 +171,12 @@ plugins = true "metadata": { "command": "example-mcp" } + }, + { + "key": "other-server", + "metadata": { + "command": "other-mcp" + } } ] } @@ -192,6 +206,33 @@ plugins = true .respond_with(ResponseTemplate::new(200).set_body_string(installed_body)) .mount(&server) .await; + Mock::given(method("GET")) + .and(path("/backend-api/connectors/directory/list")) + .and(query_param("external_logos", "true")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "apps": [ + AppInfo { + id: "example-app".to_string(), + name: "Example App".to_string(), + description: Some("Example app connector".to_string()), + logo_url: Some("https://example.com/example.png".to_string()), + logo_url_dark: None, + distribution_channel: Some("featured".to_string()), + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + } + ], + "next_token": null + }))) + .mount(&server) + .await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -234,7 +275,16 @@ plugins = true ); assert_eq!( response.plugin.mcp_servers, - vec!["example-server".to_string()] + vec!["other-server".to_string()] + ); + assert_eq!( + response + .plugin + .apps + .iter() + .map(|app| app.id.as_str()) + .collect::>(), + vec!["example-app"] ); Ok(()) } @@ -906,6 +956,10 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .join("demo-plugin/.codex-plugin/plugin.json"), r#"{"name":"demo-plugin","version":"1.2.3"}"#, )?; + std::fs::write( + repo_root.path().join("demo-plugin/.mcp.json"), + r#"{"mcpServers":{"demo":{"command":"demo-mcp"}}}"#, + )?; let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; Mock::given(method("GET")) @@ -1045,6 +1099,10 @@ async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> .join("demo-plugin/.codex-plugin/plugin.json"), r#"{"name":"demo-plugin","version":"1.2.3"}"#, )?; + std::fs::write( + repo_root.path().join("demo-plugin/.mcp.json"), + r#"{"mcpServers":{"demo":{"command":"demo-mcp"}}}"#, + )?; let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; Mock::given(method("GET")) @@ -1601,6 +1659,94 @@ async fn plugin_read_returns_app_metadata_category() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_read_hides_apps_for_api_key_auth() -> Result<()> { + let connectors = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: Some("featured".to_string()), + branding: None, + app_metadata: Some(AppMetadata { + review: None, + categories: Some(vec!["Productivity".to_string()]), + sub_categories: None, + seo_description: None, + screenshots: None, + developer: None, + version: None, + version_id: None, + version_notes: None, + first_party_type: None, + first_party_requires_install: None, + show_in_composer_when_unlinked: None, + }), + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let (server_url, server_handle) = start_apps_server(connectors).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + std::fs::write( + codex_home.path().join("auth.json"), + r#"{"OPENAI_API_KEY":"sk-test-key","tokens":null,"last_refresh":null}"#, + )?; + + let repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &["alpha"])?; + std::fs::write( + repo_root.path().join("sample-plugin/.mcp.json"), + r#"{"mcpServers":{"alpha":{"command":"alpha-mcp"}}}"#, + )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("CODEX_ACCESS_TOKEN", None), + ("CODEX_API_KEY", None), + ("OPENAI_API_KEY", None), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert!(response.plugin.apps.is_empty()); + assert_eq!(response.plugin.mcp_servers, vec!["alpha".to_string()]); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { let codex_home = TempDir::new()?; @@ -1932,6 +2078,7 @@ fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std: format!( r#" chatgpt_base_url = "{base_url}" +cli_auth_credentials_store = "file" mcp_oauth_credentials_store = "file" [features] diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 47a3a20ad..82cd1dc63 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1314,7 +1314,17 @@ impl PluginsManager { event_name: hook.event_name, }) .collect(); - let app_declarations = load_plugin_apps(source_path.as_path()).await; + let auth_mode = self.auth_mode(); + let mut app_declarations = load_plugin_apps(source_path.as_path()).await; + let mut mcp_servers = load_plugin_mcp_servers(source_path.as_path(), auth_mode).await; + if auth_mode.is_some() { + 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(); @@ -1325,10 +1335,7 @@ impl PluginsManager { app_category_by_id.insert(app.connector_id.0.clone(), category.clone()); } } - let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path(), self.auth_mode()) - .await - .into_keys() - .collect::>(); + let mut mcp_server_names = mcp_servers.into_keys().collect::>(); mcp_server_names.sort_unstable(); mcp_server_names.dedup(); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 1fb987312..d6afbfb89 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -2493,6 +2493,10 @@ plugins = true chatgpt_outcome.plugin.mcp_server_names, vec!["other-mcp".to_string()] ); + assert_eq!( + chatgpt_outcome.plugin.apps, + vec![AppConnectorId("connector_sample".to_string())] + ); let api_key_outcome = PluginsManager::new_with_options( tmp.path().to_path_buf(), @@ -2506,6 +2510,7 @@ plugins = true api_key_outcome.plugin.mcp_server_names, vec!["other-mcp".to_string(), "sample-mcp".to_string()] ); + assert!(api_key_outcome.plugin.apps.is_empty()); } #[tokio::test] diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 3a6b5f6d2..1a712c08a 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -1,3 +1,5 @@ +use crate::app_mcp_routing::apply_app_mcp_routing_policy; +use crate::loader::plugin_app_declarations_from_value; use crate::store::PLUGINS_CACHE_DIR; use crate::store::PluginStore; use codex_app_server_protocol::JSONRPCErrorError; @@ -8,7 +10,10 @@ use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::SkillInterface; use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; +use codex_plugin::AppConnectorId; +use codex_plugin::AppDeclaration; use codex_plugin::PluginId; +use codex_plugin::app_connector_ids_from_declarations; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use serde::Deserialize; @@ -16,6 +21,7 @@ use serde::Serialize; use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::collections::BTreeSet; +use std::collections::HashMap; use std::collections::HashSet; use std::fs; use std::path::Path; @@ -1064,12 +1070,29 @@ async fn build_remote_plugin_detail( enabled: !disabled_skill_names.contains(&skill.name), }) .collect(); + let mut app_declarations = plugin + .release + .app_manifest + .as_ref() + .map(plugin_app_declarations_from_value) + .unwrap_or_else(|| app_declarations_from_remote_app_ids(&plugin.release.app_ids)); let mut mcp_servers = plugin .release .mcp_servers .iter() - .map(|server| server.key.clone()) - .collect::>(); + .map(|server| (server.key.clone(), ())) + .collect::>(); + apply_app_mcp_routing_policy( + &mut app_declarations, + &mut mcp_servers, + Some(auth.api_auth_mode()), + /*plugin_active*/ true, + ); + let app_ids = app_connector_ids_from_declarations(&app_declarations) + .into_iter() + .map(|app_id| app_id.0) + .collect(); + let mut mcp_servers = mcp_servers.into_keys().collect::>(); mcp_servers.sort_unstable(); mcp_servers.dedup(); @@ -1083,7 +1106,7 @@ async fn build_remote_plugin_detail( bundle_download_url: plugin.release.bundle_download_url, app_manifest: plugin.release.app_manifest, skills, - app_ids: plugin.release.app_ids, + app_ids, app_templates: plugin .release .app_templates @@ -1104,6 +1127,17 @@ async fn build_remote_plugin_detail( }) } +fn app_declarations_from_remote_app_ids(app_ids: &[String]) -> Vec { + app_ids + .iter() + .map(|app_id| AppDeclaration { + name: app_id.clone(), + connector_id: AppConnectorId(app_id.clone()), + category: None, + }) + .collect() +} + pub async fn install_remote_plugin( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>,