diff --git a/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx b/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx
index 5e76d96f603..c436ecee9a9 100644
--- a/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx
+++ b/apps/docs/content/docs/en/platform/enterprise/self-hosted.mdx
@@ -23,7 +23,10 @@ ENTERPRISE_ENABLED=true
NEXT_PUBLIC_ENTERPRISE_ENABLED=true
```
-That turns on organizations, permission groups, SSO, whitelabeling, audit logs, session policies, data retention, data drains, workspace forks, sandboxes, and the inbox.
+That turns on organizations, permission groups, SSO, whitelabeling, audit logs,
+session policies, data retention, data drains, workspace forks, the Sandbox
+entitlement, and the inbox. Sandboxes remain unavailable until their remote
+provider and dedicated Function base are configured.
### Turning one feature off
@@ -95,9 +98,16 @@ NEXT_PUBLIC_SANDBOXES_ENABLED=true
```
`SANDBOXES_ENABLED` grants the server-side self-hosted entitlement.
-`NEXT_PUBLIC_SANDBOXES_ENABLED` exposes remote Python and Shell plus custom
-sandbox management in the browser. Set the public flag only after the selected
-provider has credentials and a valid immutable Function base configured.
+`NEXT_PUBLIC_SANDBOXES_ENABLED` projects provider readiness to the browser and
+exposes Shell plus custom Sandbox management. Set the public flag only after the
+selected provider has credentials and a valid immutable Function base configured.
+The Function language value itself is never conditioned on these flags, so a
+saved Python block cannot be silently serialized or executed as JavaScript.
+
+JavaScript without `import` or `require` does not use this remote provider and
+continues to run in the local isolated VM when all Sandbox flags are off. Python,
+Shell, JavaScript with external imports, and selected custom Sandboxes fail with
+an explicit configuration error until the remote Function base is ready.
Mothership's `function_execute` and `run_code` tools use Mothership's separate
shell image, including for JavaScript without imports. If the deployment uses
diff --git a/apps/docs/content/docs/en/platform/self-hosting/index.mdx b/apps/docs/content/docs/en/platform/self-hosting/index.mdx
index 29723555709..3e7af31abca 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/index.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/index.mdx
@@ -119,7 +119,7 @@ Sim is self-contained for the core editor and execution engine. A few features r
| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, or LiteLLM. |
| **Chat module** | `COPILOT_API_KEY` from sim.ai | Set `NEXT_PUBLIC_CHAT_DISABLED=true` to hide the module instead. |
| **Integrations** | Your own OAuth app per service | See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). |
-| **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). |
+| **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). |
-
diff --git a/apps/docs/content/docs/en/platform/self-hosting/security.mdx b/apps/docs/content/docs/en/platform/self-hosting/security.mdx
index af055d4b34f..359cb3557c4 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/security.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/security.mdx
@@ -115,7 +115,9 @@ Workflows can execute user-authored JavaScript and Python. Know which sandbox yo
| **E2B** | `E2B_ENABLED=true`, `E2B_API_KEY` | Remote sandbox per execution. Strongest isolation; requires outbound access to E2B. |
| **Daytona** | `SANDBOX_PROVIDER=daytona`, `DAYTONA_API_KEY` | Remote sandbox per execution. |
-Python execution and the tooling-dependent blocks require a remote sandbox provider — the in-process isolate runs JavaScript only.
+Python, Shell, JavaScript with external imports, and tooling-dependent blocks
+require a remote sandbox provider. JavaScript without `import` or `require`
+continues to run in the in-process isolate when no remote provider is configured.
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.
diff --git a/apps/docs/content/docs/en/workflows/blocks/function.mdx b/apps/docs/content/docs/en/workflows/blocks/function.mdx
index 3e681cd8888..8950ce8e756 100644
--- a/apps/docs/content/docs/en/workflows/blocks/function.mdx
+++ b/apps/docs/content/docs/en/workflows/blocks/function.mdx
@@ -16,7 +16,11 @@ The **Function block** runs your own JavaScript, Python, or Shell code as one st
### Code
-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}}`:
+JavaScript is the default. The language field is part of the saved workflow and is
+never removed when Sandbox configuration changes. Python remains available as a
+language choice; Shell and custom Sandbox controls appear when a remote Function
+sandbox provider is enabled. Reference an earlier output directly, with no quotes
+around the tag, and read an environment variable with `{{VAR}}`:
@@ -98,6 +102,13 @@ on a self-hosted instance, build and configure the provider's dedicated
generate are captured as images automatically.
+
+If no remote provider is configured, JavaScript without `import` or `require`
+continues to run in Sim's local isolated VM. Missing E2B or Daytona configuration
+does not disable that path. Remote-only code fails with an explicit configuration
+error; Sim does not reinterpret Python or Shell as JavaScript.
+
+
The dedicated Function base has the same runtime and universal package contract
on E2B and Daytona. It includes this data-science stack; use a workspace sandbox
when another dependency must be present:
@@ -128,7 +139,9 @@ Create and edit sandboxes in **Settings → Sandboxes**. Only workspace admins c
create or edit them. On sim.ai they need an active Max or Enterprise plan;
self-hosted deployments turn them on with `SANDBOXES_ENABLED` (see
[self-hosted enterprise](/platform/enterprise/self-hosted)). The section is
-hidden when a deployment has no sandbox provider configured.
+usable only when the deployment also has a remote provider and immutable Function
+base configured. The Function block hides its custom Sandbox selector when that
+runtime is unavailable.
1. **Name** the sandbox — `bigquery-etl`, `scraping`, whatever the job is.
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
- **Use stdout to debug.** `console.log()`, `print()`, and ordinary shell output land in `` and the run logs.
or , with no quotes around the tag — Sim replaces it with the real value before execution. For environment variables, use double curly braces: {{API_KEY}}." },
{ 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." },
{ 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." },
diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts
index 90723a6e08e..351d9f8d7d4 100644
--- a/apps/sim/app/api/function/execute/route.test.ts
+++ b/apps/sim/app/api/function/execute/route.test.ts
@@ -218,7 +218,7 @@ describe('Function Execute API Route', () => {
expect(data).toHaveProperty('error', 'Unauthorized')
})
- it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
+ it('runs import-free JavaScript in isolated-vm without a remote provider', async () => {
const req = createMockRequest('POST', {
code: 'return "test"',
})
@@ -229,6 +229,9 @@ describe('Function Execute API Route', () => {
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.output.result).toBe('test')
+ expect(mockExecuteInIsolatedVM).toHaveBeenCalledTimes(1)
+ expect(mockExecuteInSandbox).not.toHaveBeenCalled()
+ expect(mockExecuteShellInSandbox).not.toHaveBeenCalled()
})
it('does not accept a Mothership sandbox profile from the request body', async () => {
diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts
index 32368ddc3bf..e595f30ddad 100644
--- a/apps/sim/blocks/blocks.test.ts
+++ b/apps/sim/blocks/blocks.test.ts
@@ -340,7 +340,7 @@ describe.concurrent('Blocks Module', () => {
const languageSubBlock = block?.subBlocks.find((sb) => sb.id === 'language')
const codeSubBlock = block?.subBlocks.find((sb) => sb.id === 'code')
const sandboxSubBlock = block?.subBlocks.find((sb) => sb.id === 'sandboxId')
- expect(languageSubBlock?.showWhenEnvSet).toBe('NEXT_PUBLIC_SANDBOXES_ENABLED')
+ expect(languageSubBlock?.showWhenEnvSet).toBeUndefined()
expect(sandboxSubBlock?.showWhenEnvSet).toBe('NEXT_PUBLIC_SANDBOXES_ENABLED')
expect(codeSubBlock).toBeDefined()
expect(codeSubBlock?.type).toBe('code')
diff --git a/apps/sim/blocks/blocks/function.ts b/apps/sim/blocks/blocks/function.ts
index 98ff285da9e..d9921e826b1 100644
--- a/apps/sim/blocks/blocks/function.ts
+++ b/apps/sim/blocks/blocks/function.ts
@@ -1,5 +1,5 @@
import { CodeIcon } from '@/components/icons'
-import { getEnv, isTruthy } from '@/lib/core/config/env'
+import { isSandboxesEnabled } from '@/lib/core/config/env-flags'
import { CodeLanguage, getLanguageDisplayName } from '@/lib/execution/languages'
import {
fetchWorkspaceSandboxOption,
@@ -34,13 +34,12 @@ export const FunctionBlock: BlockConfig = {
options: () => [
{ label: getLanguageDisplayName(CodeLanguage.JavaScript), id: CodeLanguage.JavaScript },
{ label: getLanguageDisplayName(CodeLanguage.Python), id: CodeLanguage.Python },
- ...(isTruthy(getEnv('NEXT_PUBLIC_SANDBOXES_ENABLED'))
+ ...(isSandboxesEnabled
? [{ label: getLanguageDisplayName(CodeLanguage.Shell), id: CodeLanguage.Shell }]
: []),
],
placeholder: 'Select language',
value: () => CodeLanguage.JavaScript,
- showWhenEnvSet: 'NEXT_PUBLIC_SANDBOXES_ENABLED',
},
{
id: 'code',
diff --git a/apps/sim/lib/billing/core/subscription.test.ts b/apps/sim/lib/billing/core/subscription.test.ts
index 79bac7b6619..d8fe60956c3 100644
--- a/apps/sim/lib/billing/core/subscription.test.ts
+++ b/apps/sim/lib/billing/core/subscription.test.ts
@@ -336,16 +336,15 @@ describe('hasWorkspaceLiveSyncAccess', () => {
})
})
-/**
- * Sandboxes are an enterprise feature, so `SANDBOXES_ENABLED` must win over the
- * plan gate the way `INBOX_ENABLED` does. Both cases run with billing enabled —
- * the `!isBillingEnabled` bail would otherwise answer every one of them, hiding
- * whether the override is wired at all.
- */
describe('hasWorkspaceSandboxAccess', () => {
beforeEach(() => {
vi.clearAllMocks()
- setEnvFlags({ isBillingEnabled: true, isHosted: true, isSandboxesEnabled: false })
+ setEnvFlags({
+ isBillingEnabled: true,
+ isHosted: true,
+ isSandboxDeploymentEntitled: false,
+ isSandboxesEnabled: true,
+ })
mockGetWorkspaceWithOwner.mockResolvedValue({
id: 'workspace-host',
billedAccountUserId: 'workspace-owner',
@@ -361,17 +360,23 @@ describe('hasWorkspaceSandboxAccess', () => {
})
})
- afterAll(() => setEnvFlags({ isSandboxesEnabled: true }))
+ it('fails closed before resolving a payer when the remote feature is unavailable', async () => {
+ setEnvFlags({ isSandboxesEnabled: false })
- it('grants access from the self-hosted override without resolving a payer', async () => {
- setEnvFlags({ isSandboxesEnabled: true })
+ await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(false)
+ expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
+ expect(mockGetHighestPriorityPersonalSubscription).not.toHaveBeenCalled()
+ })
+
+ it('grants an explicit deployment override without resolving a payer', async () => {
+ setEnvFlags({ isSandboxDeploymentEntitled: true })
await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(true)
expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
expect(mockGetHighestPriorityPersonalSubscription).not.toHaveBeenCalled()
})
- it('falls back to the Max plan gate when the override is unset', async () => {
+ it('uses the Max plan gate on a billing-enabled deployment', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
referenceId: 'workspace-owner',
plan: 'pro_25000',
@@ -383,7 +388,7 @@ describe('hasWorkspaceSandboxAccess', () => {
expect(mockGetHighestPriorityPersonalSubscription).toHaveBeenCalledWith('workspace-owner')
})
- it('denies a sub-Max payer when the override is unset', async () => {
+ it('denies a sub-Max payer on a billing-enabled deployment', async () => {
mockGetHighestPriorityPersonalSubscription.mockResolvedValue({
referenceId: 'workspace-owner',
plan: 'pro_6000',
@@ -393,4 +398,22 @@ describe('hasWorkspaceSandboxAccess', () => {
await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(false)
})
+
+ it('requires an Enterprise or Sandbox deployment entitlement when billing is disabled', async () => {
+ setEnvFlags({
+ isBillingEnabled: false,
+ isSandboxDeploymentEntitled: false,
+ isSandboxesEnabled: false,
+ })
+
+ await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(false)
+
+ setEnvFlags({
+ isSandboxDeploymentEntitled: true,
+ isSandboxesEnabled: true,
+ })
+
+ await expect(hasWorkspaceSandboxAccess('workspace-host')).resolves.toBe(true)
+ expect(mockGetWorkspaceWithOwner).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts
index c8ad55d1709..e4b9fa010d2 100644
--- a/apps/sim/lib/billing/core/subscription.ts
+++ b/apps/sim/lib/billing/core/subscription.ts
@@ -30,6 +30,7 @@ import {
isBillingEnabled,
isHosted,
isInboxEnabled,
+ isSandboxDeploymentEntitled,
isSandboxesEnabled,
isSsoEnabled,
} from '@/lib/core/config/env-flags'
@@ -691,11 +692,12 @@ export async function hasWorkspaceLiveSyncAccess(workspaceId: string): Promise {
try {
- if (isSandboxesEnabled) return true
- if (!isBillingEnabled) return true
+ if (!isSandboxesEnabled) return false
+ if (isSandboxDeploymentEntitled) return true
+ if (!isBillingEnabled) return false
return await hasMaxTierWorkspaceAccess(workspaceId)
} catch (error) {
logger.error('Error checking workspace sandbox access', { error, workspaceId })
diff --git a/apps/sim/lib/core/config/enterprise-entitlements.test.ts b/apps/sim/lib/core/config/enterprise-entitlements.test.ts
index 67717240540..53b05591d4b 100644
--- a/apps/sim/lib/core/config/enterprise-entitlements.test.ts
+++ b/apps/sim/lib/core/config/enterprise-entitlements.test.ts
@@ -6,6 +6,7 @@ import {
ENTERPRISE_FEATURE_LEGACY_DEFAULTS,
type EnterpriseFeature,
resolveEnterpriseEntitlement,
+ resolveSandboxFeatureAvailability,
} from '@/lib/core/config/enterprise-entitlements'
describe('resolveEnterpriseEntitlement', () => {
@@ -110,6 +111,49 @@ describe('resolveEnterpriseEntitlement', () => {
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.accessControl).toBe(false)
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.organizations).toBe(false)
expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.sso).toBe(false)
+ expect(ENTERPRISE_FEATURE_LEGACY_DEFAULTS.sandboxes).toBe(false)
})
})
})
+
+describe('resolveSandboxFeatureAvailability', () => {
+ it.each([
+ {
+ name: 'hosted billing with a provider',
+ billingEnabled: true,
+ deploymentEntitled: false,
+ remoteProviderEnabled: true,
+ expected: true,
+ },
+ {
+ name: 'hosted billing without a provider',
+ billingEnabled: true,
+ deploymentEntitled: false,
+ remoteProviderEnabled: false,
+ expected: false,
+ },
+ {
+ name: 'billing-free Enterprise or feature entitlement with a provider',
+ billingEnabled: false,
+ deploymentEntitled: true,
+ remoteProviderEnabled: true,
+ expected: true,
+ },
+ {
+ name: 'billing-free provider credentials without an entitlement',
+ billingEnabled: false,
+ deploymentEntitled: false,
+ remoteProviderEnabled: true,
+ expected: false,
+ },
+ {
+ name: 'billing-free entitlement without a provider',
+ billingEnabled: false,
+ deploymentEntitled: true,
+ remoteProviderEnabled: false,
+ expected: false,
+ },
+ ])('$name resolves to $expected', ({ expected, ...input }) => {
+ expect(resolveSandboxFeatureAvailability(input)).toBe(expected)
+ })
+})
diff --git a/apps/sim/lib/core/config/enterprise-entitlements.ts b/apps/sim/lib/core/config/enterprise-entitlements.ts
index 9ab83057f37..345f5913737 100644
--- a/apps/sim/lib/core/config/enterprise-entitlements.ts
+++ b/apps/sim/lib/core/config/enterprise-entitlements.ts
@@ -59,11 +59,10 @@ export type EnterpriseFeature =
* delete pass is gated here. Defaulting it on would start expiring logs on
* upgrade against plan defaults the operator never chose.
*
- * `sandboxes` is `true` for the mirror-image reason: its gate already returns
- * true whenever billing is off, exactly like `inbox`. A `false` here would make
- * the settings-nav override disagree with the gate that actually answers the
- * request. Self-hosted builds run on the operator's own E2B/Daytona
- * credentials, so there is no Sim-side cost to withhold.
+ * `sandboxes` is deliberately `false`. A remote Function provider and immutable
+ * base are operational prerequisites, so a billing-free deployment must opt in
+ * through either the Enterprise pair or the Sandbox-specific pair. This keeps a
+ * settings surface from appearing when the deployment cannot execute it.
*
* Do not "tidy" these to a uniform value. Each records observed prior behavior,
* and changing one silently alters a live deployment on upgrade.
@@ -76,7 +75,7 @@ export const ENTERPRISE_FEATURE_LEGACY_DEFAULTS: Readonly,
canonicalModeOverrides?: CanonicalModeOverrides
): boolean {
- if (!isSubBlockFeatureEnabled(subBlockConfig)) return false
// Only meaningful when the block is invoked as an agent tool, where the
// value lives on the tool entry rather than the block. Serializing it here
// would let a non-UI writer (copilot, YAML import) set an invisible secret
diff --git a/apps/sim/serializer/tests/serializer.extended.test.ts b/apps/sim/serializer/tests/serializer.extended.test.ts
index 79e62bf7b93..4fa6931e547 100644
--- a/apps/sim/serializer/tests/serializer.extended.test.ts
+++ b/apps/sim/serializer/tests/serializer.extended.test.ts
@@ -96,6 +96,26 @@ const { mockBlockConfigs, createMockGetBlock, slackWithCanonicalParam } = vi.hoi
],
inputs: { input: { type: 'any' } },
},
+ envGatedFunction: {
+ name: 'Environment-gated Function',
+ description: 'Execute custom code',
+ category: 'code',
+ bgColor: '#9C27B0',
+ tools: {
+ access: ['function'],
+ config: { tool: () => 'function' },
+ },
+ subBlocks: [
+ { id: 'code', type: 'code', label: 'Code' },
+ {
+ id: 'language',
+ type: 'dropdown',
+ label: 'Language',
+ showWhenEnvSet: 'NEXT_PUBLIC_TEST_REMOTE_RUNTIME',
+ },
+ ],
+ inputs: { input: { type: 'any' } },
+ },
condition: {
name: 'Condition',
description: 'Branch based on condition',
@@ -1447,6 +1467,29 @@ describe('Serializer Extended Tests', () => {
})
describe('edge cases with empty and null values', () => {
+ it('preserves execution parameters hidden by a presentation-only environment gate', () => {
+ const serializer = new Serializer()
+ const block: BlockState = {
+ id: 'func-python',
+ type: 'envGatedFunction',
+ name: 'Python Function',
+ position: { x: 0, y: 0 },
+ subBlocks: {
+ code: { id: 'code', type: 'code', value: 'from datetime import datetime' },
+ language: { id: 'language', type: 'dropdown', value: 'python' },
+ },
+ outputs: {},
+ enabled: true,
+ }
+
+ const serialized = serializer.serializeWorkflow({ 'func-python': block }, [], {})
+
+ expect(serialized.blocks[0].config.params).toMatchObject({
+ code: 'from datetime import datetime',
+ language: 'python',
+ })
+ })
+
it('should handle blocks with all null subBlock values', () => {
const serializer = new Serializer()
const block: BlockState = {
diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts
index 6a3b5455c8c..3ede0b82876 100644
--- a/packages/testing/src/mocks/env-flags.mock.ts
+++ b/packages/testing/src/mocks/env-flags.mock.ts
@@ -31,6 +31,7 @@ export interface EnvFlagsMockState {
isAccessControlEnabled: boolean
isOrganizationsEnabled: boolean
isInboxEnabled: boolean
+ isSandboxDeploymentEntitled: boolean
isSandboxesEnabled: boolean
isWhitelabelingEnabled: boolean
isAuditLogsEnabled: boolean
@@ -81,7 +82,8 @@ const defaultEnvFlagsState: EnvFlagsMockState = {
// `true` so upgrades do not remove a feature. See
// ENTERPRISE_FEATURE_LEGACY_DEFAULTS.
isInboxEnabled: true,
- isSandboxesEnabled: true,
+ isSandboxDeploymentEntitled: false,
+ isSandboxesEnabled: false,
isWhitelabelingEnabled: true,
isSessionPoliciesEnabled: true,
isAuditLogsEnabled: false,