@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.
@@ -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
  /**