@intranefr/superbackend 1.5.1 → 1.5.3

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.
Files changed (73) hide show
  1. package/.env.example +10 -0
  2. package/index.js +2 -0
  3. package/manage.js +745 -0
  4. package/package.json +5 -2
  5. package/src/controllers/admin.controller.js +79 -6
  6. package/src/controllers/adminAgents.controller.js +37 -0
  7. package/src/controllers/adminExperiments.controller.js +200 -0
  8. package/src/controllers/adminLlm.controller.js +19 -0
  9. package/src/controllers/adminMarkdowns.controller.js +157 -0
  10. package/src/controllers/adminScripts.controller.js +243 -74
  11. package/src/controllers/adminTelegram.controller.js +72 -0
  12. package/src/controllers/experiments.controller.js +85 -0
  13. package/src/controllers/internalExperiments.controller.js +17 -0
  14. package/src/controllers/markdowns.controller.js +42 -0
  15. package/src/helpers/mongooseHelper.js +258 -0
  16. package/src/helpers/scriptBase.js +230 -0
  17. package/src/helpers/scriptRunner.js +335 -0
  18. package/src/middleware.js +195 -34
  19. package/src/models/Agent.js +105 -0
  20. package/src/models/AgentMessage.js +82 -0
  21. package/src/models/CacheEntry.js +1 -1
  22. package/src/models/ConsoleLog.js +1 -1
  23. package/src/models/Experiment.js +75 -0
  24. package/src/models/ExperimentAssignment.js +23 -0
  25. package/src/models/ExperimentEvent.js +26 -0
  26. package/src/models/ExperimentMetricBucket.js +30 -0
  27. package/src/models/GlobalSetting.js +1 -2
  28. package/src/models/Markdown.js +75 -0
  29. package/src/models/RateLimitCounter.js +1 -1
  30. package/src/models/ScriptDefinition.js +1 -0
  31. package/src/models/ScriptRun.js +8 -0
  32. package/src/models/TelegramBot.js +42 -0
  33. package/src/models/Webhook.js +2 -0
  34. package/src/routes/admin.routes.js +2 -0
  35. package/src/routes/adminAgents.routes.js +13 -0
  36. package/src/routes/adminConsoleManager.routes.js +1 -1
  37. package/src/routes/adminExperiments.routes.js +29 -0
  38. package/src/routes/adminLlm.routes.js +1 -0
  39. package/src/routes/adminMarkdowns.routes.js +16 -0
  40. package/src/routes/adminScripts.routes.js +4 -1
  41. package/src/routes/adminTelegram.routes.js +14 -0
  42. package/src/routes/blogInternal.routes.js +2 -2
  43. package/src/routes/experiments.routes.js +30 -0
  44. package/src/routes/internalExperiments.routes.js +15 -0
  45. package/src/routes/markdowns.routes.js +16 -0
  46. package/src/services/agent.service.js +546 -0
  47. package/src/services/agentHistory.service.js +345 -0
  48. package/src/services/agentTools.service.js +578 -0
  49. package/src/services/blogCronsBootstrap.service.js +7 -6
  50. package/src/services/consoleManager.service.js +56 -18
  51. package/src/services/consoleOverride.service.js +1 -0
  52. package/src/services/experiments.service.js +273 -0
  53. package/src/services/experimentsAggregation.service.js +308 -0
  54. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  55. package/src/services/experimentsRetention.service.js +43 -0
  56. package/src/services/experimentsWs.service.js +134 -0
  57. package/src/services/globalSettings.service.js +15 -0
  58. package/src/services/jsonConfigs.service.js +24 -12
  59. package/src/services/llm.service.js +219 -6
  60. package/src/services/markdowns.service.js +522 -0
  61. package/src/services/scriptsRunner.service.js +514 -23
  62. package/src/services/telegram.service.js +130 -0
  63. package/src/utils/rbac/rightsRegistry.js +4 -0
  64. package/views/admin-agents.ejs +273 -0
  65. package/views/admin-coolify-deploy.ejs +8 -8
  66. package/views/admin-dashboard.ejs +63 -12
  67. package/views/admin-experiments.ejs +91 -0
  68. package/views/admin-markdowns.ejs +905 -0
  69. package/views/admin-scripts.ejs +817 -6
  70. package/views/admin-telegram.ejs +269 -0
  71. package/views/partials/dashboard/nav-items.ejs +4 -0
  72. package/views/partials/dashboard/palette.ejs +5 -3
  73. package/src/middleware/internalCronAuth.js +0 -29
@@ -1,20 +1,187 @@
1
1
  const { EventEmitter } = require('events');
2
2
  const { spawn } = require('child_process');
3
3
  const { NodeVM } = require('vm2');
4
+ const mongoose = require('mongoose');
4
5
 
5
6
  const ScriptRun = require('../models/ScriptRun');
7
+ const { mongooseHelper } = require('../helpers/mongooseHelper');
6
8
 
7
9
  const MAX_TAIL_BYTES = 64 * 1024;
8
10
 
9
- function nowIso() {
10
- return new Date().toISOString();
11
+ function isTruthyEnv(v) {
12
+ const s = String(v || '').trim().toLowerCase();
13
+ return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === 'on';
14
+ }
15
+
16
+ function shouldAutoWrapAsyncScripts() {
17
+ if (process.env.SCRIPT_AUTO_ASYNC_WRAP === undefined) return true;
18
+ return isTruthyEnv(process.env.SCRIPT_AUTO_ASYNC_WRAP);
19
+ }
20
+
21
+ function detectTopLevelAwait(code) {
22
+ const s = String(code || '');
23
+ if (!/\bawait\b/.test(s)) return false;
24
+ if (/^\s*\(\s*async\s*\(/.test(s)) return false;
25
+ if (/^\s*async\s+function\b/.test(s)) return false;
26
+ if (/\bmodule\.exports\b/.test(s) || /\bexports\./.test(s)) return false;
27
+ return true;
28
+ }
29
+
30
+ function wrapInAsyncIife(code) {
31
+ const body = String(code || '');
32
+ return [
33
+ '(async () => {',
34
+ body,
35
+ '})().then((result) => {',
36
+ ' // Store result globally for VM2 to capture',
37
+ ' global.__scriptResult = result;',
38
+ '}).catch((err) => {',
39
+ ' try { console.error(err && err.stack ? err.stack : err); } catch {}',
40
+ ' global.__scriptResult = { error: err.message || String(err) };',
41
+ '});',
42
+ '',
43
+ ].join('\n');
44
+ }
45
+
46
+ function wrapExistingAsyncIife(code) {
47
+ const body = String(code || '');
48
+ // Remove the final closing parenthesis and semicolon, then add our result capture
49
+ const codeWithoutEnding = body.replace(/\)\s*;?\s*$/, '');
50
+ return [
51
+ '// Wrapped to capture return value',
52
+ codeWithoutEnding,
53
+ ').then((result) => {',
54
+ ' // Store result globally for VM2 to capture',
55
+ ' global.__scriptResult = result;',
56
+ '}).catch((err) => {',
57
+ ' try { console.error(err && err.stack ? err.stack : err); } catch {}',
58
+ ' global.__scriptResult = { error: err.message || String(err) };',
59
+ '});',
60
+ '',
61
+ ].join('\n');
62
+ }
63
+
64
+ function prepareVmCodeForExecution(code) {
65
+ const raw = String(code || '');
66
+ if (!shouldAutoWrapAsyncScripts()) return { code: raw, wrapped: false };
67
+ if (!detectTopLevelAwait(raw)) return { code: raw, wrapped: false };
68
+
69
+ // Check if it's already an async IIFE that doesn't expose its result
70
+ if (/^\s*\(\s*async\s+function\s*\(/.test(raw) && !/global\.__scriptResult\s*=/.test(raw)) {
71
+ // Wrap the existing async IIFE to capture its result
72
+ return { code: wrapExistingAsyncIife(raw), wrapped: true };
73
+ }
74
+
75
+ return { code: wrapInAsyncIife(raw), wrapped: true };
76
+ }
77
+
78
+ function buildAwaitSyntaxHelpMessage() {
79
+ return [
80
+ 'Your script uses `await` at top-level.',
81
+ 'Wrap it in an async IIFE, or rely on auto-wrapping:',
82
+ '',
83
+ '(async () => {',
84
+ ' const count = await countCollectionDocuments("users");',
85
+ ' console.log("count:", count);',
86
+ '})();',
87
+ '',
88
+ ].join('\n');
89
+ }
90
+
91
+ // Helper function to decode script content
92
+ function decodeScriptContent(script, format) {
93
+ if (format === 'base64') {
94
+ try {
95
+ return Buffer.from(script, 'base64').toString('utf8');
96
+ } catch (err) {
97
+ throw new Error('Failed to decode base64 script content');
98
+ }
99
+ }
100
+ return script;
101
+ }
102
+
103
+ const nowIso = () => new Date().toISOString();
104
+
105
+ const appendTail = (tail, more) => {
106
+ const max = 20000; // keep last 20k chars
107
+ tail = (tail + more).slice(-max);
108
+ return tail;
109
+ };
110
+
111
+ // Infrastructure log patterns to filter out
112
+ const infrastructurePatterns = [
113
+ 'Using existing app database connection',
114
+ 'No existing connection found',
115
+ 'Auto-wrapping script in async function',
116
+ '=== SCRIPT START ===',
117
+ '=== SCRIPT END ===',
118
+ 'Executing script',
119
+ 'Script preview',
120
+ 'Database connection established',
121
+ 'chars)',
122
+ 'Infrastructure logs'
123
+ ];
124
+
125
+ // Utility functions for output processing
126
+ function isInfrastructureLog(line) {
127
+ return infrastructurePatterns.some(pattern => line.includes(pattern));
128
+ }
129
+
130
+ function isMeaningfulConsoleLog(line) {
131
+ return !isInfrastructureLog(line) &&
132
+ line.trim().length > 0 &&
133
+ !line.startsWith('[') &&
134
+ !line.includes('===');
11
135
  }
12
136
 
13
- function appendTail(prev, chunk) {
14
- const next = String(prev || '') + String(chunk || '');
15
- const buf = Buffer.from(next, 'utf8');
16
- if (buf.length <= MAX_TAIL_BYTES) return next;
17
- return buf.slice(buf.length - MAX_TAIL_BYTES).toString('utf8');
137
+ function formatOutput(value) {
138
+ if (typeof value === 'object') {
139
+ return JSON.stringify(value, null, 2);
140
+ }
141
+ return String(value);
142
+ }
143
+
144
+ function tryParseJson(str) {
145
+ try {
146
+ return JSON.parse(str);
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function isJsonString(str) {
153
+ return tryParseJson(str) !== null;
154
+ }
155
+
156
+ function determineProgrammaticOutput(returnValue, lastConsoleLog) {
157
+ // Priority 1: Return value
158
+ if (returnValue !== undefined && returnValue !== null) {
159
+ const formatted = formatOutput(returnValue);
160
+ return {
161
+ programmaticOutput: formatted,
162
+ outputType: 'return',
163
+ isJson: isJsonString(formatted),
164
+ parsedResult: tryParseJson(formatted)
165
+ };
166
+ }
167
+
168
+ // Priority 2: Last meaningful console.log
169
+ if (lastConsoleLog && !isInfrastructureLog(lastConsoleLog)) {
170
+ return {
171
+ programmaticOutput: lastConsoleLog,
172
+ outputType: 'console',
173
+ isJson: isJsonString(lastConsoleLog),
174
+ parsedResult: tryParseJson(lastConsoleLog)
175
+ };
176
+ }
177
+
178
+ // Priority 3: No output
179
+ return {
180
+ programmaticOutput: 'No output',
181
+ outputType: 'none',
182
+ isJson: false,
183
+ parsedResult: null
184
+ };
18
185
  }
19
186
 
20
187
  function safeJsonParse(str) {
@@ -114,24 +281,16 @@ async function startRun(scriptDef, options) {
114
281
  runId: runDoc._id,
115
282
  bus,
116
283
  command: 'bash',
117
- args: ['-lc', scriptDef.script],
284
+ args: ['-lc', decodeScriptContent(scriptDef.script, scriptDef.scriptFormat)],
118
285
  env,
119
286
  cwd,
120
287
  timeoutMs,
121
288
  });
122
289
  } else if (scriptDef.type === 'node') {
123
290
  if (scriptDef.runner === 'vm2') {
124
- exitCode = await runVm2({ runId: runDoc._id, bus, code: scriptDef.script, timeoutMs });
291
+ exitCode = await runVm2({ runId: runDoc._id, bus, code: decodeScriptContent(scriptDef.script, scriptDef.scriptFormat), timeoutMs });
125
292
  } else if (scriptDef.runner === 'host') {
126
- exitCode = await runSpawned({
127
- runId: runDoc._id,
128
- bus,
129
- command: 'node',
130
- args: ['-e', scriptDef.script],
131
- env,
132
- cwd,
133
- timeoutMs,
134
- });
293
+ exitCode = await runHostWithDatabase({ runId: runDoc._id, bus, code: decodeScriptContent(scriptDef.script, scriptDef.scriptFormat), env, cwd, timeoutMs });
135
294
  } else {
136
295
  throw Object.assign(new Error('Invalid runner for node script'), { code: 'VALIDATION' });
137
296
  }
@@ -213,19 +372,316 @@ async function runSpawned({ runId, bus, command, args, env, cwd, timeoutMs }) {
213
372
  });
214
373
  }
215
374
 
375
+ async function runHostWithDatabase({ runId, bus, code, env, cwd, timeoutMs }) {
376
+ let tail = '';
377
+ let scriptResult = null;
378
+ let lastConsoleLog = null;
379
+
380
+ function pushLog(stream, line) {
381
+ const s = String(line || '');
382
+ tail = appendTail(tail, s);
383
+ bus.push({ type: 'log', ts: nowIso(), stream, line: s });
384
+
385
+ // Track last console.log for programmatic output
386
+ if (stream === 'stdout' && isMeaningfulConsoleLog(s)) {
387
+ lastConsoleLog = s.trim();
388
+ }
389
+
390
+ // Update both outputTail and fullOutput using string concatenation
391
+ return ScriptRun.findById(runId).then(run => {
392
+ if (run) {
393
+ run.outputTail = tail;
394
+ run.fullOutput = (run.fullOutput || '') + s;
395
+ run.lastConsoleLog = lastConsoleLog;
396
+ run.lastOutputUpdate = new Date();
397
+ run.outputSize = (run.outputSize || 0) + s.length;
398
+ run.lineCount = (run.lineCount || 0) + (s.split('\n').length - 1);
399
+ return run.save();
400
+ }
401
+ });
402
+ }
403
+
404
+ try {
405
+ // Use existing app connection if available, otherwise create new one
406
+ if (mongoose.connection.readyState !== 1) {
407
+ await pushLog('stdout', 'No existing connection found, establishing new connection...\n');
408
+ await mongooseHelper.connect();
409
+
410
+ // Wait for connection to be fully ready
411
+ await mongooseHelper.waitForConnection(5000);
412
+ } else {
413
+ await pushLog('stdout', 'Using existing app database connection\n');
414
+ }
415
+
416
+ // Validate connection is ready
417
+ if (mongoose.connection.readyState !== 1) {
418
+ throw new Error('Database connection is not ready');
419
+ }
420
+
421
+ const prepared = prepareVmCodeForExecution(code);
422
+ if (prepared.wrapped) {
423
+ await pushLog('stdout', 'Auto-wrapping script to capture return value\n');
424
+ }
425
+
426
+ // Create a VM with database context
427
+ const vm = new NodeVM({
428
+ console: 'inherit',
429
+ sandbox: {
430
+ // Expose pre-connected mongoose instance
431
+ mongoose: mongoose,
432
+ db: mongoose.connection.db,
433
+
434
+ // Expose helper functions
435
+ countCollectionDocuments: async (collectionName, query = {}) => {
436
+ try {
437
+ // Ensure connection is still valid
438
+ if (mongoose.connection.readyState !== 1) {
439
+ throw new Error('Database connection lost during operation');
440
+ }
441
+
442
+ const db = mongoose.connection.db;
443
+ if (!db) {
444
+ throw new Error('Database instance not available');
445
+ }
446
+
447
+ const collection = db.collection(collectionName);
448
+ const count = await collection.countDocuments(query);
449
+ return count;
450
+ } catch (error) {
451
+ throw new Error(`Failed to count documents in ${collectionName}: ${error.message}`);
452
+ }
453
+ },
454
+
455
+ // Expose connection status helper
456
+ getConnectionStatus: () => {
457
+ const readyStateMap = {
458
+ 0: 'disconnected',
459
+ 1: 'connected',
460
+ 2: 'connecting',
461
+ 3: 'disconnecting'
462
+ };
463
+
464
+ return {
465
+ readyState: mongoose.connection.readyState,
466
+ readyStateText: readyStateMap[mongoose.connection.readyState] || 'unknown',
467
+ host: mongoose.connection.host,
468
+ name: mongoose.connection.name,
469
+ hasActiveConnection: mongoose.connection.readyState === 1
470
+ };
471
+ },
472
+
473
+ // Expose models if available
474
+ models: mongoose.models || {},
475
+
476
+ // Global objects
477
+ JSON,
478
+ Date,
479
+ Math,
480
+ parseInt,
481
+ parseFloat,
482
+ String,
483
+ Number,
484
+ Object,
485
+ Array,
486
+
487
+ // Process environment
488
+ process: {
489
+ env: { ...process.env, ...env }
490
+ },
491
+
492
+ // Debug: Log available models
493
+ debugModels: () => {
494
+ console.log('Available models:', Object.keys(mongoose.models || {}));
495
+ return mongoose.models || {};
496
+ },
497
+
498
+ // Global variables for async result capture
499
+ global: {},
500
+ __scriptResult: undefined
501
+ },
502
+ require: {
503
+ external: false,
504
+ builtin: ['util', 'path', 'os', 'mongoose'], // Allow mongoose for parent app model access
505
+ },
506
+ timeout: timeoutMs,
507
+ eval: false,
508
+ wasm: false,
509
+ });
510
+
511
+ // Set up console redirection
512
+ vm.on('console.log', (...args) => {
513
+ const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
514
+
515
+ // Send to UI output
516
+ pushLog('stdout', message);
517
+
518
+ // Also send to parent process (backend logs)
519
+ console.log('[Script]', message.trim());
520
+ });
521
+ vm.on('console.error', (...args) => {
522
+ const message = args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n';
523
+
524
+ // Send to UI output
525
+ pushLog('stderr', message);
526
+
527
+ // Also send to parent process (backend logs)
528
+ console.error('[Script]', message.trim());
529
+ });
530
+
531
+ // Handle unhandled promise rejections within the VM
532
+ vm.on('unhandledRejection', (reason, promise) => {
533
+ let errorMsg = 'Unhandled Promise Rejection: ';
534
+
535
+ if (reason instanceof Error) {
536
+ errorMsg += reason.message;
537
+ if (reason.stack) {
538
+ errorMsg += '\n' + reason.stack;
539
+ }
540
+ } else {
541
+ errorMsg += JSON.stringify(reason);
542
+ }
543
+
544
+ // Send to UI output
545
+ pushLog('stderr', errorMsg + '\n');
546
+
547
+ // Also send to parent process (backend logs)
548
+ console.error('[Script]', errorMsg);
549
+ });
550
+
551
+ // Handle uncaught exceptions within the VM
552
+ vm.on('error', (error) => {
553
+ let errorMsg = 'VM Error: ';
554
+
555
+ if (error instanceof Error) {
556
+ errorMsg += error.message;
557
+ if (error.stack) {
558
+ errorMsg += '\n' + error.stack;
559
+ }
560
+ } else {
561
+ errorMsg += JSON.stringify(error);
562
+ }
563
+
564
+ // Send to UI output
565
+ pushLog('stderr', errorMsg + '\n');
566
+
567
+ // Also send to parent process (backend logs)
568
+ console.error('[Script]', errorMsg);
569
+ });
570
+
571
+ // Run the script code with better error handling
572
+ try {
573
+ await pushLog('stdout', `=== SCRIPT START ===\n`);
574
+ await pushLog('stdout', `Executing script (${prepared.code.length} chars)...\n`);
575
+
576
+ // Show first few lines of script for debugging
577
+ const scriptPreview = prepared.code.split('\n').slice(0, 5).join('\n');
578
+ await pushLog('stdout', `Script preview:\n${scriptPreview}\n...\n`);
579
+
580
+ // Run the script
581
+ vm.run(prepared.code, 'script.host.js');
582
+
583
+ // Capture result based on whether the script was wrapped
584
+ if (prepared.wrapped) {
585
+ // For wrapped scripts, wait and capture from global variable
586
+ await new Promise(resolve => setTimeout(resolve, 100)); // Wait for async completion
587
+ scriptResult = vm.sandbox.__scriptResult;
588
+ } else {
589
+ // For non-wrapped scripts, try to capture direct return or use global variable
590
+ scriptResult = vm.sandbox.__scriptResult;
591
+ // Also try to get the last meaningful console.log as fallback
592
+ if (!scriptResult && lastConsoleLog) {
593
+ scriptResult = lastConsoleLog;
594
+ }
595
+ }
596
+
597
+ await pushLog('stdout', `=== SCRIPT END ===\n`);
598
+
599
+ // Determine and save programmatic output
600
+ const programmaticOutput = determineProgrammaticOutput(scriptResult, lastConsoleLog);
601
+ await ScriptRun.updateOne(
602
+ { _id: runId },
603
+ {
604
+ $set: {
605
+ programmaticOutput: programmaticOutput.programmaticOutput,
606
+ returnResult: scriptResult !== undefined && scriptResult !== null ? formatOutput(scriptResult) : '',
607
+ outputType: programmaticOutput.outputType
608
+ }
609
+ }
610
+ );
611
+ } catch (vmError) {
612
+ let errorMsg = 'VM execution error: ';
613
+
614
+ if (vmError instanceof Error) {
615
+ errorMsg += vmError.message;
616
+ if (vmError.stack) {
617
+ errorMsg += '\n' + vmError.stack;
618
+ }
619
+ } else {
620
+ errorMsg += JSON.stringify(vmError);
621
+ }
622
+
623
+ const help = vmError?.message?.includes('await is only valid in async functions') ? `\n\n${buildAwaitSyntaxHelpMessage()}` : '';
624
+ await pushLog('stderr', errorMsg + help + '\n');
625
+ return 1;
626
+ }
627
+
628
+ return 0;
629
+
630
+ } catch (err) {
631
+ let errorMsg = 'Host script error: ';
632
+
633
+ if (err instanceof Error) {
634
+ errorMsg += err.message;
635
+ if (err.stack) {
636
+ errorMsg += '\n' + err.stack;
637
+ }
638
+ } else {
639
+ errorMsg += JSON.stringify(err);
640
+ }
641
+
642
+ await pushLog('stderr', errorMsg + '\n');
643
+ return 1;
644
+ } finally {
645
+ // Don't disconnect here - let mongooseHelper manage connection pooling
646
+ // The connection will be cleaned up when the helper decides
647
+ }
648
+ }
649
+
216
650
  async function runVm2({ runId, bus, code, timeoutMs }) {
217
651
  let tail = '';
652
+ let scriptResult = null;
653
+ let lastConsoleLog = null;
218
654
 
219
655
  function pushLog(stream, line) {
220
656
  const s = String(line || '');
221
657
  tail = appendTail(tail, s);
222
658
  bus.push({ type: 'log', ts: nowIso(), stream, line: s });
223
- return ScriptRun.updateOne({ _id: runId }, { $set: { outputTail: tail } });
659
+
660
+ // Track last console.log for programmatic output
661
+ if (stream === 'stdout' && isMeaningfulConsoleLog(s)) {
662
+ lastConsoleLog = s.trim();
663
+ }
664
+
665
+ // Update both outputTail and fullOutput using string concatenation
666
+ return ScriptRun.findById(runId).then(run => {
667
+ if (run) {
668
+ run.outputTail = tail;
669
+ run.fullOutput = (run.fullOutput || '') + s;
670
+ run.lastConsoleLog = lastConsoleLog;
671
+ run.lastOutputUpdate = new Date();
672
+ run.outputSize = (run.outputSize || 0) + s.length;
673
+ run.lineCount = (run.lineCount || 0) + (s.split('\n').length - 1);
674
+ return run.save();
675
+ }
676
+ });
224
677
  }
225
678
 
226
679
  const vm = new NodeVM({
227
680
  console: 'redirect',
228
- sandbox: {},
681
+ sandbox: {
682
+ global: {},
683
+ __scriptResult: undefined
684
+ },
229
685
  require: {
230
686
  external: false,
231
687
  builtin: [],
@@ -243,11 +699,46 @@ async function runVm2({ runId, bus, code, timeoutMs }) {
243
699
  });
244
700
 
245
701
  try {
246
- vm.run(code, 'script.vm2.js');
702
+ const prepared = prepareVmCodeForExecution(code);
703
+ if (prepared.wrapped) {
704
+ await pushLog('stdout', 'Auto-wrapping script to capture return value\n');
705
+ }
706
+
707
+ // Run the script
708
+ vm.run(prepared.code, 'script.vm2.js');
709
+
710
+ // Capture result based on whether the script was wrapped
711
+ if (prepared.wrapped) {
712
+ // For wrapped scripts, wait and capture from global variable
713
+ await new Promise(resolve => setTimeout(resolve, 100)); // Wait for async completion
714
+ scriptResult = vm.sandbox.__scriptResult;
715
+ } else {
716
+ // For non-wrapped scripts, try to capture direct return or use global variable
717
+ scriptResult = vm.sandbox.__scriptResult;
718
+ // Also try to get the last meaningful console.log as fallback
719
+ if (!scriptResult && lastConsoleLog) {
720
+ scriptResult = lastConsoleLog;
721
+ }
722
+ }
723
+
724
+ // Determine and save programmatic output
725
+ const programmaticOutput = determineProgrammaticOutput(scriptResult, lastConsoleLog);
726
+ await ScriptRun.updateOne(
727
+ { _id: runId },
728
+ {
729
+ $set: {
730
+ programmaticOutput: programmaticOutput.programmaticOutput,
731
+ returnResult: scriptResult !== undefined && scriptResult !== null ? formatOutput(scriptResult) : '',
732
+ outputType: programmaticOutput.outputType
733
+ }
734
+ }
735
+ );
736
+
247
737
  return 0;
248
738
  } catch (err) {
249
- const msg = err?.message || 'vm2 error';
250
- await pushLog('stderr', msg + '\n');
739
+ const baseMsg = err?.message || 'vm2 error';
740
+ const help = baseMsg.includes('await is only valid in async functions') ? `\n\n${buildAwaitSyntaxHelpMessage()}` : '';
741
+ await pushLog('stderr', baseMsg + help + '\n');
251
742
  return 1;
252
743
  }
253
744
  }