mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
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
This commit is contained in:
@@ -9,6 +9,7 @@ import InstanceManager from '../management/instance-manager';
|
|||||||
import ProfileRegistry from './profile-registry';
|
import ProfileRegistry from './profile-registry';
|
||||||
import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context';
|
import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context';
|
||||||
import type { ProfileType } from '../types/profile';
|
import type { ProfileType } from '../types/profile';
|
||||||
|
import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat';
|
||||||
|
|
||||||
export interface ProfileContinuityInheritanceInput {
|
export interface ProfileContinuityInheritanceInput {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
@@ -50,7 +51,14 @@ function loadLegacyContinuityInheritanceMap(): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
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 {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,13 +67,38 @@ function resolveMappedAccount(
|
|||||||
profileName: string,
|
profileName: string,
|
||||||
inheritFromAccount: Record<string, string>
|
inheritFromAccount: Record<string, string>
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const mapped = inheritFromAccount[profileName];
|
const candidates = new Set<string>([
|
||||||
if (typeof mapped !== 'string') {
|
...getProfileLookupCandidates(profileName),
|
||||||
return undefined;
|
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();
|
const normalizedCandidates = new Set(
|
||||||
return normalized.length > 0 ? normalized : undefined;
|
[...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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const registry = new ProfileRegistry();
|
try {
|
||||||
const profiles = registry.getAllProfilesMerged();
|
const registry = new ProfileRegistry();
|
||||||
const mappedProfile = profiles[sourceAccount];
|
const profiles = registry.getAllProfilesMerged();
|
||||||
if (!mappedProfile || mappedProfile.type !== 'account') {
|
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(
|
console.error(
|
||||||
warn(
|
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 {};
|
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
const result = await resolveProfileContinuityInheritance({
|
||||||
profileName: 'glm',
|
profileName: 'glm',
|
||||||
@@ -64,9 +57,6 @@ describe('resolveProfileContinuityInheritance', () => {
|
|||||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
||||||
});
|
});
|
||||||
expect(getProfilesSpy).toHaveBeenCalledTimes(1);
|
expect(getProfilesSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(hasUnifiedSpy).toHaveBeenCalledWith('pro');
|
|
||||||
expect(touchUnifiedSpy).toHaveBeenCalledWith('pro');
|
|
||||||
expect(touchLegacySpy).not.toHaveBeenCalled();
|
|
||||||
expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', {
|
expect(ensureInstanceSpy).toHaveBeenCalledWith('pro', {
|
||||||
mode: 'shared',
|
mode: 'shared',
|
||||||
group: 'team-alpha',
|
group: 'team-alpha',
|
||||||
@@ -101,11 +91,6 @@ describe('resolveProfileContinuityInheritance', () => {
|
|||||||
last_used: null,
|
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');
|
spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/work');
|
||||||
|
|
||||||
const result = await resolveProfileContinuityInheritance({
|
const result = await resolveProfileContinuityInheritance({
|
||||||
@@ -118,7 +103,6 @@ describe('resolveProfileContinuityInheritance', () => {
|
|||||||
sourceAccount: 'work',
|
sourceAccount: 'work',
|
||||||
claudeConfigDir: '/tmp/.ccs/instances/work',
|
claudeConfigDir: '/tmp/.ccs/instances/work',
|
||||||
});
|
});
|
||||||
expect(touchLegacySpy).toHaveBeenCalledWith('work');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty result when mapped source account does not exist', async () => {
|
it('returns empty result when mapped source account does not exist', async () => {
|
||||||
@@ -195,8 +179,6 @@ describe('resolveProfileContinuityInheritance', () => {
|
|||||||
last_used: null,
|
last_used: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
spyOn(ProfileRegistry.prototype, 'hasAccountUnified').mockReturnValue(true);
|
|
||||||
spyOn(ProfileRegistry.prototype, 'touchAccountUnified').mockImplementation(() => undefined);
|
|
||||||
spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/pro');
|
spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue('/tmp/.ccs/instances/pro');
|
||||||
|
|
||||||
const result = await resolveProfileContinuityInheritance({
|
const result = await resolveProfileContinuityInheritance({
|
||||||
@@ -210,4 +192,99 @@ describe('resolveProfileContinuityInheritance', () => {
|
|||||||
claudeConfigDir: '/tmp/.ccs/instances/pro',
|
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<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||||
|
|
||||||
|
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<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||||
|
|
||||||
|
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<typeof configLoader.loadOrCreateUnifiedConfig>);
|
||||||
|
|
||||||
|
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({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user