Integrate JailJS for CSP-restricted execution in browser extension

Major changes:
- Migrate browser-extension to use web-ui package (85% code reduction)
- Add JailJS content script with ES6+ transform support
- Expose DOM constructors (Event, KeyboardEvent, etc.) to JailJS
- Support top-level await by wrapping code in async IIFE
- Add returnFile() support in JailJS execution
- Refactor KeyStore into pluggable storage-adapter pattern
- Make ChatPanel configurable with sandboxUrlProvider and additionalTools
- Update jailjs to 0.1.1

Files deleted (33 duplicate files):
- All browser-extension components, dialogs, state, tools, utils
- Now using web-ui versions via @mariozechner/pi-web-ui

Files added:
- packages/browser-extension/src/content.ts (JailJS content script)
- packages/web-ui/src/state/storage-adapter.ts
- packages/web-ui/src/state/key-store.ts

Browser extension now has only 5 source files (down from 38).
This commit is contained in:
Mario Zechner 2025-10-05 16:58:31 +02:00
parent f2eecb78d2
commit aaea0f4600
61 changed files with 633 additions and 9270 deletions

View file

@ -9,7 +9,6 @@ import { createJavaScriptReplTool } from "./tools/javascript-repl.js";
import { registerToolRenderer } from "./tools/renderer-registry.js";
import { getAuthToken } from "./utils/auth-token.js";
import { i18n } from "./utils/i18n.js";
import { simpleHtml } from "./utils/test-sessions.js";
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
@ -23,6 +22,7 @@ export class ChatPanel extends LitElement {
@state() private windowWidth = window.innerWidth;
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
@property({ type: Array }) additionalTools: AgentTool<any, any>[] = [];
@property({ attribute: false }) sandboxUrlProvider?: () => string;
private resizeHandler = () => {
this.windowWidth = window.innerWidth;
@ -45,8 +45,17 @@ export class ChatPanel extends LitElement {
this.style.height = "100%";
this.style.minHeight = "0";
// Create JavaScript REPL tool with attachments provider
const javascriptReplTool = createJavaScriptReplTool();
if (this.sandboxUrlProvider) {
javascriptReplTool.sandboxUrlProvider = this.sandboxUrlProvider;
}
// Set up artifacts panel
this.artifactsPanel = new ArtifactsPanel();
if (this.sandboxUrlProvider) {
this.artifactsPanel.sandboxUrlProvider = this.sandboxUrlProvider;
}
registerToolRenderer("artifacts", this.artifactsPanel);
// Attachments provider for both REPL and artifacts
@ -72,11 +81,8 @@ export class ChatPanel extends LitElement {
return attachments;
};
this.artifactsPanel.attachmentsProvider = getAttachments;
// Set up JavaScript REPL tool with attachments provider
const javascriptReplTool = createJavaScriptReplTool();
javascriptReplTool.attachmentsProvider = getAttachments;
this.artifactsPanel.attachmentsProvider = getAttachments;
this.artifactsPanel.onArtifactsChange = () => {
const count = this.artifactsPanel.artifacts?.size ?? 0;
@ -101,14 +107,14 @@ export class ChatPanel extends LitElement {
this.requestUpdate();
};
let initialState = {
const initialState = {
systemPrompt: this.systemPrompt,
model: getModel("anthropic", "claude-sonnet-4-5-20250929"),
tools: [...this.additionalTools, javascriptReplTool, this.artifactsPanel.tool],
thinkingLevel: "off" as ThinkingLevel,
messages: [],
} satisfies Partial<AgentSessionState>;
initialState = { ...initialState, ...(simpleHtml as any) };
// initialState = { ...initialState, ...(simpleHtml as any) };
// initialState = { ...initialState, ...(longSession as any) };
// Create agent session first so attachments provider works

View file

@ -9,7 +9,7 @@ import "./MessageEditor.js";
import "./MessageList.js";
import "./Messages.js"; // Import for side effects to register the custom elements
import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
import { keyStore } from "../state/KeyStore.js";
import { getKeyStore } from "../state/key-store.js";
import "./StreamingMessageContainer.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { formatUsage } from "../utils/format.js";
@ -166,13 +166,13 @@ export class AgentInterface extends LitElement {
// Check if API key exists for the provider (only needed in direct mode)
const provider = session.state.model.provider;
let apiKey = await keyStore.getKey(provider);
let apiKey = await getKeyStore().getKey(provider);
// If no API key, open the API keys dialog
if (!apiKey) {
await ApiKeysDialog.open();
// Check again after dialog closes
apiKey = await keyStore.getKey(provider);
apiKey = await getKeyStore().getKey(provider);
// If still no API key, abort the send
if (!apiKey) {
return;

View file

@ -3,19 +3,19 @@ import { type Context, complete, getModel, getProviders } from "@mariozechner/pi
import type { PropertyValues } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Input } from "../components/Input.js";
import { keyStore } from "../state/KeyStore.js";
import { getKeyStore } from "../state/key-store.js";
import { i18n } from "../utils/i18n.js";
// Test models for each provider - known to be reliable and cheap
const TEST_MODELS: Record<string, string> = {
anthropic: "claude-3-5-haiku-20241022",
openai: "gpt-4o-mini",
google: "gemini-2.0-flash-exp",
groq: "llama-3.3-70b-versatile",
openrouter: "openai/gpt-4o-mini",
cerebras: "llama3.1-8b",
xai: "grok-2-1212",
zai: "glm-4-plus",
google: "gemini-2.5-flash",
groq: "openai/gpt-oss-20b",
openrouter: "z-ai/glm-4.6",
cerebras: "gpt-oss-120b",
xai: "grok-4-fast-non-reasoning",
zai: "glm-4.5-air",
};
@customElement("api-keys-dialog")
@ -42,7 +42,7 @@ export class ApiKeysDialog extends DialogBase {
}
private async loadKeys() {
this.apiKeys = await keyStore.getAllKeys();
this.apiKeys = await getKeyStore().getAllKeys();
}
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
@ -69,13 +69,7 @@ export class ApiKeysDialog extends DialogBase {
maxTokens: 10, // Keep it minimal for testing
} as any);
// Check if response contains expected text
const text = response.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("");
return text.toLowerCase().includes("test successful");
return true;
} catch (error) {
console.error(`API key test failed for ${provider}:`, error);
return false;
@ -95,7 +89,7 @@ export class ApiKeysDialog extends DialogBase {
const isValid = await this.testApiKey(provider, key);
if (isValid) {
await keyStore.setKey(provider, key);
await getKeyStore().setKey(provider, key);
this.apiKeyInputs[provider] = ""; // Clear input
await this.loadKeys();
this.testResults[provider] = "success";
@ -123,7 +117,7 @@ export class ApiKeysDialog extends DialogBase {
this.error = "";
try {
const apiKey = await keyStore.getKey(provider);
const apiKey = await getKeyStore().getKey(provider);
if (!apiKey) {
this.testResults[provider] = "error";
this.error = `No API key found for ${provider}`;
@ -155,7 +149,7 @@ export class ApiKeysDialog extends DialogBase {
private async removeKey(provider: string) {
if (!confirm(`Remove API key for ${provider}?`)) return;
await keyStore.removeKey(provider);
await getKeyStore().removeKey(provider);
this.apiKeyInputs[provider] = "";
await this.loadKeys();
}

View file

@ -21,11 +21,13 @@ export { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
// Dialogs
export { ModelSelector } from "./dialogs/ModelSelector.js";
export type { AgentSessionState, ThinkingLevel } from "./state/agent-session.js";
// State management
export { AgentSession } from "./state/agent-session.js";
export type { KeyStore, StorageAdapter } from "./state/KeyStore.js";
export { KeyStoreImpl, keyStore } from "./state/KeyStore.js";
export type { KeyStore } from "./state/key-store.js";
export { getKeyStore, LocalStorageKeyStore, setKeyStore } from "./state/key-store.js";
export type { StorageAdapter } from "./state/storage-adapter.js";
export { ChromeStorageAdapter, LocalStorageAdapter } from "./state/storage-adapter.js";
// Transports
export { DirectTransport } from "./state/transports/DirectTransport.js";
@ -41,6 +43,7 @@ export { SvgArtifact } from "./tools/artifacts/SvgArtifact.js";
export { TextArtifact } from "./tools/artifacts/TextArtifact.js";
// Tools
export { getToolRenderer, registerToolRenderer, renderToolParams, renderToolResult } from "./tools/index.js";
export { createJavaScriptReplTool, javascriptReplTool } from "./tools/javascript-repl.js";
export { BashRenderer } from "./tools/renderers/BashRenderer.js";
export { CalculateRenderer } from "./tools/renderers/CalculateRenderer.js";
// Tool renderers

View file

@ -1,96 +0,0 @@
import { getProviders } from "@mariozechner/pi-ai";
/**
* Generic storage adapter interface
*/
export interface StorageAdapter {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
remove(key: string): Promise<void>;
getAll(): Promise<Record<string, string>>;
}
/**
* Interface for API key storage
*/
export interface KeyStore {
getKey(provider: string): Promise<string | null>;
setKey(provider: string, key: string): Promise<void>;
removeKey(provider: string): Promise<void>;
getAllKeys(): Promise<Record<string, boolean>>; // provider -> isConfigured
}
/**
* Default localStorage implementation for web
*/
class LocalStorageAdapter implements StorageAdapter {
async get(key: string): Promise<string | null> {
return localStorage.getItem(key);
}
async set(key: string, value: string): Promise<void> {
localStorage.setItem(key, value);
}
async remove(key: string): Promise<void> {
localStorage.removeItem(key);
}
async getAll(): Promise<Record<string, string>> {
const result: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
const value = localStorage.getItem(key);
if (value) result[key] = value;
}
}
return result;
}
}
/**
* Generic KeyStore implementation
*/
class GenericKeyStore implements KeyStore {
private readonly prefix = "apiKey_";
private readonly storage: StorageAdapter;
constructor(storage?: StorageAdapter) {
this.storage = storage || new LocalStorageAdapter();
}
async getKey(provider: string): Promise<string | null> {
const key = `${this.prefix}${provider}`;
return await this.storage.get(key);
}
async setKey(provider: string, key: string): Promise<void> {
const storageKey = `${this.prefix}${provider}`;
await this.storage.set(storageKey, key);
}
async removeKey(provider: string): Promise<void> {
const key = `${this.prefix}${provider}`;
await this.storage.remove(key);
}
async getAllKeys(): Promise<Record<string, boolean>> {
const providers = getProviders();
const allStorage = await this.storage.getAll();
const result: Record<string, boolean> = {};
for (const provider of providers) {
const key = `${this.prefix}${provider}`;
result[provider] = !!allStorage[key];
}
return result;
}
}
// Export singleton instance (uses localStorage by default)
export const keyStore = new GenericKeyStore();
// Export class for custom storage implementations
export { GenericKeyStore as KeyStoreImpl };

View file

@ -0,0 +1,67 @@
import { getProviders } from "@mariozechner/pi-ai";
import { LocalStorageAdapter, type StorageAdapter } from "./storage-adapter.js";
/**
* API key storage interface
*/
export interface KeyStore {
getKey(provider: string): Promise<string | null>;
setKey(provider: string, key: string): Promise<void>;
removeKey(provider: string): Promise<void>;
getAllKeys(): Promise<Record<string, boolean>>;
}
/**
* API key storage implementation using a pluggable storage adapter
*/
export class LocalStorageKeyStore implements KeyStore {
private readonly prefix = "apiKey_";
constructor(private readonly storage: StorageAdapter) {}
async getKey(provider: string): Promise<string | null> {
const key = `${this.prefix}${provider}`;
return await this.storage.get(key);
}
async setKey(provider: string, key: string): Promise<void> {
const storageKey = `${this.prefix}${provider}`;
await this.storage.set(storageKey, key);
}
async removeKey(provider: string): Promise<void> {
const key = `${this.prefix}${provider}`;
await this.storage.remove(key);
}
async getAllKeys(): Promise<Record<string, boolean>> {
const providers = getProviders();
const allStorage = await this.storage.getAll();
const result: Record<string, boolean> = {};
for (const provider of providers) {
const key = `${this.prefix}${provider}`;
result[provider] = !!allStorage[key];
}
return result;
}
}
// Default instance using localStorage
let _keyStore: KeyStore = new LocalStorageKeyStore(new LocalStorageAdapter());
/**
* Get the current KeyStore instance
*/
export function getKeyStore(): KeyStore {
return _keyStore;
}
/**
* Set a custom KeyStore implementation
* Call this once at application startup before any components are initialized
*/
export function setKeyStore(store: KeyStore): void {
_keyStore = store;
}

View file

@ -0,0 +1,77 @@
/**
* Generic storage adapter interface for key/value persistence
*/
export interface StorageAdapter {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
remove(key: string): Promise<void>;
getAll(): Promise<Record<string, string>>;
}
/**
* LocalStorage implementation
*/
export class LocalStorageAdapter implements StorageAdapter {
async get(key: string): Promise<string | null> {
return localStorage.getItem(key);
}
async set(key: string, value: string): Promise<void> {
localStorage.setItem(key, value);
}
async remove(key: string): Promise<void> {
localStorage.removeItem(key);
}
async getAll(): Promise<Record<string, string>> {
const result: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
const value = localStorage.getItem(key);
if (value) result[key] = value;
}
}
return result;
}
}
/**
* Chrome/Firefox extension storage implementation
*/
export class ChromeStorageAdapter implements StorageAdapter {
private readonly storage: any;
constructor() {
const isBrowser = typeof globalThis !== "undefined";
const hasChrome = isBrowser && (globalThis as any).chrome?.storage;
const hasBrowser = isBrowser && (globalThis as any).browser?.storage;
if (hasBrowser) {
this.storage = (globalThis as any).browser.storage.local;
} else if (hasChrome) {
this.storage = (globalThis as any).chrome.storage.local;
} else {
throw new Error("Chrome/Browser storage not available");
}
}
async get(key: string): Promise<string | null> {
const result = await this.storage.get(key);
return result[key] || null;
}
async set(key: string, value: string): Promise<void> {
await this.storage.set({ [key]: value });
}
async remove(key: string): Promise<void> {
await this.storage.remove(key);
}
async getAll(): Promise<Record<string, string>> {
const result = await this.storage.get();
return result || {};
}
}

View file

@ -1,5 +1,5 @@
import { type AgentContext, agentLoop, type Message, type PromptConfig, type UserMessage } from "@mariozechner/pi-ai";
import { keyStore } from "../KeyStore.js";
import { getKeyStore } from "../key-store.js";
import type { AgentRunConfig, AgentTransport } from "./types.js";
export class DirectTransport implements AgentTransport {
@ -7,7 +7,7 @@ export class DirectTransport implements AgentTransport {
async *run(userMessage: Message, cfg: AgentRunConfig, signal?: AbortSignal) {
// Get API key from KeyStore
const apiKey = await keyStore.getKey(cfg.model.provider);
const apiKey = await getKeyStore().getKey(cfg.model.provider);
if (!apiKey) {
throw new Error("no-api-key");
}

View file

@ -12,6 +12,7 @@ export async function executeJavaScript(
code: string,
attachments: Attachment[] = [],
signal?: AbortSignal,
sandboxUrlProvider?: () => string,
): Promise<{ output: string; files?: SandboxFile[] }> {
if (!code) {
throw new Error("Code parameter is required");
@ -24,6 +25,9 @@ export async function executeJavaScript(
// Create a SandboxedIframe instance for execution
const sandbox = new SandboxIframe();
if (sandboxUrlProvider) {
sandbox.sandboxUrlProvider = sandboxUrlProvider;
}
sandbox.style.display = "none";
document.body.appendChild(sandbox);
@ -93,11 +97,13 @@ const javascriptReplSchema = Type.Object({
export function createJavaScriptReplTool(): AgentTool<typeof javascriptReplSchema, JavaScriptReplToolResult> & {
attachmentsProvider?: () => Attachment[];
sandboxUrlProvider?: () => string;
} {
return {
label: "JavaScript REPL",
name: "javascript_repl",
attachmentsProvider: () => [], // default to empty array
sandboxUrlProvider: undefined, // optional, for browser extensions
description: `Execute JavaScript code in a sandboxed browser environment with full modern browser capabilities.
Environment: Modern browser with ALL Web APIs available:
@ -173,7 +179,7 @@ Global variables:
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);
const result = await executeJavaScript(args.code, attachments, signal, this.sandboxUrlProvider);
// Convert files to JSON-serializable with base64 payloads
const files = (result.files || []).map((f) => {
const toBase64 = (input: string | Uint8Array): { base64: string; size: number } => {