From a9380dc1225e2088e243ee143225a226e8073733 Mon Sep 17 00:00:00 2001 From: Sashank Shukla Date: Tue, 14 Apr 2026 19:16:48 +0000 Subject: [PATCH 1/7] Revoke ChatGPT tokens on logout Co-authored-by: Codex --- .../app-server/src/codex_message_processor.rs | 19 +- codex-rs/cli/src/login.rs | 11 +- codex-rs/login/src/auth/manager.rs | 35 ++++ codex-rs/login/src/auth/mod.rs | 3 + codex-rs/login/src/auth/revoke.rs | 170 +++++++++++++++++ codex-rs/login/src/lib.rs | 3 + codex-rs/login/tests/suite/logout.rs | 175 ++++++++++++++++++ codex-rs/login/tests/suite/mod.rs | 1 + 8 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 codex-rs/login/src/auth/revoke.rs create mode 100644 codex-rs/login/tests/suite/logout.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0f3b8bf52268..2bad8a2f05f1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1697,12 +1697,19 @@ impl CodexMessageProcessor { } } - if let Err(err) = self.auth_manager.logout() { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("logout failed: {err}"), - data: None, - }); + match self.auth_manager.logout_with_revoke().await { + Ok(result) => { + if let Some(err) = result.revoke_error { + tracing::warn!("failed to revoke OAuth token during logout: {err}"); + } + } + Err(err) => { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("logout failed: {err}"), + data: None, + }); + } } // Reflect the current auth method after logout (likely None). diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index bd17a546a1ff..a840d083a6fb 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -14,7 +14,7 @@ use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; use codex_login::login_with_api_key; -use codex_login::logout; +use codex_login::logout_with_revoke; use codex_login::run_device_code_login; use codex_login::run_login_server; use codex_protocol::config_types::ForcedLoginMethod; @@ -347,12 +347,15 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match logout(&config.codex_home, config.cli_auth_credentials_store_mode) { - Ok(true) => { + match logout_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await { + Ok(result) if result.removed => { + if let Some(err) = result.revoke_error { + eprintln!("Warning: {err}"); + } eprintln!("Successfully logged out"); std::process::exit(0); } - Ok(false) => { + Ok(_) => { eprintln!("Not logged in"); std::process::exit(0); } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 3cb97f58b00b..c24f197dea45 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -21,6 +21,8 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; use super::external_bearer::BearerTokenRefresher; +use super::revoke::LogoutResult; +use super::revoke::revoke_auth_tokens; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; @@ -483,6 +485,32 @@ pub fn logout( storage.delete() } +pub async fn logout_with_revoke( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let auth_dot_json = match storage.load() { + Ok(auth_dot_json) => auth_dot_json, + Err(err) => { + let removed = logout_all_stores(codex_home, auth_credentials_store_mode)?; + return Ok(LogoutResult { + removed, + revoke_error: Some(format!("failed to load stored auth for revocation: {err}")), + }); + } + }; + let revoke_error = revoke_auth_tokens(auth_dot_json.as_ref()) + .await + .err() + .map(|err| err.to_string()); + let removed = logout_all_stores(codex_home, auth_credentials_store_mode)?; + Ok(LogoutResult { + removed, + revoke_error, + }) +} + /// Writes an `auth.json` that contains only the API key. pub fn login_with_api_key( codex_home: &Path, @@ -1637,6 +1665,13 @@ impl AuthManager { Ok(removed) } + pub async fn logout_with_revoke(&self) -> std::io::Result { + let result = logout_with_revoke(&self.codex_home, self.auth_credentials_store_mode).await?; + // Always reload to clear any cached auth (even if file absent). + self.reload(); + Ok(result) + } + pub fn get_api_auth_mode(&self) -> Option { if self.has_external_api_key_auth() { return Some(ApiAuthMode::ApiKey); diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 256cf16a8c2d..ef7133767ad7 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -5,7 +5,10 @@ mod util; mod external_bearer; mod manager; +mod revoke; pub use error::RefreshTokenFailedError; pub use error::RefreshTokenFailedReason; pub use manager::*; +pub use revoke::LogoutResult; +pub use revoke::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs new file mode 100644 index 000000000000..c0d80aef7fea --- /dev/null +++ b/codex-rs/login/src/auth/revoke.rs @@ -0,0 +1,170 @@ +use serde::Serialize; + +use codex_app_server_protocol::AuthMode as ApiAuthMode; +use codex_client::CodexHttpClient; + +use super::manager::CLIENT_ID; +use super::manager::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use super::storage::AuthDotJson; +use super::util::try_parse_error_message; +use crate::default_client::create_client; +use crate::token_data::TokenData; + +const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke"; +pub const REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REVOKE_TOKEN_URL_OVERRIDE"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogoutResult { + pub removed: bool, + pub revoke_error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RevokeTokenKind { + Access, + Refresh, +} + +impl RevokeTokenKind { + fn hint(self) -> &'static str { + match self { + Self::Access => "access_token", + Self::Refresh => "refresh_token", + } + } + + fn label(self) -> &'static str { + match self { + Self::Access => "access token", + Self::Refresh => "refresh token", + } + } + + fn client_id(self) -> Option<&'static str> { + match self { + Self::Access => None, + Self::Refresh => Some(CLIENT_ID), + } + } +} + +#[derive(Serialize)] +struct RevokeTokenRequest<'a> { + token: &'a str, + token_type_hint: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + client_id: Option<&'static str>, +} + +pub(super) async fn revoke_auth_tokens( + auth_dot_json: Option<&AuthDotJson>, +) -> Result<(), std::io::Error> { + let Some(tokens) = auth_dot_json.and_then(managed_chatgpt_tokens) else { + return Ok(()); + }; + + let client = create_client(); + if !tokens.refresh_token.is_empty() { + revoke_oauth_token( + &client, + tokens.refresh_token.as_str(), + RevokeTokenKind::Refresh, + ) + .await + } else if !tokens.access_token.is_empty() { + revoke_oauth_token( + &client, + tokens.access_token.as_str(), + RevokeTokenKind::Access, + ) + .await + } else { + Ok(()) + } +} + +fn managed_chatgpt_tokens(auth_dot_json: &AuthDotJson) -> Option<&TokenData> { + if resolved_auth_mode(auth_dot_json) == ApiAuthMode::Chatgpt { + auth_dot_json.tokens.as_ref() + } else { + None + } +} + +fn resolved_auth_mode(auth_dot_json: &AuthDotJson) -> ApiAuthMode { + if let Some(mode) = auth_dot_json.auth_mode { + return mode; + } + if auth_dot_json.openai_api_key.is_some() { + return ApiAuthMode::ApiKey; + } + ApiAuthMode::Chatgpt +} + +async fn revoke_oauth_token( + client: &CodexHttpClient, + token: &str, + kind: RevokeTokenKind, +) -> Result<(), std::io::Error> { + let request = RevokeTokenRequest { + token, + token_type_hint: kind.hint(), + client_id: kind.client_id(), + }; + + let response = client + .post(revoke_token_endpoint().as_str()) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(std::io::Error::other)?; + + let status = response.status(); + if status.is_success() { + return Ok(()); + } + + let body = response.text().await.unwrap_or_default(); + let message = try_parse_error_message(&body); + Err(std::io::Error::other(format!( + "failed to revoke {}: {}: {}", + kind.label(), + status, + message + ))) +} + +fn revoke_token_endpoint() -> String { + if let Ok(endpoint) = std::env::var(REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR) { + return endpoint; + } + + if let Ok(refresh_endpoint) = std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR) + && let Some(endpoint) = derive_revoke_token_endpoint(&refresh_endpoint) + { + return endpoint; + } + + REVOKE_TOKEN_URL.to_string() +} + +fn derive_revoke_token_endpoint(refresh_endpoint: &str) -> Option { + let mut url = url::Url::parse(refresh_endpoint).ok()?; + url.set_path("/oauth/revoke"); + url.set_query(None); + Some(url.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_revoke_url_from_refresh_token_override() { + assert_eq!( + derive_revoke_token_endpoint("http://127.0.0.1:1234/oauth/token?unified=true"), + Some("http://127.0.0.1:1234/oauth/revoke".to_string()) + ); + } +} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 4880e431061f..2e2a870f9b3e 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -33,8 +33,10 @@ pub use auth::ExternalAuthChatgptMetadata; pub use auth::ExternalAuthRefreshContext; pub use auth::ExternalAuthRefreshReason; pub use auth::ExternalAuthTokens; +pub use auth::LogoutResult; pub use auth::OPENAI_API_KEY_ENV_VAR; pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +pub use auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; pub use auth::RefreshTokenError; pub use auth::UnauthorizedRecovery; pub use auth::default_client; @@ -42,6 +44,7 @@ pub use auth::enforce_login_restrictions; pub use auth::load_auth_dot_json; pub use auth::login_with_api_key; pub use auth::logout; +pub use auth::logout_with_revoke; pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use auth_env_telemetry::AuthEnvTelemetry; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs new file mode 100644 index 000000000000..7ff442c56f32 --- /dev/null +++ b/codex-rs/login/tests/suite/logout.rs @@ -0,0 +1,175 @@ +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use codex_app_server_protocol::AuthMode; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthDotJson; +use codex_login::CLIENT_ID; +use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_login::logout_with_revoke; +use codex_login::save_auth; +use codex_login::token_data::IdTokenInfo; +use codex_login::token_data::TokenData; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::ffi::OsString; +use tempfile::TempDir; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const ACCESS_TOKEN: &str = "access-token"; +const REFRESH_TOKEN: &str = "refresh-token"; + +#[serial_test::serial(logout_revoke)] +#[tokio::test] +async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "message": "success" + }))) + .expect(1) + .mount(&server) + .await; + let _env_guard = EnvGuard::set( + REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR, + format!("{}/oauth/revoke", server.uri()), + ); + + let codex_home = TempDir::new()?; + save_auth( + codex_home.path(), + &chatgpt_auth(), + AuthCredentialsStoreMode::File, + )?; + + let result = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + + assert!(result.removed); + assert_eq!(result.revoke_error, None); + assert!(!codex_home.path().join("auth.json").exists()); + + let requests = server + .received_requests() + .await + .context("failed to fetch revoke requests")?; + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0] + .body_json::() + .context("revoke request should be JSON")?, + json!({ + "token": REFRESH_TOKEN, + "token_type_hint": "refresh_token", + "client_id": CLIENT_ID, + }) + ); + server.verify().await; + Ok(()) +} + +#[serial_test::serial(logout_revoke)] +#[tokio::test] +async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(500).set_body_json(json!({ + "error": { + "message": "revoke failed" + } + }))) + .expect(1) + .mount(&server) + .await; + let _env_guard = EnvGuard::set( + REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR, + format!("{}/oauth/revoke", server.uri()), + ); + + let codex_home = TempDir::new()?; + save_auth( + codex_home.path(), + &chatgpt_auth(), + AuthCredentialsStoreMode::File, + )?; + + let result = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + + assert!(result.removed); + assert!( + result + .revoke_error + .as_deref() + .is_some_and(|err| err.contains("revoke failed")), + "expected revoke failure in result: {result:?}" + ); + assert!(!codex_home.path().join("auth.json").exists()); + + server.verify().await; + Ok(()) +} + +fn chatgpt_auth() -> AuthDotJson { + AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: IdTokenInfo { + raw_jwt: minimal_jwt(), + ..Default::default() + }, + access_token: ACCESS_TOKEN.to_string(), + refresh_token: REFRESH_TOKEN.to_string(), + account_id: Some("account-id".to_string()), + }), + last_refresh: None, + agent_identity: None, + } +} + +fn minimal_jwt() -> String { + let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); + let header_b64 = b64(br#"{"alg":"none"}"#); + let payload_b64 = b64(br#"{"sub":"user-123"}"#); + let signature_b64 = b64(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") +} + +struct EnvGuard { + key: &'static str, + original: Option, +} + +impl EnvGuard { + fn set(key: &'static str, value: String) -> Self { + let original = std::env::var_os(key); + // SAFETY: these tests execute serially, so updating the process environment is safe. + unsafe { + std::env::set_var(key, &value); + } + Self { key, original } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + // SAFETY: the guard restores the original environment value before other tests run. + unsafe { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} diff --git a/codex-rs/login/tests/suite/mod.rs b/codex-rs/login/tests/suite/mod.rs index 3d1eddd1a1ab..3c3bb24d62dc 100644 --- a/codex-rs/login/tests/suite/mod.rs +++ b/codex-rs/login/tests/suite/mod.rs @@ -2,3 +2,4 @@ mod auth_refresh; mod device_code_login; mod login_server_e2e; +mod logout; From 85d20f78dfa19d09bfc0d5c1271c1f9847856cf0 Mon Sep 17 00:00:00 2001 From: Sashank Shukla Date: Tue, 14 Apr 2026 20:21:53 +0000 Subject: [PATCH 2/7] Address logout revoke review feedback Co-authored-by: Codex --- .../app-server/src/codex_message_processor.rs | 6 +--- codex-rs/cli/src/login.rs | 7 ++--- codex-rs/login/src/auth/manager.rs | 29 +++++-------------- codex-rs/login/src/auth/mod.rs | 2 -- codex-rs/login/src/auth/revoke.rs | 24 ++++----------- codex-rs/login/src/lib.rs | 1 - codex-rs/login/tests/suite/logout.rs | 21 ++++++-------- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 25 +++++++++++----- 8 files changed, 42 insertions(+), 73 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2bad8a2f05f1..3a3a8e6d4138 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1698,11 +1698,7 @@ impl CodexMessageProcessor { } match self.auth_manager.logout_with_revoke().await { - Ok(result) => { - if let Some(err) = result.revoke_error { - tracing::warn!("failed to revoke OAuth token during logout: {err}"); - } - } + Ok(_) => {} Err(err) => { return Err(JSONRPCErrorError { code: INTERNAL_ERROR_CODE, diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index a840d083a6fb..fd0dfee3afca 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -348,14 +348,11 @@ pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; match logout_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await { - Ok(result) if result.removed => { - if let Some(err) = result.revoke_error { - eprintln!("Warning: {err}"); - } + Ok(true) => { eprintln!("Successfully logged out"); std::process::exit(0); } - Ok(_) => { + Ok(false) => { eprintln!("Not logged in"); std::process::exit(0); } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index c24f197dea45..54a70bd6b03d 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -21,7 +21,6 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; use super::external_bearer::BearerTokenRefresher; -use super::revoke::LogoutResult; use super::revoke::revoke_auth_tokens; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; @@ -88,7 +87,9 @@ const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = "Your access token could not be refreshed. Please log out and sign in again."; const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again."; const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; +pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +pub const REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REVOKE_TOKEN_URL_OVERRIDE"; #[derive(Debug, Error)] pub enum RefreshTokenError { @@ -488,27 +489,11 @@ pub fn logout( pub async fn logout_with_revoke( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, -) -> std::io::Result { +) -> std::io::Result { let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); - let auth_dot_json = match storage.load() { - Ok(auth_dot_json) => auth_dot_json, - Err(err) => { - let removed = logout_all_stores(codex_home, auth_credentials_store_mode)?; - return Ok(LogoutResult { - removed, - revoke_error: Some(format!("failed to load stored auth for revocation: {err}")), - }); - } - }; - let revoke_error = revoke_auth_tokens(auth_dot_json.as_ref()) - .await - .err() - .map(|err| err.to_string()); - let removed = logout_all_stores(codex_home, auth_credentials_store_mode)?; - Ok(LogoutResult { - removed, - revoke_error, - }) + let auth_dot_json = storage.load()?; + revoke_auth_tokens(auth_dot_json.as_ref()).await?; + logout_all_stores(codex_home, auth_credentials_store_mode) } /// Writes an `auth.json` that contains only the API key. @@ -1665,7 +1650,7 @@ impl AuthManager { Ok(removed) } - pub async fn logout_with_revoke(&self) -> std::io::Result { + pub async fn logout_with_revoke(&self) -> std::io::Result { let result = logout_with_revoke(&self.codex_home, self.auth_credentials_store_mode).await?; // Always reload to clear any cached auth (even if file absent). self.reload(); diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index ef7133767ad7..b927f9a77520 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -10,5 +10,3 @@ mod revoke; pub use error::RefreshTokenFailedError; pub use error::RefreshTokenFailedReason; pub use manager::*; -pub use revoke::LogoutResult; -pub use revoke::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index c0d80aef7fea..1a11f9dd63dc 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -5,20 +5,13 @@ use codex_client::CodexHttpClient; use super::manager::CLIENT_ID; use super::manager::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use super::manager::REVOKE_TOKEN_URL; +use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use super::storage::AuthDotJson; use super::util::try_parse_error_message; use crate::default_client::create_client; use crate::token_data::TokenData; -const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke"; -pub const REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REVOKE_TOKEN_URL_OVERRIDE"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LogoutResult { - pub removed: bool, - pub revoke_error: Option, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RevokeTokenKind { Access, @@ -26,20 +19,13 @@ enum RevokeTokenKind { } impl RevokeTokenKind { - fn hint(self) -> &'static str { + fn as_str(self) -> &'static str { match self { Self::Access => "access_token", Self::Refresh => "refresh_token", } } - fn label(self) -> &'static str { - match self { - Self::Access => "access token", - Self::Refresh => "refresh token", - } - } - fn client_id(self) -> Option<&'static str> { match self { Self::Access => None, @@ -108,7 +94,7 @@ async fn revoke_oauth_token( ) -> Result<(), std::io::Error> { let request = RevokeTokenRequest { token, - token_type_hint: kind.hint(), + token_type_hint: kind.as_str(), client_id: kind.client_id(), }; @@ -129,7 +115,7 @@ async fn revoke_oauth_token( let message = try_parse_error_message(&body); Err(std::io::Error::other(format!( "failed to revoke {}: {}: {}", - kind.label(), + kind.as_str(), status, message ))) diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 2e2a870f9b3e..c655ec2ac982 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -33,7 +33,6 @@ pub use auth::ExternalAuthChatgptMetadata; pub use auth::ExternalAuthRefreshContext; pub use auth::ExternalAuthRefreshReason; pub use auth::ExternalAuthTokens; -pub use auth::LogoutResult; pub use auth::OPENAI_API_KEY_ENV_VAR; pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; pub use auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 7ff442c56f32..c723db69af55 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -51,10 +51,9 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< AuthCredentialsStoreMode::File, )?; - let result = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + let removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; - assert!(result.removed); - assert_eq!(result.revoke_error, None); + assert!(removed); assert!(!codex_home.path().join("auth.json").exists()); let requests = server @@ -78,7 +77,7 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< #[serial_test::serial(logout_revoke)] #[tokio::test] -async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { +async fn logout_with_revoke_keeps_auth_when_revoke_fails() -> Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; @@ -104,17 +103,15 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let result = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + let err = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File) + .await + .expect_err("revoke failure should fail logout"); - assert!(result.removed); assert!( - result - .revoke_error - .as_deref() - .is_some_and(|err| err.contains("revoke failed")), - "expected revoke failure in result: {result:?}" + err.to_string().contains("revoke failed"), + "expected revoke failure in result: {err:?}" ); - assert!(!codex_home.path().join("auth.json").exists()); + assert!(codex_home.path().join("auth.json").exists()); server.verify().await; Ok(()) diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 2c5c83b380f3..faa2bc6caefd 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -235,13 +235,24 @@ impl ChatWidget { self.request_quit_without_confirmation(); } SlashCommand::Logout => { - if let Err(e) = codex_login::logout( - &self.config.codex_home, - self.config.cli_auth_credentials_store_mode, - ) { - tracing::error!("failed to logout: {e}"); - } - self.request_quit_without_confirmation(); + let codex_home = self.config.codex_home.clone(); + let auth_credentials_store_mode = self.config.cli_auth_credentials_store_mode; + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + match codex_login::logout_with_revoke(&codex_home, auth_credentials_store_mode) + .await + { + Ok(_) => { + app_event_tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); + } + Err(e) => { + tracing::error!("failed to logout: {e}"); + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!("Logout failed: {e}")), + ))); + } + } + }); } // SlashCommand::Undo => { // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); From 1f5745d2c51a56237bce17d2f08fc3598b8d8e65 Mon Sep 17 00:00:00 2001 From: Sashank Shukla Date: Tue, 14 Apr 2026 21:08:58 +0000 Subject: [PATCH 3/7] Make logout revoke best effort Continue deleting local auth state even when the OAuth revoke request fails. Co-authored-by: Codex --- codex-rs/login/src/auth/manager.rs | 12 ++++++++++-- codex-rs/login/tests/suite/logout.rs | 13 ++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 54a70bd6b03d..b414c8212d38 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -491,8 +491,16 @@ pub async fn logout_with_revoke( auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result { let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); - let auth_dot_json = storage.load()?; - revoke_auth_tokens(auth_dot_json.as_ref()).await?; + match storage.load() { + Ok(auth_dot_json) => { + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + tracing::warn!("failed to revoke auth tokens during logout: {err}"); + } + } + Err(err) => { + tracing::warn!("failed to load auth before token revoke during logout: {err}"); + } + } logout_all_stores(codex_home, auth_credentials_store_mode) } diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index c723db69af55..f7a04ec10fa5 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -77,7 +77,7 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< #[serial_test::serial(logout_revoke)] #[tokio::test] -async fn logout_with_revoke_keeps_auth_when_revoke_fails() -> Result<()> { +async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { skip_if_no_network!(Ok(())); let server = MockServer::start().await; @@ -103,15 +103,10 @@ async fn logout_with_revoke_keeps_auth_when_revoke_fails() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let err = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File) - .await - .expect_err("revoke failure should fail logout"); + let removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; - assert!( - err.to_string().contains("revoke failed"), - "expected revoke failure in result: {err:?}" - ); - assert!(codex_home.path().join("auth.json").exists()); + assert!(removed); + assert!(!codex_home.path().join("auth.json").exists()); server.verify().await; Ok(()) From a7c4939501d8214f2807c523132c3ada6d12043b Mon Sep 17 00:00:00 2001 From: Sashank Shukla Date: Wed, 15 Apr 2026 18:43:29 +0000 Subject: [PATCH 4/7] Deflake request permissions test timeout Increase the command timeout for request-permissions shell events so CI does not time out while waiting for approval and sandbox setup. Co-authored-by: Codex --- codex-rs/core/tests/suite/request_permissions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f783..dbc30da4f8fb 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -94,7 +94,7 @@ fn shell_event_with_request_permissions( ) -> Result { let args = json!({ "command": command, - "timeout_ms": 1_000_u64, + "timeout_ms": 10_000_u64, "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, "additional_permissions": additional_permissions, }); From fc1c7cec1e0ba2a86caba3f26ff39fbe90afd459 Mon Sep 17 00:00:00 2001 From: Sashank Shukla Date: Thu, 16 Apr 2026 12:07:30 -0700 Subject: [PATCH 5/7] Limit logout revoke PR scope --- codex-rs/core/tests/suite/request_permissions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index dbc30da4f8fb..2cfd1cf6f783 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -94,7 +94,7 @@ fn shell_event_with_request_permissions( ) -> Result { let args = json!({ "command": command, - "timeout_ms": 10_000_u64, + "timeout_ms": 1_000_u64, "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, "additional_permissions": additional_permissions, }); From be4de2fe3ec8914aef4c3c6e5cc37d38c512b63b Mon Sep 17 00:00:00 2001 From: Sashank Shukla Date: Thu, 16 Apr 2026 12:16:11 -0700 Subject: [PATCH 6/7] Bound logout revoke request duration --- codex-rs/login/src/auth/revoke.rs | 49 ++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index 1a11f9dd63dc..3ba98c5e9e53 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -1,4 +1,5 @@ use serde::Serialize; +use std::time::Duration; use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_client::CodexHttpClient; @@ -12,6 +13,8 @@ use super::util::try_parse_error_message; use crate::default_client::create_client; use crate::token_data::TokenData; +const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RevokeTokenKind { Access, @@ -50,18 +53,23 @@ pub(super) async fn revoke_auth_tokens( }; let client = create_client(); + let endpoint = revoke_token_endpoint(); if !tokens.refresh_token.is_empty() { revoke_oauth_token( &client, + endpoint.as_str(), tokens.refresh_token.as_str(), RevokeTokenKind::Refresh, + REVOKE_HTTP_TIMEOUT, ) .await } else if !tokens.access_token.is_empty() { revoke_oauth_token( &client, + endpoint.as_str(), tokens.access_token.as_str(), RevokeTokenKind::Access, + REVOKE_HTTP_TIMEOUT, ) .await } else { @@ -89,8 +97,10 @@ fn resolved_auth_mode(auth_dot_json: &AuthDotJson) -> ApiAuthMode { async fn revoke_oauth_token( client: &CodexHttpClient, + endpoint: &str, token: &str, kind: RevokeTokenKind, + timeout: Duration, ) -> Result<(), std::io::Error> { let request = RevokeTokenRequest { token, @@ -99,8 +109,9 @@ async fn revoke_oauth_token( }; let response = client - .post(revoke_token_endpoint().as_str()) + .post(endpoint) .header("Content-Type", "application/json") + .timeout(timeout) .json(&request) .send() .await @@ -145,6 +156,12 @@ fn derive_revoke_token_endpoint(refresh_endpoint: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use core_test_support::skip_if_no_network; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; #[test] fn derives_revoke_url_from_refresh_token_override() { @@ -153,4 +170,34 @@ mod tests { Some("http://127.0.0.1:1234/oauth/revoke".to_string()) ); } + + #[tokio::test] + async fn revoke_request_times_out() { + skip_if_no_network!(); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(60))) + .mount(&server) + .await; + + let client = CodexHttpClient::new(reqwest::Client::new()); + let endpoint = format!("{}/oauth/revoke", server.uri()); + let error = revoke_oauth_token( + &client, + endpoint.as_str(), + "refresh-token", + RevokeTokenKind::Refresh, + Duration::from_millis(20), + ) + .await + .expect_err("stalled revoke request should time out"); + + let reqwest_error = error + .get_ref() + .and_then(|error| error.downcast_ref::()) + .expect("timeout error should preserve reqwest error"); + assert!(reqwest_error.is_timeout()); + } } From f0d50993544040a9743573f9c35e787a09ab19d4 Mon Sep 17 00:00:00 2001 From: Sashank Shukla Date: Thu, 16 Apr 2026 19:02:31 -0700 Subject: [PATCH 7/7] Address logout revoke review feedback --- codex-rs/login/src/auth/manager.rs | 27 ++++---- codex-rs/login/src/auth/revoke.rs | 6 ++ codex-rs/login/tests/suite/logout.rs | 68 ++++++++++++++++++- codex-rs/tui/src/app.rs | 12 ++++ codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/app_server_session.rs | 14 ++++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 19 +----- .../src/chatwidget/tests/slash_commands.rs | 9 +++ 8 files changed, 126 insertions(+), 32 deletions(-) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index b414c8212d38..c67d73fedff3 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -490,18 +490,13 @@ pub async fn logout_with_revoke( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result { - let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); - match storage.load() { - Ok(auth_dot_json) => { - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { - tracing::warn!("failed to revoke auth tokens during logout: {err}"); - } - } - Err(err) => { - tracing::warn!("failed to load auth before token revoke during logout: {err}"); - } - } - logout_all_stores(codex_home, auth_credentials_store_mode) + AuthManager::new( + codex_home.to_path_buf(), + /*enable_codex_api_key_env*/ false, + auth_credentials_store_mode, + ) + .logout_with_revoke() + .await } /// Writes an `auth.json` that contains only the API key. @@ -1659,7 +1654,13 @@ impl AuthManager { } pub async fn logout_with_revoke(&self) -> std::io::Result { - let result = logout_with_revoke(&self.codex_home, self.auth_credentials_store_mode).await?; + let auth_dot_json = self + .auth_cached() + .and_then(|auth| auth.get_current_auth_json()); + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + tracing::warn!("failed to revoke auth tokens during logout: {err}"); + } + let result = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; // Always reload to clear any cached auth (even if file absent). self.reload(); Ok(result) diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index 3ba98c5e9e53..71164523a9da 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -1,3 +1,9 @@ +//! Best-effort OAuth token revocation used during logout. +//! +//! Managed ChatGPT auth stores OAuth tokens locally. Logout attempts to revoke the +//! refresh token, falling back to the access token when no refresh token is +//! available, and callers still remove local auth if the revoke request fails. + use serde::Serialize; use std::time::Duration; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index f7a04ec10fa5..59de9dabda92 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -4,6 +4,7 @@ use base64::Engine; use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthDotJson; +use codex_login::AuthManager; use codex_login::CLIENT_ID; use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_login::logout_with_revoke; @@ -112,7 +113,72 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { Ok(()) } +#[serial_test::serial(logout_revoke)] +#[tokio::test] +async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/revoke")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "message": "success" + }))) + .expect(1) + .mount(&server) + .await; + let _env_guard = EnvGuard::set( + REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR, + format!("{}/oauth/revoke", server.uri()), + ); + + let codex_home = TempDir::new()?; + save_auth( + codex_home.path(), + &chatgpt_auth_with_refresh_token(REFRESH_TOKEN), + AuthCredentialsStoreMode::File, + )?; + let manager = AuthManager::new( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + ); + save_auth( + codex_home.path(), + &chatgpt_auth_with_refresh_token("newer-disk-refresh-token"), + AuthCredentialsStoreMode::File, + )?; + + let removed = manager.logout_with_revoke().await?; + + assert!(removed); + assert!(manager.auth_cached().is_none()); + assert!(!codex_home.path().join("auth.json").exists()); + + let requests = server + .received_requests() + .await + .context("failed to fetch revoke requests")?; + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0] + .body_json::() + .context("revoke request should be JSON")?, + json!({ + "token": REFRESH_TOKEN, + "token_type_hint": "refresh_token", + "client_id": CLIENT_ID, + }) + ); + server.verify().await; + Ok(()) +} + fn chatgpt_auth() -> AuthDotJson { + chatgpt_auth_with_refresh_token(REFRESH_TOKEN) +} + +fn chatgpt_auth_with_refresh_token(refresh_token: &str) -> AuthDotJson { AuthDotJson { auth_mode: Some(AuthMode::Chatgpt), openai_api_key: None, @@ -122,7 +188,7 @@ fn chatgpt_auth() -> AuthDotJson { ..Default::default() }, access_token: ACCESS_TOKEN.to_string(), - refresh_token: REFRESH_TOKEN.to_string(), + refresh_token: refresh_token.to_string(), account_id: Some("account-id".to_string()), }), last_refresh: None, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ab20b2214cd5..4efb55497d86 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4559,6 +4559,18 @@ impl App { AppEvent::Exit(mode) => { return Ok(self.handle_exit_mode(app_server, mode).await); } + AppEvent::Logout => match app_server.logout_account().await { + Ok(()) => { + return Ok(self + .handle_exit_mode(app_server, ExitMode::ShutdownFirst) + .await); + } + Err(err) => { + tracing::error!("failed to logout: {err}"); + self.chat_widget + .add_error_message(format!("Logout failed: {err}")); + } + }, AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 934baac2317e..e6f8898a5f27 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -139,6 +139,9 @@ pub(crate) enum AppEvent { /// background tasks, rollout flush, or child process cleanup). Exit(ExitMode), + /// Request app-server account logout, then exit after it succeeds. + Logout, + /// Request to exit the application due to a fatal error. #[allow(dead_code)] FatalExitRequest(String), diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 5e11a5334001..677614907ce5 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -18,6 +18,7 @@ use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; @@ -553,6 +554,19 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn logout_account(&mut self) -> Result<()> { + let request_id = self.next_request_id(); + let _: LogoutAccountResponse = self + .client + .request_typed(ClientRequest::LogoutAccount { + request_id, + params: None, + }) + .await + .wrap_err("account/logout failed in TUI")?; + Ok(()) + } + pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> { let request_id = self.next_request_id(); let _: ThreadUnsubscribeResponse = self diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index faa2bc6caefd..c2cec8626c7e 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -235,24 +235,7 @@ impl ChatWidget { self.request_quit_without_confirmation(); } SlashCommand::Logout => { - let codex_home = self.config.codex_home.clone(); - let auth_credentials_store_mode = self.config.cli_auth_credentials_store_mode; - let app_event_tx = self.app_event_tx.clone(); - tokio::spawn(async move { - match codex_login::logout_with_revoke(&codex_home, auth_credentials_store_mode) - .await - { - Ok(_) => { - app_event_tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); - } - Err(e) => { - tracing::error!("failed to logout: {e}"); - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(format!("Logout failed: {e}")), - ))); - } - } - }); + self.app_event_tx.send(AppEvent::Logout); } // SlashCommand::Undo => { // self.app_event_tx.send(AppEvent::CodexOp(Op::Undo)); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index c7caacc2eba0..e9276582594c 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -247,6 +247,15 @@ async fn slash_quit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn slash_logout_requests_app_server_logout() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Logout); + + assert_matches!(rx.try_recv(), Ok(AppEvent::Logout)); +} + #[tokio::test] async fn slash_copy_state_tracks_turn_complete_final_reply() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;