feat(gateway,acp): translate cross-boundary cwd when running in WSL

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 <VrtxOmega@pm.me>
This commit is contained in:
Brooklyn Nicholson 2026-07-12 05:32:54 -04:00
parent 88fbc8825c
commit 3c7b9f2e9d
4 changed files with 90 additions and 21 deletions

View file

@ -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/<drive>/... 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):

View file

@ -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/<drive>/...`` 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\\<distro>\\...`` 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

View file

@ -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"

View file

@ -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