Merge pull request #430 from kaitranntt/dev

Release: v7.34.0
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-02 23:49:04 -05:00
committed by GitHub
22 changed files with 1250 additions and 134 deletions
+1 -1
View File
@@ -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",
+33
View File
@@ -162,6 +162,37 @@ export const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = {
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<CLIProxyProvider, string> = {
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<CLIProxyProvider, string> = {
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;
}
+9 -3
View File
@@ -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;
}
+163 -2
View File
@@ -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<AccountInfo | null> {
// 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<string | null>((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);
+10
View File
@@ -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;
+3
View File
@@ -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',
+11 -4
View File
@@ -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}`;
+4 -1
View File
@@ -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,
};
}
+12 -1
View File
@@ -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,
+2
View File
@@ -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;
}
/**
+4
View File
@@ -171,6 +171,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['', ''], // Spacer
['ccs <provider> --auth', 'Authenticate only'],
['ccs <provider> --auth --add', 'Add another account'],
[
'ccs <provider> --paste-callback',
'Show auth URL and prompt for callback paste (cross-browser)',
],
['ccs <provider> --accounts', 'List all accounts'],
['ccs <provider> --use <name>', 'Switch to account'],
['ccs <provider> --config', 'Change model (agy, gemini)'],
+35 -5
View File
@@ -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<string, unknown>;
return hook.matcher === 'WebSearch';
if (hook.matcher !== 'WebSearch') return false;
const hookArray = hook.hooks as Array<Record<string, unknown>> | 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<string, unknown>;
const existingHooks = existingHook.hooks as Array<Record<string, unknown>>;
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<string, unknown>;
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) {
+52
View File
@@ -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<string, unknown>): boolean {
if (hook.matcher !== 'WebSearch') return false;
const hookArray = hook.hooks as Array<Record<string, unknown>> | 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<string, unknown>): boolean {
const hooks = settings.hooks as Record<string, unknown[]> | 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<string, unknown>;
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;
}
+19 -10
View File
@@ -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<string, unknown>): boolean {
if (!hooks?.PreToolUse) return false;
return hooks.PreToolUse.some((h: unknown) => {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'WebSearch') return false;
const hookArray = hook.hooks as Array<Record<string, unknown>> | 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<string, unknown>);
});
}
@@ -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<Record<string, unknown>>;
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) {
+5 -1
View File
@@ -32,7 +32,11 @@ export interface ServerInstance {
export async function startServer(options: ServerOptions): Promise<ServerInstance> {
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());
+4 -2
View File
@@ -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;
+182 -5
View File
@@ -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<void>
}
});
// ==================== 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<void> => {
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<void> => {
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<void> => {
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;
+10
View File
@@ -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');
@@ -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);
});
});
+206 -77
View File
@@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent
className="sm:max-w-md"
onInteractOutside={(e) => {
// Prevent accidental close by clicking outside during auth
if (showAuthUI) e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Add {displayName} Account</DialogTitle>
<DialogDescription>
{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.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="nickname">Nickname (optional)</Label>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" />
<Input
id="nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="e.g., work, personal"
disabled={isPending}
className="flex-1"
/>
{/* Nickname input - only show before auth starts */}
{!showAuthUI && (
<div className="space-y-2">
<Label htmlFor="nickname">Nickname (optional)</Label>
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground" />
<Input
id="nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="e.g., work, personal"
disabled={isPending}
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
A friendly name to identify this account. Auto-generated from email if left empty.
</p>
</div>
<p className="text-xs text-muted-foreground">
A friendly name to identify this account. Auto-generated from email if left empty.
</p>
</div>
)}
{/* Unified auth state: spinner + auth URL + callback paste */}
{showAuthUI && (
<div className="space-y-4">
{/* Spinner */}
<div className="text-center">
<p className="text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
Waiting for authentication...
</p>
<p className="text-xs text-muted-foreground mt-1">
Complete the authentication in your browser. This dialog closes automatically.
</p>
</div>
{/* Error from /start-url - fallback URL not available */}
{authFlow.error && !authFlow.authUrl && (
<p className="text-xs text-center text-destructive">{authFlow.error}</p>
)}
{/* Auth URL section - appears once /start-url returns */}
{authFlow.authUrl && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs">Open this URL in any browser to sign in:</Label>
<div className="p-3 bg-muted rounded-md">
<p className="text-xs text-muted-foreground break-all font-mono line-clamp-3">
{authFlow.authUrl}
</p>
<div className="flex gap-2 mt-2">
<Button variant="outline" size="sm" onClick={handleCopyUrl}>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
if (!authFlow.authUrl) return;
const popup = window.open(authFlow.authUrl, '_blank');
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
toast.warning(
'Popup blocked. Copy the URL above and open it manually in your browser.',
{ duration: 5000 }
);
}
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
Open
</Button>
</div>
</div>
</div>
{/* Callback paste field */}
<div className="space-y-2">
<Label htmlFor="callback-url" className="text-xs">
Redirect didn&apos;t work? Paste the callback URL:
</Label>
<Input
id="callback-url"
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="Paste the redirect URL here..."
className="font-mono text-xs"
/>
<Button
variant="secondary"
size="sm"
onClick={handleSubmitCallback}
disabled={!callbackUrl.trim() || authFlow.isSubmittingCallback}
>
{authFlow.isSubmittingCallback ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
Submitting...
</>
) : (
'Submit Callback'
)}
</Button>
</div>
</div>
)}
</div>
)}
{/* Kiro import loading */}
{kiroImportMutation.isPending && (
<p className="text-sm text-center text-muted-foreground">
<Loader2 className="w-4 h-4 inline mr-2 animate-spin" />
Importing token from Kiro IDE...
</p>
)}
{/* Action buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={handleCancel}>
Cancel
</Button>
{isKiro && (
{isKiro && !showAuthUI && (
<Button variant="outline" onClick={handleKiroImport} disabled={isPending}>
{kiroImportMutation.isPending ? (
<>
@@ -147,31 +294,13 @@ export function AddAccountDialog({
)}
</Button>
)}
<Button onClick={handleStartAuth} disabled={isPending}>
{startAuthMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Authenticating...
</>
) : (
<>
<ExternalLink className="w-4 h-4 mr-2" />
Authenticate
</>
)}
</Button>
{!showAuthUI && (
<Button onClick={handleAuthenticate} disabled={isPending}>
<ExternalLink className="w-4 h-4 mr-2" />
Authenticate
</Button>
)}
</div>
{startAuthMutation.isPending && (
<p className="text-sm text-center text-muted-foreground">
Complete the OAuth flow in your browser...
</p>
)}
{kiroImportMutation.isPending && (
<p className="text-sm text-center text-muted-foreground">
Importing token from Kiro IDE...
</p>
)}
</div>
</DialogContent>
</Dialog>
+191 -22
View File
@@ -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<AuthFlowState>({
provider: null,
isAuthenticating: false,
error: null,
authUrl: null,
oauthState: null,
isSubmittingCallback: false,
});
const abortControllerRef = useRef<AbortController | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollStartRef = useRef<number>(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]
);
}
+2
View File
@@ -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) */