Commit 1eacebb1430 for nodejs

commit 1eacebb1430892ae0328b94bc1e025000986e1e9
Author: Marco <marco.piraccini@gmail.com>
Date:   Tue Sep 22 08:31:22 2026 +0200

    watch: strip watch flags from NODE_OPTIONS in child process

    Signed-off-by: marcopiraccini <marco.piraccini@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/62143
    Fixes: https://github.com/nodejs/node/issues/61740
    Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
    Reviewed-By: Paolo Insogna <paolo@cowtech.it>
    Reviewed-By: René <contact.9a5d6388@renegade334.me.uk>
    Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
    Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>

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

@@ -17,7 +18,7 @@ const {
   triggerUncaughtException,
   exitCodes: { kNoFailure },
 } = internalBinding('errors');
-const { getOptionValue } = require('internal/options');
+const { getOptionValue, parseNodeOptionsEnvVar } = require('internal/options');
 const { FilesWatcher } = require('internal/watch_mode/files_watcher');
 const { green, blue, red, white, clear } = require('internal/util/colors');
 const { convertToValidSignal, kEmptyObject } = require('internal/util');
@@ -78,6 +79,37 @@ for (let i = 0; i < process.execArgv.length; i++) {

 ArrayPrototypePushApply(argsWithoutWatchOptions, kCommand);

+// Strip watch-related flags from NODE_OPTIONS to prevent infinite loop
+// when NODE_OPTIONS contains --watch (see issue #61740).
+const kNodeOptions = process.env.NODE_OPTIONS;
+let cleanNodeOptions = kNodeOptions;
+if (kNodeOptions != null) {
+  const keep = [];
+  const parts = parseNodeOptionsEnvVar(kNodeOptions);
+  for (let i = 0; i < parts.length; i++) {
+    const part = parts[i];
+    if (part === '--watch' ||
+        part === '--watch-preserve-output' ||
+        StringPrototypeStartsWith(part, '--watch=') ||
+        StringPrototypeStartsWith(part, '--watch-preserve-output=') ||
+        StringPrototypeStartsWith(part, '--watch-path=') ||
+        StringPrototypeStartsWith(part, '--watch-kill-signal=')) {
+      continue;
+    }
+    if (part === '--watch-path' || part === '--watch-kill-signal') {
+      // Skip the flag and its separate value argument
+      i++;
+      continue;
+    }
+    // The C++ tokenizer strips quotes during parsing, so values that
+    // originally contained spaces (e.g. --require "./path with spaces/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);
+  }
+  cleanNodeOptions = ArrayPrototypeJoin(keep, ' ');
+}
+
 const watcher = new FilesWatcher({ debounce: 200, mode: kShouldFilterModules ? 'filter' : 'all' });
 ArrayPrototypeForEach(kWatchedPaths, (p) => watcher.watchPath(p));

@@ -96,6 +128,7 @@ function start() {
     env: {
       ...process.env,
       WATCH_REPORT_DEPENDENCIES: '1',
+      NODE_OPTIONS: cleanNodeOptions,
     },
   });
   watcher.watchChildProcessModules(child);
diff --git a/lib/internal/options.js b/lib/internal/options.js
index 3480f5794b7..2775b1754b6 100644
--- a/lib/internal/options.js
+++ b/lib/internal/options.js
@@ -16,6 +16,7 @@ const {
   getEmbedderOptions: getEmbedderOptionsFromBinding,
   getEnvOptionsInputType,
   getNamespaceOptionsInputType,
+  parseNodeOptionsEnvVar,
 } = internalBinding('options');

 let warnOnAllowUnauthorized = true;
@@ -222,5 +223,6 @@ module.exports = {
   getAllowUnauthorized,
   getEmbedderOptions,
   generateConfigJsonSchema,
+  parseNodeOptionsEnvVar,
   refreshOptions,
 };
diff --git a/src/node_options.cc b/src/node_options.cc
index 89c2d66b394..6dcdfd6d1bd 100644
--- a/src/node_options.cc
+++ b/src/node_options.cc
@@ -2329,6 +2329,29 @@ void GetOptionsAsFlags(const FunctionCallbackInfo<Value>& args) {
   args.GetReturnValue().Set(result);
 }

+void ParseNodeOptionsEnvVarBinding(const FunctionCallbackInfo<Value>& args) {
+  Isolate* isolate = args.GetIsolate();
+  Local<Context> context = isolate->GetCurrentContext();
+
+  Utf8Value node_options(isolate, args[0]);
+  std::string options_str(*node_options, node_options.length());
+
+  std::vector<std::string> errors;
+  std::vector<std::string> result =
+      ParseNodeOptionsEnvVar(options_str, &errors);
+
+  if (!errors.empty()) {
+    Environment* env = Environment::GetCurrent(context);
+    env->ThrowError(errors[0].c_str());
+    return;
+  }
+
+  Local<Value> v8_result;
+  if (ToV8Value(context, result).ToLocal(&v8_result)) {
+    args.GetReturnValue().Set(v8_result);
+  }
+}
+
 void Initialize(Local<Object> target,
                 Local<Value> unused,
                 Local<Context> context,
@@ -2349,6 +2372,8 @@ void Initialize(Local<Object> target,
                         target,
                         "getNamespaceOptionsInputType",
                         GetNamespaceOptionsInputType);
+  SetMethodNoSideEffect(
+      context, target, "parseNodeOptionsEnvVar", ParseNodeOptionsEnvVarBinding);
   Local<Object> env_settings = Object::New(isolate);
   NODE_DEFINE_CONSTANT(env_settings, kAllowedInEnvvar);
   NODE_DEFINE_CONSTANT(env_settings, kDisallowedInEnvvar);
@@ -2377,6 +2402,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) {
   registry->Register(GetEmbedderOptions);
   registry->Register(GetEnvOptionsInputType);
   registry->Register(GetNamespaceOptionsInputType);
+  registry->Register(ParseNodeOptionsEnvVarBinding);
 }
 }  // namespace options_parser

diff --git a/test/parallel/test-options-binding.js b/test/parallel/test-options-binding.js
index 5c910360d00..71ebbd5939d 100644
--- a/test/parallel/test-options-binding.js
+++ b/test/parallel/test-options-binding.js
@@ -3,7 +3,7 @@

 const common = require('../common');
 const assert = require('assert');
-const { getOptionValue } = require('internal/options');
+const { getOptionValue, parseNodeOptionsEnvVar } = require('internal/options');

 Map.prototype.get =
   common.mustNotCall('`getOptionValue` must not call user-mutable method');
@@ -14,3 +14,30 @@ assert.strictEqual(getOptionValue('--nonexistent-option'), undefined);

 // Make the test common global leak test happy.
 delete Object.prototype['--nonexistent-option'];
+
+// parseNodeOptionsEnvVar tokenizes a NODE_OPTIONS-style string.
+assert.deepStrictEqual(
+  parseNodeOptionsEnvVar('--max-old-space-size=4096 --no-warnings'),
+  ['--max-old-space-size=4096', '--no-warnings']
+);
+
+// Quoted strings are unquoted during parsing.
+assert.deepStrictEqual(
+  parseNodeOptionsEnvVar('--require "file with spaces.js"'),
+  ['--require', 'file with spaces.js']
+);
+
+// Empty string returns an empty array.
+assert.deepStrictEqual(parseNodeOptionsEnvVar(''), []);
+
+// Throws on unterminated string.
+assert.throws(
+  () => parseNodeOptionsEnvVar('--require "unterminated'),
+  { name: 'Error', message: /unterminated string/ }
+);
+
+// Throws on invalid escape at end of string.
+assert.throws(
+  () => parseNodeOptionsEnvVar('--require "foo\\'),
+  { name: 'Error', message: /invalid escape/ }
+);
diff --git a/test/sequential/test-watch-mode.mjs b/test/sequential/test-watch-mode.mjs
index 54132b7f81c..d14493192a0 100644
--- a/test/sequential/test-watch-mode.mjs
+++ b/test/sequential/test-watch-mode.mjs
@@ -1,6 +1,7 @@
 import * as common from '../common/index.mjs';
 import tmpdir from '../common/tmpdir.js';
 import assert from 'node:assert';
+import os from 'node:os';
 import path from 'node:path';
 import { execPath } from 'node:process';
 import { describe, it } from 'node:test';
@@ -993,4 +994,112 @@ process.on('message', (message) => {
       await done();
     }
   });
+
+  it('should strip all watch flags from NODE_OPTIONS in child process', async () => {
+    const file = createTmpFile('console.log(process.env.NODE_OPTIONS);');
+    const nodeOptions = [
+      '--watch',
+      '--watch=true',
+      '--watch-path=./src',
+      '--watch-path', './test',
+      '--watch-preserve-output',
+      '--watch-preserve-output=true',
+      '--watch-kill-signal=SIGKILL',
+      '--watch-kill-signal', 'SIGINT',
+      '--max-old-space-size=4096',
+      '--no-warnings',
+    ].join(' ');
+    const { done, restart } = runInBackground({
+      args: ['--watch', file],
+      options: {
+        env: { ...process.env, NODE_OPTIONS: nodeOptions },
+      },
+    });
+
+    try {
+      const { stdout, stderr } = await restart();
+
+      assert.strictEqual(stderr, '');
+      const nodeOptionsLine = stdout.find((line) => line.includes('--max-old-space-size'));
+      assert.ok(nodeOptionsLine);
+      assert.strictEqual(nodeOptionsLine, '--max-old-space-size=4096 --no-warnings');
+    } finally {
+      await done();
+    }
+  });
+
+  it('should not strip --watch when it appears inside a quoted NODE_OPTIONS value', {
+    // Honoring --require from NODE_OPTIONS is required for this test.
+    skip: !!process.config.variables.node_without_node_options,
+  }, async () => {
+    // Use /tmp to avoid CI directories with special characters (e.g. ")
+    // that would break NODE_OPTIONS parsing.
+    const watchDir = path.join(os.tmpdir(), 'test for --watch parsing');
+    mkdirSync(watchDir, { recursive: true });
+    const reqFile = path.join(watchDir, 'req.cjs');
+    writeFileSync(reqFile, 'globalThis.requiredOk = true;');
+
+    const file = createTmpFile('console.log("required:" + !!globalThis.requiredOk);');
+    const nodeOptions = `--watch --require "${reqFile}"`;
+    const { done, restart } = runInBackground({
+      args: ['--watch', file],
+      options: {
+        env: { ...process.env, NODE_OPTIONS: nodeOptions },
+      },
+    });
+
+    try {
+      const { stdout, stderr } = await restart();
+
+      assert.strictEqual(stderr, '');
+      assert.ok(stdout.some((line) => line.includes('required:true')));
+    } 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({
+      args: ['--watch', file],
+      options: {
+        env: { ...process.env, NODE_OPTIONS: '--watch' },
+      },
+    });
+
+    try {
+      const { stdout, stderr } = await restart();
+
+      assert.strictEqual(stderr, '');
+      assert.ok(stdout.some((line) => line.includes('""')));
+    } finally {
+      await done();
+    }
+  });
+
+  it('should strip multiple --watch-path entries from NODE_OPTIONS', async () => {
+    const file = createTmpFile('console.log(process.env.NODE_OPTIONS);');
+    // Use /tmp to avoid CI directories with special characters (e.g. ")
+    // that would break NODE_OPTIONS parsing.
+    const dirA = path.join(os.tmpdir(), 'node-watch-path-a');
+    const dirB = path.join(os.tmpdir(), 'node-watch-path-b');
+    mkdirSync(dirA, { recursive: true });
+    mkdirSync(dirB, { recursive: true });
+    const nodeOptions = `--watch --watch-path=${dirA} --watch-path ${dirB} --no-warnings`;
+    const { done, restart } = runInBackground({
+      args: ['--watch', file],
+      options: {
+        env: { ...process.env, NODE_OPTIONS: nodeOptions },
+      },
+    });
+
+    try {
+      const { stdout, stderr } = await restart();
+
+      assert.strictEqual(stderr, '');
+      assert.ok(stdout.some((line) => line === '--no-warnings'));
+    } finally {
+      await done();
+    }
+  });
 });