Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
203 lines
6.3 KiB
JavaScript
203 lines
6.3 KiB
JavaScript
// ==========================================
|
|
// JavaScript Sandbox — Browser Extension
|
|
// ==========================================
|
|
// Executes JavaScript in a sandboxed iframe.
|
|
// Registers the js_eval tool so the LLM can run code,
|
|
// verify calculations, and transform data.
|
|
//
|
|
// Security: The iframe has sandbox="allow-scripts" only.
|
|
// No allow-same-origin, no network, no DOM access.
|
|
// ==========================================
|
|
|
|
Extensions.register({
|
|
id: 'js-sandbox',
|
|
|
|
_iframe: null,
|
|
_pendingCallbacks: new Map(), // messageId → { resolve, reject, timer }
|
|
_messageCounter: 0,
|
|
|
|
async init(ctx) {
|
|
const self = this;
|
|
|
|
// Create the sandboxed iframe on first use (lazy)
|
|
ctx.tools.handle('js_eval', async (args) => {
|
|
return self._execute(args.code || '');
|
|
});
|
|
},
|
|
|
|
async _execute(code) {
|
|
if (!code.trim()) {
|
|
return { error: 'No code provided' };
|
|
}
|
|
|
|
// Ensure iframe is ready
|
|
await this._ensureIframe();
|
|
|
|
const messageId = 'eval-' + (++this._messageCounter);
|
|
const TIMEOUT_MS = 10000;
|
|
|
|
return new Promise((resolve) => {
|
|
// Set up timeout
|
|
const timer = setTimeout(() => {
|
|
this._pendingCallbacks.delete(messageId);
|
|
resolve({
|
|
success: false,
|
|
error: 'Execution timed out (10s)',
|
|
output: [],
|
|
result: null,
|
|
});
|
|
}, TIMEOUT_MS);
|
|
|
|
this._pendingCallbacks.set(messageId, { resolve, timer });
|
|
|
|
// Send code to sandbox
|
|
this._iframe.contentWindow.postMessage({
|
|
type: 'eval',
|
|
id: messageId,
|
|
code: code,
|
|
}, '*');
|
|
});
|
|
},
|
|
|
|
_ensureIframe() {
|
|
if (this._iframe) return Promise.resolve();
|
|
|
|
return new Promise((resolve) => {
|
|
// Build sandbox HTML as a blob URL
|
|
const sandboxHTML = this._buildSandboxHTML();
|
|
const blob = new Blob([sandboxHTML], { type: 'text/html' });
|
|
const blobURL = URL.createObjectURL(blob);
|
|
|
|
const iframe = document.createElement('iframe');
|
|
iframe.sandbox = 'allow-scripts';
|
|
iframe.style.cssText = 'display:none;width:0;height:0;border:none;position:absolute;';
|
|
iframe.src = blobURL;
|
|
|
|
iframe.onload = () => {
|
|
this._iframe = iframe;
|
|
URL.revokeObjectURL(blobURL);
|
|
resolve();
|
|
};
|
|
|
|
// Listen for messages from sandbox
|
|
window.addEventListener('message', (event) => {
|
|
// Only accept messages from our sandbox iframe
|
|
if (!this._iframe || event.source !== this._iframe.contentWindow) return;
|
|
|
|
const data = event.data;
|
|
if (data?.type !== 'eval-result') return;
|
|
|
|
const pending = this._pendingCallbacks.get(data.id);
|
|
if (!pending) return;
|
|
|
|
clearTimeout(pending.timer);
|
|
this._pendingCallbacks.delete(data.id);
|
|
|
|
pending.resolve({
|
|
success: !data.error,
|
|
result: data.result !== undefined ? data.result : null,
|
|
output: data.output || [],
|
|
error: data.error || null,
|
|
});
|
|
});
|
|
|
|
document.body.appendChild(iframe);
|
|
});
|
|
},
|
|
|
|
_buildSandboxHTML() {
|
|
// This HTML runs INSIDE the sandboxed iframe.
|
|
// It has no access to the parent page, no network, no cookies.
|
|
return `<!DOCTYPE html>
|
|
<html><head><title>Sandbox</title></head>
|
|
<body><script>
|
|
(function() {
|
|
'use strict';
|
|
|
|
// Listen for eval messages from parent
|
|
window.addEventListener('message', function(event) {
|
|
var msg = event.data;
|
|
if (!msg || msg.type !== 'eval') return;
|
|
|
|
var output = [];
|
|
var result = undefined;
|
|
var error = null;
|
|
|
|
// Override console methods to capture output
|
|
var origLog = console.log;
|
|
var origWarn = console.warn;
|
|
var origError = console.error;
|
|
|
|
console.log = function() {
|
|
var args = Array.prototype.slice.call(arguments);
|
|
output.push({ level: 'log', text: args.map(stringify).join(' ') });
|
|
};
|
|
console.warn = function() {
|
|
var args = Array.prototype.slice.call(arguments);
|
|
output.push({ level: 'warn', text: args.map(stringify).join(' ') });
|
|
};
|
|
console.error = function() {
|
|
var args = Array.prototype.slice.call(arguments);
|
|
output.push({ level: 'error', text: args.map(stringify).join(' ') });
|
|
};
|
|
|
|
try {
|
|
// Use indirect eval for global scope
|
|
result = (0, eval)(msg.code);
|
|
} catch (e) {
|
|
error = (e && e.message) ? e.message : String(e);
|
|
}
|
|
|
|
// Restore console
|
|
console.log = origLog;
|
|
console.warn = origWarn;
|
|
console.error = origError;
|
|
|
|
// Stringify result for safe transfer
|
|
var resultStr;
|
|
try {
|
|
resultStr = stringify(result);
|
|
} catch (e2) {
|
|
resultStr = '[unserializable]';
|
|
}
|
|
|
|
// Post result back to parent
|
|
event.source.postMessage({
|
|
type: 'eval-result',
|
|
id: msg.id,
|
|
result: resultStr,
|
|
output: output,
|
|
error: error,
|
|
}, '*');
|
|
});
|
|
|
|
function stringify(val) {
|
|
if (val === undefined) return 'undefined';
|
|
if (val === null) return 'null';
|
|
if (typeof val === 'function') return val.toString();
|
|
if (typeof val === 'object') {
|
|
try { return JSON.stringify(val, null, 2); }
|
|
catch(e) { return String(val); }
|
|
}
|
|
return String(val);
|
|
}
|
|
})();
|
|
</` + `script></body></html>`;
|
|
},
|
|
|
|
destroy() {
|
|
// Clean up pending callbacks
|
|
for (const [id, pending] of this._pendingCallbacks) {
|
|
clearTimeout(pending.timer);
|
|
pending.resolve({ success: false, error: 'Extension destroyed', output: [], result: null });
|
|
}
|
|
this._pendingCallbacks.clear();
|
|
|
|
// Remove iframe
|
|
if (this._iframe) {
|
|
this._iframe.remove();
|
|
this._iframe = null;
|
|
}
|
|
}
|
|
});
|