improvement(executor): reserved keyword errors (#4482)

* improvment(executor): reserved keyword errors

* address comments and make error messages for func execute make sense block ref accs
This commit is contained in:
Vikhyath Mondreti
2026-05-06 15:59:26 -07:00
committed by GitHub
parent 690b7abb6a
commit 00c424baa9
10 changed files with 450 additions and 51 deletions
@@ -538,6 +538,36 @@ describe('Function Execute API Route', () => {
expect(data.error).toContain('undefinedVariable is not defined')
})
it('should show original source code when resolved block references cause syntax errors', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({
result: null,
stdout: '',
error: {
message: 'Unexpected identifier "globalThis"',
name: 'SyntaxError',
line: 1,
column: 7,
lineContent: 'retur globalThis["__blockRef_0"]',
},
})
const req = createMockRequest('POST', {
code: 'retur globalThis["__blockRef_0"]',
sourceCode: 'retur <start.reqerror>',
contextVariables: { __blockRef_0: 'value' },
timeout: 5000,
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(422)
expect(data.success).toBe(false)
expect(data.error).toContain('Line 1: `retur <start.reqerror>`')
expect(data.error).not.toContain('globalThis')
expect(data.debug.lineContent).toBe('retur <start.reqerror>')
})
it('should handle thrown errors gracefully', async () => {
const req = createMockRequest('POST', {
code: 'throw new Error("Custom error message");',
+45 -12
View File
@@ -410,6 +410,30 @@ function createUserFriendlyErrorMessage(
return errorMessage
}
function getErrorDisplayCode(sourceCode: string | undefined, resolvedCode: string): string {
return sourceCode && sourceCode.length > 0 ? sourceCode : resolvedCode
}
function getLineContent(code: string, line: number | undefined): string | undefined {
if (line === undefined || line < 1) {
return undefined
}
return code.split('\n')[line - 1]?.trim()
}
function getErrorDisplayMessage(
message: string,
sourceCode: string | undefined,
resolvedCode: string
): string {
if (!sourceCode || sourceCode === resolvedCode || !resolvedCode.includes('__blockRef_')) {
return message
}
return message.replace(/\s+["']globalThis["']/g, '')
}
function resolveWorkflowVariables(
code: string,
workflowVariables: Record<string, any>,
@@ -767,6 +791,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
let stdout = ''
let userCodeStartLine = 3 // Default value for error reporting
let resolvedCode = '' // Store resolved code for error reporting
let sourceCodeForErrors: string | undefined
try {
const auth = await checkInternalAuth(req)
@@ -783,6 +808,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const {
code,
sourceCode,
params = {},
timeout = DEFAULT_EXECUTION_TIMEOUT_MS,
language = DEFAULT_CODE_LANGUAGE,
@@ -801,6 +827,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
isCustomTool = false,
_sandboxFiles,
} = body
sourceCodeForErrors = sourceCode
const executionParams = { ...params }
executionParams._context = undefined
@@ -1019,11 +1046,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
})
if (e2bError) {
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode),
e2bStdout,
lang,
resolvedCode,
errorDisplayCode,
prologueLineCount + importLineCount
)
return NextResponse.json(
@@ -1101,11 +1129,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
})
if (e2bError) {
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
const { formattedError, cleanedOutput } = formatE2BError(
e2bError,
getErrorDisplayMessage(e2bError, sourceCodeForErrors, resolvedCode),
e2bStdout,
lang,
resolvedCode,
errorDisplayCode,
prologueLineCount
)
return NextResponse.json(
@@ -1192,13 +1221,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
let adjustedLineContent = ivmError.lineContent
if (prependedLineCount > 0 && ivmError.line !== undefined) {
adjustedLine = Math.max(1, ivmError.line - prependedLineCount)
const codeLines = resolvedCode.split('\n')
if (adjustedLine <= codeLines.length) {
adjustedLineContent = codeLines[adjustedLine - 1]?.trim()
}
}
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
const displayMessage = getErrorDisplayMessage(
ivmError.message,
sourceCodeForErrors,
resolvedCode
)
adjustedLineContent = getLineContent(errorDisplayCode, adjustedLine) ?? adjustedLineContent
const enhancedError: EnhancedError = {
message: ivmError.message,
message: displayMessage,
name: ivmError.name,
stack: ivmError.stack,
originalError: ivmError,
@@ -1210,7 +1242,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const userFriendlyErrorMessage = createUserFriendlyErrorMessage(
enhancedError,
requestId,
resolvedCode
errorDisplayCode
)
const detailLogFn = isSystemError ? logger.error.bind(logger) : logger.warn.bind(logger)
@@ -1261,11 +1293,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
executionTime,
})
const enhancedError = extractEnhancedError(error, userCodeStartLine, resolvedCode)
const errorDisplayCode = getErrorDisplayCode(sourceCodeForErrors, resolvedCode)
const enhancedError = extractEnhancedError(error, userCodeStartLine, errorDisplayCode)
const userFriendlyErrorMessage = createUserFriendlyErrorMessage(
enhancedError,
requestId,
resolvedCode
errorDisplayCode
)
logger.error(`[${requestId}] Enhanced error details`, {
@@ -196,6 +196,25 @@ describe('FunctionBlockHandler', () => {
)
})
it('should pass original function code for error display after reference resolution', async () => {
mockBlock.config.params = { code: 'retur <start.reqerror>' }
await handler.execute(mockContext, mockBlock, {
code: 'retur globalThis["__blockRef_0"]',
[FUNCTION_BLOCK_CONTEXT_VARS_KEY]: { __blockRef_0: 'value' },
})
expect(mockExecuteTool).toHaveBeenCalledWith(
'function_execute',
expect.objectContaining({
code: 'retur globalThis["__blockRef_0"]',
sourceCode: 'retur <start.reqerror>',
}),
false,
mockContext
)
})
it('should normalize malformed execution context records before calling function_execute', async () => {
const legacyVariable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
mockContext.workflowVariables = [legacyVariable] as unknown as Record<string, any>
@@ -12,6 +12,22 @@ import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
function readCodeContent(value: unknown): string | undefined {
if (typeof value === 'string') {
return value
}
if (Array.isArray(value)) {
return value
.map((entry) =>
entry && typeof entry === 'object' && typeof entry.content === 'string' ? entry.content : ''
)
.join('\n')
}
return undefined
}
/**
* Handler for Function blocks that execute custom code.
*/
@@ -25,37 +41,36 @@ export class FunctionBlockHandler implements BlockHandler {
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
const codeContent = Array.isArray(inputs.code)
? inputs.code.map((c: { content: string }) => c.content).join('\n')
: inputs.code
const codeContent = readCodeContent(inputs.code) ?? inputs.code
const sourceCode = readCodeContent(
(block.config?.params as Record<string, unknown> | undefined)?.code
)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const contextVariables = normalizeRecord(inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY])
const result = await executeTool(
'function_execute',
{
code: codeContent,
language: inputs.language || DEFAULT_CODE_LANGUAGE,
timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
envVars: normalizeStringRecord(ctx.environmentVariables),
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData,
blockNameMapping,
blockOutputSchemas,
contextVariables,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
const toolParams = {
code: codeContent,
...(sourceCode ? { sourceCode } : {}),
language: inputs.language || DEFAULT_CODE_LANGUAGE,
timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
envVars: normalizeStringRecord(ctx.environmentVariables),
workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData,
blockNameMapping,
blockOutputSchemas,
contextVariables,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
userId: ctx.userId,
isDeployedContext: ctx.isDeployedContext,
enforceCredentialAccess: ctx.enforceCredentialAccess,
},
false,
ctx
)
}
const result = await executeTool('function_execute', toolParams, false, ctx)
if (!result.success) {
throw new Error(result.error || 'Function execution failed')
+9
View File
@@ -213,6 +213,15 @@ export interface NormalizedBlockOutput {
_pauseMetadata?: PauseMetadata
}
export const EXECUTION_CONTROL_OUTPUT_FIELD_NAMES = [
'error',
'selectedOption',
'selectedRoute',
'_pauseMetadata',
] as const
export type ExecutionControlOutputFieldName = (typeof EXECUTION_CONTROL_OUTPUT_FIELD_NAMES)[number]
export interface BlockLog {
blockId: string
blockName?: string
+179
View File
@@ -119,6 +119,167 @@ describe('start-block utilities', () => {
expect(output.files).toEqual(files)
})
it.concurrent('rejects inputFormat fields that collide with executor routing keys', () => {
const block = createBlock('start_trigger', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 'error', type: 'string' },
{ name: 'error', type: 'string' },
{ name: ' selectedOption ', type: 'string' },
{ name: 'selectedRoute', type: 'string' },
{ name: '_pauseMetadata', type: 'object' },
],
},
},
})
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.UNIFIED,
} as const
expect(() =>
buildStartBlockOutput({
resolution,
workflowInput: { error: false, selectedRoute: 'source' },
})
).toThrow(
'Start block "block-start_trigger" cannot use reserved input format field name(s): error, selectedOption, selectedRoute, _pauseMetadata'
)
})
it.concurrent(
'rejects reserved top-level runtime input keys copied to unified Start output',
() => {
const block = createBlock('start_trigger', 'start')
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.UNIFIED,
} as const
expect(() =>
buildStartBlockOutput({
resolution,
workflowInput: { error: 'false', payload: 'value' },
})
).toThrow(
'Start block "block-start_trigger" cannot use reserved runtime input field name(s): error'
)
}
)
it.concurrent('rejects reserved nested API input keys copied to trigger output', () => {
const block = createBlock('api_trigger', 'api')
const resolution = {
blockId: 'api',
block,
path: StartBlockPath.SPLIT_API,
} as const
expect(() =>
buildStartBlockOutput({
resolution,
workflowInput: { input: { selectedRoute: 'route-1', payload: 'value' } },
})
).toThrow(
'Start block "block-api_trigger" cannot use reserved runtime input field name(s): selectedRoute'
)
})
it.concurrent('allows reserved inputFormat field names on split chat trigger output', () => {
const block = createBlock('chat_trigger', 'chat', {
subBlocks: {
inputFormat: {
value: [{ name: 'error', type: 'string' }],
},
},
})
const resolution = {
blockId: 'chat',
block,
path: StartBlockPath.SPLIT_CHAT,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: { input: 'hello', conversationId: 'conversation-1' },
})
expect(output).toEqual({ input: 'hello', conversationId: 'conversation-1' })
})
it.concurrent('allows reserved inputFormat field names on legacy chat starter output', () => {
const block = createBlock('starter', 'starter', {
subBlocks: {
startWorkflow: { value: 'chat' },
inputFormat: {
value: [{ name: 'error', type: 'string' }],
},
},
})
const resolution = {
blockId: 'starter',
block,
path: StartBlockPath.LEGACY_STARTER,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: { input: 'hello' },
})
expect(output).toEqual({ input: 'hello' })
})
it.concurrent('allows reserved inputFormat field names on serialized legacy chat starter', () => {
const block = createBlock('starter', 'starter')
block.config.params = {
startWorkflow: 'chat',
inputFormat: [{ name: 'error', type: 'string' }],
}
const resolution = {
blockId: 'starter',
block,
path: StartBlockPath.LEGACY_STARTER,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: { input: 'hello' },
})
expect(output).toEqual({ input: 'hello' })
})
it.concurrent('ignores malformed non-string inputFormat field names', () => {
const block = createBlock('start_trigger', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 123, type: 'string', value: 'ignored' },
{ name: 'customField', type: 'string' },
],
},
},
})
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.UNIFIED,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: { customField: 'value' },
})
expect(output.customField).toBe('value')
expect(output[123]).toBeUndefined()
})
describe('inputFormat default values', () => {
it.concurrent('uses default value when runtime does not provide the field', () => {
const block = createBlock('start_trigger', 'start', {
@@ -294,6 +455,24 @@ describe('start-block utilities', () => {
})
describe('EXTERNAL_TRIGGER path', () => {
it.concurrent('rejects reserved runtime input keys copied to external trigger output', () => {
const block = createBlock('webhook', 'start')
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.EXTERNAL_TRIGGER,
} as const
expect(() =>
buildStartBlockOutput({
resolution,
workflowInput: { _pauseMetadata: { contextId: 'fake-pause' }, payload: 'value' },
})
).toThrow(
'Start block "block-webhook" cannot use reserved runtime input field name(s): _pauseMetadata'
)
})
it.concurrent('preserves coerced types for integration trigger payload', () => {
const block = createBlock('webhook', 'start', {
subBlocks: {
+124 -14
View File
@@ -1,17 +1,24 @@
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import {
classifyStartBlockType,
getLegacyStarterMode,
resolveStartCandidates,
StartBlockPath,
} from '@/lib/workflows/triggers/triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import type { NormalizedBlockOutput, UserFile } from '@/executor/types'
import {
EXECUTION_CONTROL_OUTPUT_FIELD_NAMES,
type NormalizedBlockOutput,
type UserFile,
} from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { safeAssign } from '@/tools/safe-assign'
type ExecutionKind = 'chat' | 'manual' | 'api' | 'external'
const EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET = new Set<string>(
EXECUTION_CONTROL_OUTPUT_FIELD_NAMES
)
export interface ExecutorStartResolution {
blockId: string
block: SerializedBlock
@@ -133,6 +140,92 @@ function extractInputFormat(block: SerializedBlock): InputFormatField[] {
.map((field) => field)
}
function normalizeLegacyStarterMode(modeValue: unknown): 'manual' | 'api' | 'chat' | null {
if (modeValue === 'chat') return 'chat'
if (modeValue === 'api' || modeValue === 'run') return 'api'
if (modeValue === undefined || modeValue === 'manual') return 'manual'
return null
}
function getSerializedLegacyStarterMode(block: SerializedBlock): 'manual' | 'api' | 'chat' | null {
const fromMetadata = readMetadataSubBlockValue(block, 'startWorkflow')
if (fromMetadata !== undefined) {
return normalizeLegacyStarterMode(fromMetadata)
}
return normalizeLegacyStarterMode(block.config?.params?.startWorkflow)
}
function readInputFormatFieldName(field: InputFormatField): string | undefined {
return typeof field.name === 'string' ? field.name.trim() : undefined
}
function collectExecutionControlFieldNames(fieldNames: Iterable<string | undefined>): string[] {
const reservedFieldNames = new Set<string>()
for (const fieldName of fieldNames) {
if (fieldName && EXECUTION_CONTROL_OUTPUT_FIELD_NAME_SET.has(fieldName)) {
reservedFieldNames.add(fieldName)
}
}
return Array.from(reservedFieldNames)
}
function throwReservedStartOutputFieldsError(
block: SerializedBlock,
reservedFieldNames: string[],
source: 'input format' | 'runtime input'
): never {
const blockName = block.metadata?.name ?? block.id
throw new Error(
`Start block "${blockName}" cannot use reserved ${source} field name(s): ${reservedFieldNames.join(', ')}. These names control workflow execution and cannot be used as Start outputs. Rename these fields before running the workflow. Reserved names are: ${EXECUTION_CONTROL_OUTPUT_FIELD_NAMES.join(', ')}.`
)
}
function assertNoReservedInputFormatFields(
inputFormat: InputFormatField[],
block: SerializedBlock
): void {
const reservedFieldNames = collectExecutionControlFieldNames(
inputFormat.map(readInputFormatFieldName)
)
if (reservedFieldNames.length === 0) {
return
}
throwReservedStartOutputFieldsError(block, reservedFieldNames, 'input format')
}
function assertNoReservedStartOutputFields(
output: NormalizedBlockOutput,
block: SerializedBlock
): void {
const reservedFieldNames = collectExecutionControlFieldNames(Object.keys(output))
if (reservedFieldNames.length === 0) {
return
}
throwReservedStartOutputFieldsError(block, reservedFieldNames, 'runtime input')
}
function pathConsumesInputFormat(
path: StartBlockPath,
legacyStarterMode: 'manual' | 'api' | 'chat' | null
): boolean {
switch (path) {
case StartBlockPath.SPLIT_CHAT:
return false
case StartBlockPath.LEGACY_STARTER:
return legacyStarterMode !== 'chat'
default:
return true
}
}
export function coerceValue(type: string | null | undefined, value: unknown): unknown {
if (value === undefined || value === null) {
return value
@@ -190,7 +283,7 @@ function deriveInputFromFormat(
}
for (const field of inputFormat) {
const fieldName = field.name?.trim()
const fieldName = readInputFormatFieldName(field)
if (!fieldName) continue
let fieldValue: unknown
@@ -436,35 +529,52 @@ export interface StartBlockOutputOptions {
export function buildStartBlockOutput(options: StartBlockOutputOptions): NormalizedBlockOutput {
const { resolution, workflowInput } = options
const inputFormat = extractInputFormat(resolution.block)
const legacyStarterMode =
resolution.path === StartBlockPath.LEGACY_STARTER
? getSerializedLegacyStarterMode(resolution.block)
: null
if (pathConsumesInputFormat(resolution.path, legacyStarterMode)) {
assertNoReservedInputFormatFields(inputFormat, resolution.block)
}
const { finalInput, structuredInput, hasStructured } = deriveInputFromFormat(
inputFormat,
workflowInput
)
let output: NormalizedBlockOutput
switch (resolution.path) {
case StartBlockPath.UNIFIED:
return buildUnifiedStartOutput(workflowInput, structuredInput, hasStructured)
output = buildUnifiedStartOutput(workflowInput, structuredInput, hasStructured)
break
case StartBlockPath.SPLIT_API:
case StartBlockPath.SPLIT_INPUT:
return buildApiOrInputOutput(finalInput, workflowInput)
output = buildApiOrInputOutput(finalInput, workflowInput)
break
case StartBlockPath.SPLIT_CHAT:
return buildChatOutput(workflowInput)
output = buildChatOutput(workflowInput)
break
case StartBlockPath.SPLIT_MANUAL:
return buildManualTriggerOutput(finalInput, workflowInput)
output = buildManualTriggerOutput(finalInput, workflowInput)
break
case StartBlockPath.EXTERNAL_TRIGGER:
return buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured)
output = buildIntegrationTriggerOutput(workflowInput, structuredInput, hasStructured)
break
case StartBlockPath.LEGACY_STARTER:
return buildLegacyStarterOutput(
finalInput,
workflowInput,
getLegacyStarterMode({ subBlocks: extractSubBlocks(resolution.block) })
)
output = buildLegacyStarterOutput(finalInput, workflowInput, legacyStarterMode)
break
default:
return buildManualTriggerOutput(finalInput, workflowInput)
output = buildManualTriggerOutput(finalInput, workflowInput)
}
assertNoReservedStartOutputFields(output, resolution.block)
return output
}
+1
View File
@@ -86,6 +86,7 @@ export const functionExecuteContract = defineRouteContract({
path: '/api/function/execute',
body: z.object({
code: z.string().min(1, 'Code is required'),
sourceCode: z.string().optional(),
params: unknownRecordSchema.optional().default({}),
timeout: z.coerce.number().int().positive().optional(),
language: z.string().optional().default(DEFAULT_CODE_LANGUAGE),
+1
View File
@@ -122,6 +122,7 @@ export const functionExecuteTool: ToolConfig<CodeExecutionInput, CodeExecutionOu
const body: Record<string, unknown> = {
code: codeContent,
sourceCode: params.sourceCode,
language: params.language || DEFAULT_CODE_LANGUAGE,
timeout: params.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
outputPath: params.outputPath,
+2
View File
@@ -3,6 +3,8 @@ import type { ToolResponse } from '@/tools/types'
export interface CodeExecutionInput {
code: Array<{ content: string; id: string }> | string
/** Original user-authored code used for error display after execution-time reference resolution. */
sourceCode?: string
language?: CodeLanguage
useLocalVM?: boolean
timeout?: number