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" + + ""; + + // 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(() => {