feat(desktop): bridge WSL paths for a Windows host + WSL backend

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\<distro>\...`
- 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 <VrtxOmega@pm.me>
This commit is contained in:
Brooklyn Nicholson 2026-07-12 05:32:54 -04:00
parent b4829643d6
commit 88fbc8825c
5 changed files with 188 additions and 3 deletions

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