More browser extension work, disable ajv validation in browser extensions, it uses eval/new Function, which is not allowed in manifest v3 extensions

This commit is contained in:
Mario Zechner 2025-10-01 20:30:49 +02:00
parent fc1ef04b6d
commit 0e932a97df
14 changed files with 1151 additions and 11 deletions

14
package-lock.json generated
View file

@ -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",

View file

@ -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);

View file

@ -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'"
}
}

View file

@ -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'"
}
}

View file

@ -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",

View file

@ -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) {

View file

@ -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() {

View file

@ -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 {
<!-- Button Row -->
<div class="px-2 pb-2 flex items-center justify-between">
<!-- Left side - attachment button -->
<!-- Left side - attachment and quick action buttons -->
<div class="flex gap-2 items-center">
${
this.showAttachmentButton

View file

@ -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

View file

@ -0,0 +1,174 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script>
// Store files and console output for current execution
let returnedFiles = [];
let consoleOutput = [];
// Store attachments with helper methods
window.attachments = [];
// Helper function to list available files
window.listFiles = function() {
return window.attachments.map(a => ({
id: a.id,
fileName: a.fileName,
mimeType: a.mimeType,
size: a.size
}));
};
// Helper function to read text file by ID
window.readTextFile = function(attachmentId) {
const attachment = window.attachments.find(a => a.id === attachmentId);
if (!attachment) {
throw new Error('Attachment not found: ' + attachmentId);
}
// If extractedText exists, return it
if (attachment.extractedText) {
return attachment.extractedText;
}
// Otherwise decode base64 content
try {
return atob(attachment.content);
} catch (e) {
throw new Error('Failed to decode text content for: ' + attachmentId);
}
};
// Helper function to read binary file as Uint8Array
window.readBinaryFile = function(attachmentId) {
const attachment = window.attachments.find(a => a.id === attachmentId);
if (!attachment) {
throw new Error('Attachment not found: ' + attachmentId);
}
const binaryString = atob(attachment.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
// Override console
['log', 'error', 'warn', 'info', 'debug', 'table'].forEach(method => {
const original = console[method];
console[method] = (...args) => {
// Convert args to strings for plain text output
const stringArgs = args.map(arg => {
try {
if (arg === undefined) return 'undefined';
if (arg === null) return 'null';
if (typeof arg === 'function') return arg.toString();
if (typeof arg === 'symbol') return arg.toString();
if (arg instanceof Error) return arg.stack || arg.message;
if (typeof arg === 'object') return JSON.stringify(arg, null, 2);
return String(arg);
} catch {
return String(arg);
}
});
consoleOutput.push({ type: method, args: stringArgs });
original.apply(console, args);
};
});
// Single return function for files only
window.returnFile = async (fileName, content, mimeType) => {
// Validate mimeType for binary-like content to guide the LLM
const isBinaryLike = (val) => val instanceof Blob || val instanceof Uint8Array;
if (isBinaryLike(content) && (!mimeType || typeof mimeType !== 'string' || !mimeType.includes('/'))) {
const err = new Error('returnFile: mimeType is required for Blob/Uint8Array content (e.g., "image/png").');
throw err;
}
// Default for plain string content when not specified
if (typeof content === 'string' && (!mimeType || typeof mimeType !== 'string' || mimeType === '')) {
mimeType = 'text/plain';
}
// Handle data URLs - extract the data and mime type
if (typeof content === 'string' && content.startsWith('data:')) {
const matches = content.match(/^data:([^;,]+)(;base64)?,(.*)$/);
if (matches) {
if (mimeType === 'text/plain' && matches[1]) {
mimeType = matches[1];
}
const isBase64 = !!matches[2];
const data = matches[3];
if (isBase64) {
const binaryString = atob(data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
content = bytes;
} else {
content = decodeURIComponent(data);
}
}
}
// Convert Blob to Uint8Array for postMessage
else if (content instanceof Blob) {
const arrayBuffer = await content.arrayBuffer();
content = new Uint8Array(arrayBuffer);
}
// Handle different content types
else if (typeof content === 'object' && !(content instanceof Uint8Array)) {
content = JSON.stringify(content, null, 2);
if (!mimeType || mimeType === 'text/plain') {
mimeType = 'application/json';
}
}
returnedFiles.push({ fileName, content, mimeType });
};
// Message handler
window.addEventListener('message', async (e) => {
if (e.data.type === 'setAttachments') {
window.attachments = e.data.attachments;
parent.postMessage({ type: 'ready' }, '*');
} else if (e.data.type === 'execute') {
// Reset for new execution
returnedFiles = [];
consoleOutput = [];
try {
// Execute code in global scope to persist variables
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
const func = new AsyncFunction(e.data.code);
const result = await func();
// If there was a direct return value, log it
if (result !== undefined) {
console.log('Return value:', result);
}
parent.postMessage({
type: 'result',
success: true,
files: returnedFiles,
console: consoleOutput
}, '*');
} catch (error) {
parent.postMessage({
type: 'error',
success: false,
error: {
message: error.message,
stack: error.stack,
name: error.name
},
console: consoleOutput
}, '*');
}
}
});
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -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<typeof browserJavaScriptSchema, BrowserJavaScriptToolResult> = {
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<typeof browserJavaScriptSchema>, _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<string, unknown>,
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<BrowserJavaScriptParams, BrowserJavaScriptResult> = {
renderParams(params: BrowserJavaScriptParams, isStreaming?: boolean): TemplateResult {
if (isStreaming && (!params.code || params.code.length === 0)) {
return html`<div class="text-sm text-muted-foreground">Writing JavaScript code...</div>`;
}
return html`
<div class="text-sm text-muted-foreground mb-2">Executing in active tab</div>
<code-block .code=${params.code || ""} language="javascript"></code-block>
`;
},
renderResult(_params: BrowserJavaScriptParams, result: ToolResultMessage<BrowserJavaScriptResult>): 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`
<div class="text-sm">
<div class="text-destructive font-medium mb-1">Execution failed:</div>
<pre class="text-xs font-mono text-destructive bg-destructive/10 p-2 rounded overflow-x-auto">${output}</pre>
</div>
`;
}
return html`
<div class="flex flex-col gap-3">
${output ? html`<console-block .content=${output}></console-block>` : ""}
${
attachments.length
? html`<div class="flex flex-wrap gap-2">
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
</div>`
: ""
}
</div>
`;
},
};
// Auto-register the renderer
registerToolRenderer(browserJavaScriptTool.name, browserJavaScriptRenderer);

View file

@ -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";

View file

@ -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<ReplExecuteResult> {
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<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
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<typeof javascriptReplSchema>, 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<JavaScriptReplParams, JavaScriptReplResult> = {
renderParams(params: JavaScriptReplParams, isStreaming?: boolean): TemplateResult {
if (isStreaming && (!params.code || params.code.length === 0)) {
return html`<div class="text-sm text-muted-foreground">${"Writing JavaScript code..."}</div>`;
}
return html`
<div class="text-sm text-muted-foreground mb-2">${"Executing JavaScript"}</div>
<code-block .code=${params.code || ""} language="javascript"></code-block>
`;
},
renderResult(_params: JavaScriptReplParams, result: ToolResultMessage<JavaScriptReplResult>): 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`
<div class="flex flex-col gap-3">
${output ? html`<console-block .content=${output}></console-block>` : ""}
${
attachments.length
? html`<div class="flex flex-wrap gap-2">
${attachments.map((att) => html`<attachment-tile .attachment=${att}></attachment-tile>`)}
</div>`
: ""
}
</div>
`;
},
};
// Auto-register the renderer
registerToolRenderer(javascriptReplTool.name, javascriptReplRenderer);

View file

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