Commit 85df70eecc2 for nodejs

commit 85df70eecc2b7a4c611608f3ffc371e3882ad1a4
Author: Trivikram Kamat <trivikr.dev@gmail.com>
Date:   Sun Oct 4 18:08:04 2026 -0700

    watch: escape quotes and backslashes in NODE_OPTIONS

    When stripping watch flags, NODE_OPTIONS is tokenized and rejoined
    for the child process. Values were only re-quoted if they contained
    a space, and nothing inside the quotes was escaped. A value with a
    double quote made the child fail with "unterminated string", and a
    backslash inside a quoted value was silently dropped.

    Quote values that contain a space or a double quote, and escape
    backslashes and double quotes inside the quotes, so the child
    tokenizes the string back to the same values.

    Signed-off-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com>
    Assisted-by: claude:opus-5.5
    PR-URL: https://github.com/nodejs/node/pull/66363
    Fixes: https://github.com/nodejs/node/issues/66362
    Reviewed-By: Moshe Atlow <moshe@atlow.co.il>

diff --git a/lib/internal/main/watch_mode.js b/lib/internal/main/watch_mode.js
index f0f1e26bb26..21889b0304f 100644
--- a/lib/internal/main/watch_mode.js
+++ b/lib/internal/main/watch_mode.js
@@ -7,6 +7,7 @@ const {
   ArrayPrototypePushApply,
   ArrayPrototypeSlice,
   StringPrototypeIncludes,
+  StringPrototypeReplaceAll,
   StringPrototypeStartsWith,
 } = primordials;

@@ -101,11 +102,20 @@ if (kNodeOptions != null) {
       i++;
       continue;
     }
-    // The C++ tokenizer strips quotes during parsing, so values that
-    // originally contained spaces (e.g. --require "./path with spaces/f.js")
+    // The C++ tokenizer strips quotes and escapes during parsing, so values
+    // that contain spaces or double quotes (e.g. --require "./a b/f.js")
     // need to be re-quoted before rejoining into a single string, otherwise
-    // the child's C++ parser would split them into separate tokens.
-    ArrayPrototypePush(keep, StringPrototypeIncludes(part, ' ') ? `"${part}"` : part);
+    // the child's C++ parser would split or misparse them. Inside quotes,
+    // backslashes are escape characters, so `"` and `\` must be escaped.
+    // Backslashes are escaped first so the ones added for `"` aren't doubled.
+    // Outside quotes, backslashes are literal, so other values are kept as-is.
+    if (StringPrototypeIncludes(part, ' ') || StringPrototypeIncludes(part, '"')) {
+      const escaped = StringPrototypeReplaceAll(
+        StringPrototypeReplaceAll(part, '\\', '\\\\'), '"', '\\"');
+      ArrayPrototypePush(keep, `"${escaped}"`);
+    } else {
+      ArrayPrototypePush(keep, part);
+    }
   }
   cleanNodeOptions = ArrayPrototypeJoin(keep, ' ');
 }
diff --git a/test/sequential/test-watch-mode.mjs b/test/sequential/test-watch-mode.mjs
index 0c28adec017..8607331a94a 100644
--- a/test/sequential/test-watch-mode.mjs
+++ b/test/sequential/test-watch-mode.mjs
@@ -1064,6 +1064,35 @@ process.on('message', (message) => {
     }
   });

+  for (const { name, nodeOptions, expected } of [
+    { name: 'a double quote', nodeOptions: '--title="a\\"b"', expected: 'a"b' },
+    { name: 'a backslash', nodeOptions: '--title="a \\\\b"', expected: 'a \\b' },
+  ]) {
+    it(`should preserve NODE_OPTIONS values containing ${name} in child process`, {
+      // Honoring --title from NODE_OPTIONS is required for this test.
+      // process.title is always an empty string on SunOS, so --title
+      // cannot be observed there.
+      skip: !!process.config.variables.node_without_node_options || common.isSunOS,
+    }, async () => {
+      const file = createTmpFile('console.log(JSON.stringify(process.title));');
+      const { done, restart } = runInBackground({
+        args: ['--watch', file],
+        options: {
+          env: { ...process.env, NODE_OPTIONS: `--watch ${nodeOptions}` },
+        },
+      });
+
+      try {
+        const { stdout, stderr } = await restart();
+
+        assert.strictEqual(stderr, '');
+        assert.ok(stdout.includes(JSON.stringify(expected)), stdout.join('\n'));
+      } finally {
+        await done();
+      }
+    });
+  }
+
   it('should handle NODE_OPTIONS containing only watch flags', async () => {
     const file = createTmpFile('console.log(JSON.stringify(process.env.NODE_OPTIONS));');
     const { done, restart } = runInBackground({