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..8fd526ac 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/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 99276543..0e0c2831 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -134,12 +134,12 @@ export function ensureHookConfig(): boolean { return normalized.includes('.ccs/hooks/websearch-transformer'); }); - // 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. if (webSearchHookIndex !== -1) { // 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 diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 295d7070..7961979c 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,175 @@ 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 callback URL to extract code and state parameters. + */ +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/src/utils/websearch/__tests__/hook-utils.test.ts b/tests/unit/utils/websearch/hook-utils.test.ts similarity index 51% rename from src/utils/websearch/__tests__/hook-utils.test.ts rename to tests/unit/utils/websearch/hook-utils.test.ts index 0ad12f7f..6bd09c30 100644 --- a/src/utils/websearch/__tests__/hook-utils.test.ts +++ b/tests/unit/utils/websearch/hook-utils.test.ts @@ -1,93 +1,93 @@ -import { expect, test, describe } from "bun:test"; -import { isCcsWebSearchHook, deduplicateCcsHooks } from "../hook-utils"; +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)", () => { +describe('isCcsWebSearchHook', () => { + test('Returns true for CCS hook with forward slashes (Unix path)', () => { const hook = { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + 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)", () => { + test('Returns true for CCS hook with backslashes (Windows path)', () => { const hook = { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js", + command: 'node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js', }, ], }; expect(isCcsWebSearchHook(hook)).toBe(true); }); - test("Returns true for mixed path separators", () => { + test('Returns true for mixed path separators', () => { const hook = { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /home/user\\.ccs/hooks\\websearch-transformer/index.js", + command: 'node /home/user\\.ccs/hooks\\websearch-transformer/index.js', }, ], }; expect(isCcsWebSearchHook(hook)).toBe(true); }); - test("Returns false for non-WebSearch matcher", () => { + test('Returns false for non-WebSearch matcher', () => { const hook = { - matcher: "SomethingElse", + matcher: 'SomethingElse', hooks: [ { - command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + 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", () => { + test('Returns false for WebSearch with non-CCS hook command', () => { const hook = { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /some/other/path/custom-hook.js", + command: 'node /some/other/path/custom-hook.js', }, ], }; expect(isCcsWebSearchHook(hook)).toBe(false); }); - test("Returns false when hooks array is missing", () => { + test('Returns false when hooks array is missing', () => { const hook = { - matcher: "WebSearch", + matcher: 'WebSearch', }; expect(isCcsWebSearchHook(hook)).toBe(false); }); - test("Returns false when hooks array is empty", () => { + test('Returns false when hooks array is empty', () => { const hook = { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [], }; expect(isCcsWebSearchHook(hook)).toBe(false); }); - test("Returns false when command is missing", () => { + test('Returns false when command is missing', () => { const hook = { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [{}], }; expect(isCcsWebSearchHook(hook)).toBe(false); }); - test("Returns false when command is not a string", () => { + test('Returns false when command is not a string', () => { const hook = { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { command: 123, @@ -98,14 +98,14 @@ describe("isCcsWebSearchHook", () => { }); }); -describe("deduplicateCcsHooks", () => { - test("No-op when 0 CCS hooks (returns false)", () => { +describe('deduplicateCcsHooks', () => { + test('No-op when 0 CCS hooks (returns false)', () => { const settings = { hooks: { PreToolUse: [ { - matcher: "SomeOtherMatcher", - hooks: [{ command: "other-command" }], + matcher: 'SomeOtherMatcher', + hooks: [{ command: 'other-command' }], }, ], }, @@ -115,15 +115,15 @@ describe("deduplicateCcsHooks", () => { expect(settings.hooks.PreToolUse).toHaveLength(1); }); - test("No-op when 1 CCS hook (returns false)", () => { + test('No-op when 1 CCS hook (returns false)', () => { const settings = { hooks: { PreToolUse: [ { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', }, ], }, @@ -135,31 +135,31 @@ describe("deduplicateCcsHooks", () => { expect(settings.hooks.PreToolUse).toHaveLength(1); }); - test("Removes duplicates when 2+ CCS hooks (returns true, keeps first)", () => { + test('Removes duplicates when 2+ CCS hooks (returns true, keeps first)', () => { const settings = { hooks: { PreToolUse: [ { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', }, ], }, { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js", + command: 'node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js', }, ], }, { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /another/path/.ccs/hooks/websearch-transformer/index.js", + command: 'node /another/path/.ccs/hooks/websearch-transformer/index.js', }, ], }, @@ -170,37 +170,37 @@ describe("deduplicateCcsHooks", () => { expect(result).toBe(true); expect(settings.hooks.PreToolUse).toHaveLength(1); expect(settings.hooks.PreToolUse[0]).toEqual({ - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', }, ], }); }); - test("Preserves non-CCS hooks in array", () => { + test('Preserves non-CCS hooks in array', () => { const nonCcsHook = { - matcher: "SomeOtherMatcher", - hooks: [{ command: "other-command" }], + matcher: 'SomeOtherMatcher', + hooks: [{ command: 'other-command' }], }; const settings = { hooks: { PreToolUse: [ nonCcsHook, { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js', }, ], }, { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js", + command: 'node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js', }, ], }, @@ -213,13 +213,13 @@ describe("deduplicateCcsHooks", () => { expect(settings.hooks.PreToolUse[0]).toEqual(nonCcsHook); }); - test("Returns false when hooks is undefined", () => { + test('Returns false when hooks is undefined', () => { const settings = {}; const result = deduplicateCcsHooks(settings); expect(result).toBe(false); }); - test("Returns false when PreToolUse is undefined", () => { + test('Returns false when PreToolUse is undefined', () => { const settings = { hooks: {}, }; @@ -227,31 +227,31 @@ describe("deduplicateCcsHooks", () => { expect(result).toBe(false); }); - test("Handles multiple non-CCS hooks with duplicates", () => { + test('Handles multiple non-CCS hooks with duplicates', () => { const settings = { hooks: { PreToolUse: [ { - matcher: "OtherMatcher1", - hooks: [{ command: "command1" }], + matcher: 'OtherMatcher1', + hooks: [{ command: 'command1' }], }, { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /path1/.ccs/hooks/websearch-transformer/index.js", + command: 'node /path1/.ccs/hooks/websearch-transformer/index.js', }, ], }, { - matcher: "OtherMatcher2", - hooks: [{ command: "command2" }], + matcher: 'OtherMatcher2', + hooks: [{ command: 'command2' }], }, { - matcher: "WebSearch", + matcher: 'WebSearch', hooks: [ { - command: "node /path2/.ccs/hooks/websearch-transformer/index.js", + command: 'node /path2/.ccs/hooks/websearch-transformer/index.js', }, ], }, @@ -262,12 +262,12 @@ describe("deduplicateCcsHooks", () => { 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"); + 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", () => { + test('Edge case: Empty PreToolUse array', () => { const settings = { hooks: { PreToolUse: [], diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 0290bda3..15cd7523 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,151 @@ 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] ); }