From e458382d3d1cda52304572e23b2ce2c13b685976 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 11 Apr 2026 14:32:43 +0900 Subject: [PATCH] runtime: canonicalize Claude token env names --- .env.example | 4 +-- setup/environment.test.ts | 13 +++++++-- setup/verify-state.test.ts | 11 ++++++++ setup/verify-state.ts | 2 +- src/agent-runner-environment.test.ts | 26 ++++++++++++++++++ src/agent-runner-environment.ts | 16 +++++++++-- src/claude-usage.ts | 11 +++++--- src/token-rotation.test.ts | 8 ++++++ src/token-rotation.ts | 41 ++++++++++++++++------------ 9 files changed, 102 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index 5aefc8c..2727de8 100644 --- a/.env.example +++ b/.env.example @@ -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 --- diff --git a/setup/environment.test.ts b/setup/environment.test.ts index 183c012..74d4a2c 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -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); }); }); diff --git a/setup/verify-state.test.ts b/setup/verify-state.test.ts index 5a9739a..cd6d3b6 100644 --- a/setup/verify-state.test.ts +++ b/setup/verify-state.test.ts @@ -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( diff --git a/setup/verify-state.ts b/setup/verify-state.ts index 7057a65..7f2fe0b 100644 --- a/setup/verify-state.ts +++ b/setup/verify-state.ts @@ -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'; } diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index 42507e5..74a454a 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -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); diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 4137ed0..f8b66c6 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -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', diff --git a/src/claude-usage.ts b/src/claude-usage.ts index c32819d..3d08ec3 100644 --- a/src/claude-usage.ts +++ b/src/claude-usage.ts @@ -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 { - 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 { 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 [ diff --git a/src/token-rotation.test.ts b/src/token-rotation.test.ts index 8da8b61..139cdfd 100644 --- a/src/token-rotation.test.ts +++ b/src/token-rotation.test.ts @@ -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); + }); }); diff --git a/src/token-rotation.ts b/src/token-rotation.ts index 9cf6d1d..f149ab3 100644 --- a/src/token-rotation.ts +++ b/src/token-rotation.ts @@ -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();