[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 authored and GitHub committed 2026-07-08 21:46:11 -04:00
1 parent 20e5edfa74
commit 555aa79d5a
12 files changed
+355 -3

No files matched your search

+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(