//! Tests gateway-manager sharing, credential refresh, and model catalog cache isolation. //! Discovery and inference must preserve primary auth and reject requests when gateway auth fails. use super::*; use crate::AgentIdentitySessionFallback; use crate::ProviderAuthScope; use crate::create_model_provider; use crate::test_support::seed_gateway_auth; use codex_api::Compression; use codex_api::ResponsesClient; use codex_http_client::ClientRouteClass; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use codex_http_client::ReqwestTransport; use codex_login::AuthHeaders; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::auth::AgentIdentityAuthPolicy; use codex_login::default_client::ClientRedirectPolicy; use codex_login::default_client::create_client_for_route; use codex_model_provider_info::GatewayOAuthConfig; use codex_model_provider_info::GatewayOAuthDelivery; use codex_model_provider_info::GatewayOAuthSettings; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OAuthCredentialsStore; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::error::CodexErrorDetails; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::SessionSource; use http::HeaderMap; use http::HeaderName; use http::HeaderValue; use pretty_assertions::assert_eq; use serde_json::json; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; #[tokio::test] async fn gateway_credentials_accompany_primary_auth_in_models_and_responses() { let server = MockServer::start().await; let home = tempfile::tempdir().unwrap(); Mock::given(method("GET")) .and(path("/models")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"models": []}))) .expect(2) .mount(&server) .await; Mock::given(method("POST")) .and(path("/responses")) .respond_with( ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") .set_body_string( "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\"}}\n\n", ), ) .expect(2) .mount(&server) .await; let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault); for delivery in [ GatewayOAuthDelivery::Header { name: "x-gateway-auth".into(), scheme: "Bearer".into(), }, GatewayOAuthDelivery::Cookie { name: "gateway_session".into(), }, ] { let config = GatewayOAuthSettings { authorization_url: format!("{}/authorize", server.uri()), token_url: format!("{}/token", server.uri()), client_id: "test".into(), resource: None, scopes: vec![], redirect_port: None, // Avoid spending the model discovery deadline on keyring-backed decryption. credentials_store: OAuthCredentialsStore::File, delivery: delivery.clone(), }; let info = ModelProviderInfo { gateway_oauth: Some(GatewayOAuthConfig::OAuth(config)), ..ModelProviderInfo::create_openai_provider(Some(server.uri())) }; let mut primary_headers = HeaderMap::new(); primary_headers.insert( "authorization", HeaderValue::from_static("Bearer primary-token"), ); primary_headers.insert("chatgpt-account-id", HeaderValue::from_static("account")); let primary = AuthManager::from_auth_for_testing_with_home( CodexAuth::Headers(AuthHeaders::new(primary_headers)), home.path().to_path_buf(), ); let manager = seed_gateway_auth( &info, &primary, json!({"access_token": "gateway-token", "refresh_token": "refresh-secret", "expires_at": i64::MAX}), ); let provider = create_model_provider(info.clone(), Some(primary)); assert!(Arc::ptr_eq( &provider .gateway_auth_manager() .unwrap() .unwrap() .oauth_session() .unwrap(), &manager, )); let models = provider.models_manager_without_cache(/*config_model_catalog*/ None); models.set_api_key_model_discovery_enabled(/*enabled*/ true); models .raw_model_catalog(RefreshStrategy::Online, factory.clone()) .await; let (api, resolved) = provider .request_provider_and_auth(/*scope*/ None) .await .unwrap(); let auth = resolved.auth; let scoped = provider .api_auth_for_scope(ProviderAuthScope { agent_identity_policy: AgentIdentityAuthPolicy::JwtOnly, session_source: SessionSource::Cli, agent_identity_session_fallback: AgentIdentitySessionFallback::default(), }) .await .unwrap(); let mut expected = HeaderMap::from_iter([ ( http::header::AUTHORIZATION, HeaderValue::from_static("Bearer primary-token"), ), ( HeaderName::from_static("chatgpt-account-id"), HeaderValue::from_static("account"), ), ]); let (name, value) = match delivery { GatewayOAuthDelivery::Header { .. } => ("x-gateway-auth", "Bearer gateway-token"), GatewayOAuthDelivery::Cookie { .. } => ("cookie", "gateway_session=gateway-token"), }; expected.insert( HeaderName::from_static(name), HeaderValue::from_static(value), ); // Legacy consumers and asynchronous handshakes preserve the same gateway credential. assert_eq!(auth.to_auth_headers().unwrap(), expected); assert_eq!( auth.apply_auth_headers(HeaderMap::new()).await.unwrap(), expected ); assert_eq!(scoped.auth.to_auth_headers().unwrap(), expected); assert!(auth.to_auth_headers().unwrap()[name].is_sensitive()); let transport = ReqwestTransport::from_http_client( create_client_for_route( &factory, &api.url_for_path("responses"), ClientRouteClass::Api, ClientRedirectPolicy::Default, ) .unwrap(), ); let client = ResponsesClient::new(transport, api, auth); let _stream = client .stream( json!({"model": "test", "input": []}), HeaderMap::new(), Compression::None, /*turn_state*/ None, ) .await .unwrap(); } let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 4); for request in requests { assert_eq!(request.headers["authorization"], "Bearer primary-token"); assert!( request .headers .get("x-gateway-auth") .is_some_and(|value| value == "Bearer gateway-token") || request .headers .get("cookie") .is_some_and(|value| value == "gateway_session=gateway-token") ); } } #[tokio::test] async fn gateway_preserves_runtime_auth_and_rejects_header_collisions() { use codex_features::RuntimeAuthMode; use codex_runtime_agent_instance::AgentHostAuthorization; use codex_runtime_agent_instance::RuntimeAuthManager; use codex_runtime_agent_instance::RuntimeRegistrationMetadata; use codex_runtime_agent_instance::RuntimeRole; for mode in [RuntimeAuthMode::DualHeader, RuntimeAuthMode::AssertionOnly] { let server = MockServer::start().await; let home = tempfile::tempdir().unwrap(); Mock::given(method("POST")) .and(path("/register")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "runtime_agent_instance_id": "runtime-a", "agent_host_id": "host-a", "actor": {"kind":"platform", "user_id":"user-a", "organization_id":"org-a", "project_id":null}, }))) .expect(1) .mount(&server) .await; let primary = AuthManager::from_auth_for_testing_with_home( CodexAuth::from_api_key("primary-token"), home.path().to_path_buf(), ); let info = ModelProviderInfo { gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings { authorization_url: format!("{}/authorize", server.uri()), token_url: format!("{}/token", server.uri()), client_id: "client".into(), resource: None, scopes: vec![], redirect_port: None, credentials_store: Default::default(), delivery: GatewayOAuthDelivery::Header { name: "x-gateway-auth".into(), scheme: "Bearer".into(), }, })), ..ModelProviderInfo::create_openai_provider(Some(server.uri())) }; let _gateway = seed_gateway_auth( &info, &primary, json!({"access_token": "gateway-token", "expires_at": i64::MAX}), ); let runtime = Arc::new(RuntimeAuthManager::new( Arc::clone(&primary), AgentHostAuthorization::InstallationId { installation_id: "00000000-0000-0000-0000-000000000000".parse().unwrap(), }, RuntimeRegistrationMetadata { harness: None, harness_version: None, role: RuntimeRole::Orchestrator, }, format!("{}/register", server.uri()).parse().unwrap(), )); let provider = crate::test_support::runtime_model_provider(info, primary, (runtime, mode)); let auth = provider.api_auth().await.unwrap(); let stale_headers = HeaderMap::from_iter([ ( http::header::AUTHORIZATION, HeaderValue::from_static("Bearer stale"), ), ( HeaderName::from_static("x-openai-agent-assertion"), HeaderValue::from_static("stale"), ), ( HeaderName::from_static("chatgpt-account-id"), HeaderValue::from_static("stale"), ), ( HeaderName::from_static("x-caller"), HeaderValue::from_static("retained"), ), ]); let headers = auth .apply_auth_headers(stale_headers.clone()) .await .unwrap(); assert_eq!(headers["x-gateway-auth"], "Bearer gateway-token"); assert_eq!(headers["x-caller"], "retained"); assert!(!headers.contains_key("chatgpt-account-id")); let proof_header = match mode { RuntimeAuthMode::DualHeader => { assert_eq!(headers["authorization"], "Bearer primary-token"); "x-openai-agent-assertion" } RuntimeAuthMode::AssertionOnly => { assert!(!headers.contains_key("x-openai-agent-assertion")); assert!( headers["authorization"] .to_str() .unwrap() .starts_with("AgentAssertion 1.") ); "authorization" } }; let mut request = codex_http_client::Request::new(http::Method::POST, server.uri()); request.headers = stale_headers.clone(); let signed = auth.apply_auth(request).await.unwrap(); assert_eq!(signed.headers["x-gateway-auth"], "Bearer gateway-token"); assert_ne!(headers[proof_header], signed.headers[proof_header]); let mut collision = stale_headers; collision.insert("x-gateway-auth", HeaderValue::from_static("caller-token")); assert!(auth.apply_auth_headers(collision.clone()).await.is_err()); let mut request = codex_http_client::Request::new(http::Method::POST, server.uri()); request.headers = collision; assert!(auth.apply_auth(request).await.is_err()); assert_eq!(server.received_requests().await.unwrap().len(), 1); server.verify().await; } } #[tokio::test] async fn gateway_refresh_preserves_primary_auth_and_hides_issuer_errors() { for succeeds in [true, false] { let server = MockServer::start().await; let home = tempfile::tempdir().unwrap(); let primary = AuthManager::from_auth_for_testing_with_home( CodexAuth::from_api_key("primary-token"), home.path().to_path_buf(), ); let info = ModelProviderInfo { gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings { authorization_url: format!("{}/authorize", server.uri()), token_url: format!("{}/token?credential=query-secret", server.uri()), client_id: "client".into(), resource: None, scopes: vec![], redirect_port: None, credentials_store: Default::default(), delivery: GatewayOAuthDelivery::Header { name: "x-gateway-auth".into(), scheme: "Bearer".into(), }, })), ..ModelProviderInfo::create_openai_provider(Some(server.uri())) }; let body = if succeeds { json!({"access_token": "rotated-token", "expires_in": 3600}) } else { json!({"error": "server_error", "error_description": format!("query-secret {}", "sensitive ".repeat(4000))}) }; Mock::given(method("POST")) .and(path("/token")) .respond_with( ResponseTemplate::new(if succeeds { 200 } else { 500 }).set_body_json(body), ) .expect(1) .mount(&server) .await; let _gateway = seed_gateway_auth( &info, &primary, json!({"access_token": "expired", "refresh_token": "refresh-secret", "expires_at": 0}), ); let provider = create_model_provider(info, Some(primary.clone())); let result = provider.api_auth().await; if succeeds { assert_eq!( result.unwrap().to_auth_headers().unwrap(), HeaderMap::from_iter([ ( http::header::AUTHORIZATION, HeaderValue::from_static("Bearer primary-token") ), ( HeaderName::from_static("x-gateway-auth"), HeaderValue::from_static("Bearer rotated-token") ), ]) ); } else { let error = match result { Ok(_) => panic!("issuer failure should fail auth"), Err(error) => error, }; assert!(error.retry_delay(/*retry_count*/ 1).is_some()); assert_eq!( error.to_string(), "Gateway OAuth authentication failed; check the gateway configuration and credential store." ); } assert_eq!( primary.auth_cached(), Some(CodexAuth::from_api_key("primary-token")) ); let requests = server.received_requests().await.unwrap(); assert!(!requests[0].headers.contains_key("authorization")); assert!(!requests[0].headers.contains_key("x-gateway-auth")); assert_eq!( std::str::from_utf8(&requests[0].body) .unwrap() .split('&') .collect::>(), std::collections::BTreeSet::from([ "grant_type=refresh_token", "refresh_token=refresh-secret", "client_id=client" ]) ); } } #[tokio::test] async fn models_cache_is_reused_only_for_matching_gateway_configuration() { let server = MockServer::start().await; let home = tempfile::tempdir().unwrap(); let primary = AuthManager::from_auth_for_testing_with_home( CodexAuth::from_api_key("primary-token"), home.path().to_path_buf(), ); let mut info = ModelProviderInfo { model_catalog_url: Some(format!("{}/models", server.uri()).into()), http_headers: Some(std::collections::HashMap::from([( codex_login::default_client::RESIDENCY_HEADER_NAME.to_string(), "us".into(), )])), gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings { authorization_url: format!("{}/authorize", server.uri()), token_url: format!("{}/token", server.uri()), client_id: "client".into(), resource: None, scopes: vec![], redirect_port: None, // Keyring latency is outside the cache behavior under test. credentials_store: OAuthCredentialsStore::File, delivery: GatewayOAuthDelivery::Header { name: "x-gateway-auth".into(), scheme: "Bearer".into(), }, })), ..ModelProviderInfo::create_openai_provider(Some(server.uri())) }; let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault); let mut model = codex_models_manager::bundled_models_response() .unwrap() .models .remove(0); for resource in ["first", "second"] { let Some(GatewayOAuthConfig::OAuth(settings)) = &mut info.gateway_oauth else { unreachable!() }; settings.resource = Some(format!("{}/{resource}", server.uri())); model.slug = format!("catalog-{resource}"); let _gateway = seed_gateway_auth( &info, &primary, json!({"access_token": resource, "refresh_token": "refresh-secret", "expires_at": i64::MAX}), ); Mock::given(method("GET")) .and(path("/models")) .and(wiremock::matchers::header( "x-gateway-auth", format!("Bearer {resource}"), )) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"models": [&model]}))) .expect(1) .mount(&server) .await; // Recreate the provider and models manager to exercise the shared disk cache. // The first call for each resource fetches; the second must reuse its catalog. for _ in 0..2 { let provider = create_model_provider(info.clone(), Some(primary.clone())); let models = provider.models_manager( home.path().to_path_buf(), /*config_model_catalog*/ None, ); models.set_api_key_model_discovery_enabled(/*enabled*/ true); let catalog = models .raw_model_catalog(RefreshStrategy::OnlineIfUncached, factory.clone()) .await; assert_eq!(catalog.models, vec![model.clone()]); } } } #[tokio::test] async fn models_observe_gateway_rotation_from_the_same_provider_instance() { models_observe_gateway_rotation(ProviderInstance::Same).await; } #[tokio::test] async fn models_observe_gateway_rotation_from_a_separate_provider_instance() { models_observe_gateway_rotation(ProviderInstance::Separate).await; } enum ProviderInstance { Same, Separate, } async fn models_observe_gateway_rotation(instance: ProviderInstance) { let server = MockServer::start().await; let home = tempfile::tempdir().unwrap(); let primary = AuthManager::from_auth_for_testing_with_home( CodexAuth::from_api_key("primary-token"), home.path().to_path_buf(), ); let info = ModelProviderInfo { model_catalog_url: Some(format!("{}/models", server.uri()).into()), gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings { authorization_url: format!("{}/authorize", server.uri()), token_url: format!("{}/token", server.uri()), client_id: "client".into(), resource: None, scopes: vec![], redirect_port: None, credentials_store: Default::default(), delivery: GatewayOAuthDelivery::Header { name: "x-gateway-auth".into(), scheme: "Bearer".into(), }, })), ..ModelProviderInfo::create_openai_provider(Some(server.uri())) }; let gateway = seed_gateway_auth( &info, &primary, json!({"access_token": "first", "refresh_token": "refresh-secret", "expires_at": i64::MAX}), ); let provider = create_model_provider(info.clone(), Some(primary.clone())); let models = provider.models_manager_without_cache(/*config_model_catalog*/ None); models.set_api_key_model_discovery_enabled(/*enabled*/ true); let requests_provider = match instance { ProviderInstance::Same => provider, ProviderInstance::Separate => create_model_provider(info, Some(primary)), }; let mut first = codex_models_manager::bundled_models_response() .unwrap() .models .remove(0); first.slug = "catalog-first".into(); let mut second = first.clone(); second.slug = "catalog-second".into(); for (token, model) in [("first", &first), ("second", &second)] { Mock::given(method("GET")) .and(path("/models")) .and(wiremock::matchers::header( "x-gateway-auth", format!("Bearer {token}"), )) .respond_with(ResponseTemplate::new(200).set_body_json(json!({"models": [model]}))) .expect(1) .mount(&server) .await; } assert_eq!( requests_provider .api_auth() .await .unwrap() .to_auth_headers() .unwrap(), HeaderMap::from_iter([ ( http::header::AUTHORIZATION, HeaderValue::from_static("Bearer primary-token") ), ( HeaderName::from_static("x-gateway-auth"), HeaderValue::from_static("Bearer first") ), ]) ); models .raw_model_catalog( RefreshStrategy::Online, HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ) .await; assert_eq!(models.get_remote_models().await, vec![first]); Mock::given(method("POST")) .and(path("/token")) .respond_with( ResponseTemplate::new(200) .set_body_json(json!({"access_token": "second", "expires_in": 3600})), ) .expect(1) .up_to_n_times(1) .mount(&server) .await; gateway.refresh_access_token("first").await.unwrap(); assert_eq!( requests_provider .api_auth() .await .unwrap() .to_auth_headers() .unwrap(), HeaderMap::from_iter([ ( http::header::AUTHORIZATION, HeaderValue::from_static("Bearer primary-token") ), ( HeaderName::from_static("x-gateway-auth"), HeaderValue::from_static("Bearer second") ), ]) ); models .raw_model_catalog( RefreshStrategy::Online, HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ) .await; assert_eq!(models.get_remote_models().await, vec![second]); } #[tokio::test] async fn gateway_setup_errors_are_reported_when_authentication_is_requested() { use codex_models_manager::manager::ModelsEndpointClient; let server = MockServer::start().await; let mut info = ModelProviderInfo { gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings { authorization_url: format!("{}/authorize", server.uri()), token_url: format!("{}/token", server.uri()), client_id: "client".into(), resource: None, scopes: vec![], redirect_port: None, credentials_store: Default::default(), delivery: GatewayOAuthDelivery::Header { name: "x-gateway-auth".into(), scheme: "Bearer".into(), }, })), ..ModelProviderInfo::create_openai_provider(Some(server.uri())) }; let provider = create_model_provider(info.clone(), /*auth_manager*/ None); assert_eq!( provider.gateway_auth_manager().unwrap_err().to_string(), "gateway_oauth requires auth runtime configuration" ); let error = provider.api_auth().await.err().unwrap(); assert_eq!( error.to_string(), "gateway_oauth requires auth runtime configuration" ); let primary = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("primary")); let endpoint = crate::models_endpoint::OpenAiModelsEndpoint::new( info.clone(), Some(primary), Some(Err("failed to create provider OAuth HTTP client".into())), ); let error = endpoint .list_models( "test", HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ) .await .unwrap_err(); assert_eq!( error.to_string(), "failed to create provider OAuth HTTP client" ); let Some(GatewayOAuthConfig::OAuth(settings)) = &mut info.gateway_oauth else { unreachable!() }; settings.client_id.clear(); let primary = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("primary")); let error = create_model_provider(info, Some(primary)) .api_auth() .await .err() .unwrap(); assert!(matches!( error.details(), codex_protocol::error::CodexErrorDetails::InvalidRequest(_) )); assert!(server.received_requests().await.unwrap().is_empty()); } #[tokio::test] async fn gateway_login_required_invalidates_the_last_successful_catalog() { let server = MockServer::start().await; let home = tempfile::tempdir().unwrap(); let primary = AuthManager::from_auth_for_testing_with_home( CodexAuth::from_api_key("primary-token"), home.path().to_path_buf(), ); let info = ModelProviderInfo { model_catalog_url: Some(format!("{}/models", server.uri()).into()), gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings { authorization_url: format!("{}/authorize", server.uri()), token_url: format!("{}/token", server.uri()), client_id: "client".into(), resource: None, scopes: vec![], redirect_port: None, credentials_store: OAuthCredentialsStore::File, delivery: GatewayOAuthDelivery::Header { name: "x-gateway-auth".into(), scheme: "Bearer".into(), }, })), ..ModelProviderInfo::create_openai_provider(Some(server.uri())) }; let login_control = codex_login::GatewayLoginControl::for_runtime(&primary.runtime_config()); login_control.require_explicit_login(); let gateway = seed_gateway_auth( &info, &primary, json!({"access_token": "first", "refresh_token": "revoked", "expires_at": i64::MAX}), ); let provider = create_model_provider(info, Some(primary)); let models = provider.models_manager( home.path().to_path_buf(), /*config_model_catalog*/ None, ); models.set_api_key_model_discovery_enabled(/*enabled*/ true); let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault); let catalog = codex_models_manager::bundled_models_response().unwrap(); Mock::given(method("GET")) .and(path("/models")) .respond_with(ResponseTemplate::new(200).set_body_json(&catalog)) .expect(1) .mount(&server) .await; assert_eq!( models .raw_model_catalog(RefreshStrategy::Online, factory.clone()) .await, catalog ); Mock::given(method("POST")) .and(path("/token")) .respond_with(ResponseTemplate::new(400).set_body_json(json!({"error": "invalid_grant"}))) .expect(1) .mount(&server) .await; let error = gateway.refresh_access_token("first").await.unwrap_err(); assert!(matches!( error .get_ref() .and_then(|source| source.downcast_ref::()), Some(codex_login::GatewayAuthError::LoginRequired) )); assert_eq!( models .raw_model_catalog(RefreshStrategy::Online, factory.clone()) .await, codex_protocol::openai_models::ModelsResponse::default() ); // Recreate the manager to verify persistent invalidation as well as the in-memory result. let restarted = provider.models_manager( home.path().to_path_buf(), /*config_model_catalog*/ None, ); restarted.set_api_key_model_discovery_enabled(/*enabled*/ true); assert_eq!( restarted .raw_model_catalog(RefreshStrategy::Offline, factory) .await, codex_protocol::openai_models::ModelsResponse::default() ); } #[tokio::test] async fn gateway_login_states_stop_request_retries() { let server = MockServer::start().await; let home = tempfile::tempdir().unwrap(); let primary = AuthManager::from_auth_for_testing_with_home( CodexAuth::from_api_key("primary-token"), home.path().to_path_buf(), ); let info = ModelProviderInfo { gateway_oauth: Some(GatewayOAuthConfig::OAuth(GatewayOAuthSettings { authorization_url: format!("{}/authorize", server.uri()), token_url: format!("{}/token", server.uri()), client_id: "client".into(), resource: None, scopes: vec![], redirect_port: None, credentials_store: Default::default(), delivery: GatewayOAuthDelivery::Header { name: "x-gateway-auth".into(), scheme: "Bearer".into(), }, })), ..ModelProviderInfo::create_openai_provider(Some(server.uri())) }; let login_control = codex_login::GatewayLoginControl::for_runtime(&primary.runtime_config()); login_control.require_explicit_login(); let provider = create_model_provider(info, Some(primary)); let gateway = provider.gateway_auth_manager().unwrap().unwrap(); let error = provider.api_auth().await.err().expect("login required"); assert!(matches!( error.details(), CodexErrorDetails::GatewayLoginRequired(_) )); assert_eq!( ( error.to_string(), error.retry_delay(/*retry_count*/ 1), error.to_codex_protocol_error() ), ( codex_login::GatewayAuthError::LoginRequired.to_string(), None, CodexErrorInfo::Other ), ); let cancel = tokio::sync::Notify::new(); let (url_tx, url_rx) = tokio::sync::oneshot::channel(); let oauth = gateway.oauth_session().unwrap(); let login = oauth.login_with_browser(cancel.notified(), |_| { let _ = url_tx.send(()); }); let check = async { url_rx.await.unwrap(); let error = provider.api_auth().await.err().expect("login in progress"); assert!(matches!( error.details(), CodexErrorDetails::InvalidRequest(_) )); assert_eq!( (error.to_string(), error.retry_delay(/*retry_count*/ 1)), ( codex_login::GatewayAuthError::LoginInProgress.to_string(), None ), ); cancel.notify_one(); }; let (result, ()) = tokio::join!(login, check); assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Interrupted); assert!(server.received_requests().await.unwrap().is_empty()); }