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;