runtime: canonicalize Claude token env names
This commit is contained in:
@@ -15,8 +15,8 @@ OWNER_AGENT_TYPE=codex # codex | claude-code
|
||||
REVIEWER_AGENT_TYPE=claude-code # claude-code | codex
|
||||
|
||||
# --- API keys ---
|
||||
CLAUDE_CODE_OAUTH_TOKEN= # Claude Code OAuth token (primary account)
|
||||
CLAUDE_CODE_OAUTH_TOKENS= # Comma-separated tokens for multi-account rotation
|
||||
CLAUDE_CODE_OAUTH_TOKENS= # Canonical comma-separated Claude Code OAuth tokens (use one value for single-account too)
|
||||
CLAUDE_CODE_OAUTH_TOKEN= # Legacy single-token fallback
|
||||
GROQ_API_KEY= # Voice transcription (Groq Whisper, free: console.groq.com)
|
||||
|
||||
# --- Bot identity ---
|
||||
|
||||
@@ -125,21 +125,28 @@ describe('credentials detection', () => {
|
||||
const content =
|
||||
'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKENS?|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => {
|
||||
const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKENS?|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('detects CLAUDE_CODE_OAUTH_TOKENS in env content', () => {
|
||||
const content = 'CLAUDE_CODE_OAUTH_TOKENS=token123,token456';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKENS?|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no credentials', () => {
|
||||
const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKENS?|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
expect(hasCredentials).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,17 @@ describe('verify state helpers', () => {
|
||||
expect(detectCredentials(tempRoot)).toBe('configured');
|
||||
});
|
||||
|
||||
it('detects configured multi-account credentials from .env', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-verify-'));
|
||||
tempRoots.push(tempRoot);
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, '.env'),
|
||||
'CLAUDE_CODE_OAUTH_TOKENS=test-token-1,test-token-2\n',
|
||||
);
|
||||
|
||||
expect(detectCredentials(tempRoot)).toBe('configured');
|
||||
});
|
||||
|
||||
it('detects canonical role-based channel auth names from process env', () => {
|
||||
expect(
|
||||
detectChannelAuth(
|
||||
|
||||
@@ -38,7 +38,7 @@ export function detectCredentials(projectRoot: string): CredentialsStatus {
|
||||
}
|
||||
|
||||
const envContent = fs.readFileSync(envFile, 'utf-8');
|
||||
return /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)
|
||||
return /^(CLAUDE_CODE_OAUTH_TOKENS?|ANTHROPIC_API_KEY)=/m.test(envContent)
|
||||
? 'configured'
|
||||
: 'missing';
|
||||
}
|
||||
|
||||
@@ -31,6 +31,17 @@ vi.mock('./codex-token-rotation.js', () => ({
|
||||
|
||||
vi.mock('./token-rotation.js', () => ({
|
||||
getCurrentToken: vi.fn(() => undefined),
|
||||
getConfiguredClaudeTokens: vi.fn(
|
||||
(options?: { multi?: string | undefined; single?: string | undefined }) => {
|
||||
if (options?.multi) {
|
||||
return options.multi
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return options?.single ? [options.single] : [];
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./platform-prompts.js', () => ({
|
||||
@@ -309,6 +320,21 @@ describe('prepareGroupEnvironment codex auth handling', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps the canonical multi-token env to a single runner OAuth token', () => {
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||
mockReadEnvFile.mockReturnValue({
|
||||
CLAUDE_CODE_OAUTH_TOKENS: 'token-a, token-b',
|
||||
});
|
||||
|
||||
const prepared = prepareGroupEnvironment(
|
||||
{ ...group, agentType: 'claude-code' },
|
||||
false,
|
||||
'dc:test',
|
||||
);
|
||||
|
||||
expect(prepared.env.CLAUDE_CODE_OAUTH_TOKEN).toBe('token-a');
|
||||
});
|
||||
|
||||
it('returns to the normal owner prompt stack after failover is cleared', () => {
|
||||
vi.mocked(config.isReviewService).mockReturnValue(true);
|
||||
vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(true);
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
import { logger } from './logger.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { getActiveCodexAuthPath } from './codex-token-rotation.js';
|
||||
import { getCurrentToken } from './token-rotation.js';
|
||||
import {
|
||||
getConfiguredClaudeTokens,
|
||||
getCurrentToken,
|
||||
} from './token-rotation.js';
|
||||
import {
|
||||
resolveGroupFolderPath,
|
||||
resolveGroupIpcPath,
|
||||
@@ -248,8 +251,14 @@ function prepareClaudeEnvironment(args: {
|
||||
// Token rotation takes priority over static .env value
|
||||
const oauthToken =
|
||||
getCurrentToken() ||
|
||||
args.envVars.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
getConfiguredClaudeTokens({
|
||||
multi:
|
||||
args.envVars.CLAUDE_CODE_OAUTH_TOKENS ||
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKENS,
|
||||
single:
|
||||
args.envVars.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
})[0];
|
||||
if (oauthToken) {
|
||||
args.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
|
||||
}
|
||||
@@ -513,6 +522,7 @@ export function prepareGroupEnvironment(
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'CLAUDE_CODE_OAUTH_TOKENS',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'CLAUDE_MODEL',
|
||||
'CLAUDE_THINKING',
|
||||
|
||||
@@ -11,7 +11,11 @@ import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
import { getCurrentToken, getAllTokens } from './token-rotation.js';
|
||||
import {
|
||||
getAllTokens,
|
||||
getConfiguredClaudeTokens,
|
||||
getCurrentToken,
|
||||
} from './token-rotation.js';
|
||||
import { readJsonFile, writeJsonFile } from './utils.js';
|
||||
|
||||
const USAGE_CACHE_FILE = path.join(DATA_DIR, 'claude-usage-cache.json');
|
||||
@@ -274,7 +278,7 @@ async function fetchUsageForToken(
|
||||
* Uses the current active token from rotation.
|
||||
*/
|
||||
export async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
|
||||
const token = getCurrentToken() || process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
const token = getCurrentToken() || getConfiguredClaudeTokens()[0];
|
||||
if (!token) {
|
||||
logger.debug('No Claude OAuth token available for usage check');
|
||||
return null;
|
||||
@@ -431,8 +435,7 @@ export async function fetchAllClaudeUsage(): Promise<ClaudeAccountUsage[]> {
|
||||
const allTokens = getAllTokens();
|
||||
logger.debug({ tokenCount: allTokens.length }, 'fetchAllClaudeUsage called');
|
||||
if (allTokens.length === 0) {
|
||||
// Single token fallback
|
||||
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
const token = getConfiguredClaudeTokens()[0];
|
||||
if (!token) return [];
|
||||
const usage = await fetchUsageForToken(token, 0);
|
||||
return [
|
||||
|
||||
@@ -105,4 +105,12 @@ describe('token-rotation runtime reselection', () => {
|
||||
'Failed to persist Claude token rotation state',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats CLAUDE_CODE_OAUTH_TOKENS as the canonical fallback even before init', async () => {
|
||||
const mod = await import('./token-rotation.js');
|
||||
|
||||
expect(mod.getConfiguredClaudeTokens()).toEqual(['token-1', 'token-2']);
|
||||
expect(mod.getCurrentToken()).toBe('token-1');
|
||||
expect(mod.hasAvailableClaudeToken()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* OAuth Token Rotation
|
||||
*
|
||||
* Rotates between multiple CLAUDE_CODE_OAUTH_TOKEN values when
|
||||
* rate-limited. Tokens are stored as comma-separated values in
|
||||
* CLAUDE_CODE_OAUTH_TOKENS env var. Falls through to the single
|
||||
* CLAUDE_CODE_OAUTH_TOKEN if multi-token is not configured.
|
||||
* Rotates between multiple Claude Code OAuth tokens when
|
||||
* rate-limited. Tokens are stored as comma-separated values in the
|
||||
* canonical CLAUDE_CODE_OAUTH_TOKENS env var and fall through to the
|
||||
* legacy single-token CLAUDE_CODE_OAUTH_TOKEN when needed.
|
||||
*
|
||||
* On rate-limit: rotate to next token
|
||||
* All exhausted: surface error to caller
|
||||
@@ -33,21 +33,28 @@ const tokens: TokenState[] = [];
|
||||
let currentIndex = 0;
|
||||
let initialized = false;
|
||||
|
||||
export function getConfiguredClaudeTokens(options?: {
|
||||
multi?: string | undefined;
|
||||
single?: string | undefined;
|
||||
}): string[] {
|
||||
const multi = options?.multi ?? getEnv('CLAUDE_CODE_OAUTH_TOKENS');
|
||||
const single = options?.single ?? getEnv('CLAUDE_CODE_OAUTH_TOKEN');
|
||||
|
||||
if (multi) {
|
||||
return multi
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
export function initTokenRotation(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const multi = getEnv('CLAUDE_CODE_OAUTH_TOKENS');
|
||||
const single = getEnv('CLAUDE_CODE_OAUTH_TOKEN');
|
||||
|
||||
const raw = multi
|
||||
? multi
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: single
|
||||
? [single]
|
||||
: [];
|
||||
const raw = getConfiguredClaudeTokens();
|
||||
|
||||
for (const token of raw) {
|
||||
tokens.push({ token, rateLimitedUntil: null });
|
||||
@@ -155,7 +162,7 @@ function refreshRuntimeTokenSelection(): void {
|
||||
|
||||
/** Get the current active token. */
|
||||
export function getCurrentToken(): string | undefined {
|
||||
if (tokens.length === 0) return process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
if (tokens.length === 0) return getConfiguredClaudeTokens()[0];
|
||||
refreshRuntimeTokenSelection();
|
||||
return tokens[currentIndex % tokens.length]?.token;
|
||||
}
|
||||
@@ -301,7 +308,7 @@ export function getTokenRotationInfo(): {
|
||||
|
||||
export function hasAvailableClaudeToken(): boolean {
|
||||
if (tokens.length === 0) {
|
||||
return Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
||||
return getConfiguredClaudeTokens().length > 0;
|
||||
}
|
||||
refreshRuntimeTokenSelection();
|
||||
const now = Date.now();
|
||||
|
||||
Reference in New Issue
Block a user