ralphblaster-agent 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1014 @@
1
+ const { spawn } = require('child_process');
2
+ const fs = require('fs');
3
+ const fsPromises = require('fs').promises;
4
+ const path = require('path');
5
+ const logger = require('./logger');
6
+ const WorktreeManager = require('./worktree-manager');
7
+ const RalphInstanceManager = require('./ralph-instance-manager');
8
+
9
+ // Timing constants
10
+ const PROCESS_KILL_GRACE_PERIOD_MS = 2000;
11
+
12
+ class Executor {
13
+ constructor(apiClient = null) {
14
+ this.currentProcess = null; // Track current spawned process for cleanup
15
+ this.apiClient = apiClient; // Optional API client for metadata updates
16
+ }
17
+
18
+ /**
19
+ * Execute a job using Claude CLI
20
+ * @param {Object} job - Job object from API
21
+ * @param {Function} onProgress - Callback for progress updates
22
+ * @returns {Promise<Object>} Execution result
23
+ */
24
+ async execute(job, onProgress) {
25
+ const startTime = Date.now();
26
+
27
+ // Display human-friendly job description
28
+ const jobDescription = job.job_type === 'prd_generation'
29
+ ? `${job.prd_mode === 'plan' ? 'plan' : 'PRD'} generation`
30
+ : job.job_type;
31
+
32
+ logger.info(`Executing ${jobDescription} job #${job.id}`);
33
+
34
+ // Route to appropriate handler based on job type
35
+ if (job.job_type === 'prd_generation') {
36
+ return await this.executePrdGeneration(job, onProgress, startTime);
37
+ } else if (job.job_type === 'code_execution') {
38
+ return await this.executeCodeImplementation(job, onProgress, startTime);
39
+ } else {
40
+ throw new Error(`Unknown job type: ${job.job_type}`);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Validate prompt to prevent injection attacks
46
+ * @param {string} prompt - Prompt to validate
47
+ * @throws {Error} If prompt contains dangerous content
48
+ */
49
+ validatePrompt(prompt) {
50
+ if (!prompt || typeof prompt !== 'string') {
51
+ throw new Error('Prompt must be a non-empty string');
52
+ }
53
+
54
+ // Check prompt length (prevent DoS via massive prompts)
55
+ const MAX_PROMPT_LENGTH = 500000; // 500KB
56
+ if (prompt.length > MAX_PROMPT_LENGTH) {
57
+ throw new Error(`Prompt exceeds maximum length of ${MAX_PROMPT_LENGTH} characters`);
58
+ }
59
+
60
+ // Check for dangerous patterns that could lead to malicious operations
61
+ const dangerousPatterns = [
62
+ { pattern: /rm\s+-rf\s+\//, description: 'dangerous deletion command' },
63
+ { pattern: /rm\s+-rf\s+~/, description: 'dangerous home directory deletion' },
64
+ { pattern: /\/etc\/passwd/, description: 'system file access' },
65
+ { pattern: /\/etc\/shadow/, description: 'password file access' },
66
+ { pattern: /curl.*\|\s*sh/, description: 'remote code execution pattern' },
67
+ { pattern: /wget.*\|\s*sh/, description: 'remote code execution pattern' },
68
+ { pattern: /eval\s*\(/, description: 'code evaluation' },
69
+ { pattern: /exec\s*\(/, description: 'code execution' },
70
+ { pattern: /\$\(.*rm.*-rf/, description: 'command injection with deletion' },
71
+ { pattern: /`.*rm.*-rf/, description: 'command injection with deletion' },
72
+ { pattern: /base64.*decode.*eval/, description: 'obfuscated code execution' },
73
+ { pattern: /\.ssh\/id_rsa/, description: 'SSH key access' },
74
+ { pattern: /\.aws\/credentials/, description: 'AWS credentials access' }
75
+ ];
76
+
77
+ for (const { pattern, description } of dangerousPatterns) {
78
+ if (pattern.test(prompt)) {
79
+ logger.error(`Prompt validation failed: contains ${description}`);
80
+ throw new Error(`Prompt contains potentially dangerous content: ${description}`);
81
+ }
82
+ }
83
+
84
+ // Log sanitized version for security audit
85
+ const sanitizedPreview = prompt.substring(0, 200).replace(/\n/g, ' ');
86
+ logger.debug(`Prompt validated (${prompt.length} chars): ${sanitizedPreview}...`);
87
+
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * Execute PRD/Plan generation using Claude
93
+ * @param {Object} job - Job object from API
94
+ * @param {Function} onProgress - Callback for progress updates
95
+ * @param {number} startTime - Start timestamp
96
+ * @returns {Promise<Object>} Execution result
97
+ */
98
+ async executePrdGeneration(job, onProgress, startTime) {
99
+ // Determine content type from prd_mode field
100
+ const contentType = job.prd_mode === 'plan' ? 'Plan' : 'PRD';
101
+ logger.info(`Generating ${contentType} for: ${job.task_title}`);
102
+
103
+ // Route to appropriate generation method based on mode
104
+ if (job.prd_mode === 'plan') {
105
+ return await this.executePlanGeneration(job, onProgress, startTime);
106
+ } else {
107
+ return await this.executeStandardPrd(job, onProgress, startTime);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Execute standard PRD generation using Claude /prd skill
113
+ * @param {Object} job - Job object from API
114
+ * @param {Function} onProgress - Callback for progress updates
115
+ * @param {number} startTime - Start timestamp
116
+ * @returns {Promise<Object>} Execution result
117
+ */
118
+ async executeStandardPrd(job, onProgress, startTime) {
119
+ // Server must provide prompt
120
+ if (!job.prompt || !job.prompt.trim()) {
121
+ throw new Error('No prompt provided by server');
122
+ }
123
+
124
+ // Use the server-provided prompt (already formatted with template system)
125
+ const prompt = job.prompt;
126
+ logger.debug('Using server-provided prompt');
127
+
128
+ // Validate prompt for security
129
+ this.validatePrompt(prompt);
130
+
131
+ try {
132
+ // Determine and sanitize working directory
133
+ let workingDir = process.cwd();
134
+ if (job.project?.system_path) {
135
+ const sanitizedPath = this.validateAndSanitizePath(job.project.system_path);
136
+ if (sanitizedPath && fs.existsSync(sanitizedPath)) {
137
+ workingDir = sanitizedPath;
138
+ } else {
139
+ logger.warn(`Invalid or missing project path, using current directory: ${process.cwd()}`);
140
+ }
141
+ }
142
+
143
+ // Use Claude /prd skill
144
+ const output = await this.runClaudeSkill('prd', prompt, workingDir, onProgress);
145
+
146
+ const executionTimeMs = Date.now() - startTime;
147
+
148
+ return {
149
+ output: output,
150
+ prdContent: output.trim(), // The PRD content is the output
151
+ executionTimeMs: executionTimeMs
152
+ };
153
+ } catch (error) {
154
+ logger.error(`PRD generation failed for job #${job.id}:`, error.message);
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Execute plan generation using Claude Code planning mode
161
+ * @param {Object} job - Job object from API
162
+ * @param {Function} onProgress - Callback for progress updates
163
+ * @param {number} startTime - Start timestamp
164
+ * @returns {Promise<Object>} Execution result
165
+ */
166
+ async executePlanGeneration(job, onProgress, startTime) {
167
+ // Use the server-provided prompt (already formatted with template system)
168
+ const prompt = job.prompt;
169
+
170
+ try {
171
+ // Use Claude Code to trigger planning mode
172
+ const output = await this.runClaude(prompt, job.project?.system_path || process.cwd(), onProgress);
173
+
174
+ const executionTimeMs = Date.now() - startTime;
175
+
176
+ return {
177
+ output: output,
178
+ prdContent: output.trim(), // The plan content
179
+ executionTimeMs: executionTimeMs
180
+ };
181
+ } catch (error) {
182
+ logger.error(`Plan generation failed for job #${job.id}:`, error.message);
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Execute code implementation using Claude
189
+ * @param {Object} job - Job object from API
190
+ * @param {Function} onProgress - Callback for progress updates
191
+ * @param {number} startTime - Start timestamp
192
+ * @returns {Promise<Object>} Execution result
193
+ */
194
+ async executeCodeImplementation(job, onProgress, startTime) {
195
+ logger.info(`Implementing code in ${job.project.system_path}`);
196
+
197
+ // Validate and sanitize project path
198
+ const sanitizedPath = this.validateAndSanitizePath(job.project.system_path);
199
+ if (!sanitizedPath) {
200
+ throw new Error(`Invalid or unsafe project path: ${job.project.system_path}`);
201
+ }
202
+
203
+ // Validate project path exists
204
+ if (!fs.existsSync(sanitizedPath)) {
205
+ throw new Error(`Project path does not exist: ${sanitizedPath}`);
206
+ }
207
+
208
+ // Server must provide prompt
209
+ if (!job.prompt || !job.prompt.trim()) {
210
+ throw new Error('No prompt provided by server');
211
+ }
212
+
213
+ // Use the server-provided prompt (already formatted with template system)
214
+ const prompt = job.prompt;
215
+ logger.debug('Using server-provided prompt');
216
+
217
+ // Validate prompt for security
218
+ this.validatePrompt(prompt);
219
+
220
+ const worktreeManager = new WorktreeManager();
221
+ const ralphInstanceManager = new RalphInstanceManager();
222
+ let worktreePath = null;
223
+ let instancePath = null;
224
+
225
+ try {
226
+ // Create worktree before execution
227
+ worktreePath = await worktreeManager.createWorktree(job);
228
+
229
+ // Update job metadata with worktree path (best-effort)
230
+ if (this.apiClient) {
231
+ await this.apiClient.updateJobMetadata(job.id, { worktree_path: worktreePath });
232
+ }
233
+
234
+ // Create Ralph instance directory with all required files
235
+ logger.info('Creating Ralph instance...');
236
+ instancePath = await ralphInstanceManager.createInstance(worktreePath, prompt, job.id);
237
+ logger.info(`Ralph instance created at: ${instancePath}`);
238
+
239
+ // Get main repo path for environment variables
240
+ const mainRepoPath = sanitizedPath;
241
+
242
+ // Run Ralph in the instance directory
243
+ const output = await this.runRalphInstance(instancePath, worktreePath, mainRepoPath, onProgress);
244
+
245
+ // Save execution output to persistent log file (survives cleanup)
246
+ const logDir = path.join(mainRepoPath, '.ralph-logs');
247
+ try {
248
+ await fsPromises.mkdir(logDir, { recursive: true });
249
+ const logFile = path.join(logDir, `job-${job.id}.log`);
250
+ const logContent = `
251
+ ═══════════════════════════════════════════════════════════
252
+ Job #${job.id} - ${job.task_title}
253
+ Started: ${new Date(startTime).toISOString()}
254
+ ═══════════════════════════════════════════════════════════
255
+
256
+ ${output}
257
+
258
+ ═══════════════════════════════════════════════════════════
259
+ Execution completed at: ${new Date().toISOString()}
260
+ ═══════════════════════════════════════════════════════════
261
+ `;
262
+ await fsPromises.writeFile(logFile, logContent);
263
+ logger.info(`Execution log saved to: ${logFile}`);
264
+
265
+ // Also copy progress.txt and prd.json to logs
266
+ try {
267
+ const progressFile = path.join(instancePath, 'progress.txt');
268
+ const prdFile = path.join(instancePath, 'prd.json');
269
+
270
+ if (await fsPromises.access(progressFile).then(() => true).catch(() => false)) {
271
+ await fsPromises.copyFile(progressFile, path.join(logDir, `job-${job.id}-progress.txt`));
272
+ }
273
+ if (await fsPromises.access(prdFile).then(() => true).catch(() => false)) {
274
+ await fsPromises.copyFile(prdFile, path.join(logDir, `job-${job.id}-prd.json`));
275
+ }
276
+ } catch (copyError) {
277
+ logger.warn(`Failed to copy instance files to log: ${copyError.message}`);
278
+ }
279
+ } catch (logError) {
280
+ logger.warn(`Failed to save execution log: ${logError.message}`);
281
+ }
282
+
283
+ // Check for completion signal
284
+ const isComplete = ralphInstanceManager.hasCompletionSignal(output);
285
+
286
+ // Read progress summary from progress.txt
287
+ const progressSummary = await ralphInstanceManager.readProgressSummary(instancePath);
288
+
289
+ // Get branch name from prd.json
290
+ const branchName = await ralphInstanceManager.getBranchName(instancePath) || worktreeManager.getBranchName(job);
291
+
292
+ // Build execution summary
293
+ const executionSummaryLines = [];
294
+ executionSummaryLines.push('');
295
+ executionSummaryLines.push('═══════════════════════════════════════════════════════════');
296
+ executionSummaryLines.push(`Ralph Execution Summary for Job #${job.id}`);
297
+ executionSummaryLines.push('═══════════════════════════════════════════════════════════');
298
+ executionSummaryLines.push(`Task: ${job.task_title}`);
299
+ executionSummaryLines.push(`Branch: ${branchName}`);
300
+ executionSummaryLines.push(`Completed: ${isComplete ? 'YES ✓' : 'NO (max iterations reached)'}`);
301
+ executionSummaryLines.push('');
302
+ executionSummaryLines.push('Progress Summary:');
303
+ executionSummaryLines.push(progressSummary);
304
+ executionSummaryLines.push('');
305
+
306
+ const executionSummary = executionSummaryLines.join('\n');
307
+
308
+ // Log to console
309
+ logger.info(executionSummary);
310
+
311
+ // Send to server via progress callback
312
+ if (onProgress) {
313
+ onProgress(executionSummary);
314
+ }
315
+
316
+ // Log git activity details and send to server
317
+ const gitActivitySummary = await this.logGitActivity(worktreePath, branchName, job.id, onProgress);
318
+
319
+ const executionTimeMs = Date.now() - startTime;
320
+
321
+ return {
322
+ output: output,
323
+ summary: progressSummary || `Completed task: ${job.task_title}`,
324
+ branchName: branchName,
325
+ executionTimeMs: executionTimeMs,
326
+ ralphComplete: isComplete,
327
+ gitActivity: {
328
+ commitCount: gitActivitySummary.commitCount,
329
+ lastCommit: gitActivitySummary.lastCommitInfo,
330
+ changes: gitActivitySummary.changeStats,
331
+ pushedToRemote: gitActivitySummary.wasPushed,
332
+ hasUncommittedChanges: gitActivitySummary.hasUncommittedChanges
333
+ }
334
+ };
335
+ } catch (error) {
336
+ logger.error(`Code implementation failed for job #${job.id}`, error.message);
337
+ throw error;
338
+ } finally {
339
+ // Cleanup worktree if auto-cleanup enabled (default: true)
340
+ if (worktreePath && job.project.auto_cleanup_worktrees !== false) {
341
+ logger.info('Auto-cleanup enabled, removing worktree');
342
+ await worktreeManager.removeWorktree(job).catch(err =>
343
+ logger.error(`Cleanup failed: ${err.message}`)
344
+ );
345
+ } else if (worktreePath) {
346
+ logger.info(`Auto-cleanup disabled, keeping worktree: ${worktreePath}`);
347
+ logger.info(`Branch: ${worktreeManager.getBranchName(job)}`);
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Categorize error for user-friendly messaging
354
+ * @param {Error} error - The error object
355
+ * @param {string} stderr - Standard error output
356
+ * @param {number} exitCode - Process exit code
357
+ * @returns {Object} Object with category, userMessage, and technicalDetails
358
+ */
359
+ categorizeError(error, stderr = '', exitCode = null) {
360
+ let category = 'unknown';
361
+ let userMessage = error.message || String(error);
362
+ let technicalDetails = `Error: ${error.message}\nStderr: ${stderr}\nExit Code: ${exitCode}`;
363
+
364
+ // Check for Claude CLI not installed
365
+ if (error.code === 'ENOENT') {
366
+ category = 'claude_not_installed';
367
+ userMessage = 'Claude Code CLI is not installed or not found in PATH';
368
+ }
369
+ // Check for authentication issues
370
+ else if (stderr.match(/not authenticated/i) || stderr.match(/authentication failed/i) || stderr.match(/please log in/i)) {
371
+ category = 'not_authenticated';
372
+ userMessage = 'Claude CLI is not authenticated. Please run "claude auth"';
373
+ }
374
+ // Check for token limit exceeded
375
+ else if (stderr.match(/token limit exceeded/i) || stderr.match(/quota exceeded/i) || stderr.match(/insufficient credits/i)) {
376
+ category = 'out_of_tokens';
377
+ userMessage = 'Claude API token limit has been exceeded';
378
+ }
379
+ // Check for rate limiting
380
+ else if (stderr.match(/rate limit/i) || stderr.match(/too many requests/i) || stderr.match(/429/)) {
381
+ category = 'rate_limited';
382
+ userMessage = 'Claude API rate limit reached. Please wait before retrying';
383
+ }
384
+ // Check for permission denied
385
+ else if (stderr.match(/permission denied/i) || stderr.match(/EACCES/i) || error.code === 'EACCES') {
386
+ category = 'permission_denied';
387
+ userMessage = 'Permission denied accessing project files or directories';
388
+ }
389
+ // Check for timeout
390
+ else if (error.message && error.message.includes('timed out')) {
391
+ category = 'execution_timeout';
392
+ userMessage = 'Job execution exceeded the maximum timeout';
393
+ }
394
+ // Check for network errors
395
+ else if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT') {
396
+ category = 'network_error';
397
+ userMessage = 'Network error connecting to Claude API';
398
+ }
399
+ // Check for non-zero exit code (execution error)
400
+ else if (exitCode !== null && exitCode !== 0) {
401
+ category = 'execution_error';
402
+ userMessage = `Claude CLI execution failed with exit code ${exitCode}`;
403
+ }
404
+
405
+ logger.debug(`Error categorized as: ${category}`);
406
+
407
+ return {
408
+ category,
409
+ userMessage,
410
+ technicalDetails
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Get sanitized environment variables for Claude execution
416
+ * @returns {Object} Sanitized environment object
417
+ */
418
+ getSanitizedEnv() {
419
+ const safeEnv = {};
420
+
421
+ // Only copy necessary environment variables
422
+ const allowedVars = [
423
+ 'PATH',
424
+ 'HOME',
425
+ 'USER',
426
+ 'LANG',
427
+ 'LC_ALL',
428
+ 'TERM',
429
+ 'TMPDIR',
430
+ 'SHELL'
431
+ ];
432
+
433
+ for (const key of allowedVars) {
434
+ if (process.env[key]) {
435
+ safeEnv[key] = process.env[key];
436
+ }
437
+ }
438
+
439
+ logger.debug(`Sanitized environment: ${Object.keys(safeEnv).join(', ')}`);
440
+ return safeEnv;
441
+ }
442
+
443
+ /**
444
+ * Run Claude CLI skill (e.g., /prd)
445
+ * @param {string} skill - Skill name (without /)
446
+ * @param {string} prompt - Input for the skill
447
+ * @param {string} cwd - Working directory
448
+ * @param {Function} onProgress - Progress callback
449
+ * @param {number} timeout - Timeout in milliseconds (default: 1 hour)
450
+ * @returns {Promise<string>} Command output
451
+ */
452
+ runClaudeSkill(skill, prompt, cwd, onProgress, timeout = 3600000) {
453
+ return new Promise((resolve, reject) => {
454
+ logger.debug(`Running Claude skill: /${skill} with timeout: ${timeout}ms`);
455
+
456
+ const claude = spawn('claude', ['--permission-mode', 'acceptEdits', `/${skill}`], {
457
+ cwd: cwd,
458
+ shell: false, // FIXED: Don't use shell
459
+ env: this.getSanitizedEnv()
460
+ });
461
+
462
+ // Set timeout
463
+ const timer = setTimeout(() => {
464
+ logger.error(`Claude skill /${skill} timed out after ${timeout}ms`);
465
+ claude.kill('SIGTERM');
466
+ reject(new Error(`Claude skill /${skill} execution timed out after ${timeout}ms`));
467
+ }, timeout);
468
+
469
+ // Track process for shutdown cleanup
470
+ this.currentProcess = claude;
471
+
472
+ // Send prompt to stdin
473
+ claude.stdin.write(prompt);
474
+ claude.stdin.end();
475
+
476
+ let stdout = '';
477
+ let stderr = '';
478
+
479
+ claude.stdout.on('data', (data) => {
480
+ const chunk = data.toString();
481
+ stdout += chunk;
482
+
483
+ if (onProgress) {
484
+ onProgress(chunk);
485
+ }
486
+ });
487
+
488
+ claude.stderr.on('data', (data) => {
489
+ stderr += data.toString();
490
+ logger.warn('Claude stderr:', data.toString());
491
+ });
492
+
493
+ claude.on('close', (code) => {
494
+ clearTimeout(timer); // Clear timeout
495
+ this.currentProcess = null; // Clear process reference
496
+ if (code === 0) {
497
+ logger.debug(`Claude skill /${skill} completed successfully`);
498
+ resolve(stdout);
499
+ } else {
500
+ logger.error(`Claude skill /${skill} exited with code ${code}`);
501
+ const baseError = new Error(`Claude skill /${skill} failed with exit code ${code}: ${stderr}`);
502
+ const errorInfo = this.categorizeError(baseError, stderr, code);
503
+
504
+ // Attach categorization to error object
505
+ const enrichedError = new Error(errorInfo.userMessage);
506
+ enrichedError.category = errorInfo.category;
507
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
508
+ enrichedError.partialOutput = stdout; // Include any partial output
509
+
510
+ reject(enrichedError);
511
+ }
512
+ });
513
+
514
+ claude.on('error', (error) => {
515
+ clearTimeout(timer); // Clear timeout
516
+ this.currentProcess = null; // Clear process reference
517
+ logger.error(`Failed to spawn Claude skill /${skill}`, error.message);
518
+
519
+ const errorInfo = this.categorizeError(error, stderr, null);
520
+
521
+ // Attach categorization to error object
522
+ const enrichedError = new Error(errorInfo.userMessage);
523
+ enrichedError.category = errorInfo.category;
524
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
525
+ enrichedError.partialOutput = stdout; // Include any partial output
526
+
527
+ reject(enrichedError);
528
+ });
529
+ });
530
+ }
531
+
532
+ /**
533
+ * Run Claude CLI with the given prompt
534
+ * @param {string} prompt - Prompt text
535
+ * @param {string} cwd - Working directory
536
+ * @param {Function} onProgress - Progress callback
537
+ * @param {number} timeout - Timeout in milliseconds (default: 2 hours for code execution)
538
+ * @returns {Promise<string>} Command output
539
+ */
540
+ runClaude(prompt, cwd, onProgress, timeout = 7200000) {
541
+ return new Promise((resolve, reject) => {
542
+ logger.debug(`Starting Claude CLI execution with timeout: ${timeout}ms`);
543
+
544
+ // Use stdin to pass prompt - avoids shell injection
545
+ const claude = spawn('claude', ['--permission-mode', 'acceptEdits'], {
546
+ cwd: cwd,
547
+ shell: false, // FIXED: Don't use shell
548
+ env: this.getSanitizedEnv()
549
+ });
550
+
551
+ // Set timeout
552
+ const timer = setTimeout(() => {
553
+ logger.error(`Claude CLI timed out after ${timeout}ms`);
554
+ claude.kill('SIGTERM');
555
+ reject(new Error(`Claude CLI execution timed out after ${timeout}ms`));
556
+ }, timeout);
557
+
558
+ // Track process for shutdown cleanup
559
+ this.currentProcess = claude;
560
+
561
+ // Send prompt via stdin (safe from injection)
562
+ claude.stdin.write(prompt);
563
+ claude.stdin.end();
564
+
565
+ let stdout = '';
566
+ let stderr = '';
567
+
568
+ claude.stdout.on('data', (data) => {
569
+ const chunk = data.toString();
570
+ stdout += chunk;
571
+
572
+ // Send progress updates
573
+ if (onProgress) {
574
+ onProgress(chunk);
575
+ }
576
+ });
577
+
578
+ claude.stderr.on('data', (data) => {
579
+ stderr += data.toString();
580
+ logger.warn('Claude stderr:', data.toString());
581
+ });
582
+
583
+ claude.on('close', (code) => {
584
+ clearTimeout(timer); // Clear timeout
585
+ this.currentProcess = null; // Clear process reference
586
+ if (code === 0) {
587
+ logger.debug('Claude CLI execution completed successfully');
588
+ resolve(stdout);
589
+ } else {
590
+ logger.error(`Claude CLI exited with code ${code}`);
591
+ const baseError = new Error(`Claude CLI failed with exit code ${code}: ${stderr}`);
592
+ const errorInfo = this.categorizeError(baseError, stderr, code);
593
+
594
+ // Attach categorization to error object
595
+ const enrichedError = new Error(errorInfo.userMessage);
596
+ enrichedError.category = errorInfo.category;
597
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
598
+ enrichedError.partialOutput = stdout; // Include any partial output
599
+
600
+ reject(enrichedError);
601
+ }
602
+ });
603
+
604
+ claude.on('error', (error) => {
605
+ clearTimeout(timer); // Clear timeout
606
+ this.currentProcess = null; // Clear process reference
607
+ logger.error('Failed to spawn Claude CLI', error.message);
608
+
609
+ const errorInfo = this.categorizeError(error, stderr, null);
610
+
611
+ // Attach categorization to error object
612
+ const enrichedError = new Error(errorInfo.userMessage);
613
+ enrichedError.category = errorInfo.category;
614
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
615
+ enrichedError.partialOutput = stdout; // Include any partial output
616
+
617
+ reject(enrichedError);
618
+ });
619
+ });
620
+ }
621
+
622
+ /**
623
+ * Run Ralph autonomous agent in instance directory
624
+ * @param {string} instancePath - Path to Ralph instance directory
625
+ * @param {string} worktreePath - Path to git worktree
626
+ * @param {string} mainRepoPath - Path to main repository
627
+ * @param {Function} onProgress - Progress callback
628
+ * @param {number} timeout - Timeout in milliseconds (default: 2 hours)
629
+ * @returns {Promise<string>} Command output
630
+ */
631
+ runRalphInstance(instancePath, worktreePath, mainRepoPath, onProgress, timeout = 7200000) {
632
+ return new Promise((resolve, reject) => {
633
+ logger.debug(`Starting Ralph execution with timeout: ${timeout}ms`);
634
+
635
+ const ralphShPath = path.join(instancePath, 'ralph.sh');
636
+
637
+ // Execute ralph.sh with required environment variables
638
+ const ralph = spawn(ralphShPath, ['10'], { // Max 10 iterations
639
+ cwd: instancePath,
640
+ shell: false,
641
+ env: {
642
+ ...this.getSanitizedEnv(),
643
+ RALPH_WORKTREE_PATH: worktreePath,
644
+ RALPH_INSTANCE_DIR: instancePath,
645
+ RALPH_MAIN_REPO: mainRepoPath
646
+ }
647
+ });
648
+
649
+ // Set timeout
650
+ const timer = setTimeout(() => {
651
+ logger.error(`Ralph execution timed out after ${timeout}ms`);
652
+ ralph.kill('SIGTERM');
653
+ reject(new Error(`Ralph execution timed out after ${timeout}ms`));
654
+ }, timeout);
655
+
656
+ // Track process for shutdown cleanup
657
+ this.currentProcess = ralph;
658
+
659
+ let stdout = '';
660
+ let stderr = '';
661
+
662
+ ralph.stdout.on('data', (data) => {
663
+ const chunk = data.toString();
664
+ stdout += chunk;
665
+ logger.debug('Ralph output:', chunk);
666
+
667
+ // Stream output to progress callback
668
+ if (onProgress) {
669
+ onProgress(chunk);
670
+ }
671
+ });
672
+
673
+ ralph.stderr.on('data', (data) => {
674
+ stderr += data.toString();
675
+ logger.warn('Ralph stderr:', data.toString());
676
+ });
677
+
678
+ ralph.on('close', (code) => {
679
+ clearTimeout(timer);
680
+ this.currentProcess = null;
681
+
682
+ // Ralph exits with 0 on completion, 1 on max iterations without completion
683
+ if (code === 0 || code === 1) {
684
+ logger.debug(`Ralph execution completed with code ${code}`);
685
+ resolve(stdout);
686
+ } else {
687
+ logger.error(`Ralph exited with code ${code}`);
688
+ const baseError = new Error(`Ralph failed with exit code ${code}: ${stderr}`);
689
+ const errorInfo = this.categorizeError(baseError, stderr, code);
690
+
691
+ const enrichedError = new Error(errorInfo.userMessage);
692
+ enrichedError.category = errorInfo.category;
693
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
694
+ enrichedError.partialOutput = stdout;
695
+
696
+ reject(enrichedError);
697
+ }
698
+ });
699
+
700
+ ralph.on('error', (error) => {
701
+ clearTimeout(timer);
702
+ this.currentProcess = null;
703
+ logger.error('Failed to spawn Ralph', error.message);
704
+
705
+ const errorInfo = this.categorizeError(error, stderr, null);
706
+
707
+ const enrichedError = new Error(errorInfo.userMessage);
708
+ enrichedError.category = errorInfo.category;
709
+ enrichedError.technicalDetails = errorInfo.technicalDetails;
710
+ enrichedError.partialOutput = stdout;
711
+
712
+ reject(enrichedError);
713
+ });
714
+ });
715
+ }
716
+
717
+ /**
718
+ * Parse Claude output for summary and branch name
719
+ * @param {string} output - Claude output
720
+ * @returns {Object} Parsed result
721
+ */
722
+ parseOutput(output) {
723
+ const result = {
724
+ summary: null,
725
+ branchName: null
726
+ };
727
+
728
+ // Look for RALPH_SUMMARY: marker
729
+ const summaryMatch = output.match(/RALPH_SUMMARY:\s*(.+?)(?:\n|$)/);
730
+ if (summaryMatch) {
731
+ result.summary = summaryMatch[1].trim();
732
+ }
733
+
734
+ // Look for RALPH_BRANCH: marker
735
+ const branchMatch = output.match(/RALPH_BRANCH:\s*(.+?)(?:\n|$)/);
736
+ if (branchMatch) {
737
+ result.branchName = branchMatch[1].trim();
738
+ }
739
+
740
+ return result;
741
+ }
742
+
743
+ /**
744
+ * Log git activity details for a job
745
+ * @param {string} worktreePath - Path to the worktree
746
+ * @param {string} branchName - Name of the branch
747
+ * @param {number} jobId - Job ID
748
+ * @param {Function} onProgress - Optional progress callback to send updates to server
749
+ * @returns {Promise<Object>} Git activity summary object
750
+ */
751
+ async logGitActivity(worktreePath, branchName, jobId, onProgress = null) {
752
+ if (!worktreePath || !fs.existsSync(worktreePath)) {
753
+ logger.warn(`Worktree not found for git activity logging: ${worktreePath}`);
754
+ return;
755
+ }
756
+
757
+ try {
758
+ const { spawn } = require('child_process');
759
+
760
+ // Get commit count on this branch (new commits only, not in origin/main)
761
+ const commitCount = await new Promise((resolve) => {
762
+ const gitLog = spawn('git', ['rev-list', '--count', 'HEAD', `^origin/main`], {
763
+ cwd: worktreePath,
764
+ stdio: ['ignore', 'pipe', 'pipe']
765
+ });
766
+
767
+ let stdout = '';
768
+ gitLog.stdout.on('data', (data) => stdout += data.toString());
769
+ gitLog.on('close', () => resolve(parseInt(stdout.trim()) || 0));
770
+ gitLog.on('error', () => resolve(0));
771
+ });
772
+
773
+ // Also check for uncommitted changes
774
+ const hasUncommittedChanges = await new Promise((resolve) => {
775
+ const gitStatus = spawn('git', ['status', '--porcelain'], {
776
+ cwd: worktreePath,
777
+ stdio: ['ignore', 'pipe', 'pipe']
778
+ });
779
+
780
+ let stdout = '';
781
+ gitStatus.stdout.on('data', (data) => stdout += data.toString());
782
+ gitStatus.on('close', () => resolve(stdout.trim().length > 0));
783
+ gitStatus.on('error', () => resolve(false));
784
+ });
785
+
786
+ // Get last commit message and hash if commits exist
787
+ let lastCommitInfo = 'No commits yet';
788
+ if (commitCount > 0) {
789
+ lastCommitInfo = await new Promise((resolve) => {
790
+ const gitLog = spawn('git', ['log', '-1', '--pretty=format:%h - %s'], {
791
+ cwd: worktreePath,
792
+ stdio: ['ignore', 'pipe', 'pipe']
793
+ });
794
+
795
+ let stdout = '';
796
+ gitLog.stdout.on('data', (data) => stdout += data.toString());
797
+ gitLog.on('close', () => resolve(stdout.trim() || 'No commit info'));
798
+ gitLog.on('error', () => resolve('Failed to get commit info'));
799
+ });
800
+ }
801
+
802
+ // Check if branch was pushed to remote
803
+ const wasPushed = await new Promise((resolve) => {
804
+ const gitBranch = spawn('git', ['branch', '-r', '--contains', 'HEAD'], {
805
+ cwd: worktreePath,
806
+ stdio: ['ignore', 'pipe', 'pipe']
807
+ });
808
+
809
+ let stdout = '';
810
+ gitBranch.stdout.on('data', (data) => stdout += data.toString());
811
+ gitBranch.on('close', () => {
812
+ const remoteBranches = stdout.trim();
813
+ resolve(remoteBranches.includes(`origin/${branchName}`));
814
+ });
815
+ gitBranch.on('error', () => resolve(false));
816
+ });
817
+
818
+ // Get file change stats
819
+ const changeStats = await new Promise((resolve) => {
820
+ const gitDiff = spawn('git', ['diff', '--shortstat', 'origin/main...HEAD'], {
821
+ cwd: worktreePath,
822
+ stdio: ['ignore', 'pipe', 'pipe']
823
+ });
824
+
825
+ let stdout = '';
826
+ gitDiff.stdout.on('data', (data) => stdout += data.toString());
827
+ gitDiff.on('close', () => resolve(stdout.trim() || 'No changes'));
828
+ gitDiff.on('error', () => resolve('Failed to get change stats'));
829
+ });
830
+
831
+ // Build comprehensive git activity summary
832
+ const summaryLines = [];
833
+ summaryLines.push('');
834
+ summaryLines.push('═══════════════════════════════════════════════════════════');
835
+ summaryLines.push(`Git Activity Summary for Job #${jobId}`);
836
+ summaryLines.push('═══════════════════════════════════════════════════════════');
837
+ summaryLines.push(`Branch: ${branchName}`);
838
+ summaryLines.push(`New commits: ${commitCount}`);
839
+
840
+ if (commitCount > 0) {
841
+ summaryLines.push(`Latest commit: ${lastCommitInfo}`);
842
+ summaryLines.push(`Changes: ${changeStats}`);
843
+ summaryLines.push(`Pushed to remote: ${wasPushed ? 'YES ✓' : 'NO (local only)'}`);
844
+ } else {
845
+ summaryLines.push('⚠️ NO COMMITS MADE - Ralph did not create any commits');
846
+ if (hasUncommittedChanges) {
847
+ summaryLines.push('⚠️ Uncommitted changes detected - work was done but not committed!');
848
+ } else {
849
+ summaryLines.push('⚠️ No file changes detected - Ralph may have failed or had nothing to do');
850
+ }
851
+ }
852
+ summaryLines.push('═══════════════════════════════════════════════════════════');
853
+ summaryLines.push('');
854
+
855
+ const summaryText = summaryLines.join('\n');
856
+
857
+ // Log to console
858
+ logger.info(summaryText);
859
+
860
+ // Send to server if progress callback provided
861
+ if (onProgress) {
862
+ onProgress(summaryText);
863
+ }
864
+
865
+ // Return structured summary object
866
+ return {
867
+ branchName,
868
+ commitCount,
869
+ lastCommitInfo: commitCount > 0 ? lastCommitInfo : null,
870
+ changeStats: commitCount > 0 ? changeStats : null,
871
+ wasPushed,
872
+ hasUncommittedChanges,
873
+ summaryText
874
+ };
875
+ } catch (error) {
876
+ logger.warn(`Failed to log git activity: ${error.message}`);
877
+ return {
878
+ branchName,
879
+ commitCount: 0,
880
+ error: error.message,
881
+ summaryText: `Failed to gather git activity: ${error.message}`
882
+ };
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Kill the current running process if any
888
+ * Used during shutdown to prevent orphaned processes
889
+ * @returns {Promise<void>} Resolves when process is killed or grace period expires
890
+ */
891
+ async killCurrentProcess() {
892
+ if (this.currentProcess && !this.currentProcess.killed) {
893
+ logger.warn('Killing current Claude process due to shutdown');
894
+ try {
895
+ this.currentProcess.kill('SIGTERM');
896
+
897
+ // Wait for grace period, then force kill if still alive
898
+ await new Promise((resolve) => {
899
+ setTimeout(() => {
900
+ if (this.currentProcess && !this.currentProcess.killed) {
901
+ logger.warn('Force killing Claude process with SIGKILL');
902
+ try {
903
+ this.currentProcess.kill('SIGKILL');
904
+ } catch (killError) {
905
+ logger.error('Error force killing process', killError.message);
906
+ }
907
+ }
908
+ resolve();
909
+ }, PROCESS_KILL_GRACE_PERIOD_MS);
910
+ });
911
+ } catch (error) {
912
+ logger.error('Error killing Claude process', error.message);
913
+ }
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Validate and sanitize file system path to prevent directory traversal attacks
919
+ * @param {string} userPath - Path provided by user/API
920
+ * @returns {string|null} Sanitized absolute path or null if invalid
921
+ */
922
+ validateAndSanitizePath(userPath) {
923
+ if (!userPath || typeof userPath !== 'string') {
924
+ logger.warn('Path is empty or not a string');
925
+ return null;
926
+ }
927
+
928
+ try {
929
+ // Resolve to absolute path and normalize (removes .., ., etc.)
930
+ const resolvedPath = path.resolve(userPath);
931
+
932
+ // Check for null bytes (path traversal attack vector)
933
+ if (resolvedPath.includes('\0')) {
934
+ logger.error('Path contains null bytes (potential attack)');
935
+ return null;
936
+ }
937
+
938
+ // Comprehensive blacklist of protected system directories
939
+ const dangerousPaths = [
940
+ '/etc', '/bin', '/sbin', '/usr/bin', '/usr/sbin', // Core system dirs
941
+ '/System', '/Library', '/private', // macOS system dirs
942
+ '/Windows', '/Program Files', '/Program Files (x86)', // Windows system dirs
943
+ '/root', '/boot', '/dev', '/proc', '/sys' // Linux/Unix system dirs
944
+ ];
945
+
946
+ for (const dangerousPath of dangerousPaths) {
947
+ if (resolvedPath === dangerousPath || resolvedPath.startsWith(dangerousPath + '/')) {
948
+ logger.error(`Path points to protected system directory: ${resolvedPath}`);
949
+ return null;
950
+ }
951
+ }
952
+
953
+ // Block access to sensitive subdirectories even within user directories
954
+ const sensitiveSubdirectories = [
955
+ '.ssh', // SSH keys
956
+ '.aws', // AWS credentials
957
+ '.config/gcloud', // Google Cloud credentials
958
+ '.azure', // Azure credentials
959
+ '.kube', // Kubernetes configs
960
+ '.docker', // Docker credentials
961
+ '.gnupg', // GPG keys
962
+ 'Library/Keychains', // macOS keychains
963
+ 'AppData/Roaming', // Windows credential storage
964
+ '.password-store', // pass password manager
965
+ '.config/1Password', // 1Password
966
+ '.config/Bitwarden' // Bitwarden
967
+ ];
968
+
969
+ for (const sensitiveDir of sensitiveSubdirectories) {
970
+ const normalizedDir = sensitiveDir.replace(/\//g, path.sep);
971
+ if (resolvedPath.includes(path.sep + normalizedDir + path.sep) ||
972
+ resolvedPath.endsWith(path.sep + normalizedDir)) {
973
+ logger.error(`Path contains sensitive directory: ${sensitiveDir}`);
974
+ return null;
975
+ }
976
+ }
977
+
978
+ // Check if path is within allowed base paths (if configured)
979
+ const allowedBasePaths = process.env.RALPH_ALLOWED_PATHS
980
+ ? process.env.RALPH_ALLOWED_PATHS.split(':')
981
+ : null;
982
+
983
+ if (allowedBasePaths && allowedBasePaths.length > 0) {
984
+ const isAllowed = allowedBasePaths.some(basePath => {
985
+ const resolvedBase = path.resolve(basePath);
986
+ return resolvedPath === resolvedBase || resolvedPath.startsWith(resolvedBase + path.sep);
987
+ });
988
+
989
+ if (!isAllowed) {
990
+ logger.error(`Path is outside allowed base paths: ${resolvedPath}`);
991
+ return null;
992
+ }
993
+ } else {
994
+ // If no whitelist is configured, warn if path is outside typical user directories
995
+ const isUserPath = resolvedPath.startsWith('/Users/') || // macOS
996
+ resolvedPath.startsWith('/home/') || // Linux
997
+ /^[A-Z]:\\Users\\/i.test(resolvedPath); // Windows
998
+
999
+ if (!isUserPath) {
1000
+ logger.warn(`Path is outside typical user directories: ${resolvedPath}`);
1001
+ // Don't reject, just warn - some valid projects might be elsewhere
1002
+ }
1003
+ }
1004
+
1005
+ logger.debug(`Path sanitized: ${userPath} -> ${resolvedPath}`);
1006
+ return resolvedPath;
1007
+ } catch (error) {
1008
+ logger.error('Error sanitizing path', error.message);
1009
+ return null;
1010
+ }
1011
+ }
1012
+ }
1013
+
1014
+ module.exports = Executor;