From 8a45727b314194af42533ec0cad95a3314ada162 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 3 Mar 2026 02:48:22 +0700 Subject: [PATCH] fix(auth): harden continuity inheritance resolution - resolve mappings with alias/canonical profile candidates - fail open when source account initialization errors - remove metadata touch side effects from inheritance preflight --- src/auth/profile-continuity-inheritance.ts | 98 ++++++++++----- .../profile-continuity-inheritance.test.ts | 113 +++++++++++++++--- 2 files changed, 160 insertions(+), 51 deletions(-) diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts index eb5f85e8..9b2bd880 100644 --- a/src/auth/profile-continuity-inheritance.ts +++ b/src/auth/profile-continuity-inheritance.ts @@ -9,6 +9,7 @@ import InstanceManager from '../management/instance-manager'; import ProfileRegistry from './profile-registry'; import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context'; import type { ProfileType } from '../types/profile'; +import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat'; export interface ProfileContinuityInheritanceInput { profileName: string; @@ -50,7 +51,14 @@ function loadLegacyContinuityInheritanceMap(): Record { } return normalized; - } catch { + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn( + `Failed to parse legacy continuity mapping at "${configJsonPath}": ${(error as Error).message}` + ) + ); + } return {}; } } @@ -59,13 +67,38 @@ function resolveMappedAccount( profileName: string, inheritFromAccount: Record ): string | undefined { - const mapped = inheritFromAccount[profileName]; - if (typeof mapped !== 'string') { - return undefined; + const candidates = new Set([ + ...getProfileLookupCandidates(profileName), + resolveAliasToCanonical(profileName), + ]); + + for (const candidate of candidates) { + const mapped = inheritFromAccount[candidate]; + if (typeof mapped !== 'string') { + continue; + } + + const normalized = mapped.trim(); + if (normalized.length > 0) { + return normalized; + } } - const normalized = mapped.trim(); - return normalized.length > 0 ? normalized : undefined; + const normalizedCandidates = new Set( + [...candidates].map((candidate) => candidate.trim().toLowerCase()).filter(Boolean) + ); + for (const [mappedProfileName, mappedAccountName] of Object.entries(inheritFromAccount)) { + if (!normalizedCandidates.has(mappedProfileName.trim().toLowerCase())) { + continue; + } + + const normalized = mappedAccountName.trim(); + if (normalized.length > 0) { + return normalized; + } + } + + return undefined; } /** @@ -93,37 +126,36 @@ export async function resolveProfileContinuityInheritance( return {}; } - const registry = new ProfileRegistry(); - const profiles = registry.getAllProfilesMerged(); - const mappedProfile = profiles[sourceAccount]; - if (!mappedProfile || mappedProfile.type !== 'account') { + try { + const registry = new ProfileRegistry(); + const profiles = registry.getAllProfilesMerged(); + const mappedProfile = profiles[sourceAccount]; + if (!mappedProfile || mappedProfile.type !== 'account') { + console.error( + warn( + `Continuity inheritance skipped for "${input.profileName}": source "${sourceAccount}" not found or not an account profile` + ) + ); + return {}; + } + + const contextPolicy = resolveAccountContextPolicy( + isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined + ); + const instanceMgr = new InstanceManager(); + const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy); + + return { + sourceAccount, + claudeConfigDir: instancePath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); console.error( warn( - `Continuity inheritance skipped for "${input.profileName}": source account "${sourceAccount}" not found` + `Continuity inheritance skipped for "${input.profileName}": failed to initialize source "${sourceAccount}" (${message})` ) ); return {}; } - - const contextPolicy = resolveAccountContextPolicy( - isAccountContextMetadata(mappedProfile) ? mappedProfile : undefined - ); - const instanceMgr = new InstanceManager(); - const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy); - - // Best-effort touch only; execution must continue even if touch fails. - try { - if (registry.hasAccountUnified(sourceAccount)) { - registry.touchAccountUnified(sourceAccount); - } else if (registry.hasProfile(sourceAccount)) { - registry.touchProfile(sourceAccount); - } - } catch { - // Ignore metadata touch failure. - } - - return { - sourceAccount, - claudeConfigDir: instancePath, - }; } diff --git a/tests/unit/auth/profile-continuity-inheritance.test.ts b/tests/unit/auth/profile-continuity-inheritance.test.ts index f46c1e69..f8f31a4e 100644 --- a/tests/unit/auth/profile-continuity-inheritance.test.ts +++ b/tests/unit/auth/profile-continuity-inheritance.test.ts @@ -45,13 +45,6 @@ describe('resolveProfileContinuityInheritance', () => { }, } ); - const hasUnifiedSpy = spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(true); - const touchUnifiedSpy = spyOn(ProfileRegistry.prototype, 'touchAccountUnified').mockImplementation( - () => undefined - ); - const touchLegacySpy = spyOn(ProfileRegistry.prototype, 'touchProfile').mockImplementation( - () => undefined - ); const result = await resolveProfileContinuityInheritance({ profileName: 'glm', @@ -64,9 +57,6 @@ describe('resolveProfileContinuityInheritance', () => { claudeConfigDir: '/tmp/.ccs/instances/pro', }); expect(getProfilesSpy).toHaveBeenCalledTimes(1); - expect(hasUnifiedSpy).toHaveBeenCalledWith('pro'); - expect(touchUnifiedSpy).toHaveBeenCalledWith('pro'); - expect(touchLegacySpy).not.toHaveBeenCalled(); expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', { mode: 'shared', group: 'team-alpha', @@ -101,11 +91,6 @@ describe('resolveProfileContinuityInheritance', () => { last_used: null, }, }); - spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(false); - spyOn(ProfileRegistry.prototype, 'hasProfile').mockReturnValue(true); - const touchLegacySpy = spyOn(ProfileRegistry.prototype, 'touchProfile').mockImplementation( - () => undefined - ); spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/work'); const result = await resolveProfileContinuityInheritance({ @@ -118,7 +103,6 @@ describe('resolveProfileContinuityInheritance', () => { sourceAccount: 'work', claudeConfigDir: '/tmp/.ccs/instances/work', }); - expect(touchLegacySpy).toHaveBeenCalledWith('work'); }); it('returns empty result when mapped source account does not exist', async () => { @@ -195,8 +179,6 @@ describe('resolveProfileContinuityInheritance', () => { last_used: null, }, }); - spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(true); - spyOn(ProfileRegistry.prototype, 'touchAccountUnified').mockImplementation(() => undefined); spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/pro'); const result = await resolveProfileContinuityInheritance({ @@ -210,4 +192,99 @@ describe('resolveProfileContinuityInheritance', () => { claudeConfigDir: '/tmp/.ccs/instances/pro', }); }); + + it('returns empty result when mapped source exists but is not an account profile', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + glm: 'settings-profile', + }, + }, + } as ReturnType); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + 'settings-profile': { + type: 'settings', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance'); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({}); + expect(ensureInstanceSpy).not.toHaveBeenCalled(); + }); + + it('supports profile alias lookup when continuity mapping uses legacy key', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + kimi: 'pro', + }, + }, + } as ReturnType); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + pro: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + const ensureInstanceSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + '/tmp/.ccs/instances/pro' + ); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'km', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({ + sourceAccount: 'pro', + claudeConfigDir: '/tmp/.ccs/instances/pro', + }); + expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', { + mode: 'isolated', + }); + }); + + it('fails open when source account instance initialization throws', async () => { + spyOn(configLoader, 'loadOrCreateUnifiedConfig').mockReturnValue({ + version: 8, + continuity: { + inherit_from_account: { + glm: 'pro', + }, + }, + } as ReturnType); + + spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ + pro: { + type: 'account', + created: '2026-03-01T00:00:00.000Z', + last_used: null, + }, + }); + spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue( + new Error('instance init failed') + ); + + const result = await resolveProfileContinuityInheritance({ + profileName: 'glm', + profileType: 'settings', + target: 'claude', + }); + + expect(result).toEqual({}); + }); });