ui: add opt-in run_javascript frontend tool (#24244)
* ui: add opt-in run_javascript frontend tool Expose a run_javascript tool to the model, executed entirely in the browser through the existing agentic loop. Code runs in a Web Worker inside a sandboxed iframe with an opaque origin, isolated from the WebUI and its API. Console output, errors and the return value are fed back as the tool result. The parent enforces a hard timeout by removing the iframe, which terminates the worker. Disabled by default, toggle in Settings > Developer. * ui: address review feedback from allozaur Use the JsonSchemaType enum for the tool definition parameter types instead of raw string literals, extending it with STRING and NUMBER. Move the worker shim and the iframe harness html into their own files so the service no longer carries inline source blobs. Replace the remaining magic strings with constants: SANDBOX_EMPTY_OUTPUT and SANDBOX_TRUNCATION_NOTICE, and reuse NEWLINE_SEPARATOR for joins. * ui: move sandbox worker shim to a raw imported file Replace the inline worker template string with a real sandbox-worker.js imported as raw text, and build the iframe harness from it in sandbox-harness.ts. The raw worker ships as a string, not a module, so it is excluded from eslint and the typecheck program.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user