From d30766f59680a1b72b34834e75449c47bc95f313 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:51:47 +0000 Subject: [PATCH 1/5] process.env: read an empty TZ, NODE_TLS_REJECT_UNAUTHORIZED or BUN_CONFIG_VERBOSE_FETCH as an empty string The getters of these three process.env accessors returned undefined when the variable was set to an empty string. The key was still listed. A spread, JSON.stringify, a Worker, process.loadEnvFile, process.execve and child processes then saw a variable that is set as a variable that is not set. The getters now return undefined only when the variable is not set. --- src/js/builtins/ProcessObjectInternals.ts | 3 +- src/jsc/bindings/BunProcess.cpp | 6 +- src/jsc/bindings/JSEnvironmentVariableMap.cpp | 6 +- test/js/node/process/process-execve.test.ts | 66 ++++++++++++- test/js/node/process/process.test.js | 97 +++++++++++++++++++ .../worker_threads/worker_threads.test.ts | 48 +++++++++ 6 files changed, 215 insertions(+), 11 deletions(-) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 8ea675755e15..1274ed0b02fc 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -708,7 +708,8 @@ export function loadEnvFile(path) { for (const key of Object.keys(parsed)) { // Node: existing env keys win; the file only fills in missing ones. // Compare against undefined rather than `in`: accessor-backed keys (TZ, - // HTTP_PROXY) always exist but read back undefined while unset. + // NODE_TLS_REJECT_UNAUTHORIZED, BUN_CONFIG_VERBOSE_FETCH) always exist + // but read back undefined while unset. if (env[key] === undefined) { env[key] = parsed[key]; } diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index efd62913ade4..8b748dca67c3 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2005,9 +2005,9 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionExecve, __attribute__(( for (unsigned i = 0; i < envNames.size(); i++) { JSValue value = envObject->get(globalObject, envNames[i]); RETURN_IF_EXCEPTION(scope, {}); - // The accessor-backed keys (TZ, NODE_TLS_REJECT_UNAUTHORIZED, - // BUN_CONFIG_VERBOSE_FETCH) read back undefined for an unset / - // empty value; skip rather than rejecting the defaulted env. + // process.env lists a variable whose name starts with `$` or is + // not valid UTF-8, but reads it back undefined; skip rather than + // rejecting the defaulted env. if (value.isUndefined()) continue; const WTF::String& keyStr = envNames[i].string(); diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index e3d6192daed8..bc2d5f886dd8 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -301,7 +301,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsTimeZoneEnvironmentVariableGetter, (JSGlobalObject * return JSValue::encode(hasExistingValue); } - if (!Bun__getEnvValue(globalObject, &name, &value) || value.len == 0) { + if (!Bun__getEnvValue(globalObject, &name, &value)) { return JSValue::encode(jsUndefined()); } @@ -391,7 +391,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsNodeTLSRejectUnauthorizedGetter, (JSGlobalObject * gl EncodedSlice name = toEncodedSlice(propertyName.publicName()); EncodedSlice value = { nullptr, 0 }; - if (!Bun__getEnvValue(globalObject, &name, &value) || value.len == 0) { + if (!Bun__getEnvValue(globalObject, &name, &value)) { return JSValue::encode(jsUndefined()); } @@ -439,7 +439,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsBunConfigVerboseFetchGetter, (JSGlobalObject * global EncodedSlice name = toEncodedSlice(propertyName.publicName()); EncodedSlice value = { nullptr, 0 }; - if (!Bun__getEnvValue(globalObject, &name, &value) || value.len == 0) { + if (!Bun__getEnvValue(globalObject, &name, &value)) { return JSValue::encode(jsUndefined()); } diff --git a/test/js/node/process/process-execve.test.ts b/test/js/node/process/process-execve.test.ts index b2d81a7f44d5..2be5b6b24abb 100644 --- a/test/js/node/process/process-execve.test.ts +++ b/test/js/node/process/process-execve.test.ts @@ -297,10 +297,9 @@ describe.concurrent("process.execve", () => { }); test.skipIf(isWindows)("inherits process.env when env is omitted with an empty TZ in the OS env", async () => { - // The TZ / NODE_TLS_REJECT_UNAUTHORIZED / BUN_CONFIG_VERBOSE_FETCH - // accessors read back undefined for an empty value; the execve env loop - // must skip those rather than rejecting the defaulted process.env with - // ERR_INVALID_ARG_VALUE naming an argument the caller never passed. + // An empty TZ is a valid value: the execve env loop must not reject the + // defaulted process.env with ERR_INVALID_ARG_VALUE naming an argument the + // caller never passed. await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -322,6 +321,65 @@ describe.concurrent("process.execve", () => { }); }); + test.skipIf(isWindows)( + "inherits process.env when env is omitted with a `$`-prefixed name in the OS env", + async () => { + // process.env lists a `$`-prefixed name but reads it back undefined; the + // execve env loop must skip it rather than rejecting the defaulted + // process.env with ERR_INVALID_ARG_VALUE. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `try { process.execve("/definitely/does/not/exist", ["x"]); } + catch (e) { console.log(e.code); }`, + ], + env: { ...bunEnv, $EXECVE_DOLLAR: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), stderr: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ + stdout: "ENOENT", + stderr: "", + exitCode: 0, + }); + }, + ); + + // https://github.com/oven-sh/bun/issues/44379 + test.skipIf(isWindows).each([ + ["env is omitted", ""], + ["env is process.env", ", process.env"], + ["env is a spread of process.env", ", { ...process.env }"], + ])( + "passes an empty TZ, NODE_TLS_REJECT_UNAUTHORIZED and BUN_CONFIG_VERBOSE_FETCH to the new image when %s", + async (_, envArgument) => { + const names = ["TZ", "NODE_TLS_REJECT_UNAUTHORIZED", "BUN_CONFIG_VERBOSE_FETCH"]; + const print = `console.log(JSON.stringify(${JSON.stringify(names)}.map(name => process.env[name] ?? null)))`; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.execve(process.execPath, [process.execPath, "-e", ${JSON.stringify(print)}]${envArgument});`, + ], + env: { ...bunEnv, ...Object.fromEntries(names.map(name => [name, ""])) }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), stderr: exitCode === 0 ? "" : stderr, exitCode }).toEqual({ + stdout: '["","",""]', + stderr: "", + exitCode: 0, + }); + }, + ); + test.skipIf(isWindows)("validates arguments", async () => { await using proc = Bun.spawn({ cmd: [ diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 90ca9bcac27f..1f0c8ac7bcc2 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -137,6 +137,103 @@ it("process.loadEnvFile can set accessor-backed keys and respects empty values", expect({ stdout, exitCode }).toEqual({ stdout: 'http://from-dotenv:8080\n""\nfresh\n', exitCode: 0 }); }); +// https://github.com/oven-sh/bun/issues/44379 +describe.concurrent("TZ, NODE_TLS_REJECT_UNAUTHORIZED and BUN_CONFIG_VERBOSE_FETCH set to an empty string", () => { + const names = ["TZ", "NODE_TLS_REJECT_UNAUTHORIZED", "BUN_CONFIG_VERBOSE_FETCH"]; + const setTo = value => Object.fromEntries(names.map(name => [name, value])); + + async function run(files, env) { + using dir = tempDir("process-env-empty", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: { ...bunEnv, ...env }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { result: stdout ? JSON.parse(stdout) : stderr, exitCode }; + } + + const read = ` + const result = {}; + for (const name of ${JSON.stringify(names)}) { + result[name] = { + read: process.env[name], + bunEnv: Bun.env[name], + importMetaEnv: import.meta.env[name], + listed: Object.keys(process.env).includes(name), + spread: { ...process.env }[name], + json: JSON.parse(JSON.stringify(process.env))[name], + clone: structuredClone(process.env)[name], + }; + } + console.log(JSON.stringify(result)); + `; + const empty = { read: "", bunEnv: "", importMetaEnv: "", listed: true, spread: "", json: "", clone: "" }; + + it.each([ + ["the environment", { "index.js": read }, setTo("")], + [".env", { ".env": names.map(name => `${name}=\n`).join(""), "index.js": read }, setTo(undefined)], + ])("process.env reads them from %s", async (_, files, env) => { + expect(await run(files, env)).toEqual({ result: setTo(empty), exitCode: 0 }); + }); + + const load = { + "vars.env": "TZ=Asia/Tokyo\nNODE_TLS_REJECT_UNAUTHORIZED=0\nBUN_CONFIG_VERBOSE_FETCH=curl\n", + "index.js": ` + const offset = () => new Date("2024-07-15T12:00:00Z").getTimezoneOffset(); + const before = offset(); + process.loadEnvFile("vars.env"); + console.log(JSON.stringify({ + values: ${JSON.stringify(names)}.map(name => process.env[name]), + timeZoneChanged: offset() !== before, + offset: offset(), + })); + `, + }; + + it("process.loadEnvFile keeps them", async () => { + const { result, exitCode } = await run(load, setTo("")); + expect({ values: result.values, timeZoneChanged: result.timeZoneChanged, exitCode }).toEqual({ + values: ["", "", ""], + timeZoneChanged: false, + exitCode: 0, + }); + }); + + it("process.loadEnvFile fills them in when they are not set", async () => { + const { result, exitCode } = await run(load, setTo(undefined)); + // Asia/Tokyo is UTC+9 all year. + expect({ values: result.values, offset: result.offset, exitCode }).toEqual({ + values: ["Asia/Tokyo", "0", "curl"], + offset: -540, + exitCode: 0, + }); + }); + + it("child processes inherit them", async () => { + const print = `console.log(JSON.stringify(${JSON.stringify(names)}.map(name => process.env[name] ?? null)))`; + const children = { + "index.js": ` + import { execFile } from "node:child_process"; + import { promisify } from "node:util"; + const cmd = [process.execPath, "-e", ${JSON.stringify(print)}]; + const [childProcess, bunSpawn, shell] = await Promise.all([ + promisify(execFile)(cmd[0], cmd.slice(1)).then(({ stdout }) => JSON.parse(stdout)), + Bun.spawn({ cmd, env: process.env, stdout: "pipe" }).stdout.json(), + Bun.$\`\${cmd}\`.json(), + ]); + console.log(JSON.stringify({ childProcess, bunSpawn, shell })); + `, + }; + expect(await run(children, setTo(""))).toEqual({ + result: { childProcess: ["", "", ""], bunSpawn: ["", "", ""], shell: ["", "", ""] }, + exitCode: 0, + }); + }); +}); + it("process.env defineProperty matches assignment semantics", () => { // Node's EnvDefiner delegates to the env setter after validating the // descriptor: symbol keys throw a TypeError... diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 4929a68c5b5a..892780e3cd06 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1761,6 +1761,54 @@ test("env: process.env reads in a worker module are evaluated at runtime against }); }); +// https://github.com/oven-sh/bun/issues/44379 +test.concurrent.each([ + "default env", + "default env, after the main thread read process.env", + "env: process.env", + "env: SHARE_ENV", +])( + "env: a worker reads an empty TZ, NODE_TLS_REJECT_UNAUTHORIZED and BUN_CONFIG_VERBOSE_FETCH as an empty string (%s)", + async mode => { + using dir = tempDir("worker-threads-env-empty", { + "main.js": ` + const { Worker, SHARE_ENV, isMainThread, parentPort } = require("node:worker_threads"); + const read = () => + ["TZ", "NODE_TLS_REJECT_UNAUTHORIZED", "BUN_CONFIG_VERBOSE_FETCH"].map(name => process.env[name] ?? null); + if (isMainThread) { + const mode = process.argv[2]; + if (mode === "default env, after the main thread read process.env") read(); + const worker = new Worker( + __filename, + mode === "env: process.env" ? { env: process.env } : mode === "env: SHARE_ENV" ? { env: SHARE_ENV } : {}, + ); + worker.on("error", err => { console.error(err); process.exit(1); }); + worker.on("message", fromWorker => console.log(JSON.stringify({ worker: fromWorker, main: read() }))); + } else { + parentPort.postMessage(read()); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js", mode], + env: { ...bunEnv, TZ: "", NODE_TLS_REJECT_UNAUTHORIZED: "", BUN_CONFIG_VERBOSE_FETCH: "" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ + message: stdout ? JSON.parse(stdout) : stdout, + stderr: exitCode === 0 ? "" : stderr, + exitCode, + }).toEqual({ + message: { worker: ["", "", ""], main: ["", "", ""] }, + stderr: "", + exitCode: 0, + }); + }, +); + describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide one", () => { async function run(mode: string) { const proc = Bun.spawn({ From 8986322dc755afe0fb94688228192f15f4107270 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:08:37 +0000 Subject: [PATCH 2/5] process.test.js: do not structuredClone(process.env) in the empty-value test On Windows process.env is a Proxy, and structuredClone rejects a Proxy with DataCloneError. The spread and JSON.stringify reads cover the same list-then-read path on every platform. --- test/js/node/process/process.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 1f0c8ac7bcc2..745eb3111620 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -165,12 +165,11 @@ describe.concurrent("TZ, NODE_TLS_REJECT_UNAUTHORIZED and BUN_CONFIG_VERBOSE_FET listed: Object.keys(process.env).includes(name), spread: { ...process.env }[name], json: JSON.parse(JSON.stringify(process.env))[name], - clone: structuredClone(process.env)[name], }; } console.log(JSON.stringify(result)); `; - const empty = { read: "", bunEnv: "", importMetaEnv: "", listed: true, spread: "", json: "", clone: "" }; + const empty = { read: "", bunEnv: "", importMetaEnv: "", listed: true, spread: "", json: "" }; it.each([ ["the environment", { "index.js": read }, setTo("")], From d824f7666ce0615c53acea2c559b3f98502697b1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:57:18 +0000 Subject: [PATCH 3/5] Shorten the comments on the undefined checks in process.execve and process.loadEnvFile --- src/js/builtins/ProcessObjectInternals.ts | 5 +---- src/jsc/bindings/BunProcess.cpp | 4 +--- test/js/node/process/process-execve.test.ts | 8 ++------ 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 1274ed0b02fc..f5205b9b7847 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -706,10 +706,7 @@ export function loadEnvFile(path) { const parsed = require("node:util").parseEnv(content); const env = process.env; for (const key of Object.keys(parsed)) { - // Node: existing env keys win; the file only fills in missing ones. - // Compare against undefined rather than `in`: accessor-backed keys (TZ, - // NODE_TLS_REJECT_UNAUTHORIZED, BUN_CONFIG_VERBOSE_FETCH) always exist - // but read back undefined while unset. + // Node: existing env keys win. Not `in`: an accessor-backed key such as TZ exists while unset and reads undefined. if (env[key] === undefined) { env[key] = parsed[key]; } diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 8b748dca67c3..4710792c6c93 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2005,9 +2005,7 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionExecve, __attribute__(( for (unsigned i = 0; i < envNames.size(); i++) { JSValue value = envObject->get(globalObject, envNames[i]); RETURN_IF_EXCEPTION(scope, {}); - // process.env lists a variable whose name starts with `$` or is - // not valid UTF-8, but reads it back undefined; skip rather than - // rejecting the defaulted env. + // process.env reads a `$`-prefixed or non-UTF-8 name as undefined: skip it, do not reject the defaulted env. if (value.isUndefined()) continue; const WTF::String& keyStr = envNames[i].string(); diff --git a/test/js/node/process/process-execve.test.ts b/test/js/node/process/process-execve.test.ts index 2be5b6b24abb..33b9752e6c08 100644 --- a/test/js/node/process/process-execve.test.ts +++ b/test/js/node/process/process-execve.test.ts @@ -297,9 +297,7 @@ describe.concurrent("process.execve", () => { }); test.skipIf(isWindows)("inherits process.env when env is omitted with an empty TZ in the OS env", async () => { - // An empty TZ is a valid value: the execve env loop must not reject the - // defaulted process.env with ERR_INVALID_ARG_VALUE naming an argument the - // caller never passed. + // An empty TZ is a valid value: execve must not reject the defaulted process.env for it. await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -324,9 +322,7 @@ describe.concurrent("process.execve", () => { test.skipIf(isWindows)( "inherits process.env when env is omitted with a `$`-prefixed name in the OS env", async () => { - // process.env lists a `$`-prefixed name but reads it back undefined; the - // execve env loop must skip it rather than rejecting the defaulted - // process.env with ERR_INVALID_ARG_VALUE. + // process.env reads a `$`-prefixed name as undefined: execve must skip it, not reject the defaulted env. await using proc = Bun.spawn({ cmd: [ bunExe(), From 08da71b5590d3ed226baa071ec06d77eafefcfa3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:31:29 +0000 Subject: [PATCH 4/5] process.execve: state the exact condition of the undefined skip process.env reads `$NAME` as undefined only while NAME is not set. When NAME is set, it reads the value of NAME. The comment and the test now say that. --- src/jsc/bindings/BunProcess.cpp | 2 +- test/js/node/process/process-execve.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 7c470e8ab878..1516113583da 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2008,7 +2008,7 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionExecve, __attribute__(( for (unsigned i = 0; i < envNames.size(); i++) { JSValue value = envObject->get(globalObject, envNames[i]); RETURN_IF_EXCEPTION(scope, {}); - // process.env reads a `$`-prefixed or non-UTF-8 name as undefined: skip it, do not reject the defaulted env. + // process.env reads a non-UTF-8 name, and `$NAME` while NAME is not set, as undefined: skip it, do not reject the defaulted env. if (value.isUndefined()) continue; const WTF::String& keyStr = envNames[i].string(); diff --git a/test/js/node/process/process-execve.test.ts b/test/js/node/process/process-execve.test.ts index 85c92fdeb332..07831b0519db 100644 --- a/test/js/node/process/process-execve.test.ts +++ b/test/js/node/process/process-execve.test.ts @@ -322,7 +322,7 @@ describe.concurrent("process.execve", () => { test.skipIf(isWindows)( "inherits process.env when env is omitted with a `$`-prefixed name in the OS env", async () => { - // process.env reads a `$`-prefixed name as undefined: execve must skip it, not reject the defaulted env. + // process.env reads `$EXECVE_DOLLAR` as undefined while EXECVE_DOLLAR is not set: execve must skip it, not reject the defaulted env. await using proc = Bun.spawn({ cmd: [ bunExe(), @@ -330,7 +330,7 @@ describe.concurrent("process.execve", () => { `try { process.execve("/definitely/does/not/exist", ["x"]); } catch (e) { console.log(e.code); }`, ], - env: { ...bunEnv, $EXECVE_DOLLAR: "1" }, + env: { ...bunEnv, EXECVE_DOLLAR: undefined, $EXECVE_DOLLAR: "1" }, stdout: "pipe", stderr: "pipe", }); From 2eb091647359bf97397372201fec13696316e68a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:23:59 +0000 Subject: [PATCH 5/5] ci: retrigger