@johnhenry/andbox 0.2.0 → 0.3.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/src/sandbox.mjs CHANGED
@@ -19,6 +19,7 @@ import { DEFAULT_TIMEOUT_MS } from './constants.mjs';
19
19
  import { isNodeRuntime, createNodeWorkerFactory } from './node-worker.mjs';
20
20
  import { normalizeIframeOptions, createIframeFactory, makeIframeRuntimeSource } from './iframe-host.mjs';
21
21
  import { createFetchCapability, validateNetworkOptions } from './network-policy.mjs';
22
+ import { normalizeBridges, createBridgeHost } from './bridge-host.mjs';
22
23
 
23
24
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
24
25
 
@@ -314,11 +315,14 @@ async function createServiceWorkerSandbox(options = {}) {
314
315
  * @property {boolean} [dangerouslyAllowSameOrigin] - mode: 'iframe': permit 'allow-same-origin' (removes the origin boundary)
315
316
  * @property {(iframe: HTMLIFrameElement) => void} [onFrame] - mode: 'iframe': called with every new frame before it is attached
316
317
  * @property {{ allowedHosts: string[] | '*' | ((url: URL) => boolean | Promise<boolean>), fetch?: Function, credentials?: RequestCredentials }} [network] - worker, node-worker and iframe modes: install a global `fetch` in the sandbox that goes through the host (andbox#39); `allowedHosts` is required (andbox#43)
318
+ * @property {Record<string, object>} [bridges] - worker, node-worker and iframe modes: one global per entry that proxies a host API (handles, streams, abort, callbacks, consent, budgets; andbox#46)
317
319
  */
318
320
 
319
321
  const SUPPORTED_MODES = ['worker', 'node-worker', 'wasm', 'iframe', 'inline', 'data-uri', 'service-worker'];
320
322
  /** Modes whose runtime can install the host-backed `fetch` (`network`, andbox#39). */
321
323
  const NETWORK_MODES = ['worker', 'node-worker', 'iframe'];
324
+ /** Modes whose runtime can install bridges (`bridges`, andbox#46). */
325
+ const BRIDGE_MODES = NETWORK_MODES;
322
326
 
323
327
  /**
324
328
  * Create a new sandboxed runtime.
@@ -359,6 +363,17 @@ export function createSandbox(options = {}) {
359
363
  }
360
364
  // Refuse a bad `network` (no allowedHosts, andbox#43) before starting anything.
361
365
  if (options.network !== undefined) validateNetworkOptions(options.network);
366
+ if (options.bridges !== undefined && !BRIDGE_MODES.includes(mode)) {
367
+ throw new Error(
368
+ `The bridges option applies to ${BRIDGE_MODES.map((m) => `mode: '${m}'`).join(', ')}, not mode: '${mode}'.` +
369
+ (mode === 'wasm'
370
+ ? ' The QuickJS guest has no ReadableStream, AbortSignal or EventTarget and only JSON crosses its boundary; ' +
371
+ 'expose capabilities and call them with host.call().'
372
+ : '')
373
+ );
374
+ }
375
+ // Refuse a bad bridge definition before starting anything.
376
+ if (options.bridges !== undefined) normalizeBridges(options.bridges);
362
377
  if (mode === 'inline') return createInlineSandbox(options);
363
378
  if (mode === 'data-uri') return createDataUriSandbox(options);
364
379
  if (mode === 'service-worker') return createServiceWorkerSandbox(options);
@@ -451,6 +466,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
451
466
  unref = false,
452
467
  allowedImportHosts,
453
468
  network,
469
+ bridges,
454
470
  } = options;
455
471
 
456
472
  // undefined = unset: remote imports allowed. An array (even empty) restricts.
@@ -517,8 +533,24 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
517
533
  }
518
534
  const networkFetch = network !== undefined;
519
535
 
536
+ // `bridges` (andbox#46): every bridge method is also an entry in the gate,
537
+ // so `policy` limits and counts it, but `host.call()` cannot reach it.
538
+ const bridgeHost = bridges !== undefined ? createBridgeHost(normalizeBridges(bridges)) : null;
539
+ if (bridgeHost) {
540
+ for (const name of Object.keys(bridgeHost.gateEntries)) {
541
+ if (Object.prototype.hasOwnProperty.call(gatedCapabilities, name)) {
542
+ throw new Error(`The capability '${name}' has the same name as a bridge method; rename the capability.`);
543
+ }
544
+ }
545
+ gatedCapabilities = { ...gatedCapabilities, ...bridgeHost.gateEntries };
546
+ }
547
+
520
548
  // Gate capabilities with rate limits
521
- const { lookup: lookupCapability, stats: gateStats } = gateCapabilities(gatedCapabilities, policy);
549
+ const { lookup: gateLookup, stats: gateStats } = gateCapabilities(gatedCapabilities, policy);
550
+ bridgeHost?.useGate(gateLookup);
551
+ const lookupCapability = bridgeHost
552
+ ? (name) => (bridgeHost.isGateName(name) ? undefined : gateLookup(name))
553
+ : gateLookup;
522
554
 
523
555
  // Console handler — mutable so evaluate() can swap per-call
524
556
  let activeConsoleHandler = onConsole || null;
@@ -530,6 +562,8 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
530
562
  let workerBlobURL = null;
531
563
  // Lifetime of the current Worker; aborted when it is terminated.
532
564
  let workerAbort = null;
565
+ // The current Worker's bridge session: its handles, streams and callbacks.
566
+ let bridgeSession = null;
533
567
 
534
568
  // `unref`: the thread only keeps the host process alive while work is in
535
569
  // flight (startup, evaluate, defineModule); idle, it lets the process exit.
@@ -554,9 +588,12 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
554
588
  workerAbort = new AbortController();
555
589
  const source = isWasm
556
590
  ? makeWasmWorkerSource()
557
- : isIframe ? makeIframeRuntimeSource({ networkFetch }) : makeWorkerSource({ networkFetch });
591
+ : isIframe
592
+ ? makeIframeRuntimeSource({ networkFetch, bridges: !!bridgeHost })
593
+ : makeWorkerSource({ networkFetch, bridges: !!bridgeHost });
558
594
  if (workerFactory) {
559
595
  worker = workerFactory(source);
596
+ openBridgeSession();
560
597
  attachWorkerHandlers();
561
598
  worker.onstdio = (stream, text) => activeConsoleHandler?.(stream, text);
562
599
  return;
@@ -564,11 +601,29 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
564
601
  const blob = new Blob([source], { type: 'application/javascript' });
565
602
  workerBlobURL = URL.createObjectURL(blob);
566
603
  worker = new Worker(workerBlobURL, { type: 'classic' });
604
+ openBridgeSession();
567
605
  attachWorkerHandlers();
568
606
  }
569
607
 
608
+ /** Bind a bridge session to the Worker just created (and only to it). */
609
+ function openBridgeSession() {
610
+ if (!bridgeHost) return;
611
+ const target = worker;
612
+ const signal = workerAbort.signal;
613
+ bridgeSession = bridgeHost.openSession((message) => {
614
+ // Throws DataCloneError for an uncloneable value; the session reports it.
615
+ if (worker !== target || signal.aborted) return;
616
+ target.postMessage(message);
617
+ });
618
+ }
619
+
570
620
  function attachWorkerHandlers() {
621
+ const session = bridgeSession;
571
622
  worker.onmessage = ({ data: msg }) => {
623
+ if (session && typeof msg?.type === 'string' && msg.type.startsWith('bridge')) {
624
+ session.receive(msg);
625
+ return;
626
+ }
572
627
  switch (msg.type) {
573
628
  case 'configured':
574
629
  case 'moduleDefined':
@@ -631,7 +686,9 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
631
686
  const handler = ({ data }) => {
632
687
  if (data.type === 'configured') {
633
688
  worker.removeEventListener('message', handler);
634
- if (data.error) {
689
+ if (data.error && !wasmConfig) {
690
+ reject(new Error(data.error.message));
691
+ } else if (data.error) {
635
692
  const err = new Error(`Failed to load the WASM engine: ${data.error.message}`);
636
693
  err.code = 'ERR_ANDBOX_ENGINE';
637
694
  reject(err);
@@ -647,6 +704,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
647
704
  baseURL,
648
705
  allowedImportHosts: importHosts,
649
706
  virtualModules: Object.fromEntries(virtualModules),
707
+ ...(bridgeHost ? { bridges: bridgeHost.manifests } : {}),
650
708
  ...(wasmConfig ? { wasm: { ...wasmConfig.engine, memoryBytes: wasmConfig.limits.memoryBytes } } : {}),
651
709
  });
652
710
  try {
@@ -697,6 +755,12 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
697
755
  workerAbort.abort(new Error('Sandbox worker terminated'));
698
756
  workerAbort = null;
699
757
  }
758
+ if (bridgeSession) {
759
+ // Destroy every host object the runtime held, cancel its streams and
760
+ // abort its in-flight bridge calls (andbox#46).
761
+ bridgeSession.close();
762
+ bridgeSession = null;
763
+ }
700
764
  if (worker) {
701
765
  worker.terminate();
702
766
  worker = null;
@@ -860,6 +924,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
860
924
  pendingEvaluations: pending.size,
861
925
  virtualModules: [...virtualModules.keys()],
862
926
  gate: gateStats(),
927
+ ...(bridgeHost ? { bridges: bridgeHost.stats() } : {}),
863
928
  ...(wasmConfig ? { fuelUsed: wasmStats.fuelUsed, peakMemoryBytes: wasmStats.peakMemoryBytes, totalFuelUsed: wasmStats.totalFuelUsed } : {}),
864
929
  };
865
930
  }
@@ -7,6 +7,8 @@
7
7
  * host capabilities.
8
8
  */
9
9
 
10
+ import { installBridges } from './bridge-client.mjs';
11
+
10
12
  /**
11
13
  * The in-sandbox half of `createSandbox({ network })` (andbox#39): installs a
12
14
  * global `fetch` that serializes the request, sends it to the host's `fetch`
@@ -134,14 +136,17 @@ function installNetworkFetch(callHost, getBaseURL) {
134
136
  * - `capabilityCall`: RPC request to host capability
135
137
  * - `console`: Forwarded console output
136
138
  *
137
- * @param {{ networkFetch?: boolean }} [options]
139
+ * @param {{ networkFetch?: boolean, bridges?: boolean }} [options]
138
140
  * `networkFetch` (default false) installs a global `fetch` that forwards
139
141
  * every request to the host's `fetch` capability (`createSandbox({ network })`,
140
142
  * andbox#39) instead of leaving `fetch` locked.
143
+ * `bridges` (default false) includes the bridge client
144
+ * (`createSandbox({ bridges })`, andbox#46); the globals themselves are
145
+ * built from the manifests the host sends with `configure`.
141
146
  * @returns {string} The Worker script source code.
142
147
  */
143
- export function makeWorkerSource({ networkFetch = false } = {}) {
144
- return makeRuntimeSource({ lockdown: true, networkFetch });
148
+ export function makeWorkerSource({ networkFetch = false, bridges = false } = {}) {
149
+ return makeRuntimeSource({ lockdown: true, networkFetch, bridges });
145
150
  }
146
151
 
147
152
  /**
@@ -162,7 +167,7 @@ export function makeWorkerSource({ networkFetch = false } = {}) {
162
167
  * the lockdown list stays removed.
163
168
  * @returns {string}
164
169
  */
165
- export function makeRuntimeSource({ lockdown = true, networkFetch = false } = {}) {
170
+ export function makeRuntimeSource({ lockdown = true, networkFetch = false, bridges = false } = {}) {
166
171
  const lockedGlobals = lockdown
167
172
  ? `[
168
173
  ${networkFetch ? '' : "'fetch', "}'XMLHttpRequest', 'WebSocket', 'WebSocketStream', 'WebTransport', 'EventSource',
@@ -204,6 +209,9 @@ ${networkFetch ? `// ── Host-backed fetch (andbox#39) ──
204
209
  // Replaces the global fetch: every request goes to the host's
205
210
  // gated \`fetch\` capability, which decides policy and credentials.
206
211
  (${installNetworkFetch.toString()})((name, args) => callCapability(name, args), () => baseURL);
212
+ ` : ''}${bridges ? `// ── Bridges (andbox#46) ──
213
+ // Globals that proxy host APIs; installed from the host's manifests on configure.
214
+ const bridgeClient = (${installBridges.toString()})(post);
207
215
  ` : ''}// Names shadowed lexically for evaluated code as well (covers environments
208
216
  // where a global could not be deleted).
209
217
  const SHADOWED = ${shadowed};
@@ -340,7 +348,15 @@ scope.onmessage = async ({ data: msg }) => {
340
348
  virtualModules.set(name, src);
341
349
  }
342
350
  }
343
- post({ type: 'configured' });
351
+ ${bridges ? ` if (msg.bridges) {
352
+ try {
353
+ bridgeClient.install(msg.bridges);
354
+ } catch (e) {
355
+ post({ type: 'configured', error: { message: 'Failed to install bridges: ' + (e && e.message ? e.message : e) } });
356
+ break;
357
+ }
358
+ }
359
+ ` : ''} post({ type: 'configured' });
344
360
  break;
345
361
  }
346
362
 
@@ -388,7 +404,12 @@ scope.onmessage = async ({ data: msg }) => {
388
404
  break;
389
405
  }
390
406
 
391
- case 'dispose': {
407
+ ${bridges ? ` default: {
408
+ if (typeof msg.type === 'string' && msg.type.startsWith('bridge')) bridgeClient.receive(msg);
409
+ break;
410
+ }
411
+
412
+ ` : ''} case 'dispose': {
392
413
  // Reject all pending RPCs
393
414
  for (const [id, { reject }] of pendingRpc) {
394
415
  reject(new Error('Sandbox disposed'));