diff --git a/packages/browser-extension/manifest.chrome.json b/packages/browser-extension/manifest.chrome.json
index 43c2e806..a762119f 100644
--- a/packages/browser-extension/manifest.chrome.json
+++ b/packages/browser-extension/manifest.chrome.json
@@ -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'"
}
}
\ No newline at end of file
diff --git a/packages/browser-extension/manifest.firefox.json b/packages/browser-extension/manifest.firefox.json
index ff5e28dd..4d70b337 100644
--- a/packages/browser-extension/manifest.firefox.json
+++ b/packages/browser-extension/manifest.firefox.json
@@ -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'"
}
-}
\ No newline at end of file
+}
diff --git a/packages/browser-extension/manifest.json b/packages/browser-extension/manifest.json
deleted file mode 100644
index eb659b1e..00000000
--- a/packages/browser-extension/manifest.json
+++ /dev/null
@@ -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/*"
- ]
-}
diff --git a/packages/browser-extension/scripts/build.mjs b/packages/browser-extension/scripts/build.mjs
index 36a639ae..ba44d963 100644
--- a/packages/browser-extension/scripts/build.mjs
+++ b/packages/browser-extension/scripts/build.mjs
@@ -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);
diff --git a/packages/browser-extension/src/background.ts b/packages/browser-extension/src/background.ts
index c1e61bb1..b68bfa4d 100644
--- a/packages/browser-extension/src/background.ts
+++ b/packages/browser-extension/src/background.ts
@@ -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 {};
diff --git a/packages/browser-extension/src/components/SandboxIframe.ts b/packages/browser-extension/src/components/SandboxIframe.ts
new file mode 100644
index 00000000..b7e2f097
--- /dev/null
+++ b/packages/browser-extension/src/components/SandboxIframe.ts
@@ -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();
+ }
+}
diff --git a/packages/browser-extension/src/sandbox.html b/packages/browser-extension/src/sandbox.html
index 2f692eec..850ca48b 100644
--- a/packages/browser-extension/src/sandbox.html
+++ b/packages/browser-extension/src/sandbox.html
@@ -1,174 +1,15 @@
-
+
-
-
+
+
+ Sandboxed Content
+
-
+
diff --git a/packages/browser-extension/src/sandbox.js b/packages/browser-extension/src/sandbox.js
new file mode 100644
index 00000000..763a304b
--- /dev/null
+++ b/packages/browser-extension/src/sandbox.js
@@ -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 , or at the start of document
+ const headMatch = content.match(/]*>/i);
+ if (headMatch) {
+ const index = headMatch.index + headMatch[0].length;
+ content = content.slice(0, index) + helperScript + content.slice(index);
+ } else {
+ const htmlMatch = content.match(/]*>/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" }, "*");
diff --git a/packages/browser-extension/src/sidepanel.ts b/packages/browser-extension/src/sidepanel.ts
index 24439ac5..a1b40020 100644
--- a/packages/browser-extension/src/sidepanel.ts
+++ b/packages/browser-extension/src/sidepanel.ts
@@ -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 = `
+
+
+
+
+ Chart.js with Button
+
+
+
+
+
+
+
+
+
+`;
+
const app = html`
`;
diff --git a/packages/browser-extension/src/tools/artifacts/HtmlArtifact.ts b/packages/browser-extension/src/tools/artifacts/HtmlArtifact.ts
index 55be8bb8..e1370e4e 100644
--- a/packages/browser-extension/src/tools/artifacts/HtmlArtifact.ts
+++ b/packages/browser-extension/src/tools/artifacts/HtmlArtifact.ts
@@ -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 to send completion message after page loads
- const completionScript = `
-
- `;
-
- // Add console setup to head and completion script to end of body
- let enhancedContent = this._content;
-
- // Ensure iframe content has proper dimensions
- const dimensionFix = `
-
- `;
-
- // Add dimension fix and console setup to head (or beginning if no head)
- if (enhancedContent.match(/]*>/i)) {
- enhancedContent = enhancedContent.replace(
- /]*>/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}`);
- } 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();
}
diff --git a/packages/browser-extension/src/tools/browser-javascript.ts b/packages/browser-extension/src/tools/browser-javascript.ts
index 9e6cfd7b..15d2cdd6 100644
--- a/packages/browser-extension/src/tools/browser-javascript.ts
+++ b/packages/browser-extension/src/tools/browser-javascript.ts
@@ -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, _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(() => {