mirror of
https://github.com/Yeachan-Heo/oh-my-claudecode.git
synced 2026-10-22 11:12:21 -05:00
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:
4 files changed
+192
-25
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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 = '';
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user