File size: 3,280 Bytes
ef6e870
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
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);
		});
	}
}