mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user