@johnhenry/andbox 0.1.3 → 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/README.md +183 -18
- package/package.json +9 -3
- package/src/bridge-client.mjs +476 -0
- package/src/bridge-host.mjs +870 -0
- package/src/bridges/chrome-ai.d.ts +53 -0
- package/src/bridges/chrome-ai.mjs +424 -0
- package/src/iframe-host.mjs +5 -4
- package/src/index.d.ts +187 -13
- package/src/index.mjs +1 -0
- package/src/network-policy.mjs +206 -21
- package/src/sandbox.mjs +72 -5
- package/src/worker-source.mjs +27 -6
package/src/sandbox.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import { makeDeferred, makeTimeoutError, makeAbortError } from './deferred.mjs';
|
|
|
18
18
|
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
|
-
import { createFetchCapability } from './network-policy.mjs';
|
|
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
|
|
|
@@ -313,12 +314,15 @@ async function createServiceWorkerSandbox(options = {}) {
|
|
|
313
314
|
* @property {string[]} [iframeSandbox] - mode: 'iframe': extra sandbox tokens ('allow-same-origin' needs dangerouslyAllowSameOrigin)
|
|
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
|
-
* @property {{
|
|
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.
|
|
@@ -357,6 +361,19 @@ export function createSandbox(options = {}) {
|
|
|
357
361
|
(mode === 'wasm' ? " The wasm engine has no fetch; expose a capability and call it with host.call()." : '')
|
|
358
362
|
);
|
|
359
363
|
}
|
|
364
|
+
// Refuse a bad `network` (no allowedHosts, andbox#43) before starting anything.
|
|
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);
|
|
360
377
|
if (mode === 'inline') return createInlineSandbox(options);
|
|
361
378
|
if (mode === 'data-uri') return createDataUriSandbox(options);
|
|
362
379
|
if (mode === 'service-worker') return createServiceWorkerSandbox(options);
|
|
@@ -449,6 +466,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
449
466
|
unref = false,
|
|
450
467
|
allowedImportHosts,
|
|
451
468
|
network,
|
|
469
|
+
bridges,
|
|
452
470
|
} = options;
|
|
453
471
|
|
|
454
472
|
// undefined = unset: remote imports allowed. An array (even empty) restricts.
|
|
@@ -515,8 +533,24 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
515
533
|
}
|
|
516
534
|
const networkFetch = network !== undefined;
|
|
517
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
|
+
|
|
518
548
|
// Gate capabilities with rate limits
|
|
519
|
-
const { lookup:
|
|
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;
|
|
520
554
|
|
|
521
555
|
// Console handler — mutable so evaluate() can swap per-call
|
|
522
556
|
let activeConsoleHandler = onConsole || null;
|
|
@@ -528,6 +562,8 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
528
562
|
let workerBlobURL = null;
|
|
529
563
|
// Lifetime of the current Worker; aborted when it is terminated.
|
|
530
564
|
let workerAbort = null;
|
|
565
|
+
// The current Worker's bridge session: its handles, streams and callbacks.
|
|
566
|
+
let bridgeSession = null;
|
|
531
567
|
|
|
532
568
|
// `unref`: the thread only keeps the host process alive while work is in
|
|
533
569
|
// flight (startup, evaluate, defineModule); idle, it lets the process exit.
|
|
@@ -552,9 +588,12 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
552
588
|
workerAbort = new AbortController();
|
|
553
589
|
const source = isWasm
|
|
554
590
|
? makeWasmWorkerSource()
|
|
555
|
-
: isIframe
|
|
591
|
+
: isIframe
|
|
592
|
+
? makeIframeRuntimeSource({ networkFetch, bridges: !!bridgeHost })
|
|
593
|
+
: makeWorkerSource({ networkFetch, bridges: !!bridgeHost });
|
|
556
594
|
if (workerFactory) {
|
|
557
595
|
worker = workerFactory(source);
|
|
596
|
+
openBridgeSession();
|
|
558
597
|
attachWorkerHandlers();
|
|
559
598
|
worker.onstdio = (stream, text) => activeConsoleHandler?.(stream, text);
|
|
560
599
|
return;
|
|
@@ -562,11 +601,29 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
562
601
|
const blob = new Blob([source], { type: 'application/javascript' });
|
|
563
602
|
workerBlobURL = URL.createObjectURL(blob);
|
|
564
603
|
worker = new Worker(workerBlobURL, { type: 'classic' });
|
|
604
|
+
openBridgeSession();
|
|
565
605
|
attachWorkerHandlers();
|
|
566
606
|
}
|
|
567
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
|
+
|
|
568
620
|
function attachWorkerHandlers() {
|
|
621
|
+
const session = bridgeSession;
|
|
569
622
|
worker.onmessage = ({ data: msg }) => {
|
|
623
|
+
if (session && typeof msg?.type === 'string' && msg.type.startsWith('bridge')) {
|
|
624
|
+
session.receive(msg);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
570
627
|
switch (msg.type) {
|
|
571
628
|
case 'configured':
|
|
572
629
|
case 'moduleDefined':
|
|
@@ -629,7 +686,9 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
629
686
|
const handler = ({ data }) => {
|
|
630
687
|
if (data.type === 'configured') {
|
|
631
688
|
worker.removeEventListener('message', handler);
|
|
632
|
-
if (data.error) {
|
|
689
|
+
if (data.error && !wasmConfig) {
|
|
690
|
+
reject(new Error(data.error.message));
|
|
691
|
+
} else if (data.error) {
|
|
633
692
|
const err = new Error(`Failed to load the WASM engine: ${data.error.message}`);
|
|
634
693
|
err.code = 'ERR_ANDBOX_ENGINE';
|
|
635
694
|
reject(err);
|
|
@@ -645,6 +704,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
645
704
|
baseURL,
|
|
646
705
|
allowedImportHosts: importHosts,
|
|
647
706
|
virtualModules: Object.fromEntries(virtualModules),
|
|
707
|
+
...(bridgeHost ? { bridges: bridgeHost.manifests } : {}),
|
|
648
708
|
...(wasmConfig ? { wasm: { ...wasmConfig.engine, memoryBytes: wasmConfig.limits.memoryBytes } } : {}),
|
|
649
709
|
});
|
|
650
710
|
try {
|
|
@@ -695,6 +755,12 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
695
755
|
workerAbort.abort(new Error('Sandbox worker terminated'));
|
|
696
756
|
workerAbort = null;
|
|
697
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
|
+
}
|
|
698
764
|
if (worker) {
|
|
699
765
|
worker.terminate();
|
|
700
766
|
worker = null;
|
|
@@ -858,6 +924,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
858
924
|
pendingEvaluations: pending.size,
|
|
859
925
|
virtualModules: [...virtualModules.keys()],
|
|
860
926
|
gate: gateStats(),
|
|
927
|
+
...(bridgeHost ? { bridges: bridgeHost.stats() } : {}),
|
|
861
928
|
...(wasmConfig ? { fuelUsed: wasmStats.fuelUsed, peakMemoryBytes: wasmStats.peakMemoryBytes, totalFuelUsed: wasmStats.totalFuelUsed } : {}),
|
|
862
929
|
};
|
|
863
930
|
}
|
package/src/worker-source.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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'));
|