diff --git a/bun.lock b/bun.lock index b80a4d5f..e9396b8b 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/package.json b/package.json index 2d83d6fa..7b2fabcd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts index 7f0c5f1a..12a2a0e7 100644 --- a/src/auth/commands/show-command.ts +++ b/src/auth/commands/show-command.ts @@ -26,8 +26,13 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise { }); } +/** + * 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} @`); + } 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( diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts index b06a4e08..e34c5490 100644 --- a/src/cliproxy/binary/downloader.ts +++ b/src/cliproxy/binary/downloader.ts @@ -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 { @@ -445,3 +539,11 @@ export async function fetchJson( function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +// Export internal functions for testing +export const __testExports = { + getProxyUrl, + shouldBypassProxy, + getHostname, + getProxyAgent, +}; diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index a9e0cc73..6b924a35 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -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', diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index b09c5741..a504a0f8 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -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'; diff --git a/src/cliproxy/schema-sanitizer.ts b/src/cliproxy/schema-sanitizer.ts new file mode 100644 index 00000000..a63c2d6f --- /dev/null +++ b/src/cliproxy/schema-sanitizer.ts @@ -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; + /** 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 = 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 = {}; + const obj = schema as Record; + + 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 = {}; + for (const [propName, propSchema] of Object.entries(value as Record)) { + 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 = {}; + for (const [defName, defSchema] of Object.entries(value as Record)) { + 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 = {}; + for (const [pattern, patternSchema] of Object.entries(value as Record)) { + 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 = {}; + for (const [depName, depValue] of Object.entries(value as Record)) { + // 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 | 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, + 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; [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, + }; +} diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 5bfde38f..78ddecdb 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -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 }> + ); + + 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 diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 6152ce76..1dd631dd 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -184,6 +184,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], + ['ccs --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'], diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 9ee00511..a3206d6d 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -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, '\\"') + '"'; } } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index cfc3ef34..2e8b4e84 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -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) { diff --git a/tests/unit/cliproxy/binary-downloader-proxy.test.ts b/tests/unit/cliproxy/binary-downloader-proxy.test.ts new file mode 100644 index 00000000..a92926f5 --- /dev/null +++ b/tests/unit/cliproxy/binary-downloader-proxy.test.ts @@ -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 = {}; + 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); + }); + }); + }); +}); diff --git a/tests/unit/cliproxy/schema-sanitizer.test.ts b/tests/unit/cliproxy/schema-sanitizer.test.ts new file mode 100644 index 00000000..c9a5cabb --- /dev/null +++ b/tests/unit/cliproxy/schema-sanitizer.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/utils/shell-executor.test.ts b/tests/unit/utils/shell-executor.test.ts new file mode 100644 index 00000000..cca6f0cb --- /dev/null +++ b/tests/unit/utils/shell-executor.test.ts @@ -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^^!"'); + }); + }); +}); diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 38879cc2..da4bf7f2 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -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() { Backend Binary - {/* 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 && ( @@ -331,12 +333,12 @@ export default function ProxySection() { {/* Plus Backend Card */}