wolverine-ai 4.6.1 → 4.8.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/bin/wolverine.js +10 -0
- package/package.json +4 -4
- package/src/agent/agent-engine.js +112 -2
- package/src/brain/brain.js +1 -1
- package/src/core/ai-client.js +5 -0
- package/src/core/config.js +6 -2
- package/src/core/runner.js +41 -30
- package/src/core/wolverine.js +52 -42
- package/src/skills/deps.js +5 -0
- package/src/vault/vault-manager.js +37 -41
package/bin/wolverine.js
CHANGED
|
@@ -4,6 +4,16 @@ const path = require("path");
|
|
|
4
4
|
const dotenv = require("dotenv");
|
|
5
5
|
const chalk = require("chalk");
|
|
6
6
|
|
|
7
|
+
// Global error handlers — prevent parent process death from unhandled errors
|
|
8
|
+
process.on("uncaughtException", (err) => {
|
|
9
|
+
console.error(chalk.red(`\n ⚠️ Uncaught exception (wolverine survived): ${err.message}`));
|
|
10
|
+
console.error(chalk.gray(` ${err.stack?.split("\n")[1]?.trim() || ""}`));
|
|
11
|
+
});
|
|
12
|
+
process.on("unhandledRejection", (reason) => {
|
|
13
|
+
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
14
|
+
console.error(chalk.red(`\n ⚠️ Unhandled rejection (wolverine survived): ${msg}`));
|
|
15
|
+
});
|
|
16
|
+
|
|
7
17
|
// Load secrets
|
|
8
18
|
dotenv.config({ path: path.resolve(process.cwd(), ".env.local") });
|
|
9
19
|
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wolverine-ai",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.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": {
|
|
@@ -58,14 +58,14 @@
|
|
|
58
58
|
"diff": "^7.0.0",
|
|
59
59
|
"dotenv": "^16.4.7",
|
|
60
60
|
"fastify": "^5.8.4",
|
|
61
|
-
"
|
|
62
|
-
"openai": "^4.73.0",
|
|
63
|
-
"pg": "^8.0.0"
|
|
61
|
+
"openai": "^4.73.0"
|
|
64
62
|
},
|
|
65
63
|
"optionalDependencies": {
|
|
66
64
|
"@privy-io/server-auth": "1.14.0",
|
|
67
65
|
"better-sqlite3": "^11.0.0",
|
|
68
66
|
"ethers": "^6.0.0",
|
|
67
|
+
"ioredis": "^5.0.0",
|
|
68
|
+
"pg": "^8.0.0",
|
|
69
69
|
"stripe": "^18.0.0"
|
|
70
70
|
},
|
|
71
71
|
"engines": {
|
|
@@ -121,7 +121,7 @@ const TOOL_DEFINITIONS = [
|
|
|
121
121
|
type: "object",
|
|
122
122
|
properties: {
|
|
123
123
|
command: { type: "string", description: "Shell command to execute" },
|
|
124
|
-
timeout: { type: "number", description: "Timeout in ms (default:
|
|
124
|
+
timeout: { type: "number", description: "Timeout in ms (default: 30000, max: 60000)" },
|
|
125
125
|
},
|
|
126
126
|
required: ["command"],
|
|
127
127
|
},
|
|
@@ -442,6 +442,22 @@ const TOOL_DEFINITIONS = [
|
|
|
442
442
|
},
|
|
443
443
|
},
|
|
444
444
|
},
|
|
445
|
+
// ── ENVIRONMENT ──
|
|
446
|
+
{
|
|
447
|
+
type: "function",
|
|
448
|
+
function: {
|
|
449
|
+
name: "add_env_var",
|
|
450
|
+
description: "Append a key=value pair to .env.local. Only adds if the key does not already exist. Use for missing environment variable errors.",
|
|
451
|
+
parameters: {
|
|
452
|
+
type: "object",
|
|
453
|
+
properties: {
|
|
454
|
+
key: { type: "string", description: "Environment variable name (e.g. DATABASE_URL)" },
|
|
455
|
+
value: { type: "string", description: "Value to set" },
|
|
456
|
+
},
|
|
457
|
+
required: ["key", "value"],
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
},
|
|
445
461
|
// ── TASK MANAGEMENT ──
|
|
446
462
|
{
|
|
447
463
|
type: "function",
|
|
@@ -544,6 +560,16 @@ function _detectSandboxEscape(cmd, cwd) {
|
|
|
544
560
|
return "curl uploading local file (potential data exfiltration)";
|
|
545
561
|
}
|
|
546
562
|
|
|
563
|
+
// 6. Environment variable dumping to file/network (staging attack via /tmp)
|
|
564
|
+
if (/\b(env|printenv|set)\s*>/.test(c) || /\b(env|printenv)\s*\|/.test(c)) {
|
|
565
|
+
return "Environment variable dump detected (exfiltration risk)";
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// 7. Reading secrets and piping to network
|
|
569
|
+
if (/cat\s+.*\.(env|key|pem|crt)\s*\|/.test(c) || /cat\s+.*secret.*\|/.test(c)) {
|
|
570
|
+
return "Reading sensitive file and piping to output";
|
|
571
|
+
}
|
|
572
|
+
|
|
547
573
|
return null; // safe
|
|
548
574
|
}
|
|
549
575
|
|
|
@@ -804,6 +830,7 @@ class AgentEngine {
|
|
|
804
830
|
case "check_file_descriptors": return this._checkFileDescriptors(args);
|
|
805
831
|
case "check_event_loop": return this._checkEventLoop(args);
|
|
806
832
|
case "check_websocket": return this._checkWebsocket(args);
|
|
833
|
+
case "add_env_var": return this._addEnvVar(args);
|
|
807
834
|
case "done": return this._done(args);
|
|
808
835
|
// Legacy aliases
|
|
809
836
|
case "list_files": return this._globFiles({ pattern: (args.dir || ".") + "/*" + (args.pattern || "") });
|
|
@@ -1075,6 +1102,10 @@ class AgentEngine {
|
|
|
1075
1102
|
/^192\.168\./,
|
|
1076
1103
|
/^fd[0-9a-f]{2}:/i,
|
|
1077
1104
|
/^::1$/,
|
|
1105
|
+
/^0\.0\.0\.0$/,
|
|
1106
|
+
/^0\./,
|
|
1107
|
+
/^\[::1\]$/,
|
|
1108
|
+
/^metadata\.google\.internal$/i,
|
|
1078
1109
|
];
|
|
1079
1110
|
if (privatePatterns.some(p => p.test(hostname))) {
|
|
1080
1111
|
resolve({ content: `BLOCKED: Cannot fetch private/internal address "${hostname}"` });
|
|
@@ -1190,7 +1221,8 @@ class AgentEngine {
|
|
|
1190
1221
|
try {
|
|
1191
1222
|
let Database;
|
|
1192
1223
|
try { Database = require("better-sqlite3"); } catch {
|
|
1193
|
-
|
|
1224
|
+
// Fallback: try PostgreSQL via pg if available
|
|
1225
|
+
return this._inspectDbPg(args);
|
|
1194
1226
|
}
|
|
1195
1227
|
const db = new Database(dbPath, { readonly: true });
|
|
1196
1228
|
let result;
|
|
@@ -1222,6 +1254,55 @@ class AgentEngine {
|
|
|
1222
1254
|
} catch (e) { return { content: `DB error: ${e.message}` }; }
|
|
1223
1255
|
}
|
|
1224
1256
|
|
|
1257
|
+
async _inspectDbPg(args) {
|
|
1258
|
+
let pg;
|
|
1259
|
+
try { pg = require("pg"); } catch {
|
|
1260
|
+
return { content: "Neither better-sqlite3 nor pg is installed. Run: npm install better-sqlite3 (for SQLite) or npm install pg (for PostgreSQL)" };
|
|
1261
|
+
}
|
|
1262
|
+
// db_path is treated as a connection string or uses DATABASE_URL
|
|
1263
|
+
const connectionString = args.db_path.startsWith("postgres")
|
|
1264
|
+
? args.db_path
|
|
1265
|
+
: process.env.DATABASE_URL;
|
|
1266
|
+
if (!connectionString) {
|
|
1267
|
+
return { content: "PostgreSQL: no connection string. Set DATABASE_URL or pass a postgres:// URI as db_path." };
|
|
1268
|
+
}
|
|
1269
|
+
const client = new pg.Client({ connectionString, statement_timeout: 10000 });
|
|
1270
|
+
try {
|
|
1271
|
+
await client.connect();
|
|
1272
|
+
let result;
|
|
1273
|
+
if (args.action === "tables") {
|
|
1274
|
+
const res = await client.query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename");
|
|
1275
|
+
result = res.rows.map(r => r.tablename).join("\n") || "(no tables)";
|
|
1276
|
+
} else if (args.action === "schema") {
|
|
1277
|
+
const res = await client.query(`SELECT table_name, column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, ordinal_position`);
|
|
1278
|
+
const tables = {};
|
|
1279
|
+
for (const row of res.rows) {
|
|
1280
|
+
if (!tables[row.table_name]) tables[row.table_name] = [];
|
|
1281
|
+
tables[row.table_name].push(` ${row.column_name} ${row.data_type}${row.is_nullable === "NO" ? " NOT NULL" : ""}`);
|
|
1282
|
+
}
|
|
1283
|
+
result = Object.entries(tables).map(([t, cols]) => `TABLE ${t}:\n${cols.join("\n")}`).join("\n\n") || "(no tables)";
|
|
1284
|
+
} else if (args.action === "query") {
|
|
1285
|
+
if (!args.sql) return { content: "Error: sql required for query action" };
|
|
1286
|
+
const upper = args.sql.trim().toUpperCase();
|
|
1287
|
+
if (!upper.startsWith("SELECT") && !upper.startsWith("SHOW")) {
|
|
1288
|
+
return { content: "BLOCKED: inspect_db only allows SELECT/SHOW for PostgreSQL. Use run_db_fix for writes." };
|
|
1289
|
+
}
|
|
1290
|
+
const res = await client.query(args.sql);
|
|
1291
|
+
result = JSON.stringify(res.rows.slice(0, 50), null, 2);
|
|
1292
|
+
if (res.rows.length > 50) result += `\n... (${res.rows.length} total rows, showing first 50)`;
|
|
1293
|
+
} else {
|
|
1294
|
+
result = "Unknown action. Use: tables, schema, or query";
|
|
1295
|
+
}
|
|
1296
|
+
const { redact } = require("../security/secret-redactor");
|
|
1297
|
+
console.log(chalk.gray(` 🗃️ DB (pg) ${args.action}: ${args.db_path}`));
|
|
1298
|
+
return { content: redact(result) };
|
|
1299
|
+
} catch (e) {
|
|
1300
|
+
return { content: `PostgreSQL error: ${e.message}` };
|
|
1301
|
+
} finally {
|
|
1302
|
+
try { await client.end(); } catch {}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1225
1306
|
_runDbFix(args) {
|
|
1226
1307
|
const dbPath = path.resolve(this.cwd, args.db_path);
|
|
1227
1308
|
try {
|
|
@@ -1738,6 +1819,35 @@ class AgentEngine {
|
|
|
1738
1819
|
};
|
|
1739
1820
|
}
|
|
1740
1821
|
|
|
1822
|
+
// ── Environment variable tool ──
|
|
1823
|
+
// Controlled exception to _isProtectedPath: allows APPENDING to .env.local only.
|
|
1824
|
+
_addEnvVar(args) {
|
|
1825
|
+
const key = (args.key || "").trim();
|
|
1826
|
+
const value = args.value || "";
|
|
1827
|
+
if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
1828
|
+
return { content: "Error: invalid env var name. Must match [A-Za-z_][A-Za-z0-9_]*" };
|
|
1829
|
+
}
|
|
1830
|
+
const envPath = path.resolve(this.cwd, ".env.local");
|
|
1831
|
+
try {
|
|
1832
|
+
// Check if key already exists
|
|
1833
|
+
if (fs.existsSync(envPath)) {
|
|
1834
|
+
const existing = fs.readFileSync(envPath, "utf-8");
|
|
1835
|
+
const keyRegex = new RegExp(`^${key}=`, "m");
|
|
1836
|
+
if (keyRegex.test(existing)) {
|
|
1837
|
+
return { content: `${key} already exists in .env.local — not overwriting.` };
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
// Append the key=value pair
|
|
1841
|
+
const line = `${key}=${value}\n`;
|
|
1842
|
+
fs.appendFileSync(envPath, line, "utf-8");
|
|
1843
|
+
console.log(chalk.green(` 🔑 Added ${key} to .env.local`));
|
|
1844
|
+
if (this.logger) this.logger.info("agent.env_var_add", `Added ${key} to .env.local`);
|
|
1845
|
+
return { content: `Successfully added ${key} to .env.local` };
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
return { content: `Error adding env var: ${err.message}` };
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1741
1851
|
// ── Protected path guard ──
|
|
1742
1852
|
// Wolverine's own source code is off-limits to the agent.
|
|
1743
1853
|
// The agent should build/fix the USER's project, not modify itself.
|
package/src/brain/brain.js
CHANGED
|
@@ -154,7 +154,7 @@ const SEED_DOCS = [
|
|
|
154
154
|
metadata: { topic: "fastify" },
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
|
-
text: "npm package: wolverine-ai on npmjs.com (
|
|
157
|
+
text: "npm package: wolverine-ai on npmjs.com (latest). Install: npm i wolverine-ai. CLI: npx wolverine server/index.js. 85 files, 190KB compressed. Includes src/, bin/, examples/. Server directory created from src/templates/server/ on first run (never overwritten). GitHub: https://github.com/bobbyswhip/Wolverine. Unified billing: all AI calls route through inference proxy with credit-based billing. WOLVERINE_API_KEY authenticates through billing proxy, WOLVERINE_GPU_KEY for direct GPU access. 3 providers: openai, anthropic, wolverine (self-hosted GPU via Vast.ai).",
|
|
158
158
|
metadata: { topic: "npm-package" },
|
|
159
159
|
},
|
|
160
160
|
{
|
package/src/core/ai-client.js
CHANGED
|
@@ -659,6 +659,11 @@ Include both if needed, or just one.`;
|
|
|
659
659
|
const result = await aiCall({ model, systemPrompt, userPrompt, maxTokens: 2048, category: "reasoning" });
|
|
660
660
|
const content = (result.content || "").trim();
|
|
661
661
|
|
|
662
|
+
// Guard: cap length before regex extraction to prevent catastrophic backtracking
|
|
663
|
+
if (content.length > 50000) {
|
|
664
|
+
throw new Error("AI response too large for regex extraction (>50K chars)");
|
|
665
|
+
}
|
|
666
|
+
|
|
662
667
|
// Strip thinking tags (Gemma), markdown fences, and any prefix text
|
|
663
668
|
let cleaned = content
|
|
664
669
|
.replace(/<\|channel>.*?<channel\|>/gs, "")
|
package/src/core/config.js
CHANGED
|
@@ -15,11 +15,14 @@ const path = require("path");
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
let _config = null;
|
|
18
|
+
let _configRoot = null;
|
|
19
|
+
|
|
20
|
+
function setConfigRoot(root) { _configRoot = root; }
|
|
18
21
|
|
|
19
22
|
function loadConfig() {
|
|
20
23
|
if (_config) return _config;
|
|
21
24
|
|
|
22
|
-
const configPath = path.join(process.cwd(), "server", "config", "settings.json");
|
|
25
|
+
const configPath = path.join(_configRoot || process.cwd(), "server", "config", "settings.json");
|
|
23
26
|
let fileConfig = {};
|
|
24
27
|
if (fs.existsSync(configPath)) {
|
|
25
28
|
try {
|
|
@@ -109,6 +112,7 @@ function getConfig(dotPath) {
|
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
function resetConfig() { _config = null; }
|
|
115
|
+
function resetConfigRoot() { _configRoot = null; }
|
|
112
116
|
|
|
113
117
|
/**
|
|
114
118
|
* Migrate old provider-based config to new flat models format.
|
|
@@ -153,4 +157,4 @@ function _migrateAndEnsureDefaults(fileConfig, configPath) {
|
|
|
153
157
|
}
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
module.exports = { loadConfig, getConfig, resetConfig };
|
|
160
|
+
module.exports = { loadConfig, getConfig, resetConfig, setConfigRoot, resetConfigRoot };
|
package/src/core/runner.js
CHANGED
|
@@ -45,14 +45,30 @@ class WolverineRunner {
|
|
|
45
45
|
this.child = null;
|
|
46
46
|
this.running = false;
|
|
47
47
|
|
|
48
|
+
// Stability tracking
|
|
49
|
+
this._lastStartTime = null;
|
|
50
|
+
this._lastBackupId = null;
|
|
51
|
+
this._stabilityTimer = null;
|
|
52
|
+
this._stderrBuffer = "";
|
|
53
|
+
this._healInProgress = false;
|
|
54
|
+
this._healStatus = null; // { active, file, error, phase, startedAt, iteration }
|
|
55
|
+
|
|
56
|
+
this._initSubsystems(options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Initialize all subsystems — extracted from constructor for readability.
|
|
61
|
+
*/
|
|
62
|
+
_initSubsystems(options) {
|
|
48
63
|
// Core subsystems
|
|
49
64
|
this.sandbox = new Sandbox(this.cwd);
|
|
50
65
|
this.redactor = initRedactor(this.cwd);
|
|
66
|
+
const cfg = loadConfig();
|
|
51
67
|
this.rateLimiter = new RateLimiter({
|
|
52
|
-
maxCallsPerWindow:
|
|
53
|
-
windowMs:
|
|
54
|
-
minGapMs:
|
|
55
|
-
maxTokensPerHour:
|
|
68
|
+
maxCallsPerWindow: cfg.rateLimiting.maxCallsPerWindow,
|
|
69
|
+
windowMs: cfg.rateLimiting.windowMs,
|
|
70
|
+
minGapMs: cfg.rateLimiting.minGapMs,
|
|
71
|
+
maxTokensPerHour: cfg.rateLimiting.maxTokensPerHour,
|
|
56
72
|
maxGlobalHealsPerWindow: parseInt(process.env.WOLVERINE_RATE_MAX_GLOBAL_HEALS, 10) || 5,
|
|
57
73
|
globalWindowMs: parseInt(process.env.WOLVERINE_RATE_GLOBAL_WINDOW_MS, 10) || 300000,
|
|
58
74
|
});
|
|
@@ -68,14 +84,14 @@ class WolverineRunner {
|
|
|
68
84
|
});
|
|
69
85
|
|
|
70
86
|
// Health monitoring
|
|
71
|
-
const port =
|
|
87
|
+
const port = cfg.server.port;
|
|
72
88
|
this.healthMonitor = new HealthMonitor({
|
|
73
89
|
port,
|
|
74
90
|
path: options.healthPath || "/health",
|
|
75
|
-
intervalMs:
|
|
76
|
-
timeoutMs:
|
|
77
|
-
failThreshold:
|
|
78
|
-
startDelayMs:
|
|
91
|
+
intervalMs: cfg.healthCheck.intervalMs,
|
|
92
|
+
timeoutMs: cfg.healthCheck.timeoutMs,
|
|
93
|
+
failThreshold: cfg.healthCheck.failThreshold,
|
|
94
|
+
startDelayMs: cfg.healthCheck.startDelayMs,
|
|
79
95
|
});
|
|
80
96
|
|
|
81
97
|
// Performance monitoring
|
|
@@ -89,6 +105,9 @@ class WolverineRunner {
|
|
|
89
105
|
// Process monitor — heartbeat, memory, CPU, leak detection
|
|
90
106
|
this.processMonitor = new ProcessMonitor({ logger: this.logger });
|
|
91
107
|
|
|
108
|
+
// Brain — semantic memory + project context
|
|
109
|
+
this.brain = new Brain(this.cwd);
|
|
110
|
+
|
|
92
111
|
// Route prober — tests all routes periodically
|
|
93
112
|
this.routeProber = new RouteProber({
|
|
94
113
|
port,
|
|
@@ -98,9 +117,9 @@ class WolverineRunner {
|
|
|
98
117
|
|
|
99
118
|
// Error monitor — detects caught 500 errors without process crash
|
|
100
119
|
this.errorMonitor = new ErrorMonitor({
|
|
101
|
-
threshold:
|
|
102
|
-
windowMs:
|
|
103
|
-
cooldownMs:
|
|
120
|
+
threshold: cfg.errorMonitor.threshold,
|
|
121
|
+
windowMs: cfg.errorMonitor.windowMs,
|
|
122
|
+
cooldownMs: cfg.errorMonitor.cooldownMs,
|
|
104
123
|
logger: this.logger,
|
|
105
124
|
onError: (routePath, errorDetails) => this._healFromError(routePath, errorDetails),
|
|
106
125
|
});
|
|
@@ -111,9 +130,6 @@ class WolverineRunner {
|
|
|
111
130
|
windowMs: parseInt(process.env.WOLVERINE_LOOP_WINDOW_MS, 10) || 600000,
|
|
112
131
|
});
|
|
113
132
|
|
|
114
|
-
// Brain — semantic memory + project context
|
|
115
|
-
this.brain = new Brain(this.cwd);
|
|
116
|
-
|
|
117
133
|
// Skills — discoverable capabilities
|
|
118
134
|
this.skills = new SkillRegistry();
|
|
119
135
|
this.skills.load();
|
|
@@ -143,14 +159,6 @@ class WolverineRunner {
|
|
|
143
159
|
routeProber: this.routeProber,
|
|
144
160
|
errorMonitor: this.errorMonitor,
|
|
145
161
|
});
|
|
146
|
-
|
|
147
|
-
// Stability tracking
|
|
148
|
-
this._lastStartTime = null;
|
|
149
|
-
this._lastBackupId = null;
|
|
150
|
-
this._stabilityTimer = null;
|
|
151
|
-
this._stderrBuffer = "";
|
|
152
|
-
this._healInProgress = false;
|
|
153
|
-
this._healStatus = null; // { active, file, error, phase, startedAt, iteration }
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
async start() {
|
|
@@ -465,9 +473,10 @@ class WolverineRunner {
|
|
|
465
473
|
this._healInProgress = false;
|
|
466
474
|
return;
|
|
467
475
|
}
|
|
468
|
-
//
|
|
476
|
+
// Pass through directly — _healAndRestart checks _healInProgress internally,
|
|
477
|
+
// so release it just before the call to avoid a race window
|
|
469
478
|
this._healInProgress = false;
|
|
470
|
-
await this._healAndRestart();
|
|
479
|
+
await this._healAndRestart({ skipHealLockCheck: true });
|
|
471
480
|
} catch (err) {
|
|
472
481
|
// #5: Prevent unhandled errors in health callback from crashing the parent
|
|
473
482
|
console.log(chalk.red(` ⚠️ Health callback error: ${err.message}`));
|
|
@@ -570,8 +579,8 @@ class WolverineRunner {
|
|
|
570
579
|
this.errorMonitor.reset();
|
|
571
580
|
}
|
|
572
581
|
|
|
573
|
-
async _healAndRestart() {
|
|
574
|
-
if (this._healInProgress) return;
|
|
582
|
+
async _healAndRestart(options) {
|
|
583
|
+
if (this._healInProgress && !options?.skipHealLockCheck) return;
|
|
575
584
|
// #9: Bail if stop() was called during the window between crash and heal
|
|
576
585
|
if (this._shuttingDown) return;
|
|
577
586
|
this._healInProgress = true;
|
|
@@ -710,14 +719,16 @@ class WolverineRunner {
|
|
|
710
719
|
}
|
|
711
720
|
this._healInProgress = true;
|
|
712
721
|
|
|
713
|
-
//
|
|
722
|
+
// Safety timeout — must be strictly greater than heal()'s 5-min timeout to avoid concurrent heals
|
|
723
|
+
const HEAL_TIMEOUT_MS = parseInt(process.env.WOLVERINE_HEAL_TIMEOUT_MS, 10) || 300000;
|
|
724
|
+
const safetyMs = HEAL_TIMEOUT_MS + 30000; // heal timeout + 30s grace
|
|
714
725
|
const healTimeout = setTimeout(() => {
|
|
715
726
|
if (this._healInProgress) {
|
|
716
|
-
console.log(chalk.red(` ⚠️ _healFromError safety timeout (
|
|
727
|
+
console.log(chalk.red(` ⚠️ _healFromError safety timeout (${Math.round(safetyMs / 60000)}min) — releasing heal lock`));
|
|
717
728
|
this._healInProgress = false;
|
|
718
729
|
this._healStatus = null;
|
|
719
730
|
}
|
|
720
|
-
},
|
|
731
|
+
}, safetyMs);
|
|
721
732
|
|
|
722
733
|
console.log(chalk.yellow(`\n🐺 Wolverine healing caught error on ${routePath}...`));
|
|
723
734
|
this._healStatus = { active: true, route: routePath, error: errorDetails?.message?.slice(0, 200), phase: "diagnosing", startedAt: Date.now() };
|
package/src/core/wolverine.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const { execSync } = require("child_process");
|
|
1
5
|
const chalk = require("chalk");
|
|
2
6
|
const { parseError } = require("./error-parser");
|
|
3
|
-
const { requestRepair, getClient, aiCall, _trackOp } = require("./ai-client");
|
|
7
|
+
const { requestRepair, getClient, aiCall, _trackOp, getTrackerSnapshot } = require("./ai-client");
|
|
4
8
|
const { getModel } = require("./models");
|
|
5
9
|
const { applyPatch } = require("./patcher");
|
|
6
10
|
const { verifyFix } = require("./verifier");
|
|
7
11
|
const { Sandbox, SandboxViolationError } = require("../security/sandbox");
|
|
8
12
|
const { RateLimiter } = require("../security/rate-limiter");
|
|
9
13
|
const { detectInjection } = require("../security/injection-detector");
|
|
14
|
+
const { redact, hasSecrets } = require("../security/secret-redactor");
|
|
10
15
|
const { BackupManager } = require("../backup/backup-manager");
|
|
11
16
|
const { AgentEngine } = require("../agent/agent-engine");
|
|
12
17
|
const { ResearchAgent } = require("../agent/research-agent");
|
|
@@ -14,6 +19,7 @@ const { GoalLoop } = require("../agent/goal-loop");
|
|
|
14
19
|
const { exploreAndFix, spawnParallel } = require("../agent/sub-agents");
|
|
15
20
|
const { EVENT_TYPES } = require("../logger/event-logger");
|
|
16
21
|
const { diagnose: diagnoseDeps } = require("../skills/deps");
|
|
22
|
+
const { getSummary: getServerContextSummary } = require("./server-context");
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* The Wolverine healing engine — v3.
|
|
@@ -55,9 +61,7 @@ async function heal(opts) {
|
|
|
55
61
|
async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupManager, logger, brain, mcp, skills, repairHistory, routeContext }) {
|
|
56
62
|
const healStartTime = Date.now();
|
|
57
63
|
// Snapshot token tracker at heal start — diff at end = FULL pipeline cost
|
|
58
|
-
const { getTrackerSnapshot } = require("./ai-client");
|
|
59
64
|
const _snapshot = getTrackerSnapshot();
|
|
60
|
-
const { redact, hasSecrets } = require("../security/secret-redactor");
|
|
61
65
|
|
|
62
66
|
// Guard: don't burn tokens on empty stderr (signal kills, clean shutdowns, etc.)
|
|
63
67
|
if (!stderr || stderr.trim().length < 10) {
|
|
@@ -191,8 +195,6 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
191
195
|
let backupSourceCode = "";
|
|
192
196
|
if (hasFile && backupManager) {
|
|
193
197
|
try {
|
|
194
|
-
const fs = require("fs");
|
|
195
|
-
const path = require("path");
|
|
196
198
|
const stableBackups = backupManager.getAll().filter(b => b.status === "stable" || b.status === "verified");
|
|
197
199
|
if (stableBackups.length > 0) {
|
|
198
200
|
const latest = stableBackups[stableBackups.length - 1];
|
|
@@ -210,8 +212,7 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
210
212
|
let brainContext = "";
|
|
211
213
|
// Inject server context (routes, DB, config, deps) if available
|
|
212
214
|
try {
|
|
213
|
-
const
|
|
214
|
-
const serverCtx = getSummary(cwd);
|
|
215
|
+
const serverCtx = getServerContextSummary(cwd);
|
|
215
216
|
if (serverCtx) brainContext += serverCtx + "\n\n";
|
|
216
217
|
} catch {}
|
|
217
218
|
// Inject relevant skill context (claw-code: pre-enrich prompt with matched tools)
|
|
@@ -249,27 +250,35 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
249
250
|
} catch {}
|
|
250
251
|
}
|
|
251
252
|
|
|
252
|
-
// 7. Classify error complexity —
|
|
253
|
+
// 7. Classify error complexity — regex first (fast, free), AI only when uncertain
|
|
253
254
|
let errorComplexity = "moderate";
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
255
|
+
const isObviouslySimple = /TypeError|ReferenceError|SyntaxError|Cannot find module|Cannot read prop/.test(parsed.errorMessage);
|
|
256
|
+
const isObviouslyModerate = /ECONNREFUSED|timeout|ENOENT|EACCES|EADDRINUSE|ENOSPC|EMFILE/.test(parsed.errorMessage);
|
|
257
|
+
if (isObviouslySimple) {
|
|
258
|
+
errorComplexity = "simple";
|
|
259
|
+
console.log(chalk.gray(` 🏷️ Classifier (fast): simple`));
|
|
260
|
+
} else if (isObviouslyModerate) {
|
|
261
|
+
errorComplexity = "moderate";
|
|
262
|
+
console.log(chalk.gray(` 🏷️ Classifier (fast): moderate`));
|
|
263
|
+
} else {
|
|
264
|
+
// Uncertain — use AI classifier
|
|
265
|
+
try {
|
|
266
|
+
const classifyResult = await aiCall({
|
|
267
|
+
model: getModel("classifier"),
|
|
268
|
+
systemPrompt: "You classify Node.js errors. Respond with ONLY one word: SIMPLE, MODERATE, or COMPLEX.",
|
|
269
|
+
userPrompt: `Classify this error:\n${parsed.errorMessage}\n\nFile: ${parsed.filePath || "unknown"}\nType: ${parsed.errorType || "unknown"}`,
|
|
270
|
+
maxTokens: 10,
|
|
271
|
+
category: "classifier",
|
|
272
|
+
});
|
|
273
|
+
const word = (classifyResult.content || "").trim().toUpperCase();
|
|
274
|
+
if (word.includes("SIMPLE")) errorComplexity = "simple";
|
|
275
|
+
else if (word.includes("COMPLEX")) errorComplexity = "complex";
|
|
276
|
+
else errorComplexity = "moderate";
|
|
277
|
+
console.log(chalk.gray(` 🏷️ Classifier (AI): ${errorComplexity}`));
|
|
278
|
+
} catch {
|
|
279
|
+
errorComplexity = "complex"; // unknown = treat as complex to be safe
|
|
280
|
+
console.log(chalk.gray(` 🏷️ Classifier (fallback): complex`));
|
|
281
|
+
}
|
|
273
282
|
}
|
|
274
283
|
|
|
275
284
|
// 7b. Research — look up past fixes AND search for solutions
|
|
@@ -340,7 +349,6 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
340
349
|
|
|
341
350
|
// Execute shell commands first (npm install, mkdir, etc.)
|
|
342
351
|
if (repair.commands && Array.isArray(repair.commands)) {
|
|
343
|
-
const { execSync } = require("child_process");
|
|
344
352
|
for (const cmd of repair.commands) {
|
|
345
353
|
// Block dangerous commands
|
|
346
354
|
if (/rm\s+-rf\s+[/\\]|format\s+c:|mkfs/i.test(cmd)) {
|
|
@@ -546,7 +554,6 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
|
|
|
546
554
|
* Returns { fixed: boolean, action: string }
|
|
547
555
|
*/
|
|
548
556
|
async function tryOperationalFix(parsed, cwd, logger, sandbox) {
|
|
549
|
-
const { execSync } = require("child_process");
|
|
550
557
|
const msg = parsed.errorMessage || "";
|
|
551
558
|
|
|
552
559
|
// Pattern 1: Dependency issues — use deps skill for structured diagnosis
|
|
@@ -573,8 +580,6 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
|
|
|
573
580
|
|| msg.match(/cannot find.*?'([^']+\.\w+)'/i);
|
|
574
581
|
if (enoent) {
|
|
575
582
|
const missingFile = enoent[1];
|
|
576
|
-
const fs = require("fs");
|
|
577
|
-
const path = require("path");
|
|
578
583
|
|
|
579
584
|
// Only auto-create if it's inside the project and looks like a config/data file
|
|
580
585
|
const rel = path.relative(cwd, missingFile).replace(/\\/g, "/");
|
|
@@ -631,7 +636,6 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
|
|
|
631
636
|
const permFile = msg.match(/(?:EACCES|EPERM).*?'([^']+)'/);
|
|
632
637
|
if (permFile) {
|
|
633
638
|
try {
|
|
634
|
-
const fs = require("fs");
|
|
635
639
|
fs.chmodSync(permFile[1], 0o755);
|
|
636
640
|
console.log(chalk.blue(` 🔑 Fixed permissions on: ${permFile[1]}`));
|
|
637
641
|
return { fixed: true, action: `Fixed permissions (chmod 755) on: ${permFile[1]}` };
|
|
@@ -663,7 +667,6 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
|
|
|
663
667
|
// Pattern 5: ENOSPC — disk full, try automated cleanup
|
|
664
668
|
if (/ENOSPC/.test(msg)) {
|
|
665
669
|
try {
|
|
666
|
-
const os = require("os");
|
|
667
670
|
const backupDir = path.join(os.homedir(), ".wolverine-safe-backups", "snapshots");
|
|
668
671
|
let cleaned = 0;
|
|
669
672
|
if (fs.existsSync(backupDir)) {
|
|
@@ -687,13 +690,15 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
|
|
|
687
690
|
} catch {}
|
|
688
691
|
}
|
|
689
692
|
|
|
690
|
-
// Pattern 6: EMFILE — too many open files
|
|
693
|
+
// Pattern 6: EMFILE — too many open files
|
|
691
694
|
if (/EMFILE|ENFILE/.test(msg)) {
|
|
695
|
+
// Close stale file handles by clearing node_modules/.cache and restarting
|
|
692
696
|
try {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
+
const cachePath = path.join(cwd, "node_modules", ".cache");
|
|
698
|
+
if (fs.existsSync(cachePath)) {
|
|
699
|
+
execSync(`rm -rf "${cachePath}"`, { timeout: 10000 });
|
|
700
|
+
console.log(chalk.blue(" 📂 Cleared node_modules/.cache to reduce open FDs"));
|
|
701
|
+
return { fixed: true, action: "EMFILE — cleared build cache to reduce open file descriptors. Consider increasing ulimit -n in your system profile." };
|
|
697
702
|
}
|
|
698
703
|
} catch {}
|
|
699
704
|
}
|
|
@@ -707,9 +712,6 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
|
|
|
707
712
|
* Returns a JSON string with empty/default values, or null if can't infer.
|
|
708
713
|
*/
|
|
709
714
|
function _inferJsonConfig(missingFile, cwd, parsed) {
|
|
710
|
-
const fs = require("fs");
|
|
711
|
-
const path = require("path");
|
|
712
|
-
|
|
713
715
|
// Find which source file loads the missing config
|
|
714
716
|
const basename = path.basename(missingFile);
|
|
715
717
|
const sourceFile = parsed.filePath;
|
|
@@ -718,10 +720,18 @@ function _inferJsonConfig(missingFile, cwd, parsed) {
|
|
|
718
720
|
// #17: Escape all regex special characters in basename to prevent regex injection
|
|
719
721
|
const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
720
722
|
|
|
723
|
+
// #23: Guard against regex construction failure on unusual filenames
|
|
724
|
+
let configVarRegex;
|
|
725
|
+
try {
|
|
726
|
+
configVarRegex = new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:require|JSON\\.parse).*${escapedBasename}`);
|
|
727
|
+
} catch {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
|
|
721
731
|
try {
|
|
722
732
|
const source = fs.readFileSync(sourceFile, "utf-8");
|
|
723
733
|
// Look for property accesses on the loaded config: config.apiUrl, config.timeout, etc.
|
|
724
|
-
const configVarMatch = source.match(
|
|
734
|
+
const configVarMatch = source.match(configVarRegex);
|
|
725
735
|
if (!configVarMatch) return null;
|
|
726
736
|
|
|
727
737
|
const varName = configVarMatch[1];
|
package/src/skills/deps.js
CHANGED
|
@@ -161,14 +161,19 @@ function findUnused(cwd) {
|
|
|
161
161
|
|
|
162
162
|
// Scan all .js/.ts/.mjs/.cjs files for require/import statements
|
|
163
163
|
const usedPackages = new Set();
|
|
164
|
+
let _fileCount = 0;
|
|
165
|
+
const FILE_SCAN_CAP = 5000;
|
|
164
166
|
const scanDir = (dir) => {
|
|
167
|
+
if (_fileCount >= FILE_SCAN_CAP) return;
|
|
165
168
|
let entries;
|
|
166
169
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
167
170
|
for (const entry of entries) {
|
|
171
|
+
if (_fileCount >= FILE_SCAN_CAP) return;
|
|
168
172
|
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".wolverine") continue;
|
|
169
173
|
const fullPath = path.join(dir, entry.name);
|
|
170
174
|
if (entry.isDirectory()) { scanDir(fullPath); continue; }
|
|
171
175
|
if (!/\.(js|ts|mjs|cjs|jsx|tsx)$/.test(entry.name)) continue;
|
|
176
|
+
_fileCount++;
|
|
172
177
|
try {
|
|
173
178
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
174
179
|
// Match require("X") and import ... from "X"
|
|
@@ -16,9 +16,9 @@ const path = require("path");
|
|
|
16
16
|
* - Survives git pull, npm install, auto-update (lives in .wolverine/)
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
const VAULT_DIR = () => path.join(process.cwd(), ".wolverine", "vault");
|
|
20
|
-
const MASTER_KEY_PATH = () => path.join(VAULT_DIR(), "master.key");
|
|
21
|
-
const ETH_VAULT_PATH = () => path.join(VAULT_DIR(), "eth.vault");
|
|
19
|
+
const VAULT_DIR = (projectRoot) => path.join(projectRoot || process.cwd(), ".wolverine", "vault");
|
|
20
|
+
const MASTER_KEY_PATH = (projectRoot) => path.join(VAULT_DIR(projectRoot), "master.key");
|
|
21
|
+
const ETH_VAULT_PATH = (projectRoot) => path.join(VAULT_DIR(projectRoot), "eth.vault");
|
|
22
22
|
|
|
23
23
|
const ALGORITHM = "aes-256-gcm";
|
|
24
24
|
const IV_LENGTH = 16;
|
|
@@ -28,26 +28,26 @@ const AUTH_TAG_LENGTH = 16;
|
|
|
28
28
|
* Initialize the vault. Idempotent — creates keys only if missing.
|
|
29
29
|
* Called during runner startup before any server code runs.
|
|
30
30
|
*/
|
|
31
|
-
async function initVault() {
|
|
32
|
-
const vaultDir = VAULT_DIR();
|
|
31
|
+
async function initVault(projectRoot) {
|
|
32
|
+
const vaultDir = VAULT_DIR(projectRoot);
|
|
33
33
|
fs.mkdirSync(vaultDir, { recursive: true });
|
|
34
34
|
|
|
35
35
|
let created = false;
|
|
36
36
|
|
|
37
37
|
// Master encryption key
|
|
38
|
-
if (!fs.existsSync(MASTER_KEY_PATH())) {
|
|
38
|
+
if (!fs.existsSync(MASTER_KEY_PATH(projectRoot))) {
|
|
39
39
|
const masterKey = crypto.randomBytes(32);
|
|
40
|
-
fs.writeFileSync(MASTER_KEY_PATH(), masterKey);
|
|
41
|
-
try { fs.chmodSync(MASTER_KEY_PATH(), 0o600); } catch {}
|
|
40
|
+
fs.writeFileSync(MASTER_KEY_PATH(projectRoot), masterKey);
|
|
41
|
+
try { fs.chmodSync(MASTER_KEY_PATH(projectRoot), 0o600); } catch {}
|
|
42
42
|
masterKey.fill(0);
|
|
43
43
|
created = true;
|
|
44
44
|
console.log(" 🔐 Vault: master encryption key generated");
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// Ethereum private key (encrypted)
|
|
48
|
-
if (!fs.existsSync(ETH_VAULT_PATH())) {
|
|
48
|
+
if (!fs.existsSync(ETH_VAULT_PATH(projectRoot))) {
|
|
49
49
|
const ethKey = crypto.randomBytes(32);
|
|
50
|
-
await encryptAndStore(ethKey);
|
|
50
|
+
await encryptAndStore(ethKey, { projectRoot });
|
|
51
51
|
ethKey.fill(0);
|
|
52
52
|
created = true;
|
|
53
53
|
console.log(" 🔐 Vault: ethereum wallet created");
|
|
@@ -59,18 +59,19 @@ async function initVault() {
|
|
|
59
59
|
/**
|
|
60
60
|
* Check if vault is fully initialized.
|
|
61
61
|
*/
|
|
62
|
-
function isVaultInitialized() {
|
|
63
|
-
return fs.existsSync(MASTER_KEY_PATH()) && fs.existsSync(ETH_VAULT_PATH());
|
|
62
|
+
function isVaultInitialized(projectRoot) {
|
|
63
|
+
return fs.existsSync(MASTER_KEY_PATH(projectRoot)) && fs.existsSync(ETH_VAULT_PATH(projectRoot));
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* Encrypt a private key Buffer and write to eth.vault.
|
|
68
68
|
* Wipes the master key from memory after use.
|
|
69
69
|
*/
|
|
70
|
-
async function encryptAndStore(keyBuf) {
|
|
70
|
+
async function encryptAndStore(keyBuf, options) {
|
|
71
|
+
const projectRoot = options?.projectRoot;
|
|
71
72
|
let masterKey = null;
|
|
72
73
|
try {
|
|
73
|
-
masterKey = fs.readFileSync(MASTER_KEY_PATH());
|
|
74
|
+
masterKey = fs.readFileSync(MASTER_KEY_PATH(projectRoot));
|
|
74
75
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
75
76
|
const cipher = crypto.createCipheriv(ALGORITHM, masterKey, iv);
|
|
76
77
|
const encrypted = Buffer.concat([cipher.update(keyBuf), cipher.final()]);
|
|
@@ -83,13 +84,13 @@ async function encryptAndStore(keyBuf) {
|
|
|
83
84
|
authTag: authTag.toString("hex"),
|
|
84
85
|
ciphertext: encrypted.toString("hex"),
|
|
85
86
|
created: new Date().toISOString(),
|
|
86
|
-
rotated: null,
|
|
87
|
+
rotated: options?.rotated || null,
|
|
87
88
|
};
|
|
88
89
|
|
|
89
|
-
const tmpPath = ETH_VAULT_PATH() + ".tmp";
|
|
90
|
+
const tmpPath = ETH_VAULT_PATH(projectRoot) + ".tmp";
|
|
90
91
|
fs.writeFileSync(tmpPath, JSON.stringify(vault, null, 2), "utf-8");
|
|
91
|
-
fs.renameSync(tmpPath, ETH_VAULT_PATH());
|
|
92
|
-
try { fs.chmodSync(ETH_VAULT_PATH(), 0o600); } catch {}
|
|
92
|
+
fs.renameSync(tmpPath, ETH_VAULT_PATH(projectRoot));
|
|
93
|
+
try { fs.chmodSync(ETH_VAULT_PATH(projectRoot), 0o600); } catch {}
|
|
93
94
|
} finally {
|
|
94
95
|
if (masterKey) masterKey.fill(0);
|
|
95
96
|
}
|
|
@@ -99,15 +100,15 @@ async function encryptAndStore(keyBuf) {
|
|
|
99
100
|
* Decrypt the Ethereum private key. Returns a Buffer.
|
|
100
101
|
* CALLER MUST call .fill(0) on the returned Buffer when done.
|
|
101
102
|
*/
|
|
102
|
-
function decryptPrivateKey() {
|
|
103
|
-
if (!isVaultInitialized()) {
|
|
103
|
+
function decryptPrivateKey(projectRoot) {
|
|
104
|
+
if (!isVaultInitialized(projectRoot)) {
|
|
104
105
|
throw new Error("vault not initialized");
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
let masterKey = null;
|
|
108
109
|
try {
|
|
109
|
-
masterKey = fs.readFileSync(MASTER_KEY_PATH());
|
|
110
|
-
const vault = JSON.parse(fs.readFileSync(ETH_VAULT_PATH(), "utf-8"));
|
|
110
|
+
masterKey = fs.readFileSync(MASTER_KEY_PATH(projectRoot));
|
|
111
|
+
const vault = JSON.parse(fs.readFileSync(ETH_VAULT_PATH(projectRoot), "utf-8"));
|
|
111
112
|
|
|
112
113
|
if (vault.version !== 1) throw new Error("unsupported vault version");
|
|
113
114
|
|
|
@@ -128,16 +129,11 @@ function decryptPrivateKey() {
|
|
|
128
129
|
* Re-encrypt with a fresh IV. Defensive measure if key material was
|
|
129
130
|
* potentially exposed in an error message.
|
|
130
131
|
*/
|
|
131
|
-
async function rotateEncryption() {
|
|
132
|
+
async function rotateEncryption(projectRoot) {
|
|
132
133
|
let keyBuf = null;
|
|
133
134
|
try {
|
|
134
|
-
keyBuf = decryptPrivateKey();
|
|
135
|
-
await encryptAndStore(keyBuf);
|
|
136
|
-
|
|
137
|
-
// Update rotated timestamp
|
|
138
|
-
const vault = JSON.parse(fs.readFileSync(ETH_VAULT_PATH(), "utf-8"));
|
|
139
|
-
vault.rotated = new Date().toISOString();
|
|
140
|
-
fs.writeFileSync(ETH_VAULT_PATH(), JSON.stringify(vault, null, 2), "utf-8");
|
|
135
|
+
keyBuf = decryptPrivateKey(projectRoot);
|
|
136
|
+
await encryptAndStore(keyBuf, { rotated: new Date().toISOString(), projectRoot });
|
|
141
137
|
} finally {
|
|
142
138
|
if (keyBuf) keyBuf.fill(0);
|
|
143
139
|
}
|
|
@@ -147,11 +143,11 @@ async function rotateEncryption() {
|
|
|
147
143
|
* Export vault contents for backup. Returns raw Buffers.
|
|
148
144
|
* Caller MUST wipe masterKey after writing to backup.
|
|
149
145
|
*/
|
|
150
|
-
function exportVaultForBackup() {
|
|
151
|
-
if (!isVaultInitialized()) return null;
|
|
146
|
+
function exportVaultForBackup(projectRoot) {
|
|
147
|
+
if (!isVaultInitialized(projectRoot)) return null;
|
|
152
148
|
return {
|
|
153
|
-
masterKey: fs.readFileSync(MASTER_KEY_PATH()),
|
|
154
|
-
vaultFile: fs.readFileSync(ETH_VAULT_PATH(), "utf-8"),
|
|
149
|
+
masterKey: fs.readFileSync(MASTER_KEY_PATH(projectRoot)),
|
|
150
|
+
vaultFile: fs.readFileSync(ETH_VAULT_PATH(projectRoot), "utf-8"),
|
|
155
151
|
};
|
|
156
152
|
}
|
|
157
153
|
|
|
@@ -159,18 +155,18 @@ function exportVaultForBackup() {
|
|
|
159
155
|
* Import vault from backup. Only used during catastrophic recovery
|
|
160
156
|
* when both vault files are missing.
|
|
161
157
|
*/
|
|
162
|
-
function importVaultFromBackup(masterKeyBuf, vaultFileStr) {
|
|
163
|
-
const vaultDir = VAULT_DIR();
|
|
158
|
+
function importVaultFromBackup(masterKeyBuf, vaultFileStr, projectRoot) {
|
|
159
|
+
const vaultDir = VAULT_DIR(projectRoot);
|
|
164
160
|
fs.mkdirSync(vaultDir, { recursive: true });
|
|
165
161
|
|
|
166
|
-
fs.writeFileSync(MASTER_KEY_PATH(), masterKeyBuf);
|
|
167
|
-
try { fs.chmodSync(MASTER_KEY_PATH(), 0o600); } catch {}
|
|
162
|
+
fs.writeFileSync(MASTER_KEY_PATH(projectRoot), masterKeyBuf);
|
|
163
|
+
try { fs.chmodSync(MASTER_KEY_PATH(projectRoot), 0o600); } catch {}
|
|
168
164
|
|
|
169
|
-
fs.writeFileSync(ETH_VAULT_PATH(), vaultFileStr, "utf-8");
|
|
170
|
-
try { fs.chmodSync(ETH_VAULT_PATH(), 0o600); } catch {}
|
|
165
|
+
fs.writeFileSync(ETH_VAULT_PATH(projectRoot), vaultFileStr, "utf-8");
|
|
166
|
+
try { fs.chmodSync(ETH_VAULT_PATH(projectRoot), 0o600); } catch {}
|
|
171
167
|
}
|
|
172
168
|
|
|
173
|
-
function getVaultPath() { return VAULT_DIR(); }
|
|
169
|
+
function getVaultPath(projectRoot) { return VAULT_DIR(projectRoot); }
|
|
174
170
|
|
|
175
171
|
module.exports = {
|
|
176
172
|
initVault,
|