diff --git a/apps/sim/executor/orchestrators/loop.test.ts b/apps/sim/executor/orchestrators/loop.test.ts index 9180fc2bd8d..54f60f13fc7 100644 --- a/apps/sim/executor/orchestrators/loop.test.ts +++ b/apps/sim/executor/orchestrators/loop.test.ts @@ -308,6 +308,62 @@ describe('LoopOrchestrator', () => { ) }) + describe('while condition reference interpolation', () => { + async function evaluateWhileConditionFor(condition: string, resolved: unknown) { + const resolver = { resolveSingleReference: vi.fn().mockResolvedValue(resolved) } + const orchestrator = new LoopOrchestrator( + { loopConfigs: new Map(), parallelConfigs: new Map(), nodes: new Map() } as any, + createState(), + resolver as any + ) + const ctx = createContext({ + iteration: 0, + currentIterationOutputs: new Map(), + allIterationOutputs: [], + loopType: 'while', + condition, + }) + + await orchestrator.evaluateInitialCondition(ctx, 'loop-1') + + return mockExecuteInIsolatedVM.mock.calls[0][0].code as string + } + + it('does not let a quote in a resolved value escape into expression position', async () => { + const payload = 'z" ) && Boolean( "INJ".length === 3 ) && Boolean( "never' + + const code = await evaluateWhileConditionFor(' === "never"', payload) + + expect(new Function(code)()).toBe(false) + expect(code).toBe(`return Boolean(${JSON.stringify(payload)} === "never")`) + }) + + it('escapes backslashes and newlines so the condition stays compilable', async () => { + const code = await evaluateWhileConditionFor(' === "x"', 'a\\"\nb') + + expect(() => new Function(code)).not.toThrow() + expect(new Function(code)()).toBe(false) + }) + + it('escapes line separators that are legal in JSON but not in every JS host', async () => { + const code = await evaluateWhileConditionFor(' === "x"', 'a\u2028b\u2029c') + + expect(code).not.toMatch(/[\u2028\u2029]/) + expect(new Function(code)()).toBe(false) + }) + + it.each([ + ['TRUE', 'return Boolean(true)'], + [' false ', 'return Boolean(false)'], + [7, 'return Boolean(7)'], + [true, 'return Boolean(true)'], + [null, 'return Boolean(null)'], + [{ a: 1 }, 'return Boolean({"a":1})'], + ])('serializes %o as a literal operand', async (resolved, expected) => { + expect(await evaluateWhileConditionFor('', resolved)).toBe(expected) + }) + }) + it('exits doWhile loops when the configured iteration cap is reached', async () => { const { orchestrator } = createOrchestrator() const ctx = createContext({ diff --git a/apps/sim/executor/orchestrators/loop.ts b/apps/sim/executor/orchestrators/loop.ts index 913de932915..35f67b9c393 100644 --- a/apps/sim/executor/orchestrators/loop.ts +++ b/apps/sim/executor/orchestrators/loop.ts @@ -38,6 +38,42 @@ const logger = createLogger('LoopOrchestrator') const LOOP_CONDITION_TIMEOUT_MS = 5000 +/** + * Serializes a resolved reference value into a JavaScript literal for the loop + * condition expression. + * + * The result is concatenated into source that is compiled and run in the + * execution isolate, so every value must be emitted as a self-contained literal. + * Interpolating a string without escaping would let a `"` in the resolved data + * terminate the literal and continue in expression position. + */ +function formatConditionOperand(resolved: unknown): string { + if (typeof resolved === 'boolean' || typeof resolved === 'number') { + return String(resolved) + } + + if (typeof resolved === 'string') { + const lower = resolved.toLowerCase().trim() + if (lower === 'true' || lower === 'false') { + return lower + } + } + + return toJsLiteral(resolved) +} + +/** + * JSON-serializes a value and escapes the code points that are valid inside a + * JSON string but historically hazardous inside a JavaScript source literal. + */ +function toJsLiteral(value: unknown): string { + const serialized = JSON.stringify(value) + if (serialized === undefined) { + return 'undefined' + } + return serialized.replace(/\u2028/g, String.raw`\u2028`).replace(/\u2029/g, String.raw`\u2029`) +} + async function replaceLoopConditionReferences( condition: string, replacer: (match: string) => Promise @@ -724,17 +760,7 @@ export class LoopOrchestrator { resolvedType: resolved === null ? 'null' : typeof resolved, }) if (resolved !== undefined) { - if (typeof resolved === 'boolean' || typeof resolved === 'number') { - return String(resolved) - } - if (typeof resolved === 'string') { - const lower = resolved.toLowerCase().trim() - if (lower === 'true' || lower === 'false') { - return lower - } - return `"${resolved}"` - } - return JSON.stringify(resolved) + return formatConditionOperand(resolved) } return match })