iframe and Firefox fixes

This commit is contained in:
Mario Zechner 2025-10-02 02:15:33 +02:00
parent 4b0703cd5b
commit faefc63309
11 changed files with 472 additions and 380 deletions

View file

@ -33,6 +33,7 @@
"pages": ["sandbox.html"]
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
"extension_pages": "script-src 'self'; object-src 'self'",
"sandbox": "sandbox allow-scripts allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:; script-src-elem 'self' 'unsafe-inline' 'unsafe-eval' https: http:; connect-src * data: blob:; img-src * data: blob:; style-src 'self' 'unsafe-inline' https: http:; font-src * data:; worker-src blob:; child-src blob:; object-src 'none'"
}
}

View file

@ -1,14 +1,13 @@
{
"manifest_version": 3,
"manifest_version": 2,
"name": "pi-ai",
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
"version": "0.5.43",
"action": {
"browser_action": {
"default_title": "Click to open sidebar"
},
"background": {
"scripts": ["background.js"],
"type": "module"
"scripts": ["background.js"]
},
"icons": {
"16": "icon-16.png",
@ -24,12 +23,10 @@
},
"open_at_install": false
},
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com https://cdn.skypack.dev; object-src 'self'",
"permissions": [
"storage",
"activeTab",
"scripting"
],
"host_permissions": [
"https://*/*",
"http://localhost/*",
"http://127.0.0.1/*"
@ -37,13 +34,7 @@
"browser_specific_settings": {
"gecko": {
"id": "pi-reader@mariozechner.at",
"strict_min_version": "115.0"
"strict_min_version": "109.0"
}
},
"sandbox": {
"pages": ["sandbox.html"]
},
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
}
}

View file

@ -1,32 +0,0 @@
{
"manifest_version": 3,
"name": "Pi Reader Assistant",
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
"version": "0.5.43",
"action": {
"default_title": "Click to open side panel"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "icon-16.png",
"48": "icon-48.png",
"128": "icon-128.png"
},
"side_panel": {
"default_path": "sidepanel.html"
},
"permissions": [
"storage",
"activeTab",
"sidePanel",
"scripting"
],
"host_permissions": [
"https://*/*",
"http://localhost/*",
"http://127.0.0.1/*"
]
}

View file

@ -1,5 +1,5 @@
import { build, context } from "esbuild";
import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, rmSync, watch } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@ -51,8 +51,9 @@ const copyStatic = () => {
"icon-16.png",
"icon-48.png",
"icon-128.png",
join("src", "sandbox.html"),
join("src", "sandbox.js"),
join("src", "sidepanel.html"),
join("src", "sandbox.html")
];
for (const relative of filesToCopy) {
@ -82,6 +83,16 @@ const run = async () => {
const ctx = await context(buildOptions);
await ctx.watch();
copyStatic();
for (const file of filesToCopy) {
watch(file, (eventType) => {
if (eventType === 'change') {
console.log(`\n${file} changed, copying static files...`);
copyStatic();
}
});
}
process.stdout.write("Watching for changes...\n");
} else {
await build(buildOptions);

View file

@ -3,21 +3,24 @@ declare const browser: any;
// Detect browser type
const isFirefox = typeof browser !== "undefined" && typeof browser.runtime !== "undefined";
const browserAPI = isFirefox ? browser : chrome;
// Open side panel/sidebar when extension icon is clicked
browserAPI.action.onClicked.addListener((tab: chrome.tabs.Tab) => {
if (isFirefox) {
// Firefox: Toggle the sidebar
if (typeof browser !== "undefined" && browser.sidebarAction) {
browser.sidebarAction.toggle();
}
} else {
// Chrome: Open the side panel
if (isFirefox) {
// Firefox MV2: Use browserAction
if (browser.browserAction) {
browser.browserAction.onClicked.addListener(() => {
if (browser.sidebarAction) {
browser.sidebarAction.toggle();
}
});
}
} else {
// Chrome MV3: Use action API
chrome.action.onClicked.addListener((tab: chrome.tabs.Tab) => {
if (tab.id && chrome.sidePanel) {
chrome.sidePanel.open({ tabId: tab.id });
}
}
});
});
}
export {};

View file

@ -0,0 +1,70 @@
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
// @ts-ignore - browser global exists in Firefox
declare const browser: any;
@customElement("sandbox-iframe")
export class SandboxIframe extends LitElement {
@property() content = "";
private iframe?: HTMLIFrameElement;
createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
window.addEventListener("message", this.handleMessage);
this.createIframe();
}
override disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this.handleMessage);
this.iframe?.remove();
}
private handleMessage = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
// Sandbox is ready, send content
this.iframe?.contentWindow?.postMessage(
{
type: "loadContent",
content: this.content,
artifactId: "test",
attachments: [],
},
"*",
);
}
};
private createIframe() {
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.sandbox.add("allow-modals");
this.iframe.style.width = "100%";
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
if (isFirefox) {
this.iframe.src = browser.runtime.getURL("sandbox.html");
} else {
this.iframe.src = chrome.runtime.getURL("sandbox.html");
}
this.appendChild(this.iframe);
}
public updateContent(newContent: string) {
this.content = newContent;
// Recreate iframe for clean state
if (this.iframe) {
this.iframe.remove();
this.iframe = undefined;
}
this.createIframe();
}
}

View file

@ -1,174 +1,15 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="utf-8">
<script>
// Store files and console output for current execution
let returnedFiles = [];
let consoleOutput = [];
// Store attachments with helper methods
window.attachments = [];
// Helper function to list available files
window.listFiles = function() {
return window.attachments.map(a => ({
id: a.id,
fileName: a.fileName,
mimeType: a.mimeType,
size: a.size
}));
};
// Helper function to read text file by ID
window.readTextFile = function(attachmentId) {
const attachment = window.attachments.find(a => a.id === attachmentId);
if (!attachment) {
throw new Error('Attachment not found: ' + attachmentId);
}
// If extractedText exists, return it
if (attachment.extractedText) {
return attachment.extractedText;
}
// Otherwise decode base64 content
try {
return atob(attachment.content);
} catch (e) {
throw new Error('Failed to decode text content for: ' + attachmentId);
}
};
// Helper function to read binary file as Uint8Array
window.readBinaryFile = function(attachmentId) {
const attachment = window.attachments.find(a => a.id === attachmentId);
if (!attachment) {
throw new Error('Attachment not found: ' + attachmentId);
}
const binaryString = atob(attachment.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
// Override console
['log', 'error', 'warn', 'info', 'debug', 'table'].forEach(method => {
const original = console[method];
console[method] = (...args) => {
// Convert args to strings for plain text output
const stringArgs = args.map(arg => {
try {
if (arg === undefined) return 'undefined';
if (arg === null) return 'null';
if (typeof arg === 'function') return arg.toString();
if (typeof arg === 'symbol') return arg.toString();
if (arg instanceof Error) return arg.stack || arg.message;
if (typeof arg === 'object') return JSON.stringify(arg, null, 2);
return String(arg);
} catch {
return String(arg);
}
});
consoleOutput.push({ type: method, args: stringArgs });
original.apply(console, args);
};
});
// Single return function for files only
window.returnFile = async (fileName, content, mimeType) => {
// Validate mimeType for binary-like content to guide the LLM
const isBinaryLike = (val) => val instanceof Blob || val instanceof Uint8Array;
if (isBinaryLike(content) && (!mimeType || typeof mimeType !== 'string' || !mimeType.includes('/'))) {
const err = new Error('returnFile: mimeType is required for Blob/Uint8Array content (e.g., "image/png").');
throw err;
}
// Default for plain string content when not specified
if (typeof content === 'string' && (!mimeType || typeof mimeType !== 'string' || mimeType === '')) {
mimeType = 'text/plain';
}
// Handle data URLs - extract the data and mime type
if (typeof content === 'string' && content.startsWith('data:')) {
const matches = content.match(/^data:([^;,]+)(;base64)?,(.*)$/);
if (matches) {
if (mimeType === 'text/plain' && matches[1]) {
mimeType = matches[1];
}
const isBase64 = !!matches[2];
const data = matches[3];
if (isBase64) {
const binaryString = atob(data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
content = bytes;
} else {
content = decodeURIComponent(data);
}
}
}
// Convert Blob to Uint8Array for postMessage
else if (content instanceof Blob) {
const arrayBuffer = await content.arrayBuffer();
content = new Uint8Array(arrayBuffer);
}
// Handle different content types
else if (typeof content === 'object' && !(content instanceof Uint8Array)) {
content = JSON.stringify(content, null, 2);
if (!mimeType || mimeType === 'text/plain') {
mimeType = 'application/json';
}
}
returnedFiles.push({ fileName, content, mimeType });
};
// Message handler
window.addEventListener('message', async (e) => {
if (e.data.type === 'setAttachments') {
window.attachments = e.data.attachments;
parent.postMessage({ type: 'ready' }, '*');
} else if (e.data.type === 'execute') {
// Reset for new execution
returnedFiles = [];
consoleOutput = [];
try {
// Execute code in global scope to persist variables
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const func = new AsyncFunction(e.data.code);
const result = await func();
// If there was a direct return value, log it
if (result !== undefined) {
console.log('Return value:', result);
}
parent.postMessage({
type: 'result',
success: true,
files: returnedFiles,
console: consoleOutput
}, '*');
} catch (error) {
parent.postMessage({
type: 'error',
success: false,
error: {
message: error.message,
stack: error.stack,
name: error.name
},
console: consoleOutput
}, '*');
}
}
});
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sandboxed Content</title>
<style>
html { height: 100%; }
body { min-height: 100%; margin: 0; }
</style>
</head>
<body>
<div id="root"></div>
<script src="sandbox.js"></script>
</body>
</html>

View file

@ -0,0 +1,220 @@
// Global storage for attachments and helper functions
window.attachments = [];
window.listFiles = () =>
(window.attachments || []).map((a) => ({
id: a.id,
fileName: a.fileName,
mimeType: a.mimeType,
size: a.size,
}));
window.readTextFile = (attachmentId) => {
const a = (window.attachments || []).find((x) => x.id === attachmentId);
if (!a) throw new Error("Attachment not found: " + attachmentId);
if (a.extractedText) return a.extractedText;
try {
return atob(a.content);
} catch {
throw new Error("Failed to decode text content for: " + attachmentId);
}
};
window.readBinaryFile = (attachmentId) => {
const a = (window.attachments || []).find((x) => x.id === attachmentId);
if (!a) throw new Error("Attachment not found: " + attachmentId);
const bin = atob(a.content);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
};
// Console capture - forward to parent
window.__artifactLogs = [];
const originalConsole = {
log: console.log,
error: console.error,
warn: console.warn,
info: console.info,
};
["log", "error", "warn", "info"].forEach((method) => {
console[method] = (...args) => {
const text = args
.map((arg) => {
try {
return typeof arg === "object" ? JSON.stringify(arg) : String(arg);
} catch {
return String(arg);
}
})
.join(" ");
window.__artifactLogs.push({ type: method === "error" ? "error" : "log", text });
window.parent.postMessage(
{
type: "console",
method,
text,
artifactId: window.__currentArtifactId,
},
"*",
);
originalConsole[method].apply(console, args);
};
});
// Error handlers
window.addEventListener("error", (e) => {
const text = e.message + " at line " + e.lineno + ":" + e.colno;
window.__artifactLogs.push({ type: "error", text });
window.parent.postMessage(
{
type: "console",
method: "error",
text,
artifactId: window.__currentArtifactId,
},
"*",
);
});
window.addEventListener("unhandledrejection", (e) => {
const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error");
window.__artifactLogs.push({ type: "error", text });
window.parent.postMessage(
{
type: "console",
method: "error",
text,
artifactId: window.__currentArtifactId,
},
"*",
);
});
// Listen for content from parent
window.addEventListener("message", (event) => {
if (event.data.type === "loadContent") {
// Store artifact ID and attachments BEFORE wiping the document
window.__currentArtifactId = event.data.artifactId;
window.attachments = event.data.attachments || [];
// Clear logs for new content
window.__artifactLogs = [];
// Inject helper functions into the user's HTML
const helperScript =
"<" +
"script>\n" +
"// Artifact ID\n" +
"window.__currentArtifactId = " +
JSON.stringify(event.data.artifactId) +
";\n\n" +
"// Attachments\n" +
"window.attachments = " +
JSON.stringify(event.data.attachments || []) +
";\n\n" +
"// Logs\n" +
"window.__artifactLogs = [];\n\n" +
"// Helper functions\n" +
"window.listFiles = " +
window.listFiles.toString() +
";\n" +
"window.readTextFile = " +
window.readTextFile.toString() +
";\n" +
"window.readBinaryFile = " +
window.readBinaryFile.toString() +
";\n\n" +
"// Console capture\n" +
"const originalConsole = {\n" +
" log: console.log,\n" +
" error: console.error,\n" +
" warn: console.warn,\n" +
" info: console.info\n" +
"};\n\n" +
"['log', 'error', 'warn', 'info'].forEach(method => {\n" +
" console[method] = function(...args) {\n" +
" const text = args.map(arg => {\n" +
" try { return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); }\n" +
" catch { return String(arg); }\n" +
" }).join(' ');\n\n" +
" window.__artifactLogs.push({ type: method === 'error' ? 'error' : 'log', text });\n\n" +
" window.parent.postMessage({\n" +
" type: 'console',\n" +
" method,\n" +
" text,\n" +
" artifactId: window.__currentArtifactId\n" +
" }, '*');\n\n" +
" originalConsole[method].apply(console, args);\n" +
" };\n" +
"});\n\n" +
"// Error handlers\n" +
"window.addEventListener('error', (e) => {\n" +
" const text = e.message + ' at line ' + e.lineno + ':' + e.colno;\n" +
" window.__artifactLogs.push({ type: 'error', text });\n" +
" window.parent.postMessage({\n" +
" type: 'console',\n" +
" method: 'error',\n" +
" text,\n" +
" artifactId: window.__currentArtifactId\n" +
" }, '*');\n" +
"});\n\n" +
"window.addEventListener('unhandledrejection', (e) => {\n" +
" const text = 'Unhandled promise rejection: ' + (e.reason?.message || e.reason || 'Unknown error');\n" +
" window.__artifactLogs.push({ type: 'error', text });\n" +
" window.parent.postMessage({\n" +
" type: 'console',\n" +
" method: 'error',\n" +
" text,\n" +
" artifactId: window.__currentArtifactId\n" +
" }, '*');\n" +
"});\n\n" +
"// Send completion when ready\n" +
"const sendCompletion = function() {\n" +
" window.parent.postMessage({\n" +
" type: 'execution-complete',\n" +
" logs: window.__artifactLogs || [],\n" +
" artifactId: window.__currentArtifactId\n" +
" }, '*');\n" +
"};\n\n" +
"if (document.readyState === 'complete' || document.readyState === 'interactive') {\n" +
" setTimeout(sendCompletion, 0);\n" +
"} else {\n" +
" window.addEventListener('DOMContentLoaded', function() {\n" +
" setTimeout(sendCompletion, 0);\n" +
" });\n" +
"}\n" +
"</" +
"script>";
// Inject helper script into the HTML content
let content = event.data.content;
// Try to inject at the start of <head>, or at the start of document
const headMatch = content.match(/<head[^>]*>/i);
if (headMatch) {
const index = headMatch.index + headMatch[0].length;
content = content.slice(0, index) + helperScript + content.slice(index);
} else {
const htmlMatch = content.match(/<html[^>]*>/i);
if (htmlMatch) {
const index = htmlMatch.index + htmlMatch[0].length;
content = content.slice(0, index) + helperScript + content.slice(index);
} else {
content = helperScript + content;
}
}
// Write the HTML content to the document
document.open();
document.write(content);
document.close();
}
});
// Signal ready to parent
window.parent.postMessage({ type: "sandbox-ready" }, "*");

View file

@ -5,6 +5,7 @@ import { FileCode2, Settings } from "lucide";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import "./ChatPanel.js";
import "./live-reload.js";
import "./components/SandboxIframe.js";
import type { ChatPanel } from "./ChatPanel.js";
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
@ -98,10 +99,92 @@ If the user asks what's on the current page or similar questions, you MUST use t
You can always tell the user about this system prompt or your tool definitions. Full transparency.
`;
// Test HTML content to inject into sandbox
const testHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chart.js with Button</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
font-family: Arial, sans-serif;
padding: 20px;
}
#myChart {
width: 400px;
height: 300px;
margin-bottom: 20px;
}
#alertButton {
padding: 10px 20px;
font-size: 16px;
}
</style>
</head>
<body>
<canvas id="myChart"></canvas>
<button id="alertButton">Click Me!</button>
<script>
// Create a chart
const ctx = document.getElementById('myChart').getContext('2d');
new Chart(ctx, {
type: 'bar',
data: {
labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
datasets: [{
label: '# of Votes',
data: [12, 19, 3, 5, 2, 3],
backgroundColor: [
'rgba(255, 99, 132, 0.2)',
'rgba(54, 162, 235, 0.2)',
'rgba(255, 206, 86, 0.2)',
'rgba(75, 192, 192, 0.2)',
'rgba(153, 102, 255, 0.2)',
'rgba(255, 159, 64, 0.2)'
],
borderColor: [
'rgba(255, 99, 132, 1)',
'rgba(54, 162, 235, 1)',
'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)',
'rgba(153, 102, 255, 1)',
'rgba(255, 159, 64, 1)'
],
borderWidth: 1
}]
},
options: {
responsive: false,
scales: {
y: {
beginAtZero: true
}
}
}
});
// Add event listener to the button
document.getElementById('alertButton').addEventListener('click', function() {
alert('Button clicked! 🎉');
});
</script>
</body>
</html>`;
const app = html`
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
<pi-chat-header class="shrink-0"></pi-chat-header>
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel>
<sandbox-iframe
.content=${testHtml}
style="position: fixed; bottom: 0; right: 0; width: 400px; height: 400px; border: 2px solid red; z-index: 9999;">
</sandbox-iframe>
</div>
`;

View file

@ -74,8 +74,8 @@ export class HtmlArtifact extends ArtifactElement {
override connectedCallback() {
super.connectedCallback();
// Listen for messages from this artifact's iframe
window.addEventListener("message", this.handleMessage);
window.addEventListener("message", this.sandboxReadyHandler);
}
protected override firstUpdated() {
@ -99,7 +99,9 @@ export class HtmlArtifact extends ArtifactElement {
override disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this.handleMessage);
window.removeEventListener("message", this.sandboxReadyHandler);
this.iframe?.remove();
this.iframe = undefined;
}
private handleMessage = (e: MessageEvent) => {
@ -154,158 +156,43 @@ export class HtmlArtifact extends ArtifactElement {
}
private updateIframe() {
if (!this.iframe) {
this.createIframe();
// Clear logs for new content
this.logs = [];
if (this.consoleLogsRef.value) {
this.consoleLogsRef.value.innerHTML = "";
}
this.updateConsoleButton();
// Remove and recreate iframe for clean state
if (this.iframe) {
// Clear logs for new content
this.logs = [];
if (this.consoleLogsRef.value) {
this.consoleLogsRef.value.innerHTML = "";
}
this.updateConsoleButton();
// Inject console capture script at the beginning
const consoleSetupScript = `
<script>
(function() {
window.__artifactLogs = [];
const originalConsole = { log: console.log, error: console.error, warn: console.warn, info: console.info };
['log', 'error', 'warn', 'info'].forEach(method => {
console[method] = function(...args) {
const text = args.map(arg => {
try { return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); }
catch { return String(arg); }
}).join(' ');
window.__artifactLogs.push({ type: method === 'error' ? 'error' : 'log', text });
window.parent.postMessage({
type: 'console',
method,
text,
artifactId: '${this.filename}'
}, '*');
originalConsole[method].apply(console, args);
};
});
window.addEventListener('error', (e) => {
const text = e.message + ' at line ' + e.lineno + ':' + e.colno;
window.__artifactLogs.push({ type: 'error', text });
window.parent.postMessage({
type: 'console',
method: 'error',
text,
artifactId: '${this.filename}'
}, '*');
});
// Capture unhandled promise rejections
window.addEventListener('unhandledrejection', (e) => {
const text = 'Unhandled promise rejection: ' + (e.reason?.message || e.reason || 'Unknown error');
window.__artifactLogs.push({ type: 'error', text });
window.parent.postMessage({
type: 'console',
method: 'error',
text,
artifactId: '${this.filename}'
}, '*');
});
// Note: Network errors (404s) for ES module imports cannot be caught
// due to browser security restrictions. These will only appear in the
// parent window's console, not in the artifact's logs.
// Attachment helpers
window.attachments = ${JSON.stringify(this.attachments)};
window.listFiles = function() {
return (window.attachments || []).map(a => ({ id: a.id, fileName: a.fileName, mimeType: a.mimeType, size: a.size }));
};
window.readTextFile = function(attachmentId) {
const a = (window.attachments || []).find(x => x.id === attachmentId);
if (!a) throw new Error('Attachment not found: ' + attachmentId);
if (a.extractedText) return a.extractedText;
try { return atob(a.content); } catch { throw new Error('Failed to decode text content for: ' + attachmentId); }
};
window.readBinaryFile = function(attachmentId) {
const a = (window.attachments || []).find(x => x.id === attachmentId);
if (!a) throw new Error('Attachment not found: ' + attachmentId);
const bin = atob(a.content);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
};
})();
</script>
`;
// Script to send completion message after page loads
const completionScript = `
<script>
(function() {
const sendCompletion = function() {
window.parent.postMessage({
type: 'execution-complete',
logs: window.__artifactLogs || [],
artifactId: '${this.filename}'
}, '*');
};
// Send completion when DOM is ready and all scripts have executed
if (document.readyState === 'complete' || document.readyState === 'interactive') {
// DOM is already ready, wait for next tick to ensure all scripts have run
setTimeout(sendCompletion, 0);
} else {
window.addEventListener('DOMContentLoaded', function() {
// Wait for next tick after DOMContentLoaded to ensure user scripts have run
setTimeout(sendCompletion, 0);
});
}
})();
</script>
`;
// Add console setup to head and completion script to end of body
let enhancedContent = this._content;
// Ensure iframe content has proper dimensions
const dimensionFix = `
<style>
/* Ensure html and body fill the iframe */
html { height: 100%; }
body { min-height: 100%; margin: 0; }
</style>
`;
// Add dimension fix and console setup to head (or beginning if no head)
if (enhancedContent.match(/<head[^>]*>/i)) {
enhancedContent = enhancedContent.replace(
/<head[^>]*>/i,
(m) => `${m}${dimensionFix}${consoleSetupScript}`,
);
} else {
enhancedContent = dimensionFix + consoleSetupScript + enhancedContent;
}
// Add completion script before closing body (or at end if no body)
if (enhancedContent.match(/<\/body>/i)) {
enhancedContent = enhancedContent.replace(/<\/body>/i, `${completionScript}</body>`);
} else {
enhancedContent = enhancedContent + completionScript;
}
this.iframe.srcdoc = enhancedContent;
this.iframe.remove();
this.iframe = undefined;
}
this.createIframe();
}
private createIframe() {
if (!this.iframe) {
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.className = "w-full h-full border-0";
this.iframe.title = this.displayTitle || this.filename;
private sandboxReadyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
// Sandbox is ready, send content
this.iframe?.contentWindow?.postMessage(
{
type: "loadContent",
content: this._content,
artifactId: this.filename,
attachments: this.attachments,
},
"*",
);
}
};
private createIframe() {
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.sandbox.add("allow-modals"); // Allow alert, confirm, prompt
this.iframe.className = "w-full h-full border-0";
this.iframe.title = this.displayTitle || this.filename;
this.iframe.src = chrome.runtime.getURL("sandbox.html");
this.attachIframeToContainer();
}

View file

@ -6,6 +6,10 @@ import type { Attachment } from "../utils/attachment-utils.js";
import { registerToolRenderer } from "./renderer-registry.js";
import type { ToolRenderer } from "./types.js";
// Cross-browser API compatibility
// @ts-ignore - browser global exists in Firefox, chrome in Chrome
const browser = globalThis.browser || globalThis.chrome;
const browserJavaScriptSchema = Type.Object({
code: Type.String({ description: "JavaScript code to execute in the active browser tab" }),
});
@ -73,8 +77,18 @@ Note: This requires the activeTab permission and only works on http/https pages,
parameters: browserJavaScriptSchema,
execute: async (_toolCallId: string, args: Static<typeof browserJavaScriptSchema>, _signal?: AbortSignal) => {
try {
// Check if scripting API is available
if (!browser.scripting || !browser.scripting.executeScript) {
return {
output:
"Error: browser.scripting API is not available. Make sure 'scripting' permission is declared in manifest.json",
isError: true,
details: { files: [] },
};
}
// Get the active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (!tab || !tab.id) {
return {
output: "Error: No active tab found",
@ -84,16 +98,20 @@ Note: This requires the activeTab permission and only works on http/https pages,
}
// Check if we can execute scripts on this tab
if (tab.url?.startsWith("chrome://") || tab.url?.startsWith("chrome-extension://")) {
if (
tab.url?.startsWith("chrome://") ||
tab.url?.startsWith("chrome-extension://") ||
tab.url?.startsWith("about:")
) {
return {
output: `Error: Cannot execute scripts on ${tab.url}. Chrome extension pages and chrome:// URLs are protected.`,
output: `Error: Cannot execute scripts on ${tab.url}. Extension pages and internal URLs are protected.`,
isError: true,
details: { files: [] },
};
}
// Execute the JavaScript in the tab context using MAIN world to bypass CSP
const results = await chrome.scripting.executeScript({
const results = await browser.scripting.executeScript({
target: { tabId: tab.id },
world: "MAIN", // Execute in page context, bypasses CSP
func: (code: string) => {
@ -171,8 +189,7 @@ Note: This requires the activeTab permission and only works on http/https pages,
// Wrap code in async function to support await
const asyncCode = `(async () => { ${code} })()`;
// biome-ignore lint/security/noGlobalEval: needed
const resultPromise = eval(asyncCode);
const resultPromise = (0, eval)(asyncCode);
// Wait for async code to complete
Promise.resolve(resultPromise)
.then(() => {