File size: 5,785 Bytes
0ed8124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
export interface JavaScriptResult {
  value: string;
  logs: string[];
  durationMs: number;
}

const MAX_SOURCE_BYTES = 64 * 1024;
const MAX_RESULT_CHARACTERS = 16 * 1024;

const OPAQUE_WORKER_SOURCE = `
(() => {
  const send = self.postMessage.bind(self);
  const render = (value) => {
    if (typeof value === 'string') return value;
    try { return JSON.stringify(value); } catch { return String(value); }
  };
  const blocked = () => Promise.reject(new Error('Network access is disabled in js_eval'));
  const hide = (target, key, value) => {
    try { Object.defineProperty(target, key, { value, writable: false, configurable: false }); } catch {}
  };
  hide(self, 'fetch', blocked);
  hide(self, 'XMLHttpRequest', undefined);
  hide(self, 'WebSocket', undefined);
  hide(self, 'EventSource', undefined);
  hide(self, 'SharedWorker', undefined);
  hide(self, 'Worker', undefined);
  hide(self, 'BroadcastChannel', undefined);
  hide(self, 'importScripts', undefined);
  hide(self, 'indexedDB', undefined);
  hide(self, 'caches', undefined);
  hide(self.navigator, 'storage', undefined);
  hide(self, 'postMessage', undefined);
  hide(self, 'close', undefined);
  self.onmessage = async ({ data }) => {
    const logs = [];
    const capture = (...values) => logs.push(values.map(render).join(' '));
    const console = { log: capture, info: capture, warn: capture, error: capture };
    try {
      const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
      const execute = new AsyncFunction('console', '"use strict";\\n' + data.source);
      const value = await execute(console);
      send({ ok: true, value: render(value), logs });
    } catch (error) {
      send({ ok: false, error: error?.message ?? String(error), logs });
    }
  };
})();
`;

function frameSource(channel: string): string {
  const policy = [
    "default-src 'none'",
    "base-uri 'none'",
    "connect-src 'none'",
    "form-action 'none'",
    "frame-src 'none'",
    "img-src 'none'",
    "media-src 'none'",
    "object-src 'none'",
    "style-src 'none'",
    "script-src 'unsafe-inline' 'unsafe-eval' blob:",
    'worker-src blob:',
  ].join('; ');
  return `<!doctype html><meta http-equiv="Content-Security-Policy" content="${policy}"><script>
(() => {
  const channel = ${JSON.stringify(channel)};
  const workerCode = ${JSON.stringify(OPAQUE_WORKER_SOURCE)};
  let worker = null;
  addEventListener('message', (event) => {
    if (event.source !== parent || event.data?.channel !== channel || event.data?.kind !== 'run') return;
    const workerUrl = URL.createObjectURL(new Blob([workerCode], { type: 'text/javascript' }));
    worker = new Worker(workerUrl);
    URL.revokeObjectURL(workerUrl);
    worker.onmessage = ({ data }) => parent.postMessage({ channel, kind: 'result', payload: data }, '*');
    worker.onerror = (error) => parent.postMessage({ channel, kind: 'result', payload: { ok: false, error: error.message || 'js_eval worker failed', logs: [] } }, '*');
    worker.postMessage({ source: event.data.source });
  });
  parent.postMessage({ channel, kind: 'ready' }, '*');
})();
<\/script>`;
}

export function runJavaScript(
  source: string,
  timeoutMs = 5000,
  signal?: AbortSignal,
): Promise<JavaScriptResult> {
  if (new TextEncoder().encode(source).byteLength > MAX_SOURCE_BYTES) {
    return Promise.reject(new Error('js_eval source exceeds 64 KiB'));
  }
  if (typeof document === 'undefined' || !document.documentElement) {
    return Promise.reject(new Error('js_eval requires a browser document'));
  }

  const channel = crypto.randomUUID();
  const iframe = document.createElement('iframe');
  iframe.hidden = true;
  iframe.setAttribute('sandbox', 'allow-scripts');
  iframe.setAttribute('credentialless', '');
  iframe.srcdoc = frameSource(channel);
  const startedAt = performance.now();

  return new Promise((resolve, reject) => {
    const cleanup = () => {
      window.removeEventListener('message', onMessage);
      signal?.removeEventListener('abort', onAbort);
      iframe.remove();
    };
    const timer = window.setTimeout(() => {
      cleanup();
      reject(new Error(`js_eval exceeded ${timeoutMs} ms`));
    }, timeoutMs);
    const finish = (callback: () => void) => {
      window.clearTimeout(timer);
      cleanup();
      callback();
    };
    const onMessage = (event: MessageEvent<Record<string, unknown>>) => {
      if (event.source !== iframe.contentWindow || event.data?.channel !== channel) return;
      if (event.data.kind === 'ready') {
        iframe.contentWindow?.postMessage({ channel, kind: 'run', source }, '*');
        return;
      }
      if (event.data.kind !== 'result') return;
      const payload = event.data.payload;
      if (typeof payload !== 'object' || payload === null) {
        finish(() => reject(new Error('js_eval sandbox returned an invalid payload')));
        return;
      }
      const result = payload as Record<string, unknown>;
      const logs = Array.isArray(result.logs) ? result.logs.map(String).slice(0, 100) : [];
      if (result.ok !== true) {
        finish(() => reject(new Error(typeof result.error === 'string' ? result.error : 'js_eval failed')));
        return;
      }
      finish(() => resolve({
        value: String(result.value ?? '').slice(0, MAX_RESULT_CHARACTERS),
        logs: logs.map((line) => line.slice(0, 2048)),
        durationMs: performance.now() - startedAt,
      }));
    };
    const onAbort = () => finish(() => reject(new DOMException('The tool operation was aborted.', 'AbortError')));
    window.addEventListener('message', onMessage);
    if (signal?.aborted) {
      onAbort();
      return;
    }
    signal?.addEventListener('abort', onAbort, { once: true });
    document.documentElement.append(iframe);
  });
}