diff --git a/CHANGELOG.md b/CHANGELOG.md index 946f112c92c8..cc13fc98e4c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,3 +149,5 @@ - Support asynchronous full garbage collection through `node:inspector` HeapProfiler sessions on the main thread and workers, with Node-compatible connection errors and pending callback cleanup. - Publish Node HTTP server request-start and response-created diagnostics before request dispatch, preserving response constructor timing and covering injected HTTP/1 connections. Adapts [oven-sh/bun#29588](https://github.com/oven-sh/bun/pull/29588) and [oven-sh/bun#32628](https://github.com/oven-sh/bun/pull/32628). Thanks @robobun and @cirospaciari! + +- Apply `NODE_OPTIONS` and `BUN_OPTIONS` preloads in Node workers with explicit environments and `execArgv`, preserve selected CLI arguments through nested workers, and apply supported worker restrictions before preloads. Builds on [oven-sh/bun#42620](https://github.com/oven-sh/bun/pull/42620). diff --git a/docs/runtime/nodejs-compat.mdx b/docs/runtime/nodejs-compat.mdx index 6ab1e27aeff5..2159089292c7 100644 --- a/docs/runtime/nodejs-compat.mdx +++ b/docs/runtime/nodejs-compat.mdx @@ -268,6 +268,8 @@ The same replay took Node 24 **39.7 ms at 2,500 modules and 63.4 ms at 4,000**. ### [`node:worker_threads`](https://nodejs.org/api/worker_threads.html) +File workers select `--require` and `--import` preloads from their environment independently of `execArgv`. Explicit `env` or `execArgv` options re-read the worker's `NODE_OPTIONS` and `BUN_OPTIONS` before applying the selected CLI arguments. `execArgv: []` removes CLI preloads while environment preloads still apply. Require preloads run before import preloads. + `MessagePort.emit()` and `parentPort.emit()` preserve the emitted value for Node listeners. Web listeners receive `MessageEvent` objects for `message` and `messageerror`, and `CustomEvent` objects for other events. MessagePort message and close events run in the port creation AsyncLocalStorage context. Transferred endpoints capture their receiving context. diff --git a/src/bun_core/node_options.rs b/src/bun_core/node_options.rs index f1062df1c08b..7c5ea3dc8f69 100644 --- a/src/bun_core/node_options.rs +++ b/src/bun_core/node_options.rs @@ -400,6 +400,90 @@ fn is_allowed(flag: &[u8]) -> bool { ALLOWED.binary_search(&flag).is_ok() } +pub fn is_allowed_worker_argument(argument: &[u8]) -> bool { + let (name, _) = split_name_value(argument); + let normalized = normalize(name); + // Node rejects this in env even though allowedNodeEnvironmentFlags lists its alias. + normalized.as_ref() != b"--prof-process" + && (is_allowed(&normalized) || is_bun_flag(&normalized)) +} + +// Node v24.21 value options must be consumed even when Bun does not apply them. +static WORKER_VALUE_FLAGS: &[&[u8]] = &[ + b"--allow-fs-read", + b"--allow-fs-write", + b"--cpu-prof-dir", + b"--cpu-prof-interval", + b"--cpu-prof-name", + b"--debug-port", + b"--diagnostic-dir", + b"--disable-proto", + b"--experimental-loader", + b"--experimental-test-isolation", + b"--heap-prof-dir", + b"--heap-prof-interval", + b"--heap-prof-name", + b"--heapsnapshot-near-heap-limit", + b"--heapsnapshot-signal", + b"--icu-data-dir", + b"--input-type", + b"--inspect-port", + b"--inspect-publish-uid", + b"--loader", + b"--localstorage-file", + b"--max-old-space-size-percentage", + b"--network-family-autoselection-attempt-timeout", + b"--openssl-config", + b"--report-dir", + b"--report-directory", + b"--report-filename", + b"--report-signal", + b"--secure-heap", + b"--secure-heap-min", + b"--snapshot-blob", + b"--stack-trace-limit", + b"--test-coverage-branches", + b"--test-coverage-exclude", + b"--test-coverage-functions", + b"--test-coverage-include", + b"--test-coverage-lines", + b"--test-global-setup", + b"--test-isolation", + b"--test-name-pattern", + b"--test-random-seed", + b"--test-reporter", + b"--test-reporter-destination", + b"--test-rerun-failures", + b"--test-shard", + b"--test-skip-pattern", + b"--tls-cipher-list", + b"--tls-keylog", + b"--trace-event-categories", + b"--trace-event-file-pattern", + b"--trace-require-module", + b"--use-largepages", + b"--v8-pool-size", + b"--watch-kill-signal", + b"--watch-path", +]; + +pub fn worker_required_value_flag(argument: &[u8]) -> Option<&'static [u8]> { + let (name, _) = split_name_value(argument); + let normalized = normalize(name); + match supported(&normalized) { + Some((canonical, Supported::Value)) => Some(canonical), + _ => WORKER_VALUE_FLAGS + .iter() + .copied() + .find(|flag| *flag == normalized.as_ref()), + } +} + +pub fn worker_canonical_flag(argument: &[u8]) -> Option<&'static [u8]> { + let (name, _) = split_name_value(argument); + supported(&normalize(name)).map(|(canonical, _)| canonical) +} + /// Bun-specific flags that commonly reach NODE_OPTIONS via tooling that /// forwards `process.execArgv` to worker processes (Next.js, jest-worker). /// Accepted silently so that `bun --bun next build` keeps working. diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index cf036e090cae..8512fffa6267 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2937,7 +2937,11 @@ pub struct RuntimeHooks { /// Parse `execArgv` against the `RunCommand` param table (lives in `bun_runtime::cli`, forward-dep). /// Caller writes `allow_addons` / `allow_ffi_cc` back into `transform_options` and applies /// `cpu_prof` to the worker VM. - pub parse_worker_exec_argv: unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> WorkerExecArgv, + pub parse_worker_exec_argv: unsafe fn( + exec_argv: &[bun_core::WTFStringImpl], + environment_argc: usize, + inherited_exec_argv: bool, + ) -> WorkerExecArgv, /// `CronJob.clearAllForVM(vm, .teardown)`. `CronJob` lives in /// `bun_runtime::api::cron`. pub stop_cron_for_vm_teardown: fn(vm: &mut VirtualMachine), diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 764af2a69c1d..6c4f918c3aca 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -621,6 +621,7 @@ ZIG_DECL JSC::EncodedJSValue Bun__Process__createArgv(JSC::JSGlobalObject* arg0) ZIG_DECL JSC::EncodedJSValue Bun__Process__createArgv0(JSC::JSGlobalObject* arg0); ZIG_DECL JSC::EncodedJSValue Bun__Process__getCwd(JSC::JSGlobalObject* arg0); ZIG_DECL JSC::EncodedJSValue Bun__Process__createExecArgv(JSC::JSGlobalObject* arg0); +ZIG_DECL JSC::EncodedJSValue Bun__Process__tokenizeWorkerOptions(JSC::JSGlobalObject*, const BunString*, bool, bool); ZIG_DECL JSC::EncodedJSValue Bun__Process__createReportArgv(JSC::JSGlobalObject* arg0); ZIG_DECL JSC::EncodedJSValue Bun__Process__getExecPath(JSC::JSGlobalObject* arg0); ZIG_DECL bool Bun__Process__hasTitle(); diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 87f62c084e33..94ffaa2fc26b 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -125,7 +125,8 @@ static bool isNodeWorkerValueExecArgv(const String& flag) static bool isNodeWorkerDisallowedExecArgv(const String& flag) { - return flag == "--perf-basic-prof"_s + return flag == "--disallow-code-generation-from-strings"_s + || flag == "--perf-basic-prof"_s || flag == "--perf-basic-prof-only-functions"_s || flag == "--perf-prof"_s || flag == "--perf-prof-unwinding-info"_s @@ -136,13 +137,14 @@ static bool isNodeWorkerDisallowedExecArgv(const String& flag) || flag == "--zero-fill-buffers"_s; } -static std::optional parseNodeWorkerExecArgv(const Vector& execArgv, Vector& outputPreloads, size_t& evalPreloadCount, size_t& bunPreloadCount, size_t& requirePreloadCount, WorkerEvalMode& evalMode) +static std::optional parseNodeWorkerExecArgv(const Vector& execArgv, Vector& outputPreloads, size_t& evalPreloadCount, size_t& bunPreloadCount, size_t& requirePreloadCount, WorkerEvalMode& evalMode, size_t nodeOptionsCount = 0, size_t bunOptionsCount = 0, bool inheritedNodeOptions = false, bool inheritedBunOptions = false, bool inheritedExecArgv = false) { Vector bunPreloads; Vector requirePreloads; Vector importPreloads; for (size_t i = 0; i < execArgv.size(); i++) { + const size_t optionIndex = i; const String& argument = execArgv[i]; size_t equals = argument.find('='); bool hasInlineValue = equals != notFound; @@ -206,13 +208,20 @@ static std::optional parseNodeWorkerExecArgv(const Vector& execA return makeString("Initiated Worker with invalid execArgv flags: "_s, flag, " requires an argument"_s); } else if (flag == "--inspect"_s || flag == "--inspect-brk"_s || flag == "--inspect-port"_s) { continue; - } else if (!isNodeWorkerDisallowedExecArgv(flag)) { + } else if ((optionIndex < nodeOptionsCount && inheritedNodeOptions) + || (optionIndex >= nodeOptionsCount && optionIndex < nodeOptionsCount + bunOptionsCount && inheritedBunOptions) + || (optionIndex >= nodeOptionsCount + bunOptionsCount && inheritedExecArgv) + || !isNodeWorkerDisallowedExecArgv(flag)) { // The complete option parser lives above this binding. Preserve // previously accepted flags instead of rejecting valid Node // options that this local list does not need to interpret. continue; } + if (optionIndex < nodeOptionsCount) + return makeString("Initiated Worker with invalid NODE_OPTIONS env variable: "_s, flag, " is not allowed in NODE_OPTIONS"_s); + if (optionIndex < nodeOptionsCount + bunOptionsCount) + return makeString("Initiated Worker with invalid BUN_OPTIONS env variable: "_s, flag, " is not allowed in BUN_OPTIONS"_s); return makeString("Initiated Worker with invalid execArgv flags: "_s, argument); } @@ -225,6 +234,37 @@ static std::optional parseNodeWorkerExecArgv(const Vector& execA return std::nullopt; } +static void appendWorkerOptionStrings(Zig::GlobalObject* globalObject, JSValue value, Vector& out) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + Strong array(vm, uncheckedDowncast(value.asCell())); + for (unsigned i = 0; i < array->length(); ++i) { + JSValue item = array->getIndex(globalObject, i); + RETURN_IF_EXCEPTION(scope, ); + String string = item.toWTFString(globalObject).isolatedCopy(); + RETURN_IF_EXCEPTION(scope, ); + out.append(WTF::move(string)); + } +} + +static void appendWorkerEnvironmentOptions(Zig::GlobalObject* globalObject, const String& value, bool isBun, bool strict, Vector& out) +{ + if (value.isEmpty()) return; + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + BunString source = Bun::toString(value); + JSValue parsed = JSValue::decode(Bun__Process__tokenizeWorkerOptions(globalObject, &source, isBun, strict)); + RETURN_IF_EXCEPTION(scope, ); + if (parsed.isString()) { + String message = parsed.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, ); + scope.throwException(globalObject, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_INVALID_EXEC_ARGV, makeString("Initiated Worker with invalid "_s, isBun ? "BUN_OPTIONS"_s : "NODE_OPTIONS"_s, " env variable: "_s, message))); + return; + } + RELEASE_AND_RETURN(scope, appendWorkerOptionStrings(globalObject, parsed, out)); +} + // Functions static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_terminate); @@ -311,6 +351,7 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES EnsureStillAliveScope argument1 = callFrame->argument(1); WorkerOptions options {}; + bool explicitEnvironment = false; // Founding an env tree swaps the parent's process.env, so it is deferred until // every option has validated (below). bool shareEnv = false; @@ -413,6 +454,7 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES auto envValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "env"_s)); RETURN_IF_EXCEPTION(throwScope, {}); + explicitEnvironment = envValue && envValue.isObject(); // Recognize the SHARE_ENV registry symbol directly so `new globalThis.Worker(url, { env: SHARE_ENV })` // (which bypasses the node:worker_threads wrapper) shares env instead of throwing // ERR_INVALID_ARG_TYPE on its own sentinel. @@ -495,13 +537,53 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES }); RETURN_IF_EXCEPTION(throwScope, {}); options.execArgv.emplace(WTF::move(execArgv)); - if (options.kind == WorkerOptions::Kind::Node) { - if (auto error = parseNodeWorkerExecArgv(*options.execArgv, options.execArgvPreloadModules, options.execArgvEvalPreloadCount, options.execArgvBunPreloadCount, options.execArgvRequirePreloadCount, options.execArgvEvalMode)) { - throwScope.throwException(lexicalGlobalObject, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_INVALID_EXEC_ARGV, *error)); - return encodedJSValue(); - } + options.inheritExecArgv = false; + } + } + + if (options.kind == WorkerOptions::Kind::Node) { + if (!options.execArgv) { + JSValue inherited = JSValue::decode(Bun__Process__createExecArgv(globalObject)); + RETURN_IF_EXCEPTION(throwScope, {}); + options.execArgv.emplace(); + appendWorkerOptionStrings(globalObject, inherited, *options.execArgv); + RETURN_IF_EXCEPTION(throwScope, {}); + } + if (explicitEnvironment || !options.inheritExecArgv) { + options.inheritPreloads = false; + auto* parentEnv = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(throwScope, {}); + Vector effectiveArgv; + auto parentOption = [&](ASCIILiteral key) -> String { + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue value = parentEnv->get(lexicalGlobalObject, Identifier::fromString(vm, key)); + RETURN_IF_EXCEPTION(scope, {}); + if (value.isUndefined()) return {}; + RELEASE_AND_RETURN(scope, value.toWTFString(lexicalGlobalObject)); + }; + String parentNodeOptions = parentOption("NODE_OPTIONS"_s); + RETURN_IF_EXCEPTION(throwScope, {}); + String parentBunOptions = parentOption("BUN_OPTIONS"_s); + RETURN_IF_EXCEPTION(throwScope, {}); + String nodeOptions = options.env ? options.env->get("NODE_OPTIONS"_s) : parentNodeOptions; + String bunOptions = options.env ? options.env->get("BUN_OPTIONS"_s) : parentBunOptions; + appendWorkerEnvironmentOptions(globalObject, nodeOptions, false, explicitEnvironment, effectiveArgv); + RETURN_IF_EXCEPTION(throwScope, {}); + size_t nodeOptionsCount = effectiveArgv.size(); + appendWorkerEnvironmentOptions(globalObject, bunOptions, true, explicitEnvironment, effectiveArgv); + RETURN_IF_EXCEPTION(throwScope, {}); + size_t bunOptionsCount = effectiveArgv.size() - nodeOptionsCount; + options.environmentArgc = effectiveArgv.size(); + effectiveArgv.appendVector(*options.execArgv); + if (auto error = parseNodeWorkerExecArgv(effectiveArgv, options.execArgvPreloadModules, options.execArgvEvalPreloadCount, options.execArgvBunPreloadCount, options.execArgvRequirePreloadCount, options.execArgvEvalMode, nodeOptionsCount, bunOptionsCount, !explicitEnvironment || nodeOptions == parentNodeOptions, !explicitEnvironment || bunOptions == parentBunOptions, options.inheritExecArgv)) { + throwScope.throwException(lexicalGlobalObject, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_INVALID_EXEC_ARGV, *error)); + return encodedJSValue(); } + options.effectiveExecArgv = WTF::move(effectiveArgv); } + } else { + options.inheritPreloads = options.inheritExecArgv; + if (options.execArgv) options.effectiveExecArgv = *options.execArgv; } // Resolve the spawning thread's env tree (founding one if needed) so disjoint diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index b75204830a20..4836aed27ace 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -71,8 +71,12 @@ void* WebWorker__create( StringImpl** argvPtr, size_t argvLen, bool defaultExecArgv, + bool inheritPreloads, StringImpl** execArgvPtr, size_t execArgvLen, + StringImpl** effectiveExecArgvPtr, + size_t effectiveExecArgvLen, + size_t environmentArgc, // NODE_USE_SYSTEM_CA as seen by the worker's own `env` option: 1 / 0, or -1 when it inherits the env. int8_t envUseSystemCa, BunString* preloadModulesPtr, @@ -190,9 +194,13 @@ ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scr m_options.kind == WorkerOptions::Kind::Node, reinterpret_cast(m_options.argv.begin()), m_options.argv.size(), - !m_options.execArgv.has_value(), + m_options.inheritExecArgv, + m_options.inheritPreloads, execArgv.data(), execArgv.size(), + reinterpret_cast(m_options.effectiveExecArgv.begin()), + m_options.effectiveExecArgv.size(), + m_options.environmentArgc, envUseSystemCa, preloadModules.begin(), preloadModules.size(), diff --git a/src/jsc/bindings/webcore/WorkerOptions.h b/src/jsc/bindings/webcore/WorkerOptions.h index 72532e89a4d8..9e347a24c019 100644 --- a/src/jsc/bindings/webcore/WorkerOptions.h +++ b/src/jsc/bindings/webcore/WorkerOptions.h @@ -42,9 +42,13 @@ struct WorkerOptions { Vector preloadModules; std::optional> env; Vector argv; - // If nullopt, inherit execArgv from the parent thread + // Node workers snapshot their selected CLI arguments, including inherited arguments. std::optional> execArgv; - // --require/--import modules parsed from an explicit node Worker execArgv. + bool inheritExecArgv { true }; + bool inheritPreloads { true }; + Vector effectiveExecArgv; + size_t environmentArgc { 0 }; + // --require/--import modules parsed from the selected environment and execArgv. // Kept raw so resolution and evaluation happen in the worker VM. Vector execArgvPreloadModules; size_t execArgvEvalPreloadCount { 0 }; diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 8429f7f006bf..ba3c242ef60c 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -316,12 +316,11 @@ impl WebWorker { unsafe { bun_core::ffi::slice(self.argv_ptr, self.argv_len) } } - /// `None` when - /// `inherit_exec_argv` (the worker inherits the parent's execArgv), - /// otherwise `Some(slice)` (possibly empty) borrowed from C++ WorkerOptions. + /// Node workers retain a CLI argument snapshot even when inherited. Web + /// workers return `None` for inherited arguments; other slices borrow C++ WorkerOptions. #[inline] pub fn exec_argv(&self) -> Option<&[WTFStringImpl]> { - if self.inherit_exec_argv { + if self.inherit_exec_argv && !self.is_node_worker { return None; } // SAFETY: see `argv()`. @@ -358,8 +357,12 @@ impl WebWorker { argv_ptr: *const WTFStringImpl, argv_len: usize, inherit_exec_argv: bool, + inherit_preloads: bool, exec_argv_ptr: *const WTFStringImpl, exec_argv_len: usize, + effective_exec_argv_ptr: *const WTFStringImpl, + effective_exec_argv_len: usize, + environment_argc: usize, // `NODE_USE_SYSTEM_CA` from the worker's own `env` option (1 / 0), or -1 when it inherits // the env. env_use_system_ca: i8, @@ -436,7 +439,7 @@ impl WebWorker { worker_preload_require_start, worker_preload_require_count, worker_eval_mode, - ) = if inherit_exec_argv { + ) = if inherit_preloads { ( parent_ref.worker_preloads.clone(), parent_ref.worker_eval_preloads.clone(), @@ -495,20 +498,24 @@ impl WebWorker { let mut transform_options = (*parent_ref.transpiler.options.transform_options).clone(); // A worker's own `execArgv` carries node's per-Environment options (parsed with the // RunCommand param table, hence the hook); without one it inherits the parent's. - let exec_argv: virtual_machine::WorkerExecArgv = if inherit_exec_argv { + let exec_argv: virtual_machine::WorkerExecArgv = if inherit_preloads { Default::default() } else { let hooks = runtime_hooks().expect("RuntimeHooks not installed"); // SAFETY: caller passed valid (ptr,len) borrowed from the C++ WorkerOptions, alive // for the proxy's lifetime; the hook only reads the slice. unsafe { - (hooks.parse_worker_exec_argv)(bun_core::ffi::slice(exec_argv_ptr, exec_argv_len)) + (hooks.parse_worker_exec_argv)( + bun_core::ffi::slice(effective_exec_argv_ptr, effective_exec_argv_len), + environment_argc, + inherit_exec_argv, + ) } }; if let Some(invalid) = exec_argv.invalid { use bun_core::WTFStringImplExt as _; // SAFETY: an index into the same slice, whose strings the caller keeps alive. - let arg = unsafe { &**exec_argv_ptr.add(invalid) }.to_owned_slice_z(); + let arg = unsafe { &**effective_exec_argv_ptr.add(invalid) }.to_owned_slice_z(); let mut message = b"Initiated Worker with invalid execArgv flags: ".to_vec(); message.extend_from_slice(arg.as_bytes()); *error_message = BunString::clone_utf8(&message); @@ -527,7 +534,7 @@ impl WebWorker { // node_worker.cc: a Worker starts from the parent's resolved option, a custom `env` // re-derives it from that env, and then the flags (its own execArgv's, else the parent's) // win. - let use_system_ca_flag = if inherit_exec_argv { + let use_system_ca_flag = if inherit_preloads { parent_ref.use_system_ca_flag } else { exec_argv.use_system_ca @@ -558,7 +565,7 @@ impl WebWorker { env_loader, proxy_env_slots, exec_argv, - has_own_exec_argv: !inherit_exec_argv, + has_own_exec_argv: !inherit_preloads, use_system_ca: use_system_ca_flag.or(use_system_ca_base), use_system_ca_flag, }; diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index b720deee1df9..94d50e1d17fe 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1594,6 +1594,8 @@ unsafe fn apply_standalone_runtime_flags( /// Each `WTFStringImpl` in `exec_argv` is a live WTF string kept alive for the worker's lifetime. unsafe fn parse_worker_exec_argv( exec_argv: &[bun_core::WTFStringImpl], + environment_argc: usize, + inherited_exec_argv: bool, ) -> bun_jsc::virtual_machine::WorkerExecArgv { use crate::cli::arguments::replace_pid_placeholder; enum Pending { @@ -1661,10 +1663,13 @@ unsafe fn parse_worker_exec_argv( pending = Pending::Dir; } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-dir=") { out.cpu_prof_dir = Some(v.into()); - } else if matches!( - bytes.strip_prefix(b"--disallow-code-generation-from-strings".as_slice()), - Some([] | [b'=', ..]) - ) { + } else if !inherited_exec_argv + && index >= environment_argc + && matches!( + bytes.strip_prefix(b"--disallow-code-generation-from-strings".as_slice()), + Some([] | [b'=', ..]) + ) + { out.invalid.get_or_insert(index); } } diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 8dd7aee2b854..4c71809f7895 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -23,6 +23,111 @@ extern "C" fn create_report_argv(global_object: &JSGlobalObject) -> JSValue { bun_jsc::to_js_host_fn_result(global_object, result) } +#[unsafe(export_name = "Bun__Process__tokenizeWorkerOptions")] +extern "C" fn tokenize_worker_options( + global: &JSGlobalObject, + source: &bun_core::String, + is_bun: bool, + strict: bool, +) -> JSValue { + let result = (|| -> JsResult { + let source = source.to_utf8(); + let tokens = match bun_core::node_options::tokenize(source.slice()) { + Ok(tokens) => tokens, + Err(error) => { + if !strict { + return JSValue::create_empty_array(global, 0); + } + let message = match error { + bun_core::node_options::TokenizeError::UnterminatedString => { + "unterminated string" + } + bun_core::node_options::TokenizeError::InvalidEscape => "invalid escape", + }; + let variable: &[u8] = if is_bun { + b"BUN_OPTIONS" + } else { + b"NODE_OPTIONS" + }; + let mut text = b"invalid value for ".to_vec(); + text.extend_from_slice(variable); + text.extend_from_slice(b" ("); + text.extend_from_slice(message.as_bytes()); + text.extend_from_slice(b")\n"); + return bun_jsc::bun_string_jsc::create_utf8_for_js(global, &text); + } + }; + let mut parsed: Vec> = Vec::new(); + let mut i = 0; + while i < tokens.len() { + let token = &tokens[i]; + if token.len() <= 1 || token.first() != Some(&b'-') { + break; + } + let split = bun_core::strings::split_once_char(token, b'='); + let name = split.map_or_else(|| token.as_ref(), |(name, _)| name); + if !is_bun + && token.first() == Some(&b'-') + && !bun_core::node_options::is_allowed_worker_argument(token) + { + if strict { + let mut message = name.to_vec(); + message.extend_from_slice(b" is not allowed in NODE_OPTIONS"); + return bun_jsc::bun_string_jsc::create_utf8_for_js(global, &message); + } + break; + } + let required_value = if is_bun && name == b"--preload" { + Some(b"--preload".as_slice()) + } else { + bun_core::node_options::worker_required_value_flag(token) + }; + if let Some(canonical) = required_value { + let value = match split { + Some((_, value)) if !value.is_empty() => Some(value), + Some(_) => None, + None => tokens + .get(i + 1) + .filter(|value| value.first() != Some(&b'-')) + .map(|value| { + i += 1; + value + .strip_prefix(b"\\-") + .map_or_else(|| value.as_ref(), |_| &value[1..]) + }), + }; + let Some(value) = value else { + if strict { + let mut message = token.to_vec(); + message.extend_from_slice(b" requires an argument"); + return bun_jsc::bun_string_jsc::create_utf8_for_js(global, &message); + } + break; + }; + let mut argument = canonical.to_vec(); + argument.push(b'='); + argument.extend_from_slice(value); + parsed.push(argument); + } else { + let mut argument = bun_core::node_options::worker_canonical_flag(token) + .map_or_else(|| token.to_vec(), |canonical| canonical.to_vec()); + if argument.as_slice() != token.as_ref() { + if let Some((_, value)) = split { + argument.push(b'='); + argument.extend_from_slice(value); + } + } + parsed.push(argument); + } + i += 1; + } + JSValue::create_array_from_iter(global, parsed.iter(), |token| { + bun_jsc::bun_string_jsc::create_utf8_for_js(global, token) + }) + })(); + bun_jsc::to_js_host_fn_result(global, result) +} + // ───────────────────────────── argv0 / execPath ───────────────────────────── // `&JSGlobalObject` is ABI-identical to `*const JSGlobalObject` (non-null) in diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 46971b56802d..108d0e0b5ff0 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -3900,3 +3900,290 @@ test("getHeapStatistics includes array storage allocated since the last collecti await worker.terminate(); } }); + +describe("worker environment preloads", () => { + const cases = [ + ["inherited", ["env", "cli"], "cli"], + ["empty-exec", ["env"], "empty"], + ["copy-env-empty-exec", ["env"], "empty"], + ["empty-env-empty-exec", [], "empty"], + ["empty-env-inherited-exec", ["cli"], "cli"], + ["changed-env", ["changed"], "empty"], + ["own-exec", ["env", "own"], "own"], + ["shared-env", ["env"], "empty"], + ["nested", ["env", "own"], "own"], + ["mutated-inherited", ["env", "cli"], "cli"], + ["mutated-empty-exec", ["changed"], "empty"], + ] as const; + + for (const [mode, expected, argvKind] of cases) { + test.concurrent(`NODE_OPTIONS: ${mode}`, async () => { + using dir = tempDir("worker-env-preloads", { + "env.cjs": '(globalThis.preloads ??= []).push("env");', + "cli.cjs": '(globalThis.preloads ??= []).push("cli");', + "own.cjs": '(globalThis.preloads ??= []).push("own");', + "changed.cjs": '(globalThis.preloads ??= []).push("changed");', + "worker.mjs": ` + import { Worker, parentPort, workerData } from "node:worker_threads"; + if (workerData?.depth) { + const child = new Worker(new URL(import.meta.url), { workerData: { depth: workerData.depth - 1 } }); + child.once("message", message => parentPort.postMessage(message)); + child.once("error", error => { throw error; }); + } else parentPort.postMessage({ preloads: globalThis.preloads ?? [], execArgv: process.execArgv }); + `, + "parent.mjs": ` + import { Worker, SHARE_ENV } from "node:worker_threads"; + const mode = process.argv[2]; + const own = ["--import", new URL("./own.cjs", import.meta.url).href]; + const changed = "--import=" + new URL("./changed.cjs", import.meta.url).href; + let options = {}; + if (mode === "empty-exec") options = { execArgv: [] }; + if (mode === "copy-env-empty-exec") options = { env: { ...process.env }, execArgv: [] }; + if (mode === "empty-env-empty-exec") options = { env: {}, execArgv: [] }; + if (mode === "empty-env-inherited-exec") options = { env: {} }; + if (mode === "changed-env") options = { env: { NODE_OPTIONS: changed }, execArgv: [] }; + if (mode === "own-exec") options = { env: { ...process.env }, execArgv: own }; + if (mode === "shared-env") options = { env: SHARE_ENV, execArgv: [] }; + if (mode === "nested") options = { env: { ...process.env }, execArgv: own, workerData: { depth: 2 } }; + if (mode.startsWith("mutated-")) { + process.env.NODE_OPTIONS = changed; + if (mode === "mutated-empty-exec") options.execArgv = []; + } + const worker = new Worker(new URL("./worker.mjs", import.meta.url), options); + worker.once("message", message => console.log(JSON.stringify(message))); + worker.once("error", error => { throw error; }); + `, + }); + const cli = pathToFileURL(join(String(dir), "cli.cjs")).href; + const own = pathToFileURL(join(String(dir), "own.cjs")).href; + await using proc = Bun.spawn({ + cmd: [bunExe(), "--import", cli, join(String(dir), "parent.mjs"), mode], + env: { + ...bunEnv, + BUN_OPTIONS: undefined, + NODE_OPTIONS: `--import=${pathToFileURL(join(String(dir), "env.cjs")).href}`, + }, + stdout: "pipe", + 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({ + preloads: expected, + execArgv: argvKind === "empty" ? [] : ["--import", argvKind === "own" ? own : cli], + }); + expect(exitCode).toBe(0); + }); + } + + for (const variable of ["NODE_OPTIONS", "BUN_OPTIONS"]) { + test.concurrent( + `${variable}: require preloads precede import preloads with explicit env and execArgv`, + async () => { + using dir = tempDir("worker-env-preload-order", { + "env.cjs": '(globalThis.preloads ??= []).push("env-require");', + "env.mjs": '(globalThis.preloads ??= []).push("env-import");', + "own.cjs": '(globalThis.preloads ??= []).push("own-require");', + "own.mjs": '(globalThis.preloads ??= []).push("own-import");', + "worker.mjs": 'import {parentPort} from "node:worker_threads"; parentPort.postMessage(globalThis.preloads);', + "parent.mjs": ` + import { Worker } from "node:worker_threads"; + import { fileURLToPath } from "node:url"; + const worker = new Worker(new URL("./worker.mjs", import.meta.url), { + env: { ...process.env }, + execArgv: ["--import", new URL("./own.mjs", import.meta.url).href, "--require", fileURLToPath(new URL("./own.cjs", import.meta.url))], + }); + worker.once("message", message => console.log(JSON.stringify(message))); + worker.once("error", error => { throw error; }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "parent.mjs")], + cwd: String(dir), + env: { + ...bunEnv, + NODE_OPTIONS: undefined, + BUN_OPTIONS: undefined, + [variable]: `--import=${pathToFileURL(join(String(dir), "env.mjs")).href} --require=./env.cjs`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: '["env-require","own-require","env-import","own-import"]\n', + stderr: "", + exitCode: 0, + }); + }, + ); + } + + test.concurrent("rejects invalid NODE_OPTIONS in an explicit worker environment", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("node:worker_threads"); + try { + const worker = new Worker('require("node:worker_threads").parentPort.postMessage("unexpected")', { + eval: true, env: { NODE_OPTIONS: "--definitely-invalid-worker-option" }, execArgv: [], + }); + worker.on("message", value => console.log(value)); + } catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message })); } + `, + ], + env: { ...bunEnv, NODE_OPTIONS: undefined, BUN_OPTIONS: undefined }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: + '{"code":"ERR_WORKER_INVALID_EXEC_ARGV","message":"Initiated Worker with invalid NODE_OPTIONS env variable: --definitely-invalid-worker-option is not allowed in NODE_OPTIONS"}\n', + stderr: "", + exitCode: 0, + }); + }); +}); + +test.each(["--no-addons", "--no_addons"])( + "worker environment restriction %s applies before require preloads", + async flag => { + using dir = tempDir("worker-env-no-addons", { + "preload.cjs": ` + try { process.dlopen({ exports: {} }, __filename + ".missing.node"); } + catch (error) { globalThis.preloadAddonError = error.code; } + `, + "worker.mjs": + 'import {parentPort} from "node:worker_threads"; parentPort.postMessage(globalThis.preloadAddonError);', + }); + const worker = new Worker(join(String(dir), "worker.mjs"), { + env: { + ...bunEnv, + BUN_OPTIONS: undefined, + NODE_OPTIONS: `${flag} --require=${JSON.stringify(join(String(dir), "preload.cjs"))}`, + }, + execArgv: [], + }); + try { + const exited = once(worker, "exit"); + const [message] = await once(worker, "message"); + expect(message).toBe("ERR_DLOPEN_DISABLED"); + expect(await exited).toEqual([0]); + } finally { + await worker.terminate(); + } + }, +); + +test.each(["--import=", "--import ", "--preload=", "--preload "])( + "BUN_OPTIONS %s preloads remain active with explicit env and empty execArgv", + async flag => { + using dir = tempDir("worker-bun-options-empty-exec", { + "preload.mjs": "globalThis.envPreloaded = true;", + "worker.mjs": + 'import {parentPort} from "node:worker_threads"; parentPort.postMessage(globalThis.envPreloaded === true);', + "parent.mjs": ` + import { Worker } from "node:worker_threads"; + const worker = new Worker(new URL("./worker.mjs", import.meta.url), { env: { ...process.env }, execArgv: [] }); + worker.once("message", workerPreloaded => console.log(JSON.stringify({ parentPreloaded: globalThis.envPreloaded === true, workerPreloaded }))); + worker.once("error", error => { throw error; }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "parent.mjs")], + env: { + ...bunEnv, + NODE_OPTIONS: undefined, + BUN_OPTIONS: `${flag}${pathToFileURL(join(String(dir), "preload.mjs")).href}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: '{"parentPreloaded":true,"workerPreloaded":true}\n', + stderr: "", + exitCode: 0, + }); + }, +); + +describe("inherited NODE_OPTIONS errors", () => { + const cases = [ + ["unknown", false, "--definitely-invalid-worker-option is not allowed in NODE_OPTIONS"], + ["unknown-before-import", false, "--definitely-invalid-worker-option is not allowed in NODE_OPTIONS"], + ["import-before-unknown", true, "--definitely-invalid-worker-option is not allowed in NODE_OPTIONS"], + ["missing-value", true, "--import requires an argument"], + ["unterminated", false, "invalid value for NODE_OPTIONS (unterminated string)\n"], + ["positional-first", false, null], + ["positional-before-import", false, null], + ["import-before-positional", true, null], + ["value-before-import", true, null], + ["report-directory-before-import", true, null], + ["debug-port-before-import", true, null], + ["test-isolation-before-import", true, null], + ["prof-process", false, "--prof-process is not allowed in NODE_OPTIONS"], + ["v8-positional", false, null], + ["dash-positional", false, null], + ] as const; + for (const [mode, loaded, error] of cases) { + for (const explicit of [false, true]) { + test.concurrent(`${mode}, explicit env: ${explicit}`, async () => { + using dir = tempDir("worker-env-errors", { + "preload.mjs": "globalThis.envPreloaded = true;", + "parent.mjs": ` + import { Worker } from "node:worker_threads"; + const [mode, explicit] = process.argv.slice(2); + const good = "--import=" + new URL("./preload.mjs", import.meta.url).href; + const bad = "--definitely-invalid-worker-option"; + const values = { + unknown: bad, + "unknown-before-import": bad + " " + good, + "import-before-unknown": good + " " + bad, + "missing-value": good + " --import", + unterminated: good + ' "unterminated', + "positional-first": "ignored " + bad, + "positional-before-import": "ignored " + good, + "import-before-positional": good + " ignored " + bad, + "value-before-import": "--diagnostic-dir . " + good, + "report-directory-before-import": "--report-directory . " + good, + "debug-port-before-import": "--debug-port 0 " + good, + "test-isolation-before-import": "--experimental-test-isolation none " + good, + "prof-process": "--prof-process " + good, + "v8-positional": "--max-old-space-size 512 " + good, + "dash-positional": "- " + good, + }; + process.env.NODE_OPTIONS = values[mode]; + try { + const worker = new Worker(new URL('data:text/javascript,import{parentPort}from"node:worker_threads";parentPort.postMessage(globalThis.envPreloaded===true)'), { + execArgv: [], ...(explicit === "true" ? { env: { NODE_OPTIONS: values[mode] } } : {}), + }); + worker.once("message", loaded => console.log(JSON.stringify({ loaded }))); + worker.once("error", error => { throw error; }); + } catch (error) { console.log(JSON.stringify({ code: error.code, message: error.message })); } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "parent.mjs"), mode, String(explicit)], + env: { ...bunEnv, NODE_OPTIONS: undefined, BUN_OPTIONS: undefined }, + stdout: "pipe", + 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( + explicit && error !== null + ? { + code: "ERR_WORKER_INVALID_EXEC_ARGV", + message: "Initiated Worker with invalid NODE_OPTIONS env variable: " + error, + } + : { loaded }, + ); + expect(exitCode).toBe(0); + }); + } + } +});