feat(hud): respect CLAUDE_CODE_OAUTH_TOKEN when reading usage (#4004)

* feat(hud): respect CLAUDE_CODE_OAUTH_TOKEN when reading usage

The HUD usage bars read OAuth credentials from the Keychain "Claude Code-credentials"
login (and the file fallback) only. A multi-account setup that launches each session with
a per-account token via CLAUDE_CODE_OAUTH_TOKEN — which Claude Code itself honors for API
auth — therefore always shows the default login account's usage, not the account actually
in use.

getCredentials() now checks CLAUDE_CODE_OAUTH_TOKEN first and, when set, uses it directly.
Setup-tokens are long-lived and carry no refresh token, so expiresAt/refreshToken are left
unset — isCredentialExpired() then treats them as non-expiring, and no token refresh or
Keychain write-back is attempted. Keychain/file behavior is unchanged when the env var is
absent.

Adds a test: with the env var set, the token is sent as the Bearer credential and the
Keychain is never consulted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(hud): isolate env OAuth usage cache

* chore: refresh inventory for HUD OAuth fix

* test(team): retry worker-launch-ack fixture cleanup

CI failed on ENOTEMPTY while rmdir'ing a still-writing launch-attempts dir after SIGKILL of a live provider group. Retry the fixture rm on ENOTEMPTY/EBUSY/EPERM so teardown does not flake the suite.

* chore: refresh inventory for worker-launch-ack cleanup

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: gaebal-gajae <clawdbot@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-10 06:16:35 +09:00
1 parent ff77bb2f08
commit 023d56ca53
4 files changed
+192 -25

No files matched your search

+5 -5
View File
@@ -5,15 +5,15 @@
"provenance": {
"base": "05c800f40d1ad53b42a78609d2667ef4f726808b",
"planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398",
"head": "ed9a437f9b7b26847f0361d5a0748e94e3dd574c",
"sourceSha256": "6cfb5243b33182ec6bd5be4ffb8657b3bbdb387259a77dc314ad2bc80da3be14",
"head": "5093425ef98254dfe3413946f849309a2bbabf85",
"sourceSha256": "1bae3ebaf902f20e897c295fc0bc6907b4c77206821cd96f1de710d14dfacc0e",
"generatedAt": null,
"generator": "scripts/generate-inventory-graph.mjs"
},
"base": "05c800f40d1ad53b42a78609d2667ef4f726808b",
"planningHead": "0a91273e61dbbd47eb0af4c02844409251e08398",
"head": "ed9a437f9b7b26847f0361d5a0748e94e3dd574c",
"sourceSha256": "6cfb5243b33182ec6bd5be4ffb8657b3bbdb387259a77dc314ad2bc80da3be14",
"head": "5093425ef98254dfe3413946f849309a2bbabf85",
"sourceSha256": "1bae3ebaf902f20e897c295fc0bc6907b4c77206821cd96f1de710d14dfacc0e",
"counts": {
"public": {
"skills": 39,
@@ -78076,5 +78076,5 @@
}
},
"inventorySha256": "bc33e9b6f8bcdb58666930db3ce4a6786f4e688d35331f3f647be533e6820a84",
"manifestSha256": "3efce48ef984040aa9413e2becbb472c4457548a17d8f0a70112c8f67bcda66a"
"manifestSha256": "edd4da749d22f37bca872c1e9a4956f64dd21b58439bc8c83a1db6f8126f18b0"
}
+84
View File
@@ -409,6 +409,7 @@ describe('getUsage routing', () => {
// Reset env
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
// Get the mocked https module for assertions
httpsModule = await import('https') as unknown as typeof httpsModule;
});
@@ -425,6 +426,89 @@ describe('getUsage routing', () => {
expect(httpsModule.default.request).not.toHaveBeenCalled();
});
it('prefers CLAUDE_CODE_OAUTH_TOKEN over Keychain and uses it as the Bearer token', async () => {
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-oauth-token';
let capturedAuth: string | undefined;
httpsModule.default.request.mockImplementationOnce((options, callback) => {
capturedAuth = (options as { headers?: Record<string, string> })?.headers?.Authorization;
const req = new EventEmitter() as EventEmitter & { end: () => void; destroy: () => void; on: typeof EventEmitter.prototype.on };
req.destroy = vi.fn();
req.end = () => {
const res = new EventEmitter() as EventEmitter & { statusCode?: number };
res.statusCode = 200;
callback(res);
res.emit('data', JSON.stringify({
five_hour: { utilization: 20 },
seven_day: { utilization: 40 },
}));
res.emit('end');
};
return req;
});
const result = await getUsage();
// Usage is fetched successfully using the env token...
expect(result).toEqual({
rateLimits: {
fiveHourPercent: 20,
weeklyPercent: 40,
fiveHourResetsAt: null,
weeklyResetsAt: null,
},
});
// ...sent as the Bearer credential...
expect(capturedAuth).toBe('Bearer env-oauth-token');
// ...and the Keychain is never consulted (the env override short-circuits it).
expect(vi.mocked(childProcess.execFileSync)).not.toHaveBeenCalled();
});
it('does not reuse Anthropic usage cache data across env OAuth tokens', async () => {
const cachePath = '/tmp/test-claude/plugins/oh-my-claudecode/.usage-cache-anthropic.json';
let cacheContent = '{}';
const capturedAuth: string[] = [];
vi.mocked(fs.existsSync).mockImplementation(path => String(path) === cachePath);
vi.mocked(fs.readFileSync).mockImplementation(path => {
if (String(path) === cachePath) return cacheContent;
return '{}';
});
vi.mocked(fs.writeFileSync).mockImplementation((path, content) => {
if (String(path) === cachePath) cacheContent = String(content);
});
const mockUsageResponse = (fiveHourPercent: number) => {
httpsModule.default.request.mockImplementationOnce((options, callback) => {
capturedAuth.push((options as { headers?: Record<string, string> }).headers?.Authorization ?? '');
const req = new EventEmitter() as EventEmitter & { end: () => void; destroy: () => void; on: typeof EventEmitter.prototype.on };
req.destroy = vi.fn();
req.end = () => {
const res = new EventEmitter() as EventEmitter & { statusCode?: number };
res.statusCode = 200;
callback(res);
res.emit('data', JSON.stringify({ five_hour: { utilization: fiveHourPercent } }));
res.emit('end');
};
return req;
});
};
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-token-one';
mockUsageResponse(10);
const first = await getUsage();
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-token-two';
mockUsageResponse(80);
const second = await getUsage();
expect(first.rateLimits?.fiveHourPercent).toBe(10);
expect(second.rateLimits?.fiveHourPercent).toBe(80);
expect(capturedAuth).toEqual(['Bearer env-token-one', 'Bearer env-token-two']);
expect(cacheContent).not.toContain('env-token-one');
expect(cacheContent).not.toContain('env-token-two');
});
it('uses the raw ~-prefixed CLAUDE_CONFIG_DIR value for Keychain service lookup', async () => {
process.env.CLAUDE_CONFIG_DIR = '~/.claude-personal';
+88 -19
View File
@@ -80,6 +80,8 @@ interface UsageCache {
lastSuccessAt?: number;
/** User-Agent identity that received a rate-limit response (Anthropic only) */
rateLimitIdentity?: string;
/** Non-reversible OAuth credential identity (env-token sessions only) */
credentialIdentity?: string;
/** Active Anthropic rate-limit backoffs keyed by User-Agent identity */
rateLimitBackoffs?: Record<string, RateLimitBackoff>;
}
@@ -89,7 +91,7 @@ interface OAuthCredentials {
expiresAt?: number;
refreshToken?: string;
/** Where the credentials were read from, needed for write-back */
source?: 'keychain' | 'file';
source?: 'keychain' | 'file' | 'env';
/** Keychain account name used when reading (null = service-only lookup) */
keychainAccount?: string | null;
/** Subscription type from OAuth credentials (e.g. 'enterprise') */
@@ -412,6 +414,8 @@ interface WriteCacheOptions {
lastSuccessAt?: number;
/** User-Agent identity associated with an Anthropic rate-limit response */
rateLimitIdentity?: string;
/** Non-reversible OAuth credential identity (env-token sessions only) */
credentialIdentity?: string;
/** Active Anthropic rate-limit backoffs keyed by User-Agent identity */
rateLimitBackoffs?: Record<string, RateLimitBackoff>;
}
@@ -439,6 +443,7 @@ function writeCache(opts: WriteCacheOptions): void {
rateLimitedUntil: opts.rateLimitedUntil,
lastSuccessAt: opts.lastSuccessAt,
rateLimitIdentity: opts.rateLimitIdentity,
credentialIdentity: opts.credentialIdentity,
rateLimitBackoffs: opts.rateLimitBackoffs,
};
@@ -526,6 +531,18 @@ function clearRateLimitBackoff(
return Object.keys(backoffs).length > 0 ? backoffs : undefined;
}
/**
* Derive a cache-only identity for an environment-provided OAuth token.
* The token itself must never be persisted alongside usage data.
*/
function getCredentialCacheIdentity(accessToken: string): string {
return createHash('sha256').update(accessToken).digest('hex');
}
function isCacheForCredential(cache: UsageCache | null, credentialIdentity?: string): boolean {
return cache?.credentialIdentity === credentialIdentity;
}
function isCacheValid(cache: UsageCache, pollIntervalMs: number, rateLimitIdentity?: string): boolean {
if (cache.source === 'anthropic') {
const backoff = getRateLimitBackoff(cache, rateLimitIdentity);
@@ -758,6 +775,17 @@ function readFileCredentials(): OAuthCredentials | null {
* Get OAuth credentials (Keychain first, then file fallback)
*/
function getCredentials(): OAuthCredentials | null {
// Respect an explicit CLAUDE_CODE_OAUTH_TOKEN override, matching how Claude Code itself
// authenticates. Multi-account setups launch each session with a per-account token via this
// env var; without honoring it here the HUD always reads the default Keychain login and shows
// the wrong account's usage. Setup-tokens are long-lived and carry no refresh token, so leave
// expiresAt/refreshToken unset — isCredentialExpired() then treats them as non-expiring and no
// token refresh or Keychain write-back is attempted.
const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN?.trim();
if (envToken) {
return { accessToken: envToken, source: 'env' };
}
// Try Keychain first (macOS)
const keychainCreds = readKeychainCredentials();
if (keychainCreds) return keychainCreds;
@@ -1076,6 +1104,7 @@ function writeKeychainCredentials(creds: OAuthCredentials): void {
* Persist refreshed credentials back to the credential store.
* When the credentials originated from Keychain, writes back to Keychain.
* When they originated from file, updates ~/.claude/.credentials.json.
* Environment-provided credentials are process-owned and are never persisted.
* Updates only the OAuth token fields, preserving other data.
*/
function writeBackCredentials(creds: OAuthCredentials): void {
@@ -1083,6 +1112,7 @@ function writeBackCredentials(creds: OAuthCredentials): void {
writeKeychainCredentials(creds);
return;
}
if (creds.source === 'env') return;
try {
const credPath = join(getClaudeConfigDir(), '.credentials.json');
@@ -1862,8 +1892,17 @@ async function fetchAndCacheUsage<T>(opts: {
cache: UsageCache | null;
pollIntervalMs: number;
rateLimitIdentity?: string;
credentialIdentity?: string;
}): Promise<UsageResult> {
const { source, fetchFn, parseFn, cache, pollIntervalMs, rateLimitIdentity } = opts;
const {
source,
fetchFn,
parseFn,
cache,
pollIntervalMs,
rateLimitIdentity,
credentialIdentity,
} = opts;
const result = await fetchFn();
if (result.rateLimited) {
@@ -1893,6 +1932,7 @@ async function fetchAndCacheUsage<T>(opts: {
errorReason: 'rate_limited',
lastSuccessAt: rateLimitedCache.lastSuccessAt,
rateLimitIdentity: rateLimitedCache.rateLimitIdentity,
credentialIdentity,
rateLimitBackoffs: rateLimitedCache.rateLimitBackoffs,
});
if (rateLimitedCache.data) {
@@ -1912,6 +1952,7 @@ async function fetchAndCacheUsage<T>(opts: {
source,
errorReason: 'network',
lastSuccessAt: cache?.lastSuccessAt,
credentialIdentity,
rateLimitBackoffs: source === 'anthropic' ? getRateLimitBackoffs(cache) : undefined,
});
if (fallbackData) {
@@ -1926,6 +1967,7 @@ async function fetchAndCacheUsage<T>(opts: {
error: !usage,
source,
lastSuccessAt: Date.now(),
credentialIdentity,
rateLimitBackoffs: source === 'anthropic'
? clearRateLimitBackoff(cache, rateLimitIdentity)
: undefined,
@@ -1969,6 +2011,10 @@ export async function getUsage(opts?: { clientVersion?: string }): Promise<Usage
process.env.KIMI_API_KEY || process.env.ANTHROPIC_API_KEY || authToken;
const currentSource: UsageSource =
isMinimax ? 'minimax' : isKimi ? 'kimi' : isZai && authToken ? 'zai' : 'anthropic';
const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN?.trim();
const credentialIdentity = currentSource === 'anthropic' && envToken
? getCredentialCacheIdentity(envToken)
: undefined;
const pollIntervalMs = getUsagePollIntervalMs();
const rateLimitIdentity = currentSource === 'anthropic'
? buildUserAgent(opts?.clientVersion) ?? 'anonymous'
@@ -1978,23 +2024,27 @@ export async function getUsage(opts?: { clientVersion?: string }): Promise<Usage
migrateLegacyCache(currentSource);
const initialCache = readCache(currentSource);
const initialMatchingCache = isCacheForCredential(initialCache, credentialIdentity)
? initialCache
: null;
if (
initialCache &&
isCacheValid(initialCache, pollIntervalMs, rateLimitIdentity) &&
initialCache.source === currentSource
initialMatchingCache &&
isCacheValid(initialMatchingCache, pollIntervalMs, rateLimitIdentity) &&
initialMatchingCache.source === currentSource
) {
return getCachedUsageResult(initialCache, rateLimitIdentity);
return getCachedUsageResult(initialMatchingCache, rateLimitIdentity);
}
try {
return await withFileLock(lockPathFor(getCachePath(currentSource)), async () => {
const cache = readCache(currentSource);
const matchingCache = isCacheForCredential(cache, credentialIdentity) ? cache : null;
if (
cache &&
isCacheValid(cache, pollIntervalMs, rateLimitIdentity) &&
cache.source === currentSource
matchingCache &&
isCacheValid(matchingCache, pollIntervalMs, rateLimitIdentity) &&
matchingCache.source === currentSource
) {
return getCachedUsageResult(cache, rateLimitIdentity);
return getCachedUsageResult(matchingCache, rateLimitIdentity);
}
// MiniMax path (must precede z.ai and OAuth checks)
@@ -2007,7 +2057,7 @@ export async function getUsage(opts?: { clientVersion?: string }): Promise<Usage
source: 'minimax',
fetchFn: () => fetchUsageFromMinimax(minimaxApiKey),
parseFn: parseMinimaxResponse,
cache,
cache: matchingCache,
pollIntervalMs,
});
}
@@ -2022,7 +2072,7 @@ export async function getUsage(opts?: { clientVersion?: string }): Promise<Usage
source: 'kimi',
fetchFn: () => fetchUsageFromKimi(kimiApiKey),
parseFn: parseKimiResponse,
cache,
cache: matchingCache,
pollIntervalMs,
});
}
@@ -2033,7 +2083,7 @@ export async function getUsage(opts?: { clientVersion?: string }): Promise<Usage
source: 'zai',
fetchFn: () => fetchUsageFromZai(),
parseFn: parseZaiResponse,
cache,
cache: matchingCache,
pollIntervalMs,
});
}
@@ -2048,11 +2098,23 @@ export async function getUsage(opts?: { clientVersion?: string }): Promise<Usage
creds = { ...creds, ...refreshed };
writeBackCredentials(creds);
} else {
writeCache({ data: null, error: true, source: 'anthropic', errorReason: 'auth' });
writeCache({
data: null,
error: true,
source: 'anthropic',
errorReason: 'auth',
credentialIdentity,
});
return { rateLimits: null, error: 'auth' };
}
} else {
writeCache({ data: null, error: true, source: 'anthropic', errorReason: 'auth' });
writeCache({
data: null,
error: true,
source: 'anthropic',
errorReason: 'auth',
credentialIdentity,
});
return { rateLimits: null, error: 'auth' };
}
}
@@ -2067,21 +2129,28 @@ export async function getUsage(opts?: { clientVersion?: string }): Promise<Usage
subscriptionType,
rateLimitTier,
}),
cache,
cache: matchingCache,
pollIntervalMs,
rateLimitIdentity,
credentialIdentity,
});
}
writeCache({ data: null, error: true, source: 'anthropic', errorReason: 'no_credentials' });
writeCache({
data: null,
error: true,
source: 'anthropic',
errorReason: 'no_credentials',
credentialIdentity,
});
return { rateLimits: null, error: 'no_credentials' };
}, USAGE_CACHE_LOCK_OPTS);
} catch (err) {
// Lock acquisition failed — return stale cache without touching the cache file
// to avoid racing with the lock holder writing fresh data
if (err instanceof Error && err.message.startsWith('Failed to acquire file lock')) {
if (initialCache?.data) {
return { rateLimits: initialCache.data, stale: true };
if (initialMatchingCache?.data) {
return { rateLimits: initialMatchingCache.data, stale: true };
}
return { rateLimits: null, error: 'network' };
}
+15 -1
View File
@@ -77,11 +77,25 @@ function restoreFixtureEnv(): void {
originalUserProfile = undefined;
originalStateDir = undefined;
}
async function removeFixtureDir(dir: string): Promise<void> {
for (let attempt = 0; attempt < 8; attempt++) {
try {
await rm(dir, { recursive: true, force: true });
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOTEMPTY' && code !== 'EBUSY' && code !== 'EPERM') throw error;
await new Promise(resolve => setTimeout(resolve, 25 * (attempt + 1)));
}
}
await rm(dir, { recursive: true, force: true });
}
afterEach(async () => {
for (const child of [...disposableProviders]) await stopDisposableProvider(child).catch(() => undefined);
restoreFixtureEnv();
if (cwd) await rm(cwd, { recursive: true, force: true });
if (cwd) await removeFixtureDir(cwd);
cwd = '';
});