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>,