mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}})
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
|
||||
+18
@@ -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
|
||||
Reference in New Issue
Block a user