Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 81 additions & 10 deletions codex-rs/cli/src/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ use std::io::IsTerminal;
use std::io::Read;
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(
codex_home: PathBuf,
forced_chatgpt_workspace_id: Option<String>,
Expand All @@ -27,10 +39,7 @@ pub async fn login_with_chatgpt(
);
let server = run_login_server(opts)?;

eprintln!(
"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,
);
print_login_server_start(server.actual_port, &server.auth_url);

server.block_until_done().await
}
Expand All @@ -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;

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);
}

Expand All @@ -53,7 +62,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) ->
.await
{
Ok(_) => {
eprintln!("Successfully logged in");
eprintln!("{LOGIN_SUCCESS_MESSAGE}");
std::process::exit(0);
}
Err(e) => {
Expand All @@ -70,7 +79,7 @@ pub async fn run_login_with_api_key(
let config = load_config_or_exit(cli_config_overrides).await;

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);
}

Expand All @@ -80,7 +89,7 @@ pub async fn run_login_with_api_key(
config.cli_auth_credentials_store_mode,
) {
Ok(_) => {
eprintln!("Successfully logged in");
eprintln!("{LOGIN_SUCCESS_MESSAGE}");
std::process::exit(0);
}
Err(e) => {
Expand Down Expand Up @@ -125,7 +134,7 @@ pub async fn run_login_with_device_code(
) -> ! {
let config = load_config_or_exit(cli_config_overrides).await;
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);
}
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
Expand All @@ -140,7 +149,7 @@ pub async fn run_login_with_device_code(
}
match run_device_code_login(opts).await {
Ok(()) => {
eprintln!("Successfully logged in");
eprintln!("{LOGIN_SUCCESS_MESSAGE}");
std::process::exit(0);
}
Err(e) => {
Expand All @@ -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) -> ! {
let config = load_config_or_exit(cli_config_overrides).await;

Expand Down
9 changes: 9 additions & 0 deletions codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_chatgpt;
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_cloud_tasks::Cli as CloudTasksCli;
use codex_common::CliConfigOverrides;
use codex_core::env::is_headless_environment;
use codex_exec::Cli as ExecCli;
use codex_exec::Command as ExecCommand;
use codex_exec::ReviewArgs;
Expand Down Expand Up @@ -539,6 +541,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
} else if login_cli.with_api_key {
let api_key = read_api_key_from_stdin();
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 {
run_login_with_chatgpt(login_cli.config_overrides).await;
}
Expand Down
27 changes: 27 additions & 0 deletions codex-rs/core/src/env.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
//! 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.
pub fn is_wsl() -> bool {
#[cfg(target_os = "linux")]
Expand All @@ -17,3 +21,26 @@ pub fn is_wsl() -> bool {
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
}
59 changes: 43 additions & 16 deletions codex-rs/login/src/device_code_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ const ANSI_BLUE: &str = "\x1b[94m";
const ANSI_GRAY: &str = "\x1b[90m";
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)]
struct UserCodeResp {
device_auth_id: String,
Expand Down Expand Up @@ -73,7 +81,8 @@ async fn request_user_code(
if !resp.status().is_success() {
let status = resp.status();
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.",
));
}
Expand Down Expand Up @@ -137,45 +146,56 @@ 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!(
"\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\
\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\
\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 run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result<DeviceCode> {
let client = reqwest::Client::new();
let issuer_base_url = opts.issuer.trim_end_matches('/');
let api_base_url = format!("{issuer_base_url}/api/accounts");
let base_url = opts.issuer.trim_end_matches('/');
let api_base_url = format!("{base_url}/api/accounts");
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(
&client,
&api_base_url,
&uc.device_auth_id,
&uc.user_code,
uc.interval,
&device_code.device_auth_id,
&device_code.user_code,
device_code.interval,
)
.await?;

let pkce = PkceCodes {
code_verifier: code_resp.code_verifier,
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(
issuer_base_url,
base_url,
&opts.client_id,
&redirect_uri,
&pkce,
Expand All @@ -201,3 +221,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
)
.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
}
3 changes: 3 additions & 0 deletions codex-rs/login/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ mod device_code_auth;
mod pkce;
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 server::LoginServer;
pub use server::ServerOptions;
Expand Down
Loading
Loading