@liquidmetal-ai/precip 1.0.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.
Files changed (78) hide show
  1. package/.prettierrc +9 -0
  2. package/CHANGELOG.md +8 -0
  3. package/eslint.config.mjs +28 -0
  4. package/package.json +53 -0
  5. package/src/engine/agent.ts +478 -0
  6. package/src/engine/llm-provider.test.ts +275 -0
  7. package/src/engine/llm-provider.ts +330 -0
  8. package/src/engine/stream-parser.ts +170 -0
  9. package/src/index.ts +142 -0
  10. package/src/mounts/mount-manager.test.ts +516 -0
  11. package/src/mounts/mount-manager.ts +327 -0
  12. package/src/mounts/mount-registry.ts +196 -0
  13. package/src/mounts/zod-to-string.test.ts +154 -0
  14. package/src/mounts/zod-to-string.ts +213 -0
  15. package/src/presets/agent-tools.ts +57 -0
  16. package/src/presets/index.ts +5 -0
  17. package/src/sandbox/README.md +1321 -0
  18. package/src/sandbox/bridges/README.md +571 -0
  19. package/src/sandbox/bridges/actor.test.ts +229 -0
  20. package/src/sandbox/bridges/actor.ts +195 -0
  21. package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
  22. package/src/sandbox/bridges/bucket.test.ts +300 -0
  23. package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
  24. package/src/sandbox/bridges/console-multiple.test.ts +187 -0
  25. package/src/sandbox/bridges/console.test.ts +157 -0
  26. package/src/sandbox/bridges/console.ts +122 -0
  27. package/src/sandbox/bridges/fetch.ts +93 -0
  28. package/src/sandbox/bridges/index.ts +78 -0
  29. package/src/sandbox/bridges/readable-stream.ts +323 -0
  30. package/src/sandbox/bridges/response.test.ts +154 -0
  31. package/src/sandbox/bridges/response.ts +123 -0
  32. package/src/sandbox/bridges/review-fixes.test.ts +331 -0
  33. package/src/sandbox/bridges/search.test.ts +475 -0
  34. package/src/sandbox/bridges/search.ts +264 -0
  35. package/src/sandbox/bridges/shared/body-methods.ts +93 -0
  36. package/src/sandbox/bridges/shared/cleanup.ts +112 -0
  37. package/src/sandbox/bridges/shared/convert.ts +76 -0
  38. package/src/sandbox/bridges/shared/headers.ts +181 -0
  39. package/src/sandbox/bridges/shared/index.ts +36 -0
  40. package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
  41. package/src/sandbox/bridges/shared/path-parser.ts +109 -0
  42. package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
  43. package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
  44. package/src/sandbox/bridges/shared/response-object.ts +280 -0
  45. package/src/sandbox/bridges/shared/result-builder.ts +130 -0
  46. package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
  47. package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
  48. package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
  49. package/src/sandbox/bridges/storage.ts +421 -0
  50. package/src/sandbox/bridges/text-decoder.ts +190 -0
  51. package/src/sandbox/bridges/text-encoder.ts +102 -0
  52. package/src/sandbox/bridges/types.ts +39 -0
  53. package/src/sandbox/bridges/utils.ts +123 -0
  54. package/src/sandbox/index.ts +6 -0
  55. package/src/sandbox/quickjs-wasm.d.ts +9 -0
  56. package/src/sandbox/sandbox.test.ts +191 -0
  57. package/src/sandbox/sandbox.ts +831 -0
  58. package/src/sandbox/test-helper.ts +43 -0
  59. package/src/sandbox/test-mocks.ts +154 -0
  60. package/src/sandbox/user-stream.test.ts +77 -0
  61. package/src/skills/frontmatter.test.ts +305 -0
  62. package/src/skills/frontmatter.ts +200 -0
  63. package/src/skills/index.ts +9 -0
  64. package/src/skills/skills-loader.test.ts +237 -0
  65. package/src/skills/skills-loader.ts +200 -0
  66. package/src/tools/actor-storage-tools.ts +250 -0
  67. package/src/tools/code-tools.test.ts +199 -0
  68. package/src/tools/code-tools.ts +444 -0
  69. package/src/tools/file-tools.ts +206 -0
  70. package/src/tools/registry.ts +125 -0
  71. package/src/tools/script-tools.ts +145 -0
  72. package/src/tools/smartbucket-tools.ts +203 -0
  73. package/src/tools/sql-tools.ts +213 -0
  74. package/src/tools/tool-factory.ts +119 -0
  75. package/src/types.ts +512 -0
  76. package/tsconfig.eslint.json +5 -0
  77. package/tsconfig.json +15 -0
  78. package/vitest.config.ts +33 -0
@@ -0,0 +1,831 @@
1
+ /**
2
+ * Sandbox - Singleton QuickJS sandbox with queued execution
3
+ *
4
+ * Provides a single QuickJS context per worker to limit memory usage (64MB).
5
+ * Concurrent executions are queued so only one runs at a time.
6
+ *
7
+ * Key features:
8
+ * - Singleton pattern - one sandbox per worker (safe under concurrent await)
9
+ * - Execution queue - concurrent calls wait their turn
10
+ * - Context recreated per execution for clean state (future: reuse with global reset)
11
+ * - Proper cleanup - dispose() when done (e.g., actor eviction)
12
+ *
13
+ * ## Security Model
14
+ *
15
+ * The sandbox is **NOT** a hard security boundary. It provides:
16
+ * - **Isolation:** Untrusted code runs in a separate QuickJS context
17
+ * - **Memory limits:** 64MB limit per context prevents memory exhaustion attacks
18
+ * - **Controlled API access:** Bridges (fetch, storage, etc.) mediate access to host resources
19
+ *
20
+ * The sandbox does **NOT** provide:
21
+ * - Unbreakable code containment: Sandbox code can call internal helpers if it knows their names
22
+ * - Sandboxing against determined attackers: Code can attempt to break out via QuickJS bugs
23
+ * - Complete encapsulation: Internal globals are hidden but still callable (enumerable: false)
24
+ *
25
+ * For the use case of safely executing LLM-generated code with controlled resource access,
26
+ * this security model is sufficient. The bridges are the primary security boundary.
27
+ *
28
+ * Usage:
29
+ * await Sandbox.configure({ memoryLimitBytes: 64 * 1024 * 1024 });
30
+ * const sandbox = await Sandbox.getInstance();
31
+ * const result = await sandbox.execute(code, globals, options);
32
+ * await sandbox.dispose(); // Call when shutting down (actor eviction)
33
+ */
34
+
35
+ import RELEASE_SYNC_NG from '@jitl/quickjs-ng-wasmfile-release-sync';
36
+ import type { QuickJSContext, QuickJSHandle } from 'quickjs-emscripten-core';
37
+ import { newQuickJSWASMModuleFromVariant, newVariant, isFail } from 'quickjs-emscripten-core';
38
+ // Import the .wasm file so the build plugin (esbuild) copies it to the output.
39
+ // In Raindrop, this resolves to a pre-compiled WebAssembly.Module.
40
+ // In Node.js/tests, this is stubbed out (the library loads WASM from disk automatically).
41
+ // @ts-expect-error - No type definitions for .wasm file
42
+ import wasmModule from '@jitl/quickjs-ng-wasmfile-release-sync/wasm';
43
+ import type {
44
+ SandboxResult,
45
+ SandboxGlobals,
46
+ SandboxGlobal,
47
+ AsyncSandboxGlobal,
48
+ SyncSandboxGlobal,
49
+ ObjectSandboxGlobal,
50
+ Logger,
51
+ SandboxOptions
52
+ } from '../types.js';
53
+ import {
54
+ createPromiseTracker,
55
+ installAllBridges,
56
+ runCleanupHandlers,
57
+ convertToHandle,
58
+ getConsoleOutput,
59
+ createAsyncBridge,
60
+ createSyncBridge
61
+ } from './bridges/index.js';
62
+ import type { PromiseTracker, BridgeContext, BridgeInstaller } from './bridges/types.js';
63
+
64
+ /**
65
+ * Queued execution request
66
+ */
67
+ interface QueuedExecution {
68
+ code: string;
69
+ globals: SandboxGlobals;
70
+ options: SandboxOptions;
71
+ resolve: (result: SandboxResult) => void;
72
+ reject: (error: Error) => void;
73
+ }
74
+
75
+ /**
76
+ * Safely stringify a value, handling circular references and other edge cases.
77
+ * Returns the stringified value or a fallback string representation.
78
+ */
79
+ function safeStringify(obj: any): string {
80
+ try {
81
+ return JSON.stringify(obj) ?? String(obj);
82
+ } catch {
83
+ return String(obj);
84
+ }
85
+ }
86
+
87
+ // Singleton module instance
88
+ let syncModulePromise: Promise<any> | null = null;
89
+
90
+ async function getSyncModule() {
91
+ if (!syncModulePromise) {
92
+ syncModulePromise = (async () => {
93
+ // In Raindrop, `wasmModule` is a pre-compiled WebAssembly.Module
94
+ // from the static import. Pass it so the runtime can instantiate it directly.
95
+ // In Node.js/tests, `wasmModule` is undefined (stubbed out), so we omit it
96
+ // and let the library locate and load the .wasm file from disk automatically.
97
+ const variantOptions: Record<string, unknown> = {};
98
+ if (wasmModule instanceof WebAssembly.Module) {
99
+ variantOptions.wasmModule = wasmModule;
100
+ }
101
+ const variant = newVariant(RELEASE_SYNC_NG, variantOptions);
102
+ return newQuickJSWASMModuleFromVariant(variant);
103
+ })();
104
+ }
105
+ return syncModulePromise;
106
+ }
107
+
108
+ /**
109
+ * Singleton QuickJS sandbox with queued execution
110
+ */
111
+ export class Sandbox {
112
+ /** Resolved instance — checked first so steady-state calls never await a cross-request promise */
113
+ private static instance: Sandbox | null = null;
114
+
115
+ /** True while configure() is in-flight. Used for synchronous conflict detection. */
116
+ private static configuring: boolean = false;
117
+
118
+ private context: QuickJSContext | undefined;
119
+ private runtime: any;
120
+ private disposed: boolean = false;
121
+ private executionQueue: QueuedExecution[] = [];
122
+ private isExecuting: boolean = false;
123
+ private memoryLimitBytes: number;
124
+ private leakCount: number = 0;
125
+ private static readonly MAX_QUEUE_DEPTH = 100;
126
+ private static readonly MAX_LEAKS_BEFORE_RESET = 5;
127
+
128
+ /** Timeout in milliseconds (default for executions that don't specify one) */
129
+ private timeoutMs: number;
130
+
131
+ /** Custom bridge installers (provided at configure time, applied each execution) */
132
+ private bridgeInstallers: BridgeInstaller[];
133
+
134
+ private constructor(options: SandboxOptions = {}) {
135
+ this.timeoutMs = options.timeoutMs || 30000;
136
+ this.memoryLimitBytes = options.memoryLimitBytes || 64 * 1024 * 1024;
137
+ this.bridgeInstallers = options.bridgeInstallers || [];
138
+ }
139
+
140
+ /**
141
+ * Check whether the singleton is ready (synchronous, no promises).
142
+ *
143
+ * Use this in Raindrop to avoid awaiting cross-request promises.
144
+ */
145
+ static isConfigured(): boolean {
146
+ return Sandbox.instance !== null;
147
+ }
148
+
149
+ /**
150
+ * Check whether configure() is currently in-flight (synchronous).
151
+ */
152
+ static isConfiguring(): boolean {
153
+ return Sandbox.configuring;
154
+ }
155
+
156
+ /**
157
+ * Configure and create the singleton Sandbox instance.
158
+ *
159
+ * Must be called exactly once (typically in blockConcurrencyWhile).
160
+ * After this, use getInstance() to retrieve the sandbox.
161
+ *
162
+ * state.blockConcurrencyWhile(async () => {
163
+ * await Sandbox.configure({ memoryLimitBytes: 64 * 1024 * 1024 });
164
+ * });
165
+ *
166
+ * @throws Error if the sandbox has already been configured
167
+ */
168
+ static async configure(options: SandboxOptions = {}): Promise<Sandbox> {
169
+ if (Sandbox.instance) {
170
+ const logger = options.logger;
171
+ logger?.warn?.(
172
+ '[Sandbox] configure() called but instance already exists. ' +
173
+ 'Returning existing instance. Call Sandbox.reset() first to reconfigure with different options.'
174
+ );
175
+ return Sandbox.instance;
176
+ }
177
+
178
+ if (Sandbox.configuring) {
179
+ throw new Error(
180
+ 'Sandbox is currently being configured by another caller.'
181
+ );
182
+ }
183
+
184
+ Sandbox.configuring = true;
185
+ try {
186
+ const sandbox = new Sandbox(options);
187
+ await sandbox.initialize();
188
+ Sandbox.instance = sandbox;
189
+ return sandbox;
190
+ } finally {
191
+ Sandbox.configuring = false;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Get the singleton Sandbox instance.
197
+ *
198
+ * Returns the resolved instance if configured. Never returns a pending
199
+ * promise from another request context — safe for Raindrop.
200
+ *
201
+ * @throws Error if the sandbox has not been configured yet
202
+ */
203
+ static async getInstance(): Promise<Sandbox> {
204
+ if (Sandbox.instance) {
205
+ return Sandbox.instance;
206
+ }
207
+
208
+ throw new Error(
209
+ 'Sandbox has not been configured. Call Sandbox.configure(options) first.'
210
+ );
211
+ }
212
+
213
+ /**
214
+ * Initialize the QuickJS context and runtime
215
+ */
216
+ private async initialize(): Promise<void> {
217
+ if (this.disposed) {
218
+ throw new Error('Sandbox has been disposed');
219
+ }
220
+
221
+ const module = await getSyncModule();
222
+ this.runtime = module.newRuntime();
223
+ this.context = this.runtime.newContext();
224
+
225
+ // Set memory limit
226
+ this.runtime.setMemoryLimit(this.memoryLimitBytes);
227
+ }
228
+
229
+ /**
230
+ * Execute code in the sandbox with async support
231
+ *
232
+ * If another execution is in progress, this call waits in a queue.
233
+ * Logger is per-execution — pass it in options so each request gets its own context.
234
+ */
235
+ async execute(
236
+ code: string,
237
+ globals: SandboxGlobals = {},
238
+ options: SandboxOptions = {}
239
+ ): Promise<SandboxResult> {
240
+ if (this.disposed) {
241
+ throw new Error('Sandbox has been disposed');
242
+ }
243
+
244
+ if (!this.context) {
245
+ throw new Error('Sandbox not initialized');
246
+ }
247
+
248
+ // Reject if queue is full to prevent unbounded memory growth
249
+ if (this.executionQueue.length >= Sandbox.MAX_QUEUE_DEPTH) {
250
+ throw new Error(
251
+ `Sandbox execution queue is full (${Sandbox.MAX_QUEUE_DEPTH} pending). Too many concurrent requests.`
252
+ );
253
+ }
254
+
255
+ // Queue the execution if another is running
256
+ return new Promise<SandboxResult>((resolve, reject) => {
257
+ this.executionQueue.push({
258
+ code,
259
+ globals,
260
+ options,
261
+ resolve,
262
+ reject
263
+ });
264
+
265
+ this.processQueue();
266
+ });
267
+ }
268
+
269
+ /**
270
+ * Process the execution queue
271
+ */
272
+ private async processQueue(): Promise<void> {
273
+ if (this.isExecuting || this.executionQueue.length === 0) {
274
+ return;
275
+ }
276
+
277
+ this.isExecuting = true;
278
+
279
+ try {
280
+ while (this.executionQueue.length > 0) {
281
+ const execution = this.executionQueue.shift()!;
282
+
283
+ try {
284
+ const result = await this.runExecution(
285
+ execution.code,
286
+ execution.globals,
287
+ execution.options
288
+ );
289
+ execution.resolve(result);
290
+ } catch (error) {
291
+ execution.reject(error as Error);
292
+ }
293
+ }
294
+ } finally {
295
+ this.isExecuting = false;
296
+ }
297
+
298
+ // Re-check: items may have been queued between the while-loop's last
299
+ // check and the finally block setting isExecuting = false. Without this,
300
+ // the newly queued item's processQueue() call would have seen
301
+ // isExecuting = true and returned early — leaving it stranded.
302
+ if (this.executionQueue.length > 0) {
303
+ this.processQueue();
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Run a single execution (internal - assumes we have exclusive access to context)
309
+ */
310
+ private async runExecution(
311
+ code: string,
312
+ asyncGlobals: SandboxGlobals,
313
+ options: SandboxOptions
314
+ ): Promise<SandboxResult> {
315
+ // Logger is per-execution so each request gets its own logger context
316
+ const logger = options.logger;
317
+ const startTime = Date.now();
318
+ const timeoutMs = options.timeoutMs || this.timeoutMs;
319
+ const deadline = Date.now() + timeoutMs;
320
+
321
+ // Set interrupt handler for this execution's timeout
322
+ this.runtime.setInterruptHandler(() => Date.now() > deadline);
323
+
324
+ // Create per-execution promise tracker
325
+ const tracker = createPromiseTracker();
326
+
327
+ // Cleanup handlers for bridges (abort streams, etc.)
328
+ const cleanupHandlers: Array<() => void | Promise<void>> = [];
329
+
330
+ try {
331
+ // Install all bridges (fetch, TextEncoder, TextDecoder, ReadableStream)
332
+ const bridgeCtx: BridgeContext = {
333
+ context: this.context!,
334
+ runtime: this.runtime,
335
+ tracker,
336
+ logger,
337
+ cleanupHandlers
338
+ };
339
+ installAllBridges(bridgeCtx);
340
+
341
+ // Install custom bridges (only those provided at configure time, plus per-execution ones)
342
+ const allBridgeInstallers = [...this.bridgeInstallers];
343
+ if (options.bridgeInstallers) {
344
+ allBridgeInstallers.push(...options.bridgeInstallers);
345
+ }
346
+
347
+ for (const installer of allBridgeInstallers) {
348
+ try {
349
+ const cleanup = installer(bridgeCtx);
350
+ if (typeof cleanup === 'function') {
351
+ cleanupHandlers.push(cleanup);
352
+ }
353
+ } catch (e) {
354
+ logger?.error?.(`[Sandbox] Bridge installer failed: ${e}`);
355
+ throw new Error(`Bridge installer failed: ${e}`);
356
+ }
357
+ }
358
+
359
+ // Inject async globals with promise tracking
360
+ for (const [key, value] of Object.entries(asyncGlobals || {})) {
361
+ this.injectGlobal(key, value, tracker, cleanupHandlers, logger);
362
+ }
363
+
364
+ // Wrap in async IIFE so they can use await
365
+ const wrappedCode = `
366
+ (async () => {
367
+ ${code}
368
+ })()
369
+ `;
370
+
371
+ // evalCode returns a promise handle immediately (sync call!)
372
+ const evalResult = this.context!.evalCode(wrappedCode);
373
+
374
+ if (isFail(evalResult)) {
375
+ const error = this.context!.dump(evalResult.error);
376
+ evalResult.error.dispose();
377
+ throw new Error(`Evaluation error: ${JSON.stringify(error)}`);
378
+ }
379
+
380
+ const promiseHandle = evalResult.value;
381
+
382
+ // Wait for the promise using Promise.race optimization
383
+ const result = await this.racePromise(promiseHandle, tracker, timeoutMs, logger);
384
+
385
+ const executionTime = Date.now() - startTime;
386
+
387
+ // Get console output before disposing context
388
+ const consoleOutput = this.context ? getConsoleOutput(this.context) : undefined;
389
+
390
+ return {
391
+ success: true,
392
+ result,
393
+ consoleOutput,
394
+ executionTime
395
+ };
396
+ } catch (error) {
397
+ const executionTime = Date.now() - startTime;
398
+ const errorMessage = error instanceof Error ? error.message : String(error);
399
+
400
+ logger?.error?.('[Sandbox] Execution failed', error);
401
+
402
+ // Get console output even on error
403
+ const consoleOutput = this.context ? getConsoleOutput(this.context) : undefined;
404
+
405
+ return {
406
+ success: false,
407
+ error: errorMessage,
408
+ executionTime,
409
+ consoleOutput
410
+ };
411
+ } finally {
412
+ await this.cleanupExecution(tracker, cleanupHandlers, logger);
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Inject a global value into the sandbox
418
+ */
419
+ private injectGlobal(key: string, value: SandboxGlobal, tracker: PromiseTracker, cleanupHandlers: Array<() => void | Promise<void>>, logger?: Logger): void {
420
+ // Typed globals with explicit kind
421
+ if (value !== null && value !== undefined && typeof value === 'object' && 'kind' in value) {
422
+ const typed = value as AsyncSandboxGlobal | SyncSandboxGlobal | ObjectSandboxGlobal;
423
+ const bridgeCtx: BridgeContext = {
424
+ context: this.context!,
425
+ runtime: this.runtime,
426
+ tracker,
427
+ logger,
428
+ cleanupHandlers
429
+ };
430
+
431
+ if (typed.kind === 'async') {
432
+ using fnHandle = createAsyncBridge(bridgeCtx, key, typed.fn);
433
+ this.context!.setProp(this.context!.global, key, fnHandle);
434
+ } else if (typed.kind === 'sync') {
435
+ using fnHandle = createSyncBridge(bridgeCtx, key, typed.fn);
436
+ this.context!.setProp(this.context!.global, key, fnHandle);
437
+ } else if (typed.kind === 'object') {
438
+ using objHandle = this.context!.newObject();
439
+
440
+ // Install typed methods
441
+ for (const [methodName, method] of Object.entries(typed.methods)) {
442
+ using methodHandle = method.kind === 'async'
443
+ ? createAsyncBridge(bridgeCtx, methodName, method.fn)
444
+ : createSyncBridge(bridgeCtx, methodName, method.fn);
445
+ this.context!.setProp(objHandle, methodName, methodHandle);
446
+ }
447
+
448
+ // Install static properties
449
+ if (typed.properties) {
450
+ for (const [propName, propValue] of Object.entries(typed.properties)) {
451
+ using propHandle = convertToHandle(this.context!, propValue);
452
+ this.context!.setProp(objHandle, propName, propHandle);
453
+ }
454
+ }
455
+
456
+ this.context!.setProp(this.context!.global, key, objHandle);
457
+ }
458
+ } else {
459
+ // Primitive value (number, string, boolean, null, undefined) or plain array/object
460
+ using handle = convertToHandle(this.context!, value);
461
+ this.context!.setProp(this.context!.global, key, handle);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Race loop to wait for promise resolution
467
+ */
468
+ private async racePromise(
469
+ promiseHandle: QuickJSHandle,
470
+ tracker: PromiseTracker,
471
+ timeoutMs: number,
472
+ _logger?: Logger
473
+ ): Promise<any> {
474
+ return new Promise<any>((resolve, reject) => {
475
+ const startWait = Date.now();
476
+
477
+ const raceLoop = async () => {
478
+ try {
479
+ // Short-circuit if sandbox was disposed while we were awaiting
480
+ if (this.disposed) {
481
+ try { promiseHandle.dispose(); } catch { /* already disposed */ }
482
+ reject(new Error('Sandbox has been disposed'));
483
+ return;
484
+ }
485
+
486
+ // Check timeout
487
+ if (Date.now() - startWait > timeoutMs) {
488
+ promiseHandle.dispose();
489
+ reject(new Error('Execution timeout'));
490
+ return;
491
+ }
492
+
493
+ // Check if main promise is already resolved
494
+ this.runtime.executePendingJobs();
495
+
496
+ // Get promise state with error handling
497
+ let state;
498
+ try {
499
+ state = this.context!.getPromiseState(promiseHandle);
500
+ } catch (e) {
501
+ promiseHandle.dispose();
502
+ reject(new Error(`Failed to check promise state: ${e}`));
503
+ return;
504
+ }
505
+
506
+ if (state.type === 'fulfilled') {
507
+ const value = this.context!.dump(state.value);
508
+ state.value.dispose();
509
+ promiseHandle.dispose();
510
+ resolve(value);
511
+ return;
512
+ } else if (state.type === 'rejected') {
513
+ const error = this.context!.dump(state.error);
514
+ state.error.dispose();
515
+ promiseHandle.dispose();
516
+ const errorStr =
517
+ typeof error === 'string'
518
+ ? error
519
+ : error?.message || safeStringify(error) || String(error);
520
+ reject(new Error(`Promise rejected: ${errorStr}`));
521
+ return;
522
+ }
523
+
524
+ // Still pending - create fresh race signal
525
+ tracker.newPromiseSignal = new Promise<void>(resolve => {
526
+ tracker.notifyNewPromise = resolve;
527
+ });
528
+
529
+ const racers: Promise<any>[] = [
530
+ ...Array.from(tracker.pendingPromises),
531
+ tracker.newPromiseSignal,
532
+ new Promise(r => setTimeout(r, 100)) // Safety timeout
533
+ ];
534
+
535
+ await Promise.race(racers);
536
+
537
+ queueMicrotask(() => raceLoop());
538
+ } catch (e) {
539
+ // Unexpected error — dispose handle to prevent leak
540
+ try { promiseHandle.dispose(); } catch { /* already disposed */ }
541
+ reject(e instanceof Error ? e : new Error(String(e)));
542
+ }
543
+ };
544
+
545
+ raceLoop();
546
+ });
547
+ }
548
+
549
+ /**
550
+ * Cleanup after an execution
551
+ *
552
+ * Recreates context after each execution to ensure clean state.
553
+ * Future optimization: reset globals instead of full recreation.
554
+ */
555
+ private async cleanupExecution(
556
+ tracker: PromiseTracker,
557
+ cleanupHandlers: Array<() => void | Promise<void>>,
558
+ logger?: Logger
559
+ ): Promise<void> {
560
+ // STEP 1: Run bridge cleanup handlers
561
+ try {
562
+ await runCleanupHandlers(cleanupHandlers, logger);
563
+ } catch (e) {
564
+ logger?.warn?.('[Sandbox] Bridge cleanup failed: ' + e);
565
+ }
566
+
567
+ // STEP 1.5: Flush microtasks
568
+ try {
569
+ await new Promise<void>(resolve => queueMicrotask(resolve));
570
+ this.runtime.executePendingJobs();
571
+ } catch {
572
+ // Ignore
573
+ }
574
+
575
+ // STEP 2: Wait for all pending promises to settle
576
+ try {
577
+ const pendingPromises = Array.from(tracker.pendingPromises);
578
+
579
+ if (pendingPromises.length > 0) {
580
+ await Promise.race([
581
+ Promise.allSettled(pendingPromises),
582
+ new Promise(resolve => setTimeout(resolve, 3000))
583
+ ]);
584
+ }
585
+ } catch (e) {
586
+ logger?.warn?.('[Sandbox] Error waiting for promises: ' + e);
587
+ }
588
+
589
+ // STEP 2.5: Execute pending jobs after promises settle
590
+ try {
591
+ this.runtime.executePendingJobs();
592
+ } catch {
593
+ // Ignore
594
+ }
595
+
596
+ // STEP 3: Settle any remaining deferred promises
597
+ try {
598
+ for (const deferred of tracker.deferredPromises) {
599
+ try {
600
+ if (deferred?.handle && deferred.handle.alive) {
601
+ const state = this.context!.getPromiseState(deferred.handle);
602
+ if (state.type === 'pending') {
603
+ const cleanupError = this.context!.newString('Sandbox cleanup - promise cancelled');
604
+ deferred.reject(cleanupError);
605
+ cleanupError.dispose();
606
+ this.runtime.executePendingJobs();
607
+ }
608
+ }
609
+ } catch {
610
+ // Ignore
611
+ }
612
+ }
613
+ } catch (e) {
614
+ logger?.warn?.('[Sandbox] Error settling deferred promises: ' + e);
615
+ }
616
+
617
+ // STEP 4: Drain job queue
618
+ try {
619
+ let iterations = 0;
620
+ const maxIterations = 1000;
621
+ const drainDeadline = Date.now() + 1000;
622
+
623
+ while (iterations < maxIterations && Date.now() < drainDeadline) {
624
+ try {
625
+ const result = this.runtime.executePendingJobs();
626
+
627
+ if ('error' in result && result.error) {
628
+ logger?.warn?.('[Sandbox] Error executing pending jobs');
629
+ try {
630
+ result.error.dispose();
631
+ } catch {
632
+ // Ignore
633
+ }
634
+ break;
635
+ }
636
+
637
+ if ('value' in result && result.value === 0) {
638
+ break;
639
+ }
640
+
641
+ iterations++;
642
+ } catch {
643
+ break;
644
+ }
645
+ }
646
+ } catch (e) {
647
+ logger?.warn?.('[Sandbox] Error draining job queue: ' + e);
648
+ }
649
+
650
+ // STEP 5: Dispose deferred promise handles
651
+ try {
652
+ for (const deferred of tracker.deferredPromises) {
653
+ try {
654
+ if (deferred?.handle && deferred.handle.alive) {
655
+ deferred.handle.dispose();
656
+ }
657
+ } catch (e) {
658
+ logger?.warn?.('[Sandbox] Error disposing promise handle: ' + e);
659
+ }
660
+ }
661
+ tracker.deferredPromises.clear();
662
+ } catch (e) {
663
+ logger?.warn?.('[Sandbox] Error disposing promise handles: ' + e);
664
+ }
665
+
666
+ // STEP 6: Final job drain after handle disposal
667
+ try {
668
+ let iterations = 0;
669
+ const finalDrainDeadline = Date.now() + 500;
670
+
671
+ while (iterations < 100 && Date.now() < finalDrainDeadline) {
672
+ try {
673
+ const result = this.runtime.executePendingJobs();
674
+
675
+ if ('error' in result && result.error) {
676
+ logger?.warn?.('[Sandbox] Error in final drain');
677
+ try {
678
+ result.error.dispose();
679
+ } catch {
680
+ // Ignore
681
+ }
682
+ break;
683
+ }
684
+
685
+ if ('value' in result && result.value === 0) {
686
+ break;
687
+ }
688
+
689
+ iterations++;
690
+ } catch {
691
+ break;
692
+ }
693
+ }
694
+ } catch (e) {
695
+ logger?.warn?.('[Sandbox] Error in final drain: ' + e);
696
+ }
697
+
698
+ // STEP 7: Dispose context, reuse runtime
699
+ //
700
+ // The runtime holds the WASM instance and is expensive to recreate.
701
+ // We dispose only the context (cheap) and create a fresh one on the
702
+ // same runtime. If lingering GC objects prevent a clean context
703
+ // disposal, we recycle the entire runtime rather than leaking it.
704
+ try {
705
+ this.context?.dispose();
706
+ this.context = undefined;
707
+ } catch (e) {
708
+ logger?.warn?.('[Sandbox] Error disposing context: ' + e);
709
+ this.context = undefined;
710
+ }
711
+
712
+ // Check if the runtime has lingering GC objects after context disposal.
713
+ // If so, the runtime is in a bad state — force-dispose and recreate it.
714
+ let needsRuntimeRecycle = false;
715
+ try {
716
+ const memUsage = this.runtime.dumpMemoryUsage();
717
+ const gcObjectPattern = /^\s+\d+\s+(resolve|promise)\s*$/m;
718
+ if (gcObjectPattern.test(memUsage)) {
719
+ needsRuntimeRecycle = true;
720
+ logger?.warn?.(
721
+ '[Sandbox] Lingering GC objects detected after context disposal, recycling runtime'
722
+ );
723
+ }
724
+ } catch {
725
+ // Can't inspect runtime state — recycle to be safe
726
+ needsRuntimeRecycle = true;
727
+ }
728
+
729
+ if (needsRuntimeRecycle) {
730
+ try {
731
+ this.runtime?.dispose();
732
+ } catch {
733
+ // QuickJS assertion may fire here — that's OK, memory is still reclaimed
734
+ }
735
+ this.runtime = undefined;
736
+ }
737
+
738
+ // STEP 8: Recreate context (and runtime if it was recycled)
739
+ try {
740
+ await this.recreateContext();
741
+ } catch (error) {
742
+ // Context recreation failed — mark as disposed so configure() creates a fresh one.
743
+ // Reject any remaining queued executions immediately so they don't wait
744
+ // for a sandbox that will never recover.
745
+ logger?.error?.('[Sandbox] Failed to recreate context, marking sandbox as disposed', error);
746
+ this.disposed = true;
747
+ Sandbox.instance = null;
748
+ Sandbox.configuring = false;
749
+ for (const execution of this.executionQueue) {
750
+ execution.reject(new Error('Sandbox has been disposed: context recreation failed'));
751
+ }
752
+ this.executionQueue = [];
753
+ throw error;
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Recreate the QuickJS context for the next execution.
759
+ *
760
+ * Reuses the existing runtime when possible (fast path).
761
+ * If the runtime was recycled due to lingering GC objects, creates a new one.
762
+ */
763
+ private async recreateContext(): Promise<void> {
764
+ if (this.disposed) {
765
+ return;
766
+ }
767
+
768
+ if (!this.runtime) {
769
+ // Runtime was recycled — create a new one
770
+ const module = await getSyncModule();
771
+ this.runtime = module.newRuntime();
772
+ this.runtime.setMemoryLimit(this.memoryLimitBytes);
773
+ }
774
+
775
+ this.context = this.runtime.newContext();
776
+ }
777
+
778
+ /**
779
+ * Dispose the sandbox and clean up resources
780
+ *
781
+ * Call this when shutting down (e.g., actor eviction).
782
+ * After dispose(), configure() can create a new sandbox.
783
+ */
784
+ async dispose(): Promise<void> {
785
+ if (this.disposed) {
786
+ return;
787
+ }
788
+
789
+ this.disposed = true;
790
+
791
+ // Reject all queued executions
792
+ for (const execution of this.executionQueue) {
793
+ execution.reject(new Error('Sandbox has been disposed'));
794
+ }
795
+ this.executionQueue = [];
796
+
797
+ // Dispose context and runtime
798
+ try {
799
+ this.context?.dispose();
800
+ } catch {
801
+ // Ignore — best effort cleanup
802
+ }
803
+
804
+ try {
805
+ this.runtime?.dispose();
806
+ } catch {
807
+ // Ignore — best effort cleanup
808
+ }
809
+
810
+ // Clear singleton so configure() creates a fresh one
811
+ Sandbox.instance = null;
812
+ Sandbox.configuring = false;
813
+ }
814
+
815
+ /**
816
+ * Reset the singleton instance (for testing only)
817
+ *
818
+ * Disposes the existing instance if any, then clears the singleton.
819
+ */
820
+ static async reset(): Promise<void> {
821
+ if (Sandbox.instance) {
822
+ try {
823
+ await Sandbox.instance.dispose();
824
+ } catch {
825
+ // Ignore — instance may have already been disposed
826
+ }
827
+ }
828
+ Sandbox.instance = null;
829
+ Sandbox.configuring = false;
830
+ }
831
+ }