From d61c940a087d4e9134fa0a9ae32dc8d79d42648d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 11:18:20 -0500 Subject: [PATCH 01/15] fix(websearch): normalize Windows path separators in hook detection On Windows, path.join() produces backslash paths (C:\Users\.ccs\hooks\...) but detection used forward slashes, causing: - Failed hook detection on Windows - Duplicate hooks added on every CCS invocation Changes: - Normalize path separators (\ -> /) before matching - Add deduplicateCcsHooks() to auto-cleanup accumulated duplicates - Fix affects hasCcsHook(), isCcsWebSearchHook(), removeHookConfig() --- src/utils/websearch/hook-config.ts | 4 +- src/utils/websearch/profile-hook-injector.ts | 58 +++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index d05003c0..21a0c765 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -229,7 +229,9 @@ 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, '/'); + return !normalizedCommand.includes('.ccs/hooks/websearch-transformer'); // Remove if CCS hook }); if (hooks.PreToolUse.length === originalLength) { diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 214184da..56a9c477 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -40,10 +40,52 @@ function hasCcsHook(settings: Record): boolean { if (!hookArray?.[0]?.command) return false; const command = hookArray[0].command as string; - return command.includes('.ccs/hooks/websearch-transformer'); + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command.replace(/\\/g, '/'); + return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); }); } +/** + * Check if a hook entry is a CCS WebSearch hook + */ +function isCcsWebSearchHook(hook: Record): boolean { + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + if (!hookArray?.[0]?.command) return false; + + const command = hookArray[0].command as string; + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command.replace(/\\/g, '/'); + 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 + */ +function deduplicateCcsHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + let foundFirst = false; + const originalLength = hooks.PreToolUse.length; + + hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks + + if (!foundFirst) { + foundFirst = true; + return true; // Keep first CCS hook + } + return false; // Remove subsequent duplicates + }); + + return hooks.PreToolUse.length < originalLength; +} + /** * Migrate CCS hook from global settings to profile settings (one-time) */ @@ -126,6 +168,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); } @@ -189,7 +241,9 @@ function updateHookTimeoutIfNeeded( if (!hookArray?.[0]?.command) continue; const command = hookArray[0].command as string; - if (!command.includes('.ccs/hooks/websearch-transformer')) continue; + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command.replace(/\\/g, '/'); + if (!normalizedCommand.includes('.ccs/hooks/websearch-transformer')) continue; // Found CCS hook - check if needs update if (hookArray[0].command !== expectedCommand) { From 847aad00fee1fd920ddf8ea3a4b0e85aa1f3dfa4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 11:36:48 -0500 Subject: [PATCH 02/15] fix(websearch): add type guards and deduplication for global settings - Add typeof check before .replace() to prevent crash on non-string commands - Add isCcsWebSearchHook() and deduplicateCcsHooks() to hook-config.ts - Fix ensureHookConfig() detection to verify path, not just matcher - Prevent overwriting user's custom WebSearch hooks --- src/utils/websearch/hook-config.ts | 67 +++++++++++++++++++- src/utils/websearch/profile-hook-injector.ts | 9 ++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 21a0c765..fe01adc0 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -51,6 +51,47 @@ export function getHookPath(): string { return path.join(getCcsHooksDir(), WEBSEARCH_HOOK); } +/** + * Check if a hook entry is a CCS WebSearch hook + */ +function isCcsWebSearchHook(hook: Record): boolean { + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + if (!hookArray?.[0]?.command) return false; + + const command = hookArray[0].command; + if (typeof command !== 'string') return false; + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command.replace(/\\/g, '/'); + 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 + */ +function deduplicateCcsHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + let foundFirst = false; + const originalLength = hooks.PreToolUse.length; + + hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks + + if (!foundFirst) { + foundFirst = true; + return true; // Keep first CCS hook + } + return false; // Remove subsequent duplicates + }); + + return hooks.PreToolUse.length < originalLength; +} + /** * Get WebSearch hook configuration for settings.json * Timeout is computed from max provider timeout in config.yaml + buffer @@ -123,11 +164,27 @@ export function ensureHookConfig(): boolean { if (hooks?.PreToolUse) { const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => { const hook = h as Record; - return hook.matcher === 'WebSearch'; + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + const command = hookArray?.[0]?.command; + if (typeof command !== 'string') return false; + + const normalized = command.replace(/\\/g, '/'); + return normalized.includes('.ccs/hooks/websearch-transformer'); }); if (webSearchHookIndex !== -1) { - // Hook exists - check if it needs updating + // Hook exists - first clean up any duplicates + const hadDuplicates = deduplicateCcsHooks(settings); + if (hadDuplicates) { + fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(settings, null, 2), 'utf8'); + if (process.env.CCS_DEBUG) { + console.error(info('Removed duplicate WebSearch hooks from settings.json')); + } + } + + // Then check if it needs updating const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; const existingHooks = existingHook.hooks as Array>; const currentHookConfig = getWebSearchHookConfig(); @@ -170,6 +227,12 @@ export function ensureHookConfig(): boolean { settingsHooks.PreToolUse = []; } + // Remove any existing CCS hooks first to prevent duplicates + settingsHooks.PreToolUse = settingsHooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + return !isCcsWebSearchHook(hook); + }); + // Add our hook config const preToolUseHooks = hookConfig.PreToolUse as unknown[]; settingsHooks.PreToolUse.push(...preToolUseHooks); diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 56a9c477..4ef27c4b 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -39,7 +39,8 @@ function hasCcsHook(settings: Record): boolean { const hookArray = hook.hooks as Array> | undefined; if (!hookArray?.[0]?.command) return false; - const command = hookArray[0].command as string; + const command = hookArray[0].command; + if (typeof command !== 'string') return false; // Normalize path separators for cross-platform matching (Windows uses backslashes) const normalizedCommand = command.replace(/\\/g, '/'); return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); @@ -55,7 +56,8 @@ function isCcsWebSearchHook(hook: Record): boolean { const hookArray = hook.hooks as Array> | undefined; if (!hookArray?.[0]?.command) return false; - const command = hookArray[0].command as string; + const command = hookArray[0].command; + if (typeof command !== 'string') return false; // Normalize path separators for cross-platform matching (Windows uses backslashes) const normalizedCommand = command.replace(/\\/g, '/'); return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); @@ -240,7 +242,8 @@ function updateHookTimeoutIfNeeded( const hookArray = hook.hooks as Array>; if (!hookArray?.[0]?.command) continue; - const command = hookArray[0].command as string; + 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, '/'); if (!normalizedCommand.includes('.ccs/hooks/websearch-transformer')) continue; From 1f8d9b82d5ad89cefe73966e9c1ef57692dd9284 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 11:42:39 -0500 Subject: [PATCH 03/15] refactor(websearch): extract shared hook utils to DRY module - Create hook-utils.ts with isCcsWebSearchHook() and deduplicateCcsHooks() - Update profile-hook-injector.ts and hook-config.ts to use shared module - Combine double writeFileSync into single write in ensureHookConfig() Addresses code review feedback on PR #420 --- src/utils/websearch/hook-config.ts | 55 +++----------------- src/utils/websearch/hook-utils.ts | 50 ++++++++++++++++++ src/utils/websearch/profile-hook-injector.ts | 54 +------------------ 3 files changed, 58 insertions(+), 101 deletions(-) create mode 100644 src/utils/websearch/hook-utils.ts diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index fe01adc0..4b80e088 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -12,6 +12,7 @@ import * as os from 'os'; import { info, warn } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getCcsDir } from '../config-manager'; +import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; // Hook file name const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; @@ -51,47 +52,6 @@ export function getHookPath(): string { return path.join(getCcsHooksDir(), WEBSEARCH_HOOK); } -/** - * Check if a hook entry is a CCS WebSearch hook - */ -function isCcsWebSearchHook(hook: Record): boolean { - if (hook.matcher !== 'WebSearch') return false; - - const hookArray = hook.hooks as Array> | undefined; - if (!hookArray?.[0]?.command) return false; - - const command = hookArray[0].command; - if (typeof command !== 'string') return false; - // Normalize path separators for cross-platform matching (Windows uses backslashes) - const normalizedCommand = command.replace(/\\/g, '/'); - 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 - */ -function deduplicateCcsHooks(settings: Record): boolean { - const hooks = settings.hooks as Record | undefined; - if (!hooks?.PreToolUse) return false; - - let foundFirst = false; - const originalLength = hooks.PreToolUse.length; - - hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { - const hook = h as Record; - if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks - - if (!foundFirst) { - foundFirst = true; - return true; // Keep first CCS hook - } - return false; // Remove subsequent duplicates - }); - - return hooks.PreToolUse.length < originalLength; -} - /** * Get WebSearch hook configuration for settings.json * Timeout is computed from max provider timeout in config.yaml + buffer @@ -177,12 +137,6 @@ export function ensureHookConfig(): boolean { if (webSearchHookIndex !== -1) { // Hook exists - first clean up any duplicates const hadDuplicates = deduplicateCcsHooks(settings); - if (hadDuplicates) { - fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(settings, null, 2), 'utf8'); - if (process.env.CCS_DEBUG) { - console.error(info('Removed duplicate WebSearch hooks from settings.json')); - } - } // Then check if it needs updating const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; @@ -204,10 +158,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; diff --git a/src/utils/websearch/hook-utils.ts b/src/utils/websearch/hook-utils.ts new file mode 100644 index 00000000..85618d64 --- /dev/null +++ b/src/utils/websearch/hook-utils.ts @@ -0,0 +1,50 @@ +/** + * WebSearch Hook Utilities + * + * Shared helper functions for WebSearch hook detection and deduplication. + * + * @module utils/websearch/hook-utils + */ + +/** + * Check if a hook entry is a CCS WebSearch hook + * Normalizes path separators for cross-platform matching (Windows uses backslashes) + */ +export function isCcsWebSearchHook(hook: Record): boolean { + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + if (!hookArray?.[0]?.command) return false; + + const command = hookArray[0].command; + if (typeof command !== 'string') return false; + + // Normalize path separators for cross-platform matching + const normalizedCommand = command.replace(/\\/g, '/'); + return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); +} + +/** + * Remove duplicate CCS WebSearch hooks from settings, keeping only the first one + * Returns true if duplicates were removed + */ +export function deduplicateCcsHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + let foundFirst = false; + const originalLength = hooks.PreToolUse.length; + + hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks + + if (!foundFirst) { + foundFirst = true; + return true; // Keep first CCS hook + } + return false; // Remove subsequent duplicates + }); + + return hooks.PreToolUse.length < originalLength; +} diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 4ef27c4b..c4fe01de 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -14,6 +14,7 @@ import { getWebSearchHookConfig, getHookPath } from './hook-config'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { removeHookConfig } from './hook-config'; import { getCcsDir } from '../config-manager'; +import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; // Valid profile name pattern (alphanumeric, dash, underscore only) const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/; @@ -33,61 +34,10 @@ function hasCcsHook(settings: Record): boolean { if (!hooks?.PreToolUse) return false; return hooks.PreToolUse.some((h: unknown) => { - const hook = h as Record; - if (hook.matcher !== 'WebSearch') return false; - - const hookArray = hook.hooks as Array> | undefined; - if (!hookArray?.[0]?.command) return false; - - const command = hookArray[0].command; - if (typeof command !== 'string') return false; - // Normalize path separators for cross-platform matching (Windows uses backslashes) - const normalizedCommand = command.replace(/\\/g, '/'); - return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); + return isCcsWebSearchHook(h as Record); }); } -/** - * Check if a hook entry is a CCS WebSearch hook - */ -function isCcsWebSearchHook(hook: Record): boolean { - if (hook.matcher !== 'WebSearch') return false; - - const hookArray = hook.hooks as Array> | undefined; - if (!hookArray?.[0]?.command) return false; - - const command = hookArray[0].command; - if (typeof command !== 'string') return false; - // Normalize path separators for cross-platform matching (Windows uses backslashes) - const normalizedCommand = command.replace(/\\/g, '/'); - 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 - */ -function deduplicateCcsHooks(settings: Record): boolean { - const hooks = settings.hooks as Record | undefined; - if (!hooks?.PreToolUse) return false; - - let foundFirst = false; - const originalLength = hooks.PreToolUse.length; - - hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { - const hook = h as Record; - if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks - - if (!foundFirst) { - foundFirst = true; - return true; // Keep first CCS hook - } - return false; // Remove subsequent duplicates - }); - - return hooks.PreToolUse.length < originalLength; -} - /** * Migrate CCS hook from global settings to profile settings (one-time) */ From 5a308db409392d88e637ee66b9c693b8b7557198 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 11:42:02 -0500 Subject: [PATCH 04/15] fix(quota): improve 403 error messaging for forbidden accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of showing "Quota access forbidden" generic error, now shows: - Antigravity: "403 Forbidden - No Gemini Code Assist access" - Codex: "403 Forbidden - No quota API access" Keeps success=false with isForbidden flag so UI can show distinct "403" badge (similar to Antigravity-Manager) rather than conflating with 0% exhausted state. 403 ≠ 0% exhausted - they are semantically different: - 403: Account lacks API access entirely - 0%: Account has access but quota is used up (shows reset time) --- src/cliproxy/quota-fetcher-codex.ts | 5 ++++- src/cliproxy/quota-fetcher.ts | 4 +++- src/cliproxy/quota-types.ts | 2 ++ ui/src/lib/api-client.ts | 2 ++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 9343d436..878dbe2d 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -253,13 +253,16 @@ export async function fetchCodexQuota( } if (response.status === 403) { + // 403 = account lacks API access (not same as quota exhausted) + // Keep success=false with isForbidden flag for UI to show distinct "403" badge return { success: false, windows: [], planType: null, lastUpdated: Date.now(), - error: 'Quota access not available (free plan may not have quota API access)', + error: '403 Forbidden - No quota API access', accountId, + isForbidden: true, }; } diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 41203461..a2c79d41 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -407,12 +407,14 @@ async function fetchAvailableModels(accessToken: string, _projectId: string): Pr clearTimeout(timeoutId); if (response.status === 403) { + // 403 = account lacks Gemini Code Assist access (not same as quota exhausted) + // Keep success=false with isForbidden flag for UI to show distinct "403" badge return { success: false, models: [], lastUpdated: Date.now(), isForbidden: true, - error: 'Quota access forbidden for this account', + error: '403 Forbidden - No Gemini Code Assist access', }; } diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index ae0ac8d3..f8759bbe 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -45,6 +45,8 @@ export interface CodexQuotaResult { accountId?: string; /** True if token is expired and needs re-authentication */ needsReauth?: boolean; + /** True if account lacks quota access (403) - displayed as 0% instead of error */ + isForbidden?: boolean; } /** diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 51907b8a..a8d2bb87 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -180,6 +180,8 @@ export interface CodexQuotaResult { needsReauth?: boolean; /** True if result was served from cache */ cached?: boolean; + /** True if account lacks quota access (403) - displayed as 0% instead of error */ + isForbidden?: boolean; } /** Gemini CLI bucket (grouped by model series) */ From deca06c42bd8bf62173a8ae744c6d3ad90299231 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Feb 2026 16:52:35 +0000 Subject: [PATCH 05/15] chore(release): 7.34.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a9ba3ef1..e4bce75e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.0", + "version": "7.34.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From cc559996281d79db77e9bdf401c419d6b1d9c1f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Feb 2026 17:03:13 +0000 Subject: [PATCH 06/15] chore(release): 7.34.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e4bce75e..db90e29d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.0-dev.1", + "version": "7.34.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From deb62490dbe797369460ac07ad42a0790463a460 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 15:08:21 -0500 Subject: [PATCH 07/15] test(websearch): add unit tests for hook-utils module Add 17 tests for isCcsWebSearchHook and deduplicateCcsHooks functions covering Unix/Windows paths, edge cases, and duplicate cleanup. Also add invariant comment in hook-config.ts. Addresses code review feedback from PR #424. --- .../websearch/__tests__/hook-utils.test.ts | 280 ++++++++++++++++++ src/utils/websearch/hook-config.ts | 4 + 2 files changed, 284 insertions(+) create mode 100644 src/utils/websearch/__tests__/hook-utils.test.ts diff --git a/src/utils/websearch/__tests__/hook-utils.test.ts b/src/utils/websearch/__tests__/hook-utils.test.ts new file mode 100644 index 00000000..0ad12f7f --- /dev/null +++ b/src/utils/websearch/__tests__/hook-utils.test.ts @@ -0,0 +1,280 @@ +import { expect, test, describe } from "bun:test"; +import { isCcsWebSearchHook, deduplicateCcsHooks } from "../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 false for non-WebSearch matcher", () => { + const hook = { + matcher: "SomethingElse", + hooks: [ + { + command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test("Returns false for WebSearch with non-CCS hook command", () => { + const hook = { + matcher: "WebSearch", + hooks: [ + { + command: "node /some/other/path/custom-hook.js", + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test("Returns false when hooks array is missing", () => { + const hook = { + matcher: "WebSearch", + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test("Returns false when hooks array is empty", () => { + const hook = { + matcher: "WebSearch", + hooks: [], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test("Returns false when command is missing", () => { + const hook = { + matcher: "WebSearch", + hooks: [{}], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); + + test("Returns false when command is not a string", () => { + const hook = { + matcher: "WebSearch", + hooks: [ + { + command: 123, + }, + ], + }; + expect(isCcsWebSearchHook(hook)).toBe(false); + }); +}); + +describe("deduplicateCcsHooks", () => { + test("No-op when 0 CCS hooks (returns false)", () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "SomeOtherMatcher", + hooks: [{ command: "other-command" }], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + expect(settings.hooks.PreToolUse).toHaveLength(1); + }); + + test("No-op when 1 CCS hook (returns false)", () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "WebSearch", + hooks: [ + { + command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + }, + ], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + expect(settings.hooks.PreToolUse).toHaveLength(1); + }); + + test("Removes duplicates when 2+ CCS hooks (returns true, keeps first)", () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "WebSearch", + hooks: [ + { + command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + }, + ], + }, + { + matcher: "WebSearch", + hooks: [ + { + command: "node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js", + }, + ], + }, + { + matcher: "WebSearch", + hooks: [ + { + command: "node /another/path/.ccs/hooks/websearch-transformer/index.js", + }, + ], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(true); + expect(settings.hooks.PreToolUse).toHaveLength(1); + expect(settings.hooks.PreToolUse[0]).toEqual({ + matcher: "WebSearch", + hooks: [ + { + command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + }, + ], + }); + }); + + test("Preserves non-CCS hooks in array", () => { + const nonCcsHook = { + matcher: "SomeOtherMatcher", + hooks: [{ command: "other-command" }], + }; + const settings = { + hooks: { + PreToolUse: [ + nonCcsHook, + { + matcher: "WebSearch", + hooks: [ + { + command: "node /home/user/.ccs/hooks/websearch-transformer/index.js", + }, + ], + }, + { + matcher: "WebSearch", + hooks: [ + { + command: "node C:\\Users\\user\\.ccs\\hooks\\websearch-transformer\\index.js", + }, + ], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(true); + expect(settings.hooks.PreToolUse).toHaveLength(2); + expect(settings.hooks.PreToolUse[0]).toEqual(nonCcsHook); + }); + + test("Returns false when hooks is undefined", () => { + const settings = {}; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + }); + + test("Returns false when PreToolUse is undefined", () => { + const settings = { + hooks: {}, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + }); + + test("Handles multiple non-CCS hooks with duplicates", () => { + const settings = { + hooks: { + PreToolUse: [ + { + matcher: "OtherMatcher1", + hooks: [{ command: "command1" }], + }, + { + matcher: "WebSearch", + hooks: [ + { + command: "node /path1/.ccs/hooks/websearch-transformer/index.js", + }, + ], + }, + { + matcher: "OtherMatcher2", + hooks: [{ command: "command2" }], + }, + { + matcher: "WebSearch", + hooks: [ + { + command: "node /path2/.ccs/hooks/websearch-transformer/index.js", + }, + ], + }, + ], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(true); + expect(settings.hooks.PreToolUse).toHaveLength(3); + // First and third should be non-CCS hooks, second should be the first CCS hook + expect(settings.hooks.PreToolUse[0].matcher).toBe("OtherMatcher1"); + expect(settings.hooks.PreToolUse[1].matcher).toBe("WebSearch"); + expect(settings.hooks.PreToolUse[2].matcher).toBe("OtherMatcher2"); + }); + + test("Edge case: Empty PreToolUse array", () => { + const settings = { + hooks: { + PreToolUse: [], + }, + }; + const result = deduplicateCcsHooks(settings); + expect(result).toBe(false); + expect(settings.hooks.PreToolUse).toHaveLength(0); + }); +}); diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 4b80e088..99276543 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -134,6 +134,10 @@ 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 const hadDuplicates = deduplicateCcsHooks(settings); From 24b03121fd43121f229bd4c07cbd7e3ee5a0234a Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:11:36 -0500 Subject: [PATCH 08/15] fix(dashboard): cross-browser OAuth with manual callback fallback (#417) (#423) - Remove destructive /start endpoint call from Dashboard OAuth dialog (was killing running CLIProxy Docker instances via killProcessOnPort) - Use /start-url + polling only (management API, non-destructive) - Auto-open browser tab via window.open() with manual fallback URL display - Add paste-callback CLI mode (--paste-callback flag) for headless/SSH - Use dynamic proxy target with management headers instead of hardcoded localhost - Extract timeout constants, restore invariant comment - Move hook-utils tests from src/__tests__/ to tests/unit/ (fixes tsc) - Add try-catch for preset apply, remove auth URL console.log --- src/cliproxy/auth/auth-types.ts | 33 +++ src/cliproxy/auth/environment-detector.ts | 12 +- src/cliproxy/auth/oauth-handler.ts | 165 ++++++++++- src/cliproxy/auth/oauth-process.ts | 10 + src/cliproxy/cliproxy-executor.ts | 3 + src/cliproxy/proxy-target-resolver.ts | 15 +- src/commands/help-command.ts | 4 + src/utils/websearch/hook-config.ts | 8 +- src/web-server/routes/cliproxy-auth-routes.ts | 185 +++++++++++- .../unit/utils/websearch}/hook-utils.test.ts | 128 ++++---- .../components/account/add-account-dialog.tsx | 276 +++++++++++++----- ui/src/hooks/use-cliproxy-auth-flow.ts | 213 ++++++++++++-- 12 files changed, 871 insertions(+), 181 deletions(-) rename {src/utils/websearch/__tests__ => tests/unit/utils/websearch}/hook-utils.test.ts (51%) 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] ); } From 4fd2f601f676c0710b078960ad1d6c45fba8a6ca Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 23:40:04 -0500 Subject: [PATCH 09/15] fix(websocket): add maxPayload limit to prevent DoS attacks Set 1MB payload limit and disable perMessageDeflate to prevent memory exhaustion and zip bomb attacks on WebSocket server. --- src/web-server/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 51fd523c..fd64547c 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -32,7 +32,11 @@ export interface ServerInstance { export async function startServer(options: ServerOptions): Promise { const app = express(); const server = http.createServer(app); - const wss = new WebSocketServer({ server }); + const wss = new WebSocketServer({ + server, + maxPayload: 1024 * 1024, // 1MB hard limit to prevent DoS + perMessageDeflate: false, // Prevent zip bomb attacks + }); // JSON body parsing with error handler for malformed JSON app.use(express.json()); From e596ab487d9782e4cd9633be081dc42e4a176668 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 23:40:15 -0500 Subject: [PATCH 10/15] fix(quota): add explicit 429 rate limit handling Match pattern from quota-fetcher-codex.ts for consistent user-friendly error messages when rate limited. --- src/cliproxy/quota-fetcher.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index a2c79d41..2e965707 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -427,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, From 66f5fe6b2c2a955b4b616c38042ab3c9ead199a1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 23:40:25 -0500 Subject: [PATCH 11/15] fix(websearch): normalize double-slash paths in hook detection Add .replace(/\/+/g, '/') to collapse multiple forward slashes, preventing duplicate hook accumulation from malformed paths. --- src/utils/websearch/hook-config.ts | 8 ++++++-- src/utils/websearch/hook-utils.ts | 4 +++- src/utils/websearch/profile-hook-injector.ts | 4 +++- tests/unit/utils/websearch/hook-utils.test.ts | 12 ++++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 0e0c2831..ad88b0bb 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -130,7 +130,9 @@ export function ensureHookConfig(): boolean { const command = hookArray?.[0]?.command; if (typeof command !== 'string') return false; - const normalized = command.replace(/\\/g, '/'); + const normalized = command + .replace(/\\/g, '/') // Windows backslashes + .replace(/\/+/g, '/'); // Collapse multiple slashes return normalized.includes('.ccs/hooks/websearch-transformer'); }); @@ -254,7 +256,9 @@ export function removeHookConfig(): boolean { const command = hookArray[0].command as string; // Normalize path separators for cross-platform matching (Windows uses backslashes) - const normalizedCommand = command.replace(/\\/g, '/'); + const normalizedCommand = command + .replace(/\\/g, '/') // Windows backslashes + .replace(/\/+/g, '/'); // Collapse multiple slashes return !normalizedCommand.includes('.ccs/hooks/websearch-transformer'); // Remove if CCS hook }); diff --git a/src/utils/websearch/hook-utils.ts b/src/utils/websearch/hook-utils.ts index 85618d64..7a551380 100644 --- a/src/utils/websearch/hook-utils.ts +++ b/src/utils/websearch/hook-utils.ts @@ -20,7 +20,9 @@ export function isCcsWebSearchHook(hook: Record): boolean { if (typeof command !== 'string') return false; // Normalize path separators for cross-platform matching - const normalizedCommand = command.replace(/\\/g, '/'); + const normalizedCommand = command + .replace(/\\/g, '/') // Windows backslashes + .replace(/\/+/g, '/'); // Collapse multiple slashes return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); } diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index c4fe01de..5a98b00c 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -195,7 +195,9 @@ function updateHookTimeoutIfNeeded( 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, '/'); + 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 diff --git a/tests/unit/utils/websearch/hook-utils.test.ts b/tests/unit/utils/websearch/hook-utils.test.ts index 6bd09c30..a3f7bd3e 100644 --- a/tests/unit/utils/websearch/hook-utils.test.ts +++ b/tests/unit/utils/websearch/hook-utils.test.ts @@ -38,6 +38,18 @@ describe('isCcsWebSearchHook', () => { 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', From 09b5239f58213b24f2e13116cb2384748bf8913f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 23:40:36 -0500 Subject: [PATCH 12/15] fix(jsonl): add explicit UTF-8 BOM stripping Strip BOM character before JSON parsing to ensure robust cross-platform JSONL file handling. --- src/web-server/jsonl-parser.ts | 6 ++++-- tests/unit/jsonl-parser.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index 4bb21aee..cb8aa98a 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -70,10 +70,12 @@ export interface ParserOptions { * Returns null for non-assistant entries or entries without usage data */ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null { - if (!line.trim()) return null; + // Strip UTF-8 BOM if present (can occur on first line of some files) + const cleanLine = line.replace(/^\uFEFF/, '').trim(); + if (!cleanLine) return null; try { - const entry = JSON.parse(line); + const entry = JSON.parse(cleanLine); // Only process assistant entries with usage data if (entry.type !== 'assistant') return null; diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index c663d518..5fa00305 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -133,6 +133,16 @@ describe('parseUsageEntry', () => { expect(parseUsageEntry('not json at all', '/test')).toBeNull(); }); + test('strips UTF-8 BOM from line before parsing', () => { + // UTF-8 BOM character (\uFEFF) can appear at start of files + const bomEntry = '\uFEFF' + VALID_ASSISTANT_ENTRY; + const result = parseUsageEntry(bomEntry, '/test'); + + expect(result).not.toBeNull(); + expect(result!.model).toBe('claude-sonnet-4-5'); + expect(result!.inputTokens).toBe(1000); + }); + test('includes project path in result', () => { const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path'); expect(result!.projectPath).toBe('/custom/project/path'); From 441870d38e5e7d8069df5f4695cb28275f0d48b6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 23:40:48 -0500 Subject: [PATCH 13/15] fix(dashboard): detect popup blocked during OAuth flow Show toast warning with manual URL copy instructions when browser blocks OAuth popup window. --- ui/src/components/account/add-account-dialog.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 15cd7523..70f447cd 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -215,9 +215,16 @@ export function AddAccountDialog({