diff --git a/src/codex_agent.rs b/src/codex_agent.rs index 5148a9b..c904138 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; @@ -34,6 +34,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. @@ -434,15 +435,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) @@ -472,7 +469,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(), @@ -480,9 +476,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)?; + self.auth_manager.reload(); + } server .block_until_done() .await @@ -800,6 +808,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, @@ -892,3 +922,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:?}"), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 595403d..d41dd4a 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,38 @@ 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).await?); + + 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 +72,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,23 +85,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).await?); - - let stdin = tokio::io::stdin().compat(); - let stdout = tokio::io::stdout().compat_write(); + Ok(config) +} - agent - .serve(ByteStreams::new(stdout, stdin)) - .await - .map_err(|e| std::io::Error::other(format!("ACP error: {e}")))?; +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()?; + } - 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 182167a..68518c1 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"), + ] + ); + } +}