feat(release): CLI flag passthrough, proxy fixes, and UI improvements (#239)

* 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 <noreply@anthropic.com>

* chore(release): 7.11.1-dev.6 [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Shun Kakinoki <39187513+shunkakinoki@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kai (Tam Nhu) Tran
2025-12-31 16:36:18 -08:00
committed by GitHub
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com> Shun Kakinoki Claude Opus 4.5
parent cf83425bcf
commit b3ef76a07b
26 changed files with 882 additions and 55 deletions
+1 -1
View File
@@ -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",
+5
View File
@@ -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<string, string>;
/** 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,
};
}
+5 -1
View File
@@ -511,7 +511,11 @@ async function main(): Promise<void> {
// 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');
+119
View File
@@ -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<string, ActiveAuthSession>();
/**
* 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;
}
+25
View File
@@ -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<Accou
userCode: null,
};
// Register session for cancellation support
registerAuthSession(state.sessionId, provider);
attachProcessToSession(state.sessionId, authProcess);
// Listen for external cancel signal
const handleCancel = (cancelledSessionId: string) => {
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<Accou
// H5: Remove signal handlers before killing process
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
authSessionEvents.removeListener('session:cancelled', handleCancel);
unregisterAuthSession(state.sessionId);
authProcess.kill();
console.log('');
console.log(fail(`OAuth timed out after ${headless ? 5 : 2} minutes`));
@@ -391,6 +412,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
authSessionEvents.removeListener('session:cancelled', handleCancel);
unregisterAuthSession(state.sessionId);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (code === 0) {
@@ -442,6 +465,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
// H5: Remove signal handlers to prevent memory leaks
process.removeListener('SIGINT', cleanup);
process.removeListener('SIGTERM', cleanup);
authSessionEvents.removeListener('session:cancelled', handleCancel);
unregisterAuthSession(state.sessionId);
console.log('');
console.log(fail(`Failed to start auth process: ${error.message}`));
resolve(null);
+12 -2
View File
@@ -154,7 +154,17 @@ export async function execClaudeWithCLIProxy(
});
// Use resolved port from proxy config (overrides ExecutorConfig)
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
// Priority: CLI flags > 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) {
+53 -10
View File
@@ -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<string, string> {
// 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();
+25 -1
View File
@@ -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'
);
}
+12 -8
View File
@@ -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,
};
+4 -13
View File
@@ -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);
}
/**
+2
View File
@@ -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;
}
+12
View File
@@ -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 <n>', 'Limit agentic turns (prevents loops)'],
['--fallback-model <model>', 'Auto-fallback on overload (sonnet)'],
['--agents <json>', 'Inject dynamic subagents'],
['--betas <features>', 'Enable experimental features'],
['--allowedTools <list>', 'Restrict available tools'],
['--disallowedTools <list>', '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 <ms>', '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
+93 -5
View File
@@ -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);
+6 -1
View File
@@ -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)
}
/**
+36 -2
View File
@@ -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) {
+1
View File
@@ -174,6 +174,7 @@ export class InteractivePrompt {
process.stdin.setRawMode(false);
}
process.stdin.removeListener('data', onData);
process.stdin.pause();
};
const onData = (data: Buffer): void => {
@@ -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<voi
}
});
/**
* POST /api/cliproxy/auth/:provider/cancel - Cancel in-progress OAuth flow
* Terminates the OAuth process for the specified provider
*/
router.post('/:provider/cancel', (req: Request, res: Response): void => {
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
@@ -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<typeof spyOn>;
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');
});
});
});
@@ -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>
): 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');
});
});
});
@@ -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({
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" onClick={onClose} disabled={isPending}>
<Button variant="ghost" onClick={handleCancel}>
Cancel
</Button>
{isKiro && (
@@ -94,8 +94,9 @@ export function ProviderEditor({
const accounts = authStatus.accounts || [];
const handleApplyPreset = (updates: Record<string, 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',
...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,
+11 -1
View File
@@ -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;
+9 -1
View File
@@ -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(
() => ({
+10
View File
@@ -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();
+6
View File
@@ -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 }>(
+5 -2
View File
@@ -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,