Skip to content

Commit 4836095

Browse files
ui: add opt-in run_javascript frontend tool (ggml-org#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.
1 parent 49f3542 commit 4836095

15 files changed

Lines changed: 283 additions & 6 deletions

tools/ui/eslint.config.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,14 @@ export default ts.config(
4646
},
4747
{
4848
// Exclude generated build output and Storybook files from ESLint
49-
ignores: ['dist/**', 'build/**', '.svelte-kit/**', 'test-results/**', '.storybook/**/*']
49+
ignores: [
50+
'dist/**',
51+
'build/**',
52+
'.svelte-kit/**',
53+
'test-results/**',
54+
'.storybook/**/*',
55+
'src/lib/services/sandbox-worker.js'
56+
]
5057
},
5158
storybook.configs['flat/recommended']
5259
);

tools/ui/src/lib/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export * from './model-id';
3737
export * from './precision';
3838
export * from './processing-info';
3939
export * from './routes';
40+
export * from './sandbox';
4041
export * from './settings-keys';
4142
export * from './settings-registry';
4243
export * from './supported-file-types';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { JsonSchemaType, ToolCallType } from '$lib/enums';
2+
import type { OpenAIToolDefinition } from '$lib/types';
3+
4+
export const SANDBOX_TOOL_NAME = 'run_javascript';
5+
6+
export const SANDBOX_TIMEOUT_MS_DEFAULT = 10000;
7+
8+
export const SANDBOX_TIMEOUT_MS_MAX = 30000;
9+
10+
export const SANDBOX_OUTPUT_MAX_CHARS = 8192;
11+
12+
export const SANDBOX_EMPTY_OUTPUT = '(no output)';
13+
14+
export const SANDBOX_TRUNCATION_NOTICE = '[output truncated]';
15+
16+
export const SANDBOX_TOOL_DEFINITION: OpenAIToolDefinition = {
17+
type: ToolCallType.FUNCTION,
18+
function: {
19+
name: SANDBOX_TOOL_NAME,
20+
description:
21+
'Execute JavaScript in a sandboxed browser worker (no DOM, no page access). ' +
22+
'Top level await is supported. Use console.log to print intermediate values; ' +
23+
'a top level return statement is captured as the result.',
24+
parameters: {
25+
type: JsonSchemaType.OBJECT,
26+
properties: {
27+
code: {
28+
type: JsonSchemaType.STRING,
29+
description: 'JavaScript source to execute'
30+
},
31+
timeout_ms: {
32+
type: JsonSchemaType.NUMBER,
33+
description: `Execution timeout in milliseconds, default ${SANDBOX_TIMEOUT_MS_DEFAULT}, max ${SANDBOX_TIMEOUT_MS_MAX}`
34+
}
35+
},
36+
required: ['code']
37+
}
38+
}
39+
};

tools/ui/src/lib/constants/settings-keys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export const SETTINGS_KEYS = {
6969
ENABLE_THINKING: 'enableThinking',
7070
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
7171
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
72+
JS_SANDBOX_ENABLED: 'jsSandboxEnabled',
7273
CUSTOM_JSON: 'customJson',
7374
CUSTOM_CSS: 'customCss'
7475
} as const;

tools/ui/src/lib/constants/settings-registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
690690
paramType: SyncableParameterType.BOOLEAN
691691
}
692692
},
693+
{
694+
key: SETTINGS_KEYS.JS_SANDBOX_ENABLED,
695+
label: 'JavaScript sandbox tool',
696+
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.',
697+
defaultValue: false,
698+
type: SettingsFieldType.CHECKBOX,
699+
section: SETTINGS_SECTION_SLUGS.DEVELOPER
700+
},
693701
{
694702
key: SETTINGS_KEYS.CUSTOM_JSON,
695703
label: 'Custom JSON',

tools/ui/src/lib/constants/tools.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { ToolSource } from '$lib/enums/tools.enums';
22

33
export const TOOL_GROUP_LABELS = {
44
[ToolSource.BUILTIN]: 'Built-in',
5-
[ToolSource.CUSTOM]: 'JSON Schema'
5+
[ToolSource.CUSTOM]: 'JSON Schema',
6+
[ToolSource.FRONTEND]: 'Browser'
67
} as const;
78

89
export const TOOL_SERVER_LABELS = {
910
[ToolSource.BUILTIN]: 'Built-in Tools',
10-
[ToolSource.CUSTOM]: 'Custom Tools'
11+
[ToolSource.CUSTOM]: 'Custom Tools',
12+
[ToolSource.FRONTEND]: 'Browser Tools'
1113
} as const;

tools/ui/src/lib/enums/mcp.enums.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ export enum MCPContentType {
5454
* JSON Schema types used in MCP tool definitions
5555
*/
5656
export enum JsonSchemaType {
57-
OBJECT = 'object'
57+
OBJECT = 'object',
58+
STRING = 'string',
59+
NUMBER = 'number'
5860
}
5961

6062
/**

tools/ui/src/lib/enums/tools.enums.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
export enum ToolSource {
22
BUILTIN = 'builtin',
33
MCP = 'mcp',
4-
CUSTOM = 'custom'
4+
CUSTOM = 'custom',
5+
FRONTEND = 'frontend'
56
}
67

78
export enum ToolPermissionDecision {

tools/ui/src/lib/services/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,26 @@ export { ParameterSyncService } from './parameter-sync.service';
261261
*/
262262
export { MCPService } from './mcp.service';
263263

264+
/**
265+
* **SandboxService** - Frontend JavaScript execution in a browser sandbox
266+
*
267+
* Stateless executor for the run_javascript frontend tool. Model generated
268+
* code runs in a Web Worker spawned inside a sandboxed iframe with an opaque
269+
* origin: no access to the app origin, its storage or its API, and outgoing
270+
* requests carry a null origin. The code never touches a main thread, so the
271+
* parent enforces the timeout by removing the iframe, which terminates the
272+
* worker at the browser level.
273+
*
274+
* **Architecture & Relationships:**
275+
* - **SandboxService** (this class): Stateless sandbox execution
276+
* - **toolsStore**: Exposes the tool definition when the sandbox is enabled
277+
* - **agenticStore**: Dispatches ToolSource.FRONTEND calls here
278+
*
279+
* @see SANDBOX_TOOL_DEFINITION in constants/sandbox.ts - tool schema sent to the LLM
280+
* @see agenticStore in stores/agentic.svelte.ts - tool dispatch
281+
*/
282+
export { SandboxService } from './sandbox.service';
283+
264284
/**
265285
* **RouterService** — Dynamic route URL construction utility
266286
*
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import WORKER_SHIM from './sandbox-worker.js?raw';
2+
3+
/**
4+
* Harness loaded as srcdoc into a sandboxed iframe (allow-scripts only).
5+
* The opaque origin is the security boundary: no access to the app origin,
6+
* its storage or its API. The harness spawns a worker so model code never
7+
* runs on a main thread, which makes the parent timeout enforceable by
8+
* removing the iframe.
9+
*/
10+
export const SANDBOX_HARNESS_HTML = `<!doctype html><script>
11+
const SHIM = ${JSON.stringify(WORKER_SHIM)};
12+
addEventListener('message', (event) => {
13+
const respond = (payload) => parent.postMessage(payload, '*');
14+
let worker;
15+
try {
16+
worker = new Worker(URL.createObjectURL(new Blob([SHIM], { type: 'text/javascript' })));
17+
} catch (err) {
18+
respond({ logs: [], result: null, error: 'Worker creation failed: ' + err });
19+
return;
20+
}
21+
worker.onmessage = (msg) => respond(msg.data);
22+
worker.onerror = (err) => respond({ logs: [], result: null, error: String(err.message || err) });
23+
worker.postMessage({ code: event.data.code });
24+
});
25+
</script>`;

0 commit comments

Comments
 (0)