diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index a8c28b50..52ce2eb8 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -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()) + ); } /** diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index 48466962..ecf942c3 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -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; diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts index ecb21c74..affb01b3 100644 --- a/tests/unit/account-context.test.ts +++ b/tests/unit/account-context.test.ts @@ -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', diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 543086ae..9c24fa0f 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -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', () => {