Repository: denoland/deno
Pull request: fix(ext/node): child_process improvements (internalMessage, argv0, DEP0190)

## Summary

Three child_process Node.js compatibility fixes:

### 1. Emit `internalMessage` event for `NODE_` prefixed IPC messages

In Node.js, IPC messages with a `cmd` property starting with `NODE_` are internal messages emitted as `"internalMessage"` instead of `"message"`. Deno was silently dropping these messages.

### 2. Support `argv0` option in `spawn`/`spawnSync`

Pass `argv0` through a new internal `kArgv0` symbol down to the Rust op, which uses `std::os::unix::process::CommandExt::arg0()` to set `argv[0]` at the OS level via `execve`. This matches how Node.js handles it through libuv.

### 3. Emit DEP0190 warning for shell spawn with args

Emit a `DeprecationWarning` (DEP0190) when `child_process.spawn()` is called with both args and `shell: true`, matching Node.js behavior.

## Test plan

- [x] `./x test-compat test-child-process-internal.js` passes
- [x] `./x test-compat test-child-process-spawn-argv0.js` passes
- [x] `./x test-compat test-child-process-spawn-shell.js` passes
- [x] Format and lint clean


🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/internal/child_process.ts
patch: @@ -71,6 +71,7 @@ import { Pipe, socketType } from "ext:deno_node/internal_binding/pipe_wrap.ts";
 import { Server as NetServer, Socket } from "node:net";
 import { Socket as DgramSocket } from "node:dgram";
 import {
+  kArgv0,
   kExtraStdio,
   kInputOption,
   kIpc,
@@ -131,6 +132,7 @@ export function stdioStringToArray(
 const kClosesNeeded = Symbol("_closesNeeded");
 const kClosesReceived = Symbol("_closesReceived");
 const kCanDisconnect = Symbol("_canDisconnect");
+let emittedShellDeprecation = false;
 
 // We only want to emit a close event for the child process when all of
 // the writable streams have closed. The value of `child[kClosesNeeded]` should be 1 +
@@ -367,7 +369,9 @@ export class ChildProcess extends EventEmitter {
     ] = normalizedStdio;
 
     // buildCommand handles Node.js to Deno CLI arg translation when spawning Deno
-    // Note: args[0] is argv0 (prepended by normalizeSpawnArguments), so we skip it
+    // args[0] is argv0 (prepended by normalizeSpawnArguments). Capture it
+    // before slicing so we can pass it via kArgv0 for OS-level argv[0].
+    const argv0 = args.length > 0 ? args[0] : command;
     const [cmd, cmdArgs, includeNpmProcessState] = buildCommand(
       command,
       args.slice(1),
@@ -411,6 +415,7 @@ export class ChildProcess extends EventEmitter {
         [kExtraStdio]: extraStdioNormalized,
         [kNeedsNpmProcessState]: options[kNeedsNpmProcessState] ||
           includeNpmProcessState,
+        [kArgv0]: argv0 !== cmd ? argv0 : undefined,
       }).spawn();
       this.pid = this.#process.pid;
 
@@ -1126,6 +1131,15 @@ export function normalizeSpawnArguments(
     // for shell interpretation, so don't escape.
     let command;
     if (args.length > 0) {
+      if (!emittedShellDeprecation) {
+        process.emitWarning(
+          "Passing args to a child process with shell option true can lead to security " +
+            "vulnerabilities, as the arguments are not escaped, only concatenated.",
+          "DeprecationWarning",
+          "DEP0190",
+        );
+        emittedShellDeprecation = true;
+      }
       const escapedParts = [escapeShellArg(file), ...args.map(escapeShellArg)];
       command = ArrayPrototypeJoin(escapedParts, " ");
     } else {
@@ -1661,7 +1675,9 @@ export function spawnSync(
     _channel, // TODO(kt3k): handle this correctly
   ] = normalizeStdioOption(stdio);
   let includeNpmProcessState = false;
-  // Skip argv0 when calling buildCommand (same as #spawnInternal)
+  // args[0] is argv0 (prepended by normalizeSpawnArguments). Capture it
+  // before slicing so we can pass it via kArgv0 for OS-level argv[0].
+  const argv0 = args && args.length > 0 ? args[0] : command;
   const argsToProcess = args && args.length > 0 ? args.slice(1) : [];
   [command, args, includeNpmProcessState] = buildCommand(
     command,
@@ -1676,6 +1692,7 @@ export function spawnSync(
       args,
       cwd,
       env: mapValues(env, (value) => value.toString()),
+      [kArgv0]: argv0 !== command ? argv0 : undefined,
       stdout: toDenoStdio(stdout_),
       stderr: toDenoStdio(stderr_),
       stdin: stdin_ == "inherit" ? "inherit" : "null",
@@ -1839,6 +1856,11 @@ export function setupChannel(
             // TODO(nathanwhit): once we add support for sending
             // handles, if we want to support deno-node IPC interop,
             // we'll need to handle the NODE_HANDLE_* messages here.
+            // Emit as internalMessage for internal consumers.
+            if (serialization === "json") {
+              restorePrototype(msg);
+            }
+            nextTick(() => target.emit("internalMessage", msg));
             continue;
           }
         }

filename: ext/process/40_process.js
patch: @@ -166,6 +166,7 @@ export const kExtraStdio = Symbol("extraStdio");
 export const kIpc = Symbol("ipc");
 export const kNeedsNpmProcessState = Symbol("needsNpmProcessState");
 export const kSerialization = Symbol("serialization");
+const kArgv0 = Symbol("argv0");
 
 const illegalConstructorKey = Symbol("illegalConstructorKey");
 
@@ -186,12 +187,14 @@ function spawnChildInner(command, apiName, {
   [kExtraStdio]: extraStdio = [],
   [kIpc]: ipc = -1,
   [kNeedsNpmProcessState]: needsNpmProcessState = false,
+  [kArgv0]: argv0 = undefined,
 } = { __proto__: null }) {
   const child = op_spawn_child({
     cmd: pathFromURL(command),
     args: ArrayPrototypeMap(args, String),
     cwd: pathFromURL(cwd),
     clearEnv,
+    argv0,
     env: ObjectEntries(env),
     uid,
     gid,
@@ -471,6 +474,7 @@ function spawnSyncInner(command, {
   windowsRawArguments = false,
   [kInputOption]: input,
   [kNeedsNpmProcessState]: needsNpmProcessState = false,
+  [kArgv0]: argv0 = undefined,
 } = { __proto__: null }) {
   if (stdin === "piped") {
     throw new TypeError(
@@ -493,6 +497,7 @@ function spawnSyncInner(command, {
     detached: false,
     needsNpmProcessState,
     input,
+    argv0,
   });
   return {
     success: result.status.success,
@@ -595,6 +600,7 @@ function spawnAndWaitSync(command, argsOrOptions, maybeOptions) {
 export {
   ChildProcess,
   Command,
+  kArgv0,
   kill,
   kInputOption,
   Process,

filename: ext/process/lib.rs
patch: @@ -250,6 +250,8 @@ pub struct SpawnArgs {
   extra_stdio: Vec<Stdio>,
   detached: bool,
   needs_npm_process_state: bool,
+  #[cfg(unix)]
+  argv0: Option<String>,
 }
 
 #[derive(Deserialize)]
@@ -486,7 +488,12 @@ fn create_command(
   }
 
   #[cfg(not(windows))]
-  command.args(args.args);
+  {
+    if let Some(ref argv0) = args.argv0 {
+      command.arg0(argv0);
+    }
+    command.args(args.args);
+  }
 
   command.current_dir(run_env.cwd);
   command.env_clear();

filename: tests/node_compat/config.jsonc
patch: @@ -223,6 +223,7 @@
     "parallel/test-child-process-execsync-maxbuf.js": {},
     "parallel/test-child-process-exit-code.js": {},
     "parallel/test-child-process-flush-stdio.js": {},
+    "parallel/test-child-process-internal.js": {},
     "parallel/test-child-process-fork.js": {},
     "parallel/test-child-process-fork-abort-signal.js": {},
     "parallel/test-child-process-fork-and-spawn.js": {},
@@ -245,9 +246,13 @@
     "parallel/test-child-process-send-type-error.js": {},
     "parallel/test-child-process-set-blocking.js": {},
     "parallel/test-child-process-silent.js": {},
+    "parallel/test-child-process-spawn-argv0.js": {
+      "windows": false
+    },
     "parallel/test-child-process-spawn-controller.js": {},
     "parallel/test-child-process-spawn-error.js": {},
     "parallel/test-child-process-spawn-event.js": {},
+    "parallel/test-child-process-spawn-shell.js": {},
     "parallel/test-child-process-spawn-timeout-kill-signal.js": {},
     "parallel/test-child-process-spawn-typeerror.js": {},
     "parallel/test-child-process-spawnsync-args.js": {},

filename: tests/specs/node/child_process_shell_escape/main.out
patch: @@ -15,7 +15,7 @@ PASS: Args with spaces work
 Test 8: Shell features work with string command
 PASS: Shell features work
 Test 9: Async spawn escapes args
-PASS: Async spawn injection blocked
+[WILDCARD]PASS: Async spawn injection blocked
 Test 10: Backtick + $VAR injection in args
 PASS: Backtick + $VAR injection blocked
 Test 11: $() + $VAR injection in args