jevstrudel.git / website / src / jev / sandbox.mjs

A listener's song plays in a sandbox, never in this page.

Listeners' songs are someone else's code. Evaluated here, on the site's origin, they could do anything the page can: for a signed-in visitor, post comments, publish, or spend their daily Jev budget with their session. So the page never evaluates them. They play in an iframe with an opaque origin (sandbox="allow-scripts", never allow-same-origin), served with a strict CSP (sandboxPolicy.mjs): it has no cookies, no storage of the site's, no way into this page (only postMessage), no navigation of the top page, no forms or popups, and it reaches only the site's own files and the sample hosts songs use.

This module is the page's end:

  • Provenance. The editor's code is foreign from the moment a listener's song, or code from a link (Strudel's #code or ?hash), is loaded into it, until code of the site's own (a site song, a pattern, an example) replaces it. The visitor's edits keep it foreign: it is still that listener's code. guardEditor makes the editor evaluate foreign code only in the sandbox: play, ctrl+enter, block evaluation and the header's button all go there, and the page's own repl refuses it outright. A trusted loader that forgets to say so only leaves code in the sandbox; a foreign one is marked where it loads (showForeign), and a listener song's object carries its code as foreignCode (listeners.mjs), so no path that plays songs can take it.
  • The frame. One per foreign load (a new song gets a fresh frame, so no song's code outlives it), made on the first play. It posts only data (sandboxProtocol.mjs rebuilds every message), which the booth, the mixer, the playhead and the console show.
  • Jev. The frame's jev() calls come here as messages and go to the relay from this page. A listener's song or a link's code is asked with credentials: 'omit': never the visitor's session, so never their budget; the relay counts them per address, as anyone signed out. Code the visitor's own AI played through the hosted MCP (myTabs.mjs) is asked with this page's session, so it is charged to the visitor's own daily budget, as their other Jev calls are: the visitor connected that app to act for them, and anonymous calls would let it spend the address's shared allowance instead. The frame never holds the session either way: the page makes the call, and the frame gets only the answer. The relay itself refuses cross-origin calls, so the frame cannot reach it directly. A song asking more than half the relay's per-visitor rate is refused here, as allSongs.test.mjs refuses a site song that would.
43import { logger } from '@strudel/core';
44import { ATTEMPT_HEADER, BUDGET_HEADER, LEAD_HEADER, noteBudgetHeader, RELAY } from './ask.mjs';
45import { boothStore } from './boothStore.mjs';
46import { escapeHtml, fromFrame, MAX_ANSWER_BYTES } from './sandboxProtocol.mjs';
48export const SANDBOX_PATH = '/jev/sandbox/';

half the relay's JEV_LIMIT (60 a minute, worker/wrangler.json), as allSongs.test.mjs holds site songs to; sandbox.test.mjs checks it

51export const ASKS_PER_MINUTE = 30;
52const READY_MS = 30_000;

how many messages a frame may send a second before the rest are dropped

54const MESSAGES_PER_S = 200;

── provenance ──────────────────────────────────── null: the editor's code is the site's or the visitor's own. Otherwise where it came from: { song } (a listener's, from listeners.mjs), { link: true } (code in the address) or { ai: name } (code the visitor's own AI played through the hosted MCP, myTabs.mjs; name is the app's own, unverified, and only ever shown as text).

62let foreign = null;

what the page shows of it (a new object on every change, for React)

64let state = { foreign, status: 'idle', cycle: null, cps: null, at: 0, error: null, audio: null };
65let levels = null;
66const listeners = new Set();
67const changed = () => {
68  for (const l of listeners) l();
69};
70function set(fields) {
71  state = { ...state, ...fields };
72  changed();
73}
75export const sandbox = {
76  subscribe(l) {
77    listeners.add(l);
78    return () => listeners.delete(l);
79  },
80  // for useSyncExternalStore: a new object on every change
81  get: () => state,
82  foreign: () => foreign,
83  playing: () => state.status === 'playing' || state.status === 'loading',
84};

The cycle the sandbox is on, from its last report, or null when it is not playing (the booth's and mixer's playhead).

88export function sandboxCycle() {
89  if (state.status !== 'playing' || state.cycle === null) return null;
90  const elapsed = (performance.now() - state.at) / 1000;
91  return state.cycle + (state.cps ? elapsed * state.cps : 0);
92}
93export const sandboxLevels = () => (state.status === 'playing' ? levels : null);

A recorded performance of a listener's song, armed for the sandbox: { songId, jevs, changed }. Every play of that song while it is loaded sends it (fromPage's play), so the frame's jev()s play it and ask Jev nothing, as a site song's replay does in the page (performance.mjs). Anything else loaded into the editor disarms it.

100let replay = null;
101export function armSandboxReplay(songId, performance) {
102  replay = performance ? { songId, jevs: performance.jevs, changed: Boolean(performance.changed) } : null;
103}
104const replayFor = () =>
105  replay && foreign?.song?.id === replay.songId ? { jevs: replay.jevs, changed: replay.changed } : null;

Foreign code into the editor: a listener's song (its foreignCode), or code from a link. The last sandbox goes; the next play makes a new one.

109export function showForeign(editor, code, from) {
110  destroy();
111  if (from?.song?.id !== replay?.songId) replay = null;
112  foreign = from;
113  set({ foreign });
114  editor?.setCode(code);
115}

The site's or the visitor's own code into the editor.

118export function setOwnCode(editor, code) {
119  markOwn();
120  editor?.setCode(code);
121}
122export function markOwn() {
123  replay = null;
124  if (!foreign && !frame) return;
125  destroy();
126  foreign = null;
127  set({ foreign });
128}

── the frame ─────────────────────────────────────

131let frame = null; // { el, ready, run, token, bucket, asks, charge }
132let runs = 0;

What the frame logs, raw, for whoever asked (myTabs.mjs's get_logs); the page's console gets it escaped, as before.

135const logListeners = new Set();
136export function onSandboxLog(listener) {
137  logListeners.add(listener);
138  return () => logListeners.delete(listener);
139}
140const logged = (message, kind) => {
141  for (const l of logListeners) l(message, kind);
142};

Each play is numbered, and the frame's state names the play it is about, so playForeign can tell its song's start from the song before.

145let plays = 0;
146let waiter = null; // { run, finish }
148function style(el, visible) {
149  Object.assign(el.style, visible
150    ? { position: 'fixed', right: '16px', bottom: '16px', width: '320px', height: '96px', opacity: '1', pointerEvents: 'auto', zIndex: '60', border: '1px solid currentColor', borderRadius: '6px', background: 'var(--background, #222)' }
151    : { position: 'fixed', right: '0', bottom: '0', width: '1px', height: '1px', opacity: '0', pointerEvents: 'none', zIndex: '-1', border: '0' });
152}
153
154function create() {
155  const el = document.createElement('iframe');
156  // allow-scripts only: an opaque origin, no forms, popups, modals or top
157  // navigation. Never add allow-same-origin: with it the frame would be
158  // this site, and could reach into this page and its cookies.
159  el.setAttribute('sandbox', 'allow-scripts');
160  // the page's click may start the frame's audio (browsers keep audio off
161  // until a click, and let a frame use its page's only when allowed)
162  el.setAttribute('allow', 'autoplay');
163  el.setAttribute('referrerpolicy', 'no-referrer');
164  el.title = "the sandbox a listener's song plays in";
165  style(el, false);
166  let resolveReady, rejectReady;
167  const ready = new Promise((resolve, reject) => {
168    resolveReady = resolve;
169    rejectReady = reject;
170  });
171  ready.catch(() => {});
172  // whose budget the frame's Jev calls are charged to is fixed with the
173  // frame: provenance changing destroys it (showForeign, markOwn)
174  const f = { el, ready, resolveReady, run: ++runs, token: {}, bucket: { n: MESSAGES_PER_S, at: performance.now() }, asks: [], loads: 0, charge: Boolean(foreign?.ai) };
175  // A second load is the frame navigating itself away (listener code can:
176  // the sandbox cannot forbid it). Whatever loaded is still sandboxed, but
177  // it is no longer the player: end it.
178  el.addEventListener('load', () => {
179    f.loads += 1;
180    if (f.loads > 1 && frame === f) {
181      logger("A listener's song navigated its sandbox away, so it was stopped.", 'warning');
182      destroy();
183    }
184  });
185  setTimeout(() => rejectReady(new Error('the sandbox did not start')), READY_MS);
186  el.src = SANDBOX_PATH;
187  document.body.append(el);
188  return f;
189}
190
191function destroy() {
192  if (frame) {
193    frame.el.remove();
194    frame = null;
195    boothStore.showRemote(null);
196  }
197  levels = null;
198  if (state.status !== 'idle') set({ status: 'idle', cycle: null, cps: null, error: null, audio: null });
199}
200
201function send(message) {
202  frame?.el.contentWindow?.postMessage(message, '*');
203}

Plays the editor's (foreign) code in the sandbox, making it first.

206export async function playInSandbox(code) {
207  // numbered now, before any await, so playForeign knows which it is
208  const run = ++plays;
209  if (!frame) frame = create();
210  const f = frame;
211  set({ status: 'loading', error: null });
212  try {
213    await f.ready;
214  } catch (e) {
215    if (frame === f) {
216      set({ status: 'error', error: e.message });
217      logger(`The sandbox for this listener's song could not start (${escapeHtml(e.message)}).`, 'error');
218    }
219    return;
220  }
221  if (frame !== f) return;
222  send({ type: 'resume' });
223  send({ type: 'play', code, run, replay: replayFor() });
224}
226const AUDIO_HELD = "this tab's sound is held until someone clicks the page (or the ▶ in its sandbox box)";

Plays foreign code in the sandbox and resolves once it is playing ({ ok: true }) or failed ({ error }), for the hosted MCP's play_code.

230export function playForeign(code, { timeoutMs = 25_000 } = {}) {
231  waiter?.finish({ error: 'another play replaced it' });
232  return new Promise((resolve) => {
233    const w = {
234      run: plays + 1,
235      finish(result) {
236        if (waiter !== w) return;
237        waiter = null;
238        clearTimeout(timer);
239        resolve(result);
240      },
241    };
242    waiter = w;
243    const timer = setTimeout(
244      () => w.finish({ error: state.audio && state.audio !== 'running' ? AUDIO_HELD : 'the sandbox did not start playing in time' }),
245      timeoutMs,
246    );
247    playInSandbox(code).then(() => {
248      if (state.status === 'error') w.finish({ error: `the sandbox could not start (${state.error})` });
249    });
250  });
251}
253export function stopSandbox() {
254  if (!frame) return;
255  send({ type: 'stop' });
256  if (state.status !== 'idle') set({ status: 'stopped', cycle: null });
257}

The page was clicked: that click may start the frame's audio.

260if (typeof window !== 'undefined') {
261  window.addEventListener('pointerdown', () => frame && send({ type: 'resume' }), true);
262  window.addEventListener('message', (e) => {
263    const f = frame;
264    // only this page's own sandbox, and only while it is opaque: a frame
265    // that somehow had this origin would send its origin, not "null"
266    if (!f || e.source !== f.el.contentWindow || e.origin !== 'null') return;
267    const now = performance.now();
268    const b = f.bucket;
269    b.n = Math.min(MESSAGES_PER_S, b.n + ((now - b.at) / 1000) * MESSAGES_PER_S);
270    b.at = now;
271    if (b.n < 1) return;
272    b.n -= 1;
273    const m = fromFrame(e.data);
274    if (m) receive(f, m);
275  });
276}
278function receive(f, m) {
279  switch (m.type) {
280    case 'ready':
281      f.resolveReady();
282      return;
283    case 'state':
284      if (m.error) {
285        logger(`[sandbox] ${escapeHtml(m.error)}`, 'error');
286        logged(m.error, 'error');
287      }
288      if (waiter && m.run === waiter.run) {
289        if (m.error) waiter.finish({ error: m.error });
290        else if (m.playing) waiter.finish({ ok: true });
291      }
292      // a play first stops what played before: that stop leaves it loading
293      set({
294        status: m.playing ? 'playing' : state.status === 'loading' && !m.error ? 'loading' : 'stopped',
295        cycle: m.cycle,
296        cps: m.cps,
297        at: performance.now(),
298        error: m.error,
299      });
300      return;
301    case 'log':
302      // the console renders HTML: a listener's text is escaped
303      logger(`[sandbox] ${escapeHtml(m.message)}`, m.kind || undefined);
304      logged(m.message, m.kind);
305      return;
306    case 'booth':
307      if (f.boothRun !== m.run) {
308        f.boothRun = m.run;
309        f.token = {};
310      }
311      boothStore.showRemote(
312        m.booth && {
313          ...m.booth,
314          // the same evaluation keeps one identity (JevBooth's identityOf)
315          views: [f.token],
316          // played in the frame: the recorder reads its song from `foreign`
317          sandboxed: true,
318          react: (segment, kind) => send({ type: 'react', segment, kind }),
319        },
320      );
321      return;
322    case 'levels':
323      levels = m.levels;
324      return;
325    case 'audio':
326      style(f.el, m.state !== 'running' && sandbox.playing());
327      set({ audio: m.state });
328      return;
329    case 'ask':
330      ask(f, m);
331      return;
332  }
333}

A Jev call for the frame, made anonymously (see the top).

336async function ask(f, { id, body, lead, attempt }) {
337  const now = Date.now();
338  f.asks = f.asks.filter((t) => now - t < 60_000);
339  if (f.asks.length >= ASKS_PER_MINUTE) {
340    const wait = Math.ceil((60_000 - (now - f.asks[0])) / 1000);
341    send({
342      type: 'answer',
343      id,
344      status: 429,
345      headers: { 'retry-after': String(wait) },
346      body: `this song asks Jev more than ${ASKS_PER_MINUTE} times a minute`,
347    });
348    return;
349  }
350  f.asks.push(now);
351  const headers = { 'Content-Type': 'application/json', [ATTEMPT_HEADER]: String(attempt) };
352  if (lead !== null) headers[LEAD_HEADER] = lead.toFixed(2);
353  try {
354    const res = await fetch(RELAY, { method: 'POST', credentials: f.charge ? 'same-origin' : 'omit', headers, body });
355    if (f.charge) noteBudgetHeader(res.headers.get(BUDGET_HEADER));
356    const text = (await res.text()).slice(0, MAX_ANSWER_BYTES);
357    const pass = {};
358    for (const name of ['retry-after', 'retry-after-ms', 'jev-cache']) {
359      const v = res.headers.get(name);
360      if (v !== null) pass[name] = v.slice(0, 200);
361    }
362    if (frame === f) send({ type: 'answer', id, status: res.status, headers: pass, body: text });
363  } catch (e) {
364    if (frame === f) send({ type: 'answer', id, error: 'the relay could not be reached' });
365  }
366}

── the editor ──────────────────────────────────── Foreign code is evaluated only in the sandbox; see the top.

370export function guardEditor(editor) {
371  const evaluate = editor.evaluate.bind(editor);
372  const stop = editor.stop.bind(editor);
373  const repl = editor.repl;
374  const replEvaluate = repl.evaluate;
375  const replEvaluateBlock = repl.evaluateBlock;
376  const refuse = () => {
377    const e = new Error("a listener's code plays only in the sandbox");
378    logger(e.message, 'warning');
379    return Promise.reject(e);
380  };
381  editor.evaluate = async (autostart = true) => {
382    if (foreign) {
383      if (!autostart) return refuse();
384      editor.flash();
385      repl.stop();
386      return playInSandbox(editor.code);
387    }
388    stopSandbox();
389    return evaluate(autostart);
390  };
391  editor.stop = async () => {
392    stopSandbox();
393    return stop();
394  };
395  editor.toggle = async () => {
396    if (foreign) return sandbox.playing() ? stopSandbox() : editor.evaluate();
397    if (repl.scheduler.started) return stop();
398    return editor.evaluate();
399  };
400  // The page's own start that was already under way (awaiting its samples in
401  // beforeStart) when foreign code arrived must not go on to play: the
402  // scheduler's stop() does not cancel a start in flight
403  // (packages/core/cyclist.mjs), so a site song clicked a moment before a
404  // listener's song, or before the listener's AI played, would sound under it.
405  const scheduler = repl.scheduler;
406  const beforeStart = scheduler.beforeStart;
407  scheduler.beforeStart = async () => {
408    await beforeStart?.();
409    if (foreign) throw new Error('not started: the editor now holds code that plays in the sandbox');
410  };
411  repl.evaluate = (code, autostart) => (foreign ? refuse() : replEvaluate(code, autostart));
412  repl.evaluateBlock = (code, autostart, options) =>
413    foreign ? editor.evaluate() : replEvaluateBlock(code, autostart, options);
414  return editor;
415}