runtime: canonicalize Claude token env names

This commit is contained in:
ejclaw
2026-04-11 14:32:43 +09:00
parent 91caf81069
commit e458382d3d
9 changed files with 102 additions and 30 deletions

View File

@@ -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 ---

View File

@@ -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);
});
});

View File

@@ -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(

View File

@@ -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';
}

View File

@@ -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);

View File

@@ -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() ||
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;
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',

View File

@@ -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 [

View File

@@ -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);
});
});

View File

@@ -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();