child_process: honor uid and gid spawn options

Merged
JA
4mo ago
+378-9

What does this PR do?

node:child_process validates the uid/gid options but never forwarded them to the underlying spawn, so the child always ran with the parent's user/group ids. This implements them end-to-end for Bun.spawn, Bun.spawnSync, and node:child_process (spawn, spawnSync, and the exec*/fork wrappers that build on them), following libuv's behavior so it matches Node:

  • POSIX: in the child, after chdir and the fd actions and immediately before exec: best-effort setgroups(0, NULL), then setgid, then setuid. An id-set failure surfaces through the existing child-errno channel, so cp.spawn throws EPERM synchronously and cp.spawnSync returns it on result.error — the same dispatch as Node (EPERM is not in Node's deferred-to-'error'-event list).
    • On Linux the child is a vfork child sharing the parent's address space, so the id changes use raw syscall(SYS_setgroups/SYS_setgid/SYS_setuid, ...) rather than the glibc wrappers, which run the NPTL SIGSETXID broadcast over the (shared) parent thread list. glibc's own posix_spawn does the same for POSIX_SPAWN_RESETIDS.
  • macOS: uid/gid spawns are routed through the fork-based posix_spawn_bun path (already used for PTY spawns), because Darwin's system posix_spawn cannot change ids — the same fallback libuv makes.
  • Windows: uv_process_options_t.uid/.gid + UV_PROCESS_SETUID/SETGID are passed to uv_spawn, which returns UV_ENOTSUP — exactly what Node reports on Windows.

Also: errors that cp.spawn throws synchronously (i.e. not in the deferred list) now get syscall: "spawn", matching Node.

packages/bun-types/bun.d.ts documents the new uid/gid options on Bun.spawn.

Behavior verified against real Node (v26.3.0, Linux)

Outputs compared side by side with node:

  • as root, {uid: 65534, gid: 65534} → child reports uid=65534 gid=65534 groups=65534 (supplementary group 0 dropped); {gid: 65534} alone → uid=0 gid=65534; id -G prints only 65534. spawn/spawnSync/execFile all match.
  • as a non-root user, {uid: 0} → spawnSync(...).error is {code: "EPERM", errno: -1, syscall: "spawnSync id", spawnargs: []} and spawn() throws synchronously with {code: "EPERM", errno: -1, syscall: "spawn"} — identical fields to Node.
  • test-child-process-spawnsync-validation-errors.js passes both as root and as a non-root user.

One intentional deviation: the EPERM error's message string keeps Bun's existing native wording ("EPERM: operation not permitted, posix_spawn 'id'") instead of Node's "spawn EPERM", consistent with how Bun already reports ENOENT from spawn. code/errno/syscall/throw-vs-event dispatch all match Node; the new tests assert those, not the message text.

Tests

Added to the existing test/js/node/child_process/child_process.test.ts, test/js/bun/spawn/spawn.test.ts, and test/js/bun/spawn/spawnSync.test.ts:

  • root-gated (test.skipIf(!isRoot)) success-path tests: uid+gid applied, supplementary groups dropped, gid-only leaves uid unchanged, async spawn variant.
  • non-root EPERM tests (synchronous throw from spawn, result.error from spawnSync, Bun.spawn/Bun.spawnSync).
  • a root-gated fixture that uses the new option to drop to uid 65534 and then asserts the real EPERM path inside the de-privileged child, so the EPERM path is exercised on root CI runners too.
  • a Windows test asserting ENOTSUP.
  • a validation test (uid: 1.5 rejected) that runs everywhere regardless of privileges.

All of the new tests (except the deliberate negative-contract one) fail with USE_SYSTEM_BUN=1 and pass with bun bd test. Full runs of child_process.test.ts, spawn.test.ts, spawnSync.test.ts, the other test/js/node/child_process/* suites, and a sweep of test/js/node/test/parallel/test-child-process-* show no new failures.