Skip to content

Commit 858ebe5

Browse files
committed
improvement(execution): run sandbox workers on a dedicated worker host
- Isolated-vm pool can open workers as connections to a dedicated worker host (isolated-vm-host.cjs, shipped in the app image) instead of local child processes, behind IVM_REMOTE_WORKER_URL/TOKEN and the isolated-vm-remote-workers flag. Remote mode never falls back to local workers; a target change retires old workers without interrupting runs. - The host relays the unchanged worker protocol; fetch and brokers stay in the app, and the app sends its worker limits so both modes enforce the same values. - ffmpeg/ffprobe run with a minimal environment and a file-only input protocol whitelist.
1 parent e5ad3ae commit 858ebe5

18 files changed

Lines changed: 1283 additions & 70 deletions

‎.claude/rules/sim-sandbox.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
description: Isolated-vm sandbox worker security policy. Hard rules for anything that lives in the worker child process that runs user code.
33
paths:
44
- "apps/sim/lib/execution/isolated-vm-worker.cjs"
5+
- "apps/sim/lib/execution/isolated-vm-host.cjs"
6+
- "apps/sim/lib/execution/isolated-vm-remote.ts"
57
- "apps/sim/lib/execution/isolated-vm.ts"
68
- "apps/sim/lib/execution/sandbox/**"
79
- "apps/sim/sandboxes/**"
@@ -75,7 +77,29 @@ payload or `ivm.Reference` wrapper in the worker:
7577
- [ ] Does the worker read a new env var? Add it to the `buildWorkerEnv()`
7678
allowlist in `isolated-vm.ts` **and** to the allowlist regression test in
7779
`isolated-vm.test.ts` — the worker does not inherit the app environment,
78-
so an un-allowlisted var is simply absent in the child.
80+
so an un-allowlisted var is simply absent in the child. If the app owns
81+
its value, also add it to `workerLimitsEnv()` and to
82+
`CLIENT_WORKER_ENV_KEYS` in `isolated-vm-host.cjs`, so a remote worker
83+
enforces the same value.
84+
- [ ] Does the host send the worker a new message type? Add it to
85+
`ALLOWED_INBOUND_TYPES` in `isolated-vm-host.cjs`, or remote workers
86+
never receive it.
87+
88+
## Where the worker runs
89+
90+
The pool in `isolated-vm.ts` opens each worker either as a local child process
91+
or, when `IVM_REMOTE_WORKER_URL` is set and the `isolated-vm-remote-workers`
92+
flag is on, as a WebSocket connection to `isolated-vm-host.cjs`, which runs the
93+
same worker in a dedicated service that holds no platform credentials. Every
94+
rule in this file applies in both modes.
95+
96+
- `fetch()` and brokers always run in the app. The host only relays the
97+
worker's messages, so it needs no network access of its own and never gets
98+
a broker implementation.
99+
- A remote target never falls back to a local child process. An unreachable
100+
host fails executions with a system error; keep it that way.
101+
- Changes to the worker message protocol must stay compatible with the
102+
previous release: the host and its clients roll out independently.
79103

80104
## What the worker *may* hold
81105

‎.cursor/rules/sim-sandbox.mdc‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
description: "Isolated-vm sandbox worker security policy. Hard rules for anything that lives in the worker child process that runs user code."
3-
globs: ["apps/sim/lib/execution/isolated-vm-worker.cjs","apps/sim/lib/execution/isolated-vm.ts","apps/sim/lib/execution/sandbox/**","apps/sim/sandboxes/**"]
3+
globs: ["apps/sim/lib/execution/isolated-vm-worker.cjs","apps/sim/lib/execution/isolated-vm-host.cjs","apps/sim/lib/execution/isolated-vm-remote.ts","apps/sim/lib/execution/isolated-vm.ts","apps/sim/lib/execution/sandbox/**","apps/sim/sandboxes/**"]
44
---
55

66
<!-- Generated from .claude/rules/sim-sandbox.md by `bun run skills:sync`. Edit the source, not this file. -->
@@ -73,7 +73,29 @@ payload or `ivm.Reference` wrapper in the worker:
7373
- [ ] Does the worker read a new env var? Add it to the `buildWorkerEnv()`
7474
allowlist in `isolated-vm.ts` **and** to the allowlist regression test in
7575
`isolated-vm.test.ts` — the worker does not inherit the app environment,
76-
so an un-allowlisted var is simply absent in the child.
76+
so an un-allowlisted var is simply absent in the child. If the app owns
77+
its value, also add it to `workerLimitsEnv()` and to
78+
`CLIENT_WORKER_ENV_KEYS` in `isolated-vm-host.cjs`, so a remote worker
79+
enforces the same value.
80+
- [ ] Does the host send the worker a new message type? Add it to
81+
`ALLOWED_INBOUND_TYPES` in `isolated-vm-host.cjs`, or remote workers
82+
never receive it.
83+
84+
## Where the worker runs
85+
86+
The pool in `isolated-vm.ts` opens each worker either as a local child process
87+
or, when `IVM_REMOTE_WORKER_URL` is set and the `isolated-vm-remote-workers`
88+
flag is on, as a WebSocket connection to `isolated-vm-host.cjs`, which runs the
89+
same worker in a dedicated service that holds no platform credentials. Every
90+
rule in this file applies in both modes.
91+
92+
- `fetch()` and brokers always run in the app. The host only relays the
93+
worker's messages, so it needs no network access of its own and never gets
94+
a broker implementation.
95+
- A remote target never falls back to a local child process. An unreachable
96+
host fails executions with a system error; keep it that way.
97+
- Changes to the worker message protocol must stay compatible with the
98+
previous release: the host and its clients roll out independently.
7799

78100
## What the worker *may* hold
79101

‎apps/sim/lib/audio/extractor.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ describe('audio FFmpeg execution', () => {
7373
it('keeps MIME-derived input filenames inside the temporary directory', async () => {
7474
await getAudioMetadata(Buffer.from('audio'), 'audio/../../../../escaped')
7575

76+
expect(calls[0].args.slice(0, 2)).toEqual(['-protocol_whitelist', 'file,pipe'])
7677
const inputFile = calls[0].args.at(-1)
7778
expect(inputFile).toBeDefined()
7879
expect(path.basename(inputFile as string)).toBe('input.dat')
@@ -97,6 +98,8 @@ describe('audio FFmpeg execution', () => {
9798
expect(conversion?.args).toEqual([
9899
'-y',
99100
'-nostdin',
101+
'-protocol_whitelist',
102+
'file,pipe',
100103
'-i',
101104
expect.stringMatching(/input\.mp4$/),
102105
'-f',

‎apps/sim/lib/audio/extractor.ts‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import type {
1010
} from '@/lib/audio/types'
1111
import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'
1212
import { MAX_MEDIA_BYTES } from '@/lib/media/falai'
13-
import { FFMPEG_BASE_ARGS, resolveExecutable, runExecutable } from '@/lib/media/ffmpeg-process'
13+
import {
14+
ffmpegArgs,
15+
ffprobeJsonArgs,
16+
resolveExecutable,
17+
runExecutable,
18+
} from '@/lib/media/ffmpeg-process'
1419

1520
const logger = createLogger('AudioExtractor')
1621

@@ -59,7 +64,7 @@ async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>):
5964

6065
async function runFfmpeg(args: string[], signal?: AbortSignal): Promise<void> {
6166
try {
62-
await runExecutable(requireFfmpeg(), [...FFMPEG_BASE_ARGS, ...args], {
67+
await runExecutable(requireFfmpeg(), ffmpegArgs(args), {
6368
maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES,
6469
signal,
6570
timeoutMs: CONVERSION_TIMEOUT_MS,
@@ -195,15 +200,11 @@ async function getAudioMetadataFromFile(
195200
): Promise<AudioMetadata> {
196201
let stdout: string
197202
try {
198-
;({ stdout } = await runExecutable(
199-
requireFfprobe(),
200-
['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', filePath],
201-
{
202-
maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES,
203-
signal,
204-
timeoutMs: PROBE_TIMEOUT_MS,
205-
}
206-
))
203+
;({ stdout } = await runExecutable(requireFfprobe(), ffprobeJsonArgs(filePath), {
204+
maxOutputBytes: MAX_PROCESS_OUTPUT_BYTES,
205+
signal,
206+
timeoutMs: PROBE_TIMEOUT_MS,
207+
}))
207208
} catch (error) {
208209
signal?.throwIfAborted()
209210
const failure = error as NodeJS.ErrnoException & { killed?: boolean; stderr?: string }

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,9 @@ export const env = createEnv({
454454
IVM_MAX_BROKER_ARGS_JSON_CHARS: z.string().optional().default('262144'), // Max JSON payload size for sandbox task broker args (isolate→host)
455455
IVM_MAX_BROKER_RESULT_JSON_CHARS: z.string().optional().default('16777216'),// Max JSON payload size for sandbox task broker results (host→isolate)
456456
IVM_MAX_BROKERS_PER_EXECUTION: z.string().optional().default('1000'), // Max broker calls per sandbox task execution
457+
IVM_REMOTE_WORKER_URL: z.string().optional(), // ws(s)://<host>/v1/worker of a dedicated worker host; used only while the isolated-vm-remote-workers flag is on
458+
IVM_REMOTE_WORKER_TOKEN: z.string().optional(), // Token the worker host expects (its IVM_HOST_TOKEN, >= 32 chars); required with IVM_REMOTE_WORKER_URL
459+
IVM_REMOTE_WORKERS: z.string().optional(), // Fallback for the isolated-vm-remote-workers feature flag when AppConfig is not the source of truth
457460

458461
// Knowledge Base Processing Configuration - Shared across all processing methods
459462
KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes)

‎apps/sim/lib/core/config/feature-flags.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ const FEATURE_FLAGS = {
121121
'targeting. Off-AppConfig falls back to KNOWLEDGE_MEMBER_ACCESS.',
122122
fallback: 'KNOWLEDGE_MEMBER_ACCESS',
123123
},
124+
'isolated-vm-remote-workers': {
125+
description:
126+
'Run JavaScript sandbox workers on the dedicated worker host at IVM_REMOTE_WORKER_URL ' +
127+
'instead of as local child processes. Global on/off only; no effect where ' +
128+
'IVM_REMOTE_WORKER_URL is unset. While on, an unreachable host fails executions ' +
129+
'instead of falling back to local workers. Off-AppConfig falls back to IVM_REMOTE_WORKERS.',
130+
fallback: 'IVM_REMOTE_WORKERS',
131+
},
124132
'mothership-abandoned-run-takeover-disabled': {
125133
description:
126134
'Kill switch for Sim taking over Chat runs whose controller died (a deploy or a crash) and ' +

0 commit comments

Comments
 (0)