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