From 505654ac826cbbf15d6cdad6651e42825c0a2fdf Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 5 Aug 2026 20:14:12 -0700 Subject: [PATCH 1/2] fix(env): flag combinations for sandboxes --- .../en/platform/enterprise/self-hosted.mdx | 18 +++++-- .../docs/en/platform/self-hosting/index.mdx | 3 +- .../en/platform/self-hosting/security.mdx | 4 +- .../docs/en/workflows/blocks/function.mdx | 20 ++++++-- .../app/api/function/execute/route.test.ts | 5 +- apps/sim/blocks/blocks.test.ts | 2 +- apps/sim/blocks/blocks/function.ts | 5 +- .../sim/lib/billing/core/subscription.test.ts | 47 ++++++++++++++----- apps/sim/lib/billing/core/subscription.ts | 17 ++++--- .../config/enterprise-entitlements.test.ts | 1 + .../core/config/enterprise-entitlements.ts | 11 ++--- apps/sim/lib/core/config/env-flags.ts | 33 +++++++++---- apps/sim/serializer/index.ts | 2 - .../tests/serializer.extended.test.ts | 43 +++++++++++++++++ packages/testing/src/mocks/env-flags.mock.ts | 4 +- 15 files changed, 164 insertions(+), 51 deletions(-) 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..e8539fbd50b 100644 --- a/apps/sim/lib/core/config/enterprise-entitlements.test.ts +++ b/apps/sim/lib/core/config/enterprise-entitlements.test.ts @@ -110,6 +110,7 @@ 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) }) }) }) diff --git a/apps/sim/lib/core/config/enterprise-entitlements.ts b/apps/sim/lib/core/config/enterprise-entitlements.ts index 9ab83057f37..bac0421e741 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, From 8ebf68fb7adaf9d6a57493b20068003353ab78a2 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 5 Aug 2026 20:17:40 -0700 Subject: [PATCH 2/2] more changes --- .../config/enterprise-entitlements.test.ts | 43 +++++++++++++++++++ .../core/config/enterprise-entitlements.ts | 22 ++++++++++ apps/sim/lib/core/config/env-flags.ts | 14 +++--- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/core/config/enterprise-entitlements.test.ts b/apps/sim/lib/core/config/enterprise-entitlements.test.ts index e8539fbd50b..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', () => { @@ -114,3 +115,45 @@ describe('resolveEnterpriseEntitlement', () => { }) }) }) + +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 bac0421e741..345f5913737 100644 --- a/apps/sim/lib/core/config/enterprise-entitlements.ts +++ b/apps/sim/lib/core/config/enterprise-entitlements.ts @@ -101,3 +101,25 @@ export function resolveEnterpriseEntitlement({ }: ResolveEnterpriseEntitlementParams): boolean { return explicit ?? (masterEnabled || legacyDefault) } + +interface ResolveSandboxFeatureAvailabilityParams { + /** Whether hosted subscription enforcement supplies the deployment entitlement. */ + billingEnabled: boolean + /** Enterprise-master or Sandbox-specific deployment entitlement. */ + deploymentEntitled: boolean + /** Server-verified provider readiness or its public browser projection. */ + remoteProviderEnabled: boolean +} + +/** + * Combines Sandbox entitlement with runtime capability. Neither dimension may + * substitute for the other: a plan cannot create a provider, and provider + * credentials cannot grant a workspace feature by themselves. + */ +export function resolveSandboxFeatureAvailability({ + billingEnabled, + deploymentEntitled, + remoteProviderEnabled, +}: ResolveSandboxFeatureAvailabilityParams): boolean { + return remoteProviderEnabled && (billingEnabled || deploymentEntitled) +} diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index f6450ac2541..c479ce4b926 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -12,6 +12,7 @@ import { ENTERPRISE_FEATURE_LEGACY_DEFAULTS, type EnterpriseFeature, resolveEnterpriseEntitlement, + resolveSandboxFeatureAvailability, } from './enterprise-entitlements' import { env, envBoolean, getEnv, isFalsy, isTruthy } from './env' import { hasEnvCapabilityValue, inspectCapability, SANDBOX_CAPABILITY } from './env-capabilities' @@ -441,11 +442,14 @@ export const isRemoteSandboxEnabled = * provider readiness into the browser; the server always verifies credentials * and the immutable Function base directly. */ -export const isSandboxesEnabled = - (isBillingEnabled || isSandboxDeploymentEntitled) && - (typeof window === 'undefined' - ? isRemoteSandboxEnabled - : isTruthy(getEnv('NEXT_PUBLIC_SANDBOXES_ENABLED'))) +export const isSandboxesEnabled = resolveSandboxFeatureAvailability({ + billingEnabled: isBillingEnabled, + deploymentEntitled: isSandboxDeploymentEntitled, + remoteProviderEnabled: + typeof window === 'undefined' + ? isRemoteSandboxEnabled + : isTruthy(getEnv('NEXT_PUBLIC_SANDBOXES_ENABLED')), +}) /** * Whether the selected provider can serve Mothership's own code image.