diff --git a/package.json b/package.json index a9ba3ef1..4555ff5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.0", + "version": "7.34.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 3ebdff0a..91ee2692 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -162,6 +162,37 @@ export const PROVIDER_TYPE_VALUES: Record = { claude: ['claude', 'anthropic'], }; +/** + * Maps CCS provider names to CLIProxyAPI callback provider names + * Used when submitting OAuth callbacks to CLIProxyAPI management endpoint + */ +export const CLIPROXY_CALLBACK_PROVIDER_MAP: Record = { + gemini: 'gemini', + codex: 'codex', + agy: 'antigravity', + kiro: 'kiro', + ghcp: 'copilot', + claude: 'anthropic', + qwen: 'qwen', + iflow: 'iflow', +}; + +/** + * Maps CCS provider names to CLIProxyAPI auth-url endpoint prefixes. + * Used for GET /v0/management/${prefix}-auth-url endpoints. + * These differ from callback names for some providers (e.g., gemini-cli vs gemini). + */ +export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record = { + gemini: 'gemini-cli', + codex: 'codex', + agy: 'antigravity', + kiro: 'kiro', + ghcp: 'github', + claude: 'anthropic', + qwen: 'qwen', + iflow: 'iflow', +}; + /** * Get OAuth config for provider */ @@ -188,4 +219,6 @@ export interface OAuthOptions { noIncognito?: boolean; /** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */ import?: boolean; + /** Enable paste-callback mode: show auth URL and prompt for callback paste */ + pasteCallback?: boolean; } diff --git a/src/cliproxy/auth/environment-detector.ts b/src/cliproxy/auth/environment-detector.ts index 287f0076..8a484019 100644 --- a/src/cliproxy/auth/environment-detector.ts +++ b/src/cliproxy/auth/environment-detector.ts @@ -107,14 +107,20 @@ export function getTimeoutTroubleshooting( lines.push(''); lines.push('TROUBLESHOOTING:'); lines.push(' 1. Check browser completed auth (should show success page)'); + lines.push(' 2. Complete OAuth in the same browser session that opened'); if (port) { - lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`); - lines.push(` 3. Try: ccs ${provider} --auth --verbose`); + lines.push(` 3. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`); + lines.push(` 4. Try: ccs ${provider} --auth --verbose`); } else { - lines.push(` 2. Try: ccs ${provider} --auth --verbose`); + lines.push(` 3. Try: ccs ${provider} --auth --verbose`); } + lines.push(''); + lines.push('If you copied the URL to another browser:'); + lines.push(' - OAuth sessions expire after ~10 minutes'); + lines.push(' - Callback must reach localhost (same machine only)'); + return lines; } diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index f4944e16..c9b7de05 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -11,7 +11,7 @@ */ import * as fs from 'fs'; -import { fail, info, warn, color } from '../../utils/ui'; +import { fail, info, warn, color, ok } from '../../utils/ui'; import { ensureCLIProxyBinary } from '../binary-manager'; import { generateConfig } from '../config-generator'; import { CLIProxyProvider } from '../types'; @@ -27,11 +27,18 @@ import { enhancedPreflightOAuthCheck, OAUTH_CALLBACK_PORTS as OAUTH_PORTS, } from '../../management/oauth-port-diagnostics'; -import { OAuthOptions, OAUTH_CALLBACK_PORTS, getOAuthConfig } from './auth-types'; +import { + OAuthOptions, + OAUTH_CALLBACK_PORTS, + getOAuthConfig, + ProviderOAuthConfig, + CLIPROXY_CALLBACK_PROVIDER_MAP, +} from './auth-types'; import { isHeadlessEnvironment, killProcessOnPort, showStep } from './environment-detector'; import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from './token-manager'; import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; +import { getProxyTarget, buildProxyUrl, buildManagementHeaders } from '../proxy-target-resolver'; /** * Prompt user to add another account @@ -186,6 +193,154 @@ async function prepareBinary( } } +/** + * Handle paste-callback mode: show auth URL, prompt for callback paste + * Uses proxy target resolver to connect to correct CLIProxyAPI instance (local or remote) + */ +async function handlePasteCallbackMode( + provider: CLIProxyProvider, + oauthConfig: ProviderOAuthConfig, + verbose: boolean, + tokenDir: string, + nickname?: string +): Promise { + // Resolve CLIProxyAPI target (local or remote based on config) + const target = getProxyTarget(); + // OAuth state timeout (10 minutes, matches CLIProxyAPI state TTL) + const OAUTH_STATE_TIMEOUT_MS = 10 * 60 * 1000; + + console.log(''); + console.log(info(`Starting ${oauthConfig.displayName} OAuth (paste-callback mode)...`)); + + try { + // Request auth URL from CLIProxyAPI + // Note: Uses /oauth/${provider}/start endpoint (different from web-server routes which use + // /v0/management/${provider}-auth-url). Both start OAuth flows but this endpoint is simpler + // for CLI paste-callback mode as it directly returns the auth URL without is_webui param. + const startResponse = await fetch(buildProxyUrl(target, `/oauth/${provider}/start`), { + method: 'POST', + headers: buildManagementHeaders(target, { 'Content-Type': 'application/json' }), + }); + + if (!startResponse.ok) { + console.log(fail('Failed to start OAuth flow')); + return null; + } + + const startData = (await startResponse.json()) as { + url?: string; + auth_url?: string; + status?: string; + }; + const authUrl = startData.url || startData.auth_url; + + if (!authUrl) { + console.log(fail('No authorization URL received')); + return null; + } + + // Display auth URL in box + console.log(''); + console.log(' ╔══════════════════════════════════════════════════════════════╗'); + console.log(' ║ Open this URL in any browser: ║'); + console.log(' ╚══════════════════════════════════════════════════════════════╝'); + console.log(''); + console.log(` ${authUrl}`); + console.log(''); + + // Prompt for callback URL + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const callbackUrl = await new Promise((resolve) => { + let resolved = false; + + rl.on('close', () => { + if (!resolved) { + resolved = true; + resolve(null); + } + }); + + console.log(info('After completing authentication, paste the callback URL here:')); + rl.question('> ', (answer) => { + resolved = true; + rl.close(); + resolve(answer.trim() || null); + }); + + // Timeout after 10 minutes (match state TTL) + setTimeout(() => { + if (!resolved) { + resolved = true; + rl.close(); + console.log(''); + console.log(fail('Timed out waiting for callback URL (10 minutes)')); + resolve(null); + } + }, OAUTH_STATE_TIMEOUT_MS); + }); + + if (!callbackUrl) { + console.log(info('Cancelled')); + return null; + } + + // Validate callback URL + let code: string | undefined; + try { + const parsed = new URL(callbackUrl); + code = parsed.searchParams.get('code') || undefined; + } catch { + console.log(fail('Invalid URL format')); + return null; + } + + if (!code) { + console.log(fail('Invalid callback URL: missing code parameter')); + return null; + } + + // Submit callback to CLIProxyAPI + console.log(info('Submitting callback...')); + + const callbackProvider = CLIPROXY_CALLBACK_PROVIDER_MAP[provider] || provider; + + // Note: /oauth-callback is a CLIProxyAPI endpoint (not /v0/management prefix) + const callbackResponse = await fetch(buildProxyUrl(target, '/oauth-callback'), { + method: 'POST', + headers: buildManagementHeaders(target, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ + provider: callbackProvider, + redirect_url: callbackUrl, + }), + }); + + const callbackData = (await callbackResponse.json()) as { + status?: string; + error?: string; + }; + + if (!callbackResponse.ok || callbackData.status === 'error') { + console.log(fail(callbackData.error || 'OAuth callback failed')); + return null; + } + + console.log(ok('Authentication successful!')); + return registerAccountFromToken(provider, tokenDir, nickname); + } catch (error) { + if (verbose) { + console.log(fail(`Error: ${(error as Error).message}`)); + } else { + console.log(fail('OAuth failed. Use --verbose for details.')); + } + return null; + } +} + /** * Trigger OAuth flow for provider * Auto-detects headless environment and uses --no-browser flag accordingly @@ -203,6 +358,12 @@ export async function triggerOAuth( // Check for existing accounts const existingAccounts = getProviderAccounts(provider); + // Handle paste-callback mode + if (options.pasteCallback) { + const tokenDir = getProviderTokenDir(provider); + return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); + } + // For kiro/ghcp: require nickname if not provided (CLI only, not fromUI) if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) { const promptedNickname = await promptNickname(provider, existingAccounts); diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 64d8ee44..9f6fb39d 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -250,6 +250,11 @@ async function handleTokenNotFound( console.log(fail('Token not found after authentication')); console.log(''); console.log('The browser showed success but callback was not received.'); + console.log(''); + console.log('Common causes:'); + console.log(' 1. OAuth session timed out (sessions expire after ~10 minutes)'); + console.log(' 2. Callback server could not receive the redirect'); + console.log(' 3. Browser did not redirect to localhost properly'); if (process.platform === 'win32') { console.log(''); @@ -263,6 +268,11 @@ async function handleTokenNotFound( ); } + console.log(''); + console.log('If you copied the OAuth URL to a different browser:'); + console.log(' - Complete authentication within the timeout window'); + console.log(' - Ensure you are on the same machine (localhost callback)'); + console.log(' - Copy the entire URL including all parameters'); console.log(''); console.log(`Try: ccs ${provider} --auth --verbose`); return null; diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index c3876358..0d824da4 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -290,6 +290,7 @@ export async function execClaudeWithCLIProxy( // 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped) const forceAuth = argsWithoutProxy.includes('--auth'); + const pasteCallback = argsWithoutProxy.includes('--paste-callback'); const forceHeadless = argsWithoutProxy.includes('--headless'); const forceLogout = argsWithoutProxy.includes('--logout'); const forceConfig = argsWithoutProxy.includes('--config'); @@ -521,6 +522,7 @@ export async function execClaudeWithCLIProxy( ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), + ...(pasteCallback ? { pasteCallback: true } : {}), }); if (!authSuccess) { throw new Error(`Authentication required for ${providerConfig.displayName}`); @@ -963,6 +965,7 @@ export async function execClaudeWithCLIProxy( // Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig() const ccsFlags = [ '--auth', + '--paste-callback', '--headless', '--logout', '--config', diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts index c9d5cd83..a9bde5d2 100644 --- a/src/cliproxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy-target-resolver.ts @@ -13,6 +13,7 @@ import { normalizeProtocol, validateRemotePort, } from './config-generator'; +import { getEffectiveManagementSecret } from './auth-token-manager'; /** Resolved proxy target for making requests */ export interface ProxyTarget { @@ -63,9 +64,11 @@ export function getProxyTarget(): ProxyTarget { }; } + const localPort = config?.local?.port ?? CLIPROXY_DEFAULT_PORT; + return { host: '127.0.0.1', - port: config?.local?.port ?? CLIPROXY_DEFAULT_PORT, + port: localPort, protocol: 'http', isRemote: false, }; @@ -108,7 +111,8 @@ export function buildProxyHeaders( /** * Build request headers for management API endpoints (/v0/management/*). - * Uses management_key if configured, otherwise falls back to authToken. + * For remote targets: uses management_key, falls back to authToken. + * For local targets: uses the effective management secret from CCS config. * * @param target Resolved proxy target * @param additionalHeaders Extra headers to merge @@ -122,8 +126,11 @@ export function buildManagementHeaders( ...additionalHeaders, }; - // Use management key for management API, fallback to authToken - const authKey = target.managementKey ?? target.authToken; + // Remote: use configured management key or auth token + // Local: use CCS management secret (default: 'ccs') + const authKey = target.isRemote + ? (target.managementKey ?? target.authToken) + : getEffectiveManagementSecret(); if (authKey) { headers['Authorization'] = `Bearer ${authKey}`; diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 9343d436..878dbe2d 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -253,13 +253,16 @@ export async function fetchCodexQuota( } if (response.status === 403) { + // 403 = account lacks API access (not same as quota exhausted) + // Keep success=false with isForbidden flag for UI to show distinct "403" badge return { success: false, windows: [], planType: null, lastUpdated: Date.now(), - error: 'Quota access not available (free plan may not have quota API access)', + error: '403 Forbidden - No quota API access', accountId, + isForbidden: true, }; } diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 41203461..2e965707 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -407,12 +407,14 @@ async function fetchAvailableModels(accessToken: string, _projectId: string): Pr clearTimeout(timeoutId); if (response.status === 403) { + // 403 = account lacks Gemini Code Assist access (not same as quota exhausted) + // Keep success=false with isForbidden flag for UI to show distinct "403" badge return { success: false, models: [], lastUpdated: Date.now(), isForbidden: true, - error: 'Quota access forbidden for this account', + error: '403 Forbidden - No Gemini Code Assist access', }; } @@ -425,6 +427,15 @@ async function fetchAvailableModels(accessToken: string, _projectId: string): Pr }; } + if (response.status === 429) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: 'Rate limited - try again later', + }; + } + if (!response.ok) { return { success: false, diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index ae0ac8d3..f8759bbe 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -45,6 +45,8 @@ export interface CodexQuotaResult { accountId?: string; /** True if token is expired and needs re-authentication */ needsReauth?: boolean; + /** True if account lacks quota access (403) - displayed as 0% instead of error */ + isForbidden?: boolean; } /** diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 0d1bc261..ae632fb9 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -171,6 +171,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', ''], // Spacer ['ccs --auth', 'Authenticate only'], ['ccs --auth --add', 'Add another account'], + [ + 'ccs --paste-callback', + 'Show auth URL and prompt for callback paste (cross-browser)', + ], ['ccs --accounts', 'List all accounts'], ['ccs --use ', 'Switch to account'], ['ccs --config', 'Change model (agy, gemini)'], diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index d05003c0..ad88b0bb 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -12,6 +12,7 @@ import * as os from 'os'; import { info, warn } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getCcsDir } from '../config-manager'; +import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; // Hook file name const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; @@ -123,11 +124,27 @@ export function ensureHookConfig(): boolean { if (hooks?.PreToolUse) { const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => { const hook = h as Record; - return hook.matcher === 'WebSearch'; + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + const command = hookArray?.[0]?.command; + if (typeof command !== 'string') return false; + + const normalized = command + .replace(/\\/g, '/') // Windows backslashes + .replace(/\/+/g, '/'); // Collapse multiple slashes + return normalized.includes('.ccs/hooks/websearch-transformer'); }); if (webSearchHookIndex !== -1) { - // Hook exists - check if it needs updating + // Hook exists - first clean up any duplicates + // INVARIANT: webSearchHookIndex remains valid after deduplication because: + // - findIndex() returns the FIRST matching CCS hook + // - deduplicateCcsHooks() keeps the FIRST CCS hook and removes subsequent duplicates + // This means the index always points to the preserved hook. + const hadDuplicates = deduplicateCcsHooks(settings); + + // Then check if it needs updating const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; const existingHooks = existingHook.hooks as Array>; const currentHookConfig = getWebSearchHookConfig(); @@ -147,10 +164,13 @@ export function ensureHookConfig(): boolean { needsUpdate = true; } - if (needsUpdate) { + // Combine into single write if either changed + if (hadDuplicates || needsUpdate) { fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(settings, null, 2), 'utf8'); if (process.env.CCS_DEBUG) { - console.error(info('Updated WebSearch hook config in settings.json')); + if (hadDuplicates) + console.error(info('Removed duplicate WebSearch hooks from settings.json')); + if (needsUpdate) console.error(info('Updated WebSearch hook config in settings.json')); } } return true; @@ -170,6 +190,12 @@ export function ensureHookConfig(): boolean { settingsHooks.PreToolUse = []; } + // Remove any existing CCS hooks first to prevent duplicates + settingsHooks.PreToolUse = settingsHooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + return !isCcsWebSearchHook(hook); + }); + // Add our hook config const preToolUseHooks = hookConfig.PreToolUse as unknown[]; settingsHooks.PreToolUse.push(...preToolUseHooks); @@ -229,7 +255,11 @@ export function removeHookConfig(): boolean { if (!hookArray?.[0]?.command) return true; // Keep malformed entries const command = hookArray[0].command as string; - return !command.includes('.ccs/hooks/websearch-transformer'); // Remove if CCS hook + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command + .replace(/\\/g, '/') // Windows backslashes + .replace(/\/+/g, '/'); // Collapse multiple slashes + return !normalizedCommand.includes('.ccs/hooks/websearch-transformer'); // Remove if CCS hook }); if (hooks.PreToolUse.length === originalLength) { diff --git a/src/utils/websearch/hook-utils.ts b/src/utils/websearch/hook-utils.ts new file mode 100644 index 00000000..7a551380 --- /dev/null +++ b/src/utils/websearch/hook-utils.ts @@ -0,0 +1,52 @@ +/** + * WebSearch Hook Utilities + * + * Shared helper functions for WebSearch hook detection and deduplication. + * + * @module utils/websearch/hook-utils + */ + +/** + * Check if a hook entry is a CCS WebSearch hook + * Normalizes path separators for cross-platform matching (Windows uses backslashes) + */ +export function isCcsWebSearchHook(hook: Record): boolean { + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + if (!hookArray?.[0]?.command) return false; + + const command = hookArray[0].command; + if (typeof command !== 'string') return false; + + // Normalize path separators for cross-platform matching + const normalizedCommand = command + .replace(/\\/g, '/') // Windows backslashes + .replace(/\/+/g, '/'); // Collapse multiple slashes + return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); +} + +/** + * Remove duplicate CCS WebSearch hooks from settings, keeping only the first one + * Returns true if duplicates were removed + */ +export function deduplicateCcsHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + let foundFirst = false; + const originalLength = hooks.PreToolUse.length; + + hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks + + if (!foundFirst) { + foundFirst = true; + return true; // Keep first CCS hook + } + return false; // Remove subsequent duplicates + }); + + return hooks.PreToolUse.length < originalLength; +} diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 214184da..5a98b00c 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -14,6 +14,7 @@ import { getWebSearchHookConfig, getHookPath } from './hook-config'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { removeHookConfig } from './hook-config'; import { getCcsDir } from '../config-manager'; +import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; // Valid profile name pattern (alphanumeric, dash, underscore only) const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/; @@ -33,14 +34,7 @@ function hasCcsHook(settings: Record): boolean { if (!hooks?.PreToolUse) return false; return hooks.PreToolUse.some((h: unknown) => { - const hook = h as Record; - if (hook.matcher !== 'WebSearch') return false; - - const hookArray = hook.hooks as Array> | undefined; - if (!hookArray?.[0]?.command) return false; - - const command = hookArray[0].command as string; - return command.includes('.ccs/hooks/websearch-transformer'); + return isCcsWebSearchHook(h as Record); }); } @@ -126,6 +120,16 @@ export function ensureProfileHooks(profileName: string): boolean { // Check if CCS hook already present if (hasCcsHook(settings)) { + // Clean up any duplicates that may have accumulated (Windows path bug fix) + const hadDuplicates = deduplicateCcsHooks(settings); + if (hadDuplicates) { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); + if (process.env.CCS_DEBUG) { + console.error( + info(`Removed duplicate WebSearch hooks from ${profileName}.settings.json`) + ); + } + } // Update timeout if needed return updateHookTimeoutIfNeeded(settings, settingsPath); } @@ -188,8 +192,13 @@ function updateHookTimeoutIfNeeded( const hookArray = hook.hooks as Array>; if (!hookArray?.[0]?.command) continue; - const command = hookArray[0].command as string; - if (!command.includes('.ccs/hooks/websearch-transformer')) continue; + const command = hookArray[0].command; + if (typeof command !== 'string') continue; + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command + .replace(/\\/g, '/') // Windows backslashes + .replace(/\/+/g, '/'); // Collapse multiple slashes + if (!normalizedCommand.includes('.ccs/hooks/websearch-transformer')) continue; // Found CCS hook - check if needs update if (hookArray[0].command !== expectedCommand) { diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 51fd523c..fd64547c 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -32,7 +32,11 @@ export interface ServerInstance { export async function startServer(options: ServerOptions): Promise { const app = express(); const server = http.createServer(app); - const wss = new WebSocketServer({ server }); + const wss = new WebSocketServer({ + server, + maxPayload: 1024 * 1024, // 1MB hard limit to prevent DoS + perMessageDeflate: false, // Prevent zip bomb attacks + }); // JSON body parsing with error handler for malformed JSON app.use(express.json()); diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index 4bb21aee..cb8aa98a 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -70,10 +70,12 @@ export interface ParserOptions { * Returns null for non-assistant entries or entries without usage data */ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null { - if (!line.trim()) return null; + // Strip UTF-8 BOM if present (can occur on first line of some files) + const cleanLine = line.replace(/^\uFEFF/, '').trim(); + if (!cleanLine) return null; try { - const entry = JSON.parse(line); + const entry = JSON.parse(cleanLine); // Only process assistant entries with usage data if (entry.type !== 'assistant') return null; diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 295d7070..302323ba 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -1,7 +1,3 @@ -/** - * CLIProxy Auth Routes - Authentication and account management for CLIProxy providers - */ - import { Router, Request, Response } from 'express'; import { getAllAuthStatus, @@ -29,11 +25,19 @@ import { PROVIDERS_WITHOUT_EMAIL, validateNickname, } from '../../cliproxy/account-manager'; -import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import { + getProxyTarget, + buildProxyUrl, + buildManagementHeaders, +} from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; import { getProviderTokenDir } from '../../cliproxy/auth/token-manager'; +import { + CLIPROXY_CALLBACK_PROVIDER_MAP, + CLIPROXY_AUTH_URL_PROVIDER_MAP, +} from '../../cliproxy/auth/auth-types'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; @@ -532,4 +536,177 @@ router.post('/kiro/import', async (_req: Request, res: Response): Promise } }); +// ==================== Manual Callback Submission ==================== + +/** + * POST /api/cliproxy/auth/:provider/start-url - Start OAuth and return auth URL immediately + * Unlike /start which blocks until completion, this returns the URL for manual callback flow + */ +router.post('/:provider/start-url', async (req: Request, res: Response): Promise => { + const { provider } = req.params; + + // Check remote mode + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ error: 'Manual OAuth flow not available in remote mode' }); + return; + } + + // Validate provider + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + try { + const authUrlProvider = + CLIPROXY_AUTH_URL_PROVIDER_MAP[provider as CLIProxyProvider] || provider; + + // Call CLIProxyAPI to start OAuth and get auth URL + // CLIProxyAPI management routes are under /v0/management prefix + const response = await fetch( + buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true`), + { headers: buildManagementHeaders(target) } + ); + + if (!response.ok) { + const error = await response.text(); + res.status(response.status).json({ error: error || 'Failed to start OAuth' }); + return; + } + + const data = (await response.json()) as { url?: string; auth_url?: string; state?: string }; + const authUrl = data.url || data.auth_url; + + if (!authUrl) { + res.status(500).json({ error: 'No authorization URL received from CLIProxyAPI' }); + return; + } + + res.json({ + success: true, + authUrl, + state: data.state, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to start OAuth'; + res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` }); + } +}); + +/** + * GET /api/cliproxy/auth/:provider/status - Poll OAuth status + * Checks if OAuth has completed for the given state + */ +router.get('/:provider/status', async (req: Request, res: Response): Promise => { + const { provider } = req.params; + const { state } = req.query; + + if (!state || typeof state !== 'string') { + res.status(400).json({ error: 'state query parameter required' }); + return; + } + + // Validate provider + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + try { + const target = getProxyTarget(); + + // CLIProxyAPI management routes are under /v0/management prefix + const response = await fetch( + buildProxyUrl(target, `/v0/management/get-auth-status?state=${encodeURIComponent(state)}`), + { headers: buildManagementHeaders(target) } + ); + const data = (await response.json()) as { status?: string; error?: string }; + + res.json(data); + } catch { + res.status(503).json({ status: 'error', error: 'CLIProxyAPI not reachable' }); + } +}); + +/** + * Parse OAuth callback URL to extract code and state parameters. + * @param url - The callback URL to parse + * @returns Parsed components (code, state) or empty object on failure + */ +function parseCallbackUrl(url: string): { code?: string; state?: string } { + try { + const parsed = new URL(url); + return { + code: parsed.searchParams.get('code') || undefined, + state: parsed.searchParams.get('state') || undefined, + }; + } catch { + return {}; + } +} + +/** + * POST /api/cliproxy/auth/:provider/submit-callback - Submit OAuth callback URL manually + * For cross-browser OAuth flows where callback cannot redirect directly + */ +router.post('/:provider/submit-callback', async (req: Request, res: Response): Promise => { + const { provider } = req.params; + const { redirectUrl } = req.body; + + // Check remote mode + const target = getProxyTarget(); + if (target.isRemote) { + res.status(501).json({ error: 'Manual callback not available in remote mode' }); + return; + } + + // Validate provider + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + // Validate redirectUrl + if (!redirectUrl || typeof redirectUrl !== 'string') { + res.status(400).json({ error: 'redirectUrl is required' }); + return; + } + + const parsed = parseCallbackUrl(redirectUrl); + if (!parsed.code) { + res.status(400).json({ error: 'Invalid callback URL: missing code parameter' }); + return; + } + + try { + const callbackProvider = + CLIPROXY_CALLBACK_PROVIDER_MAP[provider as CLIProxyProvider] || provider; + + // Forward to CLIProxyAPI /oauth-callback endpoint (under /v0/management prefix) + const response = await fetch(buildProxyUrl(target, '/v0/management/oauth-callback'), { + method: 'POST', + headers: buildManagementHeaders(target, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ + provider: callbackProvider, + redirect_url: redirectUrl, + }), + }); + + const data = (await response.json()) as { status?: string; error?: string }; + + if (!response.ok || data.status === 'error') { + res.status(response.status >= 400 ? response.status : 400).json({ + error: data.error || 'OAuth callback failed', + }); + return; + } + + res.json({ success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to submit callback'; + res.status(503).json({ error: `CLIProxyAPI not reachable: ${message}` }); + } +}); + export default router; diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index c663d518..5fa00305 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -133,6 +133,16 @@ describe('parseUsageEntry', () => { expect(parseUsageEntry('not json at all', '/test')).toBeNull(); }); + test('strips UTF-8 BOM from line before parsing', () => { + // UTF-8 BOM character (\uFEFF) can appear at start of files + const bomEntry = '\uFEFF' + VALID_ASSISTANT_ENTRY; + const result = parseUsageEntry(bomEntry, '/test'); + + expect(result).not.toBeNull(); + expect(result!.model).toBe('claude-sonnet-4-5'); + expect(result!.inputTokens).toBe(1000); + }); + test('includes project path in result', () => { const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path'); expect(result!.projectPath).toBe('/custom/project/path'); diff --git a/tests/unit/utils/websearch/hook-utils.test.ts b/tests/unit/utils/websearch/hook-utils.test.ts new file mode 100644 index 00000000..a3f7bd3e --- /dev/null +++ b/tests/unit/utils/websearch/hook-utils.test.ts @@ -0,0 +1,292 @@ +import { expect, test, describe } from 'bun:test'; +import { isCcsWebSearchHook, deduplicateCcsHooks } from '../../../../src/utils/websearch/hook-utils'; + +describe('isCcsWebSearchHook', () => { + test('Returns true for CCS hook with forward slashes (Unix path)', () => { + const hook = { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(true); + }); + + test('Returns true for CCS hook with backslashes (Windows path)', () => { + const hook = { + matcher: 'WebSearch', + hooks: [ + { + command: 'node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js', + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(true); + }); + + test('Returns true for mixed path separators', () => { + const hook = { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /home/user\\.ccs/hooks\\websearch-transformer/index.js', + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(true); + }); + + test('Returns true for path with double slashes (normalization)', () => { + const hook = { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /home/user//.ccs//hooks/websearch-transformer/index.js', + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(true); + }); + + test('Returns false for non-WebSearch matcher', () => { + const hook = { + matcher: 'SomethingElse', + hooks: [ + { + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test('Returns false for WebSearch with non-CCS hook command', () => { + const hook = { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /some/other/path/custom-hook.js', + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test('Returns false when hooks array is missing', () => { + const hook = { + matcher: 'WebSearch', + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test('Returns false when hooks array is empty', () => { + const hook = { + matcher: 'WebSearch', + hooks: [], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test('Returns false when command is missing', () => { + const hook = { + matcher: 'WebSearch', + hooks: [{}], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test('Returns false when command is not a string', () => { + const hook = { + matcher: 'WebSearch', + hooks: [ + { + command: 123, + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); +}); + +describe('deduplicateCcsHooks', () => { + test('No-op when 0 CCS hooks (returns false)', () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: 'SomeOtherMatcher', + hooks: [{ command: 'other-command' }], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + expect(settings.hooks.PreToolUse).toHaveLength(1); + }); + + test('No-op when 1 CCS hook (returns false)', () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', + }, + ], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + expect(settings.hooks.PreToolUse).toHaveLength(1); + }); + + test('Removes duplicates when 2+ CCS hooks (returns true, keeps first)', () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', + }, + ], + }, + { + matcher: 'WebSearch', + hooks: [ + { + command: 'node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js', + }, + ], + }, + { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /another/path/.ccs/hooks/websearch-transformer/index.js', + }, + ], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(true); + expect(settings.hooks.PreToolUse).toHaveLength(1); + expect(settings.hooks.PreToolUse[0]).toEqual({ + matcher: 'WebSearch', + hooks: [ + { + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', + }, + ], + }); + }); + + test('Preserves non-CCS hooks in array', () => { + const nonCcsHook = { + matcher: 'SomeOtherMatcher', + hooks: [{ command: 'other-command' }], + }; + const settings = { + hooks: { + PreToolUse: [ + nonCcsHook, + { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', + }, + ], + }, + { + matcher: 'WebSearch', + hooks: [ + { + command: 'node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js', + }, + ], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(true); + expect(settings.hooks.PreToolUse).toHaveLength(2); + expect(settings.hooks.PreToolUse[0]).toEqual(nonCcsHook); + }); + + test('Returns false when hooks is undefined', () => { + const settings = {}; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + }); + + test('Returns false when PreToolUse is undefined', () => { + const settings = { + hooks: {}, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + }); + + test('Handles multiple non-CCS hooks with duplicates', () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: 'OtherMatcher1', + hooks: [{ command: 'command1' }], + }, + { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /path1/.ccs/hooks/websearch-transformer/index.js', + }, + ], + }, + { + matcher: 'OtherMatcher2', + hooks: [{ command: 'command2' }], + }, + { + matcher: 'WebSearch', + hooks: [ + { + command: 'node /path2/.ccs/hooks/websearch-transformer/index.js', + }, + ], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(true); + expect(settings.hooks.PreToolUse).toHaveLength(3); + // First and third should be non-CCS hooks, second should be the first CCS hook + expect(settings.hooks.PreToolUse[0].matcher).toBe('OtherMatcher1'); + expect(settings.hooks.PreToolUse[1].matcher).toBe('WebSearch'); + expect(settings.hooks.PreToolUse[2].matcher).toBe('OtherMatcher2'); + }); + + test('Edge case: Empty PreToolUse array', () => { + const settings = { + hooks: { + PreToolUse: [], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + expect(settings.hooks.PreToolUse).toHaveLength(0); + }); +}); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 0290bda3..70f447cd 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -1,11 +1,12 @@ /** * Add Account Dialog Component - * Triggers OAuth flow server-side to add another account to a provider - * Always applies default preset to ensure required env vars are set - * For Kiro: Also shows "Import from IDE" option as fallback + * Uses /start-url to get OAuth URL + polls for completion via management API. + * Does NOT call /start (which spawns a CLIProxy binary and kills running instances). + * Shows auth URL + callback paste field. Polling auto-closes on success. + * For Kiro: Also shows "Import from IDE" option. */ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Dialog, DialogContent, @@ -16,8 +17,9 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Loader2, ExternalLink, User, Download } from 'lucide-react'; -import { useStartAuth, useKiroImport, useCancelAuth } from '@/hooks/use-cliproxy'; +import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react'; +import { useKiroImport } from '@/hooks/use-cliproxy'; +import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { applyDefaultPreset } from '@/lib/preset-utils'; import { toast } from 'sonner'; @@ -38,55 +40,84 @@ export function AddAccountDialog({ isFirstAccount = false, }: AddAccountDialogProps) { const [nickname, setNickname] = useState(''); - const startAuthMutation = useStartAuth(); + const [callbackUrl, setCallbackUrl] = useState(''); + const [copied, setCopied] = useState(false); + const wasAuthenticatingRef = useRef(false); + const authFlow = useCliproxyAuthFlow(); const kiroImportMutation = useKiroImport(); - const cancelAuthMutation = useCancelAuth(); const isKiro = provider === 'kiro'; - const isPending = startAuthMutation.isPending || kiroImportMutation.isPending; + const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; - const handleCancel = () => { - if (isPending) { - cancelAuthMutation.mutate(provider); - } + const resetAndClose = () => { setNickname(''); + setCallbackUrl(''); + setCopied(false); + wasAuthenticatingRef.current = false; onClose(); }; - const handleStartAuth = () => { - startAuthMutation.mutate( - { provider, nickname: nickname.trim() || undefined }, - { - onSuccess: async () => { - // Always apply default preset to ensure BASE_URL and AUTH_TOKEN are set - const result = await applyDefaultPreset(provider); - if (result.success && result.presetName) { - if (isFirstAccount) { + // When authFlow completes successfully (polling detected success), apply preset and close + useEffect(() => { + if (!authFlow.isAuthenticating && !authFlow.error && authFlow.provider === null && open) { + if (wasAuthenticatingRef.current) { + wasAuthenticatingRef.current = false; + const applyPresetAndClose = async () => { + try { + const result = await applyDefaultPreset(provider); + if (result.success && result.presetName && isFirstAccount) { toast.success(`Applied "${result.presetName}" preset`); } - // Silent success for non-first accounts - preset ensures required vars exist - } else if (!result.success) { - toast.warning( - 'Account added, but failed to apply default preset. You may need to configure settings manually.' - ); + } catch { + // Continue to close dialog even if preset apply fails } - setNickname(''); - onClose(); - }, + resetAndClose(); + }; + applyPresetAndClose(); } - ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authFlow.isAuthenticating, authFlow.error, authFlow.provider]); + + const handleCancel = () => { + // Always cancel authFlow (handles its own no-op if not active) + authFlow.cancelAuth(); + resetAndClose(); + }; + + const handleCopyUrl = async () => { + if (authFlow.authUrl) { + await navigator.clipboard.writeText(authFlow.authUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleSubmitCallback = () => { + if (callbackUrl.trim()) { + authFlow.submitCallback(callbackUrl.trim()); + } + }; + + /** + * Authenticate via /start-url + polling only. + * Does NOT call /start (which spawns a local CLIProxy binary that kills running instances). + * /start-url uses the management API to get auth URL, then polls for completion. + */ + const handleAuthenticate = () => { + wasAuthenticatingRef.current = true; + authFlow.startAuth(provider, { nickname: nickname.trim() || undefined }); }; const handleKiroImport = () => { + wasAuthenticatingRef.current = true; kiroImportMutation.mutate(undefined, { onSuccess: async () => { - // Always apply default preset for Kiro as well const result = await applyDefaultPreset('kiro'); if (result.success && result.presetName && isFirstAccount) { toast.success(`Applied "${result.presetName}" preset`); } - setNickname(''); - onClose(); + resetAndClose(); }, }); }; @@ -97,42 +128,158 @@ export function AddAccountDialog({ } }; + const showAuthUI = authFlow.isAuthenticating; + return ( - + { + // Prevent accidental close by clicking outside during auth + if (showAuthUI) e.preventDefault(); + }} + > Add {displayName} Account {isKiro ? 'Authenticate via browser or import an existing token from Kiro IDE.' - : 'Click the button below to authenticate a new account. A browser window will open for OAuth.'} + : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'}
-
- -
- - setNickname(e.target.value)} - placeholder="e.g., work, personal" - disabled={isPending} - className="flex-1" - /> + {/* Nickname input - only show before auth starts */} + {!showAuthUI && ( +
+ +
+ + setNickname(e.target.value)} + placeholder="e.g., work, personal" + disabled={isPending} + className="flex-1" + /> +
+

+ A friendly name to identify this account. Auto-generated from email if left empty. +

-

- A friendly name to identify this account. Auto-generated from email if left empty. -

-
+ )} + {/* Unified auth state: spinner + auth URL + callback paste */} + {showAuthUI && ( +
+ {/* Spinner */} +
+

+ + Waiting for authentication... +

+

+ Complete the authentication in your browser. This dialog closes automatically. +

+
+ + {/* Error from /start-url - fallback URL not available */} + {authFlow.error && !authFlow.authUrl && ( +

{authFlow.error}

+ )} + + {/* Auth URL section - appears once /start-url returns */} + {authFlow.authUrl && ( +
+
+ +
+

+ {authFlow.authUrl} +

+
+ + +
+
+
+ + {/* Callback paste field */} +
+ + setCallbackUrl(e.target.value)} + placeholder="Paste the redirect URL here..." + className="font-mono text-xs" + /> + +
+
+ )} +
+ )} + + {/* Kiro import loading */} + {kiroImportMutation.isPending && ( +

+ + Importing token from Kiro IDE... +

+ )} + + {/* Action buttons */}
- {isKiro && ( + {isKiro && !showAuthUI && ( )} - + {!showAuthUI && ( + + )}
- - {startAuthMutation.isPending && ( -

- Complete the OAuth flow in your browser... -

- )} - {kiroImportMutation.isPending && ( -

- Importing token from Kiro IDE... -

- )}
diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index bc9c46af..d7a7663a 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -1,6 +1,6 @@ /** * OAuth Auth Flow Hook for CLIProxy - * Triggers backend-managed OAuth authentication flows + * Supports both auto-callback and manual callback flows */ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; @@ -13,92 +13,261 @@ interface AuthFlowState { provider: string | null; isAuthenticating: boolean; error: string | null; + /** Authorization URL for manual callback flow */ + authUrl: string | null; + /** OAuth state parameter for polling */ + oauthState: string | null; + /** Whether callback is being submitted */ + isSubmittingCallback: boolean; } +interface StartAuthOptions { + nickname?: string; +} + +/** Polling interval for OAuth status check (3 seconds) */ +const POLL_INTERVAL = 3000; +/** Maximum polling duration (5 minutes) */ +const MAX_POLL_DURATION = 5 * 60 * 1000; + export function useCliproxyAuthFlow() { const [state, setState] = useState({ provider: null, isAuthenticating: false, error: null, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, }); const abortControllerRef = useRef(null); + const pollIntervalRef = useRef | null>(null); + const pollStartRef = useRef(0); const queryClient = useQueryClient(); + // Clear polling + const stopPolling = useCallback(() => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + }, []); + // Cleanup on unmount useEffect(() => { return () => { abortControllerRef.current?.abort(); + stopPolling(); }; - }, []); + }, [stopPolling]); + + // Poll OAuth status + const pollStatus = useCallback( + async (provider: string, oauthState: string) => { + // Check timeout + if (Date.now() - pollStartRef.current > MAX_POLL_DURATION) { + stopPolling(); + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: 'Authentication timed out. Please try again.', + })); + return; + } + + try { + const response = await fetch( + `/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}` + ); + const data = await response.json(); + + if (data.status === 'ok') { + stopPolling(); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + toast.success(`${provider} authentication successful`); + setState({ + provider: null, + isAuthenticating: false, + error: null, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, + }); + } else if (data.status === 'error') { + stopPolling(); + const errorMsg = data.error || 'Authentication failed'; + toast.error(errorMsg); + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: errorMsg, + })); + } + // status === 'pending' means continue polling + } catch { + // Network error - continue polling + } + }, + [queryClient, stopPolling] + ); const startAuth = useCallback( - async (provider: string) => { + async (provider: string, options?: StartAuthOptions) => { if (!isValidProvider(provider)) { setState({ provider: null, isAuthenticating: false, error: `Unknown provider: ${provider}`, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, }); return; } // Abort any in-progress auth abortControllerRef.current?.abort(); - abortControllerRef.current = new AbortController(); + stopPolling(); - setState({ provider, isAuthenticating: true, error: null }); + // Create fresh controller and capture locally to avoid race with cancelAuth + const controller = new AbortController(); + abortControllerRef.current = controller; + + setState({ + provider, + isAuthenticating: true, + error: null, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, + }); try { - // POST to CCS auth endpoint - backend opens browser and waits - const response = await fetch(`/api/cliproxy/auth/${provider}/start`, { + // Call start-url to get auth URL immediately (non-blocking) + const response = await fetch(`/api/cliproxy/auth/${provider}/start-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - signal: abortControllerRef.current.signal, + body: JSON.stringify({ nickname: options?.nickname }), + signal: controller.signal, }); const data = await response.json(); - if (response.ok && data.success) { - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); - queryClient.invalidateQueries({ queryKey: ['account-quota'] }); - toast.success(`${provider} authentication successful`); - setState({ provider: null, isAuthenticating: false, error: null }); - } else { - throw new Error(data.error || 'Authentication failed'); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to start OAuth'); + } + + // Update state with auth URL + setState((prev) => ({ + ...prev, + authUrl: data.authUrl, + oauthState: data.state, + })); + + // Auto-open auth URL in new browser tab (fallback URL still shown in dialog) + if (data.authUrl) { + window.open(data.authUrl, '_blank'); + } + + // Start polling for completion + if (data.state) { + pollStartRef.current = Date.now(); + pollIntervalRef.current = setInterval(() => { + pollStatus(provider, data.state); + }, POLL_INTERVAL); } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - setState({ provider: null, isAuthenticating: false, error: null }); + setState({ + provider: null, + isAuthenticating: false, + error: null, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, + }); return; } const message = error instanceof Error ? error.message : 'Authentication failed'; toast.error(message); - setState({ provider: null, isAuthenticating: false, error: message }); + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: message, + })); } }, - [queryClient] + [pollStatus, stopPolling] ); const cancelAuth = useCallback(() => { const currentProvider = state.provider; abortControllerRef.current?.abort(); - setState({ provider: null, isAuthenticating: false, error: null }); + stopPolling(); + setState({ + provider: null, + isAuthenticating: false, + error: null, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, + }); // Also cancel on backend if (currentProvider) { api.cliproxy.auth.cancel(currentProvider).catch(() => { // Ignore errors - session may have already completed }); } - }, [state.provider]); + }, [state.provider, stopPolling]); + + const submitCallback = useCallback( + async (redirectUrl: string) => { + if (!state.provider) return; + + setState((prev) => ({ ...prev, isSubmittingCallback: true, error: null })); + + try { + const response = await fetch(`/api/cliproxy/auth/${state.provider}/submit-callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ redirectUrl }), + }); + + const data = await response.json(); + + if (response.ok && data.success) { + stopPolling(); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + toast.success(`${state.provider} authentication successful`); + setState({ + provider: null, + isAuthenticating: false, + error: null, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, + }); + } else { + throw new Error(data.error || 'Callback submission failed'); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to submit callback'; + toast.error(message); + setState((prev) => ({ ...prev, isSubmittingCallback: false, error: message })); + } + }, + [state.provider, queryClient, stopPolling] + ); return useMemo( () => ({ ...state, startAuth, cancelAuth, + submitCallback, }), - [state, startAuth, cancelAuth] + [state, startAuth, cancelAuth, submitCallback] ); } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 51907b8a..a8d2bb87 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -180,6 +180,8 @@ export interface CodexQuotaResult { needsReauth?: boolean; /** True if result was served from cache */ cached?: boolean; + /** True if account lacks quota access (403) - displayed as 0% instead of error */ + isForbidden?: boolean; } /** Gemini CLI bucket (grouped by model series) */