fix(security): prevent command injection and strengthen input validation

BREAKING: executeCodexLens now uses shell:false to prevent RCE

Security fixes:
- Remove shell:true from spawn() to prevent command injection (CRITICAL)
- Add .env value escaping to prevent injection when file is sourced
- Strengthen path validation with startsWith to block subdirectories
- Add path traversal detection (../)
- Improve JSON extraction to handle trailing CLI output

Features:
- Refactor CodexLens panel to tabbed layout (Overview/Settings/Search/Advanced)
- Add environment variables editor for ~/.codexlens/.env
- Add API concurrency settings (max_workers, batch_size)
- Add escapeHtml() helper to prevent XSS
- Implement merge mode for env saving to preserve custom variables
This commit is contained in:
catlog22
2026-01-03 18:33:47 +08:00
parent be498acf59
commit ad6c18f615
3 changed files with 744 additions and 167 deletions

View File

@@ -1,8 +1,14 @@
// @ts-nocheck
/**
* CodexLens Routes Module
* Handles all CodexLens-related API endpoints
*
* TODO: Remove @ts-nocheck and add proper types:
* - Define interfaces for request body types (ConfigBody, CleanBody, InitBody, etc.)
* - Type error catches: (e: unknown) => { const err = e as Error; ... }
* - Add null checks for extractJSON results
* - Type the handlePostRequest callback body parameter
*/
// @ts-nocheck
import type { IncomingMessage, ServerResponse } from 'http';
import {
checkVenvStatus,
@@ -65,6 +71,7 @@ function formatSize(bytes: number): string {
* Extract JSON from CLI output that may contain logging messages
* CodexLens CLI outputs logs like "INFO ..." before the JSON
* Also strips ANSI color codes that Rich library adds
* Handles trailing content after JSON (e.g., "INFO: Done" messages)
*/
function extractJSON(output: string): any {
// Strip ANSI color codes first
@@ -76,8 +83,53 @@ function extractJSON(output: string): any {
throw new Error('No JSON found in output');
}
// Extract everything from the first { or [ onwards
const jsonString = cleanOutput.substring(jsonStart);
const startChar = cleanOutput[jsonStart];
const endChar = startChar === '{' ? '}' : ']';
// Find matching closing brace/bracket using a simple counter
let depth = 0;
let inString = false;
let escapeNext = false;
let jsonEnd = -1;
for (let i = jsonStart; i < cleanOutput.length; i++) {
const char = cleanOutput[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\' && inString) {
escapeNext = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (char === startChar) {
depth++;
} else if (char === endChar) {
depth--;
if (depth === 0) {
jsonEnd = i + 1;
break;
}
}
}
}
if (jsonEnd === -1) {
// Fallback: try to parse from start to end (original behavior)
const jsonString = cleanOutput.substring(jsonStart);
return JSON.parse(jsonString);
}
const jsonString = cleanOutput.substring(jsonStart, jsonEnd);
return JSON.parse(jsonString);
}
@@ -378,7 +430,7 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
// Check if CodexLens is installed first (without auto-installing)
const venvStatus = await checkVenvStatus();
let responseData = { index_dir: '~/.codexlens/indexes', index_count: 0 };
let responseData = { index_dir: '~/.codexlens/indexes', index_count: 0, api_max_workers: 4, api_batch_size: 8 };
// If not installed, return default config without executing CodexLens
if (!venvStatus.ready) {
@@ -400,6 +452,13 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
if (config.success && config.result) {
// CLI returns index_dir (not index_root)
responseData.index_dir = config.result.index_dir || config.result.index_root || responseData.index_dir;
// Extract API settings
if (config.result.api_max_workers !== undefined) {
responseData.api_max_workers = config.result.api_max_workers;
}
if (config.result.api_batch_size !== undefined) {
responseData.api_batch_size = config.result.api_batch_size;
}
}
} catch (e) {
console.error('[CodexLens] Failed to parse config:', e.message);
@@ -432,19 +491,69 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
// API: CodexLens Config - POST (Set configuration)
if (pathname === '/api/codexlens/config' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { index_dir } = body;
const { index_dir, api_max_workers, api_batch_size } = body;
if (!index_dir) {
return { success: false, error: 'index_dir is required', status: 400 };
}
try {
const result = await executeCodexLens(['config', 'set', 'index_dir', index_dir, '--json']);
if (result.success) {
return { success: true, message: 'Configuration updated successfully' };
} else {
return { success: false, error: result.error || 'Failed to update configuration', status: 500 };
// Validate index_dir path
const indexDirStr = String(index_dir).trim();
// Check for dangerous patterns
if (indexDirStr.includes('\0')) {
return { success: false, error: 'Invalid path: contains null bytes', status: 400 };
}
// Prevent system root paths and their subdirectories (Windows and Unix)
const dangerousPaths = ['/', 'C:\\', 'C:/', '/etc', '/usr', '/bin', '/sys', '/proc', '/var',
'C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\System32'];
const normalizedPath = indexDirStr.replace(/\\/g, '/').toLowerCase();
for (const dangerous of dangerousPaths) {
const dangerousLower = dangerous.replace(/\\/g, '/').toLowerCase();
// Block exact match OR any subdirectory (using startsWith)
if (normalizedPath === dangerousLower ||
normalizedPath === dangerousLower + '/' ||
normalizedPath.startsWith(dangerousLower + '/')) {
return { success: false, error: 'Invalid path: cannot use system directories or their subdirectories', status: 400 };
}
}
// Additional check: prevent path traversal attempts
if (normalizedPath.includes('../') || normalizedPath.includes('/..')) {
return { success: false, error: 'Invalid path: path traversal not allowed', status: 400 };
}
// Validate api settings
if (api_max_workers !== undefined) {
const workers = Number(api_max_workers);
if (isNaN(workers) || workers < 1 || workers > 32) {
return { success: false, error: 'api_max_workers must be between 1 and 32', status: 400 };
}
}
if (api_batch_size !== undefined) {
const batch = Number(api_batch_size);
if (isNaN(batch) || batch < 1 || batch > 64) {
return { success: false, error: 'api_batch_size must be between 1 and 64', status: 400 };
}
}
try {
// Set index_dir
const result = await executeCodexLens(['config', 'set', 'index_dir', indexDirStr, '--json']);
if (!result.success) {
return { success: false, error: result.error || 'Failed to update index_dir', status: 500 };
}
// Set API settings if provided
if (api_max_workers !== undefined) {
await executeCodexLens(['config', 'set', 'api_max_workers', String(api_max_workers), '--json']);
}
if (api_batch_size !== undefined) {
await executeCodexLens(['config', 'set', 'api_batch_size', String(api_batch_size), '--json']);
}
return { success: true, message: 'Configuration updated successfully' };
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
@@ -1535,5 +1644,235 @@ except Exception as e:
return true;
}
// ============================================================
// ENV FILE MANAGEMENT ENDPOINTS
// ============================================================
// API: Get global env file content
if (pathname === '/api/codexlens/env' && req.method === 'GET') {
try {
const { homedir } = await import('os');
const { join } = await import('path');
const { readFile } = await import('fs/promises');
const envPath = join(homedir(), '.codexlens', '.env');
let content = '';
try {
content = await readFile(envPath, 'utf-8');
} catch (e) {
// File doesn't exist, return empty
}
// Parse env file into key-value pairs (robust parsing)
const envVars: Record<string, string> = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip empty lines and comments
if (!trimmed || trimmed.startsWith('#')) continue;
// Find first = that's part of key=value (not in a quote)
const eqIndex = trimmed.indexOf('=');
if (eqIndex <= 0) continue;
const key = trimmed.substring(0, eqIndex).trim();
// Validate key format (alphanumeric + underscore)
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
let value = trimmed.substring(eqIndex + 1);
// Handle quoted values (preserves = inside quotes)
if (value.startsWith('"')) {
// Find matching closing quote (handle escaped quotes)
let end = 1;
while (end < value.length) {
if (value[end] === '"' && value[end - 1] !== '\\') break;
end++;
}
value = value.substring(1, end).replace(/\\"/g, '"');
} else if (value.startsWith("'")) {
// Single quotes don't support escaping
const end = value.indexOf("'", 1);
value = end > 0 ? value.substring(1, end) : value.substring(1);
} else {
// Unquoted: trim and take until comment or end
const commentIndex = value.indexOf(' #');
if (commentIndex > 0) {
value = value.substring(0, commentIndex);
}
value = value.trim();
}
envVars[key] = value;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
path: envPath,
env: envVars,
raw: content
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: err.message }));
}
return true;
}
// API: Save global env file content (merge mode - preserves existing values)
if (pathname === '/api/codexlens/env' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { env } = body as { env: Record<string, string> };
if (!env || typeof env !== 'object') {
return { success: false, error: 'env object is required', status: 400 };
}
try {
const { homedir } = await import('os');
const { join, dirname } = await import('path');
const { writeFile, mkdir, readFile } = await import('fs/promises');
const envPath = join(homedir(), '.codexlens', '.env');
await mkdir(dirname(envPath), { recursive: true });
// Read existing env file to preserve custom variables
let existingEnv: Record<string, string> = {};
let existingComments: string[] = [];
try {
const content = await readFile(envPath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Preserve comment lines that aren't our headers
if (trimmed.startsWith('#') && !trimmed.includes('Managed by CCW')) {
if (!trimmed.includes('Reranker API') && !trimmed.includes('Embedding API') &&
!trimmed.includes('LiteLLM Config') && !trimmed.includes('CodexLens Settings') &&
!trimmed.includes('Other Settings') && !trimmed.includes('CodexLens Environment')) {
existingComments.push(line);
}
}
if (!trimmed || trimmed.startsWith('#')) continue;
// Robust parsing (same as GET handler)
const eqIndex = trimmed.indexOf('=');
if (eqIndex <= 0) continue;
const key = trimmed.substring(0, eqIndex).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
let value = trimmed.substring(eqIndex + 1);
if (value.startsWith('"')) {
let end = 1;
while (end < value.length) {
if (value[end] === '"' && value[end - 1] !== '\\') break;
end++;
}
value = value.substring(1, end).replace(/\\"/g, '"');
} else if (value.startsWith("'")) {
const end = value.indexOf("'", 1);
value = end > 0 ? value.substring(1, end) : value.substring(1);
} else {
const commentIndex = value.indexOf(' #');
if (commentIndex > 0) value = value.substring(0, commentIndex);
value = value.trim();
}
existingEnv[key] = value;
}
} catch (e) {
// File doesn't exist, start fresh
}
// Merge: update known keys from payload, preserve unknown keys
const knownKeys = new Set([
'RERANKER_API_KEY', 'RERANKER_API_BASE', 'RERANKER_MODEL',
'EMBEDDING_API_KEY', 'EMBEDDING_API_BASE', 'EMBEDDING_MODEL',
'LITELLM_API_KEY', 'LITELLM_API_BASE', 'LITELLM_MODEL'
]);
// Apply updates from payload
for (const [key, value] of Object.entries(env)) {
if (value) {
existingEnv[key] = value;
} else if (knownKeys.has(key)) {
// Remove known key if value is empty
delete existingEnv[key];
}
}
// Build env file content
const lines = [
'# CodexLens Environment Configuration',
'# Managed by CCW Dashboard',
''
];
// Add preserved custom comments
if (existingComments.length > 0) {
lines.push(...existingComments, '');
}
// Group by prefix
const groups: Record<string, string[]> = {
'RERANKER': [],
'EMBEDDING': [],
'LITELLM': [],
'CODEXLENS': [],
'OTHER': []
};
for (const [key, value] of Object.entries(existingEnv)) {
if (!value) continue;
// SECURITY: Escape special characters to prevent .env injection
const escapedValue = value
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/"/g, '\\"') // Escape double quotes
.replace(/\n/g, '\\n') // Escape newlines
.replace(/\r/g, '\\r'); // Escape carriage returns
const line = `${key}="${escapedValue}"`;
if (key.startsWith('RERANKER_')) groups['RERANKER'].push(line);
else if (key.startsWith('EMBEDDING_')) groups['EMBEDDING'].push(line);
else if (key.startsWith('LITELLM_')) groups['LITELLM'].push(line);
else if (key.startsWith('CODEXLENS_')) groups['CODEXLENS'].push(line);
else groups['OTHER'].push(line);
}
// Add grouped content
if (groups['RERANKER'].length) {
lines.push('# Reranker API Configuration');
lines.push(...groups['RERANKER'], '');
}
if (groups['EMBEDDING'].length) {
lines.push('# Embedding API Configuration');
lines.push(...groups['EMBEDDING'], '');
}
if (groups['LITELLM'].length) {
lines.push('# LiteLLM Configuration');
lines.push(...groups['LITELLM'], '');
}
if (groups['CODEXLENS'].length) {
lines.push('# CodexLens Settings');
lines.push(...groups['CODEXLENS'], '');
}
if (groups['OTHER'].length) {
lines.push('# Other Settings');
lines.push(...groups['OTHER'], '');
}
await writeFile(envPath, lines.join('\n'), 'utf-8');
return {
success: true,
message: 'Environment configuration saved',
path: envPath
};
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
return false;
}

View File

@@ -1,6 +1,23 @@
// CodexLens Manager - Configuration, Model Management, and Semantic Dependencies
// Extracted from cli-manager.js for better maintainability
// ============================================================
// UTILITY FUNCTIONS
// ============================================================
/**
* Escape HTML special characters to prevent XSS
*/
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ============================================================
// CODEXLENS CONFIGURATION MODAL
// ============================================================
@@ -35,15 +52,18 @@ async function showCodexLensConfigModal() {
}
/**
* Build CodexLens configuration modal content
* Build CodexLens configuration modal content - Tabbed Layout
*/
function buildCodexLensConfigContent(config) {
const indexDir = config.index_dir || '~/.codexlens/indexes';
const indexCount = config.index_count || 0;
const isInstalled = window.cliToolsStatus?.codexlens?.installed || false;
const embeddingCoverage = config.embedding_coverage || 0;
const apiMaxWorkers = config.api_max_workers || 4;
const apiBatchSize = config.api_batch_size || 8;
return '<div class="modal-backdrop" id="codexlensConfigModal">' +
'<div class="modal-container">' +
'<div class="modal-container large">' +
'<div class="modal-header">' +
'<div class="flex items-center gap-3">' +
'<div class="modal-icon">' +
@@ -59,159 +79,245 @@ function buildCodexLensConfigContent(config) {
'</button>' +
'</div>' +
'<div class="modal-body">' +
// Status Section
'<div class="tool-config-section">' +
'<h4>' + t('codexlens.status') + '</h4>' +
'<div class="flex items-center gap-4 text-sm">' +
'<div class="flex items-center gap-2">' +
'<span class="text-muted-foreground">' + t('codexlens.currentWorkspace') + ':</span>' +
'<div class="modal-body" style="padding: 0;">' +
// Tab Navigation
'<div class="flex border-b border-border bg-muted/30">' +
'<button class="codexlens-tab active flex-1 px-4 py-2.5 text-sm font-medium text-center border-b-2 border-primary text-primary" data-tab="overview">' +
'<i data-lucide="layout-dashboard" class="w-4 h-4 inline mr-1.5"></i>Overview' +
'</button>' +
'<button class="codexlens-tab flex-1 px-4 py-2.5 text-sm font-medium text-center border-b-2 border-transparent text-muted-foreground hover:text-foreground" data-tab="settings">' +
'<i data-lucide="settings" class="w-4 h-4 inline mr-1.5"></i>Settings' +
'</button>' +
(isInstalled
? '<button class="codexlens-tab flex-1 px-4 py-2.5 text-sm font-medium text-center border-b-2 border-transparent text-muted-foreground hover:text-foreground" data-tab="search">' +
'<i data-lucide="search" class="w-4 h-4 inline mr-1.5"></i>Search' +
'</button>' +
'<button class="codexlens-tab flex-1 px-4 py-2.5 text-sm font-medium text-center border-b-2 border-transparent text-muted-foreground hover:text-foreground" data-tab="advanced">' +
'<i data-lucide="wrench" class="w-4 h-4 inline mr-1.5"></i>Advanced' +
'</button>'
: '') +
'</div>' +
// Tab Content Container
'<div class="p-4">' +
// ========== OVERVIEW TAB ==========
'<div class="codexlens-tab-content active" data-tab="overview">' +
// Status Card - Compact grid layout
'<div class="grid grid-cols-2 gap-3 mb-4">' +
// Status Card
'<div class="rounded-lg border border-border p-3 bg-card">' +
'<div class="flex items-center gap-2 mb-2">' +
'<i data-lucide="circle-check" class="w-4 h-4 text-muted-foreground"></i>' +
'<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Status</span>' +
'</div>' +
(isInstalled
? '<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20">' +
'<i data-lucide="check-circle" class="w-3.5 h-3.5"></i>' +
t('codexlens.installed') +
'</span>'
: '<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground border border-border">' +
'<i data-lucide="circle" class="w-3.5 h-3.5"></i>' +
t('codexlens.notInstalled') +
'</span>') +
? '<div class="flex items-center gap-2">' +
'<span class="w-2 h-2 rounded-full bg-success animate-pulse"></span>' +
'<span class="text-sm font-medium text-success">Installed</span>' +
'</div>'
: '<div class="flex items-center gap-2">' +
'<span class="w-2 h-2 rounded-full bg-muted-foreground"></span>' +
'<span class="text-sm font-medium text-muted-foreground">Not Installed</span>' +
'</div>') +
'</div>' +
'<div class="flex items-center gap-2">' +
'<span class="text-muted-foreground">' + t('codexlens.indexes') + ':</span>' +
'<span class="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-primary/10 text-primary border border-primary/20">' +
indexCount +
'</span>' +
// Index Count Card
'<div class="rounded-lg border border-border p-3 bg-card">' +
'<div class="flex items-center gap-2 mb-2">' +
'<i data-lucide="database" class="w-4 h-4 text-muted-foreground"></i>' +
'<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Indexes</span>' +
'</div>' +
'<div class="text-2xl font-bold text-primary">' + indexCount + '</div>' +
'</div>' +
// Embeddings Coverage Card
'<div class="rounded-lg border border-border p-3 bg-card">' +
'<div class="flex items-center gap-2 mb-2">' +
'<i data-lucide="brain" class="w-4 h-4 text-muted-foreground"></i>' +
'<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Embeddings</span>' +
'</div>' +
'<div class="text-sm font-medium">' + embeddingCoverage + '%</div>' +
'</div>' +
// Storage Path Card
'<div class="rounded-lg border border-border p-3 bg-card">' +
'<div class="flex items-center gap-2 mb-2">' +
'<i data-lucide="folder" class="w-4 h-4 text-muted-foreground"></i>' +
'<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Storage</span>' +
'</div>' +
'<div class="text-xs font-mono text-muted-foreground truncate" title="' + escapeHtml(indexDir) + '">' + escapeHtml(indexDir) + '</div>' +
'</div>' +
'</div>' +
// Quick Actions
'<div class="space-y-2">' +
'<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Quick Actions</h4>' +
'<div class="grid grid-cols-2 gap-2">' +
(isInstalled
? '<button class="flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-primary/30 bg-primary/5 text-primary hover:bg-primary/10 transition-colors" onclick="initCodexLensIndex()">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i> Update Index' +
'</button>' +
'<button class="flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-border bg-background hover:bg-muted/50 transition-colors" onclick="showWatcherControlModal()">' +
'<i data-lucide="eye" class="w-4 h-4"></i> File Watcher' +
'</button>' +
'<button class="flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-border bg-background hover:bg-muted/50 transition-colors" onclick="showRerankerConfigModal()">' +
'<i data-lucide="layers" class="w-4 h-4"></i> Reranker' +
'</button>' +
'<button class="flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-border bg-background hover:bg-muted/50 transition-colors" onclick="cleanCurrentWorkspaceIndex()">' +
'<i data-lucide="eraser" class="w-4 h-4"></i> Clean Workspace' +
'</button>'
: '<button class="col-span-2 flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" onclick="installCodexLensFromManager()">' +
'<i data-lucide="download" class="w-4 h-4"></i> Install CodexLens' +
'</button>') +
'</div>' +
'</div>' +
'</div>' +
// Index Storage Path Section
'<div class="tool-config-section">' +
'<h4>' + t('codexlens.indexStoragePath') + '</h4>' +
'<div class="space-y-3">' +
'<div>' +
'<label class="block text-sm font-medium mb-1.5">' + t('codexlens.currentPath') + '</label>' +
'<div class="text-sm text-muted-foreground bg-muted/30 rounded-lg px-3 py-2 font-mono">' +
indexDir +
'</div>' +
'</div>' +
'<div>' +
'<label class="block text-sm font-medium mb-1.5">' + t('codexlens.newStoragePath') + '</label>' +
'<input type="text" id="indexDirInput" value="' + indexDir + '" ' +
// ========== SETTINGS TAB ==========
'<div class="codexlens-tab-content hidden" data-tab="settings">' +
// Index Storage Path
'<div class="space-y-4">' +
'<div class="space-y-2">' +
'<label class="block text-sm font-medium">' + t('codexlens.indexStoragePath') + '</label>' +
'<input type="text" id="indexDirInput" value="' + escapeHtml(indexDir) + '" ' +
'placeholder="' + t('codexlens.pathPlaceholder') + '" ' +
'class="tool-config-input w-full" />' +
'<p class="text-xs text-muted-foreground mt-1">' + t('codexlens.pathInfo') + '</p>' +
'<p class="text-xs text-muted-foreground">' + t('codexlens.pathInfo') + '</p>' +
'</div>' +
// API Settings (Concurrency)
'<div class="rounded-lg border border-border p-4 space-y-3">' +
'<h4 class="text-sm font-medium flex items-center gap-2">' +
'<i data-lucide="zap" class="w-4 h-4"></i> API Settings' +
'</h4>' +
'<div class="grid grid-cols-2 gap-3">' +
'<div>' +
'<label class="block text-xs font-medium text-muted-foreground mb-1">Max Workers</label>' +
'<input type="number" id="apiMaxWorkersInput" value="' + apiMaxWorkers + '" min="1" max="16" ' +
'class="tool-config-input w-full" />' +
'</div>' +
'<div>' +
'<label class="block text-xs font-medium text-muted-foreground mb-1">Batch Size</label>' +
'<input type="number" id="apiBatchSizeInput" value="' + apiBatchSize + '" min="1" max="32" ' +
'class="tool-config-input w-full" />' +
'</div>' +
'</div>' +
'<p class="text-xs text-muted-foreground">Higher values speed up embedding generation but may hit rate limits.</p>' +
'</div>' +
// Environment Variables Section
'<div class="rounded-lg border border-border p-4 space-y-3">' +
'<div class="flex items-center justify-between">' +
'<h4 class="text-sm font-medium flex items-center gap-2">' +
'<i data-lucide="file-code" class="w-4 h-4"></i> Environment Variables' +
'</h4>' +
'<button class="text-xs text-primary hover:underline" onclick="loadEnvVariables()">Load</button>' +
'</div>' +
'<div id="envVarsContainer" class="space-y-2">' +
'<div class="text-xs text-muted-foreground">Click Load to view/edit ~/.codexlens/.env</div>' +
'</div>' +
'</div>' +
// Migration Warning
'<div class="flex items-start gap-2 bg-warning/10 border border-warning/30 rounded-lg p-3">' +
'<i data-lucide="alert-triangle" class="w-4 h-4 text-warning mt-0.5"></i>' +
'<i data-lucide="alert-triangle" class="w-4 h-4 text-warning mt-0.5 flex-shrink-0"></i>' +
'<div class="text-sm">' +
'<p class="font-medium text-warning">' + t('codexlens.migrationRequired') + '</p>' +
'<p class="text-muted-foreground mt-1">' + t('codexlens.migrationWarning') + '</p>' +
'<p class="text-muted-foreground mt-1 text-xs">' + t('codexlens.migrationWarning') + '</p>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
// Actions Section
'<div class="tool-config-section">' +
'<h4>' + t('codexlens.actions') + '</h4>' +
'<div class="tool-config-actions">' +
(isInstalled
? '<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-primary/30 bg-primary/5 text-primary hover:bg-primary/10 transition-colors" onclick="initCodexLensIndex()">' +
'<i data-lucide="database" class="w-3.5 h-3.5"></i> ' + t('codexlens.initializeIndex') +
'</button>' +
'<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-primary/30 bg-primary/5 text-primary hover:bg-primary/10 transition-colors" onclick="showRerankerConfigModal()">' +
'<i data-lucide="layers" class="w-3.5 h-3.5"></i> ' + (t('codexlens.rerankerConfig') || 'Reranker Config') +
'</button>' +
'<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-primary/30 bg-primary/5 text-primary hover:bg-primary/10 transition-colors" onclick="showWatcherControlModal()">' +
'<i data-lucide="eye" class="w-3.5 h-3.5"></i> ' + (t('codexlens.watcherControl') || 'File Watcher') +
'</button>' +
'<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted/50 transition-colors" onclick="cleanCurrentWorkspaceIndex()">' +
'<i data-lucide="folder-x" class="w-3.5 h-3.5"></i> ' + t('codexlens.cleanCurrentWorkspace') +
'</button>' +
'<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted/50 transition-colors" onclick="cleanCodexLensIndexes()">' +
'<i data-lucide="trash" class="w-3.5 h-3.5"></i> ' + t('codexlens.cleanAllIndexes') +
'</button>' +
'<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-destructive/30 bg-destructive/5 text-destructive hover:bg-destructive/10 transition-colors" onclick="uninstallCodexLensFromManager()">' +
'<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> ' + t('cli.uninstall') +
'</button>'
: '<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" onclick="installCodexLensFromManager()">' +
'<i data-lucide="download" class="w-3.5 h-3.5"></i> ' + t('codexlens.installCodexLens') +
'</button>') +
'</div>' +
'</div>' +
// Semantic Dependencies Section
// ========== SEARCH TAB (only if installed) ==========
(isInstalled
? '<div class="tool-config-section">' +
'<h4>' + t('codexlens.semanticDeps') + '</h4>' +
'<div id="semanticDepsStatus" class="space-y-2">' +
'<div class="text-sm text-muted-foreground">' + t('codexlens.checkingDeps') + '</div>' +
'</div>' +
'</div>'
: '') +
// SPLADE Section
(isInstalled
? '<div class="tool-config-section">' +
'<h4>' + t('codexlens.spladeDeps') + '</h4>' +
'<div id="spladeStatus" class="space-y-2">' +
'<div class="text-sm text-muted-foreground">' + t('common.loading') + '</div>' +
'</div>' +
'</div>'
: '') +
// Model Management Section
(isInstalled
? '<div class="tool-config-section">' +
'<h4>' + t('codexlens.modelManagement') + '</h4>' +
'<div id="modelListContainer" class="space-y-2">' +
'<div class="text-sm text-muted-foreground">' + t('codexlens.loadingModels') + '</div>' +
'</div>' +
'</div>'
: '') +
// Test Search Section
(isInstalled
? '<div class="tool-config-section">' +
'<h4>' + t('codexlens.testSearch') + ' <span class="text-muted">(' + t('codexlens.testFunctionality') + ')</span></h4>' +
'<div class="space-y-3">' +
'<div class="flex gap-2">' +
'<select id="searchTypeSelect" class="tool-config-select flex-1">' +
'<option value="search">' + t('codexlens.textSearch') + '</option>' +
'<option value="search_files">' + t('codexlens.fileSearch') + '</option>' +
'<option value="symbol">' + t('codexlens.symbolSearch') + '</option>' +
'</select>' +
'<select id="searchModeSelect" class="tool-config-select flex-1">' +
'<option value="exact">' + t('codexlens.exactMode') + '</option>' +
'<option value="fuzzy">' + t('codexlens.fuzzyMode') + '</option>' +
'<option value="hybrid">' + t('codexlens.hybridMode') + '</option>' +
'<option value="vector">' + t('codexlens.vectorMode') + '</option>' +
'</select>' +
'</div>' +
'<div>' +
'<input type="text" id="searchQueryInput" class="tool-config-input w-full" ' +
'placeholder="' + t('codexlens.searchPlaceholder') + '" />' +
'</div>' +
'<div>' +
'<button class="btn-sm btn-primary w-full" id="runSearchBtn">' +
'<i data-lucide="search" class="w-3 h-3"></i> ' + t('codexlens.runSearch') +
'</button>' +
'</div>' +
'<div id="searchResults" class="hidden">' +
? '<div class="codexlens-tab-content hidden" data-tab="search">' +
'<div class="space-y-4">' +
// Search Options Row
'<div class="grid grid-cols-2 gap-3">' +
'<div>' +
'<div class="flex items-center justify-between">' +
'<p class="text-sm font-medium">' + t('codexlens.results') + ':</p>' +
'<span id="searchResultCount" class="text-xs text-muted-foreground"></span>' +
'</div>' +
'<pre id="searchResultContent"></pre>' +
'<label class="block text-xs font-medium text-muted-foreground mb-1">Search Type</label>' +
'<select id="searchTypeSelect" class="tool-config-select w-full">' +
'<option value="search">Content Search</option>' +
'<option value="search_files">File Search</option>' +
'<option value="symbol">Symbol Search</option>' +
'</select>' +
'</div>' +
'<div>' +
'<label class="block text-xs font-medium text-muted-foreground mb-1">Mode</label>' +
'<select id="searchModeSelect" class="tool-config-select w-full">' +
'<option value="dense_rerank">Semantic (default)</option>' +
'<option value="fts">Exact (FTS)</option>' +
'<option value="fuzzy">Fuzzy</option>' +
'</select>' +
'</div>' +
'</div>' +
// Query Input
'<div>' +
'<input type="text" id="searchQueryInput" class="tool-config-input w-full text-base py-2.5" ' +
'placeholder="Enter search query..." />' +
'</div>' +
// Search Button
'<button class="btn btn-primary w-full py-2.5" id="runSearchBtn">' +
'<i data-lucide="search" class="w-4 h-4 mr-2"></i> Search' +
'</button>' +
// Results
'<div id="searchResults" class="hidden">' +
'<div class="flex items-center justify-between mb-2">' +
'<span class="text-sm font-medium">Results</span>' +
'<span id="searchResultCount" class="text-xs text-muted-foreground"></span>' +
'</div>' +
'<pre id="searchResultContent" class="text-xs bg-muted/50 rounded-lg p-3 overflow-auto max-h-64"></pre>' +
'</div>' +
'</div>' +
'</div>'
: '') +
// ========== ADVANCED TAB (only if installed) ==========
(isInstalled
? '<div class="codexlens-tab-content hidden" data-tab="advanced">' +
'<div class="space-y-4">' +
// Dependencies Section
'<div class="rounded-lg border border-border p-4">' +
'<h4 class="text-sm font-medium mb-3 flex items-center gap-2">' +
'<i data-lucide="package" class="w-4 h-4"></i> Dependencies' +
'</h4>' +
'<div id="semanticDepsStatus" class="space-y-2">' +
'<div class="text-sm text-muted-foreground">' + t('codexlens.checkingDeps') + '</div>' +
'</div>' +
'<div id="spladeStatus" class="space-y-2 mt-3 pt-3 border-t border-border">' +
'<div class="text-sm text-muted-foreground">' + t('common.loading') + '</div>' +
'</div>' +
'</div>' +
// Model Management
'<div class="rounded-lg border border-border p-4">' +
'<h4 class="text-sm font-medium mb-3 flex items-center gap-2">' +
'<i data-lucide="brain" class="w-4 h-4"></i> Models' +
'</h4>' +
'<div id="modelListContainer" class="space-y-2">' +
'<div class="text-sm text-muted-foreground">' + t('codexlens.loadingModels') + '</div>' +
'</div>' +
'</div>' +
// Danger Zone
'<div class="rounded-lg border border-destructive/30 p-4">' +
'<h4 class="text-sm font-medium text-destructive mb-3 flex items-center gap-2">' +
'<i data-lucide="alert-triangle" class="w-4 h-4"></i> Danger Zone' +
'</h4>' +
'<div class="flex flex-wrap gap-2">' +
'<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted/50 transition-colors" onclick="cleanCodexLensIndexes()">' +
'<i data-lucide="trash" class="w-3.5 h-3.5"></i> Clean All Indexes' +
'</button>' +
'<button class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-destructive/30 bg-destructive/5 text-destructive hover:bg-destructive/10 transition-colors" onclick="uninstallCodexLensFromManager()">' +
'<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> Uninstall' +
'</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>'
: '') +
'</div>' +
'</div>' + // End Tab Content Container
'</div>' + // End modal-body
// Footer
'<div class="tool-config-footer">' +
@@ -227,19 +333,55 @@ function buildCodexLensConfigContent(config) {
* Initialize CodexLens config modal event handlers
*/
function initCodexLensConfigEvents(currentConfig) {
// Tab switching
document.querySelectorAll('.codexlens-tab').forEach(function(tab) {
tab.onclick = function() {
// Remove active from all tabs
document.querySelectorAll('.codexlens-tab').forEach(function(t) {
t.classList.remove('active', 'border-primary', 'text-primary');
t.classList.add('border-transparent', 'text-muted-foreground');
});
// Hide all content
document.querySelectorAll('.codexlens-tab-content').forEach(function(c) {
c.classList.add('hidden');
c.classList.remove('active');
});
// Activate clicked tab
this.classList.add('active', 'border-primary', 'text-primary');
this.classList.remove('border-transparent', 'text-muted-foreground');
// Show corresponding content
var tabName = this.dataset.tab;
var content = document.querySelector('.codexlens-tab-content[data-tab="' + tabName + '"]');
if (content) {
content.classList.remove('hidden');
content.classList.add('active');
}
};
});
// Save button
var saveBtn = document.getElementById('saveCodexLensConfigBtn');
if (saveBtn) {
saveBtn.onclick = async function() {
var indexDirInput = document.getElementById('indexDirInput');
var apiMaxWorkersInput = document.getElementById('apiMaxWorkersInput');
var apiBatchSizeInput = document.getElementById('apiBatchSizeInput');
var newIndexDir = indexDirInput ? indexDirInput.value.trim() : '';
var newMaxWorkers = apiMaxWorkersInput ? parseInt(apiMaxWorkersInput.value) || 4 : 4;
var newBatchSize = apiBatchSizeInput ? parseInt(apiBatchSizeInput.value) || 8 : 8;
if (!newIndexDir) {
showRefreshToast(t('codexlens.pathEmpty'), 'error');
return;
}
if (newIndexDir === currentConfig.index_dir) {
// Check if anything changed
var hasChanges = newIndexDir !== currentConfig.index_dir ||
newMaxWorkers !== (currentConfig.api_max_workers || 4) ||
newBatchSize !== (currentConfig.api_batch_size || 8);
if (!hasChanges) {
closeModal();
return;
}
@@ -251,7 +393,11 @@ function initCodexLensConfigEvents(currentConfig) {
var response = await fetch('/api/codexlens/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index_dir: newIndexDir })
body: JSON.stringify({
index_dir: newIndexDir,
api_max_workers: newMaxWorkers,
api_batch_size: newBatchSize
})
});
var result = await response.json();
@@ -359,6 +505,112 @@ function initCodexLensConfigEvents(currentConfig) {
loadModelList();
}
// ============================================================
// ENVIRONMENT VARIABLES MANAGEMENT
// ============================================================
// Known env variable groups
var ENV_VARIABLES = {
'RERANKER_API_KEY': { label: 'Reranker API Key', placeholder: 'sk-...', type: 'password' },
'RERANKER_API_BASE': { label: 'Reranker API Base', placeholder: 'https://api.openai.com/v1' },
'RERANKER_MODEL': { label: 'Reranker Model', placeholder: 'text-embedding-3-small' },
'EMBEDDING_API_KEY': { label: 'Embedding API Key', placeholder: 'sk-...', type: 'password' },
'EMBEDDING_API_BASE': { label: 'Embedding API Base', placeholder: 'https://api.openai.com/v1' },
'EMBEDDING_MODEL': { label: 'Embedding Model', placeholder: 'text-embedding-3-small' },
'LITELLM_API_KEY': { label: 'LiteLLM API Key', placeholder: 'sk-...', type: 'password' },
'LITELLM_API_BASE': { label: 'LiteLLM API Base', placeholder: 'http://localhost:4000' },
'LITELLM_MODEL': { label: 'LiteLLM Model', placeholder: 'gpt-3.5-turbo' }
};
/**
* Load environment variables from ~/.codexlens/.env
*/
async function loadEnvVariables() {
var container = document.getElementById('envVarsContainer');
if (!container) return;
container.innerHTML = '<div class="text-xs text-muted-foreground animate-pulse">Loading...</div>';
try {
var response = await fetch('/api/codexlens/env');
var result = await response.json();
if (!result.success) {
container.innerHTML = '<div class="text-xs text-error">' + (result.error || 'Failed to load') + '</div>';
return;
}
var env = result.env || {};
var html = '<div class="space-y-2">';
// Render known variables with their values
for (var key in ENV_VARIABLES) {
var config = ENV_VARIABLES[key];
var value = env[key] || '';
var inputType = config.type || 'text';
html += '<div class="flex items-center gap-2">' +
'<label class="text-xs text-muted-foreground w-32 flex-shrink-0" title="' + escapeHtml(key) + '">' + escapeHtml(config.label) + '</label>' +
'<input type="' + inputType + '" class="tool-config-input flex-1 text-xs py-1" ' +
'data-env-key="' + escapeHtml(key) + '" value="' + escapeHtml(value) + '" placeholder="' + escapeHtml(config.placeholder || '') + '" />' +
'</div>';
}
html += '</div>' +
'<div class="flex gap-2 mt-3">' +
'<button class="btn-sm btn-primary flex-1" onclick="saveEnvVariables()">' +
'<i data-lucide="save" class="w-3 h-3"></i> Save' +
'</button>' +
'<button class="btn-sm btn-outline" onclick="loadEnvVariables()">' +
'<i data-lucide="refresh-cw" class="w-3 h-3"></i>' +
'</button>' +
'</div>' +
'<div class="text-xs text-muted-foreground mt-2">' +
'<i data-lucide="info" class="w-3 h-3 inline"></i> ' +
'Saved to: ' + escapeHtml(result.path) +
'</div>';
container.innerHTML = html;
if (window.lucide) lucide.createIcons();
} catch (err) {
container.innerHTML = '<div class="text-xs text-error">' + err.message + '</div>';
}
}
/**
* Save environment variables to ~/.codexlens/.env
*/
async function saveEnvVariables() {
var inputs = document.querySelectorAll('[data-env-key]');
var env = {};
inputs.forEach(function(input) {
var key = input.dataset.envKey;
var value = input.value.trim();
if (value) {
env[key] = value;
}
});
try {
var response = await fetch('/api/codexlens/env', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ env: env })
});
var result = await response.json();
if (result.success) {
showRefreshToast('Environment configuration saved', 'success');
} else {
showRefreshToast('Failed to save: ' + result.error, 'error');
}
} catch (err) {
showRefreshToast('Error: ' + err.message, 'error');
}
}
// ============================================================
// SEMANTIC DEPENDENCIES MANAGEMENT
// ============================================================

View File

@@ -807,31 +807,17 @@ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): P
}
return new Promise((resolve) => {
// Build command string - quote paths for shell execution
const quotedPython = `"${VENV_PYTHON}"`;
const cmdArgs = args.map(arg => {
// Quote arguments that contain spaces or special characters
if (arg.includes(' ') || arg.includes('\\')) {
return `"${arg}"`;
}
return arg;
});
// SECURITY: Use spawn without shell to prevent command injection
// Pass arguments directly - no manual quoting needed
// spawn's cwd option handles drive changes correctly on Windows
const spawnArgs = ['-m', 'codexlens', ...args];
// Build full command - on Windows, prepend cd to handle different drives
let fullCmd: string;
if (process.platform === 'win32' && cwd) {
// Use cd /d to change drive and directory, then run command
fullCmd = `cd /d "${cwd}" && ${quotedPython} -m codexlens ${cmdArgs.join(' ')}`;
} else {
fullCmd = `${quotedPython} -m codexlens ${cmdArgs.join(' ')}`;
}
// Use spawn with shell for real-time progress updates
// spawn streams output in real-time, unlike exec which buffers until completion
const child = spawn(fullCmd, [], {
cwd: process.platform === 'win32' ? undefined : cwd,
shell: process.platform === 'win32' ? process.env.ComSpec || true : true,
const child = spawn(VENV_PYTHON, spawnArgs, {
cwd,
shell: false, // CRITICAL: Prevent command injection
timeout,
// Ensure proper encoding on Windows
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
});
// Track indexing process for cancellation (only for init commands)