fix: insert patchNodeField literal replacements verbatim, escaping JS replacement patterns (v2.71.1) - #1013
Conversation
… replacement patterns (v2.71.1) In literal mode, patchNodeField handed the replacement string straight to String.prototype.replace(), so JS replacement patterns inside it were interpreted: $' splices in everything after the match, $` everything before it, $& the match itself. A replacement as ordinary as "const money = '$' + amount.toFixed(2)" duplicated the rest of the node's source into the insertion, saved the corrupted workflow (live, if active), and reported success. Literal single-occurrence replacement and the __patch_find_replace path in updateNode now use a function replacer, so replacements are inserted verbatim. Literal replaceAll (split/join) was already safe. Regex mode keeps standard replacement-pattern semantics ($1, $$) by design; the tool docs now state the distinction. Reported with root cause and fix by @NextLevelManagementAdvisors. Fixes #1012 Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a workflow-corruption bug in the diff engine’s string patching paths by ensuring literal-mode replacements are inserted verbatim (so JavaScript $ replacement patterns are not interpreted), which is critical for safely patching code-node fields like parameters.jsCode.
Changes:
- Updated
patchNodeField(literal, single-occurrence path) andupdateNode’s legacy__patch_find_replacepath to use a function replacer (replace(..., () => text)) to prevent JS replacement-pattern parsing. - Added targeted unit tests covering the
$'corruption case, all common JS replacement patterns staying literal in literal mode, and preserved regex-mode$1semantics. - Clarified tool documentation about literal vs regex replacement semantics and bumped version/changelog for v2.71.1.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
src/services/workflow-diff-engine.ts |
Switches literal single-replace paths to function replacers to keep replacement text literal and prevent $-pattern corruption. |
tests/unit/services/workflow-diff-engine.test.ts |
Adds regression tests for $ replacement-pattern cases in literal mode and confirms intended regex-mode $1 behavior. |
src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts |
Documents literal-mode verbatim replacement vs regex-mode JS replacement-pattern behavior. |
package.json |
Bumps package version to 2.71.1. |
package.runtime.json |
Bumps runtime package version to 2.71.1. |
package-lock.json |
Aligns lockfile package versions to 2.71.1. |
CHANGELOG.md |
Adds a 2.71.1 entry describing the fix and intended semantics. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Simplification pass on the #1012 fix: patchNodeField's literal mode now uses split().join() for the single-occurrence case as well — the ambiguity guard above leaves exactly one occurrence, so it is equivalent and removes the branch. The __patch_find_replace path keeps the function replacer (it replaces only the first occurrence). Two overlapping literal-mode tests merged into one covering the #1012 repro plus every replacement pattern; doc and changelog wording tightened to match. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Test Results Summary📊 ArtifactsGenerated at Tue, 18 Aug 2026 21:19:20 GMT |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/services/workflow-diff-engine.ts:1204
- Literal-mode patchNodeField now always uses split/join (even when the ambiguity guard ensures exactly one occurrence). For large string fields this allocates an array of substrings and does a second full scan, which is a measurable memory/CPU regression vs a single
String.replacecall. Consider keeping split/join (or replaceAll) only forreplaceAll: true, and using a function replacer for the single-occurrence case:current = current.replace(patch.find, () => patch.replace)to stay verbatim while avoiding the extra allocations.
// split/join inserts the replacement verbatim; String.replace would read
// "$&", "$'" and friends in it as JS replacement patterns (#1012). Safe
// for the single-occurrence case too: the checks above leave exactly one.
current = current.split(patch.find).join(patch.replace);
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Fixes #1012.
The bug
patchNodeFieldin literal mode (the default) passed the user'sreplacestring directly toString.prototype.replace(), which interprets JS replacement patterns inside the replacement text:$'— splices in everything after the match$`— everything before the match$&— the match itself$1— a capture group reference (or literal, depending)Patching a Code node with something as ordinary as
const money = '$' + amount.toFixed(2)duplicated the rest of the node's source into the middle of the insertion, saved the workflow in that corrupted state — live, if the workflow was active — and reportedsuccess: true. In the reported incident this took down the confirm/decline webhook path of an active payment workflow.The fix
Literal-mode replacements are now inserted verbatim —
patchNodeFielduses a singlesplit().join()path for single-occurrence andreplaceAllalike (the ambiguity guard leaves exactly one occurrence in the single case), and the__patch_find_replacepath, which replaces only the first occurrence, uses a function replacer:applyPatchNodeField, literal single-occurrence pathapplyUpdateNode, the older__patch_find_replacepath (__patch_find_replace in updateNode corrupts jsCode instead of applying patch #642) — same defectLiteral
replaceAllalready usedsplit().join()and was safe. Regex mode is unchanged by design: withregex: true, replacement patterns ($1for capture groups,$$for a literal$) are standard JS regex-replace semantics and now locked in by a test. The tool documentation states the literal-vs-regex distinction explicitly.Tests
$'case from the issue (money-string insertion) — fails on the old code with the spliced corruption, passes now$&,$`,$',$1,$$,$<name>) kept literal in literal mode$in literalreplaceAll(locks the already-safe path)$1capture group reference working in regex mode (locks intended semantics)$'kept literal in__patch_find_replace302 tests pass across the diff-engine suites (after a simplification pass merged two overlapping literal-mode tests); typecheck and build clean.
Not in this PR
The issue also proposes syntax-validating patched code fields (
jsCode/pythonCode) before saving, refusing patches that would write a syntactically invalid body. That's a behavior change deserving its own discussion and PR — see the issue thread.Reported with root cause, repro, and both candidate fixes by @NextLevelManagementAdvisors — thank you for the exemplary report.
Conceived by Romuald Członkowski - www.aiadvisors.pl/en
🤖 Generated with Claude Code