From dea0e872bd529b2c6f825dc1d9e901d0896b7f41 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 2 Jan 2026 20:42:14 -0500 Subject: [PATCH 1/9] fix(cliproxy): add missing kiro/ghcp provider mappings in remote-auth-fetcher Remote mode was silently skipping kiro and ghcp providers because PROVIDER_MAP and PROVIDER_DISPLAY_NAMES were incomplete. Added: - kiro, codewhisperer -> kiro - ghcp, github-copilot, copilot -> ghcp Fixes dashboard showing '0 providers' when using Remote Proxy. --- src/cliproxy/remote-auth-fetcher.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 5acaba86..358ee42a 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -51,6 +51,11 @@ const PROVIDER_MAP: Record = { codex: 'codex', qwen: 'qwen', iflow: 'iflow', + kiro: 'kiro', + codewhisperer: 'kiro', // CLIProxyAPI may use 'codewhisperer' for Kiro + ghcp: 'ghcp', + 'github-copilot': 'ghcp', + copilot: 'ghcp', }; /** Display names for providers */ @@ -60,6 +65,8 @@ const PROVIDER_DISPLAY_NAMES: Record = { codex: 'Codex', qwen: 'Qwen', iflow: 'iFlow', + kiro: 'Kiro (AWS)', + ghcp: 'GitHub Copilot (OAuth)', }; /** From 0e58d0e8b7fd07004990e99fbdc6a080380c0304 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Fri, 2 Jan 2026 22:31:28 -0500 Subject: [PATCH 2/9] fix(cliproxy): add management_key support for remote proxy auth separation - Add management_key field to CliproxyServerConfig for separate management API auth - Update proxy-target-resolver with buildManagementHeaders() for /v0/management/* endpoints - Update stats-fetcher and remote-auth-fetcher to use management headers - Add management_key input field to dashboard remote proxy settings - Disable quota UI in remote mode (tokens on remote server) - Fix preset application to use dynamic API key from config instead of hardcoded placeholder --- src/cliproxy/proxy-target-resolver.ts | 31 +++++++++++++++++- src/cliproxy/remote-auth-fetcher.ts | 5 +-- src/cliproxy/stats-fetcher.ts | 19 +++++++---- src/config/unified-config-loader.ts | 2 ++ src/config/unified-config-types.ts | 9 +++++- .../cliproxy/provider-editor/index.tsx | 18 +++++++++-- .../provider-editor/model-config-tab.tsx | 5 ++- ui/src/lib/api-client.ts | 6 +++- ui/src/lib/preset-utils.ts | 23 ++++++++++++- .../pages/settings/hooks/use-proxy-config.ts | 3 ++ .../pages/settings/sections/proxy/index.tsx | 15 +++++++++ .../sections/proxy/remote-proxy-card.tsx | 32 +++++++++++++++++-- 12 files changed, 149 insertions(+), 19 deletions(-) diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts index 7124c7a1..c9d5cd83 100644 --- a/src/cliproxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy-target-resolver.ts @@ -22,8 +22,10 @@ export interface ProxyTarget { port: number; /** Protocol (http/https) */ protocol: 'http' | 'https'; - /** Optional auth token - only send header if defined and non-empty */ + /** Optional auth token for API endpoints - only send header if defined and non-empty */ authToken?: string; + /** Optional management key for management API endpoints (/v0/management/*) */ + managementKey?: string; /** True if targeting remote server, false if local */ isRemote: boolean; } @@ -56,6 +58,7 @@ export function getProxyTarget(): ProxyTarget { port, protocol, authToken: config.remote.auth_token || undefined, // Empty string -> undefined + managementKey: config.remote.management_key || undefined, // Empty string -> undefined isRemote: true, }; } @@ -102,3 +105,29 @@ export function buildProxyHeaders( return headers; } + +/** + * Build request headers for management API endpoints (/v0/management/*). + * Uses management_key if configured, otherwise falls back to authToken. + * + * @param target Resolved proxy target + * @param additionalHeaders Extra headers to merge + */ +export function buildManagementHeaders( + target: ProxyTarget, + additionalHeaders: Record = {} +): Record { + const headers: Record = { + Accept: 'application/json', + ...additionalHeaders, + }; + + // Use management key for management API, fallback to authToken + const authKey = target.managementKey ?? target.authToken; + + if (authKey) { + headers['Authorization'] = `Bearer ${authKey}`; + } + + return headers; +} diff --git a/src/cliproxy/remote-auth-fetcher.ts b/src/cliproxy/remote-auth-fetcher.ts index 358ee42a..5de9218e 100644 --- a/src/cliproxy/remote-auth-fetcher.ts +++ b/src/cliproxy/remote-auth-fetcher.ts @@ -6,7 +6,7 @@ import { getProxyTarget, buildProxyUrl, - buildProxyHeaders, + buildManagementHeaders, ProxyTarget, } from './proxy-target-resolver'; @@ -81,6 +81,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise controller.abort(), REMOTE_FETCH_TIMEOUT_MS); @@ -88,7 +89,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise): UnifiedConfig { auth_token: partial.cliproxy_server?.remote?.auth_token ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token, + // management_key is optional - falls back to auth_token when not set + management_key: partial.cliproxy_server?.remote?.management_key, }, fallback: { enabled: diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 18d5e3d5..166349cb 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -225,8 +225,15 @@ export interface ProxyRemoteConfig { port?: number; /** Protocol for remote connection */ protocol: 'http' | 'https'; - /** Auth token for remote proxy (optional, sent as header) */ + /** Auth token for remote proxy API endpoints (optional, sent as header) */ auth_token: string; + /** + * Management key for remote proxy management API endpoints. + * CLIProxyAPI uses separate authentication for management endpoints + * (/v0/management/*) via 'secret-key' config. + * If not set, falls back to auth_token for backwards compatibility. + */ + management_key?: string; /** Connection timeout in milliseconds (default: 2000) */ timeout?: number; } diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 7e79ffaf..53338e3c 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -5,6 +5,7 @@ /* eslint-disable react-refresh/only-export-components */ import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Loader2, Code2 } from 'lucide-react'; @@ -93,11 +94,23 @@ export function ProviderEditor({ const accounts = authStatus.accounts || []; + // Fetch effective API key for presets (uses configured value, not hardcoded) + const { data: authTokens } = useQuery<{ apiKey: { value: string } }>({ + queryKey: ['auth-tokens-raw'], + queryFn: async () => { + const response = await fetch('/api/settings/auth/tokens/raw'); + if (!response.ok) return { apiKey: { value: 'ccs-internal-managed' } }; + return response.json(); + }, + staleTime: 60000, // Cache for 1 minute + }); + const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed'; + const handleApplyPreset = (updates: Record) => { const effectivePort = port ?? CLIPROXY_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_AUTH_TOKEN: effectiveApiKey, ...updates, }); toast.success(`Applied "${updates.ANTHROPIC_MODEL?.split('/').pop() || 'preset'}" preset`); @@ -107,7 +120,7 @@ export function ProviderEditor({ const effectivePort = port ?? CLIPROXY_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_AUTH_TOKEN: effectiveApiKey, ANTHROPIC_MODEL: values.default, ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet, @@ -189,6 +202,7 @@ export function ProviderEditor({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} + isRemoteMode={isRemoteMode} /> void; isRemovingAccount?: boolean; privacyMode?: boolean; + /** True if connected to remote CLIProxy (quota not available) */ + isRemoteMode?: boolean; } export function ModelConfigTab({ @@ -60,6 +62,7 @@ export function ModelConfigTab({ onRemoveAccount, isRemovingAccount, privacyMode, + isRemoteMode, }: ModelConfigTabProps) { // Kiro-specific: no-incognito setting (defaults to true = normal browser) const isKiro = provider === 'kiro'; @@ -133,7 +136,7 @@ export function ModelConfigTab({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} - showQuota={provider === 'agy'} + showQuota={provider === 'agy' && !isRemoteMode} isKiro={isKiro} kiroNoIncognito={kiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 60e3ec02..a2cf98a5 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -193,6 +193,8 @@ export interface ProxyRemoteConfig { port?: number; protocol: 'http' | 'https'; auth_token: string; + /** Management key for /v0/management/* endpoints (optional, falls back to auth_token) */ + management_key?: string; } /** Fallback configuration */ @@ -278,7 +280,9 @@ export const api = { cliproxy: { list: () => request<{ variants: Variant[] }>('/cliproxy'), getAuthStatus: () => - request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local' }>('/cliproxy/auth'), + request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local'; error?: string }>( + '/cliproxy/auth' + ), create: (data: CreateVariant) => request('/cliproxy', { method: 'POST', diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index 80ce9673..ebdb724d 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -8,6 +8,24 @@ import { MODEL_CATALOGS } from './model-catalogs'; /** CLIProxy port - should match the backend configuration */ export const CLIPROXY_PORT = 8317; +/** Default fallback API key if fetch fails */ +const DEFAULT_API_KEY = 'ccs-internal-managed'; + +/** + * Fetch effective API key from backend + * Falls back to default if fetch fails + */ +async function fetchEffectiveApiKey(): Promise { + try { + const response = await fetch('/api/settings/auth/tokens/raw'); + if (!response.ok) return DEFAULT_API_KEY; + const data = await response.json(); + return data?.apiKey?.value ?? DEFAULT_API_KEY; + } catch { + return DEFAULT_API_KEY; + } +} + /** * Apply default preset for a provider to its settings * Uses the first model's presetMapping or falls back to using defaultModel for all tiers @@ -32,11 +50,14 @@ export async function applyDefaultPreset( haiku: catalog.defaultModel, }; + // Fetch effective API key (respects user customization) + const effectiveApiKey = await fetchEffectiveApiKey(); + const effectivePort = port ?? CLIPROXY_PORT; const settings = { env: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, - ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_AUTH_TOKEN: effectiveApiKey, ANTHROPIC_MODEL: mapping.default, ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet, diff --git a/ui/src/pages/settings/hooks/use-proxy-config.ts b/ui/src/pages/settings/hooks/use-proxy-config.ts index b9e9cb50..eae80880 100644 --- a/ui/src/pages/settings/hooks/use-proxy-config.ts +++ b/ui/src/pages/settings/hooks/use-proxy-config.ts @@ -13,6 +13,7 @@ export function useProxyConfig() { const [editedHost, setEditedHost] = useState(null); const [editedPort, setEditedPort] = useState(null); const [editedAuthToken, setEditedAuthToken] = useState(null); + const [editedManagementKey, setEditedManagementKey] = useState(null); const [editedLocalPort, setEditedLocalPort] = useState(null); const fetchConfig = useCallback(async () => { @@ -108,6 +109,8 @@ export function useProxyConfig() { setEditedPort, editedAuthToken, setEditedAuthToken, + editedManagementKey, + setEditedManagementKey, editedLocalPort, setEditedLocalPort, fetchConfig, diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 14a8b666..a9807ec8 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -28,6 +28,8 @@ export default function ProxySection() { setEditedPort, editedAuthToken, setEditedAuthToken, + editedManagementKey, + setEditedManagementKey, editedLocalPort, setEditedLocalPort, fetchConfig, @@ -62,11 +64,13 @@ export default function ProxySection() { const hostInput = config.remote.host ?? ''; const portInput = config.remote.port !== undefined ? config.remote.port.toString() : ''; const authTokenInput = config.remote.auth_token ?? ''; + const managementKeyInput = config.remote.management_key ?? ''; const localPortInput = (config.local.port ?? 8317).toString(); const displayHost = editedHost ?? hostInput; const displayPort = editedPort ?? portInput; const displayAuthToken = editedAuthToken ?? authTokenInput; + const displayManagementKey = editedManagementKey ?? managementKeyInput; const displayLocalPort = editedLocalPort ?? localPortInput; // Save functions for blur events @@ -97,6 +101,14 @@ export default function ProxySection() { setEditedAuthToken(null); }; + const saveManagementKey = () => { + const value = editedManagementKey ?? displayManagementKey; + if (value !== config.remote.management_key) { + saveConfig({ remote: { ...remoteConfig, management_key: value || undefined } }); + } + setEditedManagementKey(null); + }; + const saveLocalPort = () => { const port = parseInt(editedLocalPort ?? displayLocalPort, 10); if (!isNaN(port) && port !== config.local.port) { @@ -203,12 +215,15 @@ export default function ProxySection() { displayHost={displayHost} displayPort={displayPort} displayAuthToken={displayAuthToken} + displayManagementKey={displayManagementKey} setEditedHost={setEditedHost} setEditedPort={setEditedPort} setEditedAuthToken={setEditedAuthToken} + setEditedManagementKey={setEditedManagementKey} onSaveHost={saveHost} onSavePort={savePort} onSaveAuthToken={saveAuthToken} + onSaveManagementKey={saveManagementKey} onSaveConfig={saveConfig} onTestConnection={handleTestConnection} /> diff --git a/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx b/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx index 29e26413..ad03b65d 100644 --- a/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx +++ b/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx @@ -23,12 +23,15 @@ interface RemoteProxyCardProps { displayHost: string; displayPort: string; displayAuthToken: string; + displayManagementKey: string; setEditedHost: (value: string | null) => void; setEditedPort: (value: string | null) => void; setEditedAuthToken: (value: string | null) => void; + setEditedManagementKey: (value: string | null) => void; onSaveHost: () => void; onSavePort: () => void; onSaveAuthToken: () => void; + onSaveManagementKey: () => void; onSaveConfig: (updates: Partial) => void; onTestConnection: () => void; } @@ -41,12 +44,15 @@ export function RemoteProxyCard({ displayHost, displayPort, displayAuthToken, + displayManagementKey, setEditedHost, setEditedPort, setEditedAuthToken, + setEditedManagementKey, onSaveHost, onSavePort, onSaveAuthToken, + onSaveManagementKey, onSaveConfig, onTestConnection, }: RemoteProxyCardProps) { @@ -115,18 +121,38 @@ export function RemoteProxyCard({ - {/* Auth Token */} + {/* Auth Token (API Key) */}
- + setEditedAuthToken(e.target.value)} onBlur={onSaveAuthToken} - placeholder="Bearer token for authentication" + placeholder="For /v1/* API endpoints" className="font-mono" disabled={saving} /> +

+ Used for API requests to /v1/chat/completions +

+
+ + {/* Management Key */} +
+ + setEditedManagementKey(e.target.value)} + onBlur={onSaveManagementKey} + placeholder="For /v0/management/* endpoints" + className="font-mono" + disabled={saving} + /> +

+ Used for dashboard management APIs. Falls back to API Key if not set. +

{/* Test Connection */} From 9fdf437ace924298eff7e79cd33e5fb3a2dc5412 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Jan 2026 16:30:21 +0000 Subject: [PATCH 3/9] chore(release): 7.13.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d15c569c..261c574e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.0", + "version": "7.13.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 7bb7ccc27fe0d9885c4dd7f23de664e2c8b4866f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 3 Jan 2026 11:40:49 -0500 Subject: [PATCH 4/9] fix(cliproxy): extract unique accountId from token filename for Kiro/GHCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When OAuth accounts have empty email fields (Kiro/GHCP), the accountId was defaulting to 'default', causing multiple accounts to overwrite each other. Now extracts unique ID from filename pattern: - kiro-github-.json → github- - ghcp-amazon-.json → amazon- Fixes #258 --- src/cliproxy/account-manager.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/cliproxy/account-manager.ts b/src/cliproxy/account-manager.ts index 8ba64cd1..a65947c9 100644 --- a/src/cliproxy/account-manager.ts +++ b/src/cliproxy/account-manager.ts @@ -56,6 +56,25 @@ const DEFAULT_REGISTRY: AccountsRegistry = { providers: {}, }; +/** + * Extract unique account ID from token filename when email is unavailable + * For Kiro/GHCP OAuth, filenames are: --.json + * Extracts: - as unique identifier + * @example kiro-github-ABC123.json → github-ABC123 + * @example ghcp-amazon-XYZ789.json → amazon-XYZ789 + * @example kiro-nomail.json → default (no OAuth structure) + */ +export function extractAccountIdFromTokenFile(filename: string, email?: string): string { + if (email) return email; + + // Pattern: --.json → extract - + // Requires at least 2 hyphens to distinguish from simple filenames like "kiro-nomail.json" + const match = filename.match(/^[^-]+-([^-]+-[^.]+)\.json$/); + if (match) return match[1]; + + return 'default'; +} + /** * Generate nickname from email * Takes prefix before @ symbol, sanitizes whitespace @@ -260,8 +279,8 @@ export function registerAccount( throw new Error('Failed to initialize provider accounts'); } - // Determine account ID - const accountId = email || 'default'; + // Determine account ID - use email if available, otherwise extract from filename + const accountId = extractAccountIdFromTokenFile(tokenFile, email); const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0; // Generate nickname if not provided @@ -448,7 +467,7 @@ export function discoverExistingAccounts(): void { continue; } - // Extract email if available, fallback to filename + // Extract email if available, fallback to filename-based ID let email = data.email || undefined; // Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json") @@ -459,7 +478,8 @@ export function discoverExistingAccounts(): void { } } - const accountId = email || 'default'; + // Use unified ID extraction: email or filename-based unique ID + const accountId = extractAccountIdFromTokenFile(file, email); // Initialize provider section if needed if (!registry.providers[provider]) { From cd1c4081b2820a287af4cd7fdd3b059a4f063276 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Jan 2026 16:54:16 +0000 Subject: [PATCH 5/9] chore(release): 7.13.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 261c574e..f27b308d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.0-dev.1", + "version": "7.13.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From a6a653f14580888bcdccbf6b83b90d41b6b52136 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sat, 3 Jan 2026 12:01:06 -0500 Subject: [PATCH 6/9] 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 --- src/cliproxy/auth/gemini-token-refresh.ts | 207 ++++++++++++++++++++++ src/cliproxy/auth/token-manager.ts | 24 +++ src/cliproxy/cliproxy-executor.ts | 17 ++ 3 files changed, 248 insertions(+) create mode 100644 src/cliproxy/auth/gemini-token-refresh.ts diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts new file mode 100644 index 00000000..f6679c4c --- /dev/null +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -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 }; +} diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 4eb4fa72..436539cc 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -258,3 +258,27 @@ export function displayAuthStatus(): void { console.log('To authenticate: ccs --auth'); console.log('To logout: ccs --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 }; +} diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 4b11748d..0de849d6 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -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) From 3cdf42c2be2d577c5bf00d3a82e166f6d9ea559b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Jan 2026 17:50:24 +0000 Subject: [PATCH 7/9] chore(release): 7.13.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f27b308d..a3800ff5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.0-dev.2", + "version": "7.13.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From ae1847d9011c8bff9caff206cf1d7082c61faf40 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 5 Jan 2026 00:00:58 -0500 Subject: [PATCH 8/9] fix(validation): add Windows reserved name validation and version format edge cases - Fixed version regex to accept -N suffix (e.g., 6.6.80-0) - Added .trim() to version input for whitespace handling - Added Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) validation - Updated binary-service, variant-service, and validation-service with checks --- src/api/services/validation-service.ts | 5 ++- src/cliproxy/services/binary-service.ts | 4 +-- src/cliproxy/services/variant-service.ts | 5 ++- src/commands/cliproxy-command.ts | 6 ++-- src/config/reserved-names.ts | 41 ++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/api/services/validation-service.ts b/src/api/services/validation-service.ts index 0973ca13..e9b830ce 100644 --- a/src/api/services/validation-service.ts +++ b/src/api/services/validation-service.ts @@ -5,7 +5,7 @@ * Extracted from api-command.ts for reuse and testability. */ -import { isReservedName } from '../../config/reserved-names'; +import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; /** * Validate API profile name @@ -24,6 +24,9 @@ export function validateApiName(name: string): string | null { if (isReservedName(name)) { return `'${name}' is a reserved name`; } + if (isWindowsReservedName(name)) { + return `'${name}' is a Windows reserved device name and cannot be used`; + } return null; } diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index c044aca4..ff769e65 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -84,10 +84,10 @@ export async function checkLatestVersion(): Promise { } /** - * Validate version format + * Validate version format (supports X.Y.Z or X.Y.Z-N suffix) */ export function isValidVersionFormat(version: string): boolean { - return /^\d+\.\d+\.\d+$/.test(version); + return /^\d+\.\d+\.\d+(-\d+)?$/.test(version); } /** diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 58e4e05d..41a5dcfb 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -9,7 +9,7 @@ import * as os from 'os'; import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProvider } from '../types'; -import { isReservedName } from '../../config/reserved-names'; +import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { deleteConfigForPort } from '../config-generator'; import { deleteSessionLockForPort } from '../session-tracker'; @@ -58,6 +58,9 @@ export function validateProfileName(name: string): string | null { if (isReservedName(name)) { return `'${name}' is a reserved name`; } + if (isWindowsReservedName(name)) { + return `'${name}' is a Windows reserved device name and cannot be used`; + } return null; } diff --git a/src/commands/cliproxy-command.ts b/src/commands/cliproxy-command.ts index e0e93e35..bce8a3b9 100644 --- a/src/commands/cliproxy-command.ts +++ b/src/commands/cliproxy-command.ts @@ -619,13 +619,15 @@ export async function handleCliproxyCommand(args: string[]): Promise { const installIdx = args.indexOf('--install'); if (installIdx !== -1) { - const version = args[installIdx + 1]; + let version = args[installIdx + 1]; if (!version || version.startsWith('-')) { console.error(fail('Missing version argument for --install')); console.error(' Usage: ccs cliproxy --install '); - console.error(' Example: ccs cliproxy --install 6.5.53'); + console.error(' Example: ccs cliproxy --install 6.6.80-0'); process.exit(1); } + // Strip leading 'v' prefix and whitespace (user may type " v6.6.80-0 ") + version = version.trim().replace(/^v/, ''); await handleInstallVersion(version, verbose); return; } diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 25262265..7e53f4c3 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -19,6 +19,35 @@ export const RESERVED_PROFILE_NAMES = [ export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; +/** + * Windows reserved device names - cannot be used as filenames on Windows. + * Case-insensitive on Windows filesystem. + */ +export const WINDOWS_RESERVED_NAMES = [ + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'COM1', + 'COM2', + 'COM3', + 'COM4', + 'COM5', + 'COM6', + 'COM7', + 'COM8', + 'COM9', + 'LPT1', + 'LPT2', + 'LPT3', + 'LPT4', + 'LPT5', + 'LPT6', + 'LPT7', + 'LPT8', + 'LPT9', +] as const; + /** * Check if a name is reserved and cannot be used for user profiles. * @param name - The profile name to check @@ -28,6 +57,18 @@ export function isReservedName(name: string): boolean { return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName); } +/** + * Check if a name is a Windows reserved device name. + * These cause filesystem errors on Windows systems. + * @param name - The name to check + * @returns true if the name is a Windows reserved name + */ +export function isWindowsReservedName(name: string): boolean { + return WINDOWS_RESERVED_NAMES.includes( + name.toUpperCase() as (typeof WINDOWS_RESERVED_NAMES)[number] + ); +} + /** * Validate a profile name and throw if reserved. * @param name - The profile name to validate From 001a1890ca85c10d38541d1e95d5b75e7f67f139 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 5 Jan 2026 05:02:06 +0000 Subject: [PATCH 9/9] chore(release): 7.13.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a3800ff5..75aa643e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.13.0-dev.3", + "version": "7.13.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",