Skip to content

Commit 0048bbc

Browse files
improvement(sandbox): select the provider by SANDBOX_PROVIDER env var
Replaces the boolean sandbox-provider-daytona feature flag with a SANDBOX_PROVIDER env var naming the provider ('e2b' default, or 'daytona'). A boolean doesn't scale to a third adapter; a keyed registry does. - PROVIDERS is a Record<SandboxProviderId, SandboxProvider>, so adding an adapter is one entry plus one id-union member — an unhandled provider is a compile error, not a runtime surprise - resolveProvider() reads env synchronously and throws on an unknown value (fail fast) instead of an async feature-flag lookup - drops the sandbox-provider-daytona flag and SANDBOX_PROVIDER_DAYTONA fallback Verified end-to-end: a Python function block through the running app routes to Daytona (Creating Daytona sandbox, kind: code) with SANDBOX_PROVIDER=daytona.
1 parent e201d6c commit 0048bbc

5 files changed

Lines changed: 67 additions & 51 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,12 +454,14 @@ export const env = createEnv({
454454
MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path
455455
E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Create PR and Review Code)
456456

457-
// Daytona Remote Code Execution (manual failover for E2B; selected by the sandbox-provider-daytona flag)
457+
// Remote Code Execution provider selection
458+
SANDBOX_PROVIDER: z.string().optional(), // Which sandbox provider serves remote executions: 'e2b' (default) or 'daytona'
459+
460+
// Daytona Remote Code Execution (used when SANDBOX_PROVIDER=daytona)
458461
DAYTONA_API_KEY: z.string().optional(), // Daytona API key; needs write:snapshots to build images, write:sandboxes to run them
459462
DAYTONA_SHELL_SNAPSHOT_ID: z.string().optional(), // Daytona snapshot mirroring mothership-shell (must carry an explicit tag; latest is rejected)
460463
DAYTONA_DOC_SNAPSHOT_ID: z.string().optional(), // Daytona snapshot mirroring mothership-docs
461464
DAYTONA_PI_SNAPSHOT_ID: z.string().optional(), // Daytona snapshot mirroring the Pi template (Create PR and Review Code)
462-
SANDBOX_PROVIDER_DAYTONA: z.string().optional(), // Fallback for the sandbox-provider-daytona flag when AppConfig is not the source of truth
463465

464466
// Access Control (Permission Groups) - for self-hosted deployments
465467
ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control on self-hosted (bypasses plan requirements)

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,6 @@ const FEATURE_FLAGS = {
105105
'self-hosted/local behaviour. Fallback mirrors FORKING_ENABLED for off-AppConfig reads.',
106106
fallback: 'FORKING_ENABLED',
107107
},
108-
'sandbox-provider-daytona': {
109-
description:
110-
'Route remote sandbox execution (function blocks, shell, doc generation, Pi cloud agent) to ' +
111-
'Daytona instead of E2B — the manual failover for an E2B outage. Global on/off only: ' +
112-
'resolved without user/org context at every sandbox entry point so the whole deployment ' +
113-
'switches together, and resolved ONCE before the sandbox is created so a run is never ' +
114-
'migrated mid-execution (user code has side effects). Requires the DAYTONA_* snapshot ids; ' +
115-
'each sandbox kind fails closed when its snapshot is unset.',
116-
fallback: 'SANDBOX_PROVIDER_DAYTONA',
117-
},
118108
'deploy-as-block': {
119109
description:
120110
'Publish a deployed workflow as a reusable, org-wide custom block (custom name/SVG icon/' +

apps/sim/lib/execution/remote-sandbox/conformance.test.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1010
import { CodeLanguage } from '@/lib/execution/languages'
1111

1212
const {
13-
mockIsFeatureEnabled,
13+
mockEnv,
1414
mockE2BCreate,
1515
mockE2BRunCode,
1616
mockE2BCommandsRun,
@@ -25,7 +25,17 @@ const {
2525
mockDownloadFile,
2626
mockDelete,
2727
} = vi.hoisted(() => ({
28-
mockIsFeatureEnabled: vi.fn(),
28+
mockEnv: {
29+
SANDBOX_PROVIDER: 'e2b' as string | undefined,
30+
E2B_API_KEY: 'test-key',
31+
MOTHERSHIP_E2B_TEMPLATE_ID: 'mothership-shell',
32+
MOTHERSHIP_E2B_DOC_TEMPLATE_ID: 'mothership-docs',
33+
E2B_PI_TEMPLATE_ID: 'sim-pi',
34+
DAYTONA_API_KEY: 'test-key',
35+
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1' as string | undefined,
36+
DAYTONA_DOC_SNAPSHOT_ID: 'mothership-docs:v1' as string | undefined,
37+
DAYTONA_PI_SNAPSHOT_ID: 'sim-pi:v1' as string | undefined,
38+
},
2939
mockE2BCreate: vi.fn(),
3040
mockE2BRunCode: vi.fn(),
3141
mockE2BCommandsRun: vi.fn(),
@@ -47,19 +57,7 @@ vi.mock('@daytonaio/sdk', () => ({
4757
create = mockDaytonaCreate
4858
},
4959
}))
50-
vi.mock('@/lib/core/config/env', () => ({
51-
env: {
52-
E2B_API_KEY: 'test-key',
53-
MOTHERSHIP_E2B_TEMPLATE_ID: 'mothership-shell',
54-
MOTHERSHIP_E2B_DOC_TEMPLATE_ID: 'mothership-docs',
55-
E2B_PI_TEMPLATE_ID: 'sim-pi',
56-
DAYTONA_API_KEY: 'test-key',
57-
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
58-
DAYTONA_DOC_SNAPSHOT_ID: 'mothership-docs:v1',
59-
DAYTONA_PI_SNAPSHOT_ID: 'sim-pi:v1',
60-
},
61-
}))
62-
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled }))
60+
vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
6361

6462
import {
6563
executeInSandbox,
@@ -70,9 +68,9 @@ import {
7068
type Provider = 'e2b' | 'daytona'
7169
const PROVIDERS: Provider[] = ['e2b', 'daytona']
7270

73-
/** Points the shared layer at one provider and stubs that provider's SDK. */
71+
/** Points the shared layer at one provider via the SANDBOX_PROVIDER env var. */
7472
function useProvider(provider: Provider) {
75-
mockIsFeatureEnabled.mockResolvedValue(provider === 'daytona')
73+
mockEnv.SANDBOX_PROVIDER = provider
7674
}
7775

7876
/** Stubs a code execution that prints `stdout` and emits `result` via the marker. */
@@ -295,11 +293,11 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
295293
})
296294

297295
describe('provider selection', () => {
298-
it('routes to E2B when the flag is off and Daytona when it is on', async () => {
296+
it('routes by SANDBOX_PROVIDER, defaulting to E2B when unset', async () => {
299297
stubCodeRun('e2b', `${SIM_RESULT_PREFIX}null`)
300298
stubCodeRun('daytona', `${SIM_RESULT_PREFIX}null`)
301299

302-
useProvider('e2b')
300+
mockEnv.SANDBOX_PROVIDER = undefined
303301
await executeInSandbox({ code: 'x', language: CodeLanguage.Python, timeoutMs: 1000 })
304302
expect(mockE2BCreate).toHaveBeenCalledTimes(1)
305303
expect(mockDaytonaCreate).not.toHaveBeenCalled()
@@ -309,6 +307,13 @@ describe('provider selection', () => {
309307
expect(mockDaytonaCreate).toHaveBeenCalledTimes(1)
310308
})
311309

310+
it('throws on an unknown SANDBOX_PROVIDER', async () => {
311+
mockEnv.SANDBOX_PROVIDER = 'modal'
312+
await expect(
313+
executeInSandbox({ code: 'x', language: CodeLanguage.Python, timeoutMs: 1000 })
314+
).rejects.toThrow(/Unknown SANDBOX_PROVIDER "modal"/)
315+
})
316+
312317
it('binds language at create time so JS never runs through the Python toolbox', async () => {
313318
useProvider('daytona')
314319
mockProcessCodeRun.mockResolvedValue({ result: `${SIM_RESULT_PREFIX}null`, exitCode: 0 })
@@ -325,9 +330,8 @@ describe('provider selection', () => {
325330

326331
it('fails closed when a Daytona snapshot id is unset', async () => {
327332
useProvider('daytona')
328-
const { env } = await import('@/lib/core/config/env')
329-
const original = env.DAYTONA_DOC_SNAPSHOT_ID
330-
;(env as { DAYTONA_DOC_SNAPSHOT_ID?: string }).DAYTONA_DOC_SNAPSHOT_ID = undefined
333+
const original = mockEnv.DAYTONA_DOC_SNAPSHOT_ID
334+
mockEnv.DAYTONA_DOC_SNAPSHOT_ID = undefined
331335

332336
await expect(
333337
executeInSandbox({
@@ -337,6 +341,6 @@ describe('provider selection', () => {
337341
sandboxKind: 'doc',
338342
})
339343
).rejects.toThrow(/DAYTONA_DOC_SNAPSHOT_ID is unset/)
340-
;(env as { DAYTONA_DOC_SNAPSHOT_ID?: string }).DAYTONA_DOC_SNAPSHOT_ID = original
344+
mockEnv.DAYTONA_DOC_SNAPSHOT_ID = original
341345
})
342346
})

apps/sim/lib/execution/remote-sandbox/index.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3-
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
3+
import { env } from '@/lib/core/config/env'
44
import type { CodeLanguage } from '@/lib/execution/languages'
55
import { daytonaProvider } from '@/lib/execution/remote-sandbox/daytona'
66
import { e2bProvider } from '@/lib/execution/remote-sandbox/e2b'
@@ -12,6 +12,7 @@ import type {
1212
SandboxHandle,
1313
SandboxKind,
1414
SandboxProvider,
15+
SandboxProviderId,
1516
SandboxShellExecutionRequest,
1617
} from '@/lib/execution/remote-sandbox/types'
1718

@@ -25,24 +26,44 @@ export type {
2526
const logger = createLogger('RemoteSandbox')
2627

2728
/**
28-
* Resolves which provider serves this execution.
29+
* The known sandbox providers. Keyed by {@link SandboxProviderId}, so adding an
30+
* adapter is one entry here plus one member on the id union — the type makes an
31+
* unhandled provider a compile error, not a runtime surprise.
32+
*/
33+
const PROVIDERS: Record<SandboxProviderId, SandboxProvider> = {
34+
e2b: e2bProvider,
35+
daytona: daytonaProvider,
36+
}
37+
38+
const DEFAULT_PROVIDER: SandboxProviderId = 'e2b'
39+
40+
/**
41+
* Resolves which provider serves this execution from the `SANDBOX_PROVIDER` env
42+
* var (defaulting to {@link DEFAULT_PROVIDER}).
2943
*
3044
* Selection is deliberately resolved ONCE, before the sandbox is created, and is
3145
* never revisited mid-execution: user code has side effects (HTTP calls, S3
32-
* writes, DB mutations), so retrying a partially-executed run on the other
33-
* provider could duplicate them. This is a manual failoverflip the
34-
* `sandbox-provider-daytona` flag and new executions move over.
46+
* writes, DB mutations), so retrying a partially-executed run on another provider
47+
* could duplicate them. Changing providers is a config changeset
48+
* `SANDBOX_PROVIDER` and redeploy; in-flight executions are unaffected.
3549
*/
36-
async function resolveProvider(): Promise<SandboxProvider> {
37-
const useDaytona = await isFeatureEnabled('sandbox-provider-daytona')
38-
return useDaytona ? daytonaProvider : e2bProvider
50+
function resolveProvider(): SandboxProvider {
51+
const configured = env.SANDBOX_PROVIDER
52+
if (!configured) return PROVIDERS[DEFAULT_PROVIDER]
53+
const provider = PROVIDERS[configured as SandboxProviderId]
54+
if (!provider) {
55+
throw new Error(
56+
`Unknown SANDBOX_PROVIDER "${configured}" (expected one of: ${Object.keys(PROVIDERS).join(', ')})`
57+
)
58+
}
59+
return provider
3960
}
4061

4162
async function createSandbox(
4263
kind: SandboxKind,
4364
options?: { language?: CodeLanguage }
4465
): Promise<SandboxHandle> {
45-
const provider = await resolveProvider()
66+
const provider = resolveProvider()
4667
const sandbox = await provider.create(kind, options)
4768
logger.info('Created sandbox', { provider: provider.id, kind, sandboxId: sandbox.sandboxId })
4869
return sandbox

apps/sim/scripts/verify-sandbox-parity.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,20 @@
22

33
/**
44
* Runs the real sandbox execution paths against whichever provider the
5-
* `sandbox-provider-daytona` flag currently selects, and prints a pass/fail
6-
* matrix.
5+
* `SANDBOX_PROVIDER` env var selects, and prints a pass/fail matrix.
76
*
8-
* This is the pre-flip confidence check: run it against E2B, run it against
7+
* This is the pre-switch confidence check: run it against E2B, run it against
98
* Daytona, and compare. Every case exercises the shared layer end-to-end
109
* (`executeInSandbox` / `executeShellInSandbox`) against a live sandbox — not
1110
* mocks — so it catches the failures unit tests cannot: a missing package, an
1211
* expired snapshot, an image that vanished during an org move, blocked egress.
1312
*
1413
* Usage:
15-
* # E2B (flag off)
14+
* # E2B (default)
1615
* bun run apps/sim/scripts/verify-sandbox-parity.ts
1716
*
18-
* # Daytona (flag on via its env fallback)
19-
* SANDBOX_PROVIDER_DAYTONA=true \
17+
* # Daytona
18+
* SANDBOX_PROVIDER=daytona \
2019
* DAYTONA_SHELL_SNAPSHOT_ID=mothership-shell:<tag> \
2120
* DAYTONA_DOC_SNAPSHOT_ID=mothership-docs:<tag> \
2221
* bun run apps/sim/scripts/verify-sandbox-parity.ts
@@ -153,7 +152,7 @@ const CASES: Case[] = [
153152
]
154153

155154
async function main() {
156-
const provider = process.env.SANDBOX_PROVIDER_DAYTONA ? 'daytona' : 'e2b'
155+
const provider = process.env.SANDBOX_PROVIDER || 'e2b'
157156
const docConfigured =
158157
provider === 'daytona'
159158
? Boolean(process.env.DAYTONA_DOC_SNAPSHOT_ID)

0 commit comments

Comments
 (0)