mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
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:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Shun Kakinoki
Claude Opus 4.5
parent
cf83425bcf
commit
b3ef76a07b
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user