diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 17299fc3..8ece9bbc 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -148,6 +148,13 @@ client_request_definitions! { response: v2::LoginAccountResponse, }, + #[serde(rename = "account/login/cancel")] + #[ts(rename = "account/login/cancel")] + CancelLoginAccount { + params: v2::CancelLoginAccountParams, + response: v2::CancelLoginAccountResponse, + }, + #[serde(rename = "account/logout")] #[ts(rename = "account/logout")] LogoutAccount { @@ -235,6 +242,7 @@ client_request_definitions! { params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, response: v1::LoginChatGptResponse, }, + // DEPRECATED in favor of CancelLoginAccount CancelLoginChatGpt { params: v1::CancelLoginChatGptParams, response: v1::CancelLoginChatGptResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index bbd825ba..ea77e09a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -374,11 +374,9 @@ pub enum InputItem { LocalImage { path: PathBuf }, } -// Deprecated in favor of AccountLoginCompletedNotification. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] -/// Deprecated: use `account/login/completed` instead. +/// Deprecated in favor of AccountLoginCompletedNotification. pub struct LoginChatGptCompleteNotification { #[schemars(with = "String")] pub login_id: Uuid, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a59f00d4..ccc55723 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -167,6 +167,18 @@ pub enum LoginAccountResponse { }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountParams { + pub login_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4c14ce3a..ec96d5dc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -15,6 +15,9 @@ use codex_app_server_protocol::ArchiveConversationParams; use codex_app_server_protocol::ArchiveConversationResponse; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::AuthStatusChangeNotification; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::ExecCommandApprovalParams; @@ -233,6 +236,9 @@ impl CodexMessageProcessor { } => { self.logout_v2(request_id).await; } + ClientRequest::CancelLoginAccount { request_id, params } => { + self.cancel_login_v2(request_id, params).await; + } ClientRequest::GetAccount { request_id, params: _, @@ -644,27 +650,59 @@ impl CodexMessageProcessor { } } - async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) { + async fn cancel_login_chatgpt_common( + &mut self, + login_id: Uuid, + ) -> std::result::Result<(), JSONRPCErrorError> { let mut guard = self.active_login.lock().await; if guard.as_ref().map(|l| l.login_id) == Some(login_id) { if let Some(active) = guard.take() { active.drop(); } - drop(guard); - self.outgoing - .send_response( - request_id, - codex_app_server_protocol::CancelLoginChatGptResponse {}, - ) - .await; + Ok(()) } else { - drop(guard); - let error = JSONRPCErrorError { + Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!("login id not found: {login_id}"), data: None, - }; - self.outgoing.send_error(request_id, error).await; + }) + } + } + + async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) { + match self.cancel_login_chatgpt_common(login_id).await { + Ok(()) => { + self.outgoing + .send_response(request_id, CancelLoginChatGptResponse {}) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn cancel_login_v2(&mut self, request_id: RequestId, params: CancelLoginAccountParams) { + let login_id = params.login_id; + match Uuid::parse_str(&login_id) { + Ok(uuid) => match self.cancel_login_chatgpt_common(uuid).await { + Ok(()) => { + self.outgoing + .send_response(request_id, CancelLoginAccountResponse {}) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + }, + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid login id: {login_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 298d9fd4..59a8f20d 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -14,6 +14,7 @@ use anyhow::Context; use assert_cmd::prelude::*; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::ArchiveConversationParams; +use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; @@ -386,6 +387,15 @@ impl McpProcess { self.send_request("account/login/start", Some(params)).await } + /// Send an `account/login/cancel` JSON-RPC request. + pub async fn send_cancel_login_account_request( + &mut self, + params: CancelLoginAccountParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/login/cancel", params).await + } + /// Send a `fuzzyFileSearch` JSON-RPC request. pub async fn send_fuzzy_file_search_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index dfdc932b..a00c97b1 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -3,6 +3,8 @@ use anyhow::bail; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; @@ -16,6 +18,7 @@ use codex_login::login_with_api_key; use pretty_assertions::assert_eq; use serial_test::serial; use std::path::Path; +use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; @@ -229,17 +232,51 @@ async fn login_account_chatgpt_start() -> Result<()> { .await??; let login: LoginAccountResponse = to_response(resp)?; - let LoginAccountResponse::Chatgpt { - login_id: _, - auth_url, - } = login - else { + 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(()) }