@johnhenry/andbox 0.2.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 +166 -1
- 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 -5
- package/src/index.mjs +1 -0
- package/src/sandbox.mjs +118 -24
- package/src/wasm-worker-source.mjs +1 -1
- package/src/worker-source.mjs +30 -9
package/src/index.d.ts
CHANGED
|
@@ -295,6 +295,141 @@ export interface SandboxNetworkOptions {
|
|
|
295
295
|
credentials?: RequestCredentials;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
// ── bridges (createSandbox({ bridges }), andbox#46) ──
|
|
299
|
+
|
|
300
|
+
/** Per-sandbox limits of one bridge. 0 = unlimited. */
|
|
301
|
+
export interface BridgeLimits {
|
|
302
|
+
/** Live handles at a time. Default 32 (chromeAI: 8). */
|
|
303
|
+
maxHandles?: number;
|
|
304
|
+
/** Open streams at a time. Default 8 (chromeAI: 4). */
|
|
305
|
+
maxStreams?: number;
|
|
306
|
+
/** Approximate size of one result or stream chunk (strings as UTF-8, binary by byteLength). Default 0. */
|
|
307
|
+
maxResultBytes?: number;
|
|
308
|
+
/** Wall-clock time for one call; for a stream method, until the stream ends. Default 0. */
|
|
309
|
+
timeoutMs?: number;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Defaults for {@link BridgeLimits}. */
|
|
313
|
+
export declare const DEFAULT_BRIDGE_LIMITS: Readonly<Required<BridgeLimits>>;
|
|
314
|
+
|
|
315
|
+
/** An opaque reference to a host object, from `ctx.handle()`. Return it (or put it in a result). */
|
|
316
|
+
export interface BridgeHandleRef<T = unknown> {
|
|
317
|
+
readonly type: string;
|
|
318
|
+
readonly target: T;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** What every bridge method receives as its first argument. */
|
|
322
|
+
export interface BridgeContext<State = any, Target = any> {
|
|
323
|
+
/** The bridge's name (its global in the sandbox). */
|
|
324
|
+
bridge: string;
|
|
325
|
+
/** `'chat.create'` for an api method, `'Session.ask'` for a handle method. */
|
|
326
|
+
method: string;
|
|
327
|
+
/**
|
|
328
|
+
* Aborts when the sandbox's AbortSignal for this call fires, the call times
|
|
329
|
+
* out (`limits.timeoutMs`), its handle is destroyed, the stream is
|
|
330
|
+
* cancelled, or the sandbox's Worker/frame is terminated. Pass it on.
|
|
331
|
+
*/
|
|
332
|
+
signal: AbortSignal;
|
|
333
|
+
/** Per-sandbox state from `createState()` (lives as long as the sandbox, across restarts). */
|
|
334
|
+
state: State;
|
|
335
|
+
/** Handle methods: the host object the sandbox's handle refers to. */
|
|
336
|
+
target: Target;
|
|
337
|
+
/** Wrap a host object so the sandbox gets a handle to it (type: a key of `handles`). */
|
|
338
|
+
handle<T>(type: string, target: T): BridgeHandleRef<T>;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* A bridge method. Arguments arrive by structured clone, with sandbox
|
|
343
|
+
* functions replaced by async proxies (reverse calls into the sandbox),
|
|
344
|
+
* AbortSignals by `ctx.signal`, and handles by their host objects.
|
|
345
|
+
*/
|
|
346
|
+
export type BridgeMethodFn<State = any, Target = any> = (ctx: BridgeContext<State, Target>, ...args: any[]) => unknown;
|
|
347
|
+
|
|
348
|
+
/** The activation a method needs: `true`/`'transient'` (a gesture in the last few seconds) or `'sticky'` (any gesture since load). */
|
|
349
|
+
export type BridgeActivation = boolean | 'transient' | 'sticky';
|
|
350
|
+
|
|
351
|
+
export interface BridgeMethodSpec<State = any, Target = any> {
|
|
352
|
+
call: BridgeMethodFn<State, Target>;
|
|
353
|
+
/** Returns a ReadableStream / (async) iterable; the sandbox gets a ReadableStream at once. */
|
|
354
|
+
stream?: boolean;
|
|
355
|
+
/** Returns a handle: `limits.maxHandles` is checked before the method runs. */
|
|
356
|
+
handle?: boolean;
|
|
357
|
+
/** Needs page user activation; a function decides per call (e.g. only when a download is needed). */
|
|
358
|
+
requiresUserActivation?: BridgeActivation | ((ctx: BridgeContext<State, Target>, ...args: any[]) => BridgeActivation | Promise<BridgeActivation>);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export type BridgeMethod<State = any, Target = any> = BridgeMethodFn<State, Target> | BridgeMethodSpec<State, Target>;
|
|
362
|
+
|
|
363
|
+
/** A tree of namespaces and methods: `{ chat: { create, list } }` becomes `svc.chat.create()`. */
|
|
364
|
+
export interface BridgeApi<State = any> {
|
|
365
|
+
[name: string]: BridgeMethod<State> | BridgeApi<State>;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A host object type the sandbox holds handles to. */
|
|
369
|
+
export interface BridgeHandleType<State = any, Target = any> {
|
|
370
|
+
/** Methods callable on the sandbox's handle; `ctx.target` is the host object. */
|
|
371
|
+
methods?: Record<string, BridgeMethod<State, Target>>;
|
|
372
|
+
/** Properties copied to the sandbox (primitives and arrays of primitives) with the handle and after every call. */
|
|
373
|
+
props?: string[];
|
|
374
|
+
/** Release the host object: on handle.destroy(), and for every live handle when the sandbox is disposed, restarted or killed. */
|
|
375
|
+
destroy?: (target: Target) => void | Promise<void>;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** What `onRequest` sees for every bridge method call (not for destroy/cancel/abort). */
|
|
379
|
+
export interface BridgeRequest {
|
|
380
|
+
bridge: string;
|
|
381
|
+
/** `'chat.create'` or `'Session.ask'`; the gate name is `${bridge}.${method}`. */
|
|
382
|
+
method: string;
|
|
383
|
+
/** Decoded arguments (sandbox functions are proxies; signals are the call's signal). */
|
|
384
|
+
args: unknown[];
|
|
385
|
+
handle: { id: string; type: string } | null;
|
|
386
|
+
/** The activation the method needs for this call, or false. */
|
|
387
|
+
requiresUserActivation: false | 'transient' | 'sticky';
|
|
388
|
+
/** The page's `navigator.userActivation` now; null where the platform has none (Node). */
|
|
389
|
+
userActivation: { isActive: boolean; hasBeenActive: boolean } | null;
|
|
390
|
+
/** Aborts when the call does (the sandbox gave up, timed out or was terminated): close your consent UI. */
|
|
391
|
+
signal: AbortSignal;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface BridgeDefinition<State = any> {
|
|
395
|
+
/** Methods and namespaces of the sandbox global. */
|
|
396
|
+
api?: BridgeApi<State>;
|
|
397
|
+
/** Host object types, by name. A handle type cannot share a name with an api member. */
|
|
398
|
+
handles?: Record<string, BridgeHandleType<State>>;
|
|
399
|
+
limits?: BridgeLimits;
|
|
400
|
+
/**
|
|
401
|
+
* Consent hook, asked on the host before every method call. Only `true`
|
|
402
|
+
* (or a promise of it) allows; anything else, or a throw, rejects the call
|
|
403
|
+
* in the sandbox with `NotAllowedError`. It may wait for the user.
|
|
404
|
+
*/
|
|
405
|
+
onRequest?: (request: BridgeRequest) => boolean | Promise<boolean>;
|
|
406
|
+
/** Per-sandbox state (`ctx.state`), created once per sandbox. */
|
|
407
|
+
createState?: () => State;
|
|
408
|
+
/** Extra fields for `sandbox.stats().bridges[name]`. */
|
|
409
|
+
stats?: (state: State) => Record<string, unknown>;
|
|
410
|
+
/**
|
|
411
|
+
* Sandbox-side adapter: a self-contained function (stringified; no outside
|
|
412
|
+
* references) called in the sandbox with the generated global and
|
|
413
|
+
* `clientOptions`; returns the global to install.
|
|
414
|
+
*/
|
|
415
|
+
client?: ((api: any, options: any) => any) | string;
|
|
416
|
+
/** JSON passed to `client` in the sandbox. */
|
|
417
|
+
clientOptions?: unknown;
|
|
418
|
+
/** Extra sandbox globals aliasing paths of the api: `{ LanguageModel: 'languageModel' }`. */
|
|
419
|
+
globals?: Record<string, string>;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Check a bridge definition (throws a TypeError) and return it unchanged. */
|
|
423
|
+
export declare function defineBridge<State = any>(definition: BridgeDefinition<State>): BridgeDefinition<State>;
|
|
424
|
+
|
|
425
|
+
/** `sandbox.stats().bridges[name]`. */
|
|
426
|
+
export interface BridgeStats {
|
|
427
|
+
handles: number;
|
|
428
|
+
streams: number;
|
|
429
|
+
pendingCalls: number;
|
|
430
|
+
[extra: string]: unknown;
|
|
431
|
+
}
|
|
432
|
+
|
|
298
433
|
// ── stdio ──
|
|
299
434
|
|
|
300
435
|
/** An async iterable stdio stream with push/end controls. */
|
|
@@ -325,9 +460,11 @@ export declare function createStdio(): StdioStream;
|
|
|
325
460
|
* @param options.networkFetch Install the host-backed global `fetch` shim
|
|
326
461
|
* (what `createSandbox({ network })` uses). It calls the host's `fetch`
|
|
327
462
|
* capability. Default false: `fetch` is removed like the other network globals.
|
|
463
|
+
* @param options.bridges Include the bridge client (`createSandbox({ bridges })`);
|
|
464
|
+
* the globals are built from the manifests sent with `configure`. Default false.
|
|
328
465
|
* @returns The complete Worker script source code as a string.
|
|
329
466
|
*/
|
|
330
|
-
export declare function makeWorkerSource(options?: { networkFetch?: boolean }): string;
|
|
467
|
+
export declare function makeWorkerSource(options?: { networkFetch?: boolean; bridges?: boolean }): string;
|
|
331
468
|
|
|
332
469
|
// ── service-worker-source ──
|
|
333
470
|
|
|
@@ -380,8 +517,18 @@ export interface EvaluateOptions {
|
|
|
380
517
|
timeoutMs?: number;
|
|
381
518
|
/** AbortSignal to cancel the evaluation. */
|
|
382
519
|
signal?: AbortSignal;
|
|
383
|
-
/**
|
|
384
|
-
|
|
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;
|
|
385
532
|
/** `mode: 'wasm'` only: fuel for this call (overrides the sandbox option). */
|
|
386
533
|
fuel?: number;
|
|
387
534
|
/** `mode: 'wasm'` only: JS heap cap in bytes for this call. */
|
|
@@ -403,6 +550,25 @@ export interface CapabilityContext {
|
|
|
403
550
|
name: string;
|
|
404
551
|
}
|
|
405
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
|
+
|
|
406
572
|
/** Options for createSandbox(). */
|
|
407
573
|
export interface SandboxOptions {
|
|
408
574
|
/** Import map for module resolution inside the sandbox. */
|
|
@@ -415,8 +581,11 @@ export interface SandboxOptions {
|
|
|
415
581
|
baseURL?: string;
|
|
416
582
|
/** Rate limiting policy for capability calls. */
|
|
417
583
|
policy?: GatePolicy;
|
|
418
|
-
/**
|
|
419
|
-
|
|
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;
|
|
420
589
|
/**
|
|
421
590
|
* Worker mode selection. Omitted/`'worker'` uses a Web Worker, or
|
|
422
591
|
* `node:worker_threads` automatically when run under Node with no global
|
|
@@ -505,6 +674,17 @@ export interface SandboxOptions {
|
|
|
505
674
|
* capability named `fetch`.
|
|
506
675
|
*/
|
|
507
676
|
network?: SandboxNetworkOptions;
|
|
677
|
+
/**
|
|
678
|
+
* `worker`, `node-worker` and `iframe` modes (throws in the others): one
|
|
679
|
+
* sandbox global per entry that proxies a host API (andbox#46). Host
|
|
680
|
+
* objects never cross: the sandbox holds opaque handles, gets structured
|
|
681
|
+
* clones, pulls streams chunk by chunk, and its functions run in the
|
|
682
|
+
* sandbox when the host calls them. Every method call goes through the
|
|
683
|
+
* capability gate as `${bridge}.${method}` (so `policy` limits it) and
|
|
684
|
+
* through the bridge's `onRequest`; every handle is destroyed when the
|
|
685
|
+
* sandbox is disposed, restarted or killed. See `@johnhenry/andbox/bridges/chrome-ai`.
|
|
686
|
+
*/
|
|
687
|
+
bridges?: Record<string, BridgeDefinition>;
|
|
508
688
|
}
|
|
509
689
|
|
|
510
690
|
/** Options for the built-in Node worker_threads mode (`nodeWorker`). */
|
|
@@ -557,6 +737,8 @@ export interface SandboxStats {
|
|
|
557
737
|
pendingEvaluations: number;
|
|
558
738
|
virtualModules: string[];
|
|
559
739
|
gate: GateStatsResult;
|
|
740
|
+
/** With `bridges`: live handles/streams/calls per bridge, plus the bridge's own `stats()`. */
|
|
741
|
+
bridges?: Record<string, BridgeStats>;
|
|
560
742
|
/** `mode: 'wasm'` only: interrupt polls used by the most recent evaluate(). */
|
|
561
743
|
fuelUsed?: number;
|
|
562
744
|
/** `mode: 'wasm'` only: peak sampled JS heap bytes seen so far. */
|
package/src/index.mjs
CHANGED
|
@@ -10,6 +10,7 @@ export { createVirtualModuleRegistry } from './virtual-module-registry.mjs';
|
|
|
10
10
|
export { gateCapabilities } from './capability-gate.mjs';
|
|
11
11
|
export { createStdio } from './stdio.mjs';
|
|
12
12
|
export { createNetworkFetch } from './network-policy.mjs';
|
|
13
|
+
export { defineBridge, DEFAULT_BRIDGE_LIMITS } from './bridge-host.mjs';
|
|
13
14
|
export { makeDeferred, makeAbortError, makeTimeoutError } from './deferred.mjs';
|
|
14
15
|
export { DEFAULT_TIMEOUT_MS, DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from './constants.mjs';
|
|
15
16
|
export { makeWorkerSource } from './worker-source.mjs';
|
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,11 +533,47 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
517
533
|
}
|
|
518
534
|
const networkFetch = network !== undefined;
|
|
519
535
|
|
|
520
|
-
//
|
|
521
|
-
|
|
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
|
+
}
|
|
522
547
|
|
|
523
|
-
//
|
|
524
|
-
|
|
548
|
+
// Gate capabilities with rate limits
|
|
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;
|
|
554
|
+
|
|
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
|
+
}
|
|
525
577
|
|
|
526
578
|
// Track virtual modules for re-creation on restart
|
|
527
579
|
const virtualModules = new Map();
|
|
@@ -530,6 +582,8 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
530
582
|
let workerBlobURL = null;
|
|
531
583
|
// Lifetime of the current Worker; aborted when it is terminated.
|
|
532
584
|
let workerAbort = null;
|
|
585
|
+
// The current Worker's bridge session: its handles, streams and callbacks.
|
|
586
|
+
let bridgeSession = null;
|
|
533
587
|
|
|
534
588
|
// `unref`: the thread only keeps the host process alive while work is in
|
|
535
589
|
// flight (startup, evaluate, defineModule); idle, it lets the process exit.
|
|
@@ -554,21 +608,42 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
554
608
|
workerAbort = new AbortController();
|
|
555
609
|
const source = isWasm
|
|
556
610
|
? makeWasmWorkerSource()
|
|
557
|
-
: isIframe
|
|
611
|
+
: isIframe
|
|
612
|
+
? makeIframeRuntimeSource({ networkFetch, bridges: !!bridgeHost })
|
|
613
|
+
: makeWorkerSource({ networkFetch, bridges: !!bridgeHost });
|
|
558
614
|
if (workerFactory) {
|
|
559
615
|
worker = workerFactory(source);
|
|
616
|
+
openBridgeSession();
|
|
560
617
|
attachWorkerHandlers();
|
|
561
|
-
worker.onstdio =
|
|
618
|
+
worker.onstdio = deliverStdio;
|
|
562
619
|
return;
|
|
563
620
|
}
|
|
564
621
|
const blob = new Blob([source], { type: 'application/javascript' });
|
|
565
622
|
workerBlobURL = URL.createObjectURL(blob);
|
|
566
623
|
worker = new Worker(workerBlobURL, { type: 'classic' });
|
|
624
|
+
openBridgeSession();
|
|
567
625
|
attachWorkerHandlers();
|
|
568
626
|
}
|
|
569
627
|
|
|
628
|
+
/** Bind a bridge session to the Worker just created (and only to it). */
|
|
629
|
+
function openBridgeSession() {
|
|
630
|
+
if (!bridgeHost) return;
|
|
631
|
+
const target = worker;
|
|
632
|
+
const signal = workerAbort.signal;
|
|
633
|
+
bridgeSession = bridgeHost.openSession((message) => {
|
|
634
|
+
// Throws DataCloneError for an uncloneable value; the session reports it.
|
|
635
|
+
if (worker !== target || signal.aborted) return;
|
|
636
|
+
target.postMessage(message);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
570
640
|
function attachWorkerHandlers() {
|
|
641
|
+
const session = bridgeSession;
|
|
571
642
|
worker.onmessage = ({ data: msg }) => {
|
|
643
|
+
if (session && typeof msg?.type === 'string' && msg.type.startsWith('bridge')) {
|
|
644
|
+
session.receive(msg);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
572
647
|
switch (msg.type) {
|
|
573
648
|
case 'configured':
|
|
574
649
|
case 'moduleDefined':
|
|
@@ -591,6 +666,10 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
591
666
|
const err = new Error(msg.error?.message || 'Evaluation failed');
|
|
592
667
|
err.name = msg.error?.name || 'Error';
|
|
593
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;
|
|
594
673
|
entry.reject(err);
|
|
595
674
|
}
|
|
596
675
|
}
|
|
@@ -603,9 +682,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
603
682
|
}
|
|
604
683
|
|
|
605
684
|
case 'console': {
|
|
606
|
-
|
|
607
|
-
activeConsoleHandler(msg.level, ...msg.args);
|
|
608
|
-
}
|
|
685
|
+
deliverConsole(msg.evalId, msg.consoleId, msg.level, Array.isArray(msg.args) ? msg.args : []);
|
|
609
686
|
break;
|
|
610
687
|
}
|
|
611
688
|
}
|
|
@@ -631,7 +708,9 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
631
708
|
const handler = ({ data }) => {
|
|
632
709
|
if (data.type === 'configured') {
|
|
633
710
|
worker.removeEventListener('message', handler);
|
|
634
|
-
if (data.error) {
|
|
711
|
+
if (data.error && !wasmConfig) {
|
|
712
|
+
reject(new Error(data.error.message));
|
|
713
|
+
} else if (data.error) {
|
|
635
714
|
const err = new Error(`Failed to load the WASM engine: ${data.error.message}`);
|
|
636
715
|
err.code = 'ERR_ANDBOX_ENGINE';
|
|
637
716
|
reject(err);
|
|
@@ -647,6 +726,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
647
726
|
baseURL,
|
|
648
727
|
allowedImportHosts: importHosts,
|
|
649
728
|
virtualModules: Object.fromEntries(virtualModules),
|
|
729
|
+
...(bridgeHost ? { bridges: bridgeHost.manifests } : {}),
|
|
650
730
|
...(wasmConfig ? { wasm: { ...wasmConfig.engine, memoryBytes: wasmConfig.limits.memoryBytes } } : {}),
|
|
651
731
|
});
|
|
652
732
|
try {
|
|
@@ -697,6 +777,12 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
697
777
|
workerAbort.abort(new Error('Sandbox worker terminated'));
|
|
698
778
|
workerAbort = null;
|
|
699
779
|
}
|
|
780
|
+
if (bridgeSession) {
|
|
781
|
+
// Destroy every host object the runtime held, cancel its streams and
|
|
782
|
+
// abort its in-flight bridge calls (andbox#46).
|
|
783
|
+
bridgeSession.close();
|
|
784
|
+
bridgeSession = null;
|
|
785
|
+
}
|
|
700
786
|
if (worker) {
|
|
701
787
|
worker.terminate();
|
|
702
788
|
worker = null;
|
|
@@ -725,7 +811,11 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
725
811
|
* Evaluate JavaScript code in the sandbox.
|
|
726
812
|
*
|
|
727
813
|
* @param {string} code - JavaScript code to execute (wrapped in async IIFE).
|
|
728
|
-
* @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`).
|
|
729
819
|
* @returns {Promise<any>} The return value of the code.
|
|
730
820
|
*/
|
|
731
821
|
async function evaluate(code, opts = {}) {
|
|
@@ -733,6 +823,10 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
733
823
|
if (wasmConfig) {
|
|
734
824
|
for (const k of ['fuel', 'memoryBytes', 'stackBytes', 'deadlineMs']) checkLimit(k, opts[k]);
|
|
735
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
|
+
}
|
|
736
830
|
if (!worker || worker.dead) await restartWorker();
|
|
737
831
|
beginOp();
|
|
738
832
|
|
|
@@ -747,12 +841,6 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
747
841
|
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
|
|
748
842
|
const { promise, resolve, reject } = makeDeferred();
|
|
749
843
|
|
|
750
|
-
// Swap console handler for this evaluation if provided
|
|
751
|
-
const prevConsoleHandler = activeConsoleHandler;
|
|
752
|
-
if (opts.onConsole) {
|
|
753
|
-
activeConsoleHandler = opts.onConsole;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
844
|
// mode: 'wasm' -- limits travel with the call. The in-worker deadline
|
|
757
845
|
// (default: this call's timeoutMs) ends a busy loop gracefully; the
|
|
758
846
|
// host-side timer below becomes a hard-kill backstop a little later.
|
|
@@ -798,14 +886,19 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
798
886
|
}, { once: true });
|
|
799
887
|
}
|
|
800
888
|
|
|
801
|
-
pending.set(id, { resolve, reject, timer, nonce });
|
|
802
|
-
worker.postMessage({
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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 } : {}),
|
|
808
899
|
});
|
|
900
|
+
|
|
901
|
+
return promise.finally(endOp);
|
|
809
902
|
}
|
|
810
903
|
|
|
811
904
|
/**
|
|
@@ -860,6 +953,7 @@ async function createWorkerSandbox(options = {}, kind = 'worker') {
|
|
|
860
953
|
pendingEvaluations: pending.size,
|
|
861
954
|
virtualModules: [...virtualModules.keys()],
|
|
862
955
|
gate: gateStats(),
|
|
956
|
+
...(bridgeHost ? { bridges: bridgeHost.stats() } : {}),
|
|
863
957
|
...(wasmConfig ? { fuelUsed: wasmStats.fuelUsed, peakMemoryBytes: wasmStats.peakMemoryBytes, totalFuelUsed: wasmStats.totalFuelUsed } : {}),
|
|
864
958
|
};
|
|
865
959
|
}
|
|
@@ -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
|
@@ -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};
|
|
@@ -311,7 +319,7 @@ const host = {
|
|
|
311
319
|
|
|
312
320
|
// ── Console Forwarding ──
|
|
313
321
|
const originalConsole = { ...console };
|
|
314
|
-
function makeForwardingConsole(evalId) {
|
|
322
|
+
function makeForwardingConsole(evalId, consoleId) {
|
|
315
323
|
return new Proxy(console, {
|
|
316
324
|
get(target, prop) {
|
|
317
325
|
if (['log', 'warn', 'error', 'info', 'debug'].includes(prop)) {
|
|
@@ -320,7 +328,7 @@ function makeForwardingConsole(evalId) {
|
|
|
320
328
|
try { return typeof a === 'object' ? JSON.stringify(a) : String(a); }
|
|
321
329
|
catch { return String(a); }
|
|
322
330
|
});
|
|
323
|
-
post({ type: 'console', evalId, level: prop, args: serialized });
|
|
331
|
+
post({ type: 'console', evalId, consoleId, level: prop, args: serialized });
|
|
324
332
|
};
|
|
325
333
|
}
|
|
326
334
|
return target[prop];
|
|
@@ -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
|
|
|
@@ -351,7 +367,7 @@ scope.onmessage = async ({ data: msg }) => {
|
|
|
351
367
|
}
|
|
352
368
|
|
|
353
369
|
case 'evaluate': {
|
|
354
|
-
const fwdConsole = makeForwardingConsole(msg.id);
|
|
370
|
+
const fwdConsole = makeForwardingConsole(msg.id, msg.consoleId);
|
|
355
371
|
try {
|
|
356
372
|
// Wrap in async function for top-level await. The newlines before and
|
|
357
373
|
// after the user code matter: without the trailing one, code ending in
|
|
@@ -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'));
|