From 83d361eac0c5bb08663a3288bb0966e015f8d026 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sat, 12 Sep 2026 18:08:32 -0700 Subject: [PATCH 1/5] fix(workflows): split multi-select values without tearing references A `` or `{{ENV_VAR}}` token may legitimately contain a comma (``), and its fragments read as plain literals once split, so a naive `.split(',')` turns one dynamically bound value into several bogus ids. Adds `splitOutsideReferences`, which treats only commas outside every reference token as separators. Token spans are marked once into a lookup rather than rescanned per comma: a per-comma `tokens.some()` is O(commas x tokens) and took ~2.5s on a 240KB value of repeated `{{A}},`, which is reachable on the 10MB graph-write paths. Known limitation, unchanged from the `.split(',')` this replaces and covered by a characterization test: the tokenizer suppresses a workflow span that overlaps an environment token, so `` still splits. --- .../workflows/sanitization/references.test.ts | 54 +++++++++++++++++++ .../lib/workflows/sanitization/references.ts | 39 ++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/apps/sim/lib/workflows/sanitization/references.test.ts b/apps/sim/lib/workflows/sanitization/references.test.ts index 42786b5b340..4b2834ca575 100644 --- a/apps/sim/lib/workflows/sanitization/references.test.ts +++ b/apps/sim/lib/workflows/sanitization/references.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { containsReference, isLikelyReferenceSegment, + splitOutsideReferences, splitReferenceSegment, } from '@/lib/workflows/sanitization/references' @@ -90,3 +91,56 @@ describe('containsReference', () => { expect(containsReference('ad')).toBe(false) }) }) + +describe('splitOutsideReferences', () => { + it('splits on separator commas', () => { + expect(splitOutsideReferences('kb_a,kb_b')).toEqual(['kb_a', 'kb_b']) + }) + + it('trims entries and drops empties', () => { + expect(splitOutsideReferences(' kb_a , , kb_b ')).toEqual(['kb_a', 'kb_b']) + }) + + it('keeps a comma that sits inside a workflow reference', () => { + expect(splitOutsideReferences('')).toEqual(['']) + }) + + it('keeps a comma inside a reference while still splitting around it', () => { + expect(splitOutsideReferences('kb_a,,kb_b')).toEqual([ + 'kb_a', + '', + 'kb_b', + ]) + }) + + it('keeps a comma inside an env-var placeholder', () => { + expect(splitOutsideReferences('{{A,B}},kb_a')).toEqual(['{{A,B}}', 'kb_a']) + }) + + it('returns a single entry when there is no separator', () => { + expect(splitOutsideReferences('kb_a')).toEqual(['kb_a']) + }) + + it('stays linear on a large value instead of rescanning tokens per comma', () => { + // A per-comma `tokens.some()` is O(commas x tokens) and took ~2.5s on this input. + const value = '{{A}},'.repeat(40000) + const startedAt = performance.now() + const parts = splitOutsideReferences(value) + const elapsedMs = performance.now() - startedAt + + expect(parts).toHaveLength(40000) + expect(elapsedMs).toBeLessThan(1000) + }) + + it('does not yet protect a comma inside a reference that nests an env-var placeholder', () => { + // Known limitation, unchanged from the plain `.split(',')` this replaced: the tokenizer + // reports the inner `{{A}}` and suppresses the outer `<...>` span, so the comma reads as a + // separator. Characterized rather than fixed - the suppression lives in the shared + // `@sim/utils/workflow-references` tokenizer. + expect(splitOutsideReferences(',kb_literal')).toEqual([ + '', + 'kb_literal', + ]) + }) +}) diff --git a/apps/sim/lib/workflows/sanitization/references.ts b/apps/sim/lib/workflows/sanitization/references.ts index 5579605148e..1661bb9f626 100644 --- a/apps/sim/lib/workflows/sanitization/references.ts +++ b/apps/sim/lib/workflows/sanitization/references.ts @@ -23,6 +23,45 @@ export function containsReference(value: unknown): boolean { return findWorkflowReferenceTokens(value).length > 0 } +/** + * Split a comma-separated multi-select value without tearing a reference apart. + * + * A `` or `{{ENV_VAR}}` token may legitimately contain a comma (``), + * and its fragments read as plain literals once split, so a naive `.split(',')` turns one dynamic + * value into several bogus ones. Only commas outside every reference token are separators. + */ +export function splitOutsideReferences(value: string): string[] { + const tokens = findWorkflowReferenceTokens(value) + if (tokens.length === 0) { + return value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + } + + // Marked once up front rather than scanned per comma: a per-comma `tokens.some()` is + // O(commas x tokens), which reached ~2.5s on a 240KB value of repeated `{{A}},`. + const insideReference = new Uint8Array(value.length) + for (const token of tokens) { + const end = Math.min(token.end, value.length) + for (let index = Math.max(token.start, 0); index < end; index += 1) { + insideReference[index] = 1 + } + } + + const parts: string[] = [] + let partStart = 0 + for (let index = 0; index < value.length; index += 1) { + if (value[index] === ',' && !insideReference[index]) { + parts.push(value.slice(partStart, index)) + partStart = index + 1 + } + } + parts.push(value.slice(partStart)) + + return parts.map((part) => part.trim()).filter(Boolean) +} + export function extractReferencePrefixes(value: string): Array<{ raw: string; prefix: string }> { if (!value || typeof value !== 'string') { return [] From 36c2ea54e49be38d12a9856e3f49acad168e5443 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sat, 12 Sep 2026 18:08:44 -0700 Subject: [PATCH 2/5] fix(workflows): skip dynamically bound selectors in id validation Tier-2 selector validation is a static id-existence check against the workspace, so it cannot evaluate a value whose id only arrives at execution time. A `` or `{{ENV_VAR}}` binding written into a selector field was therefore reported as a resource that does not exist, on every graph write. `collectSelectorFields` now skips those values via the existing `containsReference`, and splits multi-select values with `splitOutsideReferences` so a reference containing a comma is not torn into fragments that each get validated as an id. Filtering is per entry rather than on the whole string: a multi-select can mix literal ids with dynamic ones, and testing `,kb_real,` as a whole would drop `kb_real` along with the references. Verified end to end against a local dev server: the three reference forms drop from one unresolved-reference finding each to zero, while a literal id that does not resolve still reports one. --- .../editing/selector-reference-guard.test.ts | 78 ++++ .../lib/workflows/editing/validation.test.ts | 359 ++++++++++++++++++ apps/sim/lib/workflows/editing/validation.ts | 19 +- 3 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/workflows/editing/selector-reference-guard.test.ts diff --git a/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts b/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts new file mode 100644 index 00000000000..55336ee6286 --- /dev/null +++ b/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + * + * Integration coverage for the Tier-2 reference guard, against the REAL block registry. + * + * `validation.test.ts` exercises the same guard with hand-written block fixtures, which cannot + * catch a fixture that has drifted from the shipped block config. This file unmocks the registry + * so the canonical pair, its `mode`s and its sub-block types come from `knowledge.ts` itself. + * Only the database lookup is mocked - it is the one dependency the guard deliberately protects. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('@/blocks/registry') + +const { mockValidateSelectorIds } = vi.hoisted(() => ({ + mockValidateSelectorIds: vi.fn(), +})) + +vi.mock('@/lib/workflows/editing/selector-validator', () => ({ + validateSelectorIds: mockValidateSelectorIds, +})) + +import { collectUnresolvedReferences } from '@/lib/workflows/editing/validation' +import { getBlock } from '@/blocks/registry' + +const CTX = { userId: 'user-1', workspaceId: 'workspace-1' } + +/** The real basic member of the knowledge block's `knowledgeBaseId` canonical pair. */ +const KB_SELECTOR_ID = 'knowledgeBaseSelector' + +function knowledgeGraph(value: string) { + return { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { [KB_SELECTOR_ID]: { value } }, + }, + }, + } +} + +describe('Tier-2 reference guard (real block registry)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) + }) + + it('the fixture matches the shipped knowledge block, so the cases below are meaningful', () => { + const config = getBlock('knowledge') + const member = config?.subBlocks?.find((s) => s.id === KB_SELECTOR_ID) + expect(member).toBeDefined() + expect(member?.type).toBe('knowledge-base-selector') + expect(member?.canonicalParamId).toBe('knowledgeBaseId') + expect(member?.mode).toBe('basic') + }) + + it.each([ + ['a block-output reference', ''], + ['an env-var reference', '{{KB_ID}}'], + ['a partially templated value', 'kb_'], + ])('never hits the database for %s', async (_label, value) => { + // Seeded so `toHaveLength(0)` is load-bearing: with a permissive mock it would pass + // whether or not the guard ran. + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [value] }) + const refs = await collectUnresolvedReferences(knowledgeGraph(value), CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('still reports a literal id that does not resolve', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_gone'] }) + const refs = await collectUnresolvedReferences(knowledgeGraph('kb_gone'), CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith('knowledge-base-selector', 'kb_gone', CTX) + expect(refs).toHaveLength(1) + expect(refs[0]).toMatchObject({ blockId: 'kb1', field: KB_SELECTOR_ID, kind: 'resource' }) + }) +}) diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index bd1a65a2d89..b21a3982e43 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -1197,6 +1197,365 @@ describe('collectUnresolvedReferences', () => { expect(refs).toHaveLength(1) expect(refs[0]).toMatchObject({ field: 'credential', kind: 'credential' }) }) + + it('does not validate a selector holding a reference', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [''] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('does not validate a selector holding a {{ENV_VAR}} reference', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['{{KB_ID}}'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '{{KB_ID}}' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('does not validate a partially templated selector value', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('validates only the literal ids in a mixed comma-separated value', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_missing,' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + ['kb_missing'], + CTX + ) + expect(refs).toHaveLength(1) + }) + + it('validates the literal entries when a multi-select opens AND closes with a template', async () => { + // `isReference` is unanchored (startsWith '<' && endsWith '>'), so this value reads as one + // whole reference. Filtering per entry is what keeps `kb_real` validated. + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_real'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: ',kb_real,' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + ['kb_real'], + CTX + ) + expect(refs).toHaveLength(1) + }) + + it('never hits the database when every entry of a mixed-delimiter list is templated', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['{{A}}'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '{{A}},' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('still validates a plain literal id (the guard must not over-skip)', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_missing' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + 'kb_missing', + CTX + ) + expect(refs).toHaveLength(1) + }) + + // `splitOutsideReferences` is what keeps a comma INSIDE a reference from becoming a separator. + // Every other reference test above would still pass with a naive `.split(',')` (no comma -> + // never split at all), so these two are the only ones that pin the reference-aware split from + // the consumer's side: a torn reference reads as plain literals and gets validated as ids. + it('does not split a reference that contains a comma', async () => { + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('does not split a {{ENV_VAR}} reference that contains a comma', async () => { + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: '{{KB_A,KB_B}}' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('skips a comma-separated value whose entries are ALL templates', async () => { + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: ',{{KB_ID}}' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + // A multi-select that already stores a native array never reaches the comma split, so the + // array filter is entered by a second, independent route. + it('filters templates out of a value that is already an array', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: ['kb_missing', '', '{{KB_ID}}'] } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + ['kb_missing'], + CTX + ) + expect(refs).toHaveLength(1) + }) + + it('skips an array value whose entries are ALL templates', async () => { + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: ['', '{{KB_ID}}'] } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + // A separator-only string is truthy, so it survives the `!subBlockValue` bail and reaches the + // split, which returns nothing. Reusing the all-references bail is what stops an empty list + // from being sent to the database as if it were a set of ids. + it('skips a value that is nothing but separators', async () => { + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value: ' , , ' } } }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + it('skips an empty array value rather than validating an empty list', async () => { + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value: [] } } }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + + // The per-entry filter calls `containsReference` on whatever the array holds, so its + // non-string bail is load-bearing here - without it a numeric entry throws. A non-string can + // never be a reference, so it must survive untouched, `null` included: the `filter(Boolean)` + // that would have dropped it lives inside the comma split, which a native array never reaches. + it('does not throw on a non-string entry inside an array value', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: [42, null, 'kb_ok', ''] } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + [42, null, 'kb_ok'], + CTX + ) + expect(refs).toHaveLength(0) + }) + + // Both delimiters are required. A lone `<` (or a lone `{{`) is a malformed literal, not a + // template, and must keep being reported rather than silently waved through. + it.each([ + ['an unclosed < delimiter', 'kb_ delimiter', 'start.kbId>'], + ['an unclosed {{ delimiter', 'kb_{{KB_ID'], + ])('still validates a value with %s', async (_label, value) => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [value] }) + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value } } }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith('knowledge-base-selector', value, CTX) + expect(refs).toHaveLength(1) + }) + + it('drops whitespace-only entries without validating an empty id', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_missing, , ,' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).toHaveBeenCalledWith( + 'knowledge-base-selector', + ['kb_missing'], + CTX + ) + expect(refs).toHaveLength(1) + }) + + // The guard runs AFTER the canonical active-member check, so a template in the active member + // must be skipped by the guard - and must not push mode resolution onto the empty twin. + it('skips a template held by the ACTIVE canonical member', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [''] }) + const state = { + blocks: { + c1: { + type: 'canonicalcred', + name: 'Cred', + subBlocks: { credential: { value: '' }, manualCredential: { value: '' } }, + }, + }, + } + const refs = await collectUnresolvedReferences(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) +}) + +// The lint path (collectUnresolvedReferences) and the agent edit path share collectSelectorFields, +// but only the edit path can REJECT an operation. A dynamically-bound selector must not block an +// edit - that rejection is the user-visible failure this guard exists to prevent. +describe('validateWorkflowSelectorIds (reference guard)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [] }) + }) + + it.each([ + ['a block-output reference', ''], + ['an env-var reference', '{{KB_ID}}'], + ['a partially templated value', 'kb_'], + ])('does not reject an edit whose selector holds %s', async (_label, value) => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: [value] }) + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value } } }, + }, + } + const errors = await validateWorkflowSelectorIds(state, CTX) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(errors).toHaveLength(0) + }) + + it('still rejects an edit whose selector holds a literal id that does not resolve', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_gone'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: 'kb_gone' } }, + }, + }, + } + const errors = await validateWorkflowSelectorIds(state, CTX) + expect(errors).toHaveLength(1) + expect(errors[0]?.error).toContain('kb_gone') + }) }) describe('validateInputsForBlock - agent tools (tool-input)', () => { diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index ec2a366a1bd..1ce3f2b9c15 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -10,7 +10,7 @@ import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' -import { containsReference } from '@/lib/workflows/sanitization/references' +import { containsReference, splitOutsideReferences } from '@/lib/workflows/sanitization/references' import { getSkillById } from '@/lib/workflows/skills/operations' import { buildCanonicalIndex, @@ -1139,10 +1139,19 @@ function collectSelectorFields( // Handle comma-separated values for multi-select let values: string | string[] = subBlockValue if (typeof subBlockValue === 'string' && subBlockValue.includes(',')) { - values = subBlockValue - .split(',') - .map((v: string) => v.trim()) - .filter(Boolean) + values = splitOutsideReferences(subBlockValue) + } + + // A dynamically bound value only acquires its id at execution time, so a static + // id-existence check cannot evaluate it. Filtered per entry rather than on the whole + // string, because a multi-select can mix literal ids with dynamic ones: testing + // `,kb_real,` as a whole would drop `kb_real` along with the references. + if (Array.isArray(values)) { + const literalValues = values.filter((entry) => !containsReference(entry)) + if (literalValues.length === 0) continue + values = literalValues + } else if (containsReference(values)) { + continue } fields.push({ From 3ee839576fef76f18d9b6b6e6aa2e970e5f5c4f7 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sat, 12 Sep 2026 18:33:12 -0700 Subject: [PATCH 3/5] fix(workflows): protect references that nest an env-var placeholder Review round 1. `findWorkflowReferenceTokens` is contractually non-overlapping, so for `` it reports only the inner `{{B}}` and discards the outer candidate. That is correct for a tokenizer and wrong for a splitter, which needs the union of protected regions rather than a disjoint set, so the comma was unprotected and `c)>` was validated as a literal id. Adds a candidate pass built from the tokenizer's own exported predicates, leaving the shared package's non-overlapping contract untouched. It runs only when an environment token is present, since overlap with one is the only reason a workflow candidate is dropped. Also caps the value length before tokenizing. Reference detection parses the whole string and the tokenizer is superlinear in candidate count (795ms for a 240KB value of repeated `,`), which this change newly puts on a write path that admits megabytes. Past the cap the field is skipped rather than parsed; the lint is advisory, so declining to check is the safe direction. Adds `as const` to the test context object per the repo's TypeScript conventions. --- .../editing/selector-reference-guard.test.ts | 2 +- .../lib/workflows/editing/validation.test.ts | 20 +++++++ apps/sim/lib/workflows/editing/validation.ts | 12 +++++ .../workflows/sanitization/references.test.ts | 21 +++++--- .../lib/workflows/sanitization/references.ts | 54 +++++++++++++++++-- 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts b/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts index 55336ee6286..0853eb0eeb2 100644 --- a/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts +++ b/apps/sim/lib/workflows/editing/selector-reference-guard.test.ts @@ -23,7 +23,7 @@ vi.mock('@/lib/workflows/editing/selector-validator', () => ({ import { collectUnresolvedReferences } from '@/lib/workflows/editing/validation' import { getBlock } from '@/blocks/registry' -const CTX = { userId: 'user-1', workspaceId: 'workspace-1' } +const CTX = { userId: 'user-1', workspaceId: 'workspace-1' } as const /** The real basic member of the knowledge block's `knowledgeBaseId` canonical pair. */ const KB_SELECTOR_ID = 'knowledgeBaseSelector' diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index b21a3982e43..66bcecca30d 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -1304,6 +1304,26 @@ describe('collectUnresolvedReferences', () => { expect(refs).toHaveLength(0) }) + it('skips an oversized selector value instead of tokenizing it', async () => { + // Reference detection tokenizes the whole string and the tokenizer is superlinear in + // candidate count, so an implausible value is skipped rather than parsed. + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['x'] }) + const state = { + blocks: { + kb1: { + type: 'knowledge', + name: 'KB', + subBlocks: { knowledgeBaseId: { value: `kb_${'a'.repeat(10_000)}` } }, + }, + }, + } + const startedAt = performance.now() + const refs = await collectUnresolvedReferences(state, CTX) + expect(performance.now() - startedAt).toBeLessThan(1000) + expect(mockValidateSelectorIds).not.toHaveBeenCalled() + expect(refs).toHaveLength(0) + }) + it('still validates a plain literal id (the guard must not over-skip)', async () => { mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) const state = { diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 1ce3f2b9c15..0c8cc4c6ff5 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -1082,6 +1082,15 @@ interface SelectorFieldToValidate { value: string | string[] } +/** + * A selector value is an id, or a short comma-separated list of them. Reference detection + * tokenizes the whole string and `findWorkflowReferenceTokens` is superlinear in candidate count, + * so an oversized value is both implausible and expensive on a write path that admits megabytes. + * Past this the field is skipped rather than tokenized: the lint is advisory, so declining to + * check is the safe direction. + */ +const MAX_SELECTOR_VALUE_LENGTH = 10_000 + /** * Walk a workflow state and collect selector/credential fields to validate. * For canonical pairs only the ACTIVE member is collected (an intentionally-empty @@ -1135,6 +1144,9 @@ function collectSelectorFields( const subBlockValue = blockData.subBlocks?.[subBlockConfig.id]?.value if (!subBlockValue) continue + if (typeof subBlockValue === 'string' && subBlockValue.length > MAX_SELECTOR_VALUE_LENGTH) { + continue + } // Handle comma-separated values for multi-select let values: string | string[] = subBlockValue diff --git a/apps/sim/lib/workflows/sanitization/references.test.ts b/apps/sim/lib/workflows/sanitization/references.test.ts index 4b2834ca575..23d2416ee06 100644 --- a/apps/sim/lib/workflows/sanitization/references.test.ts +++ b/apps/sim/lib/workflows/sanitization/references.test.ts @@ -132,15 +132,22 @@ describe('splitOutsideReferences', () => { expect(elapsedMs).toBeLessThan(1000) }) - it('does not yet protect a comma inside a reference that nests an env-var placeholder', () => { - // Known limitation, unchanged from the plain `.split(',')` this replaced: the tokenizer - // reports the inner `{{A}}` and suppresses the outer `<...>` span, so the comma reads as a - // separator. Characterized rather than fixed - the suppression lives in the shared - // `@sim/utils/workflow-references` tokenizer. + it('protects a comma inside a reference that nests an env-var placeholder', () => { + // `findWorkflowReferenceTokens` is non-overlapping, so it reports only the inner `{{A}}` and + // drops the outer candidate. The candidate pass is what keeps the outer region protected. expect(splitOutsideReferences(',kb_literal')).toEqual([ - '', + '', 'kb_literal', ]) + expect(splitOutsideReferences(',kb_literal')).toEqual([ + '', + 'kb_literal', + ]) + }) + + it('still splits a near-miss that does not read as a reference', () => { + // `` fails `isLikelyReferenceSegment` (the `+`), so it is not a protected region. + // Loud rather than silent: the fragments are reported as ids that do not resolve. + expect(splitOutsideReferences('')).toEqual(['']) }) }) diff --git a/apps/sim/lib/workflows/sanitization/references.ts b/apps/sim/lib/workflows/sanitization/references.ts index 1661bb9f626..c886826ff41 100644 --- a/apps/sim/lib/workflows/sanitization/references.ts +++ b/apps/sim/lib/workflows/sanitization/references.ts @@ -23,32 +23,78 @@ export function containsReference(value: unknown): boolean { return findWorkflowReferenceTokens(value).length > 0 } +/** + * Mark every `<...>` region that reads as a workflow reference, including one the tokenizer + * dropped for overlapping an `{{ENV_VAR}}` token. + * + * `findWorkflowReferenceTokens` is contractually NON-overlapping, so for `` it + * reports only the inner `{{B}}` and discards the outer candidate. That is right for a tokenizer + * and wrong for a splitter, which needs the UNION of protected regions rather than a disjoint set. + */ +function markCandidateReferenceRegions(value: string, insideReference: Uint8Array): void { + let candidateStart = -1 + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + if (character === REFERENCE.START && candidateStart === -1) { + candidateStart = index + continue + } + if (character === '\r' || character === '\n') { + candidateStart = -1 + continue + } + if (character !== REFERENCE.END || candidateStart === -1) continue + + const candidate = value.slice(candidateStart, index + REFERENCE.END.length) + const split = splitReferenceSegment(candidate) + if (split && isLikelyReferenceSegment(candidate)) { + const start = candidateStart + split.leading.length + const end = Math.min(start + split.reference.length, value.length) + for (let position = start; position < end; position += 1) { + insideReference[position] = 1 + } + } + candidateStart = -1 + } +} + /** * Split a comma-separated multi-select value without tearing a reference apart. * * A `` or `{{ENV_VAR}}` token may legitimately contain a comma (``), * and its fragments read as plain literals once split, so a naive `.split(',')` turns one dynamic - * value into several bogus ones. Only commas outside every reference token are separators. + * value into several bogus ones. Only commas outside every reference region are separators. */ export function splitOutsideReferences(value: string): string[] { - const tokens = findWorkflowReferenceTokens(value) - if (tokens.length === 0) { - return value + const plainSplit = () => + value .split(',') .map((part) => part.trim()) .filter(Boolean) + + if (!value.includes(REFERENCE.START) && !value.includes(REFERENCE.ENV_VAR_START)) { + return plainSplit() } // Marked once up front rather than scanned per comma: a per-comma `tokens.some()` is // O(commas x tokens), which reached ~2.5s on a 240KB value of repeated `{{A}},`. + const tokens = findWorkflowReferenceTokens(value) const insideReference = new Uint8Array(value.length) + let hasEnvironmentToken = false for (const token of tokens) { + if (token.kind === 'environment') hasEnvironmentToken = true const end = Math.min(token.end, value.length) for (let index = Math.max(token.start, 0); index < end; index += 1) { insideReference[index] = 1 } } + // Overlap with an environment token is the only reason the tokenizer drops a workflow + // candidate, so without one it already reported every region and re-scanning is duplicate work. + if (hasEnvironmentToken) { + markCandidateReferenceRegions(value, insideReference) + } + const parts: string[] = [] let partStart = 0 for (let index = 0; index < value.length; index += 1) { From 7d2fe3384f6e45ac01b9c3cf22e155e83641f204 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sat, 12 Sep 2026 18:44:30 -0700 Subject: [PATCH 4/5] fix(workflows): keep validating literal entries of an oversized selector value Review round 2. The length cap skipped the whole field, so an oversized list of plain literal ids lost validation it previously had. Literals never needed tokenization, so the cap was broader than the cost it was there to bound. It now gives up only the reference-aware split: past the cap the value is split plainly, and its entries are classified and validated as usual, since they are short enough that the tokenizer's per-candidate cost does not apply (1MB of literal ids across 30000 entries measures ~9ms). Only an individual entry past the cap is skipped, where there is no cheap way to tell a literal from a dynamic binding. --- .../lib/workflows/editing/validation.test.ts | 26 ++++++++++++++-- apps/sim/lib/workflows/editing/validation.ts | 31 ++++++++++++------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index 66bcecca30d..30f88921003 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -1304,9 +1304,7 @@ describe('collectUnresolvedReferences', () => { expect(refs).toHaveLength(0) }) - it('skips an oversized selector value instead of tokenizing it', async () => { - // Reference detection tokenizes the whole string and the tokenizer is superlinear in - // candidate count, so an implausible value is skipped rather than parsed. + it('skips a single oversized entry, which cannot be classified cheaply', async () => { mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['x'] }) const state = { blocks: { @@ -1324,6 +1322,28 @@ describe('collectUnresolvedReferences', () => { expect(refs).toHaveLength(0) }) + it('still validates the literal entries of an oversized list', async () => { + // The cap gives up reference-aware splitting, not validation: the entries are still short, + // so each is classified and the literals are still checked. + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const padding = Array.from({ length: 400 }, (_, index) => `kb_${'x'.repeat(30)}${index}`) + const value = [...padding, '', 'kb_missing'].join(',') + expect(value.length).toBeGreaterThan(10_000) + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value } } }, + }, + } + const startedAt = performance.now() + const refs = await collectUnresolvedReferences(state, CTX) + + expect(performance.now() - startedAt).toBeLessThan(1000) + const [, ids] = mockValidateSelectorIds.mock.calls[0] + expect(ids).toContain('kb_missing') + expect(ids).not.toContain('') + expect(refs).toHaveLength(1) + }) + it('still validates a plain literal id (the guard must not over-skip)', async () => { mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) const state = { diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 0c8cc4c6ff5..46747c6c69f 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -1083,11 +1083,13 @@ interface SelectorFieldToValidate { } /** - * A selector value is an id, or a short comma-separated list of them. Reference detection - * tokenizes the whole string and `findWorkflowReferenceTokens` is superlinear in candidate count, - * so an oversized value is both implausible and expensive on a write path that admits megabytes. - * Past this the field is skipped rather than tokenized: the lint is advisory, so declining to - * check is the safe direction. + * Longest selector string reference detection will tokenize. + * + * `findWorkflowReferenceTokens` parses the whole string and is superlinear in candidate count, so + * an oversized value is expensive on a write path that admits megabytes. Past this a value is + * split plainly instead: its entries are still short, so each one is classified and validated as + * usual, and only the comma-inside-a-reference protection is given up. An individual ENTRY past + * the cap is skipped, since there is no cheap way to tell a literal from a dynamic binding. */ const MAX_SELECTOR_VALUE_LENGTH = 10_000 @@ -1144,14 +1146,19 @@ function collectSelectorFields( const subBlockValue = blockData.subBlocks?.[subBlockConfig.id]?.value if (!subBlockValue) continue - if (typeof subBlockValue === 'string' && subBlockValue.length > MAX_SELECTOR_VALUE_LENGTH) { - continue - } + + const isOversized = (entry: unknown) => + typeof entry === 'string' && entry.length > MAX_SELECTOR_VALUE_LENGTH // Handle comma-separated values for multi-select let values: string | string[] = subBlockValue if (typeof subBlockValue === 'string' && subBlockValue.includes(',')) { - values = splitOutsideReferences(subBlockValue) + values = isOversized(subBlockValue) + ? subBlockValue + .split(',') + .map((entry: string) => entry.trim()) + .filter(Boolean) + : splitOutsideReferences(subBlockValue) } // A dynamically bound value only acquires its id at execution time, so a static @@ -1159,10 +1166,12 @@ function collectSelectorFields( // string, because a multi-select can mix literal ids with dynamic ones: testing // `,kb_real,` as a whole would drop `kb_real` along with the references. if (Array.isArray(values)) { - const literalValues = values.filter((entry) => !containsReference(entry)) + const literalValues = values.filter( + (entry) => !isOversized(entry) && !containsReference(entry) + ) if (literalValues.length === 0) continue values = literalValues - } else if (containsReference(values)) { + } else if (isOversized(values) || containsReference(values)) { continue } From 8bc634a3819ea3abeaf730ac0769b1d9127397ed Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sat, 12 Sep 2026 19:01:47 -0700 Subject: [PATCH 5/5] fix(workflows): split references without the non-overlapping token pass Review round 3. An oversized value fell back to plain splitting, which tore a comma-bearing reference into fragments that were then validated as literal ids. The fallback existed to avoid `findWorkflowReferenceTokens`, which is superlinear in candidate count. That pass was never needed here. It returns contractually NON-overlapping tokens, and the O(tokens^2) overlap check is the cost of producing that partition. A splitter only needs to know whether an index sits inside SOME reference, so scanning environment placeholders and `<...>` candidates independently gives the union directly - cheaper and more accurate, since nothing is suppressed. Splitting is now linear and the size fallback is gone, so a comma-bearing reference survives at any length: 240KB of `,` 682ms -> 15ms 240KB of `,` 177ms -> 12ms 1MB of literal ids 1ms The length cap now applies to a single ENTRY rather than the whole field, which is all it was ever needed for: classifying one entry tokenizes it, and there is no cheap way to tell a literal from a dynamic binding past that size. Exports `ENV_REFERENCE_PATTERN` from `@sim/utils` rather than duplicating the pattern, so the two stay in step. --- .../lib/workflows/editing/validation.test.ts | 20 +++++++ apps/sim/lib/workflows/editing/validation.ts | 23 ++++---- .../lib/workflows/sanitization/references.ts | 53 ++++++++----------- packages/utils/src/workflow-references.ts | 6 ++- 4 files changed, 55 insertions(+), 47 deletions(-) diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index 30f88921003..3aae3aa0ad4 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -1322,6 +1322,26 @@ describe('collectUnresolvedReferences', () => { expect(refs).toHaveLength(0) }) + it('keeps a comma-bearing reference intact even in an oversized list', async () => { + // Splitting scans reference regions directly and stays linear, so size does not force a + // fallback that would tear `` into fragments validated as ids. + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['kb_missing'] }) + const padding = Array.from({ length: 400 }, (_, index) => `kb_${'x'.repeat(30)}${index}`) + const value = [...padding, '', 'kb_missing'].join(',') + expect(value.length).toBeGreaterThan(10_000) + const state = { + blocks: { + kb1: { type: 'knowledge', name: 'KB', subBlocks: { knowledgeBaseId: { value } } }, + }, + } + await collectUnresolvedReferences(state, CTX) + + const [, ids] = mockValidateSelectorIds.mock.calls[0] + expect(ids).toContain('kb_missing') + expect(ids).not.toContain('') + }) + it('still validates the literal entries of an oversized list', async () => { // The cap gives up reference-aware splitting, not validation: the entries are still short, // so each is classified and the literals are still checked. diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 46747c6c69f..a9ad055967a 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -1083,15 +1083,15 @@ interface SelectorFieldToValidate { } /** - * Longest selector string reference detection will tokenize. + * Longest single selector entry `containsReference` will classify. * - * `findWorkflowReferenceTokens` parses the whole string and is superlinear in candidate count, so - * an oversized value is expensive on a write path that admits megabytes. Past this a value is - * split plainly instead: its entries are still short, so each one is classified and validated as - * usual, and only the comma-inside-a-reference protection is given up. An individual ENTRY past - * the cap is skipped, since there is no cheap way to tell a literal from a dynamic binding. + * Classifying one entry tokenizes it, and `findWorkflowReferenceTokens` is superlinear in + * candidate count, so an entry of unbounded length is expensive on a write path that admits + * megabytes. Splitting is unaffected - it scans reference regions directly and stays linear - so + * only an individual oversized ENTRY is skipped, where there is no cheap way to tell a literal + * from a dynamic binding. A long LIST of ordinary ids still splits and validates normally. */ -const MAX_SELECTOR_VALUE_LENGTH = 10_000 +const MAX_SELECTOR_ENTRY_LENGTH = 10_000 /** * Walk a workflow state and collect selector/credential fields to validate. @@ -1148,17 +1148,12 @@ function collectSelectorFields( if (!subBlockValue) continue const isOversized = (entry: unknown) => - typeof entry === 'string' && entry.length > MAX_SELECTOR_VALUE_LENGTH + typeof entry === 'string' && entry.length > MAX_SELECTOR_ENTRY_LENGTH // Handle comma-separated values for multi-select let values: string | string[] = subBlockValue if (typeof subBlockValue === 'string' && subBlockValue.includes(',')) { - values = isOversized(subBlockValue) - ? subBlockValue - .split(',') - .map((entry: string) => entry.trim()) - .filter(Boolean) - : splitOutsideReferences(subBlockValue) + values = splitOutsideReferences(subBlockValue) } // A dynamically bound value only acquires its id at execution time, so a static diff --git a/apps/sim/lib/workflows/sanitization/references.ts b/apps/sim/lib/workflows/sanitization/references.ts index c886826ff41..fd5963f31c8 100644 --- a/apps/sim/lib/workflows/sanitization/references.ts +++ b/apps/sim/lib/workflows/sanitization/references.ts @@ -1,4 +1,5 @@ import { + ENV_REFERENCE_PATTERN, findWorkflowReferenceTokens, isLikelyWorkflowReferenceSegment, splitWorkflowReferenceSegment, @@ -24,14 +25,24 @@ export function containsReference(value: unknown): boolean { } /** - * Mark every `<...>` region that reads as a workflow reference, including one the tokenizer - * dropped for overlapping an `{{ENV_VAR}}` token. + * Mark every region of `value` that belongs to a reference, as a union rather than a partition. * - * `findWorkflowReferenceTokens` is contractually NON-overlapping, so for `` it - * reports only the inner `{{B}}` and discards the outer candidate. That is right for a tokenizer - * and wrong for a splitter, which needs the UNION of protected regions rather than a disjoint set. + * Deliberately does NOT use `findWorkflowReferenceTokens`. That returns contractually + * non-overlapping tokens, which costs an O(tokens^2) overlap check and drops a `<...>` candidate + * that wraps an `{{ENV_VAR}}` - both wrong here. A splitter only needs to know whether an index is + * inside SOME reference, so scanning each kind independently is both cheaper and more accurate. */ -function markCandidateReferenceRegions(value: string, insideReference: Uint8Array): void { +function markReferenceRegions(value: string, insideReference: Uint8Array): void { + const mark = (start: number, end: number) => { + for (let index = Math.max(start, 0); index < Math.min(end, value.length); index += 1) { + insideReference[index] = 1 + } + } + + for (const match of value.matchAll(ENV_REFERENCE_PATTERN)) { + mark(match.index, match.index + match[0].length) + } + let candidateStart = -1 for (let index = 0; index < value.length; index += 1) { const character = value[index] @@ -49,10 +60,7 @@ function markCandidateReferenceRegions(value: string, insideReference: Uint8Arra const split = splitReferenceSegment(candidate) if (split && isLikelyReferenceSegment(candidate)) { const start = candidateStart + split.leading.length - const end = Math.min(start + split.reference.length, value.length) - for (let position = start; position < end; position += 1) { - insideReference[position] = 1 - } + mark(start, start + split.reference.length) } candidateStart = -1 } @@ -66,34 +74,15 @@ function markCandidateReferenceRegions(value: string, insideReference: Uint8Arra * value into several bogus ones. Only commas outside every reference region are separators. */ export function splitOutsideReferences(value: string): string[] { - const plainSplit = () => - value + if (!value.includes(REFERENCE.START) && !value.includes(REFERENCE.ENV_VAR_START)) { + return value .split(',') .map((part) => part.trim()) .filter(Boolean) - - if (!value.includes(REFERENCE.START) && !value.includes(REFERENCE.ENV_VAR_START)) { - return plainSplit() } - // Marked once up front rather than scanned per comma: a per-comma `tokens.some()` is - // O(commas x tokens), which reached ~2.5s on a 240KB value of repeated `{{A}},`. - const tokens = findWorkflowReferenceTokens(value) const insideReference = new Uint8Array(value.length) - let hasEnvironmentToken = false - for (const token of tokens) { - if (token.kind === 'environment') hasEnvironmentToken = true - const end = Math.min(token.end, value.length) - for (let index = Math.max(token.start, 0); index < end; index += 1) { - insideReference[index] = 1 - } - } - - // Overlap with an environment token is the only reason the tokenizer drops a workflow - // candidate, so without one it already reported every region and re-scanning is duplicate work. - if (hasEnvironmentToken) { - markCandidateReferenceRegions(value, insideReference) - } + markReferenceRegions(value, insideReference) const parts: string[] = [] let partStart = 0 diff --git a/packages/utils/src/workflow-references.ts b/packages/utils/src/workflow-references.ts index 64bc63b76ab..3fc84c8a1a4 100644 --- a/packages/utils/src/workflow-references.ts +++ b/packages/utils/src/workflow-references.ts @@ -3,7 +3,11 @@ const REFERENCE_END = '>' const REFERENCE_PATH_DELIMITER = '.' const INVALID_REFERENCE_CHARS = /[+*/=<>!&|]/ const LEADING_REFERENCE_PATTERN = /^[<>=!\s]*$/ -const ENV_REFERENCE_PATTERN = /\{\{[^{}\r\n]+\}\}/g +/** + * `{{ENV_VAR}}` placeholders. Exported so a consumer that needs the UNION of reference regions + * can find them without going through the non-overlapping token pass. + */ +export const ENV_REFERENCE_PATTERN = /\{\{[^{}\r\n]+\}\}/g export type WorkflowReferenceTokenKind = 'environment' | 'workflow'