child_process: honor uid and gid spawn options
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
chdirand the fd actions and immediately beforeexec: best-effortsetgroups(0, NULL), thensetgid, thensetuid. An id-set failure surfaces through the existing child-errno channel, socp.spawnthrowsEPERMsynchronously andcp.spawnSyncreturns it onresult.error— the same dispatch as Node (EPERM is not in Node's deferred-to-'error'-event list).- On Linux the child is a
vforkchild sharing the parent's address space, so the id changes use rawsyscall(SYS_setgroups/SYS_setgid/SYS_setuid, ...)rather than the glibc wrappers, which run the NPTLSIGSETXIDbroadcast over the (shared) parent thread list. glibc's ownposix_spawndoes the same forPOSIX_SPAWN_RESETIDS.
- On Linux the child is a
- macOS: uid/gid spawns are routed through the fork-based
posix_spawn_bunpath (already used for PTY spawns), because Darwin's systemposix_spawncannot change ids — the same fallback libuv makes. - Windows:
uv_process_options_t.uid/.gid+UV_PROCESS_SETUID/SETGIDare passed touv_spawn, which returnsUV_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 reportsuid=65534 gid=65534 groups=65534(supplementary group 0 dropped);{gid: 65534}alone →uid=0 gid=65534;id -Gprints only65534.spawn/spawnSync/execFileall match. - as a non-root user,
{uid: 0}→spawnSync(...).erroris{code: "EPERM", errno: -1, syscall: "spawnSync id", spawnargs: []}andspawn()throws synchronously with{code: "EPERM", errno: -1, syscall: "spawn"}— identical fields to Node. test-child-process-spawnsync-validation-errors.jspasses 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, asyncspawnvariant. - non-root EPERM tests (synchronous throw from
spawn,result.errorfromspawnSync,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.5rejected) 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.