@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.
- package/.prettierrc +9 -0
- package/CHANGELOG.md +8 -0
- package/eslint.config.mjs +28 -0
- package/package.json +53 -0
- package/src/engine/agent.ts +478 -0
- package/src/engine/llm-provider.test.ts +275 -0
- package/src/engine/llm-provider.ts +330 -0
- package/src/engine/stream-parser.ts +170 -0
- package/src/index.ts +142 -0
- package/src/mounts/mount-manager.test.ts +516 -0
- package/src/mounts/mount-manager.ts +327 -0
- package/src/mounts/mount-registry.ts +196 -0
- package/src/mounts/zod-to-string.test.ts +154 -0
- package/src/mounts/zod-to-string.ts +213 -0
- package/src/presets/agent-tools.ts +57 -0
- package/src/presets/index.ts +5 -0
- package/src/sandbox/README.md +1321 -0
- package/src/sandbox/bridges/README.md +571 -0
- package/src/sandbox/bridges/actor.test.ts +229 -0
- package/src/sandbox/bridges/actor.ts +195 -0
- package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
- package/src/sandbox/bridges/bucket.test.ts +300 -0
- package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
- package/src/sandbox/bridges/console-multiple.test.ts +187 -0
- package/src/sandbox/bridges/console.test.ts +157 -0
- package/src/sandbox/bridges/console.ts +122 -0
- package/src/sandbox/bridges/fetch.ts +93 -0
- package/src/sandbox/bridges/index.ts +78 -0
- package/src/sandbox/bridges/readable-stream.ts +323 -0
- package/src/sandbox/bridges/response.test.ts +154 -0
- package/src/sandbox/bridges/response.ts +123 -0
- package/src/sandbox/bridges/review-fixes.test.ts +331 -0
- package/src/sandbox/bridges/search.test.ts +475 -0
- package/src/sandbox/bridges/search.ts +264 -0
- package/src/sandbox/bridges/shared/body-methods.ts +93 -0
- package/src/sandbox/bridges/shared/cleanup.ts +112 -0
- package/src/sandbox/bridges/shared/convert.ts +76 -0
- package/src/sandbox/bridges/shared/headers.ts +181 -0
- package/src/sandbox/bridges/shared/index.ts +36 -0
- package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
- package/src/sandbox/bridges/shared/path-parser.ts +109 -0
- package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
- package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
- package/src/sandbox/bridges/shared/response-object.ts +280 -0
- package/src/sandbox/bridges/shared/result-builder.ts +130 -0
- package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
- package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
- package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
- package/src/sandbox/bridges/storage.ts +421 -0
- package/src/sandbox/bridges/text-decoder.ts +190 -0
- package/src/sandbox/bridges/text-encoder.ts +102 -0
- package/src/sandbox/bridges/types.ts +39 -0
- package/src/sandbox/bridges/utils.ts +123 -0
- package/src/sandbox/index.ts +6 -0
- package/src/sandbox/quickjs-wasm.d.ts +9 -0
- package/src/sandbox/sandbox.test.ts +191 -0
- package/src/sandbox/sandbox.ts +831 -0
- package/src/sandbox/test-helper.ts +43 -0
- package/src/sandbox/test-mocks.ts +154 -0
- package/src/sandbox/user-stream.test.ts +77 -0
- package/src/skills/frontmatter.test.ts +305 -0
- package/src/skills/frontmatter.ts +200 -0
- package/src/skills/index.ts +9 -0
- package/src/skills/skills-loader.test.ts +237 -0
- package/src/skills/skills-loader.ts +200 -0
- package/src/tools/actor-storage-tools.ts +250 -0
- package/src/tools/code-tools.test.ts +199 -0
- package/src/tools/code-tools.ts +444 -0
- package/src/tools/file-tools.ts +206 -0
- package/src/tools/registry.ts +125 -0
- package/src/tools/script-tools.ts +145 -0
- package/src/tools/smartbucket-tools.ts +203 -0
- package/src/tools/sql-tools.ts +213 -0
- package/src/tools/tool-factory.ts +119 -0
- package/src/types.ts +512 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +15 -0
- 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
|
+
}
|