Skip to content

Commit c530d27

Browse files
fix(env): flag combinations for sandboxes (#6308)
* fix(env): flag combinations for sandboxes * more changes
1 parent 79bfff7 commit c530d27

15 files changed

Lines changed: 233 additions & 51 deletions

File tree

apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ ENTERPRISE_ENABLED=true
2323
NEXT_PUBLIC_ENTERPRISE_ENABLED=true
2424
```
2525

26-
That turns on organizations, permission groups, SSO, whitelabeling, audit logs, session policies, data retention, data drains, workspace forks, sandboxes, and the inbox.
26+
That turns on organizations, permission groups, SSO, whitelabeling, audit logs,
27+
session policies, data retention, data drains, workspace forks, the Sandbox
28+
entitlement, and the inbox. Sandboxes remain unavailable until their remote
29+
provider and dedicated Function base are configured.
2730

2831
### Turning one feature off
2932

@@ -95,9 +98,16 @@ NEXT_PUBLIC_SANDBOXES_ENABLED=true
9598
```
9699

97100
`SANDBOXES_ENABLED` grants the server-side self-hosted entitlement.
98-
`NEXT_PUBLIC_SANDBOXES_ENABLED` exposes remote Python and Shell plus custom
99-
sandbox management in the browser. Set the public flag only after the selected
100-
provider has credentials and a valid immutable Function base configured.
101+
`NEXT_PUBLIC_SANDBOXES_ENABLED` projects provider readiness to the browser and
102+
exposes Shell plus custom Sandbox management. Set the public flag only after the
103+
selected provider has credentials and a valid immutable Function base configured.
104+
The Function language value itself is never conditioned on these flags, so a
105+
saved Python block cannot be silently serialized or executed as JavaScript.
106+
107+
JavaScript without `import` or `require` does not use this remote provider and
108+
continues to run in the local isolated VM when all Sandbox flags are off. Python,
109+
Shell, JavaScript with external imports, and selected custom Sandboxes fail with
110+
an explicit configuration error until the remote Function base is ready.
101111

102112
Mothership's `function_execute` and `run_code` tools use Mothership's separate
103113
shell image, including for JavaScript without imports. If the deployment uses

apps/docs/content/docs/en/platform/self-hosting/index.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,11 @@ Sim is self-contained for the core editor and execution engine. A few features r
119119
| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, or LiteLLM. |
120120
| **Chat module** | `COPILOT_API_KEY` from sim.ai | Set `NEXT_PUBLIC_CHAT_DISABLED=true` to hide the module instead. |
121121
| **Integrations** | Your own OAuth app per service | See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). |
122-
| **Function / Pi blocks at scale** | Optional E2B or Daytona key | Without one, code runs in the in-process isolated-vm sandbox. See [Security](/platform/self-hosting/security). |
122+
| **Remote Function / Pi execution** | Optional E2B or Daytona key | Without one, JavaScript Function code that has no `import` or `require` still runs in the in-process isolated VM. Python, Shell, JavaScript with external imports, custom Function Sandboxes, and Pi require a configured remote provider. See [Security](/platform/self-hosting/security). |
123123

124124
<FAQ items={[
125125
{ question: "What are the minimum requirements to self-host Sim?", answer: "At minimum you need 2 CPU cores, 12 GB RAM, 20 GB SSD storage, and Docker 20.10 or later. Memory is typically the constraining factor due to workflow execution (isolated-vm sandboxing), file processing, and vector operations (pgvector)."},
126126
{ question: "What are the required environment variables for production?", answer: "Three secrets are required: BETTER_AUTH_SECRET (authentication), ENCRYPTION_KEY (data encryption), and INTERNAL_API_SECRET (service-to-service auth). Generate each with openssl rand -hex 32. You also need to set NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL to your domain."},
127127
{ question: "Can I use Sim with local AI models?", answer: "Yes. Sim supports Ollama for local model inference. Use docker-compose.ollama.yml instead of docker-compose.prod.yml. It offers both GPU (with NVIDIA support) and CPU-only profiles, and automatically pulls gemma3:4b as a starter model." },
128128
]} />
129129

130-

apps/docs/content/docs/en/platform/self-hosting/security.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ Workflows can execute user-authored JavaScript and Python. Know which sandbox yo
115115
| **E2B** | `E2B_ENABLED=true`, `E2B_API_KEY` | Remote sandbox per execution. Strongest isolation; requires outbound access to E2B. |
116116
| **Daytona** | `SANDBOX_PROVIDER=daytona`, `DAYTONA_API_KEY` | Remote sandbox per execution. |
117117

118-
Python execution and the tooling-dependent blocks require a remote sandbox provider — the in-process isolate runs JavaScript only.
118+
Python, Shell, JavaScript with external imports, and tooling-dependent blocks
119+
require a remote sandbox provider. JavaScript without `import` or `require`
120+
continues to run in the in-process isolate when no remote provider is configured.
119121

120122
<Callout type="warn">
121123
With the default in-process sandbox, treat everyone who can author a workflow as someone running code in your app container's security context. If your Sim instance is open to a wide or partly-trusted audience, use a remote sandbox provider and enable the NetworkPolicy egress restrictions.

apps/docs/content/docs/en/workflows/blocks/function.mdx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ The **Function block** runs your own JavaScript, Python, or Shell code as one st
1616

1717
### Code
1818

19-
JavaScript is the default. Python and Shell appear when a remote sandbox provider is enabled. Reference an earlier output directly, with no quotes around the tag, and read an environment variable with `{{VAR}}`:
19+
JavaScript is the default. The language field is part of the saved workflow and is
20+
never removed when Sandbox configuration changes. Python remains available as a
21+
language choice; Shell and custom Sandbox controls appear when a remote Function
22+
sandbox provider is enabled. Reference an earlier output directly, with no quotes
23+
around the tag, and read an environment variable with `{{VAR}}`:
2024

2125
<Tabs items={['JavaScript', 'Python', 'Shell']}>
2226
<Tab value="JavaScript">
@@ -98,6 +102,13 @@ on a self-hosted instance, build and configure the provider's dedicated
98102
generate are captured as images automatically.
99103
</Callout>
100104

105+
<Callout type="info">
106+
If no remote provider is configured, JavaScript without `import` or `require`
107+
continues to run in Sim's local isolated VM. Missing E2B or Daytona configuration
108+
does not disable that path. Remote-only code fails with an explicit configuration
109+
error; Sim does not reinterpret Python or Shell as JavaScript.
110+
</Callout>
111+
101112
The dedicated Function base has the same runtime and universal package contract
102113
on E2B and Daytona. It includes this data-science stack; use a workspace sandbox
103114
when another dependency must be present:
@@ -128,7 +139,9 @@ Create and edit sandboxes in **Settings → Sandboxes**. Only workspace admins c
128139
create or edit them. On sim.ai they need an active Max or Enterprise plan;
129140
self-hosted deployments turn them on with `SANDBOXES_ENABLED` (see
130141
[self-hosted enterprise](/platform/enterprise/self-hosted)). The section is
131-
hidden when a deployment has no sandbox provider configured.
142+
usable only when the deployment also has a remote provider and immutable Function
143+
base configured. The Function block hides its custom Sandbox selector when that
144+
runtime is unavailable.
132145

133146
1. **Name** the sandbox — `bigquery-etl`, `scraping`, whatever the job is.
134147
2. Pick the **language**. This selects pip or npm for the dependency list. Python
@@ -363,8 +376,9 @@ The lazy `sim.files` and `sim.values` helpers are available only in JavaScript f
363376
- **Use stdout to debug.** `console.log()`, `print()`, and ordinary shell output land in `<function.stdout>` and the run logs.
364377

365378
<FAQ items={[
366-
{ question: "What languages does the Function block support?", answer: "JavaScript, Python, and Shell. JavaScript is the default. Python and Shell appear when a remote sandbox provider is enabled." },
379+
{ question: "What languages does the Function block support?", answer: "JavaScript, Python, and Shell. JavaScript is the default. Python remains a stable saved language choice; Shell and custom Sandbox controls appear when a remote sandbox provider is enabled. Python and Shell execution require that provider." },
367380
{ question: "When does code run locally vs. in a sandbox?", answer: "JavaScript without external imports runs in a local isolated sandbox for speed. JavaScript that uses import or require, Python, and Shell run in the configured remote sandbox." },
381+
{ question: "Does JavaScript still work without E2B or Daytona?", answer: "Yes. JavaScript without import or require runs in Sim's local isolated VM and does not require a remote provider. JavaScript with external imports, Python, Shell, and custom Sandboxes require E2B or Daytona and fail explicitly when it is unavailable." },
368382
{ question: "How do I reference outputs from other blocks inside my code?", answer: "Use angle-bracket syntax directly, like <agent.content> or <api.data>, with no quotes around the tag — Sim replaces it with the real value before execution. For environment variables, use double curly braces: {{API_KEY}}." },
369383
{ question: "What does the Function block return?", answer: "Two outputs: result and stdout. Use return in JavaScript, assign __sim_result__ in Python, or print an __SIM_RESULT__= marker in Shell to set result. Ordinary console, print, and command output goes to stdout." },
370384
{ question: "Can I make HTTP requests from a Function block?", answer: "Yes. fetch() is available in JavaScript with async/await. In Python, use requests or httpx. In Shell, use curl or a CLI available on the selected sandbox." },

apps/sim/app/api/function/execute/route.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ describe('Function Execute API Route', () => {
218218
expect(data).toHaveProperty('error', 'Unauthorized')
219219
})
220220

221-
it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
221+
it('runs import-free JavaScript in isolated-vm without a remote provider', async () => {
222222
const req = createMockRequest('POST', {
223223
code: 'return "test"',
224224
})
@@ -229,6 +229,9 @@ describe('Function Execute API Route', () => {
229229
expect(response.status).toBe(200)
230230
expect(data.success).toBe(true)
231231
expect(data.output.result).toBe('test')
232+
expect(mockExecuteInIsolatedVM).toHaveBeenCalledTimes(1)
233+
expect(mockExecuteInSandbox).not.toHaveBeenCalled()
234+
expect(mockExecuteShellInSandbox).not.toHaveBeenCalled()
232235
})
233236

234237
it('does not accept a Mothership sandbox profile from the request body', async () => {

apps/sim/blocks/blocks.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ describe.concurrent('Blocks Module', () => {
340340
const languageSubBlock = block?.subBlocks.find((sb) => sb.id === 'language')
341341
const codeSubBlock = block?.subBlocks.find((sb) => sb.id === 'code')
342342
const sandboxSubBlock = block?.subBlocks.find((sb) => sb.id === 'sandboxId')
343-
expect(languageSubBlock?.showWhenEnvSet).toBe('NEXT_PUBLIC_SANDBOXES_ENABLED')
343+
expect(languageSubBlock?.showWhenEnvSet).toBeUndefined()
344344
expect(sandboxSubBlock?.showWhenEnvSet).toBe('NEXT_PUBLIC_SANDBOXES_ENABLED')
345345
expect(codeSubBlock).toBeDefined()
346346
expect(codeSubBlock?.type).toBe('code')

apps/sim/blocks/blocks/function.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CodeIcon } from '@/components/icons'
2-
import { getEnv, isTruthy } from '@/lib/core/config/env'
2+
import { isSandboxesEnabled } from '@/lib/core/config/env-flags'
33
import { CodeLanguage, getLanguageDisplayName } from '@/lib/execution/languages'
44
import {
55
fetchWorkspaceSandboxOption,
@@ -34,13 +34,12 @@ export const FunctionBlock: BlockConfig<CodeExecutionOutput> = {
3434
options: () => [
3535
{ label: getLanguageDisplayName(CodeLanguage.JavaScript), id: CodeLanguage.JavaScript },
3636
{ label: getLanguageDisplayName(CodeLanguage.Python), id: CodeLanguage.Python },
37-
...(isTruthy(getEnv('NEXT_PUBLIC_SANDBOXES_ENABLED'))
37+
...(isSandboxesEnabled
3838
? [{ label: getLanguageDisplayName(CodeLanguage.Shell), id: CodeLanguage.Shell }]
3939
: []),
4040
],
4141
placeholder: 'Select language',
4242
value: () => CodeLanguage.JavaScript,
43-
showWhenEnvSet: 'NEXT_PUBLIC_SANDBOXES_ENABLED',
4443
},
4544
{
4645
id: 'code',

apps/sim/lib/billing/core/subscription.test.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -336,16 +336,15 @@ describe('hasWorkspaceLiveSyncAccess', () => {
336336
})
337337
})
338338

339-
/**
340-
* Sandboxes are an enterprise feature, so `SANDBOXES_ENABLED` must win over the
341-
* plan gate the way `INBOX_ENABLED` does. Both cases run with billing enabled —
342-
* the `!isBillingEnabled` bail would otherwise answer every one of them, hiding
343-
* whether the override is wired at all.
344-
*/
345339
describe('hasWorkspaceSandboxAccess', () => {
346340
beforeEach(() => {
347341
vi.clearAllMocks()
348-
setEnvFlags({ isBillingEnabled: true, isHosted: true, isSandboxesEnabled: false })
342+
setEnvFlags({
343+
isBillingEnabled: true,
344+
isHosted: true,
345+
isSandboxDeploymentEntitled: false,
346+
isSandboxesEnabled: true,
347+
})
349348
mockGetWorkspaceWithOwner.mockResolvedValue({
350349
id: 'workspace-host',
351350
billedAccountUserId: 'workspace-owner',
@@ -361,17 +360,23 @@ describe('hasWorkspaceSandboxAccess', () => {
361360
})
362361
})
363362

364-
afterAll(() => setEnvFlags({ isSandboxesEnabled: true }))
363+
it('fails closed before resolving a payer when the remote feature is unavailable', async () => {
364+
setEnvFlags({ isSandboxesEnabled: false })
365365

366-
it('grants access from the self-hosted override without resolving a payer', async () => {
367-
setEnvFlags({ isSandboxesEnabled: true })
366+
await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(false)
367+
expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
368+
expect(mockGetHighestPriorityPersonalSubscription).not.toHaveBeenCalled()
369+
})
370+
371+
it('grants an explicit deployment override without resolving a payer', async () => {
372+
setEnvFlags({ isSandboxDeploymentEntitled: true })
368373

369374
await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(true)
370375
expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
371376
expect(mockGetHighestPriorityPersonalSubscription).not.toHaveBeenCalled()
372377
})
373378

374-
it('falls back to the Max plan gate when the override is unset', async () => {
379+
it('uses the Max plan gate on a billing-enabled deployment', async () => {
375380
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
376381
referenceId: 'workspace-owner',
377382
plan: 'pro_25000',
@@ -383,7 +388,7 @@ describe('hasWorkspaceSandboxAccess', () => {
383388
expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledWith('workspace-owner')
384389
})
385390

386-
it('denies a sub-Max payer when the override is unset', async () => {
391+
it('denies a sub-Max payer on a billing-enabled deployment', async () => {
387392
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
388393
referenceId: 'workspace-owner',
389394
plan: 'pro_6000',
@@ -393,4 +398,22 @@ describe('hasWorkspaceSandboxAccess', () => {
393398

394399
await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(false)
395400
})
401+
402+
it('requires an Enterprise or Sandbox deployment entitlement when billing is disabled', async () => {
403+
setEnvFlags({
404+
isBillingEnabled: false,
405+
isSandboxDeploymentEntitled: false,
406+
isSandboxesEnabled: false,
407+
})
408+
409+
await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(false)
410+
411+
setEnvFlags({
412+
isSandboxDeploymentEntitled: true,
413+
isSandboxesEnabled: true,
414+
})
415+
416+
await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(true)
417+
expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
418+
})
396419
})

apps/sim/lib/billing/core/subscription.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
isBillingEnabled,
3131
isHosted,
3232
isInboxEnabled,
33+
isSandboxDeploymentEntitled,
3334
isSandboxesEnabled,
3435
isSsoEnabled,
3536
} from '@/lib/core/config/env-flags'
@@ -691,11 +692,12 @@ export async function hasWorkspaceLiveSyncAccess(workspaceId: string): Promise<b
691692
* Checks whether the exact workspace payer can discover, author, or directly
692693
* select custom Sim sandboxes through Copilot.
693694
*
694-
* Same entitlement as the inbox (Sim Mailer), and the same shape: the
695-
* `SANDBOXES_ENABLED` self-hosted override wins first, then a deployment
696-
* without billing is unrestricted, and otherwise the workspace payer must hold
697-
* a usable Max or Enterprise subscription. Builds cost provider compute and
698-
* storage, so this deliberately sits above the plain paid tier.
695+
* A configured remote Function provider is mandatory. On billing-free
696+
* deployments, the Enterprise pair or Sandbox-specific pair grants access. With
697+
* billing enabled, an explicit Sandbox deployment override wins; otherwise the
698+
* workspace payer must hold a usable Max or Enterprise subscription. Builds cost
699+
* provider compute and storage, so this deliberately sits above the plain paid
700+
* tier.
699701
*
700702
* Existing Function execution deliberately does not consult it (see
701703
* `resolveWorkspaceSandbox`), so a workspace that downgrades keeps running the
@@ -704,8 +706,9 @@ export async function hasWorkspaceLiveSyncAccess(workspaceId: string): Promise<b
704706
*/
705707
export async function hasWorkspaceSandboxAccess(workspaceId: string): Promise<boolean> {
706708
try {
707-
if (isSandboxesEnabled) return true
708-
if (!isBillingEnabled) return true
709+
if (!isSandboxesEnabled) return false
710+
if (isSandboxDeploymentEntitled) return true
711+
if (!isBillingEnabled) return false
709712
return await hasMaxTierWorkspaceAccess(workspaceId)
710713
} catch (error) {
711714
logger.error('Error checking workspace sandbox access', { error, workspaceId })

apps/sim/lib/core/config/enterprise-entitlements.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ENTERPRISE_FEATURE_LEGACY_DEFAULTS,
77
type EnterpriseFeature,
88
resolveEnterpriseEntitlement,
9+
resolveSandboxFeatureAvailability,
910
} from '@/lib/core/config/enterprise-entitlements'
1011

1112
describe('resolveEnterpriseEntitlement', () => {
@@ -110,6 +111,49 @@ describe('resolveEnterpriseEntitlement', () => {
110111
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.accessControl).toBe(false)
111112
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.organizations).toBe(false)
112113
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.sso).toBe(false)
114+
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.sandboxes).toBe(false)
113115
})
114116
})
115117
})
118+
119+
describe('resolveSandboxFeatureAvailability', () => {
120+
it.each([
121+
{
122+
name: 'hosted billing with a provider',
123+
billingEnabled: true,
124+
deploymentEntitled: false,
125+
remoteProviderEnabled: true,
126+
expected: true,
127+
},
128+
{
129+
name: 'hosted billing without a provider',
130+
billingEnabled: true,
131+
deploymentEntitled: false,
132+
remoteProviderEnabled: false,
133+
expected: false,
134+
},
135+
{
136+
name: 'billing-free Enterprise or feature entitlement with a provider',
137+
billingEnabled: false,
138+
deploymentEntitled: true,
139+
remoteProviderEnabled: true,
140+
expected: true,
141+
},
142+
{
143+
name: 'billing-free provider credentials without an entitlement',
144+
billingEnabled: false,
145+
deploymentEntitled: false,
146+
remoteProviderEnabled: true,
147+
expected: false,
148+
},
149+
{
150+
name: 'billing-free entitlement without a provider',
151+
billingEnabled: false,
152+
deploymentEntitled: true,
153+
remoteProviderEnabled: false,
154+
expected: false,
155+
},
156+
])('$name resolves to $expected', ({ expected, ...input }) => {
157+
expect(resolveSandboxFeatureAvailability(input)).toBe(expected)
158+
})
159+
})

0 commit comments

Comments
 (0)