Merge pull request #467 from kaitranntt/dev

feat: release v7.36.0 with shell escaping, backend switching, and quota improvements
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-05 11:31:22 -05:00
commit 93e9409548
17 files changed
+1385 -39

No files matched your search

+2 -1
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@kaitranntt/ccs",
@@ -15,6 +14,8 @@
"express-session": "^1.18.2",
"get-port": "^5.1.1",
"gradient-string": "^2.0.2",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"js-yaml": "^4.1.1",
"listr2": "^3.14.0",
"open": "^8.4.2",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.36.0",
"version": "7.36.0-dev.6",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
@@ -93,6 +93,8 @@
"express-session": "^1.18.2",
"get-port": "^5.1.1",
"gradient-string": "^2.0.2",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"js-yaml": "^4.1.1",
"listr2": "^3.14.0",
"open": "^8.4.2",
+7 -2
View File
@@ -26,8 +26,13 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
}
try {
const profile = ctx.registry.getProfile(profileName);
const defaultProfile = ctx.registry.getDefaultProfile();
// Use merged profiles (checks unified config first, falls back to legacy)
const allProfiles = ctx.registry.getAllProfilesMerged();
const profile = allProfiles[profileName];
if (!profile) {
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
}
const defaultProfile = ctx.registry.getDefaultResolved();
const isDefault = profileName === defaultProfile;
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
+2
View File
@@ -221,4 +221,6 @@ export interface OAuthOptions {
import?: boolean;
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
pasteCallback?: boolean;
/** If true, use port-forwarding mode (skip interactive prompt in headless) */
portForward?: boolean;
}
+65
View File
@@ -58,6 +58,55 @@ async function promptAddAccount(): Promise<boolean> {
});
}
/**
* Prompt user to choose OAuth mode for headless environment
* Returns 'paste' for paste-callback mode or 'forward' for port-forwarding
*/
async function promptOAuthModeChoice(callbackPort: number | null): Promise<'paste' | 'forward'> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log('');
console.log(info('Headless environment detected (SSH session)'));
console.log(' OAuth requires choosing a mode:');
console.log('');
console.log(' [1] Paste-callback (recommended for VPS)');
console.log(' Open URL in any browser, paste redirect URL back');
console.log('');
console.log(' [2] Port forwarding (advanced)');
if (callbackPort) {
console.log(` Requires: ssh -L ${callbackPort}:localhost:${callbackPort} <USER>@<HOST>`);
} else {
console.log(' Requires SSH tunnel to callback port');
}
console.log('');
return new Promise<'paste' | 'forward'>((resolve) => {
let resolved = false;
// Handle Ctrl+C gracefully
rl.on('close', () => {
if (!resolved) {
resolved = true;
resolve('paste'); // Safe default on cancel
}
});
rl.question('[?] Which mode? (1/2): ', (answer) => {
const choice = answer.trim();
if (choice !== '1' && choice !== '2') {
console.log(info('Invalid choice, using paste-callback mode'));
}
resolved = true;
rl.close();
resolve(choice === '2' ? 'forward' : 'paste');
});
});
}
/**
* Prompt user for account nickname (required for kiro/ghcp)
* Returns null if user cancels
@@ -389,6 +438,22 @@ export async function triggerOAuth(
const headless = options.headless ?? isHeadlessEnvironment();
const isDeviceCodeFlow = callbackPort === null;
// Interactive mode selection for headless environments
// Skip if explicit mode flag provided or device code flow (no callback needed)
if (headless && !options.pasteCallback && !options.portForward && !isDeviceCodeFlow) {
// Non-interactive environment (piped input) - default to paste mode
if (!process.stdin.isTTY) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
}
const mode = await promptOAuthModeChoice(callbackPort);
if (mode === 'paste') {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname);
}
// mode === 'forward' continues to existing port-forwarding flow below
}
if (existingAccounts.length > 0 && !add) {
console.log('');
console.log(
+106 -4
View File
@@ -2,13 +2,107 @@
* Binary Downloader
* Handles downloading files with retry logic, progress tracking, and redirect following.
* Robust handling for transient network errors (socket hang up, ECONNRESET, etc.)
* Respects http_proxy, https_proxy, and all_proxy environment variables.
*/
import * as fs from 'fs';
import * as https from 'https';
import * as http from 'http';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { HttpProxyAgent } from 'http-proxy-agent';
import { DownloadResult, ProgressCallback } from '../types';
/**
* Get proxy URL from environment variables.
* Checks: https_proxy, HTTPS_PROXY, http_proxy, HTTP_PROXY, all_proxy, ALL_PROXY
* @param isHttps Whether the target URL is HTTPS
* @returns Proxy URL or undefined if no proxy configured
*/
function getProxyUrl(isHttps: boolean): string | undefined {
if (isHttps) {
return (
process.env.https_proxy ||
process.env.HTTPS_PROXY ||
process.env.all_proxy ||
process.env.ALL_PROXY
);
}
return (
process.env.http_proxy ||
process.env.HTTP_PROXY ||
process.env.all_proxy ||
process.env.ALL_PROXY
);
}
/**
* Check if a hostname should bypass the proxy based on NO_PROXY/no_proxy env var.
* Supports: exact match, wildcard (*), and domain suffix (.example.com)
* @param hostname The hostname to check
* @returns true if the hostname should bypass the proxy
*/
function shouldBypassProxy(hostname: string): boolean {
const noProxy = process.env.no_proxy || process.env.NO_PROXY;
if (!noProxy) return false;
const noProxyList = noProxy.split(',').map((s) => s.trim().toLowerCase());
const host = hostname.toLowerCase();
return noProxyList.some((pattern) => {
if (pattern === '*') return true;
if (pattern.startsWith('.')) {
return host.endsWith(pattern) || host === pattern.slice(1);
}
return host === pattern || host.endsWith('.' + pattern);
});
}
/**
* Extract hostname from URL.
* @param url The URL to parse
* @returns Hostname or empty string if invalid
*/
function getHostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
return '';
}
}
/**
* Create appropriate proxy agent based on URL protocol.
* Respects NO_PROXY/no_proxy for bypassing specific hosts.
* @param url Target URL to determine protocol
* @returns Proxy agent or false (no agent/pooling disabled)
*/
function getProxyAgent(url: string): http.Agent | https.Agent | false {
const isHttps = url.startsWith('https');
const proxyUrl = getProxyUrl(isHttps);
if (!proxyUrl) {
return false; // No proxy configured, disable connection pooling for clean exit
}
// Check if this host should bypass the proxy
const hostname = getHostname(url);
if (hostname && shouldBypassProxy(hostname)) {
return false; // Bypass proxy for this host
}
// Create proxy agent with error handling for malformed URLs
try {
if (isHttps) {
return new HttpsProxyAgent(proxyUrl);
}
return new HttpProxyAgent(proxyUrl);
} catch {
// Invalid proxy URL, fall back to direct connection
console.error(`[cliproxy] Invalid proxy URL: ${proxyUrl}`);
return false;
}
}
/** Default configuration for downloader */
export interface DownloaderConfig {
/** Maximum retry attempts */
@@ -154,12 +248,12 @@ export function downloadFile(
const protocol = url.startsWith('https') ? https : http;
// Use agent: false to prevent connection pooling (allows process to exit)
// Use proxy agent if configured, otherwise disable connection pooling for clean exit
const options = {
headers: {
'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0',
},
agent: false, // Disable connection pooling for clean exit
agent: getProxyAgent(url),
};
const req = protocol.get(url, options, handleResponse);
@@ -288,7 +382,7 @@ function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise<s
headers: {
'User-Agent': 'CCS-CLIProxyPlus-Downloader/1.0',
},
agent: false, // Disable connection pooling for clean exit
agent: getProxyAgent(url),
};
const req = protocol.get(url, options, handleResponse);
@@ -352,7 +446,7 @@ function fetchJsonOnce(
'User-Agent': 'CCS-CLIProxyPlus-Updater/1.0',
Accept: 'application/vnd.github.v3+json',
},
agent: false, // Disable connection pooling for clean exit
agent: getProxyAgent(url),
};
const handleResponse = (res: http.IncomingMessage) => {
@@ -445,3 +539,11 @@ export async function fetchJson(
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Export internal functions for testing
export const __testExports = {
getProxyUrl,
shouldBypassProxy,
getHostname,
getProxyAgent,
};
+12
View File
@@ -316,7 +316,17 @@ export async function execClaudeWithCLIProxy(
// 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
const forceAuth = argsWithoutProxy.includes('--auth');
const pasteCallback = argsWithoutProxy.includes('--paste-callback');
const portForward = argsWithoutProxy.includes('--port-forward');
const forceHeadless = argsWithoutProxy.includes('--headless');
// Validate conflicting flags
if (pasteCallback && portForward) {
console.error(fail('Cannot use --paste-callback with --port-forward'));
console.error(' --paste-callback: Manually paste OAuth redirect URL');
console.error(' --port-forward: Use SSH port forwarding for callback');
process.exit(1);
}
const forceLogout = argsWithoutProxy.includes('--logout');
const forceConfig = argsWithoutProxy.includes('--config');
const addAccount = argsWithoutProxy.includes('--add');
@@ -548,6 +558,7 @@ export async function execClaudeWithCLIProxy(
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
...(pasteCallback ? { pasteCallback: true } : {}),
...(portForward ? { portForward: true } : {}),
});
if (!authSuccess) {
throw new Error(`Authentication required for ${providerConfig.displayName}`);
@@ -991,6 +1002,7 @@ export async function execClaudeWithCLIProxy(
const ccsFlags = [
'--auth',
'--paste-callback',
'--port-forward',
'--headless',
'--logout',
'--config',
+4
View File
@@ -205,3 +205,7 @@ export { ToolNameMapper } from './tool-name-mapper';
export type { ToolSanitizationProxyConfig } from './tool-sanitization-proxy';
export { ToolSanitizationProxy } from './tool-sanitization-proxy';
// Schema sanitization (for MCP input_schema non-standard property removal)
export type { SchemaSanitizationResult } from './schema-sanitizer';
export { sanitizeInputSchema, sanitizeToolSchemas } from './schema-sanitizer';
+369
View File
@@ -0,0 +1,369 @@
/**
* Schema Sanitizer
*
* Sanitizes MCP tool input_schema to remove non-standard JSON Schema properties
* that Gemini/Vertex APIs reject.
*
* MCP servers (especially design tools) include UI-specific metadata in schemas:
* - cornerRadius, fillColor, fontFamily, fontSize, fontWeight, gap, padding, etc.
*
* These are valid as MCP hints but invalid for strict JSON Schema validation.
*/
/** Valid JSON Schema Draft-07 keywords (used by Anthropic/Gemini APIs) */
const VALID_JSON_SCHEMA_KEYWORDS = new Set([
// Core
'type',
'properties',
'required',
'items',
'enum',
'const',
'default',
// String validation
'minLength',
'maxLength',
'pattern',
'format',
// Number validation
'minimum',
'maximum',
'exclusiveMinimum',
'exclusiveMaximum',
'multipleOf',
// Array validation
'minItems',
'maxItems',
'uniqueItems',
'contains',
'additionalItems',
// Object validation
'additionalProperties',
'patternProperties',
'minProperties',
'maxProperties',
'propertyNames',
'dependencies',
// Composition
'oneOf',
'anyOf',
'allOf',
'not',
'if',
'then',
'else',
// Metadata (allowed by most APIs)
'title',
'description',
'$id',
'$schema',
'$ref',
'$defs',
'definitions',
'$comment',
'examples',
'readOnly',
'writeOnly',
'deprecated',
'contentMediaType',
'contentEncoding',
]);
/** Maximum recursion depth to prevent stack overflow */
const MAX_DEPTH = 100;
export interface SchemaSanitizationResult {
/** The sanitized schema */
schema: Record<string, unknown>;
/** Number of properties removed */
removedCount: number;
/** List of removed property paths for logging */
removedPaths: string[];
}
/**
* Check if a key is a valid JSON Schema keyword.
*/
function isValidSchemaKey(key: string): boolean {
return VALID_JSON_SCHEMA_KEYWORDS.has(key);
}
/**
* Recursively sanitize a JSON Schema object.
* Removes non-standard properties while preserving valid schema structure.
*
* @param schema The schema object to sanitize
* @param path Current path for logging (e.g., "properties.foo.items")
* @param removedPaths Accumulator for removed property paths
* @param visited WeakSet to track visited objects and prevent circular references
* @param depth Current recursion depth to prevent stack overflow
* @returns Sanitized schema object
*/
function sanitizeSchemaRecursive(
schema: unknown,
path: string,
removedPaths: string[],
visited: WeakSet<object> = new WeakSet(),
depth: number = 0
): unknown {
// Guard: prevent stack overflow from deep nesting
if (depth > MAX_DEPTH) {
return schema;
}
// Handle non-objects (primitives, null)
if (typeof schema !== 'object' || schema === null) {
return schema;
}
// Guard: prevent infinite recursion from circular references
if (visited.has(schema)) {
return schema;
}
// Mark object as visited
visited.add(schema);
// Handle arrays
if (Array.isArray(schema)) {
return schema.map((item, index) =>
sanitizeSchemaRecursive(item, `${path}[${index}]`, removedPaths, visited, depth + 1)
);
}
// Handle objects
const result: Record<string, unknown> = {};
const obj = schema as Record<string, unknown>;
for (const [key, value] of Object.entries(obj)) {
const keyPath = path ? `${path}.${key}` : key;
// Special handling for nested schema containers
if (key === 'properties' && typeof value === 'object' && value !== null) {
// Recurse into each property definition
const sanitizedProps: Record<string, unknown> = {};
for (const [propName, propSchema] of Object.entries(value as Record<string, unknown>)) {
sanitizedProps[propName] = sanitizeSchemaRecursive(
propSchema,
`${keyPath}.${propName}`,
removedPaths,
visited,
depth + 1
);
}
result[key] = sanitizedProps;
continue;
}
if (key === 'items') {
// items can be object or array of objects
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
if (key === 'additionalProperties' && typeof value === 'object') {
// Can be boolean or schema object
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
if (key === 'additionalItems' && typeof value === 'object' && value !== null) {
// Can be boolean or schema object (tuple validation)
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
// Composition keywords contain schema arrays
if (['oneOf', 'anyOf', 'allOf'].includes(key) && Array.isArray(value)) {
result[key] = value.map((item, index) =>
sanitizeSchemaRecursive(item, `${keyPath}[${index}]`, removedPaths, visited, depth + 1)
);
continue;
}
if (key === 'not' && typeof value === 'object') {
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
if (key === 'propertyNames' && typeof value === 'object') {
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
// Conditional keywords
if (['if', 'then', 'else'].includes(key) && typeof value === 'object') {
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
if (key === 'contains' && typeof value === 'object') {
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
continue;
}
if (key === '$defs' || key === 'definitions') {
// Definition containers
if (typeof value === 'object' && value !== null) {
const sanitizedDefs: Record<string, unknown> = {};
for (const [defName, defSchema] of Object.entries(value as Record<string, unknown>)) {
sanitizedDefs[defName] = sanitizeSchemaRecursive(
defSchema,
`${keyPath}.${defName}`,
removedPaths,
visited,
depth + 1
);
}
result[key] = sanitizedDefs;
continue;
}
}
if (key === 'patternProperties') {
// Pattern property containers - preserve pattern keys and sanitize schema values
if (typeof value === 'object' && value !== null) {
const sanitizedPatterns: Record<string, unknown> = {};
for (const [pattern, patternSchema] of Object.entries(value as Record<string, unknown>)) {
sanitizedPatterns[pattern] = sanitizeSchemaRecursive(
patternSchema,
`${keyPath}.${pattern}`,
removedPaths,
visited,
depth + 1
);
}
result[key] = sanitizedPatterns;
continue;
}
}
if (key === 'dependencies') {
if (typeof value === 'object' && value !== null) {
const sanitizedDeps: Record<string, unknown> = {};
for (const [depName, depValue] of Object.entries(value as Record<string, unknown>)) {
// Schema dependencies need recursion, property dependencies (arrays) pass through
if (typeof depValue === 'object' && depValue !== null && !Array.isArray(depValue)) {
sanitizedDeps[depName] = sanitizeSchemaRecursive(
depValue,
`${keyPath}.${depName}`,
removedPaths,
visited,
depth + 1
);
} else {
sanitizedDeps[depName] = depValue;
}
}
result[key] = sanitizedDeps;
continue;
}
}
// Check if this is a valid JSON Schema keyword
if (isValidSchemaKey(key)) {
// Recurse for nested objects that might contain schemas
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
result[key] = sanitizeSchemaRecursive(value, keyPath, removedPaths, visited, depth + 1);
} else {
result[key] = value;
}
} else {
// Non-standard property - remove it
removedPaths.push(keyPath);
}
}
return result;
}
/**
* Sanitize an input_schema object, removing non-standard JSON Schema properties.
*
* @param inputSchema The tool's input_schema object
* @returns Sanitization result with cleaned schema and metadata
*/
export function sanitizeInputSchema(
inputSchema: Record<string, unknown> | null | undefined
): SchemaSanitizationResult {
// Preserve null/undefined as-is (graceful handling of missing schemas)
if (inputSchema === null || inputSchema === undefined) {
return {
schema: inputSchema as unknown as Record<string, unknown>,
removedCount: 0,
removedPaths: [],
};
}
// Non-object types (arrays, primitives) - return empty object
if (typeof inputSchema !== 'object' || Array.isArray(inputSchema)) {
return { schema: {}, removedCount: 0, removedPaths: [] };
}
const removedPaths: string[] = [];
const sanitized = sanitizeSchemaRecursive(inputSchema, '', removedPaths) as Record<
string,
unknown
>;
return {
schema: sanitized,
removedCount: removedPaths.length,
removedPaths,
};
}
/**
* Sanitize an array of tools, cleaning their input_schema properties.
*
* @param tools Array of tool definitions
* @returns Object with sanitized tools and total removed count
*/
export function sanitizeToolSchemas(
tools: Array<{ name: string; input_schema?: Record<string, unknown>; [key: string]: unknown }>
): {
tools: typeof tools;
totalRemoved: number;
removedByTool: Array<{ name: string; removed: string[] }>;
} {
const removedByTool: Array<{ name: string; removed: string[] }> = [];
let totalRemoved = 0;
const sanitizedTools = tools.map((tool) => {
// Guard against non-object elements
if (typeof tool !== 'object' || tool === null) {
return tool;
}
if (!tool.input_schema || typeof tool.input_schema !== 'object') {
return tool;
}
const result = sanitizeInputSchema(tool.input_schema);
if (result.removedCount > 0) {
removedByTool.push({
name: tool.name,
removed: result.removedPaths,
});
totalRemoved += result.removedCount;
}
return {
...tool,
input_schema: result.schema,
};
});
return {
tools: sanitizedTools,
totalRemoved,
removedByTool,
};
}
+23 -3
View File
@@ -3,8 +3,9 @@
*
* HTTP proxy that intercepts Claude CLI → CLIProxy requests to:
* 1. Sanitize MCP tool names exceeding Gemini's 64-char limit
* 2. Forward sanitized requests to upstream
* 3. Restore original names in responses
* 2. Sanitize MCP tool input_schema to remove non-standard JSON Schema properties
* 3. Forward sanitized requests to upstream
* 4. Restore original names in responses
*
* Follows CodexReasoningProxy pattern for consistency.
*/
@@ -16,6 +17,7 @@ import * as path from 'path';
import * as os from 'os';
import { URL } from 'url';
import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper';
import { sanitizeToolSchemas } from './schema-sanitizer';
import { getCcsDir } from '../utils/config-manager';
export interface ToolSanitizationProxyConfig {
@@ -207,7 +209,25 @@ export class ToolSanitizationProxy {
// Sanitize tools if present
let modifiedBody = parsed;
if (isRecord(parsed) && Array.isArray(parsed.tools)) {
const sanitizedTools = mapper.registerTools(parsed.tools as Tool[]);
// Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties)
const schemaResult = sanitizeToolSchemas(
parsed.tools as Array<{ name: string; input_schema?: Record<string, unknown> }>
);
if (schemaResult.totalRemoved > 0) {
for (const entry of schemaResult.removedByTool) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} non-standard properties`
);
}
this.log(
`Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)`
);
}
// Step 2: Sanitize tool names (truncate to 64 chars for Gemini)
const sanitizedTools = mapper.registerTools(schemaResult.tools as Tool[]);
modifiedBody = { ...parsed, tools: sanitizedTools };
// Log sanitization warnings
+1
View File
@@ -184,6 +184,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs <provider> --port-forward', 'Force port-forwarding mode (skip prompt)'],
['ccs kiro --import', 'Import token from Kiro IDE'],
['ccs kiro --incognito', 'Use incognito browser (default: normal)'],
['ccs codex "explain code"', 'Use with prompt'],
+19 -20
View File
@@ -9,33 +9,32 @@ import { ErrorManager } from './error-manager';
import { getWebSearchHookEnv } from './websearch-manager';
/**
* Escape arguments for shell execution (Windows compatibility)
* Handles PowerShell special characters: backticks, $variables, double quotes
* Escape arguments for shell execution (cross-platform)
*
* IMPORTANT: On Windows, spawn({ shell: true }) uses cmd.exe by default,
* NOT PowerShell. cmd.exe does NOT recognize single quotes as string delimiters.
* We must use double quotes for cmd.exe compatibility.
*/
export function escapeShellArg(arg: string): string {
const isWindows = process.platform === 'win32';
if (isWindows) {
// PowerShell: Use single quotes for literal strings to prevent variable expansion
// Escape single quotes by doubling them (PowerShell syntax)
// Fallback to double quotes with escapes if single quotes present
if (arg.includes("'")) {
// Contains single quote - use double quotes with escape sequences
return (
'"' +
String(arg)
.replace(/\$/g, '`$') // Escape $ to prevent variable expansion
.replace(/`/g, '``') // Escape backticks
.replace(/"/g, '`"') + // Escape double quotes
'"'
);
} else {
// No single quotes - use single quotes for literal string (safest)
return "'" + String(arg) + "'";
}
// cmd.exe: Use double quotes, escape inner double quotes by doubling them
// cmd.exe interprets "" as escaped double quote inside quoted string
// Strip newlines/tabs that can break cmd.exe parsing
return (
'"' +
String(arg)
.replace(/[\r\n\t]/g, ' ') // Replace newlines/tabs with space
.replace(/%/g, '%%') // Escape percent signs
.replace(/\^/g, '^^') // Escape carets
.replace(/!/g, '^^!') // Escape exclamation marks (delayed expansion)
.replace(/"/g, '""') + // Escape quotes
'"'
);
} else {
// Unix/macOS: Double quotes with escaped inner quotes
return '"' + String(arg).replace(/"/g, '""') + '"';
return '"' + String(arg).replace(/"/g, '\\"') + '"';
}
}
+15 -2
View File
@@ -205,8 +205,21 @@ router.delete('/:name', (req: Request, res: Response): void => {
return;
}
// Delete the profile (legacy/unified)
registry.deleteProfile(name);
// Delete from appropriate config (unified and/or legacy)
let deleted = false;
if (isUnifiedMode() && registry.hasAccountUnified(name)) {
registry.removeAccountUnified(name);
deleted = true;
}
if (registry.hasProfile(name)) {
registry.deleteProfile(name);
deleted = true;
}
if (!deleted) {
res.status(404).json({ error: `Account not found: ${name}` });
return;
}
res.json({ success: true, deleted: name });
} catch (error) {
@@ -0,0 +1,260 @@
/**
* Binary Downloader Proxy Support Tests
*
* Tests for proxy environment variable detection and NO_PROXY bypass logic.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { __testExports } from '../../../src/cliproxy/binary/downloader';
const { getProxyUrl, shouldBypassProxy, getHostname, getProxyAgent } = __testExports;
describe('Binary Downloader Proxy Support', () => {
// Store original env vars to restore after tests
const originalEnv: Record<string, string | undefined> = {};
const proxyEnvVars = [
'http_proxy',
'HTTP_PROXY',
'https_proxy',
'HTTPS_PROXY',
'all_proxy',
'ALL_PROXY',
'no_proxy',
'NO_PROXY',
];
beforeEach(() => {
// Save original values
proxyEnvVars.forEach((key) => {
originalEnv[key] = process.env[key];
delete process.env[key];
});
});
afterEach(() => {
// Restore original values
proxyEnvVars.forEach((key) => {
if (originalEnv[key] !== undefined) {
process.env[key] = originalEnv[key];
} else {
delete process.env[key];
}
});
});
describe('getProxyUrl', () => {
describe('HTTPS requests', () => {
it('should return undefined when no proxy env vars are set', () => {
expect(getProxyUrl(true)).toBeUndefined();
});
it('should prefer https_proxy (lowercase)', () => {
process.env.https_proxy = 'http://proxy1:8080';
process.env.HTTPS_PROXY = 'http://proxy2:8080';
expect(getProxyUrl(true)).toBe('http://proxy1:8080');
});
it('should fall back to HTTPS_PROXY (uppercase)', () => {
process.env.HTTPS_PROXY = 'http://proxy:8080';
expect(getProxyUrl(true)).toBe('http://proxy:8080');
});
it('should fall back to all_proxy', () => {
process.env.all_proxy = 'http://allproxy:8080';
expect(getProxyUrl(true)).toBe('http://allproxy:8080');
});
it('should fall back to ALL_PROXY', () => {
process.env.ALL_PROXY = 'http://allproxy:8080';
expect(getProxyUrl(true)).toBe('http://allproxy:8080');
});
});
describe('HTTP requests', () => {
it('should return undefined when no proxy env vars are set', () => {
expect(getProxyUrl(false)).toBeUndefined();
});
it('should prefer http_proxy (lowercase)', () => {
process.env.http_proxy = 'http://proxy1:8080';
process.env.HTTP_PROXY = 'http://proxy2:8080';
expect(getProxyUrl(false)).toBe('http://proxy1:8080');
});
it('should fall back to HTTP_PROXY (uppercase)', () => {
process.env.HTTP_PROXY = 'http://proxy:8080';
expect(getProxyUrl(false)).toBe('http://proxy:8080');
});
it('should fall back to all_proxy for HTTP', () => {
process.env.all_proxy = 'http://allproxy:8080';
expect(getProxyUrl(false)).toBe('http://allproxy:8080');
});
it('should not use https_proxy for HTTP requests', () => {
process.env.https_proxy = 'http://httpsproxy:8080';
expect(getProxyUrl(false)).toBeUndefined();
});
});
});
describe('shouldBypassProxy', () => {
describe('when NO_PROXY is not set', () => {
it('should return false', () => {
expect(shouldBypassProxy('example.com')).toBe(false);
});
});
describe('wildcard pattern', () => {
it('should bypass all hosts with *', () => {
process.env.NO_PROXY = '*';
expect(shouldBypassProxy('example.com')).toBe(true);
expect(shouldBypassProxy('any.host.here')).toBe(true);
});
});
describe('exact match', () => {
it('should match exact hostname', () => {
process.env.NO_PROXY = 'example.com';
expect(shouldBypassProxy('example.com')).toBe(true);
expect(shouldBypassProxy('other.com')).toBe(false);
});
it('should be case-insensitive', () => {
process.env.NO_PROXY = 'Example.COM';
expect(shouldBypassProxy('example.com')).toBe(true);
expect(shouldBypassProxy('EXAMPLE.COM')).toBe(true);
});
it('should not match subdomains for exact pattern', () => {
process.env.NO_PROXY = 'example.com';
expect(shouldBypassProxy('sub.example.com')).toBe(true); // matches as suffix
expect(shouldBypassProxy('notexample.com')).toBe(false);
});
});
describe('domain suffix pattern', () => {
it('should match subdomains with leading dot', () => {
process.env.NO_PROXY = '.example.com';
expect(shouldBypassProxy('sub.example.com')).toBe(true);
expect(shouldBypassProxy('deep.sub.example.com')).toBe(true);
expect(shouldBypassProxy('example.com')).toBe(true); // exact match without dot
});
it('should not match different domains', () => {
process.env.NO_PROXY = '.example.com';
expect(shouldBypassProxy('notexample.com')).toBe(false);
expect(shouldBypassProxy('example.org')).toBe(false);
});
});
describe('multiple patterns', () => {
it('should check comma-separated list', () => {
process.env.NO_PROXY = 'localhost,127.0.0.1,.internal.corp';
expect(shouldBypassProxy('localhost')).toBe(true);
expect(shouldBypassProxy('127.0.0.1')).toBe(true);
expect(shouldBypassProxy('api.internal.corp')).toBe(true);
expect(shouldBypassProxy('external.com')).toBe(false);
});
it('should handle whitespace in list', () => {
process.env.NO_PROXY = ' localhost , example.com , .corp ';
expect(shouldBypassProxy('localhost')).toBe(true);
expect(shouldBypassProxy('example.com')).toBe(true);
expect(shouldBypassProxy('host.corp')).toBe(true);
});
});
describe('lowercase no_proxy', () => {
it('should respect no_proxy (lowercase)', () => {
process.env.no_proxy = 'example.com';
expect(shouldBypassProxy('example.com')).toBe(true);
});
it('should prefer no_proxy over NO_PROXY', () => {
process.env.no_proxy = 'lower.com';
process.env.NO_PROXY = 'upper.com';
expect(shouldBypassProxy('lower.com')).toBe(true);
expect(shouldBypassProxy('upper.com')).toBe(false);
});
});
});
describe('getHostname', () => {
it('should extract hostname from HTTPS URL', () => {
expect(getHostname('https://example.com/path')).toBe('example.com');
});
it('should extract hostname from HTTP URL', () => {
expect(getHostname('http://api.github.com:443/repos')).toBe('api.github.com');
});
it('should return empty string for invalid URL', () => {
expect(getHostname('not-a-url')).toBe('');
expect(getHostname('')).toBe('');
});
it('should handle localhost', () => {
expect(getHostname('http://localhost:8080')).toBe('localhost');
});
it('should handle IP addresses', () => {
expect(getHostname('http://192.168.1.1:3000')).toBe('192.168.1.1');
});
});
describe('getProxyAgent', () => {
describe('when no proxy is configured', () => {
it('should return false for HTTPS URLs', () => {
expect(getProxyAgent('https://example.com')).toBe(false);
});
it('should return false for HTTP URLs', () => {
expect(getProxyAgent('http://example.com')).toBe(false);
});
});
describe('when proxy is configured', () => {
it('should return HttpsProxyAgent for HTTPS URLs', () => {
process.env.https_proxy = 'http://proxy:8080';
const agent = getProxyAgent('https://github.com/releases');
expect(agent).not.toBe(false);
expect(agent).toBeDefined();
});
it('should return HttpProxyAgent for HTTP URLs', () => {
process.env.http_proxy = 'http://proxy:8080';
const agent = getProxyAgent('http://example.com');
expect(agent).not.toBe(false);
expect(agent).toBeDefined();
});
});
describe('NO_PROXY bypass', () => {
it('should bypass proxy for NO_PROXY hosts', () => {
process.env.https_proxy = 'http://proxy:8080';
process.env.NO_PROXY = 'github.com';
expect(getProxyAgent('https://github.com/releases')).toBe(false);
});
it('should use proxy for non-bypassed hosts', () => {
process.env.https_proxy = 'http://proxy:8080';
process.env.NO_PROXY = 'internal.corp';
const agent = getProxyAgent('https://github.com/releases');
expect(agent).not.toBe(false);
});
});
describe('error handling', () => {
it('should return false for invalid proxy URL', () => {
process.env.https_proxy = 'not-a-valid-url';
expect(getProxyAgent('https://example.com')).toBe(false);
});
it('should return false for invalid target URL', () => {
process.env.https_proxy = 'http://proxy:8080';
expect(getProxyAgent('not-a-url')).toBe(false);
});
});
});
});
@@ -0,0 +1,405 @@
/**
* Schema Sanitizer Unit Tests
*
* Tests for MCP tool input_schema sanitization.
*/
import { describe, expect, test } from 'bun:test';
import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../dist/cliproxy/schema-sanitizer.js';
describe('sanitizeInputSchema', () => {
test('preserves valid JSON Schema properties', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string', description: 'The name' },
age: { type: 'number', minimum: 0, maximum: 150 },
},
required: ['name'],
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(0);
expect(result.removedPaths).toEqual([]);
expect(result.schema).toEqual(schema);
});
test('removes non-standard properties at top level', () => {
const schema = {
type: 'object',
cornerRadius: 8,
fillColor: '#ffffff',
properties: {
name: { type: 'string' },
},
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(2);
expect(result.removedPaths).toContain('cornerRadius');
expect(result.removedPaths).toContain('fillColor');
expect(result.schema).toEqual({
type: 'object',
properties: {
name: { type: 'string' },
},
});
});
test('removes non-standard properties from nested properties', () => {
const schema = {
type: 'object',
properties: {
style: {
type: 'object',
fontFamily: 'Arial',
fontSize: 12,
properties: {
color: { type: 'string' },
},
},
},
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(2);
expect(result.removedPaths).toContain('properties.style.fontFamily');
expect(result.removedPaths).toContain('properties.style.fontSize');
expect(result.schema).toEqual({
type: 'object',
properties: {
style: {
type: 'object',
properties: {
color: { type: 'string' },
},
},
},
});
});
test('removes non-standard properties from array items', () => {
const schema = {
type: 'array',
items: {
type: 'object',
gap: 10,
padding: 20,
properties: {
id: { type: 'string' },
},
},
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(2);
expect(result.removedPaths).toContain('items.gap');
expect(result.removedPaths).toContain('items.padding');
expect(result.schema).toEqual({
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
});
});
test('handles oneOf/anyOf/allOf with nested schemas', () => {
const schema = {
type: 'object',
oneOf: [
{ type: 'string', customProp: 'remove' },
{ type: 'number', anotherCustom: 123 },
],
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(2);
expect(result.schema).toEqual({
type: 'object',
oneOf: [{ type: 'string' }, { type: 'number' }],
});
});
test('handles deeply nested structures', () => {
const schema = {
type: 'object',
properties: {
level1: {
type: 'object',
properties: {
level2: {
type: 'object',
uiHint: 'remove-me',
properties: {
value: { type: 'string' },
},
},
},
},
},
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('properties.level1.properties.level2.uiHint');
});
test('preserves $defs and definitions', () => {
const schema = {
type: 'object',
$defs: {
address: {
type: 'object',
customUI: 'remove',
properties: {
street: { type: 'string' },
},
},
},
properties: {
home: { $ref: '#/$defs/address' },
},
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('$defs.address.customUI');
expect(result.schema).toEqual({
type: 'object',
$defs: {
address: {
type: 'object',
properties: {
street: { type: 'string' },
},
},
},
properties: {
home: { $ref: '#/$defs/address' },
},
});
});
test('handles empty schema', () => {
const result = sanitizeInputSchema({});
expect(result.removedCount).toBe(0);
expect(result.schema).toEqual({});
});
test('preserves all standard metadata keywords', () => {
const schema = {
$id: 'https://example.com/schema',
$schema: 'https://json-schema.org/draft-07/schema#',
title: 'Test Schema',
description: 'A test schema',
type: 'string',
examples: ['example1', 'example2'],
deprecated: false,
readOnly: true,
$comment: 'Internal comment',
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(0);
expect(result.schema).toEqual(schema);
});
test('preserves additionalItems keyword', () => {
const schema = {
type: 'array',
items: [{ type: 'string' }],
additionalItems: { type: 'boolean' },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(0);
expect(result.schema.additionalItems).toEqual({ type: 'boolean' });
});
test('sanitizes nested schemas in additionalItems', () => {
const schema = {
type: 'array',
items: [{ type: 'string' }],
additionalItems: { type: 'boolean', customProp: 'remove' },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('additionalItems.customProp');
expect(result.schema.additionalItems).toEqual({ type: 'boolean' });
});
test('preserves if/then/else and sanitizes nested schemas', () => {
const schema = {
if: { properties: { type: { const: 'foo' } }, uiHint: 'remove' },
then: { required: ['foo'], badProp: 123 },
else: { required: ['bar'] },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(2);
expect(result.schema.if).toEqual({ properties: { type: { const: 'foo' } } });
expect(result.schema.then).toEqual({ required: ['foo'] });
});
test('preserves patternProperties keyword and sanitizes nested schemas', () => {
const schema = {
type: 'object',
patternProperties: { '^S_': { type: 'string', customProp: 'remove' } },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('patternProperties.^S_.customProp');
expect(result.schema.patternProperties).toEqual({ '^S_': { type: 'string' } });
});
test('preserves contains keyword and sanitizes nested schema', () => {
const schema = {
type: 'array',
contains: { type: 'number', minimum: 5, customProp: 'remove' },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('contains.customProp');
expect(result.schema.contains).toEqual({ type: 'number', minimum: 5 });
});
test('preserves propertyNames keyword and sanitizes nested schema', () => {
const schema = {
type: 'object',
propertyNames: { type: 'string', pattern: '^[a-z]+$', customProp: 'remove' },
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('propertyNames.customProp');
expect(result.schema.propertyNames).toEqual({ type: 'string', pattern: '^[a-z]+$' });
});
test('handles dependencies with both property and schema dependencies', () => {
const schema = {
type: 'object',
dependencies: {
bar: ['foo'], // property dependency (array) - pass through
baz: { properties: { qux: { type: 'string' } }, customProp: 'remove' }, // schema dependency
},
};
const result = sanitizeInputSchema(schema);
expect(result.removedCount).toBe(1);
expect(result.removedPaths).toContain('dependencies.baz.customProp');
expect(result.schema.dependencies).toEqual({
bar: ['foo'],
baz: { properties: { qux: { type: 'string' } } },
});
});
test('handles null input gracefully', () => {
const result = sanitizeInputSchema(null as any);
expect(result.schema).toBeNull();
expect(result.removedCount).toBe(0);
});
test('handles undefined input gracefully', () => {
const result = sanitizeInputSchema(undefined as any);
expect(result.schema).toBeUndefined();
expect(result.removedCount).toBe(0);
});
});
describe('sanitizeToolSchemas', () => {
test('sanitizes multiple tools and tracks per-tool changes', () => {
const tools = [
{
name: 'clean_tool',
input_schema: {
type: 'object',
properties: { name: { type: 'string' } },
},
},
{
name: 'figma_tool',
input_schema: {
type: 'object',
cornerRadius: 8,
fillColor: '#000',
properties: { id: { type: 'string' } },
},
},
{
name: 'design_tool',
input_schema: {
type: 'object',
fontWeight: 'bold',
properties: { text: { type: 'string' } },
},
},
];
const result = sanitizeToolSchemas(tools);
expect(result.totalRemoved).toBe(3);
expect(result.removedByTool).toHaveLength(2);
expect(result.removedByTool[0].name).toBe('figma_tool');
expect(result.removedByTool[0].removed).toContain('cornerRadius');
expect(result.removedByTool[0].removed).toContain('fillColor');
expect(result.removedByTool[1].name).toBe('design_tool');
expect(result.removedByTool[1].removed).toContain('fontWeight');
// Verify schemas are actually sanitized
const figmaTool = result.tools.find((t) => t.name === 'figma_tool');
expect(figmaTool?.input_schema).toEqual({
type: 'object',
properties: { id: { type: 'string' } },
});
});
test('handles tools without input_schema', () => {
const tools = [
{ name: 'simple_tool', description: 'No schema' },
{ name: 'another_tool' },
];
const result = sanitizeToolSchemas(tools);
expect(result.totalRemoved).toBe(0);
expect(result.removedByTool).toHaveLength(0);
expect(result.tools).toEqual(tools);
});
test('returns unmodified tools when all schemas are valid', () => {
const tools = [
{
name: 'valid_tool',
input_schema: {
type: 'object',
properties: { value: { type: 'number', minimum: 0 } },
required: ['value'],
},
},
];
const result = sanitizeToolSchemas(tools);
expect(result.totalRemoved).toBe(0);
expect(result.removedByTool).toHaveLength(0);
});
test('handles tools without input_schema (skips null tools)', () => {
const tools = [
{ name: 'simple_tool', description: 'No schema' },
{ name: 'valid', input_schema: { type: 'object' } },
];
const result = sanitizeToolSchemas(tools);
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe('simple_tool');
expect(result.tools[1].name).toBe('valid');
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
// We need to mock process.platform for cross-platform testing
const originalPlatform = process.platform;
describe('escapeShellArg', () => {
describe('Unix (non-Windows)', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'linux' });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('wraps argument in double quotes', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('arg')).toBe('"arg"');
});
it('escapes inner double quotes with backslash', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('say "hello"')).toBe('"say \\"hello\\""');
});
it('handles paths with spaces', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('/path/to/my file')).toBe('"/path/to/my file"');
});
it('handles empty string', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('')).toBe('""');
});
});
describe('Windows (cmd.exe)', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'win32' });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('wraps argument in double quotes', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('arg')).toBe('"arg"');
});
it('escapes inner double quotes by doubling them', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('say "hello"')).toBe('"say ""hello"""');
});
it('escapes percent signs', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('%PATH%')).toBe('"%%PATH%%"');
});
it('escapes caret characters', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('a^b')).toBe('"a^^b"');
});
it('replaces newlines with spaces', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('line1\nline2')).toBe('"line1 line2"');
});
it('replaces tabs with spaces', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('col1\tcol2')).toBe('"col1 col2"');
});
it('handles Windows paths with spaces', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('C:\\Program Files\\App')).toBe('"C:\\Program Files\\App"');
});
it('escapes exclamation marks for delayed expansion', async () => {
const { escapeShellArg } = await import('../../../src/utils/shell-executor');
expect(escapeShellArg('hello!')).toBe('"hello^^!"');
});
});
});
@@ -155,6 +155,8 @@ export default function ProxySection() {
}
const isRemoteMode = config.remote.enabled ?? false;
// Only block backend switching in local mode when proxy is running
const isBackendSwitchBlocked = !isRemoteMode && isProxyRunning;
const remoteConfig = config.remote;
const fallbackConfig = config.fallback;
@@ -318,8 +320,8 @@ export default function ProxySection() {
<Box className="w-4 h-4" />
Backend Binary
</h3>
{/* Warning when proxy is running - must stop to change backend */}
{isProxyRunning && (
{/* Warning when local proxy is running - must stop to change backend (not applicable in remote mode) */}
{!isRemoteMode && isProxyRunning && (
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-amber-700 dark:text-amber-400">
@@ -331,12 +333,12 @@ export default function ProxySection() {
{/* Plus Backend Card */}
<button
onClick={() => handleBackendChange('plus')}
disabled={updateBackendMutation.isPending || isProxyRunning}
disabled={updateBackendMutation.isPending || isBackendSwitchBlocked}
className={`p-4 rounded-lg border-2 text-left transition-all ${
backend === 'plus'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/50'
} ${isProxyRunning ? 'opacity-60 cursor-not-allowed' : ''}`}
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">CLIProxyAPIPlus</span>
@@ -352,12 +354,12 @@ export default function ProxySection() {
{/* Original Backend Card */}
<button
onClick={() => handleBackendChange('original')}
disabled={updateBackendMutation.isPending || isProxyRunning}
disabled={updateBackendMutation.isPending || isBackendSwitchBlocked}
className={`p-4 rounded-lg border-2 text-left transition-all ${
backend === 'original'
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/50'
} ${isProxyRunning ? 'opacity-60 cursor-not-allowed' : ''}`}
} ${isBackendSwitchBlocked ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">CLIProxyAPI</span>