From bb22f5b37042c4bb873e6d1b4702a46ce8e754fc Mon Sep 17 00:00:00 2001 From: Tomas Cupr Date: Mon, 29 Sep 2025 00:39:55 +0200 Subject: [PATCH 1/3] fix(core): default CODEX_HOME to platform config dir with legacy ~/.codex fallback; update docs and tests\n\n- Respects CODEX_HOME when set\n- Uses ~/.codex when it already exists (back-compat)\n- Otherwise falls back to dirs::config_dir()/codex on each platform\n- Updates docs to reference rather than ~/.codex\n- Adds unit tests for env override, legacy fallback, and platform default\n\nFixes #4407 --- README.md | 4 +- codex-rs/common/src/config_override.rs | 2 +- codex-rs/core/src/config.rs | 193 ++++++++++++++++++++++++- docs/advanced.md | 6 +- docs/authentication.md | 12 +- docs/config.md | 6 +- docs/getting-started.md | 4 +- docs/prompts.md | 2 +- 8 files changed, 207 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ae04239ae6..7550bec2de 100644 --- a/README.md +++ b/README.md @@ -62,12 +62,12 @@ You can also use Codex with an API key, but this requires [additional setup](./d ### Model Context Protocol (MCP) -Codex CLI supports [MCP servers](./docs/advanced.md#model-context-protocol-mcp). Enable by adding an `mcp_servers` section to your `~/.codex/config.toml`. +Codex CLI supports [MCP servers](./docs/advanced.md#model-context-protocol-mcp). Enable by adding an `mcp_servers` section to your `$CODEX_HOME/config.toml` (see docs/config.md for the default location). ### Configuration -Codex CLI supports a rich set of configuration options, with preferences stored in `~/.codex/config.toml`. For full configuration options, see [Configuration](./docs/config.md). +Codex CLI supports a rich set of configuration options, with preferences stored in `$CODEX_HOME/config.toml`. For full configuration options and default path, see [Configuration](./docs/config.md). --- diff --git a/codex-rs/common/src/config_override.rs b/codex-rs/common/src/config_override.rs index 6b77099524..8ec5a96e36 100644 --- a/codex-rs/common/src/config_override.rs +++ b/codex-rs/common/src/config_override.rs @@ -18,7 +18,7 @@ use toml::Value; #[derive(Parser, Debug, Default, Clone)] pub struct CliConfigOverrides { /// Override a configuration value that would otherwise be loaded from - /// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override + /// `$CODEX_HOME/config.toml`. Use a dotted path (`foo.bar.baz`) to override /// nested values. The `value` portion is parsed as JSON. If it fails to /// parse as JSON, the raw string is used as a literal. /// diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 292b9f7b51..dab938fa77 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1149,22 +1149,32 @@ fn default_review_model() -> String { /// - If `CODEX_HOME` is not set, this function does not verify that the /// directory exists. pub fn find_codex_home() -> std::io::Result { - // Honor the `CODEX_HOME` environment variable when it is set to allow users - // (and tests) to override the default location. + // 1) Highest precedence: explicit override via CODEX_HOME if let Ok(val) = std::env::var("CODEX_HOME") && !val.is_empty() { return PathBuf::from(val).canonicalize(); } - let mut p = home_dir().ok_or_else(|| { + // 2) Backwards-compat: if legacy ~/.codex exists, keep using it + if let Some(home) = home_dir() { + let legacy = home.join(".codex"); + if legacy.exists() { + return Ok(legacy); + } + } + + // 3) Modern default: platform config directory /codex + // - Linux: $XDG_CONFIG_HOME/codex (or ~/.config/codex) + // - macOS: ~/Library/Application Support/codex + // - Windows: %AppData%\codex + let base = dirs::config_dir().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, - "Could not find home directory", + "Could not resolve platform config directory", ) })?; - p.push(".codex"); - Ok(p) + Ok(base.join("codex")) } /// Returns the path to the folder where Codex logs are stored. Does not verify @@ -1183,9 +1193,180 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + use std::env; + use std::sync::Mutex; + use std::sync::OnceLock; use std::time::Duration; use tempfile::TempDir; + static ENV_LOCK: OnceLock> = OnceLock::new(); + + fn env_lock() -> &'static Mutex<()> { + ENV_LOCK.get_or_init(|| Mutex::new(())) + } + + fn set_opt_var(key: &str, val: Option<&str>) { + unsafe { + match val { + Some(v) => env::set_var(key, v), + None => env::remove_var(key), + } + } + } + + #[test] + fn find_codex_home_prefers_env() -> anyhow::Result<()> { + let _g = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let prev = env::var("CODEX_HOME").ok(); + let tmp = TempDir::new()?; + unsafe { + env::set_var("CODEX_HOME", tmp.path()); + } + + let got = find_codex_home()?; + assert_eq!(got, tmp.path().canonicalize()?); + + set_opt_var("CODEX_HOME", prev.as_deref()); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn find_codex_home_legacy_unix() -> anyhow::Result<()> { + let _g = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let prev_code = env::var("CODEX_HOME").ok(); + let prev_home = env::var("HOME").ok(); + let prev_xdg = env::var("XDG_CONFIG_HOME").ok(); + + unsafe { + env::remove_var("CODEX_HOME"); + } + + let tmp = TempDir::new()?; + let legacy = tmp.path().join(".codex"); + std::fs::create_dir_all(&legacy)?; + unsafe { + env::set_var("HOME", tmp.path()); + env::set_var("XDG_CONFIG_HOME", tmp.path().join("xdg")); + } + + let got = find_codex_home()?; + assert_eq!(got, legacy); + + set_opt_var("CODEX_HOME", prev_code.as_deref()); + set_opt_var("HOME", prev_home.as_deref()); + set_opt_var("XDG_CONFIG_HOME", prev_xdg.as_deref()); + Ok(()) + } + + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn find_codex_home_defaults_to_config_dir_on_unix() -> anyhow::Result<()> { + let _g = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + + let prev_code = env::var("CODEX_HOME").ok(); + let prev_home = env::var("HOME").ok(); + let prev_xdg = env::var("XDG_CONFIG_HOME").ok(); + + unsafe { + env::remove_var("CODEX_HOME"); + } + + // Use a temporary HOME without ~/.codex + let tmp = TempDir::new()?; + unsafe { + env::set_var("HOME", tmp.path()); + } + + // Force XDG config dir to a temp location + let xdg = tmp.path().join("xdg-config"); + std::fs::create_dir_all(&xdg)?; + unsafe { + env::set_var("XDG_CONFIG_HOME", &xdg); + } + + let got = find_codex_home()?; + let expected_base = dirs::config_dir().expect("config_dir must exist"); + assert_eq!(got, expected_base.join("codex")); + + set_opt_var("CODEX_HOME", prev_code.as_deref()); + set_opt_var("HOME", prev_home.as_deref()); + set_opt_var("XDG_CONFIG_HOME", prev_xdg.as_deref()); + Ok(()) + } + + #[cfg(windows)] + #[test] + fn find_codex_home_defaults_to_appdata_on_windows() -> anyhow::Result<()> { + use std::path::PathBuf; + let _g = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + + let prev_code = env::var("CODEX_HOME").ok(); + let prev_app = env::var("APPDATA").ok(); + let prev_user = env::var("USERPROFILE").ok(); + + unsafe { + env::remove_var("CODEX_HOME"); + } + + // Create an isolated APPDATA and USERPROFILE + let tmp = TempDir::new()?; + let appdata = tmp.path().join("appdata"); + std::fs::create_dir_all(&appdata)?; + unsafe { + env::set_var("APPDATA", &appdata); + env::set_var("USERPROFILE", tmp.path()); + } + + // Ensure legacy %USERPROFILE%\.codex does not exist + let legacy = PathBuf::from(env::var("USERPROFILE")?).join(".codex"); + if legacy.exists() { + std::fs::remove_dir_all(&legacy)?; + } + + let got = find_codex_home()?; + assert_eq!(got, PathBuf::from(env::var("APPDATA")?).join("codex")); + + set_opt_var("CODEX_HOME", prev_code.as_deref()); + set_opt_var("APPDATA", prev_app.as_deref()); + set_opt_var("USERPROFILE", prev_user.as_deref()); + Ok(()) + } + + #[cfg(target_os = "macos")] + #[test] + fn find_codex_home_defaults_to_config_dir_on_macos() -> anyhow::Result<()> { + let _g = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let prev_code = env::var("CODEX_HOME").ok(); + let prev_home = env::var("HOME").ok(); + + unsafe { + env::remove_var("CODEX_HOME"); + } + + let tmp = TempDir::new()?; + unsafe { + env::set_var("HOME", tmp.path()); + } + + let got = find_codex_home()?; + let expected_base = dirs::config_dir().expect("config_dir must exist"); + assert_eq!(got, expected_base.join("codex")); + + set_opt_var("CODEX_HOME", prev_code.as_deref()); + set_opt_var("HOME", prev_home.as_deref()); + Ok(()) + } + #[test] fn test_toml_parsing() { let history_with_persistence = r#" diff --git a/docs/advanced.md b/docs/advanced.md index 4edca7646f..4b36e7dae4 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -50,10 +50,10 @@ Notes: Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior. -The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info` and log messages are written to `~/.codex/log/codex-tui.log`, so you can leave the following running in a separate terminal to monitor log messages as they are written: +The TUI defaults to `RUST_LOG=codex_core=info,codex_tui=info` and log messages are written to `$CODEX_HOME/log/codex-tui.log`, so you can leave the following running in a separate terminal to monitor log messages as they are written: ``` -tail -F ~/.codex/log/codex-tui.log +tail -F "$CODEX_HOME/log/codex-tui.log" ``` By comparison, the non-interactive mode (`codex exec`) defaults to `RUST_LOG=error`, but messages are printed inline, so there is no need to monitor a separate file. @@ -62,7 +62,7 @@ See the Rust documentation on [`RUST_LOG`](https://docs.rs/env_logger/latest/env ## Model Context Protocol (MCP) -The Codex CLI can be configured to leverage MCP servers by defining an [`mcp_servers`](./config.md#mcp_servers) section in `~/.codex/config.toml`. It is intended to mirror how tools such as Claude and Cursor define `mcpServers` in their respective JSON config files, though the Codex format is slightly different since it uses TOML rather than JSON, e.g.: +The Codex CLI can be configured to leverage MCP servers by defining an [`mcp_servers`](./config.md#mcp_servers) section in `$CODEX_HOME/config.toml`. It is intended to mirror how tools such as Claude and Cursor define `mcpServers` in their respective JSON config files, though the Codex format is slightly different since it uses TOML rather than JSON, e.g.: ```toml # IMPORTANT: the top-level key is `mcp_servers` rather than `mcpServers`. diff --git a/docs/authentication.md b/docs/authentication.md index 0db35489bf..b240699253 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -15,7 +15,7 @@ This key must, at minimum, have write access to the Responses API. If you've used the Codex CLI before with usage-based billing via an API key and want to switch to using your ChatGPT plan, follow these steps: 1. Update the CLI and ensure `codex --version` is `0.20.0` or later -2. Delete `~/.codex/auth.json` (on Windows: `C:\\Users\\USERNAME\\.codex\\auth.json`) +2. Delete `$CODEX_HOME/auth.json` (see docs/config.md for the default location on your OS) 3. Run `codex login` again ## Connecting on a "Headless" Machine @@ -24,28 +24,28 @@ Today, the login process entails running a server on `localhost:1455`. If you ar ### Authenticate locally and copy your credentials to the "headless" machine -The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODEX_HOME/auth.json` (on Mac/Linux, `$CODEX_HOME` defaults to `~/.codex` whereas on Windows, it defaults to `%USERPROFILE%\\.codex`). +The easiest solution is likely to run through the `codex login` process on your local machine such that `localhost:1455` _is_ accessible in your web browser. When you complete the authentication process, an `auth.json` file should be available at `$CODEX_HOME/auth.json`. See [Configuration](./config.md) for the default location per platform. Because the `auth.json` file is not tied to a specific host, once you complete the authentication flow locally, you can copy the `$CODEX_HOME/auth.json` file to the headless machine and then `codex` should "just work" on that machine. Note to copy a file to a Docker container, you can do: ```shell # substitute MY_CONTAINER with the name or id of your Docker container: CONTAINER_HOME=$(docker exec MY_CONTAINER printenv HOME) -docker exec MY_CONTAINER mkdir -p "$CONTAINER_HOME/.codex" +docker exec MY_CONTAINER mkdir -p "$CONTAINER_HOME/.codex" # or set CODEX_HOME and use it instead docker cp auth.json MY_CONTAINER:"$CONTAINER_HOME/.codex/auth.json" ``` whereas if you are `ssh`'d into a remote machine, you likely want to use [`scp`](https://en.wikipedia.org/wiki/Secure_copy_protocol): ```shell -ssh user@remote 'mkdir -p ~/.codex' -scp ~/.codex/auth.json user@remote:~/.codex/auth.json +ssh user@remote 'mkdir -p ${CODEX_HOME:-~/.codex}' +scp "$CODEX_HOME/auth.json" user@remote:"${CODEX_HOME:-~/.codex}/auth.json" ``` or try this one-liner: ```shell -ssh user@remote 'mkdir -p ~/.codex && cat > ~/.codex/auth.json' < ~/.codex/auth.json +ssh user@remote 'mkdir -p ${CODEX_HOME:-~/.codex} && cat > ${CODEX_HOME:-~/.codex}/auth.json' < "$CODEX_HOME/auth.json" ``` ### Connecting through VPS or remote diff --git a/docs/config.md b/docs/config.md index ba204ee00a..0c0cff87eb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,7 +12,11 @@ Codex supports several mechanisms for setting config values: - If `value` cannot be parsed as a valid TOML value, it is treated as a string value. This means that `-c model='"o3"'` and `-c model=o3` are equivalent. - In the first case, the value is the TOML string `"o3"`, while in the second the value is `o3`, which is not valid TOML and therefore treated as the TOML string `"o3"`. - Because quotes are interpreted by one's shell, `-c key="true"` will be correctly interpreted in TOML as `key = true` (a boolean) and not `key = "true"` (a string). If for some reason you needed the string `"true"`, you would need to use `-c key='"true"'` (note the two sets of quotes). -- The `$CODEX_HOME/config.toml` configuration file where the `CODEX_HOME` environment value defaults to `~/.codex`. (Note `CODEX_HOME` will also be where logs and other Codex-related information are stored.) +- The `$CODEX_HOME/config.toml` configuration file. If `CODEX_HOME` is not set, Codex now defaults to your platform's config directory, typically: + - Linux: `$XDG_CONFIG_HOME/codex` (or `~/.config/codex`) + - macOS: `~/Library/Application Support/codex` + - Windows: `%AppData%\codex` + If a legacy `~/.codex` directory already exists, Codex will continue to use it for backward compatibility. (`CODEX_HOME` is still the authoritative override.) Both the `--config` flag and the `config.toml` file support the following options: diff --git a/docs/getting-started.md b/docs/getting-started.md index e97de6a048..2d7952a0f0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,7 +14,7 @@ Key flags: `--model/-m`, `--ask-for-approval/-a`. - Run `codex resume` to display the session picker UI - Resume most recent: `codex resume --last` -- Resume by id: `codex resume ` (You can get session ids from /status or `~/.codex/sessions/`) +- Resume by id: `codex resume ` (You can get session ids from /status or `$CODEX_HOME/sessions/`) Examples: @@ -63,7 +63,7 @@ Below are a few bite-size examples you can copy-paste. Replace the text in quote You can give Codex extra instructions and guidance using `AGENTS.md` files. Codex looks for `AGENTS.md` files in the following places, and merges them top-down: -1. `~/.codex/AGENTS.md` - personal global guidance +1. `$CODEX_HOME/AGENTS.md` - personal global guidance (see docs/config.md for default location) 2. `AGENTS.md` at repo root - shared project notes 3. `AGENTS.md` in the current working directory - sub-folder/feature specifics diff --git a/docs/prompts.md b/docs/prompts.md index b98240d2ad..b3f40997cc 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -2,7 +2,7 @@ Save frequently used prompts as Markdown files and reuse them quickly from the slash menu. -- Location: Put files in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). +- Location: Put files in `$CODEX_HOME/prompts/`. - File type: Only Markdown files with the `.md` extension are recognized. - Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`. - Content: The file contents are sent as your message when you select the item in the slash popup and press Enter. From 4e7359ab999825c0627250ebe8e23a0ac5d20691 Mon Sep 17 00:00:00 2001 From: Tomas Cupr Date: Mon, 29 Sep 2025 00:50:17 +0200 Subject: [PATCH 2/3] fix(core): appease clippy redundant-closure lint in tests --- codex-rs/core/src/config.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index dab938fa77..7f8984d026 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1268,7 +1268,9 @@ mod tests { #[cfg(all(unix, not(target_os = "macos")))] #[test] fn find_codex_home_defaults_to_config_dir_on_unix() -> anyhow::Result<()> { - let _g = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _g = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let prev_code = env::var("CODEX_HOME").ok(); let prev_home = env::var("HOME").ok(); @@ -1305,7 +1307,9 @@ mod tests { #[test] fn find_codex_home_defaults_to_appdata_on_windows() -> anyhow::Result<()> { use std::path::PathBuf; - let _g = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _g = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let prev_code = env::var("CODEX_HOME").ok(); let prev_app = env::var("APPDATA").ok(); From 2818759d4862e0af2451e5d7c25761c5029219e9 Mon Sep 17 00:00:00 2001 From: Tomas Cupr Date: Mon, 29 Sep 2025 01:00:33 +0200 Subject: [PATCH 3/3] fix(core): Windows fallback when config_dir() unavailable; use APPDATA/LOCALAPPDATA/USERPROFILE as last resort --- codex-rs/core/src/config.rs | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 7f8984d026..8fd31f0382 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1168,13 +1168,33 @@ pub fn find_codex_home() -> std::io::Result { // - Linux: $XDG_CONFIG_HOME/codex (or ~/.config/codex) // - macOS: ~/Library/Application Support/codex // - Windows: %AppData%\codex - let base = dirs::config_dir().ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - "Could not resolve platform config directory", - ) - })?; - Ok(base.join("codex")) + // Prefer the OS-reported configuration directory. + if let Some(base) = dirs::config_dir() { + return Ok(base.join("codex")); + } + + // Fallbacks for environments where config_dir() may be unavailable + // (e.g., stripped env on CI or limited shells). Keep these conservative + // and platform-aware to avoid surprising locations. + #[cfg(windows)] + { + use std::env; + if let Ok(appdata) = env::var("APPDATA") { + return Ok(PathBuf::from(appdata).join("codex")); + } + if let Ok(local) = env::var("LOCALAPPDATA") { + return Ok(PathBuf::from(local).join("codex")); + } + if let Some(home) = home_dir() { + // Default Windows roaming path under the user profile. + return Ok(home.join("AppData").join("Roaming").join("codex")); + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Could not resolve platform config directory", + )) } /// Returns the path to the folder where Codex logs are stored. Does not verify