feat(cliproxy): add background token refresh worker

Proactively refreshes OAuth tokens before expiry to prevent
"context canceled" errors during long requests.

- Add TokenRefreshWorker with configurable interval/retries
- Add token-expiry-checker for multi-provider token inspection
- Add provider-refreshers abstraction (Gemini implemented)
- Integrate with service-manager lifecycle (start/stop)
- Add config schema in unified-config-types
- Include edge case protections:
  - expiry_date/refresh_token validation
  - File size limits (1MB)
  - Process exit handlers
  - Timeout wrapper
  - Config sanitization
  - Unrecoverable error detection
This commit is contained in:
kaitranntt
2026-01-05 20:47:54 -05:00
parent f2d9073b0d
commit f98bb24a98
7 changed files with 612 additions and 3 deletions
+5 -3
View File
@@ -106,11 +106,12 @@ export function isGeminiTokenExpiringSoon(): boolean {
/**
* Refresh Gemini access token using refresh_token
* @returns true if refresh succeeded, false otherwise
* @returns Result with success status, optional error, and expiry time
*/
export async function refreshGeminiToken(): Promise<{
success: boolean;
error?: string;
expiresAt?: number;
}> {
const creds = readGeminiCreds();
if (!creds || !creds.refresh_token) {
@@ -151,17 +152,18 @@ export async function refreshGeminiToken(): Promise<{
}
// Update credentials file with new token
const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000;
const updatedCreds: GeminiOAuthCreds = {
...creds,
access_token: data.access_token,
expiry_date: Date.now() + (data.expires_in ?? 3600) * 1000,
expiry_date: expiresAt,
};
const writeError = writeGeminiCreds(updatedCreds);
if (writeError) {
return { success: false, error: `Token refreshed but failed to save: ${writeError}` };
}
return { success: true };
return { success: true, expiresAt };
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
@@ -0,0 +1,69 @@
/**
* Provider Token Refreshers
*
* Exports refresh functions for each OAuth provider.
* Currently only Gemini is implemented; others return placeholder errors.
*/
import { CLIProxyProvider } from '../../types';
import { refreshGeminiToken } from '../gemini-token-refresh';
/** Token refresh result */
export interface ProviderRefreshResult {
success: boolean;
error?: string;
expiresAt?: number;
}
/**
* Refresh token for a specific provider and account
* @param provider Provider to refresh
* @param _accountId Account ID (currently unused, multi-account not yet implemented)
* @returns Refresh result with success status and optional error
*/
export async function refreshToken(
provider: CLIProxyProvider,
_accountId: string
): Promise<ProviderRefreshResult> {
switch (provider) {
case 'gemini':
return await refreshGeminiTokenWrapper();
case 'codex':
case 'agy':
case 'qwen':
case 'iflow':
case 'kiro':
case 'ghcp':
return {
success: false,
error: `Token refresh not yet implemented for ${provider}`,
};
default:
return {
success: false,
error: `Unknown provider: ${provider}`,
};
}
}
/**
* Wrapper for Gemini token refresh
* Converts gemini-token-refresh.ts format to provider-refreshers format
*/
async function refreshGeminiTokenWrapper(): Promise<ProviderRefreshResult> {
const result = await refreshGeminiToken();
if (!result.success) {
return {
success: false,
error: result.error,
};
}
return {
success: true,
expiresAt: result.expiresAt,
};
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Token Expiry Checker
*
* Inspects token files to determine expiry times and refresh requirements.
* Supports expiry_date field with fallback to file modification time.
*/
import * as fs from 'fs';
import * as path from 'path';
import { CLIProxyProvider } from '../types';
import { getProviderAccounts, getAccountTokenPath } from '../account-manager';
/** Preemptive refresh time: refresh tokens 45 minutes before expiry */
export const PREEMPTIVE_REFRESH_MINUTES = 45;
/** Fallback expiry: assume 50 minutes if no expiry_date field */
export const FALLBACK_EXPIRY_MINUTES = 50;
/** Maximum token file size in bytes (1MB) - prevent DoS from huge files */
const MAX_TOKEN_FILE_SIZE = 1024 * 1024;
/** Token expiry information for a single account */
export interface TokenExpiryInfo {
/** Provider name */
provider: CLIProxyProvider;
/** Account ID */
accountId: string;
/** Path to token file */
tokenFile: string;
/** Expiry timestamp (Unix ms) */
expiresAt: number;
/** Whether token needs refresh (within preemptive window) */
needsRefresh: boolean;
/** Token file last modified time */
lastModified: Date;
}
/**
* Token file structure
*/
interface TokenData {
access_token?: string;
refresh_token?: string;
expiry_date?: number; // Unix timestamp ms
type?: string;
}
/**
* Get token expiry info for a specific account
* @returns null if token file doesn't exist or is invalid
*/
export function getTokenExpiryInfo(
provider: CLIProxyProvider,
accountId: string
): TokenExpiryInfo | null {
const tokenPath = getAccountTokenPath(provider, accountId);
if (!tokenPath || !fs.existsSync(tokenPath)) {
return null;
}
try {
const stats = fs.statSync(tokenPath);
// Prevent DoS from huge token files
if (stats.size > MAX_TOKEN_FILE_SIZE) {
return null;
}
const content = fs.readFileSync(tokenPath, 'utf-8');
const data: TokenData = JSON.parse(content);
// Validate refresh_token exists (required for refresh)
if (!data.refresh_token || typeof data.refresh_token !== 'string') {
return null;
}
// Calculate expiry time with validation
let expiresAt: number;
if (
data.expiry_date &&
typeof data.expiry_date === 'number' &&
Number.isFinite(data.expiry_date) &&
data.expiry_date > 0
) {
// Use expiry_date field if valid
expiresAt = data.expiry_date;
} else {
// Fallback: use file mtime + 50 minutes
expiresAt = stats.mtime.getTime() + FALLBACK_EXPIRY_MINUTES * 60 * 1000;
}
// Check if needs refresh (within preemptive window)
const now = Date.now();
const timeUntilExpiry = expiresAt - now;
const preemptiveMs = PREEMPTIVE_REFRESH_MINUTES * 60 * 1000;
const needsRefresh = timeUntilExpiry < preemptiveMs;
return {
provider,
accountId,
tokenFile: path.basename(tokenPath),
expiresAt,
needsRefresh,
lastModified: stats.mtime,
};
} catch {
return null;
}
}
/**
* Get token expiry info for all accounts across all providers
* @returns Array of token expiry info, excluding invalid tokens
*/
export function getAllTokenExpiryInfo(): TokenExpiryInfo[] {
const providers: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'];
const results: TokenExpiryInfo[] = [];
for (const provider of providers) {
const accounts = getProviderAccounts(provider);
for (const account of accounts) {
const info = getTokenExpiryInfo(provider, account.id);
if (info) {
results.push(info);
}
}
}
return results;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Token Refresh Configuration
*
* Loads token refresh worker settings from unified config.
* Returns null if disabled or not configured.
*/
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import type { TokenRefreshSettings } from '../../config/unified-config-types';
/**
* Get token refresh configuration from unified config
* @returns Config if enabled, null if disabled or not configured
*/
export function getTokenRefreshConfig(): TokenRefreshSettings | null {
const config = loadOrCreateUnifiedConfig();
// Return null if not configured or explicitly disabled
if (!config.cliproxy?.token_refresh?.enabled) {
return null;
}
// Return config with defaults
return {
enabled: true,
interval_minutes: config.cliproxy.token_refresh.interval_minutes ?? 30,
preemptive_minutes: config.cliproxy.token_refresh.preemptive_minutes ?? 45,
max_retries: config.cliproxy.token_refresh.max_retries ?? 3,
verbose: config.cliproxy.token_refresh.verbose ?? false,
};
}
+280
View File
@@ -0,0 +1,280 @@
/**
* Token Refresh Worker
*
* Background worker that periodically checks and refreshes OAuth tokens
* before they expire. Runs as interval loop with retry logic.
*/
import { CLIProxyProvider } from '../types';
import { getAllTokenExpiryInfo, TokenExpiryInfo } from './token-expiry-checker';
import { refreshToken } from './provider-refreshers';
/** Worker configuration */
export interface TokenRefreshConfig {
/** Refresh check interval in minutes (default: 30) */
refreshInterval: number;
/** Preemptive refresh time in minutes (default: 45) */
preemptiveTime: number;
/** Maximum retry attempts per token (default: 3) */
maxRetries: number;
/** Base delay for exponential backoff in ms (default: 1000) */
retryBaseDelay: number;
/** Timeout for refresh operations in ms (default: 10000) */
refreshTimeout: number;
/** Enable verbose logging */
verbose: boolean;
}
/** Result of a token refresh attempt */
export interface RefreshResult {
provider: CLIProxyProvider;
accountId: string;
success: boolean;
error?: string;
refreshedAt?: Date;
nextExpiry?: number;
}
/** Default worker configuration */
const DEFAULT_CONFIG: TokenRefreshConfig = {
refreshInterval: 30,
preemptiveTime: 45,
maxRetries: 3,
retryBaseDelay: 1000,
refreshTimeout: 10000,
verbose: false,
};
/** Minimum config values to prevent infinite loops */
const MIN_REFRESH_INTERVAL = 1; // 1 minute minimum
const MIN_RETRY_BASE_DELAY = 100; // 100ms minimum
/** Unrecoverable error patterns - don't retry these */
const UNRECOVERABLE_ERRORS = [
'No refresh token',
'Invalid client',
'Invalid grant',
'Token has been revoked',
'Token not found',
];
/** Validate and sanitize config values */
function sanitizeConfig(config: TokenRefreshConfig): TokenRefreshConfig {
return {
refreshInterval: Math.max(
MIN_REFRESH_INTERVAL,
config.refreshInterval || DEFAULT_CONFIG.refreshInterval
),
preemptiveTime: Math.max(0, config.preemptiveTime || DEFAULT_CONFIG.preemptiveTime),
maxRetries: Math.max(1, config.maxRetries || DEFAULT_CONFIG.maxRetries),
retryBaseDelay: Math.max(
MIN_RETRY_BASE_DELAY,
config.retryBaseDelay || DEFAULT_CONFIG.retryBaseDelay
),
refreshTimeout: Math.max(1000, config.refreshTimeout || DEFAULT_CONFIG.refreshTimeout),
verbose: config.verbose ?? DEFAULT_CONFIG.verbose,
};
}
/** Promise with timeout */
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, errorMsg: string): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(errorMsg)), timeoutMs)),
]);
}
/**
* Background token refresh worker
* Manages periodic token refresh checks with retry logic
*/
export class TokenRefreshWorker {
private config: TokenRefreshConfig;
private intervalId: NodeJS.Timeout | null = null;
private running = false;
private lastResults: RefreshResult[] = [];
private exitHandler: (() => void) | null = null;
constructor(config: Partial<TokenRefreshConfig> = {}) {
this.config = sanitizeConfig({ ...DEFAULT_CONFIG, ...config });
}
/**
* Start the worker
* Runs refresh loop immediately, then on interval
*/
start(): void {
if (this.running) {
return;
}
this.running = true;
this.log('[i] Token refresh worker started');
// Register process exit handlers for cleanup
this.exitHandler = () => this.stop();
process.on('SIGINT', this.exitHandler);
process.on('SIGTERM', this.exitHandler);
process.on('beforeExit', this.exitHandler);
// Run immediately on start
void this.refreshLoop();
// Then run on interval
const intervalMs = this.config.refreshInterval * 60 * 1000;
this.intervalId = setInterval(() => {
void this.refreshLoop();
}, intervalMs);
}
/**
* Stop the worker
*/
stop(): void {
if (!this.running) {
return;
}
this.running = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
// Remove process exit handlers
if (this.exitHandler) {
process.off('SIGINT', this.exitHandler);
process.off('SIGTERM', this.exitHandler);
process.off('beforeExit', this.exitHandler);
this.exitHandler = null;
}
this.log('[i] Token refresh worker stopped');
}
/**
* Check if worker is active
*/
isActive(): boolean {
return this.running;
}
/**
* Manually trigger refresh check now
*/
async refreshNow(): Promise<RefreshResult[]> {
return await this.refreshLoop();
}
/**
* Get results from last refresh cycle
*/
getLastRefreshResults(): RefreshResult[] {
return [...this.lastResults];
}
/**
* Main refresh loop
* Checks all tokens and refreshes those needing refresh
*/
private async refreshLoop(): Promise<RefreshResult[]> {
const results: RefreshResult[] = [];
try {
const tokens = getAllTokenExpiryInfo();
const tokensNeedingRefresh = tokens.filter((t) => t.needsRefresh);
if (tokensNeedingRefresh.length === 0) {
this.log('[OK] All tokens valid, no refresh needed');
this.lastResults = [];
return results;
}
this.log(`[i] Refreshing ${tokensNeedingRefresh.length} token(s)...`);
for (const token of tokensNeedingRefresh) {
const result = await this.refreshWithRetry(token);
results.push(result);
if (result.success) {
this.log(`[OK] ${token.provider}/${token.accountId} refreshed`);
} else {
this.log(`[X] ${token.provider}/${token.accountId} failed: ${result.error}`);
}
}
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
this.log(`[X] Refresh loop error: ${msg}`);
}
this.lastResults = results;
return results;
}
/**
* Refresh a single token with retry logic
* Uses exponential backoff on failures
*/
private async refreshWithRetry(token: TokenExpiryInfo): Promise<RefreshResult> {
let lastError = 'Unknown error';
for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
try {
// Apply timeout to refresh operation
const result = await withTimeout(
refreshToken(token.provider, token.accountId),
this.config.refreshTimeout,
`Refresh timeout after ${this.config.refreshTimeout}ms`
);
if (result.success) {
return {
provider: token.provider,
accountId: token.accountId,
success: true,
refreshedAt: new Date(),
nextExpiry: result.expiresAt,
};
}
lastError = result.error || 'Refresh failed';
// Don't retry if error indicates unrecoverable issue
if (this.isUnrecoverableError(lastError)) {
break;
}
} catch (error) {
lastError = error instanceof Error ? error.message : 'Unknown error';
}
// Exponential backoff before retry
if (attempt < this.config.maxRetries - 1) {
const delay = this.config.retryBaseDelay * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return {
provider: token.provider,
accountId: token.accountId,
success: false,
error: lastError,
};
}
/**
* Check if error is unrecoverable (should not retry)
*/
private isUnrecoverableError(error: string): boolean {
return UNRECOVERABLE_ERRORS.some((pattern) => error.includes(pattern));
}
/**
* Log message if verbose enabled
*/
private log(msg: string): void {
if (this.config.verbose) {
console.error(`[token-refresh] ${msg}`);
}
}
}
+77
View File
@@ -25,10 +25,15 @@ import { registerSession } from './session-tracker';
import { detectRunningProxy, waitForProxyHealthy } from './proxy-detector';
import { withStartupLock } from './startup-lock';
import { isCliproxyRunning } from './stats-fetcher';
import { TokenRefreshWorker, type RefreshResult } from './auth/token-refresh-worker';
import { getTokenRefreshConfig } from './auth/token-refresh-config';
/** Background proxy process reference */
let proxyProcess: ChildProcess | null = null;
/** Token refresh worker instance */
let tokenRefreshWorker: TokenRefreshWorker | null = null;
/** Cleanup registered flag */
let cleanupRegistered = false;
@@ -77,6 +82,12 @@ function registerCleanup(): void {
if (cleanupRegistered) return;
const cleanup = () => {
// Stop token refresh worker first
if (tokenRefreshWorker && tokenRefreshWorker.isActive()) {
tokenRefreshWorker.stop();
tokenRefreshWorker = null;
}
// Then stop proxy process
if (proxyProcess && !proxyProcess.killed) {
proxyProcess.kill('SIGTERM');
proxyProcess = null;
@@ -90,6 +101,38 @@ function registerCleanup(): void {
cleanupRegistered = true;
}
/**
* Start token refresh worker if configured
* @param verbose Enable verbose logging
*/
function startTokenRefreshWorker(verbose: boolean): void {
// Skip if already running
if (tokenRefreshWorker && tokenRefreshWorker.isActive()) {
return;
}
// Load config
const config = getTokenRefreshConfig();
if (!config) {
// Not configured or disabled
return;
}
// Create and start worker
tokenRefreshWorker = new TokenRefreshWorker({
refreshInterval: config.interval_minutes ?? 30,
preemptiveTime: config.preemptive_minutes ?? 45,
maxRetries: config.max_retries ?? 3,
verbose: config.verbose || verbose,
});
tokenRefreshWorker.start();
if (verbose) {
console.error('[i] Token refresh worker started');
}
}
export interface ServiceStartResult {
started: boolean;
alreadyRunning: boolean;
@@ -254,6 +297,9 @@ export async function ensureCliproxyService(
log(`Session registered for PID ${proxyProcess.pid}`);
}
// 6. Start token refresh worker if configured
startTokenRefreshWorker(verbose);
return { started: true, alreadyRunning: false, port };
});
}
@@ -262,6 +308,13 @@ export async function ensureCliproxyService(
* Stop the managed CLIProxy service
*/
export function stopCliproxyService(): boolean {
// Stop token refresh worker first
if (tokenRefreshWorker && tokenRefreshWorker.isActive()) {
tokenRefreshWorker.stop();
tokenRefreshWorker = null;
}
// Then stop proxy process
if (proxyProcess && !proxyProcess.killed) {
proxyProcess.kill('SIGTERM');
proxyProcess = null;
@@ -283,3 +336,27 @@ export async function getServiceStatus(port: number = CLIPROXY_DEFAULT_PORT): Pr
return { running, managedByUs, port };
}
/**
* Check if token refresh worker is running
*/
export function isTokenRefreshWorkerRunning(): boolean {
return tokenRefreshWorker !== null && tokenRefreshWorker.isActive();
}
/**
* Get token refresh worker status
*/
export function getTokenRefreshStatus(): {
running: boolean;
lastResults: RefreshResult[] | null;
} {
if (!tokenRefreshWorker) {
return { running: false, lastResults: null };
}
return {
running: tokenRefreshWorker.isActive(),
lastResults: tokenRefreshWorker.getLastRefreshResults(),
};
}
+19
View File
@@ -93,6 +93,23 @@ export interface CLIProxyLoggingConfig {
request_log?: boolean;
}
/**
* Token refresh configuration.
* Manages background token refresh worker settings.
*/
export interface TokenRefreshSettings {
/** Enable background token refresh (default: false) */
enabled?: boolean;
/** Refresh check interval in minutes (default: 30) */
interval_minutes?: number;
/** Preemptive refresh time in minutes (default: 45) */
preemptive_minutes?: number;
/** Maximum retry attempts per token (default: 3) */
max_retries?: number;
/** Enable verbose logging (default: false) */
verbose?: boolean;
}
/**
* CLIProxy configuration section.
*/
@@ -109,6 +126,8 @@ export interface CLIProxyConfig {
kiro_no_incognito?: boolean;
/** Global auth configuration for CLIProxyAPI */
auth?: CLIProxyAuthConfig;
/** Background token refresh worker settings */
token_refresh?: TokenRefreshSettings;
}
/**