mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
[device-auth] When headless environment is detected, show device login flow instead. (#8756)
When headless environment is detected, show device login flow instead.
This commit is contained in:
1 parent
d3ff668f68
commit
24d6e0114f
9 files changed
+1012
-42
No files matched your search
+81
-10
@@ -14,6 +14,18 @@ use std::io::IsTerminal;
|
|||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const CHATGPT_LOGIN_DISABLED_MESSAGE: &str =
|
||||||
|
"ChatGPT login is disabled. Use API key login instead.";
|
||||||
|
const API_KEY_LOGIN_DISABLED_MESSAGE: &str =
|
||||||
|
"API key login is disabled. Use ChatGPT login instead.";
|
||||||
|
const LOGIN_SUCCESS_MESSAGE: &str = "Successfully logged in";
|
||||||
|
|
||||||
|
fn print_login_server_start(actual_port: u16, auth_url: &str) {
|
||||||
|
eprintln!(
|
||||||
|
"Starting local login server on http://localhost:{actual_port}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn login_with_chatgpt(
|
pub async fn login_with_chatgpt(
|
||||||
codex_home: PathBuf,
|
codex_home: PathBuf,
|
||||||
forced_chatgpt_workspace_id: Option<String>,
|
forced_chatgpt_workspace_id: Option<String>,
|
||||||
@@ -27,10 +39,7 @@ pub async fn login_with_chatgpt(
|
|||||||
);
|
);
|
||||||
let server = run_login_server(opts)?;
|
let server = run_login_server(opts)?;
|
||||||
|
|
||||||
eprintln!(
|
print_login_server_start(server.actual_port, &server.auth_url);
|
||||||
"Starting local login server on http://localhost:{}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{}",
|
|
||||||
server.actual_port, server.auth_url,
|
|
||||||
);
|
|
||||||
|
|
||||||
server.block_until_done().await
|
server.block_until_done().await
|
||||||
}
|
}
|
||||||
@@ -39,7 +48,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) ->
|
|||||||
let config = load_config_or_exit(cli_config_overrides).await;
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
|
|
||||||
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
|
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
|
||||||
eprintln!("ChatGPT login is disabled. Use API key login instead.");
|
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +62,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) ->
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
eprintln!("Successfully logged in");
|
eprintln!("{LOGIN_SUCCESS_MESSAGE}");
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -70,7 +79,7 @@ pub async fn run_login_with_api_key(
|
|||||||
let config = load_config_or_exit(cli_config_overrides).await;
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
|
|
||||||
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) {
|
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) {
|
||||||
eprintln!("API key login is disabled. Use ChatGPT login instead.");
|
eprintln!("{API_KEY_LOGIN_DISABLED_MESSAGE}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +89,7 @@ pub async fn run_login_with_api_key(
|
|||||||
config.cli_auth_credentials_store_mode,
|
config.cli_auth_credentials_store_mode,
|
||||||
) {
|
) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
eprintln!("Successfully logged in");
|
eprintln!("{LOGIN_SUCCESS_MESSAGE}");
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -125,7 +134,7 @@ pub async fn run_login_with_device_code(
|
|||||||
) -> ! {
|
) -> ! {
|
||||||
let config = load_config_or_exit(cli_config_overrides).await;
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
|
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
|
||||||
eprintln!("ChatGPT login is disabled. Use API key login instead.");
|
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
|
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
|
||||||
@@ -140,7 +149,7 @@ pub async fn run_login_with_device_code(
|
|||||||
}
|
}
|
||||||
match run_device_code_login(opts).await {
|
match run_device_code_login(opts).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
eprintln!("Successfully logged in");
|
eprintln!("{LOGIN_SUCCESS_MESSAGE}");
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -150,6 +159,68 @@ pub async fn run_login_with_device_code(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prefers device-code login (with `open_browser = false`) when headless environment is detected, but keeps
|
||||||
|
/// `codex login` working in environments where device-code may be disabled/feature-gated.
|
||||||
|
/// If `run_device_code_login` returns `ErrorKind::NotFound` ("device-code unsupported"), this
|
||||||
|
/// falls back to starting the local browser login server.
|
||||||
|
pub async fn run_login_with_device_code_fallback_to_browser(
|
||||||
|
cli_config_overrides: CliConfigOverrides,
|
||||||
|
issuer_base_url: Option<String>,
|
||||||
|
client_id: Option<String>,
|
||||||
|
) -> ! {
|
||||||
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
|
if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) {
|
||||||
|
eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
|
||||||
|
let mut opts = ServerOptions::new(
|
||||||
|
config.codex_home,
|
||||||
|
client_id.unwrap_or(CLIENT_ID.to_string()),
|
||||||
|
forced_chatgpt_workspace_id,
|
||||||
|
config.cli_auth_credentials_store_mode,
|
||||||
|
);
|
||||||
|
if let Some(iss) = issuer_base_url {
|
||||||
|
opts.issuer = iss;
|
||||||
|
}
|
||||||
|
opts.open_browser = false;
|
||||||
|
|
||||||
|
match run_device_code_login(opts.clone()).await {
|
||||||
|
Ok(()) => {
|
||||||
|
eprintln!("{LOGIN_SUCCESS_MESSAGE}");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
eprintln!("Device code login is not enabled; falling back to browser login.");
|
||||||
|
match run_login_server(opts) {
|
||||||
|
Ok(server) => {
|
||||||
|
print_login_server_start(server.actual_port, &server.auth_url);
|
||||||
|
match server.block_until_done().await {
|
||||||
|
Ok(()) => {
|
||||||
|
eprintln!("{LOGIN_SUCCESS_MESSAGE}");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error logging in: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Error logging in: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eprintln!("Error logging in with device code: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||||
let config = load_config_or_exit(cli_config_overrides).await;
|
let config = load_config_or_exit(cli_config_overrides).await;
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ use codex_cli::login::run_login_status;
|
|||||||
use codex_cli::login::run_login_with_api_key;
|
use codex_cli::login::run_login_with_api_key;
|
||||||
use codex_cli::login::run_login_with_chatgpt;
|
use codex_cli::login::run_login_with_chatgpt;
|
||||||
use codex_cli::login::run_login_with_device_code;
|
use codex_cli::login::run_login_with_device_code;
|
||||||
|
use codex_cli::login::run_login_with_device_code_fallback_to_browser;
|
||||||
use codex_cli::login::run_logout;
|
use codex_cli::login::run_logout;
|
||||||
use codex_cloud_tasks::Cli as CloudTasksCli;
|
use codex_cloud_tasks::Cli as CloudTasksCli;
|
||||||
use codex_common::CliConfigOverrides;
|
use codex_common::CliConfigOverrides;
|
||||||
|
use codex_core::env::is_headless_environment;
|
||||||
use codex_exec::Cli as ExecCli;
|
use codex_exec::Cli as ExecCli;
|
||||||
use codex_exec::Command as ExecCommand;
|
use codex_exec::Command as ExecCommand;
|
||||||
use codex_exec::ReviewArgs;
|
use codex_exec::ReviewArgs;
|
||||||
@@ -531,6 +533,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
|||||||
} else if login_cli.with_api_key {
|
} else if login_cli.with_api_key {
|
||||||
let api_key = read_api_key_from_stdin();
|
let api_key = read_api_key_from_stdin();
|
||||||
run_login_with_api_key(login_cli.config_overrides, api_key).await;
|
run_login_with_api_key(login_cli.config_overrides, api_key).await;
|
||||||
|
} else if is_headless_environment() {
|
||||||
|
run_login_with_device_code_fallback_to_browser(
|
||||||
|
login_cli.config_overrides,
|
||||||
|
login_cli.issuer_base_url,
|
||||||
|
login_cli.client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
} else {
|
} else {
|
||||||
run_login_with_chatgpt(login_cli.config_overrides).await;
|
run_login_with_chatgpt(login_cli.config_overrides).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
//! Functions for environment detection that need to be shared across crates.
|
//! Functions for environment detection that need to be shared across crates.
|
||||||
|
|
||||||
|
fn env_var_set(key: &str) -> bool {
|
||||||
|
std::env::var(key).is_ok_and(|v| !v.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if the current process is running under Windows Subsystem for Linux.
|
/// Returns true if the current process is running under Windows Subsystem for Linux.
|
||||||
pub fn is_wsl() -> bool {
|
pub fn is_wsl() -> bool {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -17,3 +21,26 @@ pub fn is_wsl() -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true when Codex is likely running in an environment without a usable GUI.
|
||||||
|
///
|
||||||
|
/// This is intentionally conservative and is used by frontends to avoid flows that would try to
|
||||||
|
/// open a browser (e.g. device-code auth fallback).
|
||||||
|
pub fn is_headless_environment() -> bool {
|
||||||
|
if env_var_set("CI")
|
||||||
|
|| env_var_set("SSH_CONNECTION")
|
||||||
|
|| env_var_set("SSH_CLIENT")
|
||||||
|
|| env_var_set("SSH_TTY")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if !env_var_set("DISPLAY") && !env_var_set("WAYLAND_DISPLAY") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
@@ -14,6 +14,14 @@ const ANSI_BLUE: &str = "\x1b[94m";
|
|||||||
const ANSI_GRAY: &str = "\x1b[90m";
|
const ANSI_GRAY: &str = "\x1b[90m";
|
||||||
const ANSI_RESET: &str = "\x1b[0m";
|
const ANSI_RESET: &str = "\x1b[0m";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DeviceCode {
|
||||||
|
pub verification_url: String,
|
||||||
|
pub user_code: String,
|
||||||
|
device_auth_id: String,
|
||||||
|
interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct UserCodeResp {
|
struct UserCodeResp {
|
||||||
device_auth_id: String,
|
device_auth_id: String,
|
||||||
@@ -73,7 +81,8 @@ async fn request_user_code(
|
|||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == StatusCode::NOT_FOUND {
|
if status == StatusCode::NOT_FOUND {
|
||||||
return Err(std::io::Error::other(
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
"device code login is not enabled for this Codex server. Use the browser login or verify the server URL.",
|
"device code login is not enabled for this Codex server. Use the browser login or verify the server URL.",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -137,34 +146,45 @@ async fn poll_for_token(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_device_code_prompt(code: &str, issuer_base_url: &str) {
|
fn print_device_code_prompt(verification_url: &str, code: &str) {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
println!(
|
println!(
|
||||||
"\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\
|
"\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\
|
||||||
\nFollow these steps to sign in with ChatGPT using device code authorization:\n\
|
\nFollow these steps to sign in with ChatGPT using device code authorization:\n\
|
||||||
\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}{issuer_base_url}/codex/device{ANSI_RESET}\n\
|
\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}{verification_url}{ANSI_RESET}\n\
|
||||||
\n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\
|
\n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\
|
||||||
\n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n",
|
\n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n",
|
||||||
version = env!("CARGO_PKG_VERSION"),
|
|
||||||
code = code,
|
|
||||||
issuer_base_url = issuer_base_url
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full device code login flow.
|
pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result<DeviceCode> {
|
||||||
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let issuer_base_url = opts.issuer.trim_end_matches('/');
|
let base_url = opts.issuer.trim_end_matches('/');
|
||||||
let api_base_url = format!("{issuer_base_url}/api/accounts");
|
let api_base_url = format!("{base_url}/api/accounts");
|
||||||
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
|
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
|
||||||
|
|
||||||
print_device_code_prompt(&uc.user_code, issuer_base_url);
|
Ok(DeviceCode {
|
||||||
|
verification_url: format!("{base_url}/codex/device"),
|
||||||
|
user_code: uc.user_code,
|
||||||
|
device_auth_id: uc.device_auth_id,
|
||||||
|
interval: uc.interval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn complete_device_code_login(
|
||||||
|
opts: ServerOptions,
|
||||||
|
device_code: DeviceCode,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let base_url = opts.issuer.trim_end_matches('/');
|
||||||
|
let api_base_url = format!("{base_url}/api/accounts");
|
||||||
|
|
||||||
let code_resp = poll_for_token(
|
let code_resp = poll_for_token(
|
||||||
&client,
|
&client,
|
||||||
&api_base_url,
|
&api_base_url,
|
||||||
&uc.device_auth_id,
|
&device_code.device_auth_id,
|
||||||
&uc.user_code,
|
&device_code.user_code,
|
||||||
uc.interval,
|
device_code.interval,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -172,10 +192,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
|||||||
code_verifier: code_resp.code_verifier,
|
code_verifier: code_resp.code_verifier,
|
||||||
code_challenge: code_resp.code_challenge,
|
code_challenge: code_resp.code_challenge,
|
||||||
};
|
};
|
||||||
let redirect_uri = format!("{issuer_base_url}/deviceauth/callback");
|
let redirect_uri = format!("{base_url}/deviceauth/callback");
|
||||||
|
|
||||||
let tokens = crate::server::exchange_code_for_tokens(
|
let tokens = crate::server::exchange_code_for_tokens(
|
||||||
issuer_base_url,
|
base_url,
|
||||||
&opts.client_id,
|
&opts.client_id,
|
||||||
&redirect_uri,
|
&redirect_uri,
|
||||||
&pkce,
|
&pkce,
|
||||||
@@ -201,3 +221,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full device code login flow.
|
||||||
|
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
|
||||||
|
let device_code = request_device_code(&opts).await?;
|
||||||
|
print_device_code_prompt(&device_code.verification_url, &device_code.user_code);
|
||||||
|
complete_device_code_login(opts, device_code).await
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ mod device_code_auth;
|
|||||||
mod pkce;
|
mod pkce;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
|
pub use device_code_auth::DeviceCode;
|
||||||
|
pub use device_code_auth::complete_device_code_login;
|
||||||
|
pub use device_code_auth::request_device_code;
|
||||||
pub use device_code_auth::run_device_code_login;
|
pub use device_code_auth::run_device_code_login;
|
||||||
pub use server::LoginServer;
|
pub use server::LoginServer;
|
||||||
pub use server::ServerOptions;
|
pub use server::ServerOptions;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use codex_core::auth::AuthCredentialsStoreMode;
|
|||||||
use codex_core::auth::CLIENT_ID;
|
use codex_core::auth::CLIENT_ID;
|
||||||
use codex_core::auth::login_with_api_key;
|
use codex_core::auth::login_with_api_key;
|
||||||
use codex_core::auth::read_openai_api_key_from_env;
|
use codex_core::auth::read_openai_api_key_from_env;
|
||||||
|
use codex_core::env::is_headless_environment;
|
||||||
|
use codex_login::DeviceCode;
|
||||||
use codex_login::ServerOptions;
|
use codex_login::ServerOptions;
|
||||||
use codex_login::ShutdownHandle;
|
use codex_login::ShutdownHandle;
|
||||||
use codex_login::run_login_server;
|
use codex_login::run_login_server;
|
||||||
@@ -40,13 +42,17 @@ use crate::shimmer::shimmer_spans;
|
|||||||
use crate::tui::FrameRequester;
|
use crate::tui::FrameRequester;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
use super::onboarding_screen::StepState;
|
use super::onboarding_screen::StepState;
|
||||||
|
|
||||||
|
mod headless_chatgpt_login;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) enum SignInState {
|
pub(crate) enum SignInState {
|
||||||
PickMode,
|
PickMode,
|
||||||
ChatGptContinueInBrowser(ContinueInBrowserState),
|
ChatGptContinueInBrowser(ContinueInBrowserState),
|
||||||
|
ChatGptDeviceCode(ContinueWithDeviceCodeState),
|
||||||
ChatGptSuccessMessage,
|
ChatGptSuccessMessage,
|
||||||
ChatGptSuccess,
|
ChatGptSuccess,
|
||||||
ApiKeyEntry(ApiKeyInputState),
|
ApiKeyEntry(ApiKeyInputState),
|
||||||
@@ -68,6 +74,12 @@ pub(crate) struct ContinueInBrowserState {
|
|||||||
shutdown_flag: Option<ShutdownHandle>,
|
shutdown_flag: Option<ShutdownHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct ContinueWithDeviceCodeState {
|
||||||
|
device_code: Option<DeviceCode>,
|
||||||
|
cancel: Option<Arc<Notify>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for ContinueInBrowserState {
|
impl Drop for ContinueInBrowserState {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(handle) = &self.shutdown_flag {
|
if let Some(handle) = &self.shutdown_flag {
|
||||||
@@ -128,10 +140,22 @@ impl KeyboardHandler for AuthModeWidget {
|
|||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
tracing::info!("Esc pressed");
|
tracing::info!("Esc pressed");
|
||||||
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
|
let mut sign_in_state = self.sign_in_state.write().unwrap();
|
||||||
if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
|
match &*sign_in_state {
|
||||||
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
|
SignInState::ChatGptContinueInBrowser(_) => {
|
||||||
self.request_frame.schedule_frame();
|
*sign_in_state = SignInState::PickMode;
|
||||||
|
drop(sign_in_state);
|
||||||
|
self.request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
SignInState::ChatGptDeviceCode(state) => {
|
||||||
|
if let Some(cancel) = &state.cancel {
|
||||||
|
cancel.notify_one();
|
||||||
|
}
|
||||||
|
*sign_in_state = SignInState::PickMode;
|
||||||
|
drop(sign_in_state);
|
||||||
|
self.request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -216,10 +240,12 @@ impl AuthModeWidget {
|
|||||||
vec![line1, line2]
|
vec![line1, line2]
|
||||||
};
|
};
|
||||||
|
|
||||||
let chatgpt_description = if self.is_chatgpt_login_allowed() {
|
let chatgpt_description = if !self.is_chatgpt_login_allowed() {
|
||||||
"Usage included with Plus, Pro, Business, Education, and Enterprise plans"
|
|
||||||
} else {
|
|
||||||
"ChatGPT login is disabled"
|
"ChatGPT login is disabled"
|
||||||
|
} else if is_headless_environment() {
|
||||||
|
"Uses device code login (headless environment detected)"
|
||||||
|
} else {
|
||||||
|
"Usage included with Plus, Pro, Team, and Enterprise plans"
|
||||||
};
|
};
|
||||||
lines.extend(create_mode_item(
|
lines.extend(create_mode_item(
|
||||||
0,
|
0,
|
||||||
@@ -277,7 +303,10 @@ impl AuthModeWidget {
|
|||||||
{
|
{
|
||||||
lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into());
|
lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into());
|
||||||
lines.push("".into());
|
lines.push("".into());
|
||||||
lines.push(Line::from(state.auth_url.as_str().cyan().underlined()));
|
lines.push(Line::from(vec![
|
||||||
|
" ".into(),
|
||||||
|
state.auth_url.as_str().cyan().underlined(),
|
||||||
|
]));
|
||||||
lines.push("".into());
|
lines.push("".into());
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
" On a remote or headless machine? Use ".into(),
|
" On a remote or headless machine? Use ".into(),
|
||||||
@@ -559,6 +588,7 @@ impl AuthModeWidget {
|
|||||||
self.request_frame.schedule_frame();
|
self.request_frame.schedule_frame();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kicks off the ChatGPT auth flow and keeps the UI state consistent with the attempt.
|
||||||
fn start_chatgpt_login(&mut self) {
|
fn start_chatgpt_login(&mut self) {
|
||||||
// If we're already authenticated with ChatGPT, don't start a new login –
|
// If we're already authenticated with ChatGPT, don't start a new login –
|
||||||
// just proceed to the success message flow.
|
// just proceed to the success message flow.
|
||||||
@@ -575,6 +605,12 @@ impl AuthModeWidget {
|
|||||||
self.forced_chatgpt_workspace_id.clone(),
|
self.forced_chatgpt_workspace_id.clone(),
|
||||||
self.cli_auth_credentials_store_mode,
|
self.cli_auth_credentials_store_mode,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if is_headless_environment() {
|
||||||
|
headless_chatgpt_login::start_headless_chatgpt_login(self, opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match run_login_server(opts) {
|
match run_login_server(opts) {
|
||||||
Ok(child) => {
|
Ok(child) => {
|
||||||
let sign_in_state = self.sign_in_state.clone();
|
let sign_in_state = self.sign_in_state.clone();
|
||||||
@@ -623,6 +659,7 @@ impl StepStateProvider for AuthModeWidget {
|
|||||||
SignInState::PickMode
|
SignInState::PickMode
|
||||||
| SignInState::ApiKeyEntry(_)
|
| SignInState::ApiKeyEntry(_)
|
||||||
| SignInState::ChatGptContinueInBrowser(_)
|
| SignInState::ChatGptContinueInBrowser(_)
|
||||||
|
| SignInState::ChatGptDeviceCode(_)
|
||||||
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
|
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
|
||||||
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
|
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
|
||||||
}
|
}
|
||||||
@@ -639,6 +676,9 @@ impl WidgetRef for AuthModeWidget {
|
|||||||
SignInState::ChatGptContinueInBrowser(_) => {
|
SignInState::ChatGptContinueInBrowser(_) => {
|
||||||
self.render_continue_in_browser(area, buf);
|
self.render_continue_in_browser(area, buf);
|
||||||
}
|
}
|
||||||
|
SignInState::ChatGptDeviceCode(state) => {
|
||||||
|
headless_chatgpt_login::render_device_code_login(self, area, buf, state);
|
||||||
|
}
|
||||||
SignInState::ChatGptSuccessMessage => {
|
SignInState::ChatGptSuccessMessage => {
|
||||||
self.render_chatgpt_success_message(area, buf);
|
self.render_chatgpt_success_message(area, buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
use codex_core::AuthManager;
|
||||||
|
use codex_login::ServerOptions;
|
||||||
|
use codex_login::complete_device_code_login;
|
||||||
|
use codex_login::request_device_code;
|
||||||
|
use codex_login::run_login_server;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::prelude::Widget;
|
||||||
|
use ratatui::style::Stylize;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::widgets::Wrap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
use crate::shimmer::shimmer_spans;
|
||||||
|
use crate::tui::FrameRequester;
|
||||||
|
|
||||||
|
use super::AuthModeWidget;
|
||||||
|
use super::ContinueInBrowserState;
|
||||||
|
use super::ContinueWithDeviceCodeState;
|
||||||
|
use super::SignInState;
|
||||||
|
|
||||||
|
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts: ServerOptions) {
|
||||||
|
opts.open_browser = false;
|
||||||
|
let sign_in_state = widget.sign_in_state.clone();
|
||||||
|
let request_frame = widget.request_frame.clone();
|
||||||
|
let auth_manager = widget.auth_manager.clone();
|
||||||
|
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let device_code = match request_device_code(&opts).await {
|
||||||
|
Ok(device_code) => device_code,
|
||||||
|
Err(err) => {
|
||||||
|
if err.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
let should_fallback = {
|
||||||
|
let guard = sign_in_state.read().unwrap();
|
||||||
|
device_code_attempt_matches(&guard, &cancel)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !should_fallback {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match run_login_server(opts) {
|
||||||
|
Ok(child) => {
|
||||||
|
let auth_url = child.auth_url.clone();
|
||||||
|
{
|
||||||
|
*sign_in_state.write().unwrap() =
|
||||||
|
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
|
||||||
|
auth_url,
|
||||||
|
shutdown_flag: Some(child.cancel_handle()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
let r = child.block_until_done().await;
|
||||||
|
match r {
|
||||||
|
Ok(()) => {
|
||||||
|
auth_manager.reload();
|
||||||
|
*sign_in_state.write().unwrap() =
|
||||||
|
SignInState::ChatGptSuccessMessage;
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
*sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
|
||||||
|
device_code: Some(device_code.clone()),
|
||||||
|
cancel: Some(cancel.clone()),
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.notified() => {}
|
||||||
|
r = complete_device_code_login(opts, device_code) => {
|
||||||
|
match r {
|
||||||
|
Ok(()) => {
|
||||||
|
set_device_code_success_message_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&auth_manager,
|
||||||
|
&cancel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_device_code_login(
|
||||||
|
widget: &AuthModeWidget,
|
||||||
|
area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
state: &ContinueWithDeviceCodeState,
|
||||||
|
) {
|
||||||
|
let banner = if state.device_code.is_some() {
|
||||||
|
"Finish signing in via your browser"
|
||||||
|
} else {
|
||||||
|
"Preparing device code login"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spans = vec![" ".into()];
|
||||||
|
if widget.animations_enabled {
|
||||||
|
// Schedule a follow-up frame to keep the shimmer animation going.
|
||||||
|
widget
|
||||||
|
.request_frame
|
||||||
|
.schedule_frame_in(std::time::Duration::from_millis(100));
|
||||||
|
spans.extend(shimmer_spans(banner));
|
||||||
|
} else {
|
||||||
|
spans.push(banner.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = vec![spans.into(), "".into()];
|
||||||
|
|
||||||
|
if let Some(device_code) = &state.device_code {
|
||||||
|
lines.push(" 1. Open this link in your browser and sign in".into());
|
||||||
|
lines.push("".into());
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
" ".into(),
|
||||||
|
device_code.verification_url.as_str().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("".into());
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
" ".into(),
|
||||||
|
device_code.user_code.as_str().cyan().bold(),
|
||||||
|
]));
|
||||||
|
lines.push("".into());
|
||||||
|
lines.push(
|
||||||
|
" Device codes are a common phishing target. Never share this code."
|
||||||
|
.dim()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
lines.push("".into());
|
||||||
|
} else {
|
||||||
|
lines.push(" Requesting a one-time code...".dim().into());
|
||||||
|
lines.push("".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(" Press Esc to cancel".dim().into());
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.render(area, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_code_attempt_matches(state: &SignInState, cancel: &Arc<Notify>) -> bool {
|
||||||
|
matches!(
|
||||||
|
state,
|
||||||
|
SignInState::ChatGptDeviceCode(state)
|
||||||
|
if state
|
||||||
|
.cancel
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|existing| Arc::ptr_eq(existing, cancel))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_device_code_attempt(
|
||||||
|
sign_in_state: &Arc<RwLock<SignInState>>,
|
||||||
|
request_frame: &FrameRequester,
|
||||||
|
) -> Arc<Notify> {
|
||||||
|
let cancel = Arc::new(Notify::new());
|
||||||
|
*sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
|
||||||
|
device_code: None,
|
||||||
|
cancel: Some(cancel.clone()),
|
||||||
|
});
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_device_code_state_for_active_attempt(
|
||||||
|
sign_in_state: &Arc<RwLock<SignInState>>,
|
||||||
|
request_frame: &FrameRequester,
|
||||||
|
cancel: &Arc<Notify>,
|
||||||
|
next_state: SignInState,
|
||||||
|
) -> bool {
|
||||||
|
let mut guard = sign_in_state.write().unwrap();
|
||||||
|
if !device_code_attempt_matches(&guard, cancel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
*guard = next_state;
|
||||||
|
drop(guard);
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_device_code_success_message_for_active_attempt(
|
||||||
|
sign_in_state: &Arc<RwLock<SignInState>>,
|
||||||
|
request_frame: &FrameRequester,
|
||||||
|
auth_manager: &AuthManager,
|
||||||
|
cancel: &Arc<Notify>,
|
||||||
|
) -> bool {
|
||||||
|
let mut guard = sign_in_state.write().unwrap();
|
||||||
|
if !device_code_attempt_matches(&guard, cancel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_manager.reload();
|
||||||
|
*guard = SignInState::ChatGptSuccessMessage;
|
||||||
|
drop(guard);
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use codex_core::auth::AuthCredentialsStoreMode;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn device_code_sign_in_state(cancel: Arc<Notify>) -> Arc<RwLock<SignInState>> {
|
||||||
|
Arc::new(RwLock::new(SignInState::ChatGptDeviceCode(
|
||||||
|
ContinueWithDeviceCodeState {
|
||||||
|
device_code: None,
|
||||||
|
cancel: Some(cancel),
|
||||||
|
},
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_code_attempt_matches_only_for_matching_cancel() {
|
||||||
|
let cancel = Arc::new(Notify::new());
|
||||||
|
let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
|
||||||
|
device_code: None,
|
||||||
|
cancel: Some(cancel.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(device_code_attempt_matches(&state, &cancel), true);
|
||||||
|
assert_eq!(
|
||||||
|
device_code_attempt_matches(&state, &Arc::new(Notify::new())),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
device_code_attempt_matches(&SignInState::PickMode, &cancel),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn begin_device_code_attempt_sets_state() {
|
||||||
|
let sign_in_state = Arc::new(RwLock::new(SignInState::PickMode));
|
||||||
|
let request_frame = FrameRequester::test_dummy();
|
||||||
|
|
||||||
|
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
|
||||||
|
let guard = sign_in_state.read().unwrap();
|
||||||
|
|
||||||
|
let state: &SignInState = &guard;
|
||||||
|
assert_eq!(device_code_attempt_matches(state, &cancel), true);
|
||||||
|
assert!(matches!(
|
||||||
|
state,
|
||||||
|
SignInState::ChatGptDeviceCode(state) if state.device_code.is_none()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_device_code_state_for_active_attempt_updates_only_when_active() {
|
||||||
|
let request_frame = FrameRequester::test_dummy();
|
||||||
|
let cancel = Arc::new(Notify::new());
|
||||||
|
let sign_in_state = device_code_sign_in_state(cancel.clone());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&*sign_in_state.read().unwrap(),
|
||||||
|
SignInState::PickMode
|
||||||
|
));
|
||||||
|
|
||||||
|
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
|
||||||
|
assert_eq!(
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&*sign_in_state.read().unwrap(),
|
||||||
|
SignInState::ChatGptDeviceCode(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_device_code_success_message_for_active_attempt_updates_only_when_active() {
|
||||||
|
let request_frame = FrameRequester::test_dummy();
|
||||||
|
let cancel = Arc::new(Notify::new());
|
||||||
|
let sign_in_state = device_code_sign_in_state(cancel.clone());
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let auth_manager = AuthManager::shared(
|
||||||
|
temp_dir.path().to_path_buf(),
|
||||||
|
false,
|
||||||
|
AuthCredentialsStoreMode::File,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
set_device_code_success_message_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&auth_manager,
|
||||||
|
&cancel,
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&*sign_in_state.read().unwrap(),
|
||||||
|
SignInState::ChatGptSuccessMessage
|
||||||
|
));
|
||||||
|
|
||||||
|
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
|
||||||
|
assert_eq!(
|
||||||
|
set_device_code_success_message_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&auth_manager,
|
||||||
|
&cancel,
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&*sign_in_state.read().unwrap(),
|
||||||
|
SignInState::ChatGptDeviceCode(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ use codex_core::auth::AuthCredentialsStoreMode;
|
|||||||
use codex_core::auth::CLIENT_ID;
|
use codex_core::auth::CLIENT_ID;
|
||||||
use codex_core::auth::login_with_api_key;
|
use codex_core::auth::login_with_api_key;
|
||||||
use codex_core::auth::read_openai_api_key_from_env;
|
use codex_core::auth::read_openai_api_key_from_env;
|
||||||
|
use codex_core::env::is_headless_environment;
|
||||||
|
use codex_login::DeviceCode;
|
||||||
use codex_login::ServerOptions;
|
use codex_login::ServerOptions;
|
||||||
use codex_login::ShutdownHandle;
|
use codex_login::ShutdownHandle;
|
||||||
use codex_login::run_login_server;
|
use codex_login::run_login_server;
|
||||||
@@ -40,13 +42,17 @@ use crate::shimmer::shimmer_spans;
|
|||||||
use crate::tui::FrameRequester;
|
use crate::tui::FrameRequester;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
use super::onboarding_screen::StepState;
|
use super::onboarding_screen::StepState;
|
||||||
|
|
||||||
|
mod headless_chatgpt_login;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) enum SignInState {
|
pub(crate) enum SignInState {
|
||||||
PickMode,
|
PickMode,
|
||||||
ChatGptContinueInBrowser(ContinueInBrowserState),
|
ChatGptContinueInBrowser(ContinueInBrowserState),
|
||||||
|
ChatGptDeviceCode(ContinueWithDeviceCodeState),
|
||||||
ChatGptSuccessMessage,
|
ChatGptSuccessMessage,
|
||||||
ChatGptSuccess,
|
ChatGptSuccess,
|
||||||
ApiKeyEntry(ApiKeyInputState),
|
ApiKeyEntry(ApiKeyInputState),
|
||||||
@@ -68,6 +74,12 @@ pub(crate) struct ContinueInBrowserState {
|
|||||||
shutdown_flag: Option<ShutdownHandle>,
|
shutdown_flag: Option<ShutdownHandle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct ContinueWithDeviceCodeState {
|
||||||
|
device_code: Option<DeviceCode>,
|
||||||
|
cancel: Option<Arc<Notify>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for ContinueInBrowserState {
|
impl Drop for ContinueInBrowserState {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(handle) = &self.shutdown_flag {
|
if let Some(handle) = &self.shutdown_flag {
|
||||||
@@ -128,10 +140,22 @@ impl KeyboardHandler for AuthModeWidget {
|
|||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
tracing::info!("Esc pressed");
|
tracing::info!("Esc pressed");
|
||||||
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
|
let mut sign_in_state = self.sign_in_state.write().unwrap();
|
||||||
if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
|
match &*sign_in_state {
|
||||||
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
|
SignInState::ChatGptContinueInBrowser(_) => {
|
||||||
self.request_frame.schedule_frame();
|
*sign_in_state = SignInState::PickMode;
|
||||||
|
drop(sign_in_state);
|
||||||
|
self.request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
SignInState::ChatGptDeviceCode(state) => {
|
||||||
|
if let Some(cancel) = &state.cancel {
|
||||||
|
cancel.notify_one();
|
||||||
|
}
|
||||||
|
*sign_in_state = SignInState::PickMode;
|
||||||
|
drop(sign_in_state);
|
||||||
|
self.request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -216,10 +240,12 @@ impl AuthModeWidget {
|
|||||||
vec![line1, line2]
|
vec![line1, line2]
|
||||||
};
|
};
|
||||||
|
|
||||||
let chatgpt_description = if self.is_chatgpt_login_allowed() {
|
let chatgpt_description = if !self.is_chatgpt_login_allowed() {
|
||||||
"Usage included with Plus, Pro, Business, Education, and Enterprise plans"
|
|
||||||
} else {
|
|
||||||
"ChatGPT login is disabled"
|
"ChatGPT login is disabled"
|
||||||
|
} else if is_headless_environment() {
|
||||||
|
"Uses device code login (headless environment detected)"
|
||||||
|
} else {
|
||||||
|
"Usage included with Plus, Pro, Team, and Enterprise plans"
|
||||||
};
|
};
|
||||||
lines.extend(create_mode_item(
|
lines.extend(create_mode_item(
|
||||||
0,
|
0,
|
||||||
@@ -277,7 +303,10 @@ impl AuthModeWidget {
|
|||||||
{
|
{
|
||||||
lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into());
|
lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into());
|
||||||
lines.push("".into());
|
lines.push("".into());
|
||||||
lines.push(Line::from(state.auth_url.as_str().cyan().underlined()));
|
lines.push(Line::from(vec![
|
||||||
|
" ".into(),
|
||||||
|
state.auth_url.as_str().cyan().underlined(),
|
||||||
|
]));
|
||||||
lines.push("".into());
|
lines.push("".into());
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![
|
||||||
" On a remote or headless machine? Use ".into(),
|
" On a remote or headless machine? Use ".into(),
|
||||||
@@ -575,6 +604,12 @@ impl AuthModeWidget {
|
|||||||
self.forced_chatgpt_workspace_id.clone(),
|
self.forced_chatgpt_workspace_id.clone(),
|
||||||
self.cli_auth_credentials_store_mode,
|
self.cli_auth_credentials_store_mode,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if is_headless_environment() {
|
||||||
|
headless_chatgpt_login::start_headless_chatgpt_login(self, opts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match run_login_server(opts) {
|
match run_login_server(opts) {
|
||||||
Ok(child) => {
|
Ok(child) => {
|
||||||
let sign_in_state = self.sign_in_state.clone();
|
let sign_in_state = self.sign_in_state.clone();
|
||||||
@@ -623,6 +658,7 @@ impl StepStateProvider for AuthModeWidget {
|
|||||||
SignInState::PickMode
|
SignInState::PickMode
|
||||||
| SignInState::ApiKeyEntry(_)
|
| SignInState::ApiKeyEntry(_)
|
||||||
| SignInState::ChatGptContinueInBrowser(_)
|
| SignInState::ChatGptContinueInBrowser(_)
|
||||||
|
| SignInState::ChatGptDeviceCode(_)
|
||||||
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
|
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
|
||||||
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
|
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
|
||||||
}
|
}
|
||||||
@@ -639,6 +675,9 @@ impl WidgetRef for AuthModeWidget {
|
|||||||
SignInState::ChatGptContinueInBrowser(_) => {
|
SignInState::ChatGptContinueInBrowser(_) => {
|
||||||
self.render_continue_in_browser(area, buf);
|
self.render_continue_in_browser(area, buf);
|
||||||
}
|
}
|
||||||
|
SignInState::ChatGptDeviceCode(state) => {
|
||||||
|
headless_chatgpt_login::render_device_code_login(self, area, buf, state);
|
||||||
|
}
|
||||||
SignInState::ChatGptSuccessMessage => {
|
SignInState::ChatGptSuccessMessage => {
|
||||||
self.render_chatgpt_success_message(area, buf);
|
self.render_chatgpt_success_message(area, buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
use codex_core::AuthManager;
|
||||||
|
use codex_login::ServerOptions;
|
||||||
|
use codex_login::complete_device_code_login;
|
||||||
|
use codex_login::request_device_code;
|
||||||
|
use codex_login::run_login_server;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::prelude::Widget;
|
||||||
|
use ratatui::style::Stylize;
|
||||||
|
use ratatui::text::Line;
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::widgets::Wrap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
|
use crate::shimmer::shimmer_spans;
|
||||||
|
use crate::tui::FrameRequester;
|
||||||
|
|
||||||
|
use super::AuthModeWidget;
|
||||||
|
use super::ContinueInBrowserState;
|
||||||
|
use super::ContinueWithDeviceCodeState;
|
||||||
|
use super::SignInState;
|
||||||
|
|
||||||
|
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts: ServerOptions) {
|
||||||
|
opts.open_browser = false;
|
||||||
|
let sign_in_state = widget.sign_in_state.clone();
|
||||||
|
let request_frame = widget.request_frame.clone();
|
||||||
|
let auth_manager = widget.auth_manager.clone();
|
||||||
|
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let device_code = match request_device_code(&opts).await {
|
||||||
|
Ok(device_code) => device_code,
|
||||||
|
Err(err) => {
|
||||||
|
if err.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
let should_fallback = {
|
||||||
|
let guard = sign_in_state.read().unwrap();
|
||||||
|
device_code_attempt_matches(&guard, &cancel)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !should_fallback {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match run_login_server(opts) {
|
||||||
|
Ok(child) => {
|
||||||
|
let auth_url = child.auth_url.clone();
|
||||||
|
{
|
||||||
|
*sign_in_state.write().unwrap() =
|
||||||
|
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
|
||||||
|
auth_url,
|
||||||
|
shutdown_flag: Some(child.cancel_handle()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
let r = child.block_until_done().await;
|
||||||
|
match r {
|
||||||
|
Ok(()) => {
|
||||||
|
auth_manager.reload();
|
||||||
|
*sign_in_state.write().unwrap() =
|
||||||
|
SignInState::ChatGptSuccessMessage;
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
*sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
|
||||||
|
device_code: Some(device_code.clone()),
|
||||||
|
cancel: Some(cancel.clone()),
|
||||||
|
}),
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel.notified() => {}
|
||||||
|
r = complete_device_code_login(opts, device_code) => {
|
||||||
|
match r {
|
||||||
|
Ok(()) => {
|
||||||
|
set_device_code_success_message_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&auth_manager,
|
||||||
|
&cancel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn render_device_code_login(
|
||||||
|
widget: &AuthModeWidget,
|
||||||
|
area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
state: &ContinueWithDeviceCodeState,
|
||||||
|
) {
|
||||||
|
let banner = if state.device_code.is_some() {
|
||||||
|
"Finish signing in via your browser"
|
||||||
|
} else {
|
||||||
|
"Preparing device code login"
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut spans = vec![" ".into()];
|
||||||
|
if widget.animations_enabled {
|
||||||
|
// Schedule a follow-up frame to keep the shimmer animation going.
|
||||||
|
widget
|
||||||
|
.request_frame
|
||||||
|
.schedule_frame_in(std::time::Duration::from_millis(100));
|
||||||
|
spans.extend(shimmer_spans(banner));
|
||||||
|
} else {
|
||||||
|
spans.push(banner.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = vec![spans.into(), "".into()];
|
||||||
|
|
||||||
|
if let Some(device_code) = &state.device_code {
|
||||||
|
lines.push(" 1. Open this link in your browser and sign in".into());
|
||||||
|
lines.push("".into());
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
" ".into(),
|
||||||
|
device_code.verification_url.as_str().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("".into());
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
" ".into(),
|
||||||
|
device_code.user_code.as_str().cyan().bold(),
|
||||||
|
]));
|
||||||
|
lines.push("".into());
|
||||||
|
lines.push(
|
||||||
|
" Device codes are a common phishing target. Never share this code."
|
||||||
|
.dim()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
lines.push("".into());
|
||||||
|
} else {
|
||||||
|
lines.push(" Requesting a one-time code...".dim().into());
|
||||||
|
lines.push("".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(" Press Esc to cancel".dim().into());
|
||||||
|
Paragraph::new(lines)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.render(area, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_code_attempt_matches(state: &SignInState, cancel: &Arc<Notify>) -> bool {
|
||||||
|
matches!(
|
||||||
|
state,
|
||||||
|
SignInState::ChatGptDeviceCode(state)
|
||||||
|
if state
|
||||||
|
.cancel
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|existing| Arc::ptr_eq(existing, cancel))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_device_code_attempt(
|
||||||
|
sign_in_state: &Arc<RwLock<SignInState>>,
|
||||||
|
request_frame: &FrameRequester,
|
||||||
|
) -> Arc<Notify> {
|
||||||
|
let cancel = Arc::new(Notify::new());
|
||||||
|
*sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
|
||||||
|
device_code: None,
|
||||||
|
cancel: Some(cancel.clone()),
|
||||||
|
});
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_device_code_state_for_active_attempt(
|
||||||
|
sign_in_state: &Arc<RwLock<SignInState>>,
|
||||||
|
request_frame: &FrameRequester,
|
||||||
|
cancel: &Arc<Notify>,
|
||||||
|
next_state: SignInState,
|
||||||
|
) -> bool {
|
||||||
|
let mut guard = sign_in_state.write().unwrap();
|
||||||
|
if !device_code_attempt_matches(&guard, cancel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
*guard = next_state;
|
||||||
|
drop(guard);
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_device_code_success_message_for_active_attempt(
|
||||||
|
sign_in_state: &Arc<RwLock<SignInState>>,
|
||||||
|
request_frame: &FrameRequester,
|
||||||
|
auth_manager: &AuthManager,
|
||||||
|
cancel: &Arc<Notify>,
|
||||||
|
) -> bool {
|
||||||
|
let mut guard = sign_in_state.write().unwrap();
|
||||||
|
if !device_code_attempt_matches(&guard, cancel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_manager.reload();
|
||||||
|
*guard = SignInState::ChatGptSuccessMessage;
|
||||||
|
drop(guard);
|
||||||
|
request_frame.schedule_frame();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use codex_core::auth::AuthCredentialsStoreMode;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn device_code_sign_in_state(cancel: Arc<Notify>) -> Arc<RwLock<SignInState>> {
|
||||||
|
Arc::new(RwLock::new(SignInState::ChatGptDeviceCode(
|
||||||
|
ContinueWithDeviceCodeState {
|
||||||
|
device_code: None,
|
||||||
|
cancel: Some(cancel),
|
||||||
|
},
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_code_attempt_matches_only_for_matching_cancel() {
|
||||||
|
let cancel = Arc::new(Notify::new());
|
||||||
|
let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
|
||||||
|
device_code: None,
|
||||||
|
cancel: Some(cancel.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(device_code_attempt_matches(&state, &cancel), true);
|
||||||
|
assert_eq!(
|
||||||
|
device_code_attempt_matches(&state, &Arc::new(Notify::new())),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
device_code_attempt_matches(&SignInState::PickMode, &cancel),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn begin_device_code_attempt_sets_state() {
|
||||||
|
let sign_in_state = Arc::new(RwLock::new(SignInState::PickMode));
|
||||||
|
let request_frame = FrameRequester::test_dummy();
|
||||||
|
|
||||||
|
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
|
||||||
|
let guard = sign_in_state.read().unwrap();
|
||||||
|
|
||||||
|
let state: &SignInState = &guard;
|
||||||
|
assert_eq!(device_code_attempt_matches(state, &cancel), true);
|
||||||
|
assert!(matches!(
|
||||||
|
state,
|
||||||
|
SignInState::ChatGptDeviceCode(state) if state.device_code.is_none()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_device_code_state_for_active_attempt_updates_only_when_active() {
|
||||||
|
let request_frame = FrameRequester::test_dummy();
|
||||||
|
let cancel = Arc::new(Notify::new());
|
||||||
|
let sign_in_state = device_code_sign_in_state(cancel.clone());
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&*sign_in_state.read().unwrap(),
|
||||||
|
SignInState::PickMode
|
||||||
|
));
|
||||||
|
|
||||||
|
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
|
||||||
|
assert_eq!(
|
||||||
|
set_device_code_state_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&cancel,
|
||||||
|
SignInState::PickMode,
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&*sign_in_state.read().unwrap(),
|
||||||
|
SignInState::ChatGptDeviceCode(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_device_code_success_message_for_active_attempt_updates_only_when_active() {
|
||||||
|
let request_frame = FrameRequester::test_dummy();
|
||||||
|
let cancel = Arc::new(Notify::new());
|
||||||
|
let sign_in_state = device_code_sign_in_state(cancel.clone());
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let auth_manager = AuthManager::shared(
|
||||||
|
temp_dir.path().to_path_buf(),
|
||||||
|
false,
|
||||||
|
AuthCredentialsStoreMode::File,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
set_device_code_success_message_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&auth_manager,
|
||||||
|
&cancel,
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&*sign_in_state.read().unwrap(),
|
||||||
|
SignInState::ChatGptSuccessMessage
|
||||||
|
));
|
||||||
|
|
||||||
|
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
|
||||||
|
assert_eq!(
|
||||||
|
set_device_code_success_message_for_active_attempt(
|
||||||
|
&sign_in_state,
|
||||||
|
&request_frame,
|
||||||
|
&auth_manager,
|
||||||
|
&cancel,
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
&*sign_in_state.read().unwrap(),
|
||||||
|
SignInState::ChatGptDeviceCode(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user