Files
Codewalkers/apps/server/agent/credential-handler.ts
Lukas May a2afc2e1fd fix: Convert sync file I/O to async in credential handler and account setup
Removes blocking readFileSync, writeFileSync, and mkdirSync calls from the
agent spawn hot path, replacing them with async fs/promises equivalents to
avoid stalling the Node.js event loop during credential operations.
2026-03-04 12:25:05 +01:00

210 lines
8.1 KiB
TypeScript

/**
* CredentialHandler — Account selection, credential management, and exhaustion handling.
*
* Extracted from MultiProviderAgentManager. Handles account lifecycle:
* selecting the next available account, writing credentials to disk,
* ensuring they're fresh, and marking accounts as exhausted on failure.
*/
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { AccountRepository } from '../db/repositories/account-repository.js';
import type { AccountCredentialManager } from './credentials/types.js';
import type { Account } from '../db/schema.js';
import { ensureAccountCredentials } from './accounts/usage.js';
import { getAccountConfigDir } from './accounts/paths.js';
import { setupAccountConfigDir } from './accounts/setup.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('credential-handler');
/** Default exhaustion duration: 5 hours */
const DEFAULT_EXHAUSTION_HOURS = 5;
export class CredentialHandler {
constructor(
private workspaceRoot: string,
private accountRepository?: AccountRepository,
private credentialManager?: AccountCredentialManager,
) {}
/**
* Select the next available account for a provider.
* Clears expired exhaustion, returns least-recently-used non-exhausted account.
* Returns null if no accounts are available.
*/
async selectAccount(providerName: string): Promise<{ account: Account; accountId: string; configDir: string } | null> {
if (!this.accountRepository) return null;
await this.accountRepository.clearExpiredExhaustion();
const account = await this.accountRepository.findNextAvailable(providerName);
if (!account) return null;
const configDir = getAccountConfigDir(this.workspaceRoot, account.id);
await this.accountRepository.updateLastUsed(account.id);
return { account, accountId: account.id, configDir };
}
/**
* Write account credentials from DB to the convention-based config directory.
* Must be called before ensureCredentials so the files exist on disk.
*/
async writeCredentialsToDisk(account: Account, configDir: string): Promise<void> {
if (account.configJson && account.credentials) {
await setupAccountConfigDir(configDir, {
configJson: JSON.parse(account.configJson),
credentials: account.credentials,
});
log.debug({ accountId: account.id, configDir }, 'wrote account credentials from DB to disk');
} else {
log.warn({ accountId: account.id }, 'account has no stored credentials in DB');
}
}
/**
* Read refreshed credentials from disk and persist back to DB.
* Called after credential refresh to keep DB in sync.
*/
async persistRefreshedCredentials(accountId: string, configDir: string): Promise<void> {
if (!this.accountRepository) return;
try {
const credPath = join(configDir, '.credentials.json');
const credentials = await readFile(credPath, 'utf-8');
await this.accountRepository.updateCredentials(accountId, credentials);
log.debug({ accountId }, 'persisted refreshed credentials back to DB');
} catch (err) {
log.warn({ accountId, err: err instanceof Error ? err.message : String(err) }, 'failed to persist refreshed credentials to DB');
}
}
/**
* Ensure credentials are valid before spawn/resume.
* Uses credentialManager if available, otherwise falls back to legacy function.
* Returns { valid, refreshed } so callers can persist refresh back to DB.
*/
async ensureCredentials(configDir: string, accountId?: string): Promise<{ valid: boolean; refreshed: boolean }> {
if (this.credentialManager) {
const result = await this.credentialManager.ensureValid(configDir, accountId);
return { valid: result.valid, refreshed: result.refreshed };
}
const valid = await ensureAccountCredentials(configDir);
return { valid, refreshed: false };
}
/**
* Read the access token from a config directory's .credentials.json.
* Returns null if credentials file is missing or malformed.
* Used for CLAUDE_CODE_OAUTH_TOKEN env var injection.
*/
async readAccessToken(configDir: string): Promise<string | null> {
try {
const credPath = join(configDir, '.credentials.json');
if (!existsSync(credPath)) return null;
const raw = await readFile(credPath, 'utf-8');
const parsed = JSON.parse(raw);
return parsed.claudeAiOauth?.accessToken ?? null;
} catch {
return null;
}
}
/**
* Prepare process environment with account credentials.
* Writes credentials to disk, ensures freshness, injects OAuth token.
* Used by spawn, resumeForCommit, and resumeInternal.
*/
async prepareProcessEnv(
providerEnv: Record<string, string>,
provider: { configDirEnv?: string },
accountId: string | null,
): Promise<{ processEnv: Record<string, string>; accountConfigDir: string | null }> {
const processEnv: Record<string, string> = { ...providerEnv };
let accountConfigDir: string | null = null;
if (accountId && provider.configDirEnv && this.accountRepository) {
accountConfigDir = getAccountConfigDir(this.workspaceRoot, accountId);
const account = await this.accountRepository.findById(accountId);
if (account) {
await this.writeCredentialsToDisk(account, accountConfigDir);
}
processEnv[provider.configDirEnv] = accountConfigDir;
const { valid, refreshed } = await this.ensureCredentials(accountConfigDir, accountId);
if (!valid) {
log.warn({ accountId }, 'failed to refresh credentials');
}
if (refreshed) {
await this.persistRefreshedCredentials(accountId, accountConfigDir);
}
const accessToken = await this.readAccessToken(accountConfigDir);
if (accessToken) {
processEnv['CLAUDE_CODE_OAUTH_TOKEN'] = accessToken;
log.debug({ accountId }, 'CLAUDE_CODE_OAUTH_TOKEN injected');
}
}
return { processEnv, accountConfigDir };
}
/**
* Check if an error message indicates usage limit exhaustion.
*/
isUsageLimitError(errorMessage: string): boolean {
const patterns = [
'usage limit',
'rate limit',
'quota exceeded',
'too many requests',
'capacity',
'exhausted',
];
const lower = errorMessage.toLowerCase();
return patterns.some((p) => lower.includes(p));
}
/**
* Handle account exhaustion: mark current account exhausted and find next available.
* Returns the new account info if failover succeeded, null otherwise.
* Does NOT re-spawn — the caller (manager) handles that.
*/
async handleExhaustion(
accountId: string,
providerName: string,
): Promise<{ account: Account; accountId: string; configDir: string } | null> {
if (!this.accountRepository) return null;
log.warn({ accountId, provider: providerName }, 'account exhausted, attempting failover');
// Mark current account as exhausted
const exhaustedUntil = new Date(Date.now() + DEFAULT_EXHAUSTION_HOURS * 60 * 60 * 1000);
await this.accountRepository.markExhausted(accountId, exhaustedUntil);
// Find next available account
const nextAccount = await this.accountRepository.findNextAvailable(providerName);
if (!nextAccount) {
log.warn({ accountId }, 'account failover failed, no accounts available');
return null;
}
log.info({ previousAccountId: accountId, newAccountId: nextAccount.id }, 'account failover successful');
// Write credentials and ensure they're fresh
const nextConfigDir = getAccountConfigDir(this.workspaceRoot, nextAccount.id);
await this.writeCredentialsToDisk(nextAccount, nextConfigDir);
const { valid, refreshed } = await this.ensureCredentials(nextConfigDir, nextAccount.id);
if (!valid) {
log.warn({ newAccountId: nextAccount.id }, 'failed to refresh failover account credentials');
return null;
}
if (refreshed) {
await this.persistRefreshedCredentials(nextAccount.id, nextConfigDir);
}
await this.accountRepository.updateLastUsed(nextAccount.id);
return { account: nextAccount, accountId: nextAccount.id, configDir: nextConfigDir };
}
}