[connectors] Refresh codex_apps /ps/mcp auth (#31486)

[Codex Thread
019f2408-dc59-79f2-b245-4c11debd1a61](https://codex-thread-link.openai.chatgpt-team.site/thread/019f2408-dc59-79f2-b245-4c11debd1a61)

## Why

Long-lived Codex sessions can outlive the ChatGPT bearer token that was
present when the MCP runtime started.

The Responses path already recovers from token expiration by refreshing
or reloading the shared `AuthManager`. The reserved `codex_apps`
hosted-plugin client did not observe that update: `McpConnectionManager`
built its `/ps/mcp` HTTP auth once from a `CodexAuth` snapshot, and
`auth_provider_from_auth` copied that snapshot bearer into a static
`BearerAuthProvider`.

After the copied bearer expired, `/ps/mcp` kept sending it even though
Responses had a newer token in the same `AuthManager`. The failure
occurred before downstream connector execution, so unrelated apps such
as Gmail, Slack, and Google Calendar could all fail with the same
transport-level `401 token_expired`.

This replaces
[openai/codex#29474](https://github.com/openai/codex/pull/29474), which
was closed for inactivity without being merged. A new long-lived-session
report reproduced the same simultaneous `/ps/mcp` expiry pattern across
unrelated apps.

## What changed

- Add an `AuthManager`-backed request-header provider in
`codex-model-provider`. It keeps an `Arc<AuthManager>` and reads
`auth_cached()` for each outbound request, so the next `/ps/mcp` call
sees a token refreshed by the existing Responses/auth-recovery flow.
- Scope that provider to the startup account, ChatGPT user, and
workspace identity. Same-identity token reloads are followed; an account
switch emits no ambient auth until account-scoped MCP state is rebuilt.
- Have `McpConnectionManager` construct the dynamic provider only for
the reserved `codex_apps` registration used by the hosted-plugin
`/ps/mcp` path.

| MCP path | Auth behavior after this change |
| --- | --- |
| Reserved `codex_apps` hosted-plugin `/ps/mcp` | Read current
same-identity auth from the shared `AuthManager` per request |
| `codex_apps` with `CODEX_CONNECTORS_TOKEN` | Keep the environment
bearer-token override |
| User-configured/direct MCP registrations | Keep their existing
configured auth path |

## Non-goals

- No plugin-service changes.
- No downstream Slack, Gmail, Calendar, or other connector
OAuth/link-refresh changes.
- No auth UI changes.
- No behavior change for user-configured/direct MCP registrations.
- No new `/ps/mcp`-initiated token refresh; this makes `/ps/mcp` observe
refreshes already performed through the shared `AuthManager`.

## Tests

- `just test -p codex-model-provider`
- Covers same-identity token reloads and refuses a changed startup
identity.
- `just test -p codex-mcp`
- `just test -p codex-core mcp_auth_refresh`
- Creates the reserved hosted-plugin `codex_apps` `/ps/mcp` client
before the shared `AuthManager` changes, updates that same manager
through its public external-auth path, performs a real `tools/call`, and
asserts the request uses the current bearer.
This commit is contained in:
stevenlee-oai
2026-07-08 21:46:11 -04:00
committed by GitHub
parent 20e5edfa74
commit 555aa79d5a
12 changed files with 355 additions and 3 deletions
+20 -2
View File
@@ -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<Arc<AuthManager>>,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
elicitation_lifecycle: Option<crate::ElicitationLifecycle>,
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(),
@@ -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(),
+2
View File
@@ -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(),
+4
View File
@@ -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(),
+1
View File
@@ -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(),
+4
View File
@@ -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(),
+4
View File
@@ -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(),
+45 -1
View File
@@ -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<Self> {
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<Arc<AtomicUsize>>,
) {
Mock::given(method("POST"))
.and(path_regex("^/api/codex/apps/?$"))
.and(path_regex(mcp_path_regex))
.respond_with(CodexAppsJsonRpcResponder {
connector_name,
connector_description,
@@ -0,0 +1,168 @@
#![allow(clippy::unwrap_used)]
use anyhow::Result;
use codex_config::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_core::config::Constrained;
use codex_login::AuthKeyringBackendKind;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::ExternalAuth;
use codex_login::ExternalAuthFuture;
use codex_login::ExternalAuthRefreshContext;
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;
// Installs a known snapshot through AuthManager's public external-auth path.
struct StaticExternalAuth(CodexAuth);
impl ExternalAuth for StaticExternalAuth {
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async { Ok(self.0.clone()) })
}
fn refresh(&self, _context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async { Ok(self.0.clone()) })
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn hosted_plugin_runtime_ps_mcp_tool_calls_use_current_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()?);
let expected_auth = CodexAuth::from_external_chatgpt_tokens(
"header.e30.first",
"test-account",
/*chatgpt_plan_type*/ None,
)?;
let auth_manager = AuthManager::from_auth_for_testing_with_home(
expected_auth.clone(),
home.path().to_path_buf(),
);
// 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;
// The model-provider test covers AuthManager reload behavior. Keep this
// regression focused on core MCP wiring by updating the same shared
// manager after the MCP client has been created.
auth_manager
.set_external_auth(Arc::new(StaticExternalAuth(
CodexAuth::from_external_chatgpt_tokens(
"header.e30.reloaded",
"test-account",
/*chatgpt_plan_type*/ None,
)?,
)))
.await?;
// The manager and its static fallback were created before the auth update,
// 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::<Value>(&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(())
}
+1
View File
@@ -68,6 +68,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;
+104
View File
@@ -121,6 +121,35 @@ impl AuthProvider for HeaderAuthProvider {
}
}
struct AuthManagerAuthProvider {
auth_manager: Arc<AuthManager>,
// Startup auth is only the account-scoped identity anchor. Request
// headers always come from the current AuthManager snapshot below.
expected_auth: CodexAuth,
}
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() != self.expected_auth.get_account_id()
|| auth.get_chatgpt_user_id() != self.expected_auth.get_chatgpt_user_id()
|| auth.is_workspace_account() != self.expected_auth.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,21 @@ 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<AuthManager>,
expected_auth: &CodexAuth,
) -> SharedAuthProvider {
Arc::new(AuthManagerAuthProvider {
auth_manager,
expected_auth: expected_auth.clone(),
})
}
#[cfg(test)]
mod tests {
use codex_agent_identity::generate_agent_key_material;
@@ -275,9 +319,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 +479,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(
+1
View File
@@ -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;