diff --git a/src/main/claude-accounts/claude-account-selection.ts b/src/main/claude-accounts/claude-account-selection.ts index 22234b1fdde..6f9523402ec 100644 --- a/src/main/claude-accounts/claude-account-selection.ts +++ b/src/main/claude-accounts/claude-account-selection.ts @@ -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.') } diff --git a/src/main/claude-accounts/claude-account-service.test.ts b/src/main/claude-accounts/claude-account-service.test.ts index 2baf96adf9a..72ab9844a50 100644 --- a/src/main/claude-accounts/claude-account-service.test.ts +++ b/src/main/claude-accounts/claude-account-service.test.ts @@ -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 } }) + }) }) diff --git a/src/main/claude-accounts/claude-profile-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts index e489c4a8434..1e116c7b368 100644 --- a/src/main/claude-accounts/claude-profile-paths.test.ts +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -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', diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts index 3c61c1c901d..62471150aa1 100644 --- a/src/main/claude-accounts/claude-profile-paths.ts +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -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' diff --git a/src/main/claude-accounts/claude-profile-wsl-router.test.ts b/src/main/claude-accounts/claude-profile-wsl-router.test.ts index 3da53b6a4dc..f6ba3b2b15e 100644 --- a/src/main/claude-accounts/claude-profile-wsl-router.test.ts +++ b/src/main/claude-accounts/claude-profile-wsl-router.test.ts @@ -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) } ) diff --git a/src/main/claude-accounts/runtime-selection.test.ts b/src/main/claude-accounts/runtime-selection.test.ts index b7f64aede99..902d175761f 100644 --- a/src/main/claude-accounts/runtime-selection.test.ts +++ b/src/main/claude-accounts/runtime-selection.test.ts @@ -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' diff --git a/src/main/claude-accounts/runtime-selection.ts b/src/main/claude-accounts/runtime-selection.ts index 1fc5137bb17..e9cfe2e754d 100644 --- a/src/main/claude-accounts/runtime-selection.ts +++ b/src/main/claude-accounts/runtime-selection.ts @@ -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, + 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 {