fix(claude): retain managed WSL selection across distro casing (#31992)

* fix(claude): match WSL account selection across distro casing

* fix(claude): keep exact WSL selection spelling ahead of casing aliases

A saved key that matches the launch distro exactly now resolves as before,
so existing mixed-alias selections do not regress to System default.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-22 20:31:11 -07:00
committed by GitHub
co-authored by Claude
parent 18ee07f767
commit 77f1523d72
7 changed files with 195 additions and 12 deletions
@@ -10,6 +10,7 @@ import { savedWslClaudeAccountHome } from './claude-profile-wsl-paths'
import type { ClaudeRuntimeAuthService } from './runtime-auth-service'
import {
getClaudeSelectionTargetForAccount,
getClaudeWslSelectionKey,
getSelectedClaudeAccountIdForTarget,
normalizeClaudeAccountSelectionTarget,
normalizeClaudeRuntimeSelection,
@@ -179,7 +180,9 @@ export class ClaudeAccountSelection {
const owned = normalizeClaudeAccountSelectionTarget(accountTarget)
if (
requested.runtime !== owned.runtime ||
(requested.wslDistro !== null && requested.wslDistro !== owned.wslDistro)
(requested.wslDistro !== null &&
getClaudeWslSelectionKey(requested.wslDistro) !==
getClaudeWslSelectionKey(owned.wslDistro))
) {
throw new Error('That Claude account belongs to a different runtime.')
}
@@ -544,4 +544,32 @@ describe('ClaudeAccountService', () => {
'That Claude account belongs to a different runtime.'
)
})
it('selects and clears a WSL account across casing while retaining its recorded distro', async () => {
const f = fixture([
account('a'),
account('w', {
managedAuthRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxAuthPath: '/home/u/.local/share/orca/claude-profiles/w/home'
})
])
const selected = await f.service.selectAccountForTarget('w', {
runtime: 'wsl',
wslDistro: 'ubuntu'
})
expect(selected.activeAccountIdsByRuntime).toEqual({ host: 'a', wsl: { Ubuntu: 'w' } })
expect(f.runtimeAuth.syncForCurrentSelection).toHaveBeenCalledWith({
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
await expect(
f.service.selectAccountForTarget('w', { runtime: 'wsl', wslDistro: 'Debian' })
).rejects.toThrow('different runtime')
const cleared = await f.service.selectAccountForTarget(null, {
runtime: 'wsl',
wslDistro: 'ubuntu'
})
expect(cleared.activeAccountIdsByRuntime).toEqual({ host: 'a', wsl: { Ubuntu: null } })
})
})
@@ -43,6 +43,44 @@ afterEach(() => {
const local = { runtime: 'host', executionHostId: 'local' } as const
describe('Claude profile namespace', () => {
it.skipIf(process.platform === 'win32')(
'accepts a WSL casing alias while retaining the original marker and rejecting other owners',
() => {
const dir = root()
const userHome = root()
const profile = describeClaudeProfile(dir, 'a', {
...local,
runtime: 'wsl',
distro: 'Ubuntu'
})
prepareClaudeProfileDirectory(dir, profile, userHome)()
const marker = join(dir, 'claude-profiles/a/profile.json')
const original = readFileSync(marker, 'utf8')
prepareClaudeProfileDirectory(
dir,
{ ...profile, target: { ...profile.target, runtime: 'wsl', distro: 'ubuntu' } },
userHome
)()
expect(readFileSync(marker, 'utf8')).toBe(original)
for (const distro of ['Debian', 'Ubuntu-24.04']) {
expect(() =>
prepareClaudeProfileDirectory(
dir,
{ ...profile, target: { ...local, runtime: 'wsl', distro } },
userHome
)
).toThrow('another account or target')
}
writeFileSync(
marker,
JSON.stringify({ version: 1, accountId: 'b', runtime: 'wsl', distro: 'ubuntu' })
)
expect(() => prepareClaudeProfileDirectory(dir, profile, userHome)).toThrow(
'another account or target'
)
}
)
// A WSL profile's data root is a POSIX path; a Windows temp dir cannot be one.
it.skipIf(process.platform === 'win32')(
'binds the new namespace to an account and execution target without touching legacy auth',
@@ -78,6 +78,20 @@ function ownershipRecord(
return JSON.stringify({ version, accountId, runtime, distro })
}
function comparableOwnershipRecord(
version: unknown,
accountId: unknown,
runtime: unknown,
distro: unknown
): string {
return ownershipRecord(
version,
accountId,
runtime,
runtime === 'wsl' && typeof distro === 'string' ? distro.trim().toLowerCase() : distro
)
}
function readOwnershipMarker(file: string): string | null {
try {
if (!lstatSync(file).isFile()) {
@@ -94,7 +108,7 @@ function readOwnershipMarker(file: string): string | null {
throw new ClaudeProfileSurfaceError('unreadable', 'Claude profile marker is unreadable')
}
const { version, accountId, runtime, distro } = marker.value
return ownershipRecord(version, accountId, runtime, distro)
return comparableOwnershipRecord(version, accountId, runtime, distro)
}
// Written when a first setup finishes, so its absence means no setup has finished here yet.
@@ -132,7 +146,11 @@ export function prepareClaudeProfileDirectory(
const distro = profile.target.runtime === 'wsl' ? profile.target.distro : undefined
const record = ownershipRecord(profile.version, profile.accountId, profile.target.runtime, distro)
const marker = readOwnershipMarker(markerPath)
if (marker !== null && marker !== record) {
if (
marker !== null &&
marker !==
comparableOwnershipRecord(profile.version, profile.accountId, profile.target.runtime, distro)
) {
throw new ClaudeProfileSurfaceError(
'invalid-profile',
'Claude profile belongs to another account or target'
@@ -96,6 +96,20 @@ function fixture() {
}
describe.skipIf(!posixHost)('ClaudeWslProfileRouter', () => {
it('publishes and launches the saved account across distro casing without changing guest spelling', async () => {
const f = fixture()
await f.router.publish('ubuntu')
expect(readFileSync(f.pointer, 'utf8')).toBe(f.profileHome)
const launch = await f.router.prepareLaunch('ubuntu')
expect(launch).toMatchObject({
wslDistro: 'ubuntu',
envPatch: { CLAUDE_CONFIG_DIR: f.profileHome }
})
f.settings.claudeManagedAccounts[0].wslDistro = 'ubuntu'
expect(await f.router.runningDistros()).toEqual(['Ubuntu'])
expect((await f.router.preparation('Ubuntu')).envPatch.CLAUDE_CONFIG_DIR).toBe(f.profileHome)
})
it('writes the guest pointer per build, sets up even a missing folder, and removes it with the last account', async () => {
const f = fixture()
await f.router.publish('Ubuntu')
@@ -362,13 +376,17 @@ it.skipIf(!posixHost)(
external: ['electron'],
logLevel: 'silent'
})
const run = (accountId: string) =>
spawnSync(process.execPath, [helper, home, 'Ubuntu', accountId], { encoding: 'utf8' })
const run = (accountId: string, distro = 'Ubuntu') =>
spawnSync(process.execPath, [helper, home, distro, accountId], { encoding: 'utf8' })
expect(run('a').status).toBe(0)
expect(existsSync(join(profileHome, '..', 'profile.json'))).toBe(true)
// History is a Linux link into the guest's own ~/.claude.
expect(lstatSync(join(profileHome, 'projects')).isSymbolicLink()).toBe(true)
const marker = readFileSync(join(profileHome, '..', 'profile.json'), 'utf8')
expect(run('a', 'ubuntu').status).toBe(0)
expect(readFileSync(join(profileHome, '..', 'profile.json'), 'utf8')).toBe(marker)
expect(run('a', 'Debian').status).not.toBe(0)
expect(run('../escape').status).not.toBe(0)
}
)
@@ -41,6 +41,67 @@ function createAccount(
}
describe('Claude runtime account selection', () => {
it('reads legacy distro spelling without changing the selection or launch target', () => {
const settings = createSettings({
activeClaudeManagedAccountIdsByRuntime: {
host: 'host-account',
wsl: { Ubuntu: 'wsl-account', Debian: 'debian-account' }
}
})
for (const wslDistro of ['Ubuntu', 'ubuntu', ' UBUNTU ']) {
expect(getSelectedClaudeAccountIdForTarget(settings, { runtime: 'wsl', wslDistro })).toBe(
'wsl-account'
)
}
expect(
getSelectedClaudeAccountIdForTarget(settings, { runtime: 'wsl', wslDistro: 'Fedora' })
).toBeNull()
expect(settings.activeClaudeManagedAccountIdsByRuntime?.wsl).toEqual({
Ubuntu: 'wsl-account',
Debian: 'debian-account'
})
})
it('updates every saved alias while preserving their spelling and unrelated runtimes', () => {
const selection = { host: 'host-account', wsl: { Ubuntu: 'a', ubuntu: 'a', Debian: 'd' } }
const changed = setSelectedClaudeAccountIdForTarget(selection, 'b', {
runtime: 'wsl',
wslDistro: 'UBUNTU'
})
expect(changed).toEqual({
host: 'host-account',
wsl: { Ubuntu: 'b', ubuntu: 'b', Debian: 'd' }
})
expect(
setSelectedClaudeAccountIdForTarget(changed, null, { runtime: 'wsl', wslDistro: 'ubuntu' })
).toEqual({ host: 'host-account', wsl: { Ubuntu: null, ubuntu: null, Debian: 'd' } })
expect(selection.wsl.Ubuntu).toBe('a')
})
it('keeps exact legacy spellings and does not choose between conflicting aliases', () => {
for (const wsl of [
{ Ubuntu: 'a', ubuntu: 'b' },
{ Ubuntu: 'a', ubuntu: null }
]) {
const settings = createSettings({
activeClaudeManagedAccountIdsByRuntime: { host: null, wsl }
})
const lookup = (wslDistro: string) =>
getSelectedClaudeAccountIdForTarget(settings, { runtime: 'wsl', wslDistro })
expect(lookup('Ubuntu')).toBe('a')
expect(lookup('ubuntu')).toBe(wsl.ubuntu)
expect(lookup('UBUNTU')).toBeNull()
}
})
it('retains selections when account and legacy map distro casing differ', () => {
expect(
pruneInvalidClaudeRuntimeSelection({ host: null, wsl: { ubuntu: 'a', Debian: 'a' } }, [
createAccount({ id: 'a', managedAuthRuntime: 'wsl', wslDistro: 'Ubuntu' })
])
).toEqual({ host: null, wsl: { ubuntu: 'a', Debian: null } })
})
it('selects host and WSL accounts independently', () => {
const first = setSelectedClaudeAccountIdForTarget({ host: null, wsl: {} }, 'host-account', {
runtime: 'host'
+24 -7
View File
@@ -52,7 +52,13 @@ export function getSelectedClaudeAccountIdForTarget(
return selection.host
}
if (normalizedTarget.wslDistro) {
return selection.wsl[getClaudeWslSelectionKey(normalizedTarget.wslDistro)] ?? null
// Why exact first: a saved spelling that matches keeps resolving as it did before aliasing.
if (Object.hasOwn(selection.wsl, normalizedTarget.wslDistro)) {
return selection.wsl[normalizedTarget.wslDistro] ?? null
}
const matches = matchingWslSelectionKeys(selection.wsl, normalizedTarget.wslDistro)
const ids = new Set(matches.map((key) => selection.wsl[key]))
return ids.size === 1 ? (ids.values().next().value ?? null) : null
}
const selectedIds = Array.from(new Set(Object.values(selection.wsl).filter(Boolean)))
return (
@@ -76,12 +82,15 @@ export function setSelectedClaudeAccountIdForTarget(
wsl: Object.fromEntries(Object.keys(selection.wsl).map((key) => [key, null]))
}
}
const wsl = { ...selection.wsl }
const matches = matchingWslSelectionKeys(wsl, normalizedTarget.wslDistro)
// Retain saved spelling for older clients; an explicit switch reconciles existing aliases.
for (const key of matches.length > 0 ? matches : [normalizedTarget.wslDistro ?? '__default__']) {
wsl[key] = accountId
}
return {
host: selection.host,
wsl: {
...selection.wsl,
[getClaudeWslSelectionKey(normalizedTarget.wslDistro)]: accountId
}
wsl
}
}
@@ -116,7 +125,7 @@ export function pruneInvalidClaudeRuntimeSelection(
nextWsl[distroKey] =
account &&
account.managedAuthRuntime === 'wsl' &&
getClaudeWslSelectionKey(account.wslDistro) === distroKey
getClaudeWslSelectionKey(account.wslDistro) === getClaudeWslSelectionKey(distroKey)
? accountId
: null
}
@@ -136,7 +145,15 @@ export function getClaudeSelectionTargetForAccount(
}
export function getClaudeWslSelectionKey(wslDistro: string | null | undefined): string {
return normalizeWslDistro(wslDistro) ?? '__default__'
return normalizeWslDistro(wslDistro)?.toLowerCase() ?? '__default__'
}
function matchingWslSelectionKeys(
selection: Record<string, string | null>,
distro: string | null
): string[] {
const key = getClaudeWslSelectionKey(distro)
return Object.keys(selection).filter((saved) => getClaudeWslSelectionKey(saved) === key)
}
function normalizeWslDistro(wslDistro: string | null | undefined): string | null {