Desktop folder picker broken for WSL/remote gateway
NousResearch/hermes-agent · cluster 43744 · apps/desktop
Data as of
Nothing open on GitHub. Fix merged in #63105. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #63105. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #63105. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Reports and fixes around the desktop folder picker showing incorrect Windows paths or local filesystem paths when using WSL backends or remote gateway connections. Includes related work on a remote-gateway-aware picker and git tooling.
Open issues0
Open PRs0
Fixes on record
-
#63105 fix(desktop): bridge Windows folder-picker paths for WSL backends (supersedes #43809)
files:
acp_adapter/session.py,apps/desktop/electron/fs-read-dir.ts,apps/desktop/electron/main.ts,apps/desktop/electron/wsl-path-bridge.test.ts,apps/desktop/electron/wsl-path-bridge.ts,apps/desktop/package.json,hermes_constants.py,scripts/release.py,tests/test_hermes_constants.py,tui_gateway/server.pyShow the diff
-
acp_adapter/session.py@@ -26,31 +26,18 @@ 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): -
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' })) -
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 } -
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) +}) -
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\<distro>` (Win11 / Win10 >= 21364) with a `\\wsl$\<distro>` +// 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\<distro>\...`. + * 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 +} -
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", -
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/<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 -
scripts/release.py@@ -45,6 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "[redacted:email]": "VrtxOmega", # PR #43809 salvage (desktop: WSL folder-picker path bridge) "[redacted:email]": "ansel-f", # PR #62388 salvage (approval: allow exact verifier temp cleanup without broadening rm safety boundary) "[redacted:email]": "Hopfensaft", # PR #31933 salvage (dashboard: align approvals.mode dropdown with canonical engine values) "[redacted:email]": "professorpalmer", # PR #48591 salvage (sessions: CLI workspace filter + restore-cwd-on-resume) -
tests/test_hermes_constants.py@@ -834,3 +834,38 @@ def test_symlink_to_empty_dir_returns_new(self, tmp_path, monkeypatch): 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" -
tui_gateway/server.py@@ -1859,7 +1859,10 @@ def _run() -> 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
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#43809 fix(desktop): bridge Windows folder picker paths for WSL backends · superseded by another PR →
#63105
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.39: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #63105
- Closed issues
- 2
- Attempts
- 1 PR closed without merging
- Fix link checked
- 13 Jul 2026