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.
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/bin/ralph-agent.js +87 -0
- package/package.json +59 -0
- package/src/api-client.js +344 -0
- package/src/config.js +21 -0
- package/src/executor.js +1014 -0
- package/src/index.js +243 -0
- package/src/logger.js +96 -0
- package/src/ralph/prompt.md +165 -0
- package/src/ralph/ralph.sh +239 -0
- package/src/ralph-instance-manager.js +171 -0
- package/src/worktree-manager.js +170 -0
package/src/executor.js
ADDED
|
@@ -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;
|