Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions src/utils/__tests__/usage-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,20 @@ function parseLockContents(lockContents: string | null): { blockedUntil: number;
return lockContents ? JSON.parse(lockContents) as { blockedUntil: number; error?: string } : null;
}

// createTokenHome writes an access-token-only credentials file; these tests
// need a refresh token alongside it to exercise the account fingerprint.
function writeUsageCredentials(claudeConfig: string, claudeAiOauth: { accessToken: string; refreshToken?: string }): void {
fs.writeFileSync(path.join(claudeConfig, '.credentials.json'), JSON.stringify({ claudeAiOauth }));
}

function seedUsageCache(home: string, contents: Record<string, unknown>): { cacheFile: string; mtimeMs: number } {
const cacheDir = path.join(home, '.cache', 'ccstatusline');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, 'usage.json');
fs.writeFileSync(cacheFile, JSON.stringify(contents));
return { cacheFile, mtimeMs: fs.statSync(cacheFile).mtimeMs };
}

describe('fetchUsageData error handling', () => {
const nowMs = 2200000000000;
const successResponseBody = JSON.stringify({
Expand Down Expand Up @@ -993,6 +1007,106 @@ describe('fetchUsageData error handling', () => {
}
});

it('serves a fresh cache after the access token was refreshed (same login)', () => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('access-token-refreshed');
// The access token was reissued since the cache was written; the
// refresh token, which identifies the login, is unchanged.
writeUsageCredentials(home.claudeConfig, {
accessToken: 'reissued-access-token',
refreshToken: 'stable-refresh-token'
});
const fingerprint = createHash('sha256').update('stable-refresh-token').digest('hex').slice(0, 16);
const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: fingerprint });

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: mtimeMs + 5000,
pathDir: home.bin,
requiredFields: ['sessionUsage']
});

// A refresh must not look like an account switch: the cache stands
// and no request is made.
expect(result.requestCount).toBe(0);
expect(result.first.sessionUsage).toBe(5);
} finally {
harness.cleanup();
}
});

it('serves a stale cache through a rate-limit backoff after the access token was refreshed', () => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('access-token-refreshed-rate-limit');
writeUsageCredentials(home.claudeConfig, {
accessToken: 'reissued-access-token',
refreshToken: 'stable-refresh-token'
});
const fingerprint = createHash('sha256').update('stable-refresh-token').digest('hex').slice(0, 16);
const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: fingerprint });

// Cache is past CACHE_MAX_AGE and a server-issued backoff is active,
// so the stale-cache fallback is the only thing standing between the
// widgets and error text for the rest of the window.
const backedOffNowMs = mtimeMs + 200000;
fs.writeFileSync(path.join(home.home, '.cache', 'ccstatusline', 'usage.lock'), JSON.stringify({
blockedUntil: Math.floor(backedOffNowMs / 1000) + 3600,
error: 'rate-limited'
}));

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'unexpected',
nowMs: backedOffNowMs,
pathDir: home.bin,
requiredFields: ['sessionUsage']
});

expect(result.first).toEqual({ sessionUsage: 5 });
expect(result.second).toEqual(result.first);
expect(result.requestCount).toBe(0);
} finally {
harness.cleanup();
}
});

it('refetches when the refresh token changes (account switch)', () => {
const harness = createProbeHarness();

try {
const home = harness.createTokenHome('refresh-token-switched');
writeUsageCredentials(home.claudeConfig, {
accessToken: 'other-account-access-token',
refreshToken: 'other-account-refresh-token'
});
const previousFingerprint = createHash('sha256').update('previous-refresh-token').digest('hex').slice(0, 16);
const { mtimeMs } = seedUsageCache(home.home, { sessionUsage: 5, tokenHash: previousFingerprint });

const result = harness.runProbe({
claudeConfigDir: home.claudeConfig,
home: home.home,
mode: 'success',
nowMs: mtimeMs + 5000,
pathDir: home.bin,
requiredFields: ['sessionUsage'],
responseBody: successResponseBody
});

// A different login still invalidates the cache immediately.
expect(result.requestCount).toBe(1);
expect(result.first.sessionUsage).toBe(42);
} finally {
harness.cleanup();
}
});

it('treats enabled extra usage without a monthly limit as complete for extra usage widget fields', () => {
const harness = createProbeHarness();

Expand Down
94 changes: 68 additions & 26 deletions src/utils/usage-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ const MACOS_SECURITY_DUMP_MAX_BUFFER = 8 * 1024 * 1024;

export interface FetchUsageDataOptions { requiredFields?: readonly UsageDataField[] }

// The access token is what the API is called with; the refresh token is kept
// alongside it only to fingerprint the account (see fingerprintUsageCredentials).
interface UsageCredentials {
accessToken: string;
refreshToken?: string;
}

const EXTRA_USAGE_DETAIL_FIELDS = new Set<UsageDataField>([
'extraUsageLimit',
'extraUsageUsed',
Expand All @@ -55,7 +62,12 @@ const WINDOW_RESET_FIELD_SENTINELS: Partial<Record<UsageDataField, UsageDataFiel
...Object.fromEntries(WEEKLY_MODEL_USAGE_BUCKETS.map(bucket => [bucket.resetField, bucket.usageField]))
};

const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
const UsageCredentialsSchema = z.object({
claudeAiOauth: z.object({
accessToken: z.string().nullable().optional(),
refreshToken: z.string().nullable().optional()
}).optional()
});
const UsageLockErrorSchema = z.enum(['timeout', 'rate-limited', 'parse-error']);
const UsageLockSchema = z.object({
blockedUntil: z.number(),
Expand Down Expand Up @@ -180,9 +192,16 @@ function parseJsonWithSchema<T>(rawJson: string, schema: z.ZodType<T>): T | null
}
}

function parseUsageAccessToken(rawJson: string): string | null {
const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema);
return parsed?.claudeAiOauth?.accessToken ?? null;
function parseUsageCredentials(rawJson: string): UsageCredentials | null {
const oauth = parseJsonWithSchema(rawJson, UsageCredentialsSchema)?.claudeAiOauth;
if (!oauth?.accessToken) {
return null;
}

return {
accessToken: oauth.accessToken,
refreshToken: oauth.refreshToken ?? undefined
};
}

function parseCachedUsageData(rawJson: string): UsageData | null {
Expand Down Expand Up @@ -213,14 +232,32 @@ function parseCachedUsageData(rawJson: string): UsageData | null {
};
}

// One-way fingerprint of the usage token, persisted alongside the cache so a
// login switch (e.g. enterprise<->personal, a different token) invalidates the
// cache immediately instead of waiting out the TTL. A truncated SHA-256 is a
// stable identifier, not the token itself, so it is safe to write to disk.
// One-way fingerprint of the usage credentials, persisted alongside the cache
// so a login switch (e.g. enterprise<->personal, a different account)
// invalidates the cache immediately instead of waiting out the TTL. A
// truncated SHA-256 is a stable identifier, not the token itself, so it is
// safe to write to disk.
function fingerprintUsageToken(token: string): string {
return createHash('sha256').update(token).digest('hex').slice(0, 16);
}

// The access token is reissued on a short cycle (observed: ~8h expiry), so
// hashing it makes the fingerprint change on an ordinary refresh of the very
// same account. That is indistinguishable from a login switch here, and it
// discards an otherwise valid cache - which matters most exactly when the
// cache is load-bearing, i.e. while a fetch failure is being backed off: the
// widgets then degrade to error text for the whole window (up to the server's
// Retry-After) even though the cached reading is the user's own and current.
//
// The refresh token identifies the login rather than the session (observed:
// ~13 day expiry, two orders of magnitude longer), so fingerprinting it keeps
// the account-switch guard intact without churning on every refresh. It falls
// back to the access token when absent, which keeps older credential files and
// any keychain entry that only stores an access token working as before.
function fingerprintUsageCredentials(credentials: UsageCredentials): string {
return fingerprintUsageToken(credentials.refreshToken ?? credentials.accessToken);
}

function readCachedTokenHash(rawJson: string): string | undefined {
return parseJsonWithSchema(rawJson, CachedTokenHashSchema)?.tokenHash;
}
Expand Down Expand Up @@ -469,9 +506,9 @@ function readMacKeychainSecret(service: string): string | null {
}
}

function readUsageTokenFromMacKeychainService(service: string): string | null {
function readUsageCredentialsFromMacKeychainService(service: string): UsageCredentials | null {
const secret = readMacKeychainSecret(service);
return secret ? parseUsageAccessToken(secret) : null;
return secret ? parseUsageCredentials(secret) : null;
}

function listMacKeychainCredentialCandidates(): string[] {
Expand All @@ -493,36 +530,40 @@ function listMacKeychainCredentialCandidates(): string[] {
}
}

function readUsageTokenFromMacKeychainCandidates(): string | null {
function readUsageCredentialsFromMacKeychainCandidates(): UsageCredentials | null {
const candidates = listMacKeychainCredentialCandidates();

for (const service of candidates) {
const token = readUsageTokenFromMacKeychainService(service);
if (token) {
return token;
const credentials = readUsageCredentialsFromMacKeychainService(service);
if (credentials) {
return credentials;
}
}

return null;
}

function readUsageTokenFromCredentialsFile(): string | null {
function readUsageCredentialsFromCredentialsFile(): UsageCredentials | null {
try {
const credFile = path.join(getClaudeConfigDir(), '.credentials.json');
return parseUsageAccessToken(fs.readFileSync(credFile, 'utf8'));
return parseUsageCredentials(fs.readFileSync(credFile, 'utf8'));
} catch {
return null;
}
}

export function getUsageToken(): string | null {
export function getUsageCredentials(): UsageCredentials | null {
if (process.platform !== 'darwin') {
return readUsageTokenFromCredentialsFile();
return readUsageCredentialsFromCredentialsFile();
}

return readUsageTokenFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE)
?? readUsageTokenFromMacKeychainCandidates()
?? readUsageTokenFromCredentialsFile();
return readUsageCredentialsFromMacKeychainService(MACOS_USAGE_CREDENTIALS_SERVICE)
?? readUsageCredentialsFromMacKeychainCandidates()
?? readUsageCredentialsFromCredentialsFile();
}

export function getUsageToken(): string | null {
return getUsageCredentials()?.accessToken ?? null;
}

function readStaleUsageCache(currentTokenHash: string | null): UsageData | null {
Expand Down Expand Up @@ -716,12 +757,13 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
}
}

// Resolve the token up front (before lock/rate-limit checks so auth
// failures are not masked as timeout) and fingerprint it so the file cache
// can be invalidated on an account switch: a different token, written by a
// Resolve the credentials up front (before lock/rate-limit checks so auth
// failures are not masked as timeout) and fingerprint them so the file cache
// can be invalidated on an account switch: a different login, written by a
// logout/login, no longer matches the cached fingerprint.
const token = getUsageToken();
const currentTokenHash = token ? fingerprintUsageToken(token) : null;
const credentials = getUsageCredentials();
const token = credentials?.accessToken ?? null;
const currentTokenHash = credentials ? fingerprintUsageCredentials(credentials) : null;

// Check file cache
try {
Expand Down