From 29189e4a0b967d3ded636955211b58f4f9dc31a8 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Feb 2026 14:41:30 -0800 Subject: [PATCH 1/3] feat(execution): workflow cycle detection via X-Sim-Via header --- .../sim/app/api/mcp/serve/[serverId]/route.ts | 11 +- .../app/api/workflows/[id]/execute/route.ts | 21 ++- apps/sim/background/workflow-execution.ts | 2 + apps/sim/executor/execution/executor.ts | 1 + apps/sim/executor/execution/types.ts | 7 + apps/sim/executor/handlers/api/api-handler.ts | 1 + .../handlers/workflow/workflow-handler.ts | 2 + apps/sim/executor/types.ts | 6 + .../execution/__tests__/call-chain.test.ts | 150 ++++++++++++++++++ apps/sim/lib/execution/call-chain.ts | 56 +++++++ .../lib/workflows/executor/execution-core.ts | 1 + apps/sim/tools/http/request.ts | 3 +- apps/sim/tools/http/utils.ts | 9 +- 13 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/execution/__tests__/call-chain.test.ts create mode 100644 apps/sim/lib/execution/call-chain.ts diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 1c694a59af..99f2a83089 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -23,6 +23,7 @@ import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid' import { generateInternalToken } from '@/lib/auth/internal' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkflowMcpServeAPI') @@ -181,7 +182,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise }, executeAuthContext, - server.isPublic ? server.createdBy : undefined + server.isPublic ? server.createdBy : undefined, + request.headers.get(SIM_VIA_HEADER) ) default: @@ -244,7 +246,8 @@ async function handleToolsCall( serverId: string, params: { name: string; arguments?: Record } | undefined, executeAuthContext?: ExecuteAuthContext | null, - publicServerOwnerId?: string + publicServerOwnerId?: string, + simViaHeader?: string | null ): Promise { try { if (!params?.name) { @@ -300,6 +303,10 @@ async function handleToolsCall( } } + if (simViaHeader) { + headers[SIM_VIA_HEADER] = simViaHeader + } + logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`) const response = await fetch(executeUrl, { diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 8620c509e4..ae4c48a460 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -12,6 +12,12 @@ import { import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { getBaseUrl } from '@/lib/core/utils/urls' +import { + buildNextCallChain, + parseCallChain, + SIM_VIA_HEADER, + validateCallChain, +} from '@/lib/execution/call-chain' import { createExecutionEventWriter, setExecutionMeta } from '@/lib/execution/event-buffer' import { processInputFileFields } from '@/lib/execution/files' import { preprocessExecution } from '@/lib/execution/preprocessing' @@ -155,10 +161,11 @@ type AsyncExecutionParams = { input: any triggerType: CoreTriggerType executionId: string + callChain?: string[] } async function handleAsyncExecution(params: AsyncExecutionParams): Promise { - const { requestId, workflowId, userId, input, triggerType, executionId } = params + const { requestId, workflowId, userId, input, triggerType, executionId, callChain } = params const payload: WorkflowExecutionPayload = { workflowId, @@ -166,6 +173,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise + callChain?: string[] } /** @@ -95,6 +96,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { useDraftState: false, startTime: new Date().toISOString(), isClientSession: false, + callChain: payload.callChain, } const snapshot = new ExecutionSnapshot( diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 00609fe273..a888409347 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -330,6 +330,7 @@ export class DAGExecutor { base64MaxBytes: this.contextExtensions.base64MaxBytes, runFromBlockContext: overrides?.runFromBlockContext, stopAfterBlockId: this.contextExtensions.stopAfterBlockId, + callChain: this.contextExtensions.callChain, } if (this.contextExtensions.resumeFromSnapshot) { diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index a0e7b156c0..d3ac877b89 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -27,6 +27,7 @@ export interface ExecutionMetadata { parallels?: Record deploymentVersionId?: string } + callChain?: string[] } export interface SerializableExecutionState { @@ -167,6 +168,12 @@ export interface ContextExtensions { * Stop execution after this block completes. Used for "run until block" feature. */ stopAfterBlockId?: string + + /** + * Ordered list of workflow IDs in the current call chain, used for cycle detection. + * Each hop appends the current workflow ID before making outgoing requests. + */ + callChain?: string[] } export interface WorkflowInput { diff --git a/apps/sim/executor/handlers/api/api-handler.ts b/apps/sim/executor/handlers/api/api-handler.ts index 4d4cacd3fd..04bdea4e23 100644 --- a/apps/sim/executor/handlers/api/api-handler.ts +++ b/apps/sim/executor/handlers/api/api-handler.ts @@ -75,6 +75,7 @@ export class ApiBlockHandler implements BlockHandler { userId: ctx.userId, isDeployedContext: ctx.isDeployedContext, enforceCredentialAccess: ctx.enforceCredentialAccess, + callChain: ctx.callChain, }, }, false, diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 55849dad04..588f9239a4 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { buildNextCallChain } from '@/lib/execution/call-chain' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' @@ -180,6 +181,7 @@ export class WorkflowBlockHandler implements BlockHandler { userId: ctx.userId, executionId: ctx.executionId, abortSignal: ctx.abortSignal, + callChain: buildNextCallChain(ctx.callChain || [], workflowId), ...(shouldPropagateCallbacks && { onBlockStart: ctx.onBlockStart, onBlockComplete: ctx.onBlockComplete, diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 96b359cbf4..b30dba2e1b 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -301,6 +301,12 @@ export interface ExecutionContext { */ stopAfterBlockId?: string + /** + * Ordered list of workflow IDs in the current call chain, used for cycle detection. + * Passed to outgoing HTTP requests via the X-Sim-Via header. + */ + callChain?: string[] + /** * Counter for generating monotonically increasing execution order values. * Starts at 0 and increments for each block. Use getNextExecutionOrder() to access. diff --git a/apps/sim/lib/execution/__tests__/call-chain.test.ts b/apps/sim/lib/execution/__tests__/call-chain.test.ts new file mode 100644 index 0000000000..dc02160c11 --- /dev/null +++ b/apps/sim/lib/execution/__tests__/call-chain.test.ts @@ -0,0 +1,150 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildNextCallChain, + MAX_CALL_CHAIN_DEPTH, + parseCallChain, + SIM_VIA_HEADER, + serializeCallChain, + validateCallChain, +} from '@/lib/execution/call-chain' + +describe('call-chain', () => { + describe('SIM_VIA_HEADER', () => { + it('has the expected header name', () => { + expect(SIM_VIA_HEADER).toBe('X-Sim-Via') + }) + }) + + describe('MAX_CALL_CHAIN_DEPTH', () => { + it('equals 10', () => { + expect(MAX_CALL_CHAIN_DEPTH).toBe(10) + }) + }) + + describe('parseCallChain', () => { + it('returns empty array for null', () => { + expect(parseCallChain(null)).toEqual([]) + }) + + it('returns empty array for undefined', () => { + expect(parseCallChain(undefined)).toEqual([]) + }) + + it('returns empty array for empty string', () => { + expect(parseCallChain('')).toEqual([]) + }) + + it('returns empty array for whitespace-only string', () => { + expect(parseCallChain(' ')).toEqual([]) + }) + + it('parses a single workflow ID', () => { + expect(parseCallChain('wf-abc')).toEqual(['wf-abc']) + }) + + it('parses multiple comma-separated workflow IDs', () => { + expect(parseCallChain('wf-a,wf-b,wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c']) + }) + + it('trims whitespace around workflow IDs', () => { + expect(parseCallChain(' wf-a , wf-b , wf-c ')).toEqual(['wf-a', 'wf-b', 'wf-c']) + }) + + it('filters out empty segments', () => { + expect(parseCallChain('wf-a,,wf-b')).toEqual(['wf-a', 'wf-b']) + }) + }) + + describe('serializeCallChain', () => { + it('serializes an empty array', () => { + expect(serializeCallChain([])).toBe('') + }) + + it('serializes a single ID', () => { + expect(serializeCallChain(['wf-a'])).toBe('wf-a') + }) + + it('serializes multiple IDs with commas', () => { + expect(serializeCallChain(['wf-a', 'wf-b', 'wf-c'])).toBe('wf-a,wf-b,wf-c') + }) + }) + + describe('validateCallChain', () => { + it('returns null for an empty chain', () => { + expect(validateCallChain([], 'wf-a')).toBeNull() + }) + + it('returns null when workflow is not in chain', () => { + expect(validateCallChain(['wf-a', 'wf-b'], 'wf-c')).toBeNull() + }) + + it('detects direct self-call (A → A)', () => { + const error = validateCallChain(['wf-a'], 'wf-a') + expect(error).toContain('Workflow cycle detected') + expect(error).toContain('wf-a → wf-a') + }) + + it('detects indirect cycle (A → B → A)', () => { + const error = validateCallChain(['wf-a', 'wf-b'], 'wf-a') + expect(error).toContain('Workflow cycle detected') + expect(error).toContain('wf-a → wf-b → wf-a') + }) + + it('detects cycle mid-chain (A → B → C → B)', () => { + const error = validateCallChain(['wf-a', 'wf-b', 'wf-c'], 'wf-b') + expect(error).toContain('Workflow cycle detected') + expect(error).toContain('wf-a → wf-b → wf-c → wf-b') + }) + + it('returns depth error when chain is at max depth', () => { + const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`) + const error = validateCallChain(chain, 'wf-new') + expect(error).toContain( + `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded` + ) + }) + + it('allows chain just under max depth', () => { + const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH - 1 }, (_, i) => `wf-${i}`) + expect(validateCallChain(chain, 'wf-new')).toBeNull() + }) + + it('prioritizes cycle detection over depth check', () => { + const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`) + const error = validateCallChain(chain, 'wf-0') + expect(error).toContain('Workflow cycle detected') + }) + }) + + describe('buildNextCallChain', () => { + it('appends workflow ID to empty chain', () => { + expect(buildNextCallChain([], 'wf-a')).toEqual(['wf-a']) + }) + + it('appends workflow ID to existing chain', () => { + expect(buildNextCallChain(['wf-a', 'wf-b'], 'wf-c')).toEqual(['wf-a', 'wf-b', 'wf-c']) + }) + + it('does not mutate the original chain', () => { + const original = ['wf-a'] + const result = buildNextCallChain(original, 'wf-b') + expect(original).toEqual(['wf-a']) + expect(result).toEqual(['wf-a', 'wf-b']) + }) + }) + + describe('round-trip', () => { + it('parse → serialize is identity', () => { + const header = 'wf-a,wf-b,wf-c' + expect(serializeCallChain(parseCallChain(header))).toBe(header) + }) + + it('serialize → parse is identity', () => { + const chain = ['wf-a', 'wf-b', 'wf-c'] + expect(parseCallChain(serializeCallChain(chain))).toEqual(chain) + }) + }) +}) diff --git a/apps/sim/lib/execution/call-chain.ts b/apps/sim/lib/execution/call-chain.ts new file mode 100644 index 0000000000..0e4d01c5b5 --- /dev/null +++ b/apps/sim/lib/execution/call-chain.ts @@ -0,0 +1,56 @@ +/** + * Workflow call chain detection using the Via-style pattern. + * + * Prevents infinite execution loops when workflows call themselves (directly or + * indirectly) via API or MCP endpoints. Each hop appends the current workflow ID + * to the `X-Sim-Via` header; on ingress the chain is checked for cycles and depth. + */ + +export const SIM_VIA_HEADER = 'X-Sim-Via' +export const MAX_CALL_CHAIN_DEPTH = 10 + +/** + * Parses the `X-Sim-Via` header value into an ordered list of workflow IDs. + * Returns an empty array when the header is absent or empty. + */ +export function parseCallChain(headerValue: string | null | undefined): string[] { + if (!headerValue || !headerValue.trim()) { + return [] + } + return headerValue + .split(',') + .map((id) => id.trim()) + .filter(Boolean) +} + +/** + * Serializes a call chain array back into the header value format. + */ +export function serializeCallChain(chain: string[]): string { + return chain.join(',') +} + +/** + * Validates that appending `workflowId` to `chain` would not create a cycle + * or exceed the maximum depth. Returns an error message string if invalid, + * or `null` if the chain is safe to extend. + */ +export function validateCallChain(chain: string[], workflowId: string): string | null { + if (chain.includes(workflowId)) { + const cycleVisualization = [...chain, workflowId].join(' → ') + return `Workflow cycle detected: ${cycleVisualization}. A workflow cannot call itself directly or indirectly.` + } + + if (chain.length >= MAX_CALL_CHAIN_DEPTH) { + return `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded.` + } + + return null +} + +/** + * Builds the next call chain by appending the current workflow ID. + */ +export function buildNextCallChain(chain: string[], workflowId: string): string[] { + return [...chain, workflowId] +} diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index dbf4e8d6bc..b7437bd090 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -331,6 +331,7 @@ export async function executeWorkflowCore( base64MaxBytes, stopAfterBlockId: resolvedStopAfterBlockId, onChildWorkflowInstanceReady, + callChain: metadata.callChain, } const executorInstance = new Executor({ diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index 687ccb46fe..4723c36227 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -69,7 +69,8 @@ export const requestTool: ToolConfig = { headers: (params: RequestParams) => { const headers = transformTable(params.headers || null) const processedUrl = processUrl(params.url, params.pathParams, params.params) - const allHeaders = getDefaultHeaders(headers, processedUrl) + const callChain = (params as any)._context?.callChain as string[] | undefined + const allHeaders = getDefaultHeaders(headers, processedUrl, callChain) // Set appropriate Content-Type only if not already specified by user if (params.formData) { diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts index c21f30623a..ff679e992f 100644 --- a/apps/sim/tools/http/utils.ts +++ b/apps/sim/tools/http/utils.ts @@ -1,4 +1,5 @@ import { getBaseUrl } from '@/lib/core/utils/urls' +import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { transformTable } from '@/tools/shared/table' import type { TableRow } from '@/tools/types' @@ -6,11 +7,13 @@ import type { TableRow } from '@/tools/types' * Creates a set of default headers used in HTTP requests * @param customHeaders Additional user-provided headers to include * @param url Target URL for the request (used for setting Host header) + * @param callChain Optional workflow call chain for cycle detection * @returns Record of HTTP headers */ export const getDefaultHeaders = ( customHeaders: Record = {}, - url?: string + url?: string, + callChain?: string[] ): Record => { const headers: Record = { 'User-Agent': @@ -37,6 +40,10 @@ export const getDefaultHeaders = ( } } + if (callChain && callChain.length > 0) { + headers[SIM_VIA_HEADER] = serializeCallChain(callChain) + } + return headers } From 6022b12ae65ba458196679dc2c23a56fd39a4d84 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Feb 2026 14:54:40 -0800 Subject: [PATCH 2/3] fix(execution): scope X-Sim-Via header to internal routes and add child workflow depth validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move call chain header injection from HTTP tool layer (request.ts/utils.ts) to tool execution layer (tools/index.ts) gated on isInternalRoute, preventing internal workflow IDs from leaking to external third-party APIs - Remove cycle detection from validateCallChain — depth limit alone prevents infinite loops while allowing legitimate self-recursion (pagination, tree processing, batch splitting) - Add validateCallChain check in workflow-handler.ts before spawning child executor, closing the gap where in-process child workflows skipped validation - Remove unsafe `(params as any)._context` type bypass in request.ts Co-Authored-By: Claude Opus 4.6 --- .../app/api/workflows/[id]/execute/route.ts | 2 +- .../handlers/workflow/workflow-handler.ts | 13 +++++-- .../execution/__tests__/call-chain.test.ts | 34 ++++--------------- apps/sim/lib/execution/call-chain.ts | 19 ++++------- apps/sim/tools/http/request.ts | 3 +- apps/sim/tools/http/utils.ts | 9 +---- apps/sim/tools/index.ts | 8 +++++ 7 files changed, 36 insertions(+), 52 deletions(-) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index ae4c48a460..294a9b2f88 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -245,7 +245,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const { id: workflowId } = await params const incomingCallChain = parseCallChain(req.headers.get(SIM_VIA_HEADER)) - const callChainError = validateCallChain(incomingCallChain, workflowId) + const callChainError = validateCallChain(incomingCallChain) if (callChainError) { logger.warn(`[${requestId}] Call chain rejected for workflow ${workflowId}: ${callChainError}`) return NextResponse.json({ error: callChainError }, { status: 409 }) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 588f9239a4..1bb34214f7 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { buildNextCallChain } from '@/lib/execution/call-chain' +import { buildNextCallChain, validateCallChain } from '@/lib/execution/call-chain' import { snapshotService } from '@/lib/logs/execution/snapshot/service' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' @@ -168,6 +168,15 @@ export class WorkflowBlockHandler implements BlockHandler { ctx.onChildWorkflowInstanceReady?.(effectiveBlockId, instanceId, iterationContext) } + const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId) + const depthError = validateCallChain(ctx.callChain || []) + if (depthError) { + throw new ChildWorkflowError({ + message: depthError, + childWorkflowName, + }) + } + const subExecutor = new Executor({ workflow: childWorkflow.serializedState, workflowInput: childWorkflowInput, @@ -181,7 +190,7 @@ export class WorkflowBlockHandler implements BlockHandler { userId: ctx.userId, executionId: ctx.executionId, abortSignal: ctx.abortSignal, - callChain: buildNextCallChain(ctx.callChain || [], workflowId), + callChain: childCallChain, ...(shouldPropagateCallbacks && { onBlockStart: ctx.onBlockStart, onBlockComplete: ctx.onBlockComplete, diff --git a/apps/sim/lib/execution/__tests__/call-chain.test.ts b/apps/sim/lib/execution/__tests__/call-chain.test.ts index dc02160c11..b793e1d531 100644 --- a/apps/sim/lib/execution/__tests__/call-chain.test.ts +++ b/apps/sim/lib/execution/__tests__/call-chain.test.ts @@ -74,34 +74,20 @@ describe('call-chain', () => { describe('validateCallChain', () => { it('returns null for an empty chain', () => { - expect(validateCallChain([], 'wf-a')).toBeNull() + expect(validateCallChain([])).toBeNull() }) - it('returns null when workflow is not in chain', () => { - expect(validateCallChain(['wf-a', 'wf-b'], 'wf-c')).toBeNull() + it('returns null when chain is under max depth', () => { + expect(validateCallChain(['wf-a', 'wf-b'])).toBeNull() }) - it('detects direct self-call (A → A)', () => { - const error = validateCallChain(['wf-a'], 'wf-a') - expect(error).toContain('Workflow cycle detected') - expect(error).toContain('wf-a → wf-a') - }) - - it('detects indirect cycle (A → B → A)', () => { - const error = validateCallChain(['wf-a', 'wf-b'], 'wf-a') - expect(error).toContain('Workflow cycle detected') - expect(error).toContain('wf-a → wf-b → wf-a') - }) - - it('detects cycle mid-chain (A → B → C → B)', () => { - const error = validateCallChain(['wf-a', 'wf-b', 'wf-c'], 'wf-b') - expect(error).toContain('Workflow cycle detected') - expect(error).toContain('wf-a → wf-b → wf-c → wf-b') + it('allows legitimate self-recursion', () => { + expect(validateCallChain(['wf-a', 'wf-a', 'wf-a'])).toBeNull() }) it('returns depth error when chain is at max depth', () => { const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`) - const error = validateCallChain(chain, 'wf-new') + const error = validateCallChain(chain) expect(error).toContain( `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded` ) @@ -109,13 +95,7 @@ describe('call-chain', () => { it('allows chain just under max depth', () => { const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH - 1 }, (_, i) => `wf-${i}`) - expect(validateCallChain(chain, 'wf-new')).toBeNull() - }) - - it('prioritizes cycle detection over depth check', () => { - const chain = Array.from({ length: MAX_CALL_CHAIN_DEPTH }, (_, i) => `wf-${i}`) - const error = validateCallChain(chain, 'wf-0') - expect(error).toContain('Workflow cycle detected') + expect(validateCallChain(chain)).toBeNull() }) }) diff --git a/apps/sim/lib/execution/call-chain.ts b/apps/sim/lib/execution/call-chain.ts index 0e4d01c5b5..406274fa48 100644 --- a/apps/sim/lib/execution/call-chain.ts +++ b/apps/sim/lib/execution/call-chain.ts @@ -1,9 +1,9 @@ /** * Workflow call chain detection using the Via-style pattern. * - * Prevents infinite execution loops when workflows call themselves (directly or - * indirectly) via API or MCP endpoints. Each hop appends the current workflow ID - * to the `X-Sim-Via` header; on ingress the chain is checked for cycles and depth. + * Prevents infinite execution loops when workflows call each other via API or + * MCP endpoints. Each hop appends the current workflow ID to the `X-Sim-Via` + * header; on ingress the chain is checked for depth. */ export const SIM_VIA_HEADER = 'X-Sim-Via' @@ -31,16 +31,11 @@ export function serializeCallChain(chain: string[]): string { } /** - * Validates that appending `workflowId` to `chain` would not create a cycle - * or exceed the maximum depth. Returns an error message string if invalid, - * or `null` if the chain is safe to extend. + * Validates that the call chain has not exceeded the maximum depth. + * Returns an error message string if invalid, or `null` if the chain is + * safe to extend. */ -export function validateCallChain(chain: string[], workflowId: string): string | null { - if (chain.includes(workflowId)) { - const cycleVisualization = [...chain, workflowId].join(' → ') - return `Workflow cycle detected: ${cycleVisualization}. A workflow cannot call itself directly or indirectly.` - } - +export function validateCallChain(chain: string[]): string | null { if (chain.length >= MAX_CALL_CHAIN_DEPTH) { return `Maximum workflow call chain depth (${MAX_CALL_CHAIN_DEPTH}) exceeded.` } diff --git a/apps/sim/tools/http/request.ts b/apps/sim/tools/http/request.ts index 4723c36227..687ccb46fe 100644 --- a/apps/sim/tools/http/request.ts +++ b/apps/sim/tools/http/request.ts @@ -69,8 +69,7 @@ export const requestTool: ToolConfig = { headers: (params: RequestParams) => { const headers = transformTable(params.headers || null) const processedUrl = processUrl(params.url, params.pathParams, params.params) - const callChain = (params as any)._context?.callChain as string[] | undefined - const allHeaders = getDefaultHeaders(headers, processedUrl, callChain) + const allHeaders = getDefaultHeaders(headers, processedUrl) // Set appropriate Content-Type only if not already specified by user if (params.formData) { diff --git a/apps/sim/tools/http/utils.ts b/apps/sim/tools/http/utils.ts index ff679e992f..c21f30623a 100644 --- a/apps/sim/tools/http/utils.ts +++ b/apps/sim/tools/http/utils.ts @@ -1,5 +1,4 @@ import { getBaseUrl } from '@/lib/core/utils/urls' -import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { transformTable } from '@/tools/shared/table' import type { TableRow } from '@/tools/types' @@ -7,13 +6,11 @@ import type { TableRow } from '@/tools/types' * Creates a set of default headers used in HTTP requests * @param customHeaders Additional user-provided headers to include * @param url Target URL for the request (used for setting Host header) - * @param callChain Optional workflow call chain for cycle detection * @returns Record of HTTP headers */ export const getDefaultHeaders = ( customHeaders: Record = {}, - url?: string, - callChain?: string[] + url?: string ): Record => { const headers: Record = { 'User-Agent': @@ -40,10 +37,6 @@ export const getDefaultHeaders = ( } } - if (callChain && callChain.length > 0) { - headers[SIM_VIA_HEADER] = serializeCallChain(callChain) - } - return headers } diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 9b740e8401..8cf48a749a 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -7,6 +7,7 @@ import { } from '@/lib/core/security/input-validation.server' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { SIM_VIA_HEADER, serializeCallChain } from '@/lib/execution/call-chain' import { parseMcpToolId } from '@/lib/mcp/utils' import { isCustomTool, isMcpTool } from '@/executor/constants' import { resolveSkillContent } from '@/executor/handlers/agent/skills-resolver' @@ -674,6 +675,13 @@ async function executeToolRequest( const headers = new Headers(requestParams.headers) await addInternalAuthIfNeeded(headers, isInternalRoute, requestId, toolId) + if (isInternalRoute) { + const callChain = params._context?.callChain as string[] | undefined + if (callChain && callChain.length > 0) { + headers.set(SIM_VIA_HEADER, serializeCallChain(callChain)) + } + } + // Check request body size before sending to detect potential size limit issues validateRequestBodySize(requestParams.body, requestId, toolId) From 27e956008f78e02ce3296b4b4ed53ab676dc5b69 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 23 Feb 2026 15:05:14 -0800 Subject: [PATCH 3/3] fix(execution): validate child call chain instead of parent chain Validate childCallChain (after appending current workflow ID) rather than ctx.callChain (parent). Prevents an off-by-one where a chain at depth 10 could still spawn an 11th workflow. Co-Authored-By: Claude Opus 4.6 --- apps/sim/executor/handlers/workflow/workflow-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 1bb34214f7..1666b51f2e 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -169,7 +169,7 @@ export class WorkflowBlockHandler implements BlockHandler { } const childCallChain = buildNextCallChain(ctx.callChain || [], workflowId) - const depthError = validateCallChain(ctx.callChain || []) + const depthError = validateCallChain(childCallChain) if (depthError) { throw new ChildWorkflowError({ message: depthError,