fix(cliproxy): proactive token refresh to prevent UND_ERR_SOCKET

When Gemini OAuth tokens expire, CLIProxyAPI attempts synchronous
refresh during API calls. If this fails or times out, the socket
connection closes abruptly causing Claude CLI's undici to report
UND_ERR_SOCKET errors.

This fix adds proactive token validation before spawning Claude CLI:
- New gemini-token-refresh.ts module handles Gemini OAuth refresh
- ensureTokenValid() in token-manager.ts checks expiry with 5min lead
- cliproxy-executor.ts calls ensureTokenValid() after auth check

The token is now refreshed BEFORE Claude CLI connects, eliminating
the race condition that caused socket errors.

Fixes #256
This commit is contained in:
kaitranntt
2026-01-03 12:47:50 -05:00
parent 9fdf437ace
commit a6a653f145
3 changed files with 248 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
/**
* Gemini Token Refresh
*
* Handles proactive token validation and refresh for Gemini OAuth tokens.
* Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
/**
* Gemini OAuth credentials - PUBLIC from official Gemini CLI source code
* These are not secrets - they're public OAuth client credentials that Google
* distributes with their official applications. See:
* https://github.com/google/generative-ai-python (Gemini CLI source)
*
* GitHub secret scanning may flag these, but they are intentionally hardcoded
* as they're required for OAuth token refresh and are publicly documented.
*/
const GEMINI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
const GEMINI_CLIENT_SECRET = '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';
/** Google OAuth token endpoint */
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
/** Refresh tokens 5 minutes before expiry */
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000;
/** Gemini oauth_creds.json structure */
interface GeminiOAuthCreds {
access_token: string;
refresh_token?: string;
expiry_date?: number; // Unix timestamp in milliseconds
scope?: string;
token_type?: string;
id_token?: string;
}
/** Token refresh response from Google */
interface TokenRefreshResponse {
access_token?: string;
expires_in?: number;
token_type?: string;
error?: string;
error_description?: string;
}
/**
* Get path to Gemini OAuth credentials file
*/
export function getGeminiOAuthPath(): string {
return path.join(os.homedir(), '.gemini', 'oauth_creds.json');
}
/**
* Read Gemini OAuth credentials
*/
function readGeminiCreds(): GeminiOAuthCreds | null {
const oauthPath = getGeminiOAuthPath();
if (!fs.existsSync(oauthPath)) {
return null;
}
try {
const content = fs.readFileSync(oauthPath, 'utf8');
return JSON.parse(content) as GeminiOAuthCreds;
} catch {
return null;
}
}
/**
* Write Gemini OAuth credentials
* @returns error message if write failed, undefined on success
*/
function writeGeminiCreds(creds: GeminiOAuthCreds): string | undefined {
const oauthPath = getGeminiOAuthPath();
const dir = path.dirname(oauthPath);
try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(oauthPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
return undefined;
} catch (err) {
return err instanceof Error ? err.message : 'Failed to write credentials';
}
}
/**
* Check if Gemini token is expired or expiring soon
*/
export function isGeminiTokenExpiringSoon(): boolean {
const creds = readGeminiCreds();
if (!creds || !creds.access_token) {
return true; // No token = needs auth
}
if (!creds.expiry_date) {
return false; // No expiry info = assume valid
}
const expiresIn = creds.expiry_date - Date.now();
return expiresIn < REFRESH_LEAD_TIME_MS;
}
/**
* Refresh Gemini access token using refresh_token
* @returns true if refresh succeeded, false otherwise
*/
export async function refreshGeminiToken(): Promise<{
success: boolean;
error?: string;
}> {
const creds = readGeminiCreds();
if (!creds || !creds.refresh_token) {
return { success: false, error: 'No refresh token available' };
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: creds.refresh_token,
client_id: GEMINI_CLIENT_ID,
client_secret: GEMINI_CLIENT_SECRET,
}).toString(),
});
clearTimeout(timeoutId);
const data = (await response.json()) as TokenRefreshResponse;
if (!response.ok || data.error) {
return {
success: false,
error: data.error_description || data.error || `OAuth error: ${response.status}`,
};
}
if (!data.access_token) {
return { success: false, error: 'No access_token in response' };
}
// Update credentials file with new token
const updatedCreds: GeminiOAuthCreds = {
...creds,
access_token: data.access_token,
expiry_date: Date.now() + (data.expires_in ?? 3600) * 1000,
};
const writeError = writeGeminiCreds(updatedCreds);
if (writeError) {
return { success: false, error: `Token refreshed but failed to save: ${writeError}` };
}
return { success: true };
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
return { success: false, error: 'Token refresh timeout' };
}
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
/**
* Ensure Gemini token is valid, refreshing if needed
* @param verbose Log progress if true
* @returns true if token is valid (or was refreshed), false if refresh failed
*/
export async function ensureGeminiTokenValid(verbose = false): Promise<{
valid: boolean;
refreshed: boolean;
error?: string;
}> {
const creds = readGeminiCreds();
if (!creds || !creds.access_token) {
return { valid: false, refreshed: false, error: 'No Gemini credentials found' };
}
if (!isGeminiTokenExpiringSoon()) {
return { valid: true, refreshed: false };
}
// Token is expired or expiring soon - try to refresh
if (verbose) {
console.log('[i] Gemini token expired or expiring soon, refreshing...');
}
const result = await refreshGeminiToken();
if (result.success) {
if (verbose) {
console.log('[OK] Gemini token refreshed successfully');
}
return { valid: true, refreshed: true };
}
return { valid: false, refreshed: false, error: result.error };
}
+24
View File
@@ -258,3 +258,27 @@ export function displayAuthStatus(): void {
console.log('To authenticate: ccs <provider> --auth');
console.log('To logout: ccs <provider> --logout');
}
/**
* Ensure OAuth token is valid for provider, refreshing if expired or expiring soon.
* This prevents UND_ERR_SOCKET errors caused by expired tokens during API calls.
*
* @param provider The CLIProxy provider
* @param verbose Log progress if true
* @returns Object with valid status and whether refresh occurred
*/
export async function ensureTokenValid(
provider: CLIProxyProvider,
verbose = false
): Promise<{ valid: boolean; refreshed: boolean; error?: string }> {
// Currently only Gemini uses oauth_creds.json with expiry_date
// Other providers (agy, codex) use CLIProxyAPI's internal auth management
if (provider === 'gemini') {
const { ensureGeminiTokenValid } = await import('./gemini-token-refresh');
return ensureGeminiTokenValid(verbose);
}
// For other providers, assume token is valid if authenticated
// CLIProxyAPI handles token refresh internally for antigravity/codex
return { valid: isAuthenticated(provider), refreshed: false };
}
+17
View File
@@ -444,6 +444,23 @@ export async function execClaudeWithCLIProxy(
} else {
log(`${provider} already authenticated`);
}
// 3a. Proactive token refresh - prevent UND_ERR_SOCKET from expired tokens
// CLIProxyAPI may fail mid-request if token expires during use
const { ensureTokenValid } = await import('./auth/token-manager');
const tokenResult = await ensureTokenValid(provider, verbose);
if (!tokenResult.valid) {
// Token expired and refresh failed - trigger re-auth
console.error(warn('OAuth token expired and refresh failed'));
if (tokenResult.error) {
console.error(` ${tokenResult.error}`);
}
console.error(` Run "ccs ${provider} --auth" to re-authenticate`);
process.exit(1);
}
if (tokenResult.refreshed) {
log('Token was refreshed proactively');
}
}
// 4. First-run model configuration (interactive)