Skip to main content

codex_model_provider/
auth.rs

1use std::sync::Arc;
2use std::sync::atomic::AtomicBool;
3use std::sync::atomic::Ordering;
4
5use codex_agent_identity::AgentIdentityKey;
6use codex_agent_identity::authorization_header_for_agent_task;
7use codex_api::AgentIdentityTelemetry;
8use codex_api::AuthProvider;
9use codex_api::SharedAuthProvider;
10use codex_login::AuthHeaders;
11use codex_login::AuthManager;
12use codex_login::CodexAuth;
13use codex_login::auth::AgentIdentityAuth;
14use codex_login::auth::AgentIdentityAuthError;
15use codex_login::auth::AgentIdentityAuthPolicy;
16use codex_model_provider_info::ModelProviderInfo;
17use codex_protocol::error::CodexErr;
18use codex_protocol::protocol::SessionSource;
19use http::HeaderMap;
20use http::HeaderValue;
21
22use crate::bearer_auth_provider::BearerAuthProvider;
23
24const BEDROCK_API_KEY_UNSUPPORTED_MESSAGE: &str =
25    "Bedrock API key auth is only supported by the Amazon Bedrock model provider";
26
27#[derive(Clone, Debug)]
28pub struct ProviderAuthScope {
29    pub agent_identity_policy: AgentIdentityAuthPolicy,
30    pub session_source: SessionSource,
31    pub agent_identity_session_fallback: AgentIdentitySessionFallback,
32}
33
34#[derive(Clone, Debug, Default)]
35pub struct AgentIdentitySessionFallback {
36    engaged: Arc<AtomicBool>,
37}
38
39impl AgentIdentitySessionFallback {
40    pub fn is_engaged(&self) -> bool {
41        self.engaged.load(Ordering::Relaxed)
42    }
43
44    fn engage(&self) -> bool {
45        !self.engaged.swap(true, Ordering::Relaxed)
46    }
47}
48
49/// Provider auth resolved for a request, plus metadata describing the effective auth.
50#[derive(Clone)]
51pub struct ResolvedProviderAuth {
52    pub auth: SharedAuthProvider,
53    pub agent_identity_telemetry: Option<AgentIdentityTelemetry>,
54}
55
56impl ResolvedProviderAuth {
57    pub(crate) fn new(auth: SharedAuthProvider) -> Self {
58        Self {
59            auth,
60            agent_identity_telemetry: None,
61        }
62    }
63
64    fn for_agent_identity(auth: AgentIdentityAuth) -> Self {
65        let agent_identity_telemetry = agent_identity_telemetry(&auth);
66        Self {
67            auth: Arc::new(AgentIdentityAuthProvider { auth }),
68            agent_identity_telemetry: Some(agent_identity_telemetry),
69        }
70    }
71}
72
73pub(crate) fn agent_identity_telemetry(auth: &AgentIdentityAuth) -> AgentIdentityTelemetry {
74    AgentIdentityTelemetry {
75        agent_id: auth.record().agent_runtime_id.clone(),
76        task_id: auth.run_task_id().to_string(),
77    }
78}
79
80#[derive(Clone, Debug)]
81struct AgentIdentityAuthProvider {
82    auth: AgentIdentityAuth,
83}
84
85impl AuthProvider for AgentIdentityAuthProvider {
86    fn add_auth_headers(&self, headers: &mut HeaderMap) {
87        let record = self.auth.record();
88        let header_value = authorization_header_for_agent_task(
89            AgentIdentityKey {
90                agent_runtime_id: &record.agent_runtime_id,
91                private_key_pkcs8_base64: &record.agent_private_key,
92            },
93            self.auth.run_task_id(),
94        )
95        .map_err(std::io::Error::other);
96
97        if let Ok(header_value) = header_value
98            && let Ok(header) = HeaderValue::from_str(&header_value)
99        {
100            let _ = headers.insert(http::header::AUTHORIZATION, header);
101        }
102
103        if let Ok(header) = HeaderValue::from_str(self.auth.account_id()) {
104            let _ = headers.insert("ChatGPT-Account-ID", header);
105        }
106
107        if self.auth.is_fedramp_account() {
108            let _ = headers.insert("X-OpenAI-Fedramp", HeaderValue::from_static("true"));
109        }
110    }
111}
112
113#[derive(Clone, Debug)]
114struct HeaderAuthProvider {
115    auth: AuthHeaders,
116}
117
118impl AuthProvider for HeaderAuthProvider {
119    fn add_auth_headers(&self, headers: &mut HeaderMap) {
120        headers.extend(self.auth.headers().clone());
121    }
122}
123
124struct AuthManagerAuthProvider {
125    auth_manager: Arc<AuthManager>,
126    // Startup auth is only the account-scoped identity anchor. Request
127    // headers always come from the current AuthManager snapshot below.
128    expected_auth: CodexAuth,
129}
130
131impl AuthProvider for AuthManagerAuthProvider {
132    fn add_auth_headers(&self, headers: &mut HeaderMap) {
133        let Some(auth) = self
134            .auth_manager
135            .auth_cached()
136            .filter(CodexAuth::uses_codex_backend)
137        else {
138            return;
139        };
140        // The caller's account-scoped state was built for the expected
141        // identity. Follow token refreshes for that identity, but never cross
142        // an account or workspace boundary without rebuilding that state.
143        if auth.get_account_id() != self.expected_auth.get_account_id()
144            || auth.get_chatgpt_user_id() != self.expected_auth.get_chatgpt_user_id()
145            || auth.is_workspace_account() != self.expected_auth.is_workspace_account()
146        {
147            return;
148        }
149        auth_provider_from_auth(&auth).add_auth_headers(headers);
150    }
151}
152
153// Some providers are meant to send no auth headers. Examples include local OSS
154// providers and custom test providers with `requires_openai_auth = false`.
155#[derive(Clone, Debug)]
156struct UnauthenticatedAuthProvider;
157
158impl AuthProvider for UnauthenticatedAuthProvider {
159    fn add_auth_headers(&self, _headers: &mut HeaderMap) {}
160}
161
162pub fn unauthenticated_auth_provider() -> SharedAuthProvider {
163    Arc::new(UnauthenticatedAuthProvider)
164}
165
166/// Returns the provider-scoped auth manager when this provider uses command-backed auth.
167///
168/// Providers without custom auth continue using the caller-supplied base manager, when present.
169pub(crate) fn auth_manager_for_provider(
170    auth_manager: Option<Arc<AuthManager>>,
171    provider: &ModelProviderInfo,
172) -> Option<Arc<AuthManager>> {
173    match provider.auth.clone() {
174        Some(config) => Some(AuthManager::external_bearer_only(config)),
175        None => auth_manager,
176    }
177}
178
179pub(crate) fn resolve_provider_auth(
180    auth: Option<&CodexAuth>,
181    provider: &ModelProviderInfo,
182) -> codex_protocol::error::Result<SharedAuthProvider> {
183    if matches!(auth, Some(CodexAuth::BedrockApiKey(_))) {
184        return Err(CodexErr::UnsupportedOperation(
185            BEDROCK_API_KEY_UNSUPPORTED_MESSAGE.to_string(),
186        ));
187    }
188
189    if let Some(auth) = bearer_auth_for_provider(provider)? {
190        return Ok(Arc::new(auth));
191    }
192
193    Ok(match auth {
194        Some(auth) => auth_provider_from_auth(auth),
195        None => unauthenticated_auth_provider(),
196    })
197}
198
199pub(crate) async fn resolve_provider_auth_for_scope(
200    auth_manager: Option<Arc<AuthManager>>,
201    auth: Option<&CodexAuth>,
202    provider: &ModelProviderInfo,
203    scope: ProviderAuthScope,
204) -> codex_protocol::error::Result<ResolvedProviderAuth> {
205    let ProviderAuthScope {
206        agent_identity_policy,
207        session_source,
208        agent_identity_session_fallback,
209    } = scope;
210    if let Some(CodexAuth::AgentIdentity(agent_identity_auth)) = auth {
211        return Ok(ResolvedProviderAuth::for_agent_identity(
212            agent_identity_auth.clone(),
213        ));
214    }
215
216    if !should_bootstrap_chatgpt_agent_identity(agent_identity_policy, auth)
217        || agent_identity_session_fallback.is_engaged()
218    {
219        return resolve_provider_auth(auth, provider).map(ResolvedProviderAuth::new);
220    }
221
222    let Some(auth_manager) = auth_manager else {
223        return resolve_provider_auth(auth, provider).map(ResolvedProviderAuth::new);
224    };
225
226    match auth_manager
227        .agent_identity_auth(agent_identity_policy, session_source)
228        .await
229    {
230        Ok(Some(agent_identity_auth)) => Ok(ResolvedProviderAuth::for_agent_identity(
231            agent_identity_auth,
232        )),
233        Ok(None) => resolve_provider_auth(auth, provider).map(ResolvedProviderAuth::new),
234        Err(err) => {
235            if let Some(AgentIdentityAuthError::BootstrapUnavailable {
236                operation,
237                attempts,
238                message,
239            }) = err
240                .get_ref()
241                .and_then(|source| source.downcast_ref::<AgentIdentityAuthError>())
242            {
243                let newly_engaged = agent_identity_session_fallback.engage();
244                tracing::warn!(
245                    operation,
246                    attempts = *attempts,
247                    error = %message,
248                    newly_engaged,
249                    "agent identity bootstrap unavailable; using ChatGPT bearer auth for this session"
250                );
251                resolve_provider_auth(auth, provider).map(ResolvedProviderAuth::new)
252            } else {
253                Err(err.into())
254            }
255        }
256    }
257}
258
259fn should_bootstrap_chatgpt_agent_identity(
260    agent_identity_policy: AgentIdentityAuthPolicy,
261    auth: Option<&CodexAuth>,
262) -> bool {
263    agent_identity_policy == AgentIdentityAuthPolicy::ChatGptAuth
264        && matches!(auth, Some(CodexAuth::Chatgpt(_)))
265}
266
267fn bearer_auth_for_provider(
268    provider: &ModelProviderInfo,
269) -> codex_protocol::error::Result<Option<BearerAuthProvider>> {
270    if let Some(api_key) = provider.api_key()? {
271        return Ok(Some(BearerAuthProvider::new(api_key)));
272    }
273
274    if let Some(token) = provider.experimental_bearer_token.clone() {
275        return Ok(Some(BearerAuthProvider::new(token)));
276    }
277
278    Ok(None)
279}
280
281/// Builds request-header auth for a first-party Codex auth snapshot.
282pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider {
283    match auth {
284        CodexAuth::AgentIdentity(auth) => {
285            Arc::new(AgentIdentityAuthProvider { auth: auth.clone() })
286        }
287        CodexAuth::Headers(auth) => Arc::new(HeaderAuthProvider { auth: auth.clone() }),
288        CodexAuth::BedrockApiKey(_) => unreachable!("{BEDROCK_API_KEY_UNSUPPORTED_MESSAGE}"),
289        CodexAuth::ApiKey(_)
290        | CodexAuth::Chatgpt(_)
291        | CodexAuth::ChatgptAuthTokens(_)
292        | CodexAuth::PersonalAccessToken(_) => Arc::new(BearerAuthProvider {
293            token: auth.get_token().ok(),
294            account_id: auth.get_account_id(),
295            is_fedramp_account: auth.is_fedramp_account(),
296        }),
297    }
298}
299
300/// Builds request-header auth that reads the current managed auth snapshot on
301/// every request while remaining scoped to the expected auth identity.
302///
303/// Callers with account-scoped state should pass the same snapshot that keyed
304/// that state so a later account switch cannot reuse it.
305pub fn auth_provider_from_auth_manager(
306    auth_manager: Arc<AuthManager>,
307    expected_auth: &CodexAuth,
308) -> SharedAuthProvider {
309    Arc::new(AuthManagerAuthProvider {
310        auth_manager,
311        expected_auth: expected_auth.clone(),
312    })
313}
314
315#[cfg(test)]
316mod tests {
317    use codex_agent_identity::generate_agent_key_material;
318    use codex_login::AuthCredentialsStoreMode;
319    use codex_login::AuthKeyringBackendKind;
320    use codex_login::auth::AgentIdentityAuthRecord;
321    use codex_login::auth::BedrockApiKeyAuth;
322    use codex_login::auth::login_with_chatgpt_auth_tokens;
323    use codex_model_provider_info::WireApi;
324    use codex_model_provider_info::create_oss_provider_with_base_url;
325    use codex_protocol::account::PlanType;
326    use http::header::AUTHORIZATION;
327    use pretty_assertions::assert_eq;
328    use serde_json::json;
329    use std::path::Path;
330    use std::path::PathBuf;
331    use std::sync::atomic::AtomicUsize;
332    use std::sync::atomic::Ordering;
333    use wiremock::Mock;
334    use wiremock::MockServer;
335    use wiremock::ResponseTemplate;
336    use wiremock::matchers::method;
337    use wiremock::matchers::path;
338
339    use super::*;
340
341    static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0);
342    const TEST_CHATGPT_ID_TOKEN: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaHR0cHM6Ly9hcGkub3BlbmFpLmNvbS9hdXRoIjp7ImNoYXRncHRfdXNlcl9pZCI6InVzZXItMTIzNDUiLCJ1c2VyX2lkIjoidXNlci0xMjM0NSIsImNoYXRncHRfcGxhbl90eXBlIjoicHJvIiwiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjb3VudC0xMjMifX0.c2ln";
343
344    async fn agent_identity_auth(chatgpt_account_is_fedramp: bool) -> AgentIdentityAuth {
345        let key_material = generate_agent_key_material().expect("generate key material");
346        AgentIdentityAuth::from_record(
347            AgentIdentityAuthRecord {
348                agent_runtime_id: "agent-runtime-1".to_string(),
349                agent_private_key: key_material.private_key_pkcs8_base64,
350                account_id: "account-1".to_string(),
351                chatgpt_user_id: "user-1".to_string(),
352                email: Some("agent@example.com".to_string()),
353                plan_type: PlanType::Plus,
354                chatgpt_account_is_fedramp,
355                task_id: Some("task-run-1".to_string()),
356            },
357            "https://auth.openai.com/api/accounts",
358            /*auth_route_config*/ None,
359        )
360        .await
361        .expect("agent identity auth record should include task id")
362    }
363
364    fn provider_auth_scope(
365        policy: AgentIdentityAuthPolicy,
366        fallback: AgentIdentitySessionFallback,
367    ) -> ProviderAuthScope {
368        ProviderAuthScope {
369            agent_identity_policy: policy,
370            session_source: SessionSource::Cli,
371            agent_identity_session_fallback: fallback,
372        }
373    }
374
375    fn test_codex_home() -> PathBuf {
376        let id = NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed);
377        let path = std::env::temp_dir().join(format!(
378            "codex-model-provider-agent-identity-{pid}-{id}",
379            pid = std::process::id()
380        ));
381        let _ = std::fs::remove_dir_all(&path);
382        std::fs::create_dir_all(&path).expect("create temp codex home");
383        path
384    }
385
386    fn write_chatgpt_auth_json(codex_home: &Path) {
387        let auth_json = json!({
388            "tokens": {
389                "id_token": TEST_CHATGPT_ID_TOKEN,
390                "access_token": "test-access-token",
391                "refresh_token": "test-refresh-token",
392                "account_id": "account-123"
393            },
394            "last_refresh": "2099-01-01T00:00:00Z"
395        });
396        std::fs::write(
397            codex_home.join("auth.json"),
398            serde_json::to_string_pretty(&auth_json).expect("serialize auth.json"),
399        )
400        .expect("write auth.json");
401    }
402
403    async fn chatgpt_auth_manager(
404        agent_identity_authapi_base_url: String,
405    ) -> (PathBuf, Arc<AuthManager>, CodexAuth) {
406        let codex_home = test_codex_home();
407        write_chatgpt_auth_json(&codex_home);
408        let auth_manager = AuthManager::shared(
409            codex_home.clone(),
410            /*enable_codex_api_key_env*/ false,
411            AuthCredentialsStoreMode::File,
412            /*forced_chatgpt_workspace_id*/ None,
413            /*chatgpt_base_url*/ None,
414            AuthKeyringBackendKind::default(),
415            codex_login::test_support::transport_default_auth_route_config(),
416        )
417        .await;
418        let auth = auth_manager.auth().await.expect("auth should load");
419        let auth_manager = AuthManager::from_auth_for_testing_with_agent_identity_authapi_base_url(
420            auth.clone(),
421            agent_identity_authapi_base_url,
422        );
423        (codex_home, auth_manager, auth)
424    }
425
426    async fn mount_transient_agent_registration(
427        server: &MockServer,
428        status: u16,
429        registration_count: Arc<AtomicUsize>,
430    ) {
431        Mock::given(method("POST"))
432            .and(path("/v1/agent/register"))
433            .respond_with(move |_request: &wiremock::Request| {
434                registration_count.fetch_add(1, Ordering::SeqCst);
435                ResponseTemplate::new(status)
436            })
437            .mount(server)
438            .await;
439    }
440
441    #[test]
442    fn unauthenticated_auth_provider_adds_no_headers() {
443        let provider =
444            create_oss_provider_with_base_url("http://localhost:11434/v1", WireApi::Responses);
445        let auth = resolve_provider_auth(/*auth*/ None, &provider).expect("auth should resolve");
446
447        assert!(auth.to_auth_headers().is_empty());
448    }
449
450    #[test]
451    fn header_auth_adds_predefined_headers() {
452        let mut expected = HeaderMap::new();
453        expected.insert(
454            http::header::AUTHORIZATION,
455            HeaderValue::from_static("Bearer external"),
456        );
457        expected.insert("x-external-auth", HeaderValue::from_static("enabled"));
458        let auth = CodexAuth::Headers(AuthHeaders::new(expected.clone()));
459
460        let actual = auth_provider_from_auth(&auth).to_auth_headers();
461
462        assert_eq!(actual, expected);
463    }
464
465    #[test]
466    fn openai_provider_rejects_bedrock_api_key_auth() {
467        let provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
468        let auth = CodexAuth::BedrockApiKey(BedrockApiKeyAuth {
469            api_key: "bedrock-api-key-test".to_string(),
470            region: "us-east-1".to_string(),
471        });
472
473        match resolve_provider_auth(Some(&auth), &provider) {
474            Err(CodexErr::UnsupportedOperation(message)) => {
475                assert_eq!(message, BEDROCK_API_KEY_UNSUPPORTED_MESSAGE);
476            }
477            Err(err) => panic!("unexpected auth error: {err:?}"),
478            Ok(_) => panic!("Bedrock API key auth should be rejected"),
479        }
480    }
481
482    #[tokio::test]
483    async fn auth_manager_provider_follows_refreshes_but_not_account_switches() {
484        let codex_home = test_codex_home();
485        login_with_chatgpt_auth_tokens(
486            &codex_home,
487            "header.e30.first",
488            "test-account",
489            /*chatgpt_plan_type*/ None,
490        )
491        .expect("save initial auth");
492        let auth_manager = Arc::new(
493            AuthManager::new(
494                codex_home.clone(),
495                /*enable_codex_api_key_env*/ false,
496                AuthCredentialsStoreMode::Ephemeral,
497                /*forced_chatgpt_workspace_id*/ None,
498                /*chatgpt_base_url*/ None,
499                AuthKeyringBackendKind::default(),
500                codex_login::test_support::transport_default_auth_route_config(),
501            )
502            .await,
503        );
504        let expected_auth = auth_manager
505            .auth_cached()
506            .expect("initial auth should be cached");
507        let provider = auth_provider_from_auth_manager(Arc::clone(&auth_manager), &expected_auth);
508
509        assert_eq!(
510            provider.to_auth_headers().get(AUTHORIZATION),
511            Some(&HeaderValue::from_static("Bearer header.e30.first"))
512        );
513
514        login_with_chatgpt_auth_tokens(
515            &codex_home,
516            "header.e30.reloaded",
517            "test-account",
518            /*chatgpt_plan_type*/ None,
519        )
520        .expect("save reloaded auth");
521        auth_manager.reload().await;
522
523        assert_eq!(
524            provider.to_auth_headers().get(AUTHORIZATION),
525            Some(&HeaderValue::from_static("Bearer header.e30.reloaded"))
526        );
527
528        login_with_chatgpt_auth_tokens(
529            &codex_home,
530            "header.e30.other-account",
531            "other-account",
532            /*chatgpt_plan_type*/ None,
533        )
534        .expect("save switched-account auth");
535        auth_manager.reload().await;
536
537        assert!(provider.to_auth_headers().is_empty());
538    }
539
540    #[tokio::test]
541    async fn first_party_run_scope_uses_agent_assertion_and_exposes_telemetry() {
542        let auth = CodexAuth::AgentIdentity(
543            agent_identity_auth(/*chatgpt_account_is_fedramp*/ false).await,
544        );
545        let provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
546
547        let auth = resolve_provider_auth_for_scope(
548            /*auth_manager*/ None,
549            Some(&auth),
550            &provider,
551            provider_auth_scope(
552                AgentIdentityAuthPolicy::JwtOnly,
553                AgentIdentitySessionFallback::default(),
554            ),
555        )
556        .await
557        .expect("auth should resolve");
558
559        assert_eq!(
560            auth.agent_identity_telemetry,
561            Some(AgentIdentityTelemetry {
562                agent_id: "agent-runtime-1".to_string(),
563                task_id: "task-run-1".to_string(),
564            })
565        );
566        let headers = auth.auth.to_auth_headers();
567        assert!(
568            headers
569                .get(http::header::AUTHORIZATION)
570                .and_then(|value| value.to_str().ok())
571                .is_some_and(|value| value.starts_with("AgentAssertion "))
572        );
573    }
574
575    #[tokio::test]
576    async fn agent_identity_auth_provider_preserves_account_routing_headers() {
577        let auth = agent_identity_auth(/*chatgpt_account_is_fedramp*/ true).await;
578        let provider = auth_provider_from_auth(&CodexAuth::AgentIdentity(auth));
579
580        let headers = provider.to_auth_headers();
581
582        assert!(
583            headers
584                .get(http::header::AUTHORIZATION)
585                .and_then(|value| value.to_str().ok())
586                .is_some_and(|value| value.starts_with("AgentAssertion "))
587        );
588        assert_eq!(
589            headers
590                .get("ChatGPT-Account-ID")
591                .and_then(|value| value.to_str().ok()),
592            Some("account-1")
593        );
594        assert_eq!(
595            headers
596                .get("X-OpenAI-Fedramp")
597                .and_then(|value| value.to_str().ok()),
598            Some("true")
599        );
600    }
601
602    #[tokio::test]
603    async fn chatgpt_bootstrap_unavailable_uses_session_bearer_fallback() {
604        let server = MockServer::start().await;
605        let registration_count = Arc::new(AtomicUsize::new(0));
606        mount_transient_agent_registration(
607            &server,
608            /*status*/ 503,
609            Arc::clone(&registration_count),
610        )
611        .await;
612        let (_codex_home, auth_manager, auth) = chatgpt_auth_manager(server.uri()).await;
613        let provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
614        let fallback = AgentIdentitySessionFallback::default();
615
616        let provider_auth = resolve_provider_auth_for_scope(
617            Some(auth_manager),
618            Some(&auth),
619            &provider,
620            provider_auth_scope(AgentIdentityAuthPolicy::ChatGptAuth, fallback.clone()),
621        )
622        .await
623        .expect("fallback should resolve bearer auth");
624
625        let headers = provider_auth.auth.to_auth_headers();
626        assert_eq!(
627            headers
628                .get(http::header::AUTHORIZATION)
629                .and_then(|value| value.to_str().ok()),
630            Some("Bearer test-access-token")
631        );
632        assert_eq!(
633            headers
634                .get("ChatGPT-Account-ID")
635                .and_then(|value| value.to_str().ok()),
636            Some("account-123")
637        );
638        assert!(fallback.is_engaged());
639        assert_eq!(registration_count.load(Ordering::SeqCst), 3);
640    }
641
642    #[tokio::test]
643    async fn chatgpt_session_fallback_skips_later_agent_identity_bootstrap() {
644        let server = MockServer::start().await;
645        let registration_count = Arc::new(AtomicUsize::new(0));
646        mount_transient_agent_registration(
647            &server,
648            /*status*/ 503,
649            Arc::clone(&registration_count),
650        )
651        .await;
652        let (_codex_home, auth_manager, auth) = chatgpt_auth_manager(server.uri()).await;
653        let provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
654        let fallback = AgentIdentitySessionFallback::default();
655
656        resolve_provider_auth_for_scope(
657            Some(Arc::clone(&auth_manager)),
658            Some(&auth),
659            &provider,
660            provider_auth_scope(AgentIdentityAuthPolicy::ChatGptAuth, fallback.clone()),
661        )
662        .await
663        .expect("first fallback should resolve bearer auth");
664        resolve_provider_auth_for_scope(
665            Some(auth_manager),
666            Some(&auth),
667            &provider,
668            provider_auth_scope(AgentIdentityAuthPolicy::ChatGptAuth, fallback),
669        )
670        .await
671        .expect("second fallback should resolve bearer auth");
672
673        assert_eq!(registration_count.load(Ordering::SeqCst), 3);
674    }
675
676    #[tokio::test]
677    async fn chatgpt_sessions_share_bootstrap_failure_cooldown() {
678        let server = MockServer::start().await;
679        let registration_count = Arc::new(AtomicUsize::new(0));
680        mount_transient_agent_registration(
681            &server,
682            /*status*/ 503,
683            Arc::clone(&registration_count),
684        )
685        .await;
686        let (_codex_home, auth_manager, auth) = chatgpt_auth_manager(server.uri()).await;
687        let provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None);
688        let first_fallback = AgentIdentitySessionFallback::default();
689        let second_fallback = AgentIdentitySessionFallback::default();
690
691        resolve_provider_auth_for_scope(
692            Some(Arc::clone(&auth_manager)),
693            Some(&auth),
694            &provider,
695            provider_auth_scope(AgentIdentityAuthPolicy::ChatGptAuth, first_fallback.clone()),
696        )
697        .await
698        .expect("first session fallback should resolve bearer auth");
699        resolve_provider_auth_for_scope(
700            Some(auth_manager),
701            Some(&auth),
702            &provider,
703            provider_auth_scope(
704                AgentIdentityAuthPolicy::ChatGptAuth,
705                second_fallback.clone(),
706            ),
707        )
708        .await
709        .expect("second session fallback should resolve bearer auth");
710
711        assert!(first_fallback.is_engaged());
712        assert!(second_fallback.is_engaged());
713        assert_eq!(registration_count.load(Ordering::SeqCst), 3);
714    }
715}