wolverine-ai 4.0.5 → 4.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/package.json +1 -1
- package/src/core/error-hook.js +27 -6
- package/src/core/runner.js +97 -34
- package/src/core/verifier.js +21 -2
- package/src/core/wolverine.js +39 -11
- package/src/templates/server/config/settings.json +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Self-healing Node.js server framework powered by AI. Catches crashes, diagnoses errors, generates fixes, verifies, and restarts — automatically.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
package/src/core/error-hook.js
CHANGED
|
@@ -34,7 +34,15 @@ Module._load = function (request, parent, isMain) {
|
|
|
34
34
|
_hookFastify(instance);
|
|
35
35
|
return instance;
|
|
36
36
|
};
|
|
37
|
-
|
|
37
|
+
// #23: Copy all own properties (including non-enumerable and symbols) to preserve prototype chain
|
|
38
|
+
for (const key of Object.getOwnPropertyNames(originalFastify)) {
|
|
39
|
+
if (key !== "length" && key !== "name" && key !== "prototype") {
|
|
40
|
+
try { wrapped[key] = originalFastify[key]; } catch {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const sym of Object.getOwnPropertySymbols(originalFastify)) {
|
|
44
|
+
try { wrapped[sym] = originalFastify[sym]; } catch {}
|
|
45
|
+
}
|
|
38
46
|
wrapped.default = wrapped; // ESM compat
|
|
39
47
|
return wrapped;
|
|
40
48
|
}
|
|
@@ -48,7 +56,15 @@ Module._load = function (request, parent, isMain) {
|
|
|
48
56
|
_hookExpress(app);
|
|
49
57
|
return app;
|
|
50
58
|
};
|
|
51
|
-
|
|
59
|
+
// #23: Copy all own properties (including non-enumerable and symbols)
|
|
60
|
+
for (const key of Object.getOwnPropertyNames(originalExpress)) {
|
|
61
|
+
if (key !== "length" && key !== "name" && key !== "prototype") {
|
|
62
|
+
try { wrapped[key] = originalExpress[key]; } catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const sym of Object.getOwnPropertySymbols(originalExpress)) {
|
|
66
|
+
try { wrapped[sym] = originalExpress[sym]; } catch {}
|
|
67
|
+
}
|
|
52
68
|
return wrapped;
|
|
53
69
|
}
|
|
54
70
|
|
|
@@ -94,9 +110,13 @@ function _hookExpress(app) {
|
|
|
94
110
|
// Wrap app.listen to inject error middleware AFTER all user middleware
|
|
95
111
|
const originalListen = app.listen;
|
|
96
112
|
app.listen = function (...args) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
// #24: Use process.nextTick to ensure our error middleware is added AFTER
|
|
114
|
+
// any middleware registered synchronously after listen() is called
|
|
115
|
+
process.nextTick(() => {
|
|
116
|
+
app.use(function _wolverineErrorHook(err, req, res, next) {
|
|
117
|
+
_reportError(req.originalUrl || req.url, req.method, err);
|
|
118
|
+
next(err);
|
|
119
|
+
});
|
|
100
120
|
});
|
|
101
121
|
return originalListen.apply(this, args);
|
|
102
122
|
};
|
|
@@ -114,7 +134,8 @@ function _reportError(url, method, error) {
|
|
|
114
134
|
let file = null, line = null;
|
|
115
135
|
if (error.stack) {
|
|
116
136
|
for (const frame of error.stack.split("\n")) {
|
|
117
|
-
|
|
137
|
+
// #25: Second regex uses (.+) instead of ([^\s(]+) to handle Windows paths with spaces
|
|
138
|
+
const m = frame.match(/\(([^)]+):(\d+):(\d+)\)/) || frame.match(/at\s+(.+):(\d+):(\d+)/);
|
|
118
139
|
if (m && !m[1].includes("node_modules") && !m[1].includes("node:")) {
|
|
119
140
|
file = m[1]; line = parseInt(m[2], 10); break;
|
|
120
141
|
}
|
package/src/core/runner.js
CHANGED
|
@@ -284,7 +284,9 @@ class WolverineRunner {
|
|
|
284
284
|
this._clearStabilityTimer();
|
|
285
285
|
// Clear any pending heals — restart is a clean slate
|
|
286
286
|
this._pendingErrorHeal = null;
|
|
287
|
-
|
|
287
|
+
// #1: Don't clear _healInProgress here — only the heal function itself should clear it
|
|
288
|
+
// #6: Clear stale heal status so dashboard doesn't show phantom heals
|
|
289
|
+
this._healStatus = null;
|
|
288
290
|
|
|
289
291
|
if (this.child) {
|
|
290
292
|
const oldChild = this.child;
|
|
@@ -295,11 +297,9 @@ class WolverineRunner {
|
|
|
295
297
|
const onExit = () => {
|
|
296
298
|
if (spawned) return; // Prevent double-spawn from exit + force-kill timeout
|
|
297
299
|
spawned = true;
|
|
300
|
+
// #7: Don't call _ensurePortFree() here — _spawn() already calls it
|
|
298
301
|
// Give port time to fully release (TIME_WAIT)
|
|
299
|
-
setTimeout(() =>
|
|
300
|
-
this._ensurePortFree();
|
|
301
|
-
setTimeout(() => this._spawn(), 500);
|
|
302
|
-
}, 500);
|
|
302
|
+
setTimeout(() => this._spawn(), 500);
|
|
303
303
|
};
|
|
304
304
|
|
|
305
305
|
oldChild.removeAllListeners("exit");
|
|
@@ -314,7 +314,7 @@ class WolverineRunner {
|
|
|
314
314
|
}
|
|
315
315
|
}, 3000);
|
|
316
316
|
} else {
|
|
317
|
-
|
|
317
|
+
// #7: Don't call _ensurePortFree() here — _spawn() already calls it
|
|
318
318
|
setTimeout(() => this._spawn(), 500);
|
|
319
319
|
}
|
|
320
320
|
}
|
|
@@ -394,7 +394,12 @@ class WolverineRunner {
|
|
|
394
394
|
process.stderr.write(text);
|
|
395
395
|
});
|
|
396
396
|
|
|
397
|
-
|
|
397
|
+
// #27: Only start stability timer if there's a backup to promote — don't clear
|
|
398
|
+
// an existing timer on every spawn (e.g., auto-update restart shouldn't reset
|
|
399
|
+
// the stability countdown for a previously healed backup)
|
|
400
|
+
if (this._lastBackupId) {
|
|
401
|
+
this._startStabilityTimer();
|
|
402
|
+
}
|
|
398
403
|
|
|
399
404
|
// Start process monitor (memory, CPU, heartbeat)
|
|
400
405
|
if (this.child && this.child.pid) {
|
|
@@ -418,32 +423,46 @@ class WolverineRunner {
|
|
|
418
423
|
this.healthMonitor.stop();
|
|
419
424
|
this.healthMonitor.reset();
|
|
420
425
|
this.healthMonitor.start(async (reason) => {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
this.child
|
|
432
|
-
|
|
426
|
+
try {
|
|
427
|
+
if (this._healInProgress || !this.running) return;
|
|
428
|
+
// #26: Claim the heal lock immediately — prevents exit event from starting
|
|
429
|
+
// a concurrent heal between our check and the child kill below
|
|
430
|
+
this._healInProgress = true;
|
|
431
|
+
console.log(chalk.red(`\n🚨 Health check triggered heal (reason: ${reason})`));
|
|
432
|
+
this.logger.error(EVENT_TYPES.HEALTH_UNRESPONSIVE, `Server unresponsive: ${reason}`, { reason });
|
|
433
|
+
this.healthMonitor.stop();
|
|
434
|
+
|
|
435
|
+
// Kill the hung process — remove exit listener to prevent double-heal
|
|
436
|
+
if (this.child) {
|
|
437
|
+
const pid = this.child.pid;
|
|
438
|
+
this.child.removeAllListeners("exit");
|
|
439
|
+
this._killProcessTree(pid, "SIGKILL");
|
|
440
|
+
this.child = null;
|
|
441
|
+
}
|
|
433
442
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
443
|
+
// Synthesize error context for the heal pipeline
|
|
444
|
+
this._stderrBuffer = `Server became unresponsive. Health check failed: ${reason}\n` +
|
|
445
|
+
`The server was running but stopped responding to HTTP requests.\n` +
|
|
446
|
+
`Possible causes: infinite loop, deadlock, memory exhaustion, blocked event loop.`;
|
|
438
447
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
448
|
+
this.retryCount++;
|
|
449
|
+
if (this.retryCount > this.maxRetries) {
|
|
450
|
+
console.log(chalk.red(`\n🛑 Max retries reached.`));
|
|
451
|
+
this._logRollbackHint();
|
|
452
|
+
this.running = false;
|
|
453
|
+
this._healInProgress = false;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
// Release lock so _healAndRestart can acquire it
|
|
457
|
+
this._healInProgress = false;
|
|
458
|
+
await this._healAndRestart();
|
|
459
|
+
} catch (err) {
|
|
460
|
+
// #5: Prevent unhandled errors in health callback from crashing the parent
|
|
461
|
+
console.log(chalk.red(` ⚠️ Health callback error: ${err.message}`));
|
|
462
|
+
this._healInProgress = false;
|
|
463
|
+
this._healStatus = null;
|
|
464
|
+
if (this.running) this._spawn();
|
|
445
465
|
}
|
|
446
|
-
await this._healAndRestart();
|
|
447
466
|
});
|
|
448
467
|
|
|
449
468
|
this.child.on("exit", async (code, signal) => {
|
|
@@ -459,6 +478,13 @@ class WolverineRunner {
|
|
|
459
478
|
return;
|
|
460
479
|
}
|
|
461
480
|
|
|
481
|
+
// #28: SIGKILL = likely OOM — synthesize useful stderr for the heal pipeline
|
|
482
|
+
if (signal === "SIGKILL" && (!this._stderrBuffer.trim() || this._stderrBuffer.trim().length < 10)) {
|
|
483
|
+
this._stderrBuffer = `Process killed by SIGKILL (possible OOM). Memory limit may have been exceeded. Check memory usage patterns and reduce memory consumption.\nExit code: ${code}, Signal: ${signal}`;
|
|
484
|
+
console.log(chalk.red(`\n💀 Process killed by SIGKILL (possible OOM)`));
|
|
485
|
+
this.logger.error(EVENT_TYPES.PROCESS_CRASH, "SIGKILL — possible OOM", { exitCode: code, signal });
|
|
486
|
+
}
|
|
487
|
+
|
|
462
488
|
// Killed by signal with no stderr — just restart, don't waste tokens healing
|
|
463
489
|
if (!this._stderrBuffer.trim() || this._stderrBuffer.trim().length < 10) {
|
|
464
490
|
console.log(chalk.yellow(`\n⚠️ Process killed (code: ${code}, signal: ${signal}) — no error to heal, restarting`));
|
|
@@ -483,13 +509,28 @@ class WolverineRunner {
|
|
|
483
509
|
}
|
|
484
510
|
|
|
485
511
|
this.retryCount++;
|
|
486
|
-
|
|
512
|
+
// #3: Guard against unhandled rejections — don't let heal errors crash the parent
|
|
513
|
+
try {
|
|
514
|
+
await this._healAndRestart();
|
|
515
|
+
} catch (healErr) {
|
|
516
|
+
console.log(chalk.red(` ⚠️ Heal error (recovering): ${healErr.message}`));
|
|
517
|
+
this._healInProgress = false;
|
|
518
|
+
this._healStatus = null;
|
|
519
|
+
if (this.running) this._spawn(); // restart without healing
|
|
520
|
+
}
|
|
487
521
|
});
|
|
488
522
|
|
|
489
523
|
this.child.on("error", (err) => {
|
|
490
524
|
console.log(chalk.red(`Failed to start process: ${err.message}`));
|
|
491
525
|
this.logger.error(EVENT_TYPES.PROCESS_CRASH, `Failed to start: ${err.message}`);
|
|
492
|
-
|
|
526
|
+
// #10: Retry spawn after delay instead of permanently dying
|
|
527
|
+
if (this.running && this.retryCount < this.maxRetries) {
|
|
528
|
+
this.retryCount++;
|
|
529
|
+
console.log(chalk.yellow(` Retrying spawn in 5s (attempt ${this.retryCount}/${this.maxRetries})...`));
|
|
530
|
+
setTimeout(() => { if (this.running) this._spawn(); }, 5000);
|
|
531
|
+
} else {
|
|
532
|
+
this.running = false;
|
|
533
|
+
}
|
|
493
534
|
});
|
|
494
535
|
|
|
495
536
|
// IPC channel: child reports caught 500 errors (Fastify/Express)
|
|
@@ -519,6 +560,8 @@ class WolverineRunner {
|
|
|
519
560
|
|
|
520
561
|
async _healAndRestart() {
|
|
521
562
|
if (this._healInProgress) return;
|
|
563
|
+
// #9: Bail if stop() was called during the window between crash and heal
|
|
564
|
+
if (this._shuttingDown) return;
|
|
522
565
|
this._healInProgress = true;
|
|
523
566
|
this._healStatus = { active: true, error: this._stderrBuffer.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
|
|
524
567
|
|
|
@@ -545,6 +588,8 @@ class WolverineRunner {
|
|
|
545
588
|
}
|
|
546
589
|
|
|
547
590
|
try {
|
|
591
|
+
// #9: Check again before expensive heal — stop() may have been called during loop guard
|
|
592
|
+
if (this._shuttingDown) { this._healInProgress = false; return; }
|
|
548
593
|
const result = await heal({
|
|
549
594
|
stderr: this._stderrBuffer,
|
|
550
595
|
cwd: this.cwd,
|
|
@@ -584,6 +629,8 @@ class WolverineRunner {
|
|
|
584
629
|
this._healStatus = null;
|
|
585
630
|
// Clear pending errors — the heal fixed the root cause, stale errors are irrelevant
|
|
586
631
|
this._pendingErrorHeal = null;
|
|
632
|
+
// #9: Don't restart if stop() was called while heal was running
|
|
633
|
+
if (this._shuttingDown) return;
|
|
587
634
|
// Use restart() to properly kill old child before spawning — prevents EADDRINUSE
|
|
588
635
|
this.restart();
|
|
589
636
|
} else {
|
|
@@ -626,9 +673,14 @@ class WolverineRunner {
|
|
|
626
673
|
}
|
|
627
674
|
}
|
|
628
675
|
} catch (err) {
|
|
629
|
-
|
|
676
|
+
// #4: Don't permanently die on transient errors — restart without healing
|
|
677
|
+
console.log(chalk.red(`\n🐺 Wolverine heal error (recovering): ${err.message}`));
|
|
630
678
|
this._healInProgress = false;
|
|
631
|
-
this.
|
|
679
|
+
this._healStatus = null;
|
|
680
|
+
if (this.running) {
|
|
681
|
+
console.log(chalk.yellow(" Restarting without healing..."));
|
|
682
|
+
this._spawn();
|
|
683
|
+
}
|
|
632
684
|
}
|
|
633
685
|
}
|
|
634
686
|
|
|
@@ -646,6 +698,15 @@ class WolverineRunner {
|
|
|
646
698
|
}
|
|
647
699
|
this._healInProgress = true;
|
|
648
700
|
|
|
701
|
+
// #8: Safety timeout — if heal hangs, force-release the lock after 6 minutes
|
|
702
|
+
const healTimeout = setTimeout(() => {
|
|
703
|
+
if (this._healInProgress) {
|
|
704
|
+
console.log(chalk.red(` ⚠️ _healFromError safety timeout (6min) — releasing heal lock`));
|
|
705
|
+
this._healInProgress = false;
|
|
706
|
+
this._healStatus = null;
|
|
707
|
+
}
|
|
708
|
+
}, 360000);
|
|
709
|
+
|
|
649
710
|
console.log(chalk.yellow(`\n🐺 Wolverine healing caught error on ${routePath}...`));
|
|
650
711
|
this._healStatus = { active: true, route: routePath, error: errorDetails?.message?.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
|
|
651
712
|
this.logger.info("heal.error_monitor", `Healing caught 500 on ${routePath}`, { route: routePath });
|
|
@@ -700,6 +761,7 @@ class WolverineRunner {
|
|
|
700
761
|
routeContext: { path: routePath, method: errorDetails?.method },
|
|
701
762
|
});
|
|
702
763
|
|
|
764
|
+
clearTimeout(healTimeout);
|
|
703
765
|
if (result.healed) {
|
|
704
766
|
console.log(chalk.green(`\n🐺 Wolverine healed ${routePath} via ${result.mode}! Restarting...\n`));
|
|
705
767
|
this.retryCount = 0; // Fresh start after successful heal
|
|
@@ -721,6 +783,7 @@ class WolverineRunner {
|
|
|
721
783
|
this._healStatus = null;
|
|
722
784
|
}
|
|
723
785
|
} catch (err) {
|
|
786
|
+
clearTimeout(healTimeout);
|
|
724
787
|
console.log(chalk.red(`\n🐺 Error during heal: ${err.message}`));
|
|
725
788
|
this._healInProgress = false;
|
|
726
789
|
this._healStatus = null;
|
package/src/core/verifier.js
CHANGED
|
@@ -23,6 +23,8 @@ const BOOT_PROBE_TIMEOUT_MS = 10000; // 10 seconds
|
|
|
23
23
|
*/
|
|
24
24
|
function syntaxCheck(scriptPath) {
|
|
25
25
|
return new Promise((resolve) => {
|
|
26
|
+
// #21: Guard against double resolve from exit + error firing in sequence
|
|
27
|
+
let settled = false;
|
|
26
28
|
const child = spawn("node", ["--check", scriptPath], {
|
|
27
29
|
stdio: ["ignore", "ignore", "pipe"],
|
|
28
30
|
timeout: 5000,
|
|
@@ -32,6 +34,8 @@ function syntaxCheck(scriptPath) {
|
|
|
32
34
|
child.stderr.on("data", (data) => { stderr += data.toString(); });
|
|
33
35
|
|
|
34
36
|
child.on("exit", (code) => {
|
|
37
|
+
if (settled) return;
|
|
38
|
+
settled = true;
|
|
35
39
|
resolve({
|
|
36
40
|
valid: code === 0,
|
|
37
41
|
error: code !== 0 ? stderr.trim() : undefined,
|
|
@@ -39,6 +43,8 @@ function syntaxCheck(scriptPath) {
|
|
|
39
43
|
});
|
|
40
44
|
|
|
41
45
|
child.on("error", (err) => {
|
|
46
|
+
if (settled) return;
|
|
47
|
+
settled = true;
|
|
42
48
|
resolve({ valid: false, error: err.message });
|
|
43
49
|
});
|
|
44
50
|
});
|
|
@@ -71,6 +77,8 @@ function bootProbe(scriptPath, cwd, originalErrorSignature) {
|
|
|
71
77
|
child.on("exit", (code) => {
|
|
72
78
|
if (settled) return;
|
|
73
79
|
settled = true;
|
|
80
|
+
// #19: Always ensure the child is killed after settling (handles orphan sub-processes)
|
|
81
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
74
82
|
|
|
75
83
|
if (code === 0) {
|
|
76
84
|
resolve({ status: "alive" });
|
|
@@ -101,6 +109,8 @@ function bootProbe(scriptPath, cwd, originalErrorSignature) {
|
|
|
101
109
|
child.on("error", (err) => {
|
|
102
110
|
if (settled) return;
|
|
103
111
|
settled = true;
|
|
112
|
+
// #19: Always ensure the child is killed after settling
|
|
113
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
104
114
|
resolve({ status: "crashed", stderr: err.message, sameError: false, exitCode: null });
|
|
105
115
|
});
|
|
106
116
|
|
|
@@ -192,9 +202,10 @@ async function verifyFix(scriptPath, cwd, originalErrorSignature, routeContext)
|
|
|
192
202
|
const relPath = path.relative(cwd, changedFile).replace(/\\/g, "/");
|
|
193
203
|
if (relPath.startsWith("server/") && relPath.endsWith(".js")) {
|
|
194
204
|
try {
|
|
195
|
-
const {
|
|
205
|
+
const { execFileSync } = require("child_process");
|
|
206
|
+
// #22: Use execFileSync with args array to prevent path injection via relPath
|
|
196
207
|
const testCode = `try{require('./${relPath}');console.log('MODULE_OK')}catch(e){console.error(e.message);process.exit(1)}`;
|
|
197
|
-
const out =
|
|
208
|
+
const out = execFileSync("node", ["-e", testCode], {
|
|
198
209
|
cwd, timeout: 5000, encoding: "utf-8",
|
|
199
210
|
env: { ...process.env, NODE_PATH: path.join(cwd, "node_modules") },
|
|
200
211
|
});
|
|
@@ -237,6 +248,14 @@ function routeProbe(scriptPath, cwd, routeContext) {
|
|
|
237
248
|
child.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
238
249
|
child.stderr.on("data", (d) => { stderr += d.toString(); });
|
|
239
250
|
|
|
251
|
+
// #20: Handle spawn errors (e.g., node binary not found)
|
|
252
|
+
child.on("error", (err) => {
|
|
253
|
+
clearInterval(checkPort);
|
|
254
|
+
if (settled) return;
|
|
255
|
+
settled = true;
|
|
256
|
+
resolve({ status: "failed", statusCode: 0, body: err.message });
|
|
257
|
+
});
|
|
258
|
+
|
|
240
259
|
child.on("exit", () => {
|
|
241
260
|
if (settled) return;
|
|
242
261
|
settled = true;
|
package/src/core/wolverine.js
CHANGED
|
@@ -35,6 +35,17 @@ async function heal(opts) {
|
|
|
35
35
|
if (err.message === "timeout") {
|
|
36
36
|
console.log(chalk.red(`\n🐺 Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s`));
|
|
37
37
|
if (opts.logger) opts.logger.error(EVENT_TYPES.HEAL_FAILED, `Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s`);
|
|
38
|
+
// #11: Rollback on timeout — the background _healImpl may have partially applied patches
|
|
39
|
+
if (opts.backupManager) {
|
|
40
|
+
try {
|
|
41
|
+
const all = opts.backupManager.getAll();
|
|
42
|
+
const latest = all.find(b => b.status === "unstable");
|
|
43
|
+
if (latest) {
|
|
44
|
+
opts.backupManager.rollbackTo(latest.id);
|
|
45
|
+
console.log(chalk.yellow(` ↩️ Rolled back to ${latest.id} (timeout cleanup)`));
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
38
49
|
return { healed: false, explanation: `Heal timed out after ${HEAL_TIMEOUT_MS / 1000}s` };
|
|
39
50
|
}
|
|
40
51
|
throw err;
|
|
@@ -154,7 +165,7 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
154
165
|
// 4c. Pre-heal operational fix — detect common non-code errors
|
|
155
166
|
// Some crashes aren't code bugs (missing npm packages, missing config files).
|
|
156
167
|
// Fix these directly without wasting AI tokens.
|
|
157
|
-
const opsFix = await tryOperationalFix(parsed, cwd, logger);
|
|
168
|
+
const opsFix = await tryOperationalFix(parsed, cwd, logger, sandbox);
|
|
158
169
|
if (opsFix.fixed) {
|
|
159
170
|
console.log(chalk.green(` ⚡ Operational fix applied: ${opsFix.action}`));
|
|
160
171
|
if (logger) logger.info(EVENT_TYPES.HEAL_SUCCESS, `Operational fix: ${opsFix.action}`, { action: opsFix.action });
|
|
@@ -286,8 +297,15 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
286
297
|
goal: `Fix: ${parsed.errorMessage.slice(0, 80)}`,
|
|
287
298
|
|
|
288
299
|
onAttempt: async (iteration, researchCtx, priorAttempts) => {
|
|
289
|
-
// Create backup for this attempt
|
|
290
|
-
|
|
300
|
+
// #12: Create backup for this attempt — if backup fails, skip the attempt entirely
|
|
301
|
+
let bid;
|
|
302
|
+
try {
|
|
303
|
+
bid = backupManager.createBackup(`heal attempt ${iteration}: ${parsed.errorMessage.slice(0, 60)}`);
|
|
304
|
+
} catch (backupErr) {
|
|
305
|
+
console.log(chalk.red(` ⚠️ Backup creation failed: ${backupErr.message} — skipping attempt`));
|
|
306
|
+
if (logger) logger.error(EVENT_TYPES.BACKUP_CREATED, `Backup failed: ${backupErr.message}`);
|
|
307
|
+
return { healed: false, explanation: `Backup creation failed: ${backupErr.message}` };
|
|
308
|
+
}
|
|
291
309
|
backupManager.setErrorSignature(bid, errorSignature);
|
|
292
310
|
if (logger) logger.info(EVENT_TYPES.BACKUP_CREATED, `Backup ${bid} (iteration ${iteration})`, { backupId: bid });
|
|
293
311
|
|
|
@@ -312,7 +330,7 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
312
330
|
errorMessage: parsed.errorMessage, stackTrace: parsed.stackTrace,
|
|
313
331
|
extraContext: envContext,
|
|
314
332
|
});
|
|
315
|
-
|
|
333
|
+
// #15: Don't record rate limit until AFTER verification — failed attempts shouldn't exhaust the limit
|
|
316
334
|
|
|
317
335
|
// Execute shell commands first (npm install, mkdir, etc.)
|
|
318
336
|
if (repair.commands && Array.isArray(repair.commands)) {
|
|
@@ -351,6 +369,7 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
351
369
|
const verification = await verifyFix(parsed.filePath, cwd, errorSignature, routeContext);
|
|
352
370
|
if (verification.verified) {
|
|
353
371
|
backupManager.markVerified(bid);
|
|
372
|
+
rateLimiter.record(errorSignature);
|
|
354
373
|
rateLimiter.clearSignature(errorSignature);
|
|
355
374
|
// Track tool operations: file read + patch + verify + any commands
|
|
356
375
|
// These are the same operations an agent would do with read_file/write_file/bash_exec
|
|
@@ -360,10 +379,11 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
360
379
|
return { healed: true, explanation: repair.explanation, backupId: bid, mode: "fast" };
|
|
361
380
|
}
|
|
362
381
|
|
|
363
|
-
|
|
382
|
+
// #13: Safe rollback — wrap in try/catch to prevent rollback-of-rollback loop
|
|
383
|
+
try { backupManager.rollbackTo(bid); } catch (rbErr) { console.log(chalk.red(` ⚠️ Rollback failed: ${rbErr.message}`)); }
|
|
364
384
|
return { healed: false, explanation: `Fast path: ${verification.status}` };
|
|
365
385
|
} catch (err) {
|
|
366
|
-
backupManager.rollbackTo(bid);
|
|
386
|
+
try { backupManager.rollbackTo(bid); } catch (rbErr) { console.log(chalk.red(` ⚠️ Rollback failed: ${rbErr.message}`)); }
|
|
367
387
|
return { healed: false, explanation: `Fast path error: ${err.message}` };
|
|
368
388
|
}
|
|
369
389
|
} else if (iteration <= 2) {
|
|
@@ -519,7 +539,7 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
519
539
|
* Try to fix common operational errors without AI.
|
|
520
540
|
* Returns { fixed: boolean, action: string }
|
|
521
541
|
*/
|
|
522
|
-
async function tryOperationalFix(parsed, cwd, logger) {
|
|
542
|
+
async function tryOperationalFix(parsed, cwd, logger, sandbox) {
|
|
523
543
|
const { execSync } = require("child_process");
|
|
524
544
|
const msg = parsed.errorMessage || "";
|
|
525
545
|
|
|
@@ -552,9 +572,14 @@ async function tryOperationalFix(parsed, cwd, logger) {
|
|
|
552
572
|
|
|
553
573
|
// Only auto-create if it's inside the project and looks like a config/data file
|
|
554
574
|
const rel = path.relative(cwd, missingFile).replace(/\\/g, "/");
|
|
555
|
-
|
|
575
|
+
// #18: Validate through sandbox to prevent creating files outside allowed paths
|
|
576
|
+
let safePath = missingFile;
|
|
577
|
+
if (sandbox) {
|
|
578
|
+
try { safePath = sandbox.resolve(missingFile); } catch { safePath = null; }
|
|
579
|
+
}
|
|
580
|
+
if (safePath && !rel.startsWith("..") && /\.(json|yaml|yml|toml|ini|conf|cfg|env|log|txt|csv|db|sqlite)$/i.test(missingFile)) {
|
|
556
581
|
try {
|
|
557
|
-
fs.mkdirSync(path.dirname(
|
|
582
|
+
fs.mkdirSync(path.dirname(safePath), { recursive: true });
|
|
558
583
|
const ext = path.extname(missingFile).toLowerCase();
|
|
559
584
|
|
|
560
585
|
// For JSON config files, try to infer expected structure from the code or error message
|
|
@@ -587,7 +612,7 @@ async function tryOperationalFix(parsed, cwd, logger) {
|
|
|
587
612
|
content = defaults[ext] || "";
|
|
588
613
|
}
|
|
589
614
|
|
|
590
|
-
fs.writeFileSync(
|
|
615
|
+
fs.writeFileSync(safePath, content, "utf-8");
|
|
591
616
|
console.log(chalk.blue(` 📄 Created missing file: ${rel}`));
|
|
592
617
|
return { fixed: true, action: `Created missing file: ${rel} with ${content === "{}" ? "empty" : "inferred"} config` };
|
|
593
618
|
} catch {}
|
|
@@ -646,10 +671,13 @@ function _inferJsonConfig(missingFile, cwd, parsed) {
|
|
|
646
671
|
const sourceFile = parsed.filePath;
|
|
647
672
|
if (!sourceFile) return null;
|
|
648
673
|
|
|
674
|
+
// #17: Escape all regex special characters in basename to prevent regex injection
|
|
675
|
+
const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
676
|
+
|
|
649
677
|
try {
|
|
650
678
|
const source = fs.readFileSync(sourceFile, "utf-8");
|
|
651
679
|
// Look for property accesses on the loaded config: config.apiUrl, config.timeout, etc.
|
|
652
|
-
const configVarMatch = source.match(new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:require|JSON\\.parse).*${
|
|
680
|
+
const configVarMatch = source.match(new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:require|JSON\\.parse).*${escapedBasename}`));
|
|
653
681
|
if (!configVarMatch) return null;
|
|
654
682
|
|
|
655
683
|
const varName = configVarMatch[1];
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
},
|
|
7
7
|
|
|
8
8
|
"models": {
|
|
9
|
-
"reasoning": "
|
|
10
|
-
"coding": "
|
|
11
|
-
"chat": "gpt-
|
|
9
|
+
"reasoning": "claude-sonnet-4-6",
|
|
10
|
+
"coding": "claude-sonnet-4-6",
|
|
11
|
+
"chat": "gpt-5.4-mini",
|
|
12
12
|
"tool": "gpt-4o-mini",
|
|
13
13
|
"classifier": "gpt-4o-mini",
|
|
14
|
-
"audit": "
|
|
15
|
-
"compacting": "
|
|
16
|
-
"research": "
|
|
14
|
+
"audit": "wolverine-test-1",
|
|
15
|
+
"compacting": "wolverine-test-1",
|
|
16
|
+
"research": "claude-sonnet-4-6"
|
|
17
17
|
},
|
|
18
18
|
|
|
19
19
|
"embedding": "wolverine-embedding-1",
|