diff --git a/package-lock.json b/package-lock.json
index 88c8b60d..56657c03 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3144,6 +3144,18 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/js-interpreter": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/js-interpreter/-/js-interpreter-6.0.1.tgz",
+ "integrity": "sha512-XfPw6y1FzFwHcGYB62jzPUoSCoCSIL+dICMjRJx6f8V/AmTczeodDOaVxWc4GU4p7qeN7ieuMXNKxScoaBkJ6A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimist": "^1.2.8"
+ },
+ "bin": {
+ "js-interpreter": "lib/cli.min.js"
+ }
+ },
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -3603,7 +3615,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -5389,6 +5400,7 @@
"@mariozechner/mini-lit": "^0.1.4",
"@mariozechner/pi-ai": "^0.5.43",
"docx-preview": "^0.3.7",
+ "js-interpreter": "^6.0.1",
"jszip": "^3.10.1",
"lit": "^3.3.1",
"lucide": "^0.544.0",
diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts
index 3a21d0da..08335807 100644
--- a/packages/ai/src/utils/validation.ts
+++ b/packages/ai/src/utils/validation.ts
@@ -7,9 +7,25 @@ const addFormats = (addFormatsModule as any).default || addFormatsModule;
import type { Tool, ToolCall } from "../types.js";
-// Create a singleton AJV instance with formats
-const ajv = new Ajv({ allErrors: true, strict: false });
-addFormats(ajv);
+// Detect if we're in a browser extension environment with strict CSP
+// Chrome extensions with Manifest V3 don't allow eval/Function constructor
+const isBrowserExtension = typeof globalThis !== "undefined" && (globalThis as any).chrome?.runtime?.id !== undefined;
+
+// Create a singleton AJV instance with formats (only if not in browser extension)
+// AJV requires 'unsafe-eval' CSP which is not allowed in Manifest V3
+let ajv: any = null;
+if (!isBrowserExtension) {
+ try {
+ ajv = new Ajv({
+ allErrors: true,
+ strict: false,
+ });
+ addFormats(ajv);
+ } catch (e) {
+ // AJV initialization failed (likely CSP restriction)
+ console.warn("AJV validation disabled due to CSP restrictions");
+ }
+}
/**
* Validates tool call arguments against the tool's TypeBox schema
@@ -19,6 +35,13 @@ addFormats(ajv);
* @throws Error with formatted message if validation fails
*/
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
+ // Skip validation in browser extension environment (CSP restrictions prevent AJV from working)
+ if (!ajv || isBrowserExtension) {
+ // Trust the LLM's output without validation
+ // Browser extensions can't use AJV due to Manifest V3 CSP restrictions
+ return toolCall.arguments;
+ }
+
// Compile the schema
const validate = ajv.compile(tool.parameters);
diff --git a/packages/browser-extension/manifest.chrome.json b/packages/browser-extension/manifest.chrome.json
index 257f5321..43c2e806 100644
--- a/packages/browser-extension/manifest.chrome.json
+++ b/packages/browser-extension/manifest.chrome.json
@@ -28,5 +28,11 @@
"https://*/*",
"http://localhost/*",
"http://127.0.0.1/*"
- ]
+ ],
+ "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.firefox.json b/packages/browser-extension/manifest.firefox.json
index fd0fefe4..ff5e28dd 100644
--- a/packages/browser-extension/manifest.firefox.json
+++ b/packages/browser-extension/manifest.firefox.json
@@ -39,5 +39,11 @@
"id": "pi-reader@mariozechner.at",
"strict_min_version": "115.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/package.json b/packages/browser-extension/package.json
index 24c716b9..75a1ee75 100644
--- a/packages/browser-extension/package.json
+++ b/packages/browser-extension/package.json
@@ -18,6 +18,7 @@
"@mariozechner/mini-lit": "^0.1.4",
"@mariozechner/pi-ai": "^0.5.43",
"docx-preview": "^0.3.7",
+ "js-interpreter": "^6.0.1",
"jszip": "^3.10.1",
"lit": "^3.3.1",
"lucide": "^0.544.0",
diff --git a/packages/browser-extension/scripts/build.mjs b/packages/browser-extension/scripts/build.mjs
index 29fad24f..36a639ae 100644
--- a/packages/browser-extension/scripts/build.mjs
+++ b/packages/browser-extension/scripts/build.mjs
@@ -51,7 +51,8 @@ const copyStatic = () => {
"icon-16.png",
"icon-48.png",
"icon-128.png",
- join("src", "sidepanel.html")
+ join("src", "sidepanel.html"),
+ join("src", "sandbox.html")
];
for (const relative of filesToCopy) {
diff --git a/packages/browser-extension/src/ChatPanel.ts b/packages/browser-extension/src/ChatPanel.ts
index 730a9c1d..14de27b8 100644
--- a/packages/browser-extension/src/ChatPanel.ts
+++ b/packages/browser-extension/src/ChatPanel.ts
@@ -4,6 +4,7 @@ import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import "./AgentInterface.js";
import { AgentSession } from "./state/agent-session.js";
+import { browserJavaScriptTool, createJavaScriptReplTool } from "./tools/index.js";
import { getAuthToken } from "./utils/auth-token.js";
@customElement("pi-chat-panel")
@@ -23,17 +24,44 @@ export class ChatPanel extends LitElement {
this.style.height = "100%";
this.style.minHeight = "0";
+ // Create JavaScript REPL tool with attachments provider
+ const javascriptReplTool = createJavaScriptReplTool();
+
// Create agent session with default settings
this.session = new AgentSession({
initialState: {
systemPrompt: "You are a helpful AI assistant.",
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
- tools: [calculateTool, getCurrentTimeTool],
+ tools: [calculateTool, getCurrentTimeTool, browserJavaScriptTool, javascriptReplTool],
thinkingLevel: "off",
},
authTokenProvider: async () => getAuthToken(),
transportMode: "direct", // Use direct mode by default (API keys from KeyStore)
});
+
+ // Wire up attachments provider for JavaScript REPL tool
+ // We'll need to get attachments from the AgentInterface
+ javascriptReplTool.attachmentsProvider = () => {
+ // Get all attachments from conversation messages
+ const attachments: any[] = [];
+ for (const message of this.session.state.messages) {
+ if (message.role === "user") {
+ const content = Array.isArray(message.content) ? message.content : [message.content];
+ for (const block of content) {
+ if (typeof block !== "string" && block.type === "image") {
+ attachments.push({
+ id: `image-${attachments.length}`,
+ fileName: "image.png",
+ mimeType: block.mimeType || "image/png",
+ size: 0,
+ content: block.data,
+ });
+ }
+ }
+ }
+ }
+ return attachments;
+ };
}
render() {
diff --git a/packages/browser-extension/src/MessageEditor.ts b/packages/browser-extension/src/MessageEditor.ts
index 295f29db..41371c5f 100644
--- a/packages/browser-extension/src/MessageEditor.ts
+++ b/packages/browser-extension/src/MessageEditor.ts
@@ -3,7 +3,7 @@ import type { Model } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
-import { Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
+import { BookOpen, Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
import "./AttachmentTile.js";
import { type Attachment, loadAttachment } from "./utils/attachment-utils.js";
import { i18n } from "./utils/i18n.js";
@@ -194,7 +194,7 @@ export class MessageEditor extends LitElement {
-
+
${
this.showAttachmentButton
diff --git a/packages/browser-extension/src/StreamingMessageContainer.ts b/packages/browser-extension/src/StreamingMessageContainer.ts
index af0d8e3c..d4c4b4a6 100644
--- a/packages/browser-extension/src/StreamingMessageContainer.ts
+++ b/packages/browser-extension/src/StreamingMessageContainer.ts
@@ -46,7 +46,9 @@ export class StreamingMessageContainer extends LitElement {
requestAnimationFrame(async () => {
// Only apply the update if we haven't been cleared
if (!this._immediateUpdate && this._pendingMessage !== null) {
- this._message = this._pendingMessage;
+ // Deep clone the message to ensure Lit detects changes in nested properties
+ // (like toolCall.arguments being mutated during streaming)
+ this._message = JSON.parse(JSON.stringify(this._pendingMessage));
this.requestUpdate();
}
// Reset for next batch
diff --git a/packages/browser-extension/src/sandbox.html b/packages/browser-extension/src/sandbox.html
new file mode 100644
index 00000000..2f692eec
--- /dev/null
+++ b/packages/browser-extension/src/sandbox.html
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/browser-extension/src/tools/browser-javascript.ts b/packages/browser-extension/src/tools/browser-javascript.ts
new file mode 100644
index 00000000..9e6cfd7b
--- /dev/null
+++ b/packages/browser-extension/src/tools/browser-javascript.ts
@@ -0,0 +1,424 @@
+import { html, type TemplateResult } from "@mariozechner/mini-lit";
+import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
+import { type Static, Type } from "@sinclair/typebox";
+import "../ConsoleBlock.js"; // Ensure console-block is registered
+import type { Attachment } from "../utils/attachment-utils.js";
+import { registerToolRenderer } from "./renderer-registry.js";
+import type { ToolRenderer } from "./types.js";
+
+const browserJavaScriptSchema = Type.Object({
+ code: Type.String({ description: "JavaScript code to execute in the active browser tab" }),
+});
+
+export type BrowserJavaScriptToolResult = {
+ files?:
+ | {
+ fileName: string;
+ contentBase64: string;
+ mimeType: string;
+ size: number;
+ }[]
+ | undefined;
+};
+
+export const browserJavaScriptTool: AgentTool
= {
+ label: "Browser JavaScript",
+ name: "browser_javascript",
+ description: `Execute JavaScript code in the context of the active browser tab.
+
+Environment: The current page's JavaScript context with full access to:
+- The page's DOM (document, window, all elements)
+- The page's JavaScript variables and functions
+- All web APIs available to the page
+- localStorage, sessionStorage, cookies
+- Page frameworks (React, Vue, Angular, etc.)
+- Can modify the page, read data, interact with page scripts
+
+The code is executed using eval() in the page context, so it can:
+- Access and modify global variables
+- Call page functions
+- Read/write to localStorage, cookies, etc.
+- Make fetch requests from the page's origin
+- Interact with page frameworks (React, Vue, etc.)
+
+Output:
+- console.log() - All output is captured as text
+- await returnFile(filename, content, mimeType?) - Create downloadable files (async function!)
+ * Always use await with returnFile
+ * REQUIRED: For Blob/Uint8Array binary content, you MUST supply a proper MIME type (e.g., "image/png").
+ If omitted, throws an Error with stack trace pointing to the offending line.
+ * Strings without a MIME default to text/plain.
+ * Objects are auto-JSON stringified and default to application/json unless a MIME is provided.
+ * Canvas images: Use toBlob() with await Promise wrapper
+ * Examples:
+ - await returnFile('data.txt', 'Hello World', 'text/plain')
+ - await returnFile('data.json', {key: 'value'}, 'application/json')
+ - await returnFile('page-screenshot.png', blob, 'image/png')
+ - Extract page data to CSV:
+ const links = Array.from(document.querySelectorAll('a')).map(a => ({text: a.textContent, href: a.href}));
+ const csv = 'text,href\\n' + links.map(l => \`"\${l.text}","\${l.href}"\`).join('\\n');
+ await returnFile('links.csv', csv, 'text/csv');
+
+Examples:
+- Get page title: document.title
+- Get all links: Array.from(document.querySelectorAll('a')).map(a => ({text: a.textContent, href: a.href}))
+- Extract all text: document.body.innerText
+- Modify page: document.body.style.backgroundColor = 'lightblue'
+- Read page data: window.myAppData
+- Get cookies: document.cookie
+- Execute page functions: window.myPageFunction()
+- Access React/Vue instances: window.__REACT_DEVTOOLS_GLOBAL_HOOK__, window.$vm
+
+Note: This requires the activeTab permission and only works on http/https pages, not on chrome:// URLs.`,
+ parameters: browserJavaScriptSchema,
+ execute: async (_toolCallId: string, args: Static, _signal?: AbortSignal) => {
+ try {
+ // Get the active tab
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
+ if (!tab || !tab.id) {
+ return {
+ output: "Error: No active tab found",
+ isError: true,
+ details: { files: [] },
+ };
+ }
+
+ // Check if we can execute scripts on this tab
+ if (tab.url?.startsWith("chrome://") || tab.url?.startsWith("chrome-extension://")) {
+ return {
+ output: `Error: Cannot execute scripts on ${tab.url}. Chrome extension pages and chrome:// 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({
+ target: { tabId: tab.id },
+ world: "MAIN", // Execute in page context, bypasses CSP
+ func: (code: string) => {
+ return new Promise((resolve) => {
+ // Capture console output
+ const consoleOutput: Array<{ type: string; args: unknown[] }> = [];
+ const files: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }> = [];
+
+ const originalConsole = {
+ log: console.log,
+ warn: console.warn,
+ error: console.error,
+ };
+
+ // Override console methods to capture output
+ console.log = (...args: unknown[]) => {
+ consoleOutput.push({ type: "log", args });
+ originalConsole.log(...args);
+ };
+ console.warn = (...args: unknown[]) => {
+ consoleOutput.push({ type: "warn", args });
+ originalConsole.warn(...args);
+ };
+ console.error = (...args: unknown[]) => {
+ consoleOutput.push({ type: "error", args });
+ originalConsole.error(...args);
+ };
+
+ // Create returnFile function
+ (window as any).returnFile = async (
+ fileName: string,
+ content: string | Uint8Array | Blob | Record,
+ mimeType?: string,
+ ) => {
+ let finalContent: string | Uint8Array;
+ let finalMimeType: string;
+
+ if (content instanceof Blob) {
+ // Convert Blob to Uint8Array
+ const arrayBuffer = await content.arrayBuffer();
+ finalContent = new Uint8Array(arrayBuffer);
+ finalMimeType = mimeType || content.type || "application/octet-stream";
+
+ // Enforce MIME type requirement for binary data
+ if (!mimeType && !content.type) {
+ throw new Error(
+ `returnFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., "image/png").`,
+ );
+ }
+ } else if (content instanceof Uint8Array) {
+ finalContent = content;
+ if (!mimeType) {
+ throw new Error(
+ `returnFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., "image/png").`,
+ );
+ }
+ finalMimeType = mimeType;
+ } else if (typeof content === "string") {
+ finalContent = content;
+ finalMimeType = mimeType || "text/plain";
+ } else {
+ // Assume it's an object to be JSON stringified
+ finalContent = JSON.stringify(content, null, 2);
+ finalMimeType = mimeType || "application/json";
+ }
+
+ files.push({
+ fileName,
+ content: finalContent,
+ mimeType: finalMimeType,
+ });
+ };
+
+ try {
+ // Wrap code in async function to support await
+ const asyncCode = `(async () => { ${code} })()`;
+ // biome-ignore lint/security/noGlobalEval: needed
+ const resultPromise = eval(asyncCode);
+
+ // Wait for async code to complete
+ Promise.resolve(resultPromise)
+ .then(() => {
+ // Restore console
+ console.log = originalConsole.log;
+ console.warn = originalConsole.warn;
+ console.error = originalConsole.error;
+
+ // Clean up returnFile
+ delete (window as any).returnFile;
+
+ resolve({
+ success: true,
+ console: consoleOutput,
+ files: files,
+ });
+ })
+ .catch((error: unknown) => {
+ // Restore console
+ console.log = originalConsole.log;
+ console.warn = originalConsole.warn;
+ console.error = originalConsole.error;
+
+ // Clean up returnFile
+ delete (window as any).returnFile;
+
+ const err = error as Error;
+ resolve({
+ success: false,
+ error: err.message,
+ stack: err.stack,
+ console: consoleOutput,
+ });
+ });
+ } catch (error: unknown) {
+ // Restore console
+ console.log = originalConsole.log;
+ console.warn = originalConsole.warn;
+ console.error = originalConsole.error;
+
+ // Clean up returnFile
+ delete (window as any).returnFile;
+
+ const err = error as Error;
+ resolve({
+ success: false,
+ error: err.message,
+ stack: err.stack,
+ console: consoleOutput,
+ });
+ }
+ });
+ },
+ args: [args.code],
+ });
+
+ const result = results[0]?.result as
+ | {
+ success: boolean;
+ console?: Array<{ type: string; args: unknown[] }>;
+ files?: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }>;
+ error?: string;
+ stack?: string;
+ }
+ | undefined;
+
+ if (!result) {
+ return {
+ output: "Error: No result returned from script execution",
+ isError: true,
+ details: { files: [] },
+ };
+ }
+
+ if (!result.success) {
+ // Build error output with console logs if any
+ let errorOutput = `Error: ${result.error}\n\nStack trace:\n${result.stack || "No stack trace available"}`;
+
+ if (result.console && result.console.length > 0) {
+ errorOutput += "\n\nConsole output:\n";
+ for (const entry of result.console) {
+ const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "[LOG]";
+ const line = `${prefix} ${entry.args.join(" ")}`;
+ errorOutput += line + "\n";
+ }
+ }
+
+ return {
+ output: errorOutput,
+ isError: true,
+ details: { files: [] },
+ };
+ }
+
+ // Build output with console logs
+ let output = "";
+
+ // Add console output
+ if (result.console && result.console.length > 0) {
+ for (const entry of result.console) {
+ const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "";
+ const line = prefix ? `${prefix} ${entry.args.join(" ")}` : entry.args.join(" ");
+ output += line + "\n";
+ }
+ }
+
+ // Add file notifications
+ if (result.files && result.files.length > 0) {
+ output += `\n[Files returned: ${result.files.length}]\n`;
+ for (const file of result.files) {
+ output += ` - ${file.fileName} (${file.mimeType})\n`;
+ }
+ }
+
+ // Convert files to base64 for transport
+ const files = (result.files || []).map(
+ (f: { fileName: string; content: string | Uint8Array; mimeType: string }) => {
+ const toBase64 = (input: string | Uint8Array): { base64: string; size: number } => {
+ if (input instanceof Uint8Array) {
+ let binary = "";
+ const chunk = 0x8000;
+ for (let i = 0; i < input.length; i += chunk) {
+ binary += String.fromCharCode(...input.subarray(i, i + chunk));
+ }
+ return { base64: btoa(binary), size: input.length };
+ } else {
+ const enc = new TextEncoder();
+ const bytes = enc.encode(input);
+ let binary = "";
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
+ }
+ return { base64: btoa(binary), size: bytes.length };
+ }
+ };
+
+ const { base64, size } = toBase64(f.content);
+ return {
+ fileName: f.fileName || "file",
+ mimeType: f.mimeType || "application/octet-stream",
+ size,
+ contentBase64: base64,
+ };
+ },
+ );
+
+ return {
+ output: output.trim() || "Code executed successfully (no output)",
+ isError: false,
+ details: { files },
+ };
+ } catch (error: unknown) {
+ const err = error as Error;
+ return {
+ output: `Error executing script: ${err.message}`,
+ isError: true,
+ details: { files: [] },
+ };
+ }
+ },
+};
+
+// Browser JavaScript renderer
+interface BrowserJavaScriptParams {
+ code: string;
+}
+
+interface BrowserJavaScriptResult {
+ files?: Array<{
+ fileName: string;
+ mimeType: string;
+ size: number;
+ contentBase64: string;
+ }>;
+}
+
+export const browserJavaScriptRenderer: ToolRenderer = {
+ renderParams(params: BrowserJavaScriptParams, isStreaming?: boolean): TemplateResult {
+ if (isStreaming && (!params.code || params.code.length === 0)) {
+ return html`Writing JavaScript code...
`;
+ }
+
+ return html`
+ Executing in active tab
+
+ `;
+ },
+
+ renderResult(_params: BrowserJavaScriptParams, result: ToolResultMessage): TemplateResult {
+ const output = result.output || "";
+ const files = result.details?.files || [];
+ const isError = result.isError === true;
+
+ const attachments: Attachment[] = files.map((f, i) => {
+ // Decode base64 content for text files to show in overlay
+ let extractedText: string | undefined;
+ const isTextBased =
+ f.mimeType?.startsWith("text/") ||
+ f.mimeType === "application/json" ||
+ f.mimeType === "application/javascript" ||
+ f.mimeType?.includes("xml");
+
+ if (isTextBased && f.contentBase64) {
+ try {
+ extractedText = atob(f.contentBase64);
+ } catch (e) {
+ console.warn("Failed to decode base64 content for", f.fileName);
+ }
+ }
+
+ return {
+ id: `browser-js-${Date.now()}-${i}`,
+ type: f.mimeType?.startsWith("image/") ? "image" : "document",
+ fileName: f.fileName || `file-${i}`,
+ mimeType: f.mimeType || "application/octet-stream",
+ size: f.size ?? 0,
+ content: f.contentBase64,
+ preview: f.mimeType?.startsWith("image/") ? f.contentBase64 : undefined,
+ extractedText,
+ };
+ });
+
+ if (isError) {
+ return html`
+
+
Execution failed:
+
${output}
+
+ `;
+ }
+
+ return html`
+
+ ${output ? html`
` : ""}
+ ${
+ attachments.length
+ ? html`
+ ${attachments.map((att) => html`
`)}
+
`
+ : ""
+ }
+
+ `;
+ },
+};
+
+// Auto-register the renderer
+registerToolRenderer(browserJavaScriptTool.name, browserJavaScriptRenderer);
diff --git a/packages/browser-extension/src/tools/index.ts b/packages/browser-extension/src/tools/index.ts
index e24aa90d..a40588b4 100644
--- a/packages/browser-extension/src/tools/index.ts
+++ b/packages/browser-extension/src/tools/index.ts
@@ -1,10 +1,12 @@
-import { html, type TemplateResult } from "@mariozechner/mini-lit";
+import type { TemplateResult } from "@mariozechner/mini-lit";
import type { ToolResultMessage } from "@mariozechner/pi-ai";
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
import { BashRenderer } from "./renderers/BashRenderer.js";
import { CalculateRenderer } from "./renderers/CalculateRenderer.js";
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
import { GetCurrentTimeRenderer } from "./renderers/GetCurrentTimeRenderer.js";
+import "./javascript-repl.js"; // Import for side effects (registers renderer)
+import "./browser-javascript.js"; // Import for side effects (registers renderer)
// Register all built-in tool renderers
registerToolRenderer("calculate", new CalculateRenderer());
@@ -36,3 +38,5 @@ export function renderToolResult(toolName: string, params: any, result: ToolResu
}
export { registerToolRenderer, getToolRenderer };
+export { browserJavaScriptTool } from "./browser-javascript.js";
+export { createJavaScriptReplTool, javascriptReplTool } from "./javascript-repl.js";
diff --git a/packages/browser-extension/src/tools/javascript-repl.ts b/packages/browser-extension/src/tools/javascript-repl.ts
new file mode 100644
index 00000000..16da6e2d
--- /dev/null
+++ b/packages/browser-extension/src/tools/javascript-repl.ts
@@ -0,0 +1,429 @@
+import { html, type TemplateResult } from "@mariozechner/mini-lit";
+import type { AgentTool, ToolResultMessage } from "@mariozechner/pi-ai";
+import { type Static, Type } from "@sinclair/typebox";
+import type { Attachment } from "../utils/attachment-utils.js";
+
+import { registerToolRenderer } from "./renderer-registry.js";
+import type { ToolRenderer } from "./types.js";
+import "../ConsoleBlock.js"; // Ensure console-block is registered
+
+// Core JavaScript REPL execution logic without UI dependencies
+export interface ReplExecuteResult {
+ success: boolean;
+ console?: Array<{ type: string; args: any[] }>;
+ files?: Array<{ fileName: string; content: string | Uint8Array; mimeType: string }>;
+ error?: { message: string; stack: string };
+}
+
+export class ReplExecutor {
+ private iframe: HTMLIFrameElement;
+ private ready: boolean = false;
+ private attachments: any[] = [];
+ // biome-ignore lint/complexity/noBannedTypes: fine here
+ private currentExecution: { resolve: Function; reject: Function } | null = null;
+
+ constructor(attachments: any[]) {
+ this.attachments = attachments;
+ this.iframe = this.createIframe();
+ this.setupMessageHandler();
+ this.initialize();
+ }
+
+ private createIframe(): HTMLIFrameElement {
+ const iframe = document.createElement("iframe");
+ // Use the sandboxed page from the manifest
+ iframe.src = chrome.runtime.getURL("sandbox.html");
+ iframe.style.display = "none";
+ document.body.appendChild(iframe);
+ return iframe;
+ }
+
+ private setupMessageHandler() {
+ const handler = (event: MessageEvent) => {
+ if (event.source !== this.iframe.contentWindow) return;
+
+ if (event.data.type === "ready") {
+ this.ready = true;
+ } else if (event.data.type === "result" && this.currentExecution) {
+ const { resolve } = this.currentExecution;
+ this.currentExecution = null;
+ resolve(event.data);
+ this.cleanup();
+ } else if (event.data.type === "error" && this.currentExecution) {
+ const { resolve } = this.currentExecution;
+ this.currentExecution = null;
+ resolve({
+ success: false,
+ error: event.data.error,
+ console: event.data.console || [],
+ });
+ this.cleanup();
+ }
+ };
+
+ window.addEventListener("message", handler);
+ // Store handler reference for cleanup
+ (this.iframe as any).__messageHandler = handler;
+ }
+
+ private initialize() {
+ // Send attachments once iframe is loaded
+ this.iframe.onload = () => {
+ setTimeout(() => {
+ this.iframe.contentWindow?.postMessage(
+ {
+ type: "setAttachments",
+ attachments: this.attachments,
+ },
+ "*",
+ );
+ }, 100);
+ };
+ }
+
+ cleanup() {
+ // Remove message handler
+ const handler = (this.iframe as any).__messageHandler;
+ if (handler) {
+ window.removeEventListener("message", handler);
+ }
+ // Remove iframe
+ this.iframe.remove();
+
+ // If there's a pending execution, reject it
+ if (this.currentExecution) {
+ this.currentExecution.reject(new Error("Execution aborted"));
+ this.currentExecution = null;
+ }
+ }
+
+ async execute(code: string): Promise {
+ return new Promise((resolve, reject) => {
+ this.currentExecution = { resolve, reject };
+
+ // Wait for iframe to be ready
+ const checkReady = () => {
+ if (this.ready) {
+ this.iframe.contentWindow?.postMessage(
+ {
+ type: "execute",
+ code: code,
+ },
+ "*",
+ );
+ } else {
+ setTimeout(checkReady, 10);
+ }
+ };
+ checkReady();
+
+ // Timeout after 30 seconds
+ setTimeout(() => {
+ if (this.currentExecution?.resolve === resolve) {
+ this.currentExecution = null;
+ resolve({
+ success: false,
+ error: { message: "Execution timeout (30s)", stack: "" },
+ });
+ this.cleanup();
+ }
+ }, 30000);
+ });
+ }
+}
+
+// Execute JavaScript code with attachments
+export async function executeJavaScript(
+ code: string,
+ attachments: any[] = [],
+ signal?: AbortSignal,
+): Promise<{ output: string; files?: Array<{ fileName: string; content: any; mimeType: string }> }> {
+ if (!code) {
+ throw new Error("Code parameter is required");
+ }
+
+ // Check for abort before starting
+ if (signal?.aborted) {
+ throw new Error("Execution aborted");
+ }
+
+ // Create a one-shot executor
+ const executor = new ReplExecutor(attachments);
+
+ // Listen for abort signal
+ const abortHandler = () => {
+ executor.cleanup();
+ };
+ signal?.addEventListener("abort", abortHandler);
+
+ try {
+ const result = await executor.execute(code);
+
+ // Return plain text output
+ if (!result.success) {
+ // Return error as plain text
+ return {
+ output: `${"Error:"} ${result.error?.message || "Unknown error"}\n${result.error?.stack || ""}`,
+ };
+ }
+
+ // Build plain text response
+ let output = "";
+
+ // Add console output
+ if (result.console && result.console.length > 0) {
+ for (const entry of result.console) {
+ const prefix = entry.type === "error" ? "[ERROR]" : entry.type === "warn" ? "[WARN]" : "";
+ const line = prefix ? `${prefix} ${entry.args.join(" ")}` : entry.args.join(" ");
+ output += line + "\n";
+ }
+ }
+
+ // Add file notifications
+ if (result.files && result.files.length > 0) {
+ output += `\n[Files returned: ${result.files.length}]\n`;
+ for (const file of result.files) {
+ output += ` - ${file.fileName} (${file.mimeType})\n`;
+ }
+ } else {
+ // Explicitly note when no files were returned (helpful for debugging)
+ if (code.includes("returnFile")) {
+ output += "\n[No files returned - check async operations]";
+ }
+ }
+
+ return {
+ output: output.trim() || "Code executed successfully (no output)",
+ files: result.files,
+ };
+ } catch (error: any) {
+ throw new Error(error.message || "Execution failed");
+ } finally {
+ signal?.removeEventListener("abort", abortHandler);
+ }
+}
+
+export type JavaScriptReplToolResult = {
+ files?:
+ | {
+ fileName: string;
+ contentBase64: any;
+ mimeType: string;
+ }[]
+ | undefined;
+};
+
+const javascriptReplSchema = Type.Object({
+ code: Type.String({ description: "JavaScript code to execute" }),
+});
+
+export function createJavaScriptReplTool(): AgentTool & {
+ attachmentsProvider?: () => any[];
+} {
+ return {
+ label: "JavaScript REPL",
+ name: "javascript_repl",
+ attachmentsProvider: () => [], // default to empty array
+ description: `Execute JavaScript code in a sandboxed browser environment with full modern browser capabilities.
+
+Environment: Modern browser with ALL Web APIs available:
+- ES2023+ JavaScript (async/await, optional chaining, nullish coalescing, etc.)
+- DOM APIs (document, window, Canvas, WebGL, etc.)
+- Fetch API for HTTP requests
+
+Loading external libraries via dynamic imports (use esm.run):
+- XLSX (Excel files): const XLSX = await import('https://esm.run/xlsx');
+- Papa Parse (CSV): const Papa = (await import('https://esm.run/papaparse')).default;
+- Lodash: const _ = await import('https://esm.run/lodash-es');
+- D3.js: const d3 = await import('https://esm.run/d3');
+- Chart.js: const Chart = (await import('https://esm.run/chart.js/auto')).default;
+- Three.js: const THREE = await import('https://esm.run/three');
+- Any npm package: await import('https://esm.run/package-name')
+
+IMPORTANT for graphics/canvas:
+- Use fixed dimensions like 400x400 or 800x600, NOT window.innerWidth/Height
+- For Three.js: renderer.setSize(400, 400) and camera aspect ratio of 1
+- For Chart.js: Set options: { responsive: false, animation: false } to ensure immediate rendering
+- Web Storage (localStorage, sessionStorage, IndexedDB)
+- Web Workers, WebAssembly, WebSockets
+- Media APIs (Audio, Video, WebRTC)
+- File APIs (Blob, FileReader, etc.)
+- Crypto API for cryptography
+- And much more - anything a modern browser supports!
+
+Output:
+- console.log() - All output is captured as text
+- await returnFile(filename, content, mimeType?) - Create downloadable files (async function!)
+ * Always use await with returnFile
+ * REQUIRED: For Blob/Uint8Array binary content, you MUST supply a proper MIME type (e.g., "image/png").
+ If omitted, the REPL throws an Error with stack trace pointing to the offending line.
+ * Strings without a MIME default to text/plain.
+ * Objects are auto-JSON stringified and default to application/json unless a MIME is provided.
+ * Canvas images: Use toBlob() with await Promise wrapper
+ * Examples:
+ - await returnFile('data.txt', 'Hello World', 'text/plain')
+ - await returnFile('data.json', {key: 'value'}, 'application/json')
+ - await returnFile('data.csv', 'name,age\\nJohn,30', 'text/csv')
+ - Chart.js example:
+ const Chart = (await import('https://esm.run/chart.js/auto')).default;
+ const canvas = document.createElement('canvas');
+ canvas.width = 400; canvas.height = 300;
+ document.body.appendChild(canvas);
+ new Chart(canvas, {
+ type: 'line',
+ data: {
+ labels: ['Jan', 'Feb', 'Mar', 'Apr'],
+ datasets: [{ label: 'Sales', data: [10, 20, 15, 25], borderColor: 'blue' }]
+ },
+ options: { responsive: false, animation: false }
+ });
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
+ await returnFile('chart.png', blob, 'image/png');
+
+Global variables:
+- attachments[] - Array of attachment objects from user messages
+ * Properties:
+ - id: string (unique identifier)
+ - fileName: string (e.g., "data.xlsx")
+ - mimeType: string (e.g., "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+ - size: number (bytes)
+ * Helper functions:
+ - listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
+ - readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
+ - readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
+ * Examples:
+ - const files = listFiles();
+ - const csvContent = readTextFile(files[0].id); // Read CSV as text
+ - const xlsxBytes = readBinaryFile(files[0].id); // Read Excel as binary
+- All standard browser globals (window, document, fetch, etc.)`,
+ parameters: javascriptReplSchema,
+ execute: async function (_toolCallId: string, args: Static, signal?: AbortSignal) {
+ const attachments = this.attachmentsProvider?.() || [];
+ const result = await executeJavaScript(args.code, attachments, signal);
+ // Convert files to JSON-serializable with base64 payloads
+ const files = (result.files || []).map((f) => {
+ const toBase64 = (input: any): { base64: string; size: number } => {
+ if (input instanceof Uint8Array) {
+ let binary = "";
+ const chunk = 0x8000;
+ for (let i = 0; i < input.length; i += chunk) {
+ binary += String.fromCharCode(...input.subarray(i, i + chunk));
+ }
+ return { base64: btoa(binary), size: input.length };
+ } else if (typeof input === "string") {
+ const enc = new TextEncoder();
+ const bytes = enc.encode(input);
+ let binary = "";
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
+ }
+ return { base64: btoa(binary), size: bytes.length };
+ } else {
+ const s = String(input);
+ const enc = new TextEncoder();
+ const bytes = enc.encode(s);
+ let binary = "";
+ const chunk = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunk) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
+ }
+ return { base64: btoa(binary), size: bytes.length };
+ }
+ };
+
+ const { base64, size } = toBase64((f as any).content);
+ return {
+ fileName: (f as any).fileName || "file",
+ mimeType: (f as any).mimeType || "application/octet-stream",
+ size,
+ contentBase64: base64,
+ };
+ });
+ return { output: result.output, details: { files } };
+ },
+ };
+}
+
+// Export a default instance for backward compatibility
+export const javascriptReplTool = createJavaScriptReplTool();
+
+// JavaScript REPL renderer with streaming support
+
+interface JavaScriptReplParams {
+ code: string;
+}
+
+interface JavaScriptReplResult {
+ output?: string;
+ files?: Array<{
+ fileName: string;
+ mimeType: string;
+ size: number;
+ contentBase64: string;
+ }>;
+}
+
+export const javascriptReplRenderer: ToolRenderer = {
+ renderParams(params: JavaScriptReplParams, isStreaming?: boolean): TemplateResult {
+ if (isStreaming && (!params.code || params.code.length === 0)) {
+ return html`${"Writing JavaScript code..."}
`;
+ }
+
+ return html`
+ ${"Executing JavaScript"}
+
+ `;
+ },
+
+ renderResult(_params: JavaScriptReplParams, result: ToolResultMessage): TemplateResult {
+ // Console output is in the main output field, files are in details
+ const output = result.output || "";
+ const files = result.details?.files || [];
+
+ const attachments: Attachment[] = files.map((f, i) => {
+ // Decode base64 content for text files to show in overlay
+ let extractedText: string | undefined;
+ const isTextBased =
+ f.mimeType?.startsWith("text/") ||
+ f.mimeType === "application/json" ||
+ f.mimeType === "application/javascript" ||
+ f.mimeType?.includes("xml");
+
+ if (isTextBased && f.contentBase64) {
+ try {
+ extractedText = atob(f.contentBase64);
+ } catch (e) {
+ console.warn("Failed to decode base64 content for", f.fileName);
+ }
+ }
+
+ return {
+ id: `repl-${Date.now()}-${i}`,
+ type: f.mimeType?.startsWith("image/") ? "image" : "document",
+ fileName: f.fileName || `file-${i}`,
+ mimeType: f.mimeType || "application/octet-stream",
+ size: f.size ?? 0,
+ content: f.contentBase64,
+ preview: f.mimeType?.startsWith("image/") ? f.contentBase64 : undefined,
+ extractedText,
+ };
+ });
+
+ return html`
+
+ ${output ? html`
` : ""}
+ ${
+ attachments.length
+ ? html`
+ ${attachments.map((att) => html`
`)}
+
`
+ : ""
+ }
+
+ `;
+ },
+};
+
+// Auto-register the renderer
+registerToolRenderer(javascriptReplTool.name, javascriptReplRenderer);
diff --git a/packages/browser-extension/src/utils/i18n.ts b/packages/browser-extension/src/utils/i18n.ts
index ee252b8c..390d20ac 100644
--- a/packages/browser-extension/src/utils/i18n.ts
+++ b/packages/browser-extension/src/utils/i18n.ts
@@ -68,6 +68,16 @@ declare module "@mariozechner/mini-lit" {
"Enter Auth Token": string;
"Please enter your auth token.": string;
"Auth token is required for proxy transport": string;
+ // JavaScript REPL strings
+ "Execution aborted": string;
+ "Code parameter is required": string;
+ "Unknown error": string;
+ "Code executed successfully (no output)": string;
+ "Execution failed": string;
+ "JavaScript REPL": string;
+ "JavaScript code to execute": string;
+ "Writing JavaScript code...": string;
+ "Executing JavaScript": string;
}
}
@@ -142,6 +152,16 @@ const translations = {
"Enter Auth Token": "Enter Auth Token",
"Please enter your auth token.": "Please enter your auth token.",
"Auth token is required for proxy transport": "Auth token is required for proxy transport",
+ // JavaScript REPL strings
+ "Execution aborted": "Execution aborted",
+ "Code parameter is required": "Code parameter is required",
+ "Unknown error": "Unknown error",
+ "Code executed successfully (no output)": "Code executed successfully (no output)",
+ "Execution failed": "Execution failed",
+ "JavaScript REPL": "JavaScript REPL",
+ "JavaScript code to execute": "JavaScript code to execute",
+ "Writing JavaScript code...": "Writing JavaScript code...",
+ "Executing JavaScript": "Executing JavaScript",
},
de: {
...defaultGerman,
@@ -213,6 +233,16 @@ const translations = {
"Enter Auth Token": "Auth-Token eingeben",
"Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.",
"Auth token is required for proxy transport": "Auth-Token ist für Proxy-Transport erforderlich",
+ // JavaScript REPL strings
+ "Execution aborted": "Ausführung abgebrochen",
+ "Code parameter is required": "Code-Parameter ist erforderlich",
+ "Unknown error": "Unbekannter Fehler",
+ "Code executed successfully (no output)": "Code erfolgreich ausgeführt (keine Ausgabe)",
+ "Execution failed": "Ausführung fehlgeschlagen",
+ "JavaScript REPL": "JavaScript REPL",
+ "JavaScript code to execute": "Auszuführender JavaScript-Code",
+ "Writing JavaScript code...": "Schreibe JavaScript-Code...",
+ "Executing JavaScript": "Führe JavaScript aus",
},
};