diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index e47f2f6d1..f0b635f20 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -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 }) } diff --git a/apps/sim/app/api/copilot/execute-tool/route.ts b/apps/sim/app/api/copilot/execute-tool/route.ts index c83685152..e5cb66095 100644 --- a/apps/sim/app/api/copilot/execute-tool/route.ts +++ b/apps/sim/app/api/copilot/execute-tool/route.ts @@ -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): 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 }) diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 6083a92c7..65b3381a1 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -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 } diff --git a/apps/sim/app/api/files/upload/route.ts b/apps/sim/app/api/files/upload/route.ts index 89d911e89..c23f46ec8 100644 --- a/apps/sim/app/api/files/upload/route.ts +++ b/apps/sim/app/api/files/upload/route.ts @@ -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 = { @@ -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 = { diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index b09fde257..ce42d5e67 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -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 diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts index 840fb4aae..1c4add215 100644 --- a/apps/sim/app/api/mcp/servers/test-connection/route.ts +++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts @@ -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 { - 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) { diff --git a/apps/sim/app/api/tools/google_calendar/calendars/route.ts b/apps/sim/app/api/tools/google_calendar/calendars/route.ts index 7fc17db6e..77b6291bf 100644 --- a/apps/sim/app/api/tools/google_calendar/calendars/route.ts +++ b/apps/sim/app/api/tools/google_calendar/calendars/route.ts @@ -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) { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index acf3015ab..df35fc3ca 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -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) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index 84b3996fb..7ea3acfd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -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 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts index 64503e03e..caaa59749 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-subflow-editor.ts @@ -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)) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts index b589972e1..55aa01c21 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts index 5671a4caa..95c78a369 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-connections.ts @@ -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 } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx index a3a535e73..e0497d27e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx @@ -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) diff --git a/apps/sim/background/schedule-execution.ts b/apps/sim/background/schedule-execution.ts index 34253bb7b..f0e778f79 100644 --- a/apps/sim/background/schedule-execution.ts +++ b/apps/sim/background/schedule-execution.ts @@ -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`) diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index 99d0b5b16..ecf9e4ddf 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -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, '') +} diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index e6e5e95e9..d0e259533 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -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' }] }, diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 292a154f0..4f70ebdf0 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -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 - } } diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts index abc415948..b0e1c103a 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.test.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts @@ -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', diff --git a/apps/sim/executor/handlers/generic/generic-handler.ts b/apps/sim/executor/handlers/generic/generic-handler.ts index c875ab140..4d23721e3 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.ts @@ -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 ): Promise { - 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}`) diff --git a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts index b94a0a203..5764f59fb 100644 --- a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts +++ b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts @@ -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 } diff --git a/apps/sim/executor/handlers/response/response-handler.ts b/apps/sim/executor/handlers/response/response-handler.ts index 1af1bd9f3..94bcf35e4 100644 --- a/apps/sim/executor/handlers/response/response-handler.ts +++ b/apps/sim/executor/handlers/response/response-handler.ts @@ -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 { diff --git a/apps/sim/executor/utils/block-data.ts b/apps/sim/executor/utils/block-data.ts index 64ae2097a..fc7b26ae3 100644 --- a/apps/sim/executor/utils/block-data.ts +++ b/apps/sim/executor/utils/block-data.ts @@ -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 } } } diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index bcf34d82e..9080faab7 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -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 }) diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index 253498ed3..5bd9fd6e6 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -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 + private nameToBlockId: Map 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( diff --git a/apps/sim/executor/variables/resolvers/parallel.ts b/apps/sim/executor/variables/resolvers/parallel.ts index 78fca1f9f..1f992a023 100644 --- a/apps/sim/executor/variables/resolvers/parallel.ts +++ b/apps/sim/executor/variables/resolvers/parallel.ts @@ -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 [] } diff --git a/apps/sim/executor/variables/resolvers/workflow.ts b/apps/sim/executor/variables/resolvers/workflow.ts index 2e00912df..c2acf26aa 100644 --- a/apps/sim/executor/variables/resolvers/workflow.ts +++ b/apps/sim/executor/variables/resolvers/workflow.ts @@ -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') diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/process-contents.ts index 3bf6cf564..6c362a2d5 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/process-contents.ts @@ -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) { diff --git a/apps/sim/lib/copilot/tools/client/workflow/block-output-utils.ts b/apps/sim/lib/copilot/tools/client/workflow/block-output-utils.ts index a30ca07a5..4916cb770 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/block-output-utils.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/block-output-utils.ts @@ -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 { diff --git a/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts b/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts index b27ebd402..d99ecf94d 100644 --- a/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts +++ b/apps/sim/lib/copilot/tools/client/workflow/get-block-outputs.ts @@ -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') diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index 909f3ee74..d12c554a0 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -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, + 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' diff --git a/apps/sim/lib/core/security/input-validation.test.ts b/apps/sim/lib/core/security/input-validation.test.ts index 616751995..4af48e331 100644 --- a/apps/sim/lib/core/security/input-validation.test.ts +++ b/apps/sim/lib/core/security/input-validation.test.ts @@ -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') diff --git a/apps/sim/lib/core/security/input-validation.ts b/apps/sim/lib/core/security/input-validation.ts index e84c7f8f4..8fd18e533 100644 --- a/apps/sim/lib/core/security/input-validation.ts +++ b/apps/sim/lib/core/security/input-validation.ts @@ -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) * diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts index 829f8cad6..c92507ecf 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.test.ts @@ -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', () => { diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index 0f35e1d8d..da0207729 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -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 -} diff --git a/apps/sim/lib/mcp/service.ts b/apps/sim/lib/mcp/service.ts index 1e95dd706..3626c0412 100644 --- a/apps/sim/lib/mcp/service.ts +++ b/apps/sim/lib/mcp/service.ts @@ -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 { - 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) { diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index eee16742f..1a446b675 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -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}` } diff --git a/apps/sim/lib/uploads/contexts/execution/utils.ts b/apps/sim/lib/uploads/contexts/execution/utils.ts index 9e36ec72e..ab4b98aad 100644 --- a/apps/sim/lib/uploads/contexts/execution/utils.ts +++ b/apps/sim/lib/uploads/contexts/execution/utils.ts @@ -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 diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index fc4cf1f29..d4a0d17a0 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -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}` } diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index 32751e232..0b2cc89d1 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -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 @@ -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}` diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index 800477b1a..1a6e27e2e 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -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}` diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 69042f7ef..623c1bf3e 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -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 } } diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 47a84cc33..2873b59fd 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -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 { - 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) diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 745b4865e..1fde83821 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -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) { diff --git a/apps/sim/lib/workflows/blocks/block-path-calculator.ts b/apps/sim/lib/workflows/blocks/block-path-calculator.ts index 939e47468..a67094480 100644 --- a/apps/sim/lib/workflows/blocks/block-path-calculator.ts +++ b/apps/sim/lib/workflows/blocks/block-path-calculator.ts @@ -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[] { - const accessibleBlockIds = accessibleBlocksMap.get(blockId) || new Set() - 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 - } } diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 11dc2a1f4..0607d01ac 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -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, diff --git a/apps/sim/lib/workflows/operations/deployment-utils.ts b/apps/sim/lib/workflows/operations/deployment-utils.ts index f46238bad..b5fcc3514 100644 --- a/apps/sim/lib/workflows/operations/deployment-utils.ts +++ b/apps/sim/lib/workflows/operations/deployment-utils.ts @@ -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 diff --git a/apps/sim/lib/workflows/sanitization/references.ts b/apps/sim/lib/workflows/sanitization/references.ts index 8dc978aec..2290f150f 100644 --- a/apps/sim/lib/workflows/sanitization/references.ts +++ b/apps/sim/lib/workflows/sanitization/references.ts @@ -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 } diff --git a/apps/sim/lib/workflows/sanitization/validation.ts b/apps/sim/lib/workflows/sanitization/validation.ts index 9423b0113..7519383d4 100644 --- a/apps/sim/lib/workflows/sanitization/validation.ts +++ b/apps/sim/lib/workflows/sanitization/validation.ts @@ -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) { diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 4c69e23c1..21036588e 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -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 || diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 9344d3971..33485ac56 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -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) diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index 6500f7fb2..1e0425179 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -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 - if (trimmedValue.startsWith('<') && trimmedValue.includes('>')) { + if (trimmedValue.startsWith(REFERENCE.START) && trimmedValue.includes(REFERENCE.END)) { // Keep variable references as-is return trimmedValue } diff --git a/apps/sim/stores/variables/store.ts b/apps/sim/stores/variables/store.ts index aa3c54e01..c4bcb89ff 100644 --- a/apps/sim/stores/variables/store.ts +++ b/apps/sim/stores/variables/store.ts @@ -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()( Object.entries(workflowValues).forEach(([blockId, blockValues]) => { Object.entries(blockValues as Record).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(``, 'gi') updatedWorkflowValues[blockId][subBlockId] = updateReferences( diff --git a/apps/sim/stores/workflows/subblock/utils.ts b/apps/sim/stores/workflows/subblock/utils.ts index 578ebe141..b95b050d8 100644 --- a/apps/sim/stores/workflows/subblock/utils.ts +++ b/apps/sim/stores/workflows/subblock/utils.ts @@ -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' diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index e7abd5603..a0da2e3e7 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -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 diff --git a/apps/sim/stores/workflows/workflow/store.test.ts b/apps/sim/stores/workflows/workflow/store.test.ts index 1f18f8110..b8c5cd9ef 100644 --- a/apps/sim/stores/workflows/workflow/store.test.ts +++ b/apps/sim/stores/workflows/workflow/store.test.ts @@ -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', () => { diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index cc5f41292..9504bf7c4 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -671,23 +671,21 @@ export const useWorkflowStore = create()( 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()( // 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 diff --git a/apps/sim/stores/workflows/yaml/importer.ts b/apps/sim/stores/workflows/yaml/importer.ts index dc2a6b5fd..fe9260a15 100644 --- a/apps/sim/stores/workflows/yaml/importer.ts +++ b/apps/sim/stores/workflows/yaml/importer.ts @@ -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[] { + const errors: string[] = [] + const seen = new Map() + + 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 || {}) diff --git a/apps/sim/stores/workflows/yaml/parsing-utils.ts b/apps/sim/stores/workflows/yaml/parsing-utils.ts index db97b0cee..a88e406e6 100644 --- a/apps/sim/stores/workflows/yaml/parsing-utils.ts +++ b/apps/sim/stores/workflows/yaml/parsing-utils.ts @@ -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')) { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index b4898a686..930e4e189 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -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' && diff --git a/apps/sim/tools/reddit/get_comments.ts b/apps/sim/tools/reddit/get_comments.ts index 045029318..63cf66277 100644 --- a/apps/sim/tools/reddit/get_comments.ts +++ b/apps/sim/tools/reddit/get_comments.ts @@ -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 = { @@ -108,8 +109,7 @@ export const getCommentsTool: ToolConfig { - // 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) diff --git a/apps/sim/tools/reddit/get_controversial.ts b/apps/sim/tools/reddit/get_controversial.ts index 9e69b37e9..8729fad64 100644 --- a/apps/sim/tools/reddit/get_controversial.ts +++ b/apps/sim/tools/reddit/get_controversial.ts @@ -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 = { @@ -72,8 +73,7 @@ export const getControversialTool: ToolConfig { - // 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 diff --git a/apps/sim/tools/reddit/get_posts.ts b/apps/sim/tools/reddit/get_posts.ts index bece117bb..36179b741 100644 --- a/apps/sim/tools/reddit/get_posts.ts +++ b/apps/sim/tools/reddit/get_posts.ts @@ -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 = { @@ -78,8 +79,7 @@ export const getPostsTool: ToolConfig = 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) diff --git a/apps/sim/tools/reddit/hot_posts.ts b/apps/sim/tools/reddit/hot_posts.ts index 9465f2739..79e27248a 100644 --- a/apps/sim/tools/reddit/hot_posts.ts +++ b/apps/sim/tools/reddit/hot_posts.ts @@ -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 = 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` diff --git a/apps/sim/tools/reddit/search.ts b/apps/sim/tools/reddit/search.ts index f5a53c163..05f6c1361 100644 --- a/apps/sim/tools/reddit/search.ts +++ b/apps/sim/tools/reddit/search.ts @@ -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 = { @@ -85,8 +86,7 @@ export const searchTool: ToolConfig = { 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 diff --git a/apps/sim/tools/reddit/submit_post.ts b/apps/sim/tools/reddit/submit_post.ts index 718bb3a03..24cc71f58 100644 --- a/apps/sim/tools/reddit/submit_post.ts +++ b/apps/sim/tools/reddit/submit_post.ts @@ -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 = { @@ -78,8 +79,7 @@ export const submitPostTool: ToolConfig } }, body: (params: RedditSubmitParams) => { - // Sanitize subreddit - const subreddit = params.subreddit.trim().replace(/^r\//, '') + const subreddit = normalizeSubreddit(params.subreddit) // Build form data const formData = new URLSearchParams({ diff --git a/apps/sim/tools/reddit/subscribe.ts b/apps/sim/tools/reddit/subscribe.ts index c6cad76f6..f15c501fe 100644 --- a/apps/sim/tools/reddit/subscribe.ts +++ b/apps/sim/tools/reddit/subscribe.ts @@ -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 = { @@ -53,8 +54,7 @@ export const subscribeTool: ToolConfig