diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 1b9fcffb..9d8db2f9 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -34,7 +34,7 @@ impl MessageProcessor { config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = AuthManager::shared(config.codex_home.clone()); + let auth_manager = AuthManager::shared(config.codex_home.clone(), false); let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); let codex_message_processor = CodexMessageProcessor::new( auth_manager, diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index cea19b97..da2d4eb9 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -190,7 +190,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a // Require ChatGPT login (SWIC). Exit with a clear message if missing. let _token = match codex_core::config::find_codex_home() .ok() - .map(codex_login::AuthManager::new) + .map(|home| codex_login::AuthManager::new(home, false)) .and_then(|am| am.auth()) { Some(auth) => { diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 9a610da2..8003a02f 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -70,7 +70,7 @@ pub async fn build_chatgpt_headers() -> HeaderMap { HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), ); if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new(home); + let am = codex_login::AuthManager::new(home, false); if let Some(auth) = am.auth() && let Ok(tok) = auth.get_token().await && !tok.is_empty() diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index b6854061..4eea313e 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -73,7 +73,7 @@ impl CodexAuth { /// Loads the available auth information from the auth.json. pub fn from_codex_home(codex_home: &Path) -> std::io::Result> { - load_auth(codex_home) + load_auth(codex_home, false) } pub async fn get_token_data(&self) -> Result { @@ -188,6 +188,7 @@ impl CodexAuth { } pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; +pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY"; pub fn read_openai_api_key_from_env() -> Option { env::var(OPENAI_API_KEY_ENV_VAR) @@ -196,6 +197,13 @@ pub fn read_openai_api_key_from_env() -> Option { .filter(|value| !value.is_empty()) } +pub fn read_codex_api_key_from_env() -> Option { + env::var(CODEX_API_KEY_ENV_VAR) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + pub fn get_auth_file(codex_home: &Path) -> PathBuf { codex_home.join("auth.json") } @@ -221,7 +229,18 @@ 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) -> std::io::Result> { +fn load_auth( + codex_home: &Path, + enable_codex_api_key_env: bool, +) -> std::io::Result> { + if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { + let client = crate::default_client::create_client(); + return Ok(Some(CodexAuth::from_api_key_with_client( + api_key.as_str(), + client, + ))); + } + 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) { @@ -455,7 +474,7 @@ mod tests { auth_dot_json, auth_file: _, .. - } = super::load_auth(codex_home.path()).unwrap().unwrap(); + } = super::load_auth(codex_home.path(), false).unwrap().unwrap(); assert_eq!(None, api_key); assert_eq!(AuthMode::ChatGPT, mode); @@ -494,7 +513,7 @@ mod tests { ) .unwrap(); - let auth = super::load_auth(dir.path()).unwrap().unwrap(); + let auth = super::load_auth(dir.path(), false).unwrap().unwrap(); assert_eq!(auth.mode, AuthMode::ApiKey); assert_eq!(auth.api_key, Some("sk-test-key".to_string())); @@ -577,6 +596,7 @@ mod tests { pub struct AuthManager { codex_home: PathBuf, inner: RwLock, + enable_codex_api_key_env: bool, } impl AuthManager { @@ -584,11 +604,14 @@ 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) -> Self { - let auth = CodexAuth::from_codex_home(&codex_home).ok().flatten(); + pub fn new(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Self { + let auth = load_auth(&codex_home, enable_codex_api_key_env) + .ok() + .flatten(); Self { codex_home, inner: RwLock::new(CachedAuth { auth }), + enable_codex_api_key_env, } } @@ -598,6 +621,7 @@ impl AuthManager { Arc::new(Self { codex_home: PathBuf::new(), inner: RwLock::new(cached), + enable_codex_api_key_env: false, }) } @@ -609,7 +633,9 @@ impl AuthManager { /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub fn reload(&self) -> bool { - let new_auth = CodexAuth::from_codex_home(&self.codex_home).ok().flatten(); + let new_auth = load_auth(&self.codex_home, self.enable_codex_api_key_env) + .ok() + .flatten(); if let Ok(mut guard) = self.inner.write() { let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); guard.auth = new_auth; @@ -628,8 +654,8 @@ impl AuthManager { } /// Convenience constructor returning an `Arc` wrapper. - pub fn shared(codex_home: PathBuf) -> Arc { - Arc::new(Self::new(codex_home)) + pub fn shared(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Arc { + Arc::new(Self::new(codex_home, enable_codex_api_key_env)) } /// Attempt to refresh the current auth token (if any). On success, reload diff --git a/codex-rs/core/tests/common/test_codex_exec.rs b/codex-rs/core/tests/common/test_codex_exec.rs index 6a4ce32b..478d0c57 100644 --- a/codex-rs/core/tests/common/test_codex_exec.rs +++ b/codex-rs/core/tests/common/test_codex_exec.rs @@ -1,4 +1,5 @@ #![allow(clippy::expect_used)] +use codex_core::auth::CODEX_API_KEY_ENV_VAR; use std::path::Path; use tempfile::TempDir; use wiremock::MockServer; @@ -14,7 +15,7 @@ impl TestCodexExecBuilder { .expect("should find binary for codex-exec"); cmd.current_dir(self.cwd.path()) .env("CODEX_HOME", self.home.path()) - .env("OPENAI_API_KEY", "dummy"); + .env(CODEX_API_KEY_ENV_VAR, "dummy"); cmd } pub fn cmd_with_server(&self, server: &MockServer) -> assert_cmd::Command { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d77f9685..0ec905de 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -236,8 +236,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())); + let auth_manager = AuthManager::shared(config.codex_home.clone(), true); + let conversation_manager = ConversationManager::new(auth_manager.clone()); // Handle resume subcommand by resolving a rollout path and using explicit resume API. let NewConversation { @@ -249,11 +249,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any if let Some(path) = resume_path { conversation_manager - .resume_conversation_from_rollout( - config.clone(), - path, - AuthManager::shared(config.codex_home.clone()), - ) + .resume_conversation_from_rollout(config.clone(), path, auth_manager.clone()) .await? } else { conversation_manager diff --git a/codex-rs/exec/tests/suite/auth_env.rs b/codex-rs/exec/tests/suite/auth_env.rs new file mode 100644 index 00000000..d59f46cd --- /dev/null +++ b/codex-rs/exec/tests/suite/auth_env.rs @@ -0,0 +1,34 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use core_test_support::responses::ev_completed; +use core_test_support::responses::sse; +use core_test_support::responses::sse_response; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex_exec::test_codex_exec; +use wiremock::Mock; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_uses_codex_api_key_env_var() -> anyhow::Result<()> { + let test = test_codex_exec(); + let server = start_mock_server().await; + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(header("Authorization", "Bearer dummy")) + .respond_with(sse_response(sse(vec![ev_completed("request_0")]))) + .expect(1) + .mount(&server) + .await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("-C") + .arg(env!("CARGO_MANIFEST_DIR")) + .arg("echo testing codex api key") + .assert() + .success(); + + Ok(()) +} diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 52f5bca3..d04ecd2c 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -1,5 +1,6 @@ // Aggregates all former standalone integration tests as modules. mod apply_patch; +mod auth_env; mod output_schema; mod resume; mod sandbox; diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 4c4d343e..16be995e 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -1,10 +1,9 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use anyhow::Context; -use assert_cmd::prelude::*; +use core_test_support::test_codex_exec::test_codex_exec; use serde_json::Value; -use std::process::Command; +use std::path::Path; use std::string::ToString; -use tempfile::TempDir; use uuid::Uuid; use walkdir::WalkDir; @@ -72,18 +71,15 @@ fn extract_conversation_id(path: &std::path::Path) -> String { #[test] fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { - let home = TempDir::new()?; - let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/cli_responses_fixture.sse"); + let test = test_codex_exec(); + let fixture = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse"); // 1) First run: create a session with a unique marker in the content. let marker = format!("resume-last-{}", Uuid::new_v4()); let prompt = format!("echo {marker}"); - Command::cargo_bin("codex-exec") - .context("should find binary for codex-exec")? - .env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") + test.cmd() .env("CODEX_RS_SSE_FIXTURE", &fixture) .env("OPENAI_BASE_URL", "http://unused.local") .arg("--skip-git-repo-check") @@ -94,7 +90,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { .success(); // Find the created session file containing the marker. - let sessions_dir = home.path().join("sessions"); + let sessions_dir = test.home_path().join("sessions"); let path = find_session_file_containing_marker(&sessions_dir, &marker) .expect("no session file found after first run"); @@ -102,11 +98,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { let marker2 = format!("resume-last-2-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); - let mut binding = assert_cmd::Command::cargo_bin("codex-exec") - .context("should find binary for codex-exec")?; - let cmd = binding - .env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") + test.cmd() .env("CODEX_RS_SSE_FIXTURE", &fixture) .env("OPENAI_BASE_URL", "http://unused.local") .arg("--skip-git-repo-check") @@ -114,8 +106,9 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { .arg(env!("CARGO_MANIFEST_DIR")) .arg(&prompt2) .arg("resume") - .arg("--last"); - cmd.assert().success(); + .arg("--last") + .assert() + .success(); // Ensure the same file was updated and contains both markers. let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2) @@ -132,18 +125,15 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { #[test] fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { - let home = TempDir::new()?; - let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/cli_responses_fixture.sse"); + let test = test_codex_exec(); + let fixture = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse"); // 1) First run: create a session let marker = format!("resume-by-id-{}", Uuid::new_v4()); let prompt = format!("echo {marker}"); - Command::cargo_bin("codex-exec") - .context("should find binary for codex-exec")? - .env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") + test.cmd() .env("CODEX_RS_SSE_FIXTURE", &fixture) .env("OPENAI_BASE_URL", "http://unused.local") .arg("--skip-git-repo-check") @@ -153,7 +143,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { .assert() .success(); - let sessions_dir = home.path().join("sessions"); + let sessions_dir = test.home_path().join("sessions"); let path = find_session_file_containing_marker(&sessions_dir, &marker) .expect("no session file found after first run"); let session_id = extract_conversation_id(&path); @@ -166,11 +156,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { let marker2 = format!("resume-by-id-2-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); - let mut binding = assert_cmd::Command::cargo_bin("codex-exec") - .context("should find binary for codex-exec")?; - let cmd = binding - .env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") + test.cmd() .env("CODEX_RS_SSE_FIXTURE", &fixture) .env("OPENAI_BASE_URL", "http://unused.local") .arg("--skip-git-repo-check") @@ -178,8 +164,9 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { .arg(env!("CARGO_MANIFEST_DIR")) .arg(&prompt2) .arg("resume") - .arg(&session_id); - cmd.assert().success(); + .arg(&session_id) + .assert() + .success(); let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2) .expect("no resumed session file containing marker2"); @@ -195,17 +182,14 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { #[test] fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> { - let home = TempDir::new()?; - let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/cli_responses_fixture.sse"); + let test = test_codex_exec(); + let fixture = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse"); let marker = format!("resume-config-{}", Uuid::new_v4()); let prompt = format!("echo {marker}"); - Command::cargo_bin("codex-exec") - .context("should find binary for codex-exec")? - .env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") + test.cmd() .env("CODEX_RS_SSE_FIXTURE", &fixture) .env("OPENAI_BASE_URL", "http://unused.local") .arg("--skip-git-repo-check") @@ -219,17 +203,15 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> { .assert() .success(); - let sessions_dir = home.path().join("sessions"); + let sessions_dir = test.home_path().join("sessions"); let path = find_session_file_containing_marker(&sessions_dir, &marker) .expect("no session file found after first run"); let marker2 = format!("resume-config-2-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); - let output = Command::cargo_bin("codex-exec") - .context("should find binary for codex-exec")? - .env("CODEX_HOME", home.path()) - .env("OPENAI_API_KEY", "dummy") + let output = test + .cmd() .env("CODEX_RS_SSE_FIXTURE", &fixture) .env("OPENAI_BASE_URL", "http://unused.local") .arg("--skip-git-repo-check") diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index df4fc1e7..0a64c61e 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -14,6 +14,7 @@ pub use codex_core::AuthManager; pub use codex_core::CodexAuth; pub use codex_core::auth::AuthDotJson; pub use codex_core::auth::CLIENT_ID; +pub use codex_core::auth::CODEX_API_KEY_ENV_VAR; pub use codex_core::auth::OPENAI_API_KEY_ENV_VAR; pub use codex_core::auth::get_auth_file; pub use codex_core::auth::login_with_api_key; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 039375b0..418c2a51 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -52,7 +52,7 @@ impl MessageProcessor { config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = AuthManager::shared(config.codex_home.clone()); + let auth_manager = AuthManager::shared(config.codex_home.clone(), false); let conversation_manager = Arc::new(ConversationManager::new(auth_manager)); Self { outgoing, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8130d647..d8dde74f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -361,7 +361,7 @@ async fn run_ratatui_app( // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&config); - let auth_manager = AuthManager::shared(config.codex_home.clone()); + let auth_manager = AuthManager::shared(config.codex_home.clone(), false); let login_status = get_login_status(&config); let should_show_onboarding = should_show_onboarding(login_status, &config, should_show_trust_screen); diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 91ce7639..a20d1c4d 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -58,7 +58,7 @@ export class CodexExec { env.OPENAI_BASE_URL = args.baseUrl; } if (args.apiKey) { - env.OPENAI_API_KEY = args.apiKey; + env.CODEX_API_KEY = args.apiKey; } const child = spawn(this.executablePath, commandArgs, {