From deec0432765680c704658e36697a49ce488e68a1 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Thu, 6 Aug 2026 21:55:05 +0200 Subject: [PATCH] fix(desktop): scope WSL bridge state by profile --- apps/desktop/electron/main.ts | 33 ++- .../electron/wsl-path-bridge-profile.test.ts | 242 ++++++++++++++++++ apps/desktop/electron/wsl-path-bridge.ts | 53 ++-- apps/desktop/src/global.d.ts | 2 + apps/desktop/src/lib/desktop-fs.test.ts | 8 +- apps/desktop/src/lib/desktop-fs.ts | 6 +- 6 files changed, 311 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/electron/wsl-path-bridge-profile.test.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b7546f3339..adf7338ef5 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, 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). diff --git a/apps/desktop/electron/wsl-path-bridge-profile.test.ts b/apps/desktop/electron/wsl-path-bridge-profile.test.ts new file mode 100644 index 0000000000..8f83e0fa00 --- /dev/null +++ b/apps/desktop/electron/wsl-path-bridge-profile.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/electron/wsl-path-bridge.ts b/apps/desktop/electron/wsl-path-bridge.ts index c83a217d17..74b991643c 100644 --- a/apps/desktop/electron/wsl-path-bridge.ts +++ b/apps/desktop/electron/wsl-path-bridge.ts @@ -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() +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 } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index bf85c94f35..98db0b39b3 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -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[] }> } diff --git a/apps/desktop/src/lib/desktop-fs.test.ts b/apps/desktop/src/lib/desktop-fs.test.ts index 2a3d0c54e6..274e5b0440 100644 --- a/apps/desktop/src/lib/desktop-fs.test.ts +++ b/apps/desktop/src/lib/desktop-fs.test.ts @@ -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() }) diff --git a/apps/desktop/src/lib/desktop-fs.ts b/apps/desktop/src/lib/desktop-fs.ts index d2ebc3ac10..6123197c68 100644 --- a/apps/desktop/src/lib/desktop-fs.ts +++ b/apps/desktop/src/lib/desktop-fs.ts @@ -201,13 +201,15 @@ export async function desktopFileDiff(repoRoot: string, filePath: string): Promi export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise { 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 }) : []