mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 06:17:27 +00:00
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:
17 files changed
+1385
-39
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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, '\\"') + '"';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user