From 8b8c5be1109b9f8f28d71df20f7fa32ad113d15f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:56:54 +0000 Subject: [PATCH 1/9] process.env: coerce assigned values to strings on POSIX Node.js documents that every process.env assignment coerces the value to a string. On POSIX, Bun's process.env was a plain JSObject with no put hook, so assigning a non-string value stored it verbatim: process.env.foo = undefined; // read back as undefined, not "undefined" process.env.bar = 42; // typeof process.env.bar === "number" This broke the common "=== 'undefined'" sentinel check and meant the parent's readback diverged from what a spawned child actually received. The Windows path (a Proxy with a set trap) and the SHARE_ENV path (JSSharedEnvMap::put) already coerced. This change gives the default POSIX object the same treatment by wrapping it in a JSProcessEnvMap that overrides put/putByIndex/defineOwnProperty to call toString() on the value before storing it. The CustomValue getters for lazy OS-env loading are unchanged; they sit on the base object and still work. Also un-todos the existing "setting process.env coerces" test in env.test.ts, which was todoOnPosix for exactly this reason. --- src/jsc/bindings/JSEnvironmentVariableMap.cpp | 100 ++++++++++++++++-- test/cli/run/env.test.ts | 3 +- test/js/node/process/process.test.js | 49 +++++++++ 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index fc29c4c824fa..eb509479c207 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -744,6 +744,98 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb return store; } +// The default (non-SHARE_ENV) process.env object. Identical to a plain object for +// reads, but overrides put/putByIndex/defineOwnProperty so every assigned value is +// coerced to a string before it lands, matching Node.js (`process.env.x = 42` reads +// back as "42", `= undefined` reads back as "undefined" rather than deleting). +class JSProcessEnvMap final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + + static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::OverridesPut; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSProcessEnvMap, Base); + return &vm.plainObjectSpace(); + } + + DECLARE_INFO; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static JSProcessEnvMap* create(JSC::VM& vm, JSC::Structure* structure) + { + JSProcessEnvMap* ptr = new (NotNull, JSC::allocateCell(vm)) JSProcessEnvMap(vm, structure); + ptr->finishCreation(vm); + return ptr; + } + + static bool put(JSCell*, JSGlobalObject*, JSC::PropertyName, JSC::JSValue, JSC::PutPropertySlot&); + static bool putByIndex(JSCell*, JSGlobalObject*, unsigned, JSC::JSValue, bool shouldThrow); + static bool defineOwnProperty(JSObject*, JSGlobalObject*, JSC::PropertyName, const JSC::PropertyDescriptor&, bool shouldThrow); + +private: + JSProcessEnvMap(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(JSC::VM& vm) + { + Base::finishCreation(vm); + } +}; + +const JSC::ClassInfo JSProcessEnvMap::s_info = { "ProcessEnv"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSProcessEnvMap) }; + +bool JSProcessEnvMap::put(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot) +{ + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (propertyName.isSymbol()) { + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); + } + + JSString* string = value.toString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, string, slot)); +} + +bool JSProcessEnvMap::putByIndex(JSCell* cell, JSGlobalObject* globalObject, unsigned index, JSValue value, bool shouldThrow) +{ + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSString* string = value.toString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + + RELEASE_AND_RETURN(scope, Base::putByIndex(cell, globalObject, index, string, shouldThrow)); +} + +bool JSProcessEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, const PropertyDescriptor& descriptor, bool shouldThrow) +{ + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (propertyName.isSymbol() || !descriptor.isDataDescriptor() || !descriptor.value()) { + RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, descriptor, shouldThrow)); + } + + JSString* string = descriptor.value().toString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + + PropertyDescriptor coerced(descriptor); + coerced.setValue(string); + RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, coerced, shouldThrow)); +} + JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) { VM& vm = globalObject->vm(); @@ -751,12 +843,8 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) void* list; size_t count = Bun__getEnvCount(globalObject, &list); - JSC::JSObject* object = nullptr; - if (count < 63) { - object = constructEmptyObject(globalObject, globalObject->objectPrototype(), count); - } else { - object = constructEmptyObject(globalObject, globalObject->objectPrototype()); - } + auto* structure = JSProcessEnvMap::createStructure(vm, globalObject, globalObject->objectPrototype()); + JSC::JSObject* object = JSProcessEnvMap::create(vm, structure); #if OS(WINDOWS) JSArray* keyArray = constructEmptyArray(globalObject, nullptr, count); diff --git a/test/cli/run/env.test.ts b/test/cli/run/env.test.ts index 78b29adcc02b..a1c04a170851 100644 --- a/test/cli/run/env.test.ts +++ b/test/cli/run/env.test.ts @@ -901,8 +901,7 @@ for (const shell of ["system", "bun"]) { }); } -const todoOnPosix = process.platform !== "win32" ? test.todo : test; -todoOnPosix("setting process.env coerces the value to a string", () => { +test("setting process.env coerces the value to a string", () => { // @ts-expect-error process.env.SET_TO_TRUE = true; let did_call = 0; diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index b191a4ed15ab..4f65f27d3464 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -231,6 +231,55 @@ it("process.env is spreadable and editable", () => { expect(eval(`globalThis.process.env.USER = "${orig}"`)).toBe(String(orig)); }); +it("process.env coerces assigned values to strings", () => { + try { + process.env.COERCE_UNDEF = "initial"; + process.env.COERCE_UNDEF = undefined; + process.env.COERCE_NUM = 42; + process.env.COERCE_NULL = null; + process.env.COERCE_BOOL = true; + process.env.COERCE_OBJ = { toString: () => "from-toString" }; + process.env["4242424242"] = 55; + Object.defineProperty(process.env, "COERCE_DEF", { + value: 7, + writable: true, + enumerable: true, + configurable: true, + }); + + expect({ + undef: process.env.COERCE_UNDEF, + num: process.env.COERCE_NUM, + null: process.env.COERCE_NULL, + bool: process.env.COERCE_BOOL, + obj: process.env.COERCE_OBJ, + idx: process.env["4242424242"], + def: process.env.COERCE_DEF, + }).toEqual({ + undef: "undefined", + num: "42", + null: "null", + bool: "true", + obj: "from-toString", + idx: "55", + def: "7", + }); + + expect(() => { + process.env.COERCE_THROWS = { + toString() { + throw new Error("boom"); + }, + }; + }).toThrow("boom"); + expect(process.env.COERCE_THROWS).toBeUndefined(); + } finally { + for (const k of ["COERCE_UNDEF", "COERCE_NUM", "COERCE_NULL", "COERCE_BOOL", "COERCE_OBJ", "COERCE_DEF", "COERCE_THROWS", "4242424242"]) { + delete process.env[k]; + } + } +}); + const MIN_ICU_VERSIONS_BY_PLATFORM_ARCH = { "darwin-x64": "70.1", "darwin-arm64": "72.1", From cd447f317208722f77d4be2054263061b8d052c3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:59:27 +0000 Subject: [PATCH 2/9] [autofix.ci] apply automated fixes --- test/js/node/process/process.test.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 4f65f27d3464..fade192d543c 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -274,7 +274,16 @@ it("process.env coerces assigned values to strings", () => { }).toThrow("boom"); expect(process.env.COERCE_THROWS).toBeUndefined(); } finally { - for (const k of ["COERCE_UNDEF", "COERCE_NUM", "COERCE_NULL", "COERCE_BOOL", "COERCE_OBJ", "COERCE_DEF", "COERCE_THROWS", "4242424242"]) { + for (const k of [ + "COERCE_UNDEF", + "COERCE_NUM", + "COERCE_NULL", + "COERCE_BOOL", + "COERCE_OBJ", + "COERCE_DEF", + "COERCE_THROWS", + "4242424242", + ]) { delete process.env[k]; } } From da4b6eaafda180a984eba893e6d834db9c00e6c3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:23:36 +0000 Subject: [PATCH 3/9] fixup: keep Windows internalEnv a plain object; coerce in Proxy traps The windowsEnv Proxy stores toJSON and Bun.inspect.custom as own properties on the target object before wrapping it. Routing those assignments through JSProcessEnvMap::put coerced the toJSON function to its string source, breaking typeof process.env.toJSON on Windows. Windows already coerces in the Proxy's set trap, so keep the target as a plain JSObject there and add the matching String(value) coercion to the Proxy's defineProperty trap instead. JSProcessEnvMap is now POSIX-only. --- src/js/builtins/ProcessObjectInternals.ts | 4 ++++ src/jsc/bindings/JSEnvironmentVariableMap.cpp | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 37681a67f7c6..87a42937b446 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -530,6 +530,10 @@ export function windowsEnv( defineProperty(_, p, attributes) { const k = String(p).toUpperCase(); $assert(typeof p === "string"); // proxy is only string and symbol. the symbol would have thrown by now + // Node coerces env values to strings on defineProperty too, same as assignment. + if ("value" in attributes) { + attributes = { ...attributes, value: String(attributes.value) }; + } if (!(k in internalEnv) && !envMapList.includes(p)) { envMapList.push(p); } diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index eb509479c207..af25cf3a0037 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -744,10 +744,13 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb return store; } +#if !OS(WINDOWS) // The default (non-SHARE_ENV) process.env object. Identical to a plain object for // reads, but overrides put/putByIndex/defineOwnProperty so every assigned value is // coerced to a string before it lands, matching Node.js (`process.env.x = 42` reads // back as "42", `= undefined` reads back as "undefined" rather than deleting). +// Windows keeps a plain object: the windowsEnv Proxy's set/defineProperty traps do +// the coercion there, and also store own functions (toJSON) on the target directly. class JSProcessEnvMap final : public JSC::JSNonFinalObject { public: using Base = JSC::JSNonFinalObject; @@ -835,6 +838,7 @@ bool JSProcessEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* global coerced.setValue(string); RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, coerced, shouldThrow)); } +#endif JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) { @@ -843,12 +847,21 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) void* list; size_t count = Bun__getEnvCount(globalObject, &list); - auto* structure = JSProcessEnvMap::createStructure(vm, globalObject, globalObject->objectPrototype()); - JSC::JSObject* object = JSProcessEnvMap::create(vm, structure); - #if OS(WINDOWS) + // Windows wraps this object in the windowsEnv Proxy whose set/defineProperty + // traps already coerce values, and which stores own functions (toJSON, + // inspect.custom) on it directly. Keep it a plain object so those survive. + JSC::JSObject* object = nullptr; + if (count < 63) { + object = constructEmptyObject(globalObject, globalObject->objectPrototype(), count); + } else { + object = constructEmptyObject(globalObject, globalObject->objectPrototype()); + } JSArray* keyArray = constructEmptyArray(globalObject, nullptr, count); RETURN_IF_EXCEPTION(scope, {}); +#else + auto* structure = JSProcessEnvMap::createStructure(vm, globalObject, globalObject->objectPrototype()); + JSC::JSObject* object = JSProcessEnvMap::create(vm, structure); #endif static NeverDestroyed TZ = MAKE_STATIC_STRING_IMPL("TZ"); From a6b246edb55af3852aea25ac77cf11d48ca16dda Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:31:44 +0000 Subject: [PATCH 4/9] process.env: coerce in workers spawned with an explicit env too ZigGlobalObject's initializeWorker path builds process.env for new Worker(url, { env: {...} }) as a plain constructEmptyObject and assigns it to m_processEnvObject directly, so createEnvironmentVariablesMap never runs and writes inside that worker stored raw values. Route that construction through the same JSProcessEnvMap via a small factory. JSProcessEnvMap is no longer #if-guarded: Windows still uses a plain object inside createEnvironmentVariablesMap (the windowsEnv Proxy coerces and stores toJSON on the target), but the worker-env path has no Proxy on any platform and so uses JSProcessEnvMap everywhere. Also condensed the class comment to three lines per review. --- src/jsc/bindings/JSEnvironmentVariableMap.cpp | 26 ++++++++--------- src/jsc/bindings/JSEnvironmentVariableMap.h | 4 +++ src/jsc/bindings/ZigGlobalObject.cpp | 2 +- test/js/node/process/process.test.js | 29 +++++++++++++++++++ 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index af25cf3a0037..c16b1bd264ee 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -744,13 +744,9 @@ RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalOb return store; } -#if !OS(WINDOWS) -// The default (non-SHARE_ENV) process.env object. Identical to a plain object for -// reads, but overrides put/putByIndex/defineOwnProperty so every assigned value is -// coerced to a string before it lands, matching Node.js (`process.env.x = 42` reads -// back as "42", `= undefined` reads back as "undefined" rather than deleting). -// Windows keeps a plain object: the windowsEnv Proxy's set/defineProperty traps do -// the coercion there, and also store own functions (toJSON) on the target directly. +// Default (non-SHARE_ENV) process.env: plain object for reads, with put/putByIndex/ +// defineOwnProperty overridden to toString() the value first so `= 42` reads back +// "42" and `= undefined` reads back "undefined", matching Node.js. class JSProcessEnvMap final : public JSC::JSNonFinalObject { public: using Base = JSC::JSNonFinalObject; @@ -838,7 +834,13 @@ bool JSProcessEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* global coerced.setValue(string); RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, coerced, shouldThrow)); } -#endif + +JSObject* createProcessEnvMapObject(Zig::GlobalObject* globalObject) +{ + VM& vm = globalObject->vm(); + auto* structure = JSProcessEnvMap::createStructure(vm, globalObject, globalObject->objectPrototype()); + return JSProcessEnvMap::create(vm, structure); +} JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) { @@ -848,9 +850,8 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) void* list; size_t count = Bun__getEnvCount(globalObject, &list); #if OS(WINDOWS) - // Windows wraps this object in the windowsEnv Proxy whose set/defineProperty - // traps already coerce values, and which stores own functions (toJSON, - // inspect.custom) on it directly. Keep it a plain object so those survive. + // The windowsEnv Proxy's set/defineProperty traps coerce, and windowsEnv() + // stores a toJSON function on this object directly: keep it a plain object. JSC::JSObject* object = nullptr; if (count < 63) { object = constructEmptyObject(globalObject, globalObject->objectPrototype(), count); @@ -860,8 +861,7 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) JSArray* keyArray = constructEmptyArray(globalObject, nullptr, count); RETURN_IF_EXCEPTION(scope, {}); #else - auto* structure = JSProcessEnvMap::createStructure(vm, globalObject, globalObject->objectPrototype()); - JSC::JSObject* object = JSProcessEnvMap::create(vm, structure); + JSC::JSObject* object = createProcessEnvMapObject(globalObject); #endif static NeverDestroyed TZ = MAKE_STATIC_STRING_IMPL("TZ"); diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.h b/src/jsc/bindings/JSEnvironmentVariableMap.h index 0c77d82ac27e..36d238bf1bf8 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.h +++ b/src/jsc/bindings/JSEnvironmentVariableMap.h @@ -13,6 +13,10 @@ namespace Bun { JSC::JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); +// Bare JSProcessEnvMap (no OS-env getters, no Windows Proxy wrap): coerces +// assigned values to strings like Node.js. For worker env: {...} snapshots. +JSC::JSObject* createProcessEnvMapObject(Zig::GlobalObject* globalObject); + // worker_threads SHARE_ENV: a `process.env` whose reads/writes/enumeration go // through the SharedEnvStore of the tree its global belongs to. JSC::JSValue createSharedEnvironmentVariablesMap(Zig::GlobalObject* globalObject); diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index b2b5535f071b..87acc5bff6f3 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -566,7 +566,7 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, strings.append(jsString(vm, value)); } - auto env = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), size >= JSFinalObject::maxInlineCapacity ? JSFinalObject::maxInlineCapacity : size); + auto env = Bun::createProcessEnvMapObject(globalObject); size_t i = 0; for (auto k : map) { // They can have environment variables with numbers as keys. diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index fade192d543c..8116ba7a9c88 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -289,6 +289,35 @@ it("process.env coerces assigned values to strings", () => { } }); +it("process.env coerces assigned values to strings in a worker with an explicit env", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const w = new Worker( + \`process.env.X = 42; + process.env.Y = "y"; + process.env.Y = undefined; + Object.defineProperty(process.env, "Z", { value: 7, writable: true, enumerable: true, configurable: true }); + require("worker_threads").parentPort.postMessage({ + X: process.env.X, Y: process.env.Y, Z: process.env.Z, + xt: typeof process.env.X, yt: typeof process.env.Y, zt: typeof process.env.Z, + });\`, + { eval: true, env: { SEED: "seed" } }, + ); + w.on("message", m => { console.log(JSON.stringify(m)); }); + w.on("error", e => { console.error(String(e)); process.exitCode = 1; });`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ X: "42", Y: "undefined", Z: "7", xt: "string", yt: "string", zt: "string" }); + expect(exitCode).toBe(0); +}); + const MIN_ICU_VERSIONS_BY_PLATFORM_ARCH = { "darwin-x64": "70.1", "darwin-arm64": "72.1", From 1145bf02b1680c5bd37e9943c449b0df07095176 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:34:28 +0000 Subject: [PATCH 5/9] windowsEnv: sync the new defineProperty value, not the pre-define one The Windows defineProperty trap called editWindowsEnvVar(k, internalEnv[k]) before defining the property, so a new key synced undefined (tripping the isNull()||isString() assert in debug builds) and an existing key synced its old value. Coerce the descriptor value, define, then sync the coerced string only when a value was supplied. --- src/js/builtins/ProcessObjectInternals.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 87a42937b446..ba6f98eb0f68 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -531,14 +531,19 @@ export function windowsEnv( const k = String(p).toUpperCase(); $assert(typeof p === "string"); // proxy is only string and symbol. the symbol would have thrown by now // Node coerces env values to strings on defineProperty too, same as assignment. + let coerced: string | undefined; if ("value" in attributes) { - attributes = { ...attributes, value: String(attributes.value) }; + coerced = String(attributes.value); + attributes = { ...attributes, value: coerced }; } if (!(k in internalEnv) && !envMapList.includes(p)) { envMapList.push(p); } - editWindowsEnvVar(k, internalEnv[k]); - return $Object.$defineProperty(internalEnv, k, attributes); + $Object.$defineProperty(internalEnv, k, attributes); + // Sync the new value after a successful define so the OS env sees it (the + // pre-define internalEnv[k] was the old value, or undefined for a new key). + if (coerced !== undefined) editWindowsEnvVar(k, coerced); + return true; }, getOwnPropertyDescriptor(target, p) { if (typeof p === "string") { From 6e03be40fe413d5c6d7016c350ba42bb6fae4862 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:03:57 +0000 Subject: [PATCH 6/9] initializeWorker: add a top exception scope around the env seed loop JSProcessEnvMap is a JSNonFinalObject, so canDoFastPutDirectIndex() is false and indexed putDirectMayBeIndex routes through methodTable()->defineOwnProperty. That path declares ThrowScopes, whose destructors simulate a throw to the caller; with no enclosing scope in initializeWorker the next DECLARE_*_SCOPE elsewhere trips verifyExceptionCheckNeedIsSatisfied. A TopExceptionScope (used the same way elsewhere in bun for top-of-stack loops) absorbs the simulated throw and asserts no real exception per iteration. Also: add finally-cleanup to the un-todo'd env.test.ts coercion test. --- src/jsc/bindings/ZigGlobalObject.cpp | 5 +++++ test/cli/run/env.test.ts | 31 ++++++++++++++++------------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 87acc5bff6f3..96311d156a5e 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -566,12 +566,17 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, strings.append(jsString(vm, value)); } + // JSProcessEnvMap is a JSNonFinalObject, so indexed putDirectMayBeIndex + // routes through the method-table defineOwnProperty with a throw scope; + // a top scope keeps exception-check validation balanced. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto env = Bun::createProcessEnvMapObject(globalObject); size_t i = 0; for (auto k : map) { // They can have environment variables with numbers as keys. // So we must use putDirectMayBeIndex to handle that. env->putDirectMayBeIndex(globalObject, JSC::Identifier::fromString(vm, WTF::move(k.key)), strings.at(i++)); + scope.assertNoException(); } globalObject->m_processEnvObject.set(vm, globalObject, env); } else if (options.sharedEnvStore) { diff --git a/test/cli/run/env.test.ts b/test/cli/run/env.test.ts index a1c04a170851..f889d1d5aae0 100644 --- a/test/cli/run/env.test.ts +++ b/test/cli/run/env.test.ts @@ -902,19 +902,24 @@ for (const shell of ["system", "bun"]) { } test("setting process.env coerces the value to a string", () => { - // @ts-expect-error - process.env.SET_TO_TRUE = true; - let did_call = 0; - // @ts-expect-error - process.env.SET_TO_BUN = { - toString() { - did_call++; - return "bun!"; - }, - }; - expect(process.env.SET_TO_TRUE).toBe("true"); - expect(process.env.SET_TO_BUN).toBe("bun!"); - expect(did_call).toBe(1); + try { + // @ts-expect-error + process.env.SET_TO_TRUE = true; + let did_call = 0; + // @ts-expect-error + process.env.SET_TO_BUN = { + toString() { + did_call++; + return "bun!"; + }, + }; + expect(process.env.SET_TO_TRUE).toBe("true"); + expect(process.env.SET_TO_BUN).toBe("bun!"); + expect(did_call).toBe(1); + } finally { + delete process.env.SET_TO_TRUE; + delete process.env.SET_TO_BUN; + } }); test("NODE_ENV=test loads .env.test even when .env.production exists", () => { From f9fda6b9ca8f318600fc8bc5559806469f471ce8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:30:08 +0000 Subject: [PATCH 7/9] structured-clone: allow JSProcessEnvMap/JSSharedEnvMap as plain objects JSProcessEnvMap carries its own ClassInfo, so the ObjectStartState check in CloneSerializer returned DataCloneError for structuredClone(process.env) and postMessage({env: process.env}). Node structured-clones process.env as a plain object of its string entries, and Bun did too when the object was a JSFinalObject. Whitelist both env-map classes alongside NapiPrototype via a small isEnvironmentVariablesMapObject() predicate. Test: the coercion tests now assert structuredClone(process.env) succeeds and round-trips string values; the worker test also verifies the seed env var is visible and process.env clones inside the worker. Dropped the stderr-exactly-empty assertion per repo review rules. windowsEnv defineProperty trap: record the key in envMapList only after a successful define, using the same case-insensitive check as set(). --- src/js/builtins/ProcessObjectInternals.ts | 8 ++++---- src/jsc/bindings/JSEnvironmentVariableMap.cpp | 5 +++++ src/jsc/bindings/JSEnvironmentVariableMap.h | 4 ++++ .../webcore/SerializedScriptValue.cpp | 5 ++++- test/js/node/process/process.test.js | 20 ++++++++++++++++--- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index ba6f98eb0f68..5b3105ab29d9 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -536,12 +536,12 @@ export function windowsEnv( coerced = String(attributes.value); attributes = { ...attributes, value: coerced }; } - if (!(k in internalEnv) && !envMapList.includes(p)) { + $Object.$defineProperty(internalEnv, k, attributes); + // Sync the new value and record the key for enumeration only after a + // successful define, so a failed define leaves no phantom state. + if (!envMapList.includes(p) && !envMapList.some(x => x.toUpperCase() === k)) { envMapList.push(p); } - $Object.$defineProperty(internalEnv, k, attributes); - // Sync the new value after a successful define so the OS env sees it (the - // pre-define internalEnv[k] was the old value, or undefined for a new key). if (coerced !== undefined) editWindowsEnvVar(k, coerced); return true; }, diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index c16b1bd264ee..5d541c84f883 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -842,6 +842,11 @@ JSObject* createProcessEnvMapObject(Zig::GlobalObject* globalObject) return JSProcessEnvMap::create(vm, structure); } +bool isEnvironmentVariablesMapObject(const JSC::ClassInfo* info) +{ + return info == JSProcessEnvMap::info() || info == JSSharedEnvMap::info(); +} + JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) { VM& vm = globalObject->vm(); diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.h b/src/jsc/bindings/JSEnvironmentVariableMap.h index 36d238bf1bf8..7288030c072d 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.h +++ b/src/jsc/bindings/JSEnvironmentVariableMap.h @@ -17,6 +17,10 @@ JSC::JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); // assigned values to strings like Node.js. For worker env: {...} snapshots. JSC::JSObject* createProcessEnvMapObject(Zig::GlobalObject* globalObject); +// True for JSProcessEnvMap/JSSharedEnvMap classInfo; both structured-clone like +// a plain object (Node.js structuredClone(process.env) succeeds). +bool isEnvironmentVariablesMapObject(const JSC::ClassInfo*); + // worker_threads SHARE_ENV: a `process.env` whose reads/writes/enumeration go // through the SharedEnvStore of the tree its global belongs to. JSC::JSValue createSharedEnvironmentVariablesMap(Zig::GlobalObject* globalObject); diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index 4c3d2d90deb0..ceb4ea7bcef0 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -120,6 +120,7 @@ #include "CryptoKeyType.h" #include "JSNodePerformanceHooksHistogram.h" #include "../napi.h" +#include "../JSEnvironmentVariableMap.h" #include #include @@ -2812,7 +2813,9 @@ SerializationReturnCode CloneSerializer::serialize(JSValue in) // like a plain object from JS's perspective (matches Node.js). // ObjectPrototype is allowed because %Object.prototype% is an immutable // prototype exotic object that the spec carves out of this rejection. - if (inObject->classInfo() != JSFinalObject::info() && inObject->classInfo() != Zig::NapiPrototype::info() && inObject->classInfo() != JSC::ObjectPrototype::info()) + // process.env (JSProcessEnvMap / JSSharedEnvMap) is allowed because + // Node.js structured-clones it as a plain object of its string entries. + if (inObject->classInfo() != JSFinalObject::info() && inObject->classInfo() != Zig::NapiPrototype::info() && inObject->classInfo() != JSC::ObjectPrototype::info() && !Bun::isEnvironmentVariablesMapObject(inObject->classInfo())) return SerializationReturnCode::DataCloneError; inputObjectStack.append(inObject); indexStack.append(0); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 8116ba7a9c88..6685bdf363cb 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -273,6 +273,8 @@ it("process.env coerces assigned values to strings", () => { }; }).toThrow("boom"); expect(process.env.COERCE_THROWS).toBeUndefined(); + + expect(structuredClone(process.env).COERCE_NUM).toBe("42"); } finally { for (const k of [ "COERCE_UNDEF", @@ -301,8 +303,10 @@ it("process.env coerces assigned values to strings in a worker with an explicit process.env.Y = undefined; Object.defineProperty(process.env, "Z", { value: 7, writable: true, enumerable: true, configurable: true }); require("worker_threads").parentPort.postMessage({ + SEED: process.env.SEED, X: process.env.X, Y: process.env.Y, Z: process.env.Z, xt: typeof process.env.X, yt: typeof process.env.Y, zt: typeof process.env.Z, + clone: structuredClone(process.env), });\`, { eval: true, env: { SEED: "seed" } }, ); @@ -313,9 +317,19 @@ it("process.env coerces assigned values to strings in a worker with an explicit stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual({ X: "42", Y: "undefined", Z: "7", xt: "string", yt: "string", zt: "string" }); - expect(exitCode).toBe(0); + expect({ result: JSON.parse(stdout.trim() || stderr), exitCode }).toEqual({ + result: { + SEED: "seed", + X: "42", + Y: "undefined", + Z: "7", + xt: "string", + yt: "string", + zt: "string", + clone: { SEED: "seed", X: "42", Y: "undefined", Z: "7" }, + }, + exitCode: 0, + }); }); const MIN_ICU_VERSIONS_BY_PLATFORM_ARCH = { From 9d47ddaebedb40e32a03d34001cce2a8c0f892a7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:40:39 +0000 Subject: [PATCH 8/9] test: guard main-thread structuredClone(process.env) on !isWindows Windows wraps process.env in a Proxy that structured-clone rejects; that's pre-existing and unchanged by this PR. The worker test still asserts structuredClone(process.env) on every platform (the worker env is a JSProcessEnvMap on Windows too). --- test/js/node/process/process.test.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 6685bdf363cb..e863b617c07f 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -274,7 +274,11 @@ it("process.env coerces assigned values to strings", () => { }).toThrow("boom"); expect(process.env.COERCE_THROWS).toBeUndefined(); - expect(structuredClone(process.env).COERCE_NUM).toBe("42"); + // structuredClone(process.env) must keep working now that the backing object + // has its own ClassInfo. Windows wraps it in a Proxy that was never clonable. + if (!isWindows) { + expect(structuredClone(process.env).COERCE_NUM).toBe("42"); + } } finally { for (const k of [ "COERCE_UNDEF", From 372ec7f35d88ea64659d867e9b55522137fd701b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:09:17 +0000 Subject: [PATCH 9/9] process.env: throw on Symbol values on Windows; cover BigInt/Symbol/JSON.stringify Template-literal coercion in the windowsEnv Proxy set/defineProperty traps so Symbol values throw TypeError the same as POSIX and Node.js (String() special- cases Symbol and would have silently stored "Symbol(s)"). Extends the coercion test to cover BigInt, array, function, Object.assign, the Object.values all-string invariant, and JSON.stringify(process.env) surviving a BigInt assignment. --- src/js/builtins/ProcessObjectInternals.ts | 6 +++-- test/js/node/process/process.test.js | 33 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 5b3105ab29d9..32c87326707e 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -492,7 +492,9 @@ export function windowsEnv( set(_, p, value) { const k = String(p).toUpperCase(); $assert(typeof p === "string"); // proxy is only string and symbol. the symbol would have thrown by now - value = String(value); // If toString() throws, we want to avoid it existing in the envMapList + // Spec ToString, not String(): Symbol values must throw TypeError (Node.js + // behavior), and a throwing toString() must fire before envMapList is touched. + value = `${value}`; // Track the key for enumeration if it isn't already there. Don't gate on // `k in internalEnv`: the proxy-related env-var accessors (HTTP_PROXY, // HTTPS_PROXY, NO_PROXY and lowercase variants) always exist on @@ -533,7 +535,7 @@ export function windowsEnv( // Node coerces env values to strings on defineProperty too, same as assignment. let coerced: string | undefined; if ("value" in attributes) { - coerced = String(attributes.value); + coerced = `${attributes.value}`; attributes = { ...attributes, value: coerced }; } $Object.$defineProperty(internalEnv, k, attributes); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index e863b617c07f..34971bf3eeae 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -238,8 +238,12 @@ it("process.env coerces assigned values to strings", () => { process.env.COERCE_NUM = 42; process.env.COERCE_NULL = null; process.env.COERCE_BOOL = true; + process.env.COERCE_BIG = 42n; + process.env.COERCE_ARR = [1, 2]; + process.env.COERCE_FN = function f() {}; process.env.COERCE_OBJ = { toString: () => "from-toString" }; process.env["4242424242"] = 55; + Object.assign(process.env, { COERCE_ASN: 7 }); Object.defineProperty(process.env, "COERCE_DEF", { value: 7, writable: true, @@ -252,19 +256,30 @@ it("process.env coerces assigned values to strings", () => { num: process.env.COERCE_NUM, null: process.env.COERCE_NULL, bool: process.env.COERCE_BOOL, + big: process.env.COERCE_BIG, + arr: process.env.COERCE_ARR, + fn: process.env.COERCE_FN, obj: process.env.COERCE_OBJ, idx: process.env["4242424242"], + asn: process.env.COERCE_ASN, def: process.env.COERCE_DEF, }).toEqual({ undef: "undefined", num: "42", null: "null", bool: "true", + big: "42", + arr: "1,2", + fn: String(function f() {}), obj: "from-toString", idx: "55", + asn: "7", def: "7", }); + expect(Object.values(process.env).every(v => typeof v === "string")).toBe(true); + expect(JSON.parse(JSON.stringify(process.env)).COERCE_BIG).toBe("42"); + expect(() => { process.env.COERCE_THROWS = { toString() { @@ -274,6 +289,19 @@ it("process.env coerces assigned values to strings", () => { }).toThrow("boom"); expect(process.env.COERCE_THROWS).toBeUndefined(); + // Node throws on Symbol values (ToString on Symbol throws) for set, assign, and defineProperty. + expect(() => (process.env.COERCE_SYM = Symbol("s"))).toThrow(TypeError); + expect(() => Object.assign(process.env, { COERCE_SYM: Symbol("s") })).toThrow(TypeError); + expect(() => + Object.defineProperty(process.env, "COERCE_SYM", { + value: Symbol("s"), + writable: true, + enumerable: true, + configurable: true, + }), + ).toThrow(TypeError); + expect(process.env.COERCE_SYM).toBeUndefined(); + // structuredClone(process.env) must keep working now that the backing object // has its own ClassInfo. Windows wraps it in a Proxy that was never clonable. if (!isWindows) { @@ -285,9 +313,14 @@ it("process.env coerces assigned values to strings", () => { "COERCE_NUM", "COERCE_NULL", "COERCE_BOOL", + "COERCE_BIG", + "COERCE_ARR", + "COERCE_FN", "COERCE_OBJ", + "COERCE_ASN", "COERCE_DEF", "COERCE_THROWS", + "COERCE_SYM", "4242424242", ]) { delete process.env[k];