diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 223d4296d51e..612b2ed44200 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -47,6 +47,7 @@ use codex_config::McpServerAuth; use codex_config::McpServerTransportConfig; use codex_config::types::AuthKeyringBackendKind; use codex_config::types::OAuthCredentialsStoreMode; +use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::McpServerInfo; @@ -141,6 +142,7 @@ impl McpConnectionManager { supports_openai_form_elicitation: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, + codex_apps_auth_manager: Option>, elicitation_reviewer: Option, elicitation_lifecycle: Option, elicitation_router: ElicitationRequestRouter, @@ -163,9 +165,14 @@ impl McpConnectionManager { ); let tool_plugin_provenance = Arc::new(tool_plugin_provenance); let startup_submit_id = submit_id.clone(); - let chatgpt_auth_provider = auth + let static_chatgpt_auth_provider = auth .filter(|auth| auth.uses_codex_backend()) .map(codex_model_provider::auth_provider_from_auth); + let codex_apps_auth_provider = codex_apps_auth_manager.and_then(|auth_manager| { + auth.filter(|auth| auth.uses_codex_backend()).map(|auth| { + codex_model_provider::auth_provider_from_auth_manager(auth_manager, auth) + }) + }); let mcp_servers = mcp_servers.clone(); for (server_name, server) in mcp_servers .into_iter() @@ -200,13 +207,24 @@ impl McpConnectionManager { codex_apps_tools_cache .context(codex_home.clone(), codex_apps_tools_cache_key.clone()) }); + // The reserved Codex Apps registration follows the shared + // AuthManager across refreshes. In the hosted-plugin path, this + // is the ChatGPT /ps/mcp connection. User-configured MCP + // registrations keep their existing configured auth path. + let chatgpt_auth_provider = if server_name == CODEX_APPS_MCP_SERVER_NAME { + codex_apps_auth_provider + .clone() + .or_else(|| static_chatgpt_auth_provider.clone()) + } else { + static_chatgpt_auth_provider.clone() + }; // If Codex Apps has an env bearer token, that is its auth path. Do // not also attach the ambient CodexAuth provider. let runtime_auth_provider = if server_name == CODEX_APPS_MCP_SERVER_NAME && uses_env_bearer_token { None } else { - chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider.clone()) + chatgpt_auth_provider_for_server(&server, chatgpt_auth_provider) }; let async_managed_client = AsyncManagedClient::new( server_name.clone(), diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 41fc19a594f5..d8a1f1863e54 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1561,6 +1561,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), /*auth*/ None, + /*codex_apps_auth_manager*/ None, /*elicitation_reviewer*/ None, /*elicitation_lifecycle*/ None, ElicitationRequestRouter::default(), diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 87ea25dd9f16..847dbe28661c 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -338,6 +338,7 @@ pub async fn read_mcp_resource( /*supports_openai_form_elicitation*/ false, tool_plugin_provenance(config), auth, + /*codex_apps_auth_manager*/ None, /*elicitation_reviewer*/ None, /*elicitation_lifecycle*/ None, crate::elicitation::ElicitationRequestRouter::default(), @@ -416,6 +417,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( /*supports_openai_form_elicitation*/ false, tool_plugin_provenance, auth, + /*codex_apps_auth_manager*/ None, /*elicitation_reviewer*/ None, /*elicitation_lifecycle*/ None, crate::elicitation::ElicitationRequestRouter::default(), diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 6e643f34d5ca..a35b717cad72 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -248,6 +248,9 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( drop(rx_event); let cancel_token = CancellationToken::new(); + let codex_apps_auth_manager = + codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()) + .then(|| Arc::clone(&auth_manager)); let mcp_connection_manager = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, @@ -269,6 +272,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( /*supports_openai_form_elicitation*/ false, ToolPluginProvenance::default(), auth.as_ref(), + codex_apps_auth_manager, /*elicitation_reviewer*/ None, /*elicitation_lifecycle*/ None, codex_mcp::ElicitationRequestRouter::default(), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 277caa482dd0..99d935c7aa02 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1462,6 +1462,7 @@ async fn host_owned_codex_apps_manager( /*supports_openai_form_elicitation*/ false, codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), + /*codex_apps_auth_manager*/ None, /*elicitation_reviewer*/ None, /*elicitation_lifecycle*/ None, codex_mcp::ElicitationRequestRouter::default(), diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 3f5e92eca7c5..b6dd64a400b9 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -361,6 +361,9 @@ impl Session { cancellation_token }; let current_runtime = self.services.latest_mcp_runtime(); + let codex_apps_auth_manager = + codex_mcp::host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()) + .then(|| Arc::clone(&self.services.auth_manager)); let refreshed_manager = McpConnectionManager::new( &mcp_servers, mcp_config.mcp_oauth_credentials_store_mode, @@ -382,6 +385,7 @@ impl Session { .load(std::sync::atomic::Ordering::Relaxed), tool_plugin_provenance, auth.as_ref(), + codex_apps_auth_manager, elicitation_reviewer, Some(self.mcp_elicitation_lifecycle()), current_runtime.manager().elicitation_router(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 08abb4637749..d9b3e81db714 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1194,6 +1194,9 @@ impl Session { *cancel_guard = cancel_token.clone(); cancel_token }; + let codex_apps_auth_manager = + codex_mcp::host_owned_codex_apps_enabled(&mcp_projection.config, auth) + .then(|| Arc::clone(&sess.services.auth_manager)); let mcp_connection_manager = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, @@ -1218,6 +1221,7 @@ impl Session { .load(std::sync::atomic::Ordering::Relaxed), tool_plugin_provenance, auth, + codex_apps_auth_manager, Some(sess.mcp_elicitation_reviewer()), Some(sess.mcp_elicitation_lifecycle()), codex_mcp::ElicitationRequestRouter::default(), diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 3daaada6c501..80e69f917947 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -26,6 +26,8 @@ const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar."; const CODEX_APPS_META_KEY: &str = "_codex_apps"; +const CODEX_APPS_MCP_PATH_REGEX: &str = "^/api/codex/apps/?$"; +const HOSTED_PLUGIN_RUNTIME_MCP_PATH_REGEX: &str = "^/api/codex/ps/mcp/?$"; const PROTOCOL_VERSION: &str = "2025-11-25"; const SERVER_NAME: &str = "codex-apps-test"; const SERVER_VERSION: &str = "1.0.0"; @@ -108,6 +110,24 @@ impl AppsTestServer { }) } + pub async fn mount_hosted_plugin_runtime_searchable(server: &MockServer) -> Result { + mount_oauth_metadata(server).await; + mount_connectors_directory(server).await; + mount_streamable_http_json_rpc_at_path( + server, + HOSTED_PLUGIN_RUNTIME_MCP_PATH_REGEX, + CONNECTOR_NAME.to_string(), + CONNECTOR_DESCRIPTION.to_string(), + /*searchable*/ true, + /*include_app_only_tool*/ false, + AppsTestToolsListBehavior::AlwaysAvailable, + ) + .await; + Ok(Self { + chatgpt_base_url: server.uri(), + }) + } + pub async fn mount_with_connector_name( server: &MockServer, connector_name: &str, @@ -159,6 +179,7 @@ impl AppsTestServer { }; mount_streamable_http_json_rpc_with_startup_control( server, + CODEX_APPS_MCP_PATH_REGEX, CONNECTOR_NAME.to_string(), CONNECTOR_DESCRIPTION.to_string(), /*searchable*/ true, @@ -358,9 +379,31 @@ async fn mount_streamable_http_json_rpc( searchable: bool, include_app_only_tool: bool, tools_list_behavior: AppsTestToolsListBehavior, +) { + mount_streamable_http_json_rpc_at_path( + server, + CODEX_APPS_MCP_PATH_REGEX, + connector_name, + connector_description, + searchable, + include_app_only_tool, + tools_list_behavior, + ) + .await; +} + +async fn mount_streamable_http_json_rpc_at_path( + server: &MockServer, + mcp_path_regex: &str, + connector_name: String, + connector_description: String, + searchable: bool, + include_app_only_tool: bool, + tools_list_behavior: AppsTestToolsListBehavior, ) { mount_streamable_http_json_rpc_with_startup_control( server, + mcp_path_regex, connector_name, connector_description, searchable, @@ -375,6 +418,7 @@ async fn mount_streamable_http_json_rpc( #[allow(clippy::too_many_arguments)] async fn mount_streamable_http_json_rpc_with_startup_control( server: &MockServer, + mcp_path_regex: &str, connector_name: String, connector_description: String, searchable: bool, @@ -384,7 +428,7 @@ async fn mount_streamable_http_json_rpc_with_startup_control( remaining_initialize_failures: Option>, ) { Mock::given(method("POST")) - .and(path_regex("^/api/codex/apps/?$")) + .and(path_regex(mcp_path_regex)) .respond_with(CodexAppsJsonRpcResponder { connector_name, connector_description, diff --git a/codex-rs/core/tests/suite/mcp_auth_refresh.rs b/codex-rs/core/tests/suite/mcp_auth_refresh.rs new file mode 100644 index 000000000000..6288453b02bb --- /dev/null +++ b/codex-rs/core/tests/suite/mcp_auth_refresh.rs @@ -0,0 +1,160 @@ +#![allow(clippy::unwrap_used)] + +use anyhow::Result; +use codex_config::McpServerTransportConfig; +use codex_config::types::OAuthCredentialsStoreMode; +use codex_core::config::Constrained; +use codex_login::AuthCredentialsStoreMode; +use codex_login::AuthKeyringBackendKind; +use codex_login::AuthManager; +use codex_login::auth::login_with_chatgpt_auth_tokens; +use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::CodexAppsToolsCache; +use codex_mcp::EffectiveMcpServer; +use codex_mcp::ElicitationRequestRouter; +use codex_mcp::McpConnectionManager; +use codex_mcp::McpRuntimeContext; +use codex_mcp::ToolPluginProvenance; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use rmcp::model::ElicitationCapability; +use serde_json::Value; +use serde_json::json; +use std::collections::HashMap; +use std::sync::Arc; +use tempfile::TempDir; +use tokio_util::sync::CancellationToken; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_reloaded_auth_manager_token() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_hosted_plugin_runtime_searchable(&server).await?; + let home = Arc::new(TempDir::new()?); + login_with_chatgpt_auth_tokens( + home.path(), + "header.e30.first", + "test-account", + /*chatgpt_plan_type*/ None, + )?; + let auth_manager = Arc::new( + AuthManager::new( + home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::Ephemeral, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, + ) + .await, + ); + let expected_auth = auth_manager + .auth_cached() + .expect("initial auth should be cached"); + // Build the hosted-plugin config directly so the local test origin can + // exercise the connection-manager auth path. Effective server resolution + // correctly strips ChatGPT auth from untrusted localhost origins. + let mut hosted_plugin_runtime_config = codex_mcp::hosted_plugin_runtime_mcp_server_config( + &apps_server.chatgpt_base_url, + /*apps_mcp_product_sku*/ None, + ); + let McpServerTransportConfig::StreamableHttp { + bearer_token_env_var, + .. + } = &mut hosted_plugin_runtime_config.transport + else { + panic!("hosted plugin runtime should use streamable HTTP"); + }; + // Keep the test on the AuthManager path even if the developer has the + // debug bearer override in their environment. + *bearer_token_env_var = None; + let mcp_servers = HashMap::from([( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + EffectiveMcpServer::configured(hosted_plugin_runtime_config), + )]); + let (tx_event, rx_event) = async_channel::unbounded(); + drop(rx_event); + let approval_policy = Constrained::allow_any(AskForApproval::Never); + let manager = McpConnectionManager::new( + &mcp_servers, + OAuthCredentialsStoreMode::default(), + AuthKeyringBackendKind::default(), + HashMap::new(), + &approval_policy, + "test".to_string(), + tx_event, + CancellationToken::new(), + PermissionProfile::default(), + McpRuntimeContext::new( + Arc::new(codex_exec_server::EnvironmentManager::without_environments()), + home.path().to_path_buf(), + ), + home.path().to_path_buf(), + CodexAppsToolsCache::default(), + codex_mcp::codex_apps_tools_cache_key(Some(&expected_auth)), + /*prefix_mcp_tool_names*/ true, + ElicitationCapability::default(), + /*supports_openai_form_elicitation*/ false, + ToolPluginProvenance::default(), + Some(&expected_auth), + Some(Arc::clone(&auth_manager)), + /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, + ElicitationRequestRouter::default(), + ) + .await; + login_with_chatgpt_auth_tokens( + home.path(), + "header.e30.reloaded", + "test-account", + /*chatgpt_plan_type*/ None, + )?; + auth_manager.reload().await; + + // The manager and its static fallback were created before reload, so this + // tool call only sees the new token if the Codex Apps provider reads the + // shared AuthManager at request time. + let tool_result = manager + .call_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + Some(json!({ + "title": "Lunch", + "starts_at": "2026-06-18T12:00:00Z", + })), + /*meta*/ None, + ) + .await?; + assert_eq!(tool_result.is_error, Some(false)); + + let requests = server + .received_requests() + .await + .expect("mock server should capture tool-call requests"); + let tool_call_request = requests + .iter() + .find(|request| { + request.url.path() == "/api/codex/ps/mcp" + && serde_json::from_slice::(&request.body) + .ok() + .is_some_and(|body| { + body.get("method").and_then(Value::as_str) == Some("tools/call") + }) + }) + .expect("Codex Apps should receive a tool call"); + assert_eq!( + tool_call_request + .headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some("Bearer header.e30.reloaded") + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e8bb48856cdb..438d75626037 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -67,6 +67,7 @@ mod items; mod json_result; mod live_cli; mod mcp_auth_elicitation; +mod mcp_auth_refresh; #[cfg(unix)] mod mcp_refresh_cleanup; mod mcp_tool_exposure; diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index bcd36f19121d..aab1cd1d84d9 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -121,6 +121,35 @@ impl AuthProvider for HeaderAuthProvider { } } +struct AuthManagerAuthProvider { + auth_manager: Arc, + expected_account_id: Option, + expected_chatgpt_user_id: Option, + expected_is_workspace_account: bool, +} + +impl AuthProvider for AuthManagerAuthProvider { + fn add_auth_headers(&self, headers: &mut HeaderMap) { + let Some(auth) = self + .auth_manager + .auth_cached() + .filter(CodexAuth::uses_codex_backend) + else { + return; + }; + // The caller's account-scoped state was built for the expected + // identity. Follow token refreshes for that identity, but never cross + // an account or workspace boundary without rebuilding that state. + if auth.get_account_id().as_deref() != self.expected_account_id.as_deref() + || auth.get_chatgpt_user_id().as_deref() != self.expected_chatgpt_user_id.as_deref() + || auth.is_workspace_account() != self.expected_is_workspace_account + { + return; + } + auth_provider_from_auth(&auth).add_auth_headers(headers); + } +} + // Some providers are meant to send no auth headers. Examples include local OSS // providers and custom test providers with `requires_openai_auth = false`. #[derive(Clone, Debug)] @@ -268,6 +297,23 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider { } } +/// Builds request-header auth that reads the current managed auth snapshot on +/// every request while remaining scoped to the expected auth identity. +/// +/// Callers with account-scoped state should pass the same snapshot that keyed +/// that state so a later account switch cannot reuse it. +pub fn auth_provider_from_auth_manager( + auth_manager: Arc, + expected_auth: &CodexAuth, +) -> SharedAuthProvider { + Arc::new(AuthManagerAuthProvider { + auth_manager, + expected_account_id: expected_auth.get_account_id(), + expected_chatgpt_user_id: expected_auth.get_chatgpt_user_id(), + expected_is_workspace_account: expected_auth.is_workspace_account(), + }) +} + #[cfg(test)] mod tests { use codex_agent_identity::generate_agent_key_material; @@ -275,9 +321,11 @@ mod tests { use codex_login::AuthKeyringBackendKind; use codex_login::auth::AgentIdentityAuthRecord; use codex_login::auth::BedrockApiKeyAuth; + use codex_login::auth::login_with_chatgpt_auth_tokens; use codex_model_provider_info::WireApi; use codex_model_provider_info::create_oss_provider_with_base_url; use codex_protocol::account::PlanType; + use http::header::AUTHORIZATION; use pretty_assertions::assert_eq; use serde_json::json; use std::path::Path; @@ -433,6 +481,64 @@ mod tests { } } + #[tokio::test] + async fn auth_manager_provider_follows_refreshes_but_not_account_switches() { + let codex_home = test_codex_home(); + login_with_chatgpt_auth_tokens( + &codex_home, + "header.e30.first", + "test-account", + /*chatgpt_plan_type*/ None, + ) + .expect("save initial auth"); + let auth_manager = Arc::new( + AuthManager::new( + codex_home.clone(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::Ephemeral, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, + ) + .await, + ); + let expected_auth = auth_manager + .auth_cached() + .expect("initial auth should be cached"); + let provider = auth_provider_from_auth_manager(Arc::clone(&auth_manager), &expected_auth); + + assert_eq!( + provider.to_auth_headers().get(AUTHORIZATION), + Some(&HeaderValue::from_static("Bearer header.e30.first")) + ); + + login_with_chatgpt_auth_tokens( + &codex_home, + "header.e30.reloaded", + "test-account", + /*chatgpt_plan_type*/ None, + ) + .expect("save reloaded auth"); + auth_manager.reload().await; + + assert_eq!( + provider.to_auth_headers().get(AUTHORIZATION), + Some(&HeaderValue::from_static("Bearer header.e30.reloaded")) + ); + + login_with_chatgpt_auth_tokens( + &codex_home, + "header.e30.other-account", + "other-account", + /*chatgpt_plan_type*/ None, + ) + .expect("save switched-account auth"); + auth_manager.reload().await; + + assert!(provider.to_auth_headers().is_empty()); + } + #[tokio::test] async fn first_party_run_scope_uses_agent_assertion_and_exposes_telemetry() { let auth = CodexAuth::AgentIdentity( diff --git a/codex-rs/model-provider/src/lib.rs b/codex-rs/model-provider/src/lib.rs index 15967c4fe033..39ff31e6e657 100644 --- a/codex-rs/model-provider/src/lib.rs +++ b/codex-rs/model-provider/src/lib.rs @@ -8,6 +8,7 @@ pub use auth::AgentIdentitySessionFallback; pub use auth::ProviderAuthScope; pub use auth::ResolvedProviderAuth; pub use auth::auth_provider_from_auth; +pub use auth::auth_provider_from_auth_manager; pub use auth::unauthenticated_auth_provider; pub use bearer_auth_provider::BearerAuthProvider; pub use bearer_auth_provider::BearerAuthProvider as CoreAuthProvider;