mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
b4829643d6
commit
88fbc8825c
5 changed files with 188 additions and 3 deletions
|
|
@ -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'
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
40
apps/desktop/electron/wsl-path-bridge.test.ts
Normal file
40
apps/desktop/electron/wsl-path-bridge.test.ts
Normal 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)
|
||||
})
|
||||
136
apps/desktop/electron/wsl-path-bridge.ts
Normal file
136
apps/desktop/electron/wsl-path-bridge.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue