dotenv: expose auto-loaded .env values as non-enumerable on process.env - #35481
dotenv: expose auto-loaded .env values as non-enumerable on process.env#35481robobun wants to merge 14 commits into
Conversation
Values that Bun auto-discovers in .env/.env.local/.env.{NODE_ENV} are now
installed on process.env as DontEnum CustomAccessor properties. Direct reads
(process.env.FOO, 'FOO' in process.env) still work, but Object.keys/for..in/
spread no longer list them, so tools that implement their own mode-aware
dotenv loading (Vite's loadEnv, dotenv-flow) stop mistaking them for
shell-provided overrides.
Explicit --env-file values and OS env vars stay enumerable. Writing to a
conditional key from JS promotes it to an enumerable data property.
Bun.spawn's default inherited env (no env option) still includes .env values
via the loader map.
Fixes #6338
WalkthroughChangesDotenv enumerability
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Found 4 issues this PR may fix:
🤖 Generated with Claude Code |
…for special-cased/index keys; fix vacuous --env-file assertions
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/jsc/bindings/JSEnvironmentVariableMap.cpp:825— This line makesjsSetterEnvironmentVariablethe live[[Set]]path for every auto-loaded.envkey (it was previously defined but never referenced —cached_getter_setterpassesnullptrfor the setter). Its body callsJSValue::decode(value).toString(globalObject)with noDECLARE_THROW_SCOPEand noRETURN_IF_EXCEPTION; on a throwingSymbol.toPrimitive/toString, JSC returns a non-null emptyJSString*(notnullptr), so theif (!string)guard is bypassed andputDirectwrites""with an exception pending — a throwing assignment both throws and silently overwrites the.envvalue, and tripsBUN_JSC_validateExceptionChecks=1. AddDECLARE_THROW_SCOPE+RETURN_IF_EXCEPTION(scope, false)aftertoString, matching the adjacentjsSetterProxyEnvironmentVariable.Extended reasoning...
What the bug is
Line 825 wires
jsSetterEnvironmentVariablein as the setter half ofconditional_getter_setter. At the base commit (028f7a3b), that function was defined but never referenced as a setter anywhere —cached_getter_setterpassednullptrfor its setter, and the only other hit was a comment. It was dead code. This PR makes it the live[[Set]]path for every auto-loaded.env*key on POSIX (the property is installed withPropertyAttribute::CustomAccessor, so every write invokes the custom setter).Its body:
VM& vm = globalObject->vm(); JSC::JSObject* object = JSValue::decode(thisValue).getObject(); if (!object) return false; auto string = JSValue::decode(value).toString(globalObject); // can run user JS, can throw if (!string) [[unlikely]] return false; // toString() never returns null on exception object->putDirect(vm, propertyName, string, 0); return true;
There is no
DECLARE_THROW_SCOPEand noRETURN_IF_EXCEPTIONaftertoString(globalObject).JSValue::toStringinvokes userSymbol.toPrimitive/toString/valueOf, which can throw; on exception JSC callstoStringSlowCase(..., returnEmptyStringOnError=true)and returnsjsEmptyString(vm), notnullptr. Soif (!string)does not catch it, and the code proceeds toputDirectwith a pending exception — mutatingprocess.envto the empty string as an observable side effect of a failed assignment — and returnstrue.Step-by-step proof
On POSIX with
.envcontainingAPI_KEY=secret(and nothing for that key in the OS environment):load_default_files→load_env_file::<false>→Parser::parse_bytes::<false, false, true, /*CONDITIONAL=*/true>writesHashTableValue { value: b"secret", conditional: true }forAPI_KEY.createEnvironmentVariablesMapcallsBun__isEnvKeyConditional→true→ installsconditional_getter_setterwithCustomAccessor | DontEnumforAPI_KEY.- User code runs
process.env.API_KEY = { toString() { throw new Error('boom'); } };. - Because the property is a
CustomAccessor,[[Set]]invokesjsSetterEnvironmentVariable. JSValue::decode(value).toString(globalObject)calls the user'stoString(), which throws.toStringSlowCasecatches the pending exception path and returnsjsEmptyString(vm)— a valid, non-nullJSString*.if (!string)isfalse; execution falls through.object->putDirect(vm, propertyName, string, 0)runs with an exception pending, replacing theDontEnumaccessor with an enumerable data property whose value is the empty string.- The setter returns
true; the pending exception propagates to the caller.
Under
BUN_JSC_validateExceptionChecks=1 BUN_JSC_dumpSimulatedThrows=1(which CLAUDE.md instructs contributors to run), this trips the exception-scope validator at the next scope. In release, the assignment throws to the caller andprocess.env.API_KEYhas been silently overwritten with""and promoted to an enumerable own property — so a subsequentcatch-and-retry reads the wrong value, andObject.keys(process.env)now includesAPI_KEYeven though the write conceptually failed.Why existing code doesn't prevent it
The immediately-adjacent
jsSetterProxyEnvironmentVariablein the same file shows the correct pattern:DECLARE_THROW_SCOPE(vm)at the top andRETURN_IF_EXCEPTION(scope, false)right aftertoString, followed by theif (!string)check — proving that the null guard alone is insufficient in this codebase's own idiom. There are ~88 occurrences of.toString(globalObject); RETURN_IF_EXCEPTIONacrosssrc/jsc/bindings/.jsSetterEnvironmentVariablewas never fixed because it was unreachable; this PR makes it reachable without adding the check.Windows is unaffected: the Proxy
settrap coerces viaString(value)in JS before writing to the underlying object, so the setter only ever sees aJSStringandtoStringis a no-op there.Impact
REVIEW.md lists this in the most-blocked category: "Exception checks after every call that can enter JS. Every call that can throw or run user code (toString/toNumber, ...) needs RETURN_IF_EXCEPTION under a ThrowScope (C++) ... before its result is used." This PR activates previously-dead code that violates that rule and produces observably wrong behavior (side-effect-on-error). It is not pre-existing — before this PR the function was unreferenced dead code.
Fix
Add a throw scope and check to
jsSetterEnvironmentVariable, matchingjsSetterProxyEnvironmentVariable:JSC_DEFINE_CUSTOM_SETTER(jsSetterEnvironmentVariable, ...) { VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); JSC::JSObject* object = JSValue::decode(thisValue).getObject(); if (!object) return false; auto* string = JSValue::decode(value).toString(globalObject); RETURN_IF_EXCEPTION(scope, false); if (!string) [[unlikely]] return false; object->putDirect(vm, propertyName, string, 0); return true; }
-
🔴
src/jsc/bindings/JSEnvironmentVariableMap.cpp:891-893— Beyond the SHARE_ENV path flagged above, two more internalprocess.envenumeration sites now drop auto-loaded.envkeys: (1)node:child_process'snormalizeSpawnArguments(src/js/node/child_process.ts:1015-1057) builds the default child env viafor (const key in env)overprocess.envand always sets[kBunEnv], soBun.spawn's loader-map fallback is unreachable —child_process.spawn/exec/forkwith noenvoption now silently drops.env-only values (same fornode:clusterat primary.ts:81), whileBun.spawn({cmd})with noenvstill inherits them; and (2) the non-SHARE_ENVworker_threadsdefault path at JSWorker.cpp:284 snapshots the parent viagetOwnPropertyNames(..., DontEnumPropertiesMode::Exclude)intooptions.env, and ZigGlobalObject.cpp then builds the worker'sprocess.envas a plain object from that HashMap — bypassingcreateEnvironmentVariablesMap— soprocess.env.API_KEYinside the worker returnsundefined, and whether the worker sees.envvalues non-deterministically depends on whether the parent touchedprocess.envbefore spawning. Both are direct-read regressions that violate the PR's "direct reads still work" contract; per REVIEW.md "Fix the whole class in the same PR", all internalprocess.envenumeration sites need auditing alongsideensureSharedEnvStoreForWorker.Extended reasoning...
What the bug is
The comment above covers
ensureSharedEnvStoreForWorker(theenv: SHARE_ENVpath). This PR'sDontEnumchange breaks two more internal consumers that materialiseprocess.envby enumerating it, and neither is touched by the SHARE_ENV fix:(1)
node:child_processdefault env inheritancenormalizeSpawnArguments(src/js/node/child_process.ts:1015-1057) does:const env = options.env || process.env; const bunEnv = {}; let envKeys = []; for (const key in env) { ArrayPrototypePush.$call(envKeys, key); } ... [kBunEnv]: bunEnv, // always a truthy {}
for..inskips the newCustomAccessor | DontEnumproperties, so every key that came only from an auto-discovered.env*file is dropped frombunEnv. Because[kBunEnv]is set unconditionally to a truthy{}, both consumers pick it as the explicitenv:forBun.spawn:- spawnSync path — line 566:
env: options[kBunEnv] || options.env || undefined - ChildProcess#spawn — line 1382:
options[kBunEnv] || parseEnvPairs(envPairs) || process.env
An explicit
envobject makesBun.spawnsetoverride_env = true(js_bun_spawn_bindings.rs), so the!override_envloader-map fallback that the PR description carves out ("Bun.spawn's default inherited environment … still includes.envvalues via the loader map") is unreachable fromnode:child_process.The same enumeration hits
node:clusterat src/js/internal/cluster/primary.ts:81:{ ...process.env, ...env, NODE_UNIQUE_ID: … }— spread also skipsDontEnum.(2)
worker_threadsdefault (non-SHARE_ENV) env snapshotsrc/js/node/worker_threads.tsalways passes an options object ({...options, preload:[…]}) to the native constructor. InconstructJSWorker(src/jsc/bindings/webcore/JSWorker.cpp:271-297), when the user omitsenv:} else if (globalObject->m_processEnvObject.isInitialized()) { envObject = globalObject->processEnvObject(); } ... envObject->methodTable()->getOwnPropertyNames(envObject, ..., JSC::DontEnumPropertiesMode::Exclude); // :284 ... options.env.emplace(WTF::move(env)); // :297
The auto-loaded
.envkeys are filtered out of the snapshot. Then inZigGlobalObject.cppthe worker seesoptions.env.has_value()→ true and buildsm_processEnvObjectas a plainconstructEmptyObjectpopulated viaputDirectMayBeIndexfrom that HashMap — bypassingcreateEnvironmentVariablesMapand the worker's cloned env-loader map. So inside the workerprocess.env.API_KEYreturnsundefinedfor any.env-only key: a direct-read failure, not just an enumeration change.Step-by-step proof
child_process:
// .env: DATABASE_URL=postgres://... require('child_process').execSync('printenv DATABASE_URL')
normalizeSpawnArguments:options.envundefined →env = process.env.for (const key in env)—DATABASE_URLisDontEnumafter this PR → skipped.bunEnv = {}(no DATABASE_URL);[kBunEnv]: bunEnvset.- Line 566:
env: options[kBunEnv]→{}-derived object passed toBun.spawn. Bun.spawnsetsoverride_env = true; loader-map default never consulted.- Child (
printenv, a non-Bun process) has noDATABASE_URL→ exit 1.
Before this PR the same key was an enumerable
CustomValueaccessor, so step 2 included it. MeanwhileBun.spawn({cmd:['printenv','DATABASE_URL']})with noenvstill prints the value viacreate_null_delimited_env_map()— so the two spawn APIs now silently diverge.worker_threads:
// .env: API_KEY=secret process.env.PATH; // (a) reifies m_processEnvObject new Worker('data:text/javascript,console.log(process.env.API_KEY)');
- (a) initializes
globalObject->m_processEnvObject. - worker_threads.ts always passes
{...options, preload:[…]}, sooptionsObjectis non-null andenvValue = getIfPropertyExists("env")is empty. - JSWorker.cpp:273
else if (m_processEnvObject.isInitialized())→ true →envObject = processEnvObject(). - JSWorker.cpp:284 enumerates with
DontEnumPropertiesMode::Exclude→API_KEY(nowDontEnum) is skipped. options.env.emplace(map)with only OS/enumerable keys.- Worker init:
options.env.has_value()→ builds a plain JSObject from the HashMap;createEnvironmentVariablesMapand the cloned loader map are never consulted. - Worker prints
undefined.
If the parent has not touched
process.envbefore spawning,m_processEnvObject.isInitialized()is false,options.envstaysnullopt, the worker falls through tocreateEnvironmentVariablesMapover its cloned loader map, andAPI_KEYis readable. So whether the worker sees.envvalues depends on whether the parent happened to readprocess.envfirst — spooky action at a distance.Why existing code doesn't prevent it
normalizeSpawnArgumentsunconditionally sets[kBunEnv]even whenoptions.envwas omitted, so the|| undefinedfallback at line 566 never fires andBun.spawn's loader-map default is unreachable fromnode:child_process. No test undertest/js/node/child_process/exercises.envpropagation.- The SHARE_ENV fix proposed in the previous comment (change
ensureSharedEnvStoreForWorkertoInclude) does not touch JSWorker.cpp:284, which is a separate call site with a distinct symptom (the worker'sprocess.envis a plain in-memory object with no fallback to the loader map — the value is unrecoverable inside the worker).
Impact
- child_process/cluster: silent behavior regression on a very common code path.
child_process.execSync('migration-tool')from a Bun app that relies on.envforDATABASE_URLnow runs the tool without it. If the child is a non-Bun process, or a Bun process launched with a differentcwd, the value is gone entirely. This creates an undocumentedBun.spawnvschild_process.spawninconsistency that neither the PR description nor the docs update mentions. - worker_threads: violates the PR's own "Direct reads (
process.env.FOO…) still work" contract inside the worker, and makes.envvisibility non-deterministic on unrelated parent-side reads.
Per REVIEW.md "Fix the whole class in the same PR" (parallel switch arms, POSIX/Windows branches, every caller of a changed helper): these are sibling internal
process.envenumeration sites of the same class asensureSharedEnvStoreForWorkerand should be fixed together.Fix
- child_process/cluster: when
options.envis not provided, leave[kBunEnv]unset (or set it toundefined) so the underlyingBun.spawnfalls through to the native loader map — matching the PR's statedBun.spawncontract. Alternatively, enumerateprocess.envwithObject.getOwnPropertyNames(which includes non-enumerable own properties) when defaulting fromprocess.env. If the divergence is intentional (child_process follows Node's OS-only inheritance), it must be documented alongside the enumeration change. - worker_threads default snapshot: either change JSWorker.cpp:284 to
DontEnumPropertiesMode::Include(still filtering callables/undefined, as the SHARE_ENV seed does), or — whenenvValueis absent — leaveoptions.envasnulloptregardless ofm_processEnvObject.isInitialized()so the worker buildsprocess.envviacreateEnvironmentVariablesMapover its cloned loader map (which already carries the correctconditionalflags and thus preserves both direct reads and the newDontEnumsemantics inside the worker).
- spawnSync path — line 566:
…; include DontEnum in the default worker snapshot; promote same-value writes on Windows
77c767b to
8f71910
Compare
8f71910 to
dff2fa0
Compare
|
All review concerns addressed across af13923, 24c4e37 and dff2fa0:
|
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/jsc/bindings/JSEnvironmentVariableMap.cpp (1)
56-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract the shared lookup body between the two env-var getters.
jsGetterConditionalEnvironmentVariableduplicates nearly all ofjsGetterEnvironmentVariable(this-check, name extraction,Bun__getEnvValuecall), differing only in whether the result is cached viaputDirect. Since this is the code path that enforces the DontEnum/enumerability guarantee for auto-loaded.envvalues, keeping two near-identical implementations risks the two silently diverging on a future edit (e.g. an error-handling fix applied to one but not the other).♻️ Suggested refactor: share the lookup, vary only the caching behavior
+static JSC::JSValue lookupEnvironmentVariable(JSGlobalObject* globalObject, JSC::EncodedJSValue thisValue, PropertyName propertyName, JSC::JSObject*& outThisObject) +{ + outThisObject = dynamicDowncast<JSObject>(JSValue::decode(thisValue)); + if (!outThisObject) [[unlikely]] + return jsUndefined(); + + ZigString name = toZigString(propertyName.publicName()); + ZigString value = { nullptr, 0 }; + if (name.len == 0) [[unlikely]] + return jsUndefined(); + if (!Bun__getEnvValue(globalObject, &name, &value)) + return jsUndefined(); + return jsString(globalObject->vm(), Zig::toStringCopy(value)); +} + JSC_DEFINE_CUSTOM_GETTER(jsGetterEnvironmentVariable, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, PropertyName propertyName)) { - ... (existing body) ... + JSC::JSObject* thisObject = nullptr; + JSValue result = lookupEnvironmentVariable(globalObject, thisValue, propertyName, thisObject); + if (thisObject && !result.isUndefined()) + thisObject->putDirect(globalObject->vm(), propertyName, result, 0); + return JSValue::encode(result); } JSC_DEFINE_CUSTOM_GETTER(jsGetterConditionalEnvironmentVariable, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, PropertyName propertyName)) { - ... (existing body) ... + JSC::JSObject* thisObject = nullptr; + return JSValue::encode(lookupEnvironmentVariable(globalObject, thisValue, propertyName, thisObject)); }Also applies to: 80-105
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/jsc/bindings/JSEnvironmentVariableMap.cpp` around lines 56 - 78, Extract the common environment-variable lookup logic from jsGetterEnvironmentVariable and jsGetterConditionalEnvironmentVariable into a shared helper covering this-object validation, property-name extraction, empty-name handling, and Bun__getEnvValue failure behavior. Keep the two getters responsible only for their differing caching behavior, with jsGetterEnvironmentVariable retaining putDirect and the conditional getter omitting it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/jsc/bindings/JSEnvironmentVariableMap.cpp`:
- Around line 56-78: Extract the common environment-variable lookup logic from
jsGetterEnvironmentVariable and jsGetterConditionalEnvironmentVariable into a
shared helper covering this-object validation, property-name extraction,
empty-name handling, and Bun__getEnvValue failure behavior. Keep the two getters
responsible only for their differing caching behavior, with
jsGetterEnvironmentVariable retaining putDirect and the conditional getter
omitting it.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: af63a378-423f-49ba-b8f9-542db9ec73a8
📒 Files selected for processing (11)
docs/runtime/environment-variables.mdxsrc/dotenv/env_loader.rssrc/install/PackageManager.rssrc/install_jsc/ini_jsc.rssrc/js/builtins/ProcessObjectInternals.tssrc/jsc/bindings/JSEnvironmentVariableMap.cppsrc/jsc/bindings/JSEnvironmentVariableMap.hsrc/jsc/bindings/webcore/JSWorker.cppsrc/runtime/api/BunObject.rssrc/runtime/cli/test_command.rstest/cli/run/env.test.ts
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
src/jsc/bindings/JSEnvironmentVariableMap.cpp:724-728— TheDontEnumPropertiesMode::Includefix in af13923 is incomplete on both platforms. Windows:processEnvObject()is the Proxy, andProxyObject::getOwnPropertyNamesreturns exactly theownKeys()trap result (envMapList.slice()), which this PR filters conditional keys out of at line 843-846 —Includecannot add keys the trap omitted, so the store is seeded without them andprocess.env.AUTO_FROM_FILEreadsundefinedon the main thread after the swap (the new SHARE_ENV test will fail on Windows CI). POSIX: conditional keys are seeded, butJSSharedEnvMap::getOwnPropertyNamesadds everystore->keys()entry regardless ofmodeandgetOwnPropertySlotreturns attributes0, so afternew Worker(url, {env: SHARE_ENV})the main thread'sObject.keys(process.env)starts listing auto-loaded.envkeys — contradicting the docs paragraph this PR adds. Fix: seed the store from the native env-loader map (Bun__getEnvCount/Bun__getEnvKey/Bun__isEnvKeyConditional) rather than the JS object, then overlay JS-enumerable own properties for user writes, and carry a per-keydontEnumbit throughSharedEnvStorethatJSSharedEnvMap::getOwnPropertyNames/getOwnPropertySlothonors andputclears.Extended reasoning...
What the bug is
Commit af13923 addressed the earlier SHARE_ENV review comment by switching
ensureSharedEnvStoreForWorker's enumeration fromDontEnumPropertiesMode::Excludeto::Include(line 728), and adding a test that direct reads survive founding a SHARE_ENV tree. That fix has two remaining gaps — one per platform, in opposite directions.Windows:
Includeis a no-op through the Proxy — direct reads still lostOn Windows,
m_processEnvObjectis the Proxy returned byprocessObjectInternalsWindowsEnvCodeGenerator(seecreateEnvironmentVariablesMap'sprofiledCalltail). ItsownKeys()trap (ProcessObjectInternals.ts:547-550) is:ownKeys() { return envMapList.slice(); }
envMapListis thekeyArrayargument, and this PR gateskeyArray->pushon!conditional(line 843-846). So conditional keys are never inenvMapList.ProxyObject::getOwnPropertyNamesimplements ES §10.5.11[[OwnPropertyKeys]]: it invokes theownKeystrap and returns exactly what the trap returned.DontEnumPropertiesMode::Includeonly means "do not post-filter the trap result by[[GetOwnProperty]].enumerable" — it cannot add keys the trap did not return. The ES invariant that would force target own-keys into the result only applies to non-configurable target keys; conditional accessors are installed asCustomAccessor | DontEnumwith noDontDelete, so they are configurable and the invariant doesn't help. The target is extensible, so the extensibility invariant doesn't help either.POSIX: seeded keys become enumerable — docs contradiction
On POSIX,
envObjectis a plainJSObject, soIncludedoes return the DontEnum conditional accessors.jsGetterConditionalEnvironmentVariablereturns the actual.envvalue (notundefined), so it passes theisUndefined()/isCallable()filters andstore->set(key, str)records it.But
SharedEnvStore(SharedEnvStore.h) is a plainString → Stringmap with no per-key attribute bit. After the swap toJSSharedEnvMap:getOwnPropertyNamesdoesfor (const auto& key : store->keys()) propertyNames.add(...)unconditionally — theDontEnumPropertiesMode modeparameter is ignored for store entries.getOwnPropertySlotdoesslot.setValue(object, 0, jsString(vm, value))— attributes0meansenumerable: true.
Step-by-step proof
Windows —
.envcontainsAUTO_FROM_FILE=secret, not in the OS env:createEnvironmentVariablesMap:conditional == true→keyArray->pushskipped;internalEnvgets the key asCustomAccessor | DontEnum. Returns the Proxy.console.log(process.env.AUTO_FROM_FILE)→gettrap →internalEnv[k.toUpperCase()]→ conditional getter →"secret". ✓new Worker('./worker.js', { env: SHARE_ENV })→ensureSharedEnvStoreForWorker:envObject = processEnvObject()→ the Proxy.getOwnPropertyNames(envObject, ..., Include)→ProxyObjectdispatches toownKeys()trap →envMapList.slice()→ AUTO_FROM_FILE absent.- Store seeded without it;
m_processEnvObjectswapped toJSSharedEnvMap.
process.env.AUTO_FROM_FILE→JSSharedEnvMap::getOwnPropertySlot→store->get("AUTO_FROM_FILE")is null → falls to emptyBase→undefined. ✗
The new test at
env.test.ts:314-330expects"secret\nsecret"and will fail on Windows CI.POSIX —
.envcontainsAPI_KEY=secret, not in the OS env:- Before
new Worker:Object.keys(process.env).includes('API_KEY')→false(DontEnum CustomAccessor). ✓ new Worker(url, { env: SHARE_ENV })→ seed loop readsAPI_KEYviaInclude→ getter returns"secret"→store->set("API_KEY", "secret")→ main'sprocess.envswapped toJSSharedEnvMap.- After:
Object.keys(process.env).includes('API_KEY')→getOwnPropertyNamesiteratesstore->keys()→ includesAPI_KEY;getOwnPropertySlotreports attributes0→true. ✗
Same expression flips
false → trueon the main thread as a side effect of spawning a worker — action at a distance that directly contradicts the new docs section ("Values that Bun auto-loads from.envfiles … are not enumerable").Why nothing prevents it
The comment at line 724-726 says "Include DontEnum so auto-loaded .env values … are seeded", which assumes the enumeration source is the object's own-property table. On Windows the source is the trap's return value, which line 843-846 already filtered. On POSIX the seed works, but
SharedEnvStorehas no per-key attribute storage andJSSharedEnvMapnever consultsBun__isEnvKeyConditional. The new SHARE_ENV test only asserts direct reads (process.env.AUTO_FROM_FILE), not thatObject.keysstill excludes it after the swap, so the POSIX enumeration leak has no coverage.The earlier review comment's extended reasoning noted "On Windows the same loss occurs via a different route: the Proxy's
ownKeys()trap returnskeyArray, which this PR now builds by skipping conditional keys" — the applied fix took theIncludesuggestion, which only addresses the POSIX arm.Impact
- Windows: The PR's core "direct reads still work" contract is still broken. Any app that both reads auto-loaded
.envvalues and spawns aSHARE_ENVworker loses those values on the main thread the moment the first worker is created. The newly-added test will fail on Windows CI. - POSIX: The PR's core "not enumerable" contract is broken after founding a SHARE_ENV tree. If a build tool spawns a SHARE_ENV worker before running its own
.env.{mode}loader,{ ...process.env }re-includes the auto-loaded value and the original #6338 shadowing bug reappears on the main thread.
Per REVIEW.md's "Fix the whole class in the same PR" (POSIX/Windows branches, fast/slow paths), and since af13923 already touched exactly this code path, both arms are in scope.
Fix
Seed the store from the source that actually knows the conditional bit, and carry that bit through:
- In
ensureSharedEnvStoreForWorker, seed from the native env-loader map directly — iterateBun__getEnvCount/Bun__getEnvKey/Bun__getEnvValue/Bun__isEnvKeyConditional(the same sourcecreateEnvironmentVariablesMapuses) — then overlay with the JS object's enumerable own properties to pick up user writes made since startup. This bypasses the ProxyownKeys()trap on Windows and works identically on POSIX. (Alternatively, on Windows unwrap to the Proxy target —internalEnv— before enumerating withInclude; but that doesn't solve the POSIX enumeration side, so you'd still need step 2.) - Add a per-key
dontEnumbit toSharedEnvStore(e.g.HashMap<String, std::pair<String, bool>>), set at seed time from the source's conditional flag.JSSharedEnvMap::getOwnPropertyNamesskips those keys whenmode == DontEnumPropertiesMode::Exclude;getOwnPropertySlotreturnsPropertyAttribute::DontEnumfor them;JSSharedEnvMap::put/defineOwnPropertyclear the bit (matching the "assigning promotes to enumerable" contract). - Extend the SHARE_ENV test to also assert
Object.keys(process.env).includes('AUTO_FROM_FILE') === falseboth before and after the worker is created.
-
🔴
src/jsc/bindings/JSEnvironmentVariableMap.cpp:901-904— Installing conditional keys asDontEnumbreaks three moreprocess.env-enumerating consumers that build subprocess/worker env by default.node:child_process(normalizeSpawnArguments, child_process.ts:1015-1057) doesfor (const key in (options.env || process.env))and always setsoptions[kBunEnv], soBun.spawnnever hits its loader-map fallback —execSync('printenv DATABASE_URL')withDATABASE_URLonly in.envnow fails.node:cluster(primary.ts:81) uses{ ...process.env }.worker_threadsdefault (JSWorker.cpp:273-297) enumerates the parent'sprocess.envwithDontEnumPropertiesMode::Excludewhen noenv:is passed, but only ifm_processEnvObject.isInitialized()— so whether a default worker sees.envvalues now depends on whether the parent evaluatedprocess.envfirst (before this PR both branches converged). All three contradict the PR's "direct reads still work" contract inside the child and diverge from theBun.spawnno-envbehavior the PR/docs explicitly preserve; same class as the SHARE_ENV site already fixed at line 728.Extended reasoning...
What the bug is
This PR installs auto-loaded
.envkeys onprocess.envwithPropertyAttribute::DontEnum. The SHARE_ENV seed path (ensureSharedEnvStoreForWorker, line 728) was already updated to enumerate withDontEnumPropertiesMode::Include, but three sibling sites that build a child/worker environment by enumeratingprocess.envwere not:-
src/js/node/child_process.ts:1015-1057—normalizeSpawnArgumentsdoesconst env = options.env || process.envthenfor (const key in env)to buildbunEnv, and unconditionally setsoptions[kBunEnv] = bunEnv. At line 566 (spawnSync) and line 1382 (ChildProcess#spawn),env: options[kBunEnv] || options.env || undefined—bunEnvis always a truthy object (it contains the OS env vars), soBun.spawnreceives an explicitenvand never reaches the loader-map fallback the PR description relies on. Everychild_process.{spawn,exec,execFile,fork,spawnSync,execSync,execFileSync}call withoutoptions.envnow drops.env-only values. -
src/js/internal/cluster/primary.ts:81—createWorkerProcessbuildsworkerEnv = { ...process.env, ...env, NODE_UNIQUE_ID: \${id}` }. Spread skipsDontEnum, so cluster workers' explicitenv` omits auto-loaded values. -
src/jsc/bindings/webcore/JSWorker.cpp:273-297— fornew Worker(url)with noenv:option and no SHARE_ENV, the code checkselse if (globalObject->m_processEnvObject.isInitialized())and, if the parent's lazyprocess.envhas fired, enumerates it withDontEnumPropertiesMode::Exclude(line 284), thenoptions.env.emplace(...)(line 297).ZigGlobalObject.cpp:571-590materializes that as a plainJSFinalObjectviaputDirectMayBeIndex— no accessors, no env-loader fallback — soprocess.env.API_KEY === undefinedin the worker.
The Heisenbug (site 3)
Whether a default
new Worker(url)sees.envvalues now depends on whether the parent already evaluatedprocess.env:- Parent never touched
process.env→m_processEnvObject.isInitialized()is false →envObjectstays null →options.envnot emplaced → worker falls through tom_processEnvObject.initLater→createEnvironmentVariablesMapover its cloned env-loader map (Map::clone_with_allocator()copiesHashTableValueincludingconditional) → worker sees.envvalues. - Parent read
process.env.ANYTHINGfirst → lazy init fired →Excludeenumeration skips DontEnum conditional keys → worker'sprocess.envis a plain object without them →process.env.API_KEY === undefined.
Before this PR the
.envkeys were enumerableCustomValueaccessors, soExcludereturned them and both branches converged.Step-by-step proof
child_process — with
.envcontainingDATABASE_URL=postgres://xand nothing for it in the OS environment:load_default_files→Parser::parse_bytes::<false, false, true, /* CONDITIONAL = */ true>setsconditional = trueonDATABASE_URL.createEnvironmentVariablesMap:Bun__isEnvKeyConditional→true→ installed asCustomAccessor | DontEnum.- User calls
require('child_process').execSync('printenv DATABASE_URL')with noenvoption. normalizeSpawnArguments:env = options.env || process.env→process.env;for (const key in env)skipsDATABASE_URL(DontEnum);bunEnvis built with only OS env vars;options[kBunEnv] = bunEnv.spawnSync→Bun.spawnSync({ env: options[kBunEnv] || ... })—bunEnvis truthy, so it's passed as the explicit env. The loader-map fallback inBun.spawn(which the PR description says still includes.envvalues) is never reached.- Child's OS env has no
DATABASE_URL;printenvexits nonzero. Before this PR: printedpostgres://x.
worker_threads — with
.envcontainingAPI_KEY=secret:const { Worker } = require('worker_threads'); console.log(process.env.API_KEY); // 'secret' — this ALSO forces m_processEnvObject init new Worker(`data:text/javascript,console.log(process.env.API_KEY)`); // worker prints: undefined (regression; before this PR: 'secret')
Delete the parent's
console.logline and the worker printssecretagain — order-dependent behavior on unrelated parent-thread code.Why nothing prevents it
The PR description explicitly promises "
Bun.spawn's default inherited environment (noenvoption) still includes.envvalues via the loader map", butnode:child_processnever reaches that fallback becausenormalizeSpawnArgumentsunconditionally materializesbunEnveven whenoptions.envwas undefined. TheJSWorker.cppsite is the direct native sibling of the SHARE_ENV seed path that was fixed, but it was left atExclude. No test in this PR coverschild_process/cluster/default-worker inheritance of a.env-only value. Per REVIEW.md "Fix the whole class in the same PR — grep for every sibling site sharing the pattern (sync/async twins, sibling entry points)": these enumeration sites are the same class as the SHARE_ENV site and now silently diverge from theBun.spawnbehavior the PR docs describe.Impact
Silent behavioral regression on common paths: any Bun app that auto-loads
.envand callsspawn('psql', ...),exec('python ...'),cluster.fork(), ornew Worker(url)without an explicitenvwill stop passing.envvalues through. The worker case additionally introduces lazy-init-order-dependent behavior that neither the docs nor the PR description acknowledge. The PR's own docs update ("Direct reads still work") is contradicted inside default workers.Fix
- child_process: when
options.envwas undefined, either skip buildingbunEnventirely (leaveoptions[kBunEnv]unset soBun.spawnhits its loader-map default), or build it fromObject.getOwnPropertyNames(process.env)filtered to defined string values. - cluster: same — seed
workerEnvfromObject.getOwnPropertyNames(process.env)(or leave env unset and only addNODE_UNIQUE_ID). - JSWorker.cpp default path: switch line 284 to
DontEnumPropertiesMode::Includewith the sameisUndefined()/isCallable()filtering used inensureSharedEnvStoreForWorker, or seed the worker directly from the native env-loader map when the user did not passenv:(mirroringBun.spawn's no-envfallback).
-
…ptor filtering gives correct enumeration; drop the Proxy unwrap; child_process enumerates process.env via getOwnPropertyNames
|
Follow-up in d667f35 after second-round review:
|
…$ default env; tighten child_process test; docs caveat for SHARE_ENV enumeration; drop stale comment
…t-env siblings: ShellPromise#env(), cluster.fork, and the WASI runner
…env tests; widen debug timeouts for worker-spawning tests
There was a problem hiding this comment.
No new issues found in this round. This is a user-facing behavior change to process.env enumeration semantics (auto-loaded .env values become non-enumerable), with compensating changes across eight default-env-inherit call sites, the Windows Proxy, and worker/SHARE_ENV snapshotting — worth a maintainer sign-off on the API decision itself.
What was reviewed:
- The
conditionalflag flow: onlyload_default_filessets it;--env-file,load_process, and allput()sites stayfalse. createEnvironmentVariablesMap's CustomAccessor|DontEnum arm and the non-caching getter; write-promotes-to-enumerable viajsSetterEnvironmentVariable.- The Windows Proxy:
envMapListnow includes conditional keys (ownKeys sees them),enumerableViewfilters by descriptor,set/definePropertytraps gate on list membership. - Each
getOwnPropertyNames/Includecompensated site (child_process, shell ×3, cluster, node:test, wasi, JSWorker, SHARE_ENV seeding) has a matching test except the two the author explicitly deferred (node:test run(), WASI).
Extended reasoning...
Overview
This PR changes process.env enumeration semantics: values Bun auto-discovers from .env/.env.local/.env.{NODE_ENV} are now installed as DontEnum CustomAccessors so Object.keys, for..in, and spread no longer list them (fixing Vite loadEnv and similar tools that treat enumerable process.env keys as shell-provided overrides). It touches 16 files: the Rust env loader (HashTableValue.conditional + a new CONDITIONAL const generic on the parser), a new Bun__isEnvKeyConditional FFI export, JSEnvironmentVariableMap.cpp (a non-caching conditional getter, per-key DontEnum branching, SHARE_ENV seeding switched to Include), JSWorker.cpp (env snapshot switched to Include when the object is process.env), the Windows process.env Proxy in ProcessObjectInternals.ts, and eight JS-side default-env-inherit call sites (child_process, Bun.$ ×3, cluster.fork, node:test run(), WASI runner) rewritten to snapshot via getOwnPropertyNames. Docs updated in two files; ~220 lines of tests added to env.test.ts.
Security risks
None identified. Environment values are not more exposed than before — they become less visible via enumeration. The compensated inherit paths filter undefined/callable values, so the always-present TZ/TLS/proxy accessors and Windows toJSON don't leak into child env blocks.
Level of scrutiny
High. This is an intentional, documented behavior change to a core runtime object that many tools introspect. The mechanism is sound and the implementation went through several review rounds here (Windows Proxy same-value-write promotion, defineProperty list gating, sibling call-site coverage, SHARE_ENV seeding, docs consistency), but the decision to make auto-loaded .env values non-enumerable — and the ripple that any code doing {...process.env} now silently drops them — is an API-design call that a maintainer should ratify, not a bot.
Other factors
- The author explicitly deferred three items to follow-ups: (1) TZ/NODE_TLS_REJECT_UNAUTHORIZED/BUN_CONFIG_VERBOSE_FETCH setters do not clear DontEnum on write (pre-existing gap now more visible), (2) a
node:testrun()default-env test, (3) a WASI environ test. All were acknowledged in resolved threads. - Three existing
--env-filetests were rewritten fromObject.entries-based enumeration to direct-read assertions; the intent is preserved but the assertion surface changed. - CI build #80263 is running against the head commit; I did not verify its status.
- The Windows-specific Proxy changes (
pushvsputByIndexInline,enumerableView, list-membership gating) are the trickiest platform-gated part and only exercised on Windows CI.
There was a problem hiding this comment.
No issues found in this pass, but this is a deliberate user-facing behavior change to process.env enumeration semantics across 16 files (Rust env loader, JSC C++ bindings, the Windows Proxy, and eight default-env-inheritance call sites), so a maintainer should sign off on the design.
What was reviewed:
- The
conditionalflag plumbing throughenv_loader.rsand its FFI export —load_default_filesis the only path setting it true;--env-file,load_process, andput()all set false. createEnvironmentVariablesMap's DontEnum CustomAccessor arm and the write-promotes-to-enumerable path viajsSetterEnvironmentVariable; the WindowskeyArraynow usespushso ownKeys still lists conditional keys while the descriptor trap keeps them non-enumerable.- The eight compensated default-inherit sites (child_process, Bun.$, cluster.fork, worker snapshot, SHARE_ENV seeding, node:test run, WASI runner) — each uses
getOwnPropertyNames/Includeand filters undefined/callable so unset TZ/proxy accessors andtoJSONdon't leak. - The three modified
--env-filedescribe tests were checked to still assert the same intent (auto-load fallback vs. disabled) via direct reads.
Extended reasoning...
Overview
This PR changes how Bun's auto-discovered .env* values appear on process.env: they become non-enumerable CustomAccessor | DontEnum properties so Object.keys / for..in / spread match Node's OS-only view, fixing tools like Vite's loadEnv that treat enumerable process.env entries as shell-provided overrides. It touches the Rust env loader (HashTableValue.conditional + a new const generic on Parser), a new Bun__isEnvKeyConditional FFI, JSEnvironmentVariableMap.cpp (a second non-caching getter, DontEnum installation, Windows keyArray handling, SHARE_ENV seeding via Include), JSWorker.cpp (isProcessEnv-gated Include snapshot), the Windows process.env Proxy in ProcessObjectInternals.ts, and six built-in JS modules that previously enumerated process.env via spread/for-in for default env inheritance. Docs and ~220 lines of new tests in env.test.ts round it out.
Security risks
None identified. The change narrows what enumerates out of process.env, not what is readable; direct reads and default subprocess/worker inheritance are preserved. No new untrusted-input parsing, no auth/crypto/permissions surface.
Level of scrutiny
High — this is an intentional, documented change to observable process.env semantics that every Bun user and a large fraction of the npm ecosystem interact with. It has cross-platform branches (the Windows Proxy path is materially different), and getting default-env inheritance wrong at any of the eight compensated call sites would silently drop .env values from child processes/workers. The implementation looks correct and well-tested after several review rounds, but the decision to ship this behavior (and its interaction with Bun.spawn({env: process.env}), which the author explicitly chose not to special-case) is a maintainer-level call.
Other factors
The PR has been through four review rounds; every prior finding is resolved, including the docs consistency fix in 5090e3d. Two hunks (WASI runner, node:test run()) were explicitly deferred by the author for follow-up test coverage — both are the same four-line getOwnPropertyNames snapshot used at the six other tested sites, so the risk is low, but it's another reason a human should confirm the deferral is acceptable. The author also deferred the pre-existing TZ/TLS/verbose-fetch setter DontEnum-clearing gap to a follow-up. Given the scope, the observable behavior change, and the deferred items, this warrants a human approval rather than a bot one.
|
CI on 7ef4f00 is green for everything this diff touches. The two remaining red lanes are known Windows flakes unrelated to this change:
|
## What
When Bun is invoked as `node` (via `--bun`'s shim, `bunx --bun`, or a
`node` symlink pointing at the Bun binary), it no longer auto-loads
`.env` / `.env.local` / `.env.{development,production,test}` files.
Explicit `--env-file` arguments are still honored, and direct `bun
<file>` invocations still auto-load as before.
## Repro
```sh
mkdir repro && cd repro
printf 'PUBLICPATH=/\nVITE_PUBLIC_PATH=/dev\n' > .env
printf 'PUBLICPATH=/app\nVITE_PUBLIC_PATH=/app\n' > .env.production
cat > package.json <<'EOF'
{"scripts": {"check": "node -e \"console.log(process.env.PUBLICPATH, process.env.VITE_PUBLIC_PATH)\""}}
EOF
bun --bun run check
# before: / /dev (.env leaked into the node-shimmed child)
# after: undefined undefined
```
In a Vite project this meant `loadEnv('production', cwd, '')` returned
`PUBLICPATH="/"` instead of `"/app"` because Vite (like dotenv) treats
values already present in `process.env` as shell-set overrides that
outrank `.env.{mode}` files.
## Cause
`RunAsNodeCommand` (`exec_as_if_node`) goes straight into
`RunCommand::boot`, which runs `configure_defines` and calls
`run_env_loader(options.env.disable_default_env_files)`. That flag
defaults to `false`, so the default `.env*` set was loaded even though
Node.js itself never does that. The package.json-script runner path
(`configure_env_for_run`) already passes `skip_default_env = true`, but
the node-shim child takes a different code path and never reached that
skip.
## Fix
Set `ctx.args.disable_default_env_files = true` at the top of
`exec_as_if_node`. The flag flows through `VirtualMachine::init` into
`options.env.disable_default_env_files` and makes `configure_defines`
skip default-file discovery while still loading process env and any
explicit `--env-file` list.
## Verification
- `bun bd test test/cli/run/env.test.ts`: 96 pass / 0 fail (4 new tests)
- `bun bd test test/cli/run/as-node.test.ts
test/cli/run/no-envfile.test.ts`: 20 pass / 0 fail
Related: #35481 takes a different approach (non-enumerable auto-loaded
values on `process.env`) for the direct `bun <file>` case; #35710
forwards `.env` from the parent `bun run` to non-bun subprocesses. Both
are orthogonal to this change.
Fixes #6338
Fixes #13614
Fixes #22496
<!-- robobun:evidence:begin -->
---
**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/cli/run/env.test.ts
<!-- robobun:evidence:end -->
What
Values that Bun auto-discovers in
.env/.env.local/.env.{NODE_ENV}are now installed onprocess.envas non-enumerable accessor properties. Direct reads (process.env.FOO,'FOO' in process.env,Object.hasOwn(process.env, 'FOO')) still work, butObject.keys(process.env),for..in, and{ ...process.env }no longer list them.--env-filevalues and OS env vars stay enumerable. Assigning to a conditional key from JS promotes it to an enumerable data property.Bun.spawn's default inherited environment (noenvoption) still includes.envvalues via the loader map.Repro
Cause
Bun's auto-loaded
.envvalues were indistinguishable from OS environment variables onprocess.env. Vite'sloadEnv()(and dotenv-expand) follow the standard dotenv convention that existingprocess.enventries take priority over file values; both probe that via{ ...process.env }andfor (const key in process.env). When Bun runs withoutNODE_ENVset it defaults to the development file set, so.envlands inprocess.env.PUBLICPATH, and Vite then refuses to let.env.productionoverride what it believes is a shell-provided value.Fix
HashTableValueregains aconditional: bool, set only byload_default_files(the auto-discovered.env*files).load_process,--env-file, and programmaticput()keepconditional = false.Bun__isEnvKeyConditionalFFI exposes the flag to C++.createEnvironmentVariablesMapinstalls conditional keys asCustomAccessor | DontEnumwith a non-caching getter and the existingjsSetterEnvironmentVariableso a JS write promotes to an enumerable data property. The Windows Proxy's key array skips conditional keys to keepownKeys()consistent.The
--env-filedescribe block's "fallback to default dotenv behavior" test is updated to check via direct read instead ofObject.entries; its intent (auto.envloading still happens without--env-file) is preserved.Why this is correct
Under Node,
process.envenumeration only ever yields OS environment variables, so any tool that treats enumerableprocess.envkeys as "real env wins" works by construction. This change brings Bun's enumeration in line with that contract while keeping the ergonomicprocess.env.FOOread that Bun's auto-loading exists for. The earlierconditionalfield that #9689 removed was only consulted by the script-runner's subprocess env block; this revives it for the in-processprocess.envobject instead, which is the layer the Vite conflict actually lives in.Behavior change
Object.keys(process.env)/{ ...process.env }no longer include values that came only from an auto-discovered.env*file. This is an observable change; see the updated docs indocs/runtime/environment-variables.mdx.Bun.spawnwith the default (unspecified)envstill inherits everything.Verification
Fixes #6338
Fixes #13614
Fixes #22496
no test proof · iteration 3 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/run/env.test.ts