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:
Tranquil-Flow
2026-08-05 12:44:07 +02:00
committed by Teknium
parent 930be347f1
commit b634032fa4
4 changed files with 217 additions and 7 deletions
+13 -1
View File
@@ -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()
})
})
+86 -2
View File
@@ -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)
})
+43 -4
View File
@@ -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
}