mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user