improvement(code-quality): centralize regex checks, normalization (#2554)

* improvement(code-quality): centralize regex checks, normalization

* simplify resolution

* fix(copilot): don't allow duplicate name blocks

* centralize uuid check
This commit is contained in:
Vikhyath Mondreti
2025-12-23 15:12:04 -08:00
committed by GitHub
parent b23299dae4
commit bf8fbebe22
68 changed files with 425 additions and 396 deletions

View File

@@ -10,9 +10,9 @@ import {
createRequestTracker,
createUnauthorizedResponse,
} from '@/lib/copilot/request-helpers'
import { validateUUID } from '@/lib/core/security/input-validation'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { isUuidV4 } from '@/executor/constants'
const logger = createLogger('CheckpointRevertAPI')
@@ -87,9 +87,8 @@ export async function POST(request: NextRequest) {
isDeployed: cleanedState.isDeployed,
})
const workflowIdValidation = validateUUID(checkpoint.workflowId, 'workflowId')
if (!workflowIdValidation.isValid) {
logger.error(`[${tracker.requestId}] Invalid workflow ID: ${workflowIdValidation.error}`)
if (!isUuidV4(checkpoint.workflowId)) {
logger.error(`[${tracker.requestId}] Invalid workflow ID format`)
return NextResponse.json({ error: 'Invalid workflow ID format' }, { status: 400 })
}

View File

@@ -14,6 +14,8 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
import { executeTool } from '@/tools'
import { getTool } from '@/tools/utils'
@@ -33,14 +35,18 @@ const ExecuteToolSchema = z.object({
function resolveEnvVarReferences(value: any, envVars: Record<string, string>): any {
if (typeof value === 'string') {
// Check for exact match: entire string is "{{VAR_NAME}}"
const exactMatch = /^\{\{([^}]+)\}\}$/.exec(value)
const exactMatchPattern = new RegExp(
`^\\${REFERENCE.ENV_VAR_START}([^}]+)\\${REFERENCE.ENV_VAR_END}$`
)
const exactMatch = exactMatchPattern.exec(value)
if (exactMatch) {
const envVarName = exactMatch[1].trim()
return envVars[envVarName] ?? value
}
// Check for embedded references: "prefix {{VAR}} suffix"
return value.replace(/\{\{([^}]+)\}\}/g, (match, varName) => {
const envVarPattern = createEnvVarPattern()
return value.replace(envVarPattern, (match, varName) => {
const trimmedName = varName.trim()
return envVars[trimmedName] ?? match
})

View File

@@ -14,6 +14,7 @@ import type { StorageConfig } from '@/lib/uploads/core/storage-client'
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { isUuid } from '@/executor/constants'
const logger = createLogger('FileAuthorization')
@@ -85,9 +86,7 @@ function extractWorkspaceIdFromKey(key: string): string | null {
const parts = key.split('/')
const workspaceId = parts[0]
// Validate UUID format
const UUID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
if (workspaceId && UUID_PATTERN.test(workspaceId)) {
if (workspaceId && isUuid(workspaceId)) {
return workspaceId
}

View File

@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { sanitizeFileName } from '@/executor/constants'
import '@/lib/uploads/core/setup.server'
import { getSession } from '@/lib/auth'
import type { StorageContext } from '@/lib/uploads/config'
@@ -154,7 +155,7 @@ export async function POST(request: NextRequest) {
logger.info(`Uploading knowledge-base file: ${originalName}`)
const timestamp = Date.now()
const safeFileName = originalName.replace(/\s+/g, '-')
const safeFileName = sanitizeFileName(originalName)
const storageKey = `kb/${timestamp}-${safeFileName}`
const metadata: Record<string, string> = {
@@ -267,9 +268,8 @@ export async function POST(request: NextRequest) {
logger.info(`Uploading ${context} file: ${originalName}`)
// Generate storage key with context prefix and timestamp to ensure uniqueness
const timestamp = Date.now()
const safeFileName = originalName.replace(/\s+/g, '-')
const safeFileName = sanitizeFileName(originalName)
const storageKey = `${context}/${timestamp}-${safeFileName}`
const metadata: Record<string, string> = {

View File

@@ -5,6 +5,7 @@ import { executeInE2B } from '@/lib/execution/e2b'
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { createLogger } from '@/lib/logs/console/logger'
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import {
createEnvVarPattern,
createWorkflowVariablePattern,
@@ -405,7 +406,7 @@ function resolveWorkflowVariables(
// Find the variable by name (workflowVariables is indexed by ID, values are variable objects)
const foundVariable = Object.entries(workflowVariables).find(
([_, variable]) => (variable.name || '').replace(/\s+/g, '') === variableName
([_, variable]) => normalizeName(variable.name || '') === variableName
)
let variableValue: unknown = ''
@@ -513,31 +514,26 @@ function resolveTagVariables(
): string {
let resolvedCode = code
const tagMatches = resolvedCode.match(/<([a-zA-Z_][a-zA-Z0-9_.]*[a-zA-Z0-9_])>/g) || []
const tagPattern = new RegExp(
`${REFERENCE.START}([a-zA-Z_][a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])${REFERENCE.END}`,
'g'
)
const tagMatches = resolvedCode.match(tagPattern) || []
for (const match of tagMatches) {
const tagName = match.slice(1, -1).trim()
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
// Handle nested paths like "getrecord.response.data" or "function1.response.result"
// First try params, then blockData directly, then try with block name mapping
let tagValue = getNestedValue(params, tagName) || getNestedValue(blockData, tagName) || ''
// If not found and the path starts with a block name, try mapping the block name to ID
if (!tagValue && tagName.includes('.')) {
const pathParts = tagName.split('.')
if (!tagValue && tagName.includes(REFERENCE.PATH_DELIMITER)) {
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
const normalizedBlockName = pathParts[0] // This should already be normalized like "function1"
// Find the block ID by looking for a block name that normalizes to this value
let blockId = null
for (const [blockName, id] of Object.entries(blockNameMapping)) {
// Apply the same normalization logic as the UI: remove spaces and lowercase
const normalizedName = blockName.replace(/\s+/g, '').toLowerCase()
if (normalizedName === normalizedBlockName) {
blockId = id
break
}
}
// Direct lookup using normalized block name
const blockId = blockNameMapping[normalizedBlockName] ?? null
if (blockId) {
const remainingPath = pathParts.slice(1).join('.')
@@ -617,13 +613,6 @@ function getNestedValue(obj: any, path: string): any {
}, obj)
}
/**
* Escape special regex characters in a string
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Remove one trailing newline from stdout
* This handles the common case where print() or console.log() adds a trailing \n

View File

@@ -6,6 +6,8 @@ import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
import { validateMcpServerUrl } from '@/lib/mcp/url-validator'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
const logger = createLogger('McpServerTestAPI')
@@ -23,12 +25,13 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
* Resolve environment variables in strings
*/
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
const envMatches = value.match(/\{\{([^}]+)\}\}/g)
const envVarPattern = createEnvVarPattern()
const envMatches = value.match(envVarPattern)
if (!envMatches) return value
let resolvedValue = value
for (const match of envMatches) {
const envKey = match.slice(2, -2).trim()
const envKey = match.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length).trim()
const envValue = envVars[envKey]
if (envValue === undefined) {

View File

@@ -1,9 +1,9 @@
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateUUID } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { isUuidV4 } from '@/executor/constants'
export const dynamic = 'force-dynamic'
const logger = createLogger('GoogleCalendarAPI')
@@ -35,18 +35,14 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
const credentialValidation = validateUUID(credentialId, 'credentialId')
if (!credentialValidation.isValid) {
if (!isUuidV4(credentialId)) {
logger.warn(`[${requestId}] Invalid credentialId format`, { credentialId })
return NextResponse.json({ error: credentialValidation.error }, { status: 400 })
return NextResponse.json({ error: 'Invalid credential ID format' }, { status: 400 })
}
if (workflowId) {
const workflowValidation = validateUUID(workflowId, 'workflowId')
if (!workflowValidation.isValid) {
logger.warn(`[${requestId}] Invalid workflowId format`, { workflowId })
return NextResponse.json({ error: workflowValidation.error }, { status: 400 })
}
if (workflowId && !isUuidV4(workflowId)) {
logger.warn(`[${requestId}] Invalid workflowId format`, { workflowId })
return NextResponse.json({ error: 'Invalid workflow ID format' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {

View File

@@ -22,6 +22,7 @@ import {
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
import type { WorkflowExecutionPayload } from '@/background/workflow-execution'
import { normalizeName } from '@/executor/constants'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { StreamingExecution } from '@/executor/types'
import { Serializer } from '@/serializer'
@@ -86,10 +87,9 @@ function resolveOutputIds(
const blockName = outputId.substring(0, dotIndex)
const path = outputId.substring(dotIndex + 1)
const normalizedBlockName = blockName.toLowerCase().replace(/\s+/g, '')
const normalizedBlockName = normalizeName(blockName)
const block = Object.values(blocks).find((b: any) => {
const normalized = (b.name || '').toLowerCase().replace(/\s+/g, '')
return normalized === normalizedBlockName
return normalizeName(b.name || '') === normalizedBlockName
})
if (!block) {

View File

@@ -18,6 +18,7 @@ import { getEnv } from '@/lib/core/config/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { startsWithUuid } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -289,10 +290,9 @@ export function DeployModal({
if (!open || selectedStreamingOutputs.length === 0) return
const blocks = Object.values(useWorkflowStore.getState().blocks)
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
const validOutputs = selectedStreamingOutputs.filter((outputId) => {
if (UUID_REGEX.test(outputId)) {
if (startsWithUuid(outputId)) {
const underscoreIndex = outputId.indexOf('_')
if (underscoreIndex === -1) return false

View File

@@ -7,9 +7,9 @@ import {
} from '@/lib/workflows/sanitization/references'
import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { normalizeName, REFERENCE } from '@/executor/constants'
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -89,7 +89,7 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
*/
const shouldHighlightReference = useCallback(
(part: string): boolean => {
if (!part.startsWith('<') || !part.endsWith('>')) {
if (!part.startsWith(REFERENCE.START) || !part.endsWith(REFERENCE.END)) {
return false
}
@@ -108,8 +108,8 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
return true
}
const inner = reference.slice(1, -1)
const [prefix] = inner.split('.')
const inner = reference.slice(REFERENCE.START.length, -REFERENCE.END.length)
const [prefix] = inner.split(REFERENCE.PATH_DELIMITER)
const normalizedPrefix = normalizeName(prefix)
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
import { normalizeName } from '@/stores/workflows/utils'
import { normalizeName } from '@/executor/constants'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { REFERENCE } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -44,7 +45,7 @@ function parseResponseFormatSafely(responseFormatValue: any, blockId: string): a
if (typeof responseFormatValue === 'string') {
const trimmedValue = responseFormatValue.trim()
if (trimmedValue.startsWith('<') && trimmedValue.includes('>')) {
if (trimmedValue.startsWith(REFERENCE.START) && trimmedValue.includes(REFERENCE.END)) {
return trimmedValue
}

View File

@@ -16,6 +16,7 @@ import {
import { Trash } from '@/components/emcn/icons/trash'
import { Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { isValidEnvVarName } from '@/executor/constants'
import {
usePersonalEnvironment,
useRemoveWorkspaceEnvironment,
@@ -28,7 +29,6 @@ import {
const logger = createLogger('EnvironmentVariables')
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto] items-center'
const ENV_VAR_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
const PRIMARY_BUTTON_STYLES =
'!bg-[var(--brand-tertiary-2)] !text-[var(--text-inverse)] hover:!bg-[var(--brand-tertiary-2)]/90'
@@ -59,7 +59,7 @@ interface UIEnvironmentVariable {
function validateEnvVarKey(key: string): string | undefined {
if (!key) return undefined
if (key.includes(' ')) return 'Spaces are not allowed'
if (!ENV_VAR_PATTERN.test(key)) return 'Only letters, numbers, and underscores allowed'
if (!isValidEnvVarName(key)) return 'Only letters, numbers, and underscores allowed'
return undefined
}
@@ -377,7 +377,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
if (equalIndex === -1 || equalIndex === 0) return null
const potentialKey = withoutExport.substring(0, equalIndex).trim()
if (!ENV_VAR_PATTERN.test(potentialKey)) return null
if (!isValidEnvVarName(potentialKey)) return null
let value = withoutExport.substring(equalIndex + 1)

View File

@@ -22,8 +22,10 @@ import {
getScheduleTimeValues,
getSubBlockValue,
} from '@/lib/workflows/schedules/utils'
import { REFERENCE } from '@/executor/constants'
import { type ExecutionMetadata, ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionResult } from '@/executor/types'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
const logger = createLogger('TriggerScheduleExecution')
@@ -128,17 +130,25 @@ async function ensureBlockVariablesResolvable(
await Promise.all(
Object.values(subBlocks).map(async (subBlock) => {
const value = subBlock.value
if (typeof value !== 'string' || !value.includes('{{') || !value.includes('}}')) {
if (
typeof value !== 'string' ||
!value.includes(REFERENCE.ENV_VAR_START) ||
!value.includes(REFERENCE.ENV_VAR_END)
) {
return
}
const matches = value.match(/{{([^}]+)}}/g)
const envVarPattern = createEnvVarPattern()
const matches = value.match(envVarPattern)
if (!matches) {
return
}
for (const match of matches) {
const varName = match.slice(2, -2)
const varName = match.slice(
REFERENCE.ENV_VAR_START.length,
-REFERENCE.ENV_VAR_END.length
)
const encryptedValue = variables[varName]
if (!encryptedValue) {
throw new Error(`Environment variable "${varName}" was not found`)

View File

@@ -165,6 +165,10 @@ export const AGENT = {
CUSTOM_TOOL_PREFIX: 'custom_',
} as const
export const MCP = {
TOOL_PREFIX: 'mcp-',
} as const
export const MEMORY = {
DEFAULT_SLIDING_WINDOW_SIZE: 10,
DEFAULT_SLIDING_WINDOW_TOKENS: 4000,
@@ -338,3 +342,60 @@ export function parseReferencePath(reference: string): string[] {
const content = extractReferenceContent(reference)
return content.split(REFERENCE.PATH_DELIMITER)
}
export const PATTERNS = {
UUID: /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i,
UUID_V4: /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
UUID_PREFIX: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i,
ENV_VAR_NAME: /^[A-Za-z_][A-Za-z0-9_]*$/,
} as const
export function isUuid(value: string): boolean {
return PATTERNS.UUID.test(value)
}
export function isUuidV4(value: string): boolean {
return PATTERNS.UUID_V4.test(value)
}
export function startsWithUuid(value: string): boolean {
return PATTERNS.UUID_PREFIX.test(value)
}
export function isValidEnvVarName(name: string): boolean {
return PATTERNS.ENV_VAR_NAME.test(name)
}
export function sanitizeFileName(fileName: string): string {
return fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
}
export function isCustomTool(toolId: string): boolean {
return toolId.startsWith(AGENT.CUSTOM_TOOL_PREFIX)
}
export function isMcpTool(toolId: string): boolean {
return toolId.startsWith(MCP.TOOL_PREFIX)
}
export function stripCustomToolPrefix(name: string): string {
return name.startsWith(AGENT.CUSTOM_TOOL_PREFIX)
? name.slice(AGENT.CUSTOM_TOOL_PREFIX.length)
: name
}
export function stripMcpToolPrefix(name: string): string {
return name.startsWith(MCP.TOOL_PREFIX) ? name.slice(MCP.TOOL_PREFIX.length) : name
}
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* Normalizes a name for comparison by converting to lowercase and removing spaces.
* Used for both block names and variable names to ensure consistent matching.
*/
export function normalizeName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '')
}

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { getAllBlocks } from '@/blocks'
import { BlockType } from '@/executor/constants'
import { BlockType, isMcpTool } from '@/executor/constants'
import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler'
import type { ExecutionContext, StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
@@ -1384,7 +1384,7 @@ describe('AgentBlockHandler', () => {
it('should handle MCP tools in agent execution', async () => {
mockExecuteTool.mockImplementation((toolId, params, skipProxy, skipPostProcess, context) => {
if (toolId.startsWith('mcp-')) {
if (isMcpTool(toolId)) {
return Promise.resolve({
success: true,
output: {
@@ -1660,7 +1660,7 @@ describe('AgentBlockHandler', () => {
let capturedContext: any
mockExecuteTool.mockImplementation((toolId, params, skipProxy, skipPostProcess, context) => {
capturedContext = context
if (toolId.startsWith('mcp-')) {
if (isMcpTool(toolId)) {
return Promise.resolve({
success: true,
output: { content: [{ type: 'text', text: 'Success' }] },

View File

@@ -6,7 +6,14 @@ import { createMcpToolId } from '@/lib/mcp/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getAllBlocks } from '@/blocks'
import type { BlockOutput } from '@/blocks/types'
import { AGENT, BlockType, DEFAULTS, HTTP } from '@/executor/constants'
import {
AGENT,
BlockType,
DEFAULTS,
HTTP,
REFERENCE,
stripCustomToolPrefix,
} from '@/executor/constants'
import { memoryService } from '@/executor/handlers/agent/memory'
import type {
AgentInputs,
@@ -105,7 +112,7 @@ export class AgentBlockHandler implements BlockHandler {
if (typeof responseFormat === 'string') {
const trimmedValue = responseFormat.trim()
if (trimmedValue.startsWith('<') && trimmedValue.includes('>')) {
if (trimmedValue.startsWith(REFERENCE.START) && trimmedValue.includes(REFERENCE.END)) {
return undefined
}
@@ -1337,7 +1344,7 @@ export class AgentBlockHandler implements BlockHandler {
}
private formatToolCall(tc: any) {
const toolName = this.stripCustomToolPrefix(tc.name)
const toolName = stripCustomToolPrefix(tc.name)
return {
...tc,
@@ -1349,10 +1356,4 @@ export class AgentBlockHandler implements BlockHandler {
result: tc.result || tc.output,
}
}
private stripCustomToolPrefix(name: string): string {
return name.startsWith(AGENT.CUSTOM_TOOL_PREFIX)
? name.replace(AGENT.CUSTOM_TOOL_PREFIX, '')
: name
}
}

View File

@@ -24,7 +24,7 @@ vi.mock('@/tools', () => ({
vi.mock('@/executor/utils/block-data', () => ({
collectBlockData: vi.fn(() => ({
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
blockNameMapping: { 'Source Block': 'source-block-1' },
blockNameMapping: { sourceblock: 'source-block-1' },
})),
}))
@@ -200,7 +200,7 @@ describe('ConditionBlockHandler', () => {
envVars: mockContext.environmentVariables,
workflowVariables: mockContext.workflowVariables,
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
blockNameMapping: { 'Source Block': 'source-block-1' },
blockNameMapping: { sourceblock: 'source-block-1' },
_context: {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import { getBlock } from '@/blocks/index'
import { isMcpTool } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -17,10 +18,10 @@ export class GenericBlockHandler implements BlockHandler {
block: SerializedBlock,
inputs: Record<string, any>
): Promise<any> {
const isMcpTool = block.config.tool?.startsWith('mcp-')
const isMcp = block.config.tool ? isMcpTool(block.config.tool) : false
let tool = null
if (!isMcpTool) {
if (!isMcp) {
tool = getTool(block.config.tool)
if (!tool) {
throw new Error(`Tool not found: ${block.config.tool}`)

View File

@@ -7,7 +7,9 @@ import {
buildResumeUiUrl,
type FieldType,
HTTP,
normalizeName,
PAUSE_RESUME,
REFERENCE,
} from '@/executor/constants'
import {
generatePauseContextId,
@@ -16,7 +18,6 @@ import {
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import type { SerializedBlock } from '@/serializer/types'
import { normalizeName } from '@/stores/workflows/utils'
import { executeTool } from '@/tools'
const logger = createLogger('HumanInTheLoopBlockHandler')
@@ -477,7 +478,11 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
}
private isVariableReference(value: any): boolean {
return typeof value === 'string' && value.trim().startsWith('<') && value.trim().includes('>')
return (
typeof value === 'string' &&
value.trim().startsWith(REFERENCE.START) &&
value.trim().includes(REFERENCE.END)
)
}
private parseObjectStrings(data: any): any {
@@ -590,7 +595,6 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
blockDataWithPause[pauseBlockId] = pauseOutput
if (pauseBlockName) {
blockNameMappingWithPause[pauseBlockName] = pauseBlockId
blockNameMappingWithPause[normalizeName(pauseBlockName)] = pauseBlockId
}

View File

@@ -1,6 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockOutput } from '@/blocks/types'
import { BlockType, HTTP } from '@/executor/constants'
import { BlockType, HTTP, REFERENCE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'
@@ -220,7 +220,11 @@ export class ResponseBlockHandler implements BlockHandler {
}
private isVariableReference(value: any): boolean {
return typeof value === 'string' && value.trim().startsWith('<') && value.trim().includes('>')
return (
typeof value === 'string' &&
value.trim().startsWith(REFERENCE.START) &&
value.trim().includes(REFERENCE.END)
)
}
private parseObjectStrings(data: any): any {

View File

@@ -1,3 +1,4 @@
import { normalizeName } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
export interface BlockDataCollection {
@@ -14,9 +15,7 @@ export function collectBlockData(ctx: ExecutionContext): BlockDataCollection {
blockData[id] = state.output
const workflowBlock = ctx.workflow?.blocks?.find((b) => b.id === id)
if (workflowBlock?.metadata?.name) {
blockNameMapping[workflowBlock.metadata.name] = id
const normalized = workflowBlock.metadata.name.replace(/\s+/g, '').toLowerCase()
blockNameMapping[normalized] = id
blockNameMapping[normalizeName(workflowBlock.metadata.name)] = id
}
}
}

View File

@@ -1,8 +1,8 @@
import { createLogger } from '@/lib/logs/console/logger'
import { BlockType, REFERENCE } from '@/executor/constants'
import { BlockType } from '@/executor/constants'
import type { ExecutionState, LoopScope } from '@/executor/execution/state'
import type { ExecutionContext } from '@/executor/types'
import { replaceValidReferences } from '@/executor/utils/reference-validation'
import { createEnvVarPattern, replaceValidReferences } from '@/executor/utils/reference-validation'
import { BlockResolver } from '@/executor/variables/resolvers/block'
import { EnvResolver } from '@/executor/variables/resolvers/env'
import { LoopResolver } from '@/executor/variables/resolvers/loop'
@@ -185,8 +185,7 @@ export class VariableResolver {
throw replacementError
}
const envRegex = new RegExp(`${REFERENCE.ENV_VAR_START}([^}]+)${REFERENCE.ENV_VAR_END}`, 'g')
result = result.replace(envRegex, (match) => {
result = result.replace(createEnvVarPattern(), (match) => {
const resolved = this.resolveReference(match, resolutionContext)
return typeof resolved === 'string' ? resolved : match
})
@@ -236,8 +235,7 @@ export class VariableResolver {
throw replacementError
}
const envRegex = new RegExp(`${REFERENCE.ENV_VAR_START}([^}]+)${REFERENCE.ENV_VAR_END}`, 'g')
result = result.replace(envRegex, (match) => {
result = result.replace(createEnvVarPattern(), (match) => {
const resolved = this.resolveReference(match, resolutionContext)
return typeof resolved === 'string' ? resolved : match
})

View File

@@ -1,22 +1,24 @@
import { isReference, parseReferencePath, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants'
import {
isReference,
normalizeName,
parseReferencePath,
SPECIAL_REFERENCE_PREFIXES,
} from '@/executor/constants'
import {
navigatePath,
type ResolutionContext,
type Resolver,
} from '@/executor/variables/resolvers/reference'
import type { SerializedWorkflow } from '@/serializer/types'
import { normalizeName } from '@/stores/workflows/utils'
export class BlockResolver implements Resolver {
private blockByNormalizedName: Map<string, string>
private nameToBlockId: Map<string, string>
constructor(private workflow: SerializedWorkflow) {
this.blockByNormalizedName = new Map()
this.nameToBlockId = new Map()
for (const block of workflow.blocks) {
this.blockByNormalizedName.set(block.id, block.id)
if (block.metadata?.name) {
const normalized = normalizeName(block.metadata.name)
this.blockByNormalizedName.set(normalized, block.id)
this.nameToBlockId.set(normalizeName(block.metadata.name), block.id)
}
}
}
@@ -80,11 +82,7 @@ export class BlockResolver implements Resolver {
}
private findBlockIdByName(name: string): string | undefined {
if (this.blockByNormalizedName.has(name)) {
return this.blockByNormalizedName.get(name)
}
const normalized = normalizeName(name)
return this.blockByNormalizedName.get(normalized)
return this.nameToBlockId.get(normalizeName(name))
}
public formatValueForBlock(

View File

@@ -117,7 +117,7 @@ export class ParallelResolver implements Resolver {
// String handling
if (typeof rawItems === 'string') {
// Skip references - they should be resolved by the variable resolver
if (rawItems.startsWith('<')) {
if (rawItems.startsWith(REFERENCE.START)) {
return []
}

View File

@@ -1,12 +1,11 @@
import { createLogger } from '@/lib/logs/console/logger'
import { VariableManager } from '@/lib/workflows/variables/variable-manager'
import { isReference, parseReferencePath, REFERENCE } from '@/executor/constants'
import { isReference, normalizeName, parseReferencePath, REFERENCE } from '@/executor/constants'
import {
navigatePath,
type ResolutionContext,
type Resolver,
} from '@/executor/variables/resolvers/reference'
import { normalizeName } from '@/stores/workflows/utils'
const logger = createLogger('WorkflowResolver')

View File

@@ -4,6 +4,7 @@ import { and, eq, isNull } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console/logger'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { escapeRegExp } from '@/executor/constants'
import type { ChatContext } from '@/stores/panel/copilot/types'
export type AgentContextType =
@@ -153,10 +154,6 @@ export async function processContextsServer(
return filtered
}
function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function sanitizeMessageForDocs(rawMessage: string, contexts: ChatContext[] | undefined): string {
if (!rawMessage) return ''
if (!Array.isArray(contexts) || contexts.length === 0) {

View File

@@ -4,10 +4,10 @@ import {
} from '@/lib/core/utils/response-format'
import { getBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import { useVariablesStore } from '@/stores/panel/variables/store'
import type { Variable } from '@/stores/panel/variables/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
export interface WorkflowContext {

View File

@@ -16,8 +16,8 @@ import {
type GetBlockOutputsResultType,
} from '@/lib/copilot/tools/shared/schemas'
import { createLogger } from '@/lib/logs/console/logger'
import { normalizeName } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { normalizeName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('GetBlockOutputsClientTool')

View File

@@ -12,6 +12,7 @@ import { isValidKey } from '@/lib/workflows/sanitization/key-validation'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { EDGE, normalizeName } from '@/executor/constants'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
@@ -55,6 +56,7 @@ type SkippedItemType =
| 'invalid_subblock_field'
| 'missing_required_params'
| 'invalid_subflow_parent'
| 'duplicate_block_name'
/**
* Represents an item that was skipped during operation application
@@ -80,6 +82,21 @@ function logSkippedItem(skippedItems: SkippedItem[], item: SkippedItem): void {
skippedItems.push(item)
}
/**
* Finds an existing block with the same normalized name.
*/
function findBlockWithDuplicateNormalizedName(
blocks: Record<string, any>,
name: string,
excludeBlockId: string
): [string, any] | undefined {
const normalizedName = normalizeName(name)
return Object.entries(blocks).find(
([blockId, block]: [string, any]) =>
blockId !== excludeBlockId && normalizeName(block.name || '') === normalizedName
)
}
/**
* Result of input validation
*/
@@ -773,10 +790,10 @@ function validateSourceHandleForBlock(
}
case 'condition': {
if (!sourceHandle.startsWith('condition-')) {
if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) {
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "condition-"`,
error: `Invalid source handle "${sourceHandle}" for condition block. Must start with "${EDGE.CONDITION_PREFIX}"`,
}
}
@@ -792,12 +809,12 @@ function validateSourceHandleForBlock(
}
case 'router':
if (sourceHandle === 'source' || sourceHandle.startsWith('router-')) {
if (sourceHandle === 'source' || sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) {
return { valid: true }
}
return {
valid: false,
error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, router-{targetId}, error`,
error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, ${EDGE.ROUTER_PREFIX}{targetId}, error`,
}
default:
@@ -1387,7 +1404,39 @@ function applyOperationsToWorkflowState(
block.type = params.type
}
}
if (params?.name !== undefined) block.name = params.name
if (params?.name !== undefined) {
if (!normalizeName(params.name)) {
logSkippedItem(skippedItems, {
type: 'missing_required_params',
operationType: 'edit',
blockId: block_id,
reason: `Cannot rename to empty name`,
details: { requestedName: params.name },
})
} else {
const conflictingBlock = findBlockWithDuplicateNormalizedName(
modifiedState.blocks,
params.name,
block_id
)
if (conflictingBlock) {
logSkippedItem(skippedItems, {
type: 'duplicate_block_name',
operationType: 'edit',
blockId: block_id,
reason: `Cannot rename to "${params.name}" - conflicts with "${conflictingBlock[1].name}"`,
details: {
requestedName: params.name,
conflictingBlockId: conflictingBlock[0],
conflictingBlockName: conflictingBlock[1].name,
},
})
} else {
block.name = params.name
}
}
}
// Handle trigger mode toggle
if (typeof params?.triggerMode === 'boolean') {
@@ -1571,7 +1620,7 @@ function applyOperationsToWorkflowState(
}
case 'add': {
if (!params?.type || !params?.name) {
if (!params?.type || !params?.name || !normalizeName(params.name)) {
logSkippedItem(skippedItems, {
type: 'missing_required_params',
operationType: 'add',
@@ -1582,6 +1631,27 @@ function applyOperationsToWorkflowState(
break
}
const conflictingBlock = findBlockWithDuplicateNormalizedName(
modifiedState.blocks,
params.name,
block_id
)
if (conflictingBlock) {
logSkippedItem(skippedItems, {
type: 'duplicate_block_name',
operationType: 'add',
blockId: block_id,
reason: `Block name "${params.name}" conflicts with existing block "${conflictingBlock[1].name}"`,
details: {
requestedName: params.name,
conflictingBlockId: conflictingBlock[0],
conflictingBlockName: conflictingBlock[1].name,
},
})
break
}
// Special container types (loop, parallel) are not in the block registry but are valid
const isContainerType = params.type === 'loop' || params.type === 'parallel'

View File

@@ -8,7 +8,6 @@ import {
validateNumericId,
validatePathSegment,
validateUrlWithDNS,
validateUUID,
} from '@/lib/core/security/input-validation'
import { sanitizeForLogging } from '@/lib/core/security/redaction'
@@ -194,54 +193,6 @@ describe('validatePathSegment', () => {
})
})
describe('validateUUID', () => {
describe('valid UUIDs', () => {
it.concurrent('should accept valid UUID v4', () => {
const result = validateUUID('550e8400-e29b-41d4-a716-446655440000')
expect(result.isValid).toBe(true)
})
it.concurrent('should accept UUID with uppercase letters', () => {
const result = validateUUID('550E8400-E29B-41D4-A716-446655440000')
expect(result.isValid).toBe(true)
expect(result.sanitized).toBe('550e8400-e29b-41d4-a716-446655440000')
})
it.concurrent('should normalize UUID to lowercase', () => {
const result = validateUUID('550E8400-E29B-41D4-A716-446655440000')
expect(result.sanitized).toBe('550e8400-e29b-41d4-a716-446655440000')
})
})
describe('invalid UUIDs', () => {
it.concurrent('should reject non-UUID strings', () => {
const result = validateUUID('not-a-uuid')
expect(result.isValid).toBe(false)
expect(result.error).toContain('valid UUID')
})
it.concurrent('should reject UUID with wrong version', () => {
const result = validateUUID('550e8400-e29b-31d4-a716-446655440000') // version 3
expect(result.isValid).toBe(false)
})
it.concurrent('should reject UUID with wrong variant', () => {
const result = validateUUID('550e8400-e29b-41d4-1716-446655440000') // wrong variant
expect(result.isValid).toBe(false)
})
it.concurrent('should reject empty string', () => {
const result = validateUUID('')
expect(result.isValid).toBe(false)
})
it.concurrent('should reject null', () => {
const result = validateUUID(null)
expect(result.isValid).toBe(false)
})
})
})
describe('validateAlphanumericId', () => {
it.concurrent('should accept alphanumeric IDs', () => {
const result = validateAlphanumericId('user123')

View File

@@ -171,46 +171,6 @@ export function validatePathSegment(
return { isValid: true, sanitized: value }
}
/**
* Validates a UUID (v4 format)
*
* @param value - The UUID to validate
* @param paramName - Name of the parameter for error messages
* @returns ValidationResult
*
* @example
* ```typescript
* const result = validateUUID(workflowId, 'workflowId')
* if (!result.isValid) {
* return NextResponse.json({ error: result.error }, { status: 400 })
* }
* ```
*/
export function validateUUID(
value: string | null | undefined,
paramName = 'UUID'
): ValidationResult {
if (value === null || value === undefined || value === '') {
return {
isValid: false,
error: `${paramName} is required`,
}
}
// UUID v4 pattern
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
if (!uuidPattern.test(value)) {
logger.warn('Invalid UUID format', { paramName, value: value.substring(0, 50) })
return {
isValid: false,
error: `${paramName} must be a valid UUID`,
}
}
return { isValid: true, sanitized: value.toLowerCase() }
}
/**
* Validates an alphanumeric ID (letters, numbers, hyphens, underscores only)
*

View File

@@ -1,8 +1,6 @@
import { describe, expect, test } from 'vitest'
import {
buildTraceSpans,
stripCustomToolPrefix,
} from '@/lib/logs/execution/trace-spans/trace-spans'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import { stripCustomToolPrefix } from '@/executor/constants'
import type { ExecutionResult } from '@/executor/types'
describe('buildTraceSpans', () => {

View File

@@ -1,6 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { ToolCall, TraceSpan } from '@/lib/logs/types'
import { isWorkflowBlockType } from '@/executor/constants'
import { isWorkflowBlockType, stripCustomToolPrefix } from '@/executor/constants'
import type { ExecutionResult } from '@/executor/types'
const logger = createLogger('TraceSpans')
@@ -769,7 +769,3 @@ function ensureNestedWorkflowsProcessed(span: TraceSpan): TraceSpan {
return processedSpan
}
export function stripCustomToolPrefix(name: string) {
return name.startsWith('custom_') ? name.replace('custom_', '') : name
}

View File

@@ -25,6 +25,8 @@ import type {
McpTransport,
} from '@/lib/mcp/types'
import { MCP_CONSTANTS } from '@/lib/mcp/utils'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
const logger = createLogger('McpService')
@@ -49,14 +51,17 @@ class McpService {
* Resolve environment variables in strings
*/
private resolveEnvVars(value: string, envVars: Record<string, string>): string {
const envMatches = value.match(/\{\{([^}]+)\}\}/g)
const envVarPattern = createEnvVarPattern()
const envMatches = value.match(envVarPattern)
if (!envMatches) return value
let resolvedValue = value
const missingVars: string[] = []
for (const match of envMatches) {
const envKey = match.slice(2, -2).trim()
const envKey = match
.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length)
.trim()
const envValue = envVars[envKey]
if (envValue === undefined) {

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import type { McpApiResponse } from '@/lib/mcp/types'
import { isMcpTool, MCP } from '@/executor/constants'
/**
* MCP-specific constants
@@ -124,7 +125,7 @@ export function categorizeError(error: unknown): { message: string; status: numb
* Create standardized MCP tool ID from server ID and tool name
*/
export function createMcpToolId(serverId: string, toolName: string): string {
const normalizedServerId = serverId.startsWith('mcp-') ? serverId : `mcp-${serverId}`
const normalizedServerId = isMcpTool(serverId) ? serverId : `${MCP.TOOL_PREFIX}${serverId}`
return `${normalizedServerId}-${toolName}`
}

View File

@@ -1,3 +1,4 @@
import { isUuid, sanitizeFileName } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
/**
@@ -15,7 +16,7 @@ export interface ExecutionContext {
*/
export function generateExecutionFileKey(context: ExecutionContext, fileName: string): string {
const { workspaceId, workflowId, executionId } = context
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const safeFileName = sanitizeFileName(fileName)
return `execution/${workspaceId}/${workflowId}/${executionId}/${safeFileName}`
}
@@ -26,17 +27,7 @@ export function generateFileId(): string {
return `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
}
/**
* UUID pattern for validating execution context IDs
*/
const UUID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
/**
* Check if a string matches UUID pattern
*/
export function isUuid(str: string): boolean {
return UUID_PATTERN.test(str)
}
export { isUuid }
/**
* Check if a key matches execution file pattern

View File

@@ -19,6 +19,7 @@ import {
uploadFile,
} from '@/lib/uploads/core/storage-service'
import { getFileMetadataByKey, insertFileMetadata } from '@/lib/uploads/server/metadata'
import { isUuid, sanitizeFileName } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
const logger = createLogger('WorkspaceFileStorage')
@@ -36,11 +37,6 @@ export interface WorkspaceFileRecord {
uploadedAt: Date
}
/**
* UUID pattern for validating workspace IDs
*/
const UUID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
/**
* Workspace file key pattern: workspace/{workspaceId}/{timestamp}-{random}-{filename}
*/
@@ -73,7 +69,7 @@ export function parseWorkspaceFileKey(key: string): string | null {
}
const workspaceId = match[1]
return UUID_PATTERN.test(workspaceId) ? workspaceId : null
return isUuid(workspaceId) ? workspaceId : null
}
/**
@@ -83,7 +79,7 @@ export function parseWorkspaceFileKey(key: string): string | null {
export function generateWorkspaceFileKey(workspaceId: string, fileName: string): string {
const timestamp = Date.now()
const random = Math.random().toString(36).substring(2, 9)
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const safeFileName = sanitizeFileName(fileName)
return `workspace/${workspaceId}/${timestamp}-${random}-${safeFileName}`
}

View File

@@ -8,6 +8,7 @@ import type {
} from '@/lib/uploads/providers/blob/types'
import type { FileInfo } from '@/lib/uploads/shared/types'
import { sanitizeStorageMetadata } from '@/lib/uploads/utils/file-utils'
import { sanitizeFileName } from '@/executor/constants'
type BlobServiceClientInstance = Awaited<
ReturnType<typeof import('@azure/storage-blob').BlobServiceClient.fromConnectionString>
@@ -79,7 +80,7 @@ export async function uploadToBlob(
shouldPreserveKey = preserveKey ?? false
}
const safeFileName = fileName.replace(/\s+/g, '-') // Replace spaces with hyphens
const safeFileName = sanitizeFileName(fileName)
const uniqueKey = shouldPreserveKey ? fileName : `${Date.now()}-${safeFileName}`
const blobServiceClient = await getBlobServiceClient()
@@ -357,7 +358,7 @@ export async function initiateMultipartUpload(
containerName = BLOB_CONFIG.containerName
}
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const safeFileName = sanitizeFileName(fileName)
const { v4: uuidv4 } = await import('uuid')
const uniqueKey = `kb/${uuidv4()}-${safeFileName}`

View File

@@ -22,6 +22,7 @@ import {
sanitizeFilenameForMetadata,
sanitizeStorageMetadata,
} from '@/lib/uploads/utils/file-utils'
import { sanitizeFileName } from '@/executor/constants'
let _s3Client: S3Client | null = null
@@ -84,7 +85,7 @@ export async function uploadToS3(
shouldSkipTimestamp = skipTimestampPrefix ?? false
}
const safeFileName = fileName.replace(/\s+/g, '-') // Replace spaces with hyphens
const safeFileName = sanitizeFileName(fileName)
const uniqueKey = shouldSkipTimestamp ? fileName : `${Date.now()}-${safeFileName}`
const s3Client = getS3Client()
@@ -223,7 +224,7 @@ export async function initiateS3MultipartUpload(
const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region }
const s3Client = getS3Client()
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const safeFileName = sanitizeFileName(fileName)
const { v4: uuidv4 } = await import('uuid')
const uniqueKey = `kb/${uuidv4()}-${safeFileName}`

View File

@@ -1,6 +1,7 @@
import type { Logger } from '@/lib/logs/console/logger'
import type { StorageContext } from '@/lib/uploads'
import { ACCEPTED_FILE_TYPES, SUPPORTED_DOCUMENT_EXTENSIONS } from '@/lib/uploads/utils/validation'
import { isUuid } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
export interface FileAttachment {
@@ -625,11 +626,9 @@ export function extractCleanFilename(urlOrPath: string): string {
export function extractWorkspaceIdFromExecutionKey(key: string): string | null {
const segments = key.split('/')
const UUID_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
if (segments[0] === 'execution' && segments.length >= 5) {
const workspaceId = segments[1]
if (workspaceId && UUID_PATTERN.test(workspaceId)) {
if (workspaceId && isUuid(workspaceId)) {
return workspaceId
}
}

View File

@@ -14,6 +14,8 @@ import {
verifyProviderWebhook,
} from '@/lib/webhooks/utils.server'
import { executeWebhookJob } from '@/background/webhook-execution'
import { REFERENCE } from '@/executor/constants'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
const logger = createLogger('WebhookProcessor')
@@ -170,12 +172,13 @@ export async function findWebhookAndWorkflow(
* @returns String with all {{VARIABLE}} references replaced
*/
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
const envMatches = value.match(/\{\{([^}]+)\}\}/g)
const envVarPattern = createEnvVarPattern()
const envMatches = value.match(envVarPattern)
if (!envMatches) return value
let resolvedValue = value
for (const match of envMatches) {
const envKey = match.slice(2, -2).trim()
const envKey = match.slice(REFERENCE.ENV_VAR_START.length, -REFERENCE.ENV_VAR_END.length).trim()
const envValue = envVars[envKey]
if (envValue !== undefined) {
resolvedValue = resolvedValue.replaceAll(match, envValue)

View File

@@ -11,6 +11,7 @@ import {
prepareBlockMetrics,
} from '@/lib/workflows/autolayout/utils'
import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { EDGE } from '@/executor/constants'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayout:Core')
@@ -31,8 +32,8 @@ function getSourceHandleYOffset(block: BlockState, sourceHandle?: string | null)
return HANDLE_POSITIONS.SUBFLOW_START_Y_OFFSET
}
if (block.type === 'condition' && sourceHandle?.startsWith('condition-')) {
const conditionId = sourceHandle.replace('condition-', '')
if (block.type === 'condition' && sourceHandle?.startsWith(EDGE.CONDITION_PREFIX)) {
const conditionId = sourceHandle.replace(EDGE.CONDITION_PREFIX, '')
try {
const conditionsValue = block.subBlocks?.conditions?.value
if (typeof conditionsValue === 'string' && conditionsValue) {

View File

@@ -97,41 +97,4 @@ export class BlockPathCalculator {
return accessibleMap
}
/**
* Gets accessible block names for a specific block (for error messages).
*
* @param blockId - The block ID to get accessible names for
* @param workflow - The serialized workflow
* @param accessibleBlocksMap - Pre-calculated accessible blocks map
* @returns Array of accessible block names and aliases
*/
static getAccessibleBlockNames(
blockId: string,
workflow: SerializedWorkflow,
accessibleBlocksMap: Map<string, Set<string>>
): string[] {
const accessibleBlockIds = accessibleBlocksMap.get(blockId) || new Set<string>()
const names: string[] = []
// Create a map of block IDs to blocks for efficient lookup
const blockById = new Map(workflow.blocks.map((block) => [block.id, block]))
for (const accessibleBlockId of accessibleBlockIds) {
const block = blockById.get(accessibleBlockId)
if (block) {
// Add both the actual name and the normalized name
if (block.metadata?.name) {
names.push(block.metadata.name)
names.push(block.metadata.name.toLowerCase().replace(/\s+/g, ''))
}
names.push(accessibleBlockId)
}
}
// Add special aliases
names.push('start') // Always allow start alias
return [...new Set(names)] // Remove duplicates
}
}

View File

@@ -16,8 +16,10 @@ import {
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { Executor } from '@/executor'
import { REFERENCE } from '@/executor/constants'
import type { ExecutionCallbacks, ExecutionSnapshot } from '@/executor/execution/snapshot'
import type { ExecutionResult } from '@/executor/types'
import { createEnvVarPattern } from '@/executor/utils/reference-validation'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
@@ -190,11 +192,19 @@ export async function executeWorkflowCore(
(subAcc, [key, subBlock]) => {
let value = subBlock.value
if (typeof value === 'string' && value.includes('{{') && value.includes('}}')) {
const matches = value.match(/{{([^}]+)}}/g)
if (
typeof value === 'string' &&
value.includes(REFERENCE.ENV_VAR_START) &&
value.includes(REFERENCE.ENV_VAR_END)
) {
const envVarPattern = createEnvVarPattern()
const matches = value.match(envVarPattern)
if (matches) {
for (const match of matches) {
const varName = match.slice(2, -2)
const varName = match.slice(
REFERENCE.ENV_VAR_START.length,
-REFERENCE.ENV_VAR_END.length
)
const decryptedValue = decryptedEnvVars[varName]
if (decryptedValue !== undefined) {
value = (value as string).replace(match, decryptedValue)
@@ -218,7 +228,7 @@ export async function executeWorkflowCore(
(acc, [blockId, blockState]) => {
if (blockState.responseFormat && typeof blockState.responseFormat === 'string') {
const responseFormatValue = blockState.responseFormat.trim()
if (responseFormatValue && !responseFormatValue.startsWith('<')) {
if (responseFormatValue && !responseFormatValue.startsWith(REFERENCE.START)) {
try {
acc[blockId] = {
...blockState,

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import { resolveStartCandidates, StartBlockPath } from '@/lib/workflows/triggers/triggers'
import { normalizeName, startsWithUuid } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -74,13 +75,11 @@ export function getInputFormatExample(
// Add streaming parameters if enabled and outputs are selected
if (includeStreaming && selectedStreamingOutputs.length > 0) {
exampleData.stream = true
// Convert blockId_attribute format to blockName.attribute format for display
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
const convertedOutputs = selectedStreamingOutputs
.map((outputId) => {
// If it starts with a UUID, convert to blockName.attribute format
if (UUID_REGEX.test(outputId)) {
if (startsWithUuid(outputId)) {
const underscoreIndex = outputId.indexOf('_')
if (underscoreIndex === -1) return null
@@ -90,9 +89,7 @@ export function getInputFormatExample(
// Find the block by ID and get its name
const block = blocks.find((b) => b.id === blockId)
if (block?.name) {
// Normalize block name: lowercase and remove spaces
const normalizedBlockName = block.name.toLowerCase().replace(/\s+/g, '')
return `${normalizedBlockName}.${attribute}`
return `${normalizeName(block.name)}.${attribute}`
}
// Block not found (deleted), return null to filter out
return null
@@ -104,7 +101,7 @@ export function getInputFormatExample(
const blockName = parts[0]
// Check if a block with this name exists
const block = blocks.find(
(b) => b.name?.toLowerCase().replace(/\s+/g, '') === blockName.toLowerCase()
(b) => b.name && normalizeName(b.name) === normalizeName(blockName)
)
if (!block) {
// Block not found (deleted), return null to filter out

View File

@@ -1,4 +1,4 @@
import { normalizeName } from '@/stores/workflows/utils'
import { normalizeName, REFERENCE } from '@/executor/constants'
export const SYSTEM_REFERENCE_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
@@ -9,11 +9,11 @@ const LEADING_REFERENCE_PATTERN = /^[<>=!\s]*$/
export function splitReferenceSegment(
segment: string
): { leading: string; reference: string } | null {
if (!segment.startsWith('<') || !segment.endsWith('>')) {
if (!segment.startsWith(REFERENCE.START) || !segment.endsWith(REFERENCE.END)) {
return null
}
const lastOpenBracket = segment.lastIndexOf('<')
const lastOpenBracket = segment.lastIndexOf(REFERENCE.START)
if (lastOpenBracket === -1) {
return null
}
@@ -21,7 +21,7 @@ export function splitReferenceSegment(
const leading = lastOpenBracket > 0 ? segment.slice(0, lastOpenBracket) : ''
const reference = segment.slice(lastOpenBracket)
if (!reference.startsWith('<') || !reference.endsWith('>')) {
if (!reference.startsWith(REFERENCE.START) || !reference.endsWith(REFERENCE.END)) {
return null
}
@@ -40,7 +40,7 @@ export function isLikelyReferenceSegment(segment: string): boolean {
return false
}
const inner = reference.slice(1, -1)
const inner = reference.slice(REFERENCE.START.length, -REFERENCE.END.length)
if (!inner) {
return false
@@ -58,10 +58,10 @@ export function isLikelyReferenceSegment(segment: string): boolean {
return false
}
if (inner.includes('.')) {
const dotIndex = inner.indexOf('.')
if (inner.includes(REFERENCE.PATH_DELIMITER)) {
const dotIndex = inner.indexOf(REFERENCE.PATH_DELIMITER)
const beforeDot = inner.substring(0, dotIndex)
const afterDot = inner.substring(dotIndex + 1)
const afterDot = inner.substring(dotIndex + REFERENCE.PATH_DELIMITER.length)
if (afterDot.includes(' ')) {
return false
@@ -82,7 +82,8 @@ export function extractReferencePrefixes(value: string): Array<{ raw: string; pr
return []
}
const matches = value.match(/<[^>]+>/g)
const referencePattern = new RegExp(`${REFERENCE.START}[^${REFERENCE.END}]+${REFERENCE.END}`, 'g')
const matches = value.match(referencePattern)
if (!matches) {
return []
}
@@ -105,8 +106,8 @@ export function extractReferencePrefixes(value: string): Array<{ raw: string; pr
continue
}
const inner = referenceSegment.slice(1, -1)
const [rawPrefix] = inner.split('.')
const inner = referenceSegment.slice(REFERENCE.START.length, -REFERENCE.END.length)
const [rawPrefix] = inner.split(REFERENCE.PATH_DELIMITER)
if (!rawPrefix) {
continue
}

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@/lib/logs/console/logger'
import { getBlock } from '@/blocks/registry'
import { isCustomTool, isMcpTool } from '@/executor/constants'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { getTool } from '@/tools/utils'
@@ -299,11 +300,7 @@ export function validateToolReference(
): string | null {
if (!toolId) return null
// Check if it's a custom tool or MCP tool
const isCustomTool = toolId.startsWith('custom_')
const isMcpTool = toolId.startsWith('mcp-')
if (!isCustomTool && !isMcpTool) {
if (!isCustomTool(toolId) && !isMcpTool(toolId)) {
// For built-in tools, verify they exist
const tool = getTool(toolId)
if (!tool) {

View File

@@ -458,10 +458,6 @@ export function hasWorkflowChanged(
return false
}
export function stripCustomToolPrefix(name: string) {
return name.startsWith('custom_') ? name.replace('custom_', '') : name
}
export const workflowHasResponseBlock = (executionResult: ExecutionResult): boolean => {
if (
!executionResult?.logs ||

View File

@@ -3,6 +3,7 @@ import type { CompletionUsage } from 'openai/resources/completions'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { createLogger, type Logger } from '@/lib/logs/console/logger'
import { isCustomTool } from '@/executor/constants'
import {
getComputerUseModels,
getEmbeddingModelPricing,
@@ -431,7 +432,7 @@ export async function transformBlockTool(
let toolConfig: any
if (toolId.startsWith('custom_') && getToolAsync) {
if (isCustomTool(toolId) && getToolAsync) {
toolConfig = await getToolAsync(toolId)
} else {
toolConfig = getTool(toolId)

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { REFERENCE } from '@/executor/constants'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
@@ -351,7 +352,7 @@ export class Serializer {
const trimmedValue = responseFormat.trim()
// Check for variable references like <start.input>
if (trimmedValue.startsWith('<') && trimmedValue.includes('>')) {
if (trimmedValue.startsWith(REFERENCE.START) && trimmedValue.includes(REFERENCE.END)) {
// Keep variable references as-is
return trimmedValue
}

View File

@@ -11,6 +11,7 @@ import type {
} from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
const logger = createLogger('VariablesModalStore')
@@ -303,8 +304,8 @@ export const useVariablesStore = create<VariablesStore>()(
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
Object.entries(blockValues as Record<string, any>).forEach(
([subBlockId, value]) => {
const oldVarName = oldVariableName.replace(/\s+/g, '').toLowerCase()
const newVarName = newName.replace(/\s+/g, '').toLowerCase()
const oldVarName = normalizeName(oldVariableName)
const newVarName = normalizeName(newName)
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
updatedWorkflowValues[blockId][subBlockId] = updateReferences(

View File

@@ -1,17 +1,7 @@
// DEPRECATED: useEnvironmentStore import removed as autofill functions were removed
/**
* Checks if a value is an environment variable reference in the format {{ENV_VAR}}
* Re-exports env var utilities from executor constants for backward compatibility
*/
export const isEnvVarReference = (value: string): boolean => {
// Check if the value looks like {{ENV_VAR}}
return /^\{\{[a-zA-Z0-9_-]+\}\}$/.test(value)
}
/**
* Extracts the environment variable name from a reference like {{ENV_VAR}}
*/
export const extractEnvVarName = (value: string): string | null => {
if (!isEnvVarReference(value)) return null
return value.slice(2, -2)
}
export {
extractEnvVarName,
isEnvVarReference,
} from '@/executor/constants'

View File

@@ -1,15 +1,8 @@
import { normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
/**
* Normalizes a name for comparison by converting to lowercase and removing spaces.
* Used for both block names and variable names to ensure consistent matching.
* @param name - The name to normalize
* @returns The normalized name
*/
export function normalizeName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '')
}
export { normalizeName }
/**
* Generates a unique block name by finding the highest number suffix among existing blocks

View File

@@ -594,18 +594,18 @@ describe('workflow store', () => {
}
)
it.concurrent('should handle edge cases with empty or whitespace-only names', () => {
it.concurrent('should reject empty or whitespace-only names', () => {
const { updateBlockName } = useWorkflowStore.getState()
const result1 = updateBlockName('block1', '')
expect(result1.success).toBe(true)
expect(result1.success).toBe(false)
const result2 = updateBlockName('block2', ' ')
expect(result2.success).toBe(true)
expect(result2.success).toBe(false)
const state = useWorkflowStore.getState()
expect(state.blocks.block1.name).toBe('')
expect(state.blocks.block2.name).toBe(' ')
expect(state.blocks.block1.name).toBe('column ad')
expect(state.blocks.block2.name).toBe('Employee Length')
})
it.concurrent('should return false when trying to rename a non-existent block', () => {

View File

@@ -671,23 +671,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
const oldBlock = get().blocks[id]
if (!oldBlock) return { success: false, changedSubblocks: [] }
// Check for normalized name collisions
const normalizedNewName = normalizeName(name)
const currentBlocks = get().blocks
// Find any other block with the same normalized name
const conflictingBlock = Object.entries(currentBlocks).find(([blockId, block]) => {
return (
blockId !== id && // Different block
block.name && // Has a name
normalizeName(block.name) === normalizedNewName // Same normalized name
)
})
if (!normalizedNewName) {
logger.error(`Cannot rename block to empty name`)
return { success: false, changedSubblocks: [] }
}
const currentBlocks = get().blocks
const conflictingBlock = Object.entries(currentBlocks).find(
([blockId, block]) => blockId !== id && normalizeName(block.name) === normalizedNewName
)
if (conflictingBlock) {
// Don't allow the rename - another block already uses this normalized name
logger.error(
`Cannot rename block to "${name}" - another block "${conflictingBlock[1].name}" already uses the normalized name "${normalizedNewName}"`
`Cannot rename block to "${name}" - conflicts with "${conflictingBlock[1].name}"`
)
return { success: false, changedSubblocks: [] }
}
@@ -723,8 +721,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
// Loop through subblocks and update references
Object.entries(blockValues).forEach(([subBlockId, value]) => {
const oldBlockName = oldBlock.name.replace(/\s+/g, '').toLowerCase()
const newBlockName = name.replace(/\s+/g, '').toLowerCase()
const oldBlockName = normalizeName(oldBlock.name)
const newBlockName = normalizeName(name)
const regex = new RegExp(`<${oldBlockName}\\.`, 'g')
// Use a recursive function to handle all object types

View File

@@ -2,6 +2,7 @@ import { load as yamlParse } from 'js-yaml'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import {
type ConnectionsFormat,
expandConditionInputs,
@@ -172,6 +173,34 @@ function validateBlockTypes(yamlWorkflow: YamlWorkflow): { errors: string[]; war
return { errors, warnings }
}
/**
* Validates block names are non-empty and unique (by normalized name).
*/
function validateBlockNames(blocks: Record<string, YamlBlock>): string[] {
const errors: string[] = []
const seen = new Map<string, string>()
for (const [blockId, block] of Object.entries(blocks)) {
const normalized = normalizeName(block.name)
if (!normalized) {
errors.push(`Block "${blockId}" has empty name`)
continue
}
const existingBlockId = seen.get(normalized)
if (existingBlockId) {
errors.push(
`Block "${blockId}" has same name as "${existingBlockId}" (normalized: "${normalized}")`
)
} else {
seen.set(normalized, blockId)
}
}
return errors
}
/**
* Calculate positions for blocks based on their connections
* Uses a simple layered approach similar to the auto-layout algorithm
@@ -334,18 +363,19 @@ export function convertYamlToWorkflow(yamlWorkflow: YamlWorkflow): ImportResult
errors.push(...typeErrors)
warnings.push(...typeWarnings)
// Validate block names (non-empty and unique)
const nameErrors = validateBlockNames(yamlWorkflow.blocks)
errors.push(...nameErrors)
if (errors.length > 0) {
return { blocks: [], edges: [], errors, warnings }
}
// Calculate positions
const positions = calculateBlockPositions(yamlWorkflow)
// Convert blocks
Object.entries(yamlWorkflow.blocks).forEach(([blockId, yamlBlock]) => {
const position = positions[blockId] || { x: 100, y: 100 }
// Expand condition inputs from clean format to internal format
const processedInputs =
yamlBlock.type === 'condition'
? expandConditionInputs(blockId, yamlBlock.inputs || {})

View File

@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { EDGE } from '@/executor/constants'
const logger = createLogger('YamlParsingUtils')
@@ -110,7 +111,7 @@ export function generateBlockConnections(
successTargets.push(edge.target)
} else if (handle === 'error') {
errorTargets.push(edge.target)
} else if (handle.startsWith('condition-')) {
} else if (handle.startsWith(EDGE.CONDITION_PREFIX)) {
const rawConditionId = extractConditionId(handle)
rawConditionIds.push(rawConditionId)
@@ -671,22 +672,22 @@ function createConditionHandle(blockId: string, conditionId: string, blockType?:
if (blockType === 'condition') {
// Map semantic condition IDs to the internal format the system expects
const actualConditionId = `${blockId}-${conditionId}`
return `condition-${actualConditionId}`
return `${EDGE.CONDITION_PREFIX}${actualConditionId}`
}
// For other blocks that might have conditions, use a more explicit format
return `condition-${blockId}-${conditionId}`
return `${EDGE.CONDITION_PREFIX}${blockId}-${conditionId}`
}
function extractConditionId(sourceHandle: string): string {
// Extract condition ID from handle like "condition-blockId-semantic-key"
// Example: "condition-e23e6318-bcdc-4572-a76b-5015e3950121-else-if-1752111795510"
if (!sourceHandle.startsWith('condition-')) {
if (!sourceHandle.startsWith(EDGE.CONDITION_PREFIX)) {
return sourceHandle
}
// Remove "condition-" prefix
const withoutPrefix = sourceHandle.substring('condition-'.length)
// Remove condition prefix
const withoutPrefix = sourceHandle.substring(EDGE.CONDITION_PREFIX.length)
// Special case: check if this ends with "-else" (the auto-added else condition)
if (withoutPrefix.endsWith('-else')) {

View File

@@ -3,6 +3,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { parseMcpToolId } from '@/lib/mcp/utils'
import { isCustomTool, isMcpTool } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
import type { ErrorInfo } from '@/tools/error-extractors'
import { extractErrorMessage } from '@/tools/error-extractors'
@@ -210,13 +211,13 @@ export async function executeTool(
const normalizedToolId = normalizeToolId(toolId)
// If it's a custom tool, use the async version with workflowId
if (normalizedToolId.startsWith('custom_')) {
if (isCustomTool(normalizedToolId)) {
const workflowId = params._context?.workflowId
tool = await getToolAsync(normalizedToolId, workflowId)
if (!tool) {
logger.error(`[${requestId}] Custom tool not found: ${normalizedToolId}`)
}
} else if (normalizedToolId.startsWith('mcp-')) {
} else if (isMcpTool(normalizedToolId)) {
return await executeMcpTool(
normalizedToolId,
params,
@@ -615,7 +616,7 @@ async function handleInternalRequest(
const fullUrl = fullUrlObj.toString()
if (toolId.startsWith('custom_') && tool.request.body) {
if (isCustomTool(toolId) && tool.request.body) {
const requestBody = tool.request.body(params)
if (
typeof requestBody === 'object' &&

View File

@@ -1,4 +1,5 @@
import type { RedditCommentsParams, RedditCommentsResponse } from '@/tools/reddit/types'
import { normalizeSubreddit } from '@/tools/reddit/utils'
import type { ToolConfig } from '@/tools/types'
export const getCommentsTool: ToolConfig<RedditCommentsParams, RedditCommentsResponse> = {
@@ -108,8 +109,7 @@ export const getCommentsTool: ToolConfig<RedditCommentsParams, RedditCommentsRes
request: {
url: (params: RedditCommentsParams) => {
// Sanitize inputs
const subreddit = params.subreddit.trim().replace(/^r\//, '')
const subreddit = normalizeSubreddit(params.subreddit)
const sort = params.sort || 'confidence'
const limit = Math.min(Math.max(1, params.limit || 50), 100)

View File

@@ -1,4 +1,5 @@
import type { RedditControversialParams, RedditPostsResponse } from '@/tools/reddit/types'
import { normalizeSubreddit } from '@/tools/reddit/utils'
import type { ToolConfig } from '@/tools/types'
export const getControversialTool: ToolConfig<RedditControversialParams, RedditPostsResponse> = {
@@ -72,8 +73,7 @@ export const getControversialTool: ToolConfig<RedditControversialParams, RedditP
request: {
url: (params: RedditControversialParams) => {
// Sanitize inputs
const subreddit = params.subreddit.trim().replace(/^r\//, '')
const subreddit = normalizeSubreddit(params.subreddit)
const limit = Math.min(Math.max(1, params.limit || 10), 100)
// Build URL with appropriate parameters using OAuth endpoint

View File

@@ -1,4 +1,5 @@
import type { RedditPostsParams, RedditPostsResponse } from '@/tools/reddit/types'
import { normalizeSubreddit } from '@/tools/reddit/utils'
import type { ToolConfig } from '@/tools/types'
export const getPostsTool: ToolConfig<RedditPostsParams, RedditPostsResponse> = {
@@ -78,8 +79,7 @@ export const getPostsTool: ToolConfig<RedditPostsParams, RedditPostsResponse> =
request: {
url: (params: RedditPostsParams) => {
// Sanitize inputs
const subreddit = params.subreddit.trim().replace(/^r\//, '')
const subreddit = normalizeSubreddit(params.subreddit)
const sort = params.sort || 'hot'
const limit = Math.min(Math.max(1, params.limit || 10), 100)

View File

@@ -1,4 +1,5 @@
import type { RedditHotPostsResponse, RedditPost } from '@/tools/reddit/types'
import { normalizeSubreddit } from '@/tools/reddit/utils'
import type { ToolConfig } from '@/tools/types'
interface HotPostsParams {
@@ -41,8 +42,7 @@ export const hotPostsTool: ToolConfig<HotPostsParams, RedditHotPostsResponse> =
request: {
url: (params) => {
// Sanitize inputs and enforce limits
const subreddit = params.subreddit.trim().replace(/^r\//, '')
const subreddit = normalizeSubreddit(params.subreddit)
const limit = Math.min(Math.max(1, params.limit || 10), 100)
return `https://oauth.reddit.com/r/${subreddit}/hot?limit=${limit}&raw_json=1`

View File

@@ -1,4 +1,5 @@
import type { RedditPostsResponse, RedditSearchParams } from '@/tools/reddit/types'
import { normalizeSubreddit } from '@/tools/reddit/utils'
import type { ToolConfig } from '@/tools/types'
export const searchTool: ToolConfig<RedditSearchParams, RedditPostsResponse> = {
@@ -85,8 +86,7 @@ export const searchTool: ToolConfig<RedditSearchParams, RedditPostsResponse> = {
request: {
url: (params: RedditSearchParams) => {
// Sanitize inputs
const subreddit = params.subreddit.trim().replace(/^r\//, '')
const subreddit = normalizeSubreddit(params.subreddit)
const sort = params.sort || 'relevance'
const limit = Math.min(Math.max(1, params.limit || 10), 100)
const restrict_sr = params.restrict_sr !== false // Default to true

View File

@@ -1,4 +1,5 @@
import type { RedditSubmitParams, RedditWriteResponse } from '@/tools/reddit/types'
import { normalizeSubreddit } from '@/tools/reddit/utils'
import type { ToolConfig } from '@/tools/types'
export const submitPostTool: ToolConfig<RedditSubmitParams, RedditWriteResponse> = {
@@ -78,8 +79,7 @@ export const submitPostTool: ToolConfig<RedditSubmitParams, RedditWriteResponse>
}
},
body: (params: RedditSubmitParams) => {
// Sanitize subreddit
const subreddit = params.subreddit.trim().replace(/^r\//, '')
const subreddit = normalizeSubreddit(params.subreddit)
// Build form data
const formData = new URLSearchParams({

View File

@@ -1,4 +1,5 @@
import type { RedditSubscribeParams, RedditWriteResponse } from '@/tools/reddit/types'
import { normalizeSubreddit } from '@/tools/reddit/utils'
import type { ToolConfig } from '@/tools/types'
export const subscribeTool: ToolConfig<RedditSubscribeParams, RedditWriteResponse> = {
@@ -53,8 +54,7 @@ export const subscribeTool: ToolConfig<RedditSubscribeParams, RedditWriteRespons
throw new Error('action must be "sub" or "unsub"')
}
// Sanitize subreddit
const subreddit = params.subreddit.trim().replace(/^r\//, '')
const subreddit = normalizeSubreddit(params.subreddit)
const formData = new URLSearchParams({
action: params.action,

View File

@@ -0,0 +1,10 @@
const SUBREDDIT_PREFIX = /^r\//
/**
* Normalizes a subreddit name by removing the 'r/' prefix if present and trimming whitespace.
* @param subreddit - The subreddit name to normalize
* @returns The normalized subreddit name without the 'r/' prefix
*/
export function normalizeSubreddit(subreddit: string): string {
return subreddit.trim().replace(SUBREDDIT_PREFIX, '')
}

View File

@@ -1,5 +1,6 @@
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createLogger } from '@/lib/logs/console/logger'
import { AGENT, isCustomTool } from '@/executor/constants'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import { tools } from '@/tools/registry'
@@ -286,10 +287,10 @@ export function getTool(toolId: string): ToolConfig | undefined {
if (builtInTool) return builtInTool
// Check if it's a custom tool
if (toolId.startsWith('custom_') && typeof window !== 'undefined') {
if (isCustomTool(toolId) && typeof window !== 'undefined') {
// Only try to use the sync version on the client
const customToolsStore = useCustomToolsStore.getState()
const identifier = toolId.replace('custom_', '')
const identifier = toolId.slice(AGENT.CUSTOM_TOOL_PREFIX.length)
// Try to find the tool directly by ID first
let customTool = customToolsStore.getTool(identifier)
@@ -319,7 +320,7 @@ export async function getToolAsync(
if (builtInTool) return builtInTool
// Check if it's a custom tool
if (toolId.startsWith('custom_')) {
if (isCustomTool(toolId)) {
return getCustomTool(toolId, workflowId)
}