Skip to content

Commit d76f2cd

Browse files
authored
improvement(execution): run sandbox workers on a dedicated worker host (#9348)
* 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. * fix(execution): address review on the dedicated worker host - Host: reject an unparseable upgrade target instead of throwing; bound unauthenticated connections separately so they never hold a worker slot. - Pool: re-enqueue an execution whose selected worker was retired before dispatch; forward TZ/LANG/LC_ALL with the limits so remote and local workers produce the same Date/Intl results. - Keep the child_process import in its original form so the bundler does not trace the worker spawn; give ffmpeg's env NODE_ENV for ProcessEnv. - E2E: stop the suite when a check cannot settle; assert the exact truncation bound; cover the unparseable target. * improvement(execution): replace the remote-workers flag and its env fallback with a local-workers kill switch A configured worker host is now used by default; the AppConfig-only isolated-vm-local-workers kill switch forces local workers during rollout. Drops IVM_REMOTE_WORKERS, and an unreachable AppConfig keeps the host in use. * fix(execution): re-queue work selected onto a retiring worker; stop E2E hosts with their workers - dispatchToWorker re-queues when the selected worker retired during a target switch (not only when it was removed), and cleans it up if idle. - E2E hosts run in their own process group so cleanup also stops their worker children. - Sandbox rule notes the URL needs its token.
1 parent 1a69d2a commit d76f2cd

18 files changed

Lines changed: 1362 additions & 73 deletions

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

Lines changed: 27 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,31 @@ 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, put it in `appWorkerEnv()` and add it with its accepted shape
82+
to `CLIENT_WORKER_ENV` in `isolated-vm-host.cjs`, so a remote worker
83+
gets 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 (with `IVM_REMOTE_WORKER_TOKEN`; a URL
92+
without a valid token fails executions rather than falling back) and the
93+
`isolated-vm-local-workers` kill switch is off, as a WebSocket connection to
94+
`isolated-vm-host.cjs`, which runs the
95+
same worker in a dedicated service that holds no platform credentials. Every
96+
rule in this file applies in both modes.
97+
98+
- `fetch()` and brokers always run in the app. The host only relays the
99+
worker's messages, so it needs no network access of its own and never gets
100+
a broker implementation.
101+
- A remote target never falls back to a local child process. An unreachable
102+
host fails executions with a system error; keep it that way.
103+
- Changes to the worker message protocol must stay compatible with the
104+
previous release: the host and its clients roll out independently.
79105

80106
## What the worker *may* hold
81107

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

Lines changed: 26 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,31 @@ 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, put it in `appWorkerEnv()` and add it with its accepted shape
78+
to `CLIENT_WORKER_ENV` in `isolated-vm-host.cjs`, so a remote worker
79+
gets 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 (with `IVM_REMOTE_WORKER_TOKEN`; a URL
88+
without a valid token fails executions rather than falling back) and the
89+
`isolated-vm-local-workers` kill switch is off, as a WebSocket connection to
90+
`isolated-vm-host.cjs`, which runs the
91+
same worker in a dedicated service that holds no platform credentials. Every
92+
rule in this file applies in both modes.
93+
94+
- `fetch()` and brokers always run in the app. The host only relays the
95+
worker's messages, so it needs no network access of its own and never gets
96+
a broker implementation.
97+
- A remote target never falls back to a local child process. An unreachable
98+
host fails executions with a system error; keep it that way.
99+
- Changes to the worker message protocol must stay compatible with the
100+
previous release: the host and its clients roll out independently.
77101

78102
## What the worker *may* hold
79103

‎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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,8 @@ export const env = createEnv({
456456
IVM_MAX_BROKER_ARGS_JSON_CHARS: z.string().optional().default('262144'), // Max JSON payload size for sandbox task broker args (isolate→host)
457457
IVM_MAX_BROKER_RESULT_JSON_CHARS: z.string().optional().default('16777216'),// Max JSON payload size for sandbox task broker results (host→isolate)
458458
IVM_MAX_BROKERS_PER_EXECUTION: z.string().optional().default('1000'), // Max broker calls per sandbox task execution
459+
IVM_REMOTE_WORKER_URL: z.string().optional(), // ws(s)://<host>/v1/worker of a dedicated worker host; workers run there unless the isolated-vm-local-workers kill switch is on
460+
IVM_REMOTE_WORKER_TOKEN: z.string().optional(), // Token the worker host expects (its IVM_HOST_TOKEN, >= 32 chars); required with IVM_REMOTE_WORKER_URL
459461

460462
// Knowledge Base Processing Configuration - Shared across all processing methods
461463
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-local-workers': {
125+
description:
126+
'Kill switch: run JavaScript sandbox workers as local child processes even where ' +
127+
'IVM_REMOTE_WORKER_URL names a dedicated worker host. Global on/off only; off by default, ' +
128+
'so a configured host is used unless this is turned on. AppConfig-only: when AppConfig is ' +
129+
'unreachable the host stays in use.',
130+
fallback: null,
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)