feat(wsl): serve saved WSL work behind a closed hosts gate (gate states, kill switch 1/2) (#34683)

* feat(wsl): serve saved WSL work behind a closed hosts gate (serve-only)

The WSL hosts gate gains three states. A closed gate with a saved WSL host or a
committed move becomes serve-only: saved hosts stay listed and reachable,
committed rewrites, retention, interrupted-move recovery and the way back keep
running, and the preload API is exposed through a stamped window argument.
Adding hosts and starting moves still need the open gate. With nothing saved
the gate stays closed exactly as before.

* fix(wsl): stamp serve-only into the dashboard popout and Space windows too

They load the same preload, so without the argument they hid saved WSL hosts'
status and Connect in serve-only. One helper now supplies the argument for every
window on the shared preload.

* fix(wsl): reviewer follow-ups for the serve-only gate

- Refuse wslHosts:add unless the internal gate is open here, so this PR is safe
  on its own.
- Promote on any move journal entry, so recovery can reset an in-flight move
  whose host record is gone.
- List unfinished moves in serve-only.
- An unreadable WSL deployment store fails closed in serve-only too, through a
  shared serving flag main sets at startup.
- Report the gate state in wsl_usage_snapshot as an optional field.
This commit is contained in:
Neil
2026-10-28 00:20:39 -07:00
committed by GitHub
parent b287531024
commit ad8d35eb59
31 changed files with 586 additions and 71 deletions
+5 -2
View File
@@ -35,7 +35,8 @@ import { ProfileStateRecoveryRequiredError } from './persistence/profile-state/p
import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths'
import { leaseAppImageGenerationForCurrentProcess } from './cli/appimage-generation-lease'
import { wakeRememberedWslHosts } from './wsl/wsl-host-early-wake'
import { wslHostsGateOpen } from './wsl/wsl-host-gate'
import { wslHostsServing } from './wsl/wsl-host-gate'
import { configureWslHostsGateFromDisk } from './wsl/wsl-host-gate-startup'
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
return openMainWindowController(options)
@@ -112,8 +113,10 @@ const preflightReady =
if (preflightReady) {
enableLinearRegexpFallback()
void leaseAppImageGenerationForCurrentProcess()
// Before any gate check: saved WSL work keeps a closed gate serving it.
configureWslHostsGateFromDisk(app.getPath('userData'))
// Why this early: a cold distro takes seconds to boot; starting now overlaps it with app startup.
if (process.platform === 'win32' && wslHostsGateOpen()) {
if (process.platform === 'win32' && wslHostsServing()) {
wakeRememberedWslHosts(app.getPath('userData'))
}
app.on('open-url', (event, url) => {
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
type Handler = (event: unknown, request?: unknown) => unknown
@@ -42,6 +42,7 @@ const service = vi.hoisted(() => ({
})
),
remove: vi.fn(async () => ({ stopped: true })),
hasServerFiles: vi.fn(() => true),
recordForEnvironment: vi.fn(() => ({ distro: 'Ubuntu', environmentId: 'wsl:Ubuntu' }))
}))
vi.mock('../wsl/wsl-host-service-instance', () => ({
@@ -64,7 +65,13 @@ function register(): void {
describe('WSL host IPC: the user Add and Remove (D3a)', () => {
// Removes are reported by the host service itself (see wsl-host-service.test.ts), so both the
// sidebar and Settings paths count; here the service is mocked and only the Add is the IPC's.
afterEach(() => {
vi.unstubAllEnvs()
service.add.mockClear()
})
it('reports a successful Add, never a failed one', async () => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
register()
const heard: WslHostUserAction[] = []
const stop = onWslHostUserAction((action) => heard.push(action))
@@ -75,4 +82,17 @@ describe('WSL host IPC: the user Add and Remove (D3a)', () => {
stop()
expect(heard).toEqual([{ kind: 'added', distro: 'Ubuntu' }])
})
it('refuses an Add in serve-only, keeping saved hosts usable', async () => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
register()
expect(await handlers.get('wslHosts:add')?.({}, { distro: 'Ubuntu' })).toMatchObject({
ok: false,
kind: 'disabled'
})
expect(service.add).not.toHaveBeenCalled()
await handlers.get('wslHosts:remove')?.({}, { environmentId: 'wsl:Ubuntu' })
expect(service.remove).toHaveBeenCalledWith('wsl:Ubuntu')
})
})
+11 -3
View File
@@ -18,7 +18,11 @@ import { WslHostService } from '../wsl/wsl-host-service'
import { WslHostVisibleWorkByWindow } from './wsl-host-visible-work-windows'
import type { WslHostAutomationWake } from '../wsl/wsl-host-automation-wake'
import { createWslHostAutomationWake } from './wsl-host-automation-wake-wiring'
import { wslHostsGateOpen } from '../wsl/wsl-host-gate'
import {
WSL_HOSTS_NEW_WORK_PAUSED_MESSAGE,
wslHostsGateOpen,
wslHostsServing
} from '../wsl/wsl-host-gate'
import { getWslHostService, setWslHostService } from '../wsl/wsl-host-service-instance'
import { retireRemovedRuntimeEnvironment } from './runtime-environments/runtime-environment-removal-cleanup'
import { getRuntimeEnvironmentStatus } from './runtime-environments/runtime-environment-status-probe'
@@ -108,8 +112,8 @@ type WslHostHandlerArgs = {
}
function createService(args: WslHostHandlerArgs): WslHostService | null {
// A closed gate must prevent service adoption, WSL spawns and reconnects.
if (!wslHostsGateOpen()) {
// A closed gate must prevent service adoption, WSL spawns and reconnects; serve-only keeps saved hosts.
if (!wslHostsServing()) {
return null
}
const fake = readFakeWslHostsConfig()
@@ -224,6 +228,10 @@ export function registerWslHostHandlers(args: WslHostHandlerArgs): void {
_event,
request: { distro: string; startIfStopped?: boolean }
): Promise<WslHostAddResult> => {
// Serve-only keeps saved hosts working but admits none.
if (!wslHostsGateOpen()) {
return { ok: false, kind: 'disabled', message: WSL_HOSTS_NEW_WORK_PAUSED_MESSAGE }
}
const result = await wsl.add(request)
if (result.ok) {
noteWslHostUserAction({ kind: 'added', distro: request.distro })
+23
View File
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../shared/wsl-host-internal-gate'
import { configureWslHostsGate, resetWslHostsGateForTests } from '../wsl/wsl-host-gate'
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
@@ -95,6 +96,28 @@ describe('WSL move IPC', () => {
expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([])
})
it('keeps listing unfinished moves and pending rewrites in serve-only, and nothing when closed', () => {
rmSync(userDataPath, { recursive: true, force: true })
userDataPath = mkdtempSync(join(tmpdir(), 'wsl-move-ipc-'))
seedWslMoveCutover(
userDataPath,
cutover('move-2', 'destination-committed', { automationsToReenable: ['automation-1'] })
)
const open = invoke('wslMove:listUnfinished')
expect(open).toEqual([expect.objectContaining({ migrationId: 'move-2' })])
const pending = invoke('wslMove:listPendingRendererRewrites')
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
expect(invoke('wslMove:listUnfinished')).toEqual([])
configureWslHostsGate({ hasSavedWslHost: () => false, hasWslMoveCutover: () => true })
try {
expect(invoke('wslMove:listUnfinished')).toEqual(open)
expect(invoke('wslMove:listPendingRendererRewrites')).toEqual(pending)
} finally {
resetWslHostsGateForTests()
}
})
it('writes the window storage to disk before the ledger entry', () => {
flushStorageData.mockImplementation(() => {
expect(listWslMoveCutovers(userDataPath)[0]?.rewrittenStores).toEqual([])
+4 -4
View File
@@ -28,7 +28,7 @@ import {
import type { WslMoveDriverDeps } from '../persistence/moving-wsl-projects/wsl-move-driver-ports'
import { listUnfinishedWslMoves } from '../persistence/moving-wsl-projects/wsl-move-host-automations-rewrite'
import { runTrackedWslMove } from '../telemetry/wsl-move-telemetry'
import { wslHostsGateOpen } from '../wsl/wsl-host-gate'
import { wslHostsGateOpen, wslHostsServing } from '../wsl/wsl-host-gate'
import type { WslMoveUndoPorts } from './wsl-move-undo-wiring'
import { releaseWslMovedProjectHere } from './wsl-move-release-here'
@@ -126,7 +126,7 @@ function registerWslMoveDriverHandlers(
}
)
ipcMain.handle('wslMove:listUnfinished', (): WslMoveUnfinished[] =>
wslHostsGateOpen() ? listUnfinishedWslMoves(getUserDataPath()) : []
wslHostsServing() ? listUnfinishedWslMoves(getUserDataPath()) : []
)
ipcMain.handle(
'wslMove:cancel',
@@ -147,8 +147,8 @@ function registerWslMoveUseHereHandler(
ipcMain.handle(
'wslMove:useProjectHereAgain',
async (event: { sender?: WebContents }, request: unknown): Promise<WslMoveUseLocallyResult> => {
// Gate first: with it closed, nothing is read.
if (!wslHostsGateOpen()) {
// Gate first: with it closed, nothing is read. Serve-only keeps the way back.
if (!wslHostsServing()) {
return { released: false, reason: 'gated-off' }
}
const sender = event.sender
@@ -9,7 +9,7 @@ import type {
WslMoveRecordStoreRewrittenRequest,
WslMoveRecordStoreRewrittenResult
} from '../../../shared/wsl-move/wsl-move-renderer-rewrite-types'
import { wslHostsGateOpen } from '../../wsl/wsl-host-gate'
import { wslHostsServing } from '../../wsl/wsl-host-gate'
import { listWslMoveCutovers, writeWslMoveCutover } from './wsl-move-cutover-journal'
import {
isWslMoveDesktopRewriteRegistryComplete,
@@ -189,7 +189,7 @@ export function runWslMoveDesktopRewritePass(
export function runGatedWslMoveDesktopRewritePass(
options: WslMoveDesktopRewritePassOptions
): Promise<void> {
return wslHostsGateOpen() ? runWslMoveDesktopRewritePass(options) : Promise.resolve()
return wslHostsServing() ? runWslMoveDesktopRewritePass(options) : Promise.resolve()
}
const COMMITTED_PHASE_INDEX = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-committed')
@@ -202,7 +202,7 @@ const COMMITTED_PHASE_INDEX = WSL_MOVE_CUTOVER_PHASES.indexOf('destination-commi
export function listPendingWslMoveRendererRewrites(
userDataPath: string
): WslMovePendingRendererRewrite[] {
if (!wslHostsGateOpen()) {
if (!wslHostsServing()) {
return []
}
try {
@@ -244,7 +244,7 @@ export function recordWslMoveRendererStoreRewritten(
* the write, so the renderer re-applies its stores at every startup regardless. */
flushRendererStorage: () => void = () => {}
): WslMoveRecordStoreRewrittenResult {
if (!wslHostsGateOpen()) {
if (!wslHostsServing()) {
return { recorded: false, reason: 'gated-off' }
}
const store = listWslMoveDesktopRewriteStores().find((entry) => entry.id === request.storeId)
@@ -22,7 +22,7 @@ import {
type OrcadMigrationDestinationCatalog
} from '../../ssh/orcad-migration-cutover-coordinator'
import { orcadMigrationRequestFits } from '../../ssh/orcad-migration-manifest-export'
import { wslHostsGateOpen } from '../../wsl/wsl-host-gate'
import { wslHostsServing } from '../../wsl/wsl-host-gate'
import {
listWslMoveCutovers,
removeUncommittedWslMoveCutover,
@@ -208,7 +208,7 @@ export async function recoverInterruptedWslMoves(
isDistroRegistered?: (distro: string, distroGuid?: string) => Promise<boolean | null>
): Promise<WslMoveStartupRecovery> {
const result: WslMoveStartupRecovery = { reset: [], waitingForHost: [] }
if (!wslHostsGateOpen()) {
if (!wslHostsServing()) {
return result
}
let inFlight: WslMoveCutover[]
@@ -7,7 +7,7 @@
* flag rows whose identity it changed (a later move offers them; none runs on its own), and drop
* marks no committed move explains, such as after "Use on this computer again".
*/
import { wslHostsGateOpen } from '../../wsl/wsl-host-gate'
import { wslHostsServing } from '../../wsl/wsl-host-gate'
import type { Store } from '../loading-store/store'
import {
listWslMoveCutoverChain,
@@ -104,7 +104,7 @@ let passQueue: Promise<unknown> = Promise.resolve()
export function runWslMoveRetentionPass(
options: WslMoveRetentionOptions
): Promise<WslMoveRetentionReport | null> {
if (!wslHostsGateOpen()) {
if (!wslHostsServing()) {
return Promise.resolve(null)
}
const next = passQueue.then(() =>
@@ -3,7 +3,7 @@
* the host could not turn back on yet. A pass runs at startup and after each move; this covers the
* host coming back later in the same run. Backs off, and stops once nothing is left.
*/
import { wslHostsGateOpen } from '../../wsl/wsl-host-gate'
import { wslHostsServing } from '../../wsl/wsl-host-gate'
import { listWslMoveCutovers } from './wsl-move-cutover-journal'
import { runWslMoveDesktopRewritePass } from './wsl-move-desktop-rewrite'
import { pendingWslMoveDesktopRewriteStores } from './wsl-move-desktop-rewrite-stores'
@@ -45,7 +45,7 @@ export function scheduleWslMoveRewriteRetry(
delaysMs: readonly number[] = WSL_MOVE_REWRITE_RETRY_DELAYS_MS,
attempt = 0
): void {
if (timer || !wslHostsGateOpen() || !hasUnfinishedMove(userDataPath)) {
if (timer || !wslHostsServing() || !hasUnfinishedMove(userDataPath)) {
return
}
const delay = delaysMs[Math.min(attempt, delaysMs.length - 1)] ?? 0
@@ -3,7 +3,7 @@
* see a committed move's rewrite, or the renderer hydrates the old routing and later writes it
* back over the rewrite. So startup waits for the pass, but never long.
*/
import { wslHostsGateOpen } from '../../wsl/wsl-host-gate'
import { wslHostsServing } from '../../wsl/wsl-host-gate'
import {
runWslMoveDesktopRewritePass,
type WslMoveDesktopRewritePassOptions
@@ -26,7 +26,7 @@ export function runWslMoveStartupRewrite(
options: WslMoveDesktopRewritePassOptions,
steps: WslMoveStartupRewriteSteps = {}
): Promise<void> {
if (!wslHostsGateOpen()) {
if (!wslHostsServing()) {
return Promise.resolve()
}
let timer: ReturnType<typeof setTimeout> | undefined
@@ -9,7 +9,7 @@ import {
type WslMoveCutover
} from '../../../shared/wsl-move/wsl-move-cutover'
import type { WslMoveUseLocallyResult } from '../../../shared/wsl-move/wsl-moved-project'
import { wslHostsGateOpen } from '../../wsl/wsl-host-gate'
import { wslHostsServing } from '../../wsl/wsl-host-gate'
import { isWslMoveRunning } from './wsl-move-running-moves'
import {
listWslMoveCutoverChain,
@@ -43,7 +43,7 @@ export function findWslMovedProjectMove(
userDataPath: string,
ref: WslMoveProjectRef
): { move: WslMoveCutover } | Extract<WslMoveUseLocallyResult, { released: false }> {
if (!wslHostsGateOpen()) {
if (!wslHostsServing()) {
return { released: false, reason: 'gated-off' }
}
let move: WslMoveCutover | undefined
+3 -2
View File
@@ -15,7 +15,7 @@ import { peekConfirmedRunningWslDistros } from '../wsl-running-distro-cache'
import { loadWindowsNativeRegistry, WINDOWS_REG_SZ } from '../windows-native-registry'
import { readWslDistroRegistrations } from '../wsl/wsl-distro-registry-id'
import { listWslHostDistros } from '../wsl/wsl-host-distro-list'
import { wslHostsGateOpen } from '../wsl/wsl-host-gate'
import { wslHostsGateOpen, wslHostsGateState } from '../wsl/wsl-host-gate'
import { isForeignLink, readWorktreeLinks } from '../git/worktree-prune-guard'
import { isTelemetryEnabled, track } from './client'
import {
@@ -161,7 +161,8 @@ export async function gatherWslUsageSnapshotInput(
getCachedWslDistros()?.[0] ?? null
),
windowsDriveReposWithWslWorktrees: await countWindowsDriveReposWithWslWorktrees(store),
wslHostsGateOpen: gateOpen
wslHostsGateOpen: gateOpen,
wslHostsGateState: wslHostsGateState()
}
}
@@ -118,6 +118,13 @@ describe('buildWslUsageSnapshot', () => {
expect(validate('wsl_usage_snapshot', props).ok).toBe(true)
})
it('reports the gate state when known, as an optional field', () => {
const props = buildWslUsageSnapshot({ ...baseInput, wslHostsGateState: 'serve-only' })
expect(props.wsl_hosts_gate_state).toBe('serve-only')
expect(validate('wsl_usage_snapshot', props).ok).toBe(true)
expect(buildWslUsageSnapshot(baseInput)).not.toHaveProperty('wsl_hosts_gate_state')
})
it('reports no WSL and an unreadable registry without a version', () => {
const props = buildWslUsageSnapshot({
...baseInput,
+4 -1
View File
@@ -1,4 +1,5 @@
// Count distro identities locally; only buckets/booleans reach the telemetry payload.
import type { WslHostsGateState } from '../../shared/wsl-host-internal-gate'
import type { Repo } from '../../shared/repo-types'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import { isDrvfsLinuxPath, parseWslUncPath } from '../../shared/wsl-paths'
@@ -32,6 +33,7 @@ export type WslUsageSnapshotInput = {
/** Of the Windows-drive WSL git repos, those with a linked worktree WSL Git made. */
windowsDriveReposWithWslWorktrees: number
wslHostsGateOpen: boolean
wslHostsGateState?: WslHostsGateState
}
/** `2.4.13.0` → `2.4`; anything else is dropped rather than sent. */
@@ -113,6 +115,7 @@ export function buildWslUsageSnapshot(input: WslUsageSnapshotInput): WslUsageSna
input.windowsDriveReposWithWslWorktrees
),
project_distros_used: bucketWslUsageCount(projectDistros.size),
wsl_hosts_gate_open: input.wslHostsGateOpen
wsl_hosts_gate_open: input.wslHostsGateOpen,
...(input.wslHostsGateState ? { wsl_hosts_gate_state: input.wslHostsGateState } : {})
}
}
+3 -1
View File
@@ -21,6 +21,7 @@ import { installMainWindowFocusLifecycle } from './main-window-focus-lifecycle'
import { installMainWindowShortcutRouting } from './main-window-shortcut-routing'
import { installMainWindowStateLifecycle } from './main-window-state-lifecycle'
import { stopHiddenWindowFramePumpOnShow } from './hidden-window-frame-pump'
import { wslHostsWindowArguments } from '../wsl/wsl-host-gate'
import {
formatHiddenWindowFramePumpArgument,
shouldPumpHiddenWindowFrames
@@ -143,7 +144,8 @@ export function createMainWindow(
// Pass host identity at birth so the renderer has it before first snapshot, without waiting for IPC registration.
additionalArguments: [
formatBrowserClientHostIdArgument(getBrowserClientHostId()),
...(pumpHiddenFrames ? [formatHiddenWindowFramePumpArgument()] : [])
...(pumpHiddenFrames ? [formatHiddenWindowFramePumpArgument()] : []),
...wslHostsWindowArguments()
]
}
})
@@ -145,6 +145,8 @@ import {
zoomDashboardPopoutIfFocused
} from './dashboard-popout-window'
import { readBrowserClientHostIdArgument } from '../../shared/browser-client-host-id-argument'
import { hasWslHostsServeOnlyArgument } from '../../shared/wsl-host-internal-gate'
import { configureWslHostsGate, resetWslHostsGateForTests } from '../wsl/wsl-host-gate'
type FakeWindow = InstanceType<typeof BrowserWindowMock>
@@ -198,6 +200,23 @@ describe('createOrFocusDashboardPopout', () => {
vi.clearAllMocks()
})
it('lets its preload expose saved WSL hosts in serve-only, and passes nothing otherwise', () => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
createOrFocusDashboardPopout(makeStore() as never)
expect(instances[0].options.webPreferences?.additionalArguments).toBeUndefined()
closeDashboardPopout()
configureWslHostsGate({ hasSavedWslHost: () => true, hasWslMoveCutover: () => false })
try {
createOrFocusDashboardPopout(makeStore() as never)
expect(
hasWslHostsServeOnlyArgument(instances[1].options.webPreferences?.additionalArguments ?? [])
).toBe(true)
} finally {
resetWslHostsGateForTests()
}
})
it('creates a native-frame window with the shared preload and no webview surface', () => {
createOrFocusDashboardPopout(makeStore() as never)
+5 -1
View File
@@ -13,6 +13,7 @@ import {
type KeybindingInput,
type KeybindingOverrides
} from '../../shared/keybindings'
import { wslHostsWindowArguments } from '../wsl/wsl-host-gate'
const MIN_WIDTH = 480
const MIN_HEIGHT = 360
@@ -136,6 +137,8 @@ export function createOrFocusDashboardPopout(
const savedBounds = resolveRestoredBounds(store)
// Serve-only: this window lists saved WSL hosts and their Connect, as the main window does.
const wslArguments = wslHostsWindowArguments()
const window = new BrowserWindow({
width: savedBounds?.width ?? DEFAULT_WIDTH,
height: savedBounds?.height ?? DEFAULT_HEIGHT,
@@ -153,7 +156,8 @@ export function createOrFocusDashboardPopout(
// Chromium shares zoom by origin; isolate the session to keep popout zoom local.
partition: DASHBOARD_POPOUT_PARTITION,
// No webviews run here; disable embedding and omit host identity so client-placed pages stay mirrors.
webviewTag: false
webviewTag: false,
...(wslArguments.length > 0 ? { additionalArguments: wslArguments } : {})
}
})
installPrivilegedWindowNavigationPolicy(window.webContents)
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const { instances } = vi.hoisted(() => {
const instances: { options: { webPreferences?: { additionalArguments?: string[] } } }[] = []
return { instances }
})
vi.mock('electron', () => {
class BrowserWindow {
readonly webContents = {
isDestroyed: () => false,
on: vi.fn(),
setZoomLevel: vi.fn(),
session: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn() }
}
constructor(readonly options: { webPreferences?: { additionalArguments?: string[] } }) {
instances.push(this)
}
isDestroyed = () => false
once = vi.fn()
on = vi.fn()
close = vi.fn()
loadFile = vi.fn(async () => {})
loadURL = vi.fn(async () => {})
}
return { app: { isPackaged: true }, BrowserWindow, nativeTheme: { shouldUseDarkColors: true } }
})
vi.mock('./privileged-window-navigation', () => ({
installPrivilegedWindowNavigationPolicy: vi.fn()
}))
vi.mock('../ipc/ui', () => ({ sendToTrustedUIRenderer: vi.fn() }))
import { createOrFocusWorkspaceSpaceWindow } from './workspace-space-window'
import { hasWslHostsServeOnlyArgument } from '../../shared/wsl-host-internal-gate'
import { configureWslHostsGate, resetWslHostsGateForTests } from '../wsl/wsl-host-gate'
const store = {
getUI: () => ({ uiZoomLevel: 0 }),
onUIChanged: () => () => {}
}
afterEach(() => {
vi.unstubAllEnvs()
resetWslHostsGateForTests()
})
describe('Space window and the WSL hosts gate', () => {
it('lets its preload expose saved WSL hosts in serve-only', () => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
configureWslHostsGate({ hasSavedWslHost: () => true, hasWslMoveCutover: () => false })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the window reads only getUI and onUIChanged from the store.
createOrFocusWorkspaceSpaceWindow(store as never)
expect(
hasWslHostsServeOnlyArgument(instances[0].options.webPreferences?.additionalArguments ?? [])
).toBe(true)
})
})
+5 -1
View File
@@ -5,6 +5,7 @@ import { installPrivilegedWindowNavigationPolicy } from './privileged-window-nav
import { sendToTrustedUIRenderer } from '../ipc/ui'
import type { Store } from '../persistence'
import { stepUIZoomLevel, type UIZoomDirection } from '../../shared/ui-zoom-level'
import { wslHostsWindowArguments } from '../wsl/wsl-host-gate'
let spaceWindow: BrowserWindow | null = null
@@ -40,6 +41,8 @@ export function createOrFocusWorkspaceSpaceWindow(store: Store): BrowserWindow {
return existing
}
// Serve-only: this window lists saved WSL hosts and their Connect, as the main window does.
const wslArguments = wslHostsWindowArguments()
const window = new BrowserWindow({
width: 1180,
height: 820,
@@ -53,7 +56,8 @@ export function createOrFocusWorkspaceSpaceWindow(store: Store): BrowserWindow {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
partition: 'orca-workspace-space-window',
webviewTag: false
webviewTag: false,
...(wslArguments.length > 0 ? { additionalArguments: wslArguments } : {})
}
})
spaceWindow = window
@@ -0,0 +1,59 @@
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { writeWslHostDeployment } from '../../shared/wsl-host-deployment-store'
import { wslMoveCutoverFixture as cutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-cutover-fixture'
import { seedWslMoveCutover } from '../persistence/__fixtures__/wsl-mover/wsl-move-journal-seeding'
import { wslMoveJournalDirectory } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
import { resetWslHostsGateForTests, wslHostsGateState } from './wsl-host-gate'
import { configureWslHostsGateFromDisk } from './wsl-host-gate-startup'
describe('WSL hosts gate startup promotion', () => {
let userDataPath: string
beforeEach(() => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
userDataPath = mkdtempSync(join(tmpdir(), 'orca-wsl-gate-'))
})
afterEach(() => {
vi.unstubAllEnvs()
resetWslHostsGateForTests()
rmSync(userDataPath, { recursive: true, force: true })
})
it('stays closed and creates nothing for a profile that never had WSL work', () => {
configureWslHostsGateFromDisk(userDataPath)
expect(wslHostsGateState()).toBe('closed')
expect(existsSync(wslMoveJournalDirectory(userDataPath))).toBe(false)
})
it('serves a saved WSL host', () => {
writeWslHostDeployment(userDataPath, 'wsl:ubuntu', {
kind: 'wsl',
distro: 'Ubuntu',
localPort: 41000,
remotePort: 6768,
environmentId: 'wsl:ubuntu',
createdAt: 1
})
configureWslHostsGateFromDisk(userDataPath)
expect(wslHostsGateState()).toBe('serve-only')
})
it('serves a committed move', () => {
seedWslMoveCutover(userDataPath, cutover('move-1', 'destination-committed'))
configureWslHostsGateFromDisk(userDataPath)
expect(wslHostsGateState()).toBe('serve-only')
})
// Recovery must still reset an in-flight move whose host record is already gone.
it('serves a move that never committed', () => {
seedWslMoveCutover(userDataPath, cutover('move-1', 'prepared'))
configureWslHostsGateFromDisk(userDataPath)
expect(wslHostsGateState()).toBe('serve-only')
})
})
+12
View File
@@ -0,0 +1,12 @@
import { readWslHostDeployments } from '../../shared/wsl-host-deployment-store'
import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
import { configureWslHostsGate } from './wsl-host-gate'
/** Reads only with the internal gate closed: an open gate needs no promotion. */
export function configureWslHostsGateFromDisk(userDataPath: string): void {
configureWslHostsGate({
hasSavedWslHost: () => readWslHostDeployments(userDataPath).deployments.length > 0,
// Any journal entry, committed or not: recovery must reset an in-flight move whose host is gone.
hasWslMoveCutover: () => listWslMoveCutovers(userDataPath).length > 0
})
}
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
assertWslHostReachable,
configureWslHostsGate,
isGatedOffWslHost,
resetWslHostsGateForTests,
wslHostsGateOpen,
wslHostsGateState,
wslHostsServing,
wslHostsWindowArguments,
WSL_HOSTS_GATED_OFF_MESSAGE
} from './wsl-host-gate'
const wslHost = {
orcadDeployment: { kind: 'wsl' as const, distro: 'Ubuntu', localPort: 41000, remotePort: 6768 }
}
const sshHost = { orcadDeployment: null }
const facts = (hasSavedWslHost: boolean, hasWslMoveCutover: boolean) => ({
hasSavedWslHost: () => hasSavedWslHost,
hasWslMoveCutover: () => hasWslMoveCutover
})
afterEach(() => {
vi.unstubAllEnvs()
resetWslHostsGateForTests()
})
describe('WSL hosts gate', () => {
it('flag-off with nothing saved: closed, exactly as before', () => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
configureWslHostsGate(facts(false, false))
expect(wslHostsGateState()).toBe('closed')
expect(wslHostsServing()).toBe(false)
expect(isGatedOffWslHost(wslHost)).toBe(true)
expect(() => assertWslHostReachable(wslHost)).toThrow(WSL_HOSTS_GATED_OFF_MESSAGE)
expect(isGatedOffWslHost(sshHost)).toBe(false)
})
it.each([
['a saved host', facts(true, false)],
['a move journal entry', facts(false, true)]
])('serves %s behind a closed gate without opening it', (_label, saved) => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
configureWslHostsGate(saved)
expect(wslHostsGateState()).toBe('serve-only')
expect(wslHostsServing()).toBe(true)
expect(wslHostsGateOpen()).toBe(false)
expect(isGatedOffWslHost(wslHost)).toBe(false)
expect(() => assertWslHostReachable(wslHost)).not.toThrow()
})
it('treats unreadable saved state as absent', () => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
configureWslHostsGate({
hasSavedWslHost: () => {
throw new Error('unreadable')
},
hasWslMoveCutover: () => {
throw new Error('unreadable')
}
})
expect(wslHostsGateState()).toBe('closed')
})
it('stays open with the internal gate on, and reads nothing to decide', () => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
const hasSavedWslHost = vi.fn(() => false)
const hasWslMoveCutover = vi.fn(() => false)
configureWslHostsGate({ hasSavedWslHost, hasWslMoveCutover })
expect(wslHostsGateState()).toBe('open')
expect(hasSavedWslHost).not.toHaveBeenCalled()
expect(hasWslMoveCutover).not.toHaveBeenCalled()
})
it('is closed before startup configures it', () => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
expect(wslHostsGateState()).toBe('closed')
})
it('stamps window argv only in serve-only', () => {
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', undefined)
configureWslHostsGate(facts(false, false))
expect(wslHostsWindowArguments()).toEqual([])
configureWslHostsGate(facts(true, false))
expect(wslHostsWindowArguments()).toEqual(['--orca-wsl-hosts-serve-only'])
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
expect(wslHostsWindowArguments()).toEqual([])
})
})
+62 -2
View File
@@ -1,17 +1,77 @@
import { isWslHostsInternalGateOpen } from '../../shared/wsl-host-internal-gate'
import {
formatWslHostsServeOnlyArgument,
isWslHostsInternalGateOpen,
noteWslHostsServeOnly,
resolveWslHostsGateState,
type WslHostsGateState
} from '../../shared/wsl-host-internal-gate'
import type { OrcadDeploymentLink } from '../../shared/runtime-environments'
export const WSL_HOSTS_GATED_OFF_MESSAGE = 'WSL hosts are not available in this build.'
export const WSL_HOSTS_NEW_WORK_PAUSED_MESSAGE =
'Adding WSL hosts and moving projects onto them is paused in this build. Your saved WSL hosts keep working.'
// Fixed for the run once main reads the disk; nothing can create a host or a move while not open.
let promoted: { hasSavedWslHost: boolean; hasWslMoveCutover: boolean } | null = null
/** Startup, before any gate check: a closed gate with saved WSL work becomes `serve-only`. */
export function configureWslHostsGate(facts: {
hasSavedWslHost: () => boolean
hasWslMoveCutover: () => boolean
}): void {
if (wslHostsGateOpen()) {
promoted = null
noteWslHostsServeOnly(false)
return
}
// Unreadable state counts as absent: today's closed behavior.
const read = (fact: () => boolean): boolean => {
try {
return fact()
} catch {
return false
}
}
promoted = {
hasSavedWslHost: read(facts.hasSavedWslHost),
hasWslMoveCutover: read(facts.hasWslMoveCutover)
}
noteWslHostsServeOnly(wslHostsGateState() === 'serve-only')
}
export function resetWslHostsGateForTests(): void {
promoted = null
noteWslHostsServeOnly(false)
}
export function wslHostsGateState(): WslHostsGateState {
return resolveWslHostsGateState({
gateOpen: wslHostsGateOpen(),
hasSavedWslHost: promoted?.hasSavedWslHost ?? false,
hasWslMoveCutover: promoted?.hasWslMoveCutover ?? false
})
}
/** Argv for any window on the shared preload, which cannot read the disk to learn serve-only. */
export function wslHostsWindowArguments(): string[] {
return wslHostsGateState() === 'serve-only' ? [formatWslHostsServeOnlyArgument()] : []
}
/** The internal gate itself: adding hosts and starting moves need it. */
export function wslHostsGateOpen(): boolean {
return isWslHostsInternalGateOpen(process.env)
}
/** Saved hosts and moved projects stay reachable: `open` or `serve-only`. */
export function wslHostsServing(): boolean {
return wslHostsGateState() !== 'closed'
}
/** A persisted WSL host while the gate is closed: left on disk, but never listed or reached. */
export function isGatedOffWslHost(environment: {
orcadDeployment?: OrcadDeploymentLink | null
}): boolean {
return environment.orcadDeployment?.kind === 'wsl' && !wslHostsGateOpen()
return environment.orcadDeployment?.kind === 'wsl' && !wslHostsServing()
}
export function assertWslHostReachable(environment: {
+11 -3
View File
@@ -6,9 +6,9 @@ import { getWslHostsApi } from './wsl-hosts-api'
describe('WSL hosts preload API', () => {
it('is absent on Windows and under the e2e fake while the internal gate is closed', () => {
expect(getWslHostsApi({}, 'win32')).toBeUndefined()
expect(getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: 'true' }, 'win32')).toBeUndefined()
expect(getWslHostsApi({ ORCA_E2E_FAKE_WSL_HOSTS: '{}' }, 'darwin')).toBeUndefined()
expect(getWslHostsApi({}, 'win32', [])).toBeUndefined()
expect(getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: 'true' }, 'win32', [])).toBeUndefined()
expect(getWslHostsApi({ ORCA_E2E_FAKE_WSL_HOSTS: '{}' }, 'darwin', [])).toBeUndefined()
})
it('is present behind the open gate only where WSL hosts can run', () => {
@@ -18,4 +18,12 @@ describe('WSL hosts preload API', () => {
).toBeDefined()
expect(getWslHostsApi({ ORCA_INTERNAL_WSL_HOSTS: '1' }, 'darwin')).toBeUndefined()
})
it('is present behind a closed gate when main stamped serve-only, still only where hosts run', () => {
const argv = ['--orca-wsl-hosts-serve-only']
expect(getWslHostsApi({}, 'win32', argv)).toBeDefined()
expect(getWslHostsApi({ ORCA_E2E_FAKE_WSL_HOSTS: '{}' }, 'linux', argv)).toBeDefined()
expect(getWslHostsApi({}, 'darwin', argv)).toBeUndefined()
expect(getWslHostsApi({}, 'win32', [])).toBeUndefined()
})
})
+8 -3
View File
@@ -9,7 +9,10 @@ import {
type WslHostServerStop,
type WslHostStatus
} from '../../shared/wsl-host-types'
import { isWslHostsInternalGateOpen } from '../../shared/wsl-host-internal-gate'
import {
hasWslHostsServeOnlyArgument,
isWslHostsInternalGateOpen
} from '../../shared/wsl-host-internal-gate'
export type WslHostsPreloadApi = {
getCapability: () => Promise<WslHostCapability>
@@ -51,9 +54,11 @@ const wslHostsApi: WslHostsPreloadApi = {
/** Undefined hides every WSL host surface in the renderer; main refuses the calls regardless. */
export function getWslHostsApi(
env: Readonly<Record<string, string | undefined>> = process.env,
platform: NodeJS.Platform = process.platform
platform: NodeJS.Platform = process.platform,
argv: readonly string[] = process.argv
): WslHostsPreloadApi | undefined {
if (!isWslHostsInternalGateOpen(env)) {
// Serve-only: main found saved WSL work behind the closed gate and stamped this window's argv.
if (!isWslHostsInternalGateOpen(env) && !hasWslHostsServeOnlyArgument(argv)) {
return undefined
}
return platform === 'win32' || env.ORCA_E2E_FAKE_WSL_HOSTS ? wslHostsApi : undefined
+3 -3
View File
@@ -27,7 +27,7 @@ import {
readWslHostDeployments,
WslHostDeploymentStoreInvalidError
} from './wsl-host-deployment-store'
import { isWslHostsInternalGateOpen } from './wsl-host-internal-gate'
import { isWslHostsServing } from './wsl-host-internal-gate'
export {
getEnvironmentStorePath,
@@ -53,11 +53,11 @@ export function readPersistedEnvironmentStore(
}
}
/** Unreadable WSL state fails closed only when its internal gate is enabled; otherwise it cannot hide SSH/paired servers. */
/** Unreadable WSL state fails closed only while WSL hosts are served; otherwise it cannot hide SSH/paired servers. */
function readWslHostDeploymentsForRegistry(
userDataPath: string
): ReturnType<typeof readWslHostDeployments> {
const gateOpen = isWslHostsInternalGateOpen(process.env)
const gateOpen = isWslHostsServing(process.env)
try {
const read = readWslHostDeployments(userDataPath)
return gateOpen ? read : { deployments: read.deployments, unreadableEnvironmentIds: new Set() }
@@ -45,7 +45,9 @@ export const wslUsageSnapshotSchema = z
projects_wsl_git: countBucketSchema,
projects_wsl_folder: countBucketSchema,
project_distros_used: countBucketSchema,
wsl_hosts_gate_open: z.boolean()
wsl_hosts_gate_open: z.boolean(),
/** open / serve-only / closed. Optional, so older builds' payloads still validate. */
wsl_hosts_gate_state: z.enum(['open', 'serve-only', 'closed']).optional()
})
.strict()
+13 -1
View File
@@ -31,7 +31,7 @@ import {
WslHostDeploymentStoreInvalidError,
type WslHostDeploymentRecord
} from './wsl-host-deployment-store'
import { WSL_HOSTS_INTERNAL_GATE_ENV } from './wsl-host-internal-gate'
import { noteWslHostsServeOnly, WSL_HOSTS_INTERNAL_GATE_ENV } from './wsl-host-internal-gate'
const pairing = {
v: 2 as const,
@@ -257,6 +257,18 @@ describe('WSL host deployment store', () => {
)
})
it('fails closed when the file itself is corrupt in serve-only too', () => {
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
noteWslHostsServeOnly(true)
try {
addSshManaged()
writeFileSync(getWslHostDeploymentStorePath(userDataPath), '{"version":1,"deployments":{}}')
expect(() => listEnvironments(userDataPath)).toThrow(RuntimeEnvironmentStoreError)
} finally {
noteWslHostsServeOnly(false)
}
})
it('keeps every other server listed when the file is corrupt and the gate is closed', () => {
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '')
addSshManaged()
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import {
formatWslHostsServeOnlyArgument,
hasWslHostsServeOnlyArgument,
resolveWslHostsGateState
} from './wsl-host-internal-gate'
describe('WSL hosts gate state', () => {
it('is open whenever the internal gate is, whatever is saved', () => {
for (const hasSavedWslHost of [false, true]) {
for (const hasWslMoveCutover of [false, true]) {
expect(
resolveWslHostsGateState({ gateOpen: true, hasSavedWslHost, hasWslMoveCutover })
).toBe('open')
}
}
})
it('promotes a closed gate to serve-only for a saved host or a move journal entry, else stays closed', () => {
const closed = { gateOpen: false, hasSavedWslHost: false, hasWslMoveCutover: false }
expect(resolveWslHostsGateState(closed)).toBe('closed')
expect(resolveWslHostsGateState({ ...closed, hasSavedWslHost: true })).toBe('serve-only')
expect(resolveWslHostsGateState({ ...closed, hasWslMoveCutover: true })).toBe('serve-only')
})
it('reads back only the serve-only argument main stamps', () => {
expect(hasWslHostsServeOnlyArgument(['--x', formatWslHostsServeOnlyArgument()])).toBe(true)
expect(hasWslHostsServeOnlyArgument(['--orca-wsl-hosts-serve-only=0'])).toBe(false)
expect(hasWslHostsServeOnlyArgument([])).toBe(false)
})
})
+40
View File
@@ -6,3 +6,43 @@ export function isWslHostsInternalGateOpen(
): boolean {
return env[WSL_HOSTS_INTERNAL_GATE_ENV] === '1'
}
/**
* `open`: everything. `serve-only`: the gate is closed, but a saved host or a move journal exists,
* so its projects stay reachable while nothing new is added or moved. `closed`: no WSL host surface.
*/
export type WslHostsGateState = 'open' | 'serve-only' | 'closed'
export function resolveWslHostsGateState(facts: {
gateOpen: boolean
hasSavedWslHost: boolean
hasWslMoveCutover: boolean
}): WslHostsGateState {
if (facts.gateOpen) {
return 'open'
}
return facts.hasSavedWslHost || facts.hasWslMoveCutover ? 'serve-only' : 'closed'
}
// Main records serve-only here so shared readers in its process agree; other processes never set it.
let serveOnlyNoted = false
export function noteWslHostsServeOnly(serveOnly: boolean): void {
serveOnlyNoted = serveOnly
}
/** Open, or main found saved WSL work behind the closed gate. */
export function isWslHostsServing(env: Readonly<Record<string, string | undefined>>): boolean {
return isWslHostsInternalGateOpen(env) || serveOnlyNoted
}
// Preload has no disk access, so main stamps the promoted state into the window's argv at birth.
const SERVE_ONLY_ARGUMENT = '--orca-wsl-hosts-serve-only'
export function formatWslHostsServeOnlyArgument(): string {
return SERVE_ONLY_ARGUMENT
}
export function hasWslHostsServeOnlyArgument(argv: readonly string[]): boolean {
return argv.includes(SERVE_ONLY_ARGUMENT)
}
+67 -28
View File
@@ -20,7 +20,6 @@ import { expect, forwardElectronProcessLogs, test } from './helpers/orca-app'
import { RuntimeClient } from '../../src/cli/runtime/client'
import { createRuntimeDesktopPairingOffer } from './helpers/paired-electron-client'
import { encodePairingOffer } from '../../src/shared/pairing'
import { listEnvironments } from '../../src/shared/runtime-environment-store'
import {
addWslHostEnvironment,
findWslHostDeployment
@@ -548,7 +547,7 @@ test('a build without the WSL host server files says so instead of failing an ad
}
})
test('with the internal gate closed, no WSL host surface shows and a saved host stays untouched', async ({
test('with the internal gate closed and no saved WSL work, no WSL host surface shows', async ({
orcaPage
}, testInfo) => {
test.setTimeout(180_000)
@@ -560,28 +559,7 @@ test('with the internal gate closed, no WSL host surface shows and a saved host
offer.pairingUrl,
testInfo,
{ connectLogFile },
{
internalGate: false,
// A host saved during internal testing: it must be ignored, never deleted.
beforeLaunch: (userDataDir) =>
addWslHostEnvironment(userDataDir, {
id: WSL_ENVIRONMENT_ID,
name: WSL_HOST_LABEL,
pairingCode: encodePairingOffer({
v: 2,
endpoint: 'ws://127.0.0.1:42000',
deviceToken: 'gated-token',
publicKeyB64: Buffer.alloc(32, 7).toString('base64')
}),
record: {
kind: 'wsl',
distro: 'Ubuntu',
distroId: '0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f',
localPort: 42000,
remotePort: 6768
}
})
}
{ internalGate: false }
)
try {
const { page, userDataDir } = desktop
@@ -618,13 +596,74 @@ test('with the internal gate closed, no WSL host surface shows and a saved host
await expect(page.getByText(WSL_HOST_LABEL)).toHaveCount(0)
expect(fakeWslConnects(connectLogFile)).toEqual([])
expect(listEnvironments(userDataDir).map((environment) => environment.id)).toContain(
WSL_ENVIRONMENT_ID
)
expect(findWslHostDeployment(userDataDir, WSL_ENVIRONMENT_ID)).not.toBeNull()
expect(findWslHostDeployment(userDataDir, WSL_ENVIRONMENT_ID)).toBeNull()
await page.screenshot({ path: testInfo.outputPath('wsl-hosts-gated-off-settings.png') })
} finally {
await desktop.dispose()
rmSync(logDir, { recursive: true, force: true })
}
})
test('with the internal gate closed, a saved WSL host stays listed and reachable (serve-only)', async ({
orcaPage
}, testInfo) => {
// Launches a second desktop beside the paired server, like the gate-closed case above.
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
const logDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-wsl-serve-only-'))
const connectLogFile = path.join(logDir, 'connects.log')
const desktop = await launchDesktopWithFakeWsl(
offer.pairingUrl,
testInfo,
{ connectLogFile },
{
internalGate: false,
// A host saved while the gate was open: closing the gate must not strand it.
beforeLaunch: (userDataDir) =>
addWslHostEnvironment(userDataDir, {
id: WSL_ENVIRONMENT_ID,
name: WSL_HOST_LABEL,
pairingCode: encodePairingOffer({
v: 2,
endpoint: 'ws://127.0.0.1:42000',
deviceToken: 'gated-token',
publicKeyB64: Buffer.alloc(32, 7).toString('base64')
}),
record: {
kind: 'wsl',
distro: 'Ubuntu',
distroId: '0e3a7f2c-1b4d-4c5e-9f60-7a8b9c0d1e2f',
localPort: 42000,
remotePort: 6768
}
})
}
)
try {
const { page, userDataDir } = desktop
await setWslHostsEnabled(page, true)
expect(await page.evaluate(() => Boolean(window.api.runtimeEnvironments.wslHosts))).toBe(true)
const listed = await page.evaluate(() => window.api.runtimeEnvironments.list())
expect(listed.map((environment) => environment.id)).toContain(WSL_ENVIRONMENT_ID)
await page.evaluate(
(environmentId) => window.api.runtimeEnvironments.wslHosts?.connect({ environmentId }),
WSL_ENVIRONMENT_ID
)
await expect
.poll(() => fakeWslConnects(connectLogFile), { timeout: 30_000 })
.toContain('Ubuntu')
await page.evaluate(() => {
const state = window.__store?.getState()
state?.openSettingsPage()
state?.openSettingsTarget({ pane: 'servers', repoId: null })
})
await expect(page.getByText(WSL_HOST_LABEL).first()).toBeVisible({ timeout: 30_000 })
expect(findWslHostDeployment(userDataDir, WSL_ENVIRONMENT_ID)).not.toBeNull()
await page.screenshot({ path: testInfo.outputPath('wsl-hosts-serve-only-settings.png') })
} finally {
await desktop.dispose()
rmSync(logDir, { recursive: true, force: true })
}
})