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 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-29 00:04:54 -07:00
committed by GitHub
co-authored by Claude
parent 4c963268d2
commit 478a008299
10 changed files with 187 additions and 14 deletions
+3 -1
View File
@@ -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()
}
}
@@ -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'
+7 -2
View File
@@ -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 }
: {})
}
}
+20 -2
View File
@@ -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')
+11 -2
View File
@@ -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())
})
}
+53
View File
@@ -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)
})
})
})
+29 -4
View File
@@ -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'
@@ -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<string, unknown>) => {
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')
@@ -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()
+15 -1
View File
@@ -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<typeof WslHostsPolicySchema>
@@ -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'
}