From 3c7b9f2e9d00025216e663e024b1ee6f415d959c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Jul 2026 05:32:54 -0400 Subject: [PATCH] feat(gateway,acp): translate cross-boundary cwd when running in WSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add shared translators in hermes_constants (Windows drive → /mnt, `\\wsl(.localhost|$)\` UNC → POSIX, gated on is_wsl) and apply them at the gateway session-cwd boundary so a Windows-host UI can hand the WSL backend a path it can actually chdir into. De-dups the ACP adapter's private `_win_path_to_wsl` onto the shared helper and extends it to the UNC spelling. Co-authored-by: Rage Lopez --- acp_adapter/session.py | 29 ++++++++--------------- hermes_constants.py | 42 ++++++++++++++++++++++++++++++++++ tests/test_hermes_constants.py | 35 ++++++++++++++++++++++++++++ tui_gateway/server.py | 5 +++- 4 files changed, 90 insertions(+), 21 deletions(-) diff --git a/acp_adapter/session.py b/acp_adapter/session.py index b048fae510f8..a51c4c58aa2d 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -26,31 +26,18 @@ from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) -def _win_path_to_wsl(path: str) -> str | None: - """Convert a Windows drive path to its WSL /mnt//... equivalent.""" - match = re.match(r"^([A-Za-z]):[\\/](.*)$", path) - if not match: - return None - drive = match.group(1).lower() - tail = match.group(2).replace("\\", "/") - return f"/mnt/{drive}/{tail}" - - def _translate_acp_cwd(cwd: str) -> str: """Translate Windows ACP cwd values when Hermes itself is running in WSL. Windows ACP clients can launch ``hermes acp`` inside WSL while still sending - editor workspaces as Windows drive paths such as ``E:\\Projects``. Store - and execute against the WSL mount path so agents, tools, and persisted ACP - sessions all agree on the usable workspace. Native Linux/macOS keeps the - original cwd unchanged. + editor workspaces as Windows drive paths (``E:\\Projects``) or + ``\\\\wsl.localhost\\`` UNC paths. Store and execute against the POSIX form so + agents, tools, and persisted ACP sessions all agree on the usable workspace. + Native Linux/macOS keeps the original cwd unchanged. """ - from hermes_constants import is_wsl + from hermes_constants import translate_cwd_for_wsl_backend - if not is_wsl(): - return cwd - translated = _win_path_to_wsl(str(cwd)) - return translated if translated is not None else cwd + return translate_cwd_for_wsl_backend(str(cwd)) def _normalize_cwd_for_compare(cwd: str | None) -> str: @@ -61,7 +48,9 @@ def _normalize_cwd_for_compare(cwd: str | None) -> str: # Normalize Windows drive paths into the equivalent WSL mount form so # ACP history filters match the same workspace across Windows and WSL. - translated = _win_path_to_wsl(expanded) + from hermes_constants import windows_path_to_wsl + + translated = windows_path_to_wsl(expanded) if translated is not None: expanded = translated elif re.match(r"^/mnt/[A-Za-z]/", expanded): diff --git a/hermes_constants.py b/hermes_constants.py index a705918ef0a2..26842530c22d 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -854,6 +854,48 @@ def is_wsl() -> bool: return _wsl_detected +def windows_path_to_wsl(path: str) -> str | None: + """Convert a Windows drive path (``C:\\...``) to its ``/mnt//...`` form.""" + import re + + match = re.match(r"^([A-Za-z]):[\\/](.*)$", str(path or "").strip()) + if not match: + return None + drive = match.group(1).lower() + tail = match.group(2).replace("\\", "/") + return f"/mnt/{drive}/{tail}" + + +def wsl_unc_path_to_posix(path: str) -> str | None: + """Convert a Windows WSL UNC path (``\\\\wsl.localhost\\\\...`` or the + legacy ``\\\\wsl$\\...``) to a POSIX path inside the distro.""" + import re + + normalized = str(path or "").strip().replace("/", "\\") + match = re.match(r"^\\\\wsl(?:\.localhost|\$)\\[^\\]+\\(.*)$", normalized, re.IGNORECASE) + if not match: + return None + tail = match.group(1).replace("\\", "/") + return f"/{tail}" if tail else "/" + + +def translate_cwd_for_wsl_backend(cwd: str) -> str: + """Normalize a cross-boundary cwd when Hermes itself runs inside WSL. + + A Windows-host UI (native picker / drive path / ``\\\\wsl.localhost\\`` UNC) + can hand the WSL backend a path it can't ``chdir`` into. Map it to the POSIX + equivalent so the picker, sidebar, and sessions all agree on the workspace. + No-op off WSL and for paths that are already POSIX. + """ + if not is_wsl(): + return cwd + for translator in (wsl_unc_path_to_posix, windows_path_to_wsl): + translated = translator(cwd) + if translated is not None: + return translated + return cwd + + _container_detected: bool | None = None diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 0eac5af59608..44a449d91d08 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -834,3 +834,38 @@ class TestGetHermesDir: legacy.symlink_to(empty) result = get_hermes_dir("cache/audio", "audio_cache") assert result == tmp_path / "cache/audio" + + +class TestWslPathTranslation: + """Cross-boundary path translation for a Windows-host UI + WSL backend.""" + + def test_windows_drive_to_wsl_mount(self): + assert hermes_constants.windows_path_to_wsl(r"C:\Users\alex") == "/mnt/c/Users/alex" + assert hermes_constants.windows_path_to_wsl("C:/Users/alex") == "/mnt/c/Users/alex" + assert hermes_constants.windows_path_to_wsl("D:\\") == "/mnt/d/" + + def test_windows_drive_ignores_non_drive_paths(self): + assert hermes_constants.windows_path_to_wsl("/home/alex") is None + assert hermes_constants.windows_path_to_wsl("relative\\dir") is None + + def test_wsl_unc_to_posix_both_spellings(self): + assert hermes_constants.wsl_unc_path_to_posix(r"\\wsl.localhost\Ubuntu\home\alex") == "/home/alex" + assert hermes_constants.wsl_unc_path_to_posix(r"\\wsl$\Ubuntu\home\alex") == "/home/alex" + # Forward-slash spelling and distro root. + assert hermes_constants.wsl_unc_path_to_posix("//wsl.localhost/Debian/srv/app") == "/srv/app" + assert hermes_constants.wsl_unc_path_to_posix("\\\\wsl.localhost\\Ubuntu\\") == "/" + + def test_wsl_unc_ignores_non_unc_paths(self): + assert hermes_constants.wsl_unc_path_to_posix(r"C:\Users\alex") is None + assert hermes_constants.wsl_unc_path_to_posix("/home/alex") is None + + def test_translate_is_noop_off_wsl(self, monkeypatch): + monkeypatch.setattr(hermes_constants, "is_wsl", lambda: False) + assert hermes_constants.translate_cwd_for_wsl_backend(r"C:\Users\alex") == r"C:\Users\alex" + + def test_translate_maps_windows_and_unc_on_wsl(self, monkeypatch): + monkeypatch.setattr(hermes_constants, "is_wsl", lambda: True) + assert hermes_constants.translate_cwd_for_wsl_backend(r"C:\Users\alex") == "/mnt/c/Users/alex" + assert hermes_constants.translate_cwd_for_wsl_backend(r"\\wsl.localhost\Ubuntu\home\alex") == "/home/alex" + # Already-POSIX paths pass through untouched. + assert hermes_constants.translate_cwd_for_wsl_backend("/home/alex") == "/home/alex" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 192cb6c0f9fa..3460be77de08 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1859,7 +1859,10 @@ def _persist_session_git_meta(session: dict, cwd: str) -> None: def _set_session_cwd(session: dict, cwd: str) -> str: - resolved = os.path.abspath(os.path.expanduser(str(cwd))) + from hermes_constants import translate_cwd_for_wsl_backend + + cwd = translate_cwd_for_wsl_backend(str(cwd)) + resolved = os.path.abspath(os.path.expanduser(cwd)) if not os.path.isdir(resolved): raise ValueError(f"working directory does not exist: {cwd}") session["cwd"] = resolved