From 3277ed0d98818a35050247b6ad98b64b645a66cc Mon Sep 17 00:00:00 2001 From: Bill Njoroge Date: Wed, 29 Apr 2026 11:24:30 -0400 Subject: [PATCH 1/2] Add headless ChatGPT device-code auth --- src/codex_agent.rs | 99 +++++++++++++++++++++++++++++++++++----------- src/lib.rs | 74 ++++++++++++++++++++++++++-------- src/main.rs | 82 +++++++++++++++++++++++++++++++++++++- 3 files changed, 214 insertions(+), 41 deletions(-) diff --git a/src/codex_agent.rs b/src/codex_agent.rs index 7d48bc15..c3533801 100644 --- a/src/codex_agent.rs +++ b/src/codex_agent.rs @@ -1,14 +1,14 @@ use acp::schema::{ AgentAuthCapabilities, AgentCapabilities, AuthEnvVar, AuthMethod, AuthMethodAgent, - AuthMethodEnvVar, AuthMethodId, AuthenticateRequest, AuthenticateResponse, CancelNotification, - ClientCapabilities, CloseSessionRequest, CloseSessionResponse, Implementation, - InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, - LoadSessionRequest, LoadSessionResponse, LogoutCapabilities, LogoutRequest, LogoutResponse, - McpCapabilities, McpServer, McpServerHttp, McpServerStdio, NewSessionRequest, - NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse, ProtocolVersion, - SessionCapabilities, SessionCloseCapabilities, SessionId, SessionInfo, SessionListCapabilities, - SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, - SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, + AuthMethodEnvVar, AuthMethodId, AuthMethodTerminal, AuthenticateRequest, AuthenticateResponse, + CancelNotification, ClientCapabilities, CloseSessionRequest, CloseSessionResponse, + Implementation, InitializeRequest, InitializeResponse, ListSessionsRequest, + ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, LogoutCapabilities, + LogoutRequest, LogoutResponse, McpCapabilities, McpServer, McpServerHttp, McpServerStdio, + NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, PromptResponse, + ProtocolVersion, SessionCapabilities, SessionCloseCapabilities, SessionId, SessionInfo, + SessionListCapabilities, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, + SetSessionModeRequest, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, }; use acp::{Agent, Client, ConnectTo, ConnectionTo, Error}; use agent_client_protocol as acp; @@ -35,6 +35,7 @@ use std::{ use tracing::{debug, info}; use unicode_segmentation::UnicodeSegmentation; +use crate::run_device_code_auth; use crate::thread::Thread; /// The Codex implementation of the ACP Agent. @@ -427,15 +428,11 @@ impl CodexAgent { .close(SessionCloseCapabilities::new()) .list(SessionListCapabilities::new()); - let mut auth_methods = vec![ - CodexAuthMethod::ChatGpt.into(), + let auth_methods = vec![ + chatgpt_auth_method(no_browser_mode()), CodexAuthMethod::CodexApiKey.into(), CodexAuthMethod::OpenAiApiKey.into(), ]; - // Until codex device code auth works, we can't use this in remote ssh projects - if std::env::var("NO_BROWSER").is_ok() { - auth_methods.remove(0); - } Ok(InitializeResponse::new(protocol_version) .agent_capabilities(agent_capabilities) @@ -465,7 +462,6 @@ impl CodexAgent { match auth_method { CodexAuthMethod::ChatGpt => { - // Perform browser/device login via codex-rs, then report success/failure to the client. let opts = codex_login::ServerOptions::new( self.config.codex_home.to_path_buf(), codex_login::auth::CLIENT_ID.to_string(), @@ -473,15 +469,21 @@ impl CodexAgent { self.config.cli_auth_credentials_store_mode, ); - let server = - codex_login::run_login_server(opts).map_err(Error::into_internal_error)?; + if no_browser_mode() { + run_device_code_auth(opts) + .await + .map_err(Error::into_internal_error)?; + } else { + let server = + codex_login::run_login_server(opts).map_err(Error::into_internal_error)?; - server - .block_until_done() - .await - .map_err(Error::into_internal_error)?; + server + .block_until_done() + .await + .map_err(Error::into_internal_error)?; - self.auth_manager.reload(); + self.auth_manager.reload(); + } } CodexAuthMethod::CodexApiKey => { let api_key = read_codex_api_key_from_env().ok_or_else(|| { @@ -790,6 +792,28 @@ impl CodexAgent { } } +fn no_browser_mode() -> bool { + std::env::var_os("NO_BROWSER").is_some() +} + +fn chatgpt_auth_method(no_browser: bool) -> AuthMethod { + let method = CodexAuthMethod::ChatGpt; + if no_browser { + let description = "Use your ChatGPT login with Codex CLI via device code (works in remote/headless projects)"; + AuthMethod::Terminal( + AuthMethodTerminal::new(method, "Login with ChatGPT") + .description(description) + .args(vec!["/auth".to_string()]) + .env(HashMap::from_iter([( + "NO_BROWSER".to_string(), + "1".to_string(), + )])), + ) + } else { + method.into() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CodexAuthMethod { ChatGpt, @@ -839,6 +863,35 @@ impl From for AuthMethod { } } +#[cfg(test)] +mod tests { + use super::{AuthMethod, AuthMethodId, chatgpt_auth_method}; + + #[test] + fn headless_chatgpt_auth_uses_terminal_auth() { + match chatgpt_auth_method(true) { + AuthMethod::Terminal(method) => { + assert_eq!(method.id, AuthMethodId::new("chatgpt")); + assert_eq!(method.name, "Login with ChatGPT"); + assert_eq!(method.args, vec!["/auth"]); + assert_eq!(method.env.get("NO_BROWSER").map(String::as_str), Some("1")); + } + other => panic!("expected terminal auth method, got {other:?}"), + } + } + + #[test] + fn interactive_chatgpt_auth_uses_agent_auth() { + match chatgpt_auth_method(false) { + AuthMethod::Agent(method) => { + assert_eq!(method.id, AuthMethodId::new("chatgpt")); + assert_eq!(method.name, "Login with ChatGPT"); + } + other => panic!("expected agent auth method, got {other:?}"), + } + } +} + impl TryFrom for CodexAuthMethod { type Error = Error; diff --git a/src/lib.rs b/src/lib.rs index 12a65178..92134972 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ use agent_client_protocol::ByteStreams; use codex_core::config::{Config, ConfigOverrides}; use codex_utils_cli::CliConfigOverrides; +use std::io::Write as _; use std::path::PathBuf; use std::sync::Arc; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -31,7 +32,41 @@ pub async fn run_main( .with_env_filter(EnvFilter::from_default_env()) .init(); - // Parse CLI overrides and load configuration + let config = load_config(cli_config_overrides, codex_linux_sandbox_exe.clone()).await?; + + let agent = Arc::new(codex_agent::CodexAgent::new( + config, + codex_linux_sandbox_exe, + )?); + + let stdin = tokio::io::stdin().compat(); + let stdout = tokio::io::stdout().compat_write(); + + agent + .serve(ByteStreams::new(stdout, stdin)) + .await + .map_err(|e| std::io::Error::other(format!("ACP error: {e}")))?; + + Ok(()) +} + +pub async fn run_auth_command(cli_config_overrides: CliConfigOverrides) -> std::io::Result<()> { + let config = load_config(cli_config_overrides, None).await?; + + let opts = codex_login::ServerOptions::new( + config.codex_home.to_path_buf(), + codex_login::auth::CLIENT_ID.to_string(), + None, + config.cli_auth_credentials_store_mode, + ); + + run_device_code_auth(opts).await +} + +async fn load_config( + cli_config_overrides: CliConfigOverrides, + codex_linux_sandbox_exe: Option, +) -> std::io::Result { let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -40,7 +75,7 @@ pub async fn run_main( })?; let config_overrides = ConfigOverrides { - codex_linux_sandbox_exe: codex_linux_sandbox_exe.clone(), + codex_linux_sandbox_exe, ..ConfigOverrides::default() }; @@ -53,26 +88,33 @@ pub async fn run_main( format!("error loading config: {e}"), ) })?; - // Apply residency requirement so the HTTP client sends the - // x-openai-internal-codex-residency header on all requests. + codex_login::default_client::set_default_client_residency_requirement( config.enforce_residency.value(), ); - let agent = Arc::new(codex_agent::CodexAgent::new( - config, - codex_linux_sandbox_exe, - )?); + Ok(config) +} - let stdin = tokio::io::stdin().compat(); - let stdout = tokio::io::stdout().compat_write(); +pub(crate) async fn run_device_code_auth(opts: codex_login::ServerOptions) -> std::io::Result<()> { + let device_code = codex_login::request_device_code(&opts).await?; + { + let mut stderr = std::io::stderr().lock(); + writeln!( + stderr, + "Open this link in your browser and sign in to your ChatGPT account:\n{}\n", + device_code.verification_url + )?; + writeln!( + stderr, + "Then enter this one-time code (expires in 15 minutes):\n{}\n", + device_code.user_code + )?; + writeln!(stderr, "Waiting for login to complete in the browser...")?; + stderr.flush()?; + } - agent - .serve(ByteStreams::new(stdout, stdin)) - .await - .map_err(|e| std::io::Error::other(format!("ACP error: {e}")))?; - - Ok(()) + codex_login::complete_device_code_login(opts, device_code).await } // Re-export the MCP server types for compatibility diff --git a/src/main.rs b/src/main.rs index 182167a2..68518c13 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,89 @@ use anyhow::Result; use clap::Parser; use codex_arg0::arg0_dispatch_or_else; use codex_utils_cli::CliConfigOverrides; +use std::ffi::OsString; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CommandMode { + Acp, + Auth, +} + +fn command_mode_and_args_from(mut args: Vec) -> (CommandMode, Vec) { + let mode = if matches!(args.get(1), Some(arg) if arg == "/auth") { + args.remove(1); + CommandMode::Auth + } else { + CommandMode::Acp + }; + + (mode, args) +} + +fn command_mode_and_args() -> (CommandMode, Vec) { + command_mode_and_args_from(std::env::args_os().collect()) +} fn main() -> Result<()> { + let (command_mode, filtered_args) = command_mode_and_args(); + arg0_dispatch_or_else(|args| async move { - let cli_config_overrides = CliConfigOverrides::parse(); - codex_acp::run_main(args.codex_linux_sandbox_exe, cli_config_overrides).await?; + let cli_config_overrides = CliConfigOverrides::parse_from(filtered_args); + + match command_mode { + CommandMode::Acp => { + codex_acp::run_main(args.codex_linux_sandbox_exe, cli_config_overrides).await?; + } + CommandMode::Auth => { + codex_acp::run_auth_command(cli_config_overrides).await?; + } + } + Ok(()) }) } + +#[cfg(test)] +mod tests { + use super::{CommandMode, command_mode_and_args_from}; + use std::ffi::OsString; + + #[test] + fn auth_mode_only_matches_first_positional_argument() { + let (mode, args) = command_mode_and_args_from(vec![ + OsString::from("codex-acp"), + OsString::from("--verbose"), + OsString::from("/auth"), + ]); + + assert_eq!(mode, CommandMode::Acp); + assert_eq!( + args, + vec![ + OsString::from("codex-acp"), + OsString::from("--verbose"), + OsString::from("/auth"), + ] + ); + } + + #[test] + fn auth_mode_strips_explicit_subcommand() { + let (mode, args) = command_mode_and_args_from(vec![ + OsString::from("codex-acp"), + OsString::from("/auth"), + OsString::from("--config"), + OsString::from("settings.toml"), + ]); + + assert_eq!(mode, CommandMode::Auth); + assert_eq!( + args, + vec![ + OsString::from("codex-acp"), + OsString::from("--config"), + OsString::from("settings.toml"), + ] + ); + } +} From 81d7b5da1a59b245367781e4adbf0569df80e009 Mon Sep 17 00:00:00 2001 From: Bill Njoroge Date: Wed, 29 Apr 2026 12:28:48 -0400 Subject: [PATCH 2/2] Fix clippy test-module ordering --- src/codex_agent.rs | 58 +++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/codex_agent.rs b/src/codex_agent.rs index c3533801..af41f9e1 100644 --- a/src/codex_agent.rs +++ b/src/codex_agent.rs @@ -863,35 +863,6 @@ impl From for AuthMethod { } } -#[cfg(test)] -mod tests { - use super::{AuthMethod, AuthMethodId, chatgpt_auth_method}; - - #[test] - fn headless_chatgpt_auth_uses_terminal_auth() { - match chatgpt_auth_method(true) { - AuthMethod::Terminal(method) => { - assert_eq!(method.id, AuthMethodId::new("chatgpt")); - assert_eq!(method.name, "Login with ChatGPT"); - assert_eq!(method.args, vec!["/auth"]); - assert_eq!(method.env.get("NO_BROWSER").map(String::as_str), Some("1")); - } - other => panic!("expected terminal auth method, got {other:?}"), - } - } - - #[test] - fn interactive_chatgpt_auth_uses_agent_auth() { - match chatgpt_auth_method(false) { - AuthMethod::Agent(method) => { - assert_eq!(method.id, AuthMethodId::new("chatgpt")); - assert_eq!(method.name, "Login with ChatGPT"); - } - other => panic!("expected agent auth method, got {other:?}"), - } - } -} - impl TryFrom for CodexAuthMethod { type Error = Error; @@ -935,3 +906,32 @@ fn format_session_title(message: &str) -> Option { Some(truncate_graphemes(trimmed, SESSION_TITLE_MAX_GRAPHEMES)) } } + +#[cfg(test)] +mod tests { + use super::{AuthMethod, AuthMethodId, chatgpt_auth_method}; + + #[test] + fn headless_chatgpt_auth_uses_terminal_auth() { + match chatgpt_auth_method(true) { + AuthMethod::Terminal(method) => { + assert_eq!(method.id, AuthMethodId::new("chatgpt")); + assert_eq!(method.name, "Login with ChatGPT"); + assert_eq!(method.args, vec!["/auth"]); + assert_eq!(method.env.get("NO_BROWSER").map(String::as_str), Some("1")); + } + other => panic!("expected terminal auth method, got {other:?}"), + } + } + + #[test] + fn interactive_chatgpt_auth_uses_agent_auth() { + match chatgpt_auth_method(false) { + AuthMethod::Agent(method) => { + assert_eq!(method.id, AuthMethodId::new("chatgpt")); + assert_eq!(method.name, "Login with ChatGPT"); + } + other => panic!("expected agent auth method, got {other:?}"), + } + } +}