Claude quota only worked when the user had signed into Anthropic through OpenCode. Credentials are now discovered from Claude Code itself first: the macOS Keychain entry, then the Linux/WSL credentials file (honouring CLAUDE_CONFIG_DIR), then OpenCode auth.json, then CLAUDE_CODE_OAUTH_TOKEN. All sources stay read-only and the OAuth token is never refreshed: Anthropic allows one live refresh token per client_id, so refreshing here would sign the user out of Claude Code. Credentials are re-read per request instead, and an expired token reports that Claude Code needs a sign-in rather than a bare 401. Usage is now read from the limits[] array, so model-scoped weekly limits work again after Anthropic stopped populating seven_day_sonnet/seven_day_opus, and new limit kinds no longer need a code change. Adds extra-usage spend and the plan name, and holds the last good values through Anthropic's 429s with a cooldown and an account-keyed cache.
118 lines
4.1 KiB
JavaScript
118 lines
4.1 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const execFileSync = vi.fn();
|
|
const files = new Map();
|
|
const openCodeAuth = vi.fn(() => ({}));
|
|
|
|
vi.mock('child_process', () => ({ execFileSync: (...args) => execFileSync(...args) }));
|
|
|
|
vi.mock('fs', () => {
|
|
const fs = {
|
|
existsSync: (filePath) => files.has(filePath),
|
|
readFileSync: (filePath) => {
|
|
if (!files.has(filePath)) throw new Error('ENOENT');
|
|
return files.get(filePath);
|
|
},
|
|
};
|
|
return { ...fs, default: fs };
|
|
});
|
|
|
|
vi.mock('../../../opencode/auth.js', () => ({ readAuthFile: () => openCodeAuth() }));
|
|
|
|
import { loadClaudeCredential } from './auth.js';
|
|
|
|
const claudeCodeBlob = (accessToken) => JSON.stringify({
|
|
mcpOAuth: { 'linear|abc': { accessToken: 'unrelated-mcp-token' } },
|
|
claudeAiOauth: {
|
|
accessToken,
|
|
refreshToken: `${accessToken}-refresh`,
|
|
expiresAt: 1786735755912,
|
|
subscriptionType: 'max',
|
|
},
|
|
});
|
|
|
|
const withPlatform = (platform, run) => {
|
|
const original = Object.getOwnPropertyDescriptor(process, 'platform');
|
|
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
|
|
try {
|
|
return run();
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', original);
|
|
}
|
|
};
|
|
|
|
beforeEach(() => {
|
|
files.clear();
|
|
execFileSync.mockReset();
|
|
execFileSync.mockImplementation(() => { throw new Error('no keychain entry'); });
|
|
openCodeAuth.mockReturnValue({});
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.CLAUDE_CONFIG_DIR;
|
|
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
});
|
|
|
|
describe('Claude credential discovery', () => {
|
|
it('prefers the macOS Keychain over a stale credentials file', () => {
|
|
execFileSync.mockReturnValue(claudeCodeBlob('keychain-token'));
|
|
files.set(`${process.env.HOME}/.claude/.credentials.json`, claudeCodeBlob('file-token'));
|
|
|
|
const credential = withPlatform('darwin', loadClaudeCredential);
|
|
|
|
expect(credential.accessToken).toBe('keychain-token');
|
|
expect(credential.refreshToken).toBe('keychain-token-refresh');
|
|
expect(credential.planLabel).toBe('max');
|
|
expect(credential.source).toBe('keychain');
|
|
});
|
|
|
|
it('reads the credentials file on Linux, where there is no Keychain', () => {
|
|
files.set(`${process.env.HOME}/.claude/.credentials.json`, claudeCodeBlob('file-token'));
|
|
|
|
const credential = withPlatform('linux', loadClaudeCredential);
|
|
|
|
expect(execFileSync).not.toHaveBeenCalled();
|
|
expect(credential.accessToken).toBe('file-token');
|
|
expect(credential.source).toBe('credentials-file');
|
|
});
|
|
|
|
it('honours CLAUDE_CONFIG_DIR when locating the credentials file', () => {
|
|
process.env.CLAUDE_CONFIG_DIR = '/tmp/claude-home';
|
|
files.set('/tmp/claude-home/.credentials.json', claudeCodeBlob('custom-dir-token'));
|
|
|
|
expect(withPlatform('linux', loadClaudeCredential).accessToken).toBe('custom-dir-token');
|
|
});
|
|
|
|
it('falls back to the OpenCode auth entry when Claude Code is not signed in', () => {
|
|
openCodeAuth.mockReturnValue({ anthropic: { access: 'opencode-token', refresh: 'opencode-refresh', expires: 1786735755912 } });
|
|
|
|
const credential = withPlatform('linux', loadClaudeCredential);
|
|
|
|
expect(credential.accessToken).toBe('opencode-token');
|
|
expect(credential.source).toBe('opencode-auth');
|
|
expect(credential.planLabel).toBeNull();
|
|
});
|
|
|
|
it('falls back to CLAUDE_CODE_OAUTH_TOKEN last, without a refresh token', () => {
|
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-token';
|
|
|
|
const credential = withPlatform('linux', loadClaudeCredential);
|
|
|
|
expect(credential.accessToken).toBe('env-token');
|
|
expect(credential.refreshToken).toBeNull();
|
|
expect(credential.source).toBe('env');
|
|
});
|
|
|
|
it('ignores a Keychain blob that only holds unrelated MCP tokens', () => {
|
|
execFileSync.mockReturnValue(JSON.stringify({ mcpOAuth: { 'linear|abc': { accessToken: 'unrelated' } } }));
|
|
|
|
expect(withPlatform('darwin', loadClaudeCredential)).toBeNull();
|
|
});
|
|
|
|
it('returns null when every source is empty', () => {
|
|
expect(withPlatform('darwin', loadClaudeCredential)).toBeNull();
|
|
});
|
|
});
|