@probelabs/probe-chat
Version:
CLI and web interface for Probe code search (formerly @probelabs/probe-web and @probelabs/probe-chat)
750 lines (645 loc) • 24 kB
JavaScript
/**
* Aider backend implementation for code implementation tasks
* @module AiderBackend
*/
import BaseBackend from './BaseBackend.js';
import { BackendError, ErrorTypes, ProgressTracker, FileChangeParser, TokenEstimator } from '../core/utils.js';
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import { promises as fsPromises } from 'fs';
import path from 'path';
import os from 'os';
import { TIMEOUTS, getDefaultTimeoutMs } from '../core/timeouts.js';
const execPromise = promisify(exec);
/**
* Aider implementation backend
* @class
* @extends BaseBackend
*/
class AiderBackend extends BaseBackend {
constructor() {
super('aider', '1.0.0');
this.config = null;
this.aiderVersion = null;
}
/**
* @override
*/
async initialize(config) {
this.config = {
command: 'aider',
timeout: getDefaultTimeoutMs(), // Use centralized default (20 minutes)
maxOutputSize: 10 * 1024 * 1024, // 10MB
additionalArgs: [],
environment: {},
autoCommit: false,
modelSelection: 'auto',
...config
};
// Test aider availability
const available = await this.isAvailable();
if (!available) {
throw new BackendError(
'Aider command not found or not accessible. Please install aider with: pip install aider-chat',
ErrorTypes.DEPENDENCY_MISSING,
'AIDER_NOT_FOUND'
);
}
// Get aider version
try {
const { stdout } = await execPromise('aider --version', { timeout: TIMEOUTS.VERSION_CHECK });
this.aiderVersion = stdout.trim();
this.log('info', `Initialized with aider version: ${this.aiderVersion}`);
} catch (error) {
this.log('warn', 'Could not determine aider version', { error: error.message });
}
this.initialized = true;
}
/**
* @override
*/
async isAvailable() {
try {
// Test if aider command exists
await execPromise('which aider', { timeout: TIMEOUTS.VERSION_CHECK });
// Check if API key is available
const hasApiKey = !!(
process.env.ANTHROPIC_API_KEY ||
process.env.OPENAI_API_KEY ||
process.env.GOOGLE_API_KEY ||
process.env.GEMINI_API_KEY
);
if (!hasApiKey) {
this.log('warn', 'No API key found. Aider requires ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY');
return false;
}
return true;
} catch (error) {
return false;
}
}
/**
* @override
*/
getRequiredDependencies() {
return [
{
name: 'aider-chat',
type: 'pip',
version: '>=0.20.0',
installCommand: 'pip install aider-chat',
description: 'AI pair programming tool'
},
{
name: 'API Key',
type: 'environment',
description: 'One of: ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, or GEMINI_API_KEY'
}
];
}
/**
* @override
*/
getCapabilities() {
return {
supportsLanguages: ['python', 'javascript', 'typescript', 'go', 'rust', 'java', 'cpp', 'c', 'csharp', 'ruby', 'php', 'swift'],
supportsStreaming: true,
supportsRollback: true,
supportsDirectFileEdit: true,
supportsPlanGeneration: false,
supportsTestGeneration: false,
maxConcurrentSessions: 3
};
}
/**
* @override
*/
getDescription() {
return 'Aider - AI pair programming in your terminal';
}
/**
* @override
*/
async execute(request) {
this.checkInitialized();
const validation = this.validateRequest(request);
if (!validation.valid) {
throw new BackendError(
`Invalid request: ${validation.errors.join(', ')}`,
ErrorTypes.VALIDATION_ERROR,
'INVALID_REQUEST'
);
}
const sessionInfo = this.createSessionInfo(request.sessionId);
const progressTracker = new ProgressTracker(request.sessionId, request.callbacks?.onProgress);
this.activeSessions.set(request.sessionId, sessionInfo);
try {
progressTracker.startStep('prepare', 'Preparing aider execution');
// Create temporary file for task
const tempDir = os.tmpdir();
const tempFileName = `aider-task-${request.sessionId}-${Date.now()}.txt`;
const tempFilePath = path.join(tempDir, tempFileName);
await fsPromises.writeFile(tempFilePath, request.task, 'utf8');
sessionInfo.tempFile = tempFilePath;
this.log('debug', 'Created temporary task file', { path: tempFilePath });
progressTracker.endStep();
progressTracker.startStep('execute', 'Executing aider');
// Validate working directory
const workingDir = this.validateWorkingDirectory(request.context?.workingDirectory || process.cwd());
this.updateSessionStatus(request.sessionId, {
status: 'running',
progress: 25,
message: 'Aider is processing your request'
});
// Execute aider
const result = await this.executeCommand(workingDir, request, sessionInfo, progressTracker);
progressTracker.endStep();
// Clean up temp file
try {
await fsPromises.unlink(tempFilePath);
} catch (error) {
this.log('warn', 'Failed to clean up temp file', { path: tempFilePath, error: error.message });
}
this.updateSessionStatus(request.sessionId, {
status: 'completed',
progress: 100,
message: 'Implementation completed successfully'
});
return result;
} catch (error) {
// Clean up temp file on error
if (sessionInfo.tempFile) {
try {
await fsPromises.unlink(sessionInfo.tempFile);
} catch (cleanupError) {
this.log('warn', 'Failed to clean up temp file on error', { error: cleanupError.message });
}
}
this.updateSessionStatus(request.sessionId, {
status: 'failed',
message: error.message
});
if (error instanceof BackendError) {
throw error;
}
throw new BackendError(
`Aider execution failed: ${error.message}`,
ErrorTypes.EXECUTION_FAILED,
'AIDER_EXECUTION_FAILED',
{ originalError: error, sessionId: request.sessionId }
);
} finally {
this.activeSessions.delete(request.sessionId);
}
}
/**
* Build aider command arguments
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @param {string} tempFilePath - Path to temporary file with task
* @returns {Array<string>} Command arguments array for secure execution
* @private
*/
buildCommandArgs(request, tempFilePath) {
// Validate tempFilePath to prevent injection
if (!tempFilePath || typeof tempFilePath !== 'string') {
throw new BackendError(
'Invalid temporary file path',
ErrorTypes.VALIDATION_ERROR,
'INVALID_TEMP_FILE_PATH'
);
}
const args = [
'--yes',
'--no-check-update',
'--no-analytics',
'--message-file',
tempFilePath // Separate argument to prevent injection
];
// Handle auto-commit option
if (!request.options?.autoCommit && !this.config.autoCommit) {
args.push('--no-auto-commits');
}
// Add model selection
const model = this.selectModel(request);
if (model) {
// Validate model name to prevent injection
if (this.isValidModelName(model)) {
args.push('--model');
args.push(model);
} else {
this.log('warn', `Invalid model name ignored: ${model}`);
}
}
// Add timeout if specified
if (request.options?.timeout || this.config.timeout) {
const timeoutSeconds = Math.floor((request.options?.timeout || this.config.timeout) / 1000);
// Note: aider doesn't have a built-in timeout, this would need to be handled at process level
}
// Add additional arguments from config with validation
if (this.config.additionalArgs && this.config.additionalArgs.length > 0) {
const validatedArgs = this.validateAdditionalArgs(this.config.additionalArgs);
args.push(...validatedArgs);
}
// Add any custom arguments from request with validation
if (request.options?.additionalArgs) {
const validatedArgs = this.validateAdditionalArgs(request.options.additionalArgs);
args.push(...validatedArgs);
}
return args;
}
/**
* Select the appropriate model based on configuration and environment
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @returns {string|null} Model identifier or null
* @private
*/
selectModel(request) {
// Priority: request option > config > environment-based auto-selection
if (request.options?.model) {
return request.options.model;
}
if (this.config.model) {
return this.config.model;
}
if (this.config.modelSelection === 'auto') {
// Auto-select based on available API keys
const geminiApiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
const openaiApiKey = process.env.OPENAI_API_KEY;
if (geminiApiKey) {
return 'gemini/gemini-2.5-pro';
} else if (anthropicApiKey) {
return 'claude-3-5-sonnet-20241022';
} else if (openaiApiKey) {
return 'gpt-4';
}
}
return null;
}
/**
* Validate model name to prevent command injection
* @param {string} model - Model name to validate
* @returns {boolean} True if valid, false otherwise
* @private
*/
isValidModelName(model) {
// Just check if it's a non-empty string
// Model names change frequently and formats vary
return model && typeof model === 'string' && model.trim().length > 0;
}
/**
* Validate additional arguments to prevent command injection
* @param {Array<string>} args - Arguments to validate
* @returns {Array<string>} Validated arguments
* @private
*/
validateAdditionalArgs(args) {
if (!Array.isArray(args)) {
this.log('warn', 'additionalArgs must be an array, ignoring');
return [];
}
const validatedArgs = [];
const maxArgLength = 500; // Reasonable limit for individual arguments
for (const arg of args) {
if (typeof arg !== 'string') {
this.log('warn', `Skipping non-string argument: ${typeof arg}`);
continue;
}
if (arg.length > maxArgLength) {
this.log('warn', `Skipping overly long argument (${arg.length} chars)`);
continue;
}
// Check for dangerous patterns
if (this.containsShellMetacharacters(arg)) {
this.log('warn', `Skipping argument with shell metacharacters: ${arg.substring(0, 50)}`);
continue;
}
// Validate common aider flags
if (this.isValidAiderArgument(arg)) {
validatedArgs.push(arg);
} else {
this.log('warn', `Skipping potentially unsafe argument: ${arg.substring(0, 50)}`);
}
}
return validatedArgs;
}
/**
* Check if string contains shell metacharacters
* @param {string} str - String to check
* @returns {boolean} True if contains metacharacters
* @private
*/
containsShellMetacharacters(str) {
// Common shell metacharacters that could be used for injection
const shellMetacharacters = /[;&|`$(){}[\]<>*?'"\\]/;
const controlChars = /[\x00-\x1f\x7f]/; // Control characters
return shellMetacharacters.test(str) || controlChars.test(str);
}
/**
* Validate if argument is a known safe aider argument
* @param {string} arg - Argument to validate
* @returns {boolean} True if valid aider argument
* @private
*/
isValidAiderArgument(arg) {
// Whitelist of known safe aider arguments
const safeAiderFlags = [
'--yes', '--no-check-update', '--no-analytics', '--no-auto-commits',
'--model', '--message-file', '--dry-run', '--map-tokens', '--show-model-warnings',
'--no-show-model-warnings', '--edit-format', '--architect', '--weak-model',
'--cache-prompts', '--no-cache-prompts', '--map-refresh', '--restore-chat-history',
'--encoding', '--config'
];
// Check if it's a known flag
if (safeAiderFlags.includes(arg)) {
return true;
}
// Check if it's a flag with equals sign (like --model=value)
for (const flag of safeAiderFlags) {
if (arg.startsWith(flag + '=')) {
const value = arg.substring(flag.length + 1);
return !this.containsShellMetacharacters(value) && value.length <= 100;
}
}
// Allow simple values that don't look like flags if they're safe
if (!arg.startsWith('-') && !this.containsShellMetacharacters(arg) && arg.length <= 100) {
return true;
}
return false;
}
/**
* Validate command path to prevent command injection
* @param {string} command - Command to validate
* @returns {boolean} True if valid
* @private
*/
isValidCommand(command) {
if (!command || typeof command !== 'string') {
return false;
}
// Only allow alphanumeric, hyphens, underscores, and forward slashes for paths
const validCommandPattern = /^[a-zA-Z0-9._/-]+$/;
const maxLength = 200; // Reasonable limit for command paths
return validCommandPattern.test(command) &&
command.length <= maxLength &&
!this.containsShellMetacharacters(command);
}
/**
* Validate working directory path
* @param {string} dir - Directory path to validate
* @returns {string} Validated directory path
* @private
*/
validateWorkingDirectory(dir) {
if (!dir || typeof dir !== 'string') {
throw new BackendError(
'Invalid working directory',
ErrorTypes.VALIDATION_ERROR,
'INVALID_WORKING_DIRECTORY'
);
}
// Resolve path to prevent directory traversal
const resolvedPath = path.resolve(dir);
// Basic validation - ensure it doesn't contain obvious injection attempts
if (this.containsShellMetacharacters(resolvedPath)) {
throw new BackendError(
'Working directory contains unsafe characters',
ErrorTypes.VALIDATION_ERROR,
'UNSAFE_WORKING_DIRECTORY'
);
}
return resolvedPath;
}
/**
* Validate environment variables
* @param {Object} env - Environment variables to validate
* @returns {Object} Validated environment variables
* @private
*/
validateEnvironment(env) {
if (!env || typeof env !== 'object') {
return {};
}
const validatedEnv = {};
const maxValueLength = 1000; // Reasonable limit for env values
for (const [key, value] of Object.entries(env)) {
// Validate key
if (typeof key !== 'string' || !/^[A-Z_][A-Z0-9_]*$/i.test(key)) {
this.log('warn', `Skipping invalid environment variable key: ${key}`);
continue;
}
// Validate value
if (typeof value !== 'string') {
this.log('warn', `Skipping non-string environment variable value for: ${key}`);
continue;
}
if (value.length > maxValueLength) {
this.log('warn', `Skipping overly long environment variable value for: ${key}`);
continue;
}
// Don't allow control characters in environment variables
if (/[\x00-\x1f\x7f]/.test(value)) {
this.log('warn', `Skipping environment variable with control characters: ${key}`);
continue;
}
validatedEnv[key] = value;
}
return validatedEnv;
}
/**
* Execute aider command
* @param {string} workingDir - Working directory
* @param {import('../types/BackendTypes').ImplementRequest} request - Implementation request
* @param {Object} sessionInfo - Session information
* @param {ProgressTracker} progressTracker - Progress tracker
* @returns {Promise<import('../types/BackendTypes').ImplementResult>}
* @private
*/
async executeCommand(workingDir, request, sessionInfo, progressTracker) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
// Build command arguments securely
const commandArgs = this.buildCommandArgs(request, sessionInfo.tempFile);
const commandPath = this.config.command || 'aider';
this.log('info', 'Executing aider command', {
command: commandPath,
args: commandArgs.slice(0, 5), // Log first few args only for security
workingDir
});
// Validate command exists and is safe
if (!this.isValidCommand(commandPath)) {
throw new BackendError(
'Invalid or unsafe command path',
ErrorTypes.VALIDATION_ERROR,
'INVALID_COMMAND_PATH'
);
}
// Spawn the process directly (no shell interpretation)
const childProcess = spawn(commandPath, commandArgs, {
cwd: workingDir,
env: { ...process.env, ...this.validateEnvironment(this.config.environment) }
});
sessionInfo.childProcess = childProcess;
sessionInfo.cancel = () => {
if (childProcess && !childProcess.killed) {
this.log('info', 'Cancelling aider process', { sessionId: request.sessionId });
childProcess.kill('SIGTERM');
setTimeout(() => {
if (!childProcess.killed) {
childProcess.kill('SIGKILL');
}
}, 5000);
}
};
let stdoutData = '';
let stderrData = '';
let outputSize = 0;
let lastProgressUpdate = Date.now();
// Handle stdout
childProcess.stdout.on('data', (data) => {
const output = data.toString();
outputSize += output.length;
// Check output size limit
if (outputSize > this.config.maxOutputSize) {
childProcess.kill('SIGTERM');
reject(new BackendError(
'Output size exceeded maximum limit',
ErrorTypes.EXECUTION_FAILED,
'OUTPUT_TOO_LARGE',
{ limit: this.config.maxOutputSize, actual: outputSize }
));
return;
}
stdoutData += output;
// Stream output to stderr for real-time visibility
process.stderr.write(output);
// Send progress updates (throttled)
const now = Date.now();
if (now - lastProgressUpdate > 1000) { // Update every second
progressTracker.reportMessage(output.trim(), 'stdout');
lastProgressUpdate = now;
// Update session progress
const elapsedSeconds = Math.floor((now - startTime) / 1000);
const estimatedProgress = Math.min(25 + (elapsedSeconds * 2), 90); // Cap at 90%
this.updateSessionStatus(request.sessionId, {
progress: estimatedProgress
});
}
});
// Handle stderr
childProcess.stderr.on('data', (data) => {
const output = data.toString();
stderrData += output;
// Stream to stderr
process.stderr.write(output);
// Report warnings
if (output.toLowerCase().includes('warning') || output.toLowerCase().includes('error')) {
progressTracker.reportMessage(output.trim(), 'stderr');
}
});
// Handle process completion
childProcess.on('close', (code) => {
const executionTime = Date.now() - startTime;
// Clear timeout
clearTimeout(timeoutId);
this.log('info', `Aider process exited`, {
code,
executionTime,
outputSize: stdoutData.length
});
// Parse file changes from output
const changes = FileChangeParser.parseChanges(stdoutData + stderrData, workingDir);
const diffStats = FileChangeParser.extractDiffStats(stdoutData + stderrData);
if (code === 0) {
// Check for errors in output even if exit code is 0
const combinedOutput = stdoutData + stderrData;
const hasAuthError = /AuthenticationError|Invalid API key|insufficient permissions|not able to authenticate/i.test(combinedOutput);
const hasOtherErrors = /Error:|Exception:|Failed:|fatal:/i.test(combinedOutput);
const hasChanges = changes.length > 0;
// Only consider it successful if:
// 1. No authentication/critical errors found in output
// 2. Either changes were made OR this was an informational command
const isActualSuccess = !hasAuthError && !hasOtherErrors && (hasChanges || !request.requiresChanges);
if (isActualSuccess) {
resolve({
success: true,
sessionId: request.sessionId,
output: stdoutData,
changes,
metrics: {
executionTime,
filesModified: changes.length,
linesChanged: diffStats.insertions + diffStats.deletions,
tokensUsed: TokenEstimator.estimate(request.task + stdoutData),
exitCode: code
},
metadata: {
command: commandPath,
args: commandArgs.slice(0, 5), // Limited args for security
workingDirectory: workingDir,
aiderVersion: this.aiderVersion
}
});
} else {
// Exit code 0 but actual failure detected
const errorType = hasAuthError ? 'AUTHENTICATION_ERROR' : 'EXECUTION_FAILED';
const errorMessage = hasAuthError
? 'Authentication failed - check API key and permissions'
: `Aider completed but encountered errors: ${combinedOutput.substring(0, 200)}...`;
reject(new BackendError(
errorMessage,
hasAuthError ? ErrorTypes.AUTHENTICATION : ErrorTypes.EXECUTION_FAILED,
errorType,
{
exitCode: code,
hasChanges,
hasAuthError,
hasOtherErrors,
stdout: stdoutData.substring(0, 1000),
stderr: stderrData.substring(0, 1000)
}
));
}
} else {
reject(new BackendError(
`Aider process exited with code ${code}`,
ErrorTypes.EXECUTION_FAILED,
'AIDER_PROCESS_FAILED',
{
exitCode: code,
stdout: stdoutData.substring(0, 1000),
stderr: stderrData.substring(0, 1000)
}
));
}
});
// Handle process errors
childProcess.on('error', (error) => {
// Clear timeout
clearTimeout(timeoutId);
this.log('error', 'Failed to spawn aider process', { error: error.message });
reject(new BackendError(
`Failed to spawn aider process: ${error.message}`,
ErrorTypes.EXECUTION_FAILED,
'AIDER_SPAWN_FAILED',
{ originalError: error }
));
});
// Set timeout
const timeout = request.options?.timeout || this.config.timeout;
const timeoutId = setTimeout(() => {
if (!childProcess.killed) {
this.log('warn', 'Aider execution timed out', { timeout });
childProcess.kill('SIGTERM');
reject(new BackendError(
`Aider execution timed out after ${timeout}ms`,
ErrorTypes.TIMEOUT,
'AIDER_TIMEOUT',
{ timeout }
));
}
}, timeout);
});
}
}
export default AiderBackend;