diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 19d2e1a7b2..6e5c58f6a0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -165,6 +165,7 @@ windows-sys = { workspace = true, features = [ "Win32_System_IO", "Win32_System_Pipes", "Win32_System_Threading", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } winsplit = "0.1" diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 11a777a112..74c4c3ec5a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -180,7 +180,7 @@ use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use uuid::Uuid; -const JSONRPC_INVALID_REQUEST: i64 = -32600; +pub(crate) const JSONRPC_INVALID_REQUEST: i64 = -32600; const JSONRPC_METHOD_NOT_FOUND: i64 = -32601; const JSONRPC_INVALID_PARAMS: i64 = -32602; pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str = "A previous external agent import is still running. Wait for it to finish before importing again."; @@ -251,6 +251,18 @@ fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> col color_eyre::Report::new(err).wrap_err(message) } +pub(crate) fn is_method_not_found(error: &TypedRequestError) -> bool { + let TypedRequestError::Server { method, source } = error else { + return false; + }; + // Older servers report an unknown method as an invalid request during deserialization. + source.code == JSONRPC_METHOD_NOT_FOUND + || (source.code == JSONRPC_INVALID_REQUEST + && source.message.contains(method.as_str()) + && (source.message.contains("unknown variant") + || source.message.contains("unknown method"))) +} + pub(crate) fn is_history_pagination_unsupported(source: &JSONRPCErrorError) -> bool { if source.code == JSONRPC_METHOD_NOT_FOUND { return true; diff --git a/codex-rs/tui/src/daemon_startup.rs b/codex-rs/tui/src/daemon_startup.rs index 6219b898a1..a319362dcd 100644 --- a/codex-rs/tui/src/daemon_startup.rs +++ b/codex-rs/tui/src/daemon_startup.rs @@ -7,7 +7,7 @@ //! configuration; removed features are inert, and unreported features are assumed disabled. use super::*; -use codex_app_server_client::TypedRequestError; +use crate::app_server_session::is_method_not_found; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::ExperimentalFeatureStage; @@ -196,12 +196,7 @@ pub(super) async fn compatibility_warning( requested.retain(|name, _| !required.contains_key(name)); } } - Err(TypedRequestError::Server { source, .. }) - if source.code == -32601 - || source.code == -32600 - && source.message.contains("configRequirements/read") - && (source.message.contains("unknown variant") - || source.message.contains("unknown method")) => {} + Err(err) if is_method_not_found(&err) => {} Err(_) => return Err("could not check daemon configuration requirements".to_string()), } if requested.is_empty() { diff --git a/codex-rs/tui/src/device_login.rs b/codex-rs/tui/src/device_login.rs new file mode 100644 index 0000000000..bb706cfa44 --- /dev/null +++ b/codex-rs/tui/src/device_login.rs @@ -0,0 +1,216 @@ +//! Handles gateway device login during TUI startup. +use crate::app_server_session::AppServerSession; +use crate::app_server_session::is_method_not_found; +use crate::legacy_core::config::Config; +use crate::resume_picker::SessionSelection; +use codex_app_server_client::AppServerEvent; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::GatewayOAuthCancelResponse; +use codex_app_server_protocol::GatewayOAuthLoginParams; +use codex_app_server_protocol::GatewayOAuthLoginResponse; +use codex_app_server_protocol::GatewayOAuthPrompt; +use codex_app_server_protocol::GatewayOAuthReadParams; +use codex_app_server_protocol::GatewayOAuthReadResponse; +use codex_app_server_protocol::GatewayOAuthStatus; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadStatus; +use std::io; + +pub(crate) async fn prefer_device_login() -> bool { + if crate::clipboard_copy::is_ssh_session() + || cfg!(target_os = "linux") + && !["DISPLAY", "WAYLAND_DISPLAY"] + .iter() + .any(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty())) + { + return true; + } + #[cfg(not(windows))] + let browser_available = webbrowser::Browser::is_available; + #[cfg(windows)] + let browser_available = has_http_handler; + !tokio::task::spawn_blocking(browser_available) + .await + .unwrap_or(false) +} + +#[cfg(windows)] +fn has_http_handler() -> bool { + use windows_sys::Win32::Foundation::S_FALSE; + use windows_sys::Win32::UI::Shell::ASSOCF_IS_PROTOCOL; + use windows_sys::Win32::UI::Shell::ASSOCSTR_COMMAND; + use windows_sys::Win32::UI::Shell::AssocQueryStringW; + + let mut chars = 0; + // A null output buffer asks Windows for the size of the registered HTTP command. + let result = unsafe { + AssocQueryStringW( + ASSOCF_IS_PROTOCOL, + ASSOCSTR_COMMAND, + windows_sys::w!("http"), + std::ptr::null(), + std::ptr::null_mut(), + &mut chars, + ) + }; + result == S_FALSE && chars > 1 +} + +pub(crate) async fn needs_device_login( + state: &GatewayOAuthReadResponse, + prefer_device: impl Future, +) -> bool { + state.required + && state.supports_device_auth + && matches!( + state.status, + Some(GatewayOAuthStatus::NotReady | GatewayOAuthStatus::Failed) + ) + && prefer_device.await +} + +/// Starts device-code login if this session will use a gateway that needs sign-in. +/// +/// `server` reports whether its configured gateway supports device code and checks +/// for a usable credential. `config` provides the model provider settings for this +/// launch. When `selection` resumes a thread, we also check that thread's provider. +/// +/// Returns the URL and one-time code to display, or `None` when startup can continue +/// without a prompt, including when the server refreshes a saved credential. +pub(crate) async fn start_if_needed( + server: &mut AppServerSession, + selection: &SessionSelection, + config: &Config, + prefer_device: impl Future, +) -> io::Result> { + let mut selected_provider = server.explicit_model_provider(config); + if let SessionSelection::Resume(target) = selection { + let thread = server + .thread_read(target.thread_id, /*include_turns*/ false) + .await + .map_err(io::Error::other)?; + // Resuming a thread still in memory reattaches to it with its current provider. + // An override can take effect when the server loads the thread from disk. + if selected_provider.is_none() || thread.status != ThreadStatus::NotLoaded { + selected_provider = Some(thread.model_provider); + } + } + let handle = server.request_handle(); + let state: GatewayOAuthReadResponse = match handle + .request_typed(ClientRequest::GatewayOAuthRead { + request_id: server.next_request_id(), + params: selected_provider.as_ref().map(|id| GatewayOAuthReadParams { + model_provider: Some(id.clone()), + }), + }) + .await + { + Ok(state) => state, + Err(err) if is_method_not_found(&err) => return Ok(None), + Err(error) => { + if let codex_app_server_client::TypedRequestError::Server { source, .. } = &error + && selected_provider.is_some() + && source.code == crate::app_server_session::JSONRPC_INVALID_REQUEST + && source.data.is_none() + { + // Servers with device login always include supportsDeviceAuth, even when false. + // Ask without a provider to recognize older servers that reject named reads. + let probe = handle + .request_typed::(ClientRequest::GatewayOAuthRead { + request_id: server.next_request_id(), + params: None, + }) + .await; + if let Ok(serde_json::Value::Object(state)) = probe + && state.contains_key("providerId") + && !state.contains_key("supportsDeviceAuth") + { + return Ok(None); + } + } + return Err(io::Error::other(error)); + } + }; + if selected_provider.is_some_and(|id| id != state.provider_id) + || !needs_device_login(&state, prefer_device).await + { + return Ok(None); + } + let response: GatewayOAuthLoginResponse = handle + .request_typed(ClientRequest::GatewayOAuthLogin { + request_id: server.next_request_id(), + params: Some(GatewayOAuthLoginParams { + device_auth: true, + model_provider: Some(state.provider_id), + }), + }) + .await + .map_err(io::Error::other)?; + Ok(response.prompt) +} + +pub(crate) async fn cancel(server: &mut AppServerSession) { + let _ = tokio::time::timeout( + std::time::Duration::from_secs(1), + server + .request_handle() + .request_typed::(ClientRequest::GatewayOAuthCancel { + request_id: server.next_request_id(), + params: None, + }), + ) + .await; +} + +/// Handles one app-server `event` while the device code is on screen. +/// +/// `prompt.login_id` identifies the attempt we're waiting for. Returns `Ok(true)` +/// when that attempt succeeds, `Ok(false)` to keep waiting, or an error if sign-in +/// fails or we lose the result. We use the `origin` request handle to reject +/// incoming requests the sign-in screen cannot answer. +pub(crate) async fn handle_event_during_device_login( + prompt: &GatewayOAuthPrompt, + event: AppServerEvent, + origin: &AppServerRequestHandle, +) -> io::Result { + if let AppServerEvent::ServerNotification(notification) = &event + && let ServerNotification::GatewayOAuthChanged(change) = notification.as_ref() + && change.login_id.as_deref() == Some(prompt.login_id.as_str()) + { + return match change.status { + GatewayOAuthStatus::Succeeded => Ok(true), + GatewayOAuthStatus::Failed | GatewayOAuthStatus::NotReady => Err(io::Error::other( + change.error.as_deref().unwrap_or("Gateway sign-in failed"), + )), + GatewayOAuthStatus::Started => Ok(false), + }; + } + match event { + AppServerEvent::Disconnected { message } => Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + color_eyre::eyre::eyre!(message).wrap_err(crate::onboarding::SETUP_CONNECTION_LOST), + )), + AppServerEvent::Lagged { .. } => Err(io::Error::other( + "Lost the gateway sign-in result. Restart Codex to check the connection.", + )), + AppServerEvent::ServerRequest(request) => { + origin + .server_request_reply_handle(request.id().clone()) + .reject(codex_app_server_protocol::JSONRPCErrorError { + code: -32000, + message: "Codex cannot handle this request during gateway sign-in".into(), + data: None, + }) + .await?; + Ok(false) + } + AppServerEvent::ServerNotification(_) | AppServerEvent::ThreadStreamDisconnected(_) => { + Ok(false) + } + } +} + +#[cfg(test)] +#[path = "device_login_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/device_login_tests.rs b/codex-rs/tui/src/device_login_tests.rs new file mode 100644 index 0000000000..0703015d16 --- /dev/null +++ b/codex-rs/tui/src/device_login_tests.rs @@ -0,0 +1,238 @@ +#![cfg(test)] +//! Exercise gateway sign-in over the app-server protocol and snapshot the device prompt. + +use crate::app_server_session::AppServerSession; +use crate::app_server_session::ThreadParamsMode; +use crate::backend_id::BackendId; +use crate::resume_picker::SessionSelection; +use crate::resume_picker::SessionTarget; +use codex_protocol::ThreadId; +use core_test_support::load_default_config_for_test; +use core_test_support::responses::WebSocketTestServer; +use core_test_support::responses::start_websocket_server; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; + +#[test] +fn device_prompt_shows_where_to_enter_the_code_and_how_to_cancel() { + let prompt = codex_app_server_protocol::GatewayOAuthPrompt { + login_id: "attempt".into(), + verification_url: "https://login.example.com/device".into(), + user_code: "ABCD-EFGH".into(), + }; + let backend = crate::test_backend::VT100Backend::new(/*width*/ 80, /*height*/ 16); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + crate::onboarding::onboarding_screen::render_gateway_device_prompt( + &prompt, + frame.area(), + frame.buffer_mut(), + ) + }) + .unwrap(); + insta::assert_snapshot!(terminal.backend().to_string()); +} + +#[tokio::test] +async fn login_success_is_not_lost_while_waiting_for_the_device_code() -> color_eyre::Result<()> { + let (mut session, gateway) = gateway_session([ + vec![gateway_needs_sign_in(/*id*/ 1)], + vec![ + json!({"id":"approval","method":"item/fileChange/requestApproval","params":{ + "threadId":"thread", "turnId":"turn", "itemId":"edit", "startedAtMs":0 + }}), + json!({"method":"account/gatewayOAuth/changed","params":{ + "providerId":"company","loginId":"old","status":"failed","authUrl":null,"error":"Cancelled" + }}), + json!({"method":"account/gatewayOAuth/changed","params":{ + "providerId":"company","loginId":"device","status":"succeeded","authUrl":null,"error":null + }}), + device_login_response(/*id*/ 2), + ], + ]).await?; + let prompt = start_at_startup(&mut session).await?.expect("device code"); + let origin = session.request_handle(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let event = session.next_event().await.expect("session open"); + if super::handle_event_during_device_login(&prompt, event.event, &origin).await? { + break; + } + } + Ok::<_, std::io::Error>(()) + }) + .await??; + let received = tokio::time::timeout( + std::time::Duration::from_secs(2), + gateway.wait_for_request(/*connection_index*/ 0, /*request_index*/ 4), + ) + .await?; + assert_eq!(received.body_json()["id"], "approval"); + assert_eq!(received.body_json()["error"]["code"], -32000); + Ok(()) +} + +#[tokio::test] +async fn losing_the_connection_during_device_login_explains_how_to_recover() +-> color_eyre::Result<()> { + let (mut session, gateway) = gateway_session([ + vec![gateway_needs_sign_in(/*id*/ 1)], + vec![device_login_response(/*id*/ 2)], + ]) + .await?; + let prompt = start_at_startup(&mut session).await?.expect("device code"); + gateway.shutdown().await; + let event = tokio::time::timeout(std::time::Duration::from_secs(10), session.next_event()) + .await? + .expect("disconnect event"); + let error = + super::handle_event_during_device_login(&prompt, event.event, &session.request_handle()) + .await + .expect_err("sign-in should stop when the connection closes"); + insta::assert_snapshot!(error.to_string(), @"Codex lost its connection during setup. Restart Codex to continue."); + Ok(()) +} + +#[tokio::test] +async fn selecting_openai_skips_gateway_sign_in() -> color_eyre::Result<()> { + let (mut session, gateway) = gateway_session([vec![json!({"id":1,"result":{ + "providerId":"openai","providerName":"OpenAI","required":false,"status":null + }})]]) + .await?; + session.model_provider_override = Some("openai".into()); + let home = tempfile::tempdir()?; + let config = load_default_config_for_test(&home).await; + let prompt = super::start_if_needed( + &mut session, + &SessionSelection::StartFresh, + &config, + async { panic!("OpenAI sign-in should not probe for a browser") }, + ) + .await?; + assert_eq!(prompt, None); + assert_eq!( + gateway.single_connection()[2].body_json()["params"], + json!({"modelProvider":"openai"}) + ); + assert!( + gateway + .single_connection() + .iter() + .all(|request| request.body_json()["method"] != "account/gatewayOAuth/login") + ); + Ok(()) +} + +#[tokio::test] +async fn legacy_servers_skip_device_login_but_current_servers_report_read_errors() +-> color_eyre::Result<()> { + let reason = "Named read rejected"; + for legacy in [true, false] { + let mut state = + json!({"providerId":"openai","providerName":"OpenAI","required":false,"status":null}); + if !legacy { + state["supportsDeviceAuth"] = json!(false); + } + let (mut session, gateway) = gateway_session([ + vec![json!({"id":1,"error":{"code":-32600,"message":reason}})], + vec![json!({"id":2,"result":state})], + ]) + .await?; + session.model_provider_override = Some("company".into()); + let result = start_at_startup(&mut session).await; + if legacy { + assert_eq!(result?, None); + } else { + assert!(result.unwrap_err().to_string().contains(reason)); + } + let probe = gateway.single_connection()[3].body_json(); + assert_eq!(probe["method"], "account/gatewayOAuth/read"); + assert!(probe["params"].is_null()); + } + Ok(()) +} + +#[tokio::test] +async fn resuming_a_running_thread_signs_in_to_its_saved_gateway() -> color_eyre::Result<()> { + let home = tempfile::tempdir()?; + let id = ThreadId::new(); + let thread = json!({"id":id, "sessionId":id, "preview":"", "ephemeral":false, + "modelProvider":"company", "status":{"type":"idle"}, "createdAt":0, "updatedAt":0, + "cwd":home.path(), "cliVersion":"1", "source":"cli", "turns":[]}); + let (mut session, gateway) = gateway_session([ + vec![json!({"id":1,"result":{"thread":thread}})], + vec![gateway_needs_sign_in(/*id*/ 2)], + vec![device_login_response(/*id*/ 3)], + ]) + .await?; + session.model_provider_override = Some("openai".into()); + let resume = SessionSelection::Resume(SessionTarget { + backend_id: session.backend_id(), + thread_id: id, + path: None, + cwd: None, + history_mode: None, + }); + let config = load_default_config_for_test(&home).await; + let prompt = + super::start_if_needed(&mut session, &resume, &config, std::future::ready(true)).await?; + assert!(prompt.is_some()); + let requests = gateway.single_connection(); + assert_eq!( + requests[3].body_json()["params"], + json!({"modelProvider":"company"}) + ); + assert_eq!( + requests[4].body_json()["params"], + json!({"modelProvider":"company","deviceAuth":true}) + ); + Ok(()) +} + +async fn start_at_startup( + session: &mut AppServerSession, +) -> color_eyre::Result> { + let home = tempfile::tempdir()?; + let config = load_default_config_for_test(&home).await; + Ok(super::start_if_needed( + session, + &SessionSelection::StartFresh, + &config, + std::future::ready(true), + ) + .await?) +} + +async fn gateway_session( + replies: impl IntoIterator>, +) -> color_eyre::Result<(AppServerSession, WebSocketTestServer)> { + let mut script = vec![ + vec![json!({"id": "initialize", "result": {"userAgent": "gateway-test/1.0"}})], + vec![], // initialized notification + ]; + script.extend(replies); + // Keep the connection open after the final reply. An extra request is recorded and closes it. + script.push(vec![]); + let gateway = start_websocket_server(vec![script]).await; + let client = + crate::connect_remote_app_server(crate::resolve_remote_addr(gateway.uri())?).await?; + Ok(( + AppServerSession::new(BackendId::new(), client, ThreadParamsMode::Embedded), + gateway, + )) +} + +fn device_login_response(id: i64) -> Value { + json!({"id": id, "result": {"prompt": { + "loginId": "device", "verificationUrl": "https://login.example/device", "userCode": "ABCD-EFGH" + }}}) +} + +fn gateway_needs_sign_in(id: i64) -> Value { + json!({"id": id, "result": { + "providerId": "company", "providerName": "Company", "required": true, + "supportsDeviceAuth": true, "status": "notReady" + }}) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 52d6aefaeb..2ece37efee 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -135,6 +135,7 @@ mod config_update; mod copy_input_guard; pub(crate) mod custom_terminal; mod daybreak; +mod device_login; mod experimental_features; mod image_decode; mod markdown_copy; @@ -2221,6 +2222,35 @@ async fn run_ratatui_app( && (config.model_provider.is_amazon_bedrock() || !matches!(app_server_target, AppServerTarget::Embedded)); let guidance_request_handle = app_server.request_handle(); + let gateway_sign_in = async { + if !uses_remote_workspace + && let Some(prompt) = startup_draft + .run_until( + &mut tui, + device_login::start_if_needed( + &mut app_server, + &session_selection, + &config, + device_login::prefer_device_login(), + ), + ) + .await?? + { + startup_draft.flush_pending_events(&mut tui).await?; + onboarding::onboarding_screen::run_gateway_device_login( + &mut tui, + &mut app_server, + prompt, + ) + .await?; + } + Ok::<_, color_eyre::Report>(()) + } + .await; + if let Err(err) = gateway_sign_in { + shutdown_startup_session(Some(app_server), &mut terminal_restore_guard).await; + return Err(err); + } let startup_prefetch_started_at = Instant::now(); // Fetch the catalog alongside hooks/list. Fresh starts choose the model after config/read. app_server.prefetch_model_list(); diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 78dcd369c8..ecd87a72db 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -78,6 +78,7 @@ pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { use super::onboarding_screen::StepState; mod headless_chatgpt_login; +pub(super) use headless_chatgpt_login::render_device_code_prompt; #[derive(Clone)] pub(crate) enum SignInState { @@ -1262,9 +1263,12 @@ impl WidgetRef for AuthModeWidget { state.render(area, buf, self.error_message()); } SignInState::GatewayOAuth { url, .. } => { - let mut lines = vec![Line::from( - "Finish signing in to your gateway in the browser.".bold(), - )]; + let title = if url.is_some() { + "Finish signing in to your gateway in the browser." + } else { + "Starting gateway sign-in…" + }; + let mut lines = vec![Line::from(title.bold())]; if let Some(url) = url { lines.push(Line::from(url.clone().cyan().underlined())); } diff --git a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs index 2f7f2992d7..cec52f2058 100644 --- a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs @@ -104,26 +104,42 @@ pub(super) fn render_device_code_login( spans.push(banner.into()); } - let mut lines = vec![spans.into(), "".into()]; + render_device_code_prompt( + area, + buf, + spans.into(), + state + .verification_url + .as_deref() + .zip(state.user_code.as_deref()), + [ + " 1. Open this link in your browser and sign in", + " 2. Enter this one-time code after you are signed in (expires in 15 minutes)", + ], + widget.cancel_binding(), + ); +} - let verification_url = if let (Some(verification_url), Some(user_code)) = - (&state.verification_url, &state.user_code) - { - lines.push(" 1. Open this link in your browser and sign in".into()); +pub(crate) fn render_device_code_prompt<'a>( + area: Rect, + buf: &mut Buffer, + heading: Line<'a>, + prompt: Option<(&'a str, &'a str)>, + [open_link, enter_code]: [&str; 2], + cancel: crate::key_hint::KeyBinding, +) { + let mut lines = vec![heading, "".into()]; + let verification_url = if let Some((verification_url, user_code)) = prompt { + lines.push(open_link.into()); lines.push("".into()); lines.push(Line::from(vec![ " ".into(), - verification_url.as_str().cyan().underlined(), + verification_url.cyan().underlined(), ])); lines.push("".into()); - lines.push( - " 2. Enter this one-time code after you are signed in (expires in 15 minutes)".into(), - ); + lines.push(enter_code.into()); lines.push("".into()); - lines.push(Line::from(vec![ - " ".into(), - user_code.as_str().cyan().bold(), - ])); + lines.push(Line::from(vec![" ".into(), user_code.cyan().bold()])); lines.push("".into()); lines.push( " Continue only if you started this login in Codex. If a website or another person gave you this code, cancel." @@ -131,7 +147,7 @@ pub(super) fn render_device_code_login( .into(), ); lines.push("".into()); - Some(verification_url.clone()) + Some(verification_url) } else { lines.push(" Requesting a one-time code...".dim().into()); lines.push("".into()); @@ -140,14 +156,14 @@ pub(super) fn render_device_code_login( lines.push(Line::from(vec![ " Press ".dim(), - widget.cancel_binding().into(), + cancel.into(), " to cancel".dim(), ])); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); - if let Some(url) = &verification_url { + if let Some(url) = verification_url { mark_url_hyperlink(buf, area, url); } } diff --git a/codex-rs/tui/src/onboarding/gateway_login.rs b/codex-rs/tui/src/onboarding/gateway_login.rs index 842c1da479..273f7d58e3 100644 --- a/codex-rs/tui/src/onboarding/gateway_login.rs +++ b/codex-rs/tui/src/onboarding/gateway_login.rs @@ -16,7 +16,9 @@ use crate::tui::Tui; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::GatewayOAuthCancelResponse; use codex_app_server_protocol::GatewayOAuthChangedNotification; +use codex_app_server_protocol::GatewayOAuthLoginParams; use codex_app_server_protocol::GatewayOAuthLoginResponse; +use codex_app_server_protocol::GatewayOAuthReadParams; use codex_app_server_protocol::GatewayOAuthReadResponse; use codex_app_server_protocol::GatewayOAuthStatus; use codex_protocol::config_types::ForcedLoginMethod; @@ -113,19 +115,32 @@ impl AuthModeWidget { let status = handle .request_typed::(ClientRequest::GatewayOAuthRead { request_id: onboarding_request_id(), - params: None, + params: Some(GatewayOAuthReadParams { + model_provider: Some(provider_id.clone()), + }), }) .await?; color_eyre::eyre::ensure!( status.provider_id == provider_id, "The selected gateway changed during sign-in." ); - if status.required && status.status != Some(GatewayOAuthStatus::Succeeded) { + // Startup shows the device code for the selected chat before it requests models. + if status.required + && status.status != Some(GatewayOAuthStatus::Succeeded) + && !crate::device_login::needs_device_login( + &status, + crate::device_login::prefer_device_login(), + ) + .await + { handle .request_typed::( ClientRequest::GatewayOAuthLogin { request_id: onboarding_request_id(), - params: None, + params: Some(GatewayOAuthLoginParams { + model_provider: Some(status.provider_id), + ..Default::default() + }), }, ) .await?; diff --git a/codex-rs/tui/src/onboarding/gateway_tests.rs b/codex-rs/tui/src/onboarding/gateway_tests.rs index d37ea368d7..540b7488f7 100644 --- a/codex-rs/tui/src/onboarding/gateway_tests.rs +++ b/codex-rs/tui/src/onboarding/gateway_tests.rs @@ -183,6 +183,11 @@ async fn create_highlights_the_saved_gateway_until_explicitly_selected() -> colo #[tokio::test] async fn gateway_oauth_displays_the_browser_link_and_retry_action() { let (widget, _home) = super::tests::widget_forced_chatgpt().await; + *widget.sign_in_state.write().unwrap() = SignInState::GatewayOAuth { + provider_id: "office".into(), + url: None, + }; + insta::assert_snapshot!(screen(&widget, /*height*/ 1), @"Starting gateway sign-in…"); *widget.sign_in_state.write().unwrap() = SignInState::GatewayOAuth { provider_id: "office".into(), url: Some("https://office.example.test/authorize".into()), diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index 9bc1552279..1ac1035fb8 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -16,3 +16,6 @@ pub(crate) use auth::mark_url_hyperlink; pub(crate) use onboarding_screen::DirectoryTrustOptions; pub(crate) use trust_directory::TrustCancelAction; mod welcome; + +pub(crate) const SETUP_CONNECTION_LOST: &str = + "Codex lost its connection during setup. Restart Codex to continue."; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 88adbefa7a..449956755b 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -14,6 +14,7 @@ use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::GatewayOAuthPrompt; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_exec_server::LOCAL_FS; @@ -70,6 +71,7 @@ pub(crate) use directory_trust::check_directory_trust; enum Step { Welcome(WelcomeWidget), Auth(AuthModeWidget), + GatewayDevice(GatewayOAuthPrompt), TrustDirectory(TrustDirectoryWidget), GovCloudGuidance { state: BedrockState, @@ -273,13 +275,15 @@ impl OnboardingScreen { // material so terminal selection is not interrupted by redraws. self.current_steps().into_iter().any(|step| match step { Step::Auth(widget) => widget.should_suppress_animations(), + Step::GatewayDevice(_) => true, Step::Welcome(_) | Step::TrustDirectory(_) | Step::GovCloudGuidance { .. } => false, }) } fn is_auth_in_progress(&self) -> bool { self.steps.iter().any(|step| { - matches!(step, Step::Auth(_)) && matches!(step.get_step_state(), StepState::InProgress) + matches!(step, Step::Auth(_) | Step::GatewayDevice(_)) + && matches!(step.get_step_state(), StepState::InProgress) }) } @@ -314,7 +318,10 @@ impl OnboardingScreen { pub(super) fn auth_widget_mut(&mut self) -> Option<&mut AuthModeWidget> { self.steps.iter_mut().find_map(|step| match step { Step::Auth(widget) => Some(widget), - Step::Welcome(_) | Step::TrustDirectory(_) | Step::GovCloudGuidance { .. } => None, + Step::Welcome(_) + | Step::TrustDirectory(_) + | Step::GovCloudGuidance { .. } + | Step::GatewayDevice(_) => None, }) } @@ -371,7 +378,9 @@ impl KeyboardHandler for OnboardingScreen { } let text_entry_context = self.text_entry_context(); let should_quit = key_event.kind == KeyEventKind::Press - && keys::QUIT.is_pressed(key_event) + && (keys::QUIT.is_pressed(key_event) + || matches!(self.steps.as_slice(), [Step::GatewayDevice(_)]) + && keys::CANCEL.is_pressed(key_event)) && !suppress_quit_while_typing(key_event, text_entry_context); if should_quit { if self.is_auth_in_progress() { @@ -452,7 +461,9 @@ impl WidgetRef for &OnboardingScreen { match step { Step::Welcome(widget) => widget.set_presentation(logo_presentation), Step::Auth(widget) => widget.set_animations_suppressed(suppress_animations), - Step::TrustDirectory(_) | Step::GovCloudGuidance { .. } => {} + Step::TrustDirectory(_) + | Step::GovCloudGuidance { .. } + | Step::GatewayDevice(_) => {} } } @@ -523,6 +534,7 @@ impl WidgetRef for &OnboardingScreen { impl KeyboardHandler for Step { fn handle_key_event(&mut self, key_event: KeyEvent) { match self { + Step::GatewayDevice(_) => {} Step::Welcome(widget) => widget.handle_key_event(key_event), Step::Auth(widget) => widget.handle_key_event(key_event), Step::TrustDirectory(widget) => widget.handle_key_event(key_event), @@ -537,7 +549,7 @@ impl KeyboardHandler for Step { fn handle_paste(&mut self, pasted: String) { match self { - Step::Welcome(_) | Step::GovCloudGuidance { .. } => {} + Step::Welcome(_) | Step::GovCloudGuidance { .. } | Step::GatewayDevice(_) => {} Step::Auth(widget) => widget.handle_paste(pasted), Step::TrustDirectory(widget) => widget.handle_paste(pasted), } @@ -547,6 +559,7 @@ impl KeyboardHandler for Step { impl StepStateProvider for Step { fn get_step_state(&self) -> StepState { match self { + Step::GatewayDevice(_) => StepState::InProgress, Step::Welcome(w) => w.get_step_state(), Step::Auth(w) => w.get_step_state(), Step::TrustDirectory(w) => w.get_step_state(), @@ -573,11 +586,62 @@ impl WidgetRef for Step { Step::TrustDirectory(widget) => { widget.render_ref(area, buf); } + Step::GatewayDevice(prompt) => render_gateway_device_prompt(prompt, area, buf), Step::GovCloudGuidance { state, .. } => state.render(area, buf, /*error*/ None), } } } +pub(crate) fn render_gateway_device_prompt( + prompt: &GatewayOAuthPrompt, + area: Rect, + buf: &mut Buffer, +) { + super::auth::render_device_code_prompt( + area, + buf, + " Sign in to your gateway".into(), + Some((&prompt.verification_url, &prompt.user_code)), + [ + " 1. Open this link in a browser", + " 2. Enter this one-time code when prompted", + ], + keys::CANCEL[0], + ); +} + +pub(crate) async fn run_gateway_device_login( + tui: &mut Tui, + server: &mut AppServerSession, + prompt: GatewayOAuthPrompt, +) -> Result<()> { + let screen = OnboardingScreen { + request_frame: tui.frame_requester(), + steps: vec![Step::GatewayDevice(prompt)], + remote_trust_key: None, + is_done: false, + should_exit: false, + }; + let result = run_onboarding_screen( + screen, + /*app_server_request_handle*/ None, + Some(server), + tui, + ) + .await + .and_then(|result| { + if result.should_exit { + Err(std::io::Error::new(std::io::ErrorKind::Interrupted, StartupCancelled).into()) + } else { + Ok(()) + } + }); + if result.is_err() { + crate::device_login::cancel(server).await; + } + result +} + pub(crate) async fn run_gov_cloud_guidance(tui: &mut Tui) -> Result<()> { let screen = OnboardingScreen { request_frame: tui.frame_requester(), @@ -786,7 +850,16 @@ async fn run_onboarding_screen_inner( None => None, } }, if app_server.is_some() => { - if let Some(event) = event { + let Some(event) = event else { + return Err(color_eyre::eyre::eyre!(super::SETUP_CONNECTION_LOST)); + }; + if let (Some(server), [Step::GatewayDevice(prompt)]) = (app_server.as_deref(), onboarding_screen.steps.as_slice()) { + onboarding_screen.is_done = crate::device_login::handle_event_during_device_login( + prompt, + event.event, + &server.request_handle(), + ).await?; + } else { match event.event { AppServerEvent::ServerNotification(notification) => { onboarding_screen.handle_app_server_notification(*notification); diff --git a/codex-rs/tui/src/snapshots/codex_tui__device_login__tests__device_prompt_shows_where_to_enter_the_code_and_how_to_cancel.snap b/codex-rs/tui/src/snapshots/codex_tui__device_login__tests__device_prompt_shows_where_to_enter_the_code_and_how_to_cancel.snap new file mode 100644 index 0000000000..9f2f30b493 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__device_login__tests__device_prompt_shows_where_to_enter_the_code_and_how_to_cancel.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/device_login_tests.rs +expression: terminal.backend().to_string() +--- + Sign in to your gateway + + 1. Open this link in a browser + + https://login.example.com/device + + 2. Enter this one-time code when prompted + + ABCD-EFGH + + Continue only if you started this login in Codex. If a website or another +person gave you this code, cancel. + + Press esc to cancel