mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-05 10:13:12 +00:00
fix: prevent default profile credential collision (#1628)
* fix: prevent default profile credential collision * fix: isolate default profile credential fallback
This commit is contained in:
1 parent
20df3445d9
commit
343899f6c1
4 files changed
+124
-17
No files matched your search
@@ -36,6 +36,7 @@ export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
|
||||
export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard';
|
||||
export const MAX_CONTEXT_GROUP_LENGTH = 64;
|
||||
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||
const RESERVED_ACCOUNT_PROFILE_NAMES = new Set(['default']);
|
||||
|
||||
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||
|
||||
@@ -57,7 +58,10 @@ export function isValidContextGroupName(value: string): boolean {
|
||||
* Validate account profile naming constraints.
|
||||
*/
|
||||
export function isValidAccountProfileName(value: string): boolean {
|
||||
return ACCOUNT_PROFILE_NAME_PATTERN.test(value);
|
||||
return (
|
||||
ACCOUNT_PROFILE_NAME_PATTERN.test(value) &&
|
||||
!RESERVED_ACCOUNT_PROFILE_NAMES.has(value.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -497,24 +497,31 @@ function serveCached(state: ProviderState): BarSummaryRow | null {
|
||||
* is absent or unparseable, returns null — the caller emits a parked row.
|
||||
* Never calls security/Keychain — zero new keychain access from this feature.
|
||||
*/
|
||||
function readClaudeCredentialsForProfileFromDisk(profile: string): ClaudeNativeCredentials | null {
|
||||
// The bare `ccs` default login uses the standard global credential lookup:
|
||||
// ~/.claude/.credentials.json, falling back to the single global
|
||||
// "Claude Code-credentials" Keychain item that Claude Code itself maintains.
|
||||
// This is the ONE pre-existing global read the shipped Bar already performs --
|
||||
// NOT a per-profile Keychain scan. Isolated `ccs auth` profiles below stay
|
||||
// file-only and never touch the Keychain.
|
||||
if (profile === DEFAULT_PROFILE) {
|
||||
return readClaudeCredentials();
|
||||
}
|
||||
function readClaudeCredentialsForProfileFromDisk(
|
||||
profile: string,
|
||||
readDefaultCredentials: () => ClaudeNativeCredentials | null = readClaudeCredentials
|
||||
): ClaudeNativeCredentials | null {
|
||||
try {
|
||||
const instanceDir = path.join(getCcsDir(), 'instances', profile);
|
||||
const credFile = path.join(instanceDir, '.credentials.json');
|
||||
if (!fs.existsSync(credFile)) return null;
|
||||
const raw = fs.readFileSync(credFile, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as ClaudeNativeCredentials;
|
||||
if (fs.existsSync(credFile)) {
|
||||
const raw = fs.readFileSync(credFile, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as ClaudeNativeCredentials;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The bare `ccs` default login uses the standard global credential lookup:
|
||||
// ~/.claude/.credentials.json, falling back to the single global
|
||||
// "Claude Code-credentials" Keychain item that Claude Code itself maintains.
|
||||
// This is the ONE pre-existing global read the shipped Bar already performs --
|
||||
// NOT a per-profile Keychain scan. Isolated `ccs auth` profiles stay
|
||||
// file-only and never touch the Keychain; a real instance directory named
|
||||
// "default" is therefore parked when its file is absent.
|
||||
if (profile === DEFAULT_PROFILE && !fs.existsSync(instanceDir)) {
|
||||
return readDefaultCredentials();
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
@@ -829,9 +836,10 @@ async function collectClaudeRowForProfile(
|
||||
}
|
||||
|
||||
// For per-profile reads: use the injected seam (file-only, no keychain).
|
||||
const readDefaultCredentials = deps.readCredentials ?? readClaudeCredentials;
|
||||
const readCreds =
|
||||
deps.readClaudeCredentialsForProfile ??
|
||||
((p: string) => readClaudeCredentialsForProfileFromDisk(p));
|
||||
((p: string) => readClaudeCredentialsForProfileFromDisk(p, readDefaultCredentials));
|
||||
const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken;
|
||||
const sleep = deps.sleep ?? defaultSleep;
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ describe('account context helpers', () => {
|
||||
expect(isValidAccountProfileName('gemini:default')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects reserved account profile names', () => {
|
||||
expect(isValidAccountProfileName('default')).toBe(false);
|
||||
expect(isValidAccountProfileName('Default')).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to default shared group for invalid persisted metadata', () => {
|
||||
const resolved = resolveAccountContextPolicy({
|
||||
context_mode: 'shared',
|
||||
|
||||
@@ -1251,6 +1251,96 @@ describe('multi-profile: Claude file-only reader', () => {
|
||||
expect(row?.quotaStatus).toBe('unsupported');
|
||||
expect(row?.is_subscription).toBe(true);
|
||||
});
|
||||
|
||||
it('existing default profile directory without creds does not read global credentials', async () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempCcsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-default-profile-'));
|
||||
const clock = { now: 1_000_000 };
|
||||
let defaultReadCount = 0;
|
||||
let fetchCount = 0;
|
||||
|
||||
try {
|
||||
process.env.CCS_HOME = tempCcsHome;
|
||||
fs.mkdirSync(path.join(tempCcsHome, '.ccs', 'instances', 'default'), { recursive: true });
|
||||
|
||||
const rows = await getNativeAccountRows({
|
||||
readCredentials: () => {
|
||||
defaultReadCount += 1;
|
||||
return { claudeAiOauth: { accessToken: 'global-token', subscriptionType: 'max' } };
|
||||
},
|
||||
listClaudeProfiles: () => ['default'],
|
||||
listCodexProfiles: () => [],
|
||||
defaultClaudeProfile: () => 'default',
|
||||
defaultCodexProfile: () => null,
|
||||
fetchClaudeQuota: async () => {
|
||||
fetchCount += 1;
|
||||
return successQuota();
|
||||
},
|
||||
getCodexQuota: async () => null,
|
||||
now: () => clock.now,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const row = rows.find((r) => r.surface === 'ccs' && r.profile === 'default');
|
||||
expect(defaultReadCount).toBe(0);
|
||||
expect(fetchCount).toBe(0);
|
||||
expect(row?.needsReauth).toBe(true);
|
||||
expect(row?.quotaStatus).toBe('unsupported');
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempCcsHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('existing default profile credentials win over global credentials', async () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempCcsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-default-profile-'));
|
||||
const clock = { now: 1_000_000 };
|
||||
let fetchedToken: string | null = null;
|
||||
|
||||
try {
|
||||
process.env.CCS_HOME = tempCcsHome;
|
||||
const defaultInstanceDir = path.join(tempCcsHome, '.ccs', 'instances', 'default');
|
||||
fs.mkdirSync(defaultInstanceDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(defaultInstanceDir, '.credentials.json'),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: 'profile-token', subscriptionType: 'max' } })
|
||||
);
|
||||
|
||||
const rows = await getNativeAccountRows({
|
||||
readCredentials: () => ({
|
||||
claudeAiOauth: { accessToken: 'global-token', subscriptionType: 'max' },
|
||||
}),
|
||||
listClaudeProfiles: () => ['default'],
|
||||
listCodexProfiles: () => [],
|
||||
defaultClaudeProfile: () => 'default',
|
||||
defaultCodexProfile: () => null,
|
||||
fetchClaudeQuota: async (token) => {
|
||||
fetchedToken = token;
|
||||
return successQuota();
|
||||
},
|
||||
getCodexQuota: async () => null,
|
||||
now: () => clock.now,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const row = rows.find((r) => r.surface === 'ccs' && r.profile === 'default');
|
||||
expect(fetchedToken).toBe('profile-token');
|
||||
expect(row?.needsReauth).toBe(false);
|
||||
expect(row?.quotaStatus).toBe('ok');
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempCcsHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-profile: per-profile circuit breaker isolation', () => {
|
||||
|
||||
Reference in new issue
Block a user