From b3ef76a07b4f1e6d9852e851abff059bb7049a91 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:36:18 -0800 Subject: [PATCH] feat(release): CLI flag passthrough, proxy fixes, and UI improvements (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cliproxy): pass variant port to executor for isolation Variants configured with dedicated ports (8318-8417) were not using their assigned port. The executor always defaulted to 8317. Changes: - Add port field to ProfileDetectionResult interface - Pass variant.port from profile-detector to ccs.ts - Forward port to execClaudeWithCLIProxy options - Update executor priority: CLI flags > variant port > config.yaml > default Closes #228 * fix(cliproxy): propagate port in unified config and UI preset handlers Edge case fixes identified in codebase review: - Unified config variant detection: add settingsPath and port fields - Provider editor: use variant port in handleApplyPreset/handleCustomPresetApply - preset-utils: add optional port parameter to applyDefaultPreset() * chore(release): 7.11.1-dev.1 [skip ci] * fix(cliproxy): use correct default port (8317) for remote HTTP connections Root cause: Inconsistent default port logic across code paths. - Test Connection used 8317 (correct) - Actual API calls used 80 (wrong) Changes: - Add centralized getRemoteDefaultPort() helper in config-generator.ts - Fix proxy-target-resolver.ts to use shared helper - Fix rewriteLocalhostUrls() and getRemoteEnvVars() in config-generator.ts - Update remote-proxy-client.ts to use shared helper (DRY) Fixes all 3 reported issues: 1. Port empty → now correctly uses :8317 instead of :80 2. BASEURL construction → now includes correct port 3. CLIProxy Plus auth → now fetches from remote on correct port * chore(release): 7.11.1-dev.2 [skip ci] * feat(delegation): add Claude Code CLI flag passthrough Add explicit passthrough support for key Claude Code CLI flags: - --max-turns: Limit agentic turns (prevents infinite loops) - --fallback-model: Auto-fallback when model overloaded - --agents: Dynamic subagent JSON injection - --betas: Enable experimental features Maintain extraArgs catch-all for future Claude Code flags. Update help command with new "Delegation Flags" section. Closes #89 * test(delegation): add comprehensive CLI flag passthrough tests Add 45 test cases covering all edge cases for CLI flag passthrough: - DelegationHandler: timeout/max-turns/fallback-model/agents/betas validation - HeadlessExecutor: duplicate flag filtering, undefined vs truthy checks * chore(release): 7.11.1-dev.3 [skip ci] * fix(ui): enable cancel button during OAuth authentication Resolves #234 - Cancel button was disabled during authentication flow, preventing users from canceling the OAuth process. Changes: - Add auth-session-manager.ts for tracking active OAuth sessions - Add POST /cliproxy/auth/:provider/cancel endpoint to abort sessions - Kill spawned CLIProxy auth process when cancel is triggered - Enable Cancel button in AddAccountDialog during authentication - Add cancel support to QuickSetupWizard auth step - Update useCancelAuth hook to call backend cancel endpoint * chore(release): 7.11.1-dev.4 [skip ci] * fix(prompt): add stdin.pause() to prevent process hang after password input Fixes #236. The password() method called resume() on stdin but never paused it in cleanup, keeping the event loop alive indefinitely. * chore(release): 7.11.1-dev.5 [skip ci] * feat(cliproxy): add --allow-self-signed flag for HTTPS connections (#227) Previously, allowSelfSigned was hardcoded to true for all HTTPS protocol connections, forcing use of the native https module which has issues with Cloudflare-proxied connections causing timeouts. This change: - Adds --allow-self-signed CLI flag (default: false) - Adds CCS_ALLOW_SELF_SIGNED environment variable - Uses standard fetch API by default for HTTPS (works with valid certs) - Only uses native https module when --allow-self-signed is specified Usage: - For production HTTPS proxies with valid certs: no flag needed - For dev proxies with self-signed certs: use --allow-self-signed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 * chore(release): 7.11.1-dev.6 [skip ci] --------- Co-authored-by: github-actions[bot] Co-authored-by: Shun Kakinoki <39187513+shunkakinoki@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- package.json | 2 +- src/auth/profile-detector.ts | 5 + src/ccs.ts | 6 +- src/cliproxy/auth-session-manager.ts | 119 ++++++++++ src/cliproxy/auth/oauth-process.ts | 25 +++ src/cliproxy/cliproxy-executor.ts | 14 +- src/cliproxy/config-generator.ts | 63 +++++- src/cliproxy/proxy-config-resolver.ts | 26 ++- src/cliproxy/proxy-target-resolver.ts | 20 +- src/cliproxy/remote-proxy-client.ts | 17 +- src/cliproxy/types.ts | 2 + src/commands/help-command.ts | 12 + src/delegation/delegation-handler.ts | 98 +++++++- src/delegation/executor/types.ts | 7 +- src/delegation/headless-executor.ts | 38 +++- src/utils/prompt.ts | 1 + src/web-server/routes/cliproxy-auth-routes.ts | 33 +++ .../delegation/delegation-handler.test.ts | 211 ++++++++++++++++++ .../unit/delegation/headless-executor.test.ts | 169 ++++++++++++++ .../components/account/add-account-dialog.tsx | 18 +- .../cliproxy/provider-editor/index.tsx | 6 +- ui/src/components/setup/wizard/index.tsx | 12 +- ui/src/hooks/use-cliproxy-auth-flow.ts | 10 +- ui/src/hooks/use-cliproxy.ts | 10 + ui/src/lib/api-client.ts | 6 + ui/src/lib/preset-utils.ts | 7 +- 26 files changed, 882 insertions(+), 55 deletions(-) create mode 100644 src/cliproxy/auth-session-manager.ts create mode 100644 tests/unit/delegation/delegation-handler.test.ts create mode 100644 tests/unit/delegation/headless-executor.test.ts diff --git a/package.json b/package.json index 6cf40636..fdf924ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.11.1", + "version": "7.11.1-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 16accd5f..a3fad69b 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -39,6 +39,8 @@ export interface ProfileDetectionResult { message?: string; /** For cliproxy variants: the underlying provider (gemini, codex, agy, qwen) */ provider?: CLIProxyProfileName; + /** For cliproxy variants: dedicated port for isolation (8318-8417) */ + port?: number; /** For unified config profiles: merged env vars (config + secrets) */ env?: Record; /** For copilot profile: the copilot config */ @@ -110,6 +112,8 @@ class ProfileDetector { type: 'cliproxy', name: profileName, provider: variant.provider as CLIProxyProfileName, + settingsPath: variant.settings, + port: variant.port, }; } @@ -250,6 +254,7 @@ class ProfileDetector { name: profileName, provider: variant.provider as CLIProxyProfileName, settingsPath: variant.settings, + port: variant.port, }; } diff --git a/src/ccs.ts b/src/ccs.ts index dc2df697..fc1d1d83 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -511,7 +511,11 @@ async function main(): Promise { // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles - await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath }); + const variantPort = profileInfo.port; // variant-specific port for isolation + await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { + customSettingsPath, + port: variantPort, + }); } else if (profileInfo.type === 'copilot') { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy const { executeCopilotProfile } = await import('./copilot'); diff --git a/src/cliproxy/auth-session-manager.ts b/src/cliproxy/auth-session-manager.ts new file mode 100644 index 00000000..ce718be9 --- /dev/null +++ b/src/cliproxy/auth-session-manager.ts @@ -0,0 +1,119 @@ +/** + * Auth Session Manager + * + * Tracks active OAuth sessions and provides cancellation capability. + * Used to properly terminate in-progress OAuth flows from UI. + */ + +import { EventEmitter } from 'events'; +import { ChildProcess } from 'child_process'; + +export interface ActiveAuthSession { + sessionId: string; + provider: string; + startedAt: number; + process?: ChildProcess; +} + +export const authSessionEvents = new EventEmitter(); + +const activeSessions = new Map(); + +/** + * Register an active OAuth session + */ +export function registerAuthSession( + sessionId: string, + provider: string, + process?: ChildProcess +): void { + activeSessions.set(sessionId, { + sessionId, + provider, + startedAt: Date.now(), + process, + }); + authSessionEvents.emit('session:started', sessionId, provider); +} + +/** + * Update session with process reference (if registered before spawn) + */ +export function attachProcessToSession(sessionId: string, process: ChildProcess): void { + const session = activeSessions.get(sessionId); + if (session) { + session.process = process; + } +} + +/** + * Unregister an auth session (on completion or cancellation) + */ +export function unregisterAuthSession(sessionId: string): void { + activeSessions.delete(sessionId); + authSessionEvents.emit('session:ended', sessionId); +} + +/** + * Cancel an active OAuth session + * Returns true if session was found and killed + */ +export function cancelAuthSession(sessionId: string): boolean { + const session = activeSessions.get(sessionId); + if (!session) { + return false; + } + + // Kill the process if attached + if (session.process && !session.process.killed) { + session.process.kill('SIGTERM'); + } + + activeSessions.delete(sessionId); + authSessionEvents.emit('session:cancelled', sessionId); + return true; +} + +/** + * Get active session by session ID + */ +export function getActiveSession(sessionId: string): ActiveAuthSession | null { + return activeSessions.get(sessionId) || null; +} + +/** + * Get active session for a provider (most recent) + */ +export function getActiveSessionForProvider(provider: string): ActiveAuthSession | null { + for (const session of activeSessions.values()) { + if (session.provider === provider) { + return session; + } + } + return null; +} + +/** + * Check if there's an active session for provider + */ +export function hasActiveSession(provider: string): boolean { + return getActiveSessionForProvider(provider) !== null; +} + +/** + * Cancel all sessions for a provider + */ +export function cancelAllSessionsForProvider(provider: string): number { + let count = 0; + for (const [sessionId, session] of activeSessions.entries()) { + if (session.provider === provider) { + if (session.process && !session.process.killed) { + session.process.kill('SIGTERM'); + } + activeSessions.delete(sessionId); + authSessionEvents.emit('session:cancelled', sessionId); + count++; + } + } + return count; +} diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 309df84d..c09a9536 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -25,6 +25,12 @@ import { getTimeoutTroubleshooting, showStep } from './environment-detector'; import { isAuthenticated, registerAccountFromToken } from './token-manager'; import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler'; import { OAUTH_FLOW_TYPES } from '../../management'; +import { + registerAuthSession, + attachProcessToSession, + unregisterAuthSession, + authSessionEvents, +} from '../auth-session-manager'; /** Options for OAuth process execution */ export interface OAuthProcessOptions { @@ -326,6 +332,19 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise { + if (cancelledSessionId === state.sessionId && authProcess && !authProcess.killed) { + log('Session cancelled externally'); + authProcess.kill('SIGTERM'); + } + }; + authSessionEvents.on('session:cancelled', handleCancel); + const startTime = Date.now(); authProcess.stdout?.on('data', async (data: Buffer) => { @@ -377,6 +396,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise Variant port (cfg.port) > config.yaml > default + // cfg.port is set if variant has dedicated port; proxyConfig.port comes from CLI/ENV/config.yaml + if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) { + // Variant port already set via config param - keep it (highest priority after CLI flags) + // CLI flags would have been parsed in resolveProxyConfig and set proxyConfig.port + // So if proxyConfig.port differs from default AND cfg.port differs, CLI wins + if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { + cfg.port = proxyConfig.port; // CLI/ENV override variant port + } + // else: keep cfg.port (variant port) + } else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { cfg.port = proxyConfig.port; } @@ -190,7 +200,7 @@ export async function execClaudeWithCLIProxy( protocol: proxyConfig.protocol, authToken: proxyConfig.authToken, timeout: proxyConfig.timeout ?? 2000, - allowSelfSigned: proxyConfig.protocol === 'https', + allowSelfSigned: proxyConfig.allowSelfSigned ?? false, }); if (status.reachable) { diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index 5a73a2e4..bb4872a7 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -62,6 +62,41 @@ function ensureRequiredEnvVars( /** Default CLIProxy port */ export const CLIPROXY_DEFAULT_PORT = 8317; +/** + * Normalize protocol to lowercase and validate. + * Handles runtime case sensitivity (e.g., 'HTTPS' → 'https'). + * Defaults to 'http' for invalid values. + */ +export function normalizeProtocol(protocol: string | undefined): 'http' | 'https' { + if (!protocol) return 'http'; + const normalized = protocol.toLowerCase(); + if (normalized === 'https') return 'https'; + if (normalized === 'http') return 'http'; + // Invalid protocol (e.g., 'ftp') - default to http + return 'http'; +} + +/** + * Validate and sanitize port number for remote connections. + * Returns undefined for invalid ports (letting caller use default). + * Valid range: 1-65535, must be integer. + */ +export function validateRemotePort(port: number | undefined): number | undefined { + if (port === undefined || port === null) return undefined; + if (typeof port !== 'number' || !Number.isInteger(port)) return undefined; + if (port <= 0 || port > 65535) return undefined; + return port; +} + +/** + * Get default port for remote CLIProxyAPI based on protocol. + * - HTTP: 8317 (CLIProxyAPI default) + * - HTTPS: 443 (standard SSL port) + */ +export function getRemoteDefaultPort(protocol: 'http' | 'https'): number { + return protocol === 'https' ? 443 : CLIPROXY_DEFAULT_PORT; +} + /** Internal API key for CCS-managed requests */ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; @@ -548,11 +583,15 @@ function rewriteLocalhostUrls( const localhostPattern = /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?/i; if (!localhostPattern.test(baseUrl)) return result; - // Build remote URL with smart port handling - const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80; - const effectivePort = remoteConfig.port ?? defaultPort; - const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`; - const remoteBaseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; + // Build remote URL with smart port handling (8317 for HTTP, 443 for HTTPS) + // Validate port and normalize protocol for defensive handling + const normalizedProtocol = normalizeProtocol(remoteConfig.protocol); + const validatedPort = validateRemotePort(remoteConfig.port); + const effectivePort = validatedPort ?? getRemoteDefaultPort(normalizedProtocol); + // Omit port suffix for standard web ports (80/443) for cleaner URLs + const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; + const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; + const remoteBaseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; result.ANTHROPIC_BASE_URL = remoteBaseUrl; @@ -686,11 +725,15 @@ export function getRemoteEnvVars( remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string }, customSettingsPath?: string ): Record { - // Build URL with smart port handling - omit if using protocol default - const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80; - const effectivePort = remoteConfig.port ?? defaultPort; - const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`; - const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; + // Build URL with smart port handling (8317 for HTTP, 443 for HTTPS) + // Validate port and normalize protocol for defensive handling + const normalizedProtocol = normalizeProtocol(remoteConfig.protocol); + const validatedPort = validateRemotePort(remoteConfig.port); + const effectivePort = validatedPort ?? getRemoteDefaultPort(normalizedProtocol); + // Omit port suffix for standard web ports (80/443) for cleaner URLs + const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; + const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; + const baseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; // Get global env vars (DISABLE_TELEMETRY, etc.) const globalEnv = getGlobalEnvVars(); diff --git a/src/cliproxy/proxy-config-resolver.ts b/src/cliproxy/proxy-config-resolver.ts index 4b9fb52a..f38bd7cf 100644 --- a/src/cliproxy/proxy-config-resolver.ts +++ b/src/cliproxy/proxy-config-resolver.ts @@ -19,6 +19,7 @@ export const PROXY_CLI_FLAGS = [ '--proxy-timeout', '--local-proxy', '--remote-only', + '--allow-self-signed', ] as const; /** Environment variable names for proxy configuration */ @@ -29,6 +30,7 @@ export const PROXY_ENV_VARS = { authToken: 'CCS_PROXY_AUTH_TOKEN', timeout: 'CCS_PROXY_TIMEOUT', fallbackEnabled: 'CCS_PROXY_FALLBACK_ENABLED', + allowSelfSigned: 'CCS_ALLOW_SELF_SIGNED', } as const; /** Parsed CLI proxy flags */ @@ -40,6 +42,7 @@ interface ParsedProxyFlags { timeout?: number; localProxy: boolean; remoteOnly: boolean; + allowSelfSigned?: boolean; } /** Proxy config from environment variables */ @@ -50,6 +53,7 @@ interface EnvProxyConfig { authToken?: string; timeout?: number; fallbackEnabled?: boolean; + allowSelfSigned?: boolean; } /** @@ -121,6 +125,12 @@ export function parseProxyFlags(args: string[]): { continue; } + if (arg === '--allow-self-signed') { + flags.allowSelfSigned = true; + i += 1; + continue; + } + // Not a proxy flag - keep in remaining args remainingArgs.push(arg); i += 1; @@ -180,6 +190,16 @@ export function getProxyEnvVars(): EnvProxyConfig { } } + const allowSelfSigned = process.env[PROXY_ENV_VARS.allowSelfSigned]; + if (allowSelfSigned !== undefined) { + const lower = allowSelfSigned.toLowerCase(); + if (lower === '1' || lower === 'true' || lower === 'yes') { + config.allowSelfSigned = true; + } else if (lower === '0' || lower === 'false' || lower === 'no') { + config.allowSelfSigned = false; + } + } + return config; } @@ -272,6 +292,9 @@ export function resolveProxyConfig( // Merge timeout: CLI > ENV > config.yaml > default (2000ms in executor) resolved.timeout = cliFlags.timeout ?? envConfig.timeout ?? yamlConfig.remote?.timeout; + // Merge allowSelfSigned: CLI > ENV > default (true for backwards compatibility) + resolved.allowSelfSigned = cliFlags.allowSelfSigned ?? envConfig.allowSelfSigned ?? true; + // Merge fallback enabled: ENV > config.yaml > default resolved.fallbackEnabled = envConfig.fallbackEnabled ?? yamlConfig.remote?.fallback_enabled ?? true; @@ -303,6 +326,7 @@ export function hasProxyFlags(args: string[]): boolean { arg === '--proxy-auth-token' || arg === '--proxy-timeout' || arg === '--local-proxy' || - arg === '--remote-only' + arg === '--remote-only' || + arg === '--allow-self-signed' ); } diff --git a/src/cliproxy/proxy-target-resolver.ts b/src/cliproxy/proxy-target-resolver.ts index 64778f44..7124c7a1 100644 --- a/src/cliproxy/proxy-target-resolver.ts +++ b/src/cliproxy/proxy-target-resolver.ts @@ -7,9 +7,12 @@ import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import type { CliproxyServerConfig } from '../config/unified-config-types'; - -/** Default CLIProxyAPI port */ -const DEFAULT_CLIPROXY_PORT = 8317; +import { + CLIPROXY_DEFAULT_PORT, + getRemoteDefaultPort, + normalizeProtocol, + validateRemotePort, +} from './config-generator'; /** Resolved proxy target for making requests */ export interface ProxyTarget { @@ -42,10 +45,11 @@ export function getProxyTarget(): ProxyTarget { const config = loadCliproxyServerConfig(); if (config?.remote?.enabled && config.remote?.host) { - const protocol = config.remote.protocol ?? 'http'; - // Default port based on protocol if not specified - const defaultPort = protocol === 'https' ? 443 : 80; - const port = config.remote.port ?? defaultPort; + // Normalize protocol (handles case sensitivity and invalid values) + const protocol = normalizeProtocol(config.remote.protocol); + // Validate port (returns undefined for invalid, triggering default) + const validatedPort = validateRemotePort(config.remote.port); + const port = validatedPort ?? getRemoteDefaultPort(protocol); return { host: config.remote.host, @@ -58,7 +62,7 @@ export function getProxyTarget(): ProxyTarget { return { host: '127.0.0.1', - port: config?.local?.port ?? DEFAULT_CLIPROXY_PORT, + port: config?.local?.port ?? CLIPROXY_DEFAULT_PORT, protocol: 'http', isRemote: false, }; diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts index f6b51385..a2197fe0 100644 --- a/src/cliproxy/remote-proxy-client.ts +++ b/src/cliproxy/remote-proxy-client.ts @@ -6,6 +6,7 @@ */ import * as https from 'https'; +import { getRemoteDefaultPort, validateRemotePort } from './config-generator'; /** Error codes for remote proxy status */ export type RemoteProxyErrorCode = @@ -52,17 +53,6 @@ export interface RemoteProxyClientConfig { /** Default timeout for remote proxy requests (aggressive for CLI UX) */ const DEFAULT_TIMEOUT_MS = 2000; -/** - * Get default port for CLIProxyAPI based on protocol. - * - HTTP: 8317 (CLIProxyAPI default for local/dev scenarios) - * - HTTPS: 443 (standard SSL port for production remote servers) - * - * This matches the UI labels shown to users. - */ -function getDefaultPort(protocol: 'http' | 'https'): number { - return protocol === 'https' ? 443 : 8317; -} - /** * Get standard web port for protocol (for URL display omission) * These are the ports that browsers/HTTP clients use by default. @@ -74,10 +64,11 @@ function getStandardWebPort(protocol: 'http' | 'https'): number { /** * Get effective port for CLIProxyAPI connection. - * If port is provided, use it. Otherwise use protocol-based default. + * Validates port and uses protocol-based default for invalid/undefined values. */ function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): number { - return port ?? getDefaultPort(protocol); + const validatedPort = validateRemotePort(port); + return validatedPort ?? getRemoteDefaultPort(protocol); } /** diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index c6daf020..5ebb6226 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -215,4 +215,6 @@ export interface ResolvedProxyConfig { forceLocal: boolean; /** Remote proxy connection timeout in ms (default: 2000) */ timeout?: number; + /** Allow self-signed certificates for HTTPS connections (default: true) */ + allowSelfSigned?: boolean; } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index f3c8334b..ddfa96ef 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -214,6 +214,16 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['/ccs:continue "follow-up"', 'Continue last delegation session'], ]); + // Delegation CLI Flags (Claude Code passthrough) + printSubSection('Delegation Flags (Claude Code passthrough)', [ + ['--max-turns ', 'Limit agentic turns (prevents loops)'], + ['--fallback-model ', 'Auto-fallback on overload (sonnet)'], + ['--agents ', 'Inject dynamic subagents'], + ['--betas ', 'Enable experimental features'], + ['--allowedTools ', 'Restrict available tools'], + ['--disallowedTools ', 'Block specific tools'], + ]); + // Diagnostics printSubSection('Diagnostics', [ ['ccs setup', 'First-time setup wizard'], @@ -259,6 +269,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['--proxy-timeout ', 'Connection timeout in ms (default: 2000)'], ['--local-proxy', 'Force local mode, ignore remote config'], ['--remote-only', 'Fail if remote unreachable (no fallback)'], + ['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'], ]); // CLI Proxy env vars @@ -269,6 +280,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['CCS_PROXY_AUTH_TOKEN', 'Auth token'], ['CCS_PROXY_TIMEOUT', 'Connection timeout in ms'], ['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'], + ['CCS_ALLOW_SELF_SIGNED', 'Allow self-signed certs (1/0)'], ]); // CLI Proxy paths diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index bfe4aa61..75c75f6f 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -5,7 +5,36 @@ import { SessionManager } from './session-manager'; import { ResultFormatter } from './result-formatter'; import { DelegationValidator } from '../utils/delegation-validator'; import { SettingsParser } from './settings-parser'; -import { fail } from '../utils/ui'; +import { fail, warn } from '../utils/ui'; + +/** + * Parse and validate a string flag value + * @returns value if valid, undefined if invalid/missing + */ +function parseStringFlag( + args: string[], + flagName: string, + options?: { allowDashPrefix?: boolean } +): string | undefined { + const index = args.indexOf(flagName); + if (index === -1 || index >= args.length - 1) return undefined; + + const value = args[index + 1]; + + // Reject dash-prefixed values (likely another flag) + if (!options?.allowDashPrefix && value.startsWith('-')) { + console.error(warn(`${flagName} value "${value}" looks like a flag. Ignoring.`)); + return undefined; + } + + // Reject empty/whitespace-only + if (!value.trim()) { + console.error(warn(`${flagName} value is empty. Ignoring.`)); + return undefined; + } + + return value; +} interface ParsedArgs { profile: string; @@ -17,7 +46,12 @@ interface ParsedArgs { timeout?: number; resumeSession?: boolean; sessionId?: string; - extraArgs?: string[]; // Passthrough args for Claude CLI + // Claude Code CLI passthrough flags (explicit) + maxTurns?: number; + fallbackModel?: string; + agents?: string; + betas?: string; + extraArgs?: string[]; // Catch-all for new/unknown flags }; } @@ -181,15 +215,69 @@ export class DelegationHandler { options.permissionMode = args[permModeIndex + 1]; } - // Parse timeout + // Parse timeout (validated: positive integer, max 10 minutes) const timeoutIndex = args.indexOf('--timeout'); if (timeoutIndex !== -1 && timeoutIndex < args.length - 1) { - options.timeout = parseInt(args[timeoutIndex + 1], 10); + const rawVal = args[timeoutIndex + 1]; + const val = parseInt(rawVal, 10); + if (!isNaN(val) && val > 0 && val <= 600000) { + options.timeout = val; + } else if (isNaN(val)) { + console.error(warn(`--timeout "${rawVal}" is not a number. Using default.`)); + } else if (val <= 0) { + console.error(warn(`--timeout ${val} must be positive. Using default.`)); + } else if (val > 600000) { + console.error(warn(`--timeout ${val} exceeds max (600000ms). Using default.`)); + } } + // Parse --max-turns (limit agentic turns, max 100) + const maxTurnsIndex = args.indexOf('--max-turns'); + if (maxTurnsIndex !== -1 && maxTurnsIndex < args.length - 1) { + const rawVal = args[maxTurnsIndex + 1]; + const val = parseInt(rawVal, 10); + if (!isNaN(val) && val > 0 && val <= 100) { + options.maxTurns = val; + } else if (isNaN(val)) { + console.error(warn(`--max-turns "${rawVal}" is not a number. Ignoring.`)); + } else if (val <= 0) { + console.error(warn(`--max-turns ${val} must be positive. Ignoring.`)); + } else if (val > 100) { + console.error(warn(`--max-turns ${val} exceeds max (100). Using 100.`)); + options.maxTurns = 100; + } + } + + // Parse --fallback-model (auto-fallback on overload) + options.fallbackModel = parseStringFlag(args, '--fallback-model'); + + // Parse --agents (dynamic subagent JSON) + const agentsValue = parseStringFlag(args, '--agents'); + if (agentsValue) { + // Validate JSON structure + try { + JSON.parse(agentsValue); + options.agents = agentsValue; + } catch { + console.error(warn('--agents must be valid JSON. Ignoring.')); + } + } + + // Parse --betas (experimental features) + options.betas = parseStringFlag(args, '--betas'); + // Collect extra args to pass through to Claude CLI // CCS-handled flags with values (skip these and their values): - const ccsFlagsWithValue = new Set(['-p', '--prompt', '--timeout', '--permission-mode']); + const ccsFlagsWithValue = new Set([ + '-p', + '--prompt', + '--timeout', + '--permission-mode', + '--max-turns', + '--fallback-model', + '--agents', + '--betas', + ]); const extraArgs: string[] = []; const profile = this._extractProfile(args); diff --git a/src/delegation/executor/types.ts b/src/delegation/executor/types.ts index 4efd61c6..0bb0b922 100644 --- a/src/delegation/executor/types.ts +++ b/src/delegation/executor/types.ts @@ -55,7 +55,12 @@ export interface ExecutionOptions { resumeSession?: boolean; sessionId?: string; maxRetries?: number; - extraArgs?: string[]; // Passthrough args for Claude CLI + // Claude Code CLI passthrough flags (explicit) + maxTurns?: number; // --max-turns: Limit agentic turns (prevents infinite loops) + fallbackModel?: string; // --fallback-model: Auto-fallback when model overloaded + agents?: string; // --agents: Dynamic subagent JSON injection + betas?: string; // --betas: Enable experimental features + extraArgs?: string[]; // Catch-all for new/unknown flags (future-proof) } /** diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 815c73a2..a2eceec1 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -41,6 +41,10 @@ export class HeadlessExecutor { permissionMode = 'acceptEdits', resumeSession = false, sessionId = null, + maxTurns, + fallbackModel, + agents, + betas, extraArgs = [], } = options; @@ -116,9 +120,39 @@ export class HeadlessExecutor { args.push('--disallowedTools', ...toolRestrictions.disallowedTools); } - // Passthrough extra args + // Claude Code CLI passthrough flags (explicit, validated) + // Use undefined checks (not truthy) to allow empty strings if ever valid + if (maxTurns !== undefined && maxTurns > 0) { + args.push('--max-turns', String(maxTurns)); + } + if (fallbackModel !== undefined && fallbackModel) { + args.push('--fallback-model', fallbackModel); + } + if (agents !== undefined && agents) { + args.push('--agents', agents); + } + if (betas !== undefined && betas) { + args.push('--betas', betas); + } + + // Passthrough extra args (catch-all for new/unknown flags) + // Filter out duplicates of explicitly handled flags if (extraArgs.length > 0) { - args.push(...extraArgs); + const explicitFlags = new Set(['--max-turns', '--fallback-model', '--agents', '--betas']); + const filteredExtras: string[] = []; + for (let i = 0; i < extraArgs.length; i++) { + if (explicitFlags.has(extraArgs[i])) { + // Skip this flag and its value (next element) + if (i + 1 < extraArgs.length && !extraArgs[i + 1].startsWith('-')) { + i++; // Skip value too + } + continue; + } + filteredExtras.push(extraArgs[i]); + } + if (filteredExtras.length > 0) { + args.push(...filteredExtras); + } } if (process.env.CCS_DEBUG) { diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index ae15b382..4b4c3b50 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -174,6 +174,7 @@ export class InteractivePrompt { process.stdin.setRawMode(false); } process.stdin.removeListener('data', onData); + process.stdin.pause(); }; const onData = (data: Buffer): void => { diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index a4871e38..baa5d167 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -13,6 +13,10 @@ import { submitProjectSelection, getPendingSelection, } from '../../cliproxy/project-selection-handler'; +import { + cancelAllSessionsForProvider, + hasActiveSession, +} from '../../cliproxy/auth-session-manager'; import { fetchCliproxyStats } from '../../cliproxy/stats-fetcher'; import { getAllAccountsSummary, @@ -316,6 +320,35 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise { + const { provider } = req.params; + + // Validate provider + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ error: `Invalid provider: ${provider}` }); + return; + } + + // Check if there's an active session + if (!hasActiveSession(provider)) { + res.status(404).json({ error: 'No active authentication session for this provider' }); + return; + } + + // Cancel all sessions for this provider + const cancelledCount = cancelAllSessionsForProvider(provider); + + res.json({ + success: true, + cancelled: cancelledCount, + provider, + }); +}); + /** * GET /api/cliproxy/auth/project-selection/:sessionId - Get pending project selection prompt * Returns project list for user to select from during OAuth flow diff --git a/tests/unit/delegation/delegation-handler.test.ts b/tests/unit/delegation/delegation-handler.test.ts new file mode 100644 index 00000000..793e09e0 --- /dev/null +++ b/tests/unit/delegation/delegation-handler.test.ts @@ -0,0 +1,211 @@ +/** + * Tests for delegation-handler CLI flag passthrough feature + * Covers: parseStringFlag, timeout validation, max-turns validation, agents JSON validation + */ + +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; + +// Import the DelegationHandler class +import { DelegationHandler } from '../../../src/delegation/delegation-handler'; + +describe('DelegationHandler', () => { + let handler: DelegationHandler; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + handler = new DelegationHandler(); + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + describe('_extractOptions - timeout validation', () => { + it('accepts valid positive timeout', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '30000']); + expect(options.timeout).toBe(30000); + }); + + it('rejects NaN timeout with warning', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', 'abc']); + expect(options.timeout).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('rejects negative timeout with warning', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '-5000']); + expect(options.timeout).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('rejects zero timeout with warning', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '0']); + expect(options.timeout).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('rejects timeout exceeding max (600000ms) with warning', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '700000']); + expect(options.timeout).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('ignores missing timeout value at end of args', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--timeout']); + expect(options.timeout).toBeUndefined(); + }); + }); + + describe('_extractOptions - max-turns validation', () => { + it('accepts valid positive max-turns', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '10']); + expect(options.maxTurns).toBe(10); + }); + + it('rejects NaN max-turns with warning', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', 'abc']); + expect(options.maxTurns).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('rejects negative max-turns with warning', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '-5']); + expect(options.maxTurns).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('rejects zero max-turns with warning', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '0']); + expect(options.maxTurns).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('caps max-turns at 100 when exceeding limit', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '500']); + expect(options.maxTurns).toBe(100); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('accepts max-turns at exactly 100', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '100']); + expect(options.maxTurns).toBe(100); + }); + }); + + describe('_extractOptions - fallback-model validation', () => { + it('accepts valid fallback-model', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model', 'sonnet']); + expect(options.fallbackModel).toBe('sonnet'); + }); + + it('rejects dash-prefixed value with warning', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--fallback-model', + '--other-flag', + ]); + expect(options.fallbackModel).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('rejects empty string value', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model', '']); + expect(options.fallbackModel).toBeUndefined(); + }); + + it('rejects whitespace-only value', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model', ' ']); + expect(options.fallbackModel).toBeUndefined(); + }); + }); + + describe('_extractOptions - agents JSON validation', () => { + it('accepts valid JSON for agents', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--agents', + '{"name":"test"}', + ]); + expect(options.agents).toBe('{"name":"test"}'); + }); + + it('rejects invalid JSON with warning', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--agents', '{invalid json}']); + expect(options.agents).toBeUndefined(); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('rejects dash-prefixed value', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--agents', '--other']); + expect(options.agents).toBeUndefined(); + }); + + it('accepts JSON array for agents', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--agents', '[{"a":1}]']); + expect(options.agents).toBe('[{"a":1}]'); + }); + }); + + describe('_extractOptions - betas validation', () => { + it('accepts valid betas value', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--betas', 'feature1,feature2']); + expect(options.betas).toBe('feature1,feature2'); + }); + + it('rejects dash-prefixed value', () => { + const options = handler._extractOptions(['glm', '-p', 'test', '--betas', '--feature']); + expect(options.betas).toBeUndefined(); + }); + }); + + describe('_extractOptions - extraArgs passthrough', () => { + it('passes unknown flags through to extraArgs', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--unknown-flag', + 'value', + ]); + expect(options.extraArgs).toContain('--unknown-flag'); + expect(options.extraArgs).toContain('value'); + }); + + it('excludes CCS-handled flags from extraArgs', () => { + const options = handler._extractOptions([ + 'glm', + '-p', + 'test', + '--max-turns', + '10', + '--unknown', + 'val', + ]); + expect(options.extraArgs).not.toContain('--max-turns'); + expect(options.extraArgs).not.toContain('10'); + expect(options.extraArgs).toContain('--unknown'); + }); + }); + + describe('_extractProfile', () => { + it('extracts profile name from first non-flag arg', () => { + const profile = handler._extractProfile(['glm', '-p', 'test']); + expect(profile).toBe('glm'); + }); + + it('returns empty string when no profile found', () => { + const profile = handler._extractProfile(['-p', 'test']); + expect(profile).toBe(''); + }); + + it('skips flag values correctly', () => { + const profile = handler._extractProfile(['-p', 'test', 'kimi']); + expect(profile).toBe('kimi'); + }); + }); +}); diff --git a/tests/unit/delegation/headless-executor.test.ts b/tests/unit/delegation/headless-executor.test.ts new file mode 100644 index 00000000..cb78d954 --- /dev/null +++ b/tests/unit/delegation/headless-executor.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for headless-executor CLI flag passthrough feature + * Covers: duplicate flag filtering, undefined checks, flag construction + */ + +import { describe, it, expect } from 'bun:test'; + +describe('HeadlessExecutor flag construction', () => { + // Test the flag construction logic by simulating the filtering behavior + // Since HeadlessExecutor.execute() spawns a process, we test the logic directly + + describe('Duplicate flag filtering', () => { + function filterExtraArgs( + extraArgs: string[], + explicitFlags: Set + ): string[] { + const filteredExtras: string[] = []; + for (let i = 0; i < extraArgs.length; i++) { + if (explicitFlags.has(extraArgs[i])) { + // Skip this flag and its value (next element) + if (i + 1 < extraArgs.length && !extraArgs[i + 1].startsWith('-')) { + i++; // Skip value too + } + continue; + } + filteredExtras.push(extraArgs[i]); + } + return filteredExtras; + } + + const explicitFlags = new Set(['--max-turns', '--fallback-model', '--agents', '--betas']); + + it('removes duplicate --max-turns from extraArgs', () => { + const result = filterExtraArgs(['--max-turns', '10', '--verbose'], explicitFlags); + expect(result).not.toContain('--max-turns'); + expect(result).not.toContain('10'); + expect(result).toContain('--verbose'); + }); + + it('removes duplicate --fallback-model from extraArgs', () => { + const result = filterExtraArgs(['--fallback-model', 'opus', '--debug'], explicitFlags); + expect(result).not.toContain('--fallback-model'); + expect(result).not.toContain('opus'); + expect(result).toContain('--debug'); + }); + + it('removes duplicate --agents from extraArgs', () => { + const result = filterExtraArgs(['--agents', '{"x":1}', '--json'], explicitFlags); + expect(result).not.toContain('--agents'); + expect(result).not.toContain('{"x":1}'); + expect(result).toContain('--json'); + }); + + it('removes duplicate --betas from extraArgs', () => { + const result = filterExtraArgs(['--betas', 'feature1', '--output', 'json'], explicitFlags); + expect(result).not.toContain('--betas'); + expect(result).not.toContain('feature1'); + expect(result).toContain('--output'); + expect(result).toContain('json'); + }); + + it('handles flag at end of array without value', () => { + const result = filterExtraArgs(['--verbose', '--max-turns'], explicitFlags); + expect(result).toContain('--verbose'); + expect(result).not.toContain('--max-turns'); + }); + + it('handles flag with dash-prefixed next arg (not a value)', () => { + const result = filterExtraArgs(['--max-turns', '--verbose'], explicitFlags); + // --max-turns is filtered, but --verbose is kept because it starts with - + expect(result).not.toContain('--max-turns'); + expect(result).toContain('--verbose'); + }); + + it('preserves unknown flags', () => { + const result = filterExtraArgs(['--custom-flag', 'value', '--another'], explicitFlags); + expect(result).toEqual(['--custom-flag', 'value', '--another']); + }); + + it('handles empty array', () => { + const result = filterExtraArgs([], explicitFlags); + expect(result).toEqual([]); + }); + + it('removes multiple duplicate flags', () => { + const result = filterExtraArgs( + ['--max-turns', '5', '--agents', '{}', '--fallback-model', 'sonnet'], + explicitFlags + ); + expect(result).toEqual([]); + }); + }); + + describe('Flag undefined vs truthy checks', () => { + // Simulate the flag construction logic + function buildArgs(options: { + maxTurns?: number; + fallbackModel?: string; + agents?: string; + betas?: string; + }): string[] { + const args: string[] = []; + const { maxTurns, fallbackModel, agents, betas } = options; + + if (maxTurns !== undefined && maxTurns > 0) { + args.push('--max-turns', String(maxTurns)); + } + if (fallbackModel !== undefined && fallbackModel) { + args.push('--fallback-model', fallbackModel); + } + if (agents !== undefined && agents) { + args.push('--agents', agents); + } + if (betas !== undefined && betas) { + args.push('--betas', betas); + } + + return args; + } + + it('handles all undefined options', () => { + const args = buildArgs({}); + expect(args).toEqual([]); + }); + + it('handles maxTurns = 0 (should not add flag)', () => { + const args = buildArgs({ maxTurns: 0 }); + expect(args).toEqual([]); + }); + + it('handles maxTurns > 0', () => { + const args = buildArgs({ maxTurns: 10 }); + expect(args).toEqual(['--max-turns', '10']); + }); + + it('handles empty string fallbackModel (should not add flag)', () => { + const args = buildArgs({ fallbackModel: '' }); + expect(args).toEqual([]); + }); + + it('handles valid fallbackModel', () => { + const args = buildArgs({ fallbackModel: 'sonnet' }); + expect(args).toEqual(['--fallback-model', 'sonnet']); + }); + + it('handles empty string agents (should not add flag)', () => { + const args = buildArgs({ agents: '' }); + expect(args).toEqual([]); + }); + + it('handles valid JSON agents', () => { + const args = buildArgs({ agents: '{"name":"test"}' }); + expect(args).toEqual(['--agents', '{"name":"test"}']); + }); + + it('handles all valid options', () => { + const args = buildArgs({ + maxTurns: 5, + fallbackModel: 'opus', + agents: '{}', + betas: 'feature1', + }); + expect(args).toContain('--max-turns'); + expect(args).toContain('--fallback-model'); + expect(args).toContain('--agents'); + expect(args).toContain('--betas'); + }); + }); +}); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index c8276ace..0290bda3 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -17,7 +17,7 @@ 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 } from '@/hooks/use-cliproxy'; +import { useStartAuth, useKiroImport, useCancelAuth } from '@/hooks/use-cliproxy'; import { applyDefaultPreset } from '@/lib/preset-utils'; import { toast } from 'sonner'; @@ -40,10 +40,19 @@ export function AddAccountDialog({ const [nickname, setNickname] = useState(''); const startAuthMutation = useStartAuth(); const kiroImportMutation = useKiroImport(); + const cancelAuthMutation = useCancelAuth(); const isKiro = provider === 'kiro'; const isPending = startAuthMutation.isPending || kiroImportMutation.isPending; + const handleCancel = () => { + if (isPending) { + cancelAuthMutation.mutate(provider); + } + setNickname(''); + onClose(); + }; + const handleStartAuth = () => { startAuthMutation.mutate( { provider, nickname: nickname.trim() || undefined }, @@ -83,9 +92,8 @@ export function AddAccountDialog({ }; const handleOpenChange = (isOpen: boolean) => { - if (!isOpen && !isPending) { - setNickname(''); - onClose(); + if (!isOpen) { + handleCancel(); } }; @@ -121,7 +129,7 @@ export function AddAccountDialog({
- {isKiro && ( diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 17aa3e7d..7e79ffaf 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -94,8 +94,9 @@ export function ProviderEditor({ const accounts = authStatus.accounts || []; const handleApplyPreset = (updates: Record) => { + const effectivePort = port ?? CLIPROXY_PORT; updateEnvValues({ - ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, + ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ...updates, }); @@ -103,8 +104,9 @@ export function ProviderEditor({ }; const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => { + const effectivePort = port ?? CLIPROXY_PORT; updateEnvValues({ - ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, + ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: values.default, ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus, diff --git a/ui/src/components/setup/wizard/index.tsx b/ui/src/components/setup/wizard/index.tsx index cea897c4..29e98e76 100644 --- a/ui/src/components/setup/wizard/index.tsx +++ b/ui/src/components/setup/wizard/index.tsx @@ -15,7 +15,12 @@ import { DialogDescription, } from '@/components/ui/dialog'; import { Sparkles } from 'lucide-react'; -import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy'; +import { + useCliproxyAuth, + useCreateVariant, + useStartAuth, + useCancelAuth, +} from '@/hooks/use-cliproxy'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import { applyDefaultPreset } from '@/lib/preset-utils'; import { usePrivacy } from '@/contexts/privacy-context'; @@ -42,6 +47,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { const { data: authData, refetch } = useCliproxyAuth(); const createMutation = useCreateVariant(); const startAuthMutation = useStartAuth(); + const cancelAuthMutation = useCancelAuth(); const { privacyMode } = usePrivacy(); // Get auth status for selected provider @@ -146,6 +152,10 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) { // Prevent accidental close when user has made progress const handleOpenChange = (isOpen: boolean) => { if (!isOpen) { + // Cancel any in-progress auth when closing + if (startAuthMutation.isPending && selectedProvider) { + cancelAuthMutation.mutate(selectedProvider); + } if (step === 'success' || step === 'provider') { onClose(); return; diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 5bc452b5..a91a05c3 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -6,6 +6,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; +import { api } from '@/lib/api-client'; interface AuthFlowState { provider: string | null; @@ -82,9 +83,16 @@ export function useCliproxyAuthFlow() { ); const cancelAuth = useCallback(() => { + const currentProvider = state.provider; abortControllerRef.current?.abort(); setState({ provider: null, isAuthenticating: false, error: null }); - }, []); + // Also cancel on backend + if (currentProvider) { + api.cliproxy.auth.cancel(currentProvider).catch(() => { + // Ignore errors - session may have already completed + }); + } + }, [state.provider]); return useMemo( () => ({ diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index 7dd7811b..d4b6ec9c 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -136,6 +136,16 @@ export function useStartAuth() { }); } +// Cancel OAuth flow hook +export function useCancelAuth() { + return useMutation({ + mutationFn: (provider: string) => api.cliproxy.auth.cancel(provider), + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + // Kiro IDE import hook (alternative auth path when OAuth callback fails) export function useKiroImport() { const queryClient = useQueryClient(); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 0a003cb4..60e3ec02 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -357,6 +357,12 @@ export const api = { method: 'POST', body: JSON.stringify({ nickname }), }), + /** Cancel in-progress OAuth flow */ + cancel: (provider: string) => + request<{ success: boolean; cancelled: number; provider: string }>( + `/cliproxy/auth/${provider}/cancel`, + { method: 'POST' } + ), /** Import Kiro token from Kiro IDE (Kiro only) */ kiroImport: () => request<{ success: boolean; account: OAuthAccount | null; error?: string }>( diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index 7b74ed6b..80ce9673 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -13,10 +13,12 @@ export const CLIPROXY_PORT = 8317; * Uses the first model's presetMapping or falls back to using defaultModel for all tiers * * @param provider - The provider ID (e.g., 'gemini', 'codex', 'agy') + * @param port - Optional custom port (defaults to CLIPROXY_PORT) * @returns Object with success status and applied preset name */ export async function applyDefaultPreset( - provider: string + provider: string, + port?: number ): Promise<{ success: boolean; presetName?: string }> { const catalog = MODEL_CATALOGS[provider]; if (!catalog) return { success: false }; @@ -30,9 +32,10 @@ export async function applyDefaultPreset( haiku: catalog.defaultModel, }; + const effectivePort = port ?? CLIPROXY_PORT; const settings = { env: { - ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`, + ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: mapping.default, ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,