Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/sim/lib/api/contracts/hotspots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata'
import { MAX_BLOCK_MOUNTED_FILES } from '@/lib/execution/remote-sandbox/sandbox-paths'
import { MAX_FUNCTION_CODE_LENGTH } from '@/lib/function-execution/limits'
import {
MAX_PII_VALIDATION_DETECTED_ENTITIES,
MAX_PII_VALIDATION_TEXT_CHARACTERS,
Expand Down Expand Up @@ -165,8 +166,8 @@ const functionOutputFileSchema = z

export const functionExecuteBodySchema = z
.object({
code: z.string().min(1, 'Code is required'),
sourceCode: z.string().optional(),
code: z.string().min(1, 'Code is required').max(MAX_FUNCTION_CODE_LENGTH),
Comment thread
waleedlatif1 marked this conversation as resolved.
sourceCode: z.string().max(MAX_FUNCTION_CODE_LENGTH).optional(),
params: unknownRecordSchema.optional().default({}),
timeout: z.coerce.number().int().positive().optional(),
language: z.string().optional().default(DEFAULT_CODE_LANGUAGE),
Expand Down
16 changes: 13 additions & 3 deletions apps/sim/lib/api/contracts/mothership-chats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import {
} from '@/lib/api/contracts/secret-mount-policy'
import { defineRouteContract } from '@/lib/api/contracts/types'
import type { RESOLVED_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata'
import {
MAX_CHAT_CONTEXT_LABEL_LENGTH,
MAX_CHAT_CONTEXTS,
MAX_CHAT_MESSAGE_LENGTH,
} from '@/lib/mothership/chat/context-limits'
import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol'
import type { AgentStreamEvent, TextDeltaClassification } from '@/providers/stream-events'

Expand Down Expand Up @@ -71,7 +76,11 @@ export const markMothershipChatReadContract = defineRouteContract({

const mothershipExecuteMessageSchema = z.object({
role: z.enum(['system', 'user', 'assistant']),
content: z.string(),
content: z.string().max(MAX_CHAT_MESSAGE_LENGTH),
})

const mothershipContextInputSchema = scheduleContextSchema.extend({
label: z.string().max(MAX_CHAT_CONTEXT_LABEL_LENGTH),
})

const mothershipExecuteFileAttachmentSchema = z
Expand Down Expand Up @@ -134,7 +143,7 @@ export const mothershipExecuteBodySchema = z.object({
* mirroring the interactive chat path. Headless executions use this to pass
* captured contexts into the run without a live client.
*/
contexts: z.array(scheduleContextSchema).optional(),
contexts: z.array(mothershipContextInputSchema).max(MAX_CHAT_CONTEXTS).optional(),
mcpTools: z.array(mothershipExecuteMcpToolSchema).optional(),
workflowId: z.string().optional(),
executionId: z.string().optional(),
Expand Down Expand Up @@ -162,7 +171,8 @@ export const mothershipChatGetQuerySchema = z

export const mothershipChatPostEnvelopeSchema = z
.object({
message: z.string().optional(),
message: z.string().max(MAX_CHAT_MESSAGE_LENGTH).optional(),
contexts: z.array(mothershipContextInputSchema).max(MAX_CHAT_CONTEXTS).optional(),
chatId: z.string().optional(),
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
Expand Down
47 changes: 47 additions & 0 deletions apps/sim/lib/function-execution/execute-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3157,6 +3157,53 @@ describe('Function execution request', () => {
})

describe('Template Variable Resolution', () => {
it.each([
{ code: ' '.repeat(1024 * 1024 + 1), reason: 'source length' },
{
code: 'return 1',
sourceCode: ' '.repeat(1024 * 1024 + 1),
reason: 'diagnostic source length',
},
{ code: `/* ${'< a.value>'.repeat(10_001)} */ return 1`, reason: 'block references' },
{
code: `/* ${'<variable.total>'.repeat(10_001)} */ return 1`,
reason: 'workflow references',
},
])('rejects excessive $reason before execution', async ({ code, sourceCode }) => {
const response = await POST(
createMockRequest('POST', {
code,
sourceCode,
workflowVariables: { total: { name: 'total', type: 'number', value: 1 } },
})
)
expect(response.status).toBe(400)
})

it('preserves repeated values, legacy variable precedence, and skipped-block references', async () => {
mockExecuteInIsolatedVM.mockImplementationOnce(async (request) => ({
result: await runInNewContext(`(async () => { ${request.code} })()`, {
...request.contextVariables,
}),
stdout: '',
}))
const response = await POST(
createMockRequest('POST', {
code: 'return [<source.result>.total, <source.result>.total, <variable.totalamount>, <variable.totalamount>, typeof < skipped.value>, <variable.tax-rate>, <variable.tax_rate>]',
blockNameMapping: { source: 'source-id', skipped: 'skipped-id' },
blockData: { 'source-id': { result: '{"total":3}' } },
workflowVariables: {
first: { name: 'Total amount', type: 'number', value: '7' },
second: { name: 'totalamount', type: 'number', value: '99' },
taxRate: { name: 'tax-rate', type: 'number', value: '11' },
legacyTaxRate: { name: 'tax_rate', type: 'number', value: '12' },
},
})
)
expect(response.status).toBe(200)
expect((await response.json()).output.result).toEqual([3, 3, 7, 7, 'undefined', 11, 11])
})

it('keeps an exact-name/exact-value JavaScript secret out of source and returns its raw runtime value with private provenance', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 'Test', stdout: '' })

Expand Down
110 changes: 56 additions & 54 deletions apps/sim/lib/function-execution/execute-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { sha256Hex } from '@sim/security/hash'
import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
import { toRecord } from '@sim/utils/object'
import { escapeRegExp } from '@sim/utils/string'
import { NextResponse } from 'next/server'
import type { ParsedFunctionExecuteBody } from '@/lib/api/contracts'
import { isMothershipSandboxEnabled, isRemoteSandboxEnabled } from '@/lib/core/config/env-flags'
Expand Down Expand Up @@ -87,6 +86,7 @@ import {
} from '@/lib/execution/remote-sandbox/sandbox-paths'
import type { SandboxCollectedFile, SandboxFile } from '@/lib/execution/remote-sandbox/types'
import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors'
import { MAX_FUNCTION_REFERENCES } from '@/lib/function-execution/limits'
import type { SandboxExportedFile } from '@/lib/function-execution/output'
import { planUserFileMounts, resolveUserFileMounts } from '@/lib/function-execution/sandbox-mounts'
import {
Expand Down Expand Up @@ -750,38 +750,33 @@ function scrubInternalIdentifiers(message: string, identifiers: readonly string[

function resolveWorkflowVariables(
code: string,
workflowVariables: Record<string, any>,
contextVariables: Record<string, any>
workflowVariables: Record<string, unknown>,
contextVariables: Record<string, unknown>
): string {
let resolvedCode = code

const regex = createWorkflowVariablePattern()
let match: RegExpExecArray | null
const replacements: Array<{
match: string
index: number
variableName: string
variableValue: unknown
}> = []

while ((match = regex.exec(code)) !== null) {
const variableName = match[1].trim()

const foundVariable = Object.entries(workflowVariables).find(
([_, variable]) => normalizeName(variable.name || '') === variableName
)

if (!foundVariable) {
const availableVars = Object.values(workflowVariables)
.map((v) => v.name)
.filter(Boolean)
const variablesByName = new Map<string, Record<string, unknown>>()
for (const value of Object.values(workflowVariables)) {
const variable = toRecord(value)
if (typeof variable.name !== 'string') continue
const name = normalizeName(variable.name)
if (!variablesByName.has(name)) variablesByName.set(name, variable)
}
const replacements = new Map<string, string>()
const boundNames = new Set<string>()

return code.replace(createWorkflowVariablePattern(), (_match, name: string) => {
const variableName = name.trim()
const cached = replacements.get(variableName)
if (cached !== undefined) return cached

const variable = variablesByName.get(variableName)
Comment thread
waleedlatif1 marked this conversation as resolved.
if (!variable) {
const availableVars = [...variablesByName.values()].map((value) => value.name).filter(Boolean)
throw new Error(
`Variable "${variableName}" doesn't exist.` +
(availableVars.length > 0 ? ` Available: ${availableVars.join(', ')}` : '')
)
}

const variable = foundVariable[1]
let variableValue: unknown = variable.value

if (variable.value !== undefined && variable.value !== null) {
Expand All @@ -805,24 +800,15 @@ function resolveWorkflowVariables(
}
}

replacements.push({
match: match[0],
index: match.index,
variableName,
variableValue,
})
}

for (let i = replacements.length - 1; i >= 0; i--) {
const { match: matchStr, index, variableName, variableValue } = replacements[i]

const safeVarName = `__variable_${variableName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = variableValue
resolvedCode =
resolvedCode.slice(0, index) + safeVarName + resolvedCode.slice(index + matchStr.length)
}

return resolvedCode
// The original reverse rewrite gave the first reference precedence on binding-name collisions.
if (!boundNames.has(safeVarName)) {
contextVariables[safeVarName] = variableValue
boundNames.add(safeVarName)
}
replacements.set(variableName, safeVarName)
return safeVarName
})
}

/**
Expand Down Expand Up @@ -869,13 +855,12 @@ function resolveTagVariables(
contextVariables: Record<string, unknown>,
language = 'javascript'
): string {
let resolvedCode = code
const undefinedLiteral = language === 'python' ? 'None' : 'undefined'
const replacements = new Map<string, string | undefined>()

const tagMatches = resolvedCode.match(TAG_PATTERN) || []

for (const match of tagMatches) {
return code.replace(TAG_PATTERN, (match) => {
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
if (replacements.has(tagName)) return replacements.get(tagName) ?? match
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
const blockName = pathParts[0]
const fieldPath = pathParts.slice(1)
Expand All @@ -887,14 +872,15 @@ function resolveTagVariables(
})

if (!result) {
continue
replacements.set(tagName, undefined)
return match
}

let tagValue = result.value

if (tagValue === undefined) {
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral)
continue
replacements.set(tagName, undefinedLiteral)
return undefinedLiteral
}

if (typeof tagValue === 'string') {
Expand All @@ -910,10 +896,9 @@ function resolveTagVariables(

const safeVarName = `__tag_${tagName.replace(/_/g, '_1').replace(/\./g, '_0')}`
contextVariables[safeVarName] = tagValue
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
}

return resolvedCode
replacements.set(tagName, safeVarName)
return safeVarName
})
}

/**
Expand Down Expand Up @@ -2281,6 +2266,23 @@ export async function executeFunctionRequest(
)
includePrivateResolvedSecretNames = privateResolvedSecretNamesMetadataType !== undefined

let referenceCount = 0
for (const _match of body.code.matchAll(TAG_PATTERN)) {
if (++referenceCount > MAX_FUNCTION_REFERENCES) {
return appendPrivateResolvedSecretNames(
NextResponse.json(
{
success: false,
error: `Function code exceeds the maximum of ${MAX_FUNCTION_REFERENCES} references`,
},
{ status: 400 }
),
includePrivateResolvedSecretNames ? [] : null,
privateResolvedSecretNamesMetadataType
)
}
}

const mountedWorkspaceFileProvenance = inspectMountedWorkspaceFileProvenance(req.headers, body)
if (mountedWorkspaceFileProvenance.status === 'invalid') {
return appendPrivateResolvedSecretNames(
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/function-execution/limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** Bounds source scanning and the bindings created before sandbox execution. */
export const MAX_FUNCTION_CODE_LENGTH = 1024 * 1024
export const MAX_FUNCTION_REFERENCES = 10_000
84 changes: 84 additions & 0 deletions apps/sim/lib/mothership/agent-cli/engines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,90 @@ const DEPS_STATE = {
}

describe('workflows deps', () => {
it('stops reading a wide object when its traversal budget is exhausted', async () => {
const value: Record<string, unknown> = {}
for (let index = 0; index < 10_001; index++) {
Object.defineProperty(value, String(index), {
enumerable: true,
get() {
if (index === 10_000) throw new Error('Read beyond the traversal budget')
return ''
},
})
}
const state = {
...DEPS_STATE,
blocks: {
...DEPS_STATE.blocks,
target: { ...DEPS_STATE.blocks.target, subBlocks: { code: { value } } },
},
}
const result = await runEngine(
'workflows deps',
['wf-1', 'target'],
runtimeWith({ [STATE_PATH]: { data: state } }),
{}
)
expect(result.exitCode).toBe(1)
expect(result.stderr).toMatch(/exceeds.*values/i)
expect(result.stdout).toBe('')
})

it.each([
{ reason: 'text size', value: 'x'.repeat(1024 * 1024 + 1) },
{ reason: 'reference count', value: '<fetchrows.result>'.repeat(10_001) },
{ reason: 'nested value count', value: Array.from({ length: 10_001 }, () => '') },
{
reason: 'path depth',
value: `<fetchrows.${Array.from({ length: 129 }, () => 'nested').join('.')}>`,
},
])(
'refuses excessive $reason instead of returning an incomplete dependency report',
async ({ value }) => {
const state = {
...DEPS_STATE,
blocks: {
...DEPS_STATE.blocks,
target: { ...DEPS_STATE.blocks.target, subBlocks: { code: { value } } },
},
}
const result = await runEngine(
'workflows deps',
['wf-1', 'target'],
runtimeWith({ [STATE_PATH]: { data: state } }),
{}
)
expect(result.exitCode).toBe(1)
expect(result.stderr).toMatch(/exceeds|maximum/i)
expect(result.stdout).toBe('')
}
)

it('groups block aliases and duplicate paths without changing first-reference order', async () => {
const state = structuredClone(DEPS_STATE)
state.blocks.target.subBlocks.code.value =
'<missing.value> <fetchrows.result> <fetch.result> <fetchrows.result.id> <fetchrows.result.id> {{TOKEN}} {{TOKEN}}'
const result = await runEngine(
'workflows deps',
['wf-1', 'target'],
runtimeWith({ [STATE_PATH]: { data: state } }),
{}
)
const report = JSON.parse(result.stdout)
expect(report.references).toEqual([
{ token: 'missing.value', kind: 'unknown' },
{
token: 'fetchrows.result',
kind: 'block',
blockId: 'fetch',
blockName: 'Fetch rows',
paths: ['result', 'result.id'],
},
])
expect(report.env).toEqual(['TOKEN'])
expect(report.mock['Fetch rows']).toEqual({ result: { id: null } })
})

it('builds indexed mocks that round-trip through the actual reference navigator', async () => {
const state = structuredClone(DEPS_STATE)
state.blocks.target.subBlocks.code.value =
Expand Down
Loading
Loading