Merge pull request #63105 from NousResearch/bb/salvage-43809-wsl-bridge

fix(desktop): bridge Windows folder-picker paths for WSL backends (supersedes #43809)
This commit is contained in:
brooklyn! 2026-07-12 23:17:50 -04:00 committed by GitHub
commit e589b739ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 279 additions and 24 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

@ -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'
}))

View file

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

View file

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

View file

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

View file

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

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

@ -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)
"135129512+ansel-f@users.noreply.github.com": "ansel-f", # PR #62388 salvage (approval: allow exact verifier temp cleanup without broadening rm safety boundary)
"robert@modern-minds.ai": "Hopfensaft", # PR #31933 salvage (dashboard: align approvals.mode dropdown with canonical engine values)
"palmer@dugoutfantasy.com": "professorpalmer", # PR #48591 salvage (sessions: CLI workspace filter + restore-cwd-on-resume)

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