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"] "pages": ["sandbox.html"]
}, },
"content_security_policy": { "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", "name": "pi-ai",
"description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.", "description": "Use @mariozechner/pi-ai to summarize and highlight the page you are reading.",
"version": "0.5.43", "version": "0.5.43",
"action": { "browser_action": {
"default_title": "Click to open sidebar" "default_title": "Click to open sidebar"
}, },
"background": { "background": {
"scripts": ["background.js"], "scripts": ["background.js"]
"type": "module"
}, },
"icons": { "icons": {
"16": "icon-16.png", "16": "icon-16.png",
@ -24,12 +23,10 @@
}, },
"open_at_install": false "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": [ "permissions": [
"storage", "storage",
"activeTab", "activeTab",
"scripting"
],
"host_permissions": [
"https://*/*", "https://*/*",
"http://localhost/*", "http://localhost/*",
"http://127.0.0.1/*" "http://127.0.0.1/*"
@ -37,13 +34,7 @@
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "pi-reader@mariozechner.at", "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 { 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 { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -51,8 +51,9 @@ const copyStatic = () => {
"icon-16.png", "icon-16.png",
"icon-48.png", "icon-48.png",
"icon-128.png", "icon-128.png",
join("src", "sandbox.html"),
join("src", "sandbox.js"),
join("src", "sidepanel.html"), join("src", "sidepanel.html"),
join("src", "sandbox.html")
]; ];
for (const relative of filesToCopy) { for (const relative of filesToCopy) {
@ -82,6 +83,16 @@ const run = async () => {
const ctx = await context(buildOptions); const ctx = await context(buildOptions);
await ctx.watch(); await ctx.watch();
copyStatic(); 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"); process.stdout.write("Watching for changes...\n");
} else { } else {
await build(buildOptions); await build(buildOptions);

View file

@ -3,21 +3,24 @@ declare const browser: any;
// Detect browser type // Detect browser type
const isFirefox = typeof browser !== "undefined" && typeof browser.runtime !== "undefined"; const isFirefox = typeof browser !== "undefined" && typeof browser.runtime !== "undefined";
const browserAPI = isFirefox ? browser : chrome;
// Open side panel/sidebar when extension icon is clicked // Open side panel/sidebar when extension icon is clicked
browserAPI.action.onClicked.addListener((tab: chrome.tabs.Tab) => { if (isFirefox) {
if (isFirefox) { // Firefox MV2: Use browserAction
// Firefox: Toggle the sidebar if (browser.browserAction) {
if (typeof browser !== "undefined" && browser.sidebarAction) { browser.browserAction.onClicked.addListener(() => {
browser.sidebarAction.toggle(); if (browser.sidebarAction) {
} browser.sidebarAction.toggle();
} else { }
// Chrome: Open the side panel });
}
} else {
// Chrome MV3: Use action API
chrome.action.onClicked.addListener((tab: chrome.tabs.Tab) => {
if (tab.id && chrome.sidePanel) { if (tab.id && chrome.sidePanel) {
chrome.sidePanel.open({ tabId: tab.id }); chrome.sidePanel.open({ tabId: tab.id });
} }
} });
}); }
export {}; 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> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="UTF-8">
<script> <meta name="viewport" content="width=device-width, initial-scale=1.0">
// Store files and console output for current execution <title>Sandboxed Content</title>
let returnedFiles = []; <style>
let consoleOutput = []; html { height: 100%; }
body { min-height: 100%; margin: 0; }
// Store attachments with helper methods </style>
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>
</head> </head>
<body> <body>
<div id="root"></div> <script src="sandbox.js"></script>
</body> </body>
</html> </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 "@mariozechner/mini-lit/dist/ThemeToggle.js";
import "./ChatPanel.js"; import "./ChatPanel.js";
import "./live-reload.js"; import "./live-reload.js";
import "./components/SandboxIframe.js";
import type { ChatPanel } from "./ChatPanel.js"; import type { ChatPanel } from "./ChatPanel.js";
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.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. 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` const app = html`
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden"> <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-header class="shrink-0"></pi-chat-header>
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel> <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> </div>
`; `;

View file

@ -74,8 +74,8 @@ export class HtmlArtifact extends ArtifactElement {
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
// Listen for messages from this artifact's iframe
window.addEventListener("message", this.handleMessage); window.addEventListener("message", this.handleMessage);
window.addEventListener("message", this.sandboxReadyHandler);
} }
protected override firstUpdated() { protected override firstUpdated() {
@ -99,7 +99,9 @@ export class HtmlArtifact extends ArtifactElement {
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
window.removeEventListener("message", this.handleMessage); window.removeEventListener("message", this.handleMessage);
window.removeEventListener("message", this.sandboxReadyHandler);
this.iframe?.remove(); this.iframe?.remove();
this.iframe = undefined;
} }
private handleMessage = (e: MessageEvent) => { private handleMessage = (e: MessageEvent) => {
@ -154,158 +156,43 @@ export class HtmlArtifact extends ArtifactElement {
} }
private updateIframe() { private updateIframe() {
if (!this.iframe) { // Clear logs for new content
this.createIframe(); this.logs = [];
if (this.consoleLogsRef.value) {
this.consoleLogsRef.value.innerHTML = "";
} }
this.updateConsoleButton();
// Remove and recreate iframe for clean state
if (this.iframe) { if (this.iframe) {
// Clear logs for new content this.iframe.remove();
this.logs = []; this.iframe = undefined;
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.createIframe();
} }
private createIframe() { private sandboxReadyHandler = (e: MessageEvent) => {
if (!this.iframe) { if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
this.iframe = document.createElement("iframe"); // Sandbox is ready, send content
this.iframe.sandbox.add("allow-scripts"); this.iframe?.contentWindow?.postMessage(
this.iframe.className = "w-full h-full border-0"; {
this.iframe.title = this.displayTitle || this.filename; 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(); this.attachIframeToContainer();
} }

View file

@ -6,6 +6,10 @@ import type { Attachment } from "../utils/attachment-utils.js";
import { registerToolRenderer } from "./renderer-registry.js"; import { registerToolRenderer } from "./renderer-registry.js";
import type { ToolRenderer } from "./types.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({ const browserJavaScriptSchema = Type.Object({
code: Type.String({ description: "JavaScript code to execute in the active browser tab" }), 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, parameters: browserJavaScriptSchema,
execute: async (_toolCallId: string, args: Static<typeof browserJavaScriptSchema>, _signal?: AbortSignal) => { execute: async (_toolCallId: string, args: Static<typeof browserJavaScriptSchema>, _signal?: AbortSignal) => {
try { 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 // 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) { if (!tab || !tab.id) {
return { return {
output: "Error: No active tab found", 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 // 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 { 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, isError: true,
details: { files: [] }, details: { files: [] },
}; };
} }
// Execute the JavaScript in the tab context using MAIN world to bypass CSP // 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 }, target: { tabId: tab.id },
world: "MAIN", // Execute in page context, bypasses CSP world: "MAIN", // Execute in page context, bypasses CSP
func: (code: string) => { 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 // Wrap code in async function to support await
const asyncCode = `(async () => { ${code} })()`; const asyncCode = `(async () => { ${code} })()`;
// biome-ignore lint/security/noGlobalEval: needed // biome-ignore lint/security/noGlobalEval: needed
const resultPromise = eval(asyncCode); const resultPromise = (0, eval)(asyncCode);
// Wait for async code to complete // Wait for async code to complete
Promise.resolve(resultPromise) Promise.resolve(resultPromise)
.then(() => { .then(() => {