diff --git a/src/main.rs b/src/main.rs index 0469579..7cc4397 100644 --- a/src/main.rs +++ b/src/main.rs @@ -205,10 +205,41 @@ fn resolve_codex_home(cli_codex_home: Option) -> Result { resolve_codex_home_from_env( cli_codex_home, std::env::var_os("CODEX_HOME").map(PathBuf::from), - std::env::var_os("HOME").map(PathBuf::from), + home_base_from_env( + std::env::var_os("USERPROFILE").map(PathBuf::from), + std::env::var_os("HOME").map(PathBuf::from), + cfg!(windows), + ), ) } +/// Resolve the base directory the default `.codex` home hangs off of when +/// neither `--codex-home` nor `CODEX_HOME` is set. +/// +/// This mirrors how Codex itself locates its home via `dirs::home_dir()`: on +/// Windows that resolves from `USERPROFILE` and never consults `HOME`, while on +/// Unix it uses `HOME`. threadripper historically looked only at `HOME`, so any +/// process that injects `HOME` (e.g. an app pointing it at `%APPDATA%\`) +/// sent us to the wrong `.codex` while Codex kept writing under +/// `%USERPROFILE%\.codex`. Preferring `USERPROFILE` on Windows realigns the two; +/// `HOME` stays as a fallback there for shells that only set it (Git Bash, MSYS). +fn home_base_from_env( + userprofile: Option, + home: Option, + is_windows: bool, +) -> Option { + fn non_empty(path: PathBuf) -> Option { + (!path.as_os_str().is_empty()).then_some(path) + } + if is_windows { + userprofile + .and_then(non_empty) + .or_else(|| home.and_then(non_empty)) + } else { + home.and_then(non_empty) + } +} + fn resolve_codex_home_from_env( cli_codex_home: Option, env_codex_home: Option, diff --git a/src/output.rs b/src/output.rs index b2916da..93ec3cd 100644 --- a/src/output.rs +++ b/src/output.rs @@ -350,8 +350,12 @@ pub(crate) fn bytes_value_name(locale: Locale) -> &'static str { pub(crate) fn codex_home_help(locale: Locale) -> &'static str { match locale { - Locale::En => "Codex home directory. Defaults to CODEX_HOME, then $HOME/.codex.", - Locale::ZhHans => "Codex home 目录。默认值依次使用 CODEX_HOME、$HOME/.codex。", + Locale::En => { + "Codex home directory. Defaults to CODEX_HOME, then /.codex (USERPROFILE on Windows, else HOME)." + } + Locale::ZhHans => { + "Codex home 目录。默认依次使用 CODEX_HOME、家目录下的 .codex(Windows 取 USERPROFILE,其余取 HOME)。" + } } } diff --git a/src/tests.rs b/src/tests.rs index 80660bb..d3bc53a 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -265,6 +265,65 @@ fn resolves_codex_home_from_cli_then_env_then_home() -> Result<()> { Ok(()) } +#[test] +fn home_base_prefers_userprofile_on_windows() -> Result<()> { + // Windows: USERPROFILE wins and HOME is ignored even when both are set — + // this is the reported regression (HOME hijacked to %APPDATA%\). + assert_eq!( + crate::home_base_from_env( + Some(PathBuf::from(r"C:\Users\alice")), + Some(PathBuf::from(r"C:\Users\alice\AppData\Roaming\SPB_16.6")), + true, + ), + Some(PathBuf::from(r"C:\Users\alice")), + ); + // Windows: fall back to HOME when USERPROFILE is missing or empty. + assert_eq!( + crate::home_base_from_env(None, Some(PathBuf::from(r"C:\home")), true), + Some(PathBuf::from(r"C:\home")), + ); + assert_eq!( + crate::home_base_from_env(Some(PathBuf::new()), Some(PathBuf::from(r"C:\home")), true), + Some(PathBuf::from(r"C:\home")), + ); + // Windows: nothing usable -> None (caller then defaults to "."). + assert_eq!(crate::home_base_from_env(None, None, true), None); + assert_eq!( + crate::home_base_from_env(Some(PathBuf::new()), None, true), + None, + ); + + // Non-Windows: HOME is authoritative and USERPROFILE is ignored. + assert_eq!( + crate::home_base_from_env( + Some(PathBuf::from("/should/ignore")), + Some(PathBuf::from("/home/alice")), + false, + ), + Some(PathBuf::from("/home/alice")), + ); + assert_eq!( + crate::home_base_from_env(Some(PathBuf::from("/should/ignore")), None, false), + None, + ); + + // End-to-end: with HOME hijacked but USERPROFILE intact, the default Codex + // home still resolves under the real user profile, not the app dir. + assert_eq!( + crate::resolve_codex_home_from_env( + None, + None, + crate::home_base_from_env( + Some(PathBuf::from(r"C:\Users\alice")), + Some(PathBuf::from(r"C:\Users\alice\AppData\Roaming\SPB_16.6")), + true, + ), + )?, + PathBuf::from(r"C:\Users\alice").join(".codex"), + ); + Ok(()) +} + #[test] fn resolves_sqlite_path_from_config_sqlite_home() -> Result<()> { let dir = tempfile::tempdir()?;