wolverine-ai 4.7.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wolverine-ai",
3
- "version": "4.7.0",
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": {
@@ -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: 10000)" },
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",
@@ -814,6 +830,7 @@ class AgentEngine {
814
830
  case "check_file_descriptors": return this._checkFileDescriptors(args);
815
831
  case "check_event_loop": return this._checkEventLoop(args);
816
832
  case "check_websocket": return this._checkWebsocket(args);
833
+ case "add_env_var": return this._addEnvVar(args);
817
834
  case "done": return this._done(args);
818
835
  // Legacy aliases
819
836
  case "list_files": return this._globFiles({ pattern: (args.dir || ".") + "/*" + (args.pattern || "") });
@@ -1204,7 +1221,8 @@ class AgentEngine {
1204
1221
  try {
1205
1222
  let Database;
1206
1223
  try { Database = require("better-sqlite3"); } catch {
1207
- return { content: "better-sqlite3 not installed. Run: npm install better-sqlite3" };
1224
+ // Fallback: try PostgreSQL via pg if available
1225
+ return this._inspectDbPg(args);
1208
1226
  }
1209
1227
  const db = new Database(dbPath, { readonly: true });
1210
1228
  let result;
@@ -1236,6 +1254,55 @@ class AgentEngine {
1236
1254
  } catch (e) { return { content: `DB error: ${e.message}` }; }
1237
1255
  }
1238
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
+
1239
1306
  _runDbFix(args) {
1240
1307
  const dbPath = path.resolve(this.cwd, args.db_path);
1241
1308
  try {
@@ -1752,6 +1819,35 @@ class AgentEngine {
1752
1819
  };
1753
1820
  }
1754
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
+
1755
1851
  // ── Protected path guard ──
1756
1852
  // Wolverine's own source code is off-limits to the agent.
1757
1853
  // The agent should build/fix the USER's project, not modify itself.
@@ -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 (v3.7.7). 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).",
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
  {
@@ -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, "")
@@ -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 };
@@ -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: parseInt(process.env.WOLVERINE_RATE_MAX_CALLS, 10) || 10,
53
- windowMs: parseInt(process.env.WOLVERINE_RATE_WINDOW_MS, 10) || 600000,
54
- minGapMs: parseInt(process.env.WOLVERINE_RATE_MIN_GAP_MS, 10) || 5000,
55
- maxTokensPerHour: parseInt(process.env.WOLVERINE_RATE_MAX_TOKENS_HOUR, 10) || 100000,
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 = parseInt(process.env.PORT, 10) || 3000;
87
+ const port = cfg.server.port;
72
88
  this.healthMonitor = new HealthMonitor({
73
89
  port,
74
90
  path: options.healthPath || "/health",
75
- intervalMs: parseInt(process.env.WOLVERINE_HEALTH_INTERVAL_MS, 10) || 15000,
76
- timeoutMs: parseInt(process.env.WOLVERINE_HEALTH_TIMEOUT_MS, 10) || 5000,
77
- failThreshold: parseInt(process.env.WOLVERINE_HEALTH_FAIL_THRESHOLD, 10) || 3,
78
- startDelayMs: parseInt(process.env.WOLVERINE_HEALTH_START_DELAY_MS, 10) || 10000,
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: parseInt(process.env.WOLVERINE_ERROR_THRESHOLD, 10) || 1,
102
- windowMs: parseInt(process.env.WOLVERINE_ERROR_WINDOW_MS, 10) || 30000,
103
- cooldownMs: parseInt(process.env.WOLVERINE_ERROR_COOLDOWN_MS, 10) || 60000,
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
- // Release lock so _healAndRestart can acquire it
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;
@@ -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 { getSummary } = require("./server-context");
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)
@@ -348,7 +349,6 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
348
349
 
349
350
  // Execute shell commands first (npm install, mkdir, etc.)
350
351
  if (repair.commands && Array.isArray(repair.commands)) {
351
- const { execSync } = require("child_process");
352
352
  for (const cmd of repair.commands) {
353
353
  // Block dangerous commands
354
354
  if (/rm\s+-rf\s+[/\\]|format\s+c:|mkfs/i.test(cmd)) {
@@ -554,7 +554,6 @@ async function _healImpl({ stderr, cwd, sandbox, notifier, rateLimiter, backupMa
554
554
  * Returns { fixed: boolean, action: string }
555
555
  */
556
556
  async function tryOperationalFix(parsed, cwd, logger, sandbox) {
557
- const { execSync } = require("child_process");
558
557
  const msg = parsed.errorMessage || "";
559
558
 
560
559
  // Pattern 1: Dependency issues — use deps skill for structured diagnosis
@@ -581,8 +580,6 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
581
580
  || msg.match(/cannot find.*?'([^']+\.\w+)'/i);
582
581
  if (enoent) {
583
582
  const missingFile = enoent[1];
584
- const fs = require("fs");
585
- const path = require("path");
586
583
 
587
584
  // Only auto-create if it's inside the project and looks like a config/data file
588
585
  const rel = path.relative(cwd, missingFile).replace(/\\/g, "/");
@@ -639,7 +636,6 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
639
636
  const permFile = msg.match(/(?:EACCES|EPERM).*?'([^']+)'/);
640
637
  if (permFile) {
641
638
  try {
642
- const fs = require("fs");
643
639
  fs.chmodSync(permFile[1], 0o755);
644
640
  console.log(chalk.blue(` 🔑 Fixed permissions on: ${permFile[1]}`));
645
641
  return { fixed: true, action: `Fixed permissions (chmod 755) on: ${permFile[1]}` };
@@ -671,7 +667,6 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
671
667
  // Pattern 5: ENOSPC — disk full, try automated cleanup
672
668
  if (/ENOSPC/.test(msg)) {
673
669
  try {
674
- const os = require("os");
675
670
  const backupDir = path.join(os.homedir(), ".wolverine-safe-backups", "snapshots");
676
671
  let cleaned = 0;
677
672
  if (fs.existsSync(backupDir)) {
@@ -717,9 +712,6 @@ async function tryOperationalFix(parsed, cwd, logger, sandbox) {
717
712
  * Returns a JSON string with empty/default values, or null if can't infer.
718
713
  */
719
714
  function _inferJsonConfig(missingFile, cwd, parsed) {
720
- const fs = require("fs");
721
- const path = require("path");
722
-
723
715
  // Find which source file loads the missing config
724
716
  const basename = path.basename(missingFile);
725
717
  const sourceFile = parsed.filePath;
@@ -728,10 +720,18 @@ function _inferJsonConfig(missingFile, cwd, parsed) {
728
720
  // #17: Escape all regex special characters in basename to prevent regex injection
729
721
  const escapedBasename = basename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
730
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
+
731
731
  try {
732
732
  const source = fs.readFileSync(sourceFile, "utf-8");
733
733
  // Look for property accesses on the loaded config: config.apiUrl, config.timeout, etc.
734
- const configVarMatch = source.match(new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*(?:require|JSON\\.parse).*${escapedBasename}`));
734
+ const configVarMatch = source.match(configVarRegex);
735
735
  if (!configVarMatch) return null;
736
736
 
737
737
  const varName = configVarMatch[1];
@@ -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,