@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 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.0",
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
- /** Console output handler for this evaluation (overrides sandbox-level handler). */
521
- onConsole?: (level: string, ...args: string[]) => void;
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
- /** Console output handler. Called when sandboxed code uses console.log/warn/error/etc. */
556
- onConsole?: (level: string, ...args: string[]) => void;
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 handler — mutable so evaluate() can swap per-call
556
- let activeConsoleHandler = onConsole || null;
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 = (stream, text) => activeConsoleHandler?.(stream, text);
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
- if (activeConsoleHandler) {
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({ type: 'evaluate', id, nonce, code, ...(wasmLimits ? { limits: wasmLimits } : {}) });
867
-
868
- // Restore console handler when evaluation completes
869
- return promise.finally(() => {
870
- if (opts.onConsole) activeConsoleHandler = prevConsoleHandler;
871
- endOp();
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) => {
@@ -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