diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a1bb265ab4..b7546f3339 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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). diff --git a/apps/desktop/electron/wsl-path-bridge-gate.test.ts b/apps/desktop/electron/wsl-path-bridge-gate.test.ts new file mode 100644 index 0000000000..1b864f922e --- /dev/null +++ b/apps/desktop/electron/wsl-path-bridge-gate.test.ts @@ -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() + }) +}) diff --git a/apps/desktop/electron/wsl-path-bridge.test.ts b/apps/desktop/electron/wsl-path-bridge.test.ts index 3dcf18e7b3..52073af3c3 100644 --- a/apps/desktop/electron/wsl-path-bridge.test.ts +++ b/apps/desktop/electron/wsl-path-bridge.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/wsl-path-bridge.ts b/apps/desktop/electron/wsl-path-bridge.ts index 60d3c0cf18..c83a217d17 100644 --- a/apps/desktop/electron/wsl-path-bridge.ts +++ b/apps/desktop/electron/wsl-path-bridge.ts @@ -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 }