From 195fac834b34a82e7a642ade3dc68be4c502f9a1 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 16 Apr 2026 11:58:17 +0100 Subject: [PATCH] feat: add support for CODEX_AUTH_HOME --- codex-rs/cli/tests/auth_home.rs | 39 +++++++++++++++ codex-rs/config/src/types.rs | 2 +- codex-rs/core/config.schema.json | 2 +- codex-rs/login/src/auth/storage.rs | 60 +++++++++++++++++++++--- codex-rs/login/src/auth/storage_tests.rs | 12 +++++ docs/config.md | 6 +++ 6 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 codex-rs/cli/tests/auth_home.rs diff --git a/codex-rs/cli/tests/auth_home.rs b/codex-rs/cli/tests/auth_home.rs new file mode 100644 index 0000000000..c722e16976 --- /dev/null +++ b/codex-rs/cli/tests/auth_home.rs @@ -0,0 +1,39 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path, auth_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + cmd.env("CODEX_AUTH_HOME", auth_home); + Ok(cmd) +} + +#[tokio::test] +async fn login_uses_codex_auth_home_without_writing_codex_home_auth() -> Result<()> { + let codex_home = TempDir::new()?; + let auth_root = TempDir::new()?; + let auth_home = auth_root.path().join("auth-home"); + + let mut login = codex_command(codex_home.path(), &auth_home)?; + login + .args(["login", "--with-api-key"]) + .write_stdin("sk-proj-1234567890ABCDE\n") + .assert() + .success() + .stderr(contains("Successfully logged in")); + + assert!(!codex_home.path().join("auth.json").exists()); + assert!(auth_home.join("auth.json").exists()); + + let mut status = codex_command(codex_home.path(), &auth_home)?; + status + .args(["login", "status"]) + .assert() + .success() + .stderr(contains("Logged in using an API key - sk-proj-***ABCDE")); + + Ok(()) +} diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 73a5763fe1..e26de0409b 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -41,7 +41,7 @@ const fn default_enabled() -> bool { #[serde(rename_all = "lowercase")] pub enum AuthCredentialsStoreMode { #[default] - /// Persist credentials in CODEX_HOME/auth.json. + /// Persist credentials in CODEX_AUTH_HOME/auth.json when set, otherwise CODEX_HOME/auth.json. File, /// Persist credentials in the keyring. Fail if unavailable. Keyring, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c61daa8b56..75d24e2fda 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -263,7 +263,7 @@ "description": "Determine where Codex should store CLI auth credentials.", "oneOf": [ { - "description": "Persist credentials in CODEX_HOME/auth.json.", + "description": "Persist credentials in CODEX_AUTH_HOME/auth.json when set, otherwise CODEX_HOME/auth.json.", "enum": [ "file" ], diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index f0d7a3169c..2225976396 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -12,6 +12,7 @@ use std::io::Read; use std::io::Write; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; +use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -54,8 +55,48 @@ pub struct AgentIdentityAuthRecord { pub registered_at: String, } +fn effective_auth_home(codex_home: &Path) -> PathBuf { + auth_home_from_env().unwrap_or_else(|| codex_home.to_path_buf()) +} + +fn auth_home_from_env() -> Option { + std::env::var_os("CODEX_AUTH_HOME") + .filter(|value| !value.as_os_str().is_empty()) + .map(PathBuf::from) + .map(normalize_auth_home_path) +} + +fn normalize_auth_home_path(path: PathBuf) -> PathBuf { + let path = if path.is_absolute() { + path + } else { + std::env::current_dir() + .map(|cwd| cwd.join(&path)) + .unwrap_or(path) + }; + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + + if normalized.as_os_str().is_empty() { + PathBuf::from(".") + } else { + normalized + } +} + pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { - codex_home.join("auth.json") + effective_auth_home(codex_home).join("auth.json") } pub(super) fn delete_file_if_exists(codex_home: &Path) -> std::io::Result { @@ -134,16 +175,23 @@ const KEYRING_SERVICE: &str = "Codex Auth"; // turns codex_home path into a stable, short key string fn compute_store_key(codex_home: &Path) -> std::io::Result { - let canonical = codex_home - .canonicalize() - .unwrap_or_else(|_| codex_home.to_path_buf()); - let path_str = canonical.to_string_lossy(); + let canonical = match auth_home_from_env() { + Some(auth_home) => auth_home, + None => codex_home + .canonicalize() + .unwrap_or_else(|_| codex_home.to_path_buf()), + }; + Ok(compute_store_key_for_path(&canonical)) +} + +fn compute_store_key_for_path(path: &Path) -> String { + let path_str = path.to_string_lossy(); let mut hasher = Sha256::new(); hasher.update(path_str.as_bytes()); let digest = hasher.finalize(); let hex = format!("{digest:x}"); let truncated = hex.get(..16).unwrap_or(&hex); - Ok(format!("cli|{truncated}")) + format!("cli|{truncated}") } #[derive(Clone, Debug)] diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index 2e1cc85029..135cd20cae 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -249,6 +249,18 @@ fn keyring_auth_storage_compute_store_key_for_home_directory() -> anyhow::Result Ok(()) } +#[test] +fn auth_home_store_key_path_does_not_depend_on_directory_existing() { + let root = tempdir().expect("tempdir"); + let auth_home = root.path().join("missing").join("..").join("auth"); + + let before_create = compute_store_key_for_path(&normalize_auth_home_path(auth_home.clone())); + std::fs::create_dir_all(root.path().join("auth")).expect("create auth home"); + let after_create = compute_store_key_for_path(&normalize_auth_home_path(auth_home)); + + assert_eq!(before_create, after_create); +} + #[test] fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> { let codex_home = tempdir()?; diff --git a/docs/config.md b/docs/config.md index c314ce2283..c86640ff12 100644 --- a/docs/config.md +++ b/docs/config.md @@ -60,6 +60,12 @@ Codex stores the SQLite-backed state DB under `sqlite_home` (config key) or the `CODEX_SQLITE_HOME` environment variable. When unset, WorkspaceWrite sandbox sessions default to a temp directory; other modes default to `CODEX_HOME`. +## Auth Home + +When `CODEX_AUTH_HOME` is set, Codex stores CLI auth credentials in +`CODEX_AUTH_HOME/auth.json` instead of `CODEX_HOME/auth.json`. The directory is +created as needed. Other Codex state continues to use `CODEX_HOME`. + ## Custom CA Certificates Codex can trust a custom root CA bundle for outbound HTTPS and secure websocket