@eryx-org/eryx 0.0.0 → 0.15.1
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/README.md +281 -4
- package/eryx-sandbox.conditional-host-import-0.wasm +0 -0
- package/eryx-sandbox.conditional-host-import-1.wasm +0 -0
- package/eryx-sandbox.conditional-suspending-1-i32-to-i32.wasm +0 -0
- package/eryx-sandbox.core.wasm +0 -0
- package/eryx-sandbox.core10.wasm +0 -0
- package/eryx-sandbox.core11.wasm +0 -0
- package/eryx-sandbox.core12.wasm +0 -0
- package/eryx-sandbox.core13.wasm +0 -0
- package/eryx-sandbox.core2.wasm +0 -0
- package/eryx-sandbox.core3.wasm +0 -0
- package/eryx-sandbox.core4.wasm +0 -0
- package/eryx-sandbox.core5.wasm +0 -0
- package/eryx-sandbox.core6.wasm +0 -0
- package/eryx-sandbox.core7.wasm +0 -0
- package/eryx-sandbox.core8.wasm +0 -0
- package/eryx-sandbox.core9.wasm +0 -0
- package/eryx-sandbox.d.ts +185 -0
- package/eryx-sandbox.js +55329 -0
- package/index.d.ts +331 -0
- package/index.js +698 -0
- package/instance.js +174 -0
- package/interfaces/eryx-net-tcp.d.ts +39 -0
- package/interfaces/eryx-net-tls.d.ts +33 -0
- package/interfaces/wasi-cli-environment.d.ts +3 -0
- package/interfaces/wasi-cli-exit.d.ts +3 -0
- package/interfaces/wasi-cli-stderr.d.ts +3 -0
- package/interfaces/wasi-cli-stdin.d.ts +3 -0
- package/interfaces/wasi-cli-stdout.d.ts +3 -0
- package/interfaces/wasi-cli-terminal-input.d.ts +8 -0
- package/interfaces/wasi-cli-terminal-output.d.ts +8 -0
- package/interfaces/wasi-cli-terminal-stderr.d.ts +3 -0
- package/interfaces/wasi-cli-terminal-stdin.d.ts +3 -0
- package/interfaces/wasi-cli-terminal-stdout.d.ts +3 -0
- package/interfaces/wasi-clocks-monotonic-clock.d.ts +8 -0
- package/interfaces/wasi-clocks-wall-clock.d.ts +7 -0
- package/interfaces/wasi-filesystem-preopens.d.ts +3 -0
- package/interfaces/wasi-filesystem-types.d.ts +288 -0
- package/interfaces/wasi-io-error.d.ts +8 -0
- package/interfaces/wasi-io-poll.d.ts +10 -0
- package/interfaces/wasi-io-streams.d.ts +51 -0
- package/interfaces/wasi-random-random.d.ts +2 -0
- package/interfaces/wasi-sockets-instance-network.d.ts +3 -0
- package/interfaces/wasi-sockets-ip-name-lookup.d.ts +21 -0
- package/interfaces/wasi-sockets-network.d.ts +92 -0
- package/interfaces/wasi-sockets-tcp-create-socket.d.ts +8 -0
- package/interfaces/wasi-sockets-tcp.d.ts +127 -0
- package/interfaces/wasi-sockets-udp-create-socket.d.ts +8 -0
- package/interfaces/wasi-sockets-udp.d.ts +93 -0
- package/package.json +69 -3
- package/python-stdlib.tar.gz +0 -0
- package/shims/callbacks.d.ts +45 -0
- package/shims/callbacks.js +469 -0
- package/shims/ip.js +46 -0
- package/shims/net-fetch.js +264 -0
- package/shims/net-node.js +236 -0
- package/shims/net.d.ts +84 -0
- package/shims/net.js +260 -0
- package/shims/preview2-node.js +35 -0
- package/shims/raw-json.js +111 -0
- package/shims/sockets.js +53 -0
- package/stdlib-loader.js +174 -0
- package/worker.js +100 -0
package/index.js
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eryx - JavaScript API
|
|
3
|
+
*
|
|
4
|
+
* This module provides a high-level API for the eryx Python sandbox
|
|
5
|
+
* WebAssembly component.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```js
|
|
9
|
+
* import { Sandbox } from '@eryx-org/eryx';
|
|
10
|
+
* const sandbox = new Sandbox();
|
|
11
|
+
* const result = await sandbox.execute('print("hello")');
|
|
12
|
+
* console.log(result.stdout); // "hello\n"
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ExecutionError,
|
|
18
|
+
PythonExceptionError,
|
|
19
|
+
TimeoutError,
|
|
20
|
+
} from "@eryx-org/eryx-run/shared";
|
|
21
|
+
import { createCallbackHost } from "./shims/callbacks.js";
|
|
22
|
+
import { createNet, setNetConfig as setDefaultNetConfig } from "./shims/net.js";
|
|
23
|
+
import {
|
|
24
|
+
_fileTree,
|
|
25
|
+
decodeError,
|
|
26
|
+
encodeError,
|
|
27
|
+
forkTree,
|
|
28
|
+
getCoreModule,
|
|
29
|
+
instantiateSandbox,
|
|
30
|
+
isNode,
|
|
31
|
+
} from "./instance.js";
|
|
32
|
+
|
|
33
|
+
export { _fileTree } from "./instance.js";
|
|
34
|
+
|
|
35
|
+
// The errors a run throws, shared with eryx.run's client (@eryx-org/eryx-run),
|
|
36
|
+
// so one `instanceof` handles both
|
|
37
|
+
export {
|
|
38
|
+
EryxError,
|
|
39
|
+
ExecutionError,
|
|
40
|
+
PythonExceptionError,
|
|
41
|
+
ResourceLimitError,
|
|
42
|
+
TimeoutError,
|
|
43
|
+
} from "@eryx-org/eryx-run/shared";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start a sandbox instance in its own Worker and return a proxy with the
|
|
47
|
+
* instance's methods, plus `terminate()`. The handlers, journal and network
|
|
48
|
+
* stay on this thread; the Worker forwards to them (see worker.js).
|
|
49
|
+
* @param {ReturnType<typeof createCallbackHost>} host
|
|
50
|
+
* @param {ReturnType<typeof createNet>} net - The sandbox's connection table
|
|
51
|
+
*/
|
|
52
|
+
async function instantiateInWorker(host, net) {
|
|
53
|
+
const url = new URL("./worker.js", import.meta.url);
|
|
54
|
+
const worker = isNode
|
|
55
|
+
? // Not the parent's execArgv, which can hold `-e`/`--input-type` a Worker
|
|
56
|
+
// rejects. V8 flags such as --experimental-wasm-jspi are process-wide,
|
|
57
|
+
// so the Worker keeps them.
|
|
58
|
+
new (await import(/* webpackIgnore: true */ "node:worker_threads")).Worker(url, {
|
|
59
|
+
execArgv: [],
|
|
60
|
+
})
|
|
61
|
+
: new Worker(url, { type: "module" });
|
|
62
|
+
const targets = {
|
|
63
|
+
module: getCoreModule,
|
|
64
|
+
invoke: host.imports.invoke,
|
|
65
|
+
reportTrace: host.imports.reportTrace,
|
|
66
|
+
reportOutput: host.imports.reportOutput,
|
|
67
|
+
"tcp.connect": net.tcp.connect,
|
|
68
|
+
"tcp.read": net.tcp.read,
|
|
69
|
+
"tcp.write": net.tcp.write,
|
|
70
|
+
"tcp.close": net.tcp.close,
|
|
71
|
+
"tls.upgrade": net.tls.upgrade,
|
|
72
|
+
"tls.read": net.tls.read,
|
|
73
|
+
"tls.write": net.tls.write,
|
|
74
|
+
"tls.close": net.tls.close,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let nextId = 0;
|
|
78
|
+
const pending = new Map();
|
|
79
|
+
// An idle Worker mustn't keep Node running, but a busy one must.
|
|
80
|
+
const busy = (yes) => isNode && (yes ? worker.ref() : worker.unref());
|
|
81
|
+
let failure = null;
|
|
82
|
+
let ready;
|
|
83
|
+
const started = new Promise((resolve, reject) => (ready = { resolve, reject }));
|
|
84
|
+
// Once the Worker is gone, nothing will close what its code left open.
|
|
85
|
+
const fail = (error) => {
|
|
86
|
+
if (failure) return;
|
|
87
|
+
failure = error;
|
|
88
|
+
net.closeConnections();
|
|
89
|
+
ready.reject(failure);
|
|
90
|
+
for (const { reject } of pending.values()) reject(failure);
|
|
91
|
+
pending.clear();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const onMessage = async (message) => {
|
|
95
|
+
switch (message.type) {
|
|
96
|
+
case "ready":
|
|
97
|
+
busy(false);
|
|
98
|
+
ready.resolve();
|
|
99
|
+
break;
|
|
100
|
+
case "failed":
|
|
101
|
+
fail(decodeError(message));
|
|
102
|
+
break;
|
|
103
|
+
case "result": {
|
|
104
|
+
const call = pending.get(message.id);
|
|
105
|
+
pending.delete(message.id);
|
|
106
|
+
if (pending.size === 0) busy(false);
|
|
107
|
+
if ("value" in message) call?.resolve(message.value);
|
|
108
|
+
else call?.reject(decodeError(message));
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "notify":
|
|
112
|
+
if (!failure) targets[message.target](...message.args);
|
|
113
|
+
break;
|
|
114
|
+
case "request":
|
|
115
|
+
// A dead Worker's queued requests mustn't reach handlers or the network.
|
|
116
|
+
if (failure) break;
|
|
117
|
+
try {
|
|
118
|
+
const value = await targets[message.target](...message.args);
|
|
119
|
+
if (failure) {
|
|
120
|
+
// Opened after the Worker was stopped: nothing would ever close it.
|
|
121
|
+
if (message.target === "tcp.connect") net.tcp.close(value);
|
|
122
|
+
else if (message.target === "tls.upgrade") net.tls.close(value);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
worker.postMessage({ type: "reply", id: message.id, value });
|
|
126
|
+
} catch (e) {
|
|
127
|
+
if (!failure) worker.postMessage({ type: "reply", id: message.id, ...encodeError(e) });
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const onError = (e) => fail(e instanceof Error ? e : new Error(e.message ?? "sandbox worker failed"));
|
|
133
|
+
if (isNode) {
|
|
134
|
+
worker.on("message", onMessage);
|
|
135
|
+
worker.on("error", onError);
|
|
136
|
+
worker.on("exit", () => fail(new Error("sandbox worker exited")));
|
|
137
|
+
} else {
|
|
138
|
+
worker.addEventListener("message", (e) => onMessage(e.data));
|
|
139
|
+
worker.addEventListener("error", onError);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const call = (method, ...args) => {
|
|
143
|
+
if (failure) return Promise.reject(failure);
|
|
144
|
+
const id = nextId++;
|
|
145
|
+
busy(true);
|
|
146
|
+
worker.postMessage({
|
|
147
|
+
type: "call",
|
|
148
|
+
id,
|
|
149
|
+
method,
|
|
150
|
+
args,
|
|
151
|
+
callbacks: host.imports.listCallbacks(),
|
|
152
|
+
options: host.imports.getExecutionOptions(),
|
|
153
|
+
});
|
|
154
|
+
return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
await started;
|
|
158
|
+
return {
|
|
159
|
+
execute: (code) => call("execute", code),
|
|
160
|
+
snapshotState: () => call("snapshotState"),
|
|
161
|
+
restoreState: (data) => call("restoreState", data),
|
|
162
|
+
clearState: () => call("clearState"),
|
|
163
|
+
setResultVariable: (name) => call("setResultVariable", name),
|
|
164
|
+
/** Stop the Worker, rejecting anything still pending with `error`. */
|
|
165
|
+
terminate(error) {
|
|
166
|
+
fail(error);
|
|
167
|
+
worker.terminate();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Terminates the Worker of a `timeoutMs` sandbox that was garbage collected
|
|
174
|
+
* without close(). Holds only the sandbox's Worker slot, never the sandbox.
|
|
175
|
+
* @type {FinalizationRegistry<{worker: any, closed: Error | null}>}
|
|
176
|
+
*/
|
|
177
|
+
const _workerReaper = new FinalizationRegistry((slot) => {
|
|
178
|
+
slot.closed ??= new Error("sandbox was garbage collected");
|
|
179
|
+
slot.worker?.terminate(slot.closed);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Re-export callback configuration
|
|
183
|
+
export {
|
|
184
|
+
setTraceHandler,
|
|
185
|
+
setOutputHandler,
|
|
186
|
+
SuspendCallback,
|
|
187
|
+
} from "./shims/callbacks.js";
|
|
188
|
+
|
|
189
|
+
// Re-export network policy defaults
|
|
190
|
+
export { DEFAULT_BLOCKED_HOSTS, DEFAULT_BLOCKED_NETWORKS } from "./shims/net.js";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Result from executing Python code in the sandbox: eryx.run's
|
|
194
|
+
* `ExecuteResult`, so code handles results from either the same way. `result`
|
|
195
|
+
* keeps integers outside the safe range exact by parsing them as BigInt (see
|
|
196
|
+
* {@link setResultVariable} to capture another variable), `stateWasReset` says
|
|
197
|
+
* an earlier call hit `timeoutMs` and replaced the Worker (reported once, by
|
|
198
|
+
* the next successful run), and `fuelConsumed`, `peakMemoryBytes`,
|
|
199
|
+
* `networkBytes` and `traceId` are undefined.
|
|
200
|
+
* @typedef {import("@eryx-org/eryx-run/shared").ExecuteResult} ExecuteResult
|
|
201
|
+
*/
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A captured Python session state, from {@link Sandbox#snapshotState}.
|
|
205
|
+
* @typedef {Object} StateSnapshot
|
|
206
|
+
* @property {Uint8Array} data - The serialized state, accepted by restoreState()
|
|
207
|
+
* @property {string[]} skippedGlobals - Names of the globals left out because
|
|
208
|
+
* they could not be serialized (e.g. open sockets, generators)
|
|
209
|
+
*/
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Parse the captured result JSON, promoting integers outside the IEEE-754 safe
|
|
213
|
+
* range to BigInt so they round-trip without precision loss.
|
|
214
|
+
*
|
|
215
|
+
* Uses the ES2023 reviver `context.source` (the raw source text of the value) to
|
|
216
|
+
* distinguish a true integer literal from a float. When the runtime doesn't
|
|
217
|
+
* provide it (older engines), falls back to a plain (lossy) parse.
|
|
218
|
+
* @param {string} json
|
|
219
|
+
* @returns {*}
|
|
220
|
+
*/
|
|
221
|
+
function _parseResultJson(json) {
|
|
222
|
+
return JSON.parse(json, (_key, value, context) => {
|
|
223
|
+
if (
|
|
224
|
+
typeof value === "number" &&
|
|
225
|
+
Number.isInteger(value) &&
|
|
226
|
+
!Number.isSafeInteger(value) &&
|
|
227
|
+
context &&
|
|
228
|
+
typeof context.source === "string" &&
|
|
229
|
+
/^-?\d+$/.test(context.source)
|
|
230
|
+
) {
|
|
231
|
+
return BigInt(context.source);
|
|
232
|
+
}
|
|
233
|
+
return value;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Map a raw jco execute-output record into the public ExecuteResult shape,
|
|
239
|
+
* parsing the captured result variable from JSON.
|
|
240
|
+
* @param {Object} output
|
|
241
|
+
* @param {boolean} stateWasReset
|
|
242
|
+
* @returns {ExecuteResult}
|
|
243
|
+
*/
|
|
244
|
+
const _decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
|
|
245
|
+
|
|
246
|
+
function _toResult(output, stateWasReset, stats) {
|
|
247
|
+
return {
|
|
248
|
+
stdout: _decoder.decode(output.stdout),
|
|
249
|
+
stderr: _decoder.decode(output.stderr),
|
|
250
|
+
result: output.resultJson ? _parseResultJson(output.resultJson) : undefined,
|
|
251
|
+
resultJson: output.resultJson || undefined,
|
|
252
|
+
resultError: output.resultError || undefined,
|
|
253
|
+
stateWasReset,
|
|
254
|
+
unsavedVariables: [],
|
|
255
|
+
...stats,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The outcome of executeWithJournal().
|
|
261
|
+
* @typedef {Object} ReplayOutcome
|
|
262
|
+
* @property {ExecuteResult} [result] - The execution result, or undefined if it failed
|
|
263
|
+
* @property {Error} [error] - Why execution failed (Python exception, or the
|
|
264
|
+
* SuspendCallback that halted it), or undefined on success
|
|
265
|
+
* @property {string} journal - Callbacks completed during this run, serialized
|
|
266
|
+
* as JSON in the same shape as the Rust `CallbackJournal`. Always present, even
|
|
267
|
+
* on error. Store it as-is and pass it back via `options.journal`.
|
|
268
|
+
* @property {number} replayedCallbacks - How many callbacks were served from
|
|
269
|
+
* the supplied journal instead of running live
|
|
270
|
+
* @property {{name: string, argsJson: string, reason: string}} [suspended] -
|
|
271
|
+
* Set if a callback threw SuspendCallback
|
|
272
|
+
*/
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Options for a {@link Sandbox}. `traceHandler` and `outputHandler` override,
|
|
276
|
+
* for this sandbox only, the module-wide default set by the setter of the
|
|
277
|
+
* same name; an omitted one follows that default.
|
|
278
|
+
* @typedef {Object} SandboxOptions
|
|
279
|
+
* @property {import("@eryx-org/eryx-run/shared").Callbacks} [callbacks] -
|
|
280
|
+
* Functions the code can `await`, by name, as for eryx.run's client: each a
|
|
281
|
+
* function of the code's keyword arguments, or `{ handler, description }`.
|
|
282
|
+
* What it returns must be JSON; if it throws, the call fails in Python.
|
|
283
|
+
* @property {((lineno: number, eventJson: string, contextJson: string) => void) | null} [traceHandler]
|
|
284
|
+
* @property {((stream: number, data: string) => void) | null} [outputHandler]
|
|
285
|
+
* @property {import("./shims/net.js").NetConfig | null} [netConfig] - This
|
|
286
|
+
* sandbox's network policy (`null` disables networking). Omitted, the
|
|
287
|
+
* sandbox copies the default set with {@link setNetConfig} at creation.
|
|
288
|
+
* @property {number} [timeoutMs] - Run the sandbox in its own Worker and
|
|
289
|
+
* reject any call that takes longer than this many milliseconds with a
|
|
290
|
+
* {@link TimeoutError}. The Worker is then terminated and replaced, so the
|
|
291
|
+
* sandbox's Python state is lost. The deadline is wall-clock time, including
|
|
292
|
+
* callbacks; instantiation doesn't count. The Worker costs about 70 MB.
|
|
293
|
+
* Without it, nothing interrupts a call.
|
|
294
|
+
*/
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A Python sandbox powered by WebAssembly.
|
|
298
|
+
*
|
|
299
|
+
* Each Sandbox is its own WebAssembly instance, with its own Python
|
|
300
|
+
* interpreter, filesystem, network connections, callback handlers and
|
|
301
|
+
* journal, so code run in one can't see or change another's state. Python
|
|
302
|
+
* state (variables, imports, etc.) persists across execute() calls on the same
|
|
303
|
+
* Sandbox; connections are closed when each call finishes.
|
|
304
|
+
*
|
|
305
|
+
* The compiled code is shared, but every instance has its own linear memory
|
|
306
|
+
* (the pre-initialized interpreter image plus whatever the code allocates), so
|
|
307
|
+
* each live Sandbox costs tens of megabytes (about 20 MB idle on Node.js 24).
|
|
308
|
+
* One created with `timeoutMs` also runs in its own Worker, which adds about
|
|
309
|
+
* 70 MB. Call close() (or use `using`) when you're done with a sandbox: it
|
|
310
|
+
* stops the Worker at once and closes the sandbox's connections. A sandbox
|
|
311
|
+
* that is only dropped is freed whenever the garbage collector gets to it.
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* const sandbox = new Sandbox();
|
|
315
|
+
*
|
|
316
|
+
* // Variables persist across calls
|
|
317
|
+
* await sandbox.execute('x = 42');
|
|
318
|
+
* const result = await sandbox.execute('print(x)');
|
|
319
|
+
* console.log(result.stdout); // "42"
|
|
320
|
+
*
|
|
321
|
+
* // Reset state
|
|
322
|
+
* await sandbox.clearState();
|
|
323
|
+
*/
|
|
324
|
+
export class Sandbox {
|
|
325
|
+
/** @type {ReturnType<typeof createCallbackHost>} */
|
|
326
|
+
#host;
|
|
327
|
+
/** @type {ReturnType<typeof createNet>} */
|
|
328
|
+
#net = createNet();
|
|
329
|
+
/** @type {number | undefined} */
|
|
330
|
+
#timeoutMs;
|
|
331
|
+
/** @type {Promise<any>} */
|
|
332
|
+
#instance;
|
|
333
|
+
/** The Worker of a `timeoutMs` sandbox, and why the sandbox was closed. */
|
|
334
|
+
#slot = { worker: null, closed: /** @type {Error | null} */ (null) };
|
|
335
|
+
/** The last name passed to setResultVariable(), re-applied after a restart. */
|
|
336
|
+
#resultVariable;
|
|
337
|
+
/** Set when a timeout restarts the Worker; reported by the next execution. */
|
|
338
|
+
#stateWasReset = false;
|
|
339
|
+
// The tail of the queue of calls into this instance. Each call starts only
|
|
340
|
+
// after the previous one settles, so executions never overlap: the journal
|
|
341
|
+
// and the streaming output decoders belong to the instance, and jco can't
|
|
342
|
+
// run two calls on one instance at once.
|
|
343
|
+
#queue = Promise.resolve();
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Create a sandbox. Instantiation starts right away and finishes in the
|
|
347
|
+
* background; the first call waits for it, and rejects if it failed.
|
|
348
|
+
*
|
|
349
|
+
* @param {SandboxOptions} [options]
|
|
350
|
+
*/
|
|
351
|
+
constructor(options = {}) {
|
|
352
|
+
const { timeoutMs } = options;
|
|
353
|
+
if (timeoutMs !== undefined && !(Number.isFinite(timeoutMs) && timeoutMs > 0)) {
|
|
354
|
+
throw new TypeError("timeoutMs must be a positive number of milliseconds");
|
|
355
|
+
}
|
|
356
|
+
this.#timeoutMs = timeoutMs;
|
|
357
|
+
/**
|
|
358
|
+
* This sandbox's own file tree (a copy of the module's `_fileTree`).
|
|
359
|
+
* Changes to it are visible to the sandbox's next filesystem access.
|
|
360
|
+
* Undefined for a sandbox with `timeoutMs`, whose files live in its Worker.
|
|
361
|
+
* @internal For the demo app and advanced integrations.
|
|
362
|
+
*/
|
|
363
|
+
this._fileTree = timeoutMs === undefined ? forkTree(_fileTree) : undefined;
|
|
364
|
+
if ("callbackHandler" in options) {
|
|
365
|
+
throw new TypeError(
|
|
366
|
+
"callbackHandler is gone: pass callbacks as an object of functions by name, " +
|
|
367
|
+
"e.g. { get_user: async ({ id }) => ... }",
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
this.#host = createCallbackHost(options);
|
|
371
|
+
if (options.netConfig !== undefined) this.#net.setNetConfig(options.netConfig);
|
|
372
|
+
if (timeoutMs !== undefined) _workerReaper.register(this, this.#slot, this.#slot);
|
|
373
|
+
this.#start();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#start() {
|
|
377
|
+
if (this.#timeoutMs === undefined) {
|
|
378
|
+
this.#instance = instantiateSandbox(this._fileTree, this.#host.imports, this.#net);
|
|
379
|
+
} else {
|
|
380
|
+
const slot = this.#slot;
|
|
381
|
+
const name = this.#resultVariable;
|
|
382
|
+
this.#instance = instantiateInWorker(this.#host, this.#net).then(async (worker) => {
|
|
383
|
+
slot.worker = worker;
|
|
384
|
+
if (slot.closed) worker.terminate(slot.closed);
|
|
385
|
+
// A restarted Worker starts with the default result variable.
|
|
386
|
+
else if (name !== undefined) await worker.setResultVariable(name);
|
|
387
|
+
return worker;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
// Surfaced by the first call instead.
|
|
391
|
+
this.#instance.catch(() => {});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Stop using this sandbox. Its open connections are closed, and every later
|
|
396
|
+
* call rejects. For a `timeoutMs` sandbox the Worker is terminated at once,
|
|
397
|
+
* so a call in flight rejects too; without `timeoutMs` a call in flight
|
|
398
|
+
* runs to the end. Calling it again does nothing.
|
|
399
|
+
*/
|
|
400
|
+
close() {
|
|
401
|
+
if (this.#slot.closed) return;
|
|
402
|
+
this.#slot.closed = new Error("sandbox is closed");
|
|
403
|
+
_workerReaper.unregister(this.#slot);
|
|
404
|
+
this.#slot.worker?.terminate(this.#slot.closed);
|
|
405
|
+
this.#net.closeConnections();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Replace this sandbox's network policy (`null` disables networking). It
|
|
410
|
+
* applies to connections opened after the call, and doesn't change the
|
|
411
|
+
* default set with the module-level {@link setNetConfig} or other sandboxes.
|
|
412
|
+
*
|
|
413
|
+
* Throws a TypeError if a network isn't a valid CIDR or `maxConnections`
|
|
414
|
+
* isn't a non-negative integer.
|
|
415
|
+
*
|
|
416
|
+
* @param {import("./shims/net.js").NetConfig | null} netConfig
|
|
417
|
+
*/
|
|
418
|
+
setNetConfig(netConfig) {
|
|
419
|
+
this.#net.setNetConfig(netConfig);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Run `fn` on the instance once every call queued before it has settled.
|
|
424
|
+
* @template T
|
|
425
|
+
* @param {(instance: any) => Promise<T>} fn
|
|
426
|
+
* @returns {Promise<T>}
|
|
427
|
+
*/
|
|
428
|
+
#serialized(fn) {
|
|
429
|
+
const run = this.#queue.then(async () => {
|
|
430
|
+
if (this.#slot.closed) throw this.#slot.closed;
|
|
431
|
+
try {
|
|
432
|
+
return await this.#call(fn);
|
|
433
|
+
} finally {
|
|
434
|
+
// Connections don't outlive the call that opened them.
|
|
435
|
+
this.#net.closeConnections();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
this.#queue = run.catch(() => {});
|
|
439
|
+
return run;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Run `fn` on the instance, under the deadline if there is one. On timeout,
|
|
444
|
+
* the Worker is terminated and a fresh one started for later calls.
|
|
445
|
+
* @template T
|
|
446
|
+
* @param {(instance: any) => Promise<T>} fn
|
|
447
|
+
* @returns {Promise<T>}
|
|
448
|
+
*/
|
|
449
|
+
async #call(fn) {
|
|
450
|
+
const instance = await this.#instance;
|
|
451
|
+
if (this.#timeoutMs === undefined) return fn(instance);
|
|
452
|
+
// Terminating the Worker rejects the call in flight with the TimeoutError,
|
|
453
|
+
// which executeWithJournal() then reports like any other failure.
|
|
454
|
+
const timer = setTimeout(() => {
|
|
455
|
+
instance.terminate(new TimeoutError(`execution timed out after ${this.#timeoutMs} ms`));
|
|
456
|
+
this.#stateWasReset = true;
|
|
457
|
+
this.#start();
|
|
458
|
+
}, this.#timeoutMs);
|
|
459
|
+
try {
|
|
460
|
+
return await fn(instance);
|
|
461
|
+
} finally {
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Run code, flushing streamed output when it finishes. Callers must hold the
|
|
468
|
+
* queue, so the flush can't cut into another execution's output.
|
|
469
|
+
* @param {any} instance
|
|
470
|
+
* @param {string} code
|
|
471
|
+
* @returns {Promise<ExecuteResult>}
|
|
472
|
+
*/
|
|
473
|
+
async #run(instance, code) {
|
|
474
|
+
const start = performance.now();
|
|
475
|
+
this.#host.takeInvocations();
|
|
476
|
+
const stats = () => ({
|
|
477
|
+
durationMs: performance.now() - start,
|
|
478
|
+
callbackInvocations: this.#host.takeInvocations(),
|
|
479
|
+
});
|
|
480
|
+
try {
|
|
481
|
+
// Frame the code with an empty preamble (see the guest's PREAMBLE_HEADER),
|
|
482
|
+
// so a leading header-like line in user code isn't misparsed.
|
|
483
|
+
const output = await instance.execute(`#eryx-preamble:0\n${code}`);
|
|
484
|
+
const result = _toResult(output, this.#stateWasReset, stats());
|
|
485
|
+
this.#stateWasReset = false;
|
|
486
|
+
return result;
|
|
487
|
+
} catch (e) {
|
|
488
|
+
// A failed run's payload is an execute-error record: surface its
|
|
489
|
+
// traceback as the message, with the output captured before it failed.
|
|
490
|
+
const payload = e?.payload;
|
|
491
|
+
if (payload && typeof payload.message === "string") {
|
|
492
|
+
// As eryx.run's client tells them apart: an exception in the code has
|
|
493
|
+
// its traceback as the message
|
|
494
|
+
const failed = payload.message.startsWith("Traceback (most recent call last)")
|
|
495
|
+
? Object.assign(new PythonExceptionError(payload.message, { cause: e }), {
|
|
496
|
+
stdout: _decoder.decode(payload.stdout),
|
|
497
|
+
stderr: _decoder.decode(payload.stderr),
|
|
498
|
+
})
|
|
499
|
+
: new ExecutionError(payload.message, { cause: e });
|
|
500
|
+
throw Object.assign(failed, stats());
|
|
501
|
+
}
|
|
502
|
+
throw e;
|
|
503
|
+
} finally {
|
|
504
|
+
this.#host.flushOutput();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Execute Python code in the sandbox.
|
|
510
|
+
*
|
|
511
|
+
* The code runs in the sandboxed Python interpreter. Output to stdout
|
|
512
|
+
* and stderr is captured and returned. Variables and imports persist
|
|
513
|
+
* across calls on the same Sandbox instance.
|
|
514
|
+
*
|
|
515
|
+
* Calls on one Sandbox run one at a time, in call order; an execute() made
|
|
516
|
+
* while another call on it is running waits for it to finish. Don't await
|
|
517
|
+
* execute() on the same Sandbox from inside its callback handler: it would
|
|
518
|
+
* wait for the execution that invoked the handler, which never finishes.
|
|
519
|
+
*
|
|
520
|
+
* @param {string} code - Python source code to execute
|
|
521
|
+
* @returns {Promise<ExecuteResult>} Captured stdout and stderr
|
|
522
|
+
* @throws {Error} If the Python code raises an unhandled exception
|
|
523
|
+
*
|
|
524
|
+
* @example
|
|
525
|
+
* const result = await sandbox.execute('print("hello")');
|
|
526
|
+
* console.log(result.stdout); // "hello\n"
|
|
527
|
+
*/
|
|
528
|
+
async execute(code) {
|
|
529
|
+
return this.#serialized((instance) => this.#run(instance, code));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Execute Python code, journaling callback results so a later run can replay
|
|
534
|
+
* them instead of re-invoking the callbacks.
|
|
535
|
+
*
|
|
536
|
+
* Callbacks matching an entry of `options.journal` (by name and canonical
|
|
537
|
+
* arguments, FIFO) return the recorded result without calling the handler;
|
|
538
|
+
* the first miss switches the rest of the run to live calls. A handler can
|
|
539
|
+
* throw SuspendCallback to halt execution; the outcome then has `suspended`
|
|
540
|
+
* set and a journal to resume from.
|
|
541
|
+
*
|
|
542
|
+
* Never throws for script failures: check `suspended`, then `error`. Like
|
|
543
|
+
* every call on this Sandbox, it waits for earlier calls to finish first.
|
|
544
|
+
*
|
|
545
|
+
* @param {string} code - Python source code to execute
|
|
546
|
+
* @param {{journal?: string}} [options] - A journal from a previous run to replay
|
|
547
|
+
* @returns {Promise<ReplayOutcome>}
|
|
548
|
+
*/
|
|
549
|
+
async executeWithJournal(code, options) {
|
|
550
|
+
return this.#serialized(async (instance) => {
|
|
551
|
+
this.#host.beginReplay(options?.journal);
|
|
552
|
+
let result;
|
|
553
|
+
let error;
|
|
554
|
+
try {
|
|
555
|
+
result = await this.#run(instance, code);
|
|
556
|
+
} catch (e) {
|
|
557
|
+
error = e;
|
|
558
|
+
}
|
|
559
|
+
return { result, error, ...this.#host.endReplay(code) };
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Set the name of the variable captured as the structured result.
|
|
565
|
+
*
|
|
566
|
+
* After each execute(), the variable with this name is read from the
|
|
567
|
+
* script's namespace, JSON-serialized, and returned as
|
|
568
|
+
* `ExecuteResult.result`. Defaults to "result". Takes effect for calls made
|
|
569
|
+
* after it.
|
|
570
|
+
*
|
|
571
|
+
* @param {string} name - The variable name to capture
|
|
572
|
+
* @returns {Promise<void>}
|
|
573
|
+
*/
|
|
574
|
+
async setResultVariable(name) {
|
|
575
|
+
await this.#serialized((instance) => instance.setResultVariable(name));
|
|
576
|
+
this.#resultVariable = name;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Capture a snapshot of the current Python session state.
|
|
581
|
+
*
|
|
582
|
+
* Returns serialized state (via pickle) that can be restored later
|
|
583
|
+
* with restoreState(). This captures all user-defined variables that can
|
|
584
|
+
* be serialized, and names the others in `skippedGlobals`.
|
|
585
|
+
*
|
|
586
|
+
* @returns {Promise<StateSnapshot>} Serialized Python state
|
|
587
|
+
* @throws {Error} If serialization fails
|
|
588
|
+
*/
|
|
589
|
+
async snapshotState() {
|
|
590
|
+
return this.#serialized((instance) => instance.snapshotState());
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Restore Python session state from a previously captured snapshot.
|
|
595
|
+
*
|
|
596
|
+
* After restore, subsequent execute() calls will have access to all
|
|
597
|
+
* variables that were present when the snapshot was taken.
|
|
598
|
+
*
|
|
599
|
+
* @param {StateSnapshot | Uint8Array} snapshot - A snapshot from
|
|
600
|
+
* snapshotState(), or its `data`
|
|
601
|
+
* @throws {Error} If deserialization fails
|
|
602
|
+
*/
|
|
603
|
+
async restoreState(snapshot) {
|
|
604
|
+
const data = snapshot instanceof Uint8Array ? snapshot : snapshot.data;
|
|
605
|
+
await this.#serialized((instance) => instance.restoreState(data));
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Clear all persistent state from the session.
|
|
610
|
+
*
|
|
611
|
+
* After clear, subsequent execute() calls will start with a fresh
|
|
612
|
+
* namespace (no user-defined variables from previous calls).
|
|
613
|
+
*/
|
|
614
|
+
async clearState() {
|
|
615
|
+
await this.#serialized((instance) => instance.clearState());
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// `using sandbox = new Sandbox()` closes it at the end of the scope, where the
|
|
620
|
+
// runtime has explicit resource management.
|
|
621
|
+
for (const symbol of [Symbol.dispose, Symbol.asyncDispose]) {
|
|
622
|
+
if (symbol) {
|
|
623
|
+
Object.defineProperty(Sandbox.prototype, symbol, {
|
|
624
|
+
value() {
|
|
625
|
+
this.close();
|
|
626
|
+
},
|
|
627
|
+
configurable: true,
|
|
628
|
+
writable: true,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** @type {Sandbox | undefined} */
|
|
634
|
+
let _defaultSandbox;
|
|
635
|
+
|
|
636
|
+
/** The sandbox behind the module-level helpers, created on first use. */
|
|
637
|
+
function _default() {
|
|
638
|
+
return (_defaultSandbox ??= new Sandbox());
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Set the default network policy (`null` disables networking, the initial
|
|
643
|
+
* default). Each Sandbox copies it when it is created, unless given its own
|
|
644
|
+
* `netConfig`; sandboxes that already exist keep their policy. It also
|
|
645
|
+
* applies to the default sandbox behind the module-level helpers.
|
|
646
|
+
*
|
|
647
|
+
* Throws a TypeError if a network isn't a valid CIDR or `maxConnections`
|
|
648
|
+
* isn't a non-negative integer.
|
|
649
|
+
*
|
|
650
|
+
* @param {import("./shims/net.js").NetConfig | null} netConfig
|
|
651
|
+
*/
|
|
652
|
+
export function setNetConfig(netConfig) {
|
|
653
|
+
setDefaultNetConfig(netConfig);
|
|
654
|
+
_defaultSandbox?.setNetConfig(netConfig);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Execute Python code in the default sandbox.
|
|
659
|
+
*
|
|
660
|
+
* This is a convenience function backed by one module-wide Sandbox, created
|
|
661
|
+
* on first use, so state from previous execute() calls persists. It shares
|
|
662
|
+
* nothing with Sandbox instances you create yourself; use one of those for
|
|
663
|
+
* isolated execution.
|
|
664
|
+
*
|
|
665
|
+
* @param {string} code - Python source code to execute
|
|
666
|
+
* @returns {Promise<ExecuteResult>} Captured stdout and stderr
|
|
667
|
+
* @throws {Error} If the Python code raises an unhandled exception
|
|
668
|
+
*
|
|
669
|
+
* @example
|
|
670
|
+
* const result = await execute('print("hello")');
|
|
671
|
+
* console.log(result.stdout); // "hello"
|
|
672
|
+
*/
|
|
673
|
+
export async function execute(code) {
|
|
674
|
+
return _default().execute(code);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Execute Python code in the default sandbox, journaling callback results.
|
|
679
|
+
* See {@link Sandbox#executeWithJournal}.
|
|
680
|
+
*
|
|
681
|
+
* @param {string} code - Python source code to execute
|
|
682
|
+
* @param {{journal?: string}} [options] - A journal from a previous run to replay
|
|
683
|
+
* @returns {Promise<ReplayOutcome>}
|
|
684
|
+
*/
|
|
685
|
+
export async function executeWithJournal(code, options) {
|
|
686
|
+
return _default().executeWithJournal(code, options);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Set the name of the variable captured as the structured result in the
|
|
691
|
+
* default sandbox. See {@link Sandbox#setResultVariable}.
|
|
692
|
+
*
|
|
693
|
+
* @param {string} name - The variable name to capture
|
|
694
|
+
* @returns {Promise<void>}
|
|
695
|
+
*/
|
|
696
|
+
export function setResultVariable(name) {
|
|
697
|
+
return _default().setResultVariable(name);
|
|
698
|
+
}
|