mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
fix(env): flag combinations for sandboxes (#6308)
* fix(env): flag combinations for sandboxes * more changes
This commit is contained in:
committed by
GitHub
parent
79bfff728b
commit
c530d276b7
@@ -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
|
||||
|
||||
@@ -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). |
|
||||
|
||||
<FAQ items={[
|
||||
{ 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)."},
|
||||
@@ -127,4 +127,3 @@ Sim is self-contained for the core editor and execution engine. A few features r
|
||||
{ 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." },
|
||||
]} />
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Callout type="warn">
|
||||
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.
|
||||
|
||||
@@ -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}}`:
|
||||
|
||||
<Tabs items={['JavaScript', 'Python', 'Shell']}>
|
||||
<Tab value="JavaScript">
|
||||
@@ -98,6 +102,13 @@ on a self-hosted instance, build and configure the provider's dedicated
|
||||
generate are captured as images automatically.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
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.
|
||||
</Callout>
|
||||
|
||||
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 `<function.stdout>` and the run logs.
|
||||
|
||||
<FAQ items={[
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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." },
|
||||
{ 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}}." },
|
||||
{ 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." },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<CodeExecutionOutput> = {
|
||||
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',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<b
|
||||
* Checks whether the exact workspace payer can discover, author, or directly
|
||||
* select custom Sim sandboxes through Copilot.
|
||||
*
|
||||
* Same entitlement as the inbox (Sim Mailer), and the same shape: the
|
||||
* `SANDBOXES_ENABLED` self-hosted override wins first, then a deployment
|
||||
* without billing is unrestricted, and otherwise the workspace payer must hold
|
||||
* a usable Max or Enterprise subscription. Builds cost provider compute and
|
||||
* storage, so this deliberately sits above the plain paid tier.
|
||||
* A configured remote Function provider is mandatory. On billing-free
|
||||
* deployments, the Enterprise pair or Sandbox-specific pair grants access. With
|
||||
* billing enabled, an explicit Sandbox deployment override wins; otherwise the
|
||||
* workspace payer must hold a usable Max or Enterprise subscription. Builds cost
|
||||
* provider compute and storage, so this deliberately sits above the plain paid
|
||||
* tier.
|
||||
*
|
||||
* Existing Function execution deliberately does not consult it (see
|
||||
* `resolveWorkspaceSandbox`), so a workspace that downgrades keeps running the
|
||||
@@ -704,8 +706,9 @@ export async function hasWorkspaceLiveSyncAccess(workspaceId: string): Promise<b
|
||||
*/
|
||||
export async function hasWorkspaceSandboxAccess(workspaceId: string): Promise<boolean> {
|
||||
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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Record<EnterpriseFeatu
|
||||
forking: false,
|
||||
inbox: true,
|
||||
organizations: false,
|
||||
sandboxes: true,
|
||||
sandboxes: false,
|
||||
sessionPolicies: true,
|
||||
sso: false,
|
||||
whitelabeling: true,
|
||||
@@ -102,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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -319,13 +320,14 @@ export const isInboxEnabled = enterpriseFeatureEnabled(
|
||||
)
|
||||
|
||||
/**
|
||||
* Are custom sandboxes (workspace dependency sets for Function blocks) enabled.
|
||||
* Whether deployment configuration entitles custom Function sandboxes.
|
||||
*
|
||||
* The server flag is the self-hosted entitlement override. In the browser,
|
||||
* custom sandbox management follows the same public flag as the remote Function
|
||||
* runtime because custom images cannot work without that provider/base.
|
||||
* With billing enabled this is an explicit plan-gate override. Without billing,
|
||||
* either the Enterprise master switch or the Sandbox-specific server/client pair
|
||||
* enables the feature. Provider readiness is applied separately by
|
||||
* {@link isSandboxesEnabled} so entitlement can never advertise a missing runtime.
|
||||
*/
|
||||
export const isSandboxesEnabled = enterpriseFeatureEnabled(
|
||||
export const isSandboxDeploymentEntitled = enterpriseFeatureEnabled(
|
||||
'sandboxes',
|
||||
env.SANDBOXES_ENABLED,
|
||||
'NEXT_PUBLIC_SANDBOXES_ENABLED'
|
||||
@@ -407,9 +409,10 @@ const sandboxProvider = inspectCapability(SANDBOX_CAPABILITY, env).providerId
|
||||
* deployment must build and configure the Function-owned image before exposing
|
||||
* the runtime.
|
||||
*
|
||||
* The browser twin is `NEXT_PUBLIC_SANDBOXES_ENABLED`, read by the Function
|
||||
* block's `showWhenEnvSet` gates. Set it only after this server-side provider
|
||||
* check succeeds; `bun run setup --doctor` reports mismatches in either direction.
|
||||
* The browser cannot inspect provider credentials, so
|
||||
* `NEXT_PUBLIC_SANDBOXES_ENABLED` is its readiness projection. Set the public
|
||||
* value only after this server-side check succeeds; `bun run setup --doctor`
|
||||
* reports mismatches in either direction.
|
||||
*/
|
||||
export const isRemoteSandboxEnabled =
|
||||
sandboxProvider === 'daytona'
|
||||
@@ -430,6 +433,24 @@ export const isRemoteSandboxEnabled =
|
||||
)
|
||||
: false
|
||||
|
||||
/**
|
||||
* Whether the complete custom-Sandbox feature is available on this deployment.
|
||||
*
|
||||
* Billing supplies hosted entitlement, while billing-free deployments require
|
||||
* the Enterprise pair or the Sandbox-specific pair. Both modes additionally
|
||||
* require a configured remote Function provider. The public flag projects that
|
||||
* provider readiness into the browser; the server always verifies credentials
|
||||
* and the immutable Function base directly.
|
||||
*/
|
||||
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.
|
||||
* This is intentionally independent of {@link isRemoteSandboxEnabled}: the
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getCanonicalValues,
|
||||
isCanonicalPair,
|
||||
isNonEmptyValue,
|
||||
isSubBlockFeatureEnabled,
|
||||
isSubBlockHidden,
|
||||
isToolInputOnlySubBlock,
|
||||
resolveCanonicalMode,
|
||||
@@ -52,7 +51,6 @@ function shouldSerializeSubBlock(
|
||||
canonicalIndex: ReturnType<typeof buildCanonicalIndex>,
|
||||
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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user