@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.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `@johnhenry/andbox/bridges/chrome-ai`: Chrome's built-in AI APIs, bridged
3
+ * into a sandbox (andbox#46). See the README section "Chrome AI bridge".
4
+ */
5
+ import type { BridgeDefinition, BridgeLimits, BridgeRequest } from '../index.js';
6
+
7
+ /** The API keys `chromeAI({ apis })` accepts; each is a namespace of the sandbox global. */
8
+ export type ChromeAIApi =
9
+ | 'languageModel'
10
+ | 'summarizer'
11
+ | 'writer'
12
+ | 'rewriter'
13
+ | 'translator'
14
+ | 'languageDetector'
15
+ | 'proofreader';
16
+
17
+ export declare const CHROME_AI_APIS: readonly ChromeAIApi[];
18
+
19
+ export interface ChromeAIBudgets {
20
+ /**
21
+ * Model input tokens per sandbox lifetime, measured with
22
+ * `measureContextUsage()` (or `measureInputUsage()`) before each
23
+ * prompt/append/summarize/write/rewrite/translate/detect/proofread call,
24
+ * plus the tokens `initialPrompts` use at create(). Output tokens are not
25
+ * counted. A call over budget rejects with QuotaExceededError before it
26
+ * reaches the model. 0 = unlimited.
27
+ */
28
+ maxInputTokens?: number;
29
+ /** Model input tokens of a single call. 0 = unlimited. */
30
+ maxInputTokensPerCall?: number;
31
+ }
32
+
33
+ export interface ChromeAIOptions {
34
+ /** Which APIs to expose (default: all). Missing ones on the host report 'unavailable'. */
35
+ apis?: ChromeAIApi[];
36
+ /**
37
+ * Consent hook for every model call (`method` is e.g. 'languageModel.create'
38
+ * or 'LanguageModel.prompt'). Only `true` allows. When
39
+ * `requiresUserActivation` is 'sticky' (the model must be downloaded),
40
+ * resolve from a click handler so the page has activation.
41
+ */
42
+ onRequest?: (request: BridgeRequest) => boolean | Promise<boolean>;
43
+ budgets?: ChromeAIBudgets;
44
+ /** Bridge limits. Default { maxHandles: 8, maxStreams: 4 }. */
45
+ limits?: BridgeLimits;
46
+ /** Also install LanguageModel, Summarizer, ... in the sandbox as aliases of `ai.languageModel`, ... */
47
+ globals?: boolean;
48
+ /** Where the platform constructors are looked up (default globalThis). For tests and polyfills. */
49
+ scope?: Record<string, unknown>;
50
+ }
51
+
52
+ /** The Chrome built-in AI bridge: `createSandbox({ bridges: { ai: chromeAI() } })`. */
53
+ export declare function chromeAI(options?: ChromeAIOptions): BridgeDefinition<{ inputTokens: number }>;
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Chrome built-in AI bridge (andbox#46).
3
+ *
4
+ * import { chromeAI } from '@johnhenry/andbox/bridges/chrome-ai';
5
+ * const sandbox = await createSandbox({ bridges: { ai: chromeAI({ onRequest }) } });
6
+ * await sandbox.evaluate(`
7
+ * const session = await ai.languageModel.create();
8
+ * return session.prompt('Hello');
9
+ * `);
10
+ *
11
+ * Chrome exposes its built-in AI APIs (LanguageModel, Summarizer, Writer,
12
+ * Rewriter, Translator, LanguageDetector, Proofreader) to Window contexts only:
13
+ * not to Workers, and not to a sandboxed opaque-origin iframe (permissions
14
+ * policy features default to 'self'). So sandboxed code reaches them only
15
+ * through the page, which is what this bridge does: the page owns every model
16
+ * session; the sandbox holds handles to them.
17
+ *
18
+ * Zero dependencies. The sandbox-side surface mirrors the platform's names
19
+ * and shapes (see README "Chrome AI bridge" for the differences).
20
+ */
21
+
22
+ import { defineBridge } from '../bridge-host.mjs';
23
+
24
+ /**
25
+ * The APIs this bridge knows. `global` is the platform constructor on the
26
+ * host; `methods` are instance methods (`input`: argument 0 is the model
27
+ * input, counted by token budgets; `opts`: index of the options argument,
28
+ * which gets the call's AbortSignal).
29
+ */
30
+ const API_TABLE = {
31
+ languageModel: {
32
+ global: 'LanguageModel',
33
+ methods: {
34
+ prompt: { input: true, opts: 1 },
35
+ promptStreaming: { input: true, opts: 1, stream: true },
36
+ append: { input: true, opts: 1 },
37
+ measureContextUsage: { opts: 1, alias: 'measureInputUsage' },
38
+ measureInputUsage: { opts: 1, alias: 'measureContextUsage' },
39
+ clone: { opts: 0, handle: true },
40
+ },
41
+ props: ['contextUsage', 'contextWindow', 'inputUsage', 'inputQuota', 'samplingMode', 'topK', 'temperature'],
42
+ },
43
+ summarizer: {
44
+ global: 'Summarizer',
45
+ methods: {
46
+ summarize: { input: true, opts: 1 },
47
+ summarizeStreaming: { input: true, opts: 1, stream: true },
48
+ measureInputUsage: { opts: 1 },
49
+ },
50
+ props: ['sharedContext', 'type', 'format', 'length', 'expectedInputLanguages', 'expectedContextLanguages', 'outputLanguage', 'inputQuota'],
51
+ },
52
+ writer: {
53
+ global: 'Writer',
54
+ methods: {
55
+ write: { input: true, opts: 1 },
56
+ writeStreaming: { input: true, opts: 1, stream: true },
57
+ measureInputUsage: { opts: 1 },
58
+ },
59
+ props: ['sharedContext', 'tone', 'format', 'length', 'expectedInputLanguages', 'expectedContextLanguages', 'outputLanguage', 'inputQuota'],
60
+ },
61
+ rewriter: {
62
+ global: 'Rewriter',
63
+ methods: {
64
+ rewrite: { input: true, opts: 1 },
65
+ rewriteStreaming: { input: true, opts: 1, stream: true },
66
+ measureInputUsage: { opts: 1 },
67
+ },
68
+ props: ['sharedContext', 'tone', 'format', 'length', 'expectedInputLanguages', 'expectedContextLanguages', 'outputLanguage', 'inputQuota'],
69
+ },
70
+ translator: {
71
+ global: 'Translator',
72
+ methods: {
73
+ translate: { input: true, opts: 1 },
74
+ translateStreaming: { input: true, opts: 1, stream: true },
75
+ measureInputUsage: { opts: 1 },
76
+ },
77
+ props: ['sourceLanguage', 'targetLanguage', 'inputQuota'],
78
+ },
79
+ languageDetector: {
80
+ global: 'LanguageDetector',
81
+ methods: {
82
+ detect: { input: true, opts: 1 },
83
+ measureInputUsage: { opts: 1 },
84
+ },
85
+ props: ['expectedInputLanguages', 'inputQuota'],
86
+ },
87
+ proofreader: {
88
+ global: 'Proofreader',
89
+ methods: {
90
+ proofread: { input: true, opts: 1 },
91
+ measureInputUsage: { opts: 1 },
92
+ },
93
+ props: ['includeCorrectionTypes', 'includeCorrectionExplanations', 'expectedInputLanguages', 'correctionExplanationLanguage', 'inputQuota'],
94
+ },
95
+ };
96
+
97
+ /** Every API key `chromeAI({ apis })` accepts, in order. */
98
+ export const CHROME_AI_APIS = Object.freeze(Object.keys(API_TABLE));
99
+
100
+ /** Create-option keys that never reach the platform's `availability()`. */
101
+ const NOT_FOR_AVAILABILITY = new Set(['signal', 'monitor', 'onDownloadProgress', 'initialPrompts', 'sharedContext']);
102
+
103
+ const DEFAULT_LIMITS = Object.freeze({ maxHandles: 8, maxStreams: 4 });
104
+
105
+ function domError(message, name) {
106
+ return new DOMException(message, name);
107
+ }
108
+
109
+ function isObject(v) {
110
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
111
+ }
112
+
113
+ function checkBudget(where, key, value) {
114
+ if (value === undefined) return;
115
+ if (!(typeof value === 'number' && Number.isFinite(value) && value >= 0)) {
116
+ throw new TypeError(`${where}.${key} must be a non-negative number (0 = unlimited)`);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The sandbox-side adapter, stringified into the sandbox (no outside
122
+ * references). Chrome's `monitor` is a synchronous callback that receives an
123
+ * EventTarget; a function cannot cross, so the adapter calls `monitor` with a
124
+ * local EventTarget and passes the host a progress callback instead, whose
125
+ * calls it turns into `downloadprogress` events.
126
+ */
127
+ function chromeAIClient(api) {
128
+ const G = globalThis;
129
+ const EventTargetCtor = G.EventTarget;
130
+ const EventCtor = G.Event;
131
+ const ProgressEventCtor = G.ProgressEvent;
132
+ const defineProperty = Object.defineProperty;
133
+ const keys = Object.keys;
134
+
135
+ function progressEvent(p) {
136
+ const init = {
137
+ lengthComputable: true,
138
+ loaded: p && typeof p.loaded === 'number' ? p.loaded : 0,
139
+ total: p && typeof p.total === 'number' ? p.total : 1,
140
+ };
141
+ if (typeof ProgressEventCtor === 'function') return new ProgressEventCtor('downloadprogress', init);
142
+ const e = new EventCtor('downloadprogress');
143
+ for (const k of keys(init)) defineProperty(e, k, { value: init[k], enumerable: true });
144
+ return e;
145
+ }
146
+
147
+ function makeMonitor() {
148
+ const target = new EventTargetCtor();
149
+ let handler = null;
150
+ defineProperty(target, 'ondownloadprogress', {
151
+ enumerable: true,
152
+ get() { return handler; },
153
+ set(fn) {
154
+ if (handler) target.removeEventListener('downloadprogress', handler);
155
+ handler = typeof fn === 'function' ? fn : null;
156
+ if (handler) target.addEventListener('downloadprogress', handler);
157
+ },
158
+ });
159
+ return target;
160
+ }
161
+
162
+ for (const key of keys(api)) {
163
+ const ns = api[key];
164
+ if (!ns || typeof ns.create !== 'function') continue;
165
+ const create = ns.create;
166
+ ns.create = function create_(options) {
167
+ if (options !== null && typeof options === 'object' && typeof options.monitor === 'function') {
168
+ const monitor = makeMonitor();
169
+ options.monitor(monitor);
170
+ const rest = {};
171
+ for (const k of keys(options)) if (k !== 'monitor') rest[k] = options[k];
172
+ rest.onDownloadProgress = (p) => { monitor.dispatchEvent(progressEvent(p)); };
173
+ options = rest;
174
+ }
175
+ return create(options);
176
+ };
177
+ defineProperty(ns.create, 'name', { value: 'create' });
178
+ }
179
+ return api;
180
+ }
181
+
182
+ /**
183
+ * Tool-use content (`tool-call` / `tool-response`) holds platform objects
184
+ * (LanguageModelToolCall, LanguageModelToolSuccess, LanguageModelToolError)
185
+ * that cannot be cloned. In the sandbox they are plain objects with the same
186
+ * fields; the host converts in both directions.
187
+ */
188
+ function toPlatformContent(item, scope) {
189
+ if (!isObject(item) || !isObject(item.value)) return item;
190
+ const v = item.value;
191
+ if (item.type === 'tool-call' && typeof scope.LanguageModelToolCall === 'function') {
192
+ return { ...item, value: new scope.LanguageModelToolCall(v) };
193
+ }
194
+ if (item.type === 'tool-response') {
195
+ if (v.errorMessage !== undefined && typeof scope.LanguageModelToolError === 'function') {
196
+ return { ...item, value: new scope.LanguageModelToolError(v) };
197
+ }
198
+ if (typeof scope.LanguageModelToolSuccess === 'function') {
199
+ return { ...item, value: new scope.LanguageModelToolSuccess(v) };
200
+ }
201
+ }
202
+ return item;
203
+ }
204
+
205
+ function toPlatformPrompt(input, scope) {
206
+ if (!Array.isArray(input)) return input;
207
+ return input.map((msg) => (isObject(msg) && Array.isArray(msg.content)
208
+ ? { ...msg, content: msg.content.map((c) => toPlatformContent(c, scope)) }
209
+ : msg));
210
+ }
211
+
212
+ function toPlainContent(item) {
213
+ if (!isObject(item) || item.value === null || typeof item.value !== 'object') return item;
214
+ const v = item.value;
215
+ if (item.type === 'tool-call') {
216
+ return { type: item.type, value: { callId: v.callId, name: v.name, arguments: v.arguments ?? null } };
217
+ }
218
+ if (item.type === 'tool-response') {
219
+ return {
220
+ type: item.type,
221
+ value: v.errorMessage !== undefined
222
+ ? { callId: v.callId, name: v.name, errorMessage: v.errorMessage }
223
+ : { callId: v.callId, name: v.name, result: v.result ? [...v.result] : [] },
224
+ };
225
+ }
226
+ return item;
227
+ }
228
+
229
+ function toPlainResult(value) {
230
+ return Array.isArray(value) ? value.map(toPlainContent) : value;
231
+ }
232
+
233
+ /**
234
+ * Create the Chrome built-in AI bridge.
235
+ *
236
+ * @param {object} [options]
237
+ * @param {string[]} [options.apis] which APIs to expose (default: all of CHROME_AI_APIS)
238
+ * @param {(request: object) => boolean | Promise<boolean>} [options.onRequest] consent hook; only `true` allows
239
+ * @param {{ maxInputTokens?: number, maxInputTokensPerCall?: number }} [options.budgets] token budgets per sandbox
240
+ * @param {object} [options.limits] bridge limits (default { maxHandles: 8, maxStreams: 4 })
241
+ * @param {boolean} [options.globals] also install LanguageModel, Summarizer, ... as sandbox globals
242
+ * @param {object} [options.scope] where the platform constructors live (default globalThis; tests pass fakes)
243
+ */
244
+ export function chromeAI(options = {}) {
245
+ if (!isObject(options)) throw new TypeError('chromeAI(options): options must be an object');
246
+ for (const k of Object.keys(options)) {
247
+ if (!['apis', 'onRequest', 'budgets', 'limits', 'globals', 'scope'].includes(k)) {
248
+ throw new TypeError(`chromeAI: '${k}' is not a known option (apis, onRequest, budgets, limits, globals, scope)`);
249
+ }
250
+ }
251
+ const { apis = CHROME_AI_APIS, onRequest, budgets = {}, limits = {}, globals = false, scope = globalThis } = options;
252
+ if (!Array.isArray(apis) || !apis.every((a) => CHROME_AI_APIS.includes(a))) {
253
+ throw new TypeError(`chromeAI: apis must be an array of ${CHROME_AI_APIS.map((a) => `'${a}'`).join(', ')}`);
254
+ }
255
+ if (!isObject(budgets)) throw new TypeError('chromeAI: budgets must be an object');
256
+ for (const k of Object.keys(budgets)) {
257
+ if (!['maxInputTokens', 'maxInputTokensPerCall'].includes(k)) {
258
+ throw new TypeError(`chromeAI: budgets.${k} is not a known budget (maxInputTokens, maxInputTokensPerCall)`);
259
+ }
260
+ checkBudget('chromeAI: budgets', k, budgets[k]);
261
+ }
262
+ if (!isObject(scope)) throw new TypeError('chromeAI: scope must be an object');
263
+ const maxTotal = budgets.maxInputTokens ?? 0;
264
+ const maxPerCall = budgets.maxInputTokensPerCall ?? 0;
265
+
266
+ /** Count model input against the budgets before it reaches the model. */
267
+ async function charge(ctx, target, typeName, input, opts) {
268
+ if (!maxTotal && !maxPerCall) return;
269
+ const measure = typeof target.measureContextUsage === 'function'
270
+ ? target.measureContextUsage
271
+ : typeof target.measureInputUsage === 'function' ? target.measureInputUsage : null;
272
+ if (!measure) {
273
+ throw domError(`${typeName} cannot measure its input in this browser, so the token budget cannot be enforced`, 'NotSupportedError');
274
+ }
275
+ const { signal, ...measureOpts } = opts;
276
+ const n = Number(await measure.call(target, input, { ...measureOpts, signal }));
277
+ const tokens = Number.isFinite(n) ? n : 0;
278
+ if (maxPerCall > 0 && tokens > maxPerCall) {
279
+ throw Object.assign(domError(`Input is ${tokens} tokens, over budgets.maxInputTokensPerCall (${maxPerCall})`, 'QuotaExceededError'), { requested: tokens, quota: maxPerCall });
280
+ }
281
+ if (maxTotal > 0 && ctx.state.inputTokens + tokens > maxTotal) {
282
+ throw Object.assign(
283
+ domError(`Input of ${tokens} tokens would exceed budgets.maxInputTokens (${ctx.state.inputTokens} of ${maxTotal} used)`, 'QuotaExceededError'),
284
+ { requested: tokens, quota: maxTotal - ctx.state.inputTokens }
285
+ );
286
+ }
287
+ ctx.state.inputTokens += tokens;
288
+ }
289
+
290
+ const api = {};
291
+ const handles = {};
292
+ const aliases = {};
293
+ for (const key of apis) {
294
+ const info = API_TABLE[key];
295
+ const typeName = info.global;
296
+ const getAPI = () => {
297
+ const C = scope[typeName];
298
+ return typeof C === 'function' || isObject(C) ? C : null;
299
+ };
300
+ const coreOptions = (opts) => {
301
+ if (!isObject(opts)) return opts;
302
+ const out = {};
303
+ for (const k of Object.keys(opts)) if (!NOT_FOR_AVAILABILITY.has(k)) out[k] = opts[k];
304
+ return out;
305
+ };
306
+
307
+ const ns = {
308
+ async availability(ctx, opts) {
309
+ const C = getAPI();
310
+ if (!C || typeof C.availability !== 'function') return 'unavailable';
311
+ return C.availability(coreOptions(opts));
312
+ },
313
+ create: {
314
+ handle: true,
315
+ // Chrome needs a user gesture when the model still has to be
316
+ // downloaded (sticky activation is enough in shipping Chrome).
317
+ async requiresUserActivation(ctx, opts) {
318
+ const C = getAPI();
319
+ if (!C || typeof C.availability !== 'function') return false;
320
+ try {
321
+ return (await C.availability(coreOptions(opts))) === 'downloadable' ? 'sticky' : false;
322
+ } catch {
323
+ return false;
324
+ }
325
+ },
326
+ async call(ctx, opts) {
327
+ const C = getAPI();
328
+ if (!C || typeof C.create !== 'function') {
329
+ throw domError(`${typeName} is not available in this browser`, 'NotSupportedError');
330
+ }
331
+ let createOpts = opts;
332
+ if (isObject(opts)) {
333
+ const { monitor, onDownloadProgress, signal, ...rest } = opts;
334
+ if (Array.isArray(rest.tools) && rest.tools.some((t) => isObject(t) && typeof t.execute === 'function')) {
335
+ throw domError(
336
+ "Prompt API tools have no execute() callback: tool use is open-loop (prompt() returns 'tool-call' content " +
337
+ "and you append a 'tool-response'). Drop execute from the tool declarations.",
338
+ 'NotSupportedError'
339
+ );
340
+ }
341
+ createOpts = { ...rest, signal: ctx.signal };
342
+ if (Array.isArray(rest.initialPrompts)) createOpts.initialPrompts = toPlatformPrompt(rest.initialPrompts, scope);
343
+ if (typeof onDownloadProgress === 'function') {
344
+ createOpts.monitor = (m) => {
345
+ m.addEventListener('downloadprogress', (e) => {
346
+ onDownloadProgress({ loaded: e.loaded, total: e.total }).catch(() => {});
347
+ });
348
+ };
349
+ }
350
+ } else if (opts === undefined) {
351
+ createOpts = { signal: ctx.signal };
352
+ }
353
+ const instance = await C.create(createOpts);
354
+ // The tokens initial prompts already used count against the budget.
355
+ if (maxTotal > 0 && key === 'languageModel') {
356
+ const used = Number(instance?.contextUsage ?? instance?.inputUsage ?? 0);
357
+ if (Number.isFinite(used) && used > 0) {
358
+ if (ctx.state.inputTokens + used > maxTotal) {
359
+ try { instance.destroy?.(); } catch {}
360
+ throw Object.assign(
361
+ domError(`initialPrompts use ${used} tokens, over budgets.maxInputTokens (${ctx.state.inputTokens} of ${maxTotal} used)`, 'QuotaExceededError'),
362
+ { requested: used, quota: maxTotal - ctx.state.inputTokens }
363
+ );
364
+ }
365
+ ctx.state.inputTokens += used;
366
+ }
367
+ }
368
+ return ctx.handle(typeName, instance);
369
+ },
370
+ },
371
+ };
372
+ if (key === 'languageModel' && typeof scope.LanguageModel?.params === 'function') {
373
+ // Extension contexts only; LanguageModelParams is a platform object.
374
+ ns.params = async () => {
375
+ const p = await scope.LanguageModel.params();
376
+ return p == null ? null : {
377
+ defaultTopK: p.defaultTopK, maxTopK: p.maxTopK, defaultTemperature: p.defaultTemperature, maxTemperature: p.maxTemperature,
378
+ };
379
+ };
380
+ }
381
+ api[key] = ns;
382
+
383
+ const methods = {};
384
+ for (const [name, m] of Object.entries(info.methods)) {
385
+ const call = async (ctx, ...args) => {
386
+ const target = ctx.target;
387
+ let fn = target[name];
388
+ if (typeof fn !== 'function' && m.alias) fn = target[m.alias];
389
+ if (typeof fn !== 'function') {
390
+ throw domError(`${typeName}.${name}() is not available in this browser`, 'NotSupportedError');
391
+ }
392
+ const a = args.slice();
393
+ while (a.length <= m.opts) a.push(undefined);
394
+ const opts = isObject(a[m.opts]) ? { ...a[m.opts] } : {};
395
+ opts.signal = ctx.signal;
396
+ a[m.opts] = opts;
397
+ if (key === 'languageModel' && m.opts === 1) a[0] = toPlatformPrompt(a[0], scope);
398
+ if (m.input) await charge(ctx, target, typeName, a[0], opts);
399
+ const result = fn.apply(target, a);
400
+ if (m.stream) return result;
401
+ if (m.handle) return ctx.handle(typeName, await result);
402
+ return toPlainResult(await result);
403
+ };
404
+ methods[name] = { call, stream: m.stream === true, handle: m.handle === true };
405
+ }
406
+ handles[typeName] = {
407
+ methods,
408
+ props: info.props,
409
+ destroy: (target) => target.destroy?.(),
410
+ };
411
+ aliases[typeName] = key;
412
+ }
413
+
414
+ return defineBridge({
415
+ api,
416
+ handles,
417
+ limits: { ...DEFAULT_LIMITS, ...limits },
418
+ ...(onRequest !== undefined ? { onRequest } : {}),
419
+ createState: () => ({ inputTokens: 0 }),
420
+ stats: (state) => ({ inputTokens: state.inputTokens }),
421
+ client: chromeAIClient,
422
+ ...(globals ? { globals: aliases } : {}),
423
+ });
424
+ }
@@ -29,11 +29,12 @@ const OWNED_ATTRIBUTES = new Set(['srcdoc', 'src', 'sandbox']);
29
29
 
30
30
  /**
31
31
  * The runtime script the frame runs: Worker mode's, minus the global lockdown.
32
- * @param {{ networkFetch?: boolean }} [options] `networkFetch`: replace the
33
- * frame's `fetch` with the host-backed one (`createSandbox({ network })`).
32
+ * @param {{ networkFetch?: boolean, bridges?: boolean }} [options] `networkFetch`: replace the
33
+ * frame's `fetch` with the host-backed one (`createSandbox({ network })`);
34
+ * `bridges`: include the bridge client (`createSandbox({ bridges })`).
34
35
  */
35
- export function makeIframeRuntimeSource({ networkFetch = false } = {}) {
36
- return makeRuntimeSource({ lockdown: false, networkFetch });
36
+ export function makeIframeRuntimeSource({ networkFetch = false, bridges = false } = {}) {
37
+ return makeRuntimeSource({ lockdown: false, networkFetch, bridges });
37
38
  }
38
39
 
39
40
  /**
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
 
@@ -505,6 +642,17 @@ export interface SandboxOptions {
505
642
  * capability named `fetch`.
506
643
  */
507
644
  network?: SandboxNetworkOptions;
645
+ /**
646
+ * `worker`, `node-worker` and `iframe` modes (throws in the others): one
647
+ * sandbox global per entry that proxies a host API (andbox#46). Host
648
+ * objects never cross: the sandbox holds opaque handles, gets structured
649
+ * clones, pulls streams chunk by chunk, and its functions run in the
650
+ * sandbox when the host calls them. Every method call goes through the
651
+ * capability gate as `${bridge}.${method}` (so `policy` limits it) and
652
+ * through the bridge's `onRequest`; every handle is destroyed when the
653
+ * sandbox is disposed, restarted or killed. See `@johnhenry/andbox/bridges/chrome-ai`.
654
+ */
655
+ bridges?: Record<string, BridgeDefinition>;
508
656
  }
509
657
 
510
658
  /** Options for the built-in Node worker_threads mode (`nodeWorker`). */
@@ -557,6 +705,8 @@ export interface SandboxStats {
557
705
  pendingEvaluations: number;
558
706
  virtualModules: string[];
559
707
  gate: GateStatsResult;
708
+ /** With `bridges`: live handles/streams/calls per bridge, plus the bridge's own `stats()`. */
709
+ bridges?: Record<string, BridgeStats>;
560
710
  /** `mode: 'wasm'` only: interrupt polls used by the most recent evaluate(). */
561
711
  fuelUsed?: number;
562
712
  /** `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';