},
{
// Exclude generated build output and Storybook files from ESLint
- ignores: ['dist/**', 'build/**', '.svelte-kit/**', 'test-results/**', '.storybook/**/*']
+ ignores: [
+ 'dist/**',
+ 'build/**',
+ '.svelte-kit/**',
+ 'test-results/**',
+ '.storybook/**/*',
+ 'src/lib/services/sandbox-worker.js'
+ ]
},
storybook.configs['flat/recommended']
);
export * from './precision';
export * from './processing-info';
export * from './routes';
+export * from './sandbox';
export * from './settings-keys';
export * from './settings-registry';
export * from './supported-file-types';
--- /dev/null
+import { JsonSchemaType, ToolCallType } from '$lib/enums';
+import type { OpenAIToolDefinition } from '$lib/types';
+
+export const SANDBOX_TOOL_NAME = 'run_javascript';
+
+export const SANDBOX_TIMEOUT_MS_DEFAULT = 10000;
+
+export const SANDBOX_TIMEOUT_MS_MAX = 30000;
+
+export const SANDBOX_OUTPUT_MAX_CHARS = 8192;
+
+export const SANDBOX_EMPTY_OUTPUT = '(no output)';
+
+export const SANDBOX_TRUNCATION_NOTICE = '[output truncated]';
+
+export const SANDBOX_TOOL_DEFINITION: OpenAIToolDefinition = {
+ type: ToolCallType.FUNCTION,
+ function: {
+ name: SANDBOX_TOOL_NAME,
+ description:
+ 'Execute JavaScript in a sandboxed browser worker (no DOM, no page access). ' +
+ 'Top level await is supported. Use console.log to print intermediate values; ' +
+ 'a top level return statement is captured as the result.',
+ parameters: {
+ type: JsonSchemaType.OBJECT,
+ properties: {
+ code: {
+ type: JsonSchemaType.STRING,
+ description: 'JavaScript source to execute'
+ },
+ timeout_ms: {
+ type: JsonSchemaType.NUMBER,
+ description: `Execution timeout in milliseconds, default ${SANDBOX_TIMEOUT_MS_DEFAULT}, max ${SANDBOX_TIMEOUT_MS_MAX}`
+ }
+ },
+ required: ['code']
+ }
+ }
+};
ENABLE_THINKING: 'enableThinking',
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
+ JS_SANDBOX_ENABLED: 'jsSandboxEnabled',
CUSTOM_JSON: 'customJson',
CUSTOM_CSS: 'customCss'
} as const;
paramType: SyncableParameterType.BOOLEAN
}
},
+ {
+ key: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
+ label: 'JavaScript sandbox tool',
+ help: 'Expose a run_javascript tool to the model. Code runs in a Web Worker inside a sandboxed iframe with an opaque origin, isolated from the WebUI and its API, with a hard timeout.',
+ defaultValue: false,
+ type: SettingsFieldType.CHECKBOX,
+ section: SETTINGS_SECTION_SLUGS.DEVELOPER
+ },
{
key: SETTINGS_KEYS.CUSTOM_JSON,
label: 'Custom JSON',
export const TOOL_GROUP_LABELS = {
[ToolSource.BUILTIN]: 'Built-in',
- [ToolSource.CUSTOM]: 'JSON Schema'
+ [ToolSource.CUSTOM]: 'JSON Schema',
+ [ToolSource.FRONTEND]: 'Browser'
} as const;
export const TOOL_SERVER_LABELS = {
[ToolSource.BUILTIN]: 'Built-in Tools',
- [ToolSource.CUSTOM]: 'Custom Tools'
+ [ToolSource.CUSTOM]: 'Custom Tools',
+ [ToolSource.FRONTEND]: 'Browser Tools'
} as const;
* JSON Schema types used in MCP tool definitions
*/
export enum JsonSchemaType {
- OBJECT = 'object'
+ OBJECT = 'object',
+ STRING = 'string',
+ NUMBER = 'number'
}
/**
export enum ToolSource {
BUILTIN = 'builtin',
MCP = 'mcp',
- CUSTOM = 'custom'
+ CUSTOM = 'custom',
+ FRONTEND = 'frontend'
}
export enum ToolPermissionDecision {
*/
export { MCPService } from './mcp.service';
+/**
+ * **SandboxService** - Frontend JavaScript execution in a browser sandbox
+ *
+ * Stateless executor for the run_javascript frontend tool. Model generated
+ * code runs in a Web Worker spawned inside a sandboxed iframe with an opaque
+ * origin: no access to the app origin, its storage or its API, and outgoing
+ * requests carry a null origin. The code never touches a main thread, so the
+ * parent enforces the timeout by removing the iframe, which terminates the
+ * worker at the browser level.
+ *
+ * **Architecture & Relationships:**
+ * - **SandboxService** (this class): Stateless sandbox execution
+ * - **toolsStore**: Exposes the tool definition when the sandbox is enabled
+ * - **agenticStore**: Dispatches ToolSource.FRONTEND calls here
+ *
+ * @see SANDBOX_TOOL_DEFINITION in constants/sandbox.ts - tool schema sent to the LLM
+ * @see agenticStore in stores/agentic.svelte.ts - tool dispatch
+ */
+export { SandboxService } from './sandbox.service';
+
/**
* **RouterService** — Dynamic route URL construction utility
*
--- /dev/null
+import WORKER_SHIM from './sandbox-worker.js?raw';
+
+/**
+ * Harness loaded as srcdoc into a sandboxed iframe (allow-scripts only).
+ * The opaque origin is the security boundary: no access to the app origin,
+ * its storage or its API. The harness spawns a worker so model code never
+ * runs on a main thread, which makes the parent timeout enforceable by
+ * removing the iframe.
+ */
+export const SANDBOX_HARNESS_HTML = `<!doctype html><script>
+const SHIM = ${JSON.stringify(WORKER_SHIM)};
+addEventListener('message', (event) => {
+ const respond = (payload) => parent.postMessage(payload, '*');
+ let worker;
+ try {
+ worker = new Worker(URL.createObjectURL(new Blob([SHIM], { type: 'text/javascript' })));
+ } catch (err) {
+ respond({ logs: [], result: null, error: 'Worker creation failed: ' + err });
+ return;
+ }
+ worker.onmessage = (msg) => respond(msg.data);
+ worker.onerror = (err) => respond({ logs: [], result: null, error: String(err.message || err) });
+ worker.postMessage({ code: event.data.code });
+});
+</script>`;
--- /dev/null
+const logs = [];
+const fmt = (value) => {
+ if (typeof value === 'string') return value;
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+};
+const capture =
+ (level, prefix) =>
+ (...args) => {
+ logs.push(prefix + args.map(fmt).join(' '));
+ };
+console.log = capture('log', '');
+console.info = capture('info', '');
+console.debug = capture('debug', '');
+console.warn = capture('warn', 'warn: ');
+console.error = capture('error', 'error: ');
+self.onmessage = async (event) => {
+ const reply = { logs, result: null, error: null };
+ try {
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
+ const value = await new AsyncFunction(event.data.code)();
+ if (value !== undefined) reply.result = fmt(value);
+ } catch (err) {
+ reply.error = err instanceof Error ? err.stack || err.message : String(err);
+ }
+ self.postMessage(reply);
+};
--- /dev/null
+import {
+ NEWLINE_SEPARATOR,
+ SANDBOX_EMPTY_OUTPUT,
+ SANDBOX_OUTPUT_MAX_CHARS,
+ SANDBOX_TIMEOUT_MS_DEFAULT,
+ SANDBOX_TIMEOUT_MS_MAX,
+ SANDBOX_TOOL_NAME,
+ SANDBOX_TRUNCATION_NOTICE
+} from '$lib/constants';
+import { SANDBOX_HARNESS_HTML } from './sandbox-harness';
+import type { ToolExecutionResult } from '$lib/types';
+
+interface SandboxReply {
+ logs?: unknown;
+ result?: unknown;
+ error?: unknown;
+}
+
+function formatReply(reply: SandboxReply): ToolExecutionResult {
+ const lines: string[] = [];
+
+ if (Array.isArray(reply.logs)) {
+ for (const line of reply.logs) lines.push(String(line));
+ }
+
+ if (reply.error != null) {
+ lines.push(`Error: ${String(reply.error)}`);
+ } else if (reply.result != null) {
+ lines.push(`=> ${String(reply.result)}`);
+ }
+
+ let content = lines.join(NEWLINE_SEPARATOR);
+ if (!content) content = SANDBOX_EMPTY_OUTPUT;
+ if (content.length > SANDBOX_OUTPUT_MAX_CHARS) {
+ content = `${content.slice(0, SANDBOX_OUTPUT_MAX_CHARS)}${NEWLINE_SEPARATOR}${SANDBOX_TRUNCATION_NOTICE}`;
+ }
+
+ return { content, isError: reply.error != null };
+}
+
+export class SandboxService {
+ /**
+ * Execute a frontend sandbox tool call and return its output.
+ * One disposable iframe per execution, removed on completion,
+ * timeout or abort. Removing the iframe terminates the worker
+ * at the browser level, so runaway code cannot outlive it.
+ */
+ static executeTool(
+ toolName: string,
+ params: Record<string, unknown>,
+ signal?: AbortSignal
+ ): Promise<ToolExecutionResult> {
+ if (toolName !== SANDBOX_TOOL_NAME) {
+ return Promise.resolve({ content: `Unknown frontend tool: ${toolName}`, isError: true });
+ }
+
+ const code = typeof params.code === 'string' ? params.code : '';
+ if (!code) {
+ return Promise.resolve({ content: 'Missing required parameter: code', isError: true });
+ }
+
+ const requested = Number(params.timeout_ms);
+ const timeoutMs =
+ Number.isFinite(requested) && requested > 0
+ ? Math.min(requested, SANDBOX_TIMEOUT_MS_MAX)
+ : SANDBOX_TIMEOUT_MS_DEFAULT;
+
+ return new Promise<ToolExecutionResult>((resolve, reject) => {
+ const iframe = document.createElement('iframe');
+ iframe.setAttribute('sandbox', 'allow-scripts');
+ iframe.style.display = 'none';
+ iframe.srcdoc = SANDBOX_HARNESS_HTML;
+
+ let settled = false;
+
+ const cleanup = () => {
+ settled = true;
+ clearTimeout(timer);
+ window.removeEventListener('message', onMessage);
+ signal?.removeEventListener('abort', onAbort);
+ iframe.remove();
+ };
+
+ const finish = (result: ToolExecutionResult) => {
+ if (settled) return;
+ cleanup();
+ resolve(result);
+ };
+
+ const onAbort = () => {
+ if (settled) return;
+ cleanup();
+ reject(new DOMException('Sandbox execution aborted', 'AbortError'));
+ };
+
+ const onMessage = (event: MessageEvent) => {
+ if (event.source !== iframe.contentWindow) return;
+ finish(formatReply((event.data ?? {}) as SandboxReply));
+ };
+
+ const timer = setTimeout(
+ () => finish({ content: `Execution timed out after ${timeoutMs} ms`, isError: true }),
+ timeoutMs
+ );
+
+ window.addEventListener('message', onMessage);
+ signal?.addEventListener('abort', onAbort);
+ iframe.onload = () => iframe.contentWindow?.postMessage({ code }, '*');
+ document.body.appendChild(iframe);
+ });
+ }
+}
import { ToolSource, ToolPermissionDecision } from '$lib/enums';
import { SvelteMap } from 'svelte/reactivity';
import { ToolsService } from '$lib/services/tools.service';
+import { SandboxService } from '$lib/services/sandbox.service';
import { isAbortError } from '$lib/utils';
import { DEFAULT_AGENTIC_CONFIG, NEWLINE_SEPARATOR } from '$lib/constants';
import {
result = executionResult.content;
+ if (executionResult.isError) toolSuccess = false;
+ } else if (toolSource === ToolSource.FRONTEND) {
+ const args = this.parseToolArguments(toolCall.function.arguments);
+ const executionResult = await SandboxService.executeTool(toolName, args, signal);
+
+ result = executionResult.content;
+
if (executionResult.isError) toolSuccess = false;
} else {
const mcpCall: MCPToolCall = {
import { config } from '$lib/stores/settings.svelte';
import {
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
+ SANDBOX_TOOL_DEFINITION,
TOOL_GROUP_LABELS,
TOOL_SERVER_LABELS
} from '$lib/constants';
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
case ToolSource.CUSTOM:
return `custom:${name}`;
+ case ToolSource.FRONTEND:
+ return `frontend:${name}`;
default:
return `builtin:${name}`;
}
return mcpStore.getToolDefinitionsForLLM();
}
+ get frontendTools(): OpenAIToolDefinition[] {
+ return config().jsSandboxEnabled ? [SANDBOX_TOOL_DEFINITION] : [];
+ }
+
get customTools(): OpenAIToolDefinition[] {
const raw = config().customJson;
if (!raw || typeof raw !== 'string') return [];
push({ source: ToolSource.BUILTIN, key: toolKey(ToolSource.BUILTIN, name), definition: def });
}
+ for (const def of this.frontendTools) {
+ const name = def.function.name;
+ push({
+ source: ToolSource.FRONTEND,
+ key: toolKey(ToolSource.FRONTEND, name),
+ definition: def
+ });
+ }
+
for (const { serverId, serverName, definition } of this.mcpEntries()) {
const name = definition.function.name;
push({
return entry.serverName ?? '';
case ToolSource.CUSTOM:
return TOOL_GROUP_LABELS[ToolSource.CUSTOM];
+ case ToolSource.FRONTEND:
+ return TOOL_GROUP_LABELS[ToolSource.FRONTEND];
default:
return TOOL_GROUP_LABELS[ToolSource.BUILTIN];
}
};
for (const def of this._builtinTools) take(def);
+ for (const def of this.frontendTools) take(def);
for (const def of mcpStore.getToolDefinitionsForLLM()) take(def);
for (const def of this.customTools) take(def);
if (entry.serverName) return mcpStore.getServerDisplayName(entry.serverName);
if (entry.source === ToolSource.BUILTIN) return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
if (entry.source === ToolSource.CUSTOM) return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
+ if (entry.source === ToolSource.FRONTEND) return TOOL_SERVER_LABELS[ToolSource.FRONTEND];
return '';
}
"tests/**/*.svelte",
".storybook/**/*.ts",
".storybook/**/*.svelte"
- ]
+ ],
+ "exclude": ["src/lib/services/sandbox-worker.js"]
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//