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", }, };