From 22f7ef1cb77692fcbf2c4af204ef97f1722a8b50 Mon Sep 17 00:00:00 2001 From: sashank-oai Date: Thu, 16 Apr 2026 22:51:21 -0700 Subject: [PATCH] [codex] Revoke ChatGPT tokens on logout (#17825) ## Summary This changes Codex logout so managed ChatGPT auth is revoked against AuthAPI before local auth state is removed. CLI logout, TUI `/logout`, and the app-server account logout path now use the token-revoking logout flow instead of only deleting `auth.json` / credential store state. ## Root Cause Logout previously cleared only local auth storage. That removed Codex's local credentials but did not ask the backend to invalidate the refresh/access token state associated with a managed ChatGPT login. ## Behavior For managed ChatGPT auth, logout sends the stored refresh token to `https://auth.openai.com/oauth/revoke` with `token_type_hint: refresh_token` and the Codex OAuth client id, then deletes all local auth stores after revocation succeeds. If only an access token is available, it falls back to revoking that access token. API key auth and externally supplied `chatgptAuthTokens` are still only cleared locally because Codex does not own a refresh token for those modes. Revocation failures are fail-closed: if Codex cannot load stored auth or the backend revoke call fails, logout returns an error and leaves local auth in place so the user can retry instead of silently clearing local state while backend tokens remain valid. ## Validation ran local version of `codex-cli` with staging overrides/harness for auth ran `codex login` then `codex logout`: saw auth.json clear and backend revocation endpoints were called ``` POST /oauth/revoke status: 200 revoking access token should clear auth session clearing auth session due to token revocation successfully revoked session and access token CANONICAL-API-LINE Response: status='200' method='POST' path='/oauth/revoke ``` --------- Co-authored-by: Codex --- .../app-server/src/codex_message_processor.rs | 15 +- codex-rs/cli/src/login.rs | 4 +- codex-rs/login/src/auth/manager.rs | 29 +++ codex-rs/login/src/auth/mod.rs | 1 + codex-rs/login/src/auth/revoke.rs | 209 ++++++++++++++++ codex-rs/login/src/lib.rs | 2 + codex-rs/login/tests/suite/logout.rs | 233 ++++++++++++++++++ codex-rs/login/tests/suite/mod.rs | 1 + 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 | 8 +- .../src/chatwidget/tests/slash_commands.rs | 9 + 13 files changed, 525 insertions(+), 15 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 eb4ad03e76..3b21a2e822 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1702,12 +1702,15 @@ 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(_) => {} + 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 bd17a546a1..fd0dfee3af 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,7 +347,7 @@ 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) { + match logout_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await { Ok(true) => { eprintln!("Successfully logged out"); std::process::exit(0); diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 3cb97f58b0..c67d73fedf 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -21,6 +21,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; use super::external_bearer::BearerTokenRefresher; +use super::revoke::revoke_auth_tokens; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; @@ -86,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 { @@ -483,6 +486,19 @@ pub fn logout( storage.delete() } +pub async fn logout_with_revoke( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result { + 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. pub fn login_with_api_key( codex_home: &Path, @@ -1637,6 +1653,19 @@ impl AuthManager { Ok(removed) } + pub async fn logout_with_revoke(&self) -> std::io::Result { + 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) + } + 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 256cf16a8c..b927f9a775 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -5,6 +5,7 @@ mod util; mod external_bearer; mod manager; +mod revoke; pub use error::RefreshTokenFailedError; pub use error::RefreshTokenFailedReason; diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs new file mode 100644 index 0000000000..71164523a9 --- /dev/null +++ b/codex-rs/login/src/auth/revoke.rs @@ -0,0 +1,209 @@ +//! 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; + +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::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_HTTP_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RevokeTokenKind { + Access, + Refresh, +} + +impl RevokeTokenKind { + fn as_str(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(); + 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 { + 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, + endpoint: &str, + token: &str, + kind: RevokeTokenKind, + timeout: Duration, +) -> Result<(), std::io::Error> { + let request = RevokeTokenRequest { + token, + token_type_hint: kind.as_str(), + client_id: kind.client_id(), + }; + + let response = client + .post(endpoint) + .header("Content-Type", "application/json") + .timeout(timeout) + .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.as_str(), + 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::*; + 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() { + 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()) + ); + } + + #[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()); + } +} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 2aa967d152..d819b0946d 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -32,6 +32,7 @@ pub use auth::ExternalAuthRefreshReason; pub use auth::ExternalAuthTokens; 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; @@ -39,6 +40,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 0000000000..59de9dabda --- /dev/null +++ b/codex-rs/login/tests/suite/logout.rs @@ -0,0 +1,233 @@ +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::AuthManager; +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 removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + + assert!(removed); + 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 removed = logout_with_revoke(codex_home.path(), AuthCredentialsStoreMode::File).await?; + + assert!(removed); + assert!(!codex_home.path().join("auth.json").exists()); + + server.verify().await; + 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, + 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 3d1eddd1a1..3c3bb24d62 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; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ab20b2214c..4efb55497d 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 934baac231..e6f8898a5f 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 5e11a53340..677614907c 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 2c5c83b380..c2cec8626c 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -235,13 +235,7 @@ 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(); + 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 c7caacc2eb..e927658259 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;