From 014a59fb0b8d70429d5b398e316ea5cfd9e01098 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Thu, 5 Mar 2026 22:45:00 -0800 Subject: [PATCH] check app auth in plugin/install (#13685) #### What on `plugin/install`, check if installed apps are already authed on chatgpt, and return list of all apps that are not. clients can use this list to trigger auth workflows as needed. checks are best effort based on `codex_apps` loading, much like `app/list`. #### Tests Added integration tests, tested locally. --- .../codex_app_server_protocol.schemas.json | 39 ++ .../codex_app_server_protocol.v2.schemas.json | 39 ++ .../schema/json/v2/PluginInstallResponse.json | 41 ++ .../schema/typescript/v2/AppSummary.ts | 8 + .../typescript/v2/PluginInstallResponse.ts | 3 +- .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 26 +- codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 160 ++++++- .../app-server/tests/common/mcp_process.rs | 1 - .../tests/suite/v2/plugin_install.rs | 402 ++++++++++++++++++ codex-rs/core/src/plugins/manager.rs | 9 +- codex-rs/core/src/plugins/mod.rs | 1 + 13 files changed, 715 insertions(+), 17 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AppSummary.ts diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 979bd18dd..b7dcff147 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8157,6 +8157,34 @@ ], "type": "object" }, + "AppSummary": { + "description": "EXPERIMENTAL - app metadata summary for plugin-install responses.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "AppToolApproval": { "enum": [ "auto", @@ -11210,6 +11238,17 @@ }, "PluginInstallResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "appsNeedingAuth": { + "items": { + "$ref": "#/definitions/v2/AppSummary" + }, + "type": "array" + } + }, + "required": [ + "appsNeedingAuth" + ], "title": "PluginInstallResponse", "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 58451c28a..3f6e1308e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -560,6 +560,34 @@ ], "type": "object" }, + "AppSummary": { + "description": "EXPERIMENTAL - app metadata summary for plugin-install responses.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, "AppToolApproval": { "enum": [ "auto", @@ -8411,6 +8439,17 @@ }, "PluginInstallResponse": { "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "appsNeedingAuth": { + "items": { + "$ref": "#/definitions/AppSummary" + }, + "type": "array" + } + }, + "required": [ + "appsNeedingAuth" + ], "title": "PluginInstallResponse", "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json index d430a6056..a294dbcba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstallResponse.json @@ -1,5 +1,46 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AppSummary": { + "description": "EXPERIMENTAL - app metadata summary for plugin-install responses.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + } + }, + "properties": { + "appsNeedingAuth": { + "items": { + "$ref": "#/definitions/AppSummary" + }, + "type": "array" + } + }, + "required": [ + "appsNeedingAuth" + ], "title": "PluginInstallResponse", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppSummary.ts new file mode 100644 index 000000000..d5777b185 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppSummary.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * EXPERIMENTAL - app metadata summary for plugin-install responses. + */ +export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts index 843d9d4b7..08c61f37d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallResponse.ts @@ -1,5 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AppSummary } from "./AppSummary"; -export type PluginInstallResponse = Record; +export type PluginInstallResponse = { appsNeedingAuth: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index daa8f7113..5ded88865 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -16,6 +16,7 @@ export type { AppListUpdatedNotification } from "./AppListUpdatedNotification"; export type { AppMetadata } from "./AppMetadata"; export type { AppReview } from "./AppReview"; export type { AppScreenshot } from "./AppScreenshot"; +export type { AppSummary } from "./AppSummary"; export type { AppToolApproval } from "./AppToolApproval"; export type { AppToolsConfig } from "./AppToolsConfig"; export type { AppsConfig } from "./AppsConfig"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 5c72e6e25..dd1fb5f21 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1712,6 +1712,28 @@ pub struct AppInfo { pub plugin_display_names: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata summary for plugin-install responses. +pub struct AppSummary { + pub id: String, + pub name: String, + pub description: Option, + pub install_url: Option, +} + +impl From for AppSummary { + fn from(value: AppInfo) -> Self { + Self { + id: value.id, + name: value.name, + description: value.description, + install_url: value.install_url, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2604,7 +2626,9 @@ pub struct PluginInstallParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct PluginInstallResponse {} +pub struct PluginInstallResponse { + pub apps_needing_auth: Vec, +} impl From for SkillMetadata { fn from(value: CoreSkillMetadata) -> Self { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8b7500ecf..799bec403 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -154,7 +154,7 @@ Example with notification opt-out: - `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**). - `app/list` — list available apps. - `skills/config/write` — write user-level skill config by path. -- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplacePath` (**under development; do not call from production clients yet**). +- `plugin/install` — install a plugin from a discovered marketplace entry by `pluginName` and `marketplacePath`; on success it returns `appsNeedingAuth` for any plugin-declared apps that still are not accessible in the current ChatGPT auth context (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 213709b9d..7898e2ffb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; +use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; @@ -187,6 +188,8 @@ use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::McpServerTransportConfig; use codex_core::config_loader::CloudRequirementsLoader; +use codex_core::connectors::filter_disallowed_connectors; +use codex_core::connectors::merge_plugin_apps; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::error::CodexErr; use codex_core::exec::ExecParams; @@ -203,10 +206,12 @@ use codex_core::mcp::collect_mcp_snapshot; use codex_core::mcp::group_tools_by_server; use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_core::parse_cursor; +use codex_core::plugins::AppConnectorId; use codex_core::plugins::MarketplaceError; use codex_core::plugins::MarketplacePluginSourceSummary; use codex_core::plugins::PluginInstallError as CorePluginInstallError; use codex_core::plugins::PluginInstallRequest; +use codex_core::plugins::load_plugin_apps; use codex_core::read_head_for_summary; use codex_core::read_session_meta_line; use codex_core::rollout_date_parts; @@ -468,10 +473,14 @@ impl CodexMessageProcessor { } } - async fn load_latest_config(&self) -> Result { + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { let cloud_requirements = self.current_cloud_requirements(); let mut config = codex_core::config::ConfigBuilder::default() .cli_overrides(self.cli_overrides.clone()) + .fallback_cwd(fallback_cwd) .cloud_requirements(cloud_requirements) .build() .await @@ -3913,7 +3922,7 @@ impl CodexMessageProcessor { params: ExperimentalFeatureListParams, ) { let ExperimentalFeatureListParams { cursor, limit } = params; - let config = match self.load_latest_config().await { + let config = match self.load_latest_config(None).await { Ok(config) => config, Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -4028,7 +4037,7 @@ impl CodexMessageProcessor { } async fn mcp_server_refresh(&self, request_id: ConnectionRequestId, _params: Option<()>) { - let config = match self.load_latest_config().await { + let config = match self.load_latest_config(None).await { Ok(config) => config, Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -4087,7 +4096,7 @@ impl CodexMessageProcessor { request_id: ConnectionRequestId, params: McpServerOauthLoginParams, ) { - let config = match self.load_latest_config().await { + let config = match self.load_latest_config(None).await { Ok(config) => config, Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -4193,7 +4202,7 @@ impl CodexMessageProcessor { let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); - let config = match self.load_latest_config().await { + let config = match self.load_latest_config(None).await { Ok(config) => config, Err(error) => { self.outgoing.send_error(request, error).await; @@ -4616,7 +4625,7 @@ impl CodexMessageProcessor { } async fn apps_list(&self, request_id: ConnectionRequestId, params: AppsListParams) { - let mut config = match self.load_latest_config().await { + let mut config = match self.load_latest_config(None).await { Ok(config) => config, Err(error) => { self.outgoing.send_error(request_id, error).await; @@ -4847,6 +4856,36 @@ impl CodexMessageProcessor { connectors::merge_connectors_with_accessible(all, accessible, all_connectors_loaded) } + fn plugin_apps_needing_auth( + all_connectors: &[AppInfo], + accessible_connectors: &[AppInfo], + plugin_apps: &[AppConnectorId], + codex_apps_ready: bool, + ) -> Vec { + if !codex_apps_ready { + return Vec::new(); + } + + let accessible_ids = accessible_connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect::>(); + let plugin_app_ids = plugin_apps + .iter() + .map(|connector_id| connector_id.0.as_str()) + .collect::>(); + + all_connectors + .iter() + .filter(|connector| { + plugin_app_ids.contains(connector.id.as_str()) + && !accessible_ids.contains(connector.id.as_str()) + }) + .cloned() + .map(AppSummary::from) + .collect() + } + fn should_send_app_list_updated_notification( connectors: &[AppInfo], accessible_loaded: bool, @@ -4963,7 +5002,7 @@ impl CodexMessageProcessor { let plugins_manager = self.thread_manager.plugins_manager(); let roots = params.cwds.unwrap_or_default(); - let config = match self.load_latest_config().await { + let config = match self.load_latest_config(None).await { Ok(config) => config, Err(err) => { self.outgoing.send_error(request_id, err).await; @@ -5132,6 +5171,7 @@ impl CodexMessageProcessor { marketplace_path, plugin_name, } = params; + let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); let plugins_manager = self.thread_manager.plugins_manager(); let request = PluginInstallRequest { @@ -5140,11 +5180,84 @@ impl CodexMessageProcessor { }; match plugins_manager.install_plugin(request).await { - Ok(_) => { + Ok(result) => { + let config = match self.load_latest_config(config_cwd).await { + Ok(config) => config, + Err(err) => { + warn!( + "failed to reload config after plugin install, using current config: {err:?}" + ); + self.config.as_ref().clone() + } + }; + let plugin_apps = load_plugin_apps(&result.installed_path); + let apps_needing_auth = if plugin_apps.is_empty() + || !config.features.enabled(Feature::Apps) + { + Vec::new() + } else { + let (all_connectors_result, accessible_connectors_result) = tokio::join!( + connectors::list_all_connectors_with_options(&config, true), + connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status( + &config, true + ), + ); + + let all_connectors = match all_connectors_result { + Ok(connectors) => filter_disallowed_connectors(merge_plugin_apps( + connectors, + plugin_apps.clone(), + )), + Err(err) => { + warn!( + plugin = result.plugin_id.as_key(), + "failed to load app metadata after plugin install: {err:#}" + ); + filter_disallowed_connectors(merge_plugin_apps( + connectors::list_cached_all_connectors(&config) + .await + .unwrap_or_default(), + plugin_apps.clone(), + )) + } + }; + let (accessible_connectors, codex_apps_ready) = + match accessible_connectors_result { + Ok(status) => (status.connectors, status.codex_apps_ready), + Err(err) => { + warn!( + plugin = result.plugin_id.as_key(), + "failed to load accessible apps after plugin install: {err:#}" + ); + ( + connectors::list_cached_accessible_connectors_from_mcp_tools( + &config, + ) + .await + .unwrap_or_default(), + false, + ) + } + }; + if !codex_apps_ready { + warn!( + plugin = result.plugin_id.as_key(), + "codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check" + ); + } + + Self::plugin_apps_needing_auth( + &all_connectors, + &accessible_connectors, + &plugin_apps, + codex_apps_ready, + ) + }; + plugins_manager.clear_cache(); self.thread_manager.skills_manager().clear_cache(); self.outgoing - .send_response(request_id, PluginInstallResponse {}) + .send_response(request_id, PluginInstallResponse { apps_needing_auth }) .await; } Err(err) => { @@ -7370,6 +7483,35 @@ mod tests { validate_dynamic_tools(&tools).expect("valid schema"); } + #[test] + fn plugin_apps_needing_auth_returns_empty_when_codex_apps_is_not_ready() { + let all_connectors = vec![AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + + assert_eq!( + CodexMessageProcessor::plugin_apps_needing_auth( + &all_connectors, + &[], + &[AppConnectorId("alpha".to_string())], + false, + ), + Vec::::new() + ); + } + #[test] fn collect_resume_override_mismatches_includes_service_tier() { let request = ThreadResumeParams { diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 40df460ad..58514f39f 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -467,7 +467,6 @@ impl McpProcess { ) -> anyhow::Result { self.send_request(method, params).await } - /// Send a `collaborationMode/list` JSON-RPC request. pub async fn send_list_collaboration_modes_request( &mut self, 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 811e789ed..8ffbbe283 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -1,12 +1,45 @@ +use std::borrow::Cow; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use axum::Json; +use axum::Router; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::Uri; +use axum::http::header::AUTHORIZATION; +use axum::routing::get; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use rmcp::handler::server::ServerHandler; +use rmcp::model::JsonObject; +use rmcp::model::ListToolsResult; +use rmcp::model::Meta; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerInfo; +use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; +use rmcp::transport::StreamableHttpServerConfig; +use rmcp::transport::StreamableHttpService; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use serde_json::json; use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -64,3 +97,372 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() - assert!(err.error.message.contains("does not exist")); Ok(()) } + +#[tokio::test] +async fn plugin_install_returns_apps_needing_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: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }, + ]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = start_apps_server(connectors, tools).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + 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 repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + )?; + write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path, + 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: PluginInstallResponse = to_response(response)?; + + assert_eq!( + response, + PluginInstallResponse { + apps_needing_auth: vec![AppSummary { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + }], + } + ); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[tokio::test] +async fn plugin_install_filters_disallowed_apps_needing_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: 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, Vec::new()).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + 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 repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./sample-plugin", + )?; + write_plugin_source( + repo_root.path(), + "sample-plugin", + &["alpha", "asdk_app_6938a94a61d881918ef32cb999ff937c"], + )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path, + 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: PluginInstallResponse = to_response(response)?; + + assert_eq!( + response, + PluginInstallResponse { + apps_needing_auth: vec![AppSummary { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), + install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), + }], + } + ); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + +#[derive(Clone)] +struct AppsServerState { + response: Arc>, +} + +#[derive(Clone)] +struct PluginInstallMcpServer { + tools: Arc>>, +} + +impl ServerHandler for PluginInstallMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..ServerInfo::default() + } + } + + fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + let tools = self.tools.clone(); + async move { + let tools = tools + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Ok(ListToolsResult { + tools, + next_cursor: None, + meta: None, + }) + } + } +} + +async fn start_apps_server( + connectors: Vec, + tools: Vec, +) -> Result<(String, JoinHandle<()>)> { + let state = Arc::new(AppsServerState { + response: Arc::new(StdMutex::new( + json!({ "apps": connectors, "next_token": null }), + )), + }); + let tools = Arc::new(StdMutex::new(tools)); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let mcp_service = StreamableHttpService::new( + { + let tools = tools.clone(); + move || { + Ok(PluginInstallMcpServer { + tools: tools.clone(), + }) + } + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let router = Router::new() + .route("/connectors/directory/list", get(list_directory_connectors)) + .route( + "/connectors/directory/list_workspace", + get(list_directory_connectors), + ) + .with_state(state) + .nest_service("/api/codex/apps", mcp_service); + + let handle = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + + Ok((format!("http://{addr}"), handle)) +} + +async fn list_directory_connectors( + State(state): State>, + headers: HeaderMap, + uri: Uri, +) -> Result { + let bearer_ok = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "Bearer chatgpt-token"); + let account_ok = headers + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "account-123"); + let external_logos_ok = uri + .query() + .is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true")); + + if !bearer_ok || !account_ok { + Err(StatusCode::UNAUTHORIZED) + } else if !external_logos_ok { + Err(StatusCode::BAD_REQUEST) + } else { + let response = state + .response + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + Ok(Json(response)) + } +} + +fn connector_tool(connector_id: &str, connector_name: &str) -> Result { + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "additionalProperties": false + }))?; + let mut tool = Tool::new( + Cow::Owned(format!("connector_{connector_id}")), + Cow::Borrowed("Connector test tool"), + Arc::new(schema), + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + + let mut meta = Meta::new(); + meta.0 + .insert("connector_id".to_string(), json!(connector_id)); + meta.0 + .insert("connector_name".to_string(), json!(connector_name)); + tool.meta = Some(meta); + Ok(tool) +} + +fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +chatgpt_base_url = "{base_url}" +mcp_oauth_credentials_store = "file" + +[features] +connectors = true +"# + ), + ) +} + +fn write_plugin_marketplace( + repo_root: &std::path::Path, + marketplace_name: &str, + plugin_name: &str, + source_path: &str, +) -> std::io::Result<()> { + std::fs::create_dir_all(repo_root.join(".git"))?; + std::fs::create_dir_all(repo_root.join(".agents/plugins"))?; + std::fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "{marketplace_name}", + "plugins": [ + {{ + "name": "{plugin_name}", + "source": {{ + "source": "local", + "path": "{source_path}" + }} + }} + ] +}}"# + ), + ) +} + +fn write_plugin_source( + repo_root: &std::path::Path, + plugin_name: &str, + app_ids: &[&str], +) -> Result<()> { + let plugin_root = repo_root.join(".agents/plugins").join(plugin_name); + 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}"}}"#), + )?; + + let apps = app_ids + .iter() + .map(|app_id| ((*app_id).to_string(), json!({ "id": app_id }))) + .collect::>(); + std::fs::write( + plugin_root.join(".app.json"), + serde_json::to_vec_pretty(&json!({ "apps": apps }))?, + )?; + Ok(()) +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 6f46bf00d..2e45a2b3e 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -523,10 +523,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) } } loaded_plugin.mcp_servers = mcp_servers; - loaded_plugin.apps = load_apps_from_file( - plugin_root.as_path(), - &plugin_root.as_path().join(DEFAULT_APP_CONFIG_FILE), - ); + loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()); loaded_plugin } @@ -550,6 +547,10 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec { paths } +pub fn load_plugin_apps(plugin_root: &Path) -> Vec { + load_apps_from_file(plugin_root, &plugin_root.join(DEFAULT_APP_CONFIG_FILE)) +} + fn load_apps_from_file(plugin_root: &Path, app_config_path: &Path) -> Vec { let Ok(contents) = fs::read_to_string(app_config_path) else { return Vec::new(); diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index c0bec248f..93f903d76 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -15,6 +15,7 @@ pub use manager::PluginInstallError; pub use manager::PluginInstallRequest; pub use manager::PluginLoadOutcome; pub use manager::PluginsManager; +pub use manager::load_plugin_apps; pub(crate) use manager::plugin_namespace_for_skill_path; pub(crate) use manifest::load_plugin_manifest; pub(crate) use manifest::plugin_manifest_name;