@johnhenry/andbox 0.3.0 → 0.3.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 +18 -1
- package/package.json +1 -1
- package/src/index.d.ts +36 -4
- package/src/sandbox.mjs +49 -20
- package/src/wasm-worker-source.mjs +1 -1
- package/src/worker-source.mjs +3 -3
package/README.md
CHANGED
|
@@ -529,7 +529,8 @@ Evaluates JavaScript code in the sandbox. The code is wrapped in an async IIFE -
|
|
|
529
529
|
|--------|------|-------------|
|
|
530
530
|
| `timeoutMs` | `number` | Override default timeout |
|
|
531
531
|
| `signal` | `AbortSignal` | Abort evaluation |
|
|
532
|
-
| `onConsole` | `(level, ...args) => void` | Per-call console handler |
|
|
532
|
+
| `onConsole` | `(level, ...args) => void` | Per-call console handler: gets this call's console output only |
|
|
533
|
+
| `consoleId` | `string \| number` | A name for this call's console output: every console handler gets it as `this.consoleId` |
|
|
533
534
|
|
|
534
535
|
**Inside sandbox code (Worker mode):**
|
|
535
536
|
|
|
@@ -537,6 +538,22 @@ Evaluates JavaScript code in the sandbox. The code is wrapped in an async IIFE -
|
|
|
537
538
|
- `sandboxImport(name)` -- Import a virtual module
|
|
538
539
|
- `console.log/warn/error/info` -- Forwarded to host `onConsole`
|
|
539
540
|
|
|
541
|
+
**Console attribution (0.3.1).** Calls on one sandbox may overlap, and each call's console output goes to that call's own `onConsole` while it is running, never to whichever call started last ([andbox#41](https://github.com/johnhenry/andbox/issues/41)). Output from a call that passed no `onConsole`, and output that arrives after a call settled (a timer it left behind), goes to the sandbox-level `onConsole`. Every handler is called with `this.consoleId`, the `consoleId` of the call that logged (`undefined` if it gave none), so late output can still be credited (use a `function`, not an arrow):
|
|
542
|
+
|
|
543
|
+
```js
|
|
544
|
+
const sb = await createSandbox({
|
|
545
|
+
onConsole(level, ...args) { log(this.consoleId ?? 'sandbox', level, args); },
|
|
546
|
+
});
|
|
547
|
+
await Promise.all([
|
|
548
|
+
sb.evaluate(codeA, { consoleId: 'pane-a' }),
|
|
549
|
+
sb.evaluate(codeB, { consoleId: 'pane-b' }),
|
|
550
|
+
]);
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
What it covers: the `console` andbox gives the evaluated code, including closures that keep it (a timer, a callback) after the call returns. It is attribution, not authentication: the runtime carries the id with each message, so code in the sandbox that holds another call's `console` logs as that call. Thread stdout/stderr (`node-worker`'s `captureStdio`) names no call and goes to the newest running call's handler, as before.
|
|
554
|
+
|
|
555
|
+
**Errors keep the sandbox's stack (0.3.1).** A rejected `evaluate()` has the error's `name` and `message` (and `code`, when set); `err.stack` is the host's own stack, and `err.sandboxStack` is the stack as the sandbox saw it, with the evaluated code's frames, line numbers and any `//# sourceURL=` names ([andbox#41](https://github.com/johnhenry/andbox/issues/41)). Line numbers count from andbox's wrapper, which puts three lines before your code in the `new Function`-based modes: in V8 (Chrome, Node) line 1 of your code is reported as line 4; other engines may count differently. It is `undefined` when the sandbox reported no stack.
|
|
556
|
+
|
|
540
557
|
### `sandbox.defineModule(name, source)`
|
|
541
558
|
|
|
542
559
|
Defines a virtual module that sandbox code can import via `sandboxImport(name)`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/andbox",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, timeouts, and an optional QuickJS-in-WebAssembly mode",
|
|
6
6
|
"main": "./src/index.mjs",
|
package/src/index.d.ts
CHANGED
|
@@ -517,8 +517,18 @@ export interface EvaluateOptions {
|
|
|
517
517
|
timeoutMs?: number;
|
|
518
518
|
/** AbortSignal to cancel the evaluation. */
|
|
519
519
|
signal?: AbortSignal;
|
|
520
|
-
/**
|
|
521
|
-
|
|
520
|
+
/**
|
|
521
|
+
* Console output handler for this evaluation: it gets this call's console
|
|
522
|
+
* output only, while the call is pending (andbox#41). Output from a call
|
|
523
|
+
* without one, or after the call settled, goes to the sandbox-level handler.
|
|
524
|
+
*/
|
|
525
|
+
onConsole?: (this: ConsoleContext, level: string, ...args: string[]) => void;
|
|
526
|
+
/**
|
|
527
|
+
* A name for this call's console output: every console handler (this call's
|
|
528
|
+
* or the sandbox-level one, for output after the call settled) is called
|
|
529
|
+
* with `this.consoleId` set to it. Attribution, not authentication.
|
|
530
|
+
*/
|
|
531
|
+
consoleId?: string | number;
|
|
522
532
|
/** `mode: 'wasm'` only: fuel for this call (overrides the sandbox option). */
|
|
523
533
|
fuel?: number;
|
|
524
534
|
/** `mode: 'wasm'` only: JS heap cap in bytes for this call. */
|
|
@@ -540,6 +550,25 @@ export interface CapabilityContext {
|
|
|
540
550
|
name: string;
|
|
541
551
|
}
|
|
542
552
|
|
|
553
|
+
/**
|
|
554
|
+
* `this` inside an `onConsole` handler (use a `function`, not an arrow):
|
|
555
|
+
* `consoleId` is the `consoleId` option of the `evaluate()` call whose code
|
|
556
|
+
* logged, or `undefined` (none given, or thread stdio).
|
|
557
|
+
*/
|
|
558
|
+
export interface ConsoleContext {
|
|
559
|
+
readonly consoleId: string | number | undefined;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* An error from a failed `evaluate()`. `sandboxStack` is the stack as the
|
|
564
|
+
* sandbox saw it (the evaluated code's frames, lines and `//# sourceURL=`
|
|
565
|
+
* names); `stack` is the host's own.
|
|
566
|
+
*/
|
|
567
|
+
export interface SandboxEvaluationError extends Error {
|
|
568
|
+
code?: string;
|
|
569
|
+
sandboxStack?: string;
|
|
570
|
+
}
|
|
571
|
+
|
|
543
572
|
/** Options for createSandbox(). */
|
|
544
573
|
export interface SandboxOptions {
|
|
545
574
|
/** Import map for module resolution inside the sandbox. */
|
|
@@ -552,8 +581,11 @@ export interface SandboxOptions {
|
|
|
552
581
|
baseURL?: string;
|
|
553
582
|
/** Rate limiting policy for capability calls. */
|
|
554
583
|
policy?: GatePolicy;
|
|
555
|
-
/**
|
|
556
|
-
|
|
584
|
+
/**
|
|
585
|
+
* Console output handler. Called when sandboxed code uses console.log/warn/error/etc.
|
|
586
|
+
* and no per-call `onConsole` is running for it; `this.consoleId` names the call.
|
|
587
|
+
*/
|
|
588
|
+
onConsole?: (this: ConsoleContext, level: string, ...args: string[]) => void;
|
|
557
589
|
/**
|
|
558
590
|
* Worker mode selection. Omitted/`'worker'` uses a Web Worker, or
|
|
559
591
|
* `node:worker_threads` automatically when run under Node with no global
|
package/src/sandbox.mjs
CHANGED
|
@@ -552,8 +552,28 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
552
552
|
? (name) => (bridgeHost.isGateName(name) ? undefined : gateLookup(name))
|
|
553
553
|
: gateLookup;
|
|
554
554
|
|
|
555
|
-
// Console
|
|
556
|
-
|
|
555
|
+
// Console routing (andbox#41). Every console message names the evaluate()
|
|
556
|
+
// call it came from (its `evalId`), so it goes to that call's own
|
|
557
|
+
// `onConsole` while the call is pending, never to whichever call started
|
|
558
|
+
// last; otherwise (no per-call handler, or output after the call settled)
|
|
559
|
+
// to the sandbox-level one. Handlers get `this.consoleId`: the call's
|
|
560
|
+
// `consoleId` option. Thread stdio (node-worker `captureStdio`) carries no
|
|
561
|
+
// call, so it goes to the newest pending call's handler, as before.
|
|
562
|
+
function deliverConsole(evalId, carriedConsoleId, level, args) {
|
|
563
|
+
const entry = evalId == null ? undefined : pending.get(evalId);
|
|
564
|
+
const handler = entry?.onConsole ?? onConsole;
|
|
565
|
+
if (!handler) return;
|
|
566
|
+
// A pending call's id is the host's own copy; output after the call
|
|
567
|
+
// settled has only the id the runtime carried with it.
|
|
568
|
+
const consoleId = entry ? entry.consoleId : carriedConsoleId;
|
|
569
|
+
handler.call(Object.freeze({ consoleId }), level, ...args);
|
|
570
|
+
}
|
|
571
|
+
function deliverStdio(stream, text) {
|
|
572
|
+
let entry;
|
|
573
|
+
for (const e of pending.values()) if (e.onConsole) entry = e;
|
|
574
|
+
const handler = entry?.onConsole ?? onConsole;
|
|
575
|
+
handler?.call(Object.freeze({ consoleId: entry?.consoleId }), stream, text);
|
|
576
|
+
}
|
|
557
577
|
|
|
558
578
|
// Track virtual modules for re-creation on restart
|
|
559
579
|
const virtualModules = new Map();
|
|
@@ -595,7 +615,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
595
615
|
worker = workerFactory(source);
|
|
596
616
|
openBridgeSession();
|
|
597
617
|
attachWorkerHandlers();
|
|
598
|
-
worker.onstdio =
|
|
618
|
+
worker.onstdio = deliverStdio;
|
|
599
619
|
return;
|
|
600
620
|
}
|
|
601
621
|
const blob = new Blob([source], { type: 'application/javascript' });
|
|
@@ -646,6 +666,10 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
646
666
|
const err = new Error(msg.error?.message || 'Evaluation failed');
|
|
647
667
|
err.name = msg.error?.name || 'Error';
|
|
648
668
|
if (msg.error?.code) err.code = msg.error.code;
|
|
669
|
+
// The stack as the sandbox saw it (andbox#41): its frames name
|
|
670
|
+
// the evaluated code (`//# sourceURL=` names included) and its
|
|
671
|
+
// line numbers, which the host-side `err.stack` cannot.
|
|
672
|
+
if (typeof msg.error?.stack === 'string') err.sandboxStack = msg.error.stack;
|
|
649
673
|
entry.reject(err);
|
|
650
674
|
}
|
|
651
675
|
}
|
|
@@ -658,9 +682,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
658
682
|
}
|
|
659
683
|
|
|
660
684
|
case 'console': {
|
|
661
|
-
|
|
662
|
-
activeConsoleHandler(msg.level, ...msg.args);
|
|
663
|
-
}
|
|
685
|
+
deliverConsole(msg.evalId, msg.consoleId, msg.level, Array.isArray(msg.args) ? msg.args : []);
|
|
664
686
|
break;
|
|
665
687
|
}
|
|
666
688
|
}
|
|
@@ -789,7 +811,11 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
789
811
|
* Evaluate JavaScript code in the sandbox.
|
|
790
812
|
*
|
|
791
813
|
* @param {string} code - JavaScript code to execute (wrapped in async IIFE).
|
|
792
|
-
* @param {{ timeoutMs?: number, signal?: AbortSignal, onConsole?: (level: string, ...args: string[]) => void }} [opts]
|
|
814
|
+
* @param {{ timeoutMs?: number, signal?: AbortSignal, onConsole?: (level: string, ...args: string[]) => void, consoleId?: string | number }} [opts]
|
|
815
|
+
* `onConsole` gets this call's console output only (andbox#41); every
|
|
816
|
+
* console handler is called with `this.consoleId` set to the `consoleId`
|
|
817
|
+
* of the call that logged, including output that arrives after the call
|
|
818
|
+
* settled (which goes to the sandbox-level `onConsole`).
|
|
793
819
|
* @returns {Promise<any>} The return value of the code.
|
|
794
820
|
*/
|
|
795
821
|
async function evaluate(code, opts = {}) {
|
|
@@ -797,6 +823,10 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
797
823
|
if (wasmConfig) {
|
|
798
824
|
for (const k of ['fuel', 'memoryBytes', 'stackBytes', 'deadlineMs']) checkLimit(k, opts[k]);
|
|
799
825
|
}
|
|
826
|
+
const { consoleId } = opts;
|
|
827
|
+
if (consoleId !== undefined && typeof consoleId !== 'string' && !(typeof consoleId === 'number' && Number.isFinite(consoleId))) {
|
|
828
|
+
throw new TypeError('evaluate(): consoleId must be a string or a finite number');
|
|
829
|
+
}
|
|
800
830
|
if (!worker || worker.dead) await restartWorker();
|
|
801
831
|
beginOp();
|
|
802
832
|
|
|
@@ -811,12 +841,6 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
811
841
|
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
|
|
812
842
|
const { promise, resolve, reject } = makeDeferred();
|
|
813
843
|
|
|
814
|
-
// Swap console handler for this evaluation if provided
|
|
815
|
-
const prevConsoleHandler = activeConsoleHandler;
|
|
816
|
-
if (opts.onConsole) {
|
|
817
|
-
activeConsoleHandler = opts.onConsole;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
844
|
// mode: 'wasm' -- limits travel with the call. The in-worker deadline
|
|
821
845
|
// (default: this call's timeoutMs) ends a busy loop gracefully; the
|
|
822
846
|
// host-side timer below becomes a hard-kill backstop a little later.
|
|
@@ -862,14 +886,19 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
862
886
|
}, { once: true });
|
|
863
887
|
}
|
|
864
888
|
|
|
865
|
-
pending.set(id, { resolve, reject, timer, nonce });
|
|
866
|
-
worker.postMessage({
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
889
|
+
pending.set(id, { resolve, reject, timer, nonce, onConsole: opts.onConsole || null, consoleId });
|
|
890
|
+
worker.postMessage({
|
|
891
|
+
type: 'evaluate',
|
|
892
|
+
id,
|
|
893
|
+
nonce,
|
|
894
|
+
code,
|
|
895
|
+
// The runtime carries it on this call's console messages, so output
|
|
896
|
+
// after the call settled is still attributed (andbox#41).
|
|
897
|
+
...(consoleId !== undefined ? { consoleId } : {}),
|
|
898
|
+
...(wasmLimits ? { limits: wasmLimits } : {}),
|
|
872
899
|
});
|
|
900
|
+
|
|
901
|
+
return promise.finally(endOp);
|
|
873
902
|
}
|
|
874
903
|
|
|
875
904
|
/**
|
|
@@ -319,7 +319,7 @@ function wasmWorkerMain() {
|
|
|
319
319
|
if (done) return;
|
|
320
320
|
let args;
|
|
321
321
|
try { args = JSON.parse(ctx.getString(argsH)); } catch { args = []; }
|
|
322
|
-
self.postMessage({ type: 'console', evalId: msg.id, level: ctx.getString(levelH), args });
|
|
322
|
+
self.postMessage({ type: 'console', evalId: msg.id, consoleId: msg.consoleId, level: ctx.getString(levelH), args });
|
|
323
323
|
}));
|
|
324
324
|
|
|
325
325
|
const nativeCall = own(ctx.newFunction('call', (nameH, argsH) => {
|
package/src/worker-source.mjs
CHANGED
|
@@ -319,7 +319,7 @@ const host = {
|
|
|
319
319
|
|
|
320
320
|
// ── Console Forwarding ──
|
|
321
321
|
const originalConsole = { ...console };
|
|
322
|
-
function makeForwardingConsole(evalId) {
|
|
322
|
+
function makeForwardingConsole(evalId, consoleId) {
|
|
323
323
|
return new Proxy(console, {
|
|
324
324
|
get(target, prop) {
|
|
325
325
|
if (['log', 'warn', 'error', 'info', 'debug'].includes(prop)) {
|
|
@@ -328,7 +328,7 @@ function makeForwardingConsole(evalId) {
|
|
|
328
328
|
try { return typeof a === 'object' ? JSON.stringify(a) : String(a); }
|
|
329
329
|
catch { return String(a); }
|
|
330
330
|
});
|
|
331
|
-
post({ type: 'console', evalId, level: prop, args: serialized });
|
|
331
|
+
post({ type: 'console', evalId, consoleId, level: prop, args: serialized });
|
|
332
332
|
};
|
|
333
333
|
}
|
|
334
334
|
return target[prop];
|
|
@@ -367,7 +367,7 @@ ${bridges ? ` if (msg.bridges) {
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
case 'evaluate': {
|
|
370
|
-
const fwdConsole = makeForwardingConsole(msg.id);
|
|
370
|
+
const fwdConsole = makeForwardingConsole(msg.id, msg.consoleId);
|
|
371
371
|
try {
|
|
372
372
|
// Wrap in async function for top-level await. The newlines before and
|
|
373
373
|
// after the user code matter: without the trailing one, code ending in
|