Support gateway device-code sign-in in headless TUI sessions (#60662)

## Why

Gateway sign-in needs a way to complete authentication over SSH or when no local browser is available.

## What changed

- Prefer device authentication for gateways that support it in SSH sessions, headless Linux environments, or when no browser handler is available.
- Check the selected session's provider and complete required gateway sign-in before requesting models, including when resuming a thread.
- Display the verification URL, one-time code, and cancellation instructions. Handle sign-in results, cancellation, and connection failures while the prompt is open.
- Skip device login for older servers that do not support it while preserving errors from current servers.

## Testing

Add protocol tests for early sign-in completion, connection loss, provider selection, resumed threads, and legacy-server compatibility, plus snapshots of the device prompt and gateway sign-in loading text.

GitOrigin-RevId: 4f3675ab2fc7fccafbad17796c72a7736ebb86b5
This commit is contained in:
smurching-oai
2026-11-10 21:09:59 +00:00
committed by copyberry
parent c9bbed2dae
commit 45d1f24d79
13 changed files with 662 additions and 36 deletions
+1
View File
@@ -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"
+13 -1
View File
@@ -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;
+2 -7
View File
@@ -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() {
+216
View File
@@ -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<Output = bool>,
) -> 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<Output = bool>,
) -> io::Result<Option<GatewayOAuthPrompt>> {
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::<serde_json::Value>(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::<GatewayOAuthCancelResponse>(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<bool> {
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;
+238
View File
@@ -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<Option<codex_app_server_protocol::GatewayOAuthPrompt>> {
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<Item = Vec<Value>>,
) -> 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"
}})
}
+30
View File
@@ -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();
+7 -3
View File
@@ -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()));
}
@@ -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);
}
}
+18 -3
View File
@@ -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::<GatewayOAuthReadResponse>(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::<GatewayOAuthLoginResponse>(
ClientRequest::GatewayOAuthLogin {
request_id: onboarding_request_id(),
params: None,
params: Some(GatewayOAuthLoginParams {
model_provider: Some(status.provider_id),
..Default::default()
}),
},
)
.await?;
@@ -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()),
+3
View File
@@ -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.";
@@ -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);
@@ -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