From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 1/9] v0.6.29: login improvements, posthog telemetry (#4026) * feat(posthog): Add tracking on mothership abort (#4023) Co-authored-by: Theodore Li * fix(login): fix captcha headers for manual login (#4025) * fix(signup): fix turnstile key loading * fix(login): fix captcha header passing * Catch user already exists, remove login form captcha --- apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++-------- .../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++-- .../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++- apps/sim/lib/posthog/events.ts | 5 +++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 55a0508ec1b..afb27cd729a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -270,10 +270,8 @@ function SignupFormContent({ name: sanitizedName, }, { - fetchOptions: { - headers: { - ...(token ? { 'x-captcha-response': token } : {}), - }, + headers: { + ...(token ? { 'x-captcha-response': token } : {}), }, onError: (ctx) => { logger.error('Signup error:', ctx.error) @@ -282,10 +280,7 @@ function SignupFormContent({ let errorCode = 'unknown' if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { errorCode = 'user_already_exists' - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) - setEmailError(errorMessage[0]) + setEmailError('An account with this email already exists. Please sign in instead.') } else if ( ctx.error.code?.includes('BAD_REQUEST') || ctx.error.message?.includes('Email and password sign up is not enabled') diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d76f17ff454..38367339197 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) { posthogRef.current = posthog }, [posthog]) + const handleStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'mothership', + }) + stopGeneration() + }, [stopGeneration, workspaceId]) + const handleSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) { defaultValue={initialPrompt} onSubmit={handleSubmit} isSending={isSending} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} userId={session?.user?.id} onContextAdd={handleContextAdd} /> @@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) { isSending={isSending} isReconnecting={isReconnecting} onSubmit={handleSubmit} - onStopGeneration={stopGeneration} + onStopGeneration={handleStopGeneration} messageQueue={messageQueue} onRemoveQueuedMessage={removeFromQueue} onSendQueuedMessage={sendNow} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 4d485c763ce..da51910789b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { History, Plus, Square } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { BubbleChatClose, @@ -33,6 +34,7 @@ import { import { Lock, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' +import { captureEvent } from '@/lib/posthog/client' import { generateWorkflowJson } from '@/lib/workflows/operations/import-export' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' @@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const params = useParams() const workspaceId = propWorkspaceId ?? (params.workspaceId as string) + const posthog = usePostHog() + const posthogRef = useRef(posthog) + const panelRef = useRef(null) const fileInputRef = useRef(null) const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore( @@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel loadCopilotChats() }, [loadCopilotChats]) + useEffect(() => { + posthogRef.current = posthog + }, [posthog]) + const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => { setCopilotChatId(chat.id) setCopilotChatTitle(chat.title) @@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel [copilotEditQueuedMessage] ) + const handleCopilotStopGeneration = useCallback(() => { + captureEvent(posthogRef.current, 'task_generation_aborted', { + workspace_id: workspaceId, + view: 'copilot', + }) + copilotStopGeneration() + }, [copilotStopGeneration, workspaceId]) + const handleCopilotSubmit = useCallback( (text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { const trimmed = text.trim() @@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel isSending={copilotIsSending} isReconnecting={copilotIsReconnecting} onSubmit={handleCopilotSubmit} - onStopGeneration={copilotStopGeneration} + onStopGeneration={handleCopilotStopGeneration} messageQueue={copilotMessageQueue} onRemoveQueuedMessage={copilotRemoveFromQueue} onSendQueuedMessage={copilotSendNow} diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 537a9864282..faf9895bf62 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -378,6 +378,11 @@ export interface PostHogEventMap { workspace_id: string } + task_generation_aborted: { + workspace_id: string + view: 'mothership' | 'copilot' + } + task_message_sent: { workspace_id: string has_attachments: boolean From 67340525e83d276aebd67fe1353ed0bc00b3b7e1 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Sat, 18 Apr 2026 11:10:39 +0800 Subject: [PATCH 2/9] fix: use context variables for block outputs in function block code When a function block references another block's output via , the executor previously embedded the full value as a JavaScript literal directly in the code string. For large outputs (>50 KB), this caused the code string to exceed the terminal console display limit, making inputs appear truncated or replaced with { __simTruncated: true } in the UI. Instead, block output references in function block code are now stored as named global variables (__blockRef_N) in the isolated VM context. The code string only contains the compact variable name, keeping it small regardless of the referenced value size. Loop/parallel/env/workflow references are still inlined as literals since the API route has no way to resolve them independently. The _runtimeContextVars key is filtered from sanitizeInputsForLog so it does not appear in execution logs or SSE events. Pre-resolved context variables are merged with any variables produced by the API route resolveCodeVariables, with executor values taking precedence. Fixes #4195 --- apps/sim/app/api/function/execute/route.ts | 6 +- apps/sim/executor/execution/block-executor.ts | 15 ++- .../handlers/function/function-handler.ts | 6 + apps/sim/executor/variables/resolver.ts | 124 ++++++++++++++++++ apps/sim/tools/function/execute.ts | 1 + apps/sim/tools/function/types.ts | 9 ++ 6 files changed, 157 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index e172f31d771..bdc00a151a7 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -719,6 +719,7 @@ export async function POST(req: NextRequest) { blockNameMapping = {}, blockOutputSchemas = {}, workflowVariables = {}, + contextVariables: preResolvedContextVariables = {}, workflowId, workspaceId, isCustomTool = false, @@ -755,7 +756,10 @@ export async function POST(req: NextRequest) { lang ) resolvedCode = codeResolution.resolvedCode - contextVariables = codeResolution.contextVariables + // Merge pre-resolved block output variables from the executor. These take precedence + // because they were produced by the resolver using full execution-state context + // (including loop/parallel scope) and should not be overwritten. + contextVariables = { ...codeResolution.contextVariables, ...preResolvedContextVariables } } let jsImports = '' diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 5044eab5639..ce06bae5d73 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -42,7 +42,10 @@ import { } from '@/executor/utils/iteration-context' import { isJSONString } from '@/executor/utils/json' import { filterOutputForLog } from '@/executor/utils/output-filter' -import type { VariableResolver } from '@/executor/variables/resolver' +import { + FUNCTION_BLOCK_CONTEXT_VARS_KEY, + type VariableResolver, +} from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' @@ -108,7 +111,13 @@ export class BlockExecutor { await validateBlockType(ctx.userId, blockType, ctx) } - resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block) + if (block.metadata?.id === BlockType.FUNCTION) { + const { resolvedInputs: fnInputs, contextVariables } = + this.resolver.resolveInputsForFunctionBlock(ctx, node.id, block.config.params, block) + resolvedInputs = { ...fnInputs, [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables } + } else { + resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block) + } if (blockLog) { blockLog.input = this.sanitizeInputsForLog(resolvedInputs) @@ -418,7 +427,7 @@ export class BlockExecutor { const result: Record = {} for (const [key, value] of Object.entries(inputs)) { - if (SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode') { + if (SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode' || key === FUNCTION_BLOCK_CONTEXT_VARS_KEY) { continue } diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts index 68302412bcb..33558ae4ad6 100644 --- a/apps/sim/executor/handlers/function/function-handler.ts +++ b/apps/sim/executor/handlers/function/function-handler.ts @@ -3,6 +3,7 @@ import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' import { BlockType } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' +import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' @@ -25,6 +26,10 @@ export class FunctionBlockHandler implements BlockHandler { const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx) + const contextVariables = (inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY] as + | Record + | undefined) ?? {} + const result = await executeTool( 'function_execute', { @@ -36,6 +41,7 @@ export class FunctionBlockHandler implements BlockHandler { blockData, blockNameMapping, blockOutputSchemas, + contextVariables, _context: { workflowId: ctx.workflowId, workspaceId: ctx.workspaceId, diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index 88b23d72340..15b24ee117e 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -15,6 +15,9 @@ import { import { WorkflowResolver } from '@/executor/variables/resolvers/workflow' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' +/** Key used to carry pre-resolved context variables through the inputs map. */ +export const FUNCTION_BLOCK_CONTEXT_VARS_KEY = '_runtimeContextVars' + const logger = createLogger('VariableResolver') export class VariableResolver { @@ -36,6 +39,63 @@ export class VariableResolver { ] } + /** + * Resolves inputs for function blocks. Block output references in the `code` field + * are stored as named context variables instead of being embedded as JavaScript + * literals, preventing large values from bloating the code string. + * + * Returns the resolved inputs and a `contextVariables` map. Callers should inject + * contextVariables into the function execution request body so the isolated VM can + * access them as global variables. + */ + resolveInputsForFunctionBlock( + ctx: ExecutionContext, + currentNodeId: string, + params: Record, + block: SerializedBlock + ): { resolvedInputs: Record; contextVariables: Record } { + const contextVariables: Record = {} + const resolved: Record = {} + + for (const [key, value] of Object.entries(params)) { + if (key === 'code') { + if (typeof value === 'string') { + resolved[key] = this.resolveCodeWithContextVars( + ctx, + currentNodeId, + value, + undefined, + block, + contextVariables + ) + } else if (Array.isArray(value)) { + resolved[key] = value.map((item: any) => { + if (item && typeof item === 'object' && typeof item.content === 'string') { + return { + ...item, + content: this.resolveCodeWithContextVars( + ctx, + currentNodeId, + item.content, + undefined, + block, + contextVariables + ), + } + } + return item + }) + } else { + resolved[key] = this.resolveValue(ctx, currentNodeId, value, undefined, block) + } + } else { + resolved[key] = this.resolveValue(ctx, currentNodeId, value, undefined, block) + } + } + + return { resolvedInputs: resolved, contextVariables } + } + resolveInputs( ctx: ExecutionContext, currentNodeId: string, @@ -149,6 +209,70 @@ export class VariableResolver { } return value } + /** + * Resolves a code template for a function block. Block output references are stored + * in `contextVarAccumulator` as named variables (e.g. `__blockRef_0`) and replaced + * with those variable names in the returned code string. Non-block references (loop + * items, workflow variables, env vars) are still inlined as literals so they remain + * available without any extra passing mechanism. + */ + private resolveCodeWithContextVars( + ctx: ExecutionContext, + currentNodeId: string, + template: string, + loopScope: LoopScope | undefined, + block: SerializedBlock, + contextVarAccumulator: Record + ): string { + const resolutionContext: ResolutionContext = { + executionContext: ctx, + executionState: this.state, + currentNodeId, + loopScope, + } + + const language = (block.config?.params as Record | undefined)?.language as + | string + | undefined + + let replacementError: Error | null = null + + let result = replaceValidReferences(template, (match) => { + if (replacementError) return match + + try { + const resolved = this.resolveReference(match, resolutionContext) + if (resolved === undefined) return match + + const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved + + if (this.blockResolver.canResolve(match)) { + // Block output: store in contextVarAccumulator, replace with variable name + const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}` + contextVarAccumulator[varName] = effectiveValue + return varName + } + + // Non-block reference (loop, parallel, workflow, env): embed as literal + return this.blockResolver.formatValueForBlock(effectiveValue, BlockType.FUNCTION, language) + } catch (error) { + replacementError = error instanceof Error ? error : new Error(String(error)) + return match + } + }) + + if (replacementError !== null) { + throw replacementError + } + + result = result.replace(createEnvVarPattern(), (match) => { + const resolved = this.resolveReference(match, resolutionContext) + return typeof resolved === 'string' ? resolved : match + }) + + return result + } + private resolveTemplate( ctx: ExecutionContext, currentNodeId: string, diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index 844b1c6d515..59873843cbb 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -128,6 +128,7 @@ export const functionExecuteTool: ToolConfig blockNameMapping?: Record blockOutputSchemas?: Record> + /** Pre-resolved block output variables from the executor, injected as VM globals. */ + contextVariables?: Record _context?: { workflowId?: string userId?: string @@ -32,3 +34,10 @@ export interface CodeExecutionOutput extends ToolResponse { stdout: string } } + +export interface CodeExecutionOutput extends ToolResponse { + output: { + result: any + stdout: string + } +} From 27836065ae81168c61174ed4f1c4acadb2983005 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Sat, 18 Apr 2026 13:22:30 +0800 Subject: [PATCH 3/9] fix: address Cursor and Greptile bot review comments - Pass preResolvedContextVariables through to shellEnvs for Shell language (Cursor: shell loses pre-resolved block refs, executes against undefined vars) - Remove duplicate CodeExecutionOutput interface declaration (Cursor + Greptile: dead duplicate declaration in tools/function/types.ts) - Deduplicate identical block references in resolveCodeWithContextVars so the same reused multiple times shares one __blockRef_N slot (Greptile P2: avoid duplicating large payloads across the wire) --- apps/sim/app/api/function/execute/route.ts | 4 ++++ apps/sim/executor/variables/resolver.ts | 21 +++++++++++++++++---- apps/sim/tools/function/types.ts | 7 ------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index bdc00a151a7..bc0070ed58a 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -744,6 +744,10 @@ export async function POST(req: NextRequest) { // For shell, env vars are injected as OS env vars via shellEnvs. // Replace {{VAR}} placeholders with $VAR so the shell can access them natively. resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, '$$$1') + // Carry pre-resolved block output variables (e.g. __blockRef_N) so they can be + // injected as shell env vars below. The executor replaces block references in the + // code with these names, so the values must be present at runtime. + contextVariables = { ...preResolvedContextVariables } } else { const codeResolution = resolveCodeVariables( code, diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index 15b24ee117e..1294675c2b3 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -237,22 +237,35 @@ export class VariableResolver { let replacementError: Error | null = null + const blockRefByMatch = new Map() + let result = replaceValidReferences(template, (match) => { if (replacementError) return match try { - const resolved = this.resolveReference(match, resolutionContext) - if (resolved === undefined) return match + if (this.blockResolver.canResolve(match)) { + // Deduplicate: identical references in the same template share a single + // accumulator slot so we do not duplicate large payloads. + const existing = blockRefByMatch.get(match) + if (existing !== undefined) return existing - const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved + const resolved = this.resolveReference(match, resolutionContext) + if (resolved === undefined) return match + + const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved - if (this.blockResolver.canResolve(match)) { // Block output: store in contextVarAccumulator, replace with variable name const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}` contextVarAccumulator[varName] = effectiveValue + blockRefByMatch.set(match, varName) return varName } + const resolved = this.resolveReference(match, resolutionContext) + if (resolved === undefined) return match + + const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved + // Non-block reference (loop, parallel, workflow, env): embed as literal return this.blockResolver.formatValueForBlock(effectiveValue, BlockType.FUNCTION, language) } catch (error) { diff --git a/apps/sim/tools/function/types.ts b/apps/sim/tools/function/types.ts index f1945fb1b99..b46aee1561b 100644 --- a/apps/sim/tools/function/types.ts +++ b/apps/sim/tools/function/types.ts @@ -34,10 +34,3 @@ export interface CodeExecutionOutput extends ToolResponse { stdout: string } } - -export interface CodeExecutionOutput extends ToolResponse { - output: { - result: any - stdout: string - } -} From 19787e1a7501998ce28ae216eaa144a5b0269e88 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Wed, 6 May 2026 13:16:37 +0800 Subject: [PATCH 4/9] fix: shell block references and complex env value serialization Two follow-ups to the function-block context-variable refactor: - resolveCodeWithContextVars now emits `$__blockRef_N` for shell function blocks so the script dereferences the env var injected by the executor. Other languages still receive the bare identifier. - The function-execute route now JSON-stringifies non-primitive values when building shell env vars, replacing the previous `String(v)` call that produced `[object Object]` for objects/arrays. Co-Authored-By: Octopus --- apps/sim/app/api/function/execute/route.ts | 24 ++++++++++++++++++++-- apps/sim/executor/variables/resolver.ts | 11 +++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index bc0070ed58a..c882a368223 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -586,6 +586,26 @@ function cleanStdout(stdout: string): string { return stdout } +/** + * Serializes a value for use as a shell environment variable. Strings pass through + * unchanged; primitives are coerced via `String`; objects, arrays, and other complex + * values are JSON-stringified so that referencing them via `$VAR` yields a useful + * representation instead of `[object Object]`. `null`/`undefined` become an empty + * string to match POSIX env semantics. + */ +function serializeForShellEnv(value: unknown): string { + if (value === null || value === undefined) return '' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value) + } + try { + return JSON.stringify(value) ?? '' + } catch { + return String(value) + } +} + async function maybeExportSandboxFileToWorkspace(args: { authUserId: string workflowId?: string @@ -788,10 +808,10 @@ export async function POST(req: NextRequest) { const shellEnvs: Record = {} for (const [k, v] of Object.entries(envVars)) { - shellEnvs[k] = String(v) + shellEnvs[k] = serializeForShellEnv(v) } for (const [k, v] of Object.entries(contextVariables)) { - shellEnvs[k] = String(v) + shellEnvs[k] = serializeForShellEnv(v) } logger.info(`[${requestId}] E2B shell execution`, { diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index 1294675c2b3..c50da3d737d 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -237,6 +237,7 @@ export class VariableResolver { let replacementError: Error | null = null + const isShell = language === 'shell' const blockRefByMatch = new Map() let result = replaceValidReferences(template, (match) => { @@ -254,11 +255,15 @@ export class VariableResolver { const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved - // Block output: store in contextVarAccumulator, replace with variable name + // Block output: store in contextVarAccumulator, replace with variable name. + // For shell, emit `$__blockRef_N` so the script dereferences the env var + // injected by the function-execute route; other languages receive the bare + // identifier and read it as a global injected into the VM. const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}` contextVarAccumulator[varName] = effectiveValue - blockRefByMatch.set(match, varName) - return varName + const replacement = isShell ? `$${varName}` : varName + blockRefByMatch.set(match, replacement) + return replacement } const resolved = this.resolveReference(match, resolutionContext) From 7d972ae233962a85800281a1d5668badca0978a8 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 5 May 2026 23:27:58 -0700 Subject: [PATCH 5/9] fix lint --- apps/sim/executor/execution/block-executor.ts | 6 +++++- apps/sim/executor/handlers/function/function-handler.ts | 5 ++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index ce06bae5d73..f7216f43b23 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -427,7 +427,11 @@ export class BlockExecutor { const result: Record = {} for (const [key, value] of Object.entries(inputs)) { - if (SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode' || key === FUNCTION_BLOCK_CONTEXT_VARS_KEY) { + if ( + SYSTEM_SUBBLOCK_IDS.includes(key) || + key === 'triggerMode' || + key === FUNCTION_BLOCK_CONTEXT_VARS_KEY + ) { continue } diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts index 33558ae4ad6..c008a8d07ea 100644 --- a/apps/sim/executor/handlers/function/function-handler.ts +++ b/apps/sim/executor/handlers/function/function-handler.ts @@ -26,9 +26,8 @@ export class FunctionBlockHandler implements BlockHandler { const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx) - const contextVariables = (inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY] as - | Record - | undefined) ?? {} + const contextVariables = + (inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY] as Record | undefined) ?? {} const result = await executeTool( 'function_execute', From 4d695cfc8ee3e5a473b94ff746d381b8c3e3da08 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 5 May 2026 23:55:44 -0700 Subject: [PATCH 6/9] review pass --- apps/sim/app/api/function/execute/route.ts | 10 +- .../function/function-handler.test.ts | 28 ++++ .../executor/utils/reference-validation.ts | 6 +- apps/sim/executor/variables/resolver.test.ts | 137 ++++++++++++++++++ apps/sim/executor/variables/resolver.ts | 102 +++++++++++-- 5 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 apps/sim/executor/variables/resolver.test.ts diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index c882a368223..89965fbb6a7 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -593,8 +593,8 @@ function cleanStdout(stdout: string): string { * representation instead of `[object Object]`. `null`/`undefined` become an empty * string to match POSIX env semantics. */ -function serializeForShellEnv(value: unknown): string { - if (value === null || value === undefined) return '' +function serializeForShellEnv(value: unknown, nullValue = ''): string { + if (value === null || value === undefined) return nullValue if (typeof value === 'string') return value if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { return String(value) @@ -811,7 +811,7 @@ export async function POST(req: NextRequest) { shellEnvs[k] = serializeForShellEnv(v) } for (const [k, v] of Object.entries(contextVariables)) { - shellEnvs[k] = serializeForShellEnv(v) + shellEnvs[k] = serializeForShellEnv(v, 'null') } logger.info(`[${requestId}] E2B shell execution`, { @@ -918,7 +918,9 @@ export async function POST(req: NextRequest) { prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n` prologueLineCount++ for (const [k, v] of Object.entries(contextVariables)) { - prologue += `const ${k} = ${formatLiteralForCode(v, 'javascript')};\n` + prologue += `globalThis[${JSON.stringify(k)}] = ${formatLiteralForCode(v, 'javascript')};\n` + prologue += `const ${k} = globalThis[${JSON.stringify(k)}];\n` + prologueLineCount++ prologueLineCount++ } diff --git a/apps/sim/executor/handlers/function/function-handler.test.ts b/apps/sim/executor/handlers/function/function-handler.test.ts index 5426610c701..c927c99c2a2 100644 --- a/apps/sim/executor/handlers/function/function-handler.test.ts +++ b/apps/sim/executor/handlers/function/function-handler.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { BlockType } from '@/executor/constants' import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler' import type { ExecutionContext } from '@/executor/types' +import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' @@ -76,10 +77,13 @@ describe('FunctionBlockHandler', () => { blockData: {}, blockNameMapping: {}, blockOutputSchemas: {}, + contextVariables: {}, _context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId, + userId: mockContext.userId, isDeployedContext: mockContext.isDeployedContext, + enforceCredentialAccess: mockContext.enforceCredentialAccess, }, } const expectedOutput: any = { result: 'Success' } @@ -113,10 +117,13 @@ describe('FunctionBlockHandler', () => { blockData: {}, blockNameMapping: {}, blockOutputSchemas: {}, + contextVariables: {}, _context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId, + userId: mockContext.userId, isDeployedContext: mockContext.isDeployedContext, + enforceCredentialAccess: mockContext.enforceCredentialAccess, }, } const expectedOutput: any = { result: 'Success' } @@ -143,10 +150,13 @@ describe('FunctionBlockHandler', () => { blockData: {}, blockNameMapping: {}, blockOutputSchemas: {}, + contextVariables: {}, _context: { workflowId: mockContext.workflowId, workspaceId: mockContext.workspaceId, + userId: mockContext.userId, isDeployedContext: mockContext.isDeployedContext, + enforceCredentialAccess: mockContext.enforceCredentialAccess, }, } @@ -171,6 +181,24 @@ describe('FunctionBlockHandler', () => { expect(mockExecuteTool).toHaveBeenCalled() }) + it('should pass runtime context variables to function_execute', async () => { + const contextVariables = { __blockRef_0: { result: 'from-block' } } + + await handler.execute(mockContext, mockBlock, { + code: 'return globalThis["__blockRef_0"]', + [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables, + }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'function_execute', + expect.objectContaining({ + contextVariables, + }), + false, + mockContext + ) + }) + it('should handle tool error with no specific message', async () => { const inputs = { code: 'some code' } const errorResult = { success: false } diff --git a/apps/sim/executor/utils/reference-validation.ts b/apps/sim/executor/utils/reference-validation.ts index 60e57b69639..18b2b76d89f 100644 --- a/apps/sim/executor/utils/reference-validation.ts +++ b/apps/sim/executor/utils/reference-validation.ts @@ -143,14 +143,14 @@ export function createCombinedPattern(): RegExp { */ export function replaceValidReferences( template: string, - replacer: (match: string) => string + replacer: (match: string, index: number, template: string) => string ): string { const pattern = createReferencePattern() - return template.replace(pattern, (match) => { + return template.replace(pattern, (match, _content, index) => { if (!isLikelyReferenceSegment(match)) { return match } - return replacer(match) + return replacer(match, index, template) }) } diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts new file mode 100644 index 00000000000..46a4903f717 --- /dev/null +++ b/apps/sim/executor/variables/resolver.test.ts @@ -0,0 +1,137 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { BlockType } from '@/executor/constants' +import { ExecutionState } from '@/executor/execution/state' +import type { ExecutionContext } from '@/executor/types' +import { VariableResolver } from '@/executor/variables/resolver' +import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' + +function createBlock(id: string, name: string, type: string, params = {}): SerializedBlock { + return { + id, + metadata: { id: type, name }, + position: { x: 0, y: 0 }, + config: { tool: type, params }, + inputs: {}, + outputs: { + result: 'string', + items: 'json', + }, + enabled: true, + } +} + +function createResolver(language = 'javascript') { + const producer = createBlock('producer', 'Producer', BlockType.API) + const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, { + language, + }) + const workflow: SerializedWorkflow = { + version: '1', + blocks: [producer, functionBlock], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + state.setBlockOutput('producer', { + result: 'hello world', + items: ['a', 'b'], + }) + const ctx = { + blockStates: state.getBlockStates(), + blockLogs: [], + environmentVariables: {}, + workflowVariables: {}, + decisions: { router: new Map(), condition: new Map() }, + loopExecutions: new Map(), + executedBlocks: new Set(), + activeExecutionPath: new Set(), + completedLoops: new Set(), + metadata: {}, + } as ExecutionContext + + return { + block: functionBlock, + ctx, + resolver: new VariableResolver(workflow, {}, state), + } +} + +describe('VariableResolver function block inputs', () => { + it('returns empty inputs when params are missing', () => { + const { block, ctx, resolver } = createResolver() + + const result = resolver.resolveInputsForFunctionBlock(ctx, 'function', undefined, block) + + expect(result).toEqual({ resolvedInputs: {}, contextVariables: {} }) + }) + + it('resolves JavaScript block references through globalThis context variables', () => { + const { block, ctx, resolver } = createResolver('javascript') + + const result = resolver.resolveInputsForFunctionBlock( + ctx, + 'function', + { code: 'return ' }, + block + ) + + expect(result.resolvedInputs.code).toBe('return globalThis["__blockRef_0"]') + expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' }) + }) + + it('resolves Python block references through globals lookup', () => { + const { block, ctx, resolver } = createResolver('python') + + const result = resolver.resolveInputsForFunctionBlock( + ctx, + 'function', + { code: 'return ' }, + block + ) + + expect(result.resolvedInputs.code).toBe('return globals()["__blockRef_0"]') + expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' }) + }) + + it('uses separate Python context variables for repeated mutable references', () => { + const { block, ctx, resolver } = createResolver('python') + + const result = resolver.resolveInputsForFunctionBlock( + ctx, + 'function', + { code: 'a = \nb = \nreturn b' }, + block + ) + + expect(result.resolvedInputs.code).toBe( + 'a = globals()["__blockRef_0"]\nb = globals()["__blockRef_1"]\nreturn b' + ) + expect(result.contextVariables).toEqual({ + __blockRef_0: ['a', 'b'], + __blockRef_1: ['a', 'b'], + }) + }) + + it('uses shell-safe expansions for block references', () => { + const { block, ctx, resolver } = createResolver('shell') + + const result = resolver.resolveInputsForFunctionBlock( + ctx, + 'function', + { code: 'echo suffix && echo ""' }, + block + ) + + expect(result.resolvedInputs.code).toBe( + `echo "\${__blockRef_0}"suffix && echo "\${__blockRef_1}"` + ) + expect(result.contextVariables).toEqual({ + __blockRef_0: 'hello world', + __blockRef_1: 'hello world', + }) + }) +}) diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index c50da3d737d..700239771f1 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -20,6 +20,8 @@ export const FUNCTION_BLOCK_CONTEXT_VARS_KEY = '_runtimeContextVars' const logger = createLogger('VariableResolver') +type ShellQuoteContext = 'single' | 'double' | null + export class VariableResolver { private resolvers: Resolver[] private blockResolver: BlockResolver @@ -51,12 +53,16 @@ export class VariableResolver { resolveInputsForFunctionBlock( ctx: ExecutionContext, currentNodeId: string, - params: Record, + params: Record | null | undefined, block: SerializedBlock ): { resolvedInputs: Record; contextVariables: Record } { const contextVariables: Record = {} const resolved: Record = {} + if (!params) { + return { resolvedInputs: resolved, contextVariables } + } + for (const [key, value] of Object.entries(params)) { if (key === 'code') { if (typeof value === 'string') { @@ -237,32 +243,27 @@ export class VariableResolver { let replacementError: Error | null = null - const isShell = language === 'shell' - const blockRefByMatch = new Map() - - let result = replaceValidReferences(template, (match) => { + let result = replaceValidReferences(template, (match, index) => { if (replacementError) return match try { if (this.blockResolver.canResolve(match)) { - // Deduplicate: identical references in the same template share a single - // accumulator slot so we do not duplicate large payloads. - const existing = blockRefByMatch.get(match) - if (existing !== undefined) return existing - const resolved = this.resolveReference(match, resolutionContext) if (resolved === undefined) return match const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved - // Block output: store in contextVarAccumulator, replace with variable name. - // For shell, emit `$__blockRef_N` so the script dereferences the env var - // injected by the function-execute route; other languages receive the bare - // identifier and read it as a global injected into the VM. + // Block output: store in contextVarAccumulator and replace the reference + // with language-specific runtime access to that stored value. const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}` contextVarAccumulator[varName] = effectiveValue - const replacement = isShell ? `$${varName}` : varName - blockRefByMatch.set(match, replacement) + const replacement = this.formatContextVariableReference( + varName, + language, + template, + index, + effectiveValue + ) return replacement } @@ -291,6 +292,75 @@ export class VariableResolver { return result } + private formatContextVariableReference( + varName: string, + language: string | undefined, + template: string, + matchIndex: number, + value: unknown + ): string { + if (language === 'python') { + return `globals()[${JSON.stringify(varName)}]` + } + + if (language === 'shell') { + return this.formatShellContextVariableReference(varName, template, matchIndex, value) + } + + return `globalThis[${JSON.stringify(varName)}]` + } + + private formatShellContextVariableReference( + varName: string, + template: string, + matchIndex: number, + value: unknown + ): string { + const expansion = `\${${varName}}` + const quoteContext = this.getShellQuoteContext(template, matchIndex) + if (quoteContext === 'double') { + return expansion + } + + const shouldQuote = + quoteContext === 'single' || + typeof value === 'string' || + (typeof value === 'object' && value !== null) || + Array.isArray(value) + + if (!shouldQuote) { + return expansion + } + + const quotedExpansion = `"${expansion}"` + if (quoteContext === 'single') { + return `'${quotedExpansion}'` + } + + return quotedExpansion + } + + private getShellQuoteContext(template: string, index: number): ShellQuoteContext { + let quoteContext: ShellQuoteContext = null + + for (let i = 0; i < index; i++) { + const char = template[i] + + if (char === '\\' && quoteContext !== 'single') { + i++ + continue + } + + if (char === "'" && quoteContext !== 'double') { + quoteContext = quoteContext === 'single' ? null : 'single' + } else if (char === '"' && quoteContext !== 'single') { + quoteContext = quoteContext === 'double' ? null : 'double' + } + } + + return quoteContext + } + private resolveTemplate( ctx: ExecutionContext, currentNodeId: string, From 3208e07c299cfa517246987416eaac83d3da99fb Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 5 May 2026 23:57:57 -0700 Subject: [PATCH 7/9] ignore shell comments --- apps/sim/executor/variables/resolver.test.ts | 16 ++++++++++++++++ apps/sim/executor/variables/resolver.ts | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index 46a4903f717..915afa54ae6 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -134,4 +134,20 @@ describe('VariableResolver function block inputs', () => { __blockRef_1: 'hello world', }) }) + + it('ignores shell comment quotes when formatting later block references', () => { + const { block, ctx, resolver } = createResolver('shell') + + const result = resolver.resolveInputsForFunctionBlock( + ctx, + 'function', + { code: "# don't confuse quote tracking\necho " }, + block + ) + + expect(result.resolvedInputs.code).toBe( + `# don't confuse quote tracking\necho "\${__blockRef_0}"` + ) + expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' }) + }) }) diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index 700239771f1..43e3e9685a6 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -346,6 +346,15 @@ export class VariableResolver { for (let i = 0; i < index; i++) { const char = template[i] + if (quoteContext === null && this.isShellCommentStart(template, i)) { + const nextNewline = template.indexOf('\n', i + 1) + if (nextNewline === -1 || nextNewline >= index) { + break + } + i = nextNewline + continue + } + if (char === '\\' && quoteContext !== 'single') { i++ continue @@ -361,6 +370,15 @@ export class VariableResolver { return quoteContext } + private isShellCommentStart(template: string, index: number): boolean { + if (template[index] !== '#') { + return false + } + + const previous = template[index - 1] + return previous === undefined || /\s|[;&|()<>]/.test(previous) + } + private resolveTemplate( ctx: ExecutionContext, currentNodeId: string, From a6be98480a8c66b66229c422e063806753e0f423 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 6 May 2026 00:06:01 -0700 Subject: [PATCH 8/9] update contract --- apps/sim/lib/api/contracts/hotspots.ts | 1 + bun.lock | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index 08baa37b499..c4e7a277303 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -99,6 +99,7 @@ export const functionExecuteContract = defineRouteContract({ blockNameMapping: z.record(z.string(), z.string()).optional().default({}), blockOutputSchemas: z.record(z.string(), unknownRecordSchema).optional().default({}), workflowVariables: unknownRecordSchema.optional().default({}), + contextVariables: unknownRecordSchema.optional().default({}), workflowId: z.string().optional(), workspaceId: z.string().optional(), userId: z.string().optional(), diff --git a/bun.lock b/bun.lock index cd73fd610fd..979cb1eb048 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", From 5def77c0a2363b651cb6aaafeda180025379d8e1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 6 May 2026 00:13:09 -0700 Subject: [PATCH 9/9] fix tests --- apps/sim/tools/function/execute.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts index e1a966fe94d..73eb21de9e6 100644 --- a/apps/sim/tools/function/execute.test.ts +++ b/apps/sim/tools/function/execute.test.ts @@ -56,10 +56,17 @@ describe('Function Execute Tool', () => { blockData: {}, blockNameMapping: {}, blockOutputSchemas: {}, + contextVariables: {}, isCustomTool: false, language: 'javascript', + outputFormat: undefined, + outputMimeType: undefined, + outputPath: undefined, + outputSandboxPath: undefined, + outputTable: undefined, timeout: 5000, workflowId: undefined, + workspaceId: undefined, userId: undefined, }) }) @@ -85,9 +92,16 @@ describe('Function Execute Tool', () => { blockData: {}, blockNameMapping: {}, blockOutputSchemas: {}, + contextVariables: {}, isCustomTool: false, language: 'javascript', + outputFormat: undefined, + outputMimeType: undefined, + outputPath: undefined, + outputSandboxPath: undefined, + outputTable: undefined, workflowId: undefined, + workspaceId: undefined, userId: undefined, }) }) @@ -105,9 +119,16 @@ describe('Function Execute Tool', () => { blockData: {}, blockNameMapping: {}, blockOutputSchemas: {}, + contextVariables: {}, isCustomTool: false, language: 'javascript', + outputFormat: undefined, + outputMimeType: undefined, + outputPath: undefined, + outputSandboxPath: undefined, + outputTable: undefined, workflowId: undefined, + workspaceId: undefined, userId: undefined, }) })