diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 8f44e83830c3..f2701a17c195 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1586,6 +1586,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 35c9dc86be77..57d42fae52fb 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10075,6 +10075,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 03ac3475e14a..4117ac3a2d84 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6729,6 +6729,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" diff --git a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json index a933b71a83ae..ab7b852c9185 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/LoginAccountParams.json @@ -23,6 +23,9 @@ }, { "properties": { + "codexStreamlinedLogin": { + "type": "boolean" + }, "type": { "enum": [ "chatgpt" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts index 4831a6b2ded2..e6f1e2ed4369 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/LoginAccountParams.ts @@ -2,7 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt" } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", +export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens", /** * Access token (JWT) supplied by the client. * This token is used for backend API requests and email extraction. diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 01985b7bd3fc..48c663fabbaf 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2104,7 +2104,9 @@ mod tests { fn serialize_account_login_chatgpt() -> Result<()> { let request = ClientRequest::LoginAccount { request_id: RequestId::Integer(3), - params: v2::LoginAccountParams::Chatgpt, + params: v2::LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + }, }; assert_eq!( json!({ @@ -2119,6 +2121,28 @@ mod tests { Ok(()) } + #[test] + fn serialize_account_login_chatgpt_streamlined() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(3), + params: v2::LoginAccountParams::Chatgpt { + codex_streamlined_login: true, + }, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 3, + "params": { + "type": "chatgpt", + "codexStreamlinedLogin": true + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_account_login_chatgpt_device_code() -> Result<()> { let request = ClientRequest::LoginAccount { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4669ec6cc9b0..a164f37ee66b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2174,9 +2174,12 @@ pub enum LoginAccountParams { #[ts(rename = "apiKey")] api_key: String, }, - #[serde(rename = "chatgpt")] - #[ts(rename = "chatgpt")] - Chatgpt, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + codex_streamlined_login: bool, + }, #[serde(rename = "chatgptDeviceCode")] #[ts(rename = "chatgptDeviceCode")] ChatgptDeviceCode, diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 2a3cea273bf9..edea431c61f8 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1607,7 +1607,9 @@ impl CodexClient { let request_id = self.request_id(); let request = ClientRequest::LoginAccount { request_id: request_id.clone(), - params: codex_app_server_protocol::LoginAccountParams::Chatgpt, + params: codex_app_server_protocol::LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + }, }; self.send_request(request, request_id, "account/login/start") diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 77fe5f9ec99e..ae132161a2ea 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1308,8 +1308,11 @@ impl CodexMessageProcessor { self.login_api_key_v2(request_id, LoginApiKeyParams { api_key }) .await; } - LoginAccountParams::Chatgpt => { - self.login_chatgpt_v2(request_id).await; + LoginAccountParams::Chatgpt { + codex_streamlined_login, + } => { + self.login_chatgpt_v2(request_id, codex_streamlined_login) + .await; } LoginAccountParams::ChatgptDeviceCode => { self.login_chatgpt_device_code_v2(request_id).await; @@ -1411,6 +1414,7 @@ impl CodexMessageProcessor { // Build options for a ChatGPT login attempt; performs validation. async fn login_chatgpt_common( &self, + codex_streamlined_login: bool, ) -> std::result::Result { let config = self.config.as_ref(); @@ -1428,6 +1432,7 @@ impl CodexMessageProcessor { let opts = LoginServerOptions { open_browser: false, + codex_streamlined_login, ..LoginServerOptions::new( config.codex_home.to_path_buf(), CLIENT_ID.to_string(), @@ -1466,13 +1471,20 @@ impl CodexMessageProcessor { } } - async fn login_chatgpt_v2(&self, request_id: ConnectionRequestId) { - let result = self.login_chatgpt_response().await; + async fn login_chatgpt_v2( + &self, + request_id: ConnectionRequestId, + codex_streamlined_login: bool, + ) { + let result = self.login_chatgpt_response(codex_streamlined_login).await; self.outgoing.send_result(request_id, result).await; } - async fn login_chatgpt_response(&self) -> Result { - let opts = self.login_chatgpt_common().await?; + async fn login_chatgpt_response( + &self, + codex_streamlined_login: bool, + ) -> Result { + let opts = self.login_chatgpt_common(codex_streamlined_login).await?; let server = run_login_server(opts) .map_err(|err| internal_error(format!("failed to start login server: {err}")))?; let login_id = Uuid::new_v4(); @@ -1543,7 +1555,9 @@ impl CodexMessageProcessor { async fn login_chatgpt_device_code_response( &self, ) -> Result { - let opts = self.login_chatgpt_common().await?; + let opts = self + .login_chatgpt_common(/*codex_streamlined_login*/ false) + .await?; let device_code = request_device_code(&opts) .await .map_err(Self::login_chatgpt_device_code_start_error)?; diff --git a/codex-rs/login/BUILD.bazel b/codex-rs/login/BUILD.bazel index fe5866577fc8..1265a83779ad 100644 --- a/codex-rs/login/BUILD.bazel +++ b/codex-rs/login/BUILD.bazel @@ -6,5 +6,6 @@ codex_rust_crate( compile_data = [ "src/assets/error.html", "src/assets/success.html", + "src/assets/success_legacy.html", ], ) diff --git a/codex-rs/login/src/assets/success.html b/codex-rs/login/src/assets/success.html index 382f864c6a51..5b01ceb90e96 100644 --- a/codex-rs/login/src/assets/success.html +++ b/codex-rs/login/src/assets/success.html @@ -2,197 +2,236 @@ - Sign into Codex - + + Signed in to Codex + -
-
-
- -
Signed in to Codex
-
- - + - - \ No newline at end of file + diff --git a/codex-rs/login/src/assets/success_legacy.html b/codex-rs/login/src/assets/success_legacy.html new file mode 100644 index 000000000000..015866ee546d --- /dev/null +++ b/codex-rs/login/src/assets/success_legacy.html @@ -0,0 +1,197 @@ + + + + + Sign into Codex + + + + +
+
+
+ +
Signed in to Codex
+
+ + +
+
+ + + diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 0c7b81018432..3dd74199e79b 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -65,6 +65,7 @@ pub struct ServerOptions { pub open_browser: bool, pub force_state: Option, pub forced_chatgpt_workspace_id: Option, + pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, } @@ -84,6 +85,7 @@ impl ServerOptions { open_browser: true, force_state: None, forced_chatgpt_workspace_id, + codex_streamlined_login: false, cli_auth_credentials_store_mode, } } @@ -372,6 +374,7 @@ async fn process_request( &opts.issuer, &tokens.id_token, &tokens.access_token, + opts.codex_streamlined_login, ); match tiny_http::Header::from_bytes(&b"Location"[..], success_url.as_bytes()) { Ok(header) => HandledRequest::RedirectWithHeader(header), @@ -396,7 +399,14 @@ async fn process_request( } } "/success" => { - let body = include_str!("assets/success.html"); + let use_streamlined_success = parsed_url + .query_pairs() + .any(|(key, value)| key == "codex_streamlined_login" && value == "true"); + let body = if use_streamlined_success { + include_str!("assets/success.html") + } else { + include_str!("assets/success_legacy.html") + }; HandledRequest::ResponseAndExit { headers: match Header::from_bytes( &b"Content-Type"[..], @@ -696,13 +706,15 @@ pub(crate) async fn exchange_code_for_tokens( } let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; + let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); info!( issuer = %sanitize_url_for_logging(issuer), + token_endpoint = %sanitize_url_for_logging(&token_endpoint), redirect_uri = %redirect_uri, "starting oauth token exchange" ); let resp = client - .post(format!("{issuer}/oauth/token")) + .post(token_endpoint) .header("Content-Type", "application/x-www-form-urlencoded") .body(format!( "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}", @@ -789,7 +801,13 @@ pub(crate) async fn persist_tokens_async( .map_err(|e| io::Error::other(format!("persist task failed: {e}")))? } -fn compose_success_url(port: u16, issuer: &str, id_token: &str, access_token: &str) -> String { +fn compose_success_url( + port: u16, + issuer: &str, + id_token: &str, + access_token: &str, + codex_streamlined_login: bool, +) -> String { let token_claims = jwt_auth_claims(id_token); let access_claims = jwt_auth_claims(access_token); @@ -829,6 +847,9 @@ fn compose_success_url(port: u16, issuer: &str, id_token: &str, access_token: &s ("plan_type", plan_type.to_string()), ("platform_url", platform_url.to_string()), ]; + if codex_streamlined_login { + params.push(("codex_streamlined_login", "true".to_string())); + } let qs = params .drain(..) .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v))) @@ -1068,8 +1089,9 @@ pub(crate) async fn obtain_api_key( access_token: String, } let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; + let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); let resp = client - .post(format!("{issuer}/oauth/token")) + .post(token_endpoint) .header("Content-Type", "application/x-www-form-urlencoded") .body(format!( "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}", @@ -1095,7 +1117,9 @@ pub(crate) async fn obtain_api_key( mod tests { use pretty_assertions::assert_eq; + use super::DEFAULT_ISSUER; use super::TokenEndpointErrorDetail; + use super::compose_success_url; use super::html_escape; use super::is_missing_codex_entitlement_error; use super::parse_token_endpoint_error; @@ -1202,6 +1226,43 @@ mod tests { ); } + #[test] + fn compose_success_url_omits_streamlined_success_by_default() { + let url = url::Url::parse(&compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + /*codex_streamlined_login*/ false, + )) + .expect("success url should parse"); + + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "codex_streamlined_login"), + None + ); + } + + #[test] + fn compose_success_url_includes_streamlined_success_when_requested() { + let url = url::Url::parse(&compose_success_url( + /*port*/ 1455, + DEFAULT_ISSUER, + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + "e30.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnt9fQ.sig", + /*codex_streamlined_login*/ true, + )) + .expect("success url should parse"); + + assert_eq!( + url.query_pairs() + .find(|(key, _)| key == "codex_streamlined_login") + .map(|(_, value)| value.into_owned()), + Some("true".to_string()) + ); + } + #[test] fn render_login_error_page_escapes_dynamic_fields() { let body = String::from_utf8(render_login_error_page( diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 9522f5b0b0a5..516fba9d9fd1 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -118,6 +118,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { open_browser: false, force_state: Some(state), forced_chatgpt_workspace_id: Some(chatgpt_account_id.to_string()), + codex_streamlined_login: false, }; let server = run_login_server(opts)?; assert!( @@ -179,6 +180,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { open_browser: false, force_state: Some(state), forced_chatgpt_workspace_id: None, + codex_streamlined_login: false, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -218,6 +220,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { open_browser: false, force_state: Some(state.clone()), forced_chatgpt_workspace_id: Some("org-required".to_string()), + codex_streamlined_login: false, }; let server = run_login_server(opts)?; assert!( @@ -275,6 +278,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() open_browser: false, force_state: Some(state.clone()), forced_chatgpt_workspace_id: None, + codex_streamlined_login: false, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -342,6 +346,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< open_browser: false, force_state: Some(state.clone()), forced_chatgpt_workspace_id: None, + codex_streamlined_login: false, }; let server = run_login_server(opts)?; let login_port = server.actual_port; @@ -420,6 +425,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { open_browser: false, force_state: Some("cancel_state".to_string()), forced_chatgpt_workspace_id: None, + codex_streamlined_login: false, }; let first_server = run_login_server(first_opts)?; @@ -440,6 +446,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { open_browser: false, force_state: Some("cancel_state_2".to_string()), forced_chatgpt_workspace_id: None, + codex_streamlined_login: false, }; let second_server = run_login_server(second_opts)?; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 4698fabd92c8..63373b75512b 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -876,7 +876,9 @@ impl AuthModeWidget { match request_handle .request_typed::(ClientRequest::LoginAccount { request_id: onboarding_request_id(), - params: LoginAccountParams::Chatgpt, + params: LoginAccountParams::Chatgpt { + codex_streamlined_login: false, + }, }) .await {