From 88fbc8825cd2dedfe145c21252dd5d2953e255aa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Jul 2026 05:32:54 -0400 Subject: [PATCH 1/3] feat(desktop): bridge WSL paths for a Windows host + WSL backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the desktop UI runs on Windows and the gateway runs in WSL, a WSL/POSIX cwd isn't openable/readable from the Windows host. Add wsl-path-bridge.ts to translate the Windows-side direction only: - native folder dialog defaultPath: `/home/...` → `\\wsl.localhost\\...` - fs read path: WSL cwd → its UNC / `C:\` drive form Distro detection reads `wsl.exe -l -q` with `WSL_UTF8=1` and strips stray NUL bytes, since older wsl.exe emits UTF-16LE (microsoft/WSL#4607) — the original utf8 read returned a garbled distro name. UNC uses `\\wsl.localhost\` with a `\\wsl$\` fallback for older Windows. The reverse (any path → POSIX) is handled once gateway-side, so the picker result needs no desktop translation. Co-authored-by: Rage Lopez --- apps/desktop/electron/fs-read-dir.ts | 7 +- apps/desktop/electron/main.ts | 6 +- apps/desktop/electron/wsl-path-bridge.test.ts | 40 ++++++ apps/desktop/electron/wsl-path-bridge.ts | 136 ++++++++++++++++++ apps/desktop/package.json | 2 +- 5 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/electron/wsl-path-bridge.test.ts create mode 100644 apps/desktop/electron/wsl-path-bridge.ts diff --git a/apps/desktop/electron/fs-read-dir.ts b/apps/desktop/electron/fs-read-dir.ts index 7d09650c81f0..fe8f58b0bf28 100644 --- a/apps/desktop/electron/fs-read-dir.ts +++ b/apps/desktop/electron/fs-read-dir.ts @@ -2,6 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import { resolveDirectoryForIpc } from './hardening' +import { resolveLocalReadPath } from './wsl-path-bridge' const FS_READDIR_STAT_CONCURRENCY = 16 @@ -81,8 +82,12 @@ async function readDirForIpc(dirPath, options: any = {}) { const fsImpl = options.fs || fs let resolved + // On a Windows host with a WSL backend, a WSL/POSIX cwd (`/home/...`, + // `/mnt/c/...`) isn't readable as-is; bridge it to a UNC/drive form first. + const readPath = resolveLocalReadPath(String(dirPath ?? '')) + try { - ;({ resolvedPath: resolved } = await resolveDirectoryForIpc(dirPath, { + ;({ resolvedPath: resolved } = await resolveDirectoryForIpc(readPath, { fs: fsImpl, purpose: 'Directory read' })) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index df73863a53c2..ca7efd8fbdcf 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -65,6 +65,7 @@ import { } from './desktop-uninstall' import { installEmbedReferer } from './embed-referer' import { readDirForIpc } from './fs-read-dir' +import { resolvePickerDefaultPath } from './wsl-path-bridge' import { probeGatewayWebSocket } from './gateway-ws-probe' import { scanGitRepos } from './git-repo-scan' import { @@ -8037,7 +8038,10 @@ ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { if (options?.defaultPath) { try { - resolvedDefaultPath = path.resolve(String(options.defaultPath)) + // On a Windows host with a WSL backend the cwd may be a POSIX/WSL path; + // bridge it to a UNC/drive form the native dialog can actually open. + const bridged = IS_WINDOWS ? resolvePickerDefaultPath(String(options.defaultPath)) : String(options.defaultPath) + resolvedDefaultPath = bridged ? path.resolve(bridged) : undefined } catch { resolvedDefaultPath = undefined } diff --git a/apps/desktop/electron/wsl-path-bridge.test.ts b/apps/desktop/electron/wsl-path-bridge.test.ts new file mode 100644 index 000000000000..e6d2bfa87e7e --- /dev/null +++ b/apps/desktop/electron/wsl-path-bridge.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge' + +test('parseDefaultDistro reads the first distro from clean utf-8 output', () => { + assert.equal(parseDefaultDistro('Ubuntu\nDebian\n'), 'Ubuntu') +}) + +test('parseDefaultDistro survives UTF-16LE NUL bytes older wsl.exe leaves in (WSL#4607)', () => { + // `wsl.exe -l -q` emits UTF-16LE without a BOM on builds that ignore + // WSL_UTF8; decoded as utf8 that reads as NUL-interleaved text. + const utf16ish = '\0U\0b\0u\0n\0t\0u\0\r\0\n\0D\0e\0b\0i\0a\0n\0' + assert.equal(parseDefaultDistro(utf16ish), 'Ubuntu') +}) + +test('parseDefaultDistro strips the default-marker and blank lines', () => { + assert.equal(parseDefaultDistro('\n* Ubuntu\nDebian\n'), 'Ubuntu') + assert.equal(parseDefaultDistro(' \n\n'), null) +}) + +test('wslPosixToWindowsAccessible maps a drvfs mount to its Windows drive', () => { + assert.equal(wslPosixToWindowsAccessible('/mnt/c/Users/alex', 'Ubuntu'), 'C:\\Users\\alex') + assert.equal(wslPosixToWindowsAccessible('/mnt/d', 'Ubuntu'), 'D:\\') +}) + +test('wslPosixToWindowsAccessible maps an in-distro POSIX path to a UNC share', () => { + assert.equal(wslPosixToWindowsAccessible('/home/alex/proj', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex\\proj') +}) + +test('wslPosixToWindowsAccessible leaves non-absolute / already-Windows paths alone', () => { + assert.equal(wslPosixToWindowsAccessible('C:\\Users\\alex', 'Ubuntu'), 'C:\\Users\\alex') + assert.equal(wslPosixToWindowsAccessible('relative/dir', 'Ubuntu'), 'relative/dir') +}) + +test('resolvePickerDefaultPath bridges a WSL cwd but passes Windows paths and empties through', () => { + assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex') + assert.equal(resolvePickerDefaultPath('C:\\proj', 'Ubuntu'), 'C:\\proj') + assert.equal(resolvePickerDefaultPath(undefined, 'Ubuntu'), undefined) +}) diff --git a/apps/desktop/electron/wsl-path-bridge.ts b/apps/desktop/electron/wsl-path-bridge.ts new file mode 100644 index 000000000000..0b14604df671 --- /dev/null +++ b/apps/desktop/electron/wsl-path-bridge.ts @@ -0,0 +1,136 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' + +// Bridges WSL/POSIX paths into forms the *Windows host* can open, for the case +// where the desktop UI runs on Windows and the gateway runs inside WSL (remote +// mode). Only the Windows-side direction lives here: the native folder dialog's +// defaultPath and the fs read path. The reverse (whatever path the backend +// receives → POSIX) is handled once, gateway-side, in +// hermes_constants.translate_cwd_for_wsl_backend, so it stays picker-agnostic. + +const IS_WINDOWS = process.platform === 'win32' +const WIN_DRIVE_RE = /^([A-Za-z]):[\\/]/ +// `/mnt/c` and `/mnt/c/...` (drvfs default automount root). +const WSL_MOUNT_RE = /^\/mnt\/([a-z])(?:\/(.*))?$/i + +let cachedDistro: null | string = null +let cachedUncBase: null | string = null + +/** + * Pick the default distro from `wsl.exe -l -q` output. + * + * `wsl.exe` emits UTF-16LE without a BOM unless `WSL_UTF8=1` (WSL >= 0.64), so + * older builds leave NUL bytes between characters even when we ask for utf8 — + * strip them defensively before splitting. The default distro is the first + * (`*`-marked, decoration removed by `-q`) entry. See microsoft/WSL#4607. + */ +export function parseDefaultDistro(raw: string): null | string { + return ( + String(raw || '') + .replace(/\0/g, '') + .split(/\r?\n/) + .map(line => line.replace(/^\*?\s*/, '').trim()) + .find(Boolean) || null + ) +} + +/** Default WSL distro name (cached). Falls back to `Ubuntu`. */ +export function resolveDefaultWslDistro(): string { + if (cachedDistro) { + return cachedDistro + } + + if (!IS_WINDOWS) { + cachedDistro = 'Ubuntu' + + return cachedDistro + } + + try { + const out = execFileSync('wsl.exe', ['-l', '-q'], { + encoding: 'utf8', + env: { ...process.env, WSL_UTF8: '1' }, + timeout: 2000, + windowsHide: true + }) + cachedDistro = parseDefaultDistro(out) || 'Ubuntu' + } catch { + cachedDistro = 'Ubuntu' + } + + return cachedDistro +} + +// `\\wsl.localhost\` (Win11 / Win10 >= 21364) with a `\\wsl$\` +// fallback for older builds. Probed once; defaults to wsl.localhost. +function wslUncBase(distro: string): string { + if (cachedUncBase) { + return cachedUncBase + } + + const modern = `\\\\wsl.localhost\\${distro}` + const legacy = `\\\\wsl$\\${distro}` + + try { + if (!fs.existsSync(modern) && fs.existsSync(legacy)) { + cachedUncBase = legacy + + return cachedUncBase + } + } catch { + // Network-path probe failed — prefer the modern form. + } + + cachedUncBase = modern + + return cachedUncBase +} + +/** + * A WSL/POSIX path → a path the Windows host can open: `/mnt/c/...` → `C:\...` + * (drvfs mount), any other absolute POSIX path → `\\wsl.localhost\\...`. + * Non-absolute or already-Windows paths pass through. + */ +export function wslPosixToWindowsAccessible(posixPath: string, distro: string = resolveDefaultWslDistro()): string { + const value = String(posixPath || '').trim() + const normalized = value.replace(/\\/g, '/') + + if (!normalized.startsWith('/')) { + return value + } + + const mount = normalized.match(WSL_MOUNT_RE) + + if (mount) { + const tail = (mount[2] || '').replace(/\//g, '\\') + + return tail ? `${mount[1].toUpperCase()}:\\${tail}` : `${mount[1].toUpperCase()}:\\` + } + + const relative = normalized.replace(/^\/+/, '').replace(/\//g, '\\') + + return `${wslUncBase(distro)}\\${relative}` +} + +/** Native folder dialog `defaultPath`: open a WSL cwd in the Windows picker. */ +export function resolvePickerDefaultPath( + defaultPath: string | undefined, + distro: string = resolveDefaultWslDistro() +): string | undefined { + if (!defaultPath) { + return undefined + } + + const value = String(defaultPath).trim() + + return value.startsWith('/') && !WIN_DRIVE_RE.test(value) ? wslPosixToWindowsAccessible(value, distro) : defaultPath +} + +/** fs read path: on Windows, make a WSL cwd readable via its UNC / drive form. */ +export function resolveLocalReadPath(dirPath: string, distro: string = resolveDefaultWslDistro()): string { + const value = String(dirPath || '').trim() + + return IS_WINDOWS && value.startsWith('/') && !WIN_DRIVE_RE.test(value) + ? wslPosixToWindowsAccessible(value, distro) + : value +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 05e193790bfe..28f976a99b4f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -38,7 +38,7 @@ "test:desktop:nsis": "node scripts/test-desktop.mjs nsis", "test:desktop:existing": "node scripts/test-desktop.mjs existing", "test:desktop:fresh": "node scripts/test-desktop.mjs fresh", - "test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts", + "test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/wsl-path-bridge.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-process.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts", "typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", From 3c7b9f2e9d00025216e663e024b1ee6f415d959c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Jul 2026 05:32:54 -0400 Subject: [PATCH 2/3] 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 From 650d04d87917300a3613c4a5e5ac226e21b39428 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Jul 2026 05:32:54 -0400 Subject: [PATCH 3/3] chore(attribution): map VrtxOmega@pm.me -> VrtxOmega (#43809 salvage) --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 89b4da4d7493..378924df7eca 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "VrtxOmega@pm.me": "VrtxOmega", # PR #43809 salvage (desktop: WSL folder-picker path bridge) "palmer@dugoutfantasy.com": "professorpalmer", # PR #48591 salvage (sessions: CLI workspace filter + restore-cwd-on-resume) "true@supersynergy.de": "Supersynergy", # PR #59241 salvage (desktop: workspace path status-bar action) "esthon@gmail.com": "esthonjr", # PR #61950 salvage (desktop: legacy non-git workspace grouping + Windows path identity)