From e13b35ecb0131ad02e52f4ac87b18b023a08ca5f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 11 Sep 2025 09:16:34 -0700 Subject: [PATCH] Simplify auth flow and reconcile differences between ChatGPT and API Key auth (#3189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR does the following: * Adds the ability to paste or type an API key. * Removes the `preferred_auth_method` config option. The last login method is always persisted in auth.json, so this isn't needed. * If OPENAI_API_KEY env variable is defined, the value is used to prepopulate the new UI. The env variable is otherwise ignored by the CLI. * Adds a new MCP server entry point "login_api_key" so we can implement this same API key behavior for the VS Code extension. Screenshot 2025-09-04 at 3 51 04 PM Screenshot 2025-09-04 at 3 51 32 PM --- codex-rs/Cargo.lock | 1 - codex-rs/chatgpt/Cargo.toml | 1 - codex-rs/chatgpt/src/chatgpt_token.rs | 3 +- codex-rs/cli/src/login.rs | 12 +- codex-rs/cli/src/proto.rs | 6 +- codex-rs/core/src/auth.rs | 188 ++--------- codex-rs/core/src/config.rs | 12 - codex-rs/core/src/model_provider_info.rs | 5 +- codex-rs/core/src/token_data.rs | 45 --- codex-rs/core/tests/suite/client.rs | 86 +---- codex-rs/exec/src/lib.rs | 6 +- .../mcp-server/src/codex_message_processor.rs | 52 ++- codex-rs/mcp-server/src/message_processor.rs | 3 +- .../mcp-server/tests/common/mcp_process.rs | 10 + codex-rs/mcp-server/tests/suite/auth.rs | 36 ++- codex-rs/mcp-server/tests/suite/login.rs | 4 +- codex-rs/protocol-ts/src/lib.rs | 2 + codex-rs/protocol/src/mcp_protocol.rs | 22 +- codex-rs/tui/src/lib.rs | 46 +-- codex-rs/tui/src/onboarding/auth.rs | 303 ++++++++++++++---- .../tui/src/onboarding/onboarding_screen.rs | 26 +- docs/advanced.md | 2 +- docs/authentication.md | 34 +- docs/config.md | 1 - 24 files changed, 412 insertions(+), 494 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eab2dede1..3955f79f1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -561,7 +561,6 @@ dependencies = [ "clap", "codex-common", "codex-core", - "codex-protocol", "serde", "serde_json", "tempfile", diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index db6e754f9..af5f910ef 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -11,7 +11,6 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } codex-common = { path = "../common", features = ["cli"] } codex-core = { path = "../core" } -codex-protocol = { path = "../protocol" } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index 15192ce3f..ce9b7475c 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -1,5 +1,4 @@ use codex_core::CodexAuth; -use codex_protocol::mcp_protocol::AuthMode; use std::path::Path; use std::sync::LazyLock; use std::sync::RwLock; @@ -20,7 +19,7 @@ pub fn set_chatgpt_token_data(value: TokenData) { /// Initialize the ChatGPT token from auth.json file pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> { - let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT)?; + let auth = CodexAuth::from_codex_home(codex_home)?; if let Some(auth) = auth { let token_data = auth.get_token_data().await?; set_chatgpt_token_data(token_data); diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index f03509618..f0816d0b2 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -1,7 +1,6 @@ use codex_common::CliConfigOverrides; use codex_core::CodexAuth; use codex_core::auth::CLIENT_ID; -use codex_core::auth::OPENAI_API_KEY_ENV_VAR; use codex_core::auth::login_with_api_key; use codex_core::auth::logout; use codex_core::config::Config; @@ -9,7 +8,6 @@ use codex_core::config::ConfigOverrides; use codex_login::ServerOptions; use codex_login::run_login_server; use codex_protocol::mcp_protocol::AuthMode; -use std::env; use std::path::PathBuf; pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { @@ -60,19 +58,11 @@ pub async fn run_login_with_api_key( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides); - match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) { + match CodexAuth::from_codex_home(&config.codex_home) { Ok(Some(auth)) => match auth.mode { AuthMode::ApiKey => match auth.get_token().await { Ok(api_key) => { eprintln!("Logged in using an API key - {}", safe_format_key(&api_key)); - - if let Ok(env_api_key) = env::var(OPENAI_API_KEY_ENV_VAR) - && env_api_key == api_key - { - eprintln!( - " API loaded from OPENAI_API_KEY environment variable or .env file" - ); - } std::process::exit(0); } Err(e) => { diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index 9b8cb92ee..623edca5a 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -37,10 +37,8 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?; // Use conversation_manager API to start a conversation - let conversation_manager = ConversationManager::new(AuthManager::shared( - config.codex_home.clone(), - config.preferred_auth_method, - )); + let conversation_manager = + ConversationManager::new(AuthManager::shared(config.codex_home.clone())); let NewConversation { conversation_id: _, conversation, diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 1256584ce..e2dd1d73d 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -70,13 +70,9 @@ impl CodexAuth { Ok(access) } - /// Loads the available auth information from the auth.json or - /// OPENAI_API_KEY environment variable. - pub fn from_codex_home( - codex_home: &Path, - preferred_auth_method: AuthMode, - ) -> std::io::Result> { - load_auth(codex_home, true, preferred_auth_method) + /// Loads the available auth information from the auth.json. + pub fn from_codex_home(codex_home: &Path) -> std::io::Result> { + load_auth(codex_home) } pub async fn get_token_data(&self) -> Result { @@ -193,10 +189,11 @@ impl CodexAuth { pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; -fn read_openai_api_key_from_env() -> Option { +pub fn read_openai_api_key_from_env() -> Option { env::var(OPENAI_API_KEY_ENV_VAR) .ok() - .filter(|s| !s.is_empty()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } pub fn get_auth_file(codex_home: &Path) -> PathBuf { @@ -214,7 +211,7 @@ pub fn logout(codex_home: &Path) -> std::io::Result { } } -/// Writes an `auth.json` that contains only the API key. Intended for CLI use. +/// Writes an `auth.json` that contains only the API key. pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { let auth_dot_json = AuthDotJson { openai_api_key: Some(api_key.to_string()), @@ -224,28 +221,11 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<( write_auth_json(&get_auth_file(codex_home), &auth_dot_json) } -fn load_auth( - codex_home: &Path, - include_env_var: bool, - preferred_auth_method: AuthMode, -) -> std::io::Result> { - // First, check to see if there is a valid auth.json file. If not, we fall - // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable - // (if it is set). +fn load_auth(codex_home: &Path) -> std::io::Result> { let auth_file = get_auth_file(codex_home); let client = crate::default_client::create_client(); let auth_dot_json = match try_read_auth_json(&auth_file) { Ok(auth) => auth, - // If auth.json does not exist, try to read the OPENAI_API_KEY from the - // environment variable. - Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => { - return match read_openai_api_key_from_env() { - Some(api_key) => Ok(Some(CodexAuth::from_api_key_with_client(&api_key, client))), - None => Ok(None), - }; - } - // Though if auth.json exists but is malformed, do not fall back to the - // env var because the user may be expecting to use AuthMode::ChatGPT. Err(e) => { return Err(e); } @@ -257,32 +237,11 @@ fn load_auth( last_refresh, } = auth_dot_json; - // If the auth.json has an API key AND does not appear to be on a plan that - // should prefer AuthMode::ChatGPT, use AuthMode::ApiKey. + // Prefer AuthMode.ApiKey if it's set in the auth.json. if let Some(api_key) = &auth_json_api_key { - // Should any of these be AuthMode::ChatGPT with the api_key set? - // Does AuthMode::ChatGPT indicate that there is an auth.json that is - // "refreshable" even if we are using the API key for auth? - match &tokens { - Some(tokens) => { - if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { - return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); - } else { - // Ignore the API key and fall through to ChatGPT auth. - } - } - None => { - // We have an API key but no tokens in the auth.json file. - // Perhaps the user ran `codex login --api-key ` or updated - // auth.json by hand. Either way, let's assume they are trying - // to use their API key. - return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); - } - } + return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); } - // For the AuthMode::ChatGPT variant, perhaps neither api_key nor - // openai_api_key should exist? Ok(Some(CodexAuth { api_key: None, mode: AuthMode::ChatGPT, @@ -412,7 +371,6 @@ use std::sync::RwLock; /// Internal cached auth state. #[derive(Clone, Debug)] struct CachedAuth { - preferred_auth_mode: AuthMode, auth: Option, } @@ -468,9 +426,7 @@ mod tests { auth_dot_json, auth_file: _, .. - } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); + } = super::load_auth(codex_home.path()).unwrap().unwrap(); assert_eq!(None, api_key); assert_eq!(AuthMode::ChatGPT, mode); @@ -499,88 +455,6 @@ mod tests { ) } - /// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in - /// [`TokenData::is_plan_that_should_use_api_key`], it should use - /// [`AuthMode::ChatGPT`]. - #[tokio::test] - async fn pro_account_with_api_key_still_uses_chatgpt_auth() { - let codex_home = tempdir().unwrap(); - let fake_jwt = write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "pro".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - .. - } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(None, api_key); - assert_eq!(AuthMode::ChatGPT, mode); - - let guard = auth_dot_json.lock().unwrap(); - let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); - assert_eq!( - &AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token: IdTokenInfo { - email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), - raw_jwt: fake_jwt, - }, - access_token: "test-access-token".to_string(), - refresh_token: "test-refresh-token".to_string(), - account_id: None, - }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), - }, - auth_dot_json - ) - } - - /// If the OPENAI_API_KEY is set in auth.json and it is an enterprise - /// account, then it should use [`AuthMode::ApiKey`]. - #[tokio::test] - async fn enterprise_account_with_api_key_uses_apikey_auth() { - let codex_home = tempdir().unwrap(); - write_auth_file( - AuthFileParams { - openai_api_key: Some("sk-test-key".to_string()), - chatgpt_plan_type: "enterprise".to_string(), - }, - codex_home.path(), - ) - .expect("failed to write auth file"); - - let CodexAuth { - api_key, - mode, - auth_dot_json, - auth_file: _, - .. - } = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); - assert_eq!(Some("sk-test-key".to_string()), api_key); - assert_eq!(AuthMode::ApiKey, mode); - - let guard = auth_dot_json.lock().expect("should unwrap"); - assert!(guard.is_none(), "auth_dot_json should be None"); - } - #[tokio::test] async fn loads_api_key_from_auth_json() { let dir = tempdir().unwrap(); @@ -591,9 +465,7 @@ mod tests { ) .unwrap(); - let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT) - .unwrap() - .unwrap(); + let auth = super::load_auth(dir.path()).unwrap().unwrap(); assert_eq!(auth.mode, AuthMode::ApiKey); assert_eq!(auth.api_key, Some("sk-test-key".to_string())); @@ -683,26 +555,17 @@ impl AuthManager { /// preferred auth method. Errors loading auth are swallowed; `auth()` will /// simply return `None` in that case so callers can treat it as an /// unauthenticated state. - pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self { - let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode) - .ok() - .flatten(); + pub fn new(codex_home: PathBuf) -> Self { + let auth = CodexAuth::from_codex_home(&codex_home).ok().flatten(); Self { codex_home, - inner: RwLock::new(CachedAuth { - preferred_auth_mode, - auth, - }), + inner: RwLock::new(CachedAuth { auth }), } } /// Create an AuthManager with a specific CodexAuth, for testing only. pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { - let preferred_auth_mode = auth.mode; - let cached = CachedAuth { - preferred_auth_mode, - auth: Some(auth), - }; + let cached = CachedAuth { auth: Some(auth) }; Arc::new(Self { codex_home: PathBuf::new(), inner: RwLock::new(cached), @@ -714,21 +577,10 @@ impl AuthManager { self.inner.read().ok().and_then(|c| c.auth.clone()) } - /// Preferred auth method used when (re)loading. - pub fn preferred_auth_method(&self) -> AuthMode { - self.inner - .read() - .map(|c| c.preferred_auth_mode) - .unwrap_or(AuthMode::ApiKey) - } - - /// Force a reload using the existing preferred auth method. Returns + /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub fn reload(&self) -> bool { - let preferred = self.preferred_auth_method(); - let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred) - .ok() - .flatten(); + let new_auth = CodexAuth::from_codex_home(&self.codex_home).ok().flatten(); if let Ok(mut guard) = self.inner.write() { let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); guard.auth = new_auth; @@ -747,8 +599,8 @@ impl AuthManager { } /// Convenience constructor returning an `Arc` wrapper. - pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc { - Arc::new(Self::new(codex_home, preferred_auth_mode)) + pub fn shared(codex_home: PathBuf) -> Arc { + Arc::new(Self::new(codex_home)) } /// Attempt to refresh the current auth token (if any). On success, reload diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 316f7276c..b4f96b5ec 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -19,7 +19,6 @@ use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; -use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::Tools; use codex_protocol::mcp_protocol::UserSavedConfig; use dirs::home_dir; @@ -167,9 +166,6 @@ pub struct Config { pub tools_web_search_request: bool, - /// If set to `true`, the API key will be signed with the `originator` header. - pub preferred_auth_method: AuthMode, - pub use_experimental_streamable_shell_tool: bool, /// If set to `true`, used only the experimental unified exec tool. @@ -494,9 +490,6 @@ pub struct ConfigToml { pub projects: Option>, - /// If set to `true`, the API key will be signed with the `originator` header. - pub preferred_auth_method: Option, - /// Nested tools section for feature toggles pub tools: Option, @@ -837,7 +830,6 @@ impl Config { include_plan_tool: include_plan_tool.unwrap_or(false), include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false), tools_web_search_request, - preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT), use_experimental_streamable_shell_tool: cfg .experimental_use_exec_command_tool .unwrap_or(false), @@ -1217,7 +1209,6 @@ model_verbosity = "high" include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, - preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: true, include_view_image_tool: true, @@ -1275,7 +1266,6 @@ model_verbosity = "high" include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, - preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: true, include_view_image_tool: true, @@ -1348,7 +1338,6 @@ model_verbosity = "high" include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, - preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: true, include_view_image_tool: true, @@ -1407,7 +1396,6 @@ model_verbosity = "high" include_plan_tool: false, include_apply_patch_tool: false, tools_web_search_request: false, - preferred_auth_method: AuthMode::ChatGPT, use_experimental_streamable_shell_tool: false, use_experimental_unified_exec_tool: true, include_view_image_tool: true, diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index c7da1194e..7fca131cd 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -80,7 +80,10 @@ pub struct ModelProviderInfo { /// the connection as lost. pub stream_idle_timeout_ms: Option, - /// Whether this provider requires some form of standard authentication (API key, ChatGPT token). + /// Does this provider require an OpenAI API Key or ChatGPT login token? If true, + /// user is presented with login screen on first run, and login preference and token/key + /// are stored in auth.json. If false (which is the default), login screen is skipped, + /// and API key (if needed) comes from the "env_key" environment variable. #[serde(default)] pub requires_openai_auth: bool, } diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 626b03054..2c4f859c6 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -3,8 +3,6 @@ use serde::Deserialize; use serde::Serialize; use thiserror::Error; -use codex_protocol::mcp_protocol::AuthMode; - #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] pub struct TokenData { /// Flat info parsed from the JWT in auth.json. @@ -22,36 +20,6 @@ pub struct TokenData { pub account_id: Option, } -impl TokenData { - /// Returns true if this is a plan that should use the traditional - /// "metered" billing via an API key. - pub(crate) fn should_use_api_key( - &self, - preferred_auth_method: AuthMode, - is_openai_email: bool, - ) -> bool { - if preferred_auth_method == AuthMode::ApiKey { - return true; - } - // If the email is an OpenAI email, use AuthMode::ChatGPT unless preferred_auth_method is AuthMode::ApiKey. - if is_openai_email { - return false; - } - - self.id_token - .chatgpt_plan_type - .as_ref() - .is_none_or(|plan| plan.is_plan_that_should_use_api_key()) - } - - pub fn is_openai_email(&self) -> bool { - self.id_token - .email - .as_deref() - .is_some_and(|email| email.trim().to_ascii_lowercase().ends_with("@openai.com")) - } -} - /// Flat subset of useful claims in id_token from auth.json. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct IdTokenInfo { @@ -80,19 +48,6 @@ pub(crate) enum PlanType { } impl PlanType { - fn is_plan_that_should_use_api_key(&self) -> bool { - match self { - Self::Known(known) => { - use KnownPlan::*; - !matches!(known, Free | Plus | Pro | Team) - } - Self::Unknown(_) => { - // Unknown plans should use the API key. - true - } - } - } - pub fn as_string(&self) -> String { match self { Self::Known(known) => format!("{known:?}").to_lowercase(), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 32466295e..992679df7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -8,7 +8,6 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_protocol::mcp_protocol::AuthMode; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; @@ -489,79 +488,6 @@ async fn chatgpt_auth_sends_correct_request() { ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn prefers_chatgpt_token_when_config_prefers_chatgpt() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } - - // Mock server - let server = MockServer::start().await; - - let first = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(sse_completed("resp1"), "text/event-stream"); - - // Expect ChatGPT base path and correct headers - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(header_regex("Authorization", r"Bearer Access-123")) - .and(header_regex("chatgpt-account-id", r"acc-123")) - .respond_with(first) - .expect(1) - .mount(&server) - .await; - - let model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers()["openai"].clone() - }; - - // Init session - let codex_home = TempDir::new().unwrap(); - // Write auth.json that contains both API key and ChatGPT tokens for a plan that should prefer ChatGPT. - let _jwt = write_auth_json( - &codex_home, - Some("sk-test-key"), - "pro", - "Access-123", - Some("acc-123"), - ); - - let mut config = load_default_config_for_test(&codex_home); - config.model_provider = model_provider; - config.preferred_auth_method = AuthMode::ChatGPT; - - let auth_manager = - match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), - Ok(None) => panic!("No CodexAuth found in codex_home"), - Err(e) => panic!("Failed to load CodexAuth: {e}"), - }; - let conversation_manager = ConversationManager::new(auth_manager); - let NewConversation { - conversation: codex, - .. - } = conversation_manager - .new_conversation(config) - .await - .expect("create new conversation"); - - codex - .submit(Op::UserInput { - items: vec![InputItem::Text { - text: "hello".into(), - }], - }) - .await - .unwrap(); - - wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { @@ -606,14 +532,12 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - config.preferred_auth_method = AuthMode::ApiKey; - let auth_manager = - match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), - Ok(None) => panic!("No CodexAuth found in codex_home"), - Err(e) => panic!("Failed to load CodexAuth: {e}"), - }; + let auth_manager = match CodexAuth::from_codex_home(codex_home.path()) { + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), + Ok(None) => panic!("No CodexAuth found in codex_home"), + Err(e) => panic!("Failed to load CodexAuth: {e}"), + }; let conversation_manager = ConversationManager::new(auth_manager); let NewConversation { conversation: codex, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 66d0c0906..40b30f2bb 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -187,10 +187,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any std::process::exit(1); } - let conversation_manager = ConversationManager::new(AuthManager::shared( - config.codex_home.clone(), - config.preferred_auth_method, - )); + let conversation_manager = + ConversationManager::new(AuthManager::shared(config.codex_home.clone())); let NewConversation { conversation_id: _, conversation, diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index e2ff119f2..858bb99c2 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -12,6 +12,7 @@ use codex_core::RolloutRecorder; use codex_core::SessionMeta; use codex_core::auth::CLIENT_ID; use codex_core::auth::get_auth_file; +use codex_core::auth::login_with_api_key; use codex_core::auth::try_read_auth_json; use codex_core::config::Config; use codex_core::config::ConfigOverrides; @@ -39,7 +40,6 @@ use codex_protocol::mcp_protocol::ApplyPatchApprovalParams; use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse; use codex_protocol::mcp_protocol::ArchiveConversationParams; use codex_protocol::mcp_protocol::ArchiveConversationResponse; -use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::AuthStatusChangeNotification; use codex_protocol::mcp_protocol::ClientRequest; use codex_protocol::mcp_protocol::ConversationId; @@ -57,6 +57,8 @@ use codex_protocol::mcp_protocol::InterruptConversationParams; use codex_protocol::mcp_protocol::InterruptConversationResponse; use codex_protocol::mcp_protocol::ListConversationsParams; use codex_protocol::mcp_protocol::ListConversationsResponse; +use codex_protocol::mcp_protocol::LoginApiKeyParams; +use codex_protocol::mcp_protocol::LoginApiKeyResponse; use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; use codex_protocol::mcp_protocol::LoginChatGptResponse; use codex_protocol::mcp_protocol::NewConversationParams; @@ -172,6 +174,9 @@ impl CodexMessageProcessor { ClientRequest::GitDiffToRemote { request_id, params } => { self.git_diff_to_origin(request_id, params.cwd).await; } + ClientRequest::LoginApiKey { request_id, params } => { + self.login_api_key(request_id, params).await; + } ClientRequest::LoginChatGpt { request_id } => { self.login_chatgpt(request_id).await; } @@ -199,6 +204,39 @@ impl CodexMessageProcessor { } } + async fn login_api_key(&mut self, request_id: RequestId, params: LoginApiKeyParams) { + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + active.drop(); + } + } + + match login_with_api_key(&self.config.codex_home, ¶ms.api_key) { + Ok(()) => { + self.auth_manager.reload(); + self.outgoing + .send_response(request_id, LoginApiKeyResponse {}) + .await; + + let payload = AuthStatusChangeNotification { + auth_method: self.auth_manager.auth().map(|auth| auth.mode), + }; + self.outgoing + .send_server_notification(ServerNotification::AuthStatusChange(payload)) + .await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to save api key: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn login_chatgpt(&mut self, request_id: RequestId) { let config = self.config.as_ref(); @@ -352,7 +390,7 @@ impl CodexMessageProcessor { .await; // Send auth status change notification reflecting the current auth mode - // after logout (which may fall back to API key via env var). + // after logout. let current_auth_method = self.auth_manager.auth().map(|auth| auth.mode); let payload = AuthStatusChangeNotification { auth_method: current_auth_method, @@ -367,7 +405,6 @@ impl CodexMessageProcessor { request_id: RequestId, params: codex_protocol::mcp_protocol::GetAuthStatusParams, ) { - let preferred_auth_method: AuthMode = self.auth_manager.preferred_auth_method(); let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); @@ -375,6 +412,11 @@ impl CodexMessageProcessor { tracing::warn!("failed to refresh token while getting auth status: {err}"); } + // Determine whether auth is required based on the active model provider. + // If a custom provider is configured with `requires_openai_auth == false`, + // then no auth step is required; otherwise, default to requiring auth. + let requires_openai_auth = Some(self.config.model_provider.requires_openai_auth); + let response = match self.auth_manager.auth() { Some(auth) => { let (reported_auth_method, token_opt) = match auth.get_token().await { @@ -390,14 +432,14 @@ impl CodexMessageProcessor { }; codex_protocol::mcp_protocol::GetAuthStatusResponse { auth_method: reported_auth_method, - preferred_auth_method, auth_token: token_opt, + requires_openai_auth, } } None => codex_protocol::mcp_protocol::GetAuthStatusResponse { auth_method: None, - preferred_auth_method, auth_token: None, + requires_openai_auth, }, }; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 83e3d1cb7..a108b7555 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -56,8 +56,7 @@ impl MessageProcessor { config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = - AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method); + let auth_manager = AuthManager::shared(config.codex_home.clone()); let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); let codex_message_processor = CodexMessageProcessor::new( auth_manager, diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index ec7953ded..a7969f952 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -18,6 +18,7 @@ use codex_protocol::mcp_protocol::CancelLoginChatGptParams; use codex_protocol::mcp_protocol::GetAuthStatusParams; use codex_protocol::mcp_protocol::InterruptConversationParams; use codex_protocol::mcp_protocol::ListConversationsParams; +use codex_protocol::mcp_protocol::LoginApiKeyParams; use codex_protocol::mcp_protocol::NewConversationParams; use codex_protocol::mcp_protocol::RemoveConversationListenerParams; use codex_protocol::mcp_protocol::ResumeConversationParams; @@ -318,6 +319,15 @@ impl McpProcess { self.send_request("resumeConversation", params).await } + /// Send a `loginApiKey` JSON-RPC request. + pub async fn send_login_api_key_request( + &mut self, + params: LoginApiKeyParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("loginApiKey", params).await + } + /// Send a `loginChatGpt` JSON-RPC request. pub async fn send_login_chat_gpt_request(&mut self) -> anyhow::Result { self.send_request("loginChatGpt", None).await diff --git a/codex-rs/mcp-server/tests/suite/auth.rs b/codex-rs/mcp-server/tests/suite/auth.rs index 415392780..a3ccd3394 100644 --- a/codex-rs/mcp-server/tests/suite/auth.rs +++ b/codex-rs/mcp-server/tests/suite/auth.rs @@ -1,9 +1,10 @@ use std::path::Path; -use codex_core::auth::login_with_api_key; use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GetAuthStatusParams; use codex_protocol::mcp_protocol::GetAuthStatusResponse; +use codex_protocol::mcp_protocol::LoginApiKeyParams; +use codex_protocol::mcp_protocol::LoginApiKeyResponse; use mcp_test_support::McpProcess; use mcp_test_support::to_response; use mcp_types::JSONRPCResponse; @@ -36,10 +37,29 @@ stream_max_retries = 0 ) } +async fn login_with_api_key_via_request(mcp: &mut McpProcess, api_key: &str) { + let request_id = mcp + .send_login_api_key_request(LoginApiKeyParams { + api_key: api_key.to_string(), + }) + .await + .unwrap_or_else(|e| panic!("send loginApiKey: {e}")); + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await + .unwrap_or_else(|e| panic!("loginApiKey timeout: {e}")) + .unwrap_or_else(|e| panic!("loginApiKey response: {e}")); + let _: LoginApiKeyResponse = + to_response(resp).unwrap_or_else(|e| panic!("deserialize login response: {e}")); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_auth_status_no_auth() { let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).expect("write config.toml"); + create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}")); let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]) .await @@ -72,8 +92,7 @@ async fn get_auth_status_no_auth() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_auth_status_with_api_key() { let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).expect("write config.toml"); - login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key"); + create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}")); let mut mcp = McpProcess::new(codex_home.path()) .await @@ -83,6 +102,8 @@ async fn get_auth_status_with_api_key() { .expect("init timeout") .expect("init failed"); + login_with_api_key_via_request(&mut mcp, "sk-test-key").await; + let request_id = mcp .send_get_auth_status_request(GetAuthStatusParams { include_token: Some(true), @@ -101,14 +122,12 @@ async fn get_auth_status_with_api_key() { let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status"); assert_eq!(status.auth_method, Some(AuthMode::ApiKey)); assert_eq!(status.auth_token, Some("sk-test-key".to_string())); - assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_auth_status_with_api_key_no_include_token() { let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).expect("write config.toml"); - login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key"); + create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}")); let mut mcp = McpProcess::new(codex_home.path()) .await @@ -118,6 +137,8 @@ async fn get_auth_status_with_api_key_no_include_token() { .expect("init timeout") .expect("init failed"); + login_with_api_key_via_request(&mut mcp, "sk-test-key").await; + // Build params via struct so None field is omitted in wire JSON. let params = GetAuthStatusParams { include_token: None, @@ -138,5 +159,4 @@ async fn get_auth_status_with_api_key_no_include_token() { let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status"); assert_eq!(status.auth_method, Some(AuthMode::ApiKey)); assert!(status.auth_token.is_none(), "token must be omitted"); - assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT); } diff --git a/codex-rs/mcp-server/tests/suite/login.rs b/codex-rs/mcp-server/tests/suite/login.rs index bbc055877..071154c64 100644 --- a/codex-rs/mcp-server/tests/suite/login.rs +++ b/codex-rs/mcp-server/tests/suite/login.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::time::Duration; -use codex_core::auth::login_with_api_key; +use codex_login::login_with_api_key; use codex_protocol::mcp_protocol::CancelLoginChatGptParams; use codex_protocol::mcp_protocol::CancelLoginChatGptResponse; use codex_protocol::mcp_protocol::GetAuthStatusParams; @@ -95,7 +95,7 @@ async fn logout_chatgpt_removes_auth() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn login_and_cancel_chatgpt() { let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).expect("write config.toml"); + create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}")); let mut mcp = McpProcess::new(codex_home.path()) .await diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index 1a1d53561..6fda7074f 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -31,6 +31,8 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::LoginApiKeyParams::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::LoginApiKeyResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?; diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index e003abea4..26887aa59 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -126,6 +126,11 @@ pub enum ClientRequest { request_id: RequestId, params: GitDiffToRemoteParams, }, + LoginApiKey { + #[serde(rename = "id")] + request_id: RequestId, + params: LoginApiKeyParams, + }, LoginChatGpt { #[serde(rename = "id")] request_id: RequestId, @@ -288,6 +293,16 @@ pub struct ArchiveConversationResponse {} #[serde(rename_all = "camelCase")] pub struct RemoveConversationSubscriptionResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct LoginApiKeyParams { + pub api_key: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct LoginApiKeyResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct LoginChatGptResponse { @@ -367,9 +382,14 @@ pub struct ExecArbitraryCommandResponse { pub struct GetAuthStatusResponse { #[serde(skip_serializing_if = "Option::is_none")] pub auth_method: Option, - pub preferred_auth_method: AuthMode, #[serde(skip_serializing_if = "Option::is_none")] pub auth_token: Option, + + // Indicates that auth method must be valid to use the server. + // This can be false if using a custom provider that is configured + // with requires_openai_auth == false. + #[serde(skip_serializing_if = "Option::is_none")] + pub requires_openai_auth: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 710312ceb..9a5851274 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -308,7 +308,7 @@ async fn run_ratatui_app( .. } = cli; - let auth_manager = AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method); + let auth_manager = AuthManager::shared(config.codex_home.clone()); let login_status = get_login_status(&config); let should_show_onboarding = should_show_onboarding(login_status, &config, should_show_trust_screen); @@ -392,7 +392,7 @@ fn get_login_status(config: &Config) -> LoginStatus { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); - match CodexAuth::from_codex_home(&codex_home, config.preferred_auth_method) { + match CodexAuth::from_codex_home(&codex_home) { Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), Ok(None) => LoginStatus::NotAuthenticated, Err(err) => { @@ -460,60 +460,28 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool return false; } - match login_status { - LoginStatus::NotAuthenticated => true, - LoginStatus::AuthMode(method) => method != config.preferred_auth_method, - } + login_status == LoginStatus::NotAuthenticated } #[cfg(test)] mod tests { use super::*; - fn make_config(preferred: AuthMode) -> Config { - let mut cfg = Config::load_from_base_config_with_overrides( + fn make_config() -> Config { + Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), std::env::temp_dir(), ) - .expect("load default config"); - cfg.preferred_auth_method = preferred; - cfg + .expect("load default config") } #[test] fn shows_login_when_not_authenticated() { - let cfg = make_config(AuthMode::ChatGPT); + let cfg = make_config(); assert!(should_show_login_screen( LoginStatus::NotAuthenticated, &cfg )); } - - #[test] - fn shows_login_when_api_key_but_prefers_chatgpt() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), - &cfg - )) - } - - #[test] - fn hides_login_when_api_key_and_prefers_api_key() { - let cfg = make_config(AuthMode::ApiKey); - assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), - &cfg - )) - } - - #[test] - fn hides_login_when_chatgpt_and_prefers_chatgpt() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ChatGPT), - &cfg - )) - } } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index c7217ac1a..97a6b7c21 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -2,12 +2,17 @@ use codex_core::AuthManager; use codex_core::auth::CLIENT_ID; +use codex_core::auth::login_with_api_key; +use codex_core::auth::read_openai_api_key_from_env; use codex_login::ServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::prelude::Widget; use ratatui::style::Color; @@ -15,6 +20,9 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::BorderType; +use ratatui::widgets::Borders; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; @@ -38,8 +46,14 @@ pub(crate) enum SignInState { ChatGptContinueInBrowser(ContinueInBrowserState), ChatGptSuccessMessage, ChatGptSuccess, - EnvVarMissing, - EnvVarFound, + ApiKeyEntry(ApiKeyInputState), + ApiKeyConfigured, +} + +#[derive(Clone, Default)] +pub(crate) struct ApiKeyInputState { + value: String, + prepopulated_from_env: bool, } #[derive(Clone)] @@ -59,6 +73,10 @@ impl Drop for ContinueInBrowserState { impl KeyboardHandler for AuthModeWidget { fn handle_key_event(&mut self, key_event: KeyEvent) { + if self.handle_api_key_entry_key_event(&key_event) { + return; + } + match key_event.code { KeyCode::Up | KeyCode::Char('k') => { self.highlighted_mode = AuthMode::ChatGPT; @@ -69,7 +87,7 @@ impl KeyboardHandler for AuthModeWidget { KeyCode::Char('1') => { self.start_chatgpt_login(); } - KeyCode::Char('2') => self.verify_api_key(), + KeyCode::Char('2') => self.start_api_key_entry(), KeyCode::Enter => { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { @@ -78,12 +96,9 @@ impl KeyboardHandler for AuthModeWidget { self.start_chatgpt_login(); } AuthMode::ApiKey => { - self.verify_api_key(); + self.start_api_key_entry(); } }, - SignInState::EnvVarMissing => { - *self.sign_in_state.write().unwrap() = SignInState::PickMode; - } SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } @@ -101,6 +116,10 @@ impl KeyboardHandler for AuthModeWidget { _ => {} } } + + fn handle_paste(&mut self, pasted: String) { + let _ = self.handle_api_key_entry_paste(pasted); + } } #[derive(Clone)] @@ -111,7 +130,6 @@ pub(crate) struct AuthModeWidget { pub sign_in_state: Arc>, pub codex_home: PathBuf, pub login_status: LoginStatus, - pub preferred_auth_method: AuthMode, pub auth_manager: Arc, } @@ -129,24 +147,6 @@ impl AuthModeWidget { "".into(), ]; - // If the user is already authenticated but the method differs from their - // preferred auth method, show a brief explanation. - if let LoginStatus::AuthMode(current) = self.login_status - && current != self.preferred_auth_method - { - let to_label = |mode: AuthMode| match mode { - AuthMode::ApiKey => "API key", - AuthMode::ChatGPT => "ChatGPT", - }; - let msg = format!( - " You’re currently using {} while your preferred method is {}.", - to_label(current), - to_label(self.preferred_auth_method) - ); - lines.push(msg.into()); - lines.push("".into()); - } - let create_mode_item = |idx: usize, selected_mode: AuthMode, text: &str, @@ -175,29 +175,17 @@ impl AuthModeWidget { vec![line1, line2] }; - let chatgpt_label = if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) - { - "Continue using ChatGPT" - } else { - "Sign in with ChatGPT" - }; lines.extend(create_mode_item( 0, AuthMode::ChatGPT, - chatgpt_label, + "Sign in with ChatGPT", "Usage included with Plus, Pro, and Team plans", )); - let api_key_label = if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) - { - "Continue using API key" - } else { - "Provide your own API key" - }; lines.extend(create_mode_item( 1, AuthMode::ApiKey, - api_key_label, + "Provide your own API key", "Pay for what you use", )); lines.push("".into()); @@ -282,26 +270,213 @@ impl AuthModeWidget { .render(area, buf); } - fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) { - let lines = vec!["✓ Using OPENAI_API_KEY".fg(Color::Green).into()]; + fn render_api_key_configured(&self, area: Rect, buf: &mut Buffer) { + let lines = vec![ + "✓ API key configured".fg(Color::Green).into(), + "".into(), + " Codex will use usage-based billing with your API key.".into(), + ]; Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); } - fn render_env_var_missing(&self, area: Rect, buf: &mut Buffer) { - let lines = vec![ - " To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment" - .fg(Color::Cyan) - .into(), - "".into(), - " Press Enter to return".dim().into(), - ]; + fn render_api_key_entry(&self, area: Rect, buf: &mut Buffer, state: &ApiKeyInputState) { + let [intro_area, input_area, footer_area] = Layout::vertical([ + Constraint::Min(4), + Constraint::Length(3), + Constraint::Min(2), + ]) + .areas(area); - Paragraph::new(lines) + let mut intro_lines: Vec = vec![ + Line::from(vec![ + "> ".into(), + "Use your own OpenAI API key for usage-based billing".bold(), + ]), + "".into(), + " Paste or type your API key below. It will be stored locally in auth.json.".into(), + "".into(), + ]; + if state.prepopulated_from_env { + intro_lines.push(" Detected OPENAI_API_KEY environment variable.".into()); + intro_lines.push( + " Paste a different key if you prefer to use another account." + .dim() + .into(), + ); + intro_lines.push("".into()); + } + Paragraph::new(intro_lines) .wrap(Wrap { trim: false }) - .render(area, buf); + .render(intro_area, buf); + + let content_line: Line = if state.value.is_empty() { + vec!["Paste or type your API key".dim()].into() + } else { + Line::from(state.value.clone()) + }; + Paragraph::new(content_line) + .wrap(Wrap { trim: false }) + .block( + Block::default() + .title("API key") + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(Color::Cyan)), + ) + .render(input_area, buf); + + let mut footer_lines: Vec = vec![ + " Press Enter to save".dim().into(), + " Press Esc to go back".dim().into(), + ]; + if let Some(error) = &self.error { + footer_lines.push("".into()); + footer_lines.push(error.as_str().red().into()); + } + Paragraph::new(footer_lines) + .wrap(Wrap { trim: false }) + .render(footer_area, buf); + } + + fn handle_api_key_entry_key_event(&mut self, key_event: &KeyEvent) -> bool { + let mut should_save: Option = None; + let mut should_request_frame = false; + + { + let mut guard = self.sign_in_state.write().unwrap(); + if let SignInState::ApiKeyEntry(state) = &mut *guard { + match key_event.code { + KeyCode::Esc => { + *guard = SignInState::PickMode; + self.error = None; + should_request_frame = true; + } + KeyCode::Enter => { + let trimmed = state.value.trim().to_string(); + if trimmed.is_empty() { + self.error = Some("API key cannot be empty".to_string()); + should_request_frame = true; + } else { + should_save = Some(trimmed); + } + } + KeyCode::Backspace => { + if state.prepopulated_from_env { + state.value.clear(); + state.prepopulated_from_env = false; + } else { + state.value.pop(); + } + self.error = None; + should_request_frame = true; + } + KeyCode::Char(c) + if !key_event.modifiers.contains(KeyModifiers::CONTROL) + && !key_event.modifiers.contains(KeyModifiers::ALT) => + { + if state.prepopulated_from_env { + state.value.clear(); + state.prepopulated_from_env = false; + } + state.value.push(c); + self.error = None; + should_request_frame = true; + } + _ => {} + } + // handled; let guard drop before potential save + } else { + return false; + } + } + + if let Some(api_key) = should_save { + self.save_api_key(api_key); + } else if should_request_frame { + self.request_frame.schedule_frame(); + } + true + } + + fn handle_api_key_entry_paste(&mut self, pasted: String) -> bool { + let trimmed = pasted.trim(); + if trimmed.is_empty() { + return false; + } + + let mut guard = self.sign_in_state.write().unwrap(); + if let SignInState::ApiKeyEntry(state) = &mut *guard { + if state.prepopulated_from_env { + state.value = trimmed.to_string(); + state.prepopulated_from_env = false; + } else { + state.value.push_str(trimmed); + } + self.error = None; + } else { + return false; + } + + drop(guard); + self.request_frame.schedule_frame(); + true + } + + fn start_api_key_entry(&mut self) { + self.error = None; + let prefill_from_env = read_openai_api_key_from_env(); + let mut guard = self.sign_in_state.write().unwrap(); + match &mut *guard { + SignInState::ApiKeyEntry(state) => { + if state.value.is_empty() { + if let Some(prefill) = prefill_from_env.clone() { + state.value = prefill; + state.prepopulated_from_env = true; + } else { + state.prepopulated_from_env = false; + } + } + } + _ => { + *guard = SignInState::ApiKeyEntry(ApiKeyInputState { + value: prefill_from_env.clone().unwrap_or_default(), + prepopulated_from_env: prefill_from_env.is_some(), + }); + } + } + drop(guard); + self.request_frame.schedule_frame(); + } + + fn save_api_key(&mut self, api_key: String) { + match login_with_api_key(&self.codex_home, &api_key) { + Ok(()) => { + self.error = None; + self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey); + self.auth_manager.reload(); + *self.sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured; + } + Err(err) => { + self.error = Some(format!("Failed to save API key: {err}")); + let mut guard = self.sign_in_state.write().unwrap(); + if let SignInState::ApiKeyEntry(existing) = &mut *guard { + if existing.value.is_empty() { + existing.value.push_str(&api_key); + } + existing.prepopulated_from_env = false; + } else { + *guard = SignInState::ApiKeyEntry(ApiKeyInputState { + value: api_key, + prepopulated_from_env: false, + }); + } + } + } + + self.request_frame.schedule_frame(); } fn start_chatgpt_login(&mut self) { @@ -354,18 +529,6 @@ impl AuthModeWidget { } } } - - /// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY. - fn verify_api_key(&mut self) { - if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) { - // We already have an API key configured (e.g., from auth.json or env), - // so mark this step complete immediately. - *self.sign_in_state.write().unwrap() = SignInState::EnvVarFound; - } else { - *self.sign_in_state.write().unwrap() = SignInState::EnvVarMissing; - } - self.request_frame.schedule_frame(); - } } impl StepStateProvider for AuthModeWidget { @@ -373,10 +536,10 @@ impl StepStateProvider for AuthModeWidget { let sign_in_state = self.sign_in_state.read().unwrap(); match &*sign_in_state { SignInState::PickMode - | SignInState::EnvVarMissing + | SignInState::ApiKeyEntry(_) | SignInState::ChatGptContinueInBrowser(_) | SignInState::ChatGptSuccessMessage => StepState::InProgress, - SignInState::ChatGptSuccess | SignInState::EnvVarFound => StepState::Complete, + SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete, } } } @@ -397,11 +560,11 @@ impl WidgetRef for AuthModeWidget { SignInState::ChatGptSuccess => { self.render_chatgpt_success(area, buf); } - SignInState::EnvVarMissing => { - self.render_env_var_missing(area, buf); + SignInState::ApiKeyEntry(state) => { + self.render_api_key_entry(area, buf, state); } - SignInState::EnvVarFound => { - self.render_env_var_found(area, buf); + SignInState::ApiKeyConfigured => { + self.render_api_key_configured(area, buf); } } } diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index a43b4c0f9..29bf2e5bc 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -34,6 +34,7 @@ enum Step { pub(crate) trait KeyboardHandler { fn handle_key_event(&mut self, key_event: KeyEvent); + fn handle_paste(&mut self, _pasted: String) {} } pub(crate) enum StepState { @@ -69,7 +70,6 @@ impl OnboardingScreen { auth_manager, config, } = args; - let preferred_auth_method = config.preferred_auth_method; let cwd = config.cwd.clone(); let codex_home = config.codex_home.clone(); let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { @@ -84,7 +84,6 @@ impl OnboardingScreen { codex_home: codex_home.clone(), login_status, auth_manager, - preferred_auth_method, })) } let is_git_repo = get_git_repo_root(&cwd).is_some(); @@ -194,6 +193,17 @@ impl KeyboardHandler for OnboardingScreen { }; self.request_frame.schedule_frame(); } + + fn handle_paste(&mut self, pasted: String) { + if pasted.is_empty() { + return; + } + + if let Some(active_step) = self.current_steps_mut().into_iter().last() { + active_step.handle_paste(pasted); + } + self.request_frame.schedule_frame(); + } } impl WidgetRef for &OnboardingScreen { @@ -263,6 +273,14 @@ impl KeyboardHandler for Step { Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } } + + fn handle_paste(&mut self, pasted: String) { + match self { + Step::Welcome(_) => {} + Step::Auth(widget) => widget.handle_paste(pasted), + Step::TrustDirectory(widget) => widget.handle_paste(pasted), + } + } } impl StepStateProvider for Step { @@ -312,12 +330,14 @@ pub(crate) async fn run_onboarding_app( TuiEvent::Key(key_event) => { onboarding_screen.handle_key_event(key_event); } + TuiEvent::Paste(text) => { + onboarding_screen.handle_paste(text); + } TuiEvent::Draw => { let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&onboarding_screen, frame.area()); }); } - _ => {} } } } diff --git a/docs/advanced.md b/docs/advanced.md index 42bbcc338..a08b35c11 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -8,7 +8,7 @@ Run Codex head-less in pipelines. Example GitHub Action step: - name: Update changelog via Codex run: | npm install -g @openai/codex - export OPENAI_API_KEY="${{ secrets.OPENAI_KEY }}" + codex login --api-key "${{ secrets.OPENAI_KEY }}" codex exec --full-auto "update CHANGELOG for next release" ``` diff --git a/docs/authentication.md b/docs/authentication.md index 5eb520409..0db35489b 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -2,10 +2,10 @@ ## Usage-based billing alternative: Use an OpenAI API key -If you prefer to pay-as-you-go, you can still authenticate with your OpenAI API key by setting it as an environment variable: +If you prefer to pay-as-you-go, you can still authenticate with your OpenAI API key: ```shell -export OPENAI_API_KEY="your-api-key-here" +codex login --api-key "your-api-key-here" ``` This key must, at minimum, have write access to the Responses API. @@ -18,36 +18,6 @@ If you've used the Codex CLI before with usage-based billing via an API key and 2. Delete `~/.codex/auth.json` (on Windows: `C:\\Users\\USERNAME\\.codex\\auth.json`) 3. Run `codex login` again -## Forcing a specific auth method (advanced) - -You can explicitly choose which authentication Codex should prefer when both are available. - -- To always use your API key (even when ChatGPT auth exists), set: - -```toml -# ~/.codex/config.toml -preferred_auth_method = "apikey" -``` - -Or override ad-hoc via CLI: - -```bash -codex --config preferred_auth_method="apikey" -``` - -- To prefer ChatGPT auth (default), set: - -```toml -# ~/.codex/config.toml -preferred_auth_method = "chatgpt" -``` - -Notes: - -- When `preferred_auth_method = "apikey"` and an API key is available, the login screen is skipped. -- When `preferred_auth_method = "chatgpt"` (default), Codex prefers ChatGPT auth if present; if only an API key is present, it will use the API key. Certain account types may also require API-key mode. -- To check which auth method is being used during a session, use the `/status` command in the TUI. - ## Connecting on a "Headless" Machine Today, the login process entails running a server on `localhost:1455`. If you are on a "headless" server, such as a Docker container or are `ssh`'d into a remote machine, loading `localhost:1455` in the browser on your local machine will not automatically connect to the webserver running on the _headless_ machine, so you must use one of the following workarounds: diff --git a/docs/config.md b/docs/config.md index aebdf9ce3..efd152aa4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -612,5 +612,4 @@ Options that are specific to the TUI. | `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | | `responses_originator_header_internal_override` | string | Override `originator` header value. | | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | -| `preferred_auth_method` | `chatgpt` \| `apikey` | Select default auth method (default: `chatgpt`). | | `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). |