From 478a0082995e75f096d12b22854bd9bd8a92c6b7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:04:54 -0700 Subject: [PATCH] feat(wsl): a WSL hosts block can ask for already-moved projects back (#35762) The cached policy gains an optional movedProjects: keep | bring-back, read only with a block. An unknown value reads as keep, so it never drops the block, and older builds ignore the field. The gate records the choice for the run (wslHostsBringBackMovedProjects) and telemetry reports it as wsl_hosts_moved_projects. Nothing acts on it yet; bringing projects back follows. Co-authored-by: Claude --- src/main/telemetry/wsl-usage-reporter.ts | 4 +- src/main/telemetry/wsl-usage-snapshot.test.ts | 19 +++++++ src/main/telemetry/wsl-usage-snapshot.ts | 9 +++- src/main/wsl/wsl-host-gate-startup.test.ts | 22 +++++++- src/main/wsl/wsl-host-gate-startup.ts | 13 ++++- src/main/wsl/wsl-host-gate.test.ts | 53 +++++++++++++++++++ src/main/wsl/wsl-host-gate.ts | 33 ++++++++++-- .../wsl/wsl-hosts-policy-live-update.test.ts | 28 +++++++++- .../telemetry-wsl-usage-event-schemas.ts | 4 +- src/shared/wsl-hosts-policy.ts | 16 +++++- 10 files changed, 187 insertions(+), 14 deletions(-) diff --git a/src/main/telemetry/wsl-usage-reporter.ts b/src/main/telemetry/wsl-usage-reporter.ts index 6c295cd9eac..abc1bc94f80 100644 --- a/src/main/telemetry/wsl-usage-reporter.ts +++ b/src/main/telemetry/wsl-usage-reporter.ts @@ -16,6 +16,7 @@ import { loadWindowsNativeRegistry, WINDOWS_REG_SZ } from '../windows-native-reg import { readWslDistroRegistrations } from '../wsl/wsl-distro-registry-id' import { listWslHostDistros } from '../wsl/wsl-host-distro-list' import { + wslHostsAppliedMovedProjects, wslHostsAppliedPolicyState, wslHostsGateOpen, wslHostsGateState @@ -167,7 +168,8 @@ export async function gatherWslUsageSnapshotInput( windowsDriveReposWithWslWorktrees: await countWindowsDriveReposWithWslWorktrees(store), wslHostsGateOpen: gateOpen, wslHostsGateState: wslHostsGateState(), - wslHostsPolicy: wslHostsAppliedPolicyState() + wslHostsPolicy: wslHostsAppliedPolicyState(), + wslHostsMovedProjects: wslHostsAppliedMovedProjects() } } diff --git a/src/main/telemetry/wsl-usage-snapshot.test.ts b/src/main/telemetry/wsl-usage-snapshot.test.ts index 9b6e919dc51..91d519cd351 100644 --- a/src/main/telemetry/wsl-usage-snapshot.test.ts +++ b/src/main/telemetry/wsl-usage-snapshot.test.ts @@ -134,6 +134,25 @@ describe('buildWslUsageSnapshot', () => { } ) + it.each(['keep', 'bring-back'] as const)( + 'reports what a block asks for moved projects (%s) as an optional field', + (wslHostsMovedProjects) => { + const props = buildWslUsageSnapshot({ + ...baseInput, + wslHostsPolicy: 'block', + wslHostsMovedProjects + }) + expect(props.wsl_hosts_moved_projects).toBe(wslHostsMovedProjects) + expect(validate('wsl_usage_snapshot', props).ok).toBe(true) + } + ) + + it('omits moved projects when nothing blocks', () => { + expect( + buildWslUsageSnapshot({ ...baseInput, wslHostsPolicy: 'allow', wslHostsMovedProjects: null }) + ).not.toHaveProperty('wsl_hosts_moved_projects') + }) + it('omits the remote policy when the internal gate left it unread', () => { expect(buildWslUsageSnapshot({ ...baseInput, wslHostsPolicy: null })).not.toHaveProperty( 'wsl_hosts_policy' diff --git a/src/main/telemetry/wsl-usage-snapshot.ts b/src/main/telemetry/wsl-usage-snapshot.ts index c374bedbfa2..7d12c82ee0b 100644 --- a/src/main/telemetry/wsl-usage-snapshot.ts +++ b/src/main/telemetry/wsl-usage-snapshot.ts @@ -1,6 +1,6 @@ // Count distro identities locally; only buckets/booleans reach the telemetry payload. import type { WslHostsGateState } from '../../shared/wsl-host-internal-gate' -import type { WslHostsPolicyState } from '../../shared/wsl-hosts-policy' +import type { WslHostsMovedProjects, WslHostsPolicyState } from '../../shared/wsl-hosts-policy' import type { Repo } from '../../shared/repo-types' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { isDrvfsLinuxPath, parseWslUncPath } from '../../shared/wsl-paths' @@ -37,6 +37,8 @@ export type WslUsageSnapshotInput = { wslHostsGateState?: WslHostsGateState /** The cached remote policy applied at launch; absent when the internal gate left it unread. */ wslHostsPolicy?: WslHostsPolicyState | null + /** What an applied block asks for moved projects; absent when nothing blocks. */ + wslHostsMovedProjects?: WslHostsMovedProjects | null } /** `2.4.13.0` → `2.4`; anything else is dropped rather than sent. */ @@ -120,6 +122,9 @@ export function buildWslUsageSnapshot(input: WslUsageSnapshotInput): WslUsageSna project_distros_used: bucketWslUsageCount(projectDistros.size), wsl_hosts_gate_open: input.wslHostsGateOpen, ...(input.wslHostsGateState ? { wsl_hosts_gate_state: input.wslHostsGateState } : {}), - ...(input.wslHostsPolicy ? { wsl_hosts_policy: input.wslHostsPolicy } : {}) + ...(input.wslHostsPolicy ? { wsl_hosts_policy: input.wslHostsPolicy } : {}), + ...(input.wslHostsMovedProjects + ? { wsl_hosts_moved_projects: input.wslHostsMovedProjects } + : {}) } } diff --git a/src/main/wsl/wsl-host-gate-startup.test.ts b/src/main/wsl/wsl-host-gate-startup.test.ts index ba8a7c6a60f..9407b0ca5b2 100644 --- a/src/main/wsl/wsl-host-gate-startup.test.ts +++ b/src/main/wsl/wsl-host-gate-startup.test.ts @@ -9,6 +9,7 @@ import { wslMoveJournalDirectory } from '../persistence/moving-wsl-projects/wsl- import { resetWslHostsGateForTests, wslHostsAppliedPolicyState, + wslHostsBringBackMovedProjects, wslHostsGateOpen, wslHostsGateState, wslHostsPolicyBlocked @@ -68,10 +69,10 @@ describe('WSL hosts gate startup promotion', () => { }) describe('with the internal gate open and a cached remote policy', () => { - const cachePolicy = (wslHosts: 'allow' | 'block') => + const cachePolicy = (wslHosts: 'allow' | 'block', movedProjects?: 'keep' | 'bring-back') => writeFileSync( join(userDataPath, wslHostsPolicyCacheFileName('stable')), - JSON.stringify({ version: 1, revision: 1, wslHosts }) + JSON.stringify({ version: 1, revision: 1, wslHosts, movedProjects }) ) const seedSavedHost = () => writeWslHostDeployment(userDataPath, 'wsl:ubuntu', { @@ -132,6 +133,23 @@ describe('WSL hosts gate startup promotion', () => { expect(wslHostsGateOpen()).toBe(false) }) + it('reads a block that asks for moved projects back', () => { + cachePolicy('block', 'bring-back') + seedSavedHost() + configureWslHostsGateFromDisk(userDataPath, STABLE) + + expect(wslHostsGateState()).toBe('serve-only') + expect(wslHostsBringBackMovedProjects()).toBe(true) + }) + + it('ignores bring-back on an allow', () => { + cachePolicy('allow', 'bring-back') + configureWslHostsGateFromDisk(userDataPath, STABLE) + + expect(wslHostsGateState()).toBe('open') + expect(wslHostsBringBackMovedProjects()).toBe(false) + }) + it('ignores a block cached for another channel', () => { cachePolicy('block') configureWslHostsGateFromDisk(userDataPath, '1.4.3-rc.1') diff --git a/src/main/wsl/wsl-host-gate-startup.ts b/src/main/wsl/wsl-host-gate-startup.ts index ec88b8a6b2b..f482d7b2481 100644 --- a/src/main/wsl/wsl-host-gate-startup.ts +++ b/src/main/wsl/wsl-host-gate-startup.ts @@ -1,15 +1,24 @@ import { readWslHostDeployments } from '../../shared/wsl-host-deployment-store' -import { wslHostsPolicyState } from '../../shared/wsl-hosts-policy' +import { + wslHostsPolicyMovedProjects, + wslHostsPolicyState, + type WslHostsPolicy +} from '../../shared/wsl-hosts-policy' import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal' import { configureWslHostsGate } from './wsl-host-gate' import { readCachedWslHostsPolicy } from './wsl-hosts-policy-live-update' /** Reads the policy only with the internal gate open, and saved work only once it is closed. */ export function configureWslHostsGateFromDisk(userDataPath: string, appVersion: string): void { + // One read serves both facts, so they can't disagree about a cache rewritten in between. + let cached: { policy: WslHostsPolicy | null } | null = null + const readPolicy = (): WslHostsPolicy | null => + (cached ??= { policy: readCachedWslHostsPolicy(userDataPath, appVersion) }).policy 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, - policy: () => wslHostsPolicyState(readCachedWslHostsPolicy(userDataPath, appVersion)) + policy: () => wslHostsPolicyState(readPolicy()), + movedProjects: () => wslHostsPolicyMovedProjects(readPolicy()) }) } diff --git a/src/main/wsl/wsl-host-gate.test.ts b/src/main/wsl/wsl-host-gate.test.ts index 995982c17ad..f25a5b856a9 100644 --- a/src/main/wsl/wsl-host-gate.test.ts +++ b/src/main/wsl/wsl-host-gate.test.ts @@ -5,7 +5,9 @@ import { isGatedOffWslHost, resetWslHostsGateForTests, wslHostsGateOpen, + wslHostsAppliedMovedProjects, wslHostsAppliedPolicyState, + wslHostsBringBackMovedProjects, wslHostsGateState, wslHostsPolicyBlocked, wslHostsServing, @@ -209,4 +211,55 @@ describe('WSL hosts gate', () => { expect(process.env[WSL_HOSTS_GATE_STATE_ENV]).toBeUndefined() }) }) + + describe('moved projects under a block', () => { + it('asks for moved projects back only when the block says so', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + configureWslHostsGate({ + ...facts(true, true), + policy: () => 'block', + movedProjects: () => 'bring-back' + }) + + expect(wslHostsBringBackMovedProjects()).toBe(true) + expect(wslHostsAppliedMovedProjects()).toBe('bring-back') + expect(wslHostsGateState()).toBe('serve-only') + }) + + it('never reads it without a block', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + const movedProjects = vi.fn(() => 'bring-back' as const) + configureWslHostsGate({ ...facts(true, true), policy: () => 'allow', movedProjects }) + + expect(movedProjects).not.toHaveBeenCalled() + expect(wslHostsBringBackMovedProjects()).toBe(false) + expect(wslHostsAppliedMovedProjects()).toBeNull() + }) + + it('keeps moved projects served when the choice cannot be read', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + configureWslHostsGate({ + ...facts(true, true), + policy: () => 'block', + movedProjects: () => { + throw new Error('unreadable') + } + }) + + expect(wslHostsBringBackMovedProjects()).toBe(false) + expect(wslHostsAppliedMovedProjects()).toBe('keep') + }) + + it('clears on reset', () => { + vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1') + configureWslHostsGate({ + ...facts(true, true), + policy: () => 'block', + movedProjects: () => 'bring-back' + }) + resetWslHostsGateForTests() + + expect(wslHostsBringBackMovedProjects()).toBe(false) + }) + }) }) diff --git a/src/main/wsl/wsl-host-gate.ts b/src/main/wsl/wsl-host-gate.ts index 2b02c95ca12..f354bd3a012 100644 --- a/src/main/wsl/wsl-host-gate.ts +++ b/src/main/wsl/wsl-host-gate.ts @@ -9,7 +9,7 @@ import { type WslHostsGateState } from '../../shared/wsl-host-internal-gate' import type { OrcadDeploymentLink } from '../../shared/runtime-environments' -import type { WslHostsPolicyState } from '../../shared/wsl-hosts-policy' +import type { WslHostsMovedProjects, WslHostsPolicyState } from '../../shared/wsl-hosts-policy' export const WSL_HOSTS_GATED_OFF_MESSAGE = 'WSL hosts are not available in this build.' @@ -19,6 +19,8 @@ let promoted: { hasSavedWslHost: boolean; hasWslMoveCutover: boolean } | null = let policyBlocked = false // The cached remote policy this launch read; null when the internal gate was closed, so none was read. let policyState: WslHostsPolicyState | null = null +// What a block asks for already-moved projects; `keep` whenever nothing blocks. +let movedProjects: WslHostsMovedProjects = 'keep' // Unreadable state counts as absent: today's closed behavior. function readFact(fact: () => boolean): boolean { @@ -41,10 +43,21 @@ function readPolicy(read: (() => WslHostsPolicyState) | undefined): WslHostsPoli } } -function notePolicy(state: WslHostsPolicyState | null): void { +function notePolicy( + state: WslHostsPolicyState | null, + moved: (() => WslHostsMovedProjects) | undefined +): void { policyState = state policyBlocked = state === 'block' noteWslHostsPolicyBlocked(policyBlocked) + movedProjects = 'keep' + if (policyBlocked && moved) { + try { + movedProjects = moved() + } catch { + // Unreadable: moved projects stay served, which is what a block alone does. + } + } } /** @@ -55,8 +68,10 @@ export function configureWslHostsGate(facts: { hasSavedWslHost: () => boolean hasWslMoveCutover: () => boolean policy?: () => WslHostsPolicyState + /** Read only when the policy blocks. */ + movedProjects?: () => WslHostsMovedProjects }): void { - notePolicy(readPolicy(facts.policy)) + notePolicy(readPolicy(facts.policy), facts.movedProjects) promoted = wslHostsGateOpen() ? null : { @@ -72,7 +87,7 @@ export function configureWslHostsGate(facts: { export function resetWslHostsGateForTests(): void { promoted = null noteWslHostsServeOnly(false) - notePolicy(null) + notePolicy(null, undefined) publishWslHostsGateState(process.env, null) } @@ -107,6 +122,16 @@ export function wslHostsAppliedPolicyState(): WslHostsPolicyState | null { return policyState } +/** This run's block asks for already-moved projects to come back to this computer. */ +export function wslHostsBringBackMovedProjects(): boolean { + return movedProjects === 'bring-back' +} + +/** What this run's block asks for moved projects; null when nothing blocks. */ +export function wslHostsAppliedMovedProjects(): WslHostsMovedProjects | null { + return policyBlocked ? movedProjects : null +} + /** Saved hosts and moved projects stay reachable: `open` or `serve-only`. */ export function wslHostsServing(): boolean { return wslHostsGateState() !== 'closed' diff --git a/src/main/wsl/wsl-hosts-policy-live-update.test.ts b/src/main/wsl/wsl-hosts-policy-live-update.test.ts index a5fdaacab4b..5f9e5aa3adb 100644 --- a/src/main/wsl/wsl-hosts-policy-live-update.test.ts +++ b/src/main/wsl/wsl-hosts-policy-live-update.test.ts @@ -2,7 +2,11 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { parseWslHostsPolicy, wslHostsPolicyState } from '../../shared/wsl-hosts-policy' +import { + parseWslHostsPolicy, + wslHostsPolicyMovedProjects, + wslHostsPolicyState +} from '../../shared/wsl-hosts-policy' import { readCachedWslHostsPolicy, WslHostsPolicyLiveUpdater, @@ -36,6 +40,28 @@ describe('WSL hosts policy parsing', () => { expect(parseWslHostsPolicy(text).ok).toBe(false) }) + it('reads moved projects as keep unless a block asks for them back', () => { + const parse = (fields: Record) => { + const parsed = parseWslHostsPolicy(JSON.stringify({ version: 1, revision: 1, ...fields })) + if (!parsed.ok) { + throw new Error(parsed.error) + } + return parsed.policy + } + expect(wslHostsPolicyMovedProjects(null)).toBe('keep') + expect(wslHostsPolicyMovedProjects(parse({ wslHosts: 'block' }))).toBe('keep') + expect( + wslHostsPolicyMovedProjects(parse({ wslHosts: 'block', movedProjects: 'bring-back' })) + ).toBe('bring-back') + expect( + wslHostsPolicyMovedProjects(parse({ wslHosts: 'allow', movedProjects: 'bring-back' })) + ).toBe('keep') + // A later value must not drop the block it rides on. + const later = parse({ wslHosts: 'block', movedProjects: 'some-later-mode' }) + expect(wslHostsPolicyState(later)).toBe('block') + expect(wslHostsPolicyMovedProjects(later)).toBe('keep') + }) + it('reads no policy as missing, never as a block', () => { expect(wslHostsPolicyState(null)).toBe('missing') expect(wslHostsPolicyState({ version: 1, revision: 1, wslHosts: 'allow' })).toBe('allow') diff --git a/src/shared/telemetry-wsl-usage-event-schemas.ts b/src/shared/telemetry-wsl-usage-event-schemas.ts index 235613ddb75..878a498eea1 100644 --- a/src/shared/telemetry-wsl-usage-event-schemas.ts +++ b/src/shared/telemetry-wsl-usage-event-schemas.ts @@ -49,7 +49,9 @@ export const wslUsageSnapshotSchema = z /** open / serve-only / closed. Optional, so older builds' payloads still validate. */ wsl_hosts_gate_state: z.enum(['open', 'serve-only', 'closed']).optional(), /** The cached remote WSL hosts policy applied at launch. Optional, for older builds. */ - wsl_hosts_policy: z.enum(['allow', 'block', 'missing']).optional() + wsl_hosts_policy: z.enum(['allow', 'block', 'missing']).optional(), + /** With a block only: whether it asks for moved projects back. Optional, for older builds. */ + wsl_hosts_moved_projects: z.enum(['keep', 'bring-back']).optional() }) .strict() diff --git a/src/shared/wsl-hosts-policy.ts b/src/shared/wsl-hosts-policy.ts index ba5bc81951e..a0a1cfa43e0 100644 --- a/src/shared/wsl-hosts-policy.ts +++ b/src/shared/wsl-hosts-policy.ts @@ -11,7 +11,12 @@ const WslHostsPolicySchema = z.object({ version: z.literal(1), /** Monotonic: a lower or equal revision never replaces the cached one. */ revision: z.number().int().positive(), - wslHosts: z.enum(['allow', 'block']) + wslHosts: z.enum(['allow', 'block']), + /** + * With `block` only: `bring-back` returns already-moved projects to this computer. An unknown + * value reads as absent (`keep`), so a later value never drops the block itself. + */ + movedProjects: z.enum(['keep', 'bring-back']).optional().catch(undefined) }) export type WslHostsPolicy = z.infer @@ -37,3 +42,12 @@ export type WslHostsPolicyState = 'allow' | 'block' | 'missing' export function wslHostsPolicyState(policy: WslHostsPolicy | null): WslHostsPolicyState { return policy?.wslHosts ?? 'missing' } + +export type WslHostsMovedProjects = 'keep' | 'bring-back' + +/** `keep` unless the policy blocks and asks for moved projects back. */ +export function wslHostsPolicyMovedProjects(policy: WslHostsPolicy | null): WslHostsMovedProjects { + return policy?.wslHosts === 'block' && policy.movedProjects === 'bring-back' + ? 'bring-back' + : 'keep' +}