Codex Handbook
app-server/tests/suite/v2/account.rs 1954 lines
use anyhow::Result;use anyhow::bail;use app_test_support::TestAppServer;use app_test_support::to_response;use app_test_support::ChatGptAuthFixture;use app_test_support::ChatGptIdTokenClaims;use app_test_support::encode_id_token;use app_test_support::write_chatgpt_auth;use app_test_support::write_models_cache;use chrono::Duration as ChronoDuration;use chrono::Utc;use codex_app_server_protocol::Account;use codex_app_server_protocol::AuthMode;use codex_app_server_protocol::CancelLoginAccountParams;use codex_app_server_protocol::CancelLoginAccountResponse;use codex_app_server_protocol::CancelLoginAccountStatus;use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;use codex_app_server_protocol::GetAccountParams;use codex_app_server_protocol::GetAccountResponse;use codex_app_server_protocol::GetAuthStatusParams;use codex_app_server_protocol::GetAuthStatusResponse;use codex_app_server_protocol::JSONRPCError;use codex_app_server_protocol::JSONRPCErrorError;use codex_app_server_protocol::JSONRPCNotification;use codex_app_server_protocol::JSONRPCResponse;use codex_app_server_protocol::LoginAccountResponse;use codex_app_server_protocol::LogoutAccountResponse;use codex_app_server_protocol::RequestId;use codex_app_server_protocol::ServerNotification;use codex_app_server_protocol::ServerRequest;use codex_app_server_protocol::TurnCompletedNotification;use codex_app_server_protocol::TurnStatus;use codex_config::types::AuthCredentialsStoreMode;use codex_login::AuthKeyringBackendKind;use codex_login::CLIENT_ID_OVERRIDE_ENV_VAR;use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;use codex_login::login_with_api_key;use codex_login::login_with_bedrock_api_key;use codex_protocol::account::AmazonBedrockCredentialSource;use codex_protocol::account::PlanType as AccountPlanType;use core_test_support::responses;use pretty_assertions::assert_eq;use serde_json::json;use serial_test::serial;use std::path::Path;use std::time::Duration;use tempfile::TempDir;use tokio::time::timeout;use url::Url;use wiremock::Mock;use wiremock::MockServer;use wiremock::ResponseTemplate;use wiremock::matchers::method;use wiremock::matchers::path;const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);const LOGIN_ISSUER_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";const WORKSPACE_ID_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174000";const WORKSPACE_ID_SECOND_ALLOWED: &str = "123e4567-e89b-42d3-a456-426614174001";const WORKSPACE_ID_DISALLOWED: &str = "123e4567-e89b-42d3-a456-426614174002";const WORKSPACE_ID_EMBEDDED: &str = "123e4567-e89b-42d3-a456-426614174010";const WORKSPACE_ID_INITIAL: &str = "123e4567-e89b-42d3-a456-426614174011";const WORKSPACE_ID_REFRESHED: &str = "123e4567-e89b-42d3-a456-426614174012";const WORKSPACE_ID_DEVICE: &str = "123e4567-e89b-42d3-a456-426614174013";const WORKSPACE_ID_STALE: &str = "123e4567-e89b-42d3-a456-426614174014";// Helper to create a minimal config.toml for the app server#[derive(Default)]struct CreateConfigTomlParams {    forced_method: Option<String>,    forced_workspace_id: Option<String>,    forced_workspace_ids: Option<Vec<String>>,    requires_openai_auth: Option<bool>,    base_url: Option<String>,    model_provider_id: Option<String>,    extra_provider_config: Option<String>,}fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> {    let config_toml = codex_home.join("config.toml");    let base_url = params        .base_url        .unwrap_or_else(|| "http://127.0.0.1:0/v1".to_string());    let forced_line = if let Some(method) = params.forced_method {        format!("forced_login_method = \"{method}\"\n")    } else {        String::new()    };    let forced_workspace_line = if let Some(ws) = params.forced_workspace_id {        format!("forced_chatgpt_workspace_id = \"{ws}\"\n")    } else if let Some(workspaces) = params.forced_workspace_ids {        let workspaces = workspaces            .into_iter()            .map(|workspace_id| format!("\"{workspace_id}\""))            .collect::<Vec<_>>()            .join(", ");        format!("forced_chatgpt_workspace_id = [{workspaces}]\n")    } else {        String::new()    };    let requires_line = match params.requires_openai_auth {        Some(true) => "requires_openai_auth = true\n".to_string(),        Some(false) => String::new(),        None => String::new(),    };    let model_provider_id = params        .model_provider_id        .unwrap_or_else(|| "mock_provider".to_string());    let provider_section = if model_provider_id == "mock_provider" {        format!(            r#"[model_providers.mock_provider]name = "Mock provider for test"base_url = "{base_url}"wire_api = "responses"request_max_retries = 0stream_max_retries = 0{requires_line}"#        )    } else {        params.extra_provider_config.unwrap_or_default()    };    let contents = format!(        r#"model = "mock-model"approval_policy = "never"sandbox_mode = "danger-full-access"{forced_line}{forced_workspace_line}model_provider = "{model_provider_id}"[features]shell_snapshot = false{provider_section}"#    );    std::fs::write(config_toml, contents)}async fn mock_device_code_usercode(server: &MockServer, interval_seconds: u64) {    Mock::given(method("POST"))        .and(path("/api/accounts/deviceauth/usercode"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "device_auth_id": "device-auth-123",            "user_code": "CODE-12345",            "interval": interval_seconds.to_string(),        })))        .mount(server)        .await;}async fn mock_device_code_usercode_failure(server: &MockServer, status: u16) {    Mock::given(method("POST"))        .and(path("/api/accounts/deviceauth/usercode"))        .respond_with(ResponseTemplate::new(status))        .mount(server)        .await;}async fn mock_device_code_token_success(server: &MockServer) {    Mock::given(method("POST"))        .and(path("/api/accounts/deviceauth/token"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "authorization_code": "poll-code-321",            "code_challenge": "code-challenge-321",            "code_verifier": "code-verifier-321",        })))        .mount(server)        .await;}async fn mock_device_code_token_failure(server: &MockServer, status: u16) {    Mock::given(method("POST"))        .and(path("/api/accounts/deviceauth/token"))        .respond_with(ResponseTemplate::new(status))        .mount(server)        .await;}async fn mock_device_code_oauth_token(server: &MockServer, id_token: &str) {    Mock::given(method("POST"))        .and(path("/oauth/token"))        .respond_with(ResponseTemplate::new(200).set_body_json(json!({            "id_token": id_token,            "access_token": "access-token-123",            "refresh_token": "refresh-token-123",        })))        .mount(server)        .await;}#[tokio::test]async fn logout_account_removes_auth_and_notifies() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;    login_with_api_key(        codex_home.path(),        "sk-test-key",        AuthCredentialsStoreMode::File,        AuthKeyringBackendKind::default(),    )?;    assert!(codex_home.path().join("auth.json").exists());    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let id = mcp.send_logout_account_request().await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(id)),    )    .await??;    let _ok: LogoutAccountResponse = to_response(resp)?;    let note = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    let parsed: ServerNotification = note.try_into()?;    let ServerNotification::AccountUpdated(payload) = parsed else {        bail!("unexpected notification: {parsed:?}");    };    assert!(        payload.auth_mode.is_none(),        "auth_method should be None after logout"    );    assert_eq!(payload.plan_type, None);    assert!(        !codex_home.path().join("auth.json").exists(),        "auth.json should be deleted"    );    let get_id = mcp        .send_get_account_request(GetAccountParams {            refresh_token: false,        })        .await?;    let get_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(get_id)),    )    .await??;    let account: GetAccountResponse = to_response(get_resp)?;    assert_eq!(account.account, None);    Ok(())}#[tokio::test]async fn set_auth_token_updates_account_and_notifies() -> Result<()> {    let codex_home = TempDir::new()?;    let mock_server = MockServer::start().await;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            base_url: Some(format!("{}/v1", mock_server.uri())),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    let access_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("embedded@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_EMBEDDED),    )?;    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let set_id = mcp        .send_chatgpt_auth_tokens_login_request(            access_token,            WORKSPACE_ID_EMBEDDED.to_string(),            Some("pro".to_string()),        )        .await?;    let set_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(set_id)),    )    .await??;    let response: LoginAccountResponse = to_response(set_resp)?;    assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});    let note = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    let parsed: ServerNotification = note.try_into()?;    let ServerNotification::AccountUpdated(payload) = parsed else {        bail!("unexpected notification: {parsed:?}");    };    assert_eq!(payload.auth_mode, Some(AuthMode::ChatgptAuthTokens));    assert_eq!(payload.plan_type, Some(AccountPlanType::Pro));    let get_id = mcp        .send_get_account_request(GetAccountParams {            refresh_token: false,        })        .await?;    let get_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(get_id)),    )    .await??;    let account: GetAccountResponse = to_response(get_resp)?;    assert_eq!(        account,        GetAccountResponse {            account: Some(Account::Chatgpt {                email: "embedded@example.com".to_string(),                plan_type: AccountPlanType::Pro,            }),            requires_openai_auth: true,        }    );    Ok(())}#[tokio::test]async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    let access_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("embedded@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_EMBEDDED),    )?;    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let set_id = mcp        .send_chatgpt_auth_tokens_login_request(            access_token,            WORKSPACE_ID_EMBEDDED.to_string(),            Some("pro".to_string()),        )        .await?;    let set_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(set_id)),    )    .await??;    let response: LoginAccountResponse = to_response(set_resp)?;    assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});    let _updated = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    let get_id = mcp        .send_get_account_request(GetAccountParams {            refresh_token: true,        })        .await?;    let get_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(get_id)),    )    .await??;    let account: GetAccountResponse = to_response(get_resp)?;    assert_eq!(        account,        GetAccountResponse {            account: Some(Account::Chatgpt {                email: "embedded@example.com".to_string(),                plan_type: AccountPlanType::Pro,            }),            requires_openai_auth: true,        }    );    let refresh_request = timeout(        Duration::from_millis(250),        mcp.read_stream_until_request_message(),    )    .await;    assert!(        refresh_request.is_err(),        "external mode should not emit account/chatgptAuthTokens/refresh for refreshToken=true"    );    Ok(())}async fn respond_to_refresh_request(    mcp: &mut TestAppServer,    access_token: &str,    chatgpt_account_id: &str,    chatgpt_plan_type: Option<&str>,) -> Result<()> {    let refresh_req: ServerRequest = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_request_message(),    )    .await??;    let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = refresh_req else {        bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}");    };    assert_eq!(params.reason, ChatgptAuthTokensRefreshReason::Unauthorized);    let response = ChatgptAuthTokensRefreshResponse {        access_token: access_token.to_string(),        chatgpt_account_id: chatgpt_account_id.to_string(),        chatgpt_plan_type: chatgpt_plan_type.map(str::to_string),    };    mcp.send_response(request_id, serde_json::to_value(response)?)        .await?;    Ok(())}#[tokio::test]// 401 response triggers account/chatgptAuthTokens/refresh and retries with new tokens.async fn external_auth_refreshes_on_unauthorized() -> Result<()> {    let codex_home = TempDir::new()?;    let mock_server = MockServer::start().await;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            base_url: Some(format!("{}/v1", mock_server.uri())),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    let success_sse = responses::sse(vec![        responses::ev_response_created("resp-turn"),        responses::ev_assistant_message("msg-turn", "turn ok"),        responses::ev_completed("resp-turn"),    ]);    let unauthorized = ResponseTemplate::new(401).set_body_json(json!({        "error": { "message": "unauthorized" }    }));    let responses_mock = responses::mount_response_sequence(        &mock_server,        vec![unauthorized, responses::sse_response(success_sse)],    )    .await;    let initial_access_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("initial@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_INITIAL),    )?;    let refreshed_access_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("refreshed@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_REFRESHED),    )?;    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let set_id = mcp        .send_chatgpt_auth_tokens_login_request(            initial_access_token.clone(),            WORKSPACE_ID_INITIAL.to_string(),            Some("pro".to_string()),        )        .await?;    let set_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(set_id)),    )    .await??;    let response: LoginAccountResponse = to_response(set_resp)?;    assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});    let _updated = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    let thread_req = mcp        .send_thread_start_request(codex_app_server_protocol::ThreadStartParams {            model: Some("mock-model".to_string()),            ..Default::default()        })        .await?;    let thread_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),    )    .await??;    let thread = to_response::<codex_app_server_protocol::ThreadStartResponse>(thread_resp)?;    let turn_req = mcp        .send_turn_start_request(codex_app_server_protocol::TurnStartParams {            thread_id: thread.thread.id,            client_user_message_id: None,            input: vec![codex_app_server_protocol::UserInput::Text {                text: "Hello".to_string(),                text_elements: Vec::new(),            }],            ..Default::default()        })        .await?;    respond_to_refresh_request(        &mut mcp,        &refreshed_access_token,        WORKSPACE_ID_REFRESHED,        Some("pro"),    )    .await?;    let _turn_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),    )    .await??;    let _turn_completed = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("turn/completed"),    )    .await??;    let requests = responses_mock.requests();    assert_eq!(requests.len(), 2);    assert_eq!(        requests[0].header("authorization"),        Some(format!("Bearer {initial_access_token}"))    );    assert_eq!(        requests[1].header("authorization"),        Some(format!("Bearer {refreshed_access_token}"))    );    Ok(())}#[tokio::test]// Client returns JSON-RPC error to refresh; turn fails.async fn external_auth_refresh_error_fails_turn() -> Result<()> {    let codex_home = TempDir::new()?;    let mock_server = MockServer::start().await;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            base_url: Some(format!("{}/v1", mock_server.uri())),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    let unauthorized = ResponseTemplate::new(401).set_body_json(json!({        "error": { "message": "unauthorized" }    }));    let _responses_mock =        responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;    let initial_access_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("initial@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_INITIAL),    )?;    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let set_id = mcp        .send_chatgpt_auth_tokens_login_request(            initial_access_token,            WORKSPACE_ID_INITIAL.to_string(),            Some("pro".to_string()),        )        .await?;    let set_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(set_id)),    )    .await??;    let response: LoginAccountResponse = to_response(set_resp)?;    assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});    let _updated = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    let thread_req = mcp        .send_thread_start_request(codex_app_server_protocol::ThreadStartParams {            model: Some("mock-model".to_string()),            ..Default::default()        })        .await?;    let thread_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),    )    .await??;    let thread = to_response::<codex_app_server_protocol::ThreadStartResponse>(thread_resp)?;    let turn_req = mcp        .send_turn_start_request(codex_app_server_protocol::TurnStartParams {            thread_id: thread.thread.id.clone(),            client_user_message_id: None,            input: vec![codex_app_server_protocol::UserInput::Text {                text: "Hello".to_string(),                text_elements: Vec::new(),            }],            ..Default::default()        })        .await?;    let refresh_req: ServerRequest = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_request_message(),    )    .await??;    let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else {        bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}");    };    mcp.send_error(        request_id,        JSONRPCErrorError {            code: -32_000,            message: "refresh failed".to_string(),            data: None,        },    )    .await?;    let _turn_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),    )    .await??;    let completed_notif: JSONRPCNotification = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("turn/completed"),    )    .await??;    let completed: TurnCompletedNotification = serde_json::from_value(        completed_notif            .params            .expect("turn/completed params must be present"),    )?;    assert_eq!(completed.turn.status, TurnStatus::Failed);    assert!(completed.turn.error.is_some());    Ok(())}#[tokio::test]// Refresh returns tokens for the wrong workspace; turn fails.async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> {    let codex_home = TempDir::new()?;    let mock_server = MockServer::start().await;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            forced_workspace_id: Some(WORKSPACE_ID_ALLOWED.to_string()),            requires_openai_auth: Some(true),            base_url: Some(format!("{}/v1", mock_server.uri())),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    let unauthorized = ResponseTemplate::new(401).set_body_json(json!({        "error": { "message": "unauthorized" }    }));    let _responses_mock =        responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;    let initial_access_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("initial@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_ALLOWED),    )?;    let refreshed_access_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("refreshed@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_DISALLOWED),    )?;    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let set_id = mcp        .send_chatgpt_auth_tokens_login_request(            initial_access_token,            WORKSPACE_ID_ALLOWED.to_string(),            Some("pro".to_string()),        )        .await?;    let set_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(set_id)),    )    .await??;    let response: LoginAccountResponse = to_response(set_resp)?;    assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});    let _updated = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    let thread_req = mcp        .send_thread_start_request(codex_app_server_protocol::ThreadStartParams {            model: Some("mock-model".to_string()),            ..Default::default()        })        .await?;    let thread_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),    )    .await??;    let thread = to_response::<codex_app_server_protocol::ThreadStartResponse>(thread_resp)?;    let turn_req = mcp        .send_turn_start_request(codex_app_server_protocol::TurnStartParams {            thread_id: thread.thread.id.clone(),            client_user_message_id: None,            input: vec![codex_app_server_protocol::UserInput::Text {                text: "Hello".to_string(),                text_elements: Vec::new(),            }],            ..Default::default()        })        .await?;    let refresh_req: ServerRequest = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_request_message(),    )    .await??;    let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else {        bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}");    };    mcp.send_response(        request_id,        serde_json::to_value(ChatgptAuthTokensRefreshResponse {            access_token: refreshed_access_token,            chatgpt_account_id: WORKSPACE_ID_DISALLOWED.to_string(),            chatgpt_plan_type: Some("pro".to_string()),        })?,    )    .await?;    let _turn_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),    )    .await??;    let completed_notif: JSONRPCNotification = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("turn/completed"),    )    .await??;    let completed: TurnCompletedNotification = serde_json::from_value(        completed_notif            .params            .expect("turn/completed params must be present"),    )?;    assert_eq!(completed.turn.status, TurnStatus::Failed);    assert!(completed.turn.error.is_some());    Ok(())}#[tokio::test]// Refresh returns a malformed access token; turn fails.async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> {    let codex_home = TempDir::new()?;    let mock_server = MockServer::start().await;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            base_url: Some(format!("{}/v1", mock_server.uri())),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    let unauthorized = ResponseTemplate::new(401).set_body_json(json!({        "error": { "message": "unauthorized" }    }));    let _responses_mock =        responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;    let initial_access_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("initial@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_INITIAL),    )?;    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let set_id = mcp        .send_chatgpt_auth_tokens_login_request(            initial_access_token,            WORKSPACE_ID_INITIAL.to_string(),            Some("pro".to_string()),        )        .await?;    let set_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(set_id)),    )    .await??;    let response: LoginAccountResponse = to_response(set_resp)?;    assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});    let _updated = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    let thread_req = mcp        .send_thread_start_request(codex_app_server_protocol::ThreadStartParams {            model: Some("mock-model".to_string()),            ..Default::default()        })        .await?;    let thread_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),    )    .await??;    let thread = to_response::<codex_app_server_protocol::ThreadStartResponse>(thread_resp)?;    let turn_req = mcp        .send_turn_start_request(codex_app_server_protocol::TurnStartParams {            thread_id: thread.thread.id.clone(),            client_user_message_id: None,            input: vec![codex_app_server_protocol::UserInput::Text {                text: "Hello".to_string(),                text_elements: Vec::new(),            }],            ..Default::default()        })        .await?;    let refresh_req: ServerRequest = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_request_message(),    )    .await??;    let ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } = refresh_req else {        bail!("expected account/chatgptAuthTokens/refresh request, got {refresh_req:?}");    };    mcp.send_response(        request_id,        serde_json::to_value(ChatgptAuthTokensRefreshResponse {            access_token: "not-a-jwt".to_string(),            chatgpt_account_id: WORKSPACE_ID_INITIAL.to_string(),            chatgpt_plan_type: Some("pro".to_string()),        })?,    )    .await?;    let _turn_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),    )    .await??;    let completed_notif: JSONRPCNotification = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("turn/completed"),    )    .await??;    let completed: TurnCompletedNotification = serde_json::from_value(        completed_notif            .params            .expect("turn/completed params must be present"),    )?;    assert_eq!(completed.turn.status, TurnStatus::Failed);    assert!(completed.turn.error.is_some());    Ok(())}#[tokio::test]async fn login_account_api_key_succeeds_and_notifies() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let req_id = mcp        .send_login_account_api_key_request("sk-test-key")        .await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(req_id)),    )    .await??;    let login: LoginAccountResponse = to_response(resp)?;    assert_eq!(login, LoginAccountResponse::ApiKey {});    let note = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/login/completed"),    )    .await??;    let parsed: ServerNotification = note.try_into()?;    let ServerNotification::AccountLoginCompleted(payload) = parsed else {        bail!("unexpected notification: {parsed:?}");    };    pretty_assertions::assert_eq!(payload.login_id, None);    pretty_assertions::assert_eq!(payload.success, true);    pretty_assertions::assert_eq!(payload.error, None);    let note = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    let parsed: ServerNotification = note.try_into()?;    let ServerNotification::AccountUpdated(payload) = parsed else {        bail!("unexpected notification: {parsed:?}");    };    pretty_assertions::assert_eq!(payload.auth_mode, Some(AuthMode::ApiKey));    pretty_assertions::assert_eq!(payload.plan_type, None);    assert!(codex_home.path().join("auth.json").exists());    Ok(())}#[tokio::test]async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            forced_method: Some("chatgpt".to_string()),            ..Default::default()        },    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_login_account_api_key_request("sk-test-key")        .await?;    let err: JSONRPCError = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(        err.error.message,        "API key login is disabled. Use ChatGPT login instead."    );    Ok(())}#[tokio::test]async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            forced_method: Some("api".to_string()),            ..Default::default()        },    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp.send_login_account_chatgpt_request().await?;    let err: JSONRPCError = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert_eq!(        err.error.message,        "ChatGPT login is disabled. Use API key login instead."    );    Ok(())}#[tokio::test]async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Result<()> {    let codex_home = TempDir::new()?;    let mock_server = MockServer::start().await;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            base_url: Some(format!("{}/v1", mock_server.uri())),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    mock_device_code_usercode_failure(&mock_server, /*status*/ 404).await;    let issuer = mock_server.uri();    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            ("OPENAI_API_KEY", None),            (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),        ],    )    .await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;    let err: JSONRPCError = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_error_message(RequestId::Integer(request_id)),    )    .await??;    assert!(        err.error            .message            .contains("device code login is not enabled"),        "unexpected error: {:?}",        err.error.message    );    let maybe_completed = timeout(        Duration::from_millis(500),        mcp.read_stream_until_notification_message("account/login/completed"),    )    .await;    assert!(        maybe_completed.is_err(),        "account/login/completed should not be emitted when device code start fails"    );    assert!(        !codex_home.path().join("auth.json").exists(),        "auth.json should not be created when device code start fails"    );    Ok(())}#[tokio::test]async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> {    let codex_home = TempDir::new()?;    let mock_server = MockServer::start().await;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            base_url: Some(format!("{}/v1", mock_server.uri())),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    mock_device_code_usercode(&mock_server, /*interval_seconds*/ 0).await;    mock_device_code_token_success(&mock_server).await;    let id_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("device@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_DEVICE),    )?;    mock_device_code_oauth_token(&mock_server, &id_token).await;    let issuer = mock_server.uri();    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            ("OPENAI_API_KEY", None),            (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),        ],    )    .await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let login: LoginAccountResponse = to_response(resp)?;    let LoginAccountResponse::ChatgptDeviceCode {        login_id,        verification_url,        user_code,    } = login    else {        bail!("unexpected login response: {login:?}");    };    assert_eq!(verification_url, format!("{issuer}/codex/device"));    assert_eq!(user_code, "CODE-12345");    let note = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/login/completed"),    )    .await??;    let parsed: ServerNotification = note.try_into()?;    let ServerNotification::AccountLoginCompleted(payload) = parsed else {        bail!("unexpected notification: {parsed:?}");    };    assert_eq!(payload.login_id, Some(login_id));    assert_eq!(payload.success, true);    assert_eq!(payload.error, None);    let note = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    let parsed: ServerNotification = note.try_into()?;    let ServerNotification::AccountUpdated(payload) = parsed else {        bail!("unexpected notification: {parsed:?}");    };    assert_eq!(payload.auth_mode, Some(AuthMode::Chatgpt));    assert_eq!(payload.plan_type, Some(AccountPlanType::Pro));    assert!(        codex_home.path().join("auth.json").exists(),        "auth.json should be created when device code login succeeds"    );    Ok(())}#[tokio::test]async fn login_account_chatgpt_device_code_failure_notifies_without_account_update() -> Result<()> {    let codex_home = TempDir::new()?;    let mock_server = MockServer::start().await;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            base_url: Some(format!("{}/v1", mock_server.uri())),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    mock_device_code_usercode(&mock_server, /*interval_seconds*/ 0).await;    mock_device_code_token_failure(&mock_server, /*status*/ 500).await;    let issuer = mock_server.uri();    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            ("OPENAI_API_KEY", None),            (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),        ],    )    .await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let login: LoginAccountResponse = to_response(resp)?;    let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else {        bail!("unexpected login response: {login:?}");    };    let note = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/login/completed"),    )    .await??;    let parsed: ServerNotification = note.try_into()?;    let ServerNotification::AccountLoginCompleted(payload) = parsed else {        bail!("unexpected notification: {parsed:?}");    };    assert_eq!(payload.login_id, Some(login_id));    assert_eq!(payload.success, false);    assert!(        payload            .error            .as_deref()            .is_some_and(|error| error.contains("device auth failed with status")),        "unexpected error: {:?}",        payload.error    );    let maybe_updated = timeout(        Duration::from_millis(500),        mcp.read_stream_until_notification_message("account/updated"),    )    .await;    assert!(        maybe_updated.is_err(),        "account/updated should not be emitted when device code login fails"    );    assert!(        !codex_home.path().join("auth.json").exists(),        "auth.json should not be created when device code login fails"    );    Ok(())}#[tokio::test]async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> {    let codex_home = TempDir::new()?;    let mock_server = MockServer::start().await;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            base_url: Some(format!("{}/v1", mock_server.uri())),            ..Default::default()        },    )?;    write_models_cache(codex_home.path())?;    mock_device_code_usercode(&mock_server, /*interval_seconds*/ 1).await;    mock_device_code_token_failure(&mock_server, /*status*/ 404).await;    let issuer = mock_server.uri();    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            ("OPENAI_API_KEY", None),            (LOGIN_ISSUER_ENV_VAR, Some(issuer.as_str())),        ],    )    .await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp.send_login_account_chatgpt_device_code_request().await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let login: LoginAccountResponse = to_response(resp)?;    let LoginAccountResponse::ChatgptDeviceCode { login_id, .. } = login else {        bail!("unexpected login response: {login:?}");    };    let cancel_id = mcp        .send_cancel_login_account_request(CancelLoginAccountParams {            login_id: login_id.clone(),        })        .await?;    let cancel_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)),    )    .await??;    let cancel: CancelLoginAccountResponse = to_response(cancel_resp)?;    assert_eq!(cancel.status, CancelLoginAccountStatus::Canceled);    let note = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/login/completed"),    )    .await??;    let parsed: ServerNotification = note.try_into()?;    let ServerNotification::AccountLoginCompleted(payload) = parsed else {        bail!("unexpected notification: {parsed:?}");    };    assert_eq!(payload.login_id, Some(login_id));    assert_eq!(payload.success, false);    assert!(        payload.error.is_some(),        "expected a non-empty error on device code cancel"    );    let maybe_updated = timeout(        Duration::from_millis(500),        mcp.read_stream_until_notification_message("account/updated"),    )    .await;    assert!(        maybe_updated.is_err(),        "account/updated should not be emitted when device code login is cancelled"    );    assert!(        !codex_home.path().join("auth.json").exists(),        "auth.json should not be created when device code login is cancelled"    );    Ok(())}#[tokio::test]// Serialize tests that launch the login server since it binds to a fixed port.#[serial(login_port)]async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp.send_login_account_chatgpt_request().await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let login: LoginAccountResponse = to_response(resp)?;    let LoginAccountResponse::Chatgpt { login_id, auth_url } = login else {        bail!("unexpected login response: {login:?}");    };    assert!(        auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost"),        "auth_url should contain a redirect_uri to localhost"    );    let cancel_id = mcp        .send_cancel_login_account_request(CancelLoginAccountParams {            login_id: login_id.clone(),        })        .await?;    let cancel_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)),    )    .await??;    let _ok: CancelLoginAccountResponse = to_response(cancel_resp)?;    let note = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/login/completed"),    )    .await??;    let parsed: ServerNotification = note.try_into()?;    let ServerNotification::AccountLoginCompleted(payload) = parsed else {        bail!("unexpected notification: {parsed:?}");    };    pretty_assertions::assert_eq!(payload.login_id, Some(login_id));    pretty_assertions::assert_eq!(payload.success, false);    assert!(        payload.error.is_some(),        "expected a non-empty error on cancel"    );    let maybe_updated = timeout(        Duration::from_millis(500),        mcp.read_stream_until_notification_message("account/updated"),    )    .await;    assert!(        maybe_updated.is_err(),        "account/updated should not be emitted when login is cancelled"    );    Ok(())}#[tokio::test]// Serialize tests that launch the login server since it binds to a fixed port.#[serial(login_port)]async fn login_account_chatgpt_uses_debug_oauth_overrides() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            (CLIENT_ID_OVERRIDE_ENV_VAR, Some("staging-client")),            (LOGIN_ISSUER_ENV_VAR, Some("https://auth.example.com")),        ],    )    .await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp.send_login_account_chatgpt_request().await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let login: LoginAccountResponse = to_response(resp)?;    let LoginAccountResponse::Chatgpt { login_id, auth_url } = login else {        bail!("unexpected login response: {login:?}");    };    let auth_url = Url::parse(&auth_url)?;    assert_eq!(        auth_url.origin().ascii_serialization(),        "https://auth.example.com"    );    assert_eq!(        auth_url            .query_pairs()            .find_map(|(key, value)| (key == "client_id").then_some(value.into_owned())),        Some("staging-client".to_string())    );    let cancel_id = mcp        .send_cancel_login_account_request(CancelLoginAccountParams { login_id })        .await?;    let cancel_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)),    )    .await??;    let _: CancelLoginAccountResponse = to_response(cancel_resp)?;    Ok(())}#[tokio::test]// Serialize tests that launch the login server since it binds to a fixed port.#[serial(login_port)]async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    // Initiate the ChatGPT login flow    let request_id = mcp.send_login_account_chatgpt_request().await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let login: LoginAccountResponse = to_response(resp)?;    let LoginAccountResponse::Chatgpt { login_id, .. } = login else {        bail!("unexpected login response: {login:?}");    };    let access_token = encode_id_token(        &ChatGptIdTokenClaims::new()            .email("embedded@example.com")            .plan_type("pro")            .chatgpt_account_id(WORKSPACE_ID_EMBEDDED),    )?;    // Set an external auth token instead of completing the ChatGPT login flow.    // This should cancel the active login attempt.    let set_id = mcp        .send_chatgpt_auth_tokens_login_request(            access_token,            WORKSPACE_ID_EMBEDDED.to_string(),            Some("pro".to_string()),        )        .await?;    let set_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(set_id)),    )    .await??;    let response: LoginAccountResponse = to_response(set_resp)?;    assert_eq!(response, LoginAccountResponse::ChatgptAuthTokens {});    let _updated = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_notification_message("account/updated"),    )    .await??;    // Verify that the active login attempt was cancelled.    // We check this by trying to cancel it and expecting a not found error.    let cancel_id = mcp        .send_cancel_login_account_request(CancelLoginAccountParams {            login_id: login_id.clone(),        })        .await?;    let cancel_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)),    )    .await??;    let cancel: CancelLoginAccountResponse = to_response(cancel_resp)?;    assert_eq!(cancel.status, CancelLoginAccountStatus::NotFound);    Ok(())}#[tokio::test]// Serialize tests that launch the login server since it binds to a fixed port.#[serial(login_port)]async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            forced_workspace_id: Some(WORKSPACE_ID_ALLOWED.to_string()),            ..Default::default()        },    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp.send_login_account_chatgpt_request().await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let login: LoginAccountResponse = to_response(resp)?;    let LoginAccountResponse::Chatgpt { auth_url, .. } = login else {        bail!("unexpected login response: {login:?}");    };    assert!(        auth_url.contains(&format!("allowed_workspace_id={WORKSPACE_ID_ALLOWED}")),        "auth URL should include forced workspace"    );    Ok(())}#[tokio::test]// Serialize tests that launch the login server since it binds to a fixed port.#[serial(login_port)]async fn login_account_chatgpt_includes_forced_workspace_allowlist_query_param() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            forced_workspace_ids: Some(vec![                WORKSPACE_ID_ALLOWED.to_string(),                WORKSPACE_ID_SECOND_ALLOWED.to_string(),            ]),            ..Default::default()        },    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp.send_login_account_chatgpt_request().await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let login: LoginAccountResponse = to_response(resp)?;    let LoginAccountResponse::Chatgpt { auth_url, .. } = login else {        bail!("unexpected login response: {login:?}");    };    let auth_url = Url::parse(&auth_url)?;    let allowed_workspace_ids = auth_url        .query_pairs()        .filter_map(|(key, value)| (key == "allowed_workspace_id").then(|| value.into_owned()))        .collect::<Vec<_>>();    assert_eq!(        allowed_workspace_ids,        vec![format!(            "{WORKSPACE_ID_ALLOWED},{WORKSPACE_ID_SECOND_ALLOWED}"        )]    );    Ok(())}#[tokio::test]async fn get_account_no_auth() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            ..Default::default()        },    )?;    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let params = GetAccountParams {        refresh_token: false,    };    let request_id = mcp.send_get_account_request(params).await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let account: GetAccountResponse = to_response(resp)?;    assert_eq!(account.account, None, "expected no account");    assert_eq!(account.requires_openai_auth, true);    Ok(())}#[tokio::test]async fn get_account_with_api_key() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            ..Default::default()        },    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let req_id = mcp        .send_login_account_api_key_request("sk-test-key")        .await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(req_id)),    )    .await??;    let _login_ok = to_response::<LoginAccountResponse>(resp)?;    let params = GetAccountParams {        refresh_token: false,    };    let request_id = mcp.send_get_account_request(params).await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let received: GetAccountResponse = to_response(resp)?;    let expected = GetAccountResponse {        account: Some(Account::ApiKey {}),        requires_openai_auth: true,    };    assert_eq!(received, expected);    Ok(())}#[tokio::test]async fn get_account_when_auth_not_required() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(false),            ..Default::default()        },    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let params = GetAccountParams {        refresh_token: false,    };    let request_id = mcp.send_get_account_request(params).await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let received: GetAccountResponse = to_response(resp)?;    let expected = GetAccountResponse {        account: None,        requires_openai_auth: false,    };    assert_eq!(received, expected);    Ok(())}#[tokio::test]async fn get_account_with_aws_provider() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            model_provider_id: Some("amazon-bedrock".to_string()),            extra_provider_config: Some(                r#"[model_providers.amazon-bedrock.aws]profile = "codex-bedrock"region = "us-west-2""#                .to_string(),            ),            ..Default::default()        },    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let params = GetAccountParams {        refresh_token: false,    };    let request_id = mcp.send_get_account_request(params).await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let received: GetAccountResponse = to_response(resp)?;    let expected = GetAccountResponse {        account: Some(Account::AmazonBedrock {            credential_source: AmazonBedrockCredentialSource::AwsManaged,        }),        requires_openai_auth: false,    };    assert_eq!(received, expected);    Ok(())}#[tokio::test]async fn get_account_with_managed_bedrock_provider() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            model_provider_id: Some("amazon-bedrock".to_string()),            ..Default::default()        },    )?;    login_with_bedrock_api_key(        codex_home.path(),        "managed-bedrock-api-key",        "us-west-2",        AuthCredentialsStoreMode::File,        AuthKeyringBackendKind::default(),    )?;    let mut mcp = TestAppServer::new(codex_home.path()).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let request_id = mcp        .send_get_account_request(GetAccountParams {            refresh_token: false,        })        .await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let received: GetAccountResponse = to_response(resp)?;    assert_eq!(        received,        GetAccountResponse {            account: Some(Account::AmazonBedrock {                credential_source: AmazonBedrockCredentialSource::CodexManaged,            }),            requires_openai_auth: false,        }    );    Ok(())}#[tokio::test]async fn get_account_with_chatgpt() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            ..Default::default()        },    )?;    write_chatgpt_auth(        codex_home.path(),        ChatGptAuthFixture::new("access-chatgpt")            .email("user@example.com")            .plan_type("pro"),        AuthCredentialsStoreMode::File,    )?;    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let params = GetAccountParams {        refresh_token: false,    };    let request_id = mcp.send_get_account_request(params).await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let received: GetAccountResponse = to_response(resp)?;    let expected = GetAccountResponse {        account: Some(Account::Chatgpt {            email: "user@example.com".to_string(),            plan_type: AccountPlanType::Pro,        }),        requires_openai_auth: true,    };    assert_eq!(received, expected);    Ok(())}#[tokio::test]async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            ..Default::default()        },    )?;    write_chatgpt_auth(        codex_home.path(),        ChatGptAuthFixture::new("stale-access-token")            .refresh_token("stale-refresh-token")            .account_id(WORKSPACE_ID_STALE)            .email("user@example.com")            .plan_type("pro")            .last_refresh(Some(Utc::now() - ChronoDuration::days(9))),        AuthCredentialsStoreMode::File,    )?;    let server = MockServer::start().await;    Mock::given(method("POST"))        .and(path("/oauth/token"))        .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({            "error": {                "code": "refresh_token_reused"            }        })))        .expect(1..=2)        .mount(&server)        .await;    let refresh_url = format!("{}/oauth/token", server.uri());    let mut mcp = TestAppServer::new_with_env(        codex_home.path(),        &[            ("OPENAI_API_KEY", None),            (                REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,                Some(refresh_url.as_str()),            ),        ],    )    .await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let auth_status_request_id = mcp        .send_get_auth_status_request(GetAuthStatusParams {            include_token: Some(true),            refresh_token: Some(true),        })        .await?;    let auth_status_resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(auth_status_request_id)),    )    .await??;    let _: GetAuthStatusResponse = to_response(auth_status_resp)?;    let request_id = mcp        .send_get_account_request(GetAccountParams {            refresh_token: false,        })        .await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let received: GetAccountResponse = to_response(resp)?;    assert_eq!(        received,        GetAccountResponse {            account: None,            requires_openai_auth: true,        }    );    server.verify().await;    Ok(())}#[tokio::test]async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result<()> {    let codex_home = TempDir::new()?;    create_config_toml(        codex_home.path(),        CreateConfigTomlParams {            requires_openai_auth: Some(true),            ..Default::default()        },    )?;    write_chatgpt_auth(        codex_home.path(),        ChatGptAuthFixture::new("access-chatgpt").email("user@example.com"),        AuthCredentialsStoreMode::File,    )?;    let mut mcp =        TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;    timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;    let params = GetAccountParams {        refresh_token: false,    };    let request_id = mcp.send_get_account_request(params).await?;    let resp: JSONRPCResponse = timeout(        DEFAULT_READ_TIMEOUT,        mcp.read_stream_until_response_message(RequestId::Integer(request_id)),    )    .await??;    let received: GetAccountResponse = to_response(resp)?;    let expected = GetAccountResponse {        account: Some(Account::Chatgpt {            email: "user@example.com".to_string(),            plan_type: AccountPlanType::Unknown,        }),        requires_openai_auth: true,    };    assert_eq!(received, expected);    Ok(())}