fix(desktop): gate WSL bridge for remote backends
Seed backend-mode state before creating the first window and update it after runtime resolution. Keep remote reconnects gated until a local backend is confirmed. Cover Windows child-process suppression and bridge state transitions.
This commit is contained in:
@@ -353,7 +353,7 @@ import { installWindowsSystemCaTrust } from './windows-system-ca'
|
||||
import { readWindowsUserEnvVar } from './windows-user-env'
|
||||
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
|
||||
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
|
||||
import { resolvePickerDefaultPath } from './wsl-path-bridge'
|
||||
import { resolvePickerDefaultPath, setWslBridgeActive } from './wsl-path-bridge'
|
||||
|
||||
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
|
||||
|
||||
@@ -10669,9 +10669,18 @@ async function startHermes() {
|
||||
})
|
||||
|
||||
if (setup.kind === 'remote') {
|
||||
// Paths from the remote backend belong to a host the Windows desktop
|
||||
// cannot open via wsl.exe — disable WSL path bridging so native dialogs
|
||||
// and file panels don't spawn wsl.exe (or the interactive install prompt
|
||||
// on WSL-less machines) for unresolvable paths. (#66433)
|
||||
setWslBridgeActive(false)
|
||||
|
||||
return setup.connection
|
||||
}
|
||||
|
||||
// Local WSL backend — paths are bridgeable.
|
||||
setWslBridgeActive(true)
|
||||
|
||||
const backend = setup.backend
|
||||
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
|
||||
backend.args = getBackendArgsForRuntime(backend)
|
||||
@@ -15030,6 +15039,9 @@ app.whenReady().then(() => {
|
||||
registerPowerResumeListeners()
|
||||
keepAwake.set(readPersistedKeepAwake())
|
||||
f12Blocked = readPersistedDisableF12()
|
||||
// Seed this before the first window exists: a picker can open before
|
||||
// startHermes() finishes resolving the configured backend.
|
||||
setWslBridgeActive(!primaryBackendIsRemote())
|
||||
// Quick Entry's global chord — registered on ready so a cold launch restores
|
||||
// it without the renderer visiting Settings. A failed registration is logged
|
||||
// here and surfaced in Settings via the IPC state (never silent).
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Windows-platform regression for the WSL path-bridge gate (#66433).
|
||||
*
|
||||
* The behavioural tests in wsl-path-bridge.test.ts prove the no-op contract
|
||||
* (paths pass through unchanged when the bridge is inactive). This file goes
|
||||
* one rung further: with `process.platform` stubbed to `win32` and
|
||||
* `child_process.execFileSync` mocked, it proves the actual `wsl.exe` spawn is
|
||||
* suppressed — not just that the return value looks right.
|
||||
*
|
||||
* Each test re-imports the module fresh (vi.resetModules) so IS_WINDOWS is
|
||||
* re-evaluated against the stubbed platform.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
const execFileSyncMock = vi.fn(() => 'Ubuntu\n')
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
|
||||
|
||||
describe('WSL bridge gate on Windows (#66433)', () => {
|
||||
const realPlatform = process.platform
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
|
||||
vi.resetModules()
|
||||
execFileSyncMock.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true })
|
||||
})
|
||||
|
||||
test('wsl.exe IS probed for a POSIX path when the bridge is active (control)', async () => {
|
||||
const { resolveLocalReadPath } = await import('./wsl-path-bridge')
|
||||
resolveLocalReadPath('/home/ubuntu/project')
|
||||
expect(execFileSyncMock).toHaveBeenCalled()
|
||||
// Sanity: it really was wsl.exe, not some other binary.
|
||||
expect(execFileSyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'wsl.exe',
|
||||
expect.arrayContaining(['-l', '-q']),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
test('wsl.exe is NEVER probed when the bridge is inactive — even for POSIX paths', async () => {
|
||||
const { resolveLocalReadPath, setWslBridgeActive } = await import('./wsl-path-bridge')
|
||||
setWslBridgeActive(false)
|
||||
// A POSIX path that WOULD trigger bridging (and the wsl.exe probe) when
|
||||
// active — but with the bridge off, resolveDefaultWslDistro is never
|
||||
// reached because resolveLocalReadPath returns before it.
|
||||
const result = resolveLocalReadPath('/home/ubuntu/project')
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
expect(result).toBe('/home/ubuntu/project')
|
||||
})
|
||||
|
||||
test('the picker default-path also skips the wsl.exe probe when inactive', async () => {
|
||||
const { resolvePickerDefaultPath, setWslBridgeActive } = await import('./wsl-path-bridge')
|
||||
setWslBridgeActive(false)
|
||||
const result = resolvePickerDefaultPath('/home/ubuntu')
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
expect(result).toBe('/home/ubuntu')
|
||||
})
|
||||
|
||||
test('re-enabling the bridge restores wsl.exe probing', async () => {
|
||||
const { resolveLocalReadPath, setWslBridgeActive } = await import('./wsl-path-bridge')
|
||||
setWslBridgeActive(false)
|
||||
resolveLocalReadPath('/home/ubuntu/project')
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled()
|
||||
|
||||
setWslBridgeActive(true)
|
||||
execFileSyncMock.mockClear()
|
||||
resolveLocalReadPath('/home/ubuntu/project')
|
||||
expect(execFileSyncMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
import { afterEach, test } from 'vitest'
|
||||
|
||||
import { parseDefaultDistro, resolvePickerDefaultPath, wslPosixToWindowsAccessible } from './wsl-path-bridge'
|
||||
import {
|
||||
isWslBridgeActive,
|
||||
parseDefaultDistro,
|
||||
resolveLocalReadPath,
|
||||
resolvePickerDefaultPath,
|
||||
setWslBridgeActive,
|
||||
wslPosixToWindowsAccessible
|
||||
} from './wsl-path-bridge'
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Reset the bridge to its default active state after every test so no test
|
||||
* leaks global state into the next one. */
|
||||
afterEach(() => {
|
||||
setWslBridgeActive(true)
|
||||
})
|
||||
|
||||
// ── distro parsing (unchanged) ───────────────────────────────────────
|
||||
|
||||
test('parseDefaultDistro reads the first distro from clean utf-8 output', () => {
|
||||
assert.equal(parseDefaultDistro('Ubuntu\nDebian\n'), 'Ubuntu')
|
||||
@@ -20,6 +37,8 @@ test('parseDefaultDistro strips the default-marker and blank lines', () => {
|
||||
assert.equal(parseDefaultDistro(' \n\n'), null)
|
||||
})
|
||||
|
||||
// ── wslPosixToWindowsAccessible ──────────────────────────────────────
|
||||
|
||||
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:\\')
|
||||
@@ -34,8 +53,73 @@ test('wslPosixToWindowsAccessible leaves non-absolute / already-Windows paths al
|
||||
assert.equal(wslPosixToWindowsAccessible('relative/dir', 'Ubuntu'), 'relative/dir')
|
||||
})
|
||||
|
||||
// ── resolvePickerDefaultPath (bridge active) ─────────────────────────
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// ── bridge active / inactive ─────────────────────────────────────────
|
||||
|
||||
test('bridge defaults to active', () => {
|
||||
assert.equal(isWslBridgeActive(), true)
|
||||
})
|
||||
|
||||
test('setWslBridgeActive(false) → resolvePickerDefaultPath passes raw path through without bridging', () => {
|
||||
setWslBridgeActive(false)
|
||||
// Even a clear WSL POSIX path must pass through unchanged when the bridge
|
||||
// is inactive — no distro probe, no wsl.exe, no install prompt.
|
||||
assert.equal(resolvePickerDefaultPath('/home/alex'), '/home/alex')
|
||||
assert.equal(resolvePickerDefaultPath('/mnt/c/Users/alex'), '/mnt/c/Users/alex')
|
||||
// Windows paths and empties are unaffected either way.
|
||||
assert.equal(resolvePickerDefaultPath('C:\\proj'), 'C:\\proj')
|
||||
assert.equal(resolvePickerDefaultPath(undefined), undefined)
|
||||
})
|
||||
|
||||
test('setWslBridgeActive(false) → resolveLocalReadPath passes raw path through without bridging', () => {
|
||||
setWslBridgeActive(false)
|
||||
// resolveLocalReadPath is used by fs-read-dir to make WSL paths readable
|
||||
// on the Windows host. When the bridge is inactive (remote gateway), the
|
||||
// raw POSIX path must be returned as-is — no UNC rewriting, no distro
|
||||
// resolution. The downstream fs call will fail gracefully on non-WSL
|
||||
// hosts, which is the desired behaviour.
|
||||
assert.equal(resolveLocalReadPath('/home/alex/proj'), '/home/alex/proj')
|
||||
assert.equal(resolveLocalReadPath('/mnt/c/Users/alex'), '/mnt/c/Users/alex')
|
||||
// Non-POSIX paths are never bridged regardless of state.
|
||||
assert.equal(resolveLocalReadPath('C:\\Users\\alex'), 'C:\\Users\\alex')
|
||||
assert.equal(resolveLocalReadPath(''), '')
|
||||
})
|
||||
|
||||
test('setWslBridgeActive(true) restores picker bridging', () => {
|
||||
setWslBridgeActive(false)
|
||||
assert.equal(resolvePickerDefaultPath('/home/alex'), '/home/alex')
|
||||
|
||||
setWslBridgeActive(true)
|
||||
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex')
|
||||
})
|
||||
|
||||
test('toggling the bridge is idempotent and does not corrupt cached state', () => {
|
||||
// Toggle twice each way.
|
||||
setWslBridgeActive(false)
|
||||
assert.equal(isWslBridgeActive(), false)
|
||||
setWslBridgeActive(false)
|
||||
assert.equal(isWslBridgeActive(), false)
|
||||
|
||||
setWslBridgeActive(true)
|
||||
assert.equal(isWslBridgeActive(), true)
|
||||
setWslBridgeActive(true)
|
||||
assert.equal(isWslBridgeActive(), true)
|
||||
|
||||
// Bridging still works after the toggles.
|
||||
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex')
|
||||
})
|
||||
|
||||
// ── state isolation: every test sees a clean active bridge ────────────
|
||||
|
||||
test('state isolation: bridge is active after a previous test toggled it off', () => {
|
||||
// This test relies on afterEach resetting the bridge.
|
||||
// If isolation is broken, isWslBridgeActive() would be false here.
|
||||
assert.equal(isWslBridgeActive(), true)
|
||||
})
|
||||
|
||||
@@ -16,6 +16,28 @@ const WSL_MOUNT_RE = /^\/mnt\/([a-z])(?:\/(.*))?$/i
|
||||
let cachedDistro: null | string = null
|
||||
let cachedUncBase: null | string = null
|
||||
|
||||
/**
|
||||
* Whether WSL path bridging is active. The bridge only makes sense when the
|
||||
* desktop runs on Windows AND the gateway is a *local* backend (e.g. running
|
||||
* inside WSL on the same machine). When the gateway is a remote host, the
|
||||
* POSIX paths it reports belong to a machine the Windows host cannot open via
|
||||
* `wsl.exe` — bridging them only spawns `wsl.exe` (and on WSL-less machines,
|
||||
* the interactive "Install WSL" console prompt) for paths that can never be
|
||||
* resolved locally. main.ts toggles this off once it resolves a remote
|
||||
* backend. Defaults to active so a local Windows+WSL boot is unaffected.
|
||||
*/
|
||||
let wslBridgeActive = true
|
||||
|
||||
/** Enable/disable WSL path bridging at runtime (called by main.ts). */
|
||||
export function setWslBridgeActive(active: boolean): void {
|
||||
wslBridgeActive = active
|
||||
}
|
||||
|
||||
/** Test seam: is the bridge currently active? */
|
||||
export function isWslBridgeActive(): boolean {
|
||||
return wslBridgeActive
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the default distro from `wsl.exe -l -q` output.
|
||||
*
|
||||
@@ -116,22 +138,39 @@ export function wslPosixToWindowsAccessible(posixPath: string, distro: string =
|
||||
/** Native folder dialog `defaultPath`: open a WSL cwd in the Windows picker. */
|
||||
export function resolvePickerDefaultPath(
|
||||
defaultPath: string | undefined,
|
||||
distro: string = resolveDefaultWslDistro()
|
||||
distro?: string
|
||||
): string | undefined {
|
||||
if (!defaultPath) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Remote-gateway POSIX paths can't be opened via wsl.exe — no-op the bridge
|
||||
// so the native dialog gets the raw path (it falls back gracefully) instead
|
||||
// of triggering a wsl.exe spawn / install prompt. (#66433)
|
||||
if (!wslBridgeActive) {
|
||||
return defaultPath
|
||||
}
|
||||
|
||||
const value = String(defaultPath).trim()
|
||||
|
||||
return value.startsWith('/') && !WIN_DRIVE_RE.test(value) ? wslPosixToWindowsAccessible(value, distro) : defaultPath
|
||||
return value.startsWith('/') && !WIN_DRIVE_RE.test(value)
|
||||
? wslPosixToWindowsAccessible(value, distro ?? resolveDefaultWslDistro())
|
||||
: 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 {
|
||||
export function resolveLocalReadPath(dirPath: string, distro?: string): string {
|
||||
const value = String(dirPath || '').trim()
|
||||
|
||||
// In remote-gateway mode the POSIX paths belong to a host the Windows
|
||||
// desktop cannot open locally — skip the WSL bridge entirely (no distro
|
||||
// probe, no wsl.exe) so the file panel never spawns the install prompt on
|
||||
// WSL-less machines. (#66433)
|
||||
if (!wslBridgeActive) {
|
||||
return value
|
||||
}
|
||||
|
||||
return IS_WINDOWS && value.startsWith('/') && !WIN_DRIVE_RE.test(value)
|
||||
? wslPosixToWindowsAccessible(value, distro)
|
||||
? wslPosixToWindowsAccessible(value, distro ?? resolveDefaultWslDistro())
|
||||
: value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user