fix(desktop): scope WSL bridge state by profile

This commit is contained in:
Tranquil-Flow
2026-08-06 21:55:05 +02:00
committed by Teknium
parent b634032fa4
commit deec043276
6 changed files with 311 additions and 33 deletions

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, setWslBridgeActive } from './wsl-path-bridge'
import { resolvePickerDefaultPath, setActiveGatewayProfile, setWslBridgeProfileState } from './wsl-path-bridge'
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
@@ -9898,6 +9898,7 @@ async function ensureBackend(profile) {
if (route.backend === 'primary') {
const connection = await startHermes()
setWslBridgeProfileState(key, connection.mode !== 'remote')
// A shared backend still owes the caller its profile scope, so renderer-side
// WebSocket, filesystem, and cache routing target the selected profile.
@@ -9921,8 +9922,10 @@ async function ensureBackend(profile) {
if (existing) {
existing.lastActiveAt = Date.now()
const connection = await existing.connectionPromise
setWslBridgeProfileState(key, connection.mode !== 'remote')
return existing.connectionPromise
return connection
}
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
@@ -9955,7 +9958,10 @@ async function ensureBackend(profile) {
backendPool.set(key, entry)
startPoolIdleReaper()
return entry.connectionPromise
const connection = await entry.connectionPromise
setWslBridgeProfileState(key, connection.mode !== 'remote')
return connection
}
// ── Registry-scoped backends (multi-connection, PR 2 of the campaign) ──────
@@ -10579,6 +10585,11 @@ async function startHermes() {
}
const connectionAttempt = backendConnectionState.startAttempt()
const primaryProfile = primaryProfileKey()
// Legacy path callers without an explicit profile belong to the primary
// window backend. Profile-scoped callers still pass their key directly.
setActiveGatewayProfile(primaryProfile)
// Classify this boot BEFORE the throwing resolve/mint runs: a remote failure
// must NOT latch (it's transient — see shouldLatchBackendStartFailure), while
@@ -10660,7 +10671,7 @@ async function startHermes() {
// both for an already-saved remote and after first-run remote Apply.
attemptedRemote = primaryBackendIsRemote()
return resolveRemoteBackend(primaryProfileKey())
return resolveRemoteBackend(primaryProfile)
},
waitForDecision: waitForFirstRunSetupChoice,
// Mutual exclusion with an in-app update (#50238). Remote connections
@@ -10673,13 +10684,13 @@ async function startHermes() {
// 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)
setWslBridgeProfileState(primaryProfile, false)
return setup.connection
}
// Local WSL backend — paths are bridgeable.
setWslBridgeActive(true)
setWslBridgeProfileState(primaryProfile, true)
const backend = setup.backend
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
@@ -13949,7 +13960,10 @@ ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => {
try {
// 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)
const bridged = IS_WINDOWS
? resolvePickerDefaultPath(String(options.defaultPath), undefined, options?.profile)
: String(options.defaultPath)
resolvedDefaultPath = bridged ? path.resolve(bridged) : undefined
} catch {
resolvedDefaultPath = undefined
@@ -15041,7 +15055,10 @@ app.whenReady().then(() => {
f12Blocked = readPersistedDisableF12()
// Seed this before the first window exists: a picker can open before
// startHermes() finishes resolving the configured backend.
setWslBridgeActive(!primaryBackendIsRemote())
const primaryProfile = primaryProfileKey()
setActiveGatewayProfile(primaryProfile)
setWslBridgeProfileState(primaryProfile, !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).

View File

@@ -0,0 +1,242 @@
/**
* Profile-scoped eligibility for the WSL path bridge (#66447).
*
* The single-profile tests in wsl-path-bridge.test.ts and the Windows-platform
* gate tests in wsl-path-bridge-gate.test.ts cover the *what* (paths pass
* through unchanged when bridging is disabled) but not the *which profile*. The
* desktop is multi-profile: the renderer can swap the live gateway onto any
* profile (primary or pool) without reloading the window — so the bridge
* eligibility MUST be keyed off the **currently active profile's** backend
* configuration, not a process-global boolean. This file proves the
* per-profile contract:
*
* 1. local primary profile → bridge ON (preserved)
* 2. remote primary profile → bridge OFF (preserved)
* 3. local primary + remote non-primary → bridge OFF when the non-primary
* is active; bridge ON when the primary is active again — **no bleed**.
* 4. remote primary + local non-primary → bridge ON when the non-primary
* is active; bridge OFF when the primary is active again — **no bleed**.
* 5. profile-scoped calls accept a profile argument; absent it falls back
* to the live gateway profile that the renderer announced.
* 6. afterEach resets state so tests don't bleed into each other.
*
* These tests are pure behavior: they assert the eligibility function's output
* and the public selectors' output against observable calls. They do NOT read
* the implementation source — only the public surface exported from
* `./wsl-path-bridge`.
*/
import assert from 'node:assert/strict'
import { afterEach, describe, test } from 'vitest'
import {
isWslBridgeActive,
resolveLocalReadPath,
resolvePickerDefaultPath,
setActiveGatewayProfile,
setWslBridgeActive,
setWslBridgeProfileState,
wslPosixToWindowsAccessible
} from './wsl-path-bridge'
// ── helpers ──────────────────────────────────────────────────────────
const PROFILE_PRIMARY = 'default'
const PROFILE_LOCAL = 'team-local'
const PROFILE_REMOTE = 'team-remote'
/** Reset every profile key the bridge knows about to a clean state. */
afterEach(() => {
setWslBridgeProfileState(PROFILE_PRIMARY, true)
setWslBridgeProfileState(PROFILE_LOCAL, true)
setWslBridgeProfileState(PROFILE_REMOTE, false)
setActiveGatewayProfile(PROFILE_PRIMARY)
setWslBridgeActive(true)
})
// ── single-profile contract (preserved behaviour) ────────────────────
describe('WSL bridge profile eligibility — single-profile contract preserved', () => {
test('primary local → bridge ON (preserved)', () => {
setWslBridgeProfileState(PROFILE_PRIMARY, true)
setActiveGatewayProfile(PROFILE_PRIMARY)
assert.equal(isWslBridgeActive(), true)
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_PRIMARY),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
})
test('primary remote → bridge OFF (preserved)', () => {
setWslBridgeProfileState(PROFILE_PRIMARY, false)
setActiveGatewayProfile(PROFILE_PRIMARY)
assert.equal(isWslBridgeActive(), false)
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_PRIMARY), '/home/alex')
assert.equal(resolveLocalReadPath('/home/alex/proj', undefined, PROFILE_PRIMARY), '/home/alex/proj')
})
})
// ── multi-profile regression (the gap) ────────────────────────────────
describe('WSL bridge profile eligibility — multi-profile (no cross-profile bleed)', () => {
test('local primary + remote non-primary → non-primary OFF, primary ON', () => {
// Primary is a local backend (WSL on this Windows host). A second profile
// points at a remote host whose POSIX paths the Windows host CANNOT open
// via wsl.exe — bridging must be OFF for it.
setWslBridgeProfileState(PROFILE_PRIMARY, true)
setWslBridgeProfileState(PROFILE_REMOTE, false)
// Non-primary remote is foregrounded — bridge must be OFF for it.
setActiveGatewayProfile(PROFILE_REMOTE)
assert.equal(isWslBridgeActive(), false)
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_REMOTE), '/home/alex')
assert.equal(resolveLocalReadPath('/home/alex/proj', undefined, PROFILE_REMOTE), '/home/alex/proj')
// Swap back to local primary — bridge must be ON again, no bleed.
setActiveGatewayProfile(PROFILE_PRIMARY)
assert.equal(isWslBridgeActive(), true)
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_PRIMARY),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
})
test('remote primary + local non-primary → non-primary ON, primary OFF', () => {
// Primary is remote (no local WSL paths). A second profile is local —
// bridging should be ON for it because its paths CAN be opened locally.
setWslBridgeProfileState(PROFILE_PRIMARY, false)
setWslBridgeProfileState(PROFILE_LOCAL, true)
// Local non-primary foregrounded — bridge ON for it.
setActiveGatewayProfile(PROFILE_LOCAL)
assert.equal(isWslBridgeActive(), true)
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_LOCAL),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
// Swap back to remote primary — bridge OFF for it, no bleed.
setActiveGatewayProfile(PROFILE_PRIMARY)
assert.equal(isWslBridgeActive(), false)
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_PRIMARY), '/home/alex')
})
test('three profiles: each profile behaves independently', () => {
setWslBridgeProfileState(PROFILE_PRIMARY, true)
setWslBridgeProfileState(PROFILE_LOCAL, true)
setWslBridgeProfileState(PROFILE_REMOTE, false)
// Same path, different profiles, different outcomes.
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_PRIMARY),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_LOCAL),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_REMOTE), '/home/alex')
})
})
// ── profile-argument contract ────────────────────────────────────────
describe('WSL bridge profile eligibility — selector argument contract', () => {
test("selector with explicit profile → that profile's bridge state", () => {
setWslBridgeProfileState(PROFILE_PRIMARY, true)
setWslBridgeProfileState(PROFILE_REMOTE, false)
setActiveGatewayProfile(PROFILE_PRIMARY)
// Explicit profile wins over the live gateway.
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_REMOTE), '/home/alex')
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_PRIMARY),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
})
test("selector with no profile → live gateway profile's bridge state", () => {
setWslBridgeProfileState(PROFILE_PRIMARY, true)
setWslBridgeProfileState(PROFILE_REMOTE, false)
setActiveGatewayProfile(PROFILE_REMOTE)
// No profile argument → fallback to active gateway profile (remote → OFF).
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '/home/alex')
assert.equal(resolveLocalReadPath('/home/alex/proj'), '/home/alex/proj')
setActiveGatewayProfile(PROFILE_PRIMARY)
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex')
})
test('selector with unknown profile → bridge ON (defaults to active for new profiles)', () => {
// A profile that has never been seeded should default to the safe
// "bridge ON" behaviour so a brand-new local profile isn't accidentally
// disabled. The renderer seeds the state at boot; an unknown key here is
// either a renderer race or a profile created mid-session.
setWslBridgeProfileState(PROFILE_PRIMARY, false)
setActiveGatewayProfile(PROFILE_PRIMARY)
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', 'unknown-profile'),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
})
})
// ── legacy back-compat: setWslBridgeActive targets the active profile ─
describe('WSL bridge profile eligibility — legacy toggle targets active profile', () => {
test('setWslBridgeActive(false) flips the active profile, not a process-global', () => {
setWslBridgeProfileState(PROFILE_PRIMARY, true)
setWslBridgeProfileState(PROFILE_REMOTE, true)
setActiveGatewayProfile(PROFILE_REMOTE)
setWslBridgeActive(false)
// Active profile (REMOTE) flipped to OFF; primary untouched.
assert.equal(isWslBridgeActive(), false)
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_PRIMARY),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_REMOTE), '/home/alex')
})
test('setWslBridgeActive(true) restores the active profile only', () => {
setWslBridgeProfileState(PROFILE_PRIMARY, true)
setWslBridgeProfileState(PROFILE_REMOTE, false)
setActiveGatewayProfile(PROFILE_REMOTE)
setWslBridgeActive(true)
// Active (REMOTE) restored; primary unchanged.
assert.equal(isWslBridgeActive(), true)
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_REMOTE),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
assert.equal(
resolvePickerDefaultPath('/home/alex', 'Ubuntu', PROFILE_PRIMARY),
'\\\\wsl.localhost\\Ubuntu\\home\\alex'
)
})
})
// ── wslPosixToWindowsAccessible stays pure / unchanged ───────────────
describe('WSL bridge profile eligibility — POSIX translation stays pure', () => {
test('wslPosixToWindowsAccessible ignores bridge state (pure translation)', () => {
setWslBridgeProfileState(PROFILE_PRIMARY, false)
setActiveGatewayProfile(PROFILE_PRIMARY)
// The translator does NOT consult the bridge state — it's pure POSIX → UNC.
// Tests elsewhere assert that the bridge gate short-circuits BEFORE this
// function is reached. A regression here would mean coupling leaked into
// a helper that should stay side-effect-free.
assert.equal(
wslPosixToWindowsAccessible('/home/alex/proj', 'Ubuntu'),
'\\\\wsl.localhost\\Ubuntu\\home\\alex\\proj'
)
assert.equal(wslPosixToWindowsAccessible('/mnt/c/Users/alex', 'Ubuntu'), 'C:\\Users\\alex')
})
})

View File

@@ -17,25 +17,39 @@ 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.
* WSL path eligibility belongs to the backend profile that produced the path.
* A single desktop process can keep a local primary backend and a remote pool
* backend alive simultaneously, so a process-global boolean can bleed between
* them. Unknown profiles retain the historical local default until Electron
* resolves and records their actual backend mode.
*/
let wslBridgeActive = true
const DEFAULT_WSL_BRIDGE_PROFILE = 'default'
const wslBridgeProfiles = new Map<string, boolean>()
let activeWslBridgeProfile = DEFAULT_WSL_BRIDGE_PROFILE
/** Enable/disable WSL path bridging at runtime (called by main.ts). */
export function setWslBridgeActive(active: boolean): void {
wslBridgeActive = active
function normalizeWslBridgeProfile(profile?: null | string): string {
return String(profile || '').trim() || DEFAULT_WSL_BRIDGE_PROFILE
}
/** Test seam: is the bridge currently active? */
export function isWslBridgeActive(): boolean {
return wslBridgeActive
/** Select the profile used by legacy callers that cannot pass one explicitly. */
export function setActiveGatewayProfile(profile?: null | string): void {
activeWslBridgeProfile = normalizeWslBridgeProfile(profile)
}
/** Record whether paths returned by one profile belong to this host's WSL. */
export function setWslBridgeProfileState(profile: null | string, active: boolean): void {
wslBridgeProfiles.set(normalizeWslBridgeProfile(profile), Boolean(active))
}
/** Backward-compatible toggle: update only the current fallback profile. */
export function setWslBridgeActive(active: boolean): void {
setWslBridgeProfileState(activeWslBridgeProfile, active)
}
export function isWslBridgeActive(profile?: null | string): boolean {
const key = profile == null ? activeWslBridgeProfile : normalizeWslBridgeProfile(profile)
return wslBridgeProfiles.get(key) ?? true
}
/**
@@ -138,7 +152,8 @@ 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
distro?: string,
profile?: null | string
): string | undefined {
if (!defaultPath) {
return undefined
@@ -147,7 +162,7 @@ export function resolvePickerDefaultPath(
// 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) {
if (!isWslBridgeActive(profile)) {
return defaultPath
}
@@ -159,14 +174,14 @@ export function resolvePickerDefaultPath(
}
/** fs read path: on Windows, make a WSL cwd readable via its UNC / drive form. */
export function resolveLocalReadPath(dirPath: string, distro?: string): string {
export function resolveLocalReadPath(dirPath: string, distro?: string, profile?: null | 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) {
if (!isWslBridgeActive(profile)) {
return value
}

View File

@@ -1311,6 +1311,8 @@ export interface HermesSelectPathsOptions {
defaultPath?: string
directories?: boolean
multiple?: boolean
/** Backend profile that produced defaultPath; Electron uses it for WSL gating. */
profile?: string
filters?: Array<{ name: string; extensions: string[] }>
}

View File

@@ -76,7 +76,7 @@ describe('desktop filesystem facade', () => {
})
it('uses local Electron filesystem methods in local mode', async () => {
$connection.set({ mode: 'local' } as never)
$connection.set({ mode: 'local', profile: 'team-local' } as never)
await expect(readDesktopDir('/work')).resolves.toEqual({
entries: [{ name: 'local', path: '/local', isDirectory: true }]
@@ -90,7 +90,7 @@ describe('desktop filesystem facade', () => {
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
expect(gitRoot).toHaveBeenCalledWith('/work')
expect(selectPaths).toHaveBeenCalledWith({ directories: true })
expect(selectPaths).toHaveBeenCalledWith({ directories: true, profile: 'team-local' })
expect(api).not.toHaveBeenCalled()
})
@@ -216,12 +216,12 @@ describe('desktop filesystem facade', () => {
it('uses the local Electron picker for remote file selection', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
$connection.set({ mode: 'remote', profile: 'team-remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual(['/local'])
expect(selectPaths).toHaveBeenCalledWith({ directories: false, multiple: false })
expect(selectPaths).toHaveBeenCalledWith({ directories: false, multiple: false, profile: 'team-remote' })
expect(remoteSelect).not.toHaveBeenCalled()
})

View File

@@ -201,13 +201,15 @@ export async function desktopFileDiff(repoRoot: string, filePath: string): Promi
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
const desktop = bridge()
const profile = desktopFsProfile()
const localOptions = profile ? { ...options, profile } : options
if (!isDesktopFsRemoteMode()) {
return desktop.selectPaths(options)
return desktop.selectPaths(localOptions)
}
if (!options?.directories) {
return desktop.selectPaths(options)
return desktop.selectPaths(localOptions)
}
return remotePicker ? remotePicker.selectPaths({ ...options, multiple: false }) : []