From 00a6fead2a73fed73d9c29166e65903b0cb0c12e Mon Sep 17 00:00:00 2001 From: chrisluersen <57424623+chrisluersen@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:24:23 -0400 Subject: [PATCH] fix(windows): normalize MSYS Hermes environment paths git-bash/MSYS rewrites Win32 env vars to POSIX form when spawning native Python, so HERMES_HOME and LOCALAPPDATA arrive as /c/Users/... instead of C:/Users/... A bare Path() mangles the leading /c into a rooted relative path (\c\Users\...) pointing at a non-existent shadow tree, which breaks board enumeration and can create junk C:\c\... dirs. Add _normalize_msys_env_path() to convert only the intentional MSYS drive form //... to :/... on win32, applied at the three Path() construction sites for HERMES_HOME and LOCALAPPDATA. Native drive paths, UNC paths, custom root-relative paths, Unicode non-drive paths, a bare /c, and all non-Windows values pass through unchanged. --- hermes_constants.py | 33 ++++++++++- tests/test_hermes_constants.py | 104 +++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/hermes_constants.py b/hermes_constants.py index e7af1883970e..cec1c5954e94 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -54,11 +54,40 @@ def _get_platform_default_hermes_home() -> Path: """Return the platform-native default Hermes home path.""" if sys.platform == "win32": local_appdata = os.environ.get("LOCALAPPDATA", "").strip() + local_appdata = _normalize_msys_env_path(local_appdata) base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local" return base / "hermes" return Path.home() / ".hermes" +def _normalize_msys_env_path(value: str) -> str: + """Convert an MSYS/POSIX-form Windows path to native Windows form. + + Under git-bash/MSYS, Win32 env vars are rewritten to POSIX form when + spawning native Python -- ``HERMES_HOME`` arrives as ``/c/Users//...`` + instead of ``C:/Users//...``. A bare ``Path(value)`` then mangles the + leading ``/c`` into a rooted relative path (``\\c\\Users\\...``) pointing at + a non-existent shadow tree, breaking board enumeration and allowing junk + ``C:\\c\\...`` dirs to be created. + + This normalizes only the narrow, intentional MSYS form on win32: + ``//...`` -> ``:/...``. Every other value + -- native drive paths (slash or backslash), UNC paths, custom root-relative + paths, Unicode non-drive paths, and all non-Windows values -- passes through + unchanged. A bare ``/c`` (no trailing slash) is also left unchanged. + """ + if ( + sys.platform == "win32" + and len(value) >= 3 + and value[0] == "/" + and value[1].isascii() + and value[1].isalpha() + and value[2] == "/" + ): + return f"{value[1].upper()}:/{value[3:]}" + return value + + def _hermes_home_from_env() -> Path: """Resolve HERMES_HOME from the process environment only. @@ -70,7 +99,7 @@ def _hermes_home_from_env() -> Path: """ val = os.environ.get("HERMES_HOME", "").strip() if val: - return Path(val) + return Path(_normalize_msys_env_path(val)) return _get_platform_default_hermes_home() @@ -208,7 +237,7 @@ def get_default_hermes_root() -> Path: if not env_home: result = native_home else: - env_path = Path(env_home) + env_path = Path(_normalize_msys_env_path(env_home)) try: env_path.resolve().relative_to(native_home.resolve()) # HERMES_HOME is under ~/.hermes (normal or profile mode) diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 9df43e08ff9d..37fe5cb5e8a9 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -1099,3 +1099,107 @@ def fake_heal(): # The flag is set, so the once-per-process budget is spent. assert heal_hermes_managed_node() is False assert calls["n"] == 1 + + +class TestMsysEnvPathNormalization: + """_normalize_msys_env_path() converts only the intentional MSYS drive form. + + Under git-bash/MSYS, Win32 env vars arrive as ``/c/Users/...`` instead of + ``C:/Users/...``. A bare ``Path()`` mangles the leading ``/c`` into a rooted + relative path (``\\c\\Users\\...``) pointing at a non-existent shadow tree. + The helper converts ``//...`` -> ``:/...`` + on win32 only, leaving every other form unchanged. + """ + + # --- win32: MSYS drive paths are converted --- + + def test_direct_msys_hermes_home(self): + assert hermes_constants._normalize_msys_env_path( + "/c/Users/alex/AppData/Local/hermes" + ) == "C:/Users/alex/AppData/Local/hermes" + + def test_msys_hermes_home_via_process_home(self, monkeypatch): + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) + monkeypatch.setenv("HERMES_HOME", "/c/Users/alex/AppData/Local/hermes") + assert hermes_constants.get_process_hermes_home() == Path( + "C:/Users/alex/AppData/Local/hermes" + ) + + def test_msys_lowercase_drive_uppercased(self): + assert hermes_constants._normalize_msys_env_path( + "/d/other/path" + ) == "D:/other/path" + + def test_msys_profile_path_resolves_via_get_default_root(self, monkeypatch, tmp_path): + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) + native_root = tmp_path / "LocalAppData" / "hermes" + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "LocalAppData")) + # Build the MSYS form of /profiles/coder: /c/Users/... + # becomes /Users/... i.e. the drive letter lowercased with no colon. + profile = native_root / "profiles" / "coder" + msys = f"/{profile.drive[0].lower()}/{str(profile)[2:].replace(chr(92), '/')}" + monkeypatch.setenv("HERMES_HOME", msys) + # After normalization the profile lives under the native root, so the + # root unwraps to the native home rather than treating it as Docker. + root = hermes_constants.get_default_hermes_root() + assert root == native_root + + def test_msys_localappdata_with_hermes_home_unset(self, monkeypatch, tmp_path): + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setenv("LOCALAPPDATA", "/c/Users/alex/AppData/Local") + monkeypatch.setattr(Path, "home", lambda: tmp_path / "Home") + monkeypatch.setattr(hermes_constants, "_profile_fallback_warned", False) + assert hermes_constants._get_platform_default_hermes_home() == ( + Path("C:/Users/alex/AppData/Local") / "hermes" + ) + + # --- win32: native forms pass through unchanged --- + + def test_native_forward_slash_drive_path_unchanged(self): + assert hermes_constants._normalize_msys_env_path( + "C:/Users/alex/AppData/Local/hermes" + ) == "C:/Users/alex/AppData/Local/hermes" + + def test_native_backslash_drive_path_unchanged(self): + assert hermes_constants._normalize_msys_env_path( + "C:\\Users\\alex\\AppData\\Local\\hermes" + ) == "C:\\Users\\alex\\AppData\\Local\\hermes" + + def test_unc_path_unchanged(self): + assert hermes_constants._normalize_msys_env_path( + "\\\\server\\share\\hermes" + ) == "\\\\server\\share\\hermes" + + def test_custom_root_relative_path_unchanged(self): + # /custom/hermes is not a drive path (third char isn't a slash). + assert hermes_constants._normalize_msys_env_path( + "/custom/hermes" + ) == "/custom/hermes" + + def test_bare_drive_slash_unchanged(self): + # A bare /c (no following slash) is intentionally left untouched. + assert hermes_constants._normalize_msys_env_path("/c") == "/c" + + def test_unicode_non_drive_path_unchanged(self): + # /é/... is not an ASCII drive letter path, so it passes through. + assert hermes_constants._normalize_msys_env_path( + "/é/not-a-drive" + ) == "/é/not-a-drive" + + # --- non-win32: everything passes through unchanged --- + + def test_non_windows_passthrough(self, monkeypatch): + monkeypatch.setattr(hermes_constants.sys, "platform", "linux") + assert hermes_constants._normalize_msys_env_path( + "/c/Users/alex/AppData/Local/hermes" + ) == "/c/Users/alex/AppData/Local/hermes" + + def test_non_windows_passthrough_via_hermes_home(self, monkeypatch, tmp_path): + monkeypatch.setattr(hermes_constants.sys, "platform", "linux") + monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None) + monkeypatch.setenv("HERMES_HOME", "/home/alex/.hermes") + assert hermes_constants.get_process_hermes_home() == Path("/home/alex/.hermes") \ No newline at end of file