Skip to content

Commit 36fa8e3

Browse files
committed
fix(copilot): keep sandbox config visible
1 parent 4e43f67 commit 36fa8e3

7 files changed

Lines changed: 94 additions & 53 deletions

File tree

apps/sim/blocks/blocks/function.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ try {
164164
timeout: { type: 'number', description: 'Execution timeout' },
165165
sandboxId: {
166166
type: 'string',
167-
description: 'Sim sandbox providing dependencies, system packages, and managed CLIs',
167+
description:
168+
'Sim sandbox providing dependencies, system packages, and managed CLIs. Selecting or clearing it requires an active Max or Enterprise plan.',
168169
},
169170
secretScope: { type: 'string', description: 'Secret access mode: all or selected' },
170171
mountedSecrets: {

apps/sim/lib/copilot/sim-sandbox-projection.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import type { CopilotSanitizationOptions } from '@/lib/workflows/sanitization/json-sanitizer'
1+
import { SIM_SANDBOXES_ENTITLEMENT } from '@/lib/copilot/entitlements'
22

3-
export const HIDE_SIM_SANDBOX_INPUTS: CopilotSanitizationOptions = {
4-
hiddenInputIdsByBlockType: new Map([['function', new Set(['sandboxId'])]]),
5-
}
3+
export const RESTRICTED_SIM_SANDBOX_INPUTS = new Map([
4+
[
5+
'sandboxId',
6+
{
7+
requiredEntitlement: SIM_SANDBOXES_ENTITLEMENT,
8+
reason:
9+
'Selecting or clearing a Sim sandbox requires an active Max or Enterprise plan. Preserve any existing selection unless the user upgrades.',
10+
},
11+
],
12+
])
613

714
/** Whether an edit_workflow operation tries to set or clear Function sandboxId. */
815
export function operationsReferenceSimSandbox(

apps/sim/lib/copilot/tools/handlers/workflow/queries.ts

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { toError } from '@sim/utils/errors'
22
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
3-
import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription'
43
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
5-
import { HIDE_SIM_SANDBOX_INPUTS } from '@/lib/copilot/sim-sandbox-projection'
64
import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils'
75
import { mcpService } from '@/lib/mcp/service'
86
import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace'
@@ -505,24 +503,16 @@ export async function executeGetDeployedWorkflowState(
505503
return { success: false, error: 'workflowId is required' }
506504
}
507505

508-
const { workflow: workflowRecord, workspaceId } = await ensureWorkflowAccess(
509-
workflowId,
510-
context.userId
511-
)
506+
const { workflow: workflowRecord } = await ensureWorkflowAccess(workflowId, context.userId)
512507

513508
try {
514509
const deployedState = await loadDeployedWorkflowState(workflowId)
515-
const formatted = formatNormalizedWorkflowForCopilot(
516-
{
517-
blocks: deployedState.blocks,
518-
edges: deployedState.edges,
519-
loops: deployedState.loops as Record<string, Loop>,
520-
parallels: deployedState.parallels as Record<string, Parallel>,
521-
},
522-
workspaceId && (await hasWorkspaceSandboxAccess(workspaceId))
523-
? undefined
524-
: HIDE_SIM_SANDBOX_INPUTS
525-
)
510+
const formatted = formatNormalizedWorkflowForCopilot({
511+
blocks: deployedState.blocks,
512+
edges: deployedState.edges,
513+
loops: deployedState.loops as Record<string, Loop>,
514+
parallels: deployedState.parallels as Record<string, Parallel>,
515+
})
526516

527517
return {
528518
success: true,

apps/sim/lib/copilot/vfs/serializers.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ describe('VFS metadata serializers', () => {
177177
})
178178

179179
describe('entitlement-projected block schemas', () => {
180-
it('removes a gated input from both subBlocks and inputs', () => {
180+
it('keeps a gated input readable while marking it unavailable for mutation', () => {
181181
const block = {
182182
type: 'function',
183183
name: 'Function',
@@ -198,12 +198,35 @@ describe('entitlement-projected block schemas', () => {
198198
} as unknown as BlockConfig
199199

200200
const schema = JSON.parse(
201-
serializeBlockSchema(block, { hiddenInputIds: new Set(['sandboxId']) })
201+
serializeBlockSchema(block, {
202+
restrictedInputs: new Map([
203+
[
204+
'sandboxId',
205+
{
206+
requiredEntitlement: 'sim-sandboxes',
207+
reason: 'Requires an active Max or Enterprise plan.',
208+
},
209+
],
210+
]),
211+
})
202212
)
203213

204-
expect(schema.subBlocks.map((subBlock: { id: string }) => subBlock.id)).toEqual(['code'])
214+
expect(schema.subBlocks.map((subBlock: { id: string }) => subBlock.id)).toEqual([
215+
'code',
216+
'sandboxId',
217+
])
218+
expect(schema.subBlocks[1]).toMatchObject({
219+
readOnly: true,
220+
requiredEntitlement: 'sim-sandboxes',
221+
restrictionReason: 'Requires an active Max or Enterprise plan.',
222+
})
205223
expect(schema.inputs).toHaveProperty('code')
206-
expect(schema.inputs).not.toHaveProperty('sandboxId')
224+
expect(schema.inputs.sandboxId).toMatchObject({
225+
type: 'string',
226+
readOnly: true,
227+
requiredEntitlement: 'sim-sandboxes',
228+
restrictionReason: 'Requires an active Max or Enterprise plan.',
229+
})
207230
})
208231
})
209232

apps/sim/lib/copilot/vfs/serializers.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ export interface ComponentSerializationOptions {
9090
ownerBlockType?: string
9191
/** Product-gated inputs removed from both subBlocks and the input schema. */
9292
hiddenInputIds?: ReadonlySet<string>
93+
/** Product-gated inputs that remain discoverable but cannot be mutated by this viewer. */
94+
restrictedInputs?: ReadonlyMap<
95+
string,
96+
{
97+
requiredEntitlement: string
98+
reason: string
99+
}
100+
>
93101
}
94102

95103
/**
@@ -616,6 +624,12 @@ export function serializeBlockSchema(
616624

617625
const subBlocks = visibleSubBlocks.map((sb) => {
618626
const serialized = serializeSubBlock(sb)
627+
const restriction = options?.restrictedInputs?.get(sb.id)
628+
if (restriction) {
629+
serialized.readOnly = true
630+
serialized.requiredEntitlement = restriction.requiredEntitlement
631+
serialized.restrictionReason = restriction.reason
632+
}
619633

620634
if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') {
621635
serialized.options = getStaticModelOptionsForVFS()
@@ -633,10 +647,28 @@ export function serializeBlockSchema(
633647
if (auth) toolAuth[toolId] = auth
634648
}
635649

636-
const inputs =
650+
const visibleInputs =
637651
block.inputs && hiddenIds.size > 0
638652
? Object.fromEntries(Object.entries(block.inputs).filter(([key]) => !hiddenIds.has(key)))
639653
: block.inputs
654+
const inputs = visibleInputs
655+
? Object.fromEntries(
656+
Object.entries(visibleInputs).map(([key, input]) => {
657+
const restriction = options?.restrictedInputs?.get(key)
658+
return restriction
659+
? [
660+
key,
661+
{
662+
...input,
663+
readOnly: true,
664+
requiredEntitlement: restriction.requiredEntitlement,
665+
restrictionReason: restriction.reason,
666+
},
667+
]
668+
: [key, input]
669+
})
670+
)
671+
: visibleInputs
640672

641673
return JSON.stringify(
642674
{

apps/sim/lib/copilot/vfs/workspace-vfs.ts

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import {
3838
filterSecretNamesByMountPolicy,
3939
type SecretMountPolicy,
4040
} from '@/lib/copilot/secret-mount-policy'
41-
import { HIDE_SIM_SANDBOX_INPUTS } from '@/lib/copilot/sim-sandbox-projection'
41+
import { RESTRICTED_SIM_SANDBOX_INPUTS } from '@/lib/copilot/sim-sandbox-projection'
4242
import { compileDoc, getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile'
4343
import { extractDocText, isExtractableDocExt } from '@/lib/copilot/tools/server/files/doc-extract'
4444
import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc'
@@ -166,7 +166,7 @@ const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024
166166
* (see {@link isStaticFileHidden}).
167167
*/
168168
let staticComponentFiles: Map<string, string> | null = null
169-
let staticFunctionSchemaWithoutSimSandboxes: string | null = null
169+
let staticFunctionSchemaWithRestrictedSimSandboxes: string | null = null
170170

171171
/**
172172
* Owning block for each `components/integrations/**` file, recorded at build
@@ -363,9 +363,9 @@ function getStaticComponentFiles(): Map<string, string> {
363363
const path = `components/blocks/${block.type}.json`
364364
files.set(path, serializeBlockSchema(block, { toolConfigs }))
365365
if (block.type === 'function') {
366-
staticFunctionSchemaWithoutSimSandboxes = serializeBlockSchema(block, {
366+
staticFunctionSchemaWithRestrictedSimSandboxes = serializeBlockSchema(block, {
367367
toolConfigs,
368-
hiddenInputIds: new Set(['sandboxId']),
368+
restrictedInputs: RESTRICTED_SIM_SANDBOX_INPUTS,
369369
})
370370
}
371371
}
@@ -572,9 +572,6 @@ export class WorkspaceVFS {
572572
>()
573573
private deploymentCache = new Map<string, Promise<DeploymentData | null>>()
574574
private _workspaceId = ''
575-
// Defaults to hidden so partial/failed materialization cannot leak a gated
576-
// Function input. Set from the live Sim entitlement before the VFS is used.
577-
private _simSandboxEntitled = false
578575
/**
579576
* Types of the org's CURRENT custom blocks (enabled + disabled — a disabled block
580577
* still resolves/renders). Populated by {@link materializeCustomBlocks}; used to
@@ -817,8 +814,6 @@ export class WorkspaceVFS {
817814
// prompt prefix), so nothing is destructured from this one.
818815
timed('tasks', this.materializeTasks(workspaceId, userId)),
819816
])
820-
this._simSandboxEntitled = sandboxEntitled
821-
822817
const workspaceMdData: WorkspaceMdData = {
823818
workspace: wsRow,
824819
members,
@@ -853,7 +848,7 @@ export class WorkspaceVFS {
853848
if (isStaticFileHidden(path, blockVisibility, allowedIntegrationTypes)) continue
854849
const projectedContent =
855850
path === 'components/blocks/function.json' && !sandboxEntitled
856-
? (staticFunctionSchemaWithoutSimSandboxes ?? content)
851+
? (staticFunctionSchemaWithRestrictedSimSandboxes ?? content)
857852
: content
858853
this.files.set(path, projectedContent)
859854
}
@@ -1546,15 +1541,12 @@ export class WorkspaceVFS {
15461541
// workflow; it still exists and must be readable, so emit an
15471542
// empty-but-valid state.json rather than a 404.
15481543
const sanitized = normalized
1549-
? sanitizeForCopilot(
1550-
{
1551-
blocks: normalized.blocks,
1552-
edges: normalized.edges,
1553-
loops: normalized.loops,
1554-
parallels: normalized.parallels,
1555-
} as any,
1556-
this._simSandboxEntitled ? undefined : HIDE_SIM_SANDBOX_INPUTS
1557-
)
1544+
? sanitizeForCopilot({
1545+
blocks: normalized.blocks,
1546+
edges: normalized.edges,
1547+
loops: normalized.loops,
1548+
parallels: normalized.parallels,
1549+
} as any)
15581550
: sanitizeForCopilot({ blocks: {}, edges: [], loops: {}, parallels: {} } as any)
15591551
return JSON.stringify(sanitized, null, 2)
15601552
})

apps/sim/lib/workflows/sanitization/json-sanitizer.test.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
*/
44
import { resetUrlsMock, urlsMockFns } from '@sim/testing'
55
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
6-
import { HIDE_SIM_SANDBOX_INPUTS } from '@/lib/copilot/sim-sandbox-projection'
76
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
87
import type { WorkflowState } from '@/stores/workflows/workflow/types'
98
import { TRIGGER_WEBHOOK_URL_FIELD } from '@/triggers/constants'
@@ -165,7 +164,7 @@ describe('sanitizeForCopilot server-only block inputs', () => {
165164
})
166165

167166
describe('sanitizeForCopilot product-gated block inputs', () => {
168-
it('can hide a persisted Function sandbox selection without hiding ordinary inputs', () => {
167+
it('retains a persisted Function sandbox selection for model-visible read access', () => {
169168
const state = makeSingleBlockWorkflow('function-1', {
170169
type: 'function',
171170
name: 'Function 1',
@@ -177,13 +176,10 @@ describe('sanitizeForCopilot product-gated block inputs', () => {
177176
},
178177
})
179178

180-
expect(sanitizeForCopilot(state).blocks['function-1'].inputs).toHaveProperty(
181-
'sandboxId',
182-
'sandbox-1'
183-
)
184-
expect(sanitizeForCopilot(state, HIDE_SIM_SANDBOX_INPUTS).blocks['function-1'].inputs).toEqual({
179+
expect(sanitizeForCopilot(state).blocks['function-1'].inputs).toEqual({
185180
code: 'return 1',
186181
language: 'javascript',
182+
sandboxId: 'sandbox-1',
187183
})
188184
})
189185
})

0 commit comments

Comments
 (0)