From 9d96535d28bf7070e8eccde6af16ef79262a65cf Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 14:57:05 -0500 Subject: [PATCH 1/3] fix(cliproxy): read Gemini tokens from CLIProxy auth directory Fixes token mismatch where Gemini auth succeeds but token immediately expires. The issue was that isAuthenticated() checked CLIProxy auth dir (~/.ccs/cliproxy/auth/) but ensureGeminiTokenValid() only read from ~/.gemini/oauth_creds.json. Changes: - Add readCliproxyGeminiCreds() to read from CLIProxy auth dir first - Add mapCliproxyToGeminiCreds() to convert CLIProxyAPI format to internal - Track token source path for correct write-back on refresh - Add writeCliproxyGeminiCreds() to preserve email/project_id fields - Fall back to ~/.gemini/oauth_creds.json for backward compatibility Closes #368 --- src/cliproxy/auth/gemini-token-refresh.ts | 133 ++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts index fedb48dd..727ed831 100644 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -3,11 +3,18 @@ * * Handles proactive token validation and refresh for Gemini OAuth tokens. * Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use. + * + * Token sources (priority order): + * 1. CLIProxy auth dir (~/.ccs/cliproxy/auth/) - CCS-managed tokens + * 2. Standard Gemini CLI (~/.gemini/oauth_creds.json) - backward compatibility */ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { getProviderAuthDir } from '../config-generator'; +import { getDefaultAccount, getProviderAccounts } from '../account-manager'; +import { isTokenFileForProvider } from './token-manager'; /** * Gemini OAuth credentials - PUBLIC from official Gemini CLI source code @@ -39,6 +46,21 @@ interface GeminiOAuthCreds { id_token?: string; } +/** CLIProxyAPI Gemini token structure (from GeminiTokenStorage Go struct) */ +interface CliproxyGeminiToken { + token: { + access_token: string; + refresh_token?: string; + expiry?: number; // Unix timestamp in milliseconds + }; + project_id: string; + email: string; + type: 'gemini'; +} + +/** Tracks the source path of the last read token (for write-back) */ +let lastTokenSourcePath: string | null = null; + /** Token refresh response from Google */ interface TokenRefreshResponse { access_token?: string; @@ -55,15 +77,96 @@ export function getGeminiOAuthPath(): string { return path.join(os.homedir(), '.gemini', 'oauth_creds.json'); } +/** + * Map CLIProxyAPI token format to internal GeminiOAuthCreds format + */ +function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCreds { + return { + access_token: cliproxy.token.access_token, + refresh_token: cliproxy.token.refresh_token, + expiry_date: cliproxy.token.expiry, + token_type: 'Bearer', + }; +} + +/** + * Read Gemini token from CLIProxy auth directory + * Returns null if no valid token found + */ +function readCliproxyGeminiCreds(): GeminiOAuthCreds | null { + const authDir = getProviderAuthDir('gemini'); + if (!fs.existsSync(authDir)) return null; + + // Try to find default account's token file + const defaultAccount = getDefaultAccount('gemini'); + let tokenPath: string | null = null; + + if (defaultAccount) { + tokenPath = path.join(authDir, defaultAccount.tokenFile); + if (!fs.existsSync(tokenPath)) tokenPath = null; + } + + // Fallback: find any gemini token file by prefix or type + if (!tokenPath) { + const accounts = getProviderAccounts('gemini'); + if (accounts.length > 0) { + tokenPath = path.join(authDir, accounts[0].tokenFile); + if (!fs.existsSync(tokenPath)) tokenPath = null; + } + } + + // Last fallback: scan directory for gemini token files + if (!tokenPath) { + try { + const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json')); + for (const file of files) { + const filePath = path.join(authDir, file); + if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) { + tokenPath = filePath; + break; + } + } + } catch { + return null; + } + } + + if (!tokenPath) return null; + + try { + const content = fs.readFileSync(tokenPath, 'utf8'); + const data = JSON.parse(content); + + // Check if this is CLIProxyAPI format (has nested token object) + if (data.type === 'gemini' && data.token) { + lastTokenSourcePath = tokenPath; + return mapCliproxyToGeminiCreds(data as CliproxyGeminiToken); + } + + return null; + } catch { + return null; + } +} + /** * Read Gemini OAuth credentials + * Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json */ function readGeminiCreds(): GeminiOAuthCreds | null { + // 1. Try CLIProxy auth directory first (CCS-managed tokens) + const cliproxyCreds = readCliproxyGeminiCreds(); + if (cliproxyCreds) { + return cliproxyCreds; + } + + // 2. Fall back to standard Gemini CLI location const oauthPath = getGeminiOAuthPath(); if (!fs.existsSync(oauthPath)) { return null; } try { + lastTokenSourcePath = oauthPath; const content = fs.readFileSync(oauthPath, 'utf8'); return JSON.parse(content) as GeminiOAuthCreds; } catch { @@ -71,11 +174,41 @@ function readGeminiCreds(): GeminiOAuthCreds | null { } } +/** + * Write updated credentials to CLIProxy token file + * Preserves existing fields (email, project_id), only updates token subfields + */ +function writeCliproxyGeminiCreds(tokenPath: string, creds: GeminiOAuthCreds): string | undefined { + try { + const existing = JSON.parse(fs.readFileSync(tokenPath, 'utf8')); + const updated = { + ...existing, + token: { + ...existing.token, + access_token: creds.access_token, + refresh_token: creds.refresh_token, + expiry: creds.expiry_date, + }, + }; + fs.writeFileSync(tokenPath, JSON.stringify(updated, null, 2), { mode: 0o600 }); + return undefined; + } catch (err) { + return err instanceof Error ? err.message : 'Failed to write credentials'; + } +} + /** * Write Gemini OAuth credentials + * Writes back to the source location (CLIProxy or ~/.gemini) * @returns error message if write failed, undefined on success */ function writeGeminiCreds(creds: GeminiOAuthCreds): string | undefined { + // If we read from CLIProxy, write back there in CLIProxy format + if (lastTokenSourcePath && !lastTokenSourcePath.includes('.gemini')) { + return writeCliproxyGeminiCreds(lastTokenSourcePath, creds); + } + + // Otherwise write to standard Gemini CLI location const oauthPath = getGeminiOAuthPath(); const dir = path.dirname(oauthPath); try { From cddf931fe6fb8b4c2b6b9edeb9d4e41a25e29535 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 15:10:20 -0500 Subject: [PATCH 2/3] refactor(cliproxy): address code review feedback for token handling - Remove module-level mutable state (lastTokenSourcePath) - Add GeminiCredsWithSource interface for explicit source path passing - Add isValidCliproxyToken() for proper type validation - Use explicit path comparison instead of .includes('.gemini') heuristic - Add descriptive comment for directory scan error handling Addresses review feedback from PR #396 --- src/cliproxy/auth/gemini-token-refresh.ts | 101 ++++++++++++++-------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts index 727ed831..2117a3be 100644 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -46,6 +46,12 @@ interface GeminiOAuthCreds { id_token?: string; } +/** Gemini credentials with source path for write-back */ +interface GeminiCredsWithSource { + creds: GeminiOAuthCreds; + sourcePath: string; +} + /** CLIProxyAPI Gemini token structure (from GeminiTokenStorage Go struct) */ interface CliproxyGeminiToken { token: { @@ -58,9 +64,6 @@ interface CliproxyGeminiToken { type: 'gemini'; } -/** Tracks the source path of the last read token (for write-back) */ -let lastTokenSourcePath: string | null = null; - /** Token refresh response from Google */ interface TokenRefreshResponse { access_token?: string; @@ -90,10 +93,22 @@ function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCre } /** - * Read Gemini token from CLIProxy auth directory - * Returns null if no valid token found + * Validate CLIProxyAPI token structure has required fields */ -function readCliproxyGeminiCreds(): GeminiOAuthCreds | null { +function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken { + if (typeof data !== 'object' || data === null) return false; + const obj = data as Record; + if (obj.type !== 'gemini') return false; + if (typeof obj.token !== 'object' || obj.token === null) return false; + const token = obj.token as Record; + return typeof token.access_token === 'string'; +} + +/** + * Read Gemini token from CLIProxy auth directory + * Returns credentials with source path, or null if no valid token found + */ +function readCliproxyGeminiCreds(): GeminiCredsWithSource | null { const authDir = getProviderAuthDir('gemini'); if (!fs.existsSync(authDir)) return null; @@ -127,6 +142,7 @@ function readCliproxyGeminiCreds(): GeminiOAuthCreds | null { } } } catch { + // Directory read failed - continue to return null return null; } } @@ -135,12 +151,14 @@ function readCliproxyGeminiCreds(): GeminiOAuthCreds | null { try { const content = fs.readFileSync(tokenPath, 'utf8'); - const data = JSON.parse(content); + const data: unknown = JSON.parse(content); - // Check if this is CLIProxyAPI format (has nested token object) - if (data.type === 'gemini' && data.token) { - lastTokenSourcePath = tokenPath; - return mapCliproxyToGeminiCreds(data as CliproxyGeminiToken); + // Validate CLIProxyAPI format with proper type checking + if (isValidCliproxyToken(data)) { + return { + creds: mapCliproxyToGeminiCreds(data), + sourcePath: tokenPath, + }; } return null; @@ -152,12 +170,13 @@ function readCliproxyGeminiCreds(): GeminiOAuthCreds | null { /** * Read Gemini OAuth credentials * Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json + * Returns credentials with source path for correct write-back */ -function readGeminiCreds(): GeminiOAuthCreds | null { +function readGeminiCreds(): GeminiCredsWithSource | null { // 1. Try CLIProxy auth directory first (CCS-managed tokens) - const cliproxyCreds = readCliproxyGeminiCreds(); - if (cliproxyCreds) { - return cliproxyCreds; + const cliproxyResult = readCliproxyGeminiCreds(); + if (cliproxyResult) { + return cliproxyResult; } // 2. Fall back to standard Gemini CLI location @@ -166,9 +185,11 @@ function readGeminiCreds(): GeminiOAuthCreds | null { return null; } try { - lastTokenSourcePath = oauthPath; const content = fs.readFileSync(oauthPath, 'utf8'); - return JSON.parse(content) as GeminiOAuthCreds; + return { + creds: JSON.parse(content) as GeminiOAuthCreds, + sourcePath: oauthPath, + }; } catch { return null; } @@ -199,23 +220,26 @@ function writeCliproxyGeminiCreds(tokenPath: string, creds: GeminiOAuthCreds): s /** * Write Gemini OAuth credentials - * Writes back to the source location (CLIProxy or ~/.gemini) + * Writes back to the specified source location (CLIProxy or ~/.gemini) + * @param creds - The credentials to write + * @param sourcePath - The path where credentials were originally read from * @returns error message if write failed, undefined on success */ -function writeGeminiCreds(creds: GeminiOAuthCreds): string | undefined { - // If we read from CLIProxy, write back there in CLIProxy format - if (lastTokenSourcePath && !lastTokenSourcePath.includes('.gemini')) { - return writeCliproxyGeminiCreds(lastTokenSourcePath, creds); +function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string | undefined { + const geminiOAuthPath = getGeminiOAuthPath(); + + // If source is not the standard Gemini path, write to CLIProxy format + if (sourcePath !== geminiOAuthPath) { + return writeCliproxyGeminiCreds(sourcePath, creds); } // Otherwise write to standard Gemini CLI location - const oauthPath = getGeminiOAuthPath(); - const dir = path.dirname(oauthPath); + const dir = path.dirname(geminiOAuthPath); try { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } - fs.writeFileSync(oauthPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); + fs.writeFileSync(geminiOAuthPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); return undefined; } catch (err) { return err instanceof Error ? err.message : 'Failed to write credentials'; @@ -226,14 +250,14 @@ function writeGeminiCreds(creds: GeminiOAuthCreds): string | undefined { * Check if Gemini token is expired or expiring soon */ export function isGeminiTokenExpiringSoon(): boolean { - const creds = readGeminiCreds(); - if (!creds || !creds.access_token) { + const result = readGeminiCreds(); + if (!result || !result.creds.access_token) { return true; // No token = needs auth } - if (!creds.expiry_date) { + if (!result.creds.expiry_date) { return false; // No expiry info = assume valid } - const expiresIn = creds.expiry_date - Date.now(); + const expiresIn = result.creds.expiry_date - Date.now(); return expiresIn < REFRESH_LEAD_TIME_MS; } @@ -246,11 +270,12 @@ export async function refreshGeminiToken(): Promise<{ error?: string; expiresAt?: number; }> { - const creds = readGeminiCreds(); - if (!creds || !creds.refresh_token) { + const result = readGeminiCreds(); + if (!result || !result.creds.refresh_token) { return { success: false, error: 'No refresh token available' }; } + const { creds, sourcePath } = result; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); @@ -263,7 +288,7 @@ export async function refreshGeminiToken(): Promise<{ }, body: new URLSearchParams({ grant_type: 'refresh_token', - refresh_token: creds.refresh_token, + refresh_token: creds.refresh_token as string, // Already validated above client_id: GEMINI_CLIENT_ID, client_secret: GEMINI_CLIENT_SECRET, }).toString(), @@ -291,7 +316,7 @@ export async function refreshGeminiToken(): Promise<{ access_token: data.access_token, expiry_date: expiresAt, }; - const writeError = writeGeminiCreds(updatedCreds); + const writeError = writeGeminiCreds(updatedCreds, sourcePath); if (writeError) { return { success: false, error: `Token refreshed but failed to save: ${writeError}` }; } @@ -316,8 +341,8 @@ export async function ensureGeminiTokenValid(verbose = false): Promise<{ refreshed: boolean; error?: string; }> { - const creds = readGeminiCreds(); - if (!creds || !creds.access_token) { + const result = readGeminiCreds(); + if (!result || !result.creds.access_token) { return { valid: false, refreshed: false, error: 'No Gemini credentials found' }; } @@ -330,13 +355,13 @@ export async function ensureGeminiTokenValid(verbose = false): Promise<{ console.log('[i] Gemini token expired or expiring soon, refreshing...'); } - const result = await refreshGeminiToken(); - if (result.success) { + const refreshResult = await refreshGeminiToken(); + if (refreshResult.success) { if (verbose) { console.log('[OK] Gemini token refreshed successfully'); } return { valid: true, refreshed: true }; } - return { valid: false, refreshed: false, error: result.error }; + return { valid: false, refreshed: false, error: refreshResult.error }; } From ad1a18d31fea656fd8ebc64145b43b58614c25f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 29 Jan 2026 20:16:35 +0000 Subject: [PATCH 3/3] chore(release): 7.31.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b6f3da14..9093059e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.31.0", + "version": "7.31.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",