mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 08:00:59 +00:00
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:
parent
fc1ef04b6d
commit
0e932a97df
14 changed files with 1151 additions and 11 deletions
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -3144,6 +3144,18 @@
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
|
|
@ -3603,7 +3615,6 @@
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
|
@ -5389,6 +5400,7 @@
|
||||||
"@mariozechner/mini-lit": "^0.1.4",
|
"@mariozechner/mini-lit": "^0.1.4",
|
||||||
"@mariozechner/pi-ai": "^0.5.43",
|
"@mariozechner/pi-ai": "^0.5.43",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
|
"js-interpreter": "^6.0.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lit": "^3.3.1",
|
"lit": "^3.3.1",
|
||||||
"lucide": "^0.544.0",
|
"lucide": "^0.544.0",
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,25 @@ const addFormats = (addFormatsModule as any).default || addFormatsModule;
|
||||||
|
|
||||||
import type { Tool, ToolCall } from "../types.js";
|
import type { Tool, ToolCall } from "../types.js";
|
||||||
|
|
||||||
// Create a singleton AJV instance with formats
|
// Detect if we're in a browser extension environment with strict CSP
|
||||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
// Chrome extensions with Manifest V3 don't allow eval/Function constructor
|
||||||
addFormats(ajv);
|
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
|
* Validates tool call arguments against the tool's TypeBox schema
|
||||||
|
|
@ -19,6 +35,13 @@ addFormats(ajv);
|
||||||
* @throws Error with formatted message if validation fails
|
* @throws Error with formatted message if validation fails
|
||||||
*/
|
*/
|
||||||
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
|
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
|
// Compile the schema
|
||||||
const validate = ajv.compile(tool.parameters);
|
const validate = ajv.compile(tool.parameters);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,5 +28,11 @@
|
||||||
"https://*/*",
|
"https://*/*",
|
||||||
"http://localhost/*",
|
"http://localhost/*",
|
||||||
"http://127.0.0.1/*"
|
"http://127.0.0.1/*"
|
||||||
]
|
],
|
||||||
|
"sandbox": {
|
||||||
|
"pages": ["sandbox.html"]
|
||||||
|
},
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -39,5 +39,11 @@
|
||||||
"id": "pi-reader@mariozechner.at",
|
"id": "pi-reader@mariozechner.at",
|
||||||
"strict_min_version": "115.0"
|
"strict_min_version": "115.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"pages": ["sandbox.html"]
|
||||||
|
},
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self'; object-src 'self'"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"@mariozechner/mini-lit": "^0.1.4",
|
"@mariozechner/mini-lit": "^0.1.4",
|
||||||
"@mariozechner/pi-ai": "^0.5.43",
|
"@mariozechner/pi-ai": "^0.5.43",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
|
"js-interpreter": "^6.0.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lit": "^3.3.1",
|
"lit": "^3.3.1",
|
||||||
"lucide": "^0.544.0",
|
"lucide": "^0.544.0",
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,8 @@ const copyStatic = () => {
|
||||||
"icon-16.png",
|
"icon-16.png",
|
||||||
"icon-48.png",
|
"icon-48.png",
|
||||||
"icon-128.png",
|
"icon-128.png",
|
||||||
join("src", "sidepanel.html")
|
join("src", "sidepanel.html"),
|
||||||
|
join("src", "sandbox.html")
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const relative of filesToCopy) {
|
for (const relative of filesToCopy) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { LitElement } from "lit";
|
||||||
import { customElement, state } from "lit/decorators.js";
|
import { customElement, state } from "lit/decorators.js";
|
||||||
import "./AgentInterface.js";
|
import "./AgentInterface.js";
|
||||||
import { AgentSession } from "./state/agent-session.js";
|
import { AgentSession } from "./state/agent-session.js";
|
||||||
|
import { browserJavaScriptTool, createJavaScriptReplTool } from "./tools/index.js";
|
||||||
import { getAuthToken } from "./utils/auth-token.js";
|
import { getAuthToken } from "./utils/auth-token.js";
|
||||||
|
|
||||||
@customElement("pi-chat-panel")
|
@customElement("pi-chat-panel")
|
||||||
|
|
@ -23,17 +24,44 @@ export class ChatPanel extends LitElement {
|
||||||
this.style.height = "100%";
|
this.style.height = "100%";
|
||||||
this.style.minHeight = "0";
|
this.style.minHeight = "0";
|
||||||
|
|
||||||
|
// Create JavaScript REPL tool with attachments provider
|
||||||
|
const javascriptReplTool = createJavaScriptReplTool();
|
||||||
|
|
||||||
// Create agent session with default settings
|
// Create agent session with default settings
|
||||||
this.session = new AgentSession({
|
this.session = new AgentSession({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful AI assistant.",
|
systemPrompt: "You are a helpful AI assistant.",
|
||||||
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
|
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
|
||||||
tools: [calculateTool, getCurrentTimeTool],
|
tools: [calculateTool, getCurrentTimeTool, browserJavaScriptTool, javascriptReplTool],
|
||||||
thinkingLevel: "off",
|
thinkingLevel: "off",
|
||||||
},
|
},
|
||||||
authTokenProvider: async () => getAuthToken(),
|
authTokenProvider: async () => getAuthToken(),
|
||||||
transportMode: "direct", // Use direct mode by default (API keys from KeyStore)
|
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() {
|
render() {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Model } from "@mariozechner/pi-ai";
|
||||||
import { LitElement } from "lit";
|
import { LitElement } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators.js";
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
import { createRef, ref } from "lit/directives/ref.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 "./AttachmentTile.js";
|
||||||
import { type Attachment, loadAttachment } from "./utils/attachment-utils.js";
|
import { type Attachment, loadAttachment } from "./utils/attachment-utils.js";
|
||||||
import { i18n } from "./utils/i18n.js";
|
import { i18n } from "./utils/i18n.js";
|
||||||
|
|
@ -194,7 +194,7 @@ export class MessageEditor extends LitElement {
|
||||||
|
|
||||||
<!-- Button Row -->
|
<!-- Button Row -->
|
||||||
<div class="px-2 pb-2 flex items-center justify-between">
|
<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">
|
<div class="flex gap-2 items-center">
|
||||||
${
|
${
|
||||||
this.showAttachmentButton
|
this.showAttachmentButton
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,9 @@ export class StreamingMessageContainer extends LitElement {
|
||||||
requestAnimationFrame(async () => {
|
requestAnimationFrame(async () => {
|
||||||
// Only apply the update if we haven't been cleared
|
// Only apply the update if we haven't been cleared
|
||||||
if (!this._immediateUpdate && this._pendingMessage !== null) {
|
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();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
// Reset for next batch
|
// Reset for next batch
|
||||||
|
|
|
||||||
174
packages/browser-extension/src/sandbox.html
Normal file
174
packages/browser-extension/src/sandbox.html
Normal 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>
|
||||||
424
packages/browser-extension/src/tools/browser-javascript.ts
Normal file
424
packages/browser-extension/src/tools/browser-javascript.ts
Normal 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);
|
||||||
|
|
@ -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 type { ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
|
import { getToolRenderer, registerToolRenderer } from "./renderer-registry.js";
|
||||||
import { BashRenderer } from "./renderers/BashRenderer.js";
|
import { BashRenderer } from "./renderers/BashRenderer.js";
|
||||||
import { CalculateRenderer } from "./renderers/CalculateRenderer.js";
|
import { CalculateRenderer } from "./renderers/CalculateRenderer.js";
|
||||||
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
|
import { DefaultRenderer } from "./renderers/DefaultRenderer.js";
|
||||||
import { GetCurrentTimeRenderer } from "./renderers/GetCurrentTimeRenderer.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
|
// Register all built-in tool renderers
|
||||||
registerToolRenderer("calculate", new CalculateRenderer());
|
registerToolRenderer("calculate", new CalculateRenderer());
|
||||||
|
|
@ -36,3 +38,5 @@ export function renderToolResult(toolName: string, params: any, result: ToolResu
|
||||||
}
|
}
|
||||||
|
|
||||||
export { registerToolRenderer, getToolRenderer };
|
export { registerToolRenderer, getToolRenderer };
|
||||||
|
export { browserJavaScriptTool } from "./browser-javascript.js";
|
||||||
|
export { createJavaScriptReplTool, javascriptReplTool } from "./javascript-repl.js";
|
||||||
|
|
|
||||||
429
packages/browser-extension/src/tools/javascript-repl.ts
Normal file
429
packages/browser-extension/src/tools/javascript-repl.ts
Normal 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);
|
||||||
|
|
@ -68,6 +68,16 @@ declare module "@mariozechner/mini-lit" {
|
||||||
"Enter Auth Token": string;
|
"Enter Auth Token": string;
|
||||||
"Please enter your auth token.": string;
|
"Please enter your auth token.": string;
|
||||||
"Auth token is required for proxy transport": 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",
|
"Enter Auth Token": "Enter Auth Token",
|
||||||
"Please enter your auth token.": "Please enter your 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",
|
"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: {
|
de: {
|
||||||
...defaultGerman,
|
...defaultGerman,
|
||||||
|
|
@ -213,6 +233,16 @@ const translations = {
|
||||||
"Enter Auth Token": "Auth-Token eingeben",
|
"Enter Auth Token": "Auth-Token eingeben",
|
||||||
"Please enter your auth token.": "Bitte geben Sie Ihr Auth-Token ein.",
|
"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",
|
"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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue